diff --git a/docs/articles/concepts/bootstrap-lifecycle.md b/docs/articles/concepts/bootstrap-lifecycle.md index 52a39c4..bc5c3f0 100644 --- a/docs/articles/concepts/bootstrap-lifecycle.md +++ b/docs/articles/concepts/bootstrap-lifecycle.md @@ -96,3 +96,11 @@ sequenceDiagram ## RunAsync for long-running hosts For long-running hosts, call `RunAsync`. It starts every service and then blocks until cancellation, stopping services cleanly on shutdown. Resolve dependencies anywhere with `bootstrap.Resolve()`. See the [architecture](architecture.md) overview for how the host fits the layers. + +`RunAsync` also completes when a shutdown is requested through the shared lifetime - this is +what the `exit` command of SquidStd.ConsoleCommands does - and Ctrl+C performs an orderly +shutdown instead of killing the process: + +```csharp +bootstrap.Container.Resolve().RequestShutdown(); +``` diff --git a/src/SquidStd.ConsoleCommands/Extensions/ConsoleCommandsRegistrationExtensions.cs b/src/SquidStd.ConsoleCommands/Extensions/ConsoleCommandsRegistrationExtensions.cs index df5f816..d759c05 100644 --- a/src/SquidStd.ConsoleCommands/Extensions/ConsoleCommandsRegistrationExtensions.cs +++ b/src/SquidStd.ConsoleCommands/Extensions/ConsoleCommandsRegistrationExtensions.cs @@ -8,6 +8,7 @@ using SquidStd.ConsoleCommands.Internal.Logging; using SquidStd.ConsoleCommands.Services; using SquidStd.Core.Interfaces.Bootstrap; +using SquidStd.Core.Interfaces.Lifecycle; namespace SquidStd.ConsoleCommands.Extensions; @@ -35,9 +36,11 @@ public IContainer AddConsoleCommands() { var ui = resolver.Resolve(); var system = new CommandSystemService(ui.WriteLine); + var lifetime = resolver.Resolve(IfUnresolved.ReturnDefault); var bootstrap = resolver.Resolve(IfUnresolved.ReturnDefault); BuiltinConsoleCommands.Register( system, + lifetime, bootstrap, static () => { diff --git a/src/SquidStd.ConsoleCommands/Internal/BuiltinConsoleCommands.cs b/src/SquidStd.ConsoleCommands/Internal/BuiltinConsoleCommands.cs index 8ee11de..ab43d58 100644 --- a/src/SquidStd.ConsoleCommands/Internal/BuiltinConsoleCommands.cs +++ b/src/SquidStd.ConsoleCommands/Internal/BuiltinConsoleCommands.cs @@ -1,5 +1,6 @@ using SquidStd.ConsoleCommands.Interfaces; using SquidStd.Core.Interfaces.Bootstrap; +using SquidStd.Core.Interfaces.Lifecycle; namespace SquidStd.ConsoleCommands.Internal; @@ -8,7 +9,12 @@ namespace SquidStd.ConsoleCommands.Internal; /// internal static class BuiltinConsoleCommands { - internal static void Register(ICommandSystemService system, ISquidStdBootstrap? bootstrap, Action clearScreen) + internal static void Register( + ICommandSystemService system, + ISquidStdLifetime? lifetime, + ISquidStdBootstrap? bootstrap, + Action clearScreen + ) { system.RegisterCommand( "help|?", @@ -42,9 +48,17 @@ internal static void Register(ICommandSystemService system, ISquidStdBootstrap? "exit|quit", context => { + if (lifetime is not null) + { + context.WriteLine("Shutting down..."); + lifetime.RequestShutdown(); + + return Task.CompletedTask; + } + if (bootstrap is null) { - context.WriteLine("Shutdown is not available: no bootstrap registered."); + context.WriteLine("Shutdown is not available: no bootstrap or lifetime registered."); return Task.CompletedTask; } diff --git a/src/SquidStd.ConsoleCommands/README.md b/src/SquidStd.ConsoleCommands/README.md index 4bb2338..d340334 100644 --- a/src/SquidStd.ConsoleCommands/README.md +++ b/src/SquidStd.ConsoleCommands/README.md @@ -71,7 +71,10 @@ internal sealed class PingCommand : IConsoleCommandExecutor ``` The builtin commands `help` (`?`), `clear` (`cls`) and `exit` (`quit`) are registered by -`AddConsoleCommands()`. +`AddConsoleCommands()`. `exit` requests a graceful shutdown through `ISquidStdLifetime` - with a +`RunAsync` host the process stops its services in order and exits cleanly. With a manual +`StartAsync`/`StopAsync` host, observe `ISquidStdLifetime.ShutdownToken` yourself - or just use +`RunAsync`. ## Configuration diff --git a/src/SquidStd.Core/Interfaces/Bootstrap/ISquidStdBootstrap.cs b/src/SquidStd.Core/Interfaces/Bootstrap/ISquidStdBootstrap.cs index 7007af7..ebaa0bd 100644 --- a/src/SquidStd.Core/Interfaces/Bootstrap/ISquidStdBootstrap.cs +++ b/src/SquidStd.Core/Interfaces/Bootstrap/ISquidStdBootstrap.cs @@ -68,7 +68,9 @@ public interface ISquidStdBootstrap : IAsyncDisposable void ConfigureLogging(); /// - /// Starts services, waits until cancellation, and then stops services. + /// Starts services, waits until the caller token is cancelled or a shutdown is requested + /// through (Ctrl+C is turned into a graceful + /// shutdown request), and then stops services. /// /// Token that controls the run lifetime. /// A task that completes after services have stopped. diff --git a/src/SquidStd.Core/Interfaces/Lifecycle/ISquidStdLifetime.cs b/src/SquidStd.Core/Interfaces/Lifecycle/ISquidStdLifetime.cs new file mode 100644 index 0000000..6c966e1 --- /dev/null +++ b/src/SquidStd.Core/Interfaces/Lifecycle/ISquidStdLifetime.cs @@ -0,0 +1,13 @@ +namespace SquidStd.Core.Interfaces.Lifecycle; + +/// +/// Tracks shutdown requests for the host and exposes a shared cancellation token. +/// +public interface ISquidStdLifetime +{ + /// Token cancelled when a shutdown has been requested. + CancellationToken ShutdownToken { get; } + + /// Requests a graceful shutdown. Idempotent. + void RequestShutdown(); +} diff --git a/src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs b/src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs index d2e70ba..031f1c2 100644 --- a/src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs +++ b/src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs @@ -12,9 +12,11 @@ using SquidStd.Core.Interfaces.Bootstrap; using SquidStd.Core.Interfaces.Config; using SquidStd.Core.Interfaces.Events; +using SquidStd.Core.Interfaces.Lifecycle; using SquidStd.Core.Types; using SquidStd.Services.Core.Extensions; using SquidStd.Services.Core.Extensions.Logger; +using SquidStd.Services.Core.Services.Lifecycle; using SquidStd.Services.Core.Types; namespace SquidStd.Services.Core.Services.Bootstrap; @@ -26,6 +28,7 @@ public sealed class SquidStdBootstrap : ISquidStdBootstrap { private readonly List<(Type ConfigType, Action Apply)> _configHooks = []; private readonly List> _configReadyCallbacks = []; + private readonly SquidStdLifetimeService _lifetime = new(); private readonly bool _ownsContainer; private readonly List _startedServices = []; private readonly Lock _syncRoot = new(); @@ -67,6 +70,7 @@ private SquidStdBootstrap(SquidStdOptions options, IContainer container, bool ow Container.RegisterInstance(this, IfAlreadyRegistered.Replace); Container.RegisterInstance(this, IfAlreadyRegistered.Replace); Container.RegisterInstance(Options, IfAlreadyRegistered.Replace); + Container.RegisterInstance(_lifetime, IfAlreadyRegistered.Replace); Container.RegisterConfigServices(Options.ConfigName, Options.RootDirectory); } @@ -152,6 +156,8 @@ public async ValueTask DisposeAsync() await Log.CloseAndFlushAsync(); } + _lifetime.Dispose(); + if (_ownsContainer) { Container.Dispose(); @@ -194,13 +200,29 @@ public async Task RunAsync(CancellationToken cancellationToken = default) { await StartAsync(cancellationToken); + ConsoleCancelEventHandler? cancelHandler = null; + try { - await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _lifetime.ShutdownToken); + cancelHandler = (_, args) => + { + args.Cancel = true; + _lifetime.RequestShutdown(); + }; + Console.CancelKeyPress += cancelHandler; + + await Task.Delay(Timeout.InfiniteTimeSpan, linked.Token); } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested + || _lifetime.ShutdownToken.IsCancellationRequested) { } finally { + if (cancelHandler is not null) + { + Console.CancelKeyPress -= cancelHandler; + } + await StopAsync(CancellationToken.None); } } diff --git a/src/SquidStd.Services.Core/Services/Lifecycle/SquidStdLifetimeService.cs b/src/SquidStd.Services.Core/Services/Lifecycle/SquidStdLifetimeService.cs new file mode 100644 index 0000000..a5946ee --- /dev/null +++ b/src/SquidStd.Services.Core/Services/Lifecycle/SquidStdLifetimeService.cs @@ -0,0 +1,40 @@ +using SquidStd.Core.Interfaces.Lifecycle; + +namespace SquidStd.Services.Core.Services.Lifecycle; + +/// +/// Default backed by a cancellation token source owned by +/// the bootstrap. +/// +public sealed class SquidStdLifetimeService : ISquidStdLifetime, IDisposable +{ + private readonly CancellationTokenSource _shutdownSource = new(); + + /// + public CancellationToken ShutdownToken => _shutdownSource.Token; + + /// + public void RequestShutdown() + { + if (_shutdownSource.IsCancellationRequested) + { + return; + } + + try + { + _shutdownSource.Cancel(); + } + catch (ObjectDisposedException) + { + // Requested after the bootstrap disposed the lifetime: idempotent no-op. + } + } + + /// + public void Dispose() + { + _shutdownSource.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/tests/SquidStd.Tests/Bootstrap/SquidStdLifetimeTests.cs b/tests/SquidStd.Tests/Bootstrap/SquidStdLifetimeTests.cs new file mode 100644 index 0000000..9418fa6 --- /dev/null +++ b/tests/SquidStd.Tests/Bootstrap/SquidStdLifetimeTests.cs @@ -0,0 +1,97 @@ +using DryIoc; +using SquidStd.Core.Data.Bootstrap; +using SquidStd.Core.Interfaces.Events; +using SquidStd.Core.Interfaces.Lifecycle; +using SquidStd.Services.Core.Extensions; +using SquidStd.Services.Core.Services.Bootstrap; +using SquidStd.Tests.Support; + +namespace SquidStd.Tests.Bootstrap; + +[Collection(SerilogEventSinkCollection.Name)] +public class SquidStdLifetimeTests +{ + [Fact] + public async Task RequestShutdown_CompletesRunAsync() + { + using var temp = new TempDirectory(); + await using var bootstrap = SquidStdBootstrap.Create( + new SquidStdOptions { ConfigName = "app", RootDirectory = temp.Path, AppName = "LifetimeApp" } + ); + bootstrap.ConfigureServices(c => c.RegisterCoreServices()); + + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var stopped = false; + var bus = bootstrap.Container.Resolve(); + using var startedSubscription = bus.Subscribe( + (_, _) => + { + started.TrySetResult(); + + return Task.CompletedTask; + } + ); + using var stoppedSubscription = bus.Subscribe( + (_, _) => + { + stopped = true; + + return Task.CompletedTask; + } + ); + + var runTask = bootstrap.RunAsync(); + + await started.Task.WaitAsync(TimeSpan.FromSeconds(5)); + bootstrap.Container.Resolve().RequestShutdown(); + await runTask.WaitAsync(TimeSpan.FromSeconds(10)); + + Assert.True(runTask.IsCompletedSuccessfully); + Assert.True(stopped); + } + + [Fact] + public async Task RequestShutdown_IsIdempotent() + { + using var temp = new TempDirectory(); + await using var bootstrap = SquidStdBootstrap.Create( + new SquidStdOptions { ConfigName = "app", RootDirectory = temp.Path } + ); + + var lifetime = bootstrap.Container.Resolve(); + lifetime.RequestShutdown(); + lifetime.RequestShutdown(); + + Assert.True(lifetime.ShutdownToken.IsCancellationRequested); + } + + [Fact] + public async Task RunAsync_CallerToken_StillWorks() + { + using var temp = new TempDirectory(); + using var cts = new CancellationTokenSource(); + await using var bootstrap = SquidStdBootstrap.Create( + new SquidStdOptions { ConfigName = "app", RootDirectory = temp.Path } + ); + bootstrap.ConfigureServices(c => c.RegisterCoreServices()); + + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var bus = bootstrap.Container.Resolve(); + using var startedSubscription = bus.Subscribe( + (_, _) => + { + started.TrySetResult(); + + return Task.CompletedTask; + } + ); + + var runTask = bootstrap.RunAsync(cts.Token); + + await started.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await cts.CancelAsync(); + await runTask.WaitAsync(TimeSpan.FromSeconds(10)); + + Assert.True(runTask.IsCompletedSuccessfully); + } +} diff --git a/tests/SquidStd.Tests/ConsoleCommands/BuiltinCommandsTests.cs b/tests/SquidStd.Tests/ConsoleCommands/BuiltinCommandsTests.cs index b17127f..303a21a 100644 --- a/tests/SquidStd.Tests/ConsoleCommands/BuiltinCommandsTests.cs +++ b/tests/SquidStd.Tests/ConsoleCommands/BuiltinCommandsTests.cs @@ -4,6 +4,7 @@ using SquidStd.Core.Data.Bootstrap; using SquidStd.Core.Interfaces.Bootstrap; using SquidStd.Core.Interfaces.Config; +using SquidStd.Services.Core.Services.Lifecycle; namespace SquidStd.Tests.ConsoleCommands; @@ -14,7 +15,7 @@ public async Task Help_ListsRegisteredCommandsWithDescriptions() { var lines = new List(); var system = new CommandSystemService(lines.Add); - BuiltinConsoleCommands.Register(system, bootstrap: null, clearScreen: () => { }); + BuiltinConsoleCommands.Register(system, lifetime: null, bootstrap: null, clearScreen: () => { }); system.RegisterCommand("custom", _ => Task.CompletedTask, "does things"); var output = await system.ExecuteCommandWithOutputAsync("help"); @@ -29,7 +30,7 @@ public async Task Help_ListsRegisteredCommandsWithDescriptions() public async Task Exit_WithoutBootstrap_ReportsUnavailable() { var system = new CommandSystemService(_ => { }); - BuiltinConsoleCommands.Register(system, bootstrap: null, clearScreen: () => { }); + BuiltinConsoleCommands.Register(system, lifetime: null, bootstrap: null, clearScreen: () => { }); var output = await system.ExecuteCommandWithOutputAsync("exit"); @@ -43,6 +44,7 @@ public async Task Exit_WithBootstrap_RequestsStop() var stopSignal = new TaskCompletionSource(); BuiltinConsoleCommands.Register( system, + lifetime: null, bootstrap: new StopSpyBootstrap(() => stopSignal.TrySetResult()), clearScreen: () => { } ); @@ -53,6 +55,26 @@ public async Task Exit_WithBootstrap_RequestsStop() Assert.True(stopped); } + [Fact] + public async Task Exit_WithLifetime_RequestsShutdown_WithoutStoppingBootstrapDirectly() + { + var system = new CommandSystemService(_ => { }); + using var lifetime = new SquidStdLifetimeService(); + var bootstrapStopped = false; + BuiltinConsoleCommands.Register( + system, + lifetime: lifetime, + bootstrap: new StopSpyBootstrap(() => bootstrapStopped = true), + clearScreen: () => { } + ); + + await system.ExecuteCommandAsync("exit"); + await Task.Delay(100); + + Assert.True(lifetime.ShutdownToken.IsCancellationRequested); + Assert.False(bootstrapStopped); + } + private sealed class StopSpyBootstrap : ISquidStdBootstrap { private readonly Action _onStop;