From 2c3c15b406aab8499afb21e18bea24cb061c3613 Mon Sep 17 00:00:00 2001 From: Eric StJohn Date: Fri, 6 Jun 2025 09:03:57 -0700 Subject: [PATCH 1/2] Do not return from CTRL_SHUTDOWN_EVENT Windows will kill the process after it completes handling CTRL_SHUTDOWN_EVENT To avoid this, we add a delay to our handler. This allows the process to exit before the handler returns. We use HostOptions.ShutdownTimeout for the delay. This does mean that other SIGTERM handlers will not run, but that's a tradeoff we have to make to ensure the process isn't killed. --- .../Internal/ConsoleLifetime.netcoreapp.cs | 21 ++++- .../UnitTests/ConsoleLifetimeExitTests.cs | 91 ++++++++++++++++++- ...osoft.Extensions.Hosting.Unit.Tests.csproj | 1 + 3 files changed, 108 insertions(+), 5 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Hosting/src/Internal/ConsoleLifetime.netcoreapp.cs b/src/libraries/Microsoft.Extensions.Hosting/src/Internal/ConsoleLifetime.netcoreapp.cs index 7fcccbeb8d411f..58cc81ba294787 100644 --- a/src/libraries/Microsoft.Extensions.Hosting/src/Internal/ConsoleLifetime.netcoreapp.cs +++ b/src/libraries/Microsoft.Extensions.Hosting/src/Internal/ConsoleLifetime.netcoreapp.cs @@ -4,6 +4,7 @@ using System; using System.Diagnostics; using System.Runtime.InteropServices; +using System.Threading; namespace Microsoft.Extensions.Hosting.Internal { @@ -20,7 +21,7 @@ private partial void RegisterShutdownHandlers() Action handler = HandlePosixSignal; _sigIntRegistration = PosixSignalRegistration.Create(PosixSignal.SIGINT, handler); _sigQuitRegistration = PosixSignalRegistration.Create(PosixSignal.SIGQUIT, handler); - _sigTermRegistration = PosixSignalRegistration.Create(PosixSignal.SIGTERM, handler); + _sigTermRegistration = PosixSignalRegistration.Create(PosixSignal.SIGTERM, OperatingSystem.IsWindows() ? HandleWindowsShutdown : handler); } } @@ -32,6 +33,24 @@ private void HandlePosixSignal(PosixSignalContext context) ApplicationLifetime.StopApplication(); } + private void HandleWindowsShutdown(PosixSignalContext context) + { + // for SIGTERM on Windows we must block this thread until the application is finished + // otherwise the process will be killed immediately on return from this handler + + // don't allow Dispose to unregister handlers, since Windows has a lock that prevents the unregistration while this handler is running + // just leak these, since the process is exiting + _sigIntRegistration = null; + _sigQuitRegistration = null; + _sigTermRegistration = null; + + ApplicationLifetime.StopApplication(); + + // we could wait for a signal here, like Dispose as is done in non-netcoreapp case, but those inevitably could have user + // code that runs after them in the user's Main. Instead we just block this thread completely and let the main routine exit. + Thread.Sleep(HostOptions.ShutdownTimeout); + } + private partial void UnregisterShutdownHandlers() { _sigIntRegistration?.Dispose(); diff --git a/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/ConsoleLifetimeExitTests.cs b/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/ConsoleLifetimeExitTests.cs index 0e54db40045e96..3298400036126a 100644 --- a/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/ConsoleLifetimeExitTests.cs +++ b/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/ConsoleLifetimeExitTests.cs @@ -2,6 +2,9 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.IO; +using System.IO.Pipes; +using System.Reflection; using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; @@ -18,14 +21,22 @@ public class ConsoleLifetimeExitTests /// and the rest of "main" gets executed. /// [ConditionalTheory(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] - [PlatformSpecific(TestPlatforms.AnyUnix)] [InlineData(SIGTERM)] [InlineData(SIGINT)] [InlineData(SIGQUIT)] public async Task EnsureSignalContinuesMainMethod(int signal) { - using var remoteHandle = RemoteExecutor.Invoke(async () => + // simulate signals on Windows by using a pipe to communicate with the remote process + using var messagePipe = new AnonymousPipeServerStream(PipeDirection.Out, HandleInheritability.Inheritable); + + using var remoteHandle = RemoteExecutor.Invoke(async (pipeHandleAsString) => { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + // kick off a thread to simulate the signal on Windows + _ = Task.Run(() => SimulatePosixSignalWindows(pipeHandleAsString)); + } + await Host.CreateDefaultBuilder() .ConfigureServices((hostContext, services) => { @@ -40,7 +51,7 @@ await Host.CreateDefaultBuilder() Console.WriteLine("Run has completed"); return 123; - }, new RemoteInvokeOptions() { Start = false, ExpectedExitCode = 123 }); + }, messagePipe.GetClientHandleAsString(), new RemoteInvokeOptions() { Start = false, ExpectedExitCode = 123 }); remoteHandle.Process.StartInfo.RedirectStandardOutput = true; remoteHandle.Process.Start(); @@ -53,7 +64,16 @@ await Host.CreateDefaultBuilder() } // send the signal to the process - kill(remoteHandle.Process.Id, signal); + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + // on Windows, we use the pipe to signal the process + messagePipe.WriteByte((byte)signal); + } + else + { + // on Unix, we send the signal directly + kill(remoteHandle.Process.Id, signal); + } remoteHandle.Process.WaitForExit(); @@ -69,6 +89,69 @@ await Host.CreateDefaultBuilder() [DllImport("libc", SetLastError = true)] private static extern int kill(int pid, int sig); + + private const int CTRL_C_EVENT = 0; + private const int CTRL_BREAK_EVENT = 1; + private const int CTRL_CLOSE_EVENT = 2; + private const int CTRL_LOGOFF_EVENT = 5; + private const int CTRL_SHUTDOWN_EVENT = 6; + + private static unsafe void SimulatePosixSignalWindows(string pipeHandleAsString) + { + try + { + using var readPipe = new AnonymousPipeClientStream(PipeDirection.In, pipeHandleAsString); + + int signal = (int)readPipe.ReadByte(); + + int ctrlType = (int)signal switch + { + SIGINT => CTRL_C_EVENT, + SIGQUIT => CTRL_BREAK_EVENT, + SIGTERM => CTRL_SHUTDOWN_EVENT, + _ => throw new ArgumentOutOfRangeException(nameof(signal), "Unsupported signal") + }; + +#if NETFRAMEWORK + if (ctrlType == CTRL_C_EVENT || ctrlType == CTRL_BREAK_EVENT) + { + var handlerMethod = Type.GetType("System.Console, mscorlib")?.GetMethod("BreakEvent", BindingFlags.NonPublic | BindingFlags.Static); + Assert.NotNull(handlerMethod); + handlerMethod.Invoke(null, [ctrlType]); + } + else // CTRL_SHUTDOWN_EVENT + { + var handlerField = typeof(AppDomain).GetField("_processExit", BindingFlags.NonPublic | BindingFlags.Instance); + Assert.NotNull(handlerField); + EventHandler handler = (EventHandler)handlerField.GetValue(AppDomain.CurrentDomain); + Assert.NotNull(handler); + handler.Invoke(AppDomain.CurrentDomain, null); + } +#else + // get the System.Runtime.InteropServices.PosixSignalRegistration.HandlerRoutine private method + var handlerMethod = typeof(PosixSignalRegistration).GetMethod("HandlerRoutine", BindingFlags.NonPublic | BindingFlags.Static); + Assert.NotNull(handlerMethod); + + var handlerPtr = handlerMethod.MethodHandle.GetFunctionPointer(); + delegate* unmanaged handler = (delegate* unmanaged)handlerPtr; + + handler(ctrlType); + + if (signal == SIGTERM) + { + // on Windows the OS will kill the process immediately after this + Environment.FailFast("Simulating shutdown"); + } +#endif + } + catch (Exception ex) + { + // Exceptions on this thread will not be observed, nor will they cause the process to exit. + // Use failfast to ensure the process will exit without running any handlers. + Environment.FailFast(ex.ToString()); + } + } + private class EnsureSignalContinuesMainMethodWorker : BackgroundService { protected override async Task ExecuteAsync(CancellationToken stoppingToken) diff --git a/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/Microsoft.Extensions.Hosting.Unit.Tests.csproj b/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/Microsoft.Extensions.Hosting.Unit.Tests.csproj index ea7f4fcbce05a0..cb06f819fbfe5f 100644 --- a/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/Microsoft.Extensions.Hosting.Unit.Tests.csproj +++ b/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/Microsoft.Extensions.Hosting.Unit.Tests.csproj @@ -4,6 +4,7 @@ $(NetCoreAppCurrent)-windows;$(NetCoreAppCurrent);$(NetFrameworkCurrent) true true + true true true From 95354ecd540f7379df62c698c8ec67d71b37fd15 Mon Sep 17 00:00:00 2001 From: Eric StJohn Date: Thu, 10 Jul 2025 16:39:25 -0700 Subject: [PATCH 2/2] Update ConsoleLifetime.netcoreapp.cs --- .../src/Internal/ConsoleLifetime.netcoreapp.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Hosting/src/Internal/ConsoleLifetime.netcoreapp.cs b/src/libraries/Microsoft.Extensions.Hosting/src/Internal/ConsoleLifetime.netcoreapp.cs index 58cc81ba294787..c5be9291ffd44c 100644 --- a/src/libraries/Microsoft.Extensions.Hosting/src/Internal/ConsoleLifetime.netcoreapp.cs +++ b/src/libraries/Microsoft.Extensions.Hosting/src/Internal/ConsoleLifetime.netcoreapp.cs @@ -46,8 +46,8 @@ private void HandleWindowsShutdown(PosixSignalContext context) ApplicationLifetime.StopApplication(); - // we could wait for a signal here, like Dispose as is done in non-netcoreapp case, but those inevitably could have user - // code that runs after them in the user's Main. Instead we just block this thread completely and let the main routine exit. + // We could wait for a signal here, like Dispose as is done in non-netcoreapp case, but those inevitably could have user + // code that runs after them in the user's Main. Instead we just block this thread completely and let the main routine exit. Thread.Sleep(HostOptions.ShutdownTimeout); }