feat(scripting): out-of-process pre-emptible script runner behind Scripting:Runner (lmxopcua#780 P1)
v2-ci / unit-tests (push) Waiting to run

Opt-in (Scripting:Runner=OutOfProcess; InProcess stays the default and its code path is unchanged). Implements
the P1 slice of docs/plans/2026-11-09-out-of-process-script-runner-design.md with the owner's §14 decisions:

- Options: Scripting:Runner (InProcess | Shadow | OutOfProcess), Scripting:Runner:Seams, Scripting:Worker:*
  (Shards 2, HardGrace max(1 s,T), HeapLimitBytes 512 MiB, QuarantineAfterKills 2, UnavailableGrace 5 s,
  IsolationProbationRuns 20, PeCacheDir validated absolute + outside the content root (fail closed),
  ExecutablePath). Shadow is not implemented yet: runs InProcess with a startup Warning.
- Core.Scripting: IScriptRunner seam + InProcessScriptRunner (today's guard, the parity baseline).
- Core.Scripting.Protocol: protobuf envelopes, 4-byte LE framing (zero/oversize/truncated/garbage refused),
  Variant carrying the exact CLR type; unsupported types are explicit failures.
- ScriptWorker app: compiles in the worker, P-thread scheduler, host-driven soft cancel, fail-fast
  watchdog, heartbeats, ctx.Logger forwarding, exits on pipe EOF / parent gone, forbidden-assembly
  self-check; Akka and Grpc.Core.Api excluded from its folder. Shipped in script-worker/ (Build + Publish).
- Core.Scripting.OutOfProcess: K shards + isolation worker, soft T / hard T+G kill, attribution
  (deadline kill = one key; crash = suspects -> isolation; isolation crash = strike), kill-strike
  quarantine, backoff 250 ms -> 30 s, fail closed, cleared allow-listed env, nonce + pid handshake on a
  current-user pipe, Windows Job Object.
- VirtualTag + Calculation seams use the runner when selected; a runner-side failure (worker restarted /
  runner unavailable) publishes BadResourceUnavailable (0x80040000) — ScadaBridge#112. Script faults keep
  BadInternalError. The scripted-alarm seam stays in process (alarms last).

Tests: new ZB.MOM.WW.OtOpcUa.ScriptWorker.Tests (Category=ScriptWorker, real processes; green on macOS and
on windev/Windows), seam tests in Host/Runtime/Calculation suites; mutation-checked.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-09 23:31:51 -05:00
parent f5c30ed0ef
commit 69a6e0b4c8
49 changed files with 5107 additions and 25 deletions
@@ -0,0 +1,214 @@
// lmxopcua#780 — host <-> ScriptWorker IPC (design docs/plans/2026-11-09-out-of-process-script-runner-design.md §4).
// Framing: 4-byte little-endian length prefix + one serialized Envelope. Zero-length and oversize frames are protocol
// faults. One request frame and one reply frame per evaluation; the only other worker->host traffic is logs and
// heartbeats. Bump protocol_version on any incompatible change.
syntax = "proto3";
package otopcua.scriptworker.v1;
option csharp_namespace = "ZB.MOM.WW.OtOpcUa.Core.Scripting.Protocol.V1";
message Envelope {
uint32 protocol_version = 1;
uint64 seq = 2;
oneof body {
Hello hello = 10; // host -> worker, first frame
Ready ready = 11; // worker -> host, reply to Hello
LoadScripts load = 12; // host -> worker
LoadResult load_result = 13; // worker -> host
Unload unload = 14; // host -> worker
EvalBatch eval = 15; // host -> worker
EvalResults results = 16; // worker -> host, streamed as runs complete
LogBatch logs = 17; // worker -> host, fire-and-forget, bounded
Heartbeat heartbeat = 18; // worker -> host, every heartbeat_interval_ms
Shutdown shutdown = 19; // host -> worker
Fault fault = 20; // either way; the sender closes after it
DiagnosticsRequest diag_request = 21; // host -> worker (tests / ops)
DiagnosticsReply diag_reply = 22; // worker -> host
Cancel cancel = 23; // host -> worker, at the soft deadline
}
}
message Hello {
string nonce = 1; // must be echoed in Ready
uint32 max_frame_bytes = 2;
uint32 eval_threads = 3; // the worker's scheduler width; 1 for the isolation worker
uint32 queue_capacity = 4; // queued evaluations above this answer BUSY
uint32 hard_grace_ms = 5; // worker watchdog: fail fast past soft + grace (+ margin)
uint32 heartbeat_interval_ms = 6;
uint32 max_log_lines = 7; // outbound log buffer cap
}
message Ready {
string nonce = 1;
int32 pid = 2;
string runtime_version = 3;
}
enum ContextKind {
CONTEXT_KIND_UNSPECIFIED = 0;
VIRTUAL_TAG = 1;
CALCULATION = 2;
ALARM_PREDICATE = 3;
}
message ScriptSource {
string key = 1;
bytes source_sha256 = 2;
ContextKind kind = 3;
string source = 4;
repeated string input_paths = 5; // slot order for EvalRequest.inputs
}
message LoadScripts {
uint64 load_id = 1;
repeated ScriptSource scripts = 2;
}
message LoadOutcome {
string key = 1;
bool ok = 2;
ScriptError error = 3; // COMPILE or SANDBOX when !ok
}
message LoadResult {
uint64 load_id = 1;
repeated LoadOutcome outcomes = 2;
}
message Unload {
repeated string keys = 1;
}
message EvalBatch {
repeated EvalRequest items = 1;
}
message EvalRequest {
uint64 eval_id = 1;
string key = 2;
repeated Input inputs = 3;
int64 now_utc_ticks = 4; // ctx.Now (host clock)
uint32 soft_timeout_ms = 5;
uint32 hard_grace_ms = 6; // this run's G; 0 = Hello.hard_grace_ms
}
message Input {
uint32 slot = 1; // index into ScriptSource.input_paths; ignored when path is set
Variant value = 2;
uint32 status_code = 3;
int64 source_ts_ticks = 4; // 0 = null
int64 server_ts_ticks = 5;
string path = 6; // inline path for an input with no registered slot
bool unsupported = 7; // the host could not encode the value's CLR type (see unsupported_type)
string unsupported_type = 8;
}
message EvalResult {
uint64 eval_id = 1;
oneof outcome {
Variant value = 2;
bool predicate = 3;
ScriptError error = 4;
}
uint64 allocated_bytes = 5; // GC.GetAllocatedBytesForCurrentThread around the run
uint32 run_micros = 6;
}
message EvalResults {
repeated EvalResult items = 1;
}
message ScriptError {
enum Kind {
THREW = 0;
COMPILE = 1;
SANDBOX = 2;
SOFT_TIMEOUT = 3;
UNSUPPORTED_TYPE = 4;
BUSY = 5;
NOT_LOADED = 6;
}
Kind kind = 1;
string message = 2;
}
message Variant {
enum TypeTag {
NULL = 0;
BOOLEAN = 1;
SBYTE = 2;
BYTE = 3;
INT16 = 4;
UINT16 = 5;
INT32 = 6;
UINT32 = 7;
INT64 = 8;
UINT64 = 9;
FLOAT = 10;
DOUBLE = 11;
STRING = 12;
DATETIME_UTC = 13;
ARRAY = 14;
}
TypeTag type = 1; // the exact CLR type: (short)ctx.GetTag(x).Value must keep working
oneof v {
bool b = 2;
sint64 i = 3;
uint64 u = 4;
double d = 5;
float f = 6;
string s = 7;
int64 dt_ticks = 8;
ArrayValue array = 9;
}
}
message ArrayValue {
Variant.TypeTag element_type = 1;
repeated Variant items = 2;
}
message LogBatch {
repeated LogLine lines = 1;
uint32 dropped = 2;
}
message LogLine {
string key = 1;
uint32 level = 2; // Serilog LogEventLevel
string rendered = 3;
string exception = 4;
int64 ts_ticks = 5;
}
message Heartbeat {
uint32 inflight = 1;
uint32 queued = 2;
int64 heap_bytes = 3;
uint32 gen2_count = 4;
}
message Shutdown {
}
// Sent by the host when it has answered the caller with a soft timeout: cancels the run's ctx.CancellationToken, so a
// polling script ends itself. The worker never cancels on its own clock, which keeps the in-process ordering — the
// caller always has its timeout before the script can observe cancellation.
message Cancel {
uint64 eval_id = 1;
}
message Fault {
string message = 1;
}
message DiagnosticsRequest {
}
message DiagnosticsReply {
int32 pid = 1;
repeated string loaded_assemblies = 2;
repeated string environment_names = 3;
string working_directory = 4;
}