Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/articles/concepts/bootstrap-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>()`. 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<ISquidStdLifetime>().RequestShutdown();
```
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -35,9 +36,11 @@ public IContainer AddConsoleCommands()
{
var ui = resolver.Resolve<IConsoleUiService>();
var system = new CommandSystemService(ui.WriteLine);
var lifetime = resolver.Resolve<ISquidStdLifetime>(IfUnresolved.ReturnDefault);
var bootstrap = resolver.Resolve<ISquidStdBootstrap>(IfUnresolved.ReturnDefault);
BuiltinConsoleCommands.Register(
system,
lifetime,
bootstrap,
static () =>
{
Expand Down
18 changes: 16 additions & 2 deletions src/SquidStd.ConsoleCommands/Internal/BuiltinConsoleCommands.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using SquidStd.ConsoleCommands.Interfaces;
using SquidStd.Core.Interfaces.Bootstrap;
using SquidStd.Core.Interfaces.Lifecycle;

namespace SquidStd.ConsoleCommands.Internal;

Expand All @@ -8,7 +9,12 @@ namespace SquidStd.ConsoleCommands.Internal;
/// </summary>
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|?",
Expand Down Expand Up @@ -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;
}
Expand Down
5 changes: 4 additions & 1 deletion src/SquidStd.ConsoleCommands/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 3 additions & 1 deletion src/SquidStd.Core/Interfaces/Bootstrap/ISquidStdBootstrap.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,9 @@ public interface ISquidStdBootstrap : IAsyncDisposable
void ConfigureLogging();

/// <summary>
/// Starts services, waits until cancellation, and then stops services.
/// Starts services, waits until the caller token is cancelled or a shutdown is requested
/// through <see cref="Lifecycle.ISquidStdLifetime" /> (Ctrl+C is turned into a graceful
/// shutdown request), and then stops services.
/// </summary>
/// <param name="cancellationToken">Token that controls the run lifetime.</param>
/// <returns>A task that completes after services have stopped.</returns>
Expand Down
13 changes: 13 additions & 0 deletions src/SquidStd.Core/Interfaces/Lifecycle/ISquidStdLifetime.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
namespace SquidStd.Core.Interfaces.Lifecycle;

/// <summary>
/// Tracks shutdown requests for the host and exposes a shared cancellation token.
/// </summary>
public interface ISquidStdLifetime
{
/// <summary>Token cancelled when a shutdown has been requested.</summary>
CancellationToken ShutdownToken { get; }

/// <summary>Requests a graceful shutdown. Idempotent.</summary>
void RequestShutdown();
}
26 changes: 24 additions & 2 deletions src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -26,6 +28,7 @@
{
private readonly List<(Type ConfigType, Action<object> Apply)> _configHooks = [];
private readonly List<Action<IConfigManagerService>> _configReadyCallbacks = [];
private readonly SquidStdLifetimeService _lifetime = new();

Check notice

Code scanning / CodeQL

Missed 'using' opportunity Note

This variable is manually
disposed
in a
finally block
- consider a C# using statement as a preferable resource management technique.
private readonly bool _ownsContainer;
private readonly List<ISquidStdService> _startedServices = [];
private readonly Lock _syncRoot = new();
Expand Down Expand Up @@ -67,6 +70,7 @@
Container.RegisterInstance<ISquidStdBootstrap>(this, IfAlreadyRegistered.Replace);
Container.RegisterInstance(this, IfAlreadyRegistered.Replace);
Container.RegisterInstance(Options, IfAlreadyRegistered.Replace);
Container.RegisterInstance<ISquidStdLifetime>(_lifetime, IfAlreadyRegistered.Replace);
Container.RegisterConfigServices(Options.ConfigName, Options.RootDirectory);
}

Expand Down Expand Up @@ -152,6 +156,8 @@
await Log.CloseAndFlushAsync();
}

_lifetime.Dispose();

if (_ownsContainer)
{
Container.Dispose();
Expand Down Expand Up @@ -194,13 +200,29 @@
{
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) { }

Check notice

Code scanning / CodeQL

Poor error handling: empty catch block Note

Poor error handling: empty catch block.
Comment on lines +217 to +218
finally
{
if (cancelHandler is not null)
{
Console.CancelKeyPress -= cancelHandler;
}

await StopAsync(CancellationToken.None);
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using SquidStd.Core.Interfaces.Lifecycle;

namespace SquidStd.Services.Core.Services.Lifecycle;

/// <summary>
/// Default <see cref="ISquidStdLifetime" /> backed by a cancellation token source owned by
/// the bootstrap.
/// </summary>
public sealed class SquidStdLifetimeService : ISquidStdLifetime, IDisposable
{
private readonly CancellationTokenSource _shutdownSource = new();

/// <inheritdoc />
public CancellationToken ShutdownToken => _shutdownSource.Token;

/// <inheritdoc />
public void RequestShutdown()
{
if (_shutdownSource.IsCancellationRequested)
{
return;
}

try
{
_shutdownSource.Cancel();
}
catch (ObjectDisposedException)
{
// Requested after the bootstrap disposed the lifetime: idempotent no-op.
}
}

/// <inheritdoc />
public void Dispose()
{
_shutdownSource.Dispose();
GC.SuppressFinalize(this);
}
}
97 changes: 97 additions & 0 deletions tests/SquidStd.Tests/Bootstrap/SquidStdLifetimeTests.cs
Original file line number Diff line number Diff line change
@@ -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<IEventBus>();
using var startedSubscription = bus.Subscribe<EngineStartedEvent>(
(_, _) =>
{
started.TrySetResult();

return Task.CompletedTask;
}
);
using var stoppedSubscription = bus.Subscribe<EngineStoppedEvent>(
(_, _) =>
{
stopped = true;

return Task.CompletedTask;
}
);

var runTask = bootstrap.RunAsync();

await started.Task.WaitAsync(TimeSpan.FromSeconds(5));
bootstrap.Container.Resolve<ISquidStdLifetime>().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<ISquidStdLifetime>();
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<IEventBus>();
using var startedSubscription = bus.Subscribe<EngineStartedEvent>(
(_, _) =>
{
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);
}
}
26 changes: 24 additions & 2 deletions tests/SquidStd.Tests/ConsoleCommands/BuiltinCommandsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -14,7 +15,7 @@ public async Task Help_ListsRegisteredCommandsWithDescriptions()
{
var lines = new List<string>();
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");
Expand All @@ -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");

Expand All @@ -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: () => { }
);
Expand All @@ -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;
Expand Down
Loading