Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.ScriptWorker.Tests/SupervisionTests.cs
T
Joseph Doherty 4cd648b029 test(scriptworker): cold-burst heartbeat test kills at production's 3 s silence limit, self-diagnosing (#843)
Reproduced 10/10 under 28 CPU burners (load 100-250): every run killed for
157-309 ms of silence with every compile on its dedicated thread - the OS
scheduler, not the pool. A 1 s limit still failed 1/10 at load ~300
(1,012 ms). The limit is now production's (3 x the default 1 s heartbeat):
10/10 at load 360-430. The failure message now carries the burst length,
the limit and the kill line.

Also corrects the doc: its mutation check (compile on the pool again =>
killed for silence) no longer holds - re-checked on a 14-core Mac, the
mutated worker passed at both 150 ms and 1 s, because the host's silence
clock counts any frame and since #789 each compile writes its own
LoadStarted/LoadResult frames inline.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-11 04:36:29 -05:00

742 lines
44 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Diagnostics.Metrics;
using System.Text;
using System.Text.Json;
using Serilog;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
using ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess;
using ZB.MOM.WW.OtOpcUa.Core.Scripting.Protocol;
using ZB.MOM.WW.OtOpcUa.Core.Scripting.Protocol.V1;
namespace ZB.MOM.WW.OtOpcUa.ScriptWorker.Tests;
/// <summary>
/// lmxopcua#780 P1 supervision gaps: the quarantine race, victim re-evaluation on a shard's return, the idle
/// isolation worker, the startup orphan sweep, culprit-finding for a compile that crashes its worker, and the
/// runner state / crash-loop circuit. Real worker processes throughout.
/// </summary>
[Collection(ScriptWorkerCollection.Name)]
[Trait("Category", "ScriptWorker")]
public sealed class SupervisionTests
{
private const string Healthy = "return 7;";
private const string Runaway = "while (true) { }";
private const string Spin = "var ms = (int)ctx.GetTag(\"ms\").Value; var end = ctx.Now.AddMilliseconds(ms); while (ctx.Now < end) { } return ms;";
private static IReadOnlyDictionary<string, DataValueSnapshot> Ms(int ms) => Scripts.Input("ms", ms);
private static CancellationToken Ct => TestContext.Current.CancellationToken;
/// <summary>
/// A runner whose workers heartbeat every <paramref name="heartbeat"/> but start with a generous silence limit:
/// the cold start (launch, first compile, Roslyn's JIT) is not what the tight-heartbeat tests are about, and on a
/// loaded box it can pause the worker for longer than three short intervals (lmxopcua#827 audit: 2 in 10 runs
/// under 28 busy processes on 14 cores were killed for 160 ms of silence during the warm-up compile, and the
/// replacement workers were too). <see cref="WarmTight"/> then tightens the limit to three intervals for the
/// phase under test.
/// </summary>
private static OutOfProcessScriptRunner TightHeartbeatRunner(TimeSpan heartbeat, TimeSpan loadTimeout, CapturingSink sink)
=> new(Scripts.Options(), sink.CreateLogger(), null, heartbeatInterval: heartbeat, loadTimeout: loadTimeout,
heartbeatSilenceLimit: TimeSpan.FromSeconds(30));
/// <summary>Warms the shard's worker with <paramref name="key"/>, then arms the three-interval silence kill.</summary>
private static WorkerConnection WarmTight(OutOfProcessScriptRunner runner, ScriptRunOutcome warm, TimeSpan heartbeat, CapturingSink sink)
{
warm.Value.ShouldBe(7, $"{warm.Status} {warm.Reason}\n{string.Join(Environment.NewLine, sink.Rendered)}");
var worker = runner.Shards[0].Current.ShouldNotBeNull();
worker.SilenceLimit = heartbeat * 3;
return worker;
}
private static string VtKey(string key) => InProcessScriptRunner.ScopedKey(ScriptContextKind.VirtualTag, key);
// ---------------------------------------------------------------- the quarantine race
/// <summary>
/// The ordering invariant behind the race fix, pinned deterministically at the connection: the exit attribution
/// callback runs BEFORE any outstanding evaluation or compile is completed and before <c>Exited</c> is signalled.
/// Mutation check: moving the attribution call after the completion loop in <c>WorkerConnection.Finish</c> turns
/// both assertions red.
/// </summary>
[Fact]
public async Task Exit_attribution_runs_before_any_outstanding_evaluation_completes()
{
var spec = new WorkerLaunchSpec("t", OutOfProcessScriptRunner.ResolveWorkerPath(new ScriptWorkerOptions()),
256L * 1024 * 1024, EvalThreads: 2, HardGrace: TimeSpan.FromSeconds(60), HeartbeatInterval: TimeSpan.FromSeconds(1),
HandshakeTimeout: TimeSpan.FromSeconds(15));
PendingEval? pending = null;
WorkerConnection? connection = null;
bool? pendingCompletedAtAttribution = null, exitedCompletedAtAttribution = null;
IReadOnlyList<string>? inFlightAtAttribution = null;
connection = await WorkerConnection.StartAsync(spec, new LoggerConfiguration().CreateLogger(), _ => { }, exit =>
{
pendingCompletedAtAttribution = pending?.Completion.IsCompleted;
exitedCompletedAtAttribution = connection?.Exited.IsCompleted;
inFlightAtAttribution = exit.InFlightKeys;
}, Ct);
await using var _ = connection;
var entry = await connection.EnsureLoadedAsync("k", ContextKind.VirtualTag, Runaway,
System.Security.Cryptography.SHA256.HashData(Encoding.UTF8.GetBytes(Runaway)), []).WaitAsync(TimeSpan.FromSeconds(60), Ct);
entry!.Outcome!.Ok.ShouldBeTrue();
pending = connection.Submit("k", new EvalRequest { Key = "k", SoftTimeoutMs = 60_000, HardGraceMs = 60_000, NowUtcTicks = DateTime.UtcNow.Ticks });
await Scripts.Eventually(() => connection.LastHeartbeat is { Inflight: 1 }, TimeSpan.FromSeconds(10), "the run in flight");
Process.GetProcessById(connection.Pid).Kill();
await connection.Exited.WaitAsync(TimeSpan.FromSeconds(10), Ct);
(await pending.Completion.WaitAsync(TimeSpan.FromSeconds(5), Ct)).ShouldBeNull("the worker went away");
inFlightAtAttribution.ShouldBe(["k"]);
pendingCompletedAtAttribution.ShouldBe(false, "attribution must precede the evaluation's completion");
exitedCompletedAtAttribution.ShouldBe(false, "attribution must precede Exited");
}
/// <summary>
/// The other half of the race fix, pinned without timing: the shard hands the connection an attribution callback
/// that reaches the runner's exit handler SYNCHRONOUSLY (the pre-fix shard called it from an Exited continuation,
/// after the evaluations had already been completed). Mutation check: launching with a no-op callback and
/// attributing from the Exited continuation instead leaves <c>attributed</c> false when the callback returns.
/// </summary>
[Fact]
public async Task The_shard_attributes_an_exit_synchronously_inside_the_connections_callback()
{
Action<WorkerExit>? captured = null;
var attributed = false;
await using var shard = new WorkerShard("t", isolation: false,
(onExiting, _) =>
{
captured = onExiting;
throw new InvalidOperationException("no process in this test");
},
(_, _) => attributed = true,
new LoggerConfiguration().CreateLogger());
(await shard.GetReadyAsync(TimeSpan.FromSeconds(5), Ct)).ShouldBeNull();
captured.ShouldNotBeNull();
captured(new WorkerExit(WorkerExitReason.Crash, null, 1, ["k"]));
attributed.ShouldBeTrue("the runner's verdict is recorded before the callback returns");
}
/// <summary>
/// A burst of cold compiles must not starve the worker's heartbeat. Found on windev by the §8 perf harness: 500
/// loads at once, each compiled synchronously on its own thread-pool work item, queued the heartbeat timer and
/// the pipe writer behind them; the host saw no frame for 3 s, killed the shard (KilledHeartbeat) and made all 500
/// keys suspects. Here a 50 ms heartbeat (kill after 3 s of silence, production's limit) and 1,500 distinct cold scripts at once:
/// every script answers and the shard's worker is the one that started.
/// <para>⚠️ The old "mutation check: compile on the thread pool again and the worker is killed for silence" does
/// NOT hold any more (re-checked for lmxopcua#843 on a 14-core Mac: the mutated worker passed at both the 150 ms
/// and the 1 s limit). The host's silence clock counts ANY frame, and since lmxopcua#789 a compile writes its own
/// <c>LoadStarted</c>/<c>LoadResult</c> frames inline — so pool-queued compiles keep the pipe busy themselves (the 3 s silence
/// windev saw presumably also needed the pre-#789 pool-scheduled writer stuck behind them). What this still pins is the
/// behaviour: a cold burst under a tight heartbeat is answered without a kill.</para>
/// </summary>
[Fact]
public async Task A_burst_of_cold_compiles_does_not_starve_the_heartbeat()
{
var sink = new CapturingSink();
var heartbeat = TimeSpan.FromMilliseconds(50);
await using var runner = TightHeartbeatRunner(heartbeat, TimeSpan.FromSeconds(120), sink);
var worker = WarmTight(runner, await runner.RunAsync(Scripts.Vt("first", Healthy), Ct), heartbeat, sink);
// lmxopcua#843: three 50 ms intervals (150 ms) is a limit the OS scheduler alone breaks on an overloaded box — at
// load average 100–280 the worker or the host's reader waited 150–310 ms for a core (10/10 runs killed for silence
// under 28 CPU burners, every compile on its dedicated thread), and at ~300 even a 1 s limit was hit once in 10
// (1,012 ms). The limit is now PRODUCTION's (3 × the default 1 s heartbeat): the test kills only where a real node
// would, and the windev defect (no frame for 3 s, the whole burst) is still the size it catches. The 50 ms
// heartbeat keeps frames flowing for the silence clock.
worker.SilenceLimit = TimeSpan.FromSeconds(3);
const int count = 1500;
var burst = Stopwatch.StartNew();
var outcomes = await Task.WhenAll(Enumerable.Range(0, count).Select(i => runner.RunAsync(
Scripts.Vt("burst-" + i, $"return (int)ctx.GetTag(\"x\").Value * {i} + 1;", TimeSpan.FromSeconds(60),
Scripts.Input("x", 2)), Ct)));
burst.Stop();
// The kill line ("sent nothing for N ms") against the burst's length is the diagnosis: N close to the burst is
// the pool-starvation defect, N just over the limit is a starved box.
var log = $"burst took {burst.ElapsedMilliseconds} ms, silence limit {worker.SilenceLimit.TotalMilliseconds} ms\n"
+ string.Join(Environment.NewLine, sink.Rendered
.Where(l => l.Contains("ScriptWorker", StringComparison.Ordinal))
.Where(l => l.Contains("sent nothing", StringComparison.Ordinal) || l.Contains("kill", StringComparison.OrdinalIgnoreCase)
|| l.Contains("exit", StringComparison.OrdinalIgnoreCase))
.Take(10));
for (var i = 0; i < count; i++) outcomes[i].Value.ShouldBe(2 * i + 1, $"{outcomes[i].Status} {outcomes[i].Reason}\n{log}");
runner.Shards[0].Current.ShouldNotBeNull().Pid.ShouldBe(worker.Pid, log);
worker.Exited.IsCompleted.ShouldBeFalse(log);
}
/// <summary>
/// Single flight must not refuse a caller's NEXT run because of the run it already awaited: the slot is freed
/// before the awaiting caller resumes. The §8 perf harness found the slot freed by a continuation unordered
/// against the caller's await, so 32 of 200 back-to-back runs of one key came back "previous evaluation still
/// running" — which the VirtualTag and Calculation seams publish as Bad. Since lmxopcua#789 the release runs in
/// the connection, on the reader, before the caller's resumption is handed to the result dispatcher; the second
/// phase makes every release slow (the test hook sleeps inside it) so the ordering, not luck, is what keeps the
/// caller from being refused. Mutation check: release after handing off the resumption and the second phase goes
/// red.
/// </summary>
[Fact]
public async Task A_caller_that_awaited_its_run_is_never_refused_by_it()
{
await using var runner = Scripts.Runner(Scripts.Options());
var refused = new List<string>();
for (var i = 0; i < 1_000; i++)
{
var outcome = await runner.RunAsync(
Scripts.Vt("seq", "return (int)ctx.GetTag(\"x\").Value + 7;", inputs: Scripts.Input("x", i)), Ct);
if (outcome.Status != ScriptRunStatus.Ok || !Equals(outcome.Value, i + 7)) refused.Add($"#{i}: {outcome.Reason}");
}
refused.ShouldBeEmpty();
runner.SlotReleasingForTest = _ => Thread.Sleep(5);
for (var i = 0; i < 50; i++)
{
var outcome = await runner.RunAsync(
Scripts.Vt("seq", "return (int)ctx.GetTag(\"x\").Value + 7;", inputs: Scripts.Input("x", i)), Ct);
if (outcome.Status != ScriptRunStatus.Ok || !Equals(outcome.Value, i + 7)) refused.Add($"slow release #{i}: {outcome.Reason}");
}
refused.ShouldBeEmpty();
}
/// <summary>
/// The race itself, end to end: with QuarantineAfterKills = 1, the moment the killed script's single-flight slot is
/// released the quarantine is already in place — so no trigger can re-run it in the restart gap, and the next
/// trigger is refused without any wait. Repeated, since the old ordering lost this race only some of the time.
/// </summary>
[Fact]
public async Task A_killed_script_is_quarantined_before_its_slot_is_released()
{
await using var runner = Scripts.Runner(Scripts.Options(hardGrace: TimeSpan.FromMilliseconds(250), quarantineAfterKills: 1));
var observed = new ConcurrentQueue<bool>();
runner.SlotReleasingForTest = key =>
{
if (key == VtKey("runaway")) observed.Enqueue(runner.QuarantinedKeys(ScriptContextKind.VirtualTag).Contains("runaway"));
};
(await runner.RunAsync(Scripts.Vt("sibling", Healthy), Ct)).Value.ShouldBe(7);
for (var round = 1; round <= 3; round++)
{
var worker = runner.Shards[0].Current.ShouldNotBeNull();
(await runner.RunAsync(Scripts.Vt("runaway", Runaway, TimeSpan.FromMilliseconds(250)), Ct)).Reason!
.ShouldStartWith("script timed out after ");
await worker.Exited.WaitAsync(TimeSpan.FromSeconds(10), Ct);
await Scripts.Eventually(() => observed.Count == round, TimeSpan.FromSeconds(5), "the slot release");
// No Eventually: the very next trigger after the kill is refused.
var next = await runner.RunAsync(Scripts.Vt("runaway", Runaway, TimeSpan.FromMilliseconds(250)), Ct);
next.Reason!.ShouldStartWith(ScriptRunGuard.QuarantinedReasonPrefix);
runner.Reset(ScriptContextKind.VirtualTag);
(await runner.RunAsync(Scripts.Vt("sibling", Healthy), Ct)).Value.ShouldBe(7);
}
observed.ShouldAllBe(quarantined => quarantined, "the slot was released before the kill strike was recorded");
}
// ---------------------------------------------------------------- victims re-evaluated on return
/// <summary>
/// A victim's Unavailable outcome carries a Recovery task that completes when its shard is back — and the shard
/// comes back on its own (proactive restart), with no further trigger. Mutation check: without the shard's
/// recovery loop, Recovery never completes and no worker is running.
/// </summary>
[Fact]
public async Task A_victims_recovery_completes_when_its_shard_restarts_by_itself()
{
await using var runner = Scripts.Runner(Scripts.Options());
(await runner.RunAsync(Scripts.Vt("victim", Spin, inputs: Ms(0)), Ct)).Value.ShouldBe(0);
(await runner.RunAsync(Scripts.Vt("sibling", Healthy), Ct)).Value.ShouldBe(7);
var worker = runner.Shards[0].Current.ShouldNotBeNull();
var inFlight = runner.RunAsync(Scripts.Vt("sibling", Spin, TimeSpan.FromSeconds(20), Ms(5000)), Ct);
await Scripts.Eventually(() => worker.InFlightCount == 1, TimeSpan.FromSeconds(10), "the run in flight");
Process.GetProcessById(worker.Pid).Kill();
var victim = await inFlight.WaitAsync(TimeSpan.FromSeconds(10), Ct);
victim.Status.ShouldBe(ScriptRunStatus.Unavailable);
_ = victim.Recovery.ShouldNotBeNull("a runner failure tells the seam when to re-evaluate");
victim.ToVirtualTagEvalResult().RetryWhen.ShouldBeSameAs(victim.Recovery);
// The crash made "sibling" a suspect, so its recovery is the isolation worker's; the crashed shard restarts by
// itself as well, so the scripts placed on it find it warm.
await victim.Recovery!.WaitAsync(TimeSpan.FromSeconds(15), Ct);
runner.Isolation.Current.ShouldNotBeNull("recovered without any new trigger");
await Scripts.Eventually(() => runner.Shards[0].Current, TimeSpan.FromSeconds(15), "the shard back");
runner.Shards[0].Current!.Pid.ShouldNotBe(worker.Pid);
(await runner.RunAsync(Scripts.Vt("sibling", Spin, inputs: Ms(0)), Ct)).Value.ShouldBe(0);
}
// ---------------------------------------------------------------- the idle isolation worker
/// <summary>
/// The isolation worker stops after IsolationIdleTimeout with nothing to run — an Idle exit, not a failure — and
/// starts again for the next suspect. Mutation check: with the maintenance pass not stopping it, it stays up.
/// </summary>
[Fact]
public async Task An_idle_isolation_worker_is_stopped_and_restarts_on_demand()
{
await using var runner = Scripts.Runner(Scripts.Options(isolationIdle: TimeSpan.FromSeconds(1)),
maintenanceInterval: TimeSpan.FromMilliseconds(200));
(await runner.RunAsync(Scripts.Vt("spin", Spin, inputs: Ms(0)), Ct)).Value.ShouldBe(0);
var worker = runner.Shards[0].Current.ShouldNotBeNull();
var run = runner.RunAsync(Scripts.Vt("spin", Spin, TimeSpan.FromSeconds(20), Ms(5000)), Ct);
await Scripts.Eventually(() => worker.InFlightCount == 1, TimeSpan.FromSeconds(10), "the run in flight");
Process.GetProcessById(worker.Pid).Kill();
(await run.WaitAsync(TimeSpan.FromSeconds(10), Ct)).Status.ShouldBe(ScriptRunStatus.Unavailable);
runner.SuspectKeys.ShouldBe([VtKey("spin")]);
(await runner.RunAsync(Scripts.Vt("spin", Spin, inputs: Ms(0)), Ct)).Value.ShouldBe(0);
var iso = runner.Isolation.Current.ShouldNotBeNull();
var exit = await iso.Exited.WaitAsync(TimeSpan.FromSeconds(10), Ct);
exit.Reason.ShouldBe(WorkerExitReason.Idle);
runner.Isolation.Current.ShouldBeNull();
runner.Isolation.ConsecutiveFailures.ShouldBe(0, "an idle stop is not a failure");
runner.Isolation.DownFor.ShouldBeNull("an idle stop does not make the isolation worker 'down'");
await Scripts.Eventually(() => runner.State == ScriptRunnerState.Ready, TimeSpan.FromSeconds(15), "the runner ready again");
(await runner.RunAsync(Scripts.Vt("spin", Spin, inputs: Ms(0)), Ct)).Value.ShouldBe(0);
runner.Isolation.Current.ShouldNotBeNull().Pid.ShouldNotBe(iso.Pid, "a fresh isolation worker for the next suspect run");
}
// ---------------------------------------------------------------- the orphan sweep
private static Process StartDecoyWorker()
{
// A real ScriptWorker that will sit waiting (up to 10 s) for a pipe that does not exist: the shape of a worker
// its host abandoned.
var psi = new ProcessStartInfo(WorkerConnection.ResolveMuxer())
{
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardError = true,
RedirectStandardOutput = true,
};
psi.ArgumentList.Add("exec");
psi.ArgumentList.Add(OutOfProcessScriptRunner.ResolveWorkerPath(new ScriptWorkerOptions()));
psi.Environment[ScriptWorkerEnvironment.PipeName] = "otopcua-sw-d" + Guid.NewGuid().ToString("N")[..8];
psi.Environment[ScriptWorkerEnvironment.Nonce] = "decoy";
var p = Process.Start(psi)!;
Thread.Sleep(300);
if (p.HasExited) throw new InvalidOperationException($"the decoy worker exited {p.ExitCode}: {p.StandardError.ReadToEnd()}");
return p;
}
private static (int Pid, long StartTicks) DeadProcess()
{
var psi = new ProcessStartInfo(WorkerConnection.ResolveMuxer(), "--version")
{
UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true,
};
using var p = Process.Start(psi)!;
var start = p.StartTime.ToUniversalTime().Ticks;
var pid = p.Id;
p.WaitForExit();
return (pid, start);
}
private static string WriteMarker(string dir, Process worker, int hostPid, long hostStartTicks, long? workerStartTicks = null)
{
Directory.CreateDirectory(dir);
var record = new WorkerLaunchMarker.MarkerRecord(worker.Id, workerStartTicks ?? worker.StartTime.ToUniversalTime().Ticks,
worker.ProcessName, hostPid, hostStartTicks, "decoy");
var path = Path.Combine(dir, worker.Id + ".worker");
File.WriteAllText(path, JsonSerializer.Serialize(record));
return path;
}
/// <summary>
/// Design §3.3: a worker left behind by a DEAD host of this instance is killed at the next start — through the
/// runner's constructor, so the wiring is pinned too. Mutation check: without the Sweep call the decoy survives.
/// </summary>
[Fact]
public async Task The_startup_sweep_kills_a_worker_whose_host_is_gone()
{
using var dir = new Scripts.TempDir();
using var decoy = StartDecoyWorker();
var (deadPid, deadStart) = DeadProcess();
var marker = WriteMarker(dir.Path, decoy, deadPid, deadStart);
await using var runner = Scripts.Runner(Scripts.Options(), markerDirectory: dir.Path);
(await Task.Run(() => decoy.WaitForExit(5000), Ct)).ShouldBeTrue("the orphan was killed");
File.Exists(marker).ShouldBeFalse();
}
/// <summary>
/// Never an unrelated process: a marker whose host is ALIVE (another instance, or a sibling runner in this
/// process) is left alone, and a marker whose pid now belongs to a different process (start time differs —
/// pid reuse) never kills it. Mutation checks: dropping either the host-alive check or the start-time check
/// kills the decoy.
/// </summary>
[Fact]
public void The_sweep_never_kills_a_live_hosts_worker_or_a_reused_pid()
{
using var dir = new Scripts.TempDir();
using var decoy = StartDecoyWorker();
using var self = Process.GetCurrentProcess();
try
{
var liveHostMarker = WriteMarker(dir.Path, decoy, self.Id, self.StartTime.ToUniversalTime().Ticks);
WorkerLaunchMarker.Sweep(dir.Path, new LoggerConfiguration().CreateLogger()).ShouldBeEmpty();
decoy.HasExited.ShouldBeFalse("a live host's worker is not an orphan");
File.Exists(liveHostMarker).ShouldBeTrue("the live host still owns its marker");
var (deadPid, deadStart) = DeadProcess();
var reused = WriteMarker(dir.Path, decoy, deadPid, deadStart,
workerStartTicks: decoy.StartTime.ToUniversalTime().AddHours(-1).Ticks);
WorkerLaunchMarker.Sweep(dir.Path, new LoggerConfiguration().CreateLogger()).ShouldBeEmpty();
decoy.HasExited.ShouldBeFalse("a pid whose start time does not match is some other process");
File.Exists(reused).ShouldBeFalse("a stale marker is cleaned up");
}
finally
{
try { decoy.Kill(); } catch { /* already gone */ }
}
}
/// <summary>A worker's marker exists while it runs and is removed when it stops cleanly.</summary>
[Fact]
public async Task A_running_worker_has_a_launch_marker_that_its_clean_stop_removes()
{
using var dir = new Scripts.TempDir();
var runner = Scripts.Runner(Scripts.Options(), markerDirectory: dir.Path);
(await runner.RunAsync(Scripts.Vt("vt", Healthy), Ct)).Value.ShouldBe(7);
var pid = runner.Shards[0].Current!.Pid;
var marker = Path.Combine(dir.Path, pid + ".worker");
File.Exists(marker).ShouldBeTrue();
JsonSerializer.Deserialize<WorkerLaunchMarker.MarkerRecord>(File.ReadAllText(marker))!.HostPid.ShouldBe(Environment.ProcessId);
await runner.DisposeAsync();
File.Exists(marker).ShouldBeFalse();
}
// ---------------------------------------------------------------- a compile that crashes its worker
/// <summary>
/// Design §5.3.5 culprit-finding: a worker that dies while a script is COMPILING (no evaluation in flight) makes
/// that script a suspect; it then compiles alone in the isolation worker, where a second death is unambiguous and
/// quarantines it — so a poison source cannot crash-loop its shard forever. Mutation check: leaving the compiling
/// keys out of the attribution leaves the key unsuspected and unquarantined.
/// </summary>
[Fact]
public async Task A_script_whose_compile_keeps_crashing_its_worker_is_found_in_isolation_and_quarantined()
{
await using var runner = Scripts.Runner(Scripts.Options(quarantineAfterKills: 1));
var body = new StringBuilder("long x = 0;\n");
for (var i = 0; i < 3000; i++) body.Append("x = x * 31 + ").Append(i).Append(";\n");
body.Append("return x;");
var poison = body.ToString();
async Task<ScriptRunOutcome> CrashDuringCompile(Func<WorkerConnection?> worker)
{
var run = runner.RunAsync(Scripts.Vt("poison", poison), Ct);
var live = await Scripts.Eventually(worker, TimeSpan.FromSeconds(15), "a live worker");
await Scripts.Eventually(() => live.LoadingCount == 1, TimeSpan.FromSeconds(15), "the compile in flight");
live.InFlightCount.ShouldBe(0, "no evaluation: only the compile is in flight");
Process.GetProcessById(live.Pid).Kill();
return await run.WaitAsync(TimeSpan.FromSeconds(15), Ct);
}
var first = await CrashDuringCompile(() => runner.Shards[0].Current);
first.Status.ShouldBe(ScriptRunStatus.Unavailable);
runner.SuspectKeys.ShouldBe([VtKey("poison")], "a death during its compile makes it a suspect");
runner.QuarantinedKeys(ScriptContextKind.VirtualTag).ShouldBeEmpty("ambiguous on a shard: no strike");
await CrashDuringCompile(() => runner.Isolation.Current);
runner.QuarantinedKeys(ScriptContextKind.VirtualTag).ShouldBe(["poison"]);
var refused = await runner.RunAsync(Scripts.Vt("poison", poison), Ct);
refused.Reason!.ShouldStartWith(ScriptRunGuard.QuarantinedReasonPrefix);
refused.Reason.ShouldContain("compile crashed the isolation worker");
(await runner.RunAsync(Scripts.Vt("sibling", Healthy), Ct)).Value.ShouldBe(7);
}
// ---------------------------------------------------------------- state, circuit, gauges
[Theory]
[InlineData(new double[] { }, null, ScriptRunnerState.Ready)]
[InlineData(new double[] { -1, -1 }, null, ScriptRunnerState.Ready)]
[InlineData(new double[] { -1, 5 }, null, ScriptRunnerState.Degraded)]
[InlineData(new double[] { -1, -1 }, 5.0, ScriptRunnerState.Degraded)]
[InlineData(new double[] { 200, 5 }, null, ScriptRunnerState.Degraded)]
[InlineData(new double[] { 200, 130 }, null, ScriptRunnerState.Unavailable)]
public void The_runner_state_rule(double[] shardsDownSeconds, double? isolationDownSeconds, ScriptRunnerState expected)
{
var shards = shardsDownSeconds.Select(s => s < 0 ? (TimeSpan?)null : TimeSpan.FromSeconds(s)).ToArray();
var iso = isolationDownSeconds is { } i ? TimeSpan.FromSeconds(i) : (TimeSpan?)null;
OutOfProcessScriptRunner.ComputeState(shards, iso, TimeSpan.FromMinutes(2)).ShouldBe(expected);
}
/// <summary>
/// The crash-loop circuit: every shard down past CircuitOpenAfter makes the runner Unavailable (gauge value 2),
/// logs one Error, and answers evaluations at once instead of after UnavailableGrace. Mutation check: without the
/// zero grace under an open circuit, the evaluation waits the full 3 s.
/// </summary>
[Fact]
public async Task Every_shard_down_opens_the_circuit_answers_at_once_and_reports_state_2()
{
var sink = new CapturingSink();
var missing = Path.Combine(Path.GetTempPath(), "no-such-dir-" + Guid.NewGuid().ToString("N"), OutOfProcessScriptRunner.WorkerAssemblyFileName);
await using var runner = Scripts.Runner(
Scripts.Options(workerPath: missing, unavailableGrace: TimeSpan.FromSeconds(3), circuitOpenAfter: TimeSpan.FromSeconds(1)),
sink.CreateLogger(), maintenanceInterval: TimeSpan.FromMilliseconds(100));
runner.State.ShouldBe(ScriptRunnerState.Ready, "nothing needed yet");
(await runner.RunAsync(Scripts.Vt("vt", "return 1;"), Ct)).Status.ShouldBe(ScriptRunStatus.Unavailable);
runner.State.ShouldNotBe(ScriptRunnerState.Ready);
await Scripts.Eventually(() => runner.State == ScriptRunnerState.Unavailable, TimeSpan.FromSeconds(10), "the circuit open");
var sw = Stopwatch.StartNew();
var fast = await runner.RunAsync(Scripts.Vt("vt", "return 1;"), Ct);
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(1), "an open circuit answers without waiting out the 3 s grace");
fast.Status.ShouldBe(ScriptRunStatus.Unavailable);
fast.Reason.ShouldBe(ScriptRunOutcome.RunnerUnavailableReason);
await Scripts.Eventually(() => sink.Rendered.Any(m => m.Contains("circuit OPEN")), TimeSpan.FromSeconds(5), "the circuit Error");
await Task.Delay(500, Ct);
sink.Events.Count(e => e.Level == Serilog.Events.LogEventLevel.Error && e.RenderMessage().Contains("circuit OPEN"))
.ShouldBe(1, "logged once, not per maintenance pass");
var states = new List<int>();
using var listener = new MeterListener();
listener.InstrumentPublished = (instrument, l) =>
{
if (instrument.Name == "otopcua.script.runner.state") l.EnableMeasurementEvents(instrument);
};
listener.SetMeasurementEventCallback<int>((_, value, _, _) => states.Add(value));
listener.Start();
listener.RecordObservableInstruments();
states.ShouldContain(2);
runner.Status().State.ShouldBe(ScriptRunnerState.Unavailable);
}
/// <summary>The per-shard gauges observe the live worker (positive control: the shard is up and reports its pid).</summary>
[Fact]
public async Task The_shard_gauges_report_a_live_worker()
{
await using var runner = Scripts.Runner(Scripts.Options());
(await runner.RunAsync(Scripts.Vt("vt", Healthy), Ct)).Value.ShouldBe(7);
await Scripts.Eventually(() => runner.Shards[0].Current?.LastHeartbeat is not null, TimeSpan.FromSeconds(5), "a heartbeat");
var up = new List<(int Value, string? Shard)>();
var heap = new List<long>();
using var listener = new MeterListener();
listener.InstrumentPublished = (instrument, l) =>
{
if (instrument.Name is "otopcua.script.worker.up" or "otopcua.script.worker.heap_bytes") l.EnableMeasurementEvents(instrument);
};
listener.SetMeasurementEventCallback<int>((_, value, tags, _) =>
{
foreach (var tag in tags) if (tag.Key == "shard") up.Add((value, tag.Value as string));
});
listener.SetMeasurementEventCallback<long>((_, value, _, _) => heap.Add(value));
listener.Start();
listener.RecordObservableInstruments();
up.ShouldContain((1, "0"));
up.ShouldContain((0, "iso"), "the isolation worker is not running");
heap.ShouldContain(h => h > 0);
var status = runner.Status();
status.State.ShouldBe(ScriptRunnerState.Ready);
status.Shards.Single(s => s.Label == "0").Pid.ShouldBe(runner.Shards[0].Current!.Pid);
}
// ---------------------------------------------------------------- lmxopcua#789: who resumes the caller
/// <summary>
/// lmxopcua#789: callers resume on the connection's result dispatcher, never on the pipe reader — so a consumer
/// that blocks after its await (the Calculation pump publishes to OPC UA subscribers there) cannot stop the reader
/// from taking heartbeats and get a healthy worker killed for silence. Here a 100 ms heartbeat (kill after 300 ms
/// of silence) and a continuation that blocks for 1.5 s. Mutation check: resume callers inline on the reader (a
/// synchronous-continuation TaskCompletionSource completed by the reader, no dispatcher) and the worker is killed
/// (KilledHeartbeat).
/// </summary>
[Fact]
public async Task A_caller_that_blocks_after_its_result_does_not_starve_the_reader()
{
var sink = new CapturingSink();
var heartbeat = TimeSpan.FromMilliseconds(100);
await using var runner = TightHeartbeatRunner(heartbeat, TimeSpan.FromSeconds(60), sink);
var worker = WarmTight(runner, await runner.RunAsync(Scripts.Vt("warm", Healthy), Ct), heartbeat, sink);
string? resumedOn = null;
await runner.RunAsync(Scripts.Vt("warm", Healthy), Ct).ContinueWith(t =>
{
// Runs wherever RunAsync's own continuation completed it: the thread that resumed the caller.
resumedOn = Thread.CurrentThread.Name;
t.Result.Value.ShouldBe(7);
Thread.Sleep(1_500);
}, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
// Behaviour first: the worker was not killed for silence while the caller blocked.
(await Task.WhenAny(worker.Exited, Task.Delay(1_000, Ct))).ShouldNotBe(worker.Exited,
string.Join(Environment.NewLine, sink.Rendered.Where(l => l.Contains("ScriptWorker", StringComparison.Ordinal))));
var log = string.Join(Environment.NewLine, sink.Rendered.Where(l => l.Contains("ScriptWorker", StringComparison.Ordinal)));
resumedOn.ShouldBe("ScriptWorker[0].Results", log);
(await runner.RunAsync(Scripts.Vt("warm", Healthy), Ct)).Value.ShouldBe(7);
runner.Shards[0].Current.ShouldNotBeNull().Pid.ShouldBe(worker.Pid, log);
}
// ---------------------------------------------------------------- lmxopcua#788: start-failure logging
/// <summary>
/// lmxopcua#788: a shard that keeps failing to start logs ONE Error with the exception for the down period; its
/// retries are a Warning without a stack, carrying the attempt count, rate-limited (the rest at Debug); and the
/// start that ends the outage is one Information line naming how many attempts failed. The docker-dev drill logged
/// 28 Error+stack entries in two minutes on one node before this. Mutation check: log every failure at Error with
/// the exception again and the single-Error assertion goes red (3 Errors).
/// </summary>
[Theory]
[InlineData(0, 2, 0)]
[InlineData(3_600_000, 0, 2)]
public async Task A_shard_that_cannot_start_logs_one_error_then_concise_rate_limited_retries_then_its_recovery(
int warnEveryMs, int expectedWarnings, int expectedDebugs)
{
var sink = new CapturingSink();
var log = sink.CreateLogger();
var spec = new WorkerLaunchSpec("t", OutOfProcessScriptRunner.ResolveWorkerPath(new ScriptWorkerOptions { ExecutablePath = Scripts.WorkerUnderTest }),
256L * 1024 * 1024, EvalThreads: 2, HardGrace: TimeSpan.FromSeconds(5), HeartbeatInterval: TimeSpan.FromSeconds(1),
HandshakeTimeout: TimeSpan.FromSeconds(15));
var attempts = 0;
await using var shard = new WorkerShard("t", isolation: false,
(onExiting, ct) => Interlocked.Increment(ref attempts) <= 3
? throw new InvalidOperationException("the worker exited (code 1) before connecting to its pipe")
: WorkerConnection.StartAsync(spec, log, _ => { }, onExiting, ct),
(_, _) => { }, log, startFailureWarningInterval: TimeSpan.FromMilliseconds(warnEveryMs));
// The background recovery retries at the backoff (250 ms, 500 ms, 1 s, ±20 %) until the 4th attempt starts.
await shard.WhenRecoveredAsync(null).WaitAsync(TimeSpan.FromSeconds(30), Ct);
shard.Current.ShouldNotBeNull();
attempts.ShouldBe(4);
var events = sink.Events.Where(e => e.MessageTemplate.Text.StartsWith("ScriptWorker[{Shard}]", StringComparison.Ordinal)).ToList();
string Dump() => string.Join(Environment.NewLine, events.Select(e => $"{e.Level} {e.RenderMessage()} {(e.Exception is null ? "" : "+exception")}"));
static int Attempts(Serilog.Events.LogEvent e) => int.Parse(e.Properties["Attempts"].ToString(), System.Globalization.CultureInfo.InvariantCulture);
var errors = events.Where(e => e.Level == Serilog.Events.LogEventLevel.Error).ToList();
errors.Count.ShouldBe(1, Dump());
errors[0].Exception.ShouldNotBeNull("the first failure of the outage carries the stack");
var warnings = events.Where(e => e.Level == Serilog.Events.LogEventLevel.Warning).ToList();
warnings.Count.ShouldBe(expectedWarnings, Dump());
warnings.ShouldAllBe(e => e.Exception == null);
warnings.Select(Attempts).ShouldBe(expectedWarnings == 0 ? [] : [2, 3]);
events.Count(e => e.Level == Serilog.Events.LogEventLevel.Debug && e.Properties.ContainsKey("Attempts")).ShouldBe(expectedDebugs, Dump());
var recovered = events.Single(e => e.Level == Serilog.Events.LogEventLevel.Information && e.Properties.ContainsKey("Attempts"));
Attempts(recovered).ShouldBe(3);
recovered.RenderMessage().ShouldContain("after 3 failed start attempt(s)");
}
// ---------------------------------------------------------------- lmxopcua#790: compile timeout from compile start
/// <summary>A script of <paramref name="lines"/> statements: Roslyn needs time roughly proportional to it.</summary>
private static string Lines(int lines, int salt)
{
var body = new StringBuilder("var a = ").Append(salt).Append(";\n");
for (var i = 0; i < lines; i++) body.Append("a += (int)ctx.GetTag(\"x\").Value * ").Append(i % 97).Append(";\n");
return body.Append("return a;").ToString();
}
private static int LinesValue(int lines, int salt) => salt + Enumerable.Range(0, lines).Sum(i => i % 97);
/// <summary>
/// lmxopcua#790: the compile timeout runs from when a compile thread STARTS a load, so a queue of compiles that
/// takes several load timeouts to drain kills nothing and strikes nobody. One compile thread; the compiler is
/// warmed under a generous timeout (a cold Roslyn start alone takes seconds on the Windows VM), then the live
/// connection's timeout drops to four times one measured compile (at least 3 s; a 500-statement script takes
/// ~0.1 s on an M-series Mac and ~1.5 s on the Windows VM) and a burst of such scripts, sized from that and grown
/// until it does, queues for over three timeouts — asserted, so the test cannot pass vacuously
/// — while each compile stays far inside the timeout. Mutation check: time the load from when the host SENT it
/// (the old <c>WaitAsync(LoadTimeout)</c> around the load) and the worker is killed and the queued keys are
/// struck.
/// </summary>
[Fact]
public async Task Compiles_queued_behind_others_longer_than_the_load_timeout_are_not_killed_or_struck()
{
const int lines = 500;
var sink = new CapturingSink();
await using var runner = new OutOfProcessScriptRunner(Scripts.Options(), sink.CreateLogger(), null,
heartbeatInterval: TimeSpan.FromSeconds(1), loadTimeout: TimeSpan.FromSeconds(120), compileThreads: 1);
(await runner.RunAsync(Scripts.Vt("first", Healthy), Ct)).Value.ShouldBe(7);
var worker = runner.Shards[0].Current.ShouldNotBeNull();
ScriptInvocation Script(string prefix, int i)
=> Scripts.Vt($"{prefix}-{i}", Lines(lines, i), TimeSpan.FromSeconds(300), Scripts.Input("x", 1));
// Warm the compiler, then calibrate: the compile time of one such script on this box.
for (var i = 0; i < 3; i++) (await runner.RunAsync(Script("warm", i), Ct)).Value.ShouldBe(LinesValue(lines, i));
var probe = Stopwatch.StartNew();
for (var i = 0; i < 3; i++) (await runner.RunAsync(Script("probe", i), Ct)).Value.ShouldBe(LinesValue(lines, i));
var perCompile = probe.Elapsed / 3;
var loadTimeout = TimeSpan.FromTicks(Math.Max(TimeSpan.FromSeconds(3).Ticks, perCompile.Ticks * 4));
worker.LoadTimeout = loadTimeout;
var count = (int)Math.Clamp(Math.Ceiling(7 * loadTimeout / perCompile), 10, 300);
// A burst compiles faster per script than the sequential probe (the compiler keeps warming up), so grow the burst
// until it really outlasts three load timeouts; each burst must pass the same no-kill checks.
ScriptRunOutcome[] outcomes = [];
var elapsed = TimeSpan.Zero;
for (var round = 0; round < 4; round++)
{
runner.Reset(ScriptContextKind.VirtualTag); // unload the previous round's scripts (worker heap)
var sw = Stopwatch.StartNew();
var prefix = "queued" + round;
outcomes = await Task.WhenAll(Enumerable.Range(0, count).Select(i => runner.RunAsync(Script(prefix, i), Ct)));
elapsed = sw.Elapsed;
for (var i = 0; i < count; i++) outcomes[i].Value.ShouldBe(LinesValue(lines, i), $"round {round}: {outcomes[i].Status} {outcomes[i].Reason}");
if (elapsed > loadTimeout * 3) break;
count = Math.Min(1_200, count * 2);
}
var log = string.Join(Environment.NewLine, sink.Rendered.Where(l => l.Contains("ScriptWorker", StringComparison.Ordinal)).Take(5));
elapsed.ShouldBeGreaterThan(loadTimeout * 3,
$"precondition: {count} compiles (probe {perCompile.TotalMilliseconds:F0} ms each) must queue for several load timeouts, or this proves nothing");
worker.Exited.IsCompleted.ShouldBeFalse(log);
runner.Shards[0].Current.ShouldNotBeNull().Pid.ShouldBe(worker.Pid, log);
runner.QuarantinedKeys(ScriptContextKind.VirtualTag).ShouldBeEmpty();
runner.SuspectKeys.ShouldBeEmpty();
sink.Rendered.ShouldNotContain(l => l.Contains("killing the worker", StringComparison.Ordinal));
}
/// <summary>
/// The positive control for the test above: a compile that is still running <c>LoadTimeout</c> after it STARTED is
/// the script's fault — the worker is killed (<c>KilledLoadTimeout</c>), the caller gets the compile-timeout
/// failure, the key takes a kill strike (the second quarantines it), and the healthy key on the same shard is
/// not blamed. The healthy key comes from a primed PE cache, so a fresh worker's cold Roslyn start never counts
/// against the short timeout. Mutation check: never arm the timer on <c>LoadStarted</c> and the huge compile
/// simply finishes (status Ok, red).
/// </summary>
[Fact]
public async Task A_compile_still_running_a_load_timeout_after_it_started_is_killed_and_struck()
{
using var dir = new Scripts.TempDir();
var cache = Path.Combine(dir.Path, "pe-cache");
await using (var primer = Scripts.Runner(Scripts.Options(peCacheDir: cache)))
(await primer.RunAsync(Scripts.Vt("first", Healthy), Ct)).Value.ShouldBe(7);
var sink = new CapturingSink();
await using var runner = new OutOfProcessScriptRunner(Scripts.Options(quarantineAfterKills: 2, peCacheDir: cache),
sink.CreateLogger(), null, heartbeatInterval: TimeSpan.FromSeconds(1), loadTimeout: TimeSpan.FromSeconds(2), compileThreads: 1);
(await runner.RunAsync(Scripts.Vt("first", Healthy), Ct)).Value.ShouldBe(7);
// 20,000 statements: about 4 s of Roslyn on an M-series Mac, longer elsewhere.
var huge = Scripts.Vt("huge", Lines(20_000, 0), TimeSpan.FromSeconds(30), Scripts.Input("x", 1));
for (var strike = 1; strike <= 2; strike++)
{
var before = runner.Shards[0].Current ?? await runner.Shards[0].GetReadyAsync(TimeSpan.FromSeconds(30), Ct);
var outcome = await runner.RunAsync(huge, Ct).WaitAsync(TimeSpan.FromSeconds(60), Ct);
outcome.Status.ShouldBe(ScriptRunStatus.Failed, $"strike {strike}");
outcome.Reason.ShouldNotBeNull().ShouldContain("compile did not finish");
var exit = await before.ShouldNotBeNull().Exited.WaitAsync(TimeSpan.FromSeconds(10), Ct);
exit.Reason.ShouldBe(WorkerExitReason.KilledLoadTimeout);
exit.CulpritKey.ShouldBe(VtKey("huge"));
}
var third = await runner.RunAsync(huge, Ct);
third.Reason.ShouldNotBeNull().ShouldStartWith(ScriptRunGuard.QuarantinedReasonPrefix);
runner.QuarantinedKeys(ScriptContextKind.VirtualTag).ShouldBe(["huge"]);
(await runner.RunAsync(Scripts.Vt("first", Healthy), Ct)).Value.ShouldBe(7, "the healthy key on the same shard is not blamed");
}
}