diff --git a/frontend/src/Components/Page/Header/PageHeaderActionsMenu.tsx b/frontend/src/Components/Page/Header/PageHeaderActionsMenu.tsx index 7a0c35c1c..abe0011a4 100644 --- a/frontend/src/Components/Page/Header/PageHeaderActionsMenu.tsx +++ b/frontend/src/Components/Page/Header/PageHeaderActionsMenu.tsx @@ -21,7 +21,7 @@ function PageHeaderActionsMenu(props: PageHeaderActionsMenuProps) { const dispatch = useDispatch(); - const { authentication, isDocker } = useSelector( + const { authentication, isContainerized } = useSelector( (state: AppState) => state.system.status.item ); @@ -48,24 +48,22 @@ function PageHeaderActionsMenu(props: PageHeaderActionsMenuProps) { {translate('KeyboardShortcuts')} - {isDocker ? null : ( - <> - + - - - {translate('Restart')} - + + + {translate('Restart')} + - - - {translate('Shutdown')} - - + {isContainerized ? null : ( + + + {translate('Shutdown')} + )} {formsAuth ? ( diff --git a/frontend/src/System/Plugins/PluginsConnector.js b/frontend/src/System/Plugins/PluginsConnector.js index 3882cd10d..3825cd259 100644 --- a/frontend/src/System/Plugins/PluginsConnector.js +++ b/frontend/src/System/Plugins/PluginsConnector.js @@ -123,12 +123,10 @@ class PluginsConnector extends Component { console.log('Plugin match result:', pluginMatch); } pluginDetailsUrl = url; - } else { - if (command && command.message) { - const pluginMatch = command.message.match(/Plugin \[([^/]+)\/([^\]]+)\] v([0-9.]+) uninstalled/); - if (pluginMatch) { - pluginVersion = pluginMatch[3]; - } + } else if (command && command.message) { + const pluginMatch = command.message.match(/Plugin \[([^/]+)\/([^\]]+)\] v([0-9.]+) uninstalled/); + if (pluginMatch) { + pluginVersion = pluginMatch[3]; } } } diff --git a/frontend/src/System/Status/About/About.js b/frontend/src/System/Status/About/About.js index 8af5b4717..2a89ae3b3 100644 --- a/frontend/src/System/Status/About/About.js +++ b/frontend/src/System/Status/About/About.js @@ -20,7 +20,7 @@ class About extends Component { packageVersion, packageAuthor, isNetCore, - isDocker, + isContainerized, runtimeVersion, databaseVersion, databaseType, @@ -58,7 +58,7 @@ class About extends Component { } { - isDocker && + isContainerized && versionAdapters, Logger logger) FullName = Name; } - if (IsLinux && - (File.Exists("/.dockerenv") || - (File.Exists("/proc/1/cgroup") && File.ReadAllText("/proc/1/cgroup").Contains("/docker/")))) + if (IsLinux) { - IsDocker = true; + IsDocker = File.Exists("/.dockerenv") || + (File.Exists("/proc/1/cgroup") && File.ReadAllText("/proc/1/cgroup").Contains("/docker/")); + IsPodman = File.Exists("/run/.containerenv") || + Environment.GetEnvironmentVariable("container") != null; + + IsContainerized = IsDocker || IsPodman || Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER") is "true" or "1"; } } } @@ -94,6 +99,8 @@ public interface IOsInfo string Name { get; } string FullName { get; } bool IsDocker { get; } + bool IsPodman { get; } + bool IsContainerized { get; } } public enum Os diff --git a/src/NzbDrone.Common/EnvironmentInfo/RuntimeInfo.cs b/src/NzbDrone.Common/EnvironmentInfo/RuntimeInfo.cs index 703cd123f..78c056bd9 100644 --- a/src/NzbDrone.Common/EnvironmentInfo/RuntimeInfo.cs +++ b/src/NzbDrone.Common/EnvironmentInfo/RuntimeInfo.cs @@ -12,11 +12,13 @@ namespace NzbDrone.Common.EnvironmentInfo public class RuntimeInfo : IRuntimeInfo { private readonly Logger _logger; + private readonly IOsInfo _osInfo; private readonly DateTime _startTime = DateTime.UtcNow; - public RuntimeInfo(Logger logger, IHostLifetime hostLifetime = null) + public RuntimeInfo(Logger logger, IOsInfo osInfo, IHostLifetime hostLifetime = null) { _logger = logger; + _osInfo = osInfo; IsWindowsService = hostLifetime is WindowsServiceLifetime; IsStarting = true; @@ -83,6 +85,30 @@ public bool IsAdmin public bool IsWindowsService { get; private set; } + public bool IsContainerized => _osInfo.IsContainerized; + + public bool IsSystemdService + { + get + { + if (!OsInfo.IsLinux) + { + return false; + } + + try + { + var invocationId = Environment.GetEnvironmentVariable("INVOCATION_ID"); + return !string.IsNullOrEmpty(invocationId); + } + catch (Exception ex) + { + _logger.Warn(ex, "Error checking if system is running under systemd"); + return false; + } + } + } + public bool IsStarting { get; set; } public bool IsExiting { get; set; } diff --git a/src/NzbDrone.Common/Instrumentation/NzbDroneLogger.cs b/src/NzbDrone.Common/Instrumentation/NzbDroneLogger.cs index 36c3b1ec0..ae78881a6 100644 --- a/src/NzbDrone.Common/Instrumentation/NzbDroneLogger.cs +++ b/src/NzbDrone.Common/Instrumentation/NzbDroneLogger.cs @@ -223,6 +223,13 @@ public static void ConfigureConsoleLayout(ColoredConsoleTarget target, ConsoleLo _ => NzbDroneLogger.CleansingConsoleLayout }; } + + public static void ResetAllTargets(IStartupContext startupContext, bool updateApp, bool inConsole) + { + LogManager.Configuration = new LoggingConfiguration(); + _isConfigured = false; + Register(startupContext, updateApp, inConsole); + } } public enum ConsoleLogFormat diff --git a/src/NzbDrone.Common/Instrumentation/Sentry/SentryTarget.cs b/src/NzbDrone.Common/Instrumentation/Sentry/SentryTarget.cs index 3f8d4c227..1042ef9a9 100644 --- a/src/NzbDrone.Common/Instrumentation/Sentry/SentryTarget.cs +++ b/src/NzbDrone.Common/Instrumentation/Sentry/SentryTarget.cs @@ -173,7 +173,7 @@ public void UpdateScope(IOsInfo osInfo) { SentrySdk.ConfigureScope(scope => { - scope.SetTag("is_docker", $"{osInfo.IsDocker}"); + scope.SetTag("is_containerized", $"{osInfo.IsContainerized}"); }); } diff --git a/src/NzbDrone.Common/Messaging/LifecycleEventAttribute.cs b/src/NzbDrone.Common/Messaging/LifecycleEventAttribute.cs new file mode 100644 index 000000000..b2101ff3e --- /dev/null +++ b/src/NzbDrone.Common/Messaging/LifecycleEventAttribute.cs @@ -0,0 +1,9 @@ +using System; + +namespace NzbDrone.Common.Messaging +{ + [AttributeUsage(AttributeTargets.Class, Inherited = false)] + public sealed class LifecycleEventAttribute : Attribute + { + } +} diff --git a/src/NzbDrone.Common/Reflection/ReflectionExtensions.cs b/src/NzbDrone.Common/Reflection/ReflectionExtensions.cs index 235ab5b1d..af73c75a5 100644 --- a/src/NzbDrone.Common/Reflection/ReflectionExtensions.cs +++ b/src/NzbDrone.Common/Reflection/ReflectionExtensions.cs @@ -7,6 +7,13 @@ namespace NzbDrone.Common.Reflection { public static class ReflectionExtensions { + private static HashSet _currentAssemblies = new HashSet(); + + public static void SetCurrentAssemblies(IEnumerable assemblies) + { + _currentAssemblies = new HashSet(assemblies); + } + public static List GetSimpleProperties(this Type type) { var properties = type.GetProperties(); @@ -93,7 +100,18 @@ private static bool ShouldUseAssembly(Assembly assembly) } var name = assembly.GetName(); - return name.Name == "Lidarr.Core" || name.Name.Contains("Lidarr.Plugin"); + + if (name.Name == "Lidarr.Core") + { + return true; + } + + if (name.Name.Contains("Lidarr.Plugin")) + { + return _currentAssemblies.Count == 0 || _currentAssemblies.Contains(assembly); + } + + return false; } public static bool HasAttribute(this Type type) diff --git a/src/NzbDrone.Core/Lifecycle/ApplicationShutdownRequested.cs b/src/NzbDrone.Core/Lifecycle/ApplicationShutdownRequested.cs index 7982c1d36..35fc8ecca 100644 --- a/src/NzbDrone.Core/Lifecycle/ApplicationShutdownRequested.cs +++ b/src/NzbDrone.Core/Lifecycle/ApplicationShutdownRequested.cs @@ -2,6 +2,7 @@ namespace NzbDrone.Core.Lifecycle { + [LifecycleEvent] public class ApplicationShutdownRequested : IEvent { public bool Restarting { get; } diff --git a/src/NzbDrone.Core/Lifecycle/ApplicationStartedEvent.cs b/src/NzbDrone.Core/Lifecycle/ApplicationStartedEvent.cs index 8e0d45e15..f6e458c1f 100644 --- a/src/NzbDrone.Core/Lifecycle/ApplicationStartedEvent.cs +++ b/src/NzbDrone.Core/Lifecycle/ApplicationStartedEvent.cs @@ -2,6 +2,7 @@ namespace NzbDrone.Core.Lifecycle { + [LifecycleEvent] public class ApplicationStartedEvent : IEvent { } diff --git a/src/NzbDrone.Core/Lifecycle/ApplicationStartingEvent.cs b/src/NzbDrone.Core/Lifecycle/ApplicationStartingEvent.cs index 464ff5ff4..eb54ea5ee 100644 --- a/src/NzbDrone.Core/Lifecycle/ApplicationStartingEvent.cs +++ b/src/NzbDrone.Core/Lifecycle/ApplicationStartingEvent.cs @@ -2,6 +2,7 @@ namespace NzbDrone.Core.Lifecycle { + [LifecycleEvent] public class ApplicationStartingEvent : IEvent { } diff --git a/src/NzbDrone.Core/Lifecycle/LifecycleService.cs b/src/NzbDrone.Core/Lifecycle/LifecycleService.cs index 9ed36a42e..b3be3bec1 100644 --- a/src/NzbDrone.Core/Lifecycle/LifecycleService.cs +++ b/src/NzbDrone.Core/Lifecycle/LifecycleService.cs @@ -1,10 +1,8 @@ -using NLog; -using NzbDrone.Common; +using NLog; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Core.Lifecycle.Commands; using NzbDrone.Core.Messaging.Commands; using NzbDrone.Core.Messaging.Events; -using IServiceProvider = NzbDrone.Common.IServiceProvider; namespace NzbDrone.Core.Lifecycle { @@ -18,41 +16,28 @@ public class LifecycleService : ILifecycleService, IExecute, IE { private readonly IEventAggregator _eventAggregator; private readonly IRuntimeInfo _runtimeInfo; - private readonly IServiceProvider _serviceProvider; private readonly Logger _logger; public LifecycleService(IEventAggregator eventAggregator, IRuntimeInfo runtimeInfo, - IServiceProvider serviceProvider, Logger logger) { _eventAggregator = eventAggregator; _runtimeInfo = runtimeInfo; - _serviceProvider = serviceProvider; _logger = logger; } public void Shutdown() { _logger.Info("Shutdown requested."); - _eventAggregator.PublishEvent(new ApplicationShutdownRequested()); - - if (_runtimeInfo.IsWindowsService) - { - _serviceProvider.Stop(ServiceProvider.SERVICE_NAME); - } + _eventAggregator.PublishEvent(new ApplicationShutdownRequested(false)); } public void Restart() { _logger.Info("Restart requested."); - + _runtimeInfo.RestartPending = true; _eventAggregator.PublishEvent(new ApplicationShutdownRequested(true)); - - if (_runtimeInfo.IsWindowsService) - { - _serviceProvider.Restart(ServiceProvider.SERVICE_NAME); - } } public void Execute(ShutdownCommand message) diff --git a/src/NzbDrone.Core/Messaging/Events/EventAggregator.cs b/src/NzbDrone.Core/Messaging/Events/EventAggregator.cs index c2a5dbab0..4d21f8d07 100644 --- a/src/NzbDrone.Core/Messaging/Events/EventAggregator.cs +++ b/src/NzbDrone.Core/Messaging/Events/EventAggregator.cs @@ -5,7 +5,9 @@ using NLog; using NzbDrone.Common; using NzbDrone.Common.EnsureThat; +using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Messaging; +using NzbDrone.Common.Reflection; using NzbDrone.Common.TPL; namespace NzbDrone.Core.Messaging.Events @@ -14,6 +16,7 @@ public class EventAggregator : IEventAggregator { private readonly Logger _logger; private readonly IServiceFactory _serviceFactory; + private readonly IRuntimeInfo _runtimeInfo; private readonly TaskFactory _taskFactory; private readonly Dictionary _eventSubscribers; @@ -39,10 +42,11 @@ public EventSubscribers(IServiceFactory serviceFactory) } } - public EventAggregator(Logger logger, IServiceFactory serviceFactory) + public EventAggregator(Logger logger, IServiceFactory serviceFactory, IRuntimeInfo runtimeInfo) { _logger = logger; _serviceFactory = serviceFactory; + _runtimeInfo = runtimeInfo; _taskFactory = new TaskFactory(); _eventSubscribers = new Dictionary(); } @@ -54,6 +58,12 @@ public void PublishEvent(TEvent @event) var eventName = GetEventName(@event.GetType()); + if (_runtimeInfo.IsExiting && @event.GetType().HasAttribute()) + { + _logger.Warn("Event {0} blocked due to application shutdown", eventName); + return; + } + /* int workerThreads; int completionPortThreads; @@ -78,7 +88,15 @@ public void PublishEvent(TEvent @event) { if (!_eventSubscribers.TryGetValue(eventName, out var target)) { - _eventSubscribers[eventName] = target = new EventSubscribers(_serviceFactory); + try + { + _eventSubscribers[eventName] = target = new EventSubscribers(_serviceFactory); + } + catch (Exception ex) + { + _logger.Warn(ex, "Unable to resolve event subscribers for {0}, container may be disposed", eventName); + return; + } } subscribers = target as EventSubscribers; diff --git a/src/NzbDrone.Host/AppLifetime.cs b/src/NzbDrone.Host/AppLifetime.cs index 45616e36b..bad3df945 100644 --- a/src/NzbDrone.Host/AppLifetime.cs +++ b/src/NzbDrone.Host/AppLifetime.cs @@ -18,7 +18,6 @@ public class AppLifetime : IHostedService, IHandle private readonly IRuntimeInfo _runtimeInfo; private readonly IStartupContext _startupContext; private readonly IBrowserService _browserService; - private readonly IProcessProvider _processProvider; private readonly IEventAggregator _eventAggregator; private readonly Logger _logger; @@ -36,7 +35,6 @@ public AppLifetime(IHostApplicationLifetime appLifetime, _runtimeInfo = runtimeInfo; _startupContext = startupContext; _browserService = browserService; - _processProvider = processProvider; _eventAggregator = eventAggregator; _logger = logger; @@ -70,12 +68,13 @@ private void OnAppStarted() private void OnAppStopped() { - if (_runtimeInfo.RestartPending && !_runtimeInfo.IsWindowsService) + if (_runtimeInfo.RestartPending) { - var restartArgs = GetRestartArgs(); - - _logger.Info("Attempting restart with arguments: {0}", restartArgs); - _processProvider.SpawnNewProcess(_runtimeInfo.ExecutingApplication, restartArgs); + _logger.Info("Restart pending."); + } + else + { + _logger.Info("Application stopped without restart pending"); } } @@ -87,33 +86,21 @@ private void Shutdown() _appLifetime.StopApplication(); } - private string GetRestartArgs() - { - var args = _startupContext.PreservedArguments; - - args += " /restart"; - - if (!args.Contains("/nobrowser")) - { - args += " /nobrowser"; - } - - return args; - } - [EventHandleOrder(EventHandleOrder.Last)] public void Handle(ApplicationShutdownRequested message) { - if (!_runtimeInfo.IsWindowsService) + if (message.Restarting) { - if (message.Restarting) - { - _runtimeInfo.RestartPending = true; - } - - LogManager.Configuration = null; - Shutdown(); + _runtimeInfo.RestartPending = true; + _logger.Debug("Restart requested"); } + else + { + _logger.Debug("Shutdown requested"); + LogManager.Configuration = null; + } + + Shutdown(); } } } diff --git a/src/NzbDrone.Host/Bootstrap.cs b/src/NzbDrone.Host/Bootstrap.cs index d92f3ca68..7904e4a1c 100644 --- a/src/NzbDrone.Host/Bootstrap.cs +++ b/src/NzbDrone.Host/Bootstrap.cs @@ -8,6 +8,7 @@ using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using System.Text; +using System.Threading; using DryIoc; using DryIoc.Microsoft.DependencyInjection; using Microsoft.AspNetCore.Hosting; @@ -25,6 +26,7 @@ using NzbDrone.Common.Instrumentation; using NzbDrone.Common.Instrumentation.Extensions; using NzbDrone.Common.Options; +using NzbDrone.Common.Reflection; using NzbDrone.Core.Configuration; using NzbDrone.Core.Datastore.Extensions; using PostgresOptions = NzbDrone.Core.Datastore.PostgresOptions; @@ -51,18 +53,15 @@ public static void Start(string[] args, Action trayCallback = null var appMode = GetApplicationMode(startupContext); var config = GetConfiguration(startupContext); - switch (appMode) + if (appMode is not(ApplicationModes.Interactive or ApplicationModes.Service)) { - case ApplicationModes.Service: - StartService(startupContext); - break; - case ApplicationModes.Interactive: - StartInteractive(startupContext, trayCallback); - break; - default: - StartUtility(startupContext, appMode, config); - break; + RunUtilityMode(appMode, startupContext, config); + return; } + + RunHostUntilShutdown(args, startupContext, appMode, trayCallback); + + Logger.Info("Lidarr has shut down completely"); } catch (InvalidConfigFileException ex) { @@ -84,88 +83,12 @@ public static void Start(string[] args, Action trayCallback = null SQLiteConnection.ClearAllPools(); } - [MethodImpl(MethodImplOptions.NoInlining)] - private static void StartService(StartupContext context) + private static void RunUtilityMode(ApplicationModes appMode, StartupContext startupContext, IConfiguration config) { - Logger.Debug("Service selected"); + Logger.Debug("Utility mode: {0}", appMode); - var success = StartService(context, true, out var pluginRefs); - - if (!success) - { - var unloadSuccess = PluginLoader.UnloadPlugins(pluginRefs); - - if (unloadSuccess) - { - StartService(context, false, out _); - } - } - - CreateConsoleHostBuilder(context, false, out _).UseWindowsService().Build().Run(); - } - - [MethodImpl(MethodImplOptions.NoInlining)] - private static void StartInteractive(StartupContext context, Action trayCallback) - { - Logger.Debug(trayCallback != null ? "Tray selected" : "Console selected"); - - var success = StartInteractive(context, trayCallback, true, out var pluginRefs); - - if (!success) - { - var unloadSuccess = PluginLoader.UnloadPlugins(pluginRefs); - - if (unloadSuccess) - { - StartInteractive(context, trayCallback, false, out _); - } - } - } - - [MethodImpl(MethodImplOptions.NoInlining)] - private static bool StartService(StartupContext context, bool usePlugins, out List pluginRefs) - { - var builder = CreateConsoleHostBuilder(context, usePlugins, out pluginRefs).UseWindowsService(); - - return RunBuilder(builder, usePlugins); - } - - [MethodImpl(MethodImplOptions.NoInlining)] - private static bool StartInteractive(StartupContext context, Action trayCallback, bool usePlugins, out List pluginRefs) - { - var builder = CreateConsoleHostBuilder(context, usePlugins, out pluginRefs); - - if (trayCallback != null) - { - trayCallback(builder); - } - - return RunBuilder(builder, usePlugins); - } - - private static bool RunBuilder(IHostBuilder builder, bool usePlugins) - { - try - { - using var host = builder.Build(); - host.Run(); - } - catch (Exception e) - { - if (usePlugins) - { - Logger.Warn(e, "Error starting with plugins enabled"); - } - - return false; - } - - return true; - } - - private static void StartUtility(StartupContext context, ApplicationModes mode, IConfiguration config) - { var assemblies = AssemblyLoader.LoadBaseAssemblies(); + new HostBuilder() .UseServiceProviderFactory(new DryIocServiceProviderFactory(new Container(rules => rules.WithNzbDroneRules()))) .ConfigureContainer(c => @@ -173,9 +96,9 @@ private static void StartUtility(StartupContext context, ApplicationModes mode, c.AutoAddServices(assemblies) .AddNzbDroneLogger() .AddDatabase() - .AddStartupContext(context) + .AddStartupContext(startupContext) .Resolve() - .Route(mode); + .Route(appMode); if (config.GetValue(nameof(ConfigFileProvider.LogDbEnabled), true)) { @@ -194,10 +117,72 @@ private static void StartUtility(StartupContext context, ApplicationModes mode, services.Configure(config.GetSection("Lidarr:Server")); services.Configure(config.GetSection("Lidarr:Log")); services.Configure(config.GetSection("Lidarr:Update")); - }).Build(); + }) + .Build(); } - private static IHostBuilder CreateConsoleHostBuilder(StartupContext context, bool usePlugins, out List pluginRef) + private static void RunHostUntilShutdown(string[] args, StartupContext startupContext, ApplicationModes appMode, Action trayCallback) + { + Logger.Debug("Starting in {0} mode", trayCallback != null ? "Tray" : appMode.ToString()); + + bool shouldRestart; + do + { + var success = RunHost(args, startupContext, trayCallback, true, out var pluginRefs, out shouldRestart); + + if (!success) + { + var unloadSuccess = PluginLoader.UnloadPlugins(pluginRefs); + + if (unloadSuccess) + { + RunHost(args, startupContext, trayCallback, false, out _, out shouldRestart); + } + } + + if (shouldRestart) + { + Logger.Info("Application restart requested, reinitializing host"); + PluginLoader.UnloadPlugins(pluginRefs); + NzbDroneLogger.ResetAllTargets(startupContext, false, true); + Thread.Sleep(1000); + } + } + while (shouldRestart); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static bool RunHost(string[] args, StartupContext startupContext, Action trayCallback, bool usePlugins, out List pluginRefs, out bool shouldRestart) + { + shouldRestart = false; + + var builder = CreateConsoleHostBuilder(args, startupContext, usePlugins, out pluginRefs); + trayCallback?.Invoke(builder); + + if (OsInfo.IsWindows && WindowsServiceHelpers.IsWindowsService()) + { + builder.UseWindowsService(); + } + + try + { + using var host = builder.Build(); + shouldRestart = RunWithRestartCheck(host); + } + catch (Exception e) + { + if (usePlugins) + { + Logger.Warn(e, "Error starting with plugins enabled"); + } + + return false; + } + + return true; + } + + public static IHostBuilder CreateConsoleHostBuilder(string[] args, StartupContext context, bool usePlugins, out List pluginRef) { var config = GetConfiguration(context); @@ -224,7 +209,13 @@ private static IHostBuilder CreateConsoleHostBuilder(StartupContext context, boo var pluginPaths = new AppFolderInfo(context).GetPluginAssemblies().ToList(); (var plugins, pluginRef) = PluginLoader.LoadPlugins(pluginPaths); - assemblies.AddRange(plugins.Where(x => x != null)); + var loadedPlugins = plugins.Where(x => x != null).ToList(); + assemblies.AddRange(loadedPlugins); + ReflectionExtensions.SetCurrentAssemblies(loadedPlugins); + } + else + { + ReflectionExtensions.SetCurrentAssemblies(Enumerable.Empty()); } return new HostBuilder() @@ -367,5 +358,20 @@ private static X509Certificate2 ValidateSslCertificate(string cert, string passw return certificate; } + + private static bool RunWithRestartCheck(IHost host) + { + var shouldRestart = false; + + var lifetime = host.Services.GetRequiredService(); + lifetime.ApplicationStopped.Register(() => + { + var runtimeInfo = host.Services.GetRequiredService(); + shouldRestart = runtimeInfo.RestartPending; + }); + + host.Run(); + return shouldRestart; + } } } diff --git a/src/NzbDrone.Host/RestartableServiceLifetime.cs b/src/NzbDrone.Host/RestartableServiceLifetime.cs new file mode 100644 index 000000000..48af1cba3 --- /dev/null +++ b/src/NzbDrone.Host/RestartableServiceLifetime.cs @@ -0,0 +1,249 @@ +using System; +using System.ServiceProcess; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Hosting.Internal; +using Microsoft.Extensions.Hosting.WindowsServices; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NLog; +using NzbDrone.Common.Instrumentation; + +namespace NzbDrone.Host +{ + public class RestartableServiceLifetime : IHostLifetime, IDisposable + { + private static readonly object StaticLock = new object(); + private static readonly Logger Logger = NzbDroneLogger.GetLogger(typeof(RestartableServiceLifetime)); + private static WindowsServiceWrapper _singletonService; + private static bool _serviceBaseRunCalled; + + private readonly IHostEnvironment _environment; + private readonly bool _isWindowsService; + private readonly ConsoleLifetime _consoleLifetime; + private readonly TaskCompletionSource _waitForStartTask; + private readonly ManualResetEventSlim _hostStoppedEvent; + + private CancellationTokenRegistration _stoppedRegistration; + private bool _isDisposed; + + public IHostApplicationLifetime ApplicationLifetime { get; private set; } + internal TaskCompletionSource WaitForStartTask => _waitForStartTask; + + public RestartableServiceLifetime( + IHostEnvironment environment, + IHostApplicationLifetime applicationLifetime, + ILoggerFactory loggerFactory, + IOptions consoleOptions, + IOptions hostOptions) + { + _environment = environment; + _isWindowsService = WindowsServiceHelpers.IsWindowsService(); + ApplicationLifetime = applicationLifetime; + + if (_isWindowsService) + { + _waitForStartTask = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _hostStoppedEvent = new ManualResetEventSlim(false); + } + else + { + _consoleLifetime = new ConsoleLifetime(consoleOptions, environment, applicationLifetime, hostOptions, loggerFactory); + } + } + + public Task WaitForStartAsync(CancellationToken cancellationToken) + { + if (_isDisposed) + { + throw new ObjectDisposedException(nameof(RestartableServiceLifetime)); + } + + if (!_isWindowsService) + { + return _consoleLifetime.WaitForStartAsync(cancellationToken); + } + + lock (StaticLock) + { + _singletonService ??= new WindowsServiceWrapper(_environment.ApplicationName); + + if (_singletonService.CurrentLifetime != this) + { + Logger.Trace("In-process restart detected, preparing host"); + _singletonService.PrepareForRestart(this); + } + + if (!_serviceBaseRunCalled) + { + _serviceBaseRunCalled = true; + Logger.Debug("Starting Windows Service"); + + var serviceThread = new Thread(_singletonService.RunServiceBase) + { + IsBackground = false, + Name = "Windows.Service.Lifetime" + }; + serviceThread.Start(); + } + } + + RegisterStoppedCallback(); + return _waitForStartTask.Task.WaitAsync(cancellationToken); + } + + public Task StopAsync(CancellationToken cancellationToken) + { + if (_isDisposed) + { + return Task.CompletedTask; + } + + if (!_isWindowsService) + { + return _consoleLifetime.StopAsync(cancellationToken); + } + + try + { + _hostStoppedEvent.Wait(cancellationToken); + } + catch (OperationCanceledException) + { + Logger.Warn("Host stop was cancelled"); + } + + return Task.CompletedTask; + } + + public void Dispose() + { + if (_isDisposed) + { + return; + } + + _isDisposed = true; + + if (!_isWindowsService) + { + _consoleLifetime?.Dispose(); + } + else + { + _stoppedRegistration.Dispose(); + if (_singletonService?.CurrentLifetime != this) + { + _hostStoppedEvent?.Dispose(); + } + } + } + + internal void CleanupForRestart() + { + _stoppedRegistration.Dispose(); + } + + internal void PrepareForRestart(IHostApplicationLifetime newApplicationLifetime) + { + ApplicationLifetime = newApplicationLifetime; + _hostStoppedEvent.Reset(); + _waitForStartTask.TrySetResult(null); + } + + private void OnServiceStart() + { + try + { + _waitForStartTask.TrySetResult(null); + } + catch (Exception ex) + { + Logger.Error(ex, "Service start failed"); + _waitForStartTask.TrySetException(ex); + throw; + } + } + + private void OnServiceStop() + { + try + { + ApplicationLifetime?.StopApplication(); + } + catch (Exception ex) + { + Logger.Error(ex, "Error during service stop"); + throw; + } + } + + private void RegisterStoppedCallback() + { + _stoppedRegistration = ApplicationLifetime.ApplicationStopped.Register(state => ((RestartableServiceLifetime)state)._hostStoppedEvent.Set(), this); + } + + private sealed class WindowsServiceWrapper : ServiceBase + { + public RestartableServiceLifetime CurrentLifetime { get; private set; } + + public WindowsServiceWrapper(string serviceName) + { + ServiceName = serviceName; + } + + public void PrepareForRestart(RestartableServiceLifetime newLifetime) + { + CurrentLifetime?.CleanupForRestart(); + CurrentLifetime = newLifetime; + newLifetime.PrepareForRestart(newLifetime.ApplicationLifetime); + } + + public void RunServiceBase() + { + try + { + Run(this); + CurrentLifetime?.WaitForStartTask.TrySetException(new InvalidOperationException("Service stopped without starting")); + } + catch (Exception ex) + { + Logger.Error(ex, "Service base execution failed"); + CurrentLifetime?.WaitForStartTask.TrySetException(ex); + } + } + + protected override void OnStart(string[] args) + { + CurrentLifetime?.OnServiceStart(); + base.OnStart(args); + } + + protected override void OnStop() + { + CurrentLifetime?.OnServiceStop(); + base.OnStop(); + } + + protected override void OnShutdown() + { + try + { + CurrentLifetime?.ApplicationLifetime?.StopApplication(); + } + catch (Exception ex) + { + Logger.Error(ex, "Error during system shutdown"); + } + + base.OnShutdown(); + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + } + } + } +}