feat(scripting): self-contained script worker publish, docker-dev drill, §8 perf harness (lmxopcua#780 P1)
v2-ci / unit-tests (push) Waiting to run

- A self-contained Host publish (-r win-x64 --self-contained, the Windows installs) now publishes the
  ScriptWorker self-contained for the same RID into script-worker/. The launcher starts its apphost
  directly when runtimeconfig.json lists includedFrameworks. A framework-dependent worker (docker-dev)
  keeps running through the dotnet muxer.
- docker-dev/docker-compose.script-runner.yml: OutOfProcess for the central pair's VT + Calc seams.
  Live drill recorded in design §12.1 (runaway kill + quarantine, kill -9 recovery, open circuit
  with /health/ready still 200, metrics).
- ScriptRunnerPerfTests (Category=Perf, opt-in via OTOPCUA_SCRIPT_PERF=1): InProcess vs OutOfProcess
  at K=1/2/4. The accepted 5k evals/s/node gate passes on macOS and windev. The host-CPU, RSS and
  Windows latency budget misses are tracked as #789.
- Fix: the single-flight slot is released before the awaiting caller resumes. Before, a caller that
  started its next run right after the previous one returned could be refused with "previous
  evaluation still running", which the seams publish as Bad.
- Fix: the worker compiles on dedicated threads, not the thread pool. A burst of cold loads starved
  the heartbeat, and the host killed the shard (found on windev). Load-timeout queueing is #790.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-10 02:53:53 -05:00
parent f50b29abae
commit 1ead8f458a
13 changed files with 836 additions and 20 deletions
@@ -278,11 +278,61 @@ public sealed class OutOfProcessScriptRunner : IScriptRunner, IAsyncDisposable,
/// <summary>The default worker path, or the configured one.</summary>
/// <param name="worker">The worker options.</param>
/// <returns>The absolute worker path.</returns>
/// <remarks>
/// With no <see cref="ScriptWorkerOptions.ExecutablePath"/>, the worker is looked up in <c>script-worker/</c>
/// under the host's base directory by <see cref="ResolveWorkerEntry"/>: a self-contained worker (the Windows
/// installs, which have no .NET runtime) is started through its apphost, a framework-dependent one (docker-dev)
/// through the dotnet muxer.
/// </remarks>
public static string ResolveWorkerPath(ScriptWorkerOptions worker)
=> string.IsNullOrWhiteSpace(worker.ExecutablePath)
? Path.Combine(AppContext.BaseDirectory, WorkerFolderName, WorkerAssemblyFileName)
? ResolveWorkerEntry(Path.Combine(AppContext.BaseDirectory, WorkerFolderName))
: Path.GetFullPath(worker.ExecutablePath);
/// <summary>The worker's apphost file name on this OS.</summary>
public static string WorkerAppHostFileName
=> OperatingSystem.IsWindows() ? "ZB.MOM.WW.OtOpcUa.ScriptWorker.exe" : "ZB.MOM.WW.OtOpcUa.ScriptWorker";
/// <summary>
/// The entry a worker folder should be launched through: its apphost when the folder holds a
/// <b>self-contained</b> worker, otherwise the entry assembly (run through the dotnet muxer).
/// </summary>
/// <param name="folder">The worker folder.</param>
/// <returns>The absolute path of the apphost or of the entry assembly (which may not exist).</returns>
/// <remarks>
/// A framework-dependent build also emits an apphost, but that apphost needs an installed runtime the
/// self-contained Windows hosts do not have, and the muxer path is what docker-dev and the tests have always
/// used, so the apphost is chosen only for a self-contained worker. Self-contained is read from the worker's
/// <c>runtimeconfig.json</c>: the SDK writes <c>includedFrameworks</c> for a self-contained app and
/// <c>framework</c>/<c>frameworks</c> for a framework-dependent one.
/// </remarks>
public static string ResolveWorkerEntry(string folder)
{
var full = Path.GetFullPath(folder);
var appHost = Path.Combine(full, WorkerAppHostFileName);
return File.Exists(appHost) && IsSelfContainedWorker(full) ? appHost : Path.Combine(full, WorkerAssemblyFileName);
}
/// <summary>Whether the worker in <paramref name="folder"/> was published self-contained.</summary>
/// <param name="folder">The worker folder.</param>
/// <returns>True when its <c>runtimeconfig.json</c> lists <c>includedFrameworks</c>.</returns>
public static bool IsSelfContainedWorker(string folder)
{
var config = Path.Combine(folder, Path.GetFileNameWithoutExtension(WorkerAssemblyFileName) + ".runtimeconfig.json");
if (!File.Exists(config)) return false;
try
{
using var doc = System.Text.Json.JsonDocument.Parse(File.ReadAllText(config),
new System.Text.Json.JsonDocumentOptions { CommentHandling = System.Text.Json.JsonCommentHandling.Skip, AllowTrailingCommas = true });
return doc.RootElement.TryGetProperty("runtimeOptions", out var options)
&& options.TryGetProperty("includedFrameworks", out _);
}
catch (Exception ex) when (ex is System.Text.Json.JsonException or IOException or UnauthorizedAccessException)
{
return false;
}
}
/// <inheritdoc />
public async Task<ScriptRunOutcome> RunAsync(ScriptInvocation invocation, CancellationToken ct = default)
{
@@ -369,17 +419,26 @@ public sealed class OutOfProcessScriptRunner : IScriptRunner, IAsyncDisposable,
// death, attribution (strike, quarantine, suspect) has already run when this continuation fires.
handedOff = true;
var heldGate = gate;
_ = pending.Completion.ContinueWith(_ =>
var released = 0;
void ReleaseSlot()
{
if (Interlocked.Exchange(ref released, 1) != 0) return;
SlotReleasingForTest?.Invoke(key);
Interlocked.Exchange(ref state.InFlight, 0);
heldGate?.Release();
}, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
}
_ = pending.Completion.ContinueWith(_ => ReleaseSlot(),
CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
EvalResult? result;
try
{
result = await pending.Completion.WaitAsync(invocation.Timeout, ct).ConfigureAwait(false);
// The worker has answered (or died, with attribution already run): free the slot HERE, before this
// caller resumes. The continuation above is not ordered against this await, so relying on it alone
// let a caller that awaited one run and then started the next see "previous evaluation still running"
// (found by the §8 perf harness: 32 of 200 sequential same-key runs refused). Idempotent.
ReleaseSlot();
}
catch (TimeoutException)
{