feat(scripting): out-of-process pre-emptible script runner behind Scripting:Runner (lmxopcua#780 P1)
v2-ci / unit-tests (push) Waiting to run
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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user