From 42594afd4253a7ab0292ac7d126678f9e91d42b4 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Tue, 29 Nov 2016 03:38:07 -0800 Subject: [PATCH] Introducing IApplicationLifetimeEvents (#875) - Introduce a new DI friendly API for handling lifetime events. IApplicationLifetime isn't isn't replaceable so we introduce a new DI friendly API that can be implemented to handle lifetime events of an ASP.NET application. It should also make it possible to write up extension on IWebHostBuilder to wire up to external systems that need to register the state of the application (like systemd). - Run all handlers even if one throws - Let both sets of event handlers run before throwing (IApplicationLifetimeEvents and IApplicationLifetime cancellation token callbacks). --- .../IApplicationLifetimeEvents.cs | 31 +++ .../Internal/ApplicationLifetime.cs | 85 +++++++- .../Internal/HostingLoggerExtensions.cs | 10 +- .../Internal/LoggerEventIds.cs | 2 + .../Internal/WebHost.cs | 13 +- .../WebHostTests.cs | 191 ++++++++++++++++++ 6 files changed, 314 insertions(+), 18 deletions(-) create mode 100644 src/Microsoft.AspNetCore.Hosting.Abstractions/IApplicationLifetimeEvents.cs 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; }