feat(scripting): out-of-process pre-emptible script runner behind Scripting:Runner (lmxopcua#780 P1)
v2-ci / unit-tests (push) Waiting to run

Opt-in (Scripting:Runner=OutOfProcess; InProcess stays the default and its code path is unchanged). Implements
the P1 slice of docs/plans/2026-11-09-out-of-process-script-runner-design.md with the owner's §14 decisions:

- Options: Scripting:Runner (InProcess | Shadow | OutOfProcess), Scripting:Runner:Seams, Scripting:Worker:*
  (Shards 2, HardGrace max(1 s,T), HeapLimitBytes 512 MiB, QuarantineAfterKills 2, UnavailableGrace 5 s,
  IsolationProbationRuns 20, PeCacheDir validated absolute + outside the content root (fail closed),
  ExecutablePath). Shadow is not implemented yet: runs InProcess with a startup Warning.
- Core.Scripting: IScriptRunner seam + InProcessScriptRunner (today's guard, the parity baseline).
- Core.Scripting.Protocol: protobuf envelopes, 4-byte LE framing (zero/oversize/truncated/garbage refused),
  Variant carrying the exact CLR type; unsupported types are explicit failures.
- ScriptWorker app: compiles in the worker, P-thread scheduler, host-driven soft cancel, fail-fast
  watchdog, heartbeats, ctx.Logger forwarding, exits on pipe EOF / parent gone, forbidden-assembly
  self-check; Akka and Grpc.Core.Api excluded from its folder. Shipped in script-worker/ (Build + Publish).
- Core.Scripting.OutOfProcess: K shards + isolation worker, soft T / hard T+G kill, attribution
  (deadline kill = one key; crash = suspects -> isolation; isolation crash = strike), kill-strike
  quarantine, backoff 250 ms -> 30 s, fail closed, cleared allow-listed env, nonce + pid handshake on a
  current-user pipe, Windows Job Object.
- VirtualTag + Calculation seams use the runner when selected; a runner-side failure (worker restarted /
  runner unavailable) publishes BadResourceUnavailable (0x80040000) — ScadaBridge#112. Script faults keep
  BadInternalError. The scripted-alarm seam stays in process (alarms last).

Tests: new ZB.MOM.WW.OtOpcUa.ScriptWorker.Tests (Category=ScriptWorker, real processes; green on macOS and
on windev/Windows), seam tests in Host/Runtime/Calculation suites; mutation-checked.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-09 23:31:51 -05:00
parent f5c30ed0ef
commit 69a6e0b4c8
49 changed files with 5107 additions and 25 deletions
@@ -0,0 +1,170 @@
using Microsoft.Extensions.Configuration;
using Serilog;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.Configuration;
/// <summary>
/// lmxopcua#780 — <c>Scripting:Runner</c> binds by its documented keys (design §12), defaults to InProcess (every seam
/// gets no runner, so the in-process path is untouched), and the worker options clamp and fail closed.
/// </summary>
public sealed class ScriptRunnerSelectionTests
{
private static IConfiguration Config(Dictionary<string, string?> values) =>
new ConfigurationBuilder().AddInMemoryCollection(values).Build();
private static readonly ILogger Silent = new LoggerConfiguration().CreateLogger();
[Fact]
public void The_default_is_InProcess_and_selects_no_runner_for_any_seam()
{
var bound = ScriptingOptionsBinding.Bind(Config(new()), out var adjustments);
adjustments.ShouldBeEmpty();
bound.Runner.ShouldBe(ScriptRunnerMode.InProcess);
ScriptRunnerSelection.Validate(bound, "/srv/app").ShouldBeEmpty();
using var selection = ScriptRunnerSelection.Create(bound, Silent, null);
foreach (var seam in Enum.GetValues<ScriptSeam>()) selection.ForSeam(seam).ShouldBeNull(seam.ToString());
selection.OutOfProcessRunner.ShouldBeNull();
}
[Fact]
public void Every_documented_runner_key_binds()
{
var bound = ScriptingOptionsBinding.Bind(Config(new()
{
["Scripting:Runner"] = "OutOfProcess",
["Scripting:Runner:Seams:0"] = "Calculation",
["Scripting:Worker:Shards"] = "3",
["Scripting:Worker:HardGrace"] = "00:00:02",
["Scripting:Worker:HeapLimitBytes"] = "268435456",
["Scripting:Worker:QuarantineAfterKills"] = "4",
["Scripting:Worker:UnavailableGrace"] = "00:00:07",
["Scripting:Worker:IsolationProbationRuns"] = "9",
["Scripting:Worker:PeCacheDir"] = "/var/cache/otopcua-pe",
["Scripting:Worker:ExecutablePath"] = "/opt/w/ZB.MOM.WW.OtOpcUa.ScriptWorker.dll",
}), out var adjustments);
adjustments.ShouldBeEmpty();
bound.Runner.ShouldBe(ScriptRunnerMode.OutOfProcess);
bound.RunnerSeams.ShouldBe([ScriptSeam.Calculation]);
bound.Worker.Shards.ShouldBe(3);
bound.Worker.HardGrace.ShouldBe(TimeSpan.FromSeconds(2));
bound.Worker.HeapLimitBytes.ShouldBe(256L * 1024 * 1024);
bound.Worker.QuarantineAfterKills.ShouldBe(4);
bound.Worker.UnavailableGrace.ShouldBe(TimeSpan.FromSeconds(7));
bound.Worker.IsolationProbationRuns.ShouldBe(9);
bound.Worker.PeCacheDir.ShouldBe("/var/cache/otopcua-pe");
bound.Worker.ExecutablePath.ShouldBe("/opt/w/ZB.MOM.WW.OtOpcUa.ScriptWorker.dll");
bound.IsOutOfProcess(ScriptSeam.Calculation).ShouldBeTrue();
bound.IsOutOfProcess(ScriptSeam.VirtualTag).ShouldBeFalse();
}
[Fact]
public void A_misspelled_runner_mode_fails_startup()
=> Should.Throw<InvalidOperationException>(() =>
ScriptingOptionsBinding.Bind(Config(new() { ["Scripting:Runner"] = "OutOfProc" }), out _));
[Fact]
public void Worker_values_out_of_range_are_clamped_and_reported()
{
var bound = ScriptingOptionsBinding.Bind(Config(new()
{
["Scripting:Worker:Shards"] = "64",
["Scripting:Worker:HardGrace"] = "00:00:00.010",
["Scripting:Worker:HeapLimitBytes"] = "1024",
["Scripting:Worker:QuarantineAfterKills"] = "0",
["Scripting:Worker:UnavailableGrace"] = "00:05:00",
["Scripting:Worker:IsolationProbationRuns"] = "0",
}), out var adjustments);
bound.Worker.Shards.ShouldBe(ScriptWorkerOptions.MaxShards);
bound.Worker.HardGrace.ShouldBe(ScriptWorkerOptions.MinHardGrace);
bound.Worker.HeapLimitBytes.ShouldBe(ScriptWorkerOptions.MinHeapLimitBytes);
bound.Worker.QuarantineAfterKills.ShouldBe(1);
bound.Worker.UnavailableGrace.ShouldBe(ScriptWorkerOptions.MaxUnavailableGrace);
bound.Worker.IsolationProbationRuns.ShouldBe(1);
adjustments.Count.ShouldBe(6);
adjustments.ShouldAllBe(a => a.StartsWith("Scripting:Worker:"));
}
[Fact]
public void The_hard_grace_defaults_to_max_of_one_second_and_the_budget()
{
var worker = new ScriptWorkerOptions();
worker.EffectiveHardGrace(TimeSpan.FromMilliseconds(250)).ShouldBe(TimeSpan.FromSeconds(1));
worker.EffectiveHardGrace(TimeSpan.FromSeconds(2)).ShouldBe(TimeSpan.FromSeconds(2));
new ScriptWorkerOptions { HardGrace = TimeSpan.FromMilliseconds(400) }
.EffectiveHardGrace(TimeSpan.FromSeconds(2)).ShouldBe(TimeSpan.FromMilliseconds(400));
}
[Theory]
[InlineData("relative/cache", "must be an absolute path")]
[InlineData("{root}/pe-cache", "inside the content root")]
[InlineData("{root}", "inside the content root")]
public void A_pe_cache_dir_that_is_relative_or_inside_the_content_root_fails_startup(string dir, string why)
{
var root = Path.Combine(Path.GetTempPath(), "otopcua-root-" + Guid.NewGuid().ToString("N"));
var options = new ScriptingOptions
{
Runner = ScriptRunnerMode.OutOfProcess,
Worker = new ScriptWorkerOptions { PeCacheDir = dir.Replace("{root}", root) },
};
Should.Throw<InvalidOperationException>(() => ScriptRunnerSelection.Validate(options, root)).Message.ShouldContain(why);
}
[Fact]
public void A_pe_cache_dir_outside_the_content_root_is_accepted()
{
var root = Path.Combine(Path.GetTempPath(), "otopcua-root");
var options = new ScriptingOptions
{
Runner = ScriptRunnerMode.OutOfProcess,
Worker = new ScriptWorkerOptions { PeCacheDir = Path.Combine(Path.GetTempPath(), "otopcua-root-sibling") },
};
Should.NotThrow(() => ScriptRunnerSelection.Validate(options, root));
}
[Fact]
public void Shadow_is_not_implemented_yet_and_runs_in_process_with_a_warning()
{
var options = new ScriptingOptions { Runner = ScriptRunnerMode.Shadow };
ScriptRunnerSelection.Validate(options, "/srv").ShouldHaveSingleItem().ShouldContain("Shadow");
using var selection = ScriptRunnerSelection.Create(options, Silent, null);
selection.OutOfProcessRunner.ShouldBeNull();
}
[Fact]
public void OutOfProcess_selects_only_the_supported_requested_seams()
{
var all = new ScriptingOptions { Runner = ScriptRunnerMode.OutOfProcess };
ScriptRunnerSelection.Validate(all, "/srv").ShouldContain(w => w.Contains("ScriptedAlarm seam cannot run out of process yet"));
using (var selection = ScriptRunnerSelection.Create(all, Silent, null))
{
selection.ForSeam(ScriptSeam.VirtualTag).ShouldNotBeNull();
selection.ForSeam(ScriptSeam.Calculation).ShouldBeSameAs(selection.ForSeam(ScriptSeam.VirtualTag), "one node-wide runner");
selection.ForSeam(ScriptSeam.ScriptedAlarm).ShouldBeNull();
}
var calcOnly = new ScriptingOptions { Runner = ScriptRunnerMode.OutOfProcess, RunnerSeams = [ScriptSeam.Calculation] };
using (var selection = ScriptRunnerSelection.Create(calcOnly, Silent, null))
{
selection.ForSeam(ScriptSeam.Calculation).ShouldNotBeNull();
selection.ForSeam(ScriptSeam.VirtualTag).ShouldBeNull();
}
}
[Fact]
public void A_missing_worker_is_a_startup_warning_not_a_silent_in_process_fallback()
{
var options = new ScriptingOptions
{
Runner = ScriptRunnerMode.OutOfProcess,
RunnerSeams = [ScriptSeam.VirtualTag],
Worker = new ScriptWorkerOptions { ExecutablePath = Path.Combine(Path.GetTempPath(), "missing-" + Guid.NewGuid().ToString("N"), "w.dll") },
};
ScriptRunnerSelection.Validate(options, "/srv").ShouldHaveSingleItem().ShouldContain("BadResourceUnavailable");
}
}
@@ -0,0 +1,109 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Logging.Abstractions;
using Serilog;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
using ZB.MOM.WW.OtOpcUa.Host.Engines;
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.Engines;
/// <summary>
/// lmxopcua#780 — the virtual-tag seam over <see cref="IScriptRunner"/>: with a runner (OutOfProcess) the compiled run
/// is the runner's, keyed by virtual-tag id with the evaluator's budget, and its Unavailable outcome becomes a failure
/// carrying <see cref="OpcUaStatus.BadResourceUnavailable"/>. Without one (the InProcess default) nothing changes.
/// </summary>
public sealed class RoslynVirtualTagEvaluatorScriptRunnerTests
{
private sealed class FakeRunner : IScriptRunner
{
public ConcurrentQueue<ScriptRunOutcome> Outcomes { get; } = new();
public ConcurrentQueue<ScriptInvocation> Invocations { get; } = new();
public List<ScriptContextKind> Resets { get; } = [];
public Task<ScriptRunOutcome> RunAsync(ScriptInvocation invocation, CancellationToken ct = default)
{
Invocations.Enqueue(invocation);
return Task.FromResult(Outcomes.TryDequeue(out var next) ? next : ScriptRunOutcome.Ok(null));
}
public void Reset(ScriptContextKind kind) => Resets.Add(kind);
public IReadOnlyCollection<string> QuarantinedKeys(ScriptContextKind kind) => ["vt-q"];
}
private static RoslynVirtualTagEvaluator Build(IScriptRunner? runner) =>
new(NullLogger<RoslynVirtualTagEvaluator>.Instance, new ScriptRootLogger(new LoggerConfiguration().CreateLogger()),
runTimeout: TimeSpan.FromMilliseconds(1500), scriptRunner: runner);
private static Dictionary<string, DataValueSnapshot> Deps(int a) => new(StringComparer.Ordinal)
{
["a"] = new DataValueSnapshot(a, 0u, DateTime.UtcNow, DateTime.UtcNow),
};
[Fact]
public async Task Compiled_runs_go_to_the_runner_and_its_outcomes_map_onto_the_seam_contract()
{
var runner = new FakeRunner();
runner.Outcomes.Enqueue(ScriptRunOutcome.Ok(42));
runner.Outcomes.Enqueue(ScriptRunOutcome.Unavailable(ScriptRunOutcome.WorkerRestartedReason));
runner.Outcomes.Enqueue(ScriptRunOutcome.Failed("script threw: boom"));
using var sut = Build(runner);
const string script = "return (int)ctx.GetTag(\"a\").Value + 1;";
var ok = await sut.EvaluateAsync("vt-1", script, Deps(41));
ok.Success.ShouldBeTrue();
ok.Value.ShouldBe(42);
ok.StatusCode.ShouldBeNull();
var unavailable = await sut.EvaluateAsync("vt-1", script, Deps(41));
unavailable.Success.ShouldBeFalse();
unavailable.Reason.ShouldBe(ScriptRunOutcome.WorkerRestartedReason);
unavailable.StatusCode.ShouldBe(OpcUaStatus.BadResourceUnavailable);
var failed = await sut.EvaluateAsync("vt-1", script, Deps(41));
failed.Reason.ShouldBe("script threw: boom");
failed.StatusCode.ShouldBeNull("a script failure keeps BadInternalError");
var invocation = runner.Invocations.First();
invocation.Key.ShouldBe("vt-1");
invocation.Kind.ShouldBe(ScriptContextKind.VirtualTag);
invocation.Source.ShouldBe(script);
invocation.Timeout.ShouldBe(TimeSpan.FromMilliseconds(1500));
invocation.Inputs["a"].Value.ShouldBe(41);
invocation.ScriptLogger.ShouldNotBeNull();
}
[Fact]
public async Task The_passthrough_fast_path_and_an_empty_expression_never_reach_the_runner()
{
var runner = new FakeRunner();
using var sut = Build(runner);
(await sut.EvaluateAsync("vt-p", "return ctx.GetTag(\"a\").Value;", Deps(7))).Value.ShouldBe(7);
(await sut.EvaluateAsync("vt-e", " ", Deps(7))).Success.ShouldBeFalse();
runner.Invocations.ShouldBeEmpty();
}
[Fact]
public void The_deploy_boundary_resets_the_runners_virtual_tag_seam_and_its_quarantines_are_reported()
{
var runner = new FakeRunner();
using var sut = Build(runner);
sut.ClearCompiledScripts();
sut.ResetQuarantines();
runner.Resets.ShouldBe([ScriptContextKind.VirtualTag, ScriptContextKind.VirtualTag]);
sut.QuarantinedScripts.ShouldBe(["vt-q"]);
}
/// <summary>Positive control for the default: no runner means the in-process Roslyn path actually runs the script.</summary>
[Fact]
public async Task Without_a_runner_the_script_runs_in_process()
{
using var sut = Build(runner: null);
sut.ScriptRunner.ShouldBeNull();
(await sut.EvaluateAsync("vt-1", "return (int)ctx.GetTag(\"a\").Value + 1;", Deps(41))).Value.ShouldBe(42);
sut.RunGuard.StartedRunCount.ShouldBe(1);
}
}
@@ -0,0 +1,64 @@
using System.Collections.Concurrent;
using Akka.Actor;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.VirtualTags;
/// <summary>
/// lmxopcua#780 — when the out-of-process runner, not the script, fails (its worker restarted, no worker ready), the
/// virtual tag publishes <see cref="OpcUaStatus.BadResourceUnavailable"/> with its last value, so a client can tell a
/// sick node from a broken script. Every other failure keeps the long-standing <see cref="OpcUaStatus.BadInternalError"/>.
/// </summary>
public sealed class VirtualTagActorRunnerUnavailableTests : RuntimeActorTestBase
{
private sealed class QueuedEvaluator : IVirtualTagEvaluator
{
public ConcurrentQueue<VirtualTagEvalResult> Results { get; } = new();
public VirtualTagEvalResult Evaluate(string virtualTagId, string expression, IReadOnlyDictionary<string, object?> dependencies)
=> Results.TryDequeue(out var next) ? next : VirtualTagEvalResult.NoChange;
}
[Fact]
public void A_runner_unavailable_failure_publishes_BadResourceUnavailable_and_a_script_failure_BadInternalError()
{
var parent = CreateTestProbe();
var evaluator = new QueuedEvaluator();
evaluator.Results.Enqueue(VirtualTagEvalResult.Ok(5));
evaluator.Results.Enqueue(VirtualTagEvalResult.Unavailable("script worker restarted"));
evaluator.Results.Enqueue(VirtualTagEvalResult.Unavailable("script worker restarted"));
evaluator.Results.Enqueue(VirtualTagEvalResult.Failure("script threw: boom"));
var actor = parent.ChildActorOf(VirtualTagActor.Props("vt-1", "x", evaluator: evaluator, dependencyRefs: ["a"]));
actor.Tell(new VirtualTagActor.DependencyValueChanged("a", 1, DateTime.UtcNow));
parent.ExpectMsg<VirtualTagActor.EvaluationResult>().StatusCode.ShouldBe(OpcUaStatus.Good);
actor.Tell(new VirtualTagActor.DependencyValueChanged("a", 2, DateTime.UtcNow));
var unavailable = parent.ExpectMsg<VirtualTagActor.EvaluationResult>(PresenceBudget);
unavailable.StatusCode.ShouldBe(OpcUaStatus.BadResourceUnavailable);
unavailable.Value.ShouldBe(5, "the last known value rides out with the Bad status");
// Once per transition: the same code again publishes nothing.
actor.Tell(new VirtualTagActor.DependencyValueChanged("a", 3, DateTime.UtcNow));
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
// A script failure is a different Bad and is published.
actor.Tell(new VirtualTagActor.DependencyValueChanged("a", 4, DateTime.UtcNow));
var scriptFault = parent.ExpectMsg<VirtualTagActor.EvaluationResult>(PresenceBudget);
scriptFault.StatusCode.ShouldBe(OpcUaStatus.BadInternalError);
scriptFault.Value.ShouldBe(5);
}
[Fact]
public void A_failure_without_a_status_keeps_BadInternalError()
{
VirtualTagEvalResult.Failure("x").StatusCode.ShouldBeNull();
VirtualTagEvalResult.Unavailable("x").StatusCode.ShouldBe(OpcUaStatus.BadResourceUnavailable);
OpcUaStatus.BadResourceUnavailable.ShouldBe(0x80040000u, "the OPC UA Part 6 code ScadaBridge maps");
}
}
@@ -0,0 +1,382 @@
using System.Diagnostics;
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;
namespace ZB.MOM.WW.OtOpcUa.ScriptWorker.Tests;
/// <summary>Worker-backed tests spawn real processes and measure wall-clock bounds, so they never run in parallel.</summary>
[CollectionDefinition(Name, DisableParallelization = true)]
public sealed class ScriptWorkerCollection
{
public const string Name = "ScriptWorker";
}
/// <summary>
/// lmxopcua#780 design §13 "Containment": each case runs against the real <c>ScriptWorker</c> process, and each has a
/// positive control — a healthy sibling on the SAME shard (every runner here has one shard) that keeps producing
/// correct values before and after the event.
/// </summary>
[Collection(ScriptWorkerCollection.Name)]
[Trait("Category", "ScriptWorker")]
public sealed class ContainmentTests
{
private const string Healthy = "return 7;";
private const string Runaway = "while (true) { }";
private const string Cooperative = "while (!ctx.CancellationToken.IsCancellationRequested) { } return 1;";
/// <summary>Spins for the number of milliseconds in input <c>ms</c>, so one source serves slow and fast runs.</summary>
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;
[Fact]
public async Task A_healthy_script_runs_out_of_process_and_reads_its_inputs()
{
await using var runner = Scripts.Runner(Scripts.Options());
var outcome = await runner.RunAsync(Scripts.Vt("vt", "return (int)ctx.GetTag(\"x\").Value + 1;",
inputs: Scripts.Input("x", 41)), Ct);
outcome.Status.ShouldBe(ScriptRunStatus.Ok, outcome.Reason);
outcome.Value.ShouldBe(42);
runner.Shards[0].Current.ShouldNotBeNull().Pid.ShouldNotBe(Environment.ProcessId);
}
/// <summary>
/// The headline case: <c>while(true){}</c> ignores the token. In process it would spin a host core forever; here
/// the caller gets the usual timeout at T, the worker is killed at T + G, the host burns no CPU on it, and the
/// sibling on the same shard keeps working. Mutation check: with the hard-deadline kill disabled the worker is
/// never killed and the Exited wait times out.
/// </summary>
[Fact]
public async Task A_runaway_script_is_killed_at_its_hard_deadline_and_its_sibling_keeps_working()
{
var softT = TimeSpan.FromMilliseconds(400);
var grace = TimeSpan.FromMilliseconds(500);
await using var runner = Scripts.Runner(Scripts.Options(hardGrace: grace, quarantineAfterKills: 2));
(await runner.RunAsync(Scripts.Vt("sibling", Healthy), Ct)).Value.ShouldBe(7);
var worker = runner.Shards[0].Current.ShouldNotBeNull();
var sw = Stopwatch.StartNew();
var outcome = await runner.RunAsync(Scripts.Vt("runaway", Runaway, softT), Ct);
var softElapsed = sw.Elapsed;
outcome.Status.ShouldBe(ScriptRunStatus.Failed);
outcome.Reason!.ShouldStartWith("script timed out after ");
softElapsed.ShouldBeLessThan(softT + TimeSpan.FromMilliseconds(400), "the caller is answered at the soft deadline");
var exit = await worker.Exited.WaitAsync(TimeSpan.FromSeconds(10), Ct);
var killElapsed = sw.Elapsed;
exit.Reason.ShouldBe(WorkerExitReason.KilledDeadline);
exit.CulpritKey.ShouldBe(InProcessScriptRunner.ScopedKey(ScriptContextKind.VirtualTag, "runaway"));
killElapsed.ShouldBeGreaterThanOrEqualTo(softT + grace - TimeSpan.FromMilliseconds(50));
// Design budget is T + G + 100 ms; the slack here is for a loaded CI box (process reap + exit notification).
killElapsed.ShouldBeLessThan(softT + grace + TimeSpan.FromMilliseconds(1500));
// No orphan in the host: an in-process while(true) would burn one host core for the whole window.
var cpuBefore = Process.GetCurrentProcess().TotalProcessorTime;
await Task.Delay(TimeSpan.FromMilliseconds(1500), Ct);
var cpuUsed = Process.GetCurrentProcess().TotalProcessorTime - cpuBefore;
cpuUsed.ShouldBeLessThan(TimeSpan.FromMilliseconds(1000), "the runaway's thread died with its worker");
// The sibling recovers on a fresh worker.
(await runner.RunAsync(Scripts.Vt("sibling", Healthy), Ct)).Value.ShouldBe(7);
runner.Shards[0].Current.ShouldNotBeNull().Pid.ShouldNotBe(worker.Pid);
// 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 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);
refused.Reason!.ShouldStartWith(ScriptRunGuard.QuarantinedReasonPrefix);
refused.Reason.ShouldContain("hard deadline");
runner.QuarantinedKeys(ScriptContextKind.VirtualTag).ShouldBe(["runaway"]);
(await runner.RunAsync(Scripts.Vt("sibling", Healthy), Ct)).Value.ShouldBe(7);
// Lifted by a source change (the operator fixed it)…
(await runner.RunAsync(Scripts.Vt("runaway", "return 1;"), Ct)).Value.ShouldBe(1);
runner.QuarantinedKeys(ScriptContextKind.VirtualTag).ShouldBeEmpty();
}
[Fact]
public async Task A_redeploy_lifts_a_kill_quarantine_for_its_seam_only()
{
var softT = TimeSpan.FromMilliseconds(300);
await using var runner = Scripts.Runner(Scripts.Options(hardGrace: TimeSpan.FromMilliseconds(300), quarantineAfterKills: 1));
(await runner.RunAsync(Scripts.Vt("runaway", Runaway, softT), Ct)).Status.ShouldBe(ScriptRunStatus.Failed);
await Scripts.Eventually(() => runner.QuarantinedKeys(ScriptContextKind.VirtualTag).Count == 1, TimeSpan.FromSeconds(10), "the kill quarantine");
runner.Reset(ScriptContextKind.Calculation);
runner.QuarantinedKeys(ScriptContextKind.VirtualTag).ShouldBe(["runaway"], "another seam's redeploy leaves it alone");
runner.Reset(ScriptContextKind.VirtualTag);
runner.QuarantinedKeys(ScriptContextKind.VirtualTag).ShouldBeEmpty();
}
/// <summary>A script that polls its token ends itself at T: no kill, no strike beyond the timeout (design §13).</summary>
[Fact]
public async Task A_cooperative_script_ends_at_its_soft_deadline_without_a_kill()
{
var softT = TimeSpan.FromMilliseconds(400);
var grace = TimeSpan.FromMilliseconds(1500);
await using var runner = Scripts.Runner(Scripts.Options(hardGrace: grace, quarantineAfterKills: 1));
(await runner.RunAsync(Scripts.Vt("sibling", Healthy), Ct)).Value.ShouldBe(7);
var worker = runner.Shards[0].Current.ShouldNotBeNull();
var outcome = await runner.RunAsync(Scripts.Vt("polite", Cooperative, softT), Ct);
outcome.Reason!.ShouldStartWith("script timed out after ");
await Task.Delay(softT + grace + TimeSpan.FromMilliseconds(500), Ct);
worker.IsAlive.ShouldBeTrue("a script that ended itself must not cost the shard a restart");
runner.Shards[0].Current!.Pid.ShouldBe(worker.Pid);
runner.QuarantinedKeys(ScriptContextKind.VirtualTag).ShouldBeEmpty("no kill strike (QuarantineAfterKills = 1)");
(await runner.RunAsync(Scripts.Vt("sibling", Healthy), Ct)).Value.ShouldBe(7);
}
[Fact]
public async Task A_second_trigger_while_the_first_still_runs_is_refused_not_queued()
{
await using var runner = Scripts.Runner(Scripts.Options());
(await runner.RunAsync(Scripts.Vt("spin", Spin, inputs: Ms(0)), Ct)).Value.ShouldBe(0);
var first = runner.RunAsync(Scripts.Vt("spin", Spin, inputs: Ms(1500)), Ct);
await Task.Delay(200, Ct);
var second = await runner.RunAsync(Scripts.Vt("spin", Spin, inputs: Ms(0)), Ct);
second.Reason.ShouldBe(ScriptRunGuard.PreviousRunStillRunningReason);
(await first).Value.ShouldBe(1500);
}
/// <summary>
/// <c>kill -9</c> of the worker: the evaluation in flight is a victim — Unavailable "script worker restarted"
/// (BadResourceUnavailable), not a script fault — and, with no deadline crossed, a suspect that proves itself in
/// the isolation worker and then returns to its shard.
/// </summary>
[Fact]
public async Task An_externally_killed_worker_makes_its_in_flight_script_a_suspect_that_recovers_in_isolation()
{
await using var runner = Scripts.Runner(Scripts.Options(probation: 2));
(await runner.RunAsync(Scripts.Vt("spin", Spin, inputs: Ms(0)), Ct)).Value.ShouldBe(0);
var worker = runner.Shards[0].Current.ShouldNotBeNull();
var inFlight = runner.RunAsync(Scripts.Vt("spin", Spin, TimeSpan.FromSeconds(20), Ms(5000)), Ct);
await Task.Delay(300, Ct);
Process.GetProcessById(worker.Pid).Kill();
var victim = await inFlight.WaitAsync(TimeSpan.FromSeconds(10), Ct);
victim.Status.ShouldBe(ScriptRunStatus.Unavailable);
victim.Reason.ShouldBe(ScriptRunOutcome.WorkerRestartedReason);
(await worker.Exited).Reason.ShouldBe(WorkerExitReason.Crash);
runner.SuspectKeys.ShouldBe([InProcessScriptRunner.ScopedKey(ScriptContextKind.VirtualTag, "spin")]);
(await runner.RunAsync(Scripts.Vt("spin", Spin, inputs: Ms(0)), Ct)).Value.ShouldBe(0);
runner.Isolation.Current.ShouldNotBeNull("a suspect runs in the isolation worker");
(await runner.RunAsync(Scripts.Vt("spin", Spin, inputs: Ms(0)), Ct)).Value.ShouldBe(0);
runner.SuspectKeys.ShouldBeEmpty("two clean runs (probation = 2) return it to its shard");
runner.QuarantinedKeys(ScriptContextKind.VirtualTag).ShouldBeEmpty();
// The sibling recovers on a fresh shard worker.
(await runner.RunAsync(Scripts.Vt("sibling", Healthy), Ct)).Value.ShouldBe(7);
runner.Shards[0].Current!.Pid.ShouldNotBe(worker.Pid);
}
/// <summary>The isolation worker runs one evaluation at a time, so a crash there names exactly one key.</summary>
[Fact]
public async Task A_crash_in_the_isolation_worker_strikes_its_one_key_and_quarantines_it()
{
await using var runner = Scripts.Runner(Scripts.Options(quarantineAfterKills: 2));
(await runner.RunAsync(Scripts.Vt("spin", Spin, inputs: Ms(0)), Ct)).Value.ShouldBe(0);
async Task<ScriptRunOutcome> RunAndKill(Func<WorkerConnection?> worker)
{
var run = runner.RunAsync(Scripts.Vt("spin", Spin, TimeSpan.FromSeconds(20), Ms(5000)), Ct);
var live = await Scripts.Eventually(worker, TimeSpan.FromSeconds(15), "a live worker");
await Scripts.Eventually(() => live.InFlightCount == 1, TimeSpan.FromSeconds(15), "the evaluation in flight");
await Task.Delay(200, Ct);
Process.GetProcessById(live.Pid).Kill();
return await run.WaitAsync(TimeSpan.FromSeconds(10), Ct);
}
(await RunAndKill(() => runner.Shards[0].Current)).Reason.ShouldBe(ScriptRunOutcome.WorkerRestartedReason);
runner.SuspectKeys.Count.ShouldBe(1);
runner.QuarantinedKeys(ScriptContextKind.VirtualTag).ShouldBeEmpty("an ambiguous shard crash is not a strike");
(await RunAndKill(() => runner.Isolation.Current)).Reason.ShouldBe(ScriptRunOutcome.WorkerRestartedReason);
runner.QuarantinedKeys(ScriptContextKind.VirtualTag).ShouldBeEmpty("one isolation crash is one strike");
(await RunAndKill(() => runner.Isolation.Current)).Reason.ShouldBe(ScriptRunOutcome.WorkerRestartedReason);
await Scripts.Eventually(() => runner.QuarantinedKeys(ScriptContextKind.VirtualTag).Count == 1,
TimeSpan.FromSeconds(5), "the isolated-crash quarantine");
var refused = await runner.RunAsync(Scripts.Vt("spin", Spin, inputs: Ms(0)), Ct);
refused.Reason!.ShouldStartWith(ScriptRunGuard.QuarantinedReasonPrefix);
refused.Reason.ShouldContain("isolation worker");
(await runner.RunAsync(Scripts.Vt("sibling", Healthy), Ct)).Value.ShouldBe(7);
}
/// <summary>A deadline kill blames exactly one key: the sibling in flight on the same worker is a victim, not a suspect.</summary>
[Fact]
public async Task A_deadline_kill_blames_only_its_culprit()
{
await using var runner = Scripts.Runner(Scripts.Options(hardGrace: TimeSpan.FromMilliseconds(300), quarantineAfterKills: 1));
(await runner.RunAsync(Scripts.Vt("spin", Spin, inputs: Ms(0)), Ct)).Value.ShouldBe(0);
(await runner.RunAsync(Scripts.Vt("warm", Healthy), Ct)).Value.ShouldBe(7);
// Compile the runaway first, so both evaluations are on the worker at once.
_ = runner.Shards[0].Current!;
var victim = runner.RunAsync(Scripts.Vt("spin", Spin, TimeSpan.FromSeconds(20), Ms(5000)), Ct);
await Task.Delay(200, Ct);
var culprit = await runner.RunAsync(Scripts.Vt("runaway", Runaway, TimeSpan.FromMilliseconds(300)), Ct);
culprit.Reason!.ShouldStartWith("script timed out after ");
var victimOutcome = await victim.WaitAsync(TimeSpan.FromSeconds(15), Ct);
victimOutcome.Status.ShouldBe(ScriptRunStatus.Unavailable);
victimOutcome.Reason.ShouldBe(ScriptRunOutcome.WorkerRestartedReason);
await Scripts.Eventually(() => runner.QuarantinedKeys(ScriptContextKind.VirtualTag).Count == 1, TimeSpan.FromSeconds(5), "the culprit's quarantine");
runner.QuarantinedKeys(ScriptContextKind.VirtualTag).ShouldBe(["runaway"]);
runner.SuspectKeys.ShouldBeEmpty("a deadline kill is unambiguous: nobody else is suspected");
(await runner.RunAsync(Scripts.Vt("spin", Spin, inputs: Ms(0)), Ct)).Value.ShouldBe(0);
}
/// <summary>An allocation bomb dies in its worker (heap hard limit); the host's own memory does not move.</summary>
[Fact]
public async Task An_allocation_bomb_is_contained_in_its_worker()
{
await using var runner = Scripts.Runner(Scripts.Options(heapLimit: 128L * 1024 * 1024));
(await runner.RunAsync(Scripts.Vt("sibling", Healthy), Ct)).Value.ShouldBe(7);
GC.Collect();
var hostBefore = GC.GetTotalMemory(forceFullCollection: true);
var outcome = await runner.RunAsync(Scripts.Vt("bomb",
"var hoard = new System.Collections.Generic.List<byte[]>(); while (true) { hoard.Add(new byte[1 << 20]); }",
TimeSpan.FromSeconds(10)), Ct);
var hostAfter = GC.GetTotalMemory(forceFullCollection: true);
outcome.Status.ShouldNotBe(ScriptRunStatus.Ok);
(hostAfter - hostBefore).ShouldBeLessThan(32L * 1024 * 1024, "the hoard lived in the worker's heap, not the host's");
await Scripts.Eventually(() => runner.Shards[0].Current, TimeSpan.FromSeconds(10), "a worker");
(await runner.RunAsync(Scripts.Vt("sibling", Healthy), Ct)).Value.ShouldBe(7);
}
/// <summary>Fail closed (design §14 #4): no worker means Bad, never an in-process run.</summary>
[Fact]
public async Task A_worker_that_cannot_start_reports_unavailable_and_never_runs_in_process()
{
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(1)));
var outcome = await runner.RunAsync(Scripts.Vt("vt", "return 1;"), Ct);
outcome.Status.ShouldBe(ScriptRunStatus.Unavailable);
outcome.Reason.ShouldBe(ScriptRunOutcome.RunnerUnavailableReason);
outcome.ToVirtualTagEvalResult().StatusCode.ShouldBe(ZB.MOM.WW.OtOpcUa.Commons.OpcUa.OpcUaStatus.BadResourceUnavailable);
runner.Shards[0].ConsecutiveFailures.ShouldBeGreaterThan(0, "the failed start backs off");
}
/// <summary>A worker whose pipe reaches end of stream exits by itself (the Linux "host died" path, design §3.3).</summary>
[Fact]
public async Task A_worker_exits_by_itself_when_its_pipe_closes()
{
await using var runner = Scripts.Runner(Scripts.Options());
(await runner.RunAsync(Scripts.Vt("vt", Healthy), Ct)).Value.ShouldBe(7);
var worker = runner.Shards[0].Current.ShouldNotBeNull();
using var process = Process.GetProcessById(worker.Pid);
var sw = Stopwatch.StartNew();
worker.SeverPipeForTest();
await process.WaitForExitAsync(Ct).WaitAsync(TimeSpan.FromSeconds(5), Ct);
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(2));
var exit = await worker.Exited.WaitAsync(TimeSpan.FromSeconds(5), Ct);
exit.ExitCode.ShouldBe(ScriptWorkerEnvironment.ExitPipeClosed, "it noticed the EOF; the host did not have to kill it");
}
/// <summary>Design §6: the environment is cleared and repopulated from an allow-list. Positive control first.</summary>
[Fact]
public async Task The_worker_environment_carries_no_host_secrets()
{
const string apiKey = "ServerHistorian__ApiKey";
const string configDb = "ConnectionStrings__ConfigDb";
Environment.SetEnvironmentVariable(apiKey, "sekrit");
Environment.SetEnvironmentVariable(configDb, "Server=x;Password=y");
try
{
Environment.GetEnvironmentVariable(apiKey).ShouldBe("sekrit", "positive control: the host has it");
await using var runner = Scripts.Runner(Scripts.Options());
(await runner.RunAsync(Scripts.Vt("vt", Healthy), Ct)).Value.ShouldBe(7);
var diag = await runner.Shards[0].Current!.DiagnosticsAsync(TimeSpan.FromSeconds(10));
diag.EnvironmentNames.ShouldNotContain(apiKey);
diag.EnvironmentNames.ShouldNotContain(configDb);
diag.EnvironmentNames.ShouldContain(ScriptWorkerEnvironment.Nonce, "the allow-listed launch variables do arrive");
diag.EnvironmentNames.ShouldContain("DOTNET_GCHeapHardLimit");
diag.WorkingDirectory.ShouldNotBe(Environment.CurrentDirectory);
// An empty per-worker directory under the temp root (macOS reports /var as /private/var, so match the leaf).
diag.WorkingDirectory.ShouldContain(Path.Combine("otopcua-scriptworker", "otopcua-sw-"));
Directory.EnumerateFileSystemEntries(diag.WorkingDirectory).ShouldBeEmpty();
}
finally
{
Environment.SetEnvironmentVariable(apiKey, null);
Environment.SetEnvironmentVariable(configDb, null);
}
}
/// <summary>Design §6: the host's cluster, database, OPC UA and secrets stacks are not in the worker.</summary>
[Fact]
public async Task The_worker_loads_none_of_the_host_stacks()
{
await using var runner = Scripts.Runner(Scripts.Options());
(await runner.RunAsync(Scripts.Vt("vt", "return System.Math.Abs(-3);"), Ct)).Value.ShouldBe(3);
var diag = await runner.Shards[0].Current!.DiagnosticsAsync(TimeSpan.FromSeconds(10));
diag.LoadedAssemblies.ShouldContain("ZB.MOM.WW.OtOpcUa.Core.Scripting", "positive control: the compile ran here");
diag.LoadedAssemblies.ShouldContain("Microsoft.CodeAnalysis.CSharp");
foreach (var prefix in ScriptWorkerEnvironment.ForbiddenAssemblyPrefixes)
diag.LoadedAssemblies.ShouldNotContain(a => a.StartsWith(prefix, StringComparison.Ordinal), prefix);
// Not merely unloaded: Akka is not even deployable beside the worker.
var folder = Path.GetDirectoryName(runner.WorkerPath)!;
File.Exists(Path.Combine(folder, "ZB.MOM.WW.OtOpcUa.ScriptWorker.dll")).ShouldBeTrue("positive control");
File.Exists(Path.Combine(folder, "Akka.dll")).ShouldBeFalse();
File.Exists(Path.Combine(folder, "Grpc.Core.Api.dll")).ShouldBeFalse();
}
[Fact]
public async Task Script_log_lines_come_back_through_the_invocations_logger()
{
var sink = new CapturingSink();
await using var runner = Scripts.Runner(Scripts.Options());
(await runner.RunAsync(Scripts.Vt("vt", "ctx.Logger.Information(\"hello {N}\", 42); return 1;", logger: sink.CreateLogger()), Ct))
.Value.ShouldBe(1);
await Scripts.Eventually(() => sink.Rendered.Contains("hello 42"), TimeSpan.FromSeconds(5), "the forwarded log line");
}
[Fact]
public async Task An_alarm_predicate_runs_out_of_process()
{
await using var runner = Scripts.Runner(Scripts.Options());
var outcome = await runner.RunAsync(new ScriptInvocation("alarm-1", ScriptContextKind.AlarmPredicate,
"return (double)ctx.GetTag(\"t\").Value > 50;", Scripts.Input("t", 75.0), TimeSpan.FromSeconds(5)), Ct);
outcome.Status.ShouldBe(ScriptRunStatus.Ok, outcome.Reason);
outcome.Value.ShouldBe(true);
}
/// <summary>Design §14 #11: an unsupported type is an explicit failure, never a silent conversion.</summary>
[Fact]
public async Task Unsupported_input_and_result_types_are_explicit_failures()
{
await using var runner = Scripts.Runner(Scripts.Options());
var result = await runner.RunAsync(Scripts.Vt("list", "return new System.Collections.Generic.List<int> { 1 };"), Ct);
result.Status.ShouldBe(ScriptRunStatus.Failed);
result.Reason!.ShouldStartWith("unsupported type: script returned a value of unsupported type System.Collections.Generic.List");
var input = await runner.RunAsync(Scripts.Vt("guid", "return 1;", inputs: Scripts.Input("g", Guid.NewGuid())), Ct);
input.Status.ShouldBe(ScriptRunStatus.Failed);
input.Reason!.ShouldStartWith("unsupported type: input 'g' has a value of unsupported type System.Guid");
}
}
@@ -0,0 +1,86 @@
using System.IO.Pipes;
using System.Security.Cryptography;
using Shouldly;
using Xunit;
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 design §6 "Pipe hardening": only the launched process, echoing its nonce, is accepted.</summary>
public sealed class HandshakeTests
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private static async Task<Exception?> HandshakeWith(Func<Envelope, Envelope> reply, string expectedNonce = "right", int? expectedPid = 1234)
{
var name = "otopcua-sw-test-" + Convert.ToHexString(RandomNumberGenerator.GetBytes(6));
await using var server = new NamedPipeServerStream(name, PipeDirection.InOut, 1, PipeTransmissionMode.Byte,
PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
await using var client = new NamedPipeClientStream(".", name, PipeDirection.InOut, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
var accept = server.WaitForConnectionAsync(Ct);
await client.ConnectAsync(TimeSpan.FromSeconds(5), Ct);
await accept;
var rogue = Task.Run(async () =>
{
var hello = await ScriptWorkerFrameCodec.ReadAsync(client, ScriptWorkerFrameCodec.DefaultMaxFrameBytes, Ct);
await ScriptWorkerFrameCodec.WriteAsync(client, reply(hello!), ScriptWorkerFrameCodec.DefaultMaxFrameBytes, Ct);
await client.FlushAsync(Ct);
}, Ct);
try
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(Ct);
timeout.CancelAfter(TimeSpan.FromSeconds(5));
await WorkerConnection.HandshakeAsync(server, new Hello { Nonce = expectedNonce }, expectedNonce, expectedPid, timeout.Token);
await rogue;
return null;
}
catch (Exception ex)
{
return ex;
}
}
private static Envelope Ready(string nonce, int pid) => new()
{
ProtocolVersion = ScriptWorkerFrameCodec.ProtocolVersion,
Ready = new Ready { Nonce = nonce, Pid = pid },
};
[Fact]
public async Task The_launched_process_echoing_its_nonce_is_accepted()
=> (await HandshakeWith(_ => Ready("right", 1234))).ShouldBeNull("positive control");
[Fact]
public async Task A_wrong_nonce_is_refused()
=> (await HandshakeWith(_ => Ready("wrong", 1234))).ShouldBeOfType<ScriptWorkerProtocolException>()
.Message.ShouldContain("nonce");
[Fact]
public async Task A_client_that_is_not_the_launched_process_is_refused()
=> (await HandshakeWith(_ => Ready("right", 999))).ShouldBeOfType<ScriptWorkerProtocolException>()
.Message.ShouldContain("pid");
[Fact]
public async Task Anything_but_ready_first_is_refused()
=> (await HandshakeWith(_ => new Envelope
{
ProtocolVersion = ScriptWorkerFrameCodec.ProtocolVersion,
Heartbeat = new Heartbeat(),
})).ShouldBeOfType<ScriptWorkerProtocolException>();
[Fact]
public async Task The_hello_carries_the_nonce_to_echo()
{
string? seen = null;
(await HandshakeWith(hello =>
{
seen = hello.Hello.Nonce;
return Ready(hello.Hello.Nonce, 1234);
})).ShouldBeNull();
seen.ShouldBe("right");
}
}
@@ -0,0 +1,95 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
namespace ZB.MOM.WW.OtOpcUa.ScriptWorker.Tests;
/// <summary>
/// lmxopcua#780 design §13 "Parity": the same script, through <see cref="InProcessScriptRunner"/> (today's guard)
/// and through the worker, gives the same status, reason, value and exact CLR value type.
/// </summary>
[Collection(ScriptWorkerCollection.Name)]
[Trait("Category", "ScriptWorker")]
public sealed class ParityTests
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
public static TheoryData<string, string> Cases() => new()
{
{ "arithmetic", "return 1 + 2;" },
{ "string", "return \"a\" + ctx.GetTag(\"x\").Value;" },
{ "throw", "throw new System.InvalidOperationException(\"boom\");" },
{ "compile error", "return undefinedThing;" },
{ "sandbox violation", "return System.IO.File.Exists(\"x\");" },
{ "missing input status", "return ctx.GetTag(\"missing\").StatusCode;" },
{ "input status", "return ctx.GetTag(\"x\").StatusCode;" },
{ "source timestamp", "return ctx.GetTag(\"x\").SourceTimestampUtc;" },
{ "clock", "return ctx.Now > new System.DateTime(2000, 1, 1);" },
{ "null", "return null;" },
{ "array", "return new double[] { 1.5, 2.5 };" },
{ "cast of wrong type", "return (short)ctx.GetTag(\"x\").Value;" },
};
private static readonly IReadOnlyDictionary<string, DataValueSnapshot> Inputs =
new Dictionary<string, DataValueSnapshot>(StringComparer.Ordinal)
{
["x"] = new DataValueSnapshot(5, 0x40000000u, new DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc), DateTime.UtcNow),
};
[Theory]
[MemberData(nameof(Cases))]
public async Task In_and_out_of_process_agree(string name, string source)
{
using var inProcess = new InProcessScriptRunner();
await using var outOfProcess = Scripts.Runner(Scripts.Options());
var invocation = Scripts.Vt(name, source, inputs: Inputs);
var expected = await inProcess.RunAsync(invocation, Ct);
var actual = await outOfProcess.RunAsync(invocation, Ct);
actual.Status.ShouldBe(expected.Status, $"{name}: {actual.Reason} vs {expected.Reason}");
actual.Reason.ShouldBe(expected.Reason);
Same(actual.Value, expected.Value, name);
}
public static TheoryData<string, object> Typed() => new()
{
{ "bool", true }, { "sbyte", (sbyte)-2 }, { "byte", (byte)2 }, { "short", (short)-3 }, { "ushort", (ushort)3 },
{ "int", -4 }, { "uint", 4u }, { "long", -5L }, { "ulong", 5UL }, { "float", 1.25f }, { "double", 2.5 },
{ "string", "s" }, { "System.DateTime", new DateTime(2026, 11, 9, 0, 0, 0, DateTimeKind.Utc) },
};
/// <summary>
/// Unboxing parity (pins design assumption A-7): <c>(T)ctx.GetTag("x").Value</c> works out of process for every
/// tag type, which needs the exact boxed CLR type to survive the wire.
/// </summary>
[Theory]
[MemberData(nameof(Typed))]
public async Task Unboxing_to_the_exact_type_matches(string typeName, object value)
{
using var inProcess = new InProcessScriptRunner();
await using var outOfProcess = Scripts.Runner(Scripts.Options());
var invocation = Scripts.Vt("unbox-" + typeName, $"var v = ({typeName})ctx.GetTag(\"x\").Value; return v;",
inputs: Scripts.Input("x", value));
var expected = await inProcess.RunAsync(invocation, Ct);
var actual = await outOfProcess.RunAsync(invocation, Ct);
expected.Status.ShouldBe(ScriptRunStatus.Ok, expected.Reason);
actual.Status.ShouldBe(ScriptRunStatus.Ok, actual.Reason);
Same(actual.Value, expected.Value, typeName);
}
private static void Same(object? actual, object? expected, string because)
{
if (expected is null)
{
actual.ShouldBeNull(because);
return;
}
actual.ShouldNotBeNull(because);
actual.GetType().ShouldBe(expected.GetType(), because);
if (expected is Array array) ((Array)actual).Cast<object?>().ShouldBe(array.Cast<object?>(), because);
else actual.ShouldBe(expected, because);
}
}
@@ -0,0 +1,198 @@
using System.Buffers.Binary;
using Google.Protobuf;
using Shouldly;
using Xunit;
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 design §13 — frame codec and the CLR ↔ Variant mapping.</summary>
public sealed class ProtocolTests
{
public static IEnumerable<object?[]> Values()
{
object?[] values =
[
null, true, false, (sbyte)-5, (byte)250, (short)-3, (ushort)7, -42, 42u, long.MinValue, ulong.MaxValue,
1.5f, double.NaN, 3.25, "text", string.Empty, new DateTime(2026, 11, 9, 1, 2, 3, DateTimeKind.Utc),
new[] { 1, 2, 3 }, new short[] { -1, 1 }, new[] { "a", "b" }, new[] { true, false }, Array.Empty<double>(),
new[] { new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc) }, (short)0, 0, 0L, 0.0,
];
return values.Select(v => new[] { v });
}
/// <summary>The exact boxed CLR type survives (design §4.4): <c>(short)ctx.GetTag(x).Value</c> must keep working.</summary>
[Theory]
[MemberData(nameof(Values))]
public void Every_type_tag_round_trips_with_its_exact_clr_type(object? value)
{
VariantCodec.TryEncode(value, out var variant, out var unsupported).ShouldBeTrue();
unsupported.ShouldBeNull();
// Through the wire, not just the object: a payload the serializer drops must still decode.
var wire = Variant.Parser.ParseFrom(variant.ToByteArray());
var decoded = VariantCodec.Decode(wire);
if (value is null)
{
decoded.ShouldBeNull();
return;
}
decoded.ShouldNotBeNull();
decoded.GetType().ShouldBe(value.GetType());
if (value is Array expected)
((Array)decoded).Cast<object?>().ShouldBe(expected.Cast<object?>());
else if (value is double d && double.IsNaN(d))
double.IsNaN((double)decoded).ShouldBeTrue();
else
decoded.ShouldBe(value);
}
[Fact]
public void Unsupported_types_are_refused_not_converted()
{
foreach (var value in new object[] { new List<int> { 1 }, 1.5m, Guid.NewGuid(), new { A = 1 }, new int[1, 1], new object[] { 1 } })
{
VariantCodec.TryEncode(value, out _, out var type).ShouldBeFalse(value.GetType().Name);
type.ShouldNotBeNullOrEmpty();
}
}
[Fact]
public void Local_datetime_crosses_as_utc()
{
var local = new DateTime(2026, 6, 1, 12, 0, 0, DateTimeKind.Local);
VariantCodec.TryEncode(local, out var variant, out _).ShouldBeTrue();
var decoded = (DateTime)VariantCodec.Decode(variant)!;
decoded.Kind.ShouldBe(DateTimeKind.Utc);
decoded.ShouldBe(local.ToUniversalTime());
}
[Fact]
public void A_payload_that_contradicts_its_tag_is_a_protocol_fault()
{
Should.Throw<ScriptWorkerProtocolException>(() => VariantCodec.Decode(new Variant { Type = Variant.Types.TypeTag.Int16, S = "x" }));
Should.Throw<ScriptWorkerProtocolException>(() => VariantCodec.Decode(new Variant { Type = (Variant.Types.TypeTag)99 }));
Should.Throw<OverflowException>(() => VariantCodec.Decode(new Variant { Type = Variant.Types.TypeTag.Int16, I = 1 << 20 }));
}
private static Envelope Sample() => new()
{
ProtocolVersion = ScriptWorkerFrameCodec.ProtocolVersion,
Seq = 7,
Heartbeat = new Heartbeat { Inflight = 3, HeapBytes = 1234 },
};
[Fact]
public async Task A_frame_round_trips()
{
using var stream = new MemoryStream();
await ScriptWorkerFrameCodec.WriteAsync(stream, Sample(), 1024, TestContext.Current.CancellationToken);
stream.Position = 0;
var read = await ScriptWorkerFrameCodec.ReadAsync(stream, 1024, TestContext.Current.CancellationToken);
read.ShouldBe(Sample());
(await ScriptWorkerFrameCodec.ReadAsync(stream, 1024, TestContext.Current.CancellationToken)).ShouldBeNull("a clean end of stream at a frame boundary");
}
[Fact]
public async Task Oversize_frames_are_refused_on_write_and_on_read()
{
using var stream = new MemoryStream();
await Should.ThrowAsync<ScriptWorkerProtocolException>(async () =>
await ScriptWorkerFrameCodec.WriteAsync(stream, Sample(), 4, TestContext.Current.CancellationToken));
stream.Length.ShouldBe(0, "nothing is written for a refused frame");
await ScriptWorkerFrameCodec.WriteAsync(stream, Sample(), 1024, TestContext.Current.CancellationToken);
stream.Position = 0;
await Should.ThrowAsync<ScriptWorkerProtocolException>(async () =>
await ScriptWorkerFrameCodec.ReadAsync(stream, 4, TestContext.Current.CancellationToken));
}
public static TheoryData<string, byte[]> BadFrames()
{
var good = Sample().ToByteArray();
var header = new byte[4];
BinaryPrimitives.WriteInt32LittleEndian(header, good.Length);
var wrongVersion = new Envelope { ProtocolVersion = 99, Heartbeat = new Heartbeat() }.ToByteArray();
var noBody = new Envelope { ProtocolVersion = 1, Seq = 1 }.ToByteArray();
return new TheoryData<string, byte[]>
{
{ "zero length", new byte[] { 0, 0, 0, 0 } },
{ "negative length", new byte[] { 0xFF, 0xFF, 0xFF, 0xFF } },
{ "truncated header", new byte[] { 5, 0 } },
{ "truncated body", [.. header, .. good[..^1]] },
{ "garbage body", [5, 0, 0, 0, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF] },
{ "wrong protocol version", Frame(wrongVersion) },
{ "no body", Frame(noBody) },
};
}
private static byte[] Frame(byte[] body)
{
var header = new byte[4];
BinaryPrimitives.WriteInt32LittleEndian(header, body.Length);
return [.. header, .. body];
}
[Theory]
[MemberData(nameof(BadFrames))]
public async Task Malformed_frames_are_protocol_faults(string why, byte[] bytes)
{
using var stream = new MemoryStream(bytes);
await Should.ThrowAsync<ScriptWorkerProtocolException>(async () =>
await ScriptWorkerFrameCodec.ReadAsync(stream, 1024, TestContext.Current.CancellationToken), why);
}
/// <summary>Random bytes never escape as anything but a protocol fault (or a clean end of stream).</summary>
[Fact]
public async Task Fuzzed_input_only_ever_yields_a_protocol_fault()
{
var random = new Random(780);
for (var i = 0; i < 3000; i++)
{
var bytes = new byte[random.Next(0, 64)];
random.NextBytes(bytes);
if (bytes.Length >= 4 && random.Next(2) == 0)
BinaryPrimitives.WriteInt32LittleEndian(bytes, random.Next(1, bytes.Length));
using var stream = new MemoryStream(bytes);
try
{
while (await ScriptWorkerFrameCodec.ReadAsync(stream, 1024, TestContext.Current.CancellationToken) is { } envelope)
{
// A frame that parses must still decode its variants without an unexpected exception type.
if (envelope.BodyCase == Envelope.BodyOneofCase.Results)
foreach (var r in envelope.Results.Items)
try { VariantCodec.Decode(r.Value); } catch (Exception ex) when (ex is ScriptWorkerProtocolException or OverflowException) { }
}
}
catch (ScriptWorkerProtocolException)
{
}
}
}
[Fact]
public void Backoff_doubles_from_250ms_to_a_30s_cap_with_20_percent_jitter()
{
WorkerShard.NextBackoff(1, 0.5).ShouldBe(TimeSpan.FromMilliseconds(250));
WorkerShard.NextBackoff(2, 0.5).ShouldBe(TimeSpan.FromMilliseconds(500));
WorkerShard.NextBackoff(3, 0.5).ShouldBe(TimeSpan.FromSeconds(1));
WorkerShard.NextBackoff(20, 0.5).ShouldBe(TimeSpan.FromSeconds(30));
WorkerShard.NextBackoff(1, 0).ShouldBe(TimeSpan.FromMilliseconds(200));
WorkerShard.NextBackoff(1, 0.9999999).TotalMilliseconds.ShouldBe(300, tolerance: 0.01);
WorkerShard.NextBackoff(40, 1).ShouldBe(TimeSpan.FromSeconds(36));
}
[Fact]
public void Shard_placement_is_stable_and_in_range()
{
// FNV-1a, not string.GetHashCode (randomised per process): a script stays on its shard across restarts.
OutOfProcessScriptRunner.ShardIndex("1:vt-1", 2).ShouldBe(OutOfProcessScriptRunner.ShardIndex("1:vt-1", 2));
var spread = Enumerable.Range(0, 400).Select(i => OutOfProcessScriptRunner.ShardIndex($"1:vt-{i}", 4)).ToArray();
spread.ShouldAllBe(i => i >= 0 && i < 4);
spread.Distinct().Count().ShouldBe(4);
OutOfProcessScriptRunner.ShardIndex("abc", 1000).ShouldBe((int)(0x1A47E90Bu % 1000));
}
}
@@ -0,0 +1,88 @@
using System.Collections.Concurrent;
using System.Diagnostics;
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.Driver.Calculation;
namespace ZB.MOM.WW.OtOpcUa.ScriptWorker.Tests;
/// <summary>
/// lmxopcua#780 — the whole path a client sees: a real Calculation driver whose seam runs out of process on the real
/// worker. A script failure publishes <c>BadInternalError</c> as today; the worker dying under a script publishes
/// <c>BadResourceUnavailable</c> (0x80040000), the ScadaBridge-visible code; and the tag recovers Good afterwards.
/// </summary>
[Collection(ScriptWorkerCollection.Name)]
[Trait("Category", "ScriptWorker")]
public sealed class SeamEndToEndTests
{
private const uint StatusGood = 0u;
private const uint StatusBadInternalError = 0x80020000u;
private const uint StatusBadResourceUnavailable = 0x80040000u;
private const string Tag = "calc/e/spin";
/// <summary>Spins for <c>src/ms</c> milliseconds, throws when it is negative, and returns it otherwise.</summary>
private const string Script =
"var ms = (double)ctx.GetTag(\"src/ms\").Value; if (ms < 0) throw new System.InvalidOperationException(\"negative\"); " +
"var end = ctx.Now.AddMilliseconds(ms); while (ctx.Now < end) { } return ms;";
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private static async Task Settle(CalculationDriver driver)
{
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(60);
while (DateTime.UtcNow < deadline && !driver.IsEvaluationIdle) await Task.Delay(10);
driver.IsEvaluationIdle.ShouldBeTrue();
}
[Fact]
public async Task A_calculation_tag_publishes_BadResourceUnavailable_only_when_the_worker_died_under_it()
{
await using var runner = Scripts.Runner(Scripts.Options());
var json = $"{{\"scriptId\":\"s\",\"changeTriggered\":true,\"scriptSource\":{System.Text.Json.JsonSerializer.Serialize(Script)}}}";
var driver = new CalculationDriver(
new CalculationDriverOptions { RawTags = [new RawTagEntry(Tag, json, WriteIdempotent: false)], RunTimeout = TimeSpan.FromSeconds(20), ScriptRunner = runner },
"calc-drv", new ScriptRootLogger(new LoggerConfiguration().CreateLogger()));
var published = new ConcurrentQueue<DataChangeEventArgs>();
driver.OnDataChange += (_, e) => published.Enqueue(e);
await driver.InitializeAsync("{}", Ct);
driver.OnDependencyValue("src/ms", 0.0, StatusGood, DateTime.UtcNow);
await Settle(driver);
published.TryDequeue(out var good).ShouldBeTrue();
good!.Snapshot.StatusCode.ShouldBe(StatusGood);
good.Snapshot.Value.ShouldBe(0.0);
// A script fault: BadInternalError, exactly as in process.
driver.OnDependencyValue("src/ms", -1.0, StatusGood, DateTime.UtcNow);
await Settle(driver);
published.TryDequeue(out var threw).ShouldBeTrue();
threw!.Snapshot.StatusCode.ShouldBe(StatusBadInternalError);
driver.OnDependencyValue("src/ms", 1.0, StatusGood, DateTime.UtcNow);
await Settle(driver);
published.TryDequeue(out var back).ShouldBeTrue();
back!.Snapshot.StatusCode.ShouldBe(StatusGood);
// The worker dies under a run: the runner, not the script, failed.
driver.OnDependencyValue("src/ms", 5000.0, StatusGood, DateTime.UtcNow);
var worker = await Scripts.Eventually(() => runner.Shards[0].Current is { InFlightCount: 1 } w ? w : null,
TimeSpan.FromSeconds(10), "the run in flight");
await Task.Delay(200, Ct);
Process.GetProcessById(worker.Pid).Kill();
await Settle(driver);
published.TryDequeue(out var unavailable).ShouldBeTrue();
unavailable!.Snapshot.StatusCode.ShouldBe(StatusBadResourceUnavailable);
unavailable.Snapshot.Value.ShouldBe(1.0, "the last good value rides out with the Bad status");
// Recovery: the next trigger runs (in the isolation worker, as a suspect) and publishes Good.
driver.OnDependencyValue("src/ms", 2.0, StatusGood, DateTime.UtcNow);
await Settle(driver);
published.TryDequeue(out var recovered).ShouldBeTrue();
recovered!.Snapshot.StatusCode.ShouldBe(StatusGood);
recovered.Snapshot.Value.ShouldBe(2.0);
await driver.ShutdownAsync(Ct);
}
}
@@ -0,0 +1,82 @@
using System.Collections.Concurrent;
using Serilog;
using Serilog.Core;
using Serilog.Events;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
using ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess;
namespace ZB.MOM.WW.OtOpcUa.ScriptWorker.Tests;
/// <summary>Collects Serilog events in memory.</summary>
internal sealed class CapturingSink : ILogEventSink
{
public ConcurrentQueue<LogEvent> Events { get; } = new();
public void Emit(LogEvent logEvent) => Events.Enqueue(logEvent);
public ILogger CreateLogger() => new LoggerConfiguration().MinimumLevel.Verbose().WriteTo.Sink(this).CreateLogger();
public IEnumerable<string> Rendered => Events.Select(e => e.RenderMessage());
}
internal static class Scripts
{
public static readonly IReadOnlyDictionary<string, DataValueSnapshot> NoInputs =
new Dictionary<string, DataValueSnapshot>(StringComparer.Ordinal);
public static IReadOnlyDictionary<string, DataValueSnapshot> Input(string path, object? value, uint status = 0)
=> new Dictionary<string, DataValueSnapshot>(StringComparer.Ordinal)
{
[path] = new DataValueSnapshot(value, status, DateTime.UtcNow, DateTime.UtcNow),
};
public static ScriptInvocation Vt(string key, string source, TimeSpan? timeout = null,
IReadOnlyDictionary<string, DataValueSnapshot>? inputs = null, ILogger? logger = null)
=> new(key, ScriptContextKind.VirtualTag, source, inputs ?? NoInputs, timeout ?? TimeSpan.FromSeconds(5), logger);
/// <summary>Options for a single-shard runner with short budgets, so the containment tests run in seconds.</summary>
public static ScriptingOptions Options(int shards = 1, TimeSpan? hardGrace = null, int quarantineAfterKills = 2,
int probation = 20, TimeSpan? unavailableGrace = null, string? workerPath = null, long heapLimit = 256L * 1024 * 1024)
=> new()
{
Runner = ScriptRunnerMode.OutOfProcess,
QuarantineAfterConsecutiveTimeouts = 100,
Worker = new ScriptWorkerOptions
{
Shards = shards,
HardGrace = hardGrace ?? TimeSpan.FromMilliseconds(500),
QuarantineAfterKills = quarantineAfterKills,
IsolationProbationRuns = probation,
UnavailableGrace = unavailableGrace ?? TimeSpan.FromSeconds(30),
ExecutablePath = workerPath,
HeapLimitBytes = heapLimit,
},
};
public static OutOfProcessScriptRunner Runner(ScriptingOptions options, ILogger? log = null)
=> new(options, log ?? new LoggerConfiguration().CreateLogger(), null,
heartbeatInterval: TimeSpan.FromSeconds(1), loadTimeout: TimeSpan.FromSeconds(60));
public static async Task<T> Eventually<T>(Func<T?> probe, TimeSpan timeout, string what) where T : class
{
var deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
if (probe() is { } value) return value;
await Task.Delay(20);
}
throw new TimeoutException($"timed out waiting for {what}");
}
public static async Task Eventually(Func<bool> probe, TimeSpan timeout, string what)
{
var deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
if (probe()) return;
await Task.Delay(20);
}
throw new TimeoutException($"timed out waiting for {what}");
}
}
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>ZB.MOM.WW.OtOpcUa.ScriptWorker.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3"/>
<PackageReference Include="Shouldly"/>
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Core.Scripting\ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj"/>
<ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Core.Scripting.Protocol\ZB.MOM.WW.OtOpcUa.Core.Scripting.Protocol.csproj"/>
<ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess\ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess.csproj"/>
<!-- End-to-end seam test: a real Calculation driver over the real worker. -->
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Calculation\ZB.MOM.WW.OtOpcUa.Driver.Calculation.csproj"/>
</ItemGroup>
<!-- The real worker, built and placed in script-worker/ beside the tests, exactly as beside the Host. -->
<Import Project="..\..\..\src\Server\ZB.MOM.WW.OtOpcUa.ScriptWorker\build\ScriptWorker.Deploy.targets"/>
</Project>