Files
lmxopcua/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Protocol/ScriptWorkerFrameCodec.cs
T
Joseph Doherty 3c0e5ea4d7 perf(scripting): #789 Windows worker pipe I/O blocks on dedicated threads, off the completion port
A .NET pipe opened Asynchronous completes even its synchronous Read/Write
through the thread pool's completion port, so every frame on Windows cost a
completion packet plus a pool work item (and the pool's spin-wait). New
WindowsBlockingPipeStream issues its own overlapped ReadFile/WriteFile with the
OVERLAPPED.hEvent low bit set (no completion packet) and waits on that event on
the calling thread; reads and writes stay concurrent, unlike a synchronous
handle. ScriptWorkerFrameCodec.BlockingStream picks it on Windows and the pipe
itself on Unix, so both ends now use the dedicated blocking reader + synchronous
combining writer on every OS (UseBlockingIo and the async read loops are gone).

Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
2026-11-11 19:11:10 -05:00

206 lines
9.7 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>
/// The stream both ends drive a connected worker pipe through, with blocking I/O on dedicated threads
/// (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; so the pipe itself
/// is used, only ever synchronously. On Windows an overlapped pipe completes even its synchronous calls through the
/// thread pool's completion port, so the pipe is wrapped in a <see cref="WindowsBlockingPipeStream"/>, which waits
/// for its own overlapped I/O without the port and keeps reads and writes concurrent.
/// </summary>
/// <param name="pipe">The connected pipe (opened with <see cref="System.IO.Pipes.PipeOptions.Asynchronous"/>).</param>
/// <returns>The stream to read and write frames through; disposing it closes the pipe.</returns>
public static Stream BlockingStream(System.IO.Pipes.PipeStream pipe)
{
ArgumentNullException.ThrowIfNull(pipe);
return OperatingSystem.IsWindows() ? new WindowsBlockingPipeStream(pipe) : pipe;
}
/// <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) { }
}