fix(siteruntime): injectable ScriptExecutionScheduler + un-leakable expression-fault test

Gitea #18: the process-wide ScriptExecutionScheduler was reached via a static
singleton (Shared) directly inside ScriptActor, ScriptExecutionActor, AlarmActor,
AlarmExecutionActor, and ScriptSchedulerStatsReporter, so it could not be
substituted — a shared mutable global for anything running more than one logical
site in a process, most visibly the test assembly. Add an optional scheduler
injection seam to all five: the Host injects nothing and gets the shared pool
(byte-for-byte unchanged), while a test (or a future multi-site host) hands an
actor its own instance. Spawning actors thread their scheduler down to the
execution children they create. Shared() now also recreates a disposed cached
instance rather than returning it (new IsDisposed guard), so a disposed scheduler
can never silently poison every later script/alarm execution.

Gitea #16: ScriptActorTests now runs on its OWN scheduler (disposed at class
teardown), so the faulted-task test can no longer strand a worker on the
process-wide pool and starve unrelated test classes (one prior run failed 22
tests). EvalGate's wait is bounded to 30 s (was unbounded — the actual leak
mechanism) and reset per test; the teardown releases one permit per worker
instead of Release(Entries), which under-released in exactly the failure case.
Full SiteRuntime suite: 6/6 runs green (524/524); was 3/6 failing on clean main.

Fixes: #18
Fixes: #16
This commit was merged in pull request #21.
This commit is contained in:
Joseph Doherty
2026-07-17 15:36:39 -04:00
parent 3e84eee195
commit b1b4874090
9 changed files with 205 additions and 37 deletions
@@ -41,6 +41,15 @@ public class AlarmActor : ReceiveActor
private readonly ISiteHealthCollector? _healthCollector;
private readonly IServiceProvider? _serviceProvider;
/// <summary>
/// Script-execution scheduler seam (#18): the process-wide
/// <see cref="ScriptExecutionScheduler"/> when null, or an injected instance so this
/// alarm's trigger-expression evaluation and spawned on-trigger scripts run on a
/// caller-owned pool. Resolved lazily at each use so the null (host) path is
/// unchanged.
/// </summary>
private readonly ScriptExecutionScheduler? _scheduler;
/// <summary>
/// The optional site operational-event log, resolved once from
/// <see cref="_serviceProvider"/> at construction and cached. The
@@ -125,6 +134,7 @@ public class AlarmActor : ReceiveActor
/// <param name="onTriggerExecutionTimeoutSeconds">The on-trigger script's per-script
/// execution timeout in seconds (from its <see cref="ResolvedScript.ExecutionTimeoutSeconds"/>),
/// or null/non-positive to use the global default.</param>
/// <param name="scheduler">Optional script-execution scheduler override (#18); null uses the process-wide shared scheduler.</param>
public AlarmActor(
string alarmName,
string instanceName,
@@ -139,7 +149,9 @@ public class AlarmActor : ReceiveActor
ISiteHealthCollector? healthCollector = null,
IServiceProvider? serviceProvider = null,
// Per-script timeout for the on-trigger script (null = global).
int? onTriggerExecutionTimeoutSeconds = null)
int? onTriggerExecutionTimeoutSeconds = null,
// Script-execution scheduler seam (#18); null uses the process-wide shared scheduler.
ScriptExecutionScheduler? scheduler = null)
{
_alarmName = alarmName;
_instanceName = instanceName;
@@ -149,6 +161,7 @@ public class AlarmActor : ReceiveActor
_logger = logger;
_healthCollector = healthCollector;
_serviceProvider = serviceProvider;
_scheduler = scheduler;
// Resolve the optional site event logger once and cache it,
// rather than calling GetService on every alarm transition.
_siteEventLogger = serviceProvider?.GetService<ISiteEventLogger>();
@@ -554,7 +567,7 @@ public class AlarmActor : ReceiveActor
return false;
}
}, CancellationToken.None, TaskCreationOptions.DenyChildAttach,
ScriptExecutionScheduler.Shared(_options)).Unwrap().PipeTo(self,
_scheduler ?? ScriptExecutionScheduler.Shared(_options)).Unwrap().PipeTo(self,
success: r => new ExpressionEvalResult(r),
failure: ex => new ExpressionEvalFailed(ex));
}
@@ -685,7 +698,10 @@ public class AlarmActor : ReceiveActor
// Per-script timeout from the on-trigger script (null = global).
_onTriggerExecutionTimeoutSeconds,
// The firing context's execution id (null today).
parentExecutionId));
parentExecutionId,
// Scheduler seam (#18): share this alarm's scheduler override with the
// spawned on-trigger script body (null = process-wide shared).
_scheduler));
Context.ActorOf(props, executionId);
}