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]