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 7910670857169a..7a6eb03df41ec7 100644 --- a/src/libraries/Microsoft.Extensions.Hosting/ref/Microsoft.Extensions.Hosting.cs +++ b/src/libraries/Microsoft.Extensions.Hosting/ref/Microsoft.Extensions.Hosting.cs @@ -114,6 +114,8 @@ public partial class HostOptions { public HostOptions() { } public Microsoft.Extensions.Hosting.BackgroundServiceExceptionBehavior BackgroundServiceExceptionBehavior { get { throw null; } set { } } + public bool ServicesStartConcurrently { get { throw null; } set { } } + public bool ServicesStopConcurrently { get { throw null; } set { } } public System.TimeSpan ShutdownTimeout { get { throw null; } set { } } } } diff --git a/src/libraries/Microsoft.Extensions.Hosting/src/HostOptions.cs b/src/libraries/Microsoft.Extensions.Hosting/src/HostOptions.cs index ac7cc3f4863b3f..e6da525fc095e1 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(30); + /// + /// Determines if the will start registered instances of concurrently or sequentially + /// + public bool ServicesStartConcurrently { get; set; } + + /// + /// Determines if the will stop registered instances of concurrently or sequentially + /// + public bool ServicesStopConcurrently { get; set; } + /// /// The behavior the will follow when any of /// its instances throw an unhandled exception. @@ -30,11 +40,25 @@ public class HostOptions internal void Initialize(IConfiguration configuration) { var timeoutSeconds = configuration["shutdownTimeoutSeconds"]; - if (!string.IsNullOrEmpty(timeoutSeconds) + if (!string.IsNullOrWhiteSpace(timeoutSeconds) && int.TryParse(timeoutSeconds, NumberStyles.None, CultureInfo.InvariantCulture, out var seconds)) { ShutdownTimeout = TimeSpan.FromSeconds(seconds); } + + var servicesStartConcurrently = configuration["servicesStartConcurrently"]; + if (!string.IsNullOrWhiteSpace(servicesStartConcurrently) + && bool.TryParse(servicesStartConcurrently, out bool startBehavior)) + { + ServicesStartConcurrently = startBehavior; + } + + var servicesStopConcurrently = configuration["servicesStopConcurrently"]; + if (!string.IsNullOrWhiteSpace(servicesStopConcurrently) + && bool.TryParse(servicesStopConcurrently, out bool stopBehavior)) + { + ServicesStopConcurrently = stopBehavior; + } } } } diff --git a/src/libraries/Microsoft.Extensions.Hosting/src/Internal/Host.cs b/src/libraries/Microsoft.Extensions.Hosting/src/Internal/Host.cs index f0339b80020968..6b2daebcf8d850 100644 --- a/src/libraries/Microsoft.Extensions.Hosting/src/Internal/Host.cs +++ b/src/libraries/Microsoft.Extensions.Hosting/src/Internal/Host.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Runtime.ExceptionServices; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; @@ -65,14 +66,64 @@ public async Task StartAsync(CancellationToken cancellationToken = default) combinedCancellationToken.ThrowIfCancellationRequested(); _hostedServices = Services.GetRequiredService>(); - foreach (IHostedService hostedService in _hostedServices) + List exceptions = new List(); + + if (_options.ServicesStartConcurrently) { - // Fire IHostedService.Start - await hostedService.StartAsync(combinedCancellationToken).ConfigureAwait(false); + Task tasks = Task.WhenAll(_hostedServices.Select(async service => + { + await service.StartAsync(combinedCancellationToken).ConfigureAwait(false); + + if (service is BackgroundService backgroundService) + { + _ = TryExecuteBackgroundServiceAsync(backgroundService); + } + })); - if (hostedService is BackgroundService backgroundService) + try { - _ = TryExecuteBackgroundServiceAsync(backgroundService); + await tasks.ConfigureAwait(false); + } + catch (Exception ex) + { + exceptions.AddRange(tasks.Exception?.InnerExceptions?.ToArray() ?? new[] { ex }); + } + } + else + { + foreach (IHostedService hostedService in _hostedServices) + { + try + { + // Fire IHostedService.Start + await hostedService.StartAsync(combinedCancellationToken).ConfigureAwait(false); + + if (hostedService is BackgroundService backgroundService) + { + _ = TryExecuteBackgroundServiceAsync(backgroundService); + } + } + catch (Exception ex) + { + exceptions.Add(ex); + break; + } + } + } + + if (exceptions.Count > 0) + { + if (exceptions.Count == 1) + { + // Rethrow if it's a single error + _logger.HostedServiceStartupFaulted(exceptions[0]); + ExceptionDispatchInfo.Capture(exceptions[0]).Throw(); + } + else + { + var ex = new AggregateException("One or more hosted services failed to start.", exceptions); + _logger.HostedServiceStartupFaulted(ex); + throw ex; } } @@ -125,18 +176,37 @@ public async Task StopAsync(CancellationToken cancellationToken = default) // Trigger IHostApplicationLifetime.ApplicationStopping _applicationLifetime.StopApplication(); - IList exceptions = new List(); - if (_hostedServices != null) // Started? + List exceptions = new List(); + if (_hostedServices?.Any() == true) // Started? { - foreach (IHostedService hostedService in _hostedServices.Reverse()) + // Ensure hosted services are stopped in FILO order + IEnumerable hostedServices = _hostedServices.Reverse(); + + if (_options.ServicesStopConcurrently) { + Task tasks = Task.WhenAll(hostedServices.Select(async service => await service.StopAsync(token).ConfigureAwait(false))); + try { - await hostedService.StopAsync(token).ConfigureAwait(false); + await tasks.ConfigureAwait(false); } catch (Exception ex) { - exceptions.Add(ex); + exceptions.AddRange(tasks.Exception?.InnerExceptions?.ToArray() ?? new[] { ex }); + } + } + else + { + foreach (IHostedService hostedService in hostedServices) + { + try + { + await hostedService.StopAsync(token).ConfigureAwait(false); + } + catch (Exception ex) + { + exceptions.Add(ex); + } } } } @@ -155,9 +225,18 @@ public async Task StopAsync(CancellationToken cancellationToken = default) if (exceptions.Count > 0) { - var ex = new AggregateException("One or more hosted services failed to stop.", exceptions); - _logger.StoppedWithException(ex); - throw ex; + if (exceptions.Count == 1) + { + // Rethrow if it's a single error + _logger.HostedServiceStartupFaulted(exceptions[0]); + ExceptionDispatchInfo.Capture(exceptions[0]).Throw(); + } + else + { + var ex = new AggregateException("One or more hosted services failed to stop.", exceptions); + _logger.HostedServiceStartupFaulted(ex); + throw ex; + } } } diff --git a/src/libraries/Microsoft.Extensions.Hosting/src/Internal/HostingLoggerExtensions.cs b/src/libraries/Microsoft.Extensions.Hosting/src/Internal/HostingLoggerExtensions.cs index fc4daf79a27da6..71354c63819cac 100644 --- a/src/libraries/Microsoft.Extensions.Hosting/src/Internal/HostingLoggerExtensions.cs +++ b/src/libraries/Microsoft.Extensions.Hosting/src/Internal/HostingLoggerExtensions.cs @@ -101,5 +101,16 @@ public static void BackgroundServiceStoppingHost(this ILogger logger, Exception? message: SR.BackgroundServiceExceptionStoppedHost); } } + + public static void HostedServiceStartupFaulted(this ILogger logger, Exception? ex) + { + if (logger.IsEnabled(LogLevel.Error)) + { + logger.LogError( + eventId: LoggerEventIds.HostedServiceStartupFaulted, + exception: ex, + message: "Hosting failed to start"); + } + } } } diff --git a/src/libraries/Microsoft.Extensions.Hosting/src/Internal/LoggerEventIds.cs b/src/libraries/Microsoft.Extensions.Hosting/src/Internal/LoggerEventIds.cs index 9271953313a05c..64666cd09a0097 100644 --- a/src/libraries/Microsoft.Extensions.Hosting/src/Internal/LoggerEventIds.cs +++ b/src/libraries/Microsoft.Extensions.Hosting/src/Internal/LoggerEventIds.cs @@ -17,5 +17,6 @@ internal static class LoggerEventIds 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)); + public static readonly EventId HostedServiceStartupFaulted = new EventId(11, nameof(HostedServiceStartupFaulted)); } } diff --git a/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/HostTests.cs b/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/HostTests.cs index c4cb6ce4558108..c5b1da829d975c 100644 --- a/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/HostTests.cs +++ b/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/HostTests.cs @@ -16,8 +16,10 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration.UserSecrets; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Moq; using Xunit; namespace Microsoft.Extensions.Hosting.Tests @@ -36,6 +38,114 @@ public async Task StopAsyncWithCancellation() await host.StopAsync(cts.Token); } + public static IEnumerable StartAsync_StopAsync_Concurrency_TestCases + { + get + { + foreach (bool stopConcurrently in new[] { true, false }) + foreach (bool startConcurrently in new[] { true, false }) + foreach (int hostedServiceCount in new[] { 0, 1, 10 }) + { + yield return new object[] { stopConcurrently, startConcurrently, hostedServiceCount }; + } + } + } + [Theory] + [MemberData(nameof(StartAsync_StopAsync_Concurrency_TestCases))] + public async Task StartAsync_StopAsync_Concurrency(bool stopConcurrently, bool startConcurrently, int hostedServiceCount) + { + var startExecutionCount = 0; + var startCallOrder = 0; + + var stopCallOrder = hostedServiceCount; + var stopExecutionCount = 0; + + var hostedServices = new Mock[hostedServiceCount]; + + for (int i = 0; i < hostedServiceCount; i++) + { + var index = i; + var service = new Mock(); + service.Setup(y => y.StopAsync(It.IsAny())) + .Callback(() => + { + // Ensures that IHostedService instances are stopped in FILO order + Assert.Equal(index, --stopCallOrder); + }) + .Returns(async () => + { + if (stopConcurrently && hostedServiceCount > 1) + { + // Basically this will force all IHostedService instances to have StopAsync called before any one completes + stopExecutionCount++; + int waitCount = 0; + + while (stopExecutionCount < hostedServiceCount && waitCount++ < 20) + { + await Task.Delay(10).ConfigureAwait(false); + } + + // This will ensure that we truly are stopping all services asynchronously + Assert.Equal(hostedServiceCount, stopExecutionCount); + } + }) + .Verifiable(); + + service.Setup(y => y.StartAsync(It.IsAny())) + .Callback(() => + { + // Ensures that IHostedService instances are started in FILO order + Assert.Equal(index, startCallOrder++); + }) + .Returns(async () => + { + if (startConcurrently && hostedServiceCount > 1) + { + // Basically this will force all IHostedService instances to have StartAsync called before any one completes + startExecutionCount++; + int waitCount = 0; + + while (startExecutionCount < hostedServiceCount && waitCount++ < 20) + { + await Task.Delay(10).ConfigureAwait(false); + } + + // This will ensure that we truly are starting all services asynchronously + Assert.Equal(hostedServiceCount, startExecutionCount); + } + }) + .Verifiable(); + + hostedServices[index] = service; + } + + var builder = new HostBuilder(); + using var host = Host.CreateDefaultBuilder().ConfigureHostConfiguration(configBuilder => + { + configBuilder.AddInMemoryCollection(new KeyValuePair[] + { + new KeyValuePair("servicesStartConcurrently", startConcurrently.ToString()), + new KeyValuePair("servicesStopConcurrently", stopConcurrently.ToString()) + }); + }).ConfigureServices(configurer => + { + for (int i = 0; i < hostedServiceCount; i++) + { + var index = i; + configurer.Add(ServiceDescriptor.Singleton(hostedServices[index].Object)); + } + }).Build(); + + await host.StartAsync(CancellationToken.None); + + await host.StopAsync(CancellationToken.None); + + foreach (var service in hostedServices) + { + service.Verify(); + } + } + [Fact] public void CreateDefaultBuilder_IncludesContentRootByDefault() { 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 d55df715fc9035..51981a775a1b9e 100644 --- a/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/Internal/HostTests.cs +++ b/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/Internal/HostTests.cs @@ -217,27 +217,46 @@ public void HostedServiceRegisteredWithFactory() } } - [Fact] - public async Task AppCrashesOnStartWhenFirstHostedServiceThrows() + [Theory] + [InlineData(1, true), InlineData(1, false)] + [InlineData(2, true), InlineData(2, false)] + [InlineData(10, true), InlineData(10, false)] + public async Task AppCrashesOnStartWhenFirstHostedServiceThrows(int eventCount, bool concurrentStartup) { - bool[] events1 = null; - bool[] events2 = null; + bool[][] events = new bool[eventCount][]; using (var host = CreateBuilder() .ConfigureServices((services) => { - events1 = RegisterCallbacksThatThrow(services); - events2 = RegisterCallbacksThatThrow(services); + services.Configure(i => i.ServicesStartConcurrently = concurrentStartup); + + for (int i = 0; i < eventCount; i++) + { + events[i] = RegisterCallbacksThatThrow(services); + } }) .Build()) { - await Assert.ThrowsAsync(() => host.StartAsync()); - Assert.True(events1[0]); - Assert.False(events2[0]); + if (concurrentStartup && eventCount > 1) + await Assert.ThrowsAsync(() => host.StartAsync()); + else + await Assert.ThrowsAsync(() => host.StartAsync()); + + for (int i = 0; i < eventCount; i++) + { + if (i == 0 || concurrentStartup) + Assert.True(events[i][0]); + else + Assert.False(events[i][0]); + } + host.Dispose(); + // Stopping - Assert.False(events1[1]); - Assert.False(events2[1]); + for (int i = 0; i < eventCount; i++) + { + Assert.False(events[i][1]); + } } } @@ -1108,17 +1127,23 @@ public async Task HostDisposeApplicationDoesNotFireStopOnHostedService() } } - [Fact] - public async Task HostDoesNotNotifyIHostApplicationLifetimeCallbacksIfIHostedServicesThrow() + [Theory] + [InlineData(1, true), InlineData(1, false)] + [InlineData(2, true), InlineData(2, false)] + [InlineData(10, true), InlineData(10, false)] + public async Task HostDoesNotNotifyIHostApplicationLifetimeCallbacksIfIHostedServicesThrow(int eventCount, bool concurrentStartup) { - bool[] events1 = null; - bool[] events2 = null; + bool[][] events = new bool[eventCount][]; using (var host = CreateBuilder() .ConfigureServices((services) => { - events1 = RegisterCallbacksThatThrow(services); - events2 = RegisterCallbacksThatThrow(services); + services.Configure(i => i.ServicesStartConcurrently = concurrentStartup); + + for (int i = 0; i < eventCount; i++) + { + events[i] = RegisterCallbacksThatThrow(services); + } }) .Build()) { @@ -1127,13 +1152,28 @@ public async Task HostDoesNotNotifyIHostApplicationLifetimeCallbacksIfIHostedSer var started = RegisterCallbacksThatThrow(applicationLifetime.ApplicationStarted); var stopping = RegisterCallbacksThatThrow(applicationLifetime.ApplicationStopping); - await Assert.ThrowsAsync(() => host.StartAsync()); - Assert.True(events1[0]); - Assert.False(events2[0]); + if (concurrentStartup && eventCount > 1) + await Assert.ThrowsAsync(() => host.StartAsync()); + else + await Assert.ThrowsAsync(() => host.StartAsync()); + + for (int i = 0; i < eventCount; i++) + { + if (i == 0 || concurrentStartup) + Assert.True(events[i][0]); + else + Assert.False(events[i][0]); + } + Assert.False(started.All(s => s)); + host.Dispose(); - Assert.False(events1[1]); - Assert.False(events2[1]); + + for (int i = 0; i < eventCount; i++) + { + Assert.False(events[i][1]); + } + Assert.False(stopping.All(s => s)); } }