diff --git a/src/libraries/Microsoft.Extensions.Hosting.Systemd/tests/UseSystemdTests.cs b/src/libraries/Microsoft.Extensions.Hosting.Systemd/tests/UseSystemdTests.cs index 128858a35c338..328a1b870e04a 100644 --- a/src/libraries/Microsoft.Extensions.Hosting.Systemd/tests/UseSystemdTests.cs +++ b/src/libraries/Microsoft.Extensions.Hosting.Systemd/tests/UseSystemdTests.cs @@ -2,7 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting.Internal; +using Microsoft.Extensions.Hosting.Systemd; using Xunit; namespace Microsoft.Extensions.Hosting @@ -19,7 +19,8 @@ public void DefaultsToOffOutsideOfService() using (host) { var lifetime = host.Services.GetRequiredService(); - Assert.IsType(lifetime); + Assert.NotNull(lifetime); + Assert.IsNotType(lifetime); } } } diff --git a/src/libraries/Microsoft.Extensions.Hosting/Directory.Build.props b/src/libraries/Microsoft.Extensions.Hosting/Directory.Build.props index 668e3954f0b42..1ade7c7070766 100644 --- a/src/libraries/Microsoft.Extensions.Hosting/Directory.Build.props +++ b/src/libraries/Microsoft.Extensions.Hosting/Directory.Build.props @@ -2,5 +2,6 @@ true + true - \ No newline at end of file + diff --git a/src/libraries/Microsoft.Extensions.Hosting/docs/HostShutdown.md b/src/libraries/Microsoft.Extensions.Hosting/docs/HostShutdown.md new file mode 100644 index 0000000000000..8126cb7bd493a --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Hosting/docs/HostShutdown.md @@ -0,0 +1,68 @@ +# Host Shutdown + +A Hosted Service process can be stopped in the following ways: + +1. If someone doesn't call `Run` or `WaitForShutdown` and the app exits normally with "Main" completing +2. A crash occurs +3. The app is forcefully shut down, e.g. SIGKILL (i.e. CTRL+Z) +4. When ConsoleLifetime is used it listens for the following signals and attempts to stop the host gracefully + 1. SIGINT (i.e. CTRL+C) + 2. SIGQUIT (i.e. CTRL+BREAK on Windows, CTRL+`\` on Unix) + 3. SIGTERM (sent by other apps, e.g. `docker stop`) +5. The app calls `Environment.Exit(code)` + +Scenarios (1), (2), and (3) aren't handled directly by the Hosting code. The owner of the process needs to deal with +them the same as any application. + +Scenarios (4) and (5) are handled specially by the built-in Hosting logic. Specifically by the ConsoleLifetime +class. ConsoleLifetime tries to handle the "shutdown" signals listed in (4) and allow for a graceful exit to the +application. + +Before .NET 6, there wasn't a way for .NET code to gracefully handle SIGTERM. To work around this limitation, +ConsoleLifetime would subscribe to `AppDomain.CurrentDomain.ProcessExit`. When `ProcessExit` was raised, +ConsoleLifetime would signal the Host to stop, and block the `ProcessExit` thread, waiting for the Host to stop. +This would allow for the clean up code in the application to run - for example, `IHostedService.StopAsync` and code after +`Host.Run` in the Main method. + +This caused other issues because SIGTERM wasn't the only way `ProcessExit` was raised. It is also raised by code +in the application calling `Environment.Exit`. `Environment.Exit` isn't a graceful way of shutting down a process. +It raises the `ProcessExit` event and then exits the process. The end of the Main method doesn't get executed. However, +since ConsoleLifetime blocked `ProcessExit` waiting for the Host to shutdown, this behavior also lead to [deadlocks][deadlocks] +due to `Environment.Exit` also blocking waiting for `ProcessExit` to return. Additionally, since SIGTERM handling was attempting +to gracefully shut down the process, ConsoleLifetime would set the ExitCode to `0`, which [clobbered][clobbered] the user's +exit code passed to `Environment.Exit`. + +[deadlocks]: https://github.com/dotnet/runtime/issues/50397 +[clobbered]: https://github.com/dotnet/runtime/issues/42224 + +In .NET 6, we added new support to handle [POSIX signals][POSIX signals]. This allows for ConsoleLifetime to specifically +handle SIGTERM gracefully, and no longer get involved when `Environment.Exit` is invoked. For .NET 6+, ConsoleLifetime no longer +has logic to handle scenario (5) above. Apps that call `Environment.Exit`, and need to do clean up logic, can subscribe to +`ProcessExit` themselves. Hosting will no longer attempt to gracefully stop the Host in this scenario. + +[POSIX signals]: https://github.com/dotnet/runtime/issues/50527 + +### Hosting Shutdown Process + +The following sequence model shows how the signals in (4) above are handled internally in the Hosting code. It isn't necessary +for the majority of users to understand this process. But for developers that need a deep understanding, this may help them +get started. + +After the Host has been started, when a user calls `Run` or `WaitForShutdown`, a handler gets registered for +`ApplicationLifetime.ApplicationStopping`. Execution is paused in `WaitForShutdown`, waiting for the `ApplicationStopping` +event to be raised. This is how the "Main" method doesn't return right away, and the app stays running until +`Run`/`WaitForShutdown` returns. + +When a signal is sent to the process, it kicks off the following sequence. + +![image](images/HostShutdownSequence.png) + +The control flows from `ConsoleLifetime` to `ApplicationLifetime` to raise the `ApplicationStopping` event. This signals +`WaitForShutdownAsync` to unblock the "Main" execution code. In the meantime, the POSIX signal handler returns with +`Cancel = true` since this POSIX signal has been handled. + +The "Main" execution code starts executing again and tells the Host to `StopAsync()`, which in turn stops all the Hosted +Services and raises any other stopped events. + +Finally, `WaitForShutdown` exits, allowing for any application clean up code to execute, and for the "Main" method +to exit gracefully. \ No newline at end of file diff --git a/src/libraries/Microsoft.Extensions.Hosting/docs/images/HostShutdownSequence.png b/src/libraries/Microsoft.Extensions.Hosting/docs/images/HostShutdownSequence.png new file mode 100644 index 0000000000000..4494a35718f33 Binary files /dev/null and b/src/libraries/Microsoft.Extensions.Hosting/docs/images/HostShutdownSequence.png differ diff --git a/src/libraries/Microsoft.Extensions.Hosting/ref/Microsoft.Extensions.Hosting.cs b/src/libraries/Microsoft.Extensions.Hosting/ref/Microsoft.Extensions.Hosting.cs index 2c7bf580f9b5c..6dd8be7951592 100644 --- a/src/libraries/Microsoft.Extensions.Hosting/ref/Microsoft.Extensions.Hosting.cs +++ b/src/libraries/Microsoft.Extensions.Hosting/ref/Microsoft.Extensions.Hosting.cs @@ -50,9 +50,29 @@ public static partial class HostingHostBuilderExtensions public static Microsoft.Extensions.Hosting.IHostBuilder ConfigureLogging(this Microsoft.Extensions.Hosting.IHostBuilder hostBuilder, System.Action configureLogging) { throw null; } public static Microsoft.Extensions.Hosting.IHostBuilder ConfigureLogging(this Microsoft.Extensions.Hosting.IHostBuilder hostBuilder, System.Action configureLogging) { throw null; } public static Microsoft.Extensions.Hosting.IHostBuilder ConfigureServices(this Microsoft.Extensions.Hosting.IHostBuilder hostBuilder, System.Action configureDelegate) { throw null; } + [System.Runtime.Versioning.UnsupportedOSPlatform("android")] + [System.Runtime.Versioning.UnsupportedOSPlatform("browser")] + [System.Runtime.Versioning.UnsupportedOSPlatform("ios")] + [System.Runtime.Versioning.UnsupportedOSPlatform("maccatalyst")] + [System.Runtime.Versioning.UnsupportedOSPlatform("tvos")] public static System.Threading.Tasks.Task RunConsoleAsync(this Microsoft.Extensions.Hosting.IHostBuilder hostBuilder, System.Action configureOptions, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + [System.Runtime.Versioning.UnsupportedOSPlatform("android")] + [System.Runtime.Versioning.UnsupportedOSPlatform("browser")] + [System.Runtime.Versioning.UnsupportedOSPlatform("ios")] + [System.Runtime.Versioning.UnsupportedOSPlatform("maccatalyst")] + [System.Runtime.Versioning.UnsupportedOSPlatform("tvos")] public static System.Threading.Tasks.Task RunConsoleAsync(this Microsoft.Extensions.Hosting.IHostBuilder hostBuilder, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + [System.Runtime.Versioning.UnsupportedOSPlatform("android")] + [System.Runtime.Versioning.UnsupportedOSPlatform("browser")] + [System.Runtime.Versioning.UnsupportedOSPlatform("ios")] + [System.Runtime.Versioning.UnsupportedOSPlatform("maccatalyst")] + [System.Runtime.Versioning.UnsupportedOSPlatform("tvos")] public static Microsoft.Extensions.Hosting.IHostBuilder UseConsoleLifetime(this Microsoft.Extensions.Hosting.IHostBuilder hostBuilder) { throw null; } + [System.Runtime.Versioning.UnsupportedOSPlatform("android")] + [System.Runtime.Versioning.UnsupportedOSPlatform("browser")] + [System.Runtime.Versioning.UnsupportedOSPlatform("ios")] + [System.Runtime.Versioning.UnsupportedOSPlatform("maccatalyst")] + [System.Runtime.Versioning.UnsupportedOSPlatform("tvos")] public static Microsoft.Extensions.Hosting.IHostBuilder UseConsoleLifetime(this Microsoft.Extensions.Hosting.IHostBuilder hostBuilder, System.Action configureOptions) { throw null; } public static Microsoft.Extensions.Hosting.IHostBuilder UseContentRoot(this Microsoft.Extensions.Hosting.IHostBuilder hostBuilder, string contentRoot) { throw null; } public static Microsoft.Extensions.Hosting.IHostBuilder UseDefaultServiceProvider(this Microsoft.Extensions.Hosting.IHostBuilder hostBuilder, System.Action configure) { throw null; } @@ -78,6 +98,11 @@ public void NotifyStarted() { } public void NotifyStopped() { } public void StopApplication() { } } + [System.Runtime.Versioning.UnsupportedOSPlatform("android")] + [System.Runtime.Versioning.UnsupportedOSPlatform("browser")] + [System.Runtime.Versioning.UnsupportedOSPlatform("ios")] + [System.Runtime.Versioning.UnsupportedOSPlatform("maccatalyst")] + [System.Runtime.Versioning.UnsupportedOSPlatform("tvos")] public partial class ConsoleLifetime : Microsoft.Extensions.Hosting.IHostLifetime, System.IDisposable { public ConsoleLifetime(Microsoft.Extensions.Options.IOptions options, Microsoft.Extensions.Hosting.IHostEnvironment environment, Microsoft.Extensions.Hosting.IHostApplicationLifetime applicationLifetime, Microsoft.Extensions.Options.IOptions hostOptions) { } diff --git a/src/libraries/Microsoft.Extensions.Hosting/src/HostBuilder.cs b/src/libraries/Microsoft.Extensions.Hosting/src/HostBuilder.cs index 7ed38f701f6ae..3827761382a13 100644 --- a/src/libraries/Microsoft.Extensions.Hosting/src/HostBuilder.cs +++ b/src/libraries/Microsoft.Extensions.Hosting/src/HostBuilder.cs @@ -19,7 +19,7 @@ namespace Microsoft.Extensions.Hosting /// /// A program initialization utility. /// - public class HostBuilder : IHostBuilder + public partial class HostBuilder : IHostBuilder { private List> _configureHostConfigActions = new List>(); private List> _configureAppConfigActions = new List>(); @@ -245,7 +245,9 @@ private void CreateServiceProvider() services.AddSingleton(s => (IApplicationLifetime)s.GetService()); #pragma warning restore CS0618 // Type or member is obsolete services.AddSingleton(); - services.AddSingleton(); + + AddLifetime(services); + services.AddSingleton(_ => { return new Internal.Host(_appServices, diff --git a/src/libraries/Microsoft.Extensions.Hosting/src/HostBuilder.netcoreapp.cs b/src/libraries/Microsoft.Extensions.Hosting/src/HostBuilder.netcoreapp.cs new file mode 100644 index 0000000000000..d1eb2956347b5 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Hosting/src/HostBuilder.netcoreapp.cs @@ -0,0 +1,24 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting.Internal; + +namespace Microsoft.Extensions.Hosting +{ + public partial class HostBuilder + { + private static void AddLifetime(ServiceCollection services) + { + if (!OperatingSystem.IsAndroid() && !OperatingSystem.IsBrowser() && !OperatingSystem.IsIOS() && !OperatingSystem.IsMacCatalyst() && !OperatingSystem.IsTvOS()) + { + services.AddSingleton(); + } + else + { + services.AddSingleton(); + } + } + } +} diff --git a/src/libraries/Microsoft.Extensions.Hosting/src/HostBuilder.notnetcoreapp.cs b/src/libraries/Microsoft.Extensions.Hosting/src/HostBuilder.notnetcoreapp.cs new file mode 100644 index 0000000000000..71ecd0da61b89 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Hosting/src/HostBuilder.notnetcoreapp.cs @@ -0,0 +1,16 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting.Internal; + +namespace Microsoft.Extensions.Hosting +{ + public partial class HostBuilder + { + private static void AddLifetime(ServiceCollection services) + { + services.AddSingleton(); + } + } +} diff --git a/src/libraries/Microsoft.Extensions.Hosting/src/HostingHostBuilderExtensions.cs b/src/libraries/Microsoft.Extensions.Hosting/src/HostingHostBuilderExtensions.cs index 30981bc14970c..9f5e509d8103b 100644 --- a/src/libraries/Microsoft.Extensions.Hosting/src/HostingHostBuilderExtensions.cs +++ b/src/libraries/Microsoft.Extensions.Hosting/src/HostingHostBuilderExtensions.cs @@ -7,6 +7,7 @@ using System.IO; using System.Reflection; using System.Runtime.InteropServices; +using System.Runtime.Versioning; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Configuration; @@ -225,7 +226,12 @@ public static IHostBuilder ConfigureDefaults(this IHostBuilder builder, string[] }) .ConfigureLogging((hostingContext, logging) => { - bool isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + bool isWindows = +#if NET6_0_OR_GREATER + OperatingSystem.IsWindows(); +#else + RuntimeInformation.IsOSPlatform(OSPlatform.Windows); +#endif // IMPORTANT: This needs to be added *before* configuration is loaded, this lets // the defaults be overridden by the configuration. @@ -236,7 +242,12 @@ public static IHostBuilder ConfigureDefaults(this IHostBuilder builder, string[] } logging.AddConfiguration(hostingContext.Configuration.GetSection("Logging")); - logging.AddConsole(); +#if NET6_0_OR_GREATER + if (!OperatingSystem.IsBrowser()) +#endif + { + logging.AddConsole(); + } logging.AddDebug(); logging.AddEventSourceLogger(); @@ -274,6 +285,11 @@ public static IHostBuilder ConfigureDefaults(this IHostBuilder builder, string[] /// /// The to configure. /// The same instance of the for chaining. + [UnsupportedOSPlatform("android")] + [UnsupportedOSPlatform("browser")] + [UnsupportedOSPlatform("ios")] + [UnsupportedOSPlatform("maccatalyst")] + [UnsupportedOSPlatform("tvos")] public static IHostBuilder UseConsoleLifetime(this IHostBuilder hostBuilder) { return hostBuilder.ConfigureServices(collection => collection.AddSingleton()); @@ -286,6 +302,11 @@ public static IHostBuilder UseConsoleLifetime(this IHostBuilder hostBuilder) /// The to configure. /// The delegate for configuring the . /// The same instance of the for chaining. + [UnsupportedOSPlatform("android")] + [UnsupportedOSPlatform("browser")] + [UnsupportedOSPlatform("ios")] + [UnsupportedOSPlatform("maccatalyst")] + [UnsupportedOSPlatform("tvos")] public static IHostBuilder UseConsoleLifetime(this IHostBuilder hostBuilder, Action configureOptions) { return hostBuilder.ConfigureServices(collection => @@ -301,6 +322,11 @@ public static IHostBuilder UseConsoleLifetime(this IHostBuilder hostBuilder, Act /// The to configure. /// A that can be used to cancel the console. /// A that only completes when the token is triggered or shutdown is triggered. + [UnsupportedOSPlatform("android")] + [UnsupportedOSPlatform("browser")] + [UnsupportedOSPlatform("ios")] + [UnsupportedOSPlatform("maccatalyst")] + [UnsupportedOSPlatform("tvos")] public static Task RunConsoleAsync(this IHostBuilder hostBuilder, CancellationToken cancellationToken = default) { return hostBuilder.UseConsoleLifetime().Build().RunAsync(cancellationToken); @@ -313,6 +339,11 @@ public static Task RunConsoleAsync(this IHostBuilder hostBuilder, CancellationTo /// The delegate for configuring the . /// A that can be used to cancel the console. /// A that only completes when the token is triggered or shutdown is triggered. + [UnsupportedOSPlatform("android")] + [UnsupportedOSPlatform("browser")] + [UnsupportedOSPlatform("ios")] + [UnsupportedOSPlatform("maccatalyst")] + [UnsupportedOSPlatform("tvos")] public static Task RunConsoleAsync(this IHostBuilder hostBuilder, Action configureOptions, CancellationToken cancellationToken = default) { return hostBuilder.UseConsoleLifetime(configureOptions).Build().RunAsync(cancellationToken); diff --git a/src/libraries/Microsoft.Extensions.Hosting/src/Internal/ConsoleLifetime.cs b/src/libraries/Microsoft.Extensions.Hosting/src/Internal/ConsoleLifetime.cs index caa88e87c1f41..5b964fa6a8e7a 100644 --- a/src/libraries/Microsoft.Extensions.Hosting/src/Internal/ConsoleLifetime.cs +++ b/src/libraries/Microsoft.Extensions.Hosting/src/Internal/ConsoleLifetime.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Runtime.Versioning; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; @@ -13,9 +14,13 @@ namespace Microsoft.Extensions.Hosting.Internal /// /// Listens for Ctrl+C or SIGTERM and initiates shutdown. /// - public class ConsoleLifetime : IHostLifetime, IDisposable + [UnsupportedOSPlatform("android")] + [UnsupportedOSPlatform("browser")] + [UnsupportedOSPlatform("ios")] + [UnsupportedOSPlatform("maccatalyst")] + [UnsupportedOSPlatform("tvos")] + public partial class ConsoleLifetime : IHostLifetime, IDisposable { - private readonly ManualResetEvent _shutdownBlock = new ManualResetEvent(false); private CancellationTokenRegistration _applicationStartedRegistration; private CancellationTokenRegistration _applicationStoppingRegistration; @@ -57,13 +62,14 @@ public Task WaitForStartAsync(CancellationToken cancellationToken) this); } - AppDomain.CurrentDomain.ProcessExit += OnProcessExit; - Console.CancelKeyPress += OnCancelKeyPress; + RegisterShutdownHandlers(); // Console applications start immediately. return Task.CompletedTask; } + private partial void RegisterShutdownHandlers(); + private void OnApplicationStarted() { Logger.LogInformation("Application started. Press Ctrl+C to shut down."); @@ -76,29 +82,6 @@ private void OnApplicationStopping() Logger.LogInformation("Application is shutting down..."); } - private void OnProcessExit(object sender, EventArgs e) - { - ApplicationLifetime.StopApplication(); - if (!_shutdownBlock.WaitOne(HostOptions.ShutdownTimeout)) - { - Logger.LogInformation("Waiting for the host to be disposed. Ensure all 'IHost' instances are wrapped in 'using' blocks."); - } - _shutdownBlock.WaitOne(); - // On Linux if the shutdown is triggered by SIGTERM then that's signaled with the 143 exit code. - // Suppress that since we shut down gracefully. https://github.com/dotnet/aspnetcore/issues/6526 - System.Environment.ExitCode = 0; - } - - private void OnCancelKeyPress(object sender, ConsoleCancelEventArgs e) - { - e.Cancel = true; - ApplicationLifetime.StopApplication(); - - // Don't block in process shutdown for CTRL+C/SIGINT since we can set e.Cancel to true - // we assume that application code will unwind once StopApplication signals the token - _shutdownBlock.Set(); - } - public Task StopAsync(CancellationToken cancellationToken) { // There's nothing to do here @@ -107,13 +90,12 @@ public Task StopAsync(CancellationToken cancellationToken) public void Dispose() { - _shutdownBlock.Set(); - - AppDomain.CurrentDomain.ProcessExit -= OnProcessExit; - Console.CancelKeyPress -= OnCancelKeyPress; + UnregisterShutdownHandlers(); _applicationStartedRegistration.Dispose(); _applicationStoppingRegistration.Dispose(); } + + private partial void UnregisterShutdownHandlers(); } } diff --git a/src/libraries/Microsoft.Extensions.Hosting/src/Internal/ConsoleLifetime.netcoreapp.cs b/src/libraries/Microsoft.Extensions.Hosting/src/Internal/ConsoleLifetime.netcoreapp.cs new file mode 100644 index 0000000000000..174a85ec5f9fe --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Hosting/src/Internal/ConsoleLifetime.netcoreapp.cs @@ -0,0 +1,39 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics; +using System.Runtime.InteropServices; + +namespace Microsoft.Extensions.Hosting.Internal +{ + public partial class ConsoleLifetime : IHostLifetime + { + private PosixSignalRegistration _sigIntRegistration; + private PosixSignalRegistration _sigQuitRegistration; + private PosixSignalRegistration _sigTermRegistration; + + 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); + } + + private void HandlePosixSignal(PosixSignalContext context) + { + Debug.Assert(context.Signal == PosixSignal.SIGINT || context.Signal == PosixSignal.SIGQUIT || context.Signal == PosixSignal.SIGTERM); + + context.Cancel = true; + ApplicationLifetime.StopApplication(); + } + + private partial void UnregisterShutdownHandlers() + { + _sigIntRegistration?.Dispose(); + _sigQuitRegistration?.Dispose(); + _sigTermRegistration?.Dispose(); + } + } +} diff --git a/src/libraries/Microsoft.Extensions.Hosting/src/Internal/ConsoleLifetime.notnetcoreapp.cs b/src/libraries/Microsoft.Extensions.Hosting/src/Internal/ConsoleLifetime.notnetcoreapp.cs new file mode 100644 index 0000000000000..7f08987205c88 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Hosting/src/Internal/ConsoleLifetime.notnetcoreapp.cs @@ -0,0 +1,54 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Threading; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Extensions.Hosting.Internal +{ + public partial class ConsoleLifetime : IHostLifetime + { + private readonly ManualResetEvent _shutdownBlock = new ManualResetEvent(false); + + private partial void RegisterShutdownHandlers() + { + AppDomain.CurrentDomain.ProcessExit += OnProcessExit; + Console.CancelKeyPress += OnCancelKeyPress; + } + + private void OnCancelKeyPress(object sender, ConsoleCancelEventArgs e) + { + e.Cancel = true; + ApplicationLifetime.StopApplication(); + + // Don't block in process shutdown for CTRL+C/SIGINT since we can set e.Cancel to true + // we assume that application code will unwind once StopApplication signals the token + _shutdownBlock.Set(); + } + + private void OnProcessExit(object sender, EventArgs e) + { + ApplicationLifetime.StopApplication(); + if (!_shutdownBlock.WaitOne(HostOptions.ShutdownTimeout)) + { + Logger.LogInformation("Waiting for the host to be disposed. Ensure all 'IHost' instances are wrapped in 'using' blocks."); + } + + // wait one more time after the above log message, but only for ShutdownTimeout, so it doesn't hang forever + _shutdownBlock.WaitOne(HostOptions.ShutdownTimeout); + + // On Linux if the shutdown is triggered by SIGTERM then that's signaled with the 143 exit code. + // Suppress that since we shut down gracefully. https://github.com/dotnet/aspnetcore/issues/6526 + System.Environment.ExitCode = 0; + } + + private partial void UnregisterShutdownHandlers() + { + _shutdownBlock.Set(); + + AppDomain.CurrentDomain.ProcessExit -= OnProcessExit; + Console.CancelKeyPress -= OnCancelKeyPress; + } + } +} diff --git a/src/libraries/Microsoft.Extensions.Hosting/src/Internal/NullLifetime.cs b/src/libraries/Microsoft.Extensions.Hosting/src/Internal/NullLifetime.cs new file mode 100644 index 0000000000000..bad58fb0e0b12 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Hosting/src/Internal/NullLifetime.cs @@ -0,0 +1,24 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.Hosting.Internal +{ + /// + /// Minimalistic lifetime that does nothing. + /// + internal class NullLifetime : IHostLifetime + { + public Task WaitForStartAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + } +} diff --git a/src/libraries/Microsoft.Extensions.Hosting/src/Microsoft.Extensions.Hosting.csproj b/src/libraries/Microsoft.Extensions.Hosting/src/Microsoft.Extensions.Hosting.csproj index 4b7cf54479bfe..b868be57845e0 100644 --- a/src/libraries/Microsoft.Extensions.Hosting/src/Microsoft.Extensions.Hosting.csproj +++ b/src/libraries/Microsoft.Extensions.Hosting/src/Microsoft.Extensions.Hosting.csproj @@ -1,16 +1,30 @@ - + - netstandard2.0;netstandard2.1;net461 + $(NetCoreAppCurrent);netstandard2.0;netstandard2.1;net461 true Hosting and startup infrastructures for applications. + + false - + + + + + + + + + + + + + diff --git a/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/ConsoleLifetimeExitTests.cs b/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/ConsoleLifetimeExitTests.cs new file mode 100644 index 0000000000000..ab803145a4387 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/ConsoleLifetimeExitTests.cs @@ -0,0 +1,158 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.DotNet.RemoteExecutor; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Microsoft.Extensions.Hosting.Tests +{ + public class ConsoleLifetimeExitTests + { + /// + /// Tests that a Hosted process that receives SIGTERM/SIGINT/SIGQUIT completes successfully + /// 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 () => + { + await Host.CreateDefaultBuilder() + .ConfigureServices((hostContext, services) => + { + services.AddHostedService(); + }) + .RunConsoleAsync(); + + // adding this delay ensures the "main" method loses in a race with the normal process exit + // and can cause the below message not to be written when the normal process exit isn't canceled by the + // SIGTERM handler + await Task.Delay(100); + + Console.WriteLine("Run has completed"); + return 123; + }, new RemoteInvokeOptions() { Start = false, ExpectedExitCode = 123 }); + + remoteHandle.Process.StartInfo.RedirectStandardOutput = true; + remoteHandle.Process.Start(); + + // wait for the host process to start + string line; + while ((line = remoteHandle.Process.StandardOutput.ReadLine()).EndsWith("Started")) + { + await Task.Delay(20); + } + + // send the signal to the process + kill(remoteHandle.Process.Id, signal); + + remoteHandle.Process.WaitForExit(); + + string processOutput = remoteHandle.Process.StandardOutput.ReadToEnd(); + Assert.Contains("Run has completed", processOutput); + Assert.Equal(123, remoteHandle.Process.ExitCode); + } + + private const int SIGINT = 2; + private const int SIGQUIT = 3; + private const int SIGTERM = 15; + + [DllImport("libc", SetLastError = true)] + private static extern int kill(int pid, int sig); + + private class EnsureSignalContinuesMainMethodWorker : BackgroundService + { + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + try + { + await Task.Delay(20, stoppingToken); + Console.WriteLine("Started"); + } + catch (OperationCanceledException) + { + return; + } + } + } + } + + /// + /// Tests that calling Environment.Exit from a Hosted app sets the correct exit code. + /// + [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + // SIGTERM is only handled on net6.0+, so the workaround to "clobber" the exit code is still in place on NetFramework + [SkipOnTargetFramework(TargetFrameworkMonikers.NetFramework)] + public void EnsureEnvironmentExitCode() + { + using var remoteHandle = RemoteExecutor.Invoke(async () => + { + await Host.CreateDefaultBuilder() + .ConfigureServices((hostContext, services) => + { + services.AddHostedService(); + }) + .RunConsoleAsync(); + }); + + remoteHandle.Process.WaitForExit(); + + Assert.Equal(124, remoteHandle.Process.ExitCode); + } + + private class EnsureEnvironmentExitCodeWorker : BackgroundService + { + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await Task.Run(() => + { + Environment.Exit(124); + }); + } + } + + /// + /// Tests that calling Environment.Exit from the "main" thread doesn't hang the process forever. + /// + [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + public void EnsureEnvironmentExitDoesntHang() + { + using var remoteHandle = RemoteExecutor.Invoke(async () => + { + await Host.CreateDefaultBuilder() + .ConfigureHostOptions(o => o.ShutdownTimeout = TimeSpan.FromMilliseconds(100)) + .ConfigureServices((hostContext, services) => + { + services.AddHostedService(); + }) + .RunConsoleAsync(); + }, new RemoteInvokeOptions() { TimeOut = 10_000 }); // give a 10 second time out, so if this does hang, it doesn't hang for the full timeout + + Assert.True(remoteHandle.Process.WaitForExit(10_000), "The hosted process should have exited within 10 seconds"); + + // SIGTERM is only handled on net6.0+, so the workaround to "clobber" the exit code is still in place on NetFramework + int expectedExitCode = PlatformDetection.IsNetFramework ? 0 : 125; + Assert.Equal(expectedExitCode, remoteHandle.Process.ExitCode); + } + + private class EnsureEnvironmentExitDoesntHangWorker : BackgroundService + { + protected override Task ExecuteAsync(CancellationToken stoppingToken) + { + Environment.Exit(125); + return Task.CompletedTask; + } + } + } +} 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 c5049661e96a1..ee0c814c41b3f 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);net461 true true + true