Skip to content

fix: run data source initialization outside test timeout scope#4782

Merged
thomhurst merged 8 commits intomainfrom
fix/datasource-init-outside-timeout
Feb 14, 2026
Merged

fix: run data source initialization outside test timeout scope#4782
thomhurst merged 8 commits intomainfrom
fix/datasource-init-outside-timeout

Conversation

@thomhurst
Copy link
Owner

Summary

  • Fixes bug: SharedType.PerTestSession fixture initialization is cancelled by individual test timeouts #4772 — When a [ClassDataSource<T>(Shared = SharedType.PerTestSession)] fixture implements IAsyncInitializer, its InitializeAsync() was running inside the timeout wrapper of whichever test triggered it first. If that test had a short [Timeout], the fixture initialization would get cancelled — causing ALL tests sharing the fixture to fail.
  • Split the slow path (retry + timeout) in TestCoordinator into two phases: data source initialization runs outside the timeout, and only the test body + hooks run inside the timeout.
  • Console output during data source initialization now routes to the real console via GlobalContext instead of being captured in a test's output buffer.

Changes

File Change
TUnit.Engine/Services/TestExecution/TestCoordinator.cs Added PrepareAndInitializeOutsideTimeoutAsync helper; slow path splits lifecycle into Phase 1 (outside timeout) and Phase 2 (inside timeout)
TUnit.Engine/TestExecutor.cs Added bool skipInitialization = false parameter to ExecuteAsync to avoid re-initializing data sources already initialized by Phase 1
TUnit.Engine/Logging/TestOutputSink.cs Log/LogAsync detect GlobalContext and write to OriginalConsoleOut/OriginalConsoleError instead of capturing to context output writers

How it works

Before (broken):

RetryHelper.ExecuteWithRetry:
  TimeoutHelper.ExecuteWithTimeoutAsync:         ← timeout wraps everything
    ExecuteTestLifecycleAsync
      └─ CreateInstance → Prepare → ExecuteAsync
           └─ InitializeTestObjectsAsync          ← slow init eats timeout budget
           └─ test body

After (fixed):

RetryHelper.ExecuteWithRetry:
  Phase 1 (OUTSIDE timeout):
    - CreateInstance, check skip, Prepare
    - InitializeTestObjectsAsync                  ← runs with original CancellationToken
  Phase 2 (INSIDE timeout):
    - ExecuteAsync(skipInitialization: true)       ← only test body + hooks
  Finally:
    - OnDispose + DisposeTestInstance

The fast path (no retry, no timeout) is unchanged — it has no timeout to worry about.

Test plan

  • Verify existing tests still pass
  • Test with a PerTestSession fixture with IAsyncInitializer that takes 10+ seconds, paired with a test that has [Timeout(5_000)] — the fixture should initialize fully and the test should only start its 5s timeout after initialization completes
  • Verify console output during initialization goes to real console, not test output buffer

🤖 Generated with Claude Code

thomhurst and others added 6 commits February 14, 2026 13:53
When a [ClassDataSource<T>(Shared = SharedType.PerTestSession)] fixture
implements IAsyncInitializer, its InitializeAsync() was running inside
the timeout wrapper of whichever test triggered it first. If that test
had a short [Timeout], the fixture initialization would get cancelled,
causing ALL tests sharing the fixture to fail.

Split the slow path (retry + timeout) in TestCoordinator so that:
- Phase 1 (outside timeout): instance creation, skip check, prepare,
  and data source initialization run with the original cancellation
  token, not the timeout-linked one
- Phase 2 (inside timeout): only the test body and hooks run under
  the test's timeout

Also route console output during initialization to the real console
(via GlobalContext) instead of capturing it in a test's output buffer.

Fixes #4772

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
AsyncDependencyInjectionDataSourceSourceAttribute.cs was accidentally
created as an exact duplicate of DependencyInjectionDataSourceSourceAttribute.cs
in commit 1d6ae1f, causing CS0101 duplicate definition build errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…tputSink

GlobalContext.OriginalConsoleOut captures Console.Out which is already
the OptimizedConsoleInterceptor after initialization. Writing to it from
TestOutputSink.Log creates infinite recursion:
  TestOutputSink.Log → OriginalConsoleOut.WriteLine → Interceptor →
  RouteToSinks → TestOutputSink.Log → ...

Use StandardOutConsoleInterceptor.DefaultOut/DefaultError instead, which
are backed by Console.OpenStandardOutput/Error and always bypass the
interceptor.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The previous GlobalContext check was insufficient because Context.Current
resolves through a chain of async-local contexts (TestSessionContext,
AssemblyHookContext, etc.) before reaching GlobalContext. When
TestContext.Current is null, Context.Current typically resolves to
TestSessionContext rather than GlobalContext, causing the check to fail
and output to silently disappear into an unread StringBuilder.

Changed to check `context is not TestContext` instead — TestOutputSink's
purpose is capturing output for test results, which only applies to
TestContext. All other contexts (GlobalContext, TestSessionContext,
AssemblyHookContext, etc.) route to the real console via the
pre-interception writers (Console.OpenStandardOutput/Error).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Split console output responsibility between sinks:
- TestOutputSink: only captures TestContext output for test results,
  no-ops for all other contexts
- ConsoleOutputSink: always registered (not just --output Detailed).
  In detailed mode writes all output; in non-detailed mode only writes
  non-TestContext output (hooks, data source initialization, etc.)

This ensures infrastructure output (data source initialization, hooks)
is always visible on the real console regardless of output mode, while
test output only appears on console with --output Detailed. No
duplication occurs because each sink has a clear, non-overlapping
responsibility for non-test contexts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The previous approach extracted initialization into a separate phase
(PrepareAndInitializeOutsideTimeoutAsync) which ran initialization
BEFORE hooks, breaking the execution order. Tests depending on
BeforeClass hooks running before initialization failed.

New approach: pass the test timeout into TestExecutor.ExecuteAsync,
which applies TimeoutHelper only around ExecuteTestAsync (the test
body). Hooks and data source initialization (InitializeTestObjectsAsync)
run outside the timeout with the original cancellation token.

Also reverts the TestOutputSink/ConsoleOutputSink changes that were
causing output capture test failures — during the build phase,
Context.Current is a TestBuildContext (not TestContext), so the
"context is not TestContext" check prevented output from being
captured in test results.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The per-test categorization messages (e.g. "→ Parallel (no constraints)")
are emitted for every test and clutter debug output. Move them to trace
level. The aggregate summary block stays at debug.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@claude
Copy link
Contributor

claude bot commented Feb 14, 2026

Code review

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

The fix properly addresses #4772 by restructuring timeout enforcement. Previously, the timeout wrapped the entire test lifecycle (initialization + hooks + test body), which caused shared fixtures with slow initialization to be cancelled when triggered by tests with short timeouts. Now the timeout only wraps the test body execution, allowing data source initialization and hooks to run outside the timeout scope.

Key improvements:

  • Correct scoping: Timeout now applies only to the test body via TestExecutor.ExecuteAsync, not the entire lifecycle
  • Preserves retry semantics: The retry wrapper still functions correctly, with each retry attempt getting the proper timeout
  • Clean implementation: Uses existing TimeoutHelper patterns with no new allocations in hot paths
  • Maintains fast path: Tests without retry/timeout continue through the optimized fast path unchanged

The deleted AsyncDependencyInjectionDataSourceSourceAttribute.cs was confirmed as a duplicate file with no external references.

@thomhurst thomhurst merged commit 3b84090 into main Feb 14, 2026
12 of 14 checks passed
@thomhurst thomhurst deleted the fix/datasource-init-outside-timeout branch February 14, 2026 14:35
This was referenced Feb 14, 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: SharedType.PerTestSession fixture initialization is cancelled by individual test timeouts

1 participant