8582ce91bc
v2-ci / unit-tests (push) Has been cancelled
#789 (partial, further tuning deferred): combining inline frame writer, buffered frame reader, one timer per evaluation for T and T+G, slot release on the reader before the caller resumes, a non-spinning per-connection result dispatcher, and blocking pipe I/O on Unix (both ends) so the socket never enters the socket engine; TerminateJobObject kill on Windows. macOS host CPU at 10k evals/s K=2: 228-240 % -> 79 % (InProcess 69 %, IPC share +10 vs budget 25); windev 153 % -> 123 % (+115, still a miss). Harness reports the "vs InProcess" share, warm-cache worker RSS, and times kill latency from the soft-timeout answer. #790: the worker sends LoadStarted; the compile timeout is armed then, so a load queued behind other compiles is never killed or struck. #788: one Error with stack per down period, rate-limited Warnings with attempt count, one Information on recovery. Design doc §8.2 notes + §12.2 record. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
199 lines
9.1 KiB
C#
199 lines
9.1 KiB
C#
using System.Buffers;
|
|
using System.Buffers.Binary;
|
|
using Google.Protobuf;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Scripting.Protocol.V1;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Protocol;
|
|
|
|
/// <summary>
|
|
/// Length-prefixed protobuf framing for the ScriptWorker pipe (lmxopcua#780, design §4.2): a 4-byte little-endian
|
|
/// length, then one serialized <see cref="Envelope"/>. Copies mxaccessgw's <c>WorkerFrameProtocol</c> discipline:
|
|
/// zero-length, oversize, truncated and unparseable frames are all a <see cref="ScriptWorkerProtocolException"/>,
|
|
/// after which the stream is unusable and the peer is dropped.
|
|
/// </summary>
|
|
public static class ScriptWorkerFrameCodec
|
|
{
|
|
/// <summary>The protocol version both ends put on every envelope.</summary>
|
|
public const uint ProtocolVersion = 1;
|
|
|
|
/// <summary>Default frame cap: 4 MiB.</summary>
|
|
public const int DefaultMaxFrameBytes = 4 * 1024 * 1024;
|
|
|
|
/// <summary>Writes one frame. Not thread-safe: callers serialise writes.</summary>
|
|
/// <param name="stream">The pipe.</param>
|
|
/// <param name="envelope">The envelope.</param>
|
|
/// <param name="maxFrameBytes">The negotiated cap; a larger envelope is refused before anything is written.</param>
|
|
/// <param name="ct">Cancellation.</param>
|
|
/// <returns>A task that completes when the frame is written (not flushed).</returns>
|
|
public static async ValueTask WriteAsync(Stream stream, Envelope envelope, int maxFrameBytes, CancellationToken ct = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(stream);
|
|
ArgumentNullException.ThrowIfNull(envelope);
|
|
var size = envelope.CalculateSize();
|
|
if (size == 0) throw new ScriptWorkerProtocolException("refusing to write an empty frame");
|
|
if (size > maxFrameBytes)
|
|
throw new ScriptWorkerProtocolException($"frame of {size} bytes exceeds the {maxFrameBytes}-byte cap");
|
|
|
|
var buffer = ArrayPool<byte>.Shared.Rent(size + 4);
|
|
try
|
|
{
|
|
BinaryPrimitives.WriteInt32LittleEndian(buffer, size);
|
|
envelope.WriteTo(new Span<byte>(buffer, 4, size));
|
|
await stream.WriteAsync(buffer.AsMemory(0, size + 4), ct).ConfigureAwait(false);
|
|
}
|
|
finally
|
|
{
|
|
ArrayPool<byte>.Shared.Return(buffer);
|
|
}
|
|
}
|
|
|
|
/// <summary>Reads one frame.</summary>
|
|
/// <param name="stream">The pipe.</param>
|
|
/// <param name="maxFrameBytes">The negotiated cap.</param>
|
|
/// <param name="ct">Cancellation.</param>
|
|
/// <returns>The envelope, or null on a clean end of stream at a frame boundary.</returns>
|
|
/// <exception cref="ScriptWorkerProtocolException">A zero-length, oversize, truncated or unparseable frame.</exception>
|
|
public static async ValueTask<Envelope?> ReadAsync(Stream stream, int maxFrameBytes, CancellationToken ct = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(stream);
|
|
var header = new byte[4];
|
|
var got = await ReadFullyAsync(stream, header, ct).ConfigureAwait(false);
|
|
if (got == 0) return null;
|
|
if (got < 4) throw new ScriptWorkerProtocolException("truncated frame header");
|
|
|
|
var size = BinaryPrimitives.ReadInt32LittleEndian(header);
|
|
CheckLength(size, maxFrameBytes);
|
|
|
|
var body = ArrayPool<byte>.Shared.Rent(size);
|
|
try
|
|
{
|
|
var read = await ReadFullyAsync(stream, body.AsMemory(0, size), ct).ConfigureAwait(false);
|
|
if (read < size) throw new ScriptWorkerProtocolException($"truncated frame: {read} of {size} bytes");
|
|
return ParseBody(body.AsSpan(0, size));
|
|
}
|
|
finally
|
|
{
|
|
ArrayPool<byte>.Shared.Return(body);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Whether this platform's pipe should be driven with blocking I/O (lmxopcua#789). On Unix a .NET pipe is a
|
|
/// socket, and ONE asynchronous operation on it switches it to non-blocking for good, after which even a
|
|
/// "blocking" read goes through the socket engine (an epoll-thread hand-off and a spinning wait per frame) —
|
|
/// measured as most of the host's per-evaluation CPU. Kept blocking, a read is a plain kernel wait on a
|
|
/// dedicated thread. Not on Windows, where an overlapped pipe handle is needed for full duplex and its
|
|
/// asynchronous path is the efficient one.
|
|
/// </summary>
|
|
public static bool UseBlockingIo => !OperatingSystem.IsWindows();
|
|
|
|
/// <summary>Writes one frame synchronously (the blocking-I/O handshake). Not thread-safe.</summary>
|
|
/// <param name="stream">The pipe.</param>
|
|
/// <param name="envelope">The envelope.</param>
|
|
/// <param name="maxFrameBytes">The cap.</param>
|
|
public static void Write(Stream stream, Envelope envelope, int maxFrameBytes)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(stream);
|
|
ArgumentNullException.ThrowIfNull(envelope);
|
|
var size = envelope.CalculateSize();
|
|
if (size == 0) throw new ScriptWorkerProtocolException("refusing to write an empty frame");
|
|
if (size > maxFrameBytes)
|
|
throw new ScriptWorkerProtocolException($"frame of {size} bytes exceeds the {maxFrameBytes}-byte cap");
|
|
var buffer = ArrayPool<byte>.Shared.Rent(size + 4);
|
|
try
|
|
{
|
|
BinaryPrimitives.WriteInt32LittleEndian(buffer, size);
|
|
envelope.WriteTo(new Span<byte>(buffer, 4, size));
|
|
stream.Write(buffer, 0, size + 4);
|
|
}
|
|
finally
|
|
{
|
|
ArrayPool<byte>.Shared.Return(buffer);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads exactly one frame synchronously, never past it (the blocking-I/O handshake: what follows belongs to the
|
|
/// connection's own reader).
|
|
/// </summary>
|
|
/// <param name="stream">The pipe.</param>
|
|
/// <param name="maxFrameBytes">The cap.</param>
|
|
/// <returns>The envelope, or null on a clean end of stream at a frame boundary.</returns>
|
|
public static Envelope? Read(Stream stream, int maxFrameBytes)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(stream);
|
|
Span<byte> header = stackalloc byte[4];
|
|
var got = stream.ReadAtLeast(header, 4, throwOnEndOfStream: false);
|
|
if (got == 0) return null;
|
|
if (got < 4) throw new ScriptWorkerProtocolException("truncated frame header");
|
|
var size = BinaryPrimitives.ReadInt32LittleEndian(header);
|
|
CheckLength(size, maxFrameBytes);
|
|
var body = ArrayPool<byte>.Shared.Rent(size);
|
|
try
|
|
{
|
|
var read = stream.ReadAtLeast(body.AsSpan(0, size), size, throwOnEndOfStream: false);
|
|
if (read < size) throw new ScriptWorkerProtocolException($"truncated frame: {read} of {size} bytes");
|
|
return ParseBody(body.AsSpan(0, size));
|
|
}
|
|
finally
|
|
{
|
|
ArrayPool<byte>.Shared.Return(body);
|
|
}
|
|
}
|
|
|
|
/// <summary>Validates a frame's length prefix against the cap.</summary>
|
|
/// <param name="size">The decoded length.</param>
|
|
/// <param name="maxFrameBytes">The negotiated cap.</param>
|
|
/// <exception cref="ScriptWorkerProtocolException">A zero, negative or oversize length.</exception>
|
|
internal static void CheckLength(int size, int maxFrameBytes)
|
|
{
|
|
if (size == 0) throw new ScriptWorkerProtocolException("zero-length frame");
|
|
if (size < 0 || size > maxFrameBytes)
|
|
throw new ScriptWorkerProtocolException($"frame length {size} is outside 1..{maxFrameBytes}");
|
|
}
|
|
|
|
/// <summary>Parses and validates one frame body (everything after the length prefix).</summary>
|
|
/// <param name="body">The body bytes.</param>
|
|
/// <returns>The envelope.</returns>
|
|
/// <exception cref="ScriptWorkerProtocolException">Unparseable, the wrong protocol version, or no body.</exception>
|
|
internal static Envelope ParseBody(ReadOnlySpan<byte> body)
|
|
{
|
|
Envelope envelope;
|
|
try
|
|
{
|
|
envelope = Envelope.Parser.ParseFrom(body);
|
|
}
|
|
catch (InvalidProtocolBufferException ex)
|
|
{
|
|
throw new ScriptWorkerProtocolException($"unparseable frame: {ex.Message}", ex);
|
|
}
|
|
if (envelope.ProtocolVersion != ProtocolVersion)
|
|
throw new ScriptWorkerProtocolException(
|
|
$"protocol version {envelope.ProtocolVersion} is not the supported {ProtocolVersion}");
|
|
if (envelope.BodyCase == Envelope.BodyOneofCase.None)
|
|
throw new ScriptWorkerProtocolException("frame carries no body");
|
|
return envelope;
|
|
}
|
|
|
|
private static async ValueTask<int> ReadFullyAsync(Stream stream, Memory<byte> buffer, CancellationToken ct)
|
|
{
|
|
var total = 0;
|
|
while (total < buffer.Length)
|
|
{
|
|
var n = await stream.ReadAsync(buffer[total..], ct).ConfigureAwait(false);
|
|
if (n == 0) break;
|
|
total += n;
|
|
}
|
|
return total;
|
|
}
|
|
}
|
|
|
|
/// <summary>A framing or schema violation on the ScriptWorker pipe; the connection is dropped.</summary>
|
|
public sealed class ScriptWorkerProtocolException : Exception
|
|
{
|
|
/// <summary>Initializes the exception.</summary>
|
|
/// <param name="message">What was wrong.</param>
|
|
/// <param name="inner">The underlying parser error, if any.</param>
|
|
public ScriptWorkerProtocolException(string message, Exception? inner = null) : base(message, inner) { }
|
|
}
|