perf(scripting): cut out-of-process runner host CPU; compile timeout from compile start; quiet start-failure logs (lmxopcua#789, #790, #788)
v2-ci / unit-tests (push) Has been cancelled
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
This commit is contained in:
@@ -62,30 +62,14 @@ public static class ScriptWorkerFrameCodec
|
||||
if (got < 4) throw new ScriptWorkerProtocolException("truncated frame header");
|
||||
|
||||
var size = BinaryPrimitives.ReadInt32LittleEndian(header);
|
||||
if (size == 0) throw new ScriptWorkerProtocolException("zero-length frame");
|
||||
if (size < 0 || size > maxFrameBytes)
|
||||
throw new ScriptWorkerProtocolException($"frame length {size} is outside 1..{maxFrameBytes}");
|
||||
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");
|
||||
Envelope envelope;
|
||||
try
|
||||
{
|
||||
envelope = Envelope.Parser.ParseFrom(body, 0, size);
|
||||
}
|
||||
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;
|
||||
return ParseBody(body.AsSpan(0, size));
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -93,6 +77,104 @@ public static class ScriptWorkerFrameCodec
|
||||
}
|
||||
}
|
||||
|
||||
/// <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;
|
||||
|
||||
Reference in New Issue
Block a user