Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/ScriptRunnerSelectionTests.cs
T
Joseph Doherty 69a6e0b4c8
v2-ci / unit-tests (push) Waiting to run
feat(scripting): out-of-process pre-emptible script runner behind Scripting:Runner (lmxopcua#780 P1)
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
2026-11-09 23:33:08 -05:00

171 lines
8.0 KiB
C#

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");
}
}