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 085bc47a266b07..d8820aa7133668 100644 --- a/src/libraries/Microsoft.Extensions.Hosting/ref/Microsoft.Extensions.Hosting.cs +++ b/src/libraries/Microsoft.Extensions.Hosting/ref/Microsoft.Extensions.Hosting.cs @@ -105,6 +105,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..c6c29dc10cfb2f 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. Defaults to false. + /// + public bool ServicesStartConcurrently { get; set; } + + /// + /// Determines if the will stop registered instances of concurrently or sequentially. Defaults to false. + /// + public bool ServicesStopConcurrently { get; set; } + /// /// The behavior the will follow when any of /// its instances throw an unhandled exception. @@ -35,6 +45,20 @@ internal void Initialize(IConfiguration configuration) { ShutdownTimeout = TimeSpan.FromSeconds(seconds); } + + var servicesStartConcurrently = configuration["servicesStartConcurrently"]; + if (!string.IsNullOrEmpty(servicesStartConcurrently) + && bool.TryParse(servicesStartConcurrently, out bool startBehavior)) + { + ServicesStartConcurrently = startBehavior; + } + + var servicesStopConcurrently = configuration["servicesStopConcurrently"]; + if (!string.IsNullOrEmpty(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 33dcc552b8339f..fcc55b2f5d43ec 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,65 @@ 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 ?? new[] { ex }.AsEnumerable()); + } + } + 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 + Exception singleException = exceptions[0]; + _logger.HostedServiceStartupFaulted(singleException); + ExceptionDispatchInfo.Capture(singleException).Throw(); + } + else + { + var ex = new AggregateException("One or more hosted services failed to start.", exceptions); + _logger.HostedServiceStartupFaulted(ex); + throw ex; } } @@ -128,15 +180,34 @@ public async Task StopAsync(CancellationToken cancellationToken = default) var exceptions = new List(); if (_hostedServices != null) // Started? { - foreach (IHostedService hostedService in _hostedServices.Reverse()) + // Ensure hosted services are stopped in LIFO 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 ?? new[] { ex }.AsEnumerable()); + } + } + else + { + foreach (IHostedService hostedService in hostedServices) + { + try + { + await hostedService.StopAsync(token).ConfigureAwait(false); + } + catch (Exception ex) + { + exceptions.Add(ex); + } } } } @@ -155,9 +226,19 @@ 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 + Exception singleException = exceptions[0]; + _logger.StoppedWithException(singleException); + ExceptionDispatchInfo.Capture(singleException).Throw(); + } + else + { + var ex = new AggregateException("One or more hosted services failed to stop.", exceptions); + _logger.StoppedWithException(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/DelegateHostedService.cs b/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/DelegateHostedService.cs new file mode 100644 index 00000000000000..6f6b319138f90b --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/DelegateHostedService.cs @@ -0,0 +1,40 @@ +// 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 System.Threading.Tasks; + +namespace Microsoft.Extensions.Hosting.Unit.Tests; + +internal class DelegateHostedService : IHostedService, IDisposable +{ + private readonly Action _started; + private readonly Action _stopping; + private readonly Action _disposing; + + public DelegateHostedService(Action started, Action stopping, Action disposing) + { + _started = started; + _stopping = stopping; + _disposing = disposing; + } + + public Task StartAsync(CancellationToken token) + { + StartDate = DateTimeOffset.Now; + _started(); + return Task.CompletedTask; + } + public Task StopAsync(CancellationToken token) + { + StopDate = DateTimeOffset.Now; + _stopping(); + return Task.CompletedTask; + } + + public void Dispose() => _disposing(); + + public DateTimeOffset StartDate { get; private set; } + public DateTimeOffset StopDate { 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 eef367f8355891..4b678b8912b77e 100644 --- a/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/HostTests.cs +++ b/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/HostTests.cs @@ -9,13 +9,13 @@ using System.IO; using System.Linq; using System.Reflection; -using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; using Microsoft.DotNet.RemoteExecutor; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration.UserSecrets; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting.Unit.Tests; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Xunit; @@ -36,6 +36,77 @@ 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[] { 1, 4, 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 hostedServices = new DelegateHostedService[hostedServiceCount]; + bool[,] events = new bool[hostedServiceCount, 2]; + + for (int i = 0; i < hostedServiceCount; i++) + { + var index = i; + var service = new DelegateHostedService(() => { events[index, 0] = true; }, () => { events[index, 1] = true; } , () => { }); + + hostedServices[index] = service; + } + + using var host = Host.CreateDefaultBuilder().ConfigureHostConfiguration(configBuilder => + { + configBuilder.AddInMemoryCollection(new KeyValuePair[] + { + new KeyValuePair("servicesStartConcurrently", startConcurrently.ToString()), + new KeyValuePair("servicesStopConcurrently", stopConcurrently.ToString()) + }); + }).ConfigureServices(serviceCollection => + { + foreach (var hostedService in hostedServices) + { + serviceCollection.Add(ServiceDescriptor.Singleton(hostedService)); + } + }).Build(); + + await host.StartAsync(CancellationToken.None); + + // Verifies that StartAsync had been called and that StopAsync had not been launched yet + for (int i = 0; i < hostedServiceCount; i++) + { + Assert.True(events[i, 0]); + Assert.False(events[i, 1]); + } + + // Ensures that IHostedService instances are started in FIFO order + AssertExtensions.CollectionEqual(hostedServices, hostedServices.OrderBy(h => h.StartDate), EqualityComparer.Default); + + await host.StopAsync(CancellationToken.None); + + // Verifies that StopAsync had been called + for (int i = 0; i < hostedServiceCount; i++) + { + Assert.True(events[i, 1]); + } + + // Ensures that IHostedService instances are stopped in LIFO order + AssertExtensions.CollectionEqual(hostedServices.Reverse(), hostedServices.OrderBy(h => h.StopDate), EqualityComparer.Default); + } + [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..23cf84e91621d3 100644 --- a/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/Internal/HostTests.cs +++ b/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/Internal/HostTests.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.Diagnostics.Tracing; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -13,6 +12,7 @@ using Microsoft.Extensions.Hosting.Fakes; using Microsoft.Extensions.Hosting.Tests; using Microsoft.Extensions.Hosting.Tests.Fakes; +using Microsoft.Extensions.Hosting.Unit.Tests; using Microsoft.Extensions.Logging; using Moq; using Xunit; @@ -217,27 +217,54 @@ 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) => + .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,32 +1135,60 @@ 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()) { var applicationLifetime = host.Services.GetService(); - 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)); } } @@ -1504,33 +1559,6 @@ public void Dispose() } } - private class DelegateHostedService : IHostedService, IDisposable - { - private readonly Action _started; - private readonly Action _stopping; - private readonly Action _disposing; - - public DelegateHostedService(Action started, Action stopping, Action disposing) - { - _started = started; - _stopping = stopping; - _disposing = disposing; - } - - public Task StartAsync(CancellationToken token) - { - _started(); - return Task.CompletedTask; - } - public Task StopAsync(CancellationToken token) - { - _stopping(); - return Task.CompletedTask; - } - - public void Dispose() => _disposing(); - } - private class AsyncDisposableService : IAsyncDisposable { public bool DisposeAsyncCalled { get; set; }