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
199 lines
8.8 KiB
C#
199 lines
8.8 KiB
C#
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));
|
|
}
|
|
}
|