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; /// /// Length-prefixed protobuf framing for the ScriptWorker pipe (lmxopcua#780, design §4.2): a 4-byte little-endian /// length, then one serialized . Copies mxaccessgw's WorkerFrameProtocol discipline: /// zero-length, oversize, truncated and unparseable frames are all a , /// after which the stream is unusable and the peer is dropped. /// public static class ScriptWorkerFrameCodec { /// The protocol version both ends put on every envelope. public const uint ProtocolVersion = 1; /// Default frame cap: 4 MiB. public const int DefaultMaxFrameBytes = 4 * 1024 * 1024; /// Writes one frame. Not thread-safe: callers serialise writes. /// The pipe. /// The envelope. /// The negotiated cap; a larger envelope is refused before anything is written. /// Cancellation. /// A task that completes when the frame is written (not flushed). 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.Shared.Rent(size + 4); try { BinaryPrimitives.WriteInt32LittleEndian(buffer, size); envelope.WriteTo(new Span(buffer, 4, size)); await stream.WriteAsync(buffer.AsMemory(0, size + 4), ct).ConfigureAwait(false); } finally { ArrayPool.Shared.Return(buffer); } } /// Reads one frame. /// The pipe. /// The negotiated cap. /// Cancellation. /// The envelope, or null on a clean end of stream at a frame boundary. /// A zero-length, oversize, truncated or unparseable frame. public static async ValueTask 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.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.Shared.Return(body); } } /// /// 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. /// public static bool UseBlockingIo => !OperatingSystem.IsWindows(); /// Writes one frame synchronously (the blocking-I/O handshake). Not thread-safe. /// The pipe. /// The envelope. /// The cap. 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.Shared.Rent(size + 4); try { BinaryPrimitives.WriteInt32LittleEndian(buffer, size); envelope.WriteTo(new Span(buffer, 4, size)); stream.Write(buffer, 0, size + 4); } finally { ArrayPool.Shared.Return(buffer); } } /// /// Reads exactly one frame synchronously, never past it (the blocking-I/O handshake: what follows belongs to the /// connection's own reader). /// /// The pipe. /// The cap. /// The envelope, or null on a clean end of stream at a frame boundary. public static Envelope? Read(Stream stream, int maxFrameBytes) { ArgumentNullException.ThrowIfNull(stream); Span 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.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.Shared.Return(body); } } /// Validates a frame's length prefix against the cap. /// The decoded length. /// The negotiated cap. /// A zero, negative or oversize length. 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}"); } /// Parses and validates one frame body (everything after the length prefix). /// The body bytes. /// The envelope. /// Unparseable, the wrong protocol version, or no body. internal static Envelope ParseBody(ReadOnlySpan 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 ReadFullyAsync(Stream stream, Memory 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; } } /// A framing or schema violation on the ScriptWorker pipe; the connection is dropped. public sealed class ScriptWorkerProtocolException : Exception { /// Initializes the exception. /// What was wrong. /// The underlying parser error, if any. public ScriptWorkerProtocolException(string message, Exception? inner = null) : base(message, inner) { } }