fix(siteruntime): faulted expression-eval task clears _evalInFlight instead of killing the ScriptActor trigger (plan R2-03 T2)
This commit is contained in:
@@ -159,6 +159,10 @@ public class ScriptActor : ReceiveActor, IWithTimers
|
|||||||
|
|
||||||
// Handle the off-dispatcher trigger-expression evaluation result (P1).
|
// Handle the off-dispatcher trigger-expression evaluation result (P1).
|
||||||
Receive<ExpressionEvalResult>(HandleExpressionEvalResult);
|
Receive<ExpressionEvalResult>(HandleExpressionEvalResult);
|
||||||
|
|
||||||
|
// Handle a faulted off-dispatcher evaluation task (N2) — clears in-flight
|
||||||
|
// instead of stranding the trigger with an unhandled Status.Failure.
|
||||||
|
Receive<ExpressionEvalFailed>(HandleExpressionEvalFailed);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -310,7 +314,8 @@ public class ScriptActor : ReceiveActor, IWithTimers
|
|||||||
}
|
}
|
||||||
}, CancellationToken.None, TaskCreationOptions.DenyChildAttach,
|
}, CancellationToken.None, TaskCreationOptions.DenyChildAttach,
|
||||||
ScriptExecutionScheduler.Shared(_options)).Unwrap().PipeTo(self,
|
ScriptExecutionScheduler.Shared(_options)).Unwrap().PipeTo(self,
|
||||||
success: r => new ExpressionEvalResult(r));
|
success: r => new ExpressionEvalResult(r),
|
||||||
|
failure: ex => new ExpressionEvalFailed(ex));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -333,6 +338,18 @@ public class ScriptActor : ReceiveActor, IWithTimers
|
|||||||
if (_evalPending) { _evalPending = false; StartExpressionEvaluation(); }
|
if (_evalPending) { _evalPending = false; StartExpressionEvaluation(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
private void HandleExpressionEvalFailed(ExpressionEvalFailed msg)
|
||||||
|
{
|
||||||
|
LogExpressionError(msg.Cause);
|
||||||
|
HandleExpressionEvalResult(new ExpressionEvalResult(false));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Applies a WhileTrue trigger's condition-state transition: on the
|
/// Applies a WhileTrue trigger's condition-state transition: on the
|
||||||
/// false→true edge, fire once and start the re-fire timer; 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.
|
/// the actor thread.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private sealed record ExpressionEvalResult(bool Result);
|
private sealed record ExpressionEvalResult(bool Result);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record ExpressionEvalFailed(Exception Cause);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Trigger config types ──
|
// ── Trigger config types ──
|
||||||
|
|||||||
@@ -343,13 +343,41 @@ public class ScriptActorTests : TestKit, IDisposable
|
|||||||
private static Script<object?> CompileRawTriggerExpression(string expression)
|
private static Script<object?> CompileRawTriggerExpression(string expression)
|
||||||
{
|
{
|
||||||
var opts = ScriptOptions.Default
|
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");
|
.WithImports("System", "System.Collections.Generic", "System.Linq", "System.Threading.Tasks");
|
||||||
var s = CSharpScript.Create<object?>(expression, opts, typeof(TriggerExpressionGlobals));
|
var s = CSharpScript.Create<object?>(expression, opts, typeof(TriggerExpressionGlobals));
|
||||||
s.Compile();
|
s.Compile();
|
||||||
return s;
|
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]
|
[Fact]
|
||||||
public void ExpressionTrigger_EvaluatesOnScriptSchedulerThread_AndStillFires()
|
public void ExpressionTrigger_EvaluatesOnScriptSchedulerThread_AndStillFires()
|
||||||
{
|
{
|
||||||
@@ -504,3 +532,17 @@ public class ScriptActorTests : TestKit, IDisposable
|
|||||||
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2));
|
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Test-only hook referenced by a raw-compiled trigger expression: <see cref="Block"/>
|
||||||
|
/// blocks the calling script-scheduler thread on <see cref="Gate"/> so a test can hold an
|
||||||
|
/// evaluation "in flight" and count how many evaluations have entered. Used by
|
||||||
|
/// <c>ExpressionEvalTaskFault_ClearsInFlight_AndDrainsPendingEvaluation</c>.
|
||||||
|
/// </summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user