69a6e0b4c8
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
96 lines
4.2 KiB
C#
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);
|
|
}
|
|
}
|