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

#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:
Joseph Doherty
2026-11-10 04:25:08 -05:00
parent 963b0d66fc
commit 8582ce91bc
14 changed files with 1576 additions and 317 deletions
@@ -22,7 +22,9 @@ internal static class Program
PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
try
{
await pipe.ConnectAsync(TimeSpan.FromSeconds(10), CancellationToken.None);
// Synchronous on Unix so the socket under the pipe stays blocking (ScriptWorkerFrameCodec.UseBlockingIo).
if (ScriptWorkerFrameCodec.UseBlockingIo) pipe.Connect(TimeSpan.FromSeconds(10));
else await pipe.ConnectAsync(TimeSpan.FromSeconds(10), CancellationToken.None);
}
catch (Exception ex)
{
@@ -2,7 +2,6 @@ using System.Collections;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.IO.Pipes;
using System.Threading.Channels;
using Serilog;
using Serilog.Core;
using Serilog.Events;
@@ -33,8 +32,6 @@ internal sealed class WorkerHost
private readonly NamedPipeClientStream _pipe;
private readonly string _nonce;
private readonly int _parentPid;
private readonly Channel<Envelope> _outbound = Channel.CreateUnbounded<Envelope>(
new UnboundedChannelOptions { SingleReader = true });
private readonly ConcurrentDictionary<string, LoadedScript> _scripts = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<long, RunningEval> _running = new();
private readonly BlockingCollection<EvalRequest> _queue = new(new ConcurrentQueue<EvalRequest>());
@@ -47,8 +44,16 @@ internal sealed class WorkerHost
/// </summary>
private readonly BlockingCollection<LoadScripts> _loads = new(new ConcurrentQueue<LoadScripts>());
/// <summary>Dedicated compile threads: half the processors, at least 1, at most 8.</summary>
internal static int CompileThreads => Math.Clamp(Environment.ProcessorCount / 2, 1, 8);
/// <summary>Default dedicated compile threads: half the processors, at least 1, at most 8.</summary>
internal static int DefaultCompileThreads => Math.Clamp(Environment.ProcessorCount / 2, 1, 8);
/// <summary>
/// Outbound frames (lmxopcua#789): the thread that produces a frame — an evaluation thread with its result, a
/// compile thread with its load result — writes it itself when no write is in progress, instead of handing it to
/// a writer task through a channel (one thread-pool wake-up and spin per result).
/// </summary>
private ScriptWorkerFrameWriter? _writer;
private int _compileThreads = DefaultCompileThreads;
private readonly ForwardingLogSink _logSink;
private readonly ILogger _scriptLogger;
private int _maxFrameBytes = ScriptWorkerFrameCodec.DefaultMaxFrameBytes;
@@ -71,7 +76,11 @@ internal sealed class WorkerHost
{
try
{
var hello = await ScriptWorkerFrameCodec.ReadAsync(_pipe, ScriptWorkerFrameCodec.DefaultMaxFrameBytes);
// Blocking I/O on Unix from the first byte (lmxopcua#789): one async call would turn the socket under the
// pipe non-blocking for good, and every later read would go through the socket engine.
var hello = ScriptWorkerFrameCodec.UseBlockingIo
? ScriptWorkerFrameCodec.Read(_pipe, ScriptWorkerFrameCodec.DefaultMaxFrameBytes)
: await ScriptWorkerFrameCodec.ReadAsync(_pipe, ScriptWorkerFrameCodec.DefaultMaxFrameBytes);
if (hello is null) return ScriptWorkerEnvironment.ExitPipeClosed;
if (hello.BodyCase != Envelope.BodyOneofCase.Hello)
throw new ScriptWorkerProtocolException($"expected Hello, got {hello.BodyCase}");
@@ -79,7 +88,7 @@ internal sealed class WorkerHost
// The nonce is echoed, not trusted: the host compares it with the one it put in OUR environment, which
// proves the process on the other end of its pipe is the one it launched.
await ScriptWorkerFrameCodec.WriteAsync(_pipe, Wrap(new Envelope
var ready = Wrap(new Envelope
{
Ready = new Ready
{
@@ -87,14 +96,16 @@ internal sealed class WorkerHost
Pid = Environment.ProcessId,
RuntimeVersion = Environment.Version.ToString(),
},
}), _maxFrameBytes);
});
if (ScriptWorkerFrameCodec.UseBlockingIo) ScriptWorkerFrameCodec.Write(_pipe, ready, _maxFrameBytes);
else await ScriptWorkerFrameCodec.WriteAsync(_pipe, ready, _maxFrameBytes);
_logSink.Capacity = (int)Math.Max(1, hello.Hello.MaxLogLines == 0 ? 1000 : hello.Hello.MaxLogLines);
_writer = new ScriptWorkerFrameWriter(_pipe, OnWriteFault, _maxFrameBytes, firstSeq: Interlocked.Read(ref _seq),
synchronous: ScriptWorkerFrameCodec.UseBlockingIo);
StartBackground(Math.Max(1, (int)hello.Hello.EvalThreads));
var writer = Task.Run(WriteLoopAsync);
var exit = await ReadLoopAsync();
_outbound.Writer.TryComplete();
try { await writer.WaitAsync(TimeSpan.FromSeconds(1)); } catch { /* exiting regardless */ }
var exit = ScriptWorkerFrameCodec.UseBlockingIo ? await ReadOnDedicatedThread() : await ReadLoopAsync();
_writer.Complete();
return exit;
}
catch (ScriptWorkerProtocolException ex)
@@ -114,6 +125,7 @@ internal sealed class WorkerHost
if (hello.QueueCapacity > 0) _queueCapacity = (int)hello.QueueCapacity;
if (hello.HardGraceMs > 0) _hardGrace = TimeSpan.FromMilliseconds(hello.HardGraceMs);
if (hello.HeartbeatIntervalMs > 0) _heartbeatInterval = TimeSpan.FromMilliseconds(hello.HeartbeatIntervalMs);
if (hello.CompileThreads > 0) _compileThreads = (int)Math.Min(hello.CompileThreads, 64);
if (!string.IsNullOrEmpty(hello.PeCacheDir) && hello.PeCacheKey.Length == ScriptPeCacheStore.KeyLength)
{
// Design §7: the host owns the key; it reaches us only here, never through the environment or a file.
@@ -127,7 +139,7 @@ internal sealed class WorkerHost
{
new Thread(EvalThreadLoop) { IsBackground = true, Name = $"ScriptWorker.Eval{i}" }.Start();
}
for (var i = 0; i < CompileThreads; i++)
for (var i = 0; i < _compileThreads; i++)
{
new Thread(CompileThreadLoop) { IsBackground = true, Name = $"ScriptWorker.Compile{i}" }.Start();
}
@@ -137,14 +149,50 @@ internal sealed class WorkerHost
if (_parentPid > 0) _ = Task.Run(ParentWatchLoopAsync);
}
/// <summary>The blocking read loop on its own thread (Unix, lmxopcua#789); completes with the exit code.</summary>
private Task<int> ReadOnDedicatedThread()
{
var done = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);
new Thread(() =>
{
var reader = new ScriptWorkerFrameReader(_pipe, _maxFrameBytes);
while (true)
{
Envelope? frame;
try
{
frame = reader.Read();
}
catch (ScriptWorkerProtocolException ex)
{
Console.Error.WriteLine($"ScriptWorker: protocol fault: {ex.Message}");
done.TrySetResult(ScriptWorkerEnvironment.ExitProtocolFault);
return;
}
catch (Exception ex) when (ex is IOException or ObjectDisposedException or System.Net.Sockets.SocketException)
{
done.TrySetResult(ScriptWorkerEnvironment.ExitPipeClosed);
return;
}
if (HandleFrame(frame) is { } exit)
{
done.TrySetResult(exit);
return;
}
}
}) { IsBackground = true, Name = "ScriptWorker.Reader" }.Start();
return done.Task;
}
private async Task<int> ReadLoopAsync()
{
var reader = new ScriptWorkerFrameReader(_pipe, _maxFrameBytes);
while (true)
{
Envelope? frame;
try
{
frame = await ScriptWorkerFrameCodec.ReadAsync(_pipe, _maxFrameBytes);
frame = await reader.ReadAsync();
}
catch (ScriptWorkerProtocolException ex)
{
@@ -155,42 +203,47 @@ internal sealed class WorkerHost
{
return ScriptWorkerEnvironment.ExitPipeClosed;
}
if (frame is null) return ScriptWorkerEnvironment.ExitPipeClosed; // host gone: fatal by design
if (HandleFrame(frame) is { } exit) return exit;
}
}
switch (frame.BodyCase)
{
case Envelope.BodyOneofCase.Load:
/// <summary>Acts on one frame from the host.</summary>
/// <param name="frame">The frame; null = end of stream.</param>
/// <returns>The exit code when the worker must end, otherwise null.</returns>
private int? HandleFrame(Envelope? frame)
{
if (frame is null) return ScriptWorkerEnvironment.ExitPipeClosed; // host gone: fatal by design
switch (frame.BodyCase)
{
case Envelope.BodyOneofCase.Load:
// Compile off the reader, so evaluations for already-loaded keys keep flowing during a load, and off
// the thread pool, so the heartbeat and the writer keep flowing during a burst of them.
_loads.Add(frame.Load);
return null;
case Envelope.BodyOneofCase.Unload:
foreach (var key in frame.Unload.Keys)
if (_scripts.TryRemove(key, out var gone)) gone.Dispose();
return null;
case Envelope.BodyOneofCase.Eval:
foreach (var item in frame.Eval.Items) Admit(item);
return null;
case Envelope.BodyOneofCase.Cancel:
if (_running.TryGetValue((long)frame.Cancel.EvalId, out var toCancel))
{
// Compile off the reader, so evaluations for already-loaded keys keep flowing during a load, and
// off the thread pool, so the heartbeat and the writer keep flowing during a burst of them.
_loads.Add(frame.Load);
break;
try { toCancel.Cancellation.Cancel(); } catch (ObjectDisposedException) { /* just finished */ }
}
case Envelope.BodyOneofCase.Unload:
foreach (var key in frame.Unload.Keys)
if (_scripts.TryRemove(key, out var gone)) gone.Dispose();
break;
case Envelope.BodyOneofCase.Eval:
foreach (var item in frame.Eval.Items) Admit(item);
break;
case Envelope.BodyOneofCase.Cancel:
if (_running.TryGetValue((long)frame.Cancel.EvalId, out var toCancel))
{
try { toCancel.Cancellation.Cancel(); } catch (ObjectDisposedException) { /* just finished */ }
}
break;
case Envelope.BodyOneofCase.DiagRequest:
Send(new Envelope { DiagReply = Diagnostics() });
break;
case Envelope.BodyOneofCase.Shutdown:
return 0;
case Envelope.BodyOneofCase.Fault:
await Console.Error.WriteLineAsync($"ScriptWorker: host fault: {frame.Fault.Message}");
return ScriptWorkerEnvironment.ExitProtocolFault;
default:
await Console.Error.WriteLineAsync($"ScriptWorker: unexpected frame {frame.BodyCase}");
return ScriptWorkerEnvironment.ExitProtocolFault;
}
return null;
case Envelope.BodyOneofCase.DiagRequest:
Send(new Envelope { DiagReply = Diagnostics() });
return null;
case Envelope.BodyOneofCase.Shutdown:
return 0;
case Envelope.BodyOneofCase.Fault:
Console.Error.WriteLine($"ScriptWorker: host fault: {frame.Fault.Message}");
return ScriptWorkerEnvironment.ExitProtocolFault;
default:
Console.Error.WriteLine($"ScriptWorker: unexpected frame {frame.BodyCase}");
return ScriptWorkerEnvironment.ExitProtocolFault;
}
}
@@ -201,6 +254,8 @@ internal sealed class WorkerHost
private void Load(LoadScripts load)
{
// The host's compile timeout runs from here, not from when it sent the load (lmxopcua#790).
Send(new Envelope { LoadStarted = new LoadStarted { LoadId = load.LoadId } });
var result = new LoadResult { LoadId = load.LoadId };
foreach (var script in load.Scripts)
{
@@ -423,41 +478,15 @@ internal sealed class WorkerHost
}
}
private async Task WriteLoopAsync()
private static void OnWriteFault(Exception ex)
{
var reader = _outbound.Reader;
var pendingResults = new EvalResults();
try
// The host is gone (or a frame broke the protocol): fatal by design.
if (ex is ScriptWorkerProtocolException)
{
while (await reader.WaitToReadAsync())
{
// Opportunistic coalescing (design §4.5): drain whatever is queued right now, merging every completed
// evaluation into one EvalResults frame; no timer, so an idle pipe adds no latency.
while (reader.TryRead(out var envelope))
{
if (envelope.BodyCase == Envelope.BodyOneofCase.Results)
{
pendingResults.Items.AddRange(envelope.Results.Items);
continue;
}
await ScriptWorkerFrameCodec.WriteAsync(_pipe, envelope, _maxFrameBytes);
}
if (pendingResults.Items.Count > 0)
{
await ScriptWorkerFrameCodec.WriteAsync(_pipe, Wrap(new Envelope { Results = pendingResults }), _maxFrameBytes);
pendingResults = new EvalResults();
}
await _pipe.FlushAsync();
}
}
catch (IOException)
{
Environment.Exit(ScriptWorkerEnvironment.ExitPipeClosed);
}
catch (ObjectDisposedException)
{
Environment.Exit(ScriptWorkerEnvironment.ExitPipeClosed);
Console.Error.WriteLine($"ScriptWorker: protocol fault writing a frame: {ex.Message}");
Environment.Exit(ScriptWorkerEnvironment.ExitProtocolFault);
}
Environment.Exit(ScriptWorkerEnvironment.ExitPipeClosed);
}
private void SendResult(EvalResult result)
@@ -467,7 +496,7 @@ internal sealed class WorkerHost
Send(new Envelope { Results = results });
}
private void Send(Envelope envelope) => _outbound.Writer.TryWrite(Wrap(envelope));
private void Send(Envelope envelope) => _writer?.TryWrite(envelope);
private Envelope Wrap(Envelope envelope)
{