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 1f92e812f2fc8..a2b2b83ae5d56 100644 --- a/src/libraries/Microsoft.Extensions.Hosting/ref/Microsoft.Extensions.Hosting.cs +++ b/src/libraries/Microsoft.Extensions.Hosting/ref/Microsoft.Extensions.Hosting.cs @@ -13,6 +13,11 @@ public static partial class OptionsBuilderExtensions } namespace Microsoft.Extensions.Hosting { + public enum BackgroundServiceExceptionBehavior + { + StopHost, + Ignore + } public partial class ConsoleLifetimeOptions { public ConsoleLifetimeOptions() { } @@ -56,6 +61,7 @@ public partial class HostOptions { public HostOptions() { } public System.TimeSpan ShutdownTimeout { get { throw null; } set { } } + public Microsoft.Extensions.Hosting.BackgroundServiceExceptionBehavior BackgroundServiceExceptionBehavior { get { throw null; } set { } } } } namespace Microsoft.Extensions.Hosting.Internal diff --git a/src/libraries/Microsoft.Extensions.Hosting/src/BackgroundServiceExceptionBehavior.cs b/src/libraries/Microsoft.Extensions.Hosting/src/BackgroundServiceExceptionBehavior.cs new file mode 100644 index 0000000000000..7b3b66fc1bf3f --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Hosting/src/BackgroundServiceExceptionBehavior.cs @@ -0,0 +1,29 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Extensions.Hosting +{ + /// + /// Specifies a behavior that the will honor if an + /// unhandled exception occurs in one of its instances. + /// + public enum BackgroundServiceExceptionBehavior + { + /// + /// Stops the instance. + /// + /// + /// If a throws an exception, the instance stops, and the process continues. + /// + StopHost = 0, + + /// + /// Ignore exceptions thrown in . + /// + /// + /// If a throws an exception, the will log the error, but otherwise ignore it. + /// The is not restarted. + /// + Ignore = 1 + } +} diff --git a/src/libraries/Microsoft.Extensions.Hosting/src/HostOptions.cs b/src/libraries/Microsoft.Extensions.Hosting/src/HostOptions.cs index 3b825718642d0..208d945678db7 100644 --- a/src/libraries/Microsoft.Extensions.Hosting/src/HostOptions.cs +++ b/src/libraries/Microsoft.Extensions.Hosting/src/HostOptions.cs @@ -17,6 +17,16 @@ public class HostOptions /// public TimeSpan ShutdownTimeout { get; set; } = TimeSpan.FromSeconds(5); + /// + /// The behavior the will follow when any of + /// its instances throw an unhandled exception. + /// + /// + /// Defaults to . + /// + public BackgroundServiceExceptionBehavior BackgroundServiceExceptionBehavior { get; set; } = + BackgroundServiceExceptionBehavior.StopHost; + internal void Initialize(IConfiguration configuration) { var timeoutSeconds = configuration["shutdownTimeoutSeconds"]; diff --git a/src/libraries/Microsoft.Extensions.Hosting/src/Internal/Host.cs b/src/libraries/Microsoft.Extensions.Hosting/src/Internal/Host.cs index ca78d42a05bc6..bcaed337120e2 100644 --- a/src/libraries/Microsoft.Extensions.Hosting/src/Internal/Host.cs +++ b/src/libraries/Microsoft.Extensions.Hosting/src/Internal/Host.cs @@ -66,7 +66,7 @@ public async Task StartAsync(CancellationToken cancellationToken = default) if (hostedService is BackgroundService backgroundService) { - _ = HandleBackgroundException(backgroundService); + _ = TryExecuteBackgroundServiceAsync(backgroundService); } } @@ -76,7 +76,7 @@ public async Task StartAsync(CancellationToken cancellationToken = default) _logger.Started(); } - private async Task HandleBackgroundException(BackgroundService backgroundService) + private async Task TryExecuteBackgroundServiceAsync(BackgroundService backgroundService) { try { @@ -85,6 +85,11 @@ private async Task HandleBackgroundException(BackgroundService backgroundService catch (Exception ex) { _logger.BackgroundServiceFaulted(ex); + if (_options.BackgroundServiceExceptionBehavior == BackgroundServiceExceptionBehavior.StopHost) + { + _logger.BackgroundServiceStoppingHost(ex); + _applicationLifetime.StopApplication(); + } } } diff --git a/src/libraries/Microsoft.Extensions.Hosting/src/Internal/HostingLoggerExtensions.cs b/src/libraries/Microsoft.Extensions.Hosting/src/Internal/HostingLoggerExtensions.cs index a3c722f965336..32e560f1f74fd 100644 --- a/src/libraries/Microsoft.Extensions.Hosting/src/Internal/HostingLoggerExtensions.cs +++ b/src/libraries/Microsoft.Extensions.Hosting/src/Internal/HostingLoggerExtensions.cs @@ -11,8 +11,7 @@ internal static class HostingLoggerExtensions { public static void ApplicationError(this ILogger logger, EventId eventId, string message, Exception exception) { - var reflectionTypeLoadException = exception as ReflectionTypeLoadException; - if (reflectionTypeLoadException != null) + if (exception is ReflectionTypeLoadException reflectionTypeLoadException) { foreach (Exception ex in reflectionTypeLoadException.LoaderExceptions) { @@ -87,5 +86,16 @@ public static void BackgroundServiceFaulted(this ILogger logger, Exception ex) message: "BackgroundService failed"); } } + + public static void BackgroundServiceStoppingHost(this ILogger logger, Exception ex) + { + if (logger.IsEnabled(LogLevel.Critical)) + { + logger.LogCritical( + eventId: LoggerEventIds.BackgroundServiceStoppingHost, + exception: ex, + message: SR.BackgroundServiceExceptionStoppedHost); + } + } } } diff --git a/src/libraries/Microsoft.Extensions.Hosting/src/Internal/LoggerEventIds.cs b/src/libraries/Microsoft.Extensions.Hosting/src/Internal/LoggerEventIds.cs index 666266c1d50d4..9271953313a05 100644 --- a/src/libraries/Microsoft.Extensions.Hosting/src/Internal/LoggerEventIds.cs +++ b/src/libraries/Microsoft.Extensions.Hosting/src/Internal/LoggerEventIds.cs @@ -7,14 +7,15 @@ namespace Microsoft.Extensions.Hosting.Internal { internal static class LoggerEventIds { - public static readonly EventId Starting = new EventId(1, "Starting"); - public static readonly EventId Started = new EventId(2, "Started"); - public static readonly EventId Stopping = new EventId(3, "Stopping"); - public static readonly EventId Stopped = new EventId(4, "Stopped"); - public static readonly EventId StoppedWithException = new EventId(5, "StoppedWithException"); - public static readonly EventId ApplicationStartupException = new EventId(6, "ApplicationStartupException"); - public static readonly EventId ApplicationStoppingException = new EventId(7, "ApplicationStoppingException"); - public static readonly EventId ApplicationStoppedException = new EventId(8, "ApplicationStoppedException"); - public static readonly EventId BackgroundServiceFaulted = new EventId(9, "BackgroundServiceFaulted"); + public static readonly EventId Starting = new EventId(1, nameof(Starting)); + public static readonly EventId Started = new EventId(2, nameof(Started)); + public static readonly EventId Stopping = new EventId(3, nameof(Stopping)); + public static readonly EventId Stopped = new EventId(4, nameof(Stopped)); + public static readonly EventId StoppedWithException = new EventId(5, nameof(StoppedWithException)); + public static readonly EventId ApplicationStartupException = new EventId(6, nameof(ApplicationStartupException)); + public static readonly EventId ApplicationStoppingException = new EventId(7, nameof(ApplicationStoppingException)); + public static readonly EventId ApplicationStoppedException = new EventId(8, nameof(ApplicationStoppedException)); + public static readonly EventId BackgroundServiceFaulted = new EventId(9, nameof(BackgroundServiceFaulted)); + public static readonly EventId BackgroundServiceStoppingHost = new EventId(10, nameof(BackgroundServiceStoppingHost)); } } diff --git a/src/libraries/Microsoft.Extensions.Hosting/src/Resources/Strings.resx b/src/libraries/Microsoft.Extensions.Hosting/src/Resources/Strings.resx index c4de18cb0e1e1..90d8aad521a27 100644 --- a/src/libraries/Microsoft.Extensions.Hosting/src/Resources/Strings.resx +++ b/src/libraries/Microsoft.Extensions.Hosting/src/Resources/Strings.resx @@ -1,13 +1,13 @@  - @@ -117,6 +117,9 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + The HostOptions.BackgroundServiceExceptionBehavior is configured to StopHost. A BackgroundService has thrown an unhandled exception, and the IHost instance is stopping. To avoid this behavior, configure this to Ignore; however the BackgroundService will not be restarted. + Build can only be called once. @@ -129,4 +132,4 @@ The resolver returned a null IServiceProviderFactory - \ No newline at end of file + diff --git a/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/HostBuilderTests.cs b/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/HostBuilderTests.cs index df2d42a574c4b..1e4721987d678 100644 --- a/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/HostBuilderTests.cs +++ b/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/HostBuilderTests.cs @@ -572,6 +572,27 @@ public void HostBuilderConfigureDefaultsInterleavesMissingConfigValues() Assert.Equal(expectedContentRootPath, env.ContentRootPath); } + [Theory] + [InlineData(BackgroundServiceExceptionBehavior.Ignore)] + [InlineData(BackgroundServiceExceptionBehavior.StopHost)] + public void HostBuilderCanConfigureBackgroundServiceExceptionBehavior( + BackgroundServiceExceptionBehavior testBehavior) + { + using IHost host = new HostBuilder() + .ConfigureServices( + services => + services.Configure( + options => + options.BackgroundServiceExceptionBehavior = testBehavior)) + .Build(); + + var options = host.Services.GetRequiredService>(); + + Assert.Equal( + testBehavior, + options.Value.BackgroundServiceExceptionBehavior); + } + private class FakeFileProvider : IFileProvider, IDisposable { public bool Disposed { get; private set; } diff --git a/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/HostTests.cs b/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/HostTests.cs index 2136a23ef2bd9..aa5ad3d014a8f 100644 --- a/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/HostTests.cs +++ b/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/HostTests.cs @@ -145,7 +145,7 @@ public void CreateDefaultBuilder_EnablesValidateOnBuild() [ActiveIssue("https://github.com/dotnet/runtime/issues/34580", TestPlatforms.Windows, TargetFrameworkMonikers.Netcoreapp, TestRuntimes.Mono)] public async Task CreateDefaultBuilder_ConfigJsonDoesNotReload() { - var reloadFlagConfig = new Dictionary() {{ "hostbuilder:reloadConfigOnChange", "false" }}; + var reloadFlagConfig = new Dictionary() { { "hostbuilder:reloadConfigOnChange", "false" } }; var appSettingsPath = Path.Combine(Path.GetTempPath(), "appsettings.json"); string SaveRandomConfig() diff --git a/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/Internal/HostTests.cs b/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/Internal/HostTests.cs index f7ce5afc57ca7..4138e7df4c5c0 100644 --- a/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/Internal/HostTests.cs +++ b/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/Internal/HostTests.cs @@ -657,6 +657,100 @@ public async Task WebHostStopAsyncUsesDefaultTimeoutIfNoTokenProvided() } } + [Fact] + public async Task HostPropagatesExceptionsThrownWithBackgroundServiceExceptionBehaviorOfStopHost() + { + using IHost host = CreateBuilder() + .ConfigureServices( + services => + { + services.AddHostedService(_ => new AsyncThrowingService(Task.CompletedTask)); + services.Configure( + options => + options.BackgroundServiceExceptionBehavior = + BackgroundServiceExceptionBehavior.StopHost); + }) + .Build(); + + await Assert.ThrowsAsync(() => host.StartAsync()); + } + + [Fact] + public async Task HostStopsApplicationWithOneBackgroundServiceErrorAndOthersWithoutError() + { + var wasOtherServiceStarted = false; + + TaskCompletionSource throwingTcs = new(); + TaskCompletionSource otherTcs = new(); + + using IHost host = CreateBuilder() + .ConfigureServices( + services => + { + services.AddHostedService(_ => new AsyncThrowingService(throwingTcs.Task)); + services.AddHostedService( + _ => new TestBackgroundService(otherTcs.Task, + () => + { + wasOtherServiceStarted = true; + throwingTcs.SetResult(true); + })); + services.Configure( + options => + options.BackgroundServiceExceptionBehavior = + BackgroundServiceExceptionBehavior.StopHost); + }) + .Build(); + + var lifetime = host.Services.GetRequiredService(); + + var wasStartedCalled = false; + lifetime.ApplicationStarted.Register(() => wasStartedCalled = true); + + var wasStoppingCalled = false; + lifetime.ApplicationStopping.Register(() => + { + otherTcs.SetResult(true); + wasStoppingCalled = true; + }); + + await host.StartAsync(); + + Assert.True(wasStartedCalled); + Assert.True(wasStoppingCalled); + Assert.True(wasOtherServiceStarted); + } + + [Fact] + public void HostHandlesExceptionsThrownWithBackgroundServiceExceptionBehaviorOfIgnore() + { + var backgroundDelayTaskSource = new TaskCompletionSource(); + + using IHost host = CreateBuilder() + .ConfigureServices( + services => + { + services.AddHostedService( + _ => new AsyncThrowingService(backgroundDelayTaskSource.Task)); + + services.PostConfigure( + options => + options.BackgroundServiceExceptionBehavior = + BackgroundServiceExceptionBehavior.Ignore); + }) + .Build(); + + var lifetime = host.Services.GetRequiredService(); + var wasStoppingCalled = false; + lifetime.ApplicationStopping.Register(() => wasStoppingCalled = true); + + host.Start(); + + backgroundDelayTaskSource.SetResult(true); + + Assert.False(wasStoppingCalled); + } + [Fact] public void HostApplicationLifetimeEventsOrderedCorrectlyDuringShutdown() { @@ -1223,8 +1317,12 @@ public void ThrowExceptionForCustomImplementationOfIHostApplicationLifetime() /// Tests when a BackgroundService throws an exception asynchronously /// (after an await), the exception gets logged correctly. /// - [Fact] - public async Task BackgroundServiceAsyncExceptionGetsLogged() + [Theory] + [InlineData(BackgroundServiceExceptionBehavior.Ignore, "BackgroundService failed")] + [InlineData(BackgroundServiceExceptionBehavior.StopHost, "BackgroundService failed", "The HostOptions.BackgroundServiceExceptionBehavior is configured to StopHost")] + public async Task BackgroundServiceAsyncExceptionGetsLogged( + BackgroundServiceExceptionBehavior testBehavior, + params string[] expectedExceptionMessages) { using TestEventListener listener = new TestEventListener(); var backgroundDelayTaskSource = new TaskCompletionSource(); @@ -1236,6 +1334,9 @@ public async Task BackgroundServiceAsyncExceptionGetsLogged() }) .ConfigureServices((hostContext, services) => { + services.Configure( + options => + options.BackgroundServiceExceptionBehavior = testBehavior); services.AddHostedService(sp => new AsyncThrowingService(backgroundDelayTaskSource.Task)); }) .Start(); @@ -1243,15 +1344,19 @@ public async Task BackgroundServiceAsyncExceptionGetsLogged() backgroundDelayTaskSource.SetResult(true); // give the background service 1 minute to log the failure - TimeSpan timeout = TimeSpan.FromMinutes(1); + var timeout = TimeSpan.FromMinutes(1); Stopwatch sw = Stopwatch.StartNew(); while (true) { - EventWrittenEventArgs[] events = listener.EventData.ToArray(); - if (events.Any(e => - e.EventSource.Name == "Microsoft-Extensions-Logging" && - e.Payload.OfType().Any(p => p.Contains("BackgroundService failed")))) + EventWrittenEventArgs[] events = + listener.EventData.Where( + e => e.EventSource.Name == "Microsoft-Extensions-Logging").ToArray(); + + if (expectedExceptionMessages.All( + expectedMessage => events.Any( + e => e.Payload.OfType().Any( + p => p.Contains(expectedMessage))))) { break; } @@ -1371,6 +1476,26 @@ public ValueTask DisposeAsync() } } + private class TestBackgroundService : IHostedService + { + private readonly Action _onStart; + private readonly Task _emulateWorkTask; + + public TestBackgroundService(Task emulateWorkTask, Action onStart) + { + _emulateWorkTask = emulateWorkTask; + _onStart = onStart; + } + + public async Task StartAsync(CancellationToken stoppingToken) + { + _onStart(); + await _emulateWorkTask; + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + } + private class AsyncThrowingService : BackgroundService { private readonly Task _executeDelayTask;