Skip to content

fix(engine): keep per-test timeout token source alive through teardown (#6339)#6349

Merged
thomhurst merged 1 commit into
mainfrom
fix/6339-timeout-cts-lifetime
Jul 1, 2026
Merged

fix(engine): keep per-test timeout token source alive through teardown (#6339)#6349
thomhurst merged 1 commit into
mainfrom
fix/6339-timeout-cts-lifetime

Conversation

@thomhurst

Copy link
Copy Markdown
Owner

Problem

Issue #6339[After(Test)] hook failed: The CancellationTokenSource has been disposed. surfacing randomly in ASP.NET Core integration suites. The reporter confirmed it still reproduces on 1.57.17, which already contains the earlier narrow fix (#6340).

Root cause

When a test has a timeout (explicit [Timeout] or DefaultTestTimeout), the body runs under a linked CancellationTokenSource exposed on TestContext.Execution.CancellationToken. The earlier fix restored that property to the outer token before After hooks — but TimeoutHelper still disposed the source the instant the body returned. Any token copy captured during the body (EF Core, Respawn, an ASP.NET host all snapshot the ambient token mid-test and touch it during teardown) then pointed at a disposed source.

Verified .NET behaviour: on a disposed-but-not-cancelled CTS, only token.WaitHandle throws ObjectDisposedException — exactly what a synchronous wait inside EF Core / SemaphoreSlim / host shutdown hits during an [After(Test)] hook.

Fix

Repair the source lifetime, not just the token identity: own the linked timeout CTS across the whole per-test lifecycle instead of inside TimeoutHelper's using scope.

  • TimeoutHelper gains a CTS-owning overload — the caller passes an already-linked source (+ the external token for the timeout-vs-cancel check) and owns disposal. The original 4-arg overload delegates to it and still disposes its own source for standalone callers (e.g. the existing unit test).
  • TestExecutor stores the source on a new internal TestContext.TimeoutCancellationSource (sibling of LinkedCancellationTokens) and no longer disposes it locally; a prior retry attempt's source is released before the next replaces it.
  • TestCoordinator disposes it once at the end of the per-test finally — after After(Test) hooks, instance/OnDispose, object cleanup and After(Class/Assembly) — so a mid-body token copy stays backed by a live source throughout teardown.

The inner-finally Context.CancellationToken = <outer> restore stays: it fixes token identity (hooks/retry back-off observe a live, non-cancelled token); this change fixes source liveness. The two are complementary.

The no-timeout fast path is untouched — no new allocation, no extra state machine (one CancellationTokenSource per timed test, same as before, just re-owned).

Tests

  • Strengthened the #6339 regression test: it now asserts both on the context property and on a token copy captured during the body (the realistic failure mode). Verified it fails without the fix (After hook throws ObjectDisposedException) and passes with it.
  • TUnitSettingsTests (exercises TimeoutHelper directly) — green.
  • TimeoutCancellationTokenTests / CancellationTokenTriggeredTests direct-run counts match baseline exactly (timeout still fires correctly).
  • TUnit.Engine builds across all TFMs.

Closes #6339.

#6339)

The prior fix for #6339 restored Context.CancellationToken to the outer token
before After(Test) hooks, but only repaired the context *property*. A token
copy snapshotted during the test body (as EF Core, Respawn or an ASP.NET host
routinely do) still pointed at the timeout-scoped CancellationTokenSource, which
TimeoutHelper disposed the instant the body returned. A synchronous .WaitHandle
wait in an After(Test) hook / cleanup then threw ObjectDisposedException
"The CancellationTokenSource has been disposed." — surfacing randomly in ASP.NET
Core integration suites even on 1.57.17.

Fix the source *lifetime* rather than just the token identity: own the linked
timeout CTS across the whole per-test lifecycle instead of inside TimeoutHelper's
using-scope.

- TimeoutHelper gains a CTS-owning overload: the caller passes an already-linked
  source (+ the external token for the timeout-vs-cancel check) and is responsible
  for disposal; the old 4-arg overload now delegates to it and keeps disposing its
  own source for standalone callers.
- TestExecutor stores the source on the new TestContext.TimeoutCancellationSource
  (sibling of LinkedCancellationTokens) and no longer disposes it locally; a prior
  retry attempt's source is released before the next replaces it.
- TestCoordinator disposes it once at the end of the per-test finally — after
  After(Test) hooks, instance/OnDispose, object cleanup and After(Class/Assembly) —
  so any token copy captured mid-body stays backed by a live source throughout
  teardown. The inner-finally property restore stays (it fixes token identity; this
  fixes source liveness — the two are complementary).

The no-timeout fast path is untouched (no new allocation). Strengthens the #6339
regression test to also assert on a body-captured token copy, not just the context
property — the realistic failure mode.
@codacy-production

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 0 complexity

Metric Results
Complexity 0

View in Codacy

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.

@claude

claude Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

Summary: This PR fixes #6339 by changing the lifetime of the per-test timeout CancellationTokenSource, not just the token identity. Previously TimeoutHelper disposed its linked CTS the instant the test body returned, but app code (EF Core, Respawn, ASP.NET hosts, etc.) can capture the ambient token mid-test and only touch it later during teardown — hitting a disposed source. Now TestExecutor creates the linked CTS, stores it on TestContext.TimeoutCancellationSource, and TestCoordinator disposes it exactly once in the per-test finally, after all teardown ([After(Test)], instance/OnDispose, cleanup, [After(Class/Assembly)]) completes.

Verified:

  • No double-dispose: on retry, the prior attempt's source is disposed before being replaced; the final source is disposed exactly once in TestCoordinator.
  • No leak: non-timeout tests never allocate the source (null-conditional dispose); the dispose call sits in the once-per-test finally, so it runs regardless of pass/fail/skip/timeout outcome.
  • TimeoutHelper's new overload correctly leaves the caller-owned CTS undisposed, while the original overload still owns and disposes its own linked source — signatures and call sites match.
  • No public API or source-generator output changed (new property/overload are internal), so no snapshot updates were needed.
  • No .Result/.GetAwaiter().GetResult() blocking calls introduced.
  • Regression test was appropriately strengthened to cover the realistic failure mode (a token copy captured mid-test-body and used in an [After(Test)] hook).

🤖 Generated with Claude Code

@thomhurst thomhurst merged commit c17cca9 into main Jul 1, 2026
13 checks passed
@thomhurst thomhurst deleted the fix/6339-timeout-cts-lifetime branch July 1, 2026 18:00
This was referenced Jul 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: [Test Failure] After(Test) hook failed: The CancellationTokenSource has been disposed.

1 participant