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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/articles/concepts/bootstrap-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/tutorials/scripting-lua.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down
12 changes: 12 additions & 0 deletions samples/SquidStd.Samples.ScriptingLua/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -38,6 +40,8 @@
container.RegisterInstance(engineConfig);
container.RegisterStdService<IScriptEngineService, LuaScriptEngineService>();
container.RegisterGeneratedScriptModules();
container.RegisterScriptModule<LogModule>();
container.RegisterLuaEvents();

return container;
}
Expand All @@ -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
Expand Down
11 changes: 11 additions & 0 deletions src/SquidStd.Core/Data/Bootstrap/EngineStartedEvent.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using SquidStd.Core.Interfaces.Events;

namespace SquidStd.Core.Data.Bootstrap;

/// <summary>
/// Published on the event bus when every service has started.
/// </summary>
/// <param name="Application">The resolved application name.</param>
/// <param name="ServiceCount">Number of services started.</param>
/// <param name="ElapsedMs">Total startup time in milliseconds.</param>
public sealed record EngineStartedEvent(string Application, int ServiceCount, double ElapsedMs) : IEvent;
9 changes: 9 additions & 0 deletions src/SquidStd.Core/Data/Bootstrap/EngineStartingEvent.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using SquidStd.Core.Interfaces.Events;

namespace SquidStd.Core.Data.Bootstrap;

/// <summary>
/// Published on the event bus when the bootstrap begins starting services.
/// </summary>
/// <param name="Application">The resolved application name.</param>
public sealed record EngineStartingEvent(string Application) : IEvent;
9 changes: 9 additions & 0 deletions src/SquidStd.Core/Data/Bootstrap/EngineStoppedEvent.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using SquidStd.Core.Interfaces.Events;

namespace SquidStd.Core.Data.Bootstrap;

/// <summary>
/// Published on the event bus when the bootstrap has stopped its services.
/// </summary>
/// <param name="Application">The resolved application name.</param>
public sealed record EngineStoppedEvent(string Application) : IEvent;
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Registration helpers for the Lua events stack: bridge, events module and bus forwarder.
/// </summary>
public static class RegisterLuaEventsExtension
{
/// <param name="container">Container that receives the registrations.</param>
extension(IContainer container)
{
/// <summary>
/// Registers the Lua event bridge, the <c>events</c> script module and the bus forwarder
/// that delivers every published event to Lua callbacks by snake_case name
/// (<c>events.subscribe("engine_started", fn)</c>). The forwarder is a no-op when no
/// event bus is registered.
/// </summary>
/// <returns>The same container for chaining.</returns>
public IContainer RegisterLuaEvents()
{
container.Register<ILuaEventBridge, LuaEventBridge>(Reuse.Singleton, ifAlreadyRegistered: IfAlreadyRegistered.Keep);
container.RegisterScriptModule<EventsModule>();
container.RegisterStdService<LuaEventBusForwarder, LuaEventBusForwarder>();

return container;
}
}
}
4 changes: 4 additions & 0 deletions src/SquidStd.Scripting.Lua/Modules/EventsModule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
19 changes: 19 additions & 0 deletions src/SquidStd.Scripting.Lua/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
9 changes: 7 additions & 2 deletions src/SquidStd.Scripting.Lua/Services/LuaEventBridge.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@
namespace SquidStd.Scripting.Lua.Services;

/// <summary>
/// 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.
/// </summary>
public sealed class LuaEventBridge : ILuaEventBridge
{
private readonly ConcurrentDictionary<string, List<Closure>> _callbacks = new(StringComparer.OrdinalIgnoreCase);
private readonly Lock _invokeSync = new();
private readonly ILogger _logger = Log.ForContext<LuaEventBridge>();

private Script? _script;
Expand All @@ -30,7 +32,10 @@ public DynValue Invoke(Closure callback, IReadOnlyDictionary<string, object?> 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<string, object?> payload)
Expand Down
102 changes: 102 additions & 0 deletions src/SquidStd.Scripting.Lua/Services/LuaEventBusForwarder.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Forwards every event published on the event bus to the Lua event bridge, using the
/// snake_case convention: the CLR type name minus the <c>Event</c> suffix
/// (<c>EngineStartedEvent</c> becomes <c>engine_started</c>) with snake_case payload keys.
/// No-op when no event bus is registered.
/// </summary>
public sealed class LuaEventBusForwarder : ISquidStdService, IDisposable
{
private static readonly ConcurrentDictionary<Type, string> NameCache = new();
private static readonly ConcurrentDictionary<Type, (string Key, Func<object, object?> 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;
}

/// <inheritdoc />
public ValueTask StartAsync(CancellationToken cancellationToken = default)
{
if (_bus is not null)
{
_subscription = _bus.Subscribe<IEvent>(ForwardAsync);
}

return ValueTask.CompletedTask;
}

/// <inheritdoc />
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<object, object?> 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<object, object?>)(instance => property.GetValue(instance))))
]
);

private Task ForwardAsync(IEvent eventData, CancellationToken cancellationToken)
{
try
{
var payload = new Dictionary<string, object?>(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<LuaEventBusForwarder>()
.Warning(ex, "Failed to forward event {EventType} to Lua", eventData.GetType().Name);
}

Check notice

Code scanning / CodeQL

Generic catch clause Note

Generic catch clause.
Comment on lines +87 to +91

return Task.CompletedTask;
}

/// <inheritdoc />
public void Dispose()
{
_subscription?.Dispose();
_subscription = null;
}
}
4 changes: 4 additions & 0 deletions src/SquidStd.Scripting.Lua/SquidStd.Scripting.Lua.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,8 @@
<ProjectReference Include="..\SquidStd.Core\SquidStd.Core.csproj"/>
</ItemGroup>

<ItemGroup>
<InternalsVisibleTo Include="SquidStd.Tests"/>
</ItemGroup>

</Project>
32 changes: 32 additions & 0 deletions src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -230,6 +231,8 @@
var registrations = GetServiceRegistrations();
LogRegistrations(logger, registrations);

await PublishEngineEventAsync(new EngineStartingEvent(appName), cancellationToken);

var startedInstances = new HashSet<ISquidStdService>(ReferenceEqualityComparer.Instance);
var totalStopwatch = Stopwatch.StartNew();

Expand Down Expand Up @@ -271,6 +274,11 @@
_startedServices.Count,
totalStopwatch.Elapsed.TotalMilliseconds
);

await PublishEngineEventAsync(
new EngineStartedEvent(appName, _startedServices.Count, totalStopwatch.Elapsed.TotalMilliseconds),
cancellationToken
);
}
catch
{
Expand Down Expand Up @@ -315,6 +323,19 @@
}

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");
}

Check notice

Code scanning / CodeQL

Generic catch clause Note

Generic catch clause.
Comment on lines +335 to +338
}
finally
{
Expand Down Expand Up @@ -431,6 +452,17 @@
_loggerConfigured = true;
}

private async Task PublishEngineEventAsync<TEvent>(TEvent engineEvent, CancellationToken cancellationToken)
where TEvent : IEvent
{
if (!Container.IsRegistered<IEventBus>())
{
return;
}

await Container.Resolve<IEventBus>().PublishAsync(engineEvent, cancellationToken);
}

private void ApplyConfigHooks()
{
if (_configHooks.Count == 0 && _configReadyCallbacks.Count == 0)
Expand Down
23 changes: 23 additions & 0 deletions templates/squidstd-host/Program.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -8,4 +12,23 @@

bootstrap.ConfigureServices(container => container.RegisterCoreServices());

// Tweak a loaded config section before services start (in-memory only).
bootstrap.OnConfigLoaded<SquidStdLoggerOptions>(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<IEventBus>();
bus.Subscribe<EngineStartedEvent>((e, _) =>
{
Console.WriteLine($"{e.Application} ready with {e.ServiceCount} service(s)");
return Task.CompletedTask;
});
bus.Subscribe<EngineStoppedEvent>((e, _) =>
{
Console.WriteLine($"{e.Application} stopped");
return Task.CompletedTask;
});

await bootstrap.RunAsync();
Loading
Loading