Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.ScriptWorker.Tests/ParityTests.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

96 lines
4.2 KiB
C#

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