diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/SandboxTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/SandboxTests.cs
index eb5aa9f8..fabd199b 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/SandboxTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/SandboxTests.cs
@@ -216,14 +216,57 @@ public class SandboxTests
Assert.True(result.IsSuccess);
}
- // ── Execution timeout ──
+ // ── Execution cancellation ──
+ //
+ // These two tests used to arm a wall-clock CancellationTokenSource
+ // (500 ms / 100 ms) and hope it expired while the script body was still
+ // running. That is a race between a fixed timer and a fixed amount of
+ // work, not a synchronization, and it is wrong in both directions:
+ //
+ // * Too slow a timer relative to the work and the script simply
+ // FINISHES first, so nothing is cancelled and ThrowsAnyAsync fails
+ // with "No exception was thrown". The bounded loop below was measured
+ // at 298 ms against a 100 ms pin on this machine — a 3x margin, which
+ // a faster host (or any change to the scripting OptimizationLevel,
+ // which is Debug today) closes. Reproduced deterministically by
+ // shrinking the loop to 1M iterations: it then ran in 76 ms and the
+ // test failed with exactly that message while all 27 siblings passed.
+ // * Too fast a timer and the token is ALREADY cancelled before the body
+ // is entered — Roslyn's runner throws OperationCanceledException up
+ // front (verified), so the test goes GREEN without the script's own
+ // in-loop ThrowIfCancellationRequested ever being reached. A vacuous
+ // pass is worse than a flake: it asserts nothing.
+ //
+ // Both are replaced with a deterministic edge. The script triggers the
+ // cancellation itself, synchronously, from inside its own loop via an
+ // Action handed in through Parameters. When that call returns, the token
+ // is already cancelled ON THE SAME THREAD, so the next in-loop check is
+ // guaranteed to observe it — with iterations to spare and no reliance on
+ // wall-clock time, host speed or scheduler behaviour. Sandbox_UncancelledScript_RunsToCompletion
+ // is the negative control for the bounded case.
+
+ /// Builds globals whose "cancelNow" parameter cancels when the script invokes it.
+ private static ScriptGlobals GlobalsWithCancelHook(CancellationTokenSource cts, Action? onSignal = null)
+ => new()
+ {
+ Instance = null!,
+ Parameters = new ScriptParameters(new Dictionary
+ {
+ ["cancelNow"] = onSignal ?? (() => cts.Cancel())
+ }),
+ CancellationToken = cts.Token
+ };
[Fact]
public async Task Sandbox_InfiniteLoop_CancelledByToken()
{
- // Compile a script that loops forever
+ // An unbounded loop that cancels itself on the first pass, so the
+ // check on that same pass is the one that throws.
var code = """
+ var spins = 0;
while (true) {
+ spins++;
+ if (spins == 1) ((Action)Parameters["cancelNow"]!)();
CancellationToken.ThrowIfCancellationRequested();
}
return null;
@@ -232,14 +275,8 @@ public class SandboxTests
var result = _service.Compile("infinite", code);
Assert.True(result.IsSuccess, "Infinite loop compiles but should be cancelled at runtime");
- // Execute with a short timeout
- using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
- var globals = new ScriptGlobals
- {
- Instance = null!,
- Parameters = new ScriptParameters(),
- CancellationToken = cts.Token
- };
+ using var cts = new CancellationTokenSource();
+ var globals = GlobalsWithCancelHook(cts);
await Assert.ThrowsAnyAsync(async () =>
{
@@ -250,11 +287,16 @@ public class SandboxTests
[Fact]
public async Task Sandbox_LongRunningScript_TimesOut()
{
- // A script that does heavy computation with cancellation checks
+ // Heavy bounded computation with periodic cancellation checks. It
+ // cancels itself at i == 1, leaving ~100M iterations and ~10,000
+ // cancellation checks still ahead of it, so interruption is certain
+ // however fast the host runs — the point being that a long script
+ // does not get to run to completion once its deadline has fired.
var code = """
- var sum = 0;
+ var sum = 0L;
for (var i = 0; i < 100_000_000; i++) {
sum += i;
+ if (i == 1) ((Action)Parameters["cancelNow"]!)();
if (i % 10000 == 0) CancellationToken.ThrowIfCancellationRequested();
}
return sum;
@@ -263,13 +305,8 @@ public class SandboxTests
var result = _service.Compile("heavy", code);
Assert.True(result.IsSuccess);
- using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(100));
- var globals = new ScriptGlobals
- {
- Instance = null!,
- Parameters = new ScriptParameters(),
- CancellationToken = cts.Token
- };
+ using var cts = new CancellationTokenSource();
+ var globals = GlobalsWithCancelHook(cts);
await Assert.ThrowsAnyAsync(async () =>
{
@@ -277,6 +314,38 @@ public class SandboxTests
});
}
+ [Fact]
+ public async Task Sandbox_UncancelledScript_RunsToCompletion()
+ {
+ // Negative control for Sandbox_LongRunningScript_TimesOut: the SAME
+ // script shape, with the signal wired to a no-op instead of Cancel().
+ // It must run every iteration and return the closed-form sum. This is
+ // what proves the sibling's OperationCanceledException is caused by
+ // the cancellation and not by the loop being unreachable, the trust
+ // model rejecting the body, or the runner failing for its own reasons.
+ var code = """
+ var sum = 0L;
+ for (var i = 0; i < 100_000_000; i++) {
+ sum += i;
+ if (i == 1) ((Action)Parameters["cancelNow"]!)();
+ if (i % 10000 == 0) CancellationToken.ThrowIfCancellationRequested();
+ }
+ return sum;
+ """;
+
+ var result = _service.Compile("heavy-control", code);
+ Assert.True(result.IsSuccess);
+
+ using var cts = new CancellationTokenSource();
+ var signalled = false;
+ var globals = GlobalsWithCancelHook(cts, onSignal: () => signalled = true);
+
+ var value = await result.CompiledScript!.RunAsync(globals, cts.Token);
+
+ Assert.True(signalled, "The script's in-loop signal must actually fire.");
+ Assert.Equal(4999999950000000L, value.ReturnValue);
+ }
+
// ── Combined adversarial attempts ──
[Fact]