fix(scripting): resolve Medium code-review finding (Core.Scripting-007)

In TimedScriptEvaluator.RunAsync, the catch (TimeoutException) block
now checks ct.IsCancellationRequested before throwing
ScriptTimeoutException, so a caller cancellation that races a timeout
deterministically surfaces as OperationCanceledException regardless of
which WaitAsync observes first. Regression test
Caller_cancellation_wins_even_when_timeout_fires_first added.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-22 09:23:20 -04:00
parent 0cc3b23101
commit a6de04a297
2 changed files with 31 additions and 0 deletions

View File

@@ -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<FakeScriptContext, int>.Compile("""return 1;""");
var timed = new TimedScriptEvaluator<FakeScriptContext, int>(
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<OperationCanceledException>(async () =>
await timed.RunAsync(new FakeScriptContext(), cts.Token));
}
}