using System.Collections.Concurrent;
using System.Diagnostics;
using System.IO.Pipes;
using System.Security.Cryptography;
using System.Text;
using Serilog;
using ZB.MOM.WW.OtOpcUa.Core.Scripting.Protocol;
using ZB.MOM.WW.OtOpcUa.Core.Scripting.Protocol.V1;
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess;
/// How a worker process ended.
internal enum WorkerExitReason
{
/// It exited by itself with code 0 (a Shutdown).
Clean,
/// The host killed it: a script crossed its hard deadline ().
KilledDeadline,
/// The host killed it: no frame for three heartbeat intervals.
KilledHeartbeat,
///
/// The host killed it: it kept heartbeating but answered nothing — no result, no start, no soft-deadline notice —
/// for the progress ceiling while evaluations were outstanding (#827 review F3). Attributed like a heartbeat kill.
///
KilledUnresponsive,
/// The host killed it: a compile did not finish in time ().
KilledLoadTimeout,
/// It died by itself (OOM, heap limit, fail-fast, external kill).
Crash,
/// A framing or handshake violation.
ProtocolFault,
/// The runner is shutting down.
Disposed,
/// The host stopped an idle worker (the isolation worker after IsolationIdleTimeout); not a failure.
Idle,
}
/// The end of one worker process.
/// Why it ended.
/// The scoped key the host blamed, for a deadline or load kill.
/// The OS exit code, when known.
/// The scoped keys that had an evaluation outstanding when it ended.
/// The scoped keys whose compile (load) was outstanding when it ended.
internal sealed record WorkerExit(WorkerExitReason Reason, string? CulpritKey, int? ExitCode, IReadOnlyList InFlightKeys,
IReadOnlyList LoadingKeys)
{
/// An exit with no load in flight.
public WorkerExit(WorkerExitReason reason, string? culpritKey, int? exitCode, IReadOnlyList inFlightKeys)
: this(reason, culpritKey, exitCode, inFlightKeys, [])
{
}
}
/// One evaluation of a .
/// The scoped key.
/// The request (its id is assigned on submit).
/// T.
/// G.
/// Runs once when the worker has answered or ended, before the caller resumes (the slot release).
internal sealed record BatchSubmission(string Key, EvalRequest Request, TimeSpan SoftTimeout, TimeSpan HardGrace, Action? OnFinished);
/// What the host needs to launch one worker.
/// The shard label (0, 1, … or iso).
/// The worker entry assembly (.dll, run through the dotnet muxer) or apphost.
/// The worker's DOTNET_GCHeapHardLimit.
/// The worker scheduler's width (1 for the isolation worker).
/// The worker watchdog's grace past the soft deadline.
/// How often the worker sends a heartbeat.
/// How long a launch may take to reach Ready.
/// The compiled-PE cache directory handed to the worker; null = no cache.
/// The cache's HMAC key (with ).
/// Where the launch marker for the orphan sweep is written; null = none.
///
/// How long one compile may run, counted from the worker's LoadStarted for it (not from when the host sent
/// it, lmxopcua#790), before the worker is killed and the key struck; null = 60 s.
///
/// The worker's dedicated compile threads; 0 = its default.
///
/// Windows only: the worker identity's SID (Scripting:Worker:Identity). Set, the worker starts under a restricted
/// token () and the pipe ACL admits it beside the host; null keeps the host's token.
///
///
/// How long the worker may send nothing before it is killed (); null =
/// three s. Test seam (with ).
///
///
/// Ask the worker to run the soft clock from each run's start (lmxopcua#827; see ).
/// False (tests only) keeps the pre-#827 host clock, which is also what a worker that does not echo it gets.
///
internal sealed record WorkerLaunchSpec(
string Shard,
string WorkerPath,
long HeapLimitBytes,
int EvalThreads,
TimeSpan HardGrace,
TimeSpan HeartbeatInterval,
TimeSpan HandshakeTimeout,
string? PeCacheDirectory = null,
byte[]? PeCacheKey = null,
string? MarkerDirectory = null,
TimeSpan? LoadTimeout = null,
int CompileThreads = 0,
string? WorkerSid = null,
bool WorkerSoftClock = true,
TimeSpan? HeartbeatSilenceLimit = null)
{
/// The default compile timeout.
public static readonly TimeSpan DefaultLoadTimeout = TimeSpan.FromSeconds(60);
/// or its default.
public TimeSpan EffectiveLoadTimeout => LoadTimeout ?? DefaultLoadTimeout;
}
///
/// One live ScriptWorker process and its pipe (lmxopcua#780, design §3–§6): launch with a cleared, allow-listed
/// environment, a random pipe that accepts exactly one client, a nonce handshake, a frame reader and a coalescing
/// writer, heartbeat supervision, and the kill. It never restarts itself; does that.
///
internal sealed class WorkerConnection : IAsyncDisposable
{
///
/// Environment variables a worker inherits from the host (design §6): the runtime's own knobs, the temp
/// directories and the minimum a Windows process needs. Everything else — notably ServerHistorian__ApiKey,
/// ConfigDb connection strings and every other Section__Key override — is dropped.
///
internal static readonly IReadOnlyList InheritedEnvironment =
[
"PATH", "TEMP", "TMP", "TMPDIR", "HOME", "SystemRoot", "SYSTEMROOT", "windir", "SystemDrive", "ComSpec",
"PATHEXT", "NUMBER_OF_PROCESSORS", "PROCESSOR_ARCHITECTURE", "LOCALAPPDATA",
];
private readonly Process _process;
private readonly NamedPipeServerStream _pipe;
private readonly IDisposable? _job;
private readonly IDisposable? _stdio;
private readonly string _workDir;
private readonly ILogger _log;
private readonly Action _onLogs;
private readonly ScriptWorkerFrameWriter _writer;
private readonly ResultDispatcher _dispatcher;
private readonly ConcurrentDictionary _pending = new();
private readonly ConcurrentDictionary _loads = new();
private readonly ConcurrentDictionary _loaded = new(StringComparer.Ordinal);
private readonly TaskCompletionSource _exited = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly CancellationTokenSource _stopping = new();
private readonly TimeSpan _heartbeatInterval;
private TimeSpan _loadTimeout;
private readonly Action _onExiting;
private readonly string? _markerPath;
private TaskCompletionSource? _diag;
private Heartbeat? _lastHeartbeat;
private long _lastUsedTimestamp;
private int _finished;
private (WorkerExitReason Reason, string? Culprit)? _killedFor;
private long _lastFrameTimestamp;
///
/// of the last evaluation progress — a Results frame of any kind — or of the
/// submit that made the worker busy again. See .
///
private long _lastProgressTimestamp;
private long _nextEvalId;
private long _nextLoadId;
private int _disposed;
private WorkerConnection(string shard, Process process, NamedPipeServerStream pipe, IDisposable? job, IDisposable? stdio,
string workDir, ILogger log, Action onLogs, TimeSpan heartbeatInterval, TimeSpan loadTimeout,
Action onExiting, string? markerPath, bool workerSoftClock, TimeSpan? heartbeatSilenceLimit)
{
WorkerSoftClock = workerSoftClock;
_stdio = stdio;
_loadTimeout = loadTimeout;
_onExiting = onExiting;
_markerPath = markerPath;
Shard = shard;
_process = process;
_pipe = pipe;
_job = job;
_workDir = workDir;
_log = log;
_onLogs = onLogs;
_heartbeatInterval = heartbeatInterval;
_silenceLimitTicks = (heartbeatSilenceLimit ?? heartbeatInterval * 3).Ticks;
Pid = process.Id;
// The handshake used seq 1. A failed write means the pipe is gone: the worker is dead or dying.
_writer = new ScriptWorkerFrameWriter(pipe, OnWriteFault, ScriptWorkerFrameCodec.DefaultMaxFrameBytes, firstSeq: 1,
synchronous: ScriptWorkerFrameCodec.UseBlockingIo);
_dispatcher = new ResultDispatcher($"ScriptWorker[{shard}].Results");
}
/// The shard label.
public string Shard { get; }
/// The worker's process id.
public int Pid { get; }
///
/// lmxopcua#827: the worker runs each evaluation's soft clock from the moment an evaluation thread starts it and
/// reports the crossing, so the soft deadline no longer counts the pipe or the time a starved worker takes to
/// START the run. From the start it is wall-clock time, as in process: a pause during the run still counts. The
/// host keeps a transport deadline of its own instead (see
/// ). Negotiated: asked for in Hello, true only when the worker echoed it in
/// Ready; otherwise the host's clock, started at submit, is the soft deadline (the pre-#827 contract).
///
public bool WorkerSoftClock { get; }
/// Completes when the worker has ended, with why.
public Task Exited => _exited.Task;
/// Whether the worker is still serving.
public bool IsAlive => !_exited.Task.IsCompleted && !_stopping.IsCancellationRequested;
/// when the handshake completed.
public long ReadyTimestamp { get; private set; }
/// Evaluations currently outstanding on this worker.
public int InFlightCount => _pending.Count;
///
/// The compile timeout applied to loads that START from now on (test hook: a test warms the worker's compiler
/// under the launch value, then shortens it).
///
internal TimeSpan LoadTimeout
{
get => _loadTimeout;
set => _loadTimeout = value;
}
///
/// How long the worker may send nothing before it is killed for silence: three heartbeat intervals unless the
/// launch spec said otherwise. Test hook: a test with a tight heartbeat launches and warms the worker (its cold
/// start JITs Roslyn, which on a loaded box can pause it for longer than three short intervals) under a generous
/// limit, then tightens it for the phase it is about.
///
internal TimeSpan SilenceLimit
{
get => TimeSpan.FromTicks(Volatile.Read(ref _silenceLimitTicks));
set => Volatile.Write(ref _silenceLimitTicks, value.Ticks);
}
private long _silenceLimitTicks;
///
/// The host's own bound on a worker that heartbeats but makes no evaluation progress (#827 review F3). With the
/// worker soft clock the host's per-run timer is only the transport deadline, and the kill G is armed by the
/// worker's soft notice — so a worker whose watchdog never sends one (a bug; the design treats the worker as
/// untrusted) would hold a key's slot, and its tag at BadResourceUnavailable, forever; a deferred run has no host
/// clock at all. So: while evaluations are outstanding and none of them has produced a result, start or soft
/// notice for 3 × the largest outstanding T + G plus the silence limit, the worker is killed
/// (, no culprit: attributed like a heartbeat kill, so no key is
/// struck on a shard). A legitimate run never gets near it: by T + G it has answered or been killed. Null = that
/// rule; set = an override (tests).
///
internal TimeSpan? ProgressCeilingOverride { get; set; }
/// Test seam: ignore the worker's soft-deadline notices, as a worker whose watchdog never sends them would.
internal bool DropSoftNoticesForTest { get; set; }
/// The progress ceiling now (see ); null = no outstanding deadline.
private TimeSpan? ProgressCeiling()
{
if (ProgressCeilingOverride is { } fixedCeiling) return fixedCeiling;
var largest = TimeSpan.Zero;
foreach (var (_, pending) in _pending)
if (pending.HardDeadline > largest) largest = pending.HardDeadline;
return largest == TimeSpan.Zero ? null : (largest * 3) + SilenceLimit;
}
/// Compiles currently outstanding on this worker.
public int LoadingCount => _loads.Count;
/// The worker's last heartbeat (its in-flight / queued counts and heap), or null before the first.
public Heartbeat? LastHeartbeat => Volatile.Read(ref _lastHeartbeat);
/// of the last load or evaluation submitted (or of Ready).
public long LastUsedTimestamp => Interlocked.Read(ref _lastUsedTimestamp);
/// Launches a worker and completes the handshake.
/// What to launch.
/// Host diagnostics (stderr lines, exits).
/// Receives the worker's forwarded ctx.Logger batches.
///
/// Attribution, called synchronously when the worker has ended and BEFORE any outstanding evaluation or load is
/// completed or is signalled. A kill strike or suspect mark recorded here is therefore in
/// place before any caller can see its evaluation end and trigger the script again (the quarantine race,
/// lmxopcua#780).
///
/// Cancellation.
/// A ready connection.
public static async Task StartAsync(WorkerLaunchSpec spec, ILogger log, Action onLogs,
Action onExiting, CancellationToken ct)
{
var pipeName = "otopcua-sw-" + Convert.ToHexString(RandomNumberGenerator.GetBytes(12)).ToLowerInvariant();
var nonce = Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
var workDir = Path.Combine(Path.GetTempPath(), "otopcua-scriptworker", pipeName);
Directory.CreateDirectory(workDir);
// One instance, admitted principals only: the pipe accepts exactly one connection. Windows: an explicit,
// protected ACL — the host account and, under Scripting:Worker:Identity, the worker identity (design §6).
// Elsewhere: CurrentUserOnly (a Unix socket owned by this user, 0600).
if (spec.WorkerSid is not null && !OperatingSystem.IsWindows())
throw new PlatformNotSupportedException("Scripting:Worker:Identity is only supported on Windows");
var pipe = OperatingSystem.IsWindows()
? WorkerPipeSecurity.CreateServer(pipeName, WorkerPipeSecurity.ForCurrentHost(spec.WorkerSid))
: new NamedPipeServerStream(pipeName, PipeDirection.InOut, maxNumberOfServerInstances: 1,
PipeTransmissionMode.Byte, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
Process? process = null;
IDisposable? job = null;
IDisposable? stdio = null;
string? markerPath = null;
try
{
var psi = BuildStartInfo(spec, pipeName, nonce, workDir);
var shardLabel = spec.Shard;
void OnStderr(string? line)
{
if (line is { Length: > 0 }) log.Warning("ScriptWorker[{Shard}] {WorkerStderr}", shardLabel, line);
}
void OnStdout(string? line)
{
if (line is { Length: > 0 }) log.Debug("ScriptWorker[{Shard}] stdout {WorkerStdout}", shardLabel, line);
}
// Native and JIT memory backstop above the managed heap limit.
var jobLimit = spec.HeapLimitBytes + 256L * 1024 * 1024;
if (spec.WorkerSid is { } workerSid && OperatingSystem.IsWindows())
{
// The worker reaches only what its identity may: its working directory is the one place it writes.
RestrictedWorkerLauncher.GrantWorkDirectory(workDir, workerSid);
var launched = RestrictedWorkerLauncher.Start(psi, workerSid, jobLimit, OnStdout, OnStderr);
process = launched.Process;
job = launched.Job;
stdio = launched;
if (spec.MarkerDirectory is { } restrictedMarkerDir)
markerPath = WorkerLaunchMarker.Write(restrictedMarkerDir, process, pipeName, log);
}
else
{
process = new Process { StartInfo = psi, EnableRaisingEvents = true };
if (!process.Start()) throw new InvalidOperationException("the worker process did not start");
// Before anything else can fail: a host that dies from here on leaves a marker the next start's orphan
// sweep can match to exactly this process (design §3.3).
if (spec.MarkerDirectory is { } markerDir) markerPath = WorkerLaunchMarker.Write(markerDir, process, pipeName, log);
if (OperatingSystem.IsWindows()) job = WindowsJobObject.Assign(process, jobLimit);
process.ErrorDataReceived += (_, e) => OnStderr(e.Data);
process.OutputDataReceived += (_, e) => OnStdout(e.Data);
process.BeginErrorReadLine();
process.BeginOutputReadLine();
}
using var handshakeCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
handshakeCts.CancelAfter(spec.HandshakeTimeout);
var exitedEarly = process.WaitForExitAsync(handshakeCts.Token);
var accept = pipe.WaitForConnectionAsync(handshakeCts.Token);
if (await Task.WhenAny(accept, exitedEarly).ConfigureAwait(false) != accept)
throw new InvalidOperationException(
$"the worker exited (code {SafeExitCode(process)}) before connecting to its pipe");
await accept.ConfigureAwait(false);
var hello = new Hello
{
Nonce = nonce,
MaxFrameBytes = ScriptWorkerFrameCodec.DefaultMaxFrameBytes,
EvalThreads = (uint)spec.EvalThreads,
QueueCapacity = 4096,
HardGraceMs = (uint)Math.Clamp(spec.HardGrace.TotalMilliseconds, 1, uint.MaxValue),
HeartbeatIntervalMs = (uint)spec.HeartbeatInterval.TotalMilliseconds,
MaxLogLines = 1000,
CompileThreads = (uint)Math.Max(0, spec.CompileThreads),
WorkerSoftClock = spec.WorkerSoftClock,
};
if (spec.PeCacheDirectory is { } cacheDir && spec.PeCacheKey is { } cacheKey)
{
hello.PeCacheDir = cacheDir;
hello.PeCacheKey = Google.Protobuf.ByteString.CopyFrom(cacheKey);
}
var ready = ScriptWorkerFrameCodec.UseBlockingIo
? await HandshakeBlockingAsync(pipe, hello, nonce, process.Id, handshakeCts.Token).ConfigureAwait(false)
: await HandshakeAsync(pipe, hello, nonce, process.Id, handshakeCts.Token).ConfigureAwait(false);
var connection = new WorkerConnection(spec.Shard, process, pipe, job, stdio, workDir, log, onLogs, spec.HeartbeatInterval,
spec.EffectiveLoadTimeout, onExiting, markerPath, workerSoftClock: spec.WorkerSoftClock && ready.WorkerSoftClock,
heartbeatSilenceLimit: spec.HeartbeatSilenceLimit);
connection.Begin();
return connection;
}
catch
{
try { if (process is { HasExited: false }) process.Kill(entireProcessTree: true); } catch { /* best effort */ }
job?.Dispose();
stdio?.Dispose();
process?.Dispose();
await pipe.DisposeAsync().ConfigureAwait(false);
TryDeleteDirectory(workDir);
WorkerLaunchMarker.TryDelete(markerPath);
throw;
}
}
///
/// The host half of the handshake: send , require a Ready as the first frame,
/// echoing (constant-time compare) from .
///
/// The accepted pipe.
/// The Hello to send.
/// The nonce put in the worker's environment.
/// The launched process id; null skips the check.
/// Cancellation (the handshake timeout).
/// The worker's Ready.
/// Anything but a matching Ready.
internal static async Task HandshakeAsync(Stream stream, Hello hello, string expectedNonce, int? expectedPid, CancellationToken ct)
{
await ScriptWorkerFrameCodec.WriteAsync(stream, new Envelope
{
ProtocolVersion = ScriptWorkerFrameCodec.ProtocolVersion,
Seq = 1,
Hello = hello,
}, ScriptWorkerFrameCodec.DefaultMaxFrameBytes, ct).ConfigureAwait(false);
await stream.FlushAsync(ct).ConfigureAwait(false);
var reply = await ScriptWorkerFrameCodec.ReadAsync(stream, ScriptWorkerFrameCodec.DefaultMaxFrameBytes, ct).ConfigureAwait(false);
return CheckReady(reply, expectedNonce, expectedPid);
}
///
/// As with blocking I/O only ():
/// one asynchronous call would turn the socket under a Unix pipe non-blocking for the connection's whole life.
/// The blocking read runs off the caller and is abandoned at ; the caller then kills the
/// worker and disposes the pipe, which ends it.
///
/// The accepted pipe.
/// The Hello to send.
/// The nonce put in the worker's environment.
/// The launched process id; null skips the check.
/// Cancellation (the handshake timeout).
/// The worker's Ready.
internal static async Task HandshakeBlockingAsync(Stream stream, Hello hello, string expectedNonce, int? expectedPid,
CancellationToken ct)
{
var reply = await Task.Run(() =>
{
ScriptWorkerFrameCodec.Write(stream, new Envelope
{
ProtocolVersion = ScriptWorkerFrameCodec.ProtocolVersion,
Seq = 1,
Hello = hello,
}, ScriptWorkerFrameCodec.DefaultMaxFrameBytes);
return ScriptWorkerFrameCodec.Read(stream, ScriptWorkerFrameCodec.DefaultMaxFrameBytes);
}, CancellationToken.None).WaitAsync(ct).ConfigureAwait(false);
return CheckReady(reply, expectedNonce, expectedPid);
}
private static Ready CheckReady(Envelope? reply, string expectedNonce, int? expectedPid)
{
if (reply is null) throw new ScriptWorkerProtocolException("the worker closed the pipe during the handshake");
if (reply.BodyCase != Envelope.BodyOneofCase.Ready)
throw new ScriptWorkerProtocolException($"expected Ready, got {reply.BodyCase}");
var expected = Encoding.UTF8.GetBytes(expectedNonce);
var actual = Encoding.UTF8.GetBytes(reply.Ready.Nonce ?? string.Empty);
if (!CryptographicOperations.FixedTimeEquals(expected, actual))
throw new ScriptWorkerProtocolException("the worker's nonce does not match the launch nonce");
if (expectedPid is { } pid && reply.Ready.Pid != pid)
throw new ScriptWorkerProtocolException($"the pipe client reports pid {reply.Ready.Pid}, not the launched {pid}");
return reply.Ready;
}
internal static ProcessStartInfo BuildStartInfo(WorkerLaunchSpec spec, string pipeName, string nonce, string workDir)
{
var psi = new ProcessStartInfo
{
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardError = true,
RedirectStandardOutput = true,
RedirectStandardInput = false,
WorkingDirectory = workDir,
};
if (spec.WorkerPath.EndsWith(".dll", StringComparison.OrdinalIgnoreCase))
{
psi.FileName = ResolveMuxer();
psi.ArgumentList.Add("exec");
psi.ArgumentList.Add(spec.WorkerPath);
}
else
{
psi.FileName = spec.WorkerPath;
}
// Cleared, then repopulated from an allow-list (design §6). ProcessStartInfo.Environment starts as a copy of
// the host's environment; ScadaBridge's sandbox inherits it whole, which this design rejects.
var inherited = psi.Environment.ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.Ordinal);
psi.Environment.Clear();
foreach (var (name, value) in inherited)
{
if (value is null) continue;
if (InheritedEnvironment.Contains(name, StringComparer.OrdinalIgnoreCase)
|| (name.StartsWith("DOTNET_", StringComparison.OrdinalIgnoreCase)
&& !name.StartsWith("DOTNET_GCHeap", StringComparison.OrdinalIgnoreCase)))
psi.Environment[name] = value;
}
psi.Environment["DOTNET_GCHeapHardLimit"] = "0x" + spec.HeapLimitBytes.ToString("X", System.Globalization.CultureInfo.InvariantCulture);
psi.Environment["DOTNET_gcServer"] = "0";
psi.Environment["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1";
psi.Environment[ScriptWorkerEnvironment.PipeName] = pipeName;
psi.Environment[ScriptWorkerEnvironment.Nonce] = nonce;
psi.Environment[ScriptWorkerEnvironment.ParentPid] = Environment.ProcessId.ToString(System.Globalization.CultureInfo.InvariantCulture);
psi.Environment[ScriptWorkerEnvironment.Shard] = spec.Shard;
if (spec.WorkerSid is not null)
{
// A restricted worker cannot write the host's temp directory; its own working directory is its temp.
psi.Environment["TEMP"] = workDir;
psi.Environment["TMP"] = workDir;
// No debugger / profiler / diagnostics IPC: a restricted token cannot create the runtime's named objects,
// and nothing should attach to a sandboxed worker anyway.
psi.Environment["DOTNET_EnableDiagnostics"] = "0";
}
return psi;
}
/// The dotnet muxer that runs a framework-dependent worker .dll.
/// The muxer path, or plain dotnet (resolved on PATH) as a last resort.
internal static string ResolveMuxer()
{
var hostPath = Environment.GetEnvironmentVariable("DOTNET_HOST_PATH");
if (!string.IsNullOrEmpty(hostPath) && File.Exists(hostPath)) return hostPath;
var processPath = Environment.ProcessPath;
if (processPath is not null
&& string.Equals(Path.GetFileNameWithoutExtension(processPath), "dotnet", StringComparison.OrdinalIgnoreCase))
return processPath;
var root = Environment.GetEnvironmentVariable("DOTNET_ROOT");
if (!string.IsNullOrEmpty(root))
{
var candidate = Path.Combine(root, OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet");
if (File.Exists(candidate)) return candidate;
}
return "dotnet";
}
private void Begin()
{
ReadyTimestamp = Stopwatch.GetTimestamp();
Volatile.Write(ref _lastFrameTimestamp, ReadyTimestamp);
Interlocked.Exchange(ref _lastUsedTimestamp, ReadyTimestamp);
if (!ScriptWorkerFrameCodec.UseBlockingIo) _ = Task.Run(ReadLoopAsync);
else new Thread(ReadLoopBlocking) { IsBackground = true, Name = $"ScriptWorker[{Shard}].Reader" }.Start();
_ = Task.Run(HeartbeatMonitorAsync);
_ = Task.Run(WatchProcessAsync);
}
/// Ensures is compiled in this worker at .
/// The scoped key.
/// The context kind.
/// The source.
/// SHA-256 of the source.
/// The slot order.
/// The entry once the load has an outcome (see ).
public Task EnsureLoadedAsync(string key, ContextKind kind, string source, byte[] sourceHash, IReadOnlyList inputPaths)
=> EnsureLoadedAsync(key, kind, source, sourceHash, Convert.ToHexString(sourceHash), inputPaths);
/// As the other overload, with the hash's hex form precomputed (the runner caches it per key, lmxopcua#789).
/// The scoped key.
/// The context kind.
/// The source.
/// SHA-256 of the source.
/// Its upper-case hex form.
/// The slot order.
///
/// The entry once the load has an outcome: is the worker's verdict, or null when
/// the worker ended first; marks the compile this connection killed the worker
/// for.
///
public Task EnsureLoadedAsync(string key, ContextKind kind, string source, byte[] sourceHash, string sourceHashHex,
IReadOnlyList inputPaths)
{
if (_loaded.TryGetValue(key, out var existing) && existing.SourceHash == sourceHashHex && existing.SameSlots(inputPaths))
return existing.Ready;
var entry = new LoadEntry(sourceHashHex, inputPaths);
_loaded[key] = entry;
Interlocked.Exchange(ref _lastUsedTimestamp, Stopwatch.GetTimestamp());
var loadId = (ulong)Interlocked.Increment(ref _nextLoadId);
var load = new LoadScripts { LoadId = loadId };
var script = new ScriptSource { Key = key, Kind = kind, Source = source, SourceSha256 = Google.Protobuf.ByteString.CopyFrom(sourceHash) };
script.InputPaths.AddRange(inputPaths);
load.Scripts.Add(script);
var inFlight = new LoadInFlight(loadId, key, entry, this);
_loads[loadId] = inFlight;
if (!Send(new Envelope { Load = load }) && _loads.TryRemove(loadId, out _)) inFlight.Finish(null);
return entry.Ready;
}
/// Forgets a key's compiled form here and in the worker.
/// The scoped keys.
public void Unload(IReadOnlyCollection keys)
{
var unload = new Unload();
foreach (var key in keys)
if (_loaded.TryRemove(key, out _)) unload.Keys.Add(key);
if (unload.Keys.Count > 0) Send(new Envelope { Unload = unload });
}
/// Submits one evaluation. Its yields null when the worker ends first.
/// The scoped key.
/// The request (its id is assigned here).
/// The pending evaluation.
public PendingEval Submit(string key, EvalRequest request) => Submit(key, request, null, TimeSpan.Zero, null);
///
/// Submits one evaluation with its two deadlines (design §5.1) on ONE timer (lmxopcua#789): at
/// yields ,
/// and later, if the worker still has not answered, the worker is killed with this
/// key as the culprit.
///
/// The scoped key.
/// The request (its id is assigned here).
/// T; null = no deadlines.
/// G.
///
/// Runs once, synchronously, when the worker has answered or ended — BEFORE
/// completes — so a caller that resumes from its await never finds its own run still holding the key's slot.
///
/// The pending evaluation.
public PendingEval Submit(string key, EvalRequest request, TimeSpan? softTimeout, TimeSpan hardGrace, Action? onFinished)
{
request.EvalId = (ulong)Interlocked.Increment(ref _nextEvalId);
Interlocked.Exchange(ref _lastUsedTimestamp, Stopwatch.GetTimestamp());
if (_pending.IsEmpty) Interlocked.Exchange(ref _lastProgressTimestamp, Stopwatch.GetTimestamp());
var pending = new PendingEval(key, request.EvalId, onFinished, _dispatcher);
if (softTimeout is { } soft) pending.Arm(soft, hardGrace, OnHardDeadline, WorkerSoftClock);
_pending[request.EvalId] = pending;
var batch = new EvalBatch();
batch.Items.Add(request);
if (!Send(new Envelope { Eval = batch }) || _exited.Task.IsCompleted)
{
if (_pending.TryRemove(request.EvalId, out _)) pending.Complete(null);
}
return pending;
}
///
/// Submits several evaluations as ONE frame (lmxopcua#780 batch submit, design §4.5). Each
/// item gets its own deadlines and slot release exactly as through ;
/// an item the worker has to queue behind busy evaluation threads has its clock stopped until it starts.
///
/// The evaluations (their ids are assigned here).
/// One pending evaluation per item, in order.
public PendingEval[] SubmitBatch(IReadOnlyList items)
{
ArgumentNullException.ThrowIfNull(items);
var pendings = new PendingEval[items.Count];
var batch = new EvalBatch();
Interlocked.Exchange(ref _lastUsedTimestamp, Stopwatch.GetTimestamp());
if (_pending.IsEmpty) Interlocked.Exchange(ref _lastProgressTimestamp, Stopwatch.GetTimestamp());
for (var i = 0; i < items.Count; i++)
{
var item = items[i];
item.Request.EvalId = (ulong)Interlocked.Increment(ref _nextEvalId);
var pending = new PendingEval(item.Key, item.Request.EvalId, item.OnFinished, _dispatcher);
pending.Arm(item.SoftTimeout, item.HardGrace, OnHardDeadline, WorkerSoftClock);
_pending[item.Request.EvalId] = pending;
batch.Items.Add(item.Request);
pendings[i] = pending;
}
if (items.Count > 0 && (!Send(new Envelope { Eval = batch }) || _exited.Task.IsCompleted))
{
foreach (var pending in pendings)
if (_pending.TryRemove(pending.EvalId, out _)) pending.Complete(null);
}
return pendings;
}
///
/// The compiled form of in this worker when it is loaded at
/// with and compiled cleanly — the batch fast path, which never waits for a load.
///
/// The scoped key.
/// The source hash (hex).
/// The slot order.
/// The entry, or null when the key needs a (re)load or its load failed.
public LoadEntry? TryGetLoaded(string key, string sourceHashHex, IReadOnlyList inputPaths)
=> _loaded.TryGetValue(key, out var existing) && existing.SourceHash == sourceHashHex && existing.SameSlots(inputPaths)
&& existing.Ready.IsCompleted && existing.Outcome is { Ok: true } && !existing.TimedOut
? existing
: null;
private void OnHardDeadline(PendingEval pending)
{
_log.Warning("Script {ScriptKey} exceeded its hard deadline ({Deadline} ms); killing ScriptWorker[{Shard}] pid {Pid}",
pending.Key, (long)pending.HardDeadline.TotalMilliseconds, Shard, Pid);
Kill(WorkerExitReason.KilledDeadline, pending.Key);
}
/// Cancels a run's ctx.CancellationToken in the worker (sent at the soft deadline).
/// The evaluation.
public void Cancel(PendingEval pending)
{
if (!pending.Completion.IsCompleted) Send(new Envelope { Cancel = new Protocol.V1.Cancel { EvalId = pending.EvalId } });
}
/// Asks the worker for its process diagnostics (loaded assemblies, environment names).
/// How long to wait.
/// The reply.
public async Task DiagnosticsAsync(TimeSpan timeout)
{
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
Interlocked.Exchange(ref _diag, tcs);
Send(new Envelope { DiagRequest = new DiagnosticsRequest() });
return await tcs.Task.WaitAsync(timeout).ConfigureAwait(false);
}
/// Kills the worker (process tree). The first reason given is the one recorded.
/// Why.
/// The scoped key blamed, if any.
public void Kill(WorkerExitReason reason, string? culpritKey)
{
lock (_pending)
{
_killedFor ??= (reason, culpritKey);
}
try
{
// The job holds exactly this one process (ACTIVE_PROCESS = 1), so terminating it is the whole tree, at once.
if (OperatingSystem.IsWindows() && _job is WindowsJobObject job && job.Terminate()) return;
if (!_process.HasExited) _process.Kill(entireProcessTree: true);
}
catch (InvalidOperationException) { /* already gone */ }
catch (System.ComponentModel.Win32Exception ex)
{
_log.Warning(ex, "ScriptWorker[{Shard}] kill of pid {Pid} failed", Shard, Pid);
}
}
/// Asks the worker to stop, waits briefly, then kills it.
/// A task that completes when the worker has ended.
public ValueTask DisposeAsync() => StopAsync(WorkerExitReason.Disposed);
/// As , recording as the exit reason.
/// Why the host is stopping it ( or ).
/// A task that completes when the worker has ended.
public async ValueTask StopAsync(WorkerExitReason reason)
{
// Both the shard (on exit) and the runner (on shutdown) dispose a connection; only the first does the work.
if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
if (!_exited.Task.IsCompleted)
{
lock (_pending) { _killedFor ??= (reason, null); }
Send(new Envelope { Shutdown = new Shutdown() });
try
{
await _process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(1)).ConfigureAwait(false);
}
catch (TimeoutException)
{
Kill(WorkerExitReason.Disposed, null);
}
try { await _exited.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); } catch (TimeoutException) { }
}
_stopping.Cancel();
_writer.Complete();
await _pipe.DisposeAsync().ConfigureAwait(false);
_job?.Dispose();
_stdio?.Dispose();
_process.Dispose();
TryDeleteDirectory(_workDir);
WorkerLaunchMarker.TryDelete(_markerPath);
}
/// Test hook: closes the host end of the pipe without touching the process, as a dying host would.
internal void SeverPipeForTest() => _pipe.Dispose();
private bool Send(Envelope envelope) => _writer.TryWrite(envelope);
private void OnWriteFault(Exception ex)
{
if (!_stopping.IsCancellationRequested)
_log.Debug(ex, "ScriptWorker[{Shard}] pipe write ended", Shard);
Kill(WorkerExitReason.Crash, null);
}
///
/// The reader, on a dedicated thread with blocking reads of a socket that never turns non-blocking
/// (, lmxopcua#789): a frame wakes this thread straight from
/// the kernel, instead of the socket engine's epoll thread queueing a thread-pool work item per read. Not on
/// Windows, where a synchronous read of an overlapped pipe is itself completed through the I/O completion port;
/// the async loop is the cheaper path there.
///
private void ReadLoopBlocking()
{
WorkerExitReason? fault = null;
var reader = new ScriptWorkerFrameReader(_pipe);
try
{
while (reader.Read() is { } frame) HandleFrame(frame);
}
catch (ScriptWorkerProtocolException ex)
{
fault = ProtocolFault(ex);
}
catch (Exception ex) when (ex is IOException or ObjectDisposedException or InvalidOperationException
or System.Net.Sockets.SocketException)
{
}
_ = PipeEndedAsync(fault);
}
private async Task ReadLoopAsync()
{
WorkerExitReason? fault = null;
var reader = new ScriptWorkerFrameReader(_pipe);
try
{
while (await reader.ReadAsync(_stopping.Token).ConfigureAwait(false) is { } frame) HandleFrame(frame);
}
catch (OperationCanceledException) { }
catch (ScriptWorkerProtocolException ex)
{
fault = ProtocolFault(ex);
}
catch (Exception ex) when (ex is IOException or ObjectDisposedException)
{
}
await PipeEndedAsync(fault).ConfigureAwait(false);
}
private WorkerExitReason ProtocolFault(ScriptWorkerProtocolException ex)
{
_log.Warning("ScriptWorker[{Shard}] protocol violation: {Message}; killing pid {Pid}", Shard, ex.Message, Pid);
return WorkerExitReason.ProtocolFault;
}
private void HandleFrame(Envelope frame)
{
Volatile.Write(ref _lastFrameTimestamp, Stopwatch.GetTimestamp());
switch (frame.BodyCase)
{
case Envelope.BodyOneofCase.Results:
Volatile.Write(ref _lastProgressTimestamp, Stopwatch.GetTimestamp());
if (DropSoftNoticesForTest) frame.Results.SoftExpiredEvalIds.Clear();
// Worker soft clock (lmxopcua#827): the script itself has run for T. Only a worker that agreed to run
// the clock may send these. Before the results: the writer coalesces frames, and a notice the worker
// sent ahead of the same run's result must still answer the caller with its timeout, as in process.
if (frame.Results.SoftExpiredEvalIds.Count > 0 && !WorkerSoftClock)
throw new ScriptWorkerProtocolException("soft-deadline notice from a worker that does not run the soft clock");
foreach (var id in frame.Results.SoftExpiredEvalIds)
{
if (_pending.TryGetValue(id, out var expired)) expired.SoftExpired();
else CheckIssued(id, "soft-deadline notice");
}
foreach (var result in frame.Results.Items)
{
// An eval id with no outstanding request is a protocol violation (design §6 "what the host
// trusts"), except for one that was already completed by a kill race — ignored then.
if (_pending.TryRemove(result.EvalId, out var pending)) pending.Complete(result);
else CheckIssued(result.EvalId, "result");
}
// Batch submit (lmxopcua#780): a run that waited behind busy evaluation threads has its deadlines
// restarted when it actually starts, so it is never timed out, struck or killed for a sibling's time.
// The two notices are unordered with respect to each other (see PendingEval.Start).
foreach (var id in frame.Results.StartedEvalIds)
{
if (_pending.TryGetValue(id, out var started)) started.Start();
else CheckIssued(id, "start notice");
}
foreach (var id in frame.Results.DeferredEvalIds)
{
if (_pending.TryGetValue(id, out var deferred)) deferred.Defer();
else CheckIssued(id, "deferral notice");
}
break;
case Envelope.BodyOneofCase.LoadStarted:
// The compile timeout starts now (lmxopcua#790). An unknown id is a load already completed by a
// kill race (ignored) or one never issued (a protocol violation).
if (_loads.TryGetValue(frame.LoadStarted.LoadId, out var starting)) starting.Started(_loadTimeout);
else if (frame.LoadStarted.LoadId == 0 || frame.LoadStarted.LoadId > (ulong)Interlocked.Read(ref _nextLoadId))
throw new ScriptWorkerProtocolException($"load start for unknown load id {frame.LoadStarted.LoadId}");
break;
case Envelope.BodyOneofCase.LoadResult:
if (_loads.TryRemove(frame.LoadResult.LoadId, out var load)) load.Finish(frame.LoadResult);
else throw new ScriptWorkerProtocolException($"load result for unknown load id {frame.LoadResult.LoadId}");
break;
case Envelope.BodyOneofCase.Logs:
try { _onLogs(frame.Logs); }
catch (Exception ex) { _log.Warning(ex, "ScriptWorker[{Shard}] log forwarding threw", Shard); }
break;
case Envelope.BodyOneofCase.Heartbeat:
Volatile.Write(ref _lastHeartbeat, frame.Heartbeat);
break;
case Envelope.BodyOneofCase.DiagReply:
Volatile.Read(ref _diag)?.TrySetResult(frame.DiagReply);
break;
case Envelope.BodyOneofCase.Fault:
_log.Warning("ScriptWorker[{Shard}] reported a fault: {Fault}", Shard, frame.Fault.Message);
break;
default:
throw new ScriptWorkerProtocolException($"unexpected frame {frame.BodyCase} from the worker");
}
}
/// An id the host never issued is a protocol violation; one it issued and already completed is a benign race.
private void CheckIssued(ulong evalId, string what)
{
if (evalId == 0 || evalId > (ulong)Interlocked.Read(ref _nextEvalId))
throw new ScriptWorkerProtocolException($"{what} for unknown eval id {evalId}");
}
private async Task PipeEndedAsync(WorkerExitReason? fault)
{
// The pipe is gone: the worker is dead or dying. Make sure, then report.
try
{
if (fault is { } f) Kill(f, null);
try
{
await _process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
}
catch (TimeoutException)
{
Kill(WorkerExitReason.Crash, null);
try { await _process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); } catch (TimeoutException) { }
}
}
catch (InvalidOperationException)
{
// The process object was disposed by a concurrent shutdown: the worker is already gone.
}
Finish();
}
private async Task WatchProcessAsync()
{
try { await _process.WaitForExitAsync(_stopping.Token).ConfigureAwait(false); }
catch (OperationCanceledException) { return; }
catch (InvalidOperationException) { return; }
// The reader normally finishes first on EOF; this covers a worker that died while the pipe stayed open.
await Task.Delay(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
Finish();
}
private async Task HeartbeatMonitorAsync()
{
using var timer = new PeriodicTimer(_heartbeatInterval);
try
{
while (await timer.WaitForNextTickAsync(_stopping.Token).ConfigureAwait(false))
{
if (_exited.Task.IsCompleted) return;
var silent = Stopwatch.GetElapsedTime(Volatile.Read(ref _lastFrameTimestamp));
if (silent > SilenceLimit)
{
_log.Warning("ScriptWorker[{Shard}] pid {Pid} sent nothing for {Silent} ms; killing it", Shard, Pid, (long)silent.TotalMilliseconds);
Kill(WorkerExitReason.KilledHeartbeat, null);
return;
}
if (!_pending.IsEmpty && ProgressCeiling() is { } ceiling)
{
var stuck = Stopwatch.GetElapsedTime(Volatile.Read(ref _lastProgressTimestamp));
if (stuck > ceiling)
{
_log.Warning("ScriptWorker[{Shard}] pid {Pid} has {Count} evaluation(s) outstanding and made no progress for {Stuck} ms (ceiling {Ceiling} ms) while still heartbeating; killing it",
Shard, Pid, _pending.Count, (long)stuck.TotalMilliseconds, (long)ceiling.TotalMilliseconds);
Kill(WorkerExitReason.KilledUnresponsive, null);
return;
}
}
}
}
catch (OperationCanceledException) { }
}
private void Finish()
{
// The reader (on EOF) and the process watcher both get here; only the first reports.
if (Interlocked.Exchange(ref _finished, 1) != 0) return;
// No new frame can be queued from here on, so every Submit / load either fails its Send (and completes itself)
// or was registered before this point and is completed below.
_writer.Complete();
int? code = null;
try { if (_process.HasExited) code = _process.ExitCode; } catch (InvalidOperationException) { }
(WorkerExitReason Reason, string? Culprit)? killed;
lock (_pending) { killed = _killedFor; }
var reason = killed?.Reason ?? (code == 0 ? WorkerExitReason.Clean : WorkerExitReason.Crash);
var inFlight = _pending.Values.Select(p => p.Key).Distinct(StringComparer.Ordinal).ToArray();
var loading = _loads.Values.Select(l => l.Key).Distinct(StringComparer.Ordinal).ToArray();
var exit = new WorkerExit(reason, killed?.Culprit, code, inFlight, loading);
// Attribution FIRST, synchronously: a kill strike / quarantine / suspect mark must be in place before any
// evaluation below completes, because completing it releases the key's single-flight slot and the very next
// trigger is admitted against whatever the quarantine table says at that instant (lmxopcua#780 race fix).
try { _onExiting(exit); }
catch (Exception ex) { _log.Error(ex, "ScriptWorker[{Shard}] exit attribution threw", Shard); }
_exited.TrySetResult(exit);
// Every outstanding evaluation and load ends now; null tells the waiter "the worker went away".
foreach (var id in _pending.Keys.ToArray())
if (_pending.TryRemove(id, out var pending)) pending.Complete(null);
foreach (var id in _loads.Keys.ToArray())
if (_loads.TryRemove(id, out var load)) load.Finish(null);
Volatile.Read(ref _diag)?.TrySetCanceled();
// Resumes the callers just completed above, then ends; a later Post runs inline.
_dispatcher.Stop();
}
/// The compile timer of a started load fired: kill the worker, blaming this key alone (lmxopcua#790).
private void OnLoadTimeout(LoadInFlight load)
{
if (!_loads.ContainsKey(load.Id)) return;
load.Entry.TimedOut = true;
_log.Warning("Script {ScriptKey}: compile in ScriptWorker[{Shard}] has run for {Timeout} s since it started; killing the worker",
load.Key, Shard, _loadTimeout.TotalSeconds);
Kill(WorkerExitReason.KilledLoadTimeout, load.Key);
}
private static string SafeExitCode(Process process)
{
try { return process.HasExited ? process.ExitCode.ToString(System.Globalization.CultureInfo.InvariantCulture) : "?"; }
catch (InvalidOperationException) { return "?"; }
}
private static void TryDeleteDirectory(string dir)
{
try { if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true); } catch { /* temp dir; best effort */ }
}
/// One load sent to the worker and not yet answered: its compile timer runs from LoadStarted.
private sealed class LoadInFlight(ulong id, string key, LoadEntry entry, WorkerConnection owner)
{
private Timer? _timer;
private int _finished;
public ulong Id { get; } = id;
public string Key { get; } = key;
public LoadEntry Entry { get; } = entry;
/// The worker has started compiling it: arm the compile timeout (once).
public void Started(TimeSpan timeout)
{
if (Volatile.Read(ref _finished) != 0 || _timer is not null) return;
var timer = new Timer(static s => { var l = (LoadInFlight)s!; l.Owner.OnLoadTimeout(l); }, this, Timeout.Infinite, Timeout.Infinite);
if (Interlocked.CompareExchange(ref _timer, timer, null) is not null) { timer.Dispose(); return; }
timer.Change(timeout, Timeout.InfiniteTimeSpan);
if (Volatile.Read(ref _finished) != 0) timer.Dispose();
}
private WorkerConnection Owner { get; } = owner;
/// The worker answered () or ended (null).
public void Finish(LoadResult? result)
{
if (Interlocked.Exchange(ref _finished, 1) != 0) return;
Volatile.Read(ref _timer)?.Dispose();
if (result is null)
{
Owner._loaded.TryRemove(new KeyValuePair(Key, Entry));
Entry.Complete(null);
return;
}
var outcome = result.Outcomes.FirstOrDefault(o => o.Key == Key)
?? new LoadOutcome { Key = Key, Ok = false, Error = new ScriptError { Kind = ScriptError.Types.Kind.Threw, Message = "no load outcome" } };
if (!outcome.Ok) Owner._loaded.TryRemove(new KeyValuePair(Key, Entry));
Entry.Complete(outcome);
}
}
/// A key's compiled form in this worker.
internal sealed class LoadEntry
{
private readonly TaskCompletionSource _ready = new(TaskCreationOptions.RunContinuationsAsynchronously);
/// Set once the runner has counted this load in its metrics.
public int Counted;
public LoadEntry(string sourceHash, IReadOnlyList inputPaths)
{
SourceHash = sourceHash;
InputPaths = inputPaths;
var slots = new Dictionary(inputPaths.Count, StringComparer.Ordinal);
for (var i = 0; i < inputPaths.Count; i++) slots[inputPaths[i]] = (uint)i;
Slots = slots;
}
public string SourceHash { get; }
public IReadOnlyList InputPaths { get; }
/// Path → slot in the registered order.
public IReadOnlyDictionary Slots { get; }
/// The worker's verdict; null until it arrives, and null for good when the worker ended first.
public LoadOutcome? Outcome { get; private set; }
///
/// Set when this connection killed the worker because THIS compile overran its timeout from its start
/// (lmxopcua#790); the waiters then report a compile timeout instead of a worker restart.
///
public volatile bool TimedOut;
/// Completes with this entry once the load has an outcome (or the worker ended).
public Task Ready => _ready.Task;
public bool SameSlots(IReadOnlyList inputPaths)
{
if (inputPaths.Count != InputPaths.Count) return false;
for (var i = 0; i < inputPaths.Count; i++)
if (!string.Equals(inputPaths[i], InputPaths[i], StringComparison.Ordinal)) return false;
return true;
}
public void Complete(LoadOutcome? outcome)
{
Outcome = outcome;
_ready.TrySetResult(this);
}
}
}
///
/// One evaluation outstanding on a worker. Its deadlines share ONE timer (lmxopcua#789: the per-eval
/// WaitAsync(timeout) timer, the hard-deadline CancellationTokenSource and their continuations were a
/// measurable share of the host's CPU).
///
///
///
/// Worker soft clock (lmxopcua#827, ): the soft deadline T is
/// the worker's — it reports, through , that the script has itself run for T, and the
/// hard deadline G is armed from that notice. The host's timer is then only a TRANSPORT deadline: T + G from
/// submit (restarted at a deferred run's start). Passing it with neither a result nor a soft notice means the
/// pipe or the worker process is late, not the script — yields
/// (a runner fault: nothing is struck and the worker is not killed; a worker that
/// is really hung goes silent and the heartbeat monitor kills it). So a starved worker can no longer make a
/// trivial script report "timed out", which in Shadow mode read as a script mismatch.
///
///
/// Host clock (a worker that did not agree to the above): the timer fires at the soft deadline T, counted
/// from submit, and is re-armed for the grace G.
///
///
/// The deadlines are absolute timestamps the timer callback re-checks, so a deferral (the worker queued the run
/// behind busy evaluation threads, lmxopcua#780 batch submit) can stop the clock and a start notice can restart
/// it — the full T (or transport deadline), then G, from the start — without a stale tick firing early.
///
///
internal sealed class PendingEval
{
/// What yields when the soft deadline passed first. Never on the wire.
internal static readonly EvalResult SoftTimedOut = new();
///
/// What yields when the transport deadline passed with no word from the worker (worker soft
/// clock only, lmxopcua#827): a runner fault, not the script's. Never on the wire.
///
internal static readonly EvalResult TransportTimedOut = new();
private const int Waiting = 0;
private const int SoftReported = 1;
private const int HardFired = 2;
private const int TransportReported = 3;
private readonly TaskCompletionSource _completion = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource _outcome;
private readonly ResultDispatcher? _dispatcher;
private readonly Action? _onFinished;
private readonly object _clock = new();
private Timer? _timer;
private Action? _onHardDeadline;
private TimeSpan _soft;
private TimeSpan _grace;
private bool _workerClock;
private long _softDue;
private long _hardDue;
private long _transportDue;
private bool _deferred;
private bool _started;
private int _phase;
private int _done;
private TaskCompletionSource? _lateSoftExpiry;
public PendingEval(string key, ulong evalId, Action? onFinished = null, ResultDispatcher? dispatcher = null)
{
Key = key;
EvalId = evalId;
_onFinished = onFinished;
_dispatcher = dispatcher;
// With a dispatcher the caller resumes ON the dispatcher's thread (synchronous continuation, no pool hop);
// without one, on the thread pool as usual.
_outcome = new TaskCompletionSource(dispatcher is null
? TaskCreationOptions.RunContinuationsAsynchronously
: TaskCreationOptions.None);
}
/// The scoped key.
public string Key { get; }
/// The eval id on the wire.
public ulong EvalId { get; }
/// T + G, for the kill log line.
public TimeSpan HardDeadline { get; private set; }
/// Whether the worker reported this run as queued and has not reported it started (tests / diagnostics).
internal bool IsDeferred
{
get { lock (_clock) return _deferred; }
}
/// The worker's result, or null when the worker ended first.
public Task Completion => _completion.Task;
///
/// After (null before): completes when the worker then reports the script itself
/// over T (slow AND late: it still gets its Cancel and counts as a timeout, #827 review F2), and is cancelled
/// when the worker answers or ends first. Continuations run asynchronously, never on the reader.
///
internal Task? LateSoftExpiry
{
get { lock (_clock) return _lateSoftExpiry?.Task; }
}
///
/// What the caller waits on: the worker's result, null when the worker ended first, at
/// the soft deadline, or at the transport deadline (the run itself is still
/// outstanding in both of the last two cases).
///
public Task Outcome => _outcome.Task;
/// Starts the deadline timer. Called once, before the request is sent.
/// T.
/// G.
/// The kill.
/// The worker runs the soft clock (lmxopcua#827): this timer is the transport deadline.
internal void Arm(TimeSpan soft, TimeSpan grace, Action onHardDeadline, bool workerClock = false)
{
_soft = soft;
_grace = grace;
_onHardDeadline = onHardDeadline;
_workerClock = workerClock;
HardDeadline = soft + grace;
var now = Stopwatch.GetTimestamp();
_softDue = now + Ticks(soft);
_hardDue = _softDue + Ticks(grace);
_transportDue = _hardDue;
// Created disarmed and assigned before it can fire, so the soft tick can always re-arm it for the grace.
_timer = new Timer(static s => ((PendingEval)s!).OnTimer(), this, Timeout.Infinite, Timeout.Infinite);
_timer.Change(workerClock ? soft + grace : soft, Timeout.InfiniteTimeSpan);
}
///
/// The worker queued this run behind busy evaluation threads: stop the clock until it starts (it is not running,
/// so it cannot overrun). Ignored once a start notice has arrived — the two are unordered on the wire.
///
internal void Defer()
{
lock (_clock)
{
if (_timer is null || _started || _deferred || Volatile.Read(ref _done) != 0) return;
_deferred = true;
TryChange(Timeout.InfiniteTimeSpan);
}
}
///
/// The worker started this run: a deferred run's deadlines restart from now (T, or T + G when its soft deadline
/// had already been reported; with the worker soft clock, the transport deadline T + G). A run that was never
/// deferred keeps the clock it has had since submit.
///
internal void Start()
{
lock (_clock)
{
_started = true;
if (_timer is null || !_deferred || Volatile.Read(ref _done) != 0) return;
_deferred = false;
var now = Stopwatch.GetTimestamp();
if (_workerClock)
{
// Its soft deadline is the worker's from here; the transport deadline restarts. Already answered (a
// deferral notice that arrived after the transport deadline): nothing to re-arm — a soft notice or the
// result is what comes next.
if (Volatile.Read(ref _phase) != Waiting) return;
_transportDue = now + Ticks(_soft + _grace);
TryChange(_soft + _grace);
}
else if (Volatile.Read(ref _phase) == Waiting)
{
_softDue = now + Ticks(_soft);
_hardDue = _softDue + Ticks(_grace);
TryChange(_soft);
}
else
{
// Its caller already has the soft timeout (a deferral notice that arrived after T): the run still gets
// the whole T + G from its start before it can be blamed for a kill.
_hardDue = now + Ticks(_soft + _grace);
TryChange(_soft + _grace);
}
}
}
///
/// The worker reports that the script has itself run for T (worker soft clock, lmxopcua#827): the caller gets its
/// soft timeout now — unless the transport deadline already answered it — and the hard deadline is G from here.
///
internal void SoftExpired()
{
bool signal, late;
lock (_clock)
{
if (_timer is null || !_workerClock || Volatile.Read(ref _done) != 0) return;
var phase = Volatile.Read(ref _phase);
if (phase is SoftReported or HardFired) return;
_started = true;
_deferred = false;
signal = phase == Waiting;
late = phase == TransportReported;
Interlocked.Exchange(ref _phase, SoftReported);
_hardDue = Stopwatch.GetTimestamp() + Ticks(_grace);
TryChange(_grace);
}
// This runs on the pipe READER. The caller's resumption is the caller's code (the seam's publish, the runner's
// Cancel write): it goes through the dispatcher exactly like a result (ResultDispatcher remarks), never inline
// here, or a slow consumer would hold up heartbeats and results (#827 review F1).
if (signal)
{
if (_dispatcher is { } dispatcher) dispatcher.Post(this, SoftTimedOut);
else _outcome.TrySetResult(SoftTimedOut);
}
// The caller was already answered "worker unresponsive"; the script has now itself run for T. Its watcher
// (the runner) cancels the run's token and counts the timeout, off this thread: the completion source runs its
// continuations asynchronously (#827 review F2).
if (late) _lateSoftExpiry?.TrySetResult();
}
private void OnTimer()
{
if (Volatile.Read(ref _done) != 0) return;
bool soft = false, hard = false, transport = false;
lock (_clock)
{
if (_deferred || Volatile.Read(ref _done) != 0) return;
var now = Stopwatch.GetTimestamp();
var phase = Volatile.Read(ref _phase);
if (phase == Waiting && _workerClock)
{
// A tick armed before a deferral/start can land early: re-arm for what is left.
if (now < _transportDue) { TryChange(Stopwatch.GetElapsedTime(now, _transportDue)); return; }
Interlocked.Exchange(ref _phase, TransportReported);
_lateSoftExpiry = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
transport = true;
}
else if (phase == Waiting)
{
// A tick armed before a deferral/start can land early: re-arm for what is left.
if (now < _softDue) { TryChange(Stopwatch.GetElapsedTime(now, _softDue)); return; }
Interlocked.Exchange(ref _phase, SoftReported);
soft = true;
TryChange(_hardDue > now ? Stopwatch.GetElapsedTime(now, _hardDue) : TimeSpan.Zero);
}
else if (phase == SoftReported)
{
if (now < _hardDue) { TryChange(Stopwatch.GetElapsedTime(now, _hardDue)); return; }
Interlocked.Exchange(ref _phase, HardFired);
hard = true;
}
}
// Outside the lock: a caller with a dispatcher resumes synchronously inside TrySetResult.
if (soft) _outcome.TrySetResult(SoftTimedOut);
if (transport) _outcome.TrySetResult(TransportTimedOut);
if (hard && Volatile.Read(ref _done) == 0) _onHardDeadline?.Invoke(this);
}
private void TryChange(TimeSpan due)
{
try { _timer?.Change(due < TimeSpan.Zero ? TimeSpan.Zero : due, Timeout.InfiniteTimeSpan); }
catch (ObjectDisposedException) { /* answered meanwhile */ }
}
private static long Ticks(TimeSpan span) => (long)(span.TotalSeconds * Stopwatch.Frequency);
///
/// The worker answered (or ended, with null): cancel the deadlines and release the key's slot HERE, on the
/// reader, then hand the caller's resumption to the dispatcher (or the pool).
///
public void Complete(EvalResult? result)
{
if (Interlocked.Exchange(ref _done, 1) != 0) return;
_timer?.Dispose();
lock (_clock) _lateSoftExpiry?.TrySetCanceled();
try { _onFinished?.Invoke(); }
finally
{
_completion.TrySetResult(result);
if (_dispatcher is { } dispatcher) dispatcher.Post(this, result);
else _outcome.TrySetResult(result);
}
}
/// Resumes the caller (on the dispatcher's thread). A soft timeout may already have done so.
internal void SignalOutcome(EvalResult? result) => _outcome.TrySetResult(result);
}