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
This commit is contained in:
Joseph Doherty
2026-11-11 19:11:10 -05:00
parent f109e5f29e
commit 3c0e5ea4d7
7 changed files with 328 additions and 83 deletions
@@ -78,14 +78,21 @@ 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.
/// 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>
public static bool UseBlockingIo => !OperatingSystem.IsWindows();
/// <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>