fix(engine): reset test cancellation token before After hooks on timeout path (#6339)#6340
Conversation
…out path (#6339) When a test is subject to a timeout (explicit [Timeout] or a configured DefaultTestTimeout), TimeoutHelper runs the body with a linked CancellationTokenSource and TestExecutor stores that token on TestContext.Execution.CancellationToken. The linked CTS is disposed the moment the body returns, but the context was left pointing at it. Any After(Test)/ AfterEvery(Test) hook (or retry back-off) that touched the token in a source-accessing way -- e.g. WaitHandle via a synchronous wait in EF Core / Respawn / SemaphoreSlim cleanup -- then threw ObjectDisposedException "The CancellationTokenSource has been disposed.", surfacing randomly in ASP.NET Core integration suites. Restore the still-valid outer token at the top of the ExecuteAsync finally so the test-end phase and retry back-off observe a live token. Gated on testTimeout.HasValue to leave the no-timeout fast path (and any user-added linked token) untouched. Adds a regression test: a passing [Timeout] test whose After(Test) hook reads the context token; fails with the disposed-CTS error before the fix, passes after.
Up to standards ✅🟢 Issues
|
| Metric | Results |
|---|---|
| Complexity | 0 |
NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.
There was a problem hiding this comment.
Reviewed the fix, root-cause analysis, and regression test. This is a solid, well-scoped fix.
Verification performed (not just reading the diff):
- Traced the token flow:
TimeoutHelper.ExecuteWithTimeoutAsynccreates a linkedtimeoutCtsfrom the outercancellationTokenand disposes it viausingonce the body returns;TestExecutor.ExecuteTestAsyncstores that linked token onContext.CancellationToken. Thefinallyfix restoresContext.CancellationToken = cancellationToken(the still-valid outer token) before the test-end event receivers and After hooks run — exactly closing the gap described. - Confirmed
RetryHelper.ApplyBackoffDelay(Task.Delay(delayMs, testContext.CancellationToken)) reads the same context token, so the fix also covers the retry back-off crash mentioned in the PR description, not just the After-hook path. - Confirmed each retry attempt re-invokes
TestExecutor.ExecuteAsyncfresh (RetryHelper.ExecuteWithRetry→TestCoordinator.ExecuteTestLifecycleAsync→ExecuteAsync), so the per-attemptfinallyreset is correctly scoped — no cross-attempt state leakage. - Checked interaction with
TestContext.AddLinkedCancellationToken: the composite token it produces is never fed intoTimeoutHelperin the first place (that call always uses the plain outercancellationTokenparameter, notContext.CancellationToken), so this fix doesn't regress that feature for timeout+linked-token combinations — that gap (if it matters) predates this PR. - Built and ran the new regression test (
TimeoutAfterHookTokenTests) against the branch on net8.0/net9.0/net10.0 — passes with the fix. Confirmed viagit diffagainstmainthat onlyTestExecutor.csand the new test file changed — no source-gen/reflection divergence risk, no public API or snapshot changes, consistent with the PR description. - No unresolved reviewer feedback on the PR to reconcile (only an automated Codacy comment reporting 0 issues).
Nothing to change. The testTimeout.HasValue gate is the right scope (untouched fast path for non-timeout tests), the placement at the top of the finally (before event receivers/After hooks/retry backoff) is correct, and the regression test reproduces the exact failure mode from the issue.
Move the Context.CancellationToken restore out of the general teardown finally and into the inner finally that already wraps the timeout/body call that mutated it — same coverage (still runs before test-end receivers, After hooks, and retry back-off) but no longer couples the shared teardown block to TimeoutHelper's disposal detail. Test cleanup: simplify the body to an expression-bodied Task and fix the class doc to cite .WaitHandle (the member that actually reproduces the disposed-source throw) instead of CreateLinkedTokenSource (a no-op on a disposed-not-cancelled CTS).
Code reviewReviewed the new commit since my previous approval ( What changed: the Verification:
On the design change itself: this is a genuine improvement, not just churn. The original version put the mutation ( No issues found. Approving. |
Problem
Fixes #6339. Users running ASP.NET Core integration tests hit random failures:
Root cause
When a test is subject to a timeout (an explicit
[Timeout]or a configuredDefaultTestTimeout),TimeoutHelper.ExecuteWithTimeoutAsyncruns the body under a linkedCancellationTokenSourceandTestExecutor.ExecuteTestAsyncstores that token onTestContext.Execution.CancellationToken(TestExecutor.cs:428). The linked CTS is disposed viausingthe instant the body returns — but the context was left pointing at it.The
finallyinExecuteAsyncthen runs the test-end event receivers andAfter(Test)/AfterEvery(Test)hooks. Those hooks are invoked withCancellationToken.None, butany code that reads
context.Execution.CancellationTokenand touches the underlying source —.WaitHandle(synchronous waits in EF Core / Respawn /SemaphoreSlim.Wait),.Token,.Cancel,etc. — throws
ObjectDisposedException: The CancellationTokenSource has been disposed.The samedisposed token also feeds
RetryHelper.ApplyBackoffDelay(RetryHelper.cs:118).It looks "random" because only timed tests whose After-hooks/retries actually touch the token in a
source-accessing way fail, and the no-timeout fast path is unaffected (it stores the still-valid
outer session token).
Fix
Restore the still-valid outer token at the top of the
ExecuteAsyncfinally, before the test-endphase runs and before the exception unwinds into any retry back-off:
testTimeout.HasValueso the no-timeout fast path (and any user-added linked token viaTestContext.AddLinkedCancellationToken) is untouched.retry back-off manifestation.
Test
TUnit.TestProject/Bugs/_6339/TimeoutAfterHookTokenTests.cs— a passing[Timeout]test whose[After(Test)]hook reads the context token'sWaitHandle. Verified it fails with"The CancellationTokenSource has been disposed." before the fix and passes after.
Note for the reporter
The companion
BeforeTest hook failed: ... transient failure ... EnableRetryOnFailuremessage is aplain EF Core SqlServer transient fault in your own seeding hook, not a TUnit defect — add
options.UseSqlServer(cs, o => o.EnableRetryOnFailure())to handle it.