test(script-worker): prove Shadow non-blocking causally, not by stopwatch (#798)
ShadowModeTests' runaway case asserted "slowest < 250 ms" around evaluations whose timing included cold Roslyn compile/JIT of new script text, so it failed on a healthy published path (540-880 ms measured, both SDK baselines). The test now puts a test-owned barrier runner (GatedShadow) between the real ShadowScriptRunner and the real worker pool. The runaway's first shadow run is held before it reaches the pool, then (after it runs away in the worker, the pool answers T and the worker is KilledDeadline with the runaway as culprit) its outcome is held before comparison. Published evaluations made while a barrier is closed must return the in-process values; a Mirror that waited for its shadow hangs into a 30 s liveness failure (mutation-checked). Output correctness, pool kill/quarantine, published-side never quarantined, the single runaway mismatch (sibling is a victim, not compared) and key_busy drops are kept. The wall-clock number moves to an opt-in Category=Perf case (Shadow_published_path_latency_while_the_shadow_runs_away, OTOPCUA_SCRIPT_PERF=1) on warmed evaluators, reporting hardware and load average. Sibling: ContainmentTests' hard-deadline case timed the runaway's first (cold-compile) run; it now compiles the runaway's source first with an input that does not spin.
This commit is contained in:
@@ -61,10 +61,14 @@ public sealed class ContainmentTests
|
||||
await using var runner = Scripts.Runner(Scripts.Options(hardGrace: grace, quarantineAfterKills: 2));
|
||||
|
||||
(await runner.RunAsync(Scripts.Vt("sibling", Healthy), Ct)).Value.ShouldBe(7);
|
||||
// Compile the runaway's source first (it only spins when told to), so the stopwatch below times the soft answer
|
||||
// and the kill, not the worker's first compile of new script text (lmxopcua#798's flaw, in its runner form).
|
||||
const string runaway = "if ((int)ctx.GetTag(\"spin\").Value == 1) { while (true) { } } return 0;";
|
||||
(await runner.RunAsync(Scripts.Vt("runaway", runaway, softT, Scripts.Input("spin", 0)), Ct)).Value.ShouldBe(0);
|
||||
var worker = runner.Shards[0].Current.ShouldNotBeNull();
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var outcome = await runner.RunAsync(Scripts.Vt("runaway", Runaway, softT), Ct);
|
||||
var outcome = await runner.RunAsync(Scripts.Vt("runaway", runaway, softT, Scripts.Input("spin", 1)), Ct);
|
||||
var softElapsed = sw.Elapsed;
|
||||
outcome.Status.ShouldBe(ScriptRunStatus.Failed);
|
||||
outcome.Reason!.ShouldStartWith("script timed out after ");
|
||||
@@ -90,12 +94,12 @@ public sealed class ContainmentTests
|
||||
|
||||
// A second kill quarantines (QuarantineAfterKills = 2); the third trigger is refused without running.
|
||||
var second = runner.Shards[0].Current!;
|
||||
(await runner.RunAsync(Scripts.Vt("runaway", Runaway, softT), Ct)).Status.ShouldBe(ScriptRunStatus.Failed);
|
||||
(await runner.RunAsync(Scripts.Vt("runaway", runaway, softT, Scripts.Input("spin", 1)), Ct)).Status.ShouldBe(ScriptRunStatus.Failed);
|
||||
(await second.Exited.WaitAsync(TimeSpan.FromSeconds(10), Ct)).Reason.ShouldBe(WorkerExitReason.KilledDeadline);
|
||||
// The strike is recorded by the shard's exit handler, which runs asynchronously to the Exited completion.
|
||||
await Scripts.Eventually(() => runner.QuarantinedKeys(ScriptContextKind.VirtualTag).Count == 1,
|
||||
TimeSpan.FromSeconds(5), "the second kill strike");
|
||||
var refused = await runner.RunAsync(Scripts.Vt("runaway", Runaway, softT), Ct);
|
||||
var refused = await runner.RunAsync(Scripts.Vt("runaway", runaway, softT, Scripts.Input("spin", 1)), Ct);
|
||||
refused.Reason!.ShouldStartWith(ScriptRunGuard.QuarantinedReasonPrefix);
|
||||
refused.Reason.ShouldContain("hard deadline");
|
||||
runner.QuarantinedKeys(ScriptContextKind.VirtualTag).ShouldBe(["runaway"]);
|
||||
|
||||
Reference in New Issue
Block a user