From f149626a76ad51fbd3e37ca4118ed94d2841bf08 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 13 Jul 2026 09:48:46 -0400 Subject: [PATCH] fix(siteruntime): faulted expression-eval task clears _evalInFlight instead of killing the ScriptActor trigger (plan R2-03 T2) --- .../Actors/ScriptActor.cs | 28 +++++++++++- .../Actors/ScriptActorTests.cs | 44 ++++++++++++++++++- 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/ScriptActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/ScriptActor.cs index 68c8460a..65448f1f 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/ScriptActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/ScriptActor.cs @@ -159,6 +159,10 @@ public class ScriptActor : ReceiveActor, IWithTimers // Handle the off-dispatcher trigger-expression evaluation result (P1). Receive(HandleExpressionEvalResult); + + // Handle a faulted off-dispatcher evaluation task (N2) — clears in-flight + // instead of stranding the trigger with an unhandled Status.Failure. + Receive(HandleExpressionEvalFailed); } /// @@ -310,7 +314,8 @@ public class ScriptActor : ReceiveActor, IWithTimers } }, CancellationToken.None, TaskCreationOptions.DenyChildAttach, ScriptExecutionScheduler.Shared(_options)).Unwrap().PipeTo(self, - success: r => new ExpressionEvalResult(r)); + success: r => new ExpressionEvalResult(r), + failure: ex => new ExpressionEvalFailed(ex)); } /// @@ -333,6 +338,18 @@ public class ScriptActor : ReceiveActor, IWithTimers if (_evalPending) { _evalPending = false; StartExpressionEvaluation(); } } + /// + /// Applies a faulted off-dispatcher evaluation task (N2): logs the fault via the + /// existing error path (which also increments the health counter), then reuses the + /// false-result path — clearing in-flight, applying the false edge, and draining any + /// pending evaluation — so a transient scheduler fault cannot permanently park the trigger. + /// + private void HandleExpressionEvalFailed(ExpressionEvalFailed msg) + { + LogExpressionError(msg.Cause); + HandleExpressionEvalResult(new ExpressionEvalResult(false)); + } + /// /// Applies a WhileTrue trigger's condition-state transition: on the /// false→true edge, fire once and start the re-fire timer; on the @@ -621,6 +638,15 @@ public class ScriptActor : ReceiveActor, IWithTimers /// the actor thread. /// private sealed record ExpressionEvalResult(bool Result); + + /// + /// Piped back to self when the off-dispatcher evaluation TASK itself faults + /// (e.g. ObjectDisposedException from a disposed ScriptExecutionScheduler + /// during shutdown) — the inner body's catch never sees that. Without this + /// mapping the actor receives an unhandled Status.Failure and _evalInFlight + /// stays true forever, permanently parking the expression trigger (N2). + /// + internal sealed record ExpressionEvalFailed(Exception Cause); } // ── Trigger config types ── diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/ScriptActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/ScriptActorTests.cs index c5965c1b..1e1a722d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/ScriptActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/ScriptActorTests.cs @@ -343,13 +343,41 @@ public class ScriptActorTests : TestKit, IDisposable private static Script CompileRawTriggerExpression(string expression) { var opts = ScriptOptions.Default - .WithReferences(typeof(object).Assembly, typeof(Enumerable).Assembly) + // The test assembly is referenced so a raw-compiled expression may reference + // test-only hooks like EvalGate (used by the faulted-task N2 test). + .WithReferences(typeof(object).Assembly, typeof(Enumerable).Assembly, typeof(ScriptActorTests).Assembly) .WithImports("System", "System.Collections.Generic", "System.Linq", "System.Threading.Tasks"); var s = CSharpScript.Create(expression, opts, typeof(TriggerExpressionGlobals)); s.Compile(); return s; } + [Fact] + public void ExpressionEvalTaskFault_ClearsInFlight_AndDrainsPendingEvaluation() + { + var expr = CompileRawTriggerExpression( + "ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors.EvalGate.Block()"); + var (actor, _) = CreateTriggeredActor( + "ExprFault", "Expression", "{\"expression\":\"true\",\"mode\":\"OnTrue\"}", null, expr); + try + { + actor.Tell(Change("A", "1")); // eval starts on the scheduler and BLOCKS → _evalInFlight = true + AwaitAssert(() => Assert.Equal(1, EvalGate.Entries), TimeSpan.FromSeconds(10)); + actor.Tell(Change("A", "2")); // coalesces → _evalPending = true + + // Simulate the faulted scheduler task the PipeTo failure mapping now surfaces + // (e.g. ObjectDisposedException from a disposed ScriptExecutionScheduler). + actor.Tell(new ScriptActor.ExpressionEvalFailed(new ObjectDisposedException("scheduler"))); + + // In-flight cleared + pending drained ⇒ a SECOND evaluation starts and blocks too. + AwaitAssert(() => Assert.Equal(2, EvalGate.Entries), TimeSpan.FromSeconds(10)); + } + finally + { + EvalGate.Gate.Release(EvalGate.Entries); // ALWAYS free the shared scheduler threads + } + } + [Fact] public void ExpressionTrigger_EvaluatesOnScriptSchedulerThread_AndStillFires() { @@ -504,3 +532,17 @@ public class ScriptActorTests : TestKit, IDisposable instance.ExpectMsg(TimeSpan.FromSeconds(2)); } } + +/// +/// Test-only hook referenced by a raw-compiled trigger expression: +/// blocks the calling script-scheduler thread on so a test can hold an +/// evaluation "in flight" and count how many evaluations have entered. Used by +/// ExpressionEvalTaskFault_ClearsInFlight_AndDrainsPendingEvaluation. +/// +public static class EvalGate +{ + public static readonly SemaphoreSlim Gate = new(0); + private static int _entries; + public static int Entries => Volatile.Read(ref _entries); + public static bool Block() { Interlocked.Increment(ref _entries); Gate.Wait(); return false; } +}