diff --git a/docs/requirements/Component-SiteRuntime.md b/docs/requirements/Component-SiteRuntime.md
index 4aae2261..87561c90 100644
--- a/docs/requirements/Component-SiteRuntime.md
+++ b/docs/requirements/Component-SiteRuntime.md
@@ -216,6 +216,7 @@ When the Instance Actor is stopped (due to disable, delete, or redeployment), Ak
- Executes the script in the Akka actor context.
- Has access to the full Script Runtime API (see below).
- Returns the script's return value (if defined) to the caller, then stops.
+- The script body itself runs on the **dedicated `ScriptExecutionScheduler`** (a bounded set of dedicated threads), not the shared .NET thread pool, so blocking script I/O cannot starve the global pool or stall Akka dispatchers. The scheduler is **process-wide by default** (one pool per host, sized from `SiteRuntimeOptions.ScriptExecutionThreadCount`), but each script/alarm actor takes it through an **optional injection seam** rather than reaching for the static directly: the Host injects nothing and gets the shared pool, while tests (or a future multi-site host) can hand an actor its own instance. The shared accessor also recreates a disposed pool rather than returning it, so a disposed scheduler can never silently poison later executions.
### Handling `Instance.CallScript`
- When an external caller (another Script Execution Actor, an Alarm Execution Actor, or a routed call from the Inbound API) sends a `CallScript` message to the Script Actor, it spawns a Script Execution Actor to handle the call.
diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/AlarmActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/AlarmActor.cs
index 477c1fb1..2abb5f24 100644
--- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/AlarmActor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/AlarmActor.cs
@@ -41,6 +41,15 @@ public class AlarmActor : ReceiveActor
private readonly ISiteHealthCollector? _healthCollector;
private readonly IServiceProvider? _serviceProvider;
+ ///
+ /// Script-execution scheduler seam (#18): the process-wide
+ /// 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.
+ ///
+ private readonly ScriptExecutionScheduler? _scheduler;
+
///
/// The optional site operational-event log, resolved once from
/// at construction and cached. The
@@ -125,6 +134,7 @@ public class AlarmActor : ReceiveActor
/// The on-trigger script's per-script
/// execution timeout in seconds (from its ),
/// or null/non-positive to use the global default.
+ /// Optional script-execution scheduler override (#18); null uses the process-wide shared scheduler.
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();
@@ -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);
}
diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/AlarmExecutionActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/AlarmExecutionActor.cs
index f23fed30..2ad7d07e 100644
--- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/AlarmExecutionActor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/AlarmExecutionActor.cs
@@ -52,7 +52,10 @@ public class AlarmExecutionActor : ReceiveActor
// alarm on-trigger script. Null or non-positive falls back to the global.
int? executionTimeoutSeconds = null,
// The firing context's execution id (null today).
- Guid? parentExecutionId = null)
+ Guid? parentExecutionId = null,
+ // Script-execution scheduler seam (#18): the process-wide scheduler by
+ // default; null selects the shared default.
+ ScriptExecutionScheduler? scheduler = null)
{
var self = Self;
var parent = Context.Parent;
@@ -61,7 +64,7 @@ public class AlarmExecutionActor : ReceiveActor
alarmName, instanceName, level, priority, message,
compiledScript, instanceActor,
sharedScriptLibrary, options, self, parent, logger,
- executionTimeoutSeconds, parentExecutionId);
+ executionTimeoutSeconds, parentExecutionId, scheduler);
}
private static void ExecuteAlarmScript(
@@ -78,7 +81,8 @@ public class AlarmExecutionActor : ReceiveActor
IActorRef parent,
ILogger logger,
int? executionTimeoutSeconds,
- Guid? parentExecutionId)
+ Guid? parentExecutionId,
+ ScriptExecutionScheduler? scheduler)
{
// Per-script timeout overrides the global default. A null or
// non-positive per-script value (≤ 0) falls back to the global.
@@ -88,8 +92,9 @@ public class AlarmExecutionActor : ReceiveActor
: options.ScriptExecutionTimeoutSeconds);
// Run the alarm on-trigger body on the dedicated
- // script-execution scheduler, not the shared .NET thread pool.
- var scheduler = ScriptExecutionScheduler.Shared(options);
+ // script-execution scheduler, not the shared .NET thread pool. An injected
+ // scheduler (#18) overrides the process-wide default.
+ var executionScheduler = scheduler ?? ScriptExecutionScheduler.Shared(options);
_ = Task.Factory.StartNew(async () =>
{
@@ -157,6 +162,6 @@ public class AlarmExecutionActor : ReceiveActor
{
self.Tell(PoisonPill.Instance);
}
- }, CancellationToken.None, TaskCreationOptions.DenyChildAttach, scheduler).Unwrap();
+ }, CancellationToken.None, TaskCreationOptions.DenyChildAttach, executionScheduler).Unwrap();
}
}
diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/ScriptActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/ScriptActor.cs
index 65448f1f..cd7e4a98 100644
--- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/ScriptActor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/ScriptActor.cs
@@ -40,6 +40,15 @@ public class ScriptActor : ReceiveActor, IWithTimers
private readonly ISiteHealthCollector? _healthCollector;
private readonly IServiceProvider? _serviceProvider;
+ ///
+ /// Script-execution scheduler seam (#18): the process-wide
+ /// when null, or an injected instance so
+ /// this actor's trigger-expression evaluation and spawned script bodies run on a
+ /// caller-owned pool instead of the shared one. Resolved lazily at each use so the
+ /// null (host) path stays byte-for-byte identical to the previous static call.
+ ///
+ private readonly ScriptExecutionScheduler? _scheduler;
+
private Script
public static class EvalGate
{
- public static readonly SemaphoreSlim Gate = new(0);
+ private static SemaphoreSlim _gate = new(0);
private static int _entries;
+
+ /// The semaphore that script-scheduler threads park on inside .
+ public static SemaphoreSlim Gate => _gate;
+
+ /// Number of evaluations that have entered since the last .
public static int Entries => Volatile.Read(ref _entries);
- public static bool Block() { Interlocked.Increment(ref _entries); Gate.Wait(); return false; }
+
+ ///
+ /// Resets the gate to a fresh, permit-free state and zeroes the entry counter.
+ /// Called at the start of the test so counts never carry across runs — the old
+ /// static leaked _entries from whatever compiled an expression before it
+ /// (Gitea #16).
+ ///
+ public static void Reset()
+ {
+ _gate = new SemaphoreSlim(0);
+ Volatile.Write(ref _entries, 0);
+ }
+
+ ///
+ /// Counts the entry, then blocks the calling script-scheduler thread until a permit
+ /// is released. The wait is BOUNDED (30 s): even if a test releases too few permits,
+ /// a stranded evaluation frees its worker instead of parking it for the rest of the
+ /// process — the leak mechanism behind Gitea #16. Returns false so a trigger
+ /// expression built on it evaluates to "not fired".
+ ///
+ public static bool Block()
+ {
+ Interlocked.Increment(ref _entries);
+ _gate.Wait(TimeSpan.FromSeconds(30));
+ return false;
+ }
}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptExecutionSchedulerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptExecutionSchedulerTests.cs
index 86ad91f0..620a9c0e 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptExecutionSchedulerTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptExecutionSchedulerTests.cs
@@ -45,6 +45,24 @@ public class ScriptExecutionSchedulerTests
Assert.Same(a, b);
}
+ // Gitea #18: IsDisposed backs the Shared() recreate guard so a disposed scheduler
+ // can never be handed back and silently poison every later execution. Verified on a
+ // local instance — deliberately NOT by disposing the process-wide singleton, which
+ // would strand any script another test class queued on it (the very static hazard
+ // #18 is about).
+ [Fact]
+ public void IsDisposed_FlipsOnDispose()
+ {
+ var scheduler = new ScriptExecutionScheduler(1);
+ Assert.False(scheduler.IsDisposed);
+
+ scheduler.Dispose();
+ Assert.True(scheduler.IsDisposed);
+
+ scheduler.Dispose(); // idempotent — stays disposed, does not throw
+ Assert.True(scheduler.IsDisposed);
+ }
+
// SiteRuntime-S2/UA5: the scheduler exposes observability gauges so a stuck /
// saturated script-execution pool is visible to operators via site health.
[Fact]