diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/TimedScriptEvaluator.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/TimedScriptEvaluator.cs index 237435f..fc5141c 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/TimedScriptEvaluator.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/TimedScriptEvaluator.cs @@ -76,6 +76,14 @@ public sealed class TimedScriptEvaluator // WaitAsync's synthesized timeout — the inner task may still be running // on its thread-pool thread (known leak documented in the class summary). // Wrap so callers can distinguish from user-written timeout logic. + // + // The class docs guarantee "caller-supplied cancel wins over timeout". + // When both fire at nearly the same time, WaitAsync observes them in + // non-deterministic order, so a cancel that arrives a few µs after the + // timeout still reaches here as TimeoutException. Re-check the token so + // the guarantee holds regardless of race ordering. (Core.Scripting-007.) + if (ct.IsCancellationRequested) + throw new OperationCanceledException(ct); throw new ScriptTimeoutException(Timeout); } } diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/TimedScriptEvaluatorTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/TimedScriptEvaluatorTests.cs index 91a2ee6..685c9ba 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/TimedScriptEvaluatorTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/TimedScriptEvaluatorTests.cs @@ -132,4 +132,27 @@ public sealed class TimedScriptEvaluatorTests ex.Message.ShouldContain("ctx.Logger"); ex.Message.ShouldContain("widening the timeout"); } + + [Fact] + public async Task Caller_cancellation_wins_even_when_timeout_fires_first() + { + // Regression for Core.Scripting-007: when the caller's CancellationToken is + // already signalled at the moment WaitAsync returns TimeoutException (a tight + // race), the implementation must re-check ct.IsCancellationRequested and throw + // OperationCanceledException rather than ScriptTimeoutException. Here we use a + // token that is cancelled before the evaluator starts — the tightest possible + // race — to verify the re-check path deterministically. + var inner = ScriptEvaluator.Compile("""return 1;"""); + var timed = new TimedScriptEvaluator( + inner, TimeSpan.FromMilliseconds(1)); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); // already cancelled before RunAsync is even called + + // WaitAsync sees an already-cancelled token and may surface as either + // TimeoutException or OperationCanceledException depending on ordering. + // Either way, the caller must see OperationCanceledException, not ScriptTimeoutException. + await Should.ThrowAsync(async () => + await timed.RunAsync(new FakeScriptContext(), cts.Token)); + } }