From f9bd263a7a634170a2874fe50e9532b34c15c0cb Mon Sep 17 00:00:00 2001 From: Tom Date: Fri, 3 Jul 2026 15:02:06 +0200 Subject: [PATCH 01/11] feat(bootstrap): publish engine lifecycle events on the event bus --- .../Data/Bootstrap/EngineStartedEvent.cs | 11 +++ .../Data/Bootstrap/EngineStartingEvent.cs | 9 ++ .../Data/Bootstrap/EngineStoppedEvent.cs | 9 ++ .../Services/Bootstrap/SquidStdBootstrap.cs | 32 ++++++ .../Bootstrap/EngineEventsTests.cs | 98 +++++++++++++++++++ 5 files changed, 159 insertions(+) create mode 100644 src/SquidStd.Core/Data/Bootstrap/EngineStartedEvent.cs create mode 100644 src/SquidStd.Core/Data/Bootstrap/EngineStartingEvent.cs create mode 100644 src/SquidStd.Core/Data/Bootstrap/EngineStoppedEvent.cs create mode 100644 tests/SquidStd.Tests/Bootstrap/EngineEventsTests.cs diff --git a/src/SquidStd.Core/Data/Bootstrap/EngineStartedEvent.cs b/src/SquidStd.Core/Data/Bootstrap/EngineStartedEvent.cs new file mode 100644 index 0000000..a22b82e --- /dev/null +++ b/src/SquidStd.Core/Data/Bootstrap/EngineStartedEvent.cs @@ -0,0 +1,11 @@ +using SquidStd.Core.Interfaces.Events; + +namespace SquidStd.Core.Data.Bootstrap; + +/// +/// Published on the event bus when every service has started. +/// +/// The resolved application name. +/// Number of services started. +/// Total startup time in milliseconds. +public sealed record EngineStartedEvent(string Application, int ServiceCount, double ElapsedMs) : IEvent; diff --git a/src/SquidStd.Core/Data/Bootstrap/EngineStartingEvent.cs b/src/SquidStd.Core/Data/Bootstrap/EngineStartingEvent.cs new file mode 100644 index 0000000..9b69896 --- /dev/null +++ b/src/SquidStd.Core/Data/Bootstrap/EngineStartingEvent.cs @@ -0,0 +1,9 @@ +using SquidStd.Core.Interfaces.Events; + +namespace SquidStd.Core.Data.Bootstrap; + +/// +/// Published on the event bus when the bootstrap begins starting services. +/// +/// The resolved application name. +public sealed record EngineStartingEvent(string Application) : IEvent; diff --git a/src/SquidStd.Core/Data/Bootstrap/EngineStoppedEvent.cs b/src/SquidStd.Core/Data/Bootstrap/EngineStoppedEvent.cs new file mode 100644 index 0000000..5963065 --- /dev/null +++ b/src/SquidStd.Core/Data/Bootstrap/EngineStoppedEvent.cs @@ -0,0 +1,9 @@ +using SquidStd.Core.Interfaces.Events; + +namespace SquidStd.Core.Data.Bootstrap; + +/// +/// Published on the event bus when the bootstrap has stopped its services. +/// +/// The resolved application name. +public sealed record EngineStoppedEvent(string Application) : IEvent; diff --git a/src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs b/src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs index 644553c..d2e70ba 100644 --- a/src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs +++ b/src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs @@ -11,6 +11,7 @@ using SquidStd.Core.Extensions.Logger; using SquidStd.Core.Interfaces.Bootstrap; using SquidStd.Core.Interfaces.Config; +using SquidStd.Core.Interfaces.Events; using SquidStd.Core.Types; using SquidStd.Services.Core.Extensions; using SquidStd.Services.Core.Extensions.Logger; @@ -230,6 +231,8 @@ public async ValueTask StartAsync(CancellationToken cancellationToken = default) var registrations = GetServiceRegistrations(); LogRegistrations(logger, registrations); + await PublishEngineEventAsync(new EngineStartingEvent(appName), cancellationToken); + var startedInstances = new HashSet(ReferenceEqualityComparer.Instance); var totalStopwatch = Stopwatch.StartNew(); @@ -271,6 +274,11 @@ public async ValueTask StartAsync(CancellationToken cancellationToken = default) _startedServices.Count, totalStopwatch.Elapsed.TotalMilliseconds ); + + await PublishEngineEventAsync( + new EngineStartedEvent(appName, _startedServices.Count, totalStopwatch.Elapsed.TotalMilliseconds), + cancellationToken + ); } catch { @@ -315,6 +323,19 @@ public async ValueTask StopAsync(CancellationToken cancellationToken = default) } logger.Information("{Application:l} shutdown complete", appName); + + try + { + await PublishEngineEventAsync(new EngineStoppedEvent(appName), cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + logger.Warning(ex, "EngineStoppedEvent publish failed"); + } } finally { @@ -431,6 +452,17 @@ private void ConfigureLogger() _loggerConfigured = true; } + private async Task PublishEngineEventAsync(TEvent engineEvent, CancellationToken cancellationToken) + where TEvent : IEvent + { + if (!Container.IsRegistered()) + { + return; + } + + await Container.Resolve().PublishAsync(engineEvent, cancellationToken); + } + private void ApplyConfigHooks() { if (_configHooks.Count == 0 && _configReadyCallbacks.Count == 0) diff --git a/tests/SquidStd.Tests/Bootstrap/EngineEventsTests.cs b/tests/SquidStd.Tests/Bootstrap/EngineEventsTests.cs new file mode 100644 index 0000000..858fcb4 --- /dev/null +++ b/tests/SquidStd.Tests/Bootstrap/EngineEventsTests.cs @@ -0,0 +1,98 @@ +using DryIoc; +using SquidStd.Core.Data.Bootstrap; +using SquidStd.Core.Interfaces.Events; +using SquidStd.Services.Core.Extensions; +using SquidStd.Services.Core.Services.Bootstrap; +using SquidStd.Tests.Support; + +namespace SquidStd.Tests.Bootstrap; + +[Collection(SerilogEventSinkCollection.Name)] +public class EngineEventsTests +{ + [Fact] + public async Task StartAsync_PublishesStartingThenStarted() + { + using var temp = new TempDirectory(); + await using var bootstrap = SquidStdBootstrap.Create( + new SquidStdOptions { ConfigName = "app", RootDirectory = temp.Path, AppName = "EngineApp" } + ); + bootstrap.ConfigureServices(c => c.RegisterCoreServices()); + + var received = new List(); + EngineStartedEvent? startedEvent = null; + var bus = bootstrap.Container.Resolve(); + using var startingSubscription = bus.Subscribe( + (e, _) => + { + received.Add("starting:" + e.Application); + + return Task.CompletedTask; + } + ); + using var startedSubscription = bus.Subscribe( + (e, _) => + { + received.Add("started"); + startedEvent = e; + + return Task.CompletedTask; + } + ); + + try + { + await bootstrap.StartAsync(); + + Assert.Equal("starting:EngineApp", received[0]); + Assert.Equal("started", received[1]); + Assert.NotNull(startedEvent); + Assert.Equal("EngineApp", startedEvent.Application); + Assert.True(startedEvent.ServiceCount > 0); + Assert.True(startedEvent.ElapsedMs >= 0); + } + finally + { + await bootstrap.StopAsync(); + } + } + + [Fact] + public async Task StopAsync_PublishesStopped() + { + using var temp = new TempDirectory(); + await using var bootstrap = SquidStdBootstrap.Create( + new SquidStdOptions { ConfigName = "app", RootDirectory = temp.Path, AppName = "EngineApp" } + ); + bootstrap.ConfigureServices(c => c.RegisterCoreServices()); + + EngineStoppedEvent? stoppedEvent = null; + var bus = bootstrap.Container.Resolve(); + using var stoppedSubscription = bus.Subscribe( + (e, _) => + { + stoppedEvent = e; + + return Task.CompletedTask; + } + ); + + await bootstrap.StartAsync(); + await bootstrap.StopAsync(); + + Assert.NotNull(stoppedEvent); + Assert.Equal("EngineApp", stoppedEvent.Application); + } + + [Fact] + public async Task StartStop_WithoutEventBus_DoesNotThrow() + { + using var temp = new TempDirectory(); + await using var bootstrap = SquidStdBootstrap.Create( + new SquidStdOptions { ConfigName = "app", RootDirectory = temp.Path, AppName = "EngineApp" } + ); + + await bootstrap.StartAsync(); + await bootstrap.StopAsync(); + } +} From d6a01c5f0243bc952cadbbb75f01bc5013a56cc8 Mon Sep 17 00:00:00 2001 From: Tom Date: Fri, 3 Jul 2026 15:06:59 +0200 Subject: [PATCH 02/11] feat(lua): events.subscribe with a catch-all bus forwarder and snake_case event names Adds LuaEventBusForwarder (subscribes to the IEvent catch-all channel and forwards every event to the Lua bridge as snake_case names with snake_case payload keys), the events.subscribe function alongside events.on, a RegisterLuaEvents() registration helper, and serialized closure invocation in the bridge (bus events arrive on arbitrary threads). --- .../Scripts/RegisterLuaEventsExtension.cs | 33 ++++++ .../Modules/EventsModule.cs | 4 + .../Services/LuaEventBridge.cs | 9 +- .../Services/LuaEventBusForwarder.cs | 102 ++++++++++++++++++ .../SquidStd.Scripting.Lua.csproj | 4 + .../Lua/LuaEventBusForwarderTests.cs | 79 ++++++++++++++ .../Scripting/Lua/LuaModuleTests.cs | 14 +++ 7 files changed, 243 insertions(+), 2 deletions(-) create mode 100644 src/SquidStd.Scripting.Lua/Extensions/Scripts/RegisterLuaEventsExtension.cs create mode 100644 src/SquidStd.Scripting.Lua/Services/LuaEventBusForwarder.cs create mode 100644 tests/SquidStd.Tests/Scripting/Lua/LuaEventBusForwarderTests.cs diff --git a/src/SquidStd.Scripting.Lua/Extensions/Scripts/RegisterLuaEventsExtension.cs b/src/SquidStd.Scripting.Lua/Extensions/Scripts/RegisterLuaEventsExtension.cs new file mode 100644 index 0000000..f4c132e --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Extensions/Scripts/RegisterLuaEventsExtension.cs @@ -0,0 +1,33 @@ +using DryIoc; +using SquidStd.Abstractions.Extensions.Services; +using SquidStd.Scripting.Lua.Interfaces.Events; +using SquidStd.Scripting.Lua.Modules; +using SquidStd.Scripting.Lua.Services; + +namespace SquidStd.Scripting.Lua.Extensions.Scripts; + +/// +/// Registration helpers for the Lua events stack: bridge, events module and bus forwarder. +/// +public static class RegisterLuaEventsExtension +{ + /// Container that receives the registrations. + extension(IContainer container) + { + /// + /// Registers the Lua event bridge, the events script module and the bus forwarder + /// that delivers every published event to Lua callbacks by snake_case name + /// (events.subscribe("engine_started", fn)). The forwarder is a no-op when no + /// event bus is registered. + /// + /// The same container for chaining. + public IContainer RegisterLuaEvents() + { + container.Register(Reuse.Singleton, ifAlreadyRegistered: IfAlreadyRegistered.Keep); + container.RegisterScriptModule(); + container.RegisterStdService(); + + return container; + } + } +} diff --git a/src/SquidStd.Scripting.Lua/Modules/EventsModule.cs b/src/SquidStd.Scripting.Lua/Modules/EventsModule.cs index 9137a3e..12c851f 100644 --- a/src/SquidStd.Scripting.Lua/Modules/EventsModule.cs +++ b/src/SquidStd.Scripting.Lua/Modules/EventsModule.cs @@ -17,4 +17,8 @@ public EventsModule(ILuaEventBridge events) [ScriptFunction("on", "Registers a callback for a named server event.")] public void On(string eventName, Closure callback) => _events.Register(eventName, callback); + + [ScriptFunction("subscribe", "Registers a callback for a named server event (snake_case name).")] + public void Subscribe(string eventName, Closure callback) + => _events.Register(eventName, callback); } diff --git a/src/SquidStd.Scripting.Lua/Services/LuaEventBridge.cs b/src/SquidStd.Scripting.Lua/Services/LuaEventBridge.cs index fe473fb..a391c25 100644 --- a/src/SquidStd.Scripting.Lua/Services/LuaEventBridge.cs +++ b/src/SquidStd.Scripting.Lua/Services/LuaEventBridge.cs @@ -6,11 +6,13 @@ namespace SquidStd.Scripting.Lua.Services; /// -/// Default Lua event bridge backed by named MoonSharp closures. +/// Default Lua event bridge backed by named MoonSharp closures. Closure invocations are +/// serialized because bus events may arrive on arbitrary threads and MoonSharp is not thread-safe. /// public sealed class LuaEventBridge : ILuaEventBridge { private readonly ConcurrentDictionary> _callbacks = new(StringComparer.OrdinalIgnoreCase); + private readonly Lock _invokeSync = new(); private readonly ILogger _logger = Log.ForContext(); private Script? _script; @@ -30,7 +32,10 @@ public DynValue Invoke(Closure callback, IReadOnlyDictionary pa var script = _script ?? throw new InvalidOperationException("Lua event bridge is not attached to a script."); var table = CreatePayloadTable(script, payload); - return script.Call(callback, table); + lock (_invokeSync) + { + return script.Call(callback, table); + } } public void Publish(string eventName, IReadOnlyDictionary payload) diff --git a/src/SquidStd.Scripting.Lua/Services/LuaEventBusForwarder.cs b/src/SquidStd.Scripting.Lua/Services/LuaEventBusForwarder.cs new file mode 100644 index 0000000..a63d820 --- /dev/null +++ b/src/SquidStd.Scripting.Lua/Services/LuaEventBusForwarder.cs @@ -0,0 +1,102 @@ +using System.Collections.Concurrent; +using System.Reflection; +using Serilog; +using SquidStd.Abstractions.Interfaces.Services; +using SquidStd.Core.Extensions.Strings; +using SquidStd.Core.Interfaces.Events; +using SquidStd.Scripting.Lua.Interfaces.Events; + +namespace SquidStd.Scripting.Lua.Services; + +/// +/// Forwards every event published on the event bus to the Lua event bridge, using the +/// snake_case convention: the CLR type name minus the Event suffix +/// (EngineStartedEvent becomes engine_started) with snake_case payload keys. +/// No-op when no event bus is registered. +/// +public sealed class LuaEventBusForwarder : ISquidStdService, IDisposable +{ + private static readonly ConcurrentDictionary NameCache = new(); + private static readonly ConcurrentDictionary Get)[]> PayloadCache = new(); + + private readonly ILuaEventBridge _bridge; + private readonly IEventBus? _bus; + private IDisposable? _subscription; + + public LuaEventBusForwarder(ILuaEventBridge bridge, IEventBus? bus = null) + { + _bridge = bridge; + _bus = bus; + } + + /// + public ValueTask StartAsync(CancellationToken cancellationToken = default) + { + if (_bus is not null) + { + _subscription = _bus.Subscribe(ForwardAsync); + } + + return ValueTask.CompletedTask; + } + + /// + public ValueTask StopAsync(CancellationToken cancellationToken = default) + => ValueTask.CompletedTask; + + internal static string DeriveName(Type eventType) + => NameCache.GetOrAdd( + eventType, + static type => + { + var name = type.Name; + + if (name.EndsWith("Event", StringComparison.Ordinal) && name.Length > "Event".Length) + { + name = name[..^"Event".Length]; + } + + return name.ToSnakeCase(); + } + ); + + private static (string Key, Func Get)[] PayloadAccessors(Type eventType) + => PayloadCache.GetOrAdd( + eventType, + static type => + [ + .. type.GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(property => property.CanRead && property.GetIndexParameters().Length == 0) + .Select(property => (property.Name.ToSnakeCase(), (Func)(instance => property.GetValue(instance)))) + ] + ); + + private Task ForwardAsync(IEvent eventData, CancellationToken cancellationToken) + { + try + { + var payload = new Dictionary(StringComparer.Ordinal); + + foreach (var (key, get) in PayloadAccessors(eventData.GetType())) + { + payload[key] = get(eventData); + } + + _bridge.Publish(DeriveName(eventData.GetType()), payload); + } + catch (Exception ex) + { + Log.ForContext() + .Warning(ex, "Failed to forward event {EventType} to Lua", eventData.GetType().Name); + } + + return Task.CompletedTask; + } + + /// + public void Dispose() + { + _subscription?.Dispose(); + _subscription = null; + } +} diff --git a/src/SquidStd.Scripting.Lua/SquidStd.Scripting.Lua.csproj b/src/SquidStd.Scripting.Lua/SquidStd.Scripting.Lua.csproj index 7150ebf..64eff40 100644 --- a/src/SquidStd.Scripting.Lua/SquidStd.Scripting.Lua.csproj +++ b/src/SquidStd.Scripting.Lua/SquidStd.Scripting.Lua.csproj @@ -21,4 +21,8 @@ + + + + diff --git a/tests/SquidStd.Tests/Scripting/Lua/LuaEventBusForwarderTests.cs b/tests/SquidStd.Tests/Scripting/Lua/LuaEventBusForwarderTests.cs new file mode 100644 index 0000000..9de05f9 --- /dev/null +++ b/tests/SquidStd.Tests/Scripting/Lua/LuaEventBusForwarderTests.cs @@ -0,0 +1,79 @@ +using MoonSharp.Interpreter; +using SquidStd.Core.Data.Bootstrap; +using SquidStd.Core.Data.Events; +using SquidStd.Core.Interfaces.Events; +using SquidStd.Scripting.Lua.Services; +using SquidStd.Services.Core.Services; + +namespace SquidStd.Tests.Scripting.Lua; + +public class LuaEventBusForwarderTests +{ + [Fact] + public async Task EngineStartedEvent_ReachesLuaCallback_WithSnakeCaseNameAndPayload() + { + using var bus = new EventBusService(new EventBusOptions()); + var script = new Script(); + var bridge = new LuaEventBridge(); + bridge.Attach(script); + var forwarder = new LuaEventBusForwarder(bridge, bus); + await forwarder.StartAsync(); + var callback = script.DoString( + """ + return function(e) + captured = e.application .. ':' .. e.service_count .. ':' .. tostring(e.elapsed_ms >= 0) + end + """ + ) + .Function; + bridge.Register("engine_started", callback); + + await bus.PublishAsync(new EngineStartedEvent("MyApp", 5, 12.5)); + + Assert.Equal("MyApp:5:true", script.Globals.Get("captured").String); + + forwarder.Dispose(); + } + + [Fact] + public void EventTypeWithoutEventSuffix_UsesFullSnakeName() + { + Assert.Equal("custom_signal", LuaEventBusForwarder.DeriveName(typeof(CustomSignal))); + Assert.Equal("engine_started", LuaEventBusForwarder.DeriveName(typeof(EngineStartedEvent))); + } + + [Fact] + public async Task Forwarder_WithoutBus_IsNoOp() + { + var bridge = new LuaEventBridge(); + var forwarder = new LuaEventBusForwarder(bridge); + + await forwarder.StartAsync(); + await forwarder.StopAsync(); + forwarder.Dispose(); + } + + [Fact] + public async Task ConcurrentPublishes_AreSerializedIntoLua() + { + using var bus = new EventBusService(new EventBusOptions()); + var script = new Script(); + var bridge = new LuaEventBridge(); + bridge.Attach(script); + var forwarder = new LuaEventBusForwarder(bridge, bus); + await forwarder.StartAsync(); + script.Globals["counter"] = 0; + var callback = script.DoString("return function(e) counter = counter + 1 end").Function; + bridge.Register("engine_stopped", callback); + + var publishes = Enumerable.Range(0, 200) + .Select(_ => Task.Run(() => bus.PublishAsync(new EngineStoppedEvent("app")))); + await Task.WhenAll(publishes); + + Assert.Equal(200, (int)script.Globals.Get("counter").Number); + + forwarder.Dispose(); + } + + private sealed record CustomSignal : IEvent; +} diff --git a/tests/SquidStd.Tests/Scripting/Lua/LuaModuleTests.cs b/tests/SquidStd.Tests/Scripting/Lua/LuaModuleTests.cs index f22dfd0..9b526db 100644 --- a/tests/SquidStd.Tests/Scripting/Lua/LuaModuleTests.cs +++ b/tests/SquidStd.Tests/Scripting/Lua/LuaModuleTests.cs @@ -20,6 +20,20 @@ public void EventsModule_RegistersCallbackWithBridge() Assert.Same(callback, bridge.Callback); } + [Fact] + public void EventsModule_SubscribeIsEquivalentToOn() + { + var script = new Script(); + var bridge = new CapturingLuaEventBridge(); + var callback = script.DoString("return function() end").Function; + var module = new EventsModule(bridge); + + module.Subscribe("engine_started", callback); + + Assert.Equal("engine_started", bridge.EventName); + Assert.Same(callback, bridge.Callback); + } + [Fact] public void RandomModule_ChanceHandlesBoundaries() { From 0f6c12b55af31fa637af6008161d77444f14c097 Mon Sep 17 00:00:00 2001 From: Tom Date: Fri, 3 Jul 2026 15:17:14 +0200 Subject: [PATCH 03/11] feat(templates): host template with config hooks and engine event subscriptions Also demos events.subscribe in the ScriptingLua sample and documents the snake_case event naming convention. --- docs/articles/concepts/bootstrap-lifecycle.md | 3 +++ docs/tutorials/scripting-lua.md | 2 +- .../SquidStd.Samples.ScriptingLua/Program.cs | 12 ++++++++++ src/SquidStd.Scripting.Lua/README.md | 19 +++++++++++++++ templates/squidstd-host/Program.cs | 23 +++++++++++++++++++ 5 files changed, 58 insertions(+), 1 deletion(-) diff --git a/docs/articles/concepts/bootstrap-lifecycle.md b/docs/articles/concepts/bootstrap-lifecycle.md index fdcd558..52a39c4 100644 --- a/docs/articles/concepts/bootstrap-lifecycle.md +++ b/docs/articles/concepts/bootstrap-lifecycle.md @@ -77,6 +77,9 @@ Services implementing `ISquidStdService` participate in the lifecycle. On `Start The bootstrap logs its whole lifecycle: a startup banner with the application name and version (set `SquidStdOptions.AppName`; it defaults to the entry assembly name and is attached to every event as the `Application` / `ApplicationVersion` properties), a registration summary (per-registration detail at Debug), one line per service started with its duration, and the shutdown sequence. A service that fails to stop is logged as a warning and the remaining services are still stopped. Extra Serilog sinks can be plugged by registering `ILogEventSink` instances in the container before start. +When an event bus is registered, the bootstrap publishes `EngineStartingEvent`, `EngineStartedEvent` and `EngineStoppedEvent` on it during the lifecycle. +`EngineStartingEvent` is only visible to subscriptions made before start - auto-registered listeners start during the service loop. + ```mermaid sequenceDiagram participant App diff --git a/docs/tutorials/scripting-lua.md b/docs/tutorials/scripting-lua.md index 8c1f968..b843473 100644 --- a/docs/tutorials/scripting-lua.md +++ b/docs/tutorials/scripting-lua.md @@ -47,7 +47,7 @@ dotnet run --project samples/SquidStd.Samples.ScriptingLua Prints: ``` -lua modules = 1 +lua modules = 3 3 + 4 = 7 result = hello from C# and lua ``` diff --git a/samples/SquidStd.Samples.ScriptingLua/Program.cs b/samples/SquidStd.Samples.ScriptingLua/Program.cs index 0abed72..64910b7 100644 --- a/samples/SquidStd.Samples.ScriptingLua/Program.cs +++ b/samples/SquidStd.Samples.ScriptingLua/Program.cs @@ -4,7 +4,9 @@ using SquidStd.Scripting.Lua.Attributes; using SquidStd.Scripting.Lua.Attributes.Scripts; using SquidStd.Scripting.Lua.Data.Config; +using SquidStd.Scripting.Lua.Extensions.Scripts; using SquidStd.Scripting.Lua.Interfaces.Scripts; +using SquidStd.Scripting.Lua.Modules; using SquidStd.Scripting.Lua.Services; using SquidStd.Core.Data.Bootstrap; using SquidStd.Services.Core.Extensions; @@ -38,6 +40,8 @@ container.RegisterInstance(engineConfig); container.RegisterStdService(); container.RegisterGeneratedScriptModules(); + container.RegisterScriptModule(); + container.RegisterLuaEvents(); return container; } @@ -64,6 +68,14 @@ #endregion +engine.ExecuteScript( + """ + events.subscribe("engine_stopped", function(e) + log.info("lua saw engine_stopped for " .. e.application) + end) + """ +); + await bootstrap.StopAsync(); #region step-3 diff --git a/src/SquidStd.Scripting.Lua/README.md b/src/SquidStd.Scripting.Lua/README.md index db10684..6824e14 100644 --- a/src/SquidStd.Scripting.Lua/README.md +++ b/src/SquidStd.Scripting.Lua/README.md @@ -33,6 +33,25 @@ container.RegisterGeneratedScriptModules(); // Resolve IScriptEngineService to load and execute scripts that call math2.add(1, 2). ``` +## Subscribing to server events + +Register the Lua events stack, then subscribe from any script by snake_case event name - the +CLR type name minus the `Event` suffix (`EngineStartedEvent` becomes `engine_started`). Payload +keys are snake_case too. The convention applies to every event published on the event bus. + +```csharp +container.RegisterLuaEvents(); // bridge + events module + bus forwarder +``` + +```lua +events.subscribe("engine_started", function(e) + log.info("engine ready: " .. e.application .. " (" .. e.service_count .. " services)") +end) +``` + +The forwarder is a no-op when no event bus is registered. `events.on` remains available as an +alias of `subscribe`. + ## Key types | Type | Purpose | diff --git a/templates/squidstd-host/Program.cs b/templates/squidstd-host/Program.cs index 90b7669..5c6cfac 100644 --- a/templates/squidstd-host/Program.cs +++ b/templates/squidstd-host/Program.cs @@ -1,3 +1,7 @@ +using DryIoc; +using SquidStd.Core.Data.Bootstrap; +using SquidStd.Core.Interfaces.Events; +using SquidStd.Core.Types; using SquidStd.Services.Core.Extensions; using SquidStd.Services.Core.Services.Bootstrap; @@ -8,4 +12,23 @@ bootstrap.ConfigureServices(container => container.RegisterCoreServices()); +// Tweak a loaded config section before services start (in-memory only). +bootstrap.OnConfigLoaded(o => o.MinimumLevel = LogLevelType.Information); + +// Inspect the final configuration once every hook has been applied. +bootstrap.OnConfigReady(cfg => Console.WriteLine(cfg.Compose())); + +// React to the engine lifecycle events. +var bus = bootstrap.Container.Resolve(); +bus.Subscribe((e, _) => +{ + Console.WriteLine($"{e.Application} ready with {e.ServiceCount} service(s)"); + return Task.CompletedTask; +}); +bus.Subscribe((e, _) => +{ + Console.WriteLine($"{e.Application} stopped"); + return Task.CompletedTask; +}); + await bootstrap.RunAsync(); From bc0f5475f7fe0d23a8704db1c78efc93c1ba8b11 Mon Sep 17 00:00:00 2001 From: Tom Date: Fri, 3 Jul 2026 15:57:22 +0200 Subject: [PATCH 04/11] feat(consolecommands): new package with command registry, dispatch and quoted tokenizer --- SquidStd.slnx | 1 + .../RegisterConsoleCommandAttribute.cs | 23 +++ .../Data/Config/ConsoleCommandsConfig.cs | 16 ++ .../Data/ConsoleCommandContext.cs | 32 ++++ .../Data/ConsoleCommandDefinition.cs | 9 + .../Interfaces/ICommandSystemService.cs | 36 ++++ .../Interfaces/IConsoleCommandExecutor.cs | 15 ++ .../Interfaces/IConsoleUiService.cs | 30 ++++ .../Internal/CommandLineTokenizer.cs | 54 ++++++ .../Services/CommandSystemService.cs | 160 ++++++++++++++++++ .../SquidStd.ConsoleCommands.csproj | 25 +++ .../CommandLineTokenizerTests.cs | 20 +++ .../CommandSystemServiceTests.cs | 142 ++++++++++++++++ tests/SquidStd.Tests/SquidStd.Tests.csproj | 1 + 14 files changed, 564 insertions(+) create mode 100644 src/SquidStd.ConsoleCommands/Attributes/RegisterConsoleCommandAttribute.cs create mode 100644 src/SquidStd.ConsoleCommands/Data/Config/ConsoleCommandsConfig.cs create mode 100644 src/SquidStd.ConsoleCommands/Data/ConsoleCommandContext.cs create mode 100644 src/SquidStd.ConsoleCommands/Data/ConsoleCommandDefinition.cs create mode 100644 src/SquidStd.ConsoleCommands/Interfaces/ICommandSystemService.cs create mode 100644 src/SquidStd.ConsoleCommands/Interfaces/IConsoleCommandExecutor.cs create mode 100644 src/SquidStd.ConsoleCommands/Interfaces/IConsoleUiService.cs create mode 100644 src/SquidStd.ConsoleCommands/Internal/CommandLineTokenizer.cs create mode 100644 src/SquidStd.ConsoleCommands/Services/CommandSystemService.cs create mode 100644 src/SquidStd.ConsoleCommands/SquidStd.ConsoleCommands.csproj create mode 100644 tests/SquidStd.Tests/ConsoleCommands/CommandLineTokenizerTests.cs create mode 100644 tests/SquidStd.Tests/ConsoleCommands/CommandSystemServiceTests.cs diff --git a/SquidStd.slnx b/SquidStd.slnx index 1905f0d..c6f55ed 100644 --- a/SquidStd.slnx +++ b/SquidStd.slnx @@ -10,6 +10,7 @@ + diff --git a/src/SquidStd.ConsoleCommands/Attributes/RegisterConsoleCommandAttribute.cs b/src/SquidStd.ConsoleCommands/Attributes/RegisterConsoleCommandAttribute.cs new file mode 100644 index 0000000..04a3f3f --- /dev/null +++ b/src/SquidStd.ConsoleCommands/Attributes/RegisterConsoleCommandAttribute.cs @@ -0,0 +1,23 @@ +namespace SquidStd.ConsoleCommands.Attributes; + +/// +/// Marks an class for source-generated registration. +/// +[AttributeUsage(AttributeTargets.Class)] +public sealed class RegisterConsoleCommandAttribute : Attribute +{ + /// Primary command name or aliases separated by |. + public string CommandName { get; } + + /// Help description. + public string Description { get; } + + /// Initializes the attribute. + /// Primary command name or aliases separated by |. + /// Help description. + public RegisterConsoleCommandAttribute(string commandName, string description = "") + { + CommandName = commandName; + Description = description; + } +} diff --git a/src/SquidStd.ConsoleCommands/Data/Config/ConsoleCommandsConfig.cs b/src/SquidStd.ConsoleCommands/Data/Config/ConsoleCommandsConfig.cs new file mode 100644 index 0000000..c92b0b9 --- /dev/null +++ b/src/SquidStd.ConsoleCommands/Data/Config/ConsoleCommandsConfig.cs @@ -0,0 +1,16 @@ +namespace SquidStd.ConsoleCommands.Data.Config; + +/// +/// Configuration for the interactive console command prompt. +/// +public sealed class ConsoleCommandsConfig +{ + /// Prompt prefix shown on the input row. + public string Prompt { get; set; } = "squid> "; + + /// Whether the prompt starts locked. + public bool StartLocked { get; set; } = true; + + /// Character that unlocks the prompt when locked. + public char UnlockCharacter { get; set; } = '*'; +} diff --git a/src/SquidStd.ConsoleCommands/Data/ConsoleCommandContext.cs b/src/SquidStd.ConsoleCommands/Data/ConsoleCommandContext.cs new file mode 100644 index 0000000..5d2471a --- /dev/null +++ b/src/SquidStd.ConsoleCommands/Data/ConsoleCommandContext.cs @@ -0,0 +1,32 @@ +namespace SquidStd.ConsoleCommands.Data; + +/// +/// Execution context handed to a console command handler. +/// +public sealed class ConsoleCommandContext +{ + private readonly Action _writeLine; + + /// The raw command line as typed. + public string RawText { get; } + + /// Parsed arguments (command name excluded). + public IReadOnlyList Arguments { get; } + + /// Initializes the context. + /// Raw command line. + /// Parsed arguments. + /// Writer for command output lines. + public ConsoleCommandContext(string rawText, IReadOnlyList arguments, Action writeLine) + { + ArgumentNullException.ThrowIfNull(writeLine); + + RawText = rawText; + Arguments = arguments; + _writeLine = writeLine; + } + + /// Writes one output line. + public void WriteLine(string line) + => _writeLine(line); +} diff --git a/src/SquidStd.ConsoleCommands/Data/ConsoleCommandDefinition.cs b/src/SquidStd.ConsoleCommands/Data/ConsoleCommandDefinition.cs new file mode 100644 index 0000000..0d3d129 --- /dev/null +++ b/src/SquidStd.ConsoleCommands/Data/ConsoleCommandDefinition.cs @@ -0,0 +1,9 @@ +namespace SquidStd.ConsoleCommands.Data; + +/// +/// Describes a registered console command. +/// +/// Primary command name. +/// Additional aliases. +/// Help description. +public sealed record ConsoleCommandDefinition(string Name, IReadOnlyList Aliases, string Description); diff --git a/src/SquidStd.ConsoleCommands/Interfaces/ICommandSystemService.cs b/src/SquidStd.ConsoleCommands/Interfaces/ICommandSystemService.cs new file mode 100644 index 0000000..f4e3c80 --- /dev/null +++ b/src/SquidStd.ConsoleCommands/Interfaces/ICommandSystemService.cs @@ -0,0 +1,36 @@ +using SquidStd.ConsoleCommands.Data; + +namespace SquidStd.ConsoleCommands.Interfaces; + +/// +/// Registers and dispatches interactive console commands. +/// +public interface ICommandSystemService +{ + /// Registers one command or multiple aliases separated by |. + /// Primary name or alias list, e.g. "gc|collect". + /// Handler invoked with the parsed context. + /// Help description. + /// Optional argument suggestions for the current line. + void RegisterCommand( + string commandName, + Func handler, + string description = "", + Func>? autocompleteProvider = null + ); + + /// Executes a raw command line; failures are reported, never thrown. + Task ExecuteCommandAsync(string commandWithArgs, CancellationToken cancellationToken = default); + + /// Executes a raw command line collecting the lines it writes. + Task> ExecuteCommandWithOutputAsync( + string commandWithArgs, + CancellationToken cancellationToken = default + ); + + /// Gets suggestions for the current input line. + IReadOnlyList GetAutocompleteSuggestions(string commandWithArgs); + + /// Gets the registered command definitions. + IReadOnlyList GetRegisteredCommands(); +} diff --git a/src/SquidStd.ConsoleCommands/Interfaces/IConsoleCommandExecutor.cs b/src/SquidStd.ConsoleCommands/Interfaces/IConsoleCommandExecutor.cs new file mode 100644 index 0000000..6d4f21c --- /dev/null +++ b/src/SquidStd.ConsoleCommands/Interfaces/IConsoleCommandExecutor.cs @@ -0,0 +1,15 @@ +using SquidStd.ConsoleCommands.Data; + +namespace SquidStd.ConsoleCommands.Interfaces; + +/// +/// Contract for attribute-registered console command classes. +/// +public interface IConsoleCommandExecutor +{ + /// Help description of the command. + string Description { get; } + + /// Executes the command. + Task ExecuteAsync(ConsoleCommandContext context); +} diff --git a/src/SquidStd.ConsoleCommands/Interfaces/IConsoleUiService.cs b/src/SquidStd.ConsoleCommands/Interfaces/IConsoleUiService.cs new file mode 100644 index 0000000..661828e --- /dev/null +++ b/src/SquidStd.ConsoleCommands/Interfaces/IConsoleUiService.cs @@ -0,0 +1,30 @@ +using Serilog.Events; + +namespace SquidStd.ConsoleCommands.Interfaces; + +/// +/// Renders the command prompt and coordinates command output and log lines on the terminal. +/// +public interface IConsoleUiService +{ + /// Whether the console is an interactive terminal. + bool IsInteractive { get; } + + /// Whether the prompt input is currently locked. + bool IsInputLocked { get; } + + /// Locks the prompt input. + void LockInput(); + + /// Unlocks the prompt input. + void UnlockInput(); + + /// Updates the echoed input line. + void UpdateInput(string input); + + /// Writes a command output line above the prompt. + void WriteLine(string line); + + /// Writes a formatted log line above the prompt. + void WriteLogLine(string line, LogEventLevel level); +} diff --git a/src/SquidStd.ConsoleCommands/Internal/CommandLineTokenizer.cs b/src/SquidStd.ConsoleCommands/Internal/CommandLineTokenizer.cs new file mode 100644 index 0000000..49cc3ad --- /dev/null +++ b/src/SquidStd.ConsoleCommands/Internal/CommandLineTokenizer.cs @@ -0,0 +1,54 @@ +namespace SquidStd.ConsoleCommands.Internal; + +/// +/// Splits a raw command line into tokens, honoring double-quoted segments. +/// +internal static class CommandLineTokenizer +{ + internal static IReadOnlyList Tokenize(string input) + { + var tokens = new List(); + + if (string.IsNullOrWhiteSpace(input)) + { + return tokens; + } + + var current = new System.Text.StringBuilder(); + var inQuotes = false; + var hasToken = false; + + foreach (var character in input) + { + if (character == '"') + { + inQuotes = !inQuotes; + hasToken = true; + + continue; + } + + if (char.IsWhiteSpace(character) && !inQuotes) + { + if (hasToken) + { + tokens.Add(current.ToString()); + current.Clear(); + hasToken = false; + } + + continue; + } + + current.Append(character); + hasToken = true; + } + + if (hasToken) + { + tokens.Add(current.ToString()); + } + + return tokens; + } +} diff --git a/src/SquidStd.ConsoleCommands/Services/CommandSystemService.cs b/src/SquidStd.ConsoleCommands/Services/CommandSystemService.cs new file mode 100644 index 0000000..c152ede --- /dev/null +++ b/src/SquidStd.ConsoleCommands/Services/CommandSystemService.cs @@ -0,0 +1,160 @@ +using System.Collections.Concurrent; +using Serilog; +using SquidStd.ConsoleCommands.Data; +using SquidStd.ConsoleCommands.Interfaces; +using SquidStd.ConsoleCommands.Internal; + +namespace SquidStd.ConsoleCommands.Services; + +/// +/// Default in-memory implementation of . +/// +public sealed class CommandSystemService : ICommandSystemService +{ + private readonly ILogger _logger = Log.ForContext(); + + private readonly ConcurrentDictionary _commands = + new(StringComparer.OrdinalIgnoreCase); + + private readonly Action _writeLine; + + /// Initializes the command system. + /// Writer used for command output and dispatch messages. + public CommandSystemService(Action writeLine) + { + ArgumentNullException.ThrowIfNull(writeLine); + + _writeLine = writeLine; + } + + /// + public void RegisterCommand( + string commandName, + Func handler, + string description = "", + Func>? autocompleteProvider = null + ) + { + ArgumentException.ThrowIfNullOrWhiteSpace(commandName); + ArgumentNullException.ThrowIfNull(handler); + + var aliases = commandName.Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + var entry = new CommandEntry( + aliases[0], + aliases.Skip(1).ToArray(), + description, + handler, + autocompleteProvider + ); + + foreach (var alias in aliases) + { + if (!_commands.TryAdd(alias, entry)) + { + _logger.Warning("Console command '{Command}' redefined", alias); + _commands[alias] = entry; + } + } + } + + /// + public Task ExecuteCommandAsync(string commandWithArgs, CancellationToken cancellationToken = default) + => ExecuteCoreAsync(commandWithArgs, _writeLine, cancellationToken); + + /// + public async Task> ExecuteCommandWithOutputAsync( + string commandWithArgs, + CancellationToken cancellationToken = default + ) + { + var lines = new List(); + + await ExecuteCoreAsync(commandWithArgs, lines.Add, cancellationToken); + + return lines; + } + + /// + public IReadOnlyList GetAutocompleteSuggestions(string commandWithArgs) + { + if (string.IsNullOrWhiteSpace(commandWithArgs)) + { + return []; + } + + if (!commandWithArgs.Contains(' ')) + { + return _commands.Keys + .Where(name => name.StartsWith(commandWithArgs, StringComparison.OrdinalIgnoreCase)) + .OrderBy(name => name, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + var tokens = CommandLineTokenizer.Tokenize(commandWithArgs); + + if (tokens.Count == 0 || !_commands.TryGetValue(tokens[0], out var entry)) + { + return []; + } + + return entry.AutocompleteProvider?.Invoke(commandWithArgs) ?? []; + } + + /// + public IReadOnlyList GetRegisteredCommands() + => _commands.Values + .Distinct() + .Select(entry => new ConsoleCommandDefinition(entry.Name, entry.Aliases, entry.Description)) + .OrderBy(definition => definition.Name, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + private async Task ExecuteCoreAsync( + string commandWithArgs, + Action writeLine, + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + var tokens = CommandLineTokenizer.Tokenize(commandWithArgs); + + if (tokens.Count == 0) + { + return; + } + + var name = tokens[0]; + + if (!_commands.TryGetValue(name, out var entry)) + { + writeLine($"Unknown command '{name}'. Type 'help' for the command list."); + + return; + } + + var context = new ConsoleCommandContext(commandWithArgs, tokens.Skip(1).ToArray(), writeLine); + + try + { + await entry.Handler(context); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger.Error(ex, "Console command '{Command}' failed", name); + writeLine($"Command '{name}' failed: {ex.Message}"); + } + } + + private sealed record CommandEntry( + string Name, + IReadOnlyList Aliases, + string Description, + Func Handler, + Func>? AutocompleteProvider + ); +} diff --git a/src/SquidStd.ConsoleCommands/SquidStd.ConsoleCommands.csproj b/src/SquidStd.ConsoleCommands/SquidStd.ConsoleCommands.csproj new file mode 100644 index 0000000..2542293 --- /dev/null +++ b/src/SquidStd.ConsoleCommands/SquidStd.ConsoleCommands.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + true + + + + + + + + + + + + + + + + + + diff --git a/tests/SquidStd.Tests/ConsoleCommands/CommandLineTokenizerTests.cs b/tests/SquidStd.Tests/ConsoleCommands/CommandLineTokenizerTests.cs new file mode 100644 index 0000000..f4aaa92 --- /dev/null +++ b/tests/SquidStd.Tests/ConsoleCommands/CommandLineTokenizerTests.cs @@ -0,0 +1,20 @@ +using SquidStd.ConsoleCommands.Internal; + +namespace SquidStd.Tests.ConsoleCommands; + +public class CommandLineTokenizerTests +{ + [Theory] + [InlineData("gc", new[] { "gc" })] + [InlineData("deploy fast now", new[] { "deploy", "fast", "now" })] + [InlineData("say \"hello world\" loud", new[] { "say", "hello world", "loud" })] + [InlineData("say \"unterminated", new[] { "say", "unterminated" })] + [InlineData(" spaced out ", new[] { "spaced", "out" })] + [InlineData("empty \"\" arg", new[] { "empty", "", "arg" })] + public void Tokenize_SplitsRespectingQuotes(string input, string[] expected) + => Assert.Equal(expected, CommandLineTokenizer.Tokenize(input)); + + [Fact] + public void Tokenize_EmptyInput_ReturnsEmpty() + => Assert.Empty(CommandLineTokenizer.Tokenize(" ")); +} diff --git a/tests/SquidStd.Tests/ConsoleCommands/CommandSystemServiceTests.cs b/tests/SquidStd.Tests/ConsoleCommands/CommandSystemServiceTests.cs new file mode 100644 index 0000000..deb2407 --- /dev/null +++ b/tests/SquidStd.Tests/ConsoleCommands/CommandSystemServiceTests.cs @@ -0,0 +1,142 @@ +using SquidStd.ConsoleCommands.Services; + +namespace SquidStd.Tests.ConsoleCommands; + +public class CommandSystemServiceTests +{ + [Fact] + public async Task RegisterAndExecute_InvokesHandlerWithArguments() + { + var uiLines = new List(); + var service = new CommandSystemService(uiLines.Add); + IReadOnlyList? capturedArguments = null; + + service.RegisterCommand( + "greet", + ctx => + { + capturedArguments = ctx.Arguments; + ctx.WriteLine("hi " + ctx.Arguments[0]); + + return Task.CompletedTask; + } + ); + + await service.ExecuteCommandAsync("greet \"squid dev\""); + + Assert.Equal(new[] { "squid dev" }, capturedArguments); + Assert.Contains(uiLines, line => line.Contains("hi squid dev")); + } + + [Fact] + public async Task Aliases_ShareTheSameHandler() + { + var uiLines = new List(); + var service = new CommandSystemService(uiLines.Add); + var counter = 0; + + service.RegisterCommand( + "gc|collect", + _ => + { + counter++; + + return Task.CompletedTask; + }, + "run GC" + ); + + await service.ExecuteCommandAsync("gc"); + await service.ExecuteCommandAsync("COLLECT"); + + Assert.Equal(2, counter); + + var definitions = service.GetRegisteredCommands(); + + var definition = Assert.Single(definitions); + Assert.Equal("gc", definition.Name); + Assert.Contains("collect", definition.Aliases); + } + + [Fact] + public async Task UnknownCommand_WritesMessage_DoesNotThrow() + { + var uiLines = new List(); + var service = new CommandSystemService(uiLines.Add); + + await service.ExecuteCommandAsync("nope"); + + Assert.Contains(uiLines, line => line.Contains("Unknown command") && line.Contains("nope")); + } + + [Fact] + public async Task HandlerException_IsCaught_AndReported() + { + var uiLines = new List(); + var service = new CommandSystemService(uiLines.Add); + + service.RegisterCommand("boom", _ => throw new InvalidOperationException("kaboom")); + + await service.ExecuteCommandAsync("boom"); + + Assert.Contains(uiLines, line => line.Contains("kaboom") || line.Contains("failed")); + } + + [Fact] + public async Task ExecuteWithOutput_CapturesLines_WithoutWritingToUi() + { + var uiLines = new List(); + var service = new CommandSystemService(uiLines.Add); + + service.RegisterCommand( + "dump", + ctx => + { + ctx.WriteLine("a"); + ctx.WriteLine("b"); + + return Task.CompletedTask; + } + ); + + var output = await service.ExecuteCommandWithOutputAsync("dump"); + + Assert.Equal(new[] { "a", "b" }, output); + Assert.DoesNotContain(uiLines, line => line.Contains('a')); + } + + [Fact] + public void Autocomplete_CompletesCommandPrefix_AndUsesProviderForArgs() + { + var service = new CommandSystemService(_ => { }); + + service.RegisterCommand("status", _ => Task.CompletedTask); + service.RegisterCommand("stop", _ => Task.CompletedTask); + service.RegisterCommand( + "deploy", + _ => Task.CompletedTask, + autocompleteProvider: line => new[] { "deploy fast", "deploy slow" } + ); + + var commandSuggestions = service.GetAutocompleteSuggestions("st"); + + Assert.Contains("status", commandSuggestions); + Assert.Contains("stop", commandSuggestions); + + var argumentSuggestions = service.GetAutocompleteSuggestions("deploy f"); + + Assert.Contains("deploy fast", argumentSuggestions); + } + + [Fact] + public void RegisterSameName_ReplacesAndWarns() + { + var service = new CommandSystemService(_ => { }); + + service.RegisterCommand("x", _ => Task.CompletedTask, "first"); + service.RegisterCommand("x", _ => Task.CompletedTask, "second"); + + var definition = Assert.Single(service.GetRegisteredCommands()); + Assert.Equal("second", definition.Description); + } +} diff --git a/tests/SquidStd.Tests/SquidStd.Tests.csproj b/tests/SquidStd.Tests/SquidStd.Tests.csproj index 4c955dd..e926d6f 100644 --- a/tests/SquidStd.Tests/SquidStd.Tests.csproj +++ b/tests/SquidStd.Tests/SquidStd.Tests.csproj @@ -77,6 +77,7 @@ + From 26ce57365a09c88cfdeabf389d02521051a330f1 Mon Sep 17 00:00:00 2001 From: Tom Date: Fri, 3 Jul 2026 16:02:07 +0200 Subject: [PATCH 05/11] feat(consolecommands): builtin help/clear/exit, prompt log sink and AddConsoleCommands --- .../ConsoleCommandsRegistrationExtensions.cs | 62 ++++++++++ .../Internal/BuiltinConsoleCommands.cs | 60 ++++++++++ .../Internal/Logging/ConsolePromptLogSink.cs | 42 +++++++ .../Services/ConsoleUiService.cs | 51 +++++++++ .../ConsoleCommands/BuiltinCommandsTests.cs | 108 ++++++++++++++++++ .../ConsoleCommandsRegistrationTests.cs | 43 +++++++ 6 files changed, 366 insertions(+) create mode 100644 src/SquidStd.ConsoleCommands/Extensions/ConsoleCommandsRegistrationExtensions.cs create mode 100644 src/SquidStd.ConsoleCommands/Internal/BuiltinConsoleCommands.cs create mode 100644 src/SquidStd.ConsoleCommands/Internal/Logging/ConsolePromptLogSink.cs create mode 100644 src/SquidStd.ConsoleCommands/Services/ConsoleUiService.cs create mode 100644 tests/SquidStd.Tests/ConsoleCommands/BuiltinCommandsTests.cs create mode 100644 tests/SquidStd.Tests/ConsoleCommands/ConsoleCommandsRegistrationTests.cs diff --git a/src/SquidStd.ConsoleCommands/Extensions/ConsoleCommandsRegistrationExtensions.cs b/src/SquidStd.ConsoleCommands/Extensions/ConsoleCommandsRegistrationExtensions.cs new file mode 100644 index 0000000..b457e7b --- /dev/null +++ b/src/SquidStd.ConsoleCommands/Extensions/ConsoleCommandsRegistrationExtensions.cs @@ -0,0 +1,62 @@ +using DryIoc; +using Serilog.Core; +using SquidStd.Abstractions.Extensions.Config; +using SquidStd.ConsoleCommands.Data.Config; +using SquidStd.ConsoleCommands.Interfaces; +using SquidStd.ConsoleCommands.Internal; +using SquidStd.ConsoleCommands.Internal.Logging; +using SquidStd.ConsoleCommands.Services; +using SquidStd.Core.Interfaces.Bootstrap; + +namespace SquidStd.ConsoleCommands.Extensions; + +/// +/// Registration helpers for the interactive console command stack. +/// +public static class ConsoleCommandsRegistrationExtensions +{ + /// Container that receives the registrations. + extension(IContainer container) + { + /// + /// Registers the console command stack: the prompt UI, the command system with the + /// built-in help/clear/exit commands, and the Serilog sink that renders log lines above + /// the prompt. Set logger.EnableConsole: false so the sink replaces the standard + /// console sink. + /// + /// The same container for chaining. + public IContainer AddConsoleCommands() + { + container.RegisterConfigSection("consoleCommands", static () => new ConsoleCommandsConfig(), -50); + container.Register(Reuse.Singleton); + container.RegisterDelegate( + resolver => + { + var ui = resolver.Resolve(); + var system = new CommandSystemService(ui.WriteLine); + var bootstrap = resolver.Resolve(IfUnresolved.ReturnDefault); + BuiltinConsoleCommands.Register( + system, + bootstrap, + static () => + { + if (!Console.IsOutputRedirected) + { + Console.Clear(); + } + } + ); + + return system; + }, + Reuse.Singleton + ); + container.RegisterDelegate( + resolver => new ConsolePromptLogSink(resolver.Resolve()), + Reuse.Singleton + ); + + return container; + } + } +} diff --git a/src/SquidStd.ConsoleCommands/Internal/BuiltinConsoleCommands.cs b/src/SquidStd.ConsoleCommands/Internal/BuiltinConsoleCommands.cs new file mode 100644 index 0000000..8ee11de --- /dev/null +++ b/src/SquidStd.ConsoleCommands/Internal/BuiltinConsoleCommands.cs @@ -0,0 +1,60 @@ +using SquidStd.ConsoleCommands.Interfaces; +using SquidStd.Core.Interfaces.Bootstrap; + +namespace SquidStd.ConsoleCommands.Internal; + +/// +/// Registers the built-in console commands (help, clear, exit). +/// +internal static class BuiltinConsoleCommands +{ + internal static void Register(ICommandSystemService system, ISquidStdBootstrap? bootstrap, Action clearScreen) + { + system.RegisterCommand( + "help|?", + context => + { + foreach (var definition in system.GetRegisteredCommands()) + { + var aliases = definition.Aliases.Count > 0 + ? $" ({string.Join(", ", definition.Aliases)})" + : string.Empty; + context.WriteLine($"{definition.Name}{aliases} - {definition.Description}"); + } + + return Task.CompletedTask; + }, + "Lists the registered commands." + ); + + system.RegisterCommand( + "clear|cls", + _ => + { + clearScreen(); + + return Task.CompletedTask; + }, + "Clears the console." + ); + + system.RegisterCommand( + "exit|quit", + context => + { + if (bootstrap is null) + { + context.WriteLine("Shutdown is not available: no bootstrap registered."); + + return Task.CompletedTask; + } + + context.WriteLine("Shutting down..."); + _ = Task.Run(() => bootstrap.StopAsync().AsTask()); + + return Task.CompletedTask; + }, + "Stops the host." + ); + } +} diff --git a/src/SquidStd.ConsoleCommands/Internal/Logging/ConsolePromptLogSink.cs b/src/SquidStd.ConsoleCommands/Internal/Logging/ConsolePromptLogSink.cs new file mode 100644 index 0000000..2d48ddd --- /dev/null +++ b/src/SquidStd.ConsoleCommands/Internal/Logging/ConsolePromptLogSink.cs @@ -0,0 +1,42 @@ +using Serilog.Core; +using Serilog.Events; +using SquidStd.ConsoleCommands.Interfaces; + +namespace SquidStd.ConsoleCommands.Internal.Logging; + +/// +/// Serilog sink that routes formatted log lines through the console UI, keeping the prompt intact. +/// +internal sealed class ConsolePromptLogSink : ILogEventSink +{ + private readonly IConsoleUiService _ui; + + public ConsolePromptLogSink(IConsoleUiService ui) + { + _ui = ui; + } + + public void Emit(LogEvent logEvent) + { + var message = logEvent.RenderMessage(); + var line = $"[{logEvent.Timestamp:HH:mm:ss} {ShortLevel(logEvent.Level)}] {message}"; + + if (logEvent.Exception is not null) + { + line += Environment.NewLine + logEvent.Exception; + } + + _ui.WriteLogLine(line, logEvent.Level); + } + + private static string ShortLevel(LogEventLevel level) + => level switch + { + LogEventLevel.Verbose => "VRB", + LogEventLevel.Debug => "DBG", + LogEventLevel.Information => "INF", + LogEventLevel.Warning => "WRN", + LogEventLevel.Error => "ERR", + _ => "FTL" + }; +} diff --git a/src/SquidStd.ConsoleCommands/Services/ConsoleUiService.cs b/src/SquidStd.ConsoleCommands/Services/ConsoleUiService.cs new file mode 100644 index 0000000..13838bd --- /dev/null +++ b/src/SquidStd.ConsoleCommands/Services/ConsoleUiService.cs @@ -0,0 +1,51 @@ +using Serilog.Events; +using SquidStd.ConsoleCommands.Data.Config; +using SquidStd.ConsoleCommands.Interfaces; + +namespace SquidStd.ConsoleCommands.Services; + +/// +/// Console UI for the command prompt. In non-interactive environments (redirected streams) +/// every write is a plain and input state is inert. +/// +public sealed class ConsoleUiService : IConsoleUiService +{ + private readonly ConsoleCommandsConfig _config; + + /// + public bool IsInteractive { get; } + + /// + public bool IsInputLocked { get; private set; } + + /// Initializes the console UI from the prompt configuration. + /// Prompt configuration section. + public ConsoleUiService(ConsoleCommandsConfig config) + { + _config = config; + IsInteractive = !Console.IsInputRedirected && !Console.IsOutputRedirected; + IsInputLocked = config.StartLocked; + } + + /// + public void LockInput() + => IsInputLocked = true; + + /// + public void UnlockInput() + => IsInputLocked = false; + + /// + public void UpdateInput(string input) + { + // Non-interactive: no prompt row to render. The interactive branch lands with the input loop. + } + + /// + public void WriteLine(string line) + => Console.WriteLine(line); + + /// + public void WriteLogLine(string line, LogEventLevel level) + => Console.WriteLine(line); +} diff --git a/tests/SquidStd.Tests/ConsoleCommands/BuiltinCommandsTests.cs b/tests/SquidStd.Tests/ConsoleCommands/BuiltinCommandsTests.cs new file mode 100644 index 0000000..b17127f --- /dev/null +++ b/tests/SquidStd.Tests/ConsoleCommands/BuiltinCommandsTests.cs @@ -0,0 +1,108 @@ +using DryIoc; +using SquidStd.ConsoleCommands.Internal; +using SquidStd.ConsoleCommands.Services; +using SquidStd.Core.Data.Bootstrap; +using SquidStd.Core.Interfaces.Bootstrap; +using SquidStd.Core.Interfaces.Config; + +namespace SquidStd.Tests.ConsoleCommands; + +public class BuiltinCommandsTests +{ + [Fact] + public async Task Help_ListsRegisteredCommandsWithDescriptions() + { + var lines = new List(); + var system = new CommandSystemService(lines.Add); + BuiltinConsoleCommands.Register(system, bootstrap: null, clearScreen: () => { }); + system.RegisterCommand("custom", _ => Task.CompletedTask, "does things"); + + var output = await system.ExecuteCommandWithOutputAsync("help"); + + Assert.Contains(output, line => line.Contains("custom", StringComparison.Ordinal) + && line.Contains("does things", StringComparison.Ordinal)); + Assert.Contains(output, line => line.Contains("help", StringComparison.Ordinal)); + Assert.Contains(output, line => line.Contains("exit", StringComparison.Ordinal)); + } + + [Fact] + public async Task Exit_WithoutBootstrap_ReportsUnavailable() + { + var system = new CommandSystemService(_ => { }); + BuiltinConsoleCommands.Register(system, bootstrap: null, clearScreen: () => { }); + + var output = await system.ExecuteCommandWithOutputAsync("exit"); + + Assert.Contains(output, line => line.Contains("not available", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task Exit_WithBootstrap_RequestsStop() + { + var system = new CommandSystemService(_ => { }); + var stopSignal = new TaskCompletionSource(); + BuiltinConsoleCommands.Register( + system, + bootstrap: new StopSpyBootstrap(() => stopSignal.TrySetResult()), + clearScreen: () => { } + ); + + await system.ExecuteCommandAsync("exit"); + var stopped = await Task.WhenAny(stopSignal.Task, Task.Delay(2000)) == stopSignal.Task; + + Assert.True(stopped); + } + + private sealed class StopSpyBootstrap : ISquidStdBootstrap + { + private readonly Action _onStop; + + public SquidStdOptions Options { get; } = new(); + + public IContainer Container { get; } = new Container(); + + public StopSpyBootstrap(Action onStop) + { + _onStop = onStop; + } + + public ISquidStdBootstrap ConfigureService(Func configure) + => this; + + public ISquidStdBootstrap ConfigureServices(Func configure) + => this; + + public ISquidStdBootstrap OnConfigLoaded(Action configure) where TConfig : class + => this; + + public ISquidStdBootstrap OnConfigReady(Action ready) + => this; + + public TService Resolve() + => Container.Resolve(); + + public void ConfigureLogging() + { + } + + public Task RunAsync(CancellationToken cancellationToken = default) + => Task.CompletedTask; + + public ValueTask StartAsync(CancellationToken cancellationToken = default) + => ValueTask.CompletedTask; + + public ValueTask StopAsync(CancellationToken cancellationToken = default) + { + _onStop(); + + return ValueTask.CompletedTask; + } + + public ValueTask DisposeAsync() + { + Container.Dispose(); + + return ValueTask.CompletedTask; + } + } +} diff --git a/tests/SquidStd.Tests/ConsoleCommands/ConsoleCommandsRegistrationTests.cs b/tests/SquidStd.Tests/ConsoleCommands/ConsoleCommandsRegistrationTests.cs new file mode 100644 index 0000000..0e436d9 --- /dev/null +++ b/tests/SquidStd.Tests/ConsoleCommands/ConsoleCommandsRegistrationTests.cs @@ -0,0 +1,43 @@ +using DryIoc; +using Serilog.Core; +using SquidStd.ConsoleCommands.Extensions; +using SquidStd.ConsoleCommands.Interfaces; +using SquidStd.Core.Interfaces.Config; +using SquidStd.Services.Core.Extensions; +using SquidStd.Tests.Support; + +namespace SquidStd.Tests.ConsoleCommands; + +public class ConsoleCommandsRegistrationTests +{ + [Fact] + public void AddConsoleCommands_RegistersTheStack() + { + using var temp = new TempDirectory(); + using var container = new Container(); + container.RegisterConfigServices("app", temp.Path); + + container.AddConsoleCommands(); + container.Resolve().Load(); + + Assert.True(container.IsRegistered()); + Assert.True(container.IsRegistered()); + Assert.True(container.IsRegistered()); + Assert.NotNull(container.Resolve()); + } + + [Fact] + public async Task BuiltinsAreRegistered() + { + using var temp = new TempDirectory(); + using var container = new Container(); + container.RegisterConfigServices("app", temp.Path); + container.AddConsoleCommands(); + container.Resolve().Load(); + + var system = container.Resolve(); + var output = await system.ExecuteCommandWithOutputAsync("help"); + + Assert.Contains(output, line => line.Contains("clear", StringComparison.Ordinal)); + } +} From 86325b2785c447e1dec71cd69a0ae8dd039df5ea Mon Sep 17 00:00:00 2001 From: Tom Date: Fri, 3 Jul 2026 16:08:22 +0200 Subject: [PATCH 06/11] feat(consolecommands): interactive prompt UI and input loop ported from the reference design --- .../ConsoleCommandsRegistrationExtensions.cs | 2 + .../Services/ConsoleInputLoopService.cs | 329 ++++++++++++++++++ .../Services/ConsoleUiService.cs | 234 ++++++++++++- .../ConsoleCommands/ConsoleUiServiceTests.cs | 34 ++ 4 files changed, 588 insertions(+), 11 deletions(-) create mode 100644 src/SquidStd.ConsoleCommands/Services/ConsoleInputLoopService.cs create mode 100644 tests/SquidStd.Tests/ConsoleCommands/ConsoleUiServiceTests.cs diff --git a/src/SquidStd.ConsoleCommands/Extensions/ConsoleCommandsRegistrationExtensions.cs b/src/SquidStd.ConsoleCommands/Extensions/ConsoleCommandsRegistrationExtensions.cs index b457e7b..df5f816 100644 --- a/src/SquidStd.ConsoleCommands/Extensions/ConsoleCommandsRegistrationExtensions.cs +++ b/src/SquidStd.ConsoleCommands/Extensions/ConsoleCommandsRegistrationExtensions.cs @@ -1,6 +1,7 @@ using DryIoc; using Serilog.Core; using SquidStd.Abstractions.Extensions.Config; +using SquidStd.Abstractions.Extensions.Services; using SquidStd.ConsoleCommands.Data.Config; using SquidStd.ConsoleCommands.Interfaces; using SquidStd.ConsoleCommands.Internal; @@ -55,6 +56,7 @@ public IContainer AddConsoleCommands() resolver => new ConsolePromptLogSink(resolver.Resolve()), Reuse.Singleton ); + container.RegisterStdService(500); return container; } diff --git a/src/SquidStd.ConsoleCommands/Services/ConsoleInputLoopService.cs b/src/SquidStd.ConsoleCommands/Services/ConsoleInputLoopService.cs new file mode 100644 index 0000000..8254afd --- /dev/null +++ b/src/SquidStd.ConsoleCommands/Services/ConsoleInputLoopService.cs @@ -0,0 +1,329 @@ +using System.Text; +using Serilog; +using Serilog.Events; +using SquidStd.Abstractions.Interfaces.Services; +using SquidStd.ConsoleCommands.Data.Config; +using SquidStd.ConsoleCommands.Interfaces; + +namespace SquidStd.ConsoleCommands.Services; + +/// +/// Reads keystrokes from the interactive terminal and drives the prompt UI: printable input, +/// backspace, TAB autocomplete cycling, command history, ESC clearing and Enter dispatching to +/// the command system. Does nothing when the console is not interactive. +/// +public sealed class ConsoleInputLoopService : ISquidStdService, IDisposable +{ + private readonly List _autocompleteCandidates = []; + private readonly List _commandHistory = []; + private readonly ICommandSystemService _commands; + private readonly ConsoleCommandsConfig _config; + private readonly CancellationTokenSource _lifetimeCts = new(); + private readonly ILogger _logger = Log.ForContext(); + private readonly IConsoleUiService _ui; + + private int _autocompleteIndex = -1; + private string _autocompleteSeed = string.Empty; + private int _commandHistoryIndex = -1; + private Thread? _inputThread; + + /// Initializes the console input loop. + /// Prompt UI that renders input and log lines. + /// Command system that executes entered lines. + /// Prompt configuration section. + public ConsoleInputLoopService( + IConsoleUiService ui, + ICommandSystemService commands, + ConsoleCommandsConfig config + ) + { + _ui = ui; + _commands = commands; + _config = config; + } + + /// + /// Starts the background input thread when the console is interactive; otherwise the loop + /// stays disabled and this call is a no-op. + /// + /// Token used to cancel the asynchronous operation. + /// A completed task. + public ValueTask StartAsync(CancellationToken cancellationToken = default) + { + if (!_ui.IsInteractive) + { + _logger.Debug("Console is not interactive; input loop disabled"); + + return ValueTask.CompletedTask; + } + + _logger.Information("Interactive console prompt enabled."); + + try + { + if (_config.StartLocked) + { + _ui.LockInput(); + _ui.WriteLogLine( + $"Console input is locked. Press '{_config.UnlockCharacter}' to unlock.", + LogEventLevel.Warning + ); + } + + _inputThread = new Thread(InputLoop) + { + IsBackground = true, + Name = "SquidStd-ConsoleInput" + }; + _inputThread.Start(); + } + catch (Exception ex) when (ex is IOException or InvalidOperationException or ArgumentOutOfRangeException) + { + _logger.Warning( + ex, + "Interactive console prompt disabled because the current terminal does not support prompt rendering." + ); + _inputThread = null; + } + + return ValueTask.CompletedTask; + } + + /// + /// Stops the input loop by cancelling it and joining the input thread. + /// + /// Token used to cancel the asynchronous operation. + /// A completed task. + /// + /// The loop polls and only calls + /// when a key is buffered, so it normally reacts to + /// cancellation within one poll interval. The join uses a 2 second timeout: if the thread is + /// ever stuck in a blocked read it is a background thread and ends with the process. + /// + public ValueTask StopAsync(CancellationToken cancellationToken = default) + { + _lifetimeCts.Cancel(); + _inputThread?.Join(TimeSpan.FromSeconds(2)); + _inputThread = null; + + return ValueTask.CompletedTask; + } + + private void ApplyAutocomplete(StringBuilder buffer, bool reverse) + { + var currentInput = buffer.ToString(); + + if (_autocompleteSeed.Length == 0 || !string.Equals(_autocompleteSeed, currentInput, StringComparison.Ordinal)) + { + _autocompleteSeed = currentInput; + _autocompleteCandidates.Clear(); + _autocompleteCandidates.AddRange(_commands.GetAutocompleteSuggestions(currentInput)); + _autocompleteIndex = -1; + } + + if (_autocompleteCandidates.Count == 0) + { + return; + } + + _autocompleteIndex = reverse + ? _autocompleteIndex <= 0 ? _autocompleteCandidates.Count - 1 : _autocompleteIndex - 1 + : (_autocompleteIndex + 1) % _autocompleteCandidates.Count; + + var suggestion = _autocompleteCandidates[_autocompleteIndex]; + buffer.Clear(); + buffer.Append(suggestion); + _ui.UpdateInput(buffer.ToString()); + } + + private void InputLoop() + { + var cancellationToken = _lifetimeCts.Token; + var buffer = new StringBuilder(); + var lockWarningShown = false; + + try + { + _ui.UpdateInput(string.Empty); + + while (!cancellationToken.IsCancellationRequested) + { + if (!Console.KeyAvailable) + { + if (cancellationToken.WaitHandle.WaitOne(25)) + { + break; + } + + continue; + } + + var key = Console.ReadKey(true); + + if (_ui.IsInputLocked) + { + if (key.KeyChar == _config.UnlockCharacter) + { + _ui.UnlockInput(); + lockWarningShown = false; + _ui.WriteLogLine("Console unlocked.", LogEventLevel.Information); + } + else if (!lockWarningShown) + { + _ui.WriteLogLine( + $"Console input is locked. Press '{_config.UnlockCharacter}' to unlock.", + LogEventLevel.Warning + ); + lockWarningShown = true; + } + + continue; + } + + if (key.Key == ConsoleKey.Enter) + { + SubmitCommand(buffer.ToString(), cancellationToken); + buffer.Clear(); + _commandHistoryIndex = -1; + ResetAutocompleteState(); + _ui.UpdateInput(string.Empty); + lockWarningShown = false; + + continue; + } + + if (key.Key == ConsoleKey.Backspace) + { + if (buffer.Length > 0) + { + buffer.Length--; + ResetAutocompleteState(); + _ui.UpdateInput(buffer.ToString()); + } + + lockWarningShown = false; + + continue; + } + + if (key.Key == ConsoleKey.Escape) + { + buffer.Clear(); + _commandHistoryIndex = -1; + ResetAutocompleteState(); + + if (_config.StartLocked) + { + _ui.LockInput(); + } + + _ui.UpdateInput(string.Empty); + lockWarningShown = false; + + continue; + } + + if (key.Key == ConsoleKey.UpArrow) + { + if (_commandHistory.Count == 0) + { + continue; + } + + if (_commandHistoryIndex < _commandHistory.Count - 1) + { + _commandHistoryIndex++; + } + + buffer.Clear(); + buffer.Append(_commandHistory[_commandHistory.Count - 1 - _commandHistoryIndex]); + ResetAutocompleteState(); + _ui.UpdateInput(buffer.ToString()); + lockWarningShown = false; + + continue; + } + + if (key.Key == ConsoleKey.DownArrow) + { + if (_commandHistory.Count == 0) + { + continue; + } + + if (_commandHistoryIndex > 0) + { + _commandHistoryIndex--; + buffer.Clear(); + buffer.Append(_commandHistory[_commandHistory.Count - 1 - _commandHistoryIndex]); + } + else + { + _commandHistoryIndex = -1; + buffer.Clear(); + } + + ResetAutocompleteState(); + _ui.UpdateInput(buffer.ToString()); + lockWarningShown = false; + + continue; + } + + if (key.Key == ConsoleKey.Tab) + { + ApplyAutocomplete(buffer, (key.Modifiers & ConsoleModifiers.Shift) != 0); + lockWarningShown = false; + + continue; + } + + if (!char.IsControl(key.KeyChar)) + { + buffer.Append(key.KeyChar); + ResetAutocompleteState(); + _ui.UpdateInput(buffer.ToString()); + lockWarningShown = false; + } + } + } + catch (OperationCanceledException) + { + // graceful shutdown + } + catch (Exception ex) when (ex is IOException or InvalidOperationException or ArgumentOutOfRangeException) + { + _logger.Warning(ex, "Console input loop stopped: the terminal no longer supports interactive input."); + } + } + + private void ResetAutocompleteState() + { + _autocompleteSeed = string.Empty; + _autocompleteIndex = -1; + _autocompleteCandidates.Clear(); + } + + private void SubmitCommand(string rawCommand, CancellationToken cancellationToken) + { + var command = rawCommand.Trim(); + + if (command.Length == 0) + { + return; + } + + _commandHistory.Add(command); + + _logger.Verbose("Console command entered: {Command}", command); + + _commands.ExecuteCommandAsync(command, cancellationToken).GetAwaiter().GetResult(); + } + + /// Cancels the input loop and releases the lifetime token source. + public void Dispose() + { + _lifetimeCts.Cancel(); + _lifetimeCts.Dispose(); + } +} diff --git a/src/SquidStd.ConsoleCommands/Services/ConsoleUiService.cs b/src/SquidStd.ConsoleCommands/Services/ConsoleUiService.cs index 13838bd..16377ec 100644 --- a/src/SquidStd.ConsoleCommands/Services/ConsoleUiService.cs +++ b/src/SquidStd.ConsoleCommands/Services/ConsoleUiService.cs @@ -1,19 +1,26 @@ using Serilog.Events; +using Spectre.Console; using SquidStd.ConsoleCommands.Data.Config; using SquidStd.ConsoleCommands.Interfaces; namespace SquidStd.ConsoleCommands.Services; /// -/// Console UI for the command prompt. In non-interactive environments (redirected streams) -/// every write is a plain and input state is inert. +/// Console UI for the command prompt. On an interactive terminal the prompt is pinned to the +/// bottom row and log/output lines are written above it under a shared lock. In non-interactive +/// environments (redirected streams) every write is a plain +/// and input state is inert. /// public sealed class ConsoleUiService : IConsoleUiService { - private readonly ConsoleCommandsConfig _config; + private readonly string _lockedPromptPrefix; + private readonly string _promptPrefix; + private readonly Lock _sync = new(); + + private string _input = string.Empty; /// - public bool IsInteractive { get; } + public bool IsInteractive { get; private set; } /// public bool IsInputLocked { get; private set; } @@ -22,30 +29,235 @@ public sealed class ConsoleUiService : IConsoleUiService /// Prompt configuration section. public ConsoleUiService(ConsoleCommandsConfig config) { - _config = config; - IsInteractive = !Console.IsInputRedirected && !Console.IsOutputRedirected; + _promptPrefix = config.Prompt; + _lockedPromptPrefix = BuildLockedPromptPrefix(config.Prompt); + IsInteractive = IsInteractiveConsole(); IsInputLocked = config.StartLocked; } /// public void LockInput() - => IsInputLocked = true; + { + if (!IsInteractive) + { + IsInputLocked = true; + + return; + } + + lock (_sync) + { + IsInputLocked = true; + _input = string.Empty; + RenderPromptUnsafe(); + } + } /// public void UnlockInput() - => IsInputLocked = false; + { + if (!IsInteractive) + { + IsInputLocked = false; + + return; + } + + lock (_sync) + { + IsInputLocked = false; + RenderPromptUnsafe(); + } + } /// public void UpdateInput(string input) { - // Non-interactive: no prompt row to render. The interactive branch lands with the input loop. + if (!IsInteractive) + { + return; + } + + lock (_sync) + { + _input = input; + RenderPromptUnsafe(); + } } /// public void WriteLine(string line) - => Console.WriteLine(line); + { + if (!IsInteractive) + { + Console.WriteLine(line); + + return; + } + + lock (_sync) + { + try + { + ClearPromptRowUnsafe(); + + foreach (var item in SplitLines(line)) + { + Console.WriteLine(item); + } + + RenderPromptUnsafe(); + } + catch (IOException) + { + IsInteractive = false; + Console.WriteLine(line); + } + catch (ArgumentOutOfRangeException) + { + IsInteractive = false; + Console.WriteLine(line); + } + } + } /// public void WriteLogLine(string line, LogEventLevel level) - => Console.WriteLine(line); + { + if (!IsInteractive) + { + Console.WriteLine(line); + + return; + } + + lock (_sync) + { + try + { + ClearPromptRowUnsafe(); + + foreach (var item in SplitLines(line)) + { + AnsiConsole.MarkupLine(FormatLogMarkup(item, level)); + } + + RenderPromptUnsafe(); + } + catch (IOException) + { + IsInteractive = false; + Console.WriteLine(line); + } + catch (ArgumentOutOfRangeException) + { + IsInteractive = false; + Console.WriteLine(line); + } + } + } + + private static string BuildLockedPromptPrefix(string prompt) + { + var name = prompt.TrimEnd().TrimEnd('>').TrimEnd(); + + if (name.Length == 0) + { + return "[LOCKED]> "; + } + + return name + " [LOCKED]> "; + } + + private void ClearPromptRowUnsafe() + { + var width = Math.Max(1, Console.WindowWidth); + var promptRow = GetPromptRowUnsafe(); + + Console.SetCursorPosition(0, promptRow); + Console.Write(new string(' ', width)); + Console.SetCursorPosition(0, promptRow); + } + + private static string FormatLogMarkup(string line, LogEventLevel level) + { + var escaped = Markup.Escape(line); + + return level switch + { + LogEventLevel.Verbose => $"[grey]{escaped}[/]", + LogEventLevel.Debug => $"[grey]{escaped}[/]", + LogEventLevel.Warning => $"[yellow]{escaped}[/]", + LogEventLevel.Error => $"[red]{escaped}[/]", + LogEventLevel.Fatal => $"[red]{escaped}[/]", + _ => escaped + }; + } + + private static int GetPromptRowUnsafe() + { + var bufferHeight = Math.Max(1, Console.BufferHeight); + var windowHeight = Math.Max(1, Console.WindowHeight); + var row = Console.WindowTop + windowHeight - 1; + + return Math.Clamp(row, 0, bufferHeight - 1); + } + + private static bool IsInteractiveConsole() + { + if (!Environment.UserInteractive) + { + return false; + } + + if (Console.IsInputRedirected || Console.IsOutputRedirected) + { + return false; + } + + return true; + } + + private void RenderPromptUnsafe() + { + var width = Math.Max(1, Console.WindowWidth); + var promptRow = GetPromptRowUnsafe(); + var promptPrefix = IsInputLocked ? _lockedPromptPrefix : _promptPrefix; + + Console.SetCursorPosition(0, promptRow); + Console.Write(new string(' ', width)); + Console.SetCursorPosition(0, promptRow); + + var line = promptPrefix + _input; + var visible = line.Length > width ? line[..width] : line; + AnsiConsole.Console.Write(new Text(visible, new(Color.Green))); + + var cursorColumn = Math.Min(width - 1, promptPrefix.Length + _input.Length); + Console.SetCursorPosition(cursorColumn, promptRow); + } + + private static IEnumerable SplitLines(string line) + { + if (string.IsNullOrEmpty(line)) + { + yield return string.Empty; + + yield break; + } + + var split = line.Replace("\r", string.Empty, StringComparison.Ordinal) + .Split('\n'); + + var length = split.Length; + + if (length > 0 && split[length - 1].Length == 0) + { + length--; + } + + for (var i = 0; i < length; i++) + { + yield return split[i]; + } + } } diff --git a/tests/SquidStd.Tests/ConsoleCommands/ConsoleUiServiceTests.cs b/tests/SquidStd.Tests/ConsoleCommands/ConsoleUiServiceTests.cs new file mode 100644 index 0000000..1e30eb8 --- /dev/null +++ b/tests/SquidStd.Tests/ConsoleCommands/ConsoleUiServiceTests.cs @@ -0,0 +1,34 @@ +using Serilog.Events; +using SquidStd.ConsoleCommands.Data.Config; +using SquidStd.ConsoleCommands.Services; + +namespace SquidStd.Tests.ConsoleCommands; + +public class ConsoleUiServiceTests +{ + [Fact] + public void NonInteractive_AllOperations_DoNotThrow() + { + var ui = new ConsoleUiService(new ConsoleCommandsConfig()); + + Assert.False(ui.IsInteractive); + ui.WriteLine("plain"); + ui.WriteLogLine("log line", LogEventLevel.Information); + ui.LockInput(); + Assert.True(ui.IsInputLocked); + ui.UnlockInput(); + Assert.False(ui.IsInputLocked); + ui.UpdateInput("abc"); + } + + [Fact] + public async Task InputLoop_NonInteractive_StartsAsNoOp() + { + var ui = new ConsoleUiService(new ConsoleCommandsConfig()); + var system = new CommandSystemService(_ => { }); + using var loop = new ConsoleInputLoopService(ui, system, new ConsoleCommandsConfig()); + + await loop.StartAsync(); + await loop.StopAsync(); + } +} From ce0476f3d192a7903f02bd4fd6c669b829a41ada Mon Sep 17 00:00:00 2001 From: Tom Date: Fri, 3 Jul 2026 16:20:13 +0200 Subject: [PATCH 07/11] feat(consolecommands): RegisterConsoleCommand generator, sample and docs - feat(generators): ConsoleCommandRegistrationGenerator emitting RegisterGeneratedConsoleCommands with singleton registration and deferred RegisterCommand wiring via DryIoc RegisterInitializer - feat(generators): SQDGEN006 diagnostic for unsupported console command classes - feat(samples): SquidStd.Samples.ConsoleCommands with attribute, delegate and builtin command usage - test(generators): ConsoleCommandRegistrationGeneratorTests covering generation, description fallback, no-op and diagnostics - docs: SquidStd.ConsoleCommands package README, docfx article and toc entry --- SquidStd.slnx | 1 + docs/articles/consolecommands.md | 1 + docs/articles/toc.yml | 2 + .../Program.cs | 67 +++++++ .../SquidStd.Samples.ConsoleCommands.csproj | 17 ++ src/SquidStd.ConsoleCommands/README.md | 107 ++++++++++ .../AnalyzerReleases.Unshipped.md | 1 + .../ConsoleCommandRegistrationCandidate.cs | 35 ++++ .../ConsoleCommandRegistrationGenerator.cs | 106 ++++++++++ .../ConsoleCommandSourceBuilder.cs | 62 ++++++ .../SquidStdGeneratorDiagnostics.cs | 9 + ...onsoleCommandRegistrationGeneratorTests.cs | 184 ++++++++++++++++++ .../Support/GeneratorTestCompiler.cs | 2 + 13 files changed, 594 insertions(+) create mode 100644 docs/articles/consolecommands.md create mode 100644 samples/SquidStd.Samples.ConsoleCommands/Program.cs create mode 100644 samples/SquidStd.Samples.ConsoleCommands/SquidStd.Samples.ConsoleCommands.csproj create mode 100644 src/SquidStd.ConsoleCommands/README.md create mode 100644 src/SquidStd.Generators/ConsoleCommands/ConsoleCommandRegistrationCandidate.cs create mode 100644 src/SquidStd.Generators/ConsoleCommands/ConsoleCommandRegistrationGenerator.cs create mode 100644 src/SquidStd.Generators/ConsoleCommands/ConsoleCommandSourceBuilder.cs create mode 100644 tests/SquidStd.Tests/Generators/ConsoleCommands/ConsoleCommandRegistrationGeneratorTests.cs diff --git a/SquidStd.slnx b/SquidStd.slnx index c6f55ed..9e3564e 100644 --- a/SquidStd.slnx +++ b/SquidStd.slnx @@ -56,6 +56,7 @@ + diff --git a/docs/articles/consolecommands.md b/docs/articles/consolecommands.md new file mode 100644 index 0000000..faa5687 --- /dev/null +++ b/docs/articles/consolecommands.md @@ -0,0 +1 @@ +[!include[](../../src/SquidStd.ConsoleCommands/README.md)] diff --git a/docs/articles/toc.yml b/docs/articles/toc.yml index 144f2b1..d5cd71b 100644 --- a/docs/articles/toc.yml +++ b/docs/articles/toc.yml @@ -120,6 +120,8 @@ href: aws-abstractions.md - name: SquidStd.Tui href: tui.md +- name: SquidStd.ConsoleCommands + href: consolecommands.md - name: SquidStd.Crypto href: crypto.md - name: SquidStd.Secrets.Aws diff --git a/samples/SquidStd.Samples.ConsoleCommands/Program.cs b/samples/SquidStd.Samples.ConsoleCommands/Program.cs new file mode 100644 index 0000000..4840c54 --- /dev/null +++ b/samples/SquidStd.Samples.ConsoleCommands/Program.cs @@ -0,0 +1,67 @@ +using SquidStd.ConsoleCommands.Attributes; +using SquidStd.ConsoleCommands.Data; +using SquidStd.ConsoleCommands.Extensions; +using SquidStd.ConsoleCommands.Interfaces; +using SquidStd.Core.Data.Bootstrap; +using SquidStd.Generators.ConsoleCommands; +using SquidStd.Services.Core.Extensions; +using SquidStd.Services.Core.Services.Bootstrap; + +var bootstrap = SquidStdBootstrap.Create( + new SquidStdOptions + { + ConfigName = "squidstd", + RootDirectory = AppContext.BaseDirectory, + AppName = "ConsoleDemo" + } +); + +bootstrap.ConfigureServices( + container => + { + container.RegisterCoreServices(); + container.AddConsoleCommands(); + container.RegisterGeneratedConsoleCommands(); + + return container; + } +); + +await bootstrap.StartAsync(); + +var commands = bootstrap.Resolve(); +commands.RegisterCommand( + "echo", + ctx => + { + ctx.WriteLine(string.Join(' ', ctx.Arguments)); + + return Task.CompletedTask; + }, + "Echoes the arguments back." +); + +foreach (var line in await commands.ExecuteCommandWithOutputAsync("help")) +{ + Console.WriteLine(line); +} + +foreach (var line in await commands.ExecuteCommandWithOutputAsync("ping")) +{ + Console.WriteLine(line); +} + +await bootstrap.StopAsync(); + +[RegisterConsoleCommand("ping|p", "Replies pong.")] +internal sealed class PingCommand : IConsoleCommandExecutor +{ + public string Description => "Replies pong."; + + public Task ExecuteAsync(ConsoleCommandContext context) + { + context.WriteLine("pong"); + + return Task.CompletedTask; + } +} diff --git a/samples/SquidStd.Samples.ConsoleCommands/SquidStd.Samples.ConsoleCommands.csproj b/samples/SquidStd.Samples.ConsoleCommands/SquidStd.Samples.ConsoleCommands.csproj new file mode 100644 index 0000000..43ccddf --- /dev/null +++ b/samples/SquidStd.Samples.ConsoleCommands/SquidStd.Samples.ConsoleCommands.csproj @@ -0,0 +1,17 @@ + + + + Exe + net10.0 + enable + enable + false + + + + + + + + + diff --git a/src/SquidStd.ConsoleCommands/README.md b/src/SquidStd.ConsoleCommands/README.md new file mode 100644 index 0000000..4bb2338 --- /dev/null +++ b/src/SquidStd.ConsoleCommands/README.md @@ -0,0 +1,107 @@ +

SquidStd.ConsoleCommands

+ +Interactive console commands for SquidStd hosts, rendered on a fixed prompt at the bottom of the terminal. +A command registry supports aliases (`"gc|collect"`), quoted arguments, TAB autocomplete cycling, command +history and an optional locked mode that ignores input until an unlock character is pressed. Log lines are +rendered above the prompt through a Serilog sink, so the input row never gets torn by log output. When the +console is not interactive (redirected output, CI), the input loop disables itself and commands remain +callable programmatically. + +## Install + +```bash +dotnet add package SquidStd.ConsoleCommands +``` + +## Usage + +```csharp +using DryIoc; +using SquidStd.ConsoleCommands.Extensions; +using SquidStd.ConsoleCommands.Interfaces; + +bootstrap.ConfigureServices(container => +{ + container.RegisterCoreServices(); + container.AddConsoleCommands(); // prompt UI, command system, builtins, log sink + container.RegisterGeneratedConsoleCommands(); // attribute-registered commands (source generated) + + return container; +}); + +await bootstrap.StartAsync(); + +// Delegate-based registration. +var commands = bootstrap.Resolve(); +commands.RegisterCommand("echo", ctx => +{ + ctx.WriteLine(string.Join(' ', ctx.Arguments)); + return Task.CompletedTask; +}, "Echoes the arguments back."); + +// Programmatic execution (also works when the console is not interactive). +foreach (var line in await commands.ExecuteCommandWithOutputAsync("help")) +{ + Console.WriteLine(line); +} +``` + +Class-based commands are picked up by the `SquidStd.Generators` source generator: annotate an +`IConsoleCommandExecutor` and call `RegisterGeneratedConsoleCommands()` (namespace +`SquidStd.Generators.ConsoleCommands`). The class is registered as a singleton in the container and wired +to the command system when it is resolved. + +```csharp +using SquidStd.ConsoleCommands.Attributes; +using SquidStd.ConsoleCommands.Data; +using SquidStd.ConsoleCommands.Interfaces; + +[RegisterConsoleCommand("ping|p", "Replies pong.")] +internal sealed class PingCommand : IConsoleCommandExecutor +{ + public string Description => "Replies pong."; + + public Task ExecuteAsync(ConsoleCommandContext context) + { + context.WriteLine("pong"); + + return Task.CompletedTask; + } +} +``` + +The builtin commands `help` (`?`), `clear` (`cls`) and `exit` (`quit`) are registered by +`AddConsoleCommands()`. + +## Configuration + +The `consoleCommands` section of the SquidStd YAML config controls the prompt: + +```yaml +consoleCommands: + Prompt: "myapp> " + StartLocked: true + UnlockCharacter: "*" +``` + +Set `logger.EnableConsole: false` so the prompt log sink replaces the standard console sink; otherwise +every log line is written twice. + +## Key types + +| Type | Purpose | +|----------------------------------|------------------------------------------------------------------------------------| +| `ICommandSystemService` | Registry and dispatcher: register, execute, collect output, autocomplete. | +| `ConsoleCommandContext` | Handler context: raw text, parsed arguments and the output writer. | +| `IConsoleCommandExecutor` | Contract for class-based commands registered via the attribute. | +| `RegisterConsoleCommandAttribute`| Marks an executor for source-generated registration (name/aliases + description). | +| `ConsoleCommandsConfig` | `consoleCommands` config section: prompt text, locked start, unlock character. | + +## Related + +- Docs: [SquidStd.ConsoleCommands](https://tgiachi.github.io/squid-std/articles/consolecommands.html) +- Docs: [SquidStd.Services.Core](https://tgiachi.github.io/squid-std/articles/services-core.html) + +## License + +MIT - part of [SquidStd](https://github.com/tgiachi/squid-std). diff --git a/src/SquidStd.Generators/AnalyzerReleases.Unshipped.md b/src/SquidStd.Generators/AnalyzerReleases.Unshipped.md index 822e1d6..d7287c9 100644 --- a/src/SquidStd.Generators/AnalyzerReleases.Unshipped.md +++ b/src/SquidStd.Generators/AnalyzerReleases.Unshipped.md @@ -10,3 +10,4 @@ SQDGEN003 | SquidStd.Generators | Warning | Config section cannot be generated SQDGEN004 | SquidStd.Generators | Warning | Job handler cannot be generated SQDGEN005 | SquidStd.Generators | Warning | Script module cannot be generated + SQDGEN006 | SquidStd.Generators | Warning | Console command cannot be generated diff --git a/src/SquidStd.Generators/ConsoleCommands/ConsoleCommandRegistrationCandidate.cs b/src/SquidStd.Generators/ConsoleCommands/ConsoleCommandRegistrationCandidate.cs new file mode 100644 index 0000000..7e62729 --- /dev/null +++ b/src/SquidStd.Generators/ConsoleCommands/ConsoleCommandRegistrationCandidate.cs @@ -0,0 +1,35 @@ +using Microsoft.CodeAnalysis; + +namespace SquidStd.Generators.ConsoleCommands; + +internal sealed class ConsoleCommandRegistrationCandidate +{ + public string ExecutorTypeName { get; } + + public string CommandName { get; } + + public string Description { get; } + + public string DisplayName { get; } + + public Location? Location { get; } + + public bool IsSupported { get; } + + public ConsoleCommandRegistrationCandidate( + string executorTypeName, + string commandName, + string description, + string displayName, + Location? location, + bool isSupported + ) + { + ExecutorTypeName = executorTypeName; + CommandName = commandName; + Description = description; + DisplayName = displayName; + Location = location; + IsSupported = isSupported; + } +} diff --git a/src/SquidStd.Generators/ConsoleCommands/ConsoleCommandRegistrationGenerator.cs b/src/SquidStd.Generators/ConsoleCommands/ConsoleCommandRegistrationGenerator.cs new file mode 100644 index 0000000..b9e4433 --- /dev/null +++ b/src/SquidStd.Generators/ConsoleCommands/ConsoleCommandRegistrationGenerator.cs @@ -0,0 +1,106 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using SquidStd.Generators.Common; +using SquidStd.Generators.Diagnostics; + +namespace SquidStd.Generators.ConsoleCommands; + +[Generator(LanguageNames.CSharp)] +public sealed class ConsoleCommandRegistrationGenerator : IIncrementalGenerator +{ + private const string AttributeName = "SquidStd.ConsoleCommands.Attributes.RegisterConsoleCommandAttribute"; + private const string GeneratedFileName = "SquidStd.GeneratedConsoleCommandRegistration.g.cs"; + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var candidates = context.SyntaxProvider.ForAttributeWithMetadataName( + AttributeName, + static (node, _) => node is ClassDeclarationSyntax, + static (context, cancellationToken) => CreateCandidate(context, cancellationToken) + ); + + context.RegisterSourceOutput(candidates.Collect(), static (context, candidates) => Execute(context, candidates)); + } + + private static ConsoleCommandRegistrationCandidate CreateCandidate( + GeneratorAttributeSyntaxContext context, + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + var executorType = (INamedTypeSymbol)context.TargetSymbol; + var attribute = context.Attributes[0]; + var commandName = GetStringConstructorArgument(attribute, 0); + var description = GetStringConstructorArgument(attribute, 1); + + var isSupported = !string.IsNullOrWhiteSpace(commandName) && + GeneratorSymbolHelpers.IsConcreteNonGenericClass(executorType) && + GeneratorSymbolHelpers.IsAccessibleFromGeneratedSource(executorType) && + GeneratorSymbolHelpers.ImplementsInterface( + executorType, + "IConsoleCommandExecutor", + "SquidStd.ConsoleCommands.Interfaces" + ); + + return new( + GeneratorSymbolHelpers.FullyQualified(executorType), + commandName ?? string.Empty, + description ?? string.Empty, + GeneratorSymbolHelpers.DisplayName(executorType), + GeneratorSymbolHelpers.PrimaryLocation(executorType), + isSupported + ); + } + + private static string? GetStringConstructorArgument(AttributeData attribute, int index) + { + if (attribute.ConstructorArguments.Length <= index) + { + return null; + } + + return attribute.ConstructorArguments[index].Value as string; + } + + private static void Execute( + SourceProductionContext context, + ImmutableArray candidates + ) + { + var supported = new List(); + var seenExecutorTypes = new HashSet(StringComparer.Ordinal); + + foreach (var candidate in candidates) + { + if (!candidate.IsSupported) + { + context.ReportDiagnostic( + Diagnostic.Create( + SquidStdGeneratorDiagnostics.UnsupportedConsoleCommand, + candidate.Location, + candidate.DisplayName + ) + ); + + continue; + } + + if (seenExecutorTypes.Add(candidate.ExecutorTypeName)) + { + supported.Add(candidate); + } + } + + supported.Sort( + static (left, right) => string.Compare( + left.ExecutorTypeName, + right.ExecutorTypeName, + StringComparison.Ordinal + ) + ); + + context.AddSource(GeneratedFileName, ConsoleCommandSourceBuilder.Build(supported)); + } +} diff --git a/src/SquidStd.Generators/ConsoleCommands/ConsoleCommandSourceBuilder.cs b/src/SquidStd.Generators/ConsoleCommands/ConsoleCommandSourceBuilder.cs new file mode 100644 index 0000000..0dace06 --- /dev/null +++ b/src/SquidStd.Generators/ConsoleCommands/ConsoleCommandSourceBuilder.cs @@ -0,0 +1,62 @@ +using System.Text; + +namespace SquidStd.Generators.ConsoleCommands; + +internal static class ConsoleCommandSourceBuilder +{ + public static string Build(IReadOnlyList candidates) + { + var builder = new StringBuilder(); + + builder.AppendLine("// "); + builder.AppendLine("#nullable enable"); + builder.AppendLine(); + builder.AppendLine("namespace SquidStd.Generators.ConsoleCommands;"); + builder.AppendLine(); + builder.AppendLine("public static class GeneratedConsoleCommandRegistrationExtensions"); + builder.AppendLine("{"); + builder.AppendLine( + " public static global::DryIoc.IContainer RegisterGeneratedConsoleCommands(this global::DryIoc.IContainer container)" + ); + builder.AppendLine(" {"); + + for (var i = 0; i < candidates.Count; i++) + { + var candidate = candidates[i]; + + builder.Append(" global::DryIoc.Registrator.Register<"); + builder.Append(candidate.ExecutorTypeName); + builder.AppendLine(">(container, global::DryIoc.Reuse.Singleton);"); + builder.AppendLine( + " global::DryIoc.Registrator.RegisterInitializer(" + ); + builder.AppendLine(" container,"); + builder.AppendLine(" static (commandSystem, resolverContext) =>"); + builder.AppendLine(" {"); + builder.Append(" var executor = global::DryIoc.Resolver.Resolve<"); + builder.Append(candidate.ExecutorTypeName); + builder.AppendLine(">(resolverContext);"); + builder.Append(" commandSystem.RegisterCommand("); + builder.Append(FormatStringLiteral(candidate.CommandName)); + builder.Append(", executor.ExecuteAsync, "); + builder.Append( + candidate.Description.Length == 0 + ? "executor.Description" + : FormatStringLiteral(candidate.Description) + ); + builder.AppendLine(");"); + builder.AppendLine(" }"); + builder.AppendLine(" );"); + } + + builder.AppendLine(); + builder.AppendLine(" return container;"); + builder.AppendLine(" }"); + builder.AppendLine("}"); + + return builder.ToString(); + } + + private static string FormatStringLiteral(string value) + => "\"" + value.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\""; +} diff --git a/src/SquidStd.Generators/Diagnostics/SquidStdGeneratorDiagnostics.cs b/src/SquidStd.Generators/Diagnostics/SquidStdGeneratorDiagnostics.cs index cdb4664..0de1242 100644 --- a/src/SquidStd.Generators/Diagnostics/SquidStdGeneratorDiagnostics.cs +++ b/src/SquidStd.Generators/Diagnostics/SquidStdGeneratorDiagnostics.cs @@ -48,4 +48,13 @@ internal static class SquidStdGeneratorDiagnostics DiagnosticSeverity.Warning, true ); + + public static readonly DiagnosticDescriptor UnsupportedConsoleCommand = new( + "SQDGEN006", + "Console command cannot be generated", + "Console command '{0}' must be a non-generic public or internal class implementing IConsoleCommandExecutor with a non-empty command name", + "SquidStd.Generators", + DiagnosticSeverity.Warning, + true + ); } diff --git a/tests/SquidStd.Tests/Generators/ConsoleCommands/ConsoleCommandRegistrationGeneratorTests.cs b/tests/SquidStd.Tests/Generators/ConsoleCommands/ConsoleCommandRegistrationGeneratorTests.cs new file mode 100644 index 0000000..dd574c8 --- /dev/null +++ b/tests/SquidStd.Tests/Generators/ConsoleCommands/ConsoleCommandRegistrationGeneratorTests.cs @@ -0,0 +1,184 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using SquidStd.Generators.ConsoleCommands; +using SquidStd.Tests.Generators.Support; + +namespace SquidStd.Tests.Generators.ConsoleCommands; + +public class ConsoleCommandRegistrationGeneratorTests +{ + [Fact] + public void Run_GeneratesRegistrationExtension_WhenConsoleCommandIsAnnotated() + { + const string source = """ + using System.Threading.Tasks; + using SquidStd.ConsoleCommands.Attributes; + using SquidStd.ConsoleCommands.Data; + using SquidStd.ConsoleCommands.Interfaces; + + namespace SampleApp; + + [RegisterConsoleCommand("ping|p", "Replies pong.")] + public sealed class PingCommand : IConsoleCommandExecutor + { + public string Description => "Replies pong."; + + public Task ExecuteAsync(ConsoleCommandContext context) + { + return Task.CompletedTask; + } + } + """; + + var result = GeneratorTestCompiler.Run(source, new ConsoleCommandRegistrationGenerator()); + var generatedSource = SingleGeneratedSource(result, "SquidStd.GeneratedConsoleCommandRegistration.g.cs"); + + Assert.Contains("RegisterGeneratedConsoleCommands", generatedSource, StringComparison.Ordinal); + Assert.Contains("global::SampleApp.PingCommand", generatedSource, StringComparison.Ordinal); + Assert.Contains( + "RegisterCommand(\"ping|p\", executor.ExecuteAsync, \"Replies pong.\");", + generatedSource, + StringComparison.Ordinal + ); + } + + [Fact] + public void Run_WiresExecutorDescription_WhenAttributeDescriptionIsOmitted() + { + const string source = """ + using System.Threading.Tasks; + using SquidStd.ConsoleCommands.Attributes; + using SquidStd.ConsoleCommands.Data; + using SquidStd.ConsoleCommands.Interfaces; + + namespace SampleApp; + + [RegisterConsoleCommand("ping")] + public sealed class PingCommand : IConsoleCommandExecutor + { + public string Description => "Replies pong."; + + public Task ExecuteAsync(ConsoleCommandContext context) + { + return Task.CompletedTask; + } + } + """; + + var result = GeneratorTestCompiler.Run(source, new ConsoleCommandRegistrationGenerator()); + var generatedSource = SingleGeneratedSource(result, "SquidStd.GeneratedConsoleCommandRegistration.g.cs"); + + Assert.Contains( + "RegisterCommand(\"ping\", executor.ExecuteAsync, executor.Description);", + generatedSource, + StringComparison.Ordinal + ); + } + + [Fact] + public void Run_DoesNotRegisterConsoleCommand_WhenAttributeIsMissing() + { + const string source = """ + using System.Threading.Tasks; + using SquidStd.ConsoleCommands.Data; + using SquidStd.ConsoleCommands.Interfaces; + + namespace SampleApp; + + public sealed class PingCommand : IConsoleCommandExecutor + { + public string Description => "Replies pong."; + + public Task ExecuteAsync(ConsoleCommandContext context) + { + return Task.CompletedTask; + } + } + """; + + var result = GeneratorTestCompiler.Run(source, new ConsoleCommandRegistrationGenerator()); + var generatedSource = SingleGeneratedSource(result, "SquidStd.GeneratedConsoleCommandRegistration.g.cs"); + + Assert.Contains("RegisterGeneratedConsoleCommands", generatedSource, StringComparison.Ordinal); + Assert.DoesNotContain("RegisterCommand(", generatedSource, StringComparison.Ordinal); + } + + [Fact] + public void Run_GeneratesNoOpExtension_WhenNoConsoleCommandsExist() + { + const string source = """ + namespace SampleApp; + + public sealed class EmptyType { } + """; + + var result = GeneratorTestCompiler.Run(source, new ConsoleCommandRegistrationGenerator()); + var generatedSource = SingleGeneratedSource(result, "SquidStd.GeneratedConsoleCommandRegistration.g.cs"); + + Assert.Contains("RegisterGeneratedConsoleCommands", generatedSource, StringComparison.Ordinal); + Assert.Contains("return container;", generatedSource, StringComparison.Ordinal); + Assert.DoesNotContain("RegisterCommand(", generatedSource, StringComparison.Ordinal); + } + + [Fact] + public void Run_ReportsDiagnostic_WhenAnnotatedTypeIsNotAConsoleCommand() + { + const string source = """ + using SquidStd.ConsoleCommands.Attributes; + + namespace SampleApp; + + [RegisterConsoleCommand("ping|p", "Replies pong.")] + public sealed class PingCommand { } + """; + + var result = GeneratorTestCompiler.Run(source, new ConsoleCommandRegistrationGenerator()); + var diagnostics = result.RunResult.Diagnostics.Concat(result.Diagnostics); + + Assert.Contains(diagnostics, diagnostic => diagnostic.Id == "SQDGEN006"); + } + + [Fact] + public void Run_ReportsDiagnostic_WhenCommandNameIsEmpty() + { + const string source = """ + using System.Threading.Tasks; + using SquidStd.ConsoleCommands.Attributes; + using SquidStd.ConsoleCommands.Data; + using SquidStd.ConsoleCommands.Interfaces; + + namespace SampleApp; + + [RegisterConsoleCommand("")] + public sealed class PingCommand : IConsoleCommandExecutor + { + public string Description => "Replies pong."; + + public Task ExecuteAsync(ConsoleCommandContext context) + { + return Task.CompletedTask; + } + } + """; + + var result = GeneratorTestCompiler.Run(source, new ConsoleCommandRegistrationGenerator()); + var diagnostics = result.RunResult.Diagnostics.Concat(result.Diagnostics); + + Assert.Contains(diagnostics, diagnostic => diagnostic.Id == "SQDGEN006"); + } + + private static string SingleGeneratedSource( + (Compilation Compilation, + GeneratorDriverRunResult RunResult, + ImmutableArray Diagnostics) result, + string fileName + ) + { + var generatedTree = Assert.Single( + result.RunResult.GeneratedTrees, + tree => tree.FilePath.EndsWith(fileName, StringComparison.Ordinal) + ); + + return generatedTree.GetText().ToString(); + } +} diff --git a/tests/SquidStd.Tests/Generators/Support/GeneratorTestCompiler.cs b/tests/SquidStd.Tests/Generators/Support/GeneratorTestCompiler.cs index f558015..7ecd61e 100644 --- a/tests/SquidStd.Tests/Generators/Support/GeneratorTestCompiler.cs +++ b/tests/SquidStd.Tests/Generators/Support/GeneratorTestCompiler.cs @@ -6,6 +6,7 @@ using SquidStd.Abstractions.Extensions.Config; using SquidStd.Abstractions.Extensions.Events; using SquidStd.Abstractions.Extensions.Services; +using SquidStd.ConsoleCommands.Attributes; using SquidStd.Core.Interfaces.Events; using SquidStd.Generators.Events; using SquidStd.Scripting.Lua.Attributes; @@ -77,6 +78,7 @@ private static IReadOnlyList CreateReferences() AddReference(references, typeof(RegisterJobHandlerAttribute).Assembly.Location); AddReference(references, typeof(WorkersRegistrationExtensions).Assembly.Location); AddReference(references, typeof(IJobHandler).Assembly.Location); + AddReference(references, typeof(RegisterConsoleCommandAttribute).Assembly.Location); AddReference(references, typeof(IContainer).Assembly.Location); return references; From 310e8e243778b5728e2bc534fd924e5ac010792e Mon Sep 17 00:00:00 2001 From: Tom Date: Fri, 3 Jul 2026 16:30:49 +0200 Subject: [PATCH 08/11] fix(consolecommands): validate empty alias lists, evict displaced entries on redefine, single log stream in the sample --- .../Program.cs | 3 +++ .../Services/CommandSystemService.cs | 15 +++++++++++ .../CommandSystemServiceTests.cs | 25 +++++++++++++++++++ 3 files changed, 43 insertions(+) diff --git a/samples/SquidStd.Samples.ConsoleCommands/Program.cs b/samples/SquidStd.Samples.ConsoleCommands/Program.cs index 4840c54..3e9d496 100644 --- a/samples/SquidStd.Samples.ConsoleCommands/Program.cs +++ b/samples/SquidStd.Samples.ConsoleCommands/Program.cs @@ -27,6 +27,9 @@ } ); +// The prompt sink renders the log lines: disable the standard console sink to avoid duplicates. +bootstrap.OnConfigLoaded(o => o.EnableConsole = false); + await bootstrap.StartAsync(); var commands = bootstrap.Resolve(); diff --git a/src/SquidStd.ConsoleCommands/Services/CommandSystemService.cs b/src/SquidStd.ConsoleCommands/Services/CommandSystemService.cs index c152ede..4f05eeb 100644 --- a/src/SquidStd.ConsoleCommands/Services/CommandSystemService.cs +++ b/src/SquidStd.ConsoleCommands/Services/CommandSystemService.cs @@ -40,6 +40,11 @@ public void RegisterCommand( var aliases = commandName.Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (aliases.Length == 0) + { + throw new ArgumentException("Command name must contain at least one alias.", nameof(commandName)); + } + var entry = new CommandEntry( aliases[0], aliases.Skip(1).ToArray(), @@ -53,7 +58,17 @@ public void RegisterCommand( if (!_commands.TryAdd(alias, entry)) { _logger.Warning("Console command '{Command}' redefined", alias); + var displaced = _commands[alias]; _commands[alias] = entry; + + // Keep help output truthful: drop every other key that still points at the + // displaced entry, so its stale alias list is no longer reported. + foreach (var staleKey in _commands.Where(pair => ReferenceEquals(pair.Value, displaced)) + .Select(pair => pair.Key) + .ToArray()) + { + _commands.TryRemove(staleKey, out _); + } } } } diff --git a/tests/SquidStd.Tests/ConsoleCommands/CommandSystemServiceTests.cs b/tests/SquidStd.Tests/ConsoleCommands/CommandSystemServiceTests.cs index deb2407..b58ec18 100644 --- a/tests/SquidStd.Tests/ConsoleCommands/CommandSystemServiceTests.cs +++ b/tests/SquidStd.Tests/ConsoleCommands/CommandSystemServiceTests.cs @@ -139,4 +139,29 @@ public void RegisterSameName_ReplacesAndWarns() var definition = Assert.Single(service.GetRegisteredCommands()); Assert.Equal("second", definition.Description); } + + [Fact] + public void RegisterCommand_OnlyPipes_Throws() + { + var service = new CommandSystemService(_ => { }); + + Assert.Throws(() => service.RegisterCommand("|", _ => Task.CompletedTask)); + } + + [Fact] + public async Task RedefiningAnAlias_RemovesTheDisplacedEntryEntirely() + { + var lines = new List(); + var service = new CommandSystemService(lines.Add); + service.RegisterCommand("gc|x", _ => Task.CompletedTask, "old"); + service.RegisterCommand("x", _ => Task.CompletedTask, "new"); + + var definitions = service.GetRegisteredCommands(); + var redefined = Assert.Single(definitions, definition => definition.Name == "x"); + Assert.Equal("new", redefined.Description); + Assert.DoesNotContain(definitions, definition => definition.Name == "gc"); + + await service.ExecuteCommandAsync("gc"); + Assert.Contains(lines, line => line.Contains("Unknown command", StringComparison.OrdinalIgnoreCase)); + } } From 4b89d0eb9d9bdb48c0d1e1ed0a0520eab7bdab08 Mon Sep 17 00:00:00 2001 From: Tom Date: Fri, 3 Jul 2026 16:49:54 +0200 Subject: [PATCH 09/11] feat(bootstrap): shared shutdown lifetime ending RunAsync with graceful Ctrl+C --- .../Bootstrap/ISquidStdBootstrap.cs | 4 +- .../Interfaces/Lifecycle/ISquidStdLifetime.cs | 13 +++ .../Services/Bootstrap/SquidStdBootstrap.cs | 26 ++++- .../Lifecycle/SquidStdLifetimeService.cs | 33 +++++++ .../Bootstrap/SquidStdLifetimeTests.cs | 97 +++++++++++++++++++ 5 files changed, 170 insertions(+), 3 deletions(-) create mode 100644 src/SquidStd.Core/Interfaces/Lifecycle/ISquidStdLifetime.cs create mode 100644 src/SquidStd.Services.Core/Services/Lifecycle/SquidStdLifetimeService.cs create mode 100644 tests/SquidStd.Tests/Bootstrap/SquidStdLifetimeTests.cs 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..ead4ff8 --- /dev/null +++ b/src/SquidStd.Services.Core/Services/Lifecycle/SquidStdLifetimeService.cs @@ -0,0 +1,33 @@ +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; + } + + _shutdownSource.Cancel(); + } + + /// + 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); + } +} From b664bb8b2a81da65b409c1b958314de223fcf16f Mon Sep 17 00:00:00 2001 From: Tom Date: Fri, 3 Jul 2026 16:55:51 +0200 Subject: [PATCH 10/11] feat(consolecommands): exit requests shutdown through the shared lifetime --- docs/articles/concepts/bootstrap-lifecycle.md | 8 ++++++ .../ConsoleCommandsRegistrationExtensions.cs | 3 +++ .../Internal/BuiltinConsoleCommands.cs | 18 +++++++++++-- src/SquidStd.ConsoleCommands/README.md | 3 ++- .../ConsoleCommands/BuiltinCommandsTests.cs | 26 +++++++++++++++++-- 5 files changed, 53 insertions(+), 5 deletions(-) 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..4619093 100644 --- a/src/SquidStd.ConsoleCommands/README.md +++ b/src/SquidStd.ConsoleCommands/README.md @@ -71,7 +71,8 @@ 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. ## Configuration 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; From f00634f5dfa17f02fdc0821bec62bec763bdf848 Mon Sep 17 00:00:00 2001 From: Tom Date: Fri, 3 Jul 2026 17:06:29 +0200 Subject: [PATCH 11/11] refactor(bootstrap): post-dispose-safe RequestShutdown and manual-host note in the README --- src/SquidStd.ConsoleCommands/README.md | 4 +++- .../Services/Lifecycle/SquidStdLifetimeService.cs | 9 ++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/SquidStd.ConsoleCommands/README.md b/src/SquidStd.ConsoleCommands/README.md index 4619093..d340334 100644 --- a/src/SquidStd.ConsoleCommands/README.md +++ b/src/SquidStd.ConsoleCommands/README.md @@ -72,7 +72,9 @@ internal sealed class PingCommand : IConsoleCommandExecutor The builtin commands `help` (`?`), `clear` (`cls`) and `exit` (`quit`) are registered by `AddConsoleCommands()`. `exit` requests a graceful shutdown through `ISquidStdLifetime` - with a -`RunAsync` host the process stops its services in order and exits cleanly. +`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.Services.Core/Services/Lifecycle/SquidStdLifetimeService.cs b/src/SquidStd.Services.Core/Services/Lifecycle/SquidStdLifetimeService.cs index ead4ff8..a5946ee 100644 --- a/src/SquidStd.Services.Core/Services/Lifecycle/SquidStdLifetimeService.cs +++ b/src/SquidStd.Services.Core/Services/Lifecycle/SquidStdLifetimeService.cs @@ -21,7 +21,14 @@ public void RequestShutdown() return; } - _shutdownSource.Cancel(); + try + { + _shutdownSource.Cancel(); + } + catch (ObjectDisposedException) + { + // Requested after the bootstrap disposed the lifetime: idempotent no-op. + } } ///