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) { }
}