diff --git a/docs/articles/concepts/bootstrap-lifecycle.md b/docs/articles/concepts/bootstrap-lifecycle.md index fdcd558a..52a39c4d 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 8c1f968e..b8434730 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 0abed721..64910b74 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.Core/Data/Bootstrap/EngineStartedEvent.cs b/src/SquidStd.Core/Data/Bootstrap/EngineStartedEvent.cs new file mode 100644 index 00000000..a22b82e4 --- /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 00000000..9b698962 --- /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 00000000..59630654 --- /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.Scripting.Lua/Extensions/Scripts/RegisterLuaEventsExtension.cs b/src/SquidStd.Scripting.Lua/Extensions/Scripts/RegisterLuaEventsExtension.cs new file mode 100644 index 00000000..f4c132e1 --- /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 9137a3e8..12c851f3 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/README.md b/src/SquidStd.Scripting.Lua/README.md index db106846..6824e14e 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/src/SquidStd.Scripting.Lua/Services/LuaEventBridge.cs b/src/SquidStd.Scripting.Lua/Services/LuaEventBridge.cs index fe473fb3..a391c256 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 00000000..a63d8202 --- /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 7150ebf0..64eff403 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/src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs b/src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs index 644553c7..d2e70ba6 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/templates/squidstd-host/Program.cs b/templates/squidstd-host/Program.cs index 90b76695..5c6cfac2 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(); diff --git a/tests/SquidStd.Tests/Bootstrap/EngineEventsTests.cs b/tests/SquidStd.Tests/Bootstrap/EngineEventsTests.cs new file mode 100644 index 00000000..858fcb4e --- /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(); + } +} diff --git a/tests/SquidStd.Tests/Scripting/Lua/LuaEventBusForwarderTests.cs b/tests/SquidStd.Tests/Scripting/Lua/LuaEventBusForwarderTests.cs new file mode 100644 index 00000000..9de05f9d --- /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 f22dfd03..9b526db6 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() {