docs(scripting): design an out-of-process, pre-emptible script runner (lmxopcua#750)
v2-ci / unit-tests (push) Has been cancelled
v2-ci / unit-tests (push) Has been cancelled
Design only. Documents why today's in-process ScriptRunGuard can abandon but not stop a runaway script (file:line evidence), and proposes a sharded worker pool with an isolation worker under a ScriptRunnerActor, a named-pipe + protobuf frame protocol carrying full input snapshots, soft/hard deadlines with process kill, kill attribution and quarantine, worker sandboxing (scrubbed env, pinned assemblies, Job Object, heap limit), an HMAC'd content-addressed PE cache, performance budgets, failure-quality semantics, a flag-gated InProcess/Shadow/OutOfProcess rollout, a test plan, open decisions and an assumptions register. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
@@ -0,0 +1,846 @@
|
||||
# Out-of-process, pre-emptible script runner — design
|
||||
|
||||
**Date:** 2026-11-09 · **Status:** proposed (design only — no code in this change) · **Issue:** #750 · **Related:** #734 (Lane E remediation)
|
||||
|
||||
This document designs a script runner that can actually **stop** a user script, instead of abandoning it.
|
||||
It covers the three script seams that run operator-authored C# on a driver node: virtual tags, Calculation
|
||||
tags and scripted-alarm predicates.
|
||||
|
||||
Claims about today's code carry `file:line` evidence against `origin/master` @ `94926eb0`. Anything not
|
||||
read from the code is marked **[A-n]** and listed in §15.
|
||||
|
||||
---
|
||||
|
||||
## 1. Today: in-process execution
|
||||
|
||||
### 1.1 One compile pipeline, three seams
|
||||
|
||||
All three seams share `Core.Scripting`:
|
||||
|
||||
- **Compile.** `ScriptEvaluator<TContext,TResult>.Compile` wraps the user source in a synthesized
|
||||
`CompiledScript.Run(ScriptGlobals<TContext>)`, compiles it with Roslyn against the `ScriptSandbox` reference
|
||||
set, runs `ForbiddenTypeAnalyzer`, emits PE bytes to a `MemoryStream` and loads them into a collectible ALC
|
||||
(`src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptEvaluator.cs:125` emit, `:139` `LoadFromStream`).
|
||||
- **Cache.** `CompiledScriptCache` is keyed by SHA-256 of the source (`CompiledScriptCache.cs:157`). It is
|
||||
**in memory only**, so every process start recompiles every script on first use.
|
||||
- **Execution.** It is synchronous: `ScriptEvaluator.RunAsync` invokes the bound delegate inline and returns
|
||||
`Task.FromResult` (`ScriptEvaluator.cs:178-195`). The script-visible `ctx.CancellationToken` is an
|
||||
`AsyncLocal` set for the duration of the call (`ScriptEvaluator.cs:191`,
|
||||
`Core.Scripting.Abstractions/ScriptContext.cs:96-98`).
|
||||
- **The context is a pure read cache.** `VirtualTagContext.GetTag` and `AlarmPredicateContext.GetTag` serve
|
||||
only from a dictionary handed in by the caller (`VirtualTagContext.cs:50`, `AlarmPredicateContext.cs:40`).
|
||||
`SetVirtualTag` is dropped in both production single-tag evaluators
|
||||
(`Host/Engines/RoslynVirtualTagEvaluator.cs:129-131`, `Driver.Calculation/CalculationEvaluator.cs:185-188`)
|
||||
and throws in alarm predicates (`AlarmPredicateContext.cs:50`). Inputs are known statically, because
|
||||
`DependencyExtractor` rejects non-literal `GetTag` paths at publish.
|
||||
|
||||
This matters most for the design (§4.1). **A script needs nothing from the host while it runs**: the
|
||||
inputs go in, one value comes out, and log lines are a side channel.
|
||||
|
||||
| Seam | Where it runs | Budget | Serialisation | Evidence |
|
||||
|---|---|---|---|---|
|
||||
| Virtual tag | `VirtualTagActor` message handler calls `IVirtualTagEvaluator.Evaluate` **synchronously** on the actor's dispatcher thread; `RoslynVirtualTagEvaluator` blocks with `.GetAwaiter().GetResult()` | 2 s | per actor (one actor per VT) | `Runtime/VirtualTags/VirtualTagActor.cs:276`; `RoslynVirtualTagEvaluator.cs:70`, `:174-175`; singleton `Host/Program.cs:393-398` |
|
||||
| Calculation tag | async pump in `CalculationDriver`, one evaluation at a time per driver instance | 2 s (`runTimeoutMs`) | `_evalGate` = `SemaphoreSlim(1,1)` per driver | `CalculationDriver.cs:99`, `:523-556`, `:628`; `CalculationDriverOptions.cs:13`; `CalculationEvaluator.cs:232-233` |
|
||||
| Scripted alarm | `ScriptedAlarmEngine.ReevaluateAsync` walks the dirty set **serially** under one gate | **250 ms** (the engine is built without `scriptTimeout`, so it takes `TimedScriptEvaluator.DefaultTimeout`) | `_evalGate` per engine | `ScriptedAlarmEngine.cs:204`, `:257`, `:743-765`, `:833-835`; `Runtime/Drivers/DriverHostActor.cs:1251-1252` |
|
||||
|
||||
Both value seams skip Roslyn for the mirror shape `return ctx.GetTag("X").Value;` (`PassthroughScript.TryMatch`,
|
||||
`RoslynVirtualTagEvaluator.cs:111-118`, `CalculationEvaluator.cs:167-176`).
|
||||
|
||||
### 1.2 Timeout and cancellation model
|
||||
|
||||
Every compiled run in production goes through `ScriptRunGuard.RunAsync` (`Core.Scripting/ScriptRunGuard.cs:170-304`).
|
||||
There are three guard instances per seam owner: one per `RoslynVirtualTagEvaluator`
|
||||
(`RoslynVirtualTagEvaluator.cs:71`), one per `CalculationEvaluator` (`CalculationEvaluator.cs:88`) and one
|
||||
per `ScriptedAlarmEngine` (`ScriptedAlarmEngine.cs:258`).
|
||||
|
||||
1. **Single flight per key.** A CAS on `state.InFlight` refuses a second run while the previous one, an
|
||||
orphan included, still holds the slot. The caller gets `ScriptRunRefusedException` →
|
||||
Bad `"previous evaluation still running"` (`ScriptRunGuard.cs:188-219`).
|
||||
2. **The run is pushed to the pool** (`Task.Run`, `:231-249`), so a CPU-bound script cannot run inline and
|
||||
complete before the timeout is registered. The budget clock starts only when the pool picks the run up
|
||||
(`:268`).
|
||||
3. **The wait is `runTask.WaitAsync(timeout, ct)`** (`:273`). On timeout the guard cancels the run token
|
||||
(`:279`). Only a script that polls `ctx.CancellationToken` can see that.
|
||||
4. **Quarantine** happens on any of three triggers:
|
||||
- N consecutive timeouts (`:332-341`, default 3);
|
||||
- a refusal behind an orphan older than `StuckOrphanQuarantineFactor` × the timeout (`:199-216`,
|
||||
default 10×);
|
||||
- an allocation breach, where `GC.GetAllocatedBytesForCurrentThread` is sampled before and after the
|
||||
delegate (`:235`, `:241`, `:262-263`, `:296-302`; default 64 MB, `ScriptingOptions.cs`).
|
||||
|
||||
A quarantine lifts on a source change or a redeploy (`ResetAll`, called from `ClearCompiledScripts` at
|
||||
`RoslynVirtualTagEvaluator.cs:212-218` and from `ScriptedAlarmEngine.LoadAsync`).
|
||||
5. **What the operator sees.**
|
||||
- A VT or Calc failure publishes `BadInternalError` with the last-known value, once per Good→Bad
|
||||
transition (`VirtualTagActor.cs:384-392`, `CalculationDriver.cs:640-647`).
|
||||
- An alarm whose predicate did not complete keeps its frozen condition state, annotated
|
||||
`BadInternalError` (`ScriptedAlarmEngine.cs:895-907`).
|
||||
|
||||
`TimedScriptEvaluator` (`TimedScriptEvaluator.cs:80-83`) is the older wrapper. Its own remarks say it is now
|
||||
used only by single-shot callers (`:18-27`).
|
||||
|
||||
### 1.3 Why a runaway script cannot be pre-empted
|
||||
|
||||
.NET has no supported way to stop a managed thread. `Thread.Abort` is gone in .NET Core, and the guard's
|
||||
`WaitAsync` returns control to the **caller**; it does not stop the run. Concretely:
|
||||
|
||||
| Failure | What happens today | Evidence |
|
||||
|---|---|---|
|
||||
| `while(true){}` (ignores the token) | The pool thread spins **forever**, holding one core and its collectible ALC. The guard bounds this to one orphan per key and then quarantines, but the thread keeps spinning until the process restarts. N such scripts peg N cores. | `ScriptRunGuard.cs:17-22` remarks; `ScriptRunGuardTests.cs:170-205` asserts *exactly one* orphan stays in flight (`InFlightCount.ShouldBe(1)`) |
|
||||
| Fast allocation (`new byte[int.MaxValue]` in a loop, or a growing list) | The sample is taken **after** the delegate returns, so the process can OOM first. The whole server dies, including OPC UA sessions and every driver. | `ScriptRunGuard.cs:39-43`; `docs/VirtualTags.md:40`; review `docs/reviews/2026-10-21/05-core.md:100-105` |
|
||||
| Unbounded recursion (a local function or a self-referencing `Func<>`; neither is a class member, so `EnforceSingleRunMember` does not see it) | `StackOverflowException` cannot be caught in .NET and **terminates the host process**. **[A-1]** I did not reproduce this; it follows from runtime semantics, and no analyzer rule targets recursion (`git grep -i "recursion\|StackOverflow"` in `Core.Scripting` finds nothing). | `ScriptEvaluator.cs:228-281` (member check only) |
|
||||
| Collectible-ALC reclaim of a stuck script | `ClearCompiledScripts` disposes the evaluator, but an ALC cannot be collected while a thread is executing its code, so a stuck orphan pins its assembly until restart. **[A-2]** This is .NET ALC semantics, not observed here. | `CompiledScriptCache.Clear`; `ScriptEvaluator.Dispose` |
|
||||
| A slow (not runaway) script | VT: blocks a default-dispatcher thread for up to 2 s per evaluation. Calc / alarm: holds the driver-wide or engine-wide gate, so every sibling waits behind it (up to 2 s / 250 ms each). | `RoslynVirtualTagEvaluator.cs:174-175`; `CalculationDriver.cs:539-541`; `ScriptedAlarmEngine.cs:743-765` |
|
||||
|
||||
The sandbox (`ForbiddenTypeAnalyzer.cs:62-80`: `System.IO`, `System.Net`, `System.Diagnostics`,
|
||||
`System.Reflection`, `System.Threading[.Tasks]`, `Environment`, `GC`, …) blocks *capabilities*, and it does
|
||||
that well. It cannot block *consumption* (CPU, memory, stack). Every review that looked at this reached the
|
||||
same conclusion:
|
||||
|
||||
- `docs/VirtualTags.md:40,54`;
|
||||
- `docs/ScriptEditor.md` §"Runtime safety";
|
||||
- `docs/superpowers/plans/2026-10-22-review-remediation.md` Lane E2;
|
||||
- `docs/reviews/2026-10-21/05-core.md:103` and `03-host-controlplane-security-config.md:38`.
|
||||
|
||||
All of them say the fix is an **out-of-process runner**.
|
||||
|
||||
Script authoring is gated by the `ConfigEditor` policy, which admits Administrator **or Designer** (CLAUDE.md
|
||||
§Scripting). The issue says "admin-gated", but the gate also admits Designers. That is the primary control,
|
||||
and it explains why this work is p3.
|
||||
|
||||
---
|
||||
|
||||
## 2. Goals and non-goals
|
||||
|
||||
**Goals**
|
||||
|
||||
- G1. **Pre-emption.** A script that exceeds its budget is stopped within a bounded time. The thread, the
|
||||
memory and the ALC are all reclaimed.
|
||||
- G2. **Containment.** No script (OOM, stack overflow, `FailFast`-equivalent, native crash) can take down the
|
||||
OtOpcUa host process, its OPC UA sessions, its drivers or its Akka membership.
|
||||
- G3. **Attribution.** The script that caused a kill is identified and quarantined. Healthy scripts that
|
||||
shared the worker are not.
|
||||
- G4. **Parity.** Compile-time behaviour is unchanged: the same sandbox, analyzer, diagnostics and Monaco parity.
|
||||
Runtime semantics are unchanged too: the same `ctx` API, the same input-quality gating, the same Bad / last-value
|
||||
contract, and the same quarantine lift rules.
|
||||
- G5. **Bounded overhead** (§8). It must fit the tag counts the repo already tests at.
|
||||
- G6. **Flag-gated, reversible rollout.** In-process stays the default until the gates pass.
|
||||
|
||||
**Non-goals**
|
||||
|
||||
- N1. A new sandbox. `ForbiddenTypeAnalyzer` stays the capability control; process isolation is
|
||||
defence-in-depth, not a replacement.
|
||||
- N2. Moving the **authoring** surface. The AdminUI only compiles and analyzes (`ScriptAnalysisService`), and it
|
||||
never executes a script, so central/admin nodes are out of scope.
|
||||
- N3. Multi-tag cascades (`ctx.SetVirtualTag` taking effect). They are dropped today and stay dropped.
|
||||
- N4. Cross-node or remote execution. The worker is always local to the driver node that owns the tags.
|
||||
- N5. Per-run memory *pre-emption*. Even out of process, the limit is per *worker*. A per-run limit stays a
|
||||
sampled observation (§5.4).
|
||||
- N6. Running non-.NET or untrusted third-party code.
|
||||
|
||||
---
|
||||
|
||||
## 3. Process model
|
||||
|
||||
### 3.1 Options
|
||||
|
||||
| Option | Blast radius of a kill | Process count | Verdict |
|
||||
|---|---|---|---|
|
||||
| A. One worker per node | Every script on the node blips | 1 | Simple, but one bad script restarts every script on the node |
|
||||
| B. One worker per script | One script | = script count (hundreds to thousands) | Infeasible: ~tens of MB per .NET process **[A-3]** |
|
||||
| C. One worker per seam or driver instance | One seam / driver | 3 to ~10 | Arbitrary; a hot Calc driver still shares fate with its siblings |
|
||||
| **D. Small sharded pool + an isolation worker** | One shard (1/K of the scripts), then one script | K + 0..1 (default 2 + 1) | **Recommended** |
|
||||
|
||||
### 3.2 Recommended: sharded pool with an isolation worker
|
||||
|
||||
- **K shard workers per driver node.** `Scripting:Worker:Shards` defaults to 2 and is clamped to 1–8. A
|
||||
script is placed by `hash(scriptKey) mod K`. The script key is the guard key used today: the VT id, the calc
|
||||
RawPath, or the alarm id. Placement is deterministic, so a script's compiled PE and warm state stay on one
|
||||
worker across restarts.
|
||||
- **One isolation worker** ("penalty box") is spawned on demand. Scripts under suspicion (§5.3) run there,
|
||||
**one evaluation at a time**. A kill in the isolation worker is therefore unambiguous.
|
||||
- **Inside a worker:** a bounded evaluation scheduler with `P` dedicated threads, where
|
||||
`P = max(2, ProcessorCount / K)` **[A-4]**. Single flight per key is kept, as the guard does today. A queue
|
||||
that is full answers `Busy`, which the host treats as a refusal, not a strike.
|
||||
- **The passthrough fast path stays in the host.** It never runs user code, and it is probably the largest
|
||||
share of evaluations **[A-5]**.
|
||||
|
||||
### 3.3 Lifecycle and supervision under Akka
|
||||
|
||||
The design splits a **control plane** (actors) from a **data plane** (a lock-free client). An Akka `Ask` per
|
||||
evaluation would add mailbox hops to the hottest path for no benefit.
|
||||
|
||||
```
|
||||
DriverHostActor (driver role)
|
||||
└─ ScriptRunnerActor node-wide; owns the pool policy, attribution ledger, quarantine table
|
||||
├─ ScriptWorkerActor #0 owns one OS process + pipe; state machine below
|
||||
├─ ScriptWorkerActor #1
|
||||
└─ ScriptWorkerActor iso spawned on first suspect, stopped after IsolationIdle (default 10 min)
|
||||
|
||||
IScriptRunner (DI singleton) ── data plane ──► ScriptWorkerConnection (per worker; pipe writer + correlation map)
|
||||
used by RoslynVirtualTagEvaluator, CalculationEvaluator, ScriptedAlarmEngine
|
||||
```
|
||||
|
||||
- **Ownership.** `ScriptRunnerActor` is created by `DriverHostActor`, next to `VirtualTagHostActor` and the
|
||||
scripted-alarm host, so it exists only on driver-role nodes and dies with the driver tree. Its supervisor
|
||||
strategy is `OneForOne`, and a restart of a `ScriptWorkerActor` kills and respawns its process.
|
||||
- **Process exit is a message, not an exception.** A watcher on `Process.Exited` / `WaitForExitAsync` sends
|
||||
`WorkerExited(exitCode, signal, inFlight[])` to its `ScriptWorkerActor`, so worker crashes never escalate
|
||||
through Akka supervision.
|
||||
- **Worker states:**
|
||||
|
||||
`Spawning → Handshaking → Loading → Ready ⇄ Draining → Dead → Backoff → Spawning`
|
||||
|
||||
`Ready` is the only state that accepts evaluations. In the other states the connection queues per key, with
|
||||
latest-wins coalescing (§9).
|
||||
- **The host process must take its workers down with it.**
|
||||
- **Windows:** every worker is assigned to a Job Object with `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`. If the host
|
||||
dies, the kernel closes the job handle and kills the workers.
|
||||
- **Linux** (the docker-dev rig): the worker treats **pipe EOF as fatal**, and also polls its parent PID. The
|
||||
host is not trusted to clean up after itself.
|
||||
- **CoordinatedShutdown.** In the `before-actor-system-terminate` phase, the host sends `Shutdown`, waits
|
||||
1 s, then kills.
|
||||
- **Orphan sweep at start.** The host kills only workers whose launch nonce file sits under *this* host's
|
||||
instance directory, never "every process with this name". mxaccessgw's `OrphanWorkerTerminator` killed a
|
||||
live gateway's workers when a second gateway started (scadaproj memory "wonder upgrade program"); this
|
||||
design avoids that by construction.
|
||||
- **Redeploy.** `ClearCompiledScripts` / `LoadAsync` become `IScriptRunner.ApplyGeneration(scriptSet)`. That
|
||||
call sends `LoadScripts` / `Unload` deltas to each shard and clears the host-side quarantine rows for edited
|
||||
or removed keys, which keeps today's lift rule. No worker restart is needed. Collectible ALCs in the
|
||||
worker are unloaded as they are today, and a stuck one no longer matters because its worker gets killed.
|
||||
- **Redundancy.** No change. Each node runs its own scripts today and will keep doing so; workers are not
|
||||
shared across the pair.
|
||||
|
||||
---
|
||||
|
||||
## 4. IPC
|
||||
|
||||
### 4.1 Shape of the conversation
|
||||
|
||||
§1.1 showed that a run needs no host callbacks. Each evaluation is therefore **one request frame and one
|
||||
reply frame**, with no re-entrant RPC. ScadaBridge's `SandboxWorkerProtocol` has to proxy `call`/`reply`
|
||||
frames for external systems and databases; this design does not. Log lines from `ctx.Logger` are the only
|
||||
other worker→host traffic, and they are fire-and-forget.
|
||||
|
||||
### 4.2 Transport: named pipe vs gRPC
|
||||
|
||||
| | Named pipe + length-prefixed frames | gRPC (h2c over UDS / named pipe) |
|
||||
|---|---|---|
|
||||
| Per-call overhead | Two syscalls plus serialize; no HTTP/2 framing **[A-6]** | HTTP/2 stream setup, HPACK and a Kestrel pipeline per call |
|
||||
| Worker footprint | Plain console app | Needs Kestrel in the worker, or a client stack in it and a server in the host |
|
||||
| Batching / priority | Free to design: control frames ahead of events, as in mxaccessgw `WorkerFrameProtocol` | Per-RPC; batching means streaming RPCs, which is a frame protocol again |
|
||||
| Cross-platform | `NamedPipeServerStream` maps to a Unix domain socket on Linux; `PipeOptions.CurrentUserOnly` on both | Yes |
|
||||
| Prior art in the family | mxaccessgw gateway↔worker (protobuf `WorkerEnvelope`, 4-byte LE length, priority-drained writer); ScadaBridge sandbox (JSON frames over stdio, 1 MiB cap) | OtOpcUa mesh transport (`Commons/Protos/mesh_transport.proto`) — but that is cross-node |
|
||||
|
||||
**Recommendation: a named pipe with 4-byte little-endian length-prefixed protobuf envelopes.** It copies
|
||||
mxaccessgw's `WorkerFrameProtocol` discipline:
|
||||
|
||||
- a frame-size cap negotiated at the handshake (default 4 MiB);
|
||||
- zero-length and oversize frames rejected;
|
||||
- a monotonically increasing `seq`;
|
||||
- control frames (heartbeat, shutdown, fault) drained ahead of evaluation traffic;
|
||||
- coalesced flushes.
|
||||
|
||||
`Google.Protobuf` and `Grpc.Tools` are already central pins, and `Commons.csproj:28-30` already compiles
|
||||
protos, so no new package is needed. `MessagePack` is pinned in `Directory.Packages.props` but referenced by
|
||||
no project, so it would be a new dependency in practice. Stdio, as ScadaBridge uses, is rejected: a script
|
||||
cannot write to the console (it is forbidden), but the runtime and the logger can, and one stray byte on
|
||||
stdout corrupts the stream.
|
||||
|
||||
### 4.3 Message schema (sketch)
|
||||
|
||||
```proto
|
||||
syntax = "proto3";
|
||||
package otopcua.scriptworker.v1;
|
||||
|
||||
message Envelope {
|
||||
uint32 protocol_version = 1;
|
||||
uint64 seq = 2;
|
||||
oneof body {
|
||||
Hello hello = 10; // host→worker: nonce, max_frame_bytes, options
|
||||
Ready ready = 11; // worker→host: pid, runtime version, sandbox fingerprint
|
||||
LoadScripts load = 12; // host→worker: add/replace compiled-or-source scripts
|
||||
LoadResult load_result = 13; // worker→host: per-script compile outcome + diagnostics
|
||||
Unload unload = 14; // host→worker: drop keys (ALC unload)
|
||||
EvalBatch eval = 15; // host→worker
|
||||
EvalResults results = 16; // worker→host (one or more completions)
|
||||
LogBatch logs = 17; // worker→host, fire-and-forget, bounded
|
||||
Heartbeat heartbeat = 18; // both ways; carries worker gauges (inflight, heap, gen2 count)
|
||||
Shutdown shutdown = 19;
|
||||
Fault fault = 20;
|
||||
}
|
||||
}
|
||||
|
||||
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; bytes pe_image = 5; repeated string input_paths = 6; }
|
||||
message LoadScripts { uint64 generation = 1; repeated ScriptSource scripts = 2; }
|
||||
|
||||
message EvalBatch { repeated EvalRequest items = 1; }
|
||||
message EvalRequest {
|
||||
uint64 eval_id = 1; string key = 2; bytes source_sha256 = 3;
|
||||
repeated Input inputs = 4; // positional against ScriptSource.input_paths; see 4.4
|
||||
int64 now_utc_ticks = 5; // ctx.Now — host clock, so tests that pin the clock still work
|
||||
uint32 soft_timeout_ms = 6;
|
||||
}
|
||||
message Input { uint32 slot = 1; Variant value = 2; uint32 status_code = 3;
|
||||
int64 source_ts_ticks = 4; /* 0 = null */ int64 server_ts_ticks = 5; }
|
||||
|
||||
message EvalResult {
|
||||
uint64 eval_id = 1;
|
||||
oneof outcome { Variant value = 2; bool predicate = 3; ScriptError error = 4; }
|
||||
uint64 allocated_bytes = 5; // same GC.GetAllocatedBytesForCurrentThread sample as today
|
||||
uint32 run_micros = 6;
|
||||
}
|
||||
message EvalResults { repeated EvalResult items = 1; }
|
||||
message ScriptError { Kind kind = 1; string message = 2;
|
||||
enum Kind { THREW = 0; COMPILE = 1; SANDBOX = 2; SOFT_TIMEOUT = 3; UNSUPPORTED_TYPE = 4; BUSY = 5; } }
|
||||
|
||||
message Variant {
|
||||
TypeTag type = 1; // exact CLR type — see 4.4
|
||||
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; }
|
||||
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; }
|
||||
}
|
||||
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; string message_template = 3;
|
||||
string rendered = 4; string exception = 5; int64 ts_ticks = 6; }
|
||||
```
|
||||
|
||||
### 4.4 How values and quality cross the boundary
|
||||
|
||||
- **Quality crosses verbatim.** `DataValueSnapshot` is `(object? Value, uint StatusCode, DateTime?
|
||||
SourceTimestampUtc, DateTime ServerTimestampUtc)` (`Core.Abstractions/DataValueSnapshot.cs:13`), and it maps
|
||||
1:1 onto `Input`. The worker rebuilds the same snapshots, so `ctx.GetTag(p).StatusCode` and
|
||||
`.SourceTimestampUtc` behave as they do today. The input-quality gating does not move: VT holds evaluation
|
||||
while an input is Bad (`VirtualTagActor.cs:264-269`), Calc holds on Bad, and alarms short-circuit on
|
||||
`AreInputsReady`. A request is sent only when today's code would have run the script.
|
||||
- **The exact boxed CLR type is load-bearing.** A script that writes `(short)ctx.GetTag(x).Value` throws
|
||||
`InvalidCastException` if the value arrives boxed as an `int`. `Variant.type` therefore carries the CLR type,
|
||||
not just the OPC UA type: `DriverDataType` has `Int16` / `UInt16`, which have to survive the round trip.
|
||||
**[A-7]** I have not inventoried which CLR types each driver actually boxes, for example whether a Modbus
|
||||
`Int16` arrives as `short` or as `int`. The shadow mode in §12 exists partly to find out.
|
||||
- **Unsupported types.** An input whose CLR type has no tag is not silently converted. The request carries no
|
||||
value for that slot, and the worker answers `UNSUPPORTED_TYPE`; the host publishes Bad with the type name. On
|
||||
the result side, a script that returns an unsupported type (a `List<T>`, an anonymous object) also gets
|
||||
`UNSUPPORTED_TYPE`. That is a **behaviour change**: today the raw `object` goes to
|
||||
`OpcUaPublishActor.AttributeValueUpdate` (`VirtualTagHostActor.cs:233`), and what the sink does with it was
|
||||
not traced **[A-8]**. Shadow mode reports every such case before the flag flips.
|
||||
- **DateTime.** Values are sent as UTC ticks. A `Kind` other than `Utc` is converted at the host edge and
|
||||
counted on a metric. **[A-9]** I assume drivers already produce UTC.
|
||||
- **Positional inputs.** `LoadScripts` registers the script's ordered `input_paths`: the
|
||||
`DependencyExtractor` reads, after the `{{equip}}` resolution done at deploy. Requests then send values by slot,
|
||||
not by path string, which takes the dominant payload cost out of the hot path. A key with no registered
|
||||
slots, a fallback that should never happen, sends the paths inline.
|
||||
|
||||
### 4.5 Batching
|
||||
|
||||
- **Opportunistic, not timed.** The connection's writer drains every request queued at that moment into one
|
||||
`EvalBatch` and flushes once, as mxaccessgw's drain pass does. An idle system pays no added latency; a burst
|
||||
is amortised automatically. There is no coalescing *timer*, because it would add latency to every
|
||||
evaluation.
|
||||
- **Results are streamed, not batch-atomic.** The worker sends `EvalResults` as items complete, coalescing
|
||||
whatever finished in the same scheduler pass, so a slow script never holds a fast sibling's reply.
|
||||
- **The seams must submit concurrently, or batching never happens.** Today both Calc and alarms await one
|
||||
script at a time (§1.1), and with IPC that serial loop costs N × round-trip. Two seam changes are required:
|
||||
- **Scripted alarms:** `ReevaluateAsync` submits the whole dirty set at once, then applies results **in the
|
||||
original order** under `_evalGate`. Predicates are pure functions of a snapshot taken under the gate, so
|
||||
the state machine sees the same sequence.
|
||||
- **Calculation:** the pump dequeues everything pending, with each entry's dependency snapshot already
|
||||
captured at enqueue, submits it as one batch and publishes in dequeue order.
|
||||
- **Virtual tags become asynchronous.** `IVirtualTagEvaluator` gains `EvaluateAsync`, and `VirtualTagActor`
|
||||
`PipeTo`s the result to itself. While a run is in flight, further dependency changes update the map and set a
|
||||
"re-run" flag; when the result lands, the actor evaluates **once more** against the latest map (latest-wins).
|
||||
This also ends today's blocking of a default-dispatcher thread for up to 2 s
|
||||
(`RoslynVirtualTagEvaluator.cs:174-175`).
|
||||
|
||||
---
|
||||
|
||||
## 5. Pre-emption
|
||||
|
||||
### 5.1 Two deadlines
|
||||
|
||||
| | Soft deadline `T` | Hard deadline `T + G` |
|
||||
|---|---|---|
|
||||
| Value | Today's per-seam budget, unchanged: VT 2 s, Calc `runTimeoutMs` (2 s), alarm 250 ms | `G = Scripting:Worker:HardGrace`, default `max(1 s, T)`, clamped to 250 ms–30 s |
|
||||
| Who acts | The host connection's timer | The host `ScriptWorkerActor` (authoritative) **and** a worker-side watchdog thread |
|
||||
| Effect | The caller gets the timeout failure it gets today (Bad, same reason prefix `ScriptEvalFailureReasons.TimedOutPrefix`) and the run token is cancelled, so a polling script still ends itself as it does today | The worker process is **killed** (`Process.Kill(entireProcessTree: true)`). The worker watchdog calls `Environment.FailFast` first if it is still scheduling, so the dump and exit code name the key. |
|
||||
| Strike | Yes (as today) | Yes, plus a kill strike (§5.3) |
|
||||
|
||||
The grace period `G` keeps a script that overran by a few milliseconds from costing a whole shard restart, and
|
||||
the cooperative token from #734 still gets its chance. Making `G = 0` (kill at `T`) is an open decision (§14).
|
||||
|
||||
**Single flight between T and T+G.** The key stays in flight, and a trigger is refused as today, which is not
|
||||
a strike. After the kill there is **no orphan**, so the stuck-orphan rule (`StuckOrphanQuarantineFactor`)
|
||||
becomes moot out of process; it stays for the in-process mode.
|
||||
|
||||
### 5.2 Resource limits on the worker
|
||||
|
||||
The limits combine prior art from ScadaBridge (heap limit) and mxaccessgw (process-tree kill):
|
||||
|
||||
- **Heap:** `DOTNET_GCHeapHardLimit = Scripting:Worker:HeapLimitBytes`, default 512 MiB **[A-10]**. Breaching
|
||||
it makes the worker throw `OutOfMemoryException` or fail fast. Either way the worker dies or reports, and the
|
||||
host survives.
|
||||
- **Windows Job Object** (per worker):
|
||||
- `JOB_OBJECT_LIMIT_PROCESS_MEMORY`, set slightly above the heap limit as a backstop for native and JIT
|
||||
memory;
|
||||
- `JOB_OBJECT_LIMIT_ACTIVE_PROCESS = 1`, so no child processes are possible;
|
||||
- `KILL_ON_JOB_CLOSE`;
|
||||
- UI restrictions.
|
||||
- **Linux / containers:** the heap limit only. A container's cgroup is the rig's concern. **[A-11]** I assume
|
||||
the docker-dev containers run without CAP_SYS_ADMIN, so a nested cgroup is not available.
|
||||
- **Liveness:** a heartbeat every 1 s. Three missed heartbeats while not in a GC pause, which the heartbeat
|
||||
reports, trigger a kill with reason `heartbeat`. This catches a wedged worker that neither replies nor exits.
|
||||
|
||||
### 5.3 Attribution, restart, backoff and quarantine
|
||||
|
||||
A kill or crash takes down every in-flight evaluation on that shard. Attribution decides which **one** to
|
||||
blame.
|
||||
|
||||
1. **Hard-deadline kill:** the key that crossed `T+G` is known exactly. It takes a **kill strike**. The other
|
||||
in-flight keys on that shard are victims: their runs fail with "script worker restarted", which is not a
|
||||
strike.
|
||||
2. **Crash with no deadline crossed** (OOM, stack overflow, native fault): attribution is ambiguous. Every
|
||||
key in flight at exit becomes a **suspect**, and suspects move to the **isolation worker**.
|
||||
3. **Isolation worker:** evaluations run one at a time, so a crash there attributes to exactly one key, which
|
||||
takes a kill strike. A suspect that completes `IsolationProbation` (default 20 successful runs or 10 min)
|
||||
without incident returns to its shard.
|
||||
4. **Quarantine:** a key is quarantined after `Scripting:Worker:QuarantineAfterKills` kill strikes (default 2)
|
||||
within 1 h, or by today's rules (N consecutive soft timeouts, allocation breach). The quarantine table
|
||||
lives in the **host** (`ScriptRunnerActor`), so it survives worker restarts. It is lifted by a source
|
||||
change or a redeploy, exactly as today, and quarantine reasons keep the `"script quarantined: "` prefix so
|
||||
every existing consumer and test classifier still matches.
|
||||
5. **Restart backoff per worker:** exponential, 250 ms → 30 s cap, ×2, with ±20% jitter. The backoff resets
|
||||
after 60 s of `Ready`. A worker that dies **during** `Loading` (a poison PE or a runtime fault) does not
|
||||
loop: after 3 consecutive load-phase deaths, the scripts in its generation are loaded one by one in the
|
||||
isolation worker to find the culprit.
|
||||
6. **Crash-loop circuit:** if all shards are in backoff for longer than `Scripting:Worker:CircuitOpenAfter`
|
||||
(default 2 min), the runner reports `Unavailable` (§10) and logs one Error. It keeps retrying at the cap.
|
||||
|
||||
---
|
||||
|
||||
## 6. Security
|
||||
|
||||
Process isolation contains **failures**. Most of the **capability** control stays with the compile-time
|
||||
sandbox, which is retained. Defence-in-depth adds the following.
|
||||
|
||||
- **The compile-time sandbox is unchanged.** It still includes `ScriptSandbox` (with the TPA reference set
|
||||
filtered to `System.*`, `ScriptSandbox.cs:118-122`), `ForbiddenTypeAnalyzer`, and the injection guard. The
|
||||
worker runs **exactly** the same `ScriptEvaluator.Compile`, so editor/publish/runtime parity holds.
|
||||
- **Assembly allow-list by construction.** The worker is its own app (`ZB.MOM.WW.OtOpcUa.ScriptWorker`), with
|
||||
its own `deps.json` and only these references:
|
||||
- `Core.Scripting`;
|
||||
- `Core.Scripting.Abstractions`;
|
||||
- `Core.Abstractions`;
|
||||
- Serilog (core only, for `ILogger` on `ctx`);
|
||||
- Google.Protobuf.
|
||||
|
||||
Its trusted-platform list therefore contains the shared framework plus those assemblies. Akka, SqlClient, the
|
||||
OPC UA stack, driver assemblies and the Secrets library are **not loadable** in the worker, even through
|
||||
reflection. At startup the worker asserts its loaded-assembly set against a pinned list and exits if the list
|
||||
differs.
|
||||
- **No secrets in the environment.** `ProcessStartInfo.Environment` is **cleared** and then repopulated from
|
||||
an explicit allow-list: `DOTNET_*` runtime knobs, `TEMP`, `PATH` minimal, and the launch nonce. Host env vars
|
||||
like `ServerHistorian__ApiKey` (CLAUDE.md "Never commit secrets") and the ConfigDb connection string must
|
||||
never reach the worker. ScadaBridge's sandbox **inherits** the parent environment; do not copy that part.
|
||||
- **No configuration access.** The worker gets no appsettings path. Every option it needs (budgets, heap,
|
||||
frame cap) arrives in `Hello`.
|
||||
- **Pipe hardening (mxaccessgw pattern):**
|
||||
- a random pipe name;
|
||||
- `PipeOptions.CurrentUserOnly`, or an explicit ACL when the worker runs as a different account;
|
||||
- a one-time nonce passed in an env var (never on the command line; mxaccessgw `WorkerBootstrap.md` explains
|
||||
why), which the worker must echo in `Ready`;
|
||||
- the host accepts exactly one connection per launch.
|
||||
- **Filesystem:**
|
||||
- the working directory is an empty per-worker temp directory;
|
||||
- the PE cache (§7) is opened read-only by the worker, if the host compiles, or written only under its own
|
||||
subdirectory;
|
||||
- on Windows, a dedicated low-privilege account (see §14) gets read access only to the install folder and
|
||||
its own temp directory.
|
||||
- **Network:** the analyzer already forbids `System.Net`. Process-level options are a Windows Firewall
|
||||
outbound-block rule scoped to the worker executable, created by the installer, and on Linux nothing beyond
|
||||
the container. This is an open decision (§14). The recommendation is the installer firewall rule on Windows,
|
||||
and to accept the analyzer as the control on Linux.
|
||||
- **What the host trusts from the worker:** nothing it cannot validate. Frames are size-capped and
|
||||
schema-checked, an `eval_id` must match an outstanding request, and log lines are length-capped and
|
||||
rate-limited (§11). A compromised worker can at worst return wrong values for scripts on its own shard,
|
||||
which the same script could have done anyway.
|
||||
|
||||
---
|
||||
|
||||
## 7. Compile caching across restarts
|
||||
|
||||
The problem: today every process start recompiles every script lazily (`CompiledScriptCache` is memory-only).
|
||||
Out of process, a **shard restart** has the same cost, and it can happen in the middle of production running.
|
||||
mem-probe measured the old `CSharpScript` path at roughly 2.4 MiB of working set per retained script
|
||||
(`tools/mem-probe/MemProbe/Program.cs:1-4`). Compile *time* is not measured anywhere in the repo **[A-12]**;
|
||||
I assume tens of ms per script warm and seconds for the first compile while Roslyn JITs. For 1,000 scripts,
|
||||
that is far outside a 2 s restart budget.
|
||||
|
||||
**Recommendation: a content-addressed PE cache on node-local disk.**
|
||||
|
||||
- **Key:** SHA-256 over these fields:
|
||||
- the source;
|
||||
- the `ContextKind`, i.e. `TContext` and `TResult`;
|
||||
- the Roslyn compiler version;
|
||||
- a **sandbox fingerprint**: a hash of `ForbiddenTypeAnalyzer`'s lists, the `ScriptSandbox` reference set
|
||||
and the wrapper template.
|
||||
|
||||
An analyzer change therefore invalidates every entry, and a cached PE can never skip a newer sandbox rule.
|
||||
- **Value:** the emitted PE bytes plus an HMAC-SHA256 tag. The HMAC key is a per-node random key created at
|
||||
first start and stored next to the cache, readable only by the host account. The worker loads a PE only if
|
||||
the HMAC verifies, because loading attacker-supplied PE bytes would be code execution outside the analyzer.
|
||||
- **Location:** `Scripting:Worker:PeCacheDir`. It must be absolute and outside the content root, the same
|
||||
fail-closed rule the family applies to `Secrets:SqlitePath` (scadaproj CLAUDE.md, Secrets row). Entries
|
||||
whose key is not in the current generation are pruned at `ApplyGeneration`.
|
||||
- **Where compilation happens:** **in the worker** (recommended). Roslyn's memory and CPU leave the host. A
|
||||
pathological source that makes Roslyn itself blow up kills a worker, not the server, and the worker writes
|
||||
the PE plus HMAC (it holds the key through `Hello`). The alternative is to compile in the host and ship
|
||||
`pe_image` in `LoadScripts`. That keeps the worker free of Roslyn (smaller, faster start), but compile-time
|
||||
blowups stay in the host. This is an open decision (§14).
|
||||
- **Restart path:** `Spawning → Ready` takes about 150 ms of runtime start **[A-13]**. Then `LoadScripts`
|
||||
sends hash + key + slots only. The worker loads PEs from the cache lazily, on each key's first evaluation,
|
||||
and a cache miss compiles. Loading a PE into a collectible ALC is about 1 ms **[A-13]**.
|
||||
- **Negative cache:** Calculation already remembers compile failures by source hash (R07:P4,
|
||||
`CalculationEvaluator.cs:63-68`). The worker reports compile failures in `LoadResult`, and the host keeps
|
||||
that negative cache for **all** seams, so a broken script never re-enters Roslyn.
|
||||
|
||||
---
|
||||
|
||||
## 8. Performance budgets
|
||||
|
||||
### 8.1 Reference loads in the repo
|
||||
|
||||
| Source | Load |
|
||||
|---|---|
|
||||
| `docs/v2/Galaxy.Performance.md` (EventPump sizing, 24 h soak) | 50,000 tags at 1 Hz on one node; compressed CI soak at 1,000 tags |
|
||||
| `tests/Server/.../NodeManagerLockTimeBenchmarkTests.cs:41-105` | 10,000-tag folder; 200 equipment × 250 tags (50,000 variables) |
|
||||
| `ScriptRunGuardTests.cs:170-205` | a 1 Hz runaway and a healthy sibling over 30 triggers |
|
||||
|
||||
No repo perf test exercises scripts at scale. **[A-14]** I assume a planning envelope of **5,000 compiled
|
||||
script evaluations per second per node** (for example 5,000 VT + Calc at 1 Hz, or 500 scripts at 10 Hz) and
|
||||
size for 2× headroom (10k/s). The passthrough fast path is excluded because it stays in the host.
|
||||
|
||||
### 8.2 Budgets (acceptance gates for the implementation)
|
||||
|
||||
| Metric | Budget | Notes |
|
||||
|---|---|---|
|
||||
| Added latency per evaluation, p50 | ≤ 150 µs | Host enqueue → reply decoded, trivial script, idle system |
|
||||
| Added latency per evaluation, p99 | ≤ 2 ms | At 10k evals/s. Compare the 250 ms alarm budget: under 1% of it |
|
||||
| Throughput | ≥ 10,000 trivial evals/s/node with K=2 | With opportunistic batching |
|
||||
| Host CPU for IPC | ≤ 25% of one core at 10k evals/s | Serialisation + pipe I/O, measured by an opt-in perf test |
|
||||
| Bytes per eval on the wire | ≤ 64 B + 24 B per input | Positional slots, §4.4 |
|
||||
| Shard restart → first result, 1,000 cached scripts | ≤ 2 s | §7 lazy PE load |
|
||||
| Worker idle RSS | ≤ 80 MB + scripts **[A-3]** | Per shard |
|
||||
| Kill latency after `T+G` | ≤ 100 ms | Host timer resolution plus `Process.Kill` |
|
||||
|
||||
**Comparison with today:** the in-process cost per compiled run is one `Task.Run` hop plus `WaitAsync`, which
|
||||
is a few µs to tens of µs and was never measured **[A-15]**. The out-of-process path is therefore one to two
|
||||
orders of magnitude slower **per call**. That is acceptable only because the budgets above sit two to three
|
||||
orders of magnitude below the script budgets (250 ms / 2 s), and because batching amortises the syscalls. The
|
||||
risk case is a large scripted-alarm engine, with its serial gate and its 250 ms budget. That is why §4.5
|
||||
makes the batch-submit change a **prerequisite**, not an optimisation.
|
||||
|
||||
---
|
||||
|
||||
## 9. Failure semantics: what quality a tag gets
|
||||
|
||||
The principle: **a runner failure must never look like a script result.** In particular, an alarm must never
|
||||
clear because the worker died.
|
||||
|
||||
| Situation | Virtual tag / Calculation tag | Scripted alarm |
|
||||
|---|---|---|
|
||||
| Soft timeout (`T`) | `BadInternalError` + last value, once per transition, as today | Condition frozen + `BadInternalError` quality annotation, as today |
|
||||
| This key killed at `T+G`, or crashed in isolation | Same as soft timeout; reason `"script killed: exceeded hard deadline …"` | Same as soft timeout |
|
||||
| Victim: in flight on a shard that died for another key | `BadInternalError` + last value, reason `"script worker restarted"`; re-evaluated on the shard's return with the latest inputs (latest-wins) | Frozen + Bad annotation; re-evaluated on return |
|
||||
| Not in flight while its shard restarts | **Unchanged.** Keeps its last published value and quality. A trigger during the outage is coalesced (latest-wins, one per key) and evaluated when the shard is `Ready` | Same; the condition holds its state |
|
||||
| Shard not back within `Scripting:Worker:UnavailableGrace` (default 5 s) | Coalesced keys publish Bad + last value, reason `"script runner unavailable"` | Frozen + Bad annotation |
|
||||
| Circuit open (§5.3.6) | Every compiled-script key on the node is Bad as above; passthrough keys are unaffected | Every compiled predicate is frozen + Bad |
|
||||
| Quarantined | `"script quarantined: …"` Bad, as today | As today |
|
||||
| Host process dies | Workers die with it (Job Object / pipe EOF); no change from today's host-death behaviour | — |
|
||||
|
||||
**Status code:** `BadInternalError` stays for script-attributable faults (the existing contract). For "the
|
||||
runner is unavailable, not this script's fault", this design proposes **`BadResourceUnavailable`
|
||||
(0x80040000)**, so operators and the ScadaBridge consumer can tell a broken script from a sick node. The
|
||||
alternative is to keep `BadInternalError` everywhere for zero consumer impact. This is an open decision (§14);
|
||||
if it is taken, the ScadaBridge consumer-impact record must note it (scadaproj `sister-project-history`
|
||||
skill, `otopcua.md`).
|
||||
|
||||
**No silent fallback to in-process.** If the worker cannot start, the runner does **not** run scripts in the
|
||||
host. That would reintroduce exactly the risk the flag was set to remove. Operators flip the flag back (§12).
|
||||
This is an open decision (§14).
|
||||
|
||||
---
|
||||
|
||||
## 10. Observability
|
||||
|
||||
All metrics go on `OtOpcUaTelemetry.Meter`, so the existing `ZbTelemetryOptions` allow-list applies.
|
||||
scadaproj memory: "`ZbTelemetryOptions.Meters` is a silent allowlist"; check that the new names flow end to
|
||||
end.
|
||||
|
||||
| Metric | Type | Tags |
|
||||
|---|---|---|
|
||||
| `otopcua.script.worker.up` | gauge | `shard` |
|
||||
| `otopcua.script.worker.starts` / `.exits` | counter | `shard`, `reason` = `clean` \| `killed_deadline` \| `killed_heartbeat` \| `oom` \| `crash` \| `load_failure` |
|
||||
| `otopcua.script.worker.inflight` / `.queue_depth` | gauge | `shard` |
|
||||
| `otopcua.script.worker.heap_bytes` | gauge (from heartbeat) | `shard` |
|
||||
| `otopcua.script.ipc.roundtrip` | histogram (µs) | `kind` |
|
||||
| `otopcua.script.eval.duration` | histogram (µs, worker-measured) | `kind` |
|
||||
| `otopcua.script.compile` | counter | `cache` = `hit` \| `miss` \| `negative` |
|
||||
| `otopcua.script.quarantined` (existing) | counter | adds `reason` = `killed` \| `isolated_crash` |
|
||||
| `otopcua.script.run_refused` (existing) | counter | unchanged |
|
||||
| `otopcua.script.isolation.suspects` | gauge | — |
|
||||
| `otopcua.script.runner.state` | gauge (0 ready / 1 degraded / 2 unavailable) | — |
|
||||
|
||||
**Per-key series:** the existing counters tag the series with `script = key`. Do not add `key` tags to the new
|
||||
per-eval histograms (cardinality).
|
||||
|
||||
**Logs:**
|
||||
- the worker's own stderr is captured, prefixed with `ScriptWorker[shard]`, into the host log;
|
||||
- every kill logs one Warning naming the key and the reason, and every quarantine logs one Error, as today;
|
||||
- `ctx.Logger` lines come back in `LogBatch`. The host re-emits them through `ScriptRootLogger` with the same
|
||||
`ScriptId` / `VirtualTagId` properties, so `scripts-*.log`, the error mirror, the DPS script-log topic and its
|
||||
20/min-per-source rate limit all keep working unchanged;
|
||||
- the worker caps its outbound log buffer (default 1,000 lines / 1 MiB) and reports drops as
|
||||
`LogBatch.dropped`, so a script logging in a tight loop cannot flood the pipe.
|
||||
|
||||
**Health:** expose the runner state as a non-fatal component of the driver-node status (`/health/ready`
|
||||
stays green while degraded, because OPC UA still serves driver data). Whether `Unavailable` should fail
|
||||
readiness is an open decision (§14). The AdminUI status dashboard gets a "Script runner" tile with the
|
||||
per-shard state, restarts in the last hour and the quarantine list. Quarantines already surface through the
|
||||
`QuarantinedScripts` properties.
|
||||
|
||||
**Traces:** one `Activity` per `EvalBatch` (not per evaluation), with `batch.size` and `shard`.
|
||||
|
||||
---
|
||||
|
||||
## 11. Seam changes in the host (summary)
|
||||
|
||||
| Component | Change |
|
||||
|---|---|
|
||||
| `Core.Scripting` | New `IScriptRunner` (`EvaluateAsync(ScriptInvocation[])`, `ApplyGeneration`, `QuarantinedKeys`, `ResetAll`). `InProcessScriptRunner` wraps today's `ScriptRunGuard` + `CompiledScriptCache` exactly. |
|
||||
| New `Core.Scripting.Protocol` | The proto file, the frame codec and the `Variant` ↔ CLR mapping. Shared by host and worker. |
|
||||
| New `ZB.MOM.WW.OtOpcUa.ScriptWorker` | Console app: handshake, scheduler, watchdog, PE cache, log forwarding. Shipped in the Host's publish output. |
|
||||
| New `Runtime/Scripting/` | `ScriptRunnerActor`, `ScriptWorkerActor`, `OutOfProcessScriptRunner` (data plane), Job Object interop (Windows-only, behind `OperatingSystem.IsWindows()`). |
|
||||
| `IVirtualTagEvaluator` / `VirtualTagActor` | Add `EvaluateAsync`; the actor `PipeTo`s with latest-wins re-run (§4.5). |
|
||||
| `RoslynVirtualTagEvaluator`, `CalculationEvaluator`, `ScriptedAlarmEngine` | Replace direct `ScriptRunGuard` use with `IScriptRunner`. Calc pump and alarm `ReevaluateAsync` submit batches (§4.5). |
|
||||
| `ScriptingOptions` / `ScriptingOptionsBinding` | New keys (§12), clamped with startup Warnings like the existing ones. |
|
||||
|
||||
---
|
||||
|
||||
## 12. Migration and rollout
|
||||
|
||||
**Configuration** (node-local appsettings, `Scripting` section, consistent with the existing knobs):
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `Scripting:Runner` | `InProcess` | `InProcess` \| `OutOfProcess` \| `Shadow` |
|
||||
| `Scripting:Runner:Seams` | all three | Optional per-seam subset for `OutOfProcess` / `Shadow` (e.g. `["Calculation"]`) |
|
||||
| `Scripting:Worker:Shards` | `2` | 1–8 |
|
||||
| `Scripting:Worker:HardGrace` | `max(1s, T)` | §5.1 |
|
||||
| `Scripting:Worker:HeapLimitBytes` | `536870912` | §5.2 |
|
||||
| `Scripting:Worker:QuarantineAfterKills` | `2` | §5.3 |
|
||||
| `Scripting:Worker:UnavailableGrace` | `5s` | §9 |
|
||||
| `Scripting:Worker:PeCacheDir` | none (required when not `InProcess`) | §7; absolute, outside the content root, fail-closed |
|
||||
|
||||
**Phases** (each one is its own issue and PR):
|
||||
|
||||
1. **P0, in-process refactor with no behaviour change.**
|
||||
- Introduce `IScriptRunner` + `InProcessScriptRunner`.
|
||||
- Make VT evaluation async (`PipeTo`), and make the alarm engine and the Calc pump batch-submit.
|
||||
- Gate: every existing Scripting / VirtualTags / Calculation / ScriptedAlarms suite stays green unchanged,
|
||||
plus new ordering tests for batch-then-apply.
|
||||
- This phase has value on its own: it ends the dispatcher-thread blocking.
|
||||
2. **P1, worker + protocol behind the flag.**
|
||||
- Ship `ScriptWorker`, `OutOfProcessScriptRunner` and the PE cache.
|
||||
- Default stays `InProcess`. Enable on the docker-dev rig only.
|
||||
- Gate: §13 runaway / containment tests green on Linux (docker-dev) **and** Windows (windev box, which has
|
||||
the .NET 10 toolchain).
|
||||
3. **P2, shadow mode on docker-dev and QA.**
|
||||
- `Shadow` runs the **in-process** evaluation as the published result, and *also* submits to the worker.
|
||||
- It compares value, status class and failure kind per evaluation, and counts
|
||||
`otopcua.script.shadow.mismatch{kind, reason}`. Mismatching keys are logged once each.
|
||||
- Shadow doubles script CPU and still carries the in-process risk, so it is a time-boxed diagnostic (for
|
||||
example 1 week on QA), not a mode to run in production.
|
||||
- Exit criterion: zero unexplained mismatches. This is where [A-7] / [A-8] get answered.
|
||||
4. **P3, flip on QA, then the other tiers.**
|
||||
- Set `Scripting:Runner=OutOfProcess` per node, seam subset first (Calculation, then VT, then alarms; alarms
|
||||
last because of the 250 ms budget).
|
||||
- Note: remote command execution on the wonder boxes is currently scoped to user-requested deploys (CLAUDE.md
|
||||
hold), so tier rollout rides normal deploys.
|
||||
5. **P4, decide the long-term status of `InProcess`.** The recommendation is to keep it for dev and tests and
|
||||
as the documented rollback, and to retire `StuckOrphanQuarantineFactor` from the docs as "in-process only".
|
||||
|
||||
**Rollback:** set the flag back to `InProcess` and restart the node. No data or config migration is involved.
|
||||
The PE cache directory can be deleted at any time.
|
||||
|
||||
**Docs to update when implementing:**
|
||||
- `docs/VirtualTags.md` §"Known resource limits" and §"Per-evaluation timeout";
|
||||
- `docs/ScriptEditor.md` §"Runtime safety";
|
||||
- `docs/ScriptedAlarms.md`;
|
||||
- `docs/ServiceHosting.md`: the new process, Job Object and firewall rule;
|
||||
- `docs/security.md`;
|
||||
- `docs/Telemetry.md`;
|
||||
- the scadaproj index row, if the status-code decision changes the ScadaBridge-visible contract.
|
||||
|
||||
---
|
||||
|
||||
## 13. Test plan
|
||||
|
||||
**Unit (`Core.Scripting.Tests`, new `ScriptWorker.Tests`):**
|
||||
|
||||
- Frame codec:
|
||||
- round-trip every `Variant.TypeTag` (arrays included);
|
||||
- reject zero-length, oversize and truncated frames;
|
||||
- reject an `eval_id` with no outstanding request;
|
||||
- fuzz the reader with random bytes and assert there is no exception other than a protocol fault.
|
||||
- **Unboxing parity:** for each tag, a script doing `(T)ctx.GetTag("x").Value` behaves identically in and out
|
||||
of process. This pins [A-7].
|
||||
- Attribution ledger:
|
||||
- a deadline kill blames exactly one key;
|
||||
- a crash with 3 in flight → 3 suspects → isolation;
|
||||
- an isolation crash → one strike;
|
||||
- probation returns a suspect to its shard;
|
||||
- quarantine after 2 kills;
|
||||
- a source change lifts the quarantine;
|
||||
- a redeploy lifts every quarantine.
|
||||
- Backoff schedule, jitter bounds, the reset-after-healthy rule and the load-phase crash bisection.
|
||||
- Options binding: clamps plus a Warning per clamped key; `PeCacheDir` relative or inside the content root is a
|
||||
startup failure.
|
||||
|
||||
**Containment (worker-backed, `Category=ScriptWorker`; runs in CI on Linux, gated on windev for Windows).** Each
|
||||
case has a positive control: a healthy sibling script on the **same shard** that keeps producing correct values
|
||||
before and after the event. Following scadaproj memory "assert the artifact before the outcome", each assertion
|
||||
is checked to be capable of failing.
|
||||
|
||||
| Case | Assert |
|
||||
|---|---|
|
||||
| `while(true){}` ignoring the token | Killed within `T + G + 100 ms`; host `ThreadPool.ThreadCount` and `Process.Threads` unchanged; no orphan (`InFlightCount == 0` after the kill). Mutation check: disable the kill and see the test fail |
|
||||
| `while(!ctx.CancellationToken.IsCancellationRequested){}` | Ends at `T` by itself; **no** kill; no strike beyond the timeout |
|
||||
| Allocation bomb | Worker dies of OOM or the heap limit; **host RSS stays below a bound** and the host stays up; suspect → isolation → quarantine |
|
||||
| Unbounded recursion | Worker dies; host up; quarantined after the isolation reproduction. Also the first in-repo reproduction of [A-1] for the in-process mode, marked `Skip` there because it would kill the test host |
|
||||
| Worker killed externally (`kill -9`) | Victims Bad with "script worker restarted", then recover with the latest inputs; the alarm condition is **not** cleared |
|
||||
| Host killed | Worker exits within 2 s (pipe EOF on Linux, Job Object on Windows) |
|
||||
| Environment | The worker's environment contains no `ServerHistorian__ApiKey` / ConfigDb variables (set them in the test host first: positive control) |
|
||||
| Pipe | A second client connecting to the pipe is refused; a wrong nonce is refused |
|
||||
| Loaded assemblies | The worker's loaded set equals the pinned list; Akka / SqlClient / Opc.Ua are absent |
|
||||
| Crash loop | A poison generation trips the circuit; state `Unavailable`; keys Bad with the runner-unavailable reason; recovery after a fixed redeploy |
|
||||
|
||||
**Parity:** run the existing `RoslynVirtualTagEvaluatorTests`, `CalculationDriver*Tests` and
|
||||
`ScriptedAlarmEngine*Tests` against **both** runners. Use xunit theory or class fixtures over
|
||||
`IScriptRunner`, with the worker built as a test dependency.
|
||||
|
||||
**Performance (`Category=Perf`, opt-in, not in CI):** a Stopwatch-based harness (no new packages) that
|
||||
measures the §8 table: p50/p99 added latency, throughput at K=1/2/4, host CPU, restart-to-first-result with
|
||||
1,000 cached PEs, and RSS per shard.
|
||||
|
||||
**Live gates:**
|
||||
- docker-dev rig: a deploy with VTs, Calc tags and scripted alarms; drill a runaway script through the
|
||||
AdminUI and watch `/uns` go Bad, the quarantine appear, and siblings stay Good;
|
||||
- QA shadow week (P2);
|
||||
- a QA flip with the same drill.
|
||||
|
||||
---
|
||||
|
||||
## 14. Open decisions for the owner
|
||||
|
||||
Each has a recommendation, marked ★.
|
||||
|
||||
1. **Pool shape.**
|
||||
- ★ K=2 shards plus an on-demand isolation worker;
|
||||
- one worker per node, simplest, but every kill blips every script;
|
||||
- per-seam workers.
|
||||
2. **Hard grace `G`.**
|
||||
- ★ `max(1 s, T)`, which keeps cooperative cancellation useful;
|
||||
- `G = 0` (kill at `T`): strictest, but every soft overrun restarts a shard.
|
||||
3. **Status code for "runner unavailable / worker restarted".**
|
||||
- ★ `BadResourceUnavailable`, which separates a sick node from a broken script but is a ScadaBridge-visible
|
||||
contract change;
|
||||
- keep `BadInternalError` everywhere, for zero consumer impact.
|
||||
4. **Fallback when the worker cannot start.**
|
||||
- ★ fail closed: Bad, and no in-process execution;
|
||||
- fail open to in-process with an Error, which keeps values flowing but silently reopens the risk.
|
||||
5. **Worker identity on Windows.**
|
||||
- ★ a dedicated low-privilege virtual account created by the installer, which needs installer work, an
|
||||
explicit pipe ACL, and a deploy that touches the box;
|
||||
- the same account as the host: zero install change, and the Job Object plus the analyzer remain the
|
||||
controls.
|
||||
6. **Network isolation.**
|
||||
- ★ a Windows Firewall outbound-block rule on the worker executable (installer), with the analyzer as the
|
||||
only control on Linux;
|
||||
- analyzer only, everywhere.
|
||||
7. **Compile location.**
|
||||
- ★ in the worker: Roslyn blowups are contained and the host gets lighter;
|
||||
- in the host, shipping PE bytes: a lighter worker and faster cold start.
|
||||
8. **PE cache store.**
|
||||
- ★ a node-local directory with an HMAC;
|
||||
- LocalDb (already a dependency; it would also replicate to the pair, which is unnecessary);
|
||||
- no persistence, recompiling on restart (violates the 2 s budget at scale).
|
||||
9. **IPC encoding.**
|
||||
- ★ protobuf (already compiled in `Commons`, and mxaccessgw precedent);
|
||||
- MessagePack (pinned but unused, so in practice a new dependency).
|
||||
10. **Scripted alarms in the first flip.**
|
||||
- ★ last, after Calc and VT, because of the 250 ms budget and the engine refactor;
|
||||
- all seams at once.
|
||||
11. **Unsupported input/result CLR types.**
|
||||
- ★ an explicit Bad `UNSUPPORTED_TYPE`, surfaced in shadow mode first;
|
||||
- best-effort conversion (to string, `double`), which hides type bugs.
|
||||
12. **Readiness when the circuit is open.**
|
||||
- ★ degraded but ready (OPC UA still serves driver data);
|
||||
- not ready, which on a redundant pair would shift ServiceLevel to the peer.
|
||||
13. **Long-term status of `InProcess`.**
|
||||
- ★ keep it as the dev/test default and the rollback;
|
||||
- delete it after a soak period.
|
||||
14. **Does this need a real-deployment scale target?** [A-14]'s 5k evals/s/node is a guess. The owner should
|
||||
supply the expected VT / Calc / alarm counts per node for the target sites, or accept the guess as the
|
||||
gate.
|
||||
|
||||
---
|
||||
|
||||
## 15. Assumptions (not verified from code)
|
||||
|
||||
| Id | Assumption | How to verify |
|
||||
|---|---|---|
|
||||
| A-1 | Unbounded recursion in a script throws `StackOverflowException` and kills the host today | Reproduce in a throwaway process (not the test host) |
|
||||
| A-2 | A collectible ALC whose code is still executing on an orphan thread cannot be unloaded | .NET docs / a `WeakReference` test with a spinning orphan |
|
||||
| A-3 | A minimal .NET 10 worker idles at tens of MB RSS | Measure the P1 worker |
|
||||
| A-4 | `P = max(2, cores/K)` evaluation threads per worker is enough | P1 perf harness |
|
||||
| A-5 | Passthrough scripts are a large share of evaluations on real deployments | Count `PassthroughScript.TryMatch` hits against compiled runs on QA (add a counter in P0) |
|
||||
| A-6 | A local pipe round trip costs tens of µs | P1 perf harness |
|
||||
| A-7 | Drivers box tag values with stable, enumerable CLR primitive types | Shadow-mode type census |
|
||||
| A-8 | What `OpcUaPublishActor` / the sink does with an unsupported result object today | Trace `AttributeValueUpdate` → `SdkAddressSpaceSink` |
|
||||
| A-9 | Drivers produce UTC `DateTime` values | Shadow-mode census on `DateTime.Kind` |
|
||||
| A-10 | 512 MiB is a sane per-worker heap limit (ScadaBridge uses the same) | Size against measured RSS × script count |
|
||||
| A-11 | docker-dev containers cannot create nested cgroups | Check the compose capabilities |
|
||||
| A-12 | Warm Roslyn compile is tens of ms per script; cold is seconds | P1 perf harness |
|
||||
| A-13 | Worker start is ~150 ms; a PE load into a collectible ALC is ~1 ms | P1 perf harness |
|
||||
| A-14 | 5k compiled evals/s/node is a fair planning envelope | Owner input (decision 14) |
|
||||
| A-15 | Today's in-process overhead per compiled run is µs to tens of µs | Add the same harness to `InProcessScriptRunner` in P0 for a baseline |
|
||||
|
||||
---
|
||||
|
||||
## 16. Prior art consulted
|
||||
|
||||
- **ScadaBridge `CentralUI.SandboxWorker`** (`~/Desktop/ScadaBridge/src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/SandboxWorkerProtocol.cs`).
|
||||
It runs one disposable process per *test run* over stdio, using 4-byte LE length-prefixed JSON frames with a
|
||||
1 MiB cap, admission 4, `DOTNET_GCHeapHardLimit` 512 MiB, and `Kill(entireProcessTree)` on the deadline.
|
||||
Taken from it: the heap limit, the tree kill and the frame cap. Not taken:
|
||||
- stdio (fragile for a long-lived worker);
|
||||
- per-run process spawn (too slow for runtime evaluation);
|
||||
- inheriting the parent environment;
|
||||
- proxying host calls.
|
||||
|
||||
ScadaBridge's *runtime* scripts are still in-process (dedicated scheduler plus watchdog,
|
||||
`docs/plans/2026-08-15-script-pool-split-design.md`), so this design would be the family's first
|
||||
out-of-process **runtime** evaluator.
|
||||
- **mxaccessgw gateway↔worker** (`~/Desktop/MxAccessGateway/docs/WorkerFrameProtocol.md`,
|
||||
`WorkerProcessLauncher.md`, `WorkerBootstrap.md`). It runs a long-lived supervised worker over a named pipe:
|
||||
protobuf envelopes, a negotiated frame cap, a priority-drained writer with coalesced flushes, sequence
|
||||
numbers, a nonce in an env var (never on the command line), startup probe plus retry, and a tree kill on a
|
||||
failed start. Taken from it: the frame protocol shape, the nonce handshake and the launcher discipline.
|
||||
Avoided: an orphan terminator that is not scoped to its own instance.
|
||||
Reference in New Issue
Block a user