diff --git a/src/Microsoft.AspNetCore.Hosting.Abstractions/IApplicationLifetimeEvents.cs b/src/Microsoft.AspNetCore.Hosting.Abstractions/IApplicationLifetimeEvents.cs new file mode 100644 index 0000000000..e3f5cb6b3a --- /dev/null +++ b/src/Microsoft.AspNetCore.Hosting.Abstractions/IApplicationLifetimeEvents.cs @@ -0,0 +1,31 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Hosting +{ + /// + /// Allows consumers to perform cleanup during a graceful shutdown. + /// + public interface IApplicationLifetimeEvents + { + /// + /// Triggered when the application host has fully started and is about to wait + /// for a graceful shutdown. + /// + void OnApplicationStarted(); + + /// + /// Triggered when the application host is performing a graceful shutdown. + /// Requests may still be in flight. Shutdown will block until this event completes. + /// + void OnApplicationStopping(); + + /// + /// Triggered when the application host is performing a graceful shutdown. + /// All requests should be complete at this point. Shutdown will block + /// until this event completes. + /// + void OnApplicationStopped(); + + } +} diff --git a/src/Microsoft.AspNetCore.Hosting/Internal/ApplicationLifetime.cs b/src/Microsoft.AspNetCore.Hosting/Internal/ApplicationLifetime.cs index dbbf97d181..0517d46fad 100644 --- a/src/Microsoft.AspNetCore.Hosting/Internal/ApplicationLifetime.cs +++ b/src/Microsoft.AspNetCore.Hosting/Internal/ApplicationLifetime.cs @@ -2,7 +2,10 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Collections.Generic; +using System.Linq; using System.Threading; +using Microsoft.Extensions.Logging; namespace Microsoft.AspNetCore.Hosting.Internal { @@ -14,6 +17,14 @@ namespace Microsoft.AspNetCore.Hosting.Internal private readonly CancellationTokenSource _startedSource = new CancellationTokenSource(); private readonly CancellationTokenSource _stoppingSource = new CancellationTokenSource(); private readonly CancellationTokenSource _stoppedSource = new CancellationTokenSource(); + private readonly IEnumerable _handlers = Enumerable.Empty(); + private readonly ILogger _logger; + + public ApplicationLifetime(ILogger logger, IEnumerable handlers) + { + _logger = logger; + _handlers = handlers; + } /// /// Triggered when the application host has fully started and is about to wait @@ -46,11 +57,13 @@ namespace Microsoft.AspNetCore.Hosting.Internal { try { - _stoppingSource.Cancel(throwOnFirstException: false); + ExecuteHandlers(_stoppingSource, handler => handler.OnApplicationStopping()); } - catch (Exception) + catch (Exception ex) { - // TODO: LOG + _logger.ApplicationError(LoggerEventIds.ApplicationStoppingException, + "An error occurred stopping the application", + ex); } } } @@ -62,11 +75,13 @@ namespace Microsoft.AspNetCore.Hosting.Internal { try { - _startedSource.Cancel(throwOnFirstException: false); + ExecuteHandlers(_startedSource, handler => handler.OnApplicationStarted()); } - catch (Exception) + catch (Exception ex) { - // TODO: LOG + _logger.ApplicationError(LoggerEventIds.ApplicationStartupException, + "An error occurred starting the application", + ex); } } @@ -77,11 +92,63 @@ namespace Microsoft.AspNetCore.Hosting.Internal { try { - _stoppedSource.Cancel(throwOnFirstException: false); + ExecuteHandlers(_stoppedSource, handler => handler.OnApplicationStopped()); } - catch (Exception) + catch (Exception ex) { - // TODO: LOG + _logger.ApplicationError(LoggerEventIds.ApplicationStoppedException, + "An error occurred stopping the application", + ex); + } + } + + private void ExecuteHandlers(CancellationTokenSource cancel, Action callback) + { + // Noop if this is already cancelled + if (cancel.IsCancellationRequested) + { + return; + } + + List exceptions = null; + + try + { + // Run the cancellation token callbacks + cancel.Cancel(throwOnFirstException: false); + } + catch (Exception ex) + { + if (exceptions == null) + { + exceptions = new List(); + } + + exceptions.Add(ex); + } + + // Run the handlers + foreach (var handler in _handlers) + { + try + { + callback(handler); + } + catch (Exception ex) + { + if (exceptions == null) + { + exceptions = new List(); + } + + exceptions.Add(ex); + } + } + + // Throw an aggregate exception if there were any exceptions + if (exceptions != null) + { + throw new AggregateException(exceptions); } } } diff --git a/src/Microsoft.AspNetCore.Hosting/Internal/HostingLoggerExtensions.cs b/src/Microsoft.AspNetCore.Hosting/Internal/HostingLoggerExtensions.cs index ca0283a0aa..35072a3972 100644 --- a/src/Microsoft.AspNetCore.Hosting/Internal/HostingLoggerExtensions.cs +++ b/src/Microsoft.AspNetCore.Hosting/Internal/HostingLoggerExtensions.cs @@ -52,8 +52,14 @@ namespace Microsoft.AspNetCore.Hosting.Internal public static void ApplicationError(this ILogger logger, Exception exception) { - var message = "Application startup exception"; + logger.ApplicationError( + eventId: LoggerEventIds.ApplicationStartupException, + message: "Application startup exception", + exception: exception); + } + public static void ApplicationError(this ILogger logger, EventId eventId, string message, Exception exception) + { var reflectionTypeLoadException = exception as ReflectionTypeLoadException; if (reflectionTypeLoadException != null) { @@ -64,7 +70,7 @@ namespace Microsoft.AspNetCore.Hosting.Internal } logger.LogCritical( - eventId: LoggerEventIds.ApplicationStartupException, + eventId: eventId, message: message, exception: exception); } diff --git a/src/Microsoft.AspNetCore.Hosting/Internal/LoggerEventIds.cs b/src/Microsoft.AspNetCore.Hosting/Internal/LoggerEventIds.cs index f2978b0d20..94764ec47f 100644 --- a/src/Microsoft.AspNetCore.Hosting/Internal/LoggerEventIds.cs +++ b/src/Microsoft.AspNetCore.Hosting/Internal/LoggerEventIds.cs @@ -11,5 +11,7 @@ namespace Microsoft.AspNetCore.Hosting.Internal public const int Started = 4; public const int Shutdown = 5; public const int ApplicationStartupException = 6; + public const int ApplicationStoppingException = 7; + public const int ApplicationStoppedException = 8; } } diff --git a/src/Microsoft.AspNetCore.Hosting/Internal/WebHost.cs b/src/Microsoft.AspNetCore.Hosting/Internal/WebHost.cs index 3a6b4f7562..440b17321d 100644 --- a/src/Microsoft.AspNetCore.Hosting/Internal/WebHost.cs +++ b/src/Microsoft.AspNetCore.Hosting/Internal/WebHost.cs @@ -7,7 +7,6 @@ using System.Diagnostics; using System.Linq; using System.Reflection; using System.Runtime.InteropServices; -using System.Threading; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting.Builder; using Microsoft.AspNetCore.Hosting.Server; @@ -28,9 +27,9 @@ namespace Microsoft.AspNetCore.Hosting.Internal private readonly IServiceCollection _applicationServiceCollection; private IStartup _startup; + private ApplicationLifetime _applicationLifetime; private readonly IServiceProvider _hostingServiceProvider; - private readonly ApplicationLifetime _applicationLifetime; private readonly WebHostOptions _options; private readonly IConfiguration _config; @@ -68,8 +67,7 @@ namespace Microsoft.AspNetCore.Hosting.Internal _options = options; _applicationServiceCollection = appServices; _hostingServiceProvider = hostingServiceProvider; - _applicationLifetime = new ApplicationLifetime(); - _applicationServiceCollection.AddSingleton(_applicationLifetime); + _applicationServiceCollection.AddSingleton(); } public IServiceProvider Services @@ -101,6 +99,7 @@ namespace Microsoft.AspNetCore.Hosting.Internal { Initialize(); + _applicationLifetime = _applicationServices.GetRequiredService() as ApplicationLifetime; _logger = _applicationServices.GetRequiredService>(); var diagnosticSource = _applicationServices.GetRequiredService(); var httpContextFactory = _applicationServices.GetRequiredService(); @@ -109,7 +108,7 @@ namespace Microsoft.AspNetCore.Hosting.Internal Server.Start(new HostingApplication(_application, _logger, diagnosticSource, httpContextFactory)); - _applicationLifetime.NotifyStarted(); + _applicationLifetime?.NotifyStarted(); _logger.Started(); } @@ -244,10 +243,10 @@ namespace Microsoft.AspNetCore.Hosting.Internal public void Dispose() { _logger?.Shutdown(); - _applicationLifetime.StopApplication(); + _applicationLifetime?.StopApplication(); (_hostingServiceProvider as IDisposable)?.Dispose(); (_applicationServices as IDisposable)?.Dispose(); - _applicationLifetime.NotifyStopped(); + _applicationLifetime?.NotifyStopped(); } } } diff --git a/test/Microsoft.AspNetCore.Hosting.Tests/WebHostTests.cs b/test/Microsoft.AspNetCore.Hosting.Tests/WebHostTests.cs index ab52089b8f..44d0a720dc 100644 --- a/test/Microsoft.AspNetCore.Hosting.Tests/WebHostTests.cs +++ b/test/Microsoft.AspNetCore.Hosting.Tests/WebHostTests.cs @@ -270,6 +270,135 @@ namespace Microsoft.AspNetCore.Hosting } } + [Fact] + public void WebHostNotifiesAllIApplicationLifetimeCallbacksEvenIfTheyThrow() + { + var host = CreateBuilder() + .UseServer(this) + .Build(); + var applicationLifetime = host.Services.GetService(); + + var started = RegisterCallbacksThatThrow(applicationLifetime.ApplicationStarted); + var stopping = RegisterCallbacksThatThrow(applicationLifetime.ApplicationStopping); + var stopped = RegisterCallbacksThatThrow(applicationLifetime.ApplicationStopped); + + using (host) + { + host.Start(); + Assert.True(applicationLifetime.ApplicationStarted.IsCancellationRequested); + Assert.True(started.All(s => s)); + host.Dispose(); + Assert.True(stopping.All(s => s)); + Assert.True(stopped.All(s => s)); + } + } + + [Fact] + public void WebHostNotifiesAllIApplicationLifetimeEventsCallbacksEvenIfTheyThrow() + { + bool[] events1 = null; + bool[] events2 = null; + + var host = CreateBuilder() + .UseServer(this) + .ConfigureServices(services => + { + events1 = RegisterCallbacksThatThrow(services); + events2 = RegisterCallbacksThatThrow(services); + }) + .Build(); + + using (host) + { + host.Start(); + Assert.True(events1[0]); + Assert.True(events2[0]); + host.Dispose(); + Assert.True(events1[1]); + Assert.True(events2[1]); + Assert.True(events1[2]); + Assert.True(events2[2]); + } + } + + [Fact] + public void WebHostStopCallbacksDontMultipleTimes() + { + var stoppingCalls = 0; + var stoppedCalls = 0; + + var host = CreateBuilder() + .UseServer(this) + .ConfigureServices(services => + { + Action started = () => + { + }; + + Action stopping = () => + { + stoppingCalls++; + }; + + Action stopped = () => + { + stoppedCalls++; + }; + + services.AddSingleton(new DelegateLifetimeEvents(started, stopping, stopped)); + }) + .Build(); + var lifetime = host.Services.GetRequiredService(); + lifetime.StopApplication(); + lifetime.StopApplication(); + lifetime.StopApplication(); + + using (host) + { + host.Start(); + host.Dispose(); + + Assert.Equal(1, stoppingCalls); + Assert.Equal(1, stoppedCalls); + } + } + + [Fact] + public void WebHostNotifiesAllIApplicationLifetimeEventsCallbacksAndIApplicationLifetimeCallbacksEvenIfTheyThrow() + { + bool[] events1 = null; + bool[] events2 = null; + + var host = CreateBuilder() + .UseServer(this) + .ConfigureServices(services => + { + events1 = RegisterCallbacksThatThrow(services); + events2 = RegisterCallbacksThatThrow(services); + }) + .Build(); + var applicationLifetime = host.Services.GetService(); + + var started = RegisterCallbacksThatThrow(applicationLifetime.ApplicationStarted); + var stopping = RegisterCallbacksThatThrow(applicationLifetime.ApplicationStopping); + var stopped = RegisterCallbacksThatThrow(applicationLifetime.ApplicationStopped); + + using (host) + { + host.Start(); + Assert.True(events1[0]); + Assert.True(events2[0]); + Assert.True(started.All(s => s)); + host.Dispose(); + Assert.True(events1[1]); + Assert.True(events2[1]); + Assert.True(stopping.All(s => s)); + Assert.True(events1[2]); + Assert.True(events2[2]); + Assert.True(stopped.All(s => s)); + } + } + [Fact] public void WebHostInjectsHostingEnvironment() { @@ -523,6 +652,48 @@ namespace Microsoft.AspNetCore.Hosting return new WebHostBuilder().UseConfiguration(config ?? new ConfigurationBuilder().Build()).UseStartup("Microsoft.AspNetCore.Hosting.Tests"); } + private static bool[] RegisterCallbacksThatThrow(IServiceCollection services) + { + bool[] events = new bool[3]; + + Action started = () => + { + events[0] = true; + throw new InvalidOperationException(); + }; + + Action stopping = () => + { + events[1] = true; + throw new InvalidOperationException(); + }; + + Action stopped = () => + { + events[2] = true; + throw new InvalidOperationException(); + }; + + services.AddSingleton(new DelegateLifetimeEvents(started, stopping, stopped)); + + return events; + } + + private static bool[] RegisterCallbacksThatThrow(CancellationToken token) + { + var signals = new bool[3]; + for (int i = 0; i < signals.Length; i++) + { + token.Register(state => + { + signals[(int)state] = true; + throw new InvalidOperationException(); + }, i); + } + + return signals; + } + public void Start(IHttpApplication application) { var startInstance = new StartInstance(); @@ -551,6 +722,26 @@ namespace Microsoft.AspNetCore.Hosting } } + private class DelegateLifetimeEvents : IApplicationLifetimeEvents + { + private readonly Action _started; + private readonly Action _stopping; + private readonly Action _stopped; + + public DelegateLifetimeEvents(Action started, Action stopping, Action stopped) + { + _started = started; + _stopping = stopping; + _stopped = stopped; + } + + public void OnApplicationStarted() => _started(); + + public void OnApplicationStopped() => _stopped(); + + public void OnApplicationStopping() => _stopping(); + } + private class StartInstance : IDisposable { public int DisposeCalls { get; set; }