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; /// lmxopcua#780 design §13 — frame codec and the CLR ↔ Variant mapping. public sealed class ProtocolTests { public static IEnumerable 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(), new[] { new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc) }, (short)0, 0, 0L, 0.0, ]; return values.Select(v => new[] { v }); } /// The exact boxed CLR type survives (design §4.4): (short)ctx.GetTag(x).Value must keep working. [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().ShouldBe(expected.Cast()); 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 { 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(() => VariantCodec.Decode(new Variant { Type = Variant.Types.TypeTag.Int16, S = "x" })); Should.Throw(() => VariantCodec.Decode(new Variant { Type = (Variant.Types.TypeTag)99 })); Should.Throw(() => 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(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(async () => await ScriptWorkerFrameCodec.ReadAsync(stream, 4, TestContext.Current.CancellationToken)); } public static TheoryData 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 { { "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(async () => await ScriptWorkerFrameCodec.ReadAsync(stream, 1024, TestContext.Current.CancellationToken), why); } /// Random bytes never escape as anything but a protocol fault (or a clean end of stream). [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) { } } } // ---------------------------------------------------------------- buffered reader / combining writer (lmxopcua#789) /// Hands out at most chunk bytes per read, so frames straddle reads at every possible offset. private sealed class ChunkedStream(byte[] data, int chunk) : MemoryStream(data) { public override int Read(byte[] buffer, int offset, int count) => base.Read(buffer, offset, Math.Min(count, chunk)); public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) => base.ReadAsync(buffer[..Math.Min(buffer.Length, chunk)], cancellationToken); } private static byte[] Frames(int count, int bigEvery = 0) { using var stream = new MemoryStream(); for (var i = 0; i < count; i++) { var env = new Envelope { ProtocolVersion = 1, Seq = (ulong)i + 1, Heartbeat = new Heartbeat { Inflight = (uint)i } }; if (bigEvery > 0 && i % bigEvery == 0) env = new Envelope { ProtocolVersion = 1, Seq = (ulong)i + 1, Fault = new Fault { Message = new string('x', 100_000 + i) } }; ScriptWorkerFrameCodec.WriteAsync(stream, env, ScriptWorkerFrameCodec.DefaultMaxFrameBytes).AsTask().GetAwaiter().GetResult(); } return stream.ToArray(); } /// /// The buffered reader yields exactly the frames the per-frame codec wrote, in order, however the bytes are /// split across reads — including frames larger than its initial 64 KiB buffer — through both its async and its /// blocking read, and ends cleanly at a frame boundary. /// [Theory] [InlineData(1, false)] [InlineData(3, true)] [InlineData(7, false)] [InlineData(65_536, true)] [InlineData(1_000_000, false)] public async Task The_buffered_reader_reassembles_frames_split_at_any_offset(int chunk, bool blocking) { var bytes = Frames(200, bigEvery: 37); var reader = new ScriptWorkerFrameReader(new ChunkedStream(bytes, chunk)); for (var i = 0; i < 200; i++) { var env = (blocking ? reader.Read() : await reader.ReadAsync(TestContext.Current.CancellationToken)).ShouldNotBeNull($"frame {i}"); env.Seq.ShouldBe((ulong)i + 1); if (i % 37 == 0) env.Fault.Message.Length.ShouldBe(100_000 + i); else env.Heartbeat.Inflight.ShouldBe((uint)i); } (blocking ? reader.Read() : await reader.ReadAsync(TestContext.Current.CancellationToken)).ShouldBeNull("a clean end of stream"); } /// The buffered reader rejects exactly what the codec rejects. [Theory] [MemberData(nameof(BadFrames))] public async Task The_buffered_reader_rejects_malformed_frames_like_the_codec(string why, byte[] bytes) { await Should.ThrowAsync(async () => await new ScriptWorkerFrameReader(new MemoryStream(bytes), 1024).ReadAsync(TestContext.Current.CancellationToken), why); Should.Throw(() => new ScriptWorkerFrameReader(new ChunkedStream(bytes, 1), 1024).Read(), why); } /// Random bytes never escape the buffered reader as anything but a protocol fault (or a clean end). [Fact] public void Fuzzed_input_only_ever_yields_a_protocol_fault_from_the_buffered_reader() { var random = new Random(789); 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)); var reader = new ScriptWorkerFrameReader(new ChunkedStream(bytes, random.Next(1, 9)), 1024); try { while (reader.Read() is not null) { } } catch (ScriptWorkerProtocolException) { } } } /// A stream whose first write waits on a gate, so frames queue up behind a write in progress. private sealed class GatedStream : MemoryStream { public readonly TaskCompletionSource Gate = new(TaskCreationOptions.RunContinuationsAsynchronously); public int Writes; public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) { if (Interlocked.Increment(ref Writes) == 1) await Gate.Task; await base.WriteAsync(buffer, cancellationToken); } } private static List ReadAll(byte[] bytes) { var reader = new ScriptWorkerFrameReader(new MemoryStream(bytes)); var frames = new List(); while (reader.Read() is { } env) frames.Add(env); return frames; } private static Envelope Eval(ulong id) => new() { Eval = new EvalBatch { Items = { new EvalRequest { EvalId = id, Key = "k" } } } }; /// /// The combining writer (lmxopcua#789): frames queued while a write is in progress go out in the next pass, with /// consecutive evaluations merged into one batch, every other frame kept in its place (a Cancel is never /// reordered ahead of the evaluation it cancels), and sequence numbers increasing. /// [Fact] public async Task The_combining_writer_batches_consecutive_evaluations_and_keeps_frame_order() { var stream = new GatedStream(); Exception? fault = null; var writer = new ScriptWorkerFrameWriter(stream, ex => fault = ex, firstSeq: 1); writer.TryWrite(Eval(1)).ShouldBeTrue(); // starts a pass that blocks on the gate writer.TryWrite(Eval(2)).ShouldBeTrue(); writer.TryWrite(Eval(3)).ShouldBeTrue(); writer.TryWrite(new Envelope { Cancel = new Cancel { EvalId = 2 } }).ShouldBeTrue(); writer.TryWrite(Eval(4)).ShouldBeTrue(); stream.Gate.SetResult(); await Scripts.Eventually(() => { try { return ReadAll(stream.ToArray()).Count == 4; } catch (ScriptWorkerProtocolException) { return false; } // a pass caught mid-copy }, TimeSpan.FromSeconds(5), "both passes written"); fault.ShouldBeNull(); var frames = ReadAll(stream.ToArray()); frames.Select(f => f.BodyCase).ShouldBe([Envelope.BodyOneofCase.Eval, Envelope.BodyOneofCase.Eval, Envelope.BodyOneofCase.Cancel, Envelope.BodyOneofCase.Eval]); frames[0].Eval.Items.Select(e => e.EvalId).ShouldBe([1UL]); frames[1].Eval.Items.Select(e => e.EvalId).ShouldBe([2UL, 3UL], "queued behind the write in progress: one batch"); frames[2].Cancel.EvalId.ShouldBe(2UL); frames[3].Eval.Items.Select(e => e.EvalId).ShouldBe([4UL]); frames.Select(f => f.Seq).ShouldBe([2UL, 3UL, 4UL, 5UL]); frames.ShouldAllBe(f => f.ProtocolVersion == ScriptWorkerFrameCodec.ProtocolVersion); stream.Writes.ShouldBe(2, "one write per pass"); } /// A failed write completes the writer, reports once, and later frames are refused rather than lost silently. [Fact] public void A_failed_write_completes_the_writer_and_reports_once() { var faults = 0; var stream = new MemoryStream(); stream.Dispose(); var writer = new ScriptWorkerFrameWriter(stream, _ => Interlocked.Increment(ref faults)); writer.TryWrite(Eval(1)).ShouldBeTrue(); faults.ShouldBe(1); writer.IsCompleted.ShouldBeTrue(); writer.TryWrite(Eval(2)).ShouldBeFalse(); faults.ShouldBe(1); } [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)); } }