fix: run data source initialization outside test timeout scope#4782
Merged
fix: run data source initialization outside test timeout scope#4782
Conversation
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>
4560975 to
913073a
Compare
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>
Contributor
Code reviewNo 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:
The deleted |
1 task
This was referenced Feb 14, 2026
Open
Chore(deps): Bump TUnit.Assertions from 1.12.111 to 1.15.0
code-of-chaos/cs_code-of_chaos-testing#91
Open
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
[ClassDataSource<T>(Shared = SharedType.PerTestSession)]fixture implementsIAsyncInitializer, itsInitializeAsync()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.TestCoordinatorinto two phases: data source initialization runs outside the timeout, and only the test body + hooks run inside the timeout.GlobalContextinstead of being captured in a test's output buffer.Changes
TUnit.Engine/Services/TestExecution/TestCoordinator.csPrepareAndInitializeOutsideTimeoutAsynchelper; slow path splits lifecycle into Phase 1 (outside timeout) and Phase 2 (inside timeout)TUnit.Engine/TestExecutor.csbool skipInitialization = falseparameter toExecuteAsyncto avoid re-initializing data sources already initialized by Phase 1TUnit.Engine/Logging/TestOutputSink.csLog/LogAsyncdetectGlobalContextand write toOriginalConsoleOut/OriginalConsoleErrorinstead of capturing to context output writersHow it works
Before (broken):
After (fixed):
The fast path (no retry, no timeout) is unchanged — it has no timeout to worry about.
Test plan
PerTestSessionfixture withIAsyncInitializerthat 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🤖 Generated with Claude Code