Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Implement the out-of-process, pre-emptible script runner designed in #750 (docs/plans/2026-11-09-out-of-process-script-runner-design.md), behind
Scripting:Runner(InProcess default / Shadow / OutOfProcess), phases P1–P4 with alarms last. Blocked on the 14 owner decisions in the design (pool shape, kill grace, the "runner unavailable" OPC UA status code — a ScadaBridge-visible contract change, fail-closed start, worker account, network blocking, compile location, cache, encoding, rollout order, unsupported types, readiness when the breaker trips, keeping in-process, and the scale target).Owner decision 2026-11-09: accept all 14 ★ recommendations in docs/plans/2026-11-09-out-of-process-script-runner-design.md §14 as written — K=2 shards + isolation worker; G=max(1 s,T); BadResourceUnavailable for runner-unavailable/worker-restarted (ScadaBridge-visible contract change — coordinate via a cross-repo ScadaBridge issue before the OutOfProcess flip); fail closed; dedicated low-privilege virtual account + pipe ACL; firewall outbound block (analyzer-only on Linux); compile in the worker; node-local HMAC'd PE cache; protobuf IPC; alarms last; explicit UNSUPPORTED_TYPE; degraded-but-ready; keep InProcess as default/rollback; accept the 5k evals/s/node guess as the gate. Unblocked: P1→P4 may proceed.
P1 slice landed on
master@69a6e0b4(opt-in;Scripting:Runner=InProcessstays the default and its code path is unchanged: the seams only take the new branch when a runner is injected).What is in
Scripting:Runner(InProcess / Shadow / OutOfProcess),Scripting:Runner:Seams,Scripting:Worker:{Shards,HardGrace,HeapLimitBytes,QuarantineAfterKills,UnavailableGrace,IsolationProbationRuns,PeCacheDir,ExecutablePath}. These are bound and clamped, with a startup Warning per clamp. A relativePeCacheDir, or one inside the content root, stops startup. A misspelled mode fails startup.Shadowcurrently runs InProcess with a Warning (that is P2).IScriptRunner+InProcessScriptRunner(the existing guard, used as the parity baseline) in Core.Scripting.Core.Scripting.Protocol: protobuf envelopes with 4-byte little-endian length framing, and aVariantthat carries the exact CLR type. An unsupported input or result type is an explicitunsupported type: …failure (§14 #11).ZB.MOM.WW.OtOpcUa.ScriptWorkerapp:ctx.Loggerforwarding;script-worker/.Core.Scripting.OutOfProcess.OutOfProcessScriptRunner:BadResourceUnavailable(0x80040000) with the last value. Reasons arescript worker restartedorscript runner unavailable. Script faults keepBadInternalError. ScadaBridge consumer issue: dohertj2/ScadaBridge#112 (cross-repo, followup, p3). It must close before any node ScadaBridge reads is flipped (P3).Evidence
dotnet build ZB.MOM.WW.OtOpcUa.slnx -c Release: 0 warnings, 0 errors (also--no-incremental).tests/Server/ZB.MOM.WW.OtOpcUa.ScriptWorker.Tests(Category=ScriptWorker, real worker processes): 89/89 on macOS and 89/89 on windev (Windows). Cases:while(true){}killed at T+G, the host burns no CPU on it afterwards, the sibling keeps working, quarantine after 2 kills, lifted by a source change or a redeploy;kill -9leaves the in-flight script a victim (Unavailable), then it becomes a suspect and recovers in isolation;ServerHistorian__ApiKeyor ConnectionStrings (checked against a positive control);(T)ctx.GetTag(x).Valuefor 13 types (pins A-7);AKKA_TEST_TIMEFACTOR=3:f5c30ed0).ScriptRunGuardTests.Three_consecutive_timeouts…andDependencyMuxReplayTests.Retention…. Neither is in code this change touches.shortencoded asint;Deviations from the design
WorkerShard/OutOfProcessScriptRunner), not theScriptRunnerActor/ScriptWorkerActorpair.Scripting:Runner:Seamsis bound explicitly, becauseScripting:Runneris itself a value.P1 remaining (keep open)
PeCacheDiris validated but not used yet, so every worker restart recompiles.otopcua.script.runner.stategauge, the readiness component (degraded but still ready, §14 #12), and the rest of the §10 metrics. Onlyworker.startsandworker.exitsexist so far.Category=Perf) and measure against the accepted 5k evals/s/node gate.Exited, so a trigger that lands in the restart gap can run once more before the quarantine is in place.Then P2 (Shadow), P3 (flip, after ScadaBridge#112) and P4.
Second P1 slice landed on
master@f50b29ab(still opt-in behindScripting:Runner=OutOfProcess; InProcess unchanged). Code only: no installer work, nothing deployed.What is in (items from the P1-remaining list in the previous comment)
Scripting:Worker:PeCacheDir, optional. The host creates a per-node random key at<dir>/pe-cache.key; on Unix the key file is 0600 and the directory 0700, and on Windows it relies on the directory ACL (installer). The key reaches the worker only inHello. Entries live at<dir>/pe/<id>.pe, andid = SHA-256(compile fingerprint + kind + source). The fingerprint covers the Roslyn version, the Core.Scripting build (analyzer, wrapper, stack guard), the sandbox reference set and the runtime, so a compiler or analyzer change misses every old entry. The HMAC covers magic + id + PE. An entry that has been edited, truncated, written under another key or copied to another id is rejected, deleted and recompiled. Compile errors are never cached. An unusable directory gives a Warning and a runner without a cache. Deviation: pruning is by recency at startup (30 d / 50 000 entries), not by generation, because this runner has no generation object.Measured (40 scripts, worker restart to every first result):
WorkerConnectionnow runs exit attribution synchronously, before it completes any evaluation or load it held and beforeExited. The shard passes that callback straight to the runner. A killed key is therefore quarantined before its single-flight slot is released.otopcua.script.runner.state(0 ready / 1 degraded / 2 circuit open),worker.up,worker.inflight,worker.queue_depthandworker.heap_bytes(taggedshard, from heartbeats), andisolation.suspects;ipc.roundtripandeval.duration(µs, taggedkind, no per-key tag);compile{cache=hit|miss|rejected|uncached|negative}, andexitsgainsreason=idle;script-runnerhealth check is on the ready tier and is at worst Degraded (still 200). It is never Unhealthy.Scripting:Worker:CircuitOpenAfter(default 2 min): when it opens, one Error is logged and evaluations are answered at once while the shards keep retrying at the backoff cap.ScriptRunOutcome.Recovery→VirtualTagEvalResult.RetryWhen, and the VirtualTagActor and CalculationDriver re-evaluate once against their current inputs when it completes.Scripting:Worker:IsolationIdleTimeout(default 10 min). It is an Idle exit: no backoff and not counted as down."its compile crashed the isolation worker"). This is isolation per key, not the §5.3.5 three-strikes bisection, because loads here are per key.IScriptRunnerplus rework of the Calc pump and the alarm engine, and the alarm seam is not on the runner yet. The connection writer already merges concurrent submits into oneEvalBatch.Evidence
dotnet build ZB.MOM.WW.OtOpcUa.slnx -c Release --no-incremental: 0 warnings, 0 errors.AKKA_TEST_TIMEFACTOR=3, all on macOS:ScriptWorker.Tests is also 122/122 on windev (Windows).
New tests:
SupervisionTests(real workers),PeCacheStoreTests,PeCacheWorkerTests(cold/warm measurement and a forged entry that returns 666 and never runs),ScriptRunnerHealthCheckTests, and recovery tests for the VirtualTag actor and the Calculation driver.14 mutations, each turned its test red and was reverted:
Still open on #780 (keep open)
Category=Perf).Then P2 (Shadow), P3 (flip, after ScadaBridge#112) and P4.
Third P1 slice landed on
master@1ead8f45. It is still opt-in, andInProcessstays the default and is unchanged. Nothing was deployed and no wonder-box was touched. The full record is in design §12.1.1. Self-contained worker publish (for the Windows installs)
-r win-x64 --self-containedHost publish now publishes the ScriptWorker self-contained for the same RID intoscript-worker/. That is 236 files, about 97 MB, with its own runtime; it is not trimmed and not single-file.ZB.MOM.WW.OtOpcUa.ScriptWorker.exedirectly, but only when the worker'sruntimeconfig.jsonlistsincludedFrameworks(OutOfProcessScriptRunner.ResolveWorkerEntry). There is nodotnetmuxer in that path.dotnet exec <dll>.C:\build, and the wholeScriptWorker.Testssuite was run against the published apphost viaOTOPCUA_SCRIPTWORKER_TEST_PATH. Result: 132/132. This includes a gate test asserting that the worker process'sMainModuleis the.exe.2. docker-dev live drill (new overlay
docker-dev/docker-compose.script-runner.yml: central pair, VT + Calc seams)HR5x2= 42 Good on both nodes and the VT sibling (HR5×3) = 63 Good.while(true)VT was killed at its 4000 ms hard deadline twice and then quarantined, on both nodes. The VT and Calc on the same shard stayed Good.kill -9of both workers./health/readystayed 200: Degraded, then circuit-open Unavailable after 2 min.otopcua_script_runner_state= 2.:9000/metrics.e221b179…, identical to the pre-drillgate-cleanupdeployment;otopcua-host:devre-tagged to the pre-drill image24f9d7287ed2;Scripting__*.3. §8 perf harness
ScriptRunnerPerfTests,Category=Perf. It is opt-in withOTOPCUA_SCRIPT_PERF=1, so it skips in a default run.Defects the harness found, fixed here with tests that go red under mutation:
min(8, max(1, CPUs/2))dedicated threads.New issues
Enabledis ignored by the composer and the artifact).Evidence
--no-incremental: 0 warnings, 0 errors.AKKA_TEST_TIMEFACTOR=3:Still open on #780 (I am keeping it open)
Then P2 (Shadow) and P3 (after ScadaBridge#112).
Fourth P1 slice landed on
master@07969285. It is still opt-in, andInProcessremains the default and is unchanged. Nothing was deployed. The record is in design §12.3.1. Batch submit (design §4.5, P0)
IScriptRunner.RunBatch(invocations)returns one task per item, and results stream back as each item finishes.RunAsyncper item, started together.InProcessScriptRunneruses it.EvalBatchframe per worker. Ready means admitted, not quarantined, not a suspect, and already loaded in a live worker.RunAsyncpath on its own. A batch never waits on a load or a restart as a whole.ScriptBatchCoalescerbatches evaluations from tags that become ready together, since there is one actor per tag. It has no timer, so an evaluation on an idle seam is submitted at once.2. Per-item deadlines and blame stay exact under batching
EvalResults.deferred_eval_ids).started_eval_ids, sent on the result frame of the run that freed the thread).script worker restarted), never the culprit.3. Scripted-alarm seam behind
Scripting:Runner:SeamsSeamslist now means VirtualTag + Calculation. Alarms go out of process only whenScriptedAlarmis listed.DriverHostActorthrough a newIScriptRunnerProvider(new last parameterscriptedAlarmRunner).LoadAsyncstill compiles each predicate, to PE bytes only (CompileToImage), so a broken predicate still fails the load. No assembly is loaded into the host._evalGate.BadInternalErrorannotation, as today;BadResourceUnavailableannotation, consistent with VT/Calc, and re-evaluated by itself on recovery;boolreaches the state machine.4. Perf harness (
OTOPCUA_SCRIPT_PERF=1, newBatch_throughput_and_alarm_pass_latency)Batch submit vs the same evaluations as concurrent
RunAsynccalls, out of process, K=2:Alarm pass, measured from push to the last transition:
On macOS every row is ≤ 41 ms. Without batching, a large out-of-process alarm engine would miss its budget on the VM, which is the design's "batch submit is a prerequisite" in numbers. No predicate failed to complete in any pass.
Rerun of the §8.2 harness after the scheduler change:
Evidence
Release build
--no-incremental: 0 warnings, 0 errors.Test results (
AKKA_TEST_TIMEFACTOR=3, macOS):ScriptWorker.Testson windev is 157/157, and the alarm-seam tests and batch perf run were repeated there.The Runtime and integration suites are green, so #791 (VT→VT routing) and #787 (disabled VT → BadOutOfService) still hold.
New tests:
BatchSubmitTests: one frame per batch; parity withRunAsync, including a duplicate key; a runaway in a batch is blamed alone; a deferred run is timed from when it starts.AlarmSeamTests(real worker): runaway → kill → quarantine with the sibling still evaluating; a worker killed under a predicate →BadResourceUnavailable, the alarm is never cleared, and it is re-evaluated on return.ScriptedAlarmEngineScriptRunnerTests: same transitions as in process, one batch per pass, annotations by whose fault it was.CalculationDriverBatchPumpTestsandScriptBatchCoalescerTests.ScriptRunnerSelectionTestsupdated for the explicit alarm seam.15 mutations, every one turned its test red and was reverted:
BadInternalError;false;A test issue the full suite exposed: a worker's
Exitedcompletes before the evaluations it held are released, so a push right after can be refused with "previous evaluation still running". That is not a strike, and in-process behaves the same. The alarm test now re-pushes instead of assuming the first push lands. I filed no product defect for it.Still open on #780 (keeping it open)
ScriptedAlarmlisted. The overlay comment shows how to list it.Then P2 (Shadow), P3 (flip; alarms last, after ScadaBridge#112), and P4.
P2 Shadow landed on
master@b1eedcea(03686eb5code,b1eedcearecord). It is opt-in, andInProcessremains the default and is unchanged. Nothing was deployed and no wonder box was touched. The full record is in design §12.4.1.
Scripting:Runner=ShadowScriptedAlarmis included only when listed. Those seams get aShadowScriptRunnerand keep their in-process path byte for byte: no coalescer, the one-at-a-time Calc pump, the in-process alarm engine. What they publish is what InProcess publishes.Mirror. That copies the inputs, drops the script logger and queues the shadow run on the pool. It never blocks the seam and never throws.compile,budget(timed out / killed / refused / quarantined),threw(including the message),unsupported_type.shadow_unavailable.unsupported_type.Scripting:Shadow:MaxInFlight(256) shadow runs outstanding, and at most one per script. Anything beyond that is dropped and counted (behind|key_busy).otopcua.script.shadow.compared{kind,result},.mismatch{kind,reason,script},.dropped{kind,reason},.inflight.published Ok Int32 1; shadow Ok Int32 2). It is re-armed at the seam's deploy boundary and capped atScripting:Shadow:MismatchLogsPerMinute(20).Status()is null under Shadow, and thescript-runnerentry is always Healthy, with the shadow pool's state and counters as data. The shadow pool's kill and quarantine lines go to the host log taggedScriptRunnerRole=Shadow, never to the script log.docker-dev/docker-compose.script-runner-shadow.yml. Documented indocs/VirtualTags.mdunder "Shadow mode".2. Tests
Mutation-proved: 16 mutants, all red. The one survivor got an assertion and is now red.
ShadowScriptRunnerTests(fakes):ShadowModeTests(real worker):decimalresult are counted against exactly those two scripts.kill -9of the shadow worker.Release build has 0 warnings;
AKKA_TEST_TIMEFACTOR=3.macOS results:
windev: ScriptWorker 160 passed / 4 skipped. The first windev run caught a test-only
DateTimebug, fixed before the push.3. docker-dev drill (central pair, image built from the branch)
ushort), Calc (+1000) and the alarms produced zero mismatches on both nodes.w34-vt-div(divergent) countedvaluemismatches andw34-vt-deccountedunsupported_typemismatches. Nothing else mismatched.kill -9of both shadow workers on the leader left every published value Good, and/health/readystayed 200 / Healthy.ushortand round-trips unchanged.decimalalready publishes BadNotSupported. Out of process it would be BadInternalError "unsupported type": Bad either way, so the only change is the status code.ScriptedAlarmlisted.ipc_roundtrip{kind=alarm}).while(true)predicate was killed at 4000 ms twice and then quarantined. Its condition reads Quality BadInternalError. Its siblings on the same shard stayed Active / Good.kill -9during a 1.5 s predicate froze that condition Active + BadResourceUnavailable, without clearing it. It moved to isolation and re-evaluated to Inactive / Good with no further input change.e221b179…, identical to before;otopcua-host:dev24f9d7287ed2(never re-tagged), noScripting__*, config hash equal to the base file's;Mesh PSK secret ot-mesh-psk-* … absent. central-1 does so with its unchanged store too, so that is pre-existing rig state, not caused by this drill.Filed: lmxopcua#792 — scripts can reach
System.AppContext(SetSwitch/SetData/GetData), p3 review-finding.Still open on #780:
Installer slice landed:
2a8e2981on master (owner decisions §14 #5/#6 plus the PeCacheDir ACL; record in design §12.5).Scripting:Worker:Identity = NT SERVICE\OtOpcUaScriptWorker. Default unset; Windows only; fail-closed validation.DOTNET_EnableDiagnostics=0is set.ERROR_DLL_INIT_FAILED, from USER32's window-station connection). The fix grants the identity on the host's window station and desktop. The logon SID is deliberately not used, because the host's process DACL gives itPROCESS_VM_READ.CurrentUserOnly, it gives no ACE to the owner (Administrators on an elevated host).scripts/install/Install-ScriptWorkerIsolation.ps1(+.psm1) is idempotent and supports-WhatIf,-Uninstall(rollback) and-PlanOnly. It sets up:pe\gets Modify for the identity;script-worker\;scripts/install/tests/Test-ScriptWorkerIsolation.ps1has 46 assertions and is mutation-checked.ScriptWorkerIdentityTestsandWorkerIsolationTestscover the pipe ACL, the token's access, and a real restricted worker running a script.AKKA_TEST_TIMEFACTOR=3): macOS ScriptWorker 165/8 skip, Core.Scripting 296/1, Host 279, OpcUaServer.IntegrationTests 133; windev ScriptWorker 168/5 skip. Nothing persistent was changed on windev.Labelled
blocked. All code slices are on master, including the Windows isolation installer in2a8e2981. What's left is the QA shadow week (deploy gate #794 and #793) and the P3 flip.Implement the out-of-process script runner (design #750) — blocked on owner decisionsto Implement the out-of-process script runner (design #750) — code on master; QA shadow week (#793, #794) + P3 flip remain