test-flake: ScriptActorTests parks process-wide ScriptExecutionScheduler workers; leaks one permanently and starves other tests #16
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?
Summary
ScriptActorTests.ExpressionEvalTaskFault_ClearsInFlight_AndDrainsPendingEvaluationfails intermittently — 3 of 6 full-suite runs on a cleanmain, and it takes other tests down with it. The test parks worker threads of the process-wideScriptExecutionSchedulersingleton on an unbounded semaphore wait, and in exactly the case where it fails it leaks a worker thread permanently for the rest of the test run. Every later test that executes a script or alarm then competes for a smaller pool; one run failed 22 tests.This is a test-harness defect, not a product defect. Found while verifying an unrelated change (Gitea #14); confirmed pre-existing by stashing that work.
Symptom
The second evaluation never starts inside the 10 s
AwaitAssertwindow.Collateral failures in the same runs — script executions that never complete:
Repro and rate
Full assembly, clean
main:xUnit.ParallelizeTestCollections=false--filter)The filtered run passing 6/6 is why this hides: running alone does not reproduce it. (Same trap as #15.)
Root cause
ScriptExecutionScheduleris a process-wide static singleton with a fixed pool of dedicated threads —Shared()atsrc/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptExecutionScheduler.cs:41-53, "first caller wins",ScriptExecutionThreadCountdefault 8 (SiteRuntimeOptions.cs:46). Every script and alarm execution in the assembly runs on it:ScriptExecutionActor.cs:115,AlarmExecutionActor.cs:92,AlarmActor.cs:557. Nothing ever disposes or resets the singleton, so its pool is shared by the entire test run.The test's trigger expression calls
EvalGate.Block()(ScriptActorTests.cs:542-548):Gate.Wait()on a 0-permit semaphore blocks the dedicated scheduler worker thread running the evaluation — by design, to hold_evalInFlightopen. Two problems follow:1. The pool is shared, so this starves other tests. While a worker is parked, every other concurrently-running test class executing a script competes for the remaining workers. That is what the
RouteInboundApiCall_*timeouts are: a routed inbound-API script that never gets a worker within 10 s.2. The failure case leaks a worker permanently. Teardown is:
It releases exactly
Entriespermits. In the failing runEntries == 1, so it releases one permit and the test exits — but the coalesced second evaluation is still queued on the scheduler. When a worker eventually dequeues it,Block()increments_entriesto 2 and callsGate.Wait()with no permits left and nothing alive to release them. That worker is parked for the remainder of the process, permanently shrinking the pool from 8. Repeat across a run and the pool degrades until many script-executing tests time out — the 22-failure run._entriesis also static and never reset, so the counter carries across whatever ran before.This is the same structural family as #15: a process-wide shared resource plus xUnit cross-class parallelism, with nothing serializing access.
Why "just disable parallelization" is not the fix
xUnit.ParallelizeTestCollections=falsestill fails (1/3, and 1/2 in a second sample) — the leaked worker outlives the test that leaked it, so sequential ordering does not help, and runtime triples (1 m 50 s → 5 m). Do not take that route.Fix options
Gate.Wait(TimeSpan.FromSeconds(30))— so a stranded evaluation can never park a worker for the rest of the run, and release generously in teardown rather thanRelease(Entries)(which under-releases in exactly the failure case). Reset_entriesat test start. This keeps the test's intent (hold an evaluation in flight) while making its worst case self-healing.ScriptExecutionSchedulerinstance rather than the process-wide singleton. Needs an injection seam —ScriptExecutionActor/AlarmActorcurrently callScriptExecutionScheduler.Shared(options)directly. Bigger change, but it removes the shared-pool coupling for all tests, not just this one.HostBootCollectiondoes for Host.Tests). Reduces contention but does not fix the permanent leak — option 1 or 2 is still required.Option 1 is the smallest fix that addresses the actual failure. Option 2 is the structural one.
Adjacent risk (not the cause here)
ScriptExecutionScheduler.SharedisIDisposable, cached in a static that is never cleared. Nothing currently disposes it, but if anything ever did,_sharedwould keep handing out a disposed instance for the rest of the process and every subsequent script execution would fail. Worth a guard if the singleton stays.Notes