03ac57d5d6
- Host reader: the frame read and its handling are caught separately. Only the read's I/O exceptions mean the pipe ended; anything HandleFrame's inline completion work throws now kills that worker as ProtocolFault (outstanding runs -> worker restarted / BadResourceUnavailable) instead of escaping a dedicated thread (process crash) or being swallowed as a clean end (an InvalidOperationException used to be, leaving the worker unread until a Crash 2 s later). - Worker reader: the same catch-all around HandleFrame (exit ProtocolFault). - Worker: Cancel() runs on the pool, so a script's blocking ctx.CancellationToken callback cannot stop the worker reading its pipe. - Host write-stall check: ScriptWorkerFrameWriter.WriteInProgressFor; the heartbeat monitor kills a worker whose pipe write has been blocked longer than max(10 s, silence limit) as KilledUnresponsive, which fails the write and releases the producer (seam submit, deploy load, Cancel). Fixes the wrong 'heartbeat monitor ends it' comment on the pipe buffer quota. - StopAsync sends Shutdown off the caller so its 1 s budget holds on a full pipe. - WindowsBlockingPipeStream refuses the async surface (Stream's base versions serialise a write behind a waiting read). - Tests: PipeFaultTests (throwing completion callback -> ProtocolFault and a BadResourceUnavailable sibling, shard serves again; a worker that stops reading but heartbeats is killed within the bound and the blocked producer returns, via the Hello.test_stall_reader_on_diag seam); BlockingPipeStreamTests dispose with a write blocked on a full pipe, and with a read and a write both pending; Category=ScriptWorker on the protocol, handshake and pipe test classes. Mutation checks: no stall kill -> stall test red; catch-all without the fault -> callback test red (Crash). Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
351 lines
16 KiB
C#
351 lines
16 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>
|
|
[Trait("Category", "ScriptWorker")]
|
|
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)
|
|
{
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- buffered reader / combining writer (lmxopcua#789)
|
|
|
|
/// <summary>Hands out at most <c>chunk</c> bytes per read, so frames straddle reads at every possible offset.</summary>
|
|
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<int> ReadAsync(Memory<byte> 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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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");
|
|
}
|
|
|
|
/// <summary>The buffered reader rejects exactly what the codec rejects.</summary>
|
|
[Theory]
|
|
[MemberData(nameof(BadFrames))]
|
|
public async Task The_buffered_reader_rejects_malformed_frames_like_the_codec(string why, byte[] bytes)
|
|
{
|
|
await Should.ThrowAsync<ScriptWorkerProtocolException>(async () =>
|
|
await new ScriptWorkerFrameReader(new MemoryStream(bytes), 1024).ReadAsync(TestContext.Current.CancellationToken), why);
|
|
Should.Throw<ScriptWorkerProtocolException>(() => new ScriptWorkerFrameReader(new ChunkedStream(bytes, 1), 1024).Read(), why);
|
|
}
|
|
|
|
/// <summary>Random bytes never escape the buffered reader as anything but a protocol fault (or a clean end).</summary>
|
|
[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) { }
|
|
}
|
|
}
|
|
|
|
/// <summary>A stream whose first write waits on a gate, so frames queue up behind a write in progress.</summary>
|
|
private sealed class GatedStream : MemoryStream
|
|
{
|
|
public readonly TaskCompletionSource Gate = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
public int Writes;
|
|
|
|
public override async ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default)
|
|
{
|
|
if (Interlocked.Increment(ref Writes) == 1) await Gate.Task;
|
|
await base.WriteAsync(buffer, cancellationToken);
|
|
}
|
|
}
|
|
|
|
private static List<Envelope> ReadAll(byte[] bytes)
|
|
{
|
|
var reader = new ScriptWorkerFrameReader(new MemoryStream(bytes));
|
|
var frames = new List<Envelope>();
|
|
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" } } } };
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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");
|
|
}
|
|
|
|
/// <summary>A failed write completes the writer, reports once, and later frames are refused rather than lost silently.</summary>
|
|
[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));
|
|
}
|
|
}
|