Repository navigation
fix: route shared data source build-time output to TestSessionContext - #5072
Conversation
For SharedType.PerTestSession data sources, the constructor runs exactly once during test building (whichever test's build context wins the Lazy race). This makes the output non-deterministically land in only one test's _buildTimeOutput, causing a race-dependent test failure on CI. Now also write build-time output to TestSessionContext so it is always accessible regardless of build ordering. Update the corresponding test to check TestSessionContext.Current instead of TestContext.Current for shared data source output.
There was a problem hiding this comment.
Code Review
Good diagnosis and targeted fix for a real CI flakiness issue on macOS. The root cause analysis is accurate: for SharedType.PerTestSession sources, Lazy<T> (ExecutionAndPublication) runs the factory exactly once, so the captured build-time output only ends up in whichever test's buildContext won the parallel-build race. Routing to TestSessionContext is the right architectural choice here.
Strengths
- Minimal and well-targeted (18 changed lines, 2 files)
- Null-safe
TestSessionContext.Current?prevents NPE if no session context exists - Existing behaviour is preserved — the winning test's batch still gets output via
SetBuildTimeOutput - Excellent inline comments explaining the why (the Lazy race, ExecutionAndPublication, etc.)
- Test updated to match the new (correct) semantics
Concerns
1. Session routing applies to ALL data sources, not just PerTestSession
The new code runs unconditionally inside the if (\!string.IsNullOrEmpty(capturedOutput) || ...) block, so it fires for every data source that emits build-time output — PerClass, PerAssembly, unshared, etc. Only PerTestSession sources actually have the race condition described in the PR, because they use a single Lazy instance shared across all tests.
For non-session scoped sources, the winning batch already propagates output to its tests via SetBuildTimeOutput (they're in the same batch); routing to session is noise. A user checking TestSessionContext.Current.GetStandardOutput() will start seeing constructor output from logically-scoped data sources mixed in with session-level output. Consider guarding this on scope, or at minimum documenting the behaviour change:
That said, if the intent is "session output is a superset of all build-time output", this is fine — just make it explicit.
2. Output duplication for the "winning" test
When Test_SharedDataSource_FirstTest happens to win the build race, its output now appears in both TestContext.Current.GetStandardOutput() (via SetBuildTimeOutput) AND TestSessionContext.Current.GetStandardOutput(). The PR comment acknowledges you can no longer rely on TestContext.Current for this, but the reverse is also true: a test reading both contexts would see the output twice. Worth documenting or at least noting in the test class summary.
3. Test_SharedDataSource_SecondTest has no assertion on the fix
SecondTest intentionally skips output assertions and just verifies data.Value. Now that session routing guarantees the output is always at TestSessionContext, it would be more valuable to also assert from SecondTest that the session output contains the constructor string. This would give a regression test proving that both tests (regardless of which won the race) can access the shared output — which is the whole point of the fix.
Summary
The fix is correct and solves the reported flakiness. The concerns above are mostly about scope precision and test coverage rather than correctness. Concern #3 (adding an assertion to SecondTest) is the most actionable — it would give the fix a proper regression test. Concerns #1 and #2 are worth a conscious decision but not blockers.
There was a problem hiding this comment.
Code Review
Good diagnosis and targeted fix for a real CI flakiness issue on macOS. The root cause analysis is accurate: for SharedType.PerTestSession sources, Lazy<T> (ExecutionAndPublication) runs the factory exactly once, so the captured build-time output only ends up in whichever test's buildContext won the parallel-build race. Routing to TestSessionContext is the right architectural choice here.
Strengths
- Minimal and well-targeted (18 changed lines, 2 files)
- Null-safe
TestSessionContext.Current?.prevents NPE if no session context exists - Existing behaviour is preserved — the winning test batch still gets output via
SetBuildTimeOutput - Excellent inline comments explaining the why (the Lazy race, ExecutionAndPublication, etc.)
- Test updated to match the new correct semantics
Concerns
1. Session routing applies to ALL data sources, not just PerTestSession
The new code runs unconditionally inside the if (!string.IsNullOrEmpty(capturedOutput) || ...) block, so it fires for every data source that emits build-time output — PerClass, PerAssembly, unshared, etc. Only PerTestSession sources actually have the race condition described in the PR, because they use a single Lazy instance shared across all tests.
For non-session-scoped sources, the winning batch already propagates output to its tests via SetBuildTimeOutput (they are in the same batch), so routing to session is noise. A user checking TestSessionContext.Current.GetStandardOutput() will start seeing constructor output from logically-scoped data sources mixed in with session-level output. Consider guarding this on scope, or at minimum documenting the behaviour change.
2. Output duplication for the "winning" test
When Test_SharedDataSource_FirstTest happens to win the build race, its output now appears in both TestContext.Current.GetStandardOutput() (via SetBuildTimeOutput) AND TestSessionContext.Current.GetStandardOutput(). The PR correctly documents you can no longer rely on TestContext.Current for this, but a test reading both contexts would see the output twice. Worth noting in the test class summary.
3. Test_SharedDataSource_SecondTest has no assertion on the fix
SecondTest intentionally skips output assertions and just verifies data.Value. Now that session routing guarantees the output is always at TestSessionContext, it would be more valuable to also assert from SecondTest that the session output contains the constructor string. This gives a proper regression test proving that both tests, regardless of which won the build race, can access the shared output — which is the whole point of the fix.
Summary
The fix is correct and addresses the reported macOS CI flakiness. Concerns #1 and #2 are worth a conscious design decision but not blockers. Concern #3 (adding an assertion to SecondTest) is the most actionable improvement — it would give the fix a regression test that validates both sides of the race condition are covered.
- Add assertion in Test_SharedDataSource_SecondTest to verify it can also access the shared data source output via TestSessionContext, covering the other side of the build-order race condition. - Expand TestBuilder comment to make explicit that ALL build-time output is routed to session (not just PerTestSession), and note that the winning test's batch sees the output in both TestContext and TestSessionContext.
There was a problem hiding this comment.
Follow-up Review
Checked this against the previous two review comments (both submitted simultaneously on the first commit).
Previous concerns — status
Concern #1 — Unconditional session routing for all data source types
Resolved by documentation. The comment now explicitly states "This intentionally covers all sharing types (None, PerClass, PerAssembly, etc.), making TestSessionContext a superset of every data source's build-time output." This makes the design decision visible and deliberate rather than accidental. Acceptable resolution.
Concern #2 — Output duplication for the "winning" test
Resolved by documentation. The added note — "the winning test batch also receives the output via SetBuildTimeOutput below, so its output appears in both TestContext and TestSessionContext" — makes the double-write explicit to future maintainers. Acceptable.
Concern #3 — SecondTest had no regression assertion
Fully addressed. SecondTest now asserts sessionOutput.Contains("SharedDataSource constructor:"), which is the key regression test validating that both sides of the build-order race are covered — regardless of which test's Lazy<T> factory wins.
New observation
The second commit expanded the TestBuilder.cs comment from 4 lines to 9 lines. The expanded comment is genuinely useful and adds important context, but one phrasing is slightly imprecise:
"making TestSessionContext a superset of every data source's build-time output"
TestSessionContext output is more accurately a union/accumulation (every data source that emits output writes into it) rather than a true set-superset relationship. Minor nit — not a blocker.
Summary
All three previous concerns have been addressed in the second commit — two by clear documentation, one by adding a proper regression assertion to SecondTest. The fix is correct, the scope is minimal, and the test coverage now validates both sides of the race condition. Ready to merge.
…runs Assembly and session hooks fire for every subprocess that runs the test project, not only the subprocess that specifically targets those test classes. When the engine tests run concurrently (e.g. ExpectedStateTests and CancellationAfterHooksTests), multiple subprocesses write to the same hardcoded temp file simultaneously, causing IOException → AfterAssemblyException that propagates to unrelated tests. Fix: guard each hook with a TestClasses check so it only writes its marker file when the specific class actually participated in this run.
There was a problem hiding this comment.
Follow-up Review (3rd commit: 2e8a8aa2a1a7)
Reviewing against the previous three comments and the new changes in the third commit.
Previous concerns — all resolved
All three concerns from prior reviews have been addressed:
- Concern #1 (unconditional session routing): Documented as intentional in the expanded comment.
- Concern #2 (output duplication for the winning test): Documented explicitly in the comment.
- Concern #3 (SecondTest had no regression assertion): Fully fixed —
SecondTestnow assertssessionOutput.Contains("SharedDataSource constructor:").
Third commit: guard _3882 assembly/session hooks against unrelated subprocess runs
Root cause diagnosis is accurate. Assembly and session hooks fire per subprocess, not per test target. When concurrent engine subprocesses all load the same assembly, each one runs the hooks — causing simultaneous writes to the same temp file and IOException.
Strengths:
- The guard pattern (
context.TestClasses.Any(c => c.ClassType == typeof(...))) is the right mechanism: it checks whether the specific class participated in this subprocess's run before writing the marker file. - Consistent: the guard is applied symmetrically to both
[Before]and[After]hooks across all four affected classes. - Removing the try-catch is safe here. The original try-catch re-threw the exception anyway (it only added a
Console.WriteLinebefore thethrow), so the simplified code is functionally equivalent in terms of exception propagation. The guard now makes the exceptional path (concurrent write collision) unreachable, so the logging no longer adds value.
Minor observations (not blockers):
-
Guard duplication: The
context.TestClasses.Any(c => c.ClassType == typeof(X))pattern appears 8 times across 2 files. For test project code this is fine, but if this pattern recurs, a small helper likeIsClassInContext(context, typeof(X))or an extension method would reduce noise. Not worth changing now. -
[Before(TestSession)]guard timing:TestSessionContext.TestClassesmust be fully populated before the Before hook runs for this guard to be reliable. That should be the case in TUnit (the session context is built before hooks execute), but worth noting as a subtle assumption. -
Removed console logging on After-hook failure: The old code logged
[AfterAssembly] Failed to write marker file: {ex.Message}before re-throwing. The new code skips the write entirely for non-participating subprocesses, so there's nothing to log. For the participating subprocess, failures will now surface as raw exceptions without the context message. Acceptable tradeoff for the simplification.
Summary
All prior concerns addressed. The third commit correctly fixes a secondary race condition: concurrent subprocess marker-file collisions in the _3882 test classes. The approach (guard on class membership) is sound, the simplification of try-catch is safe, and the change is scoped appropriately. Ready to merge.
Summary
SharedType.PerTestSessiondata sources, the constructor factory runs exactly once viaLazy<T>(ExecutionAndPublicationmode). Whichever test's build context wins theConcurrentDictionaryrace triggers the factory — so the constructor output only lands in that one test's_buildTimeOutput, making test assertions order-dependent.Test_SharedDataSource_SecondTestcould win the race, leavingTest_SharedDataSource_FirstTestwith no constructor output → CI failure.buildContext.GetCapturedOutput()inTestBuilder, also write it toTestSessionContext.Current.OutputWriterso shared data source output is always accessible at the session level regardless of build ordering.TestBuildContextOutputCaptureTests.Test_SharedDataSource_FirstTestto checkTestSessionContext.Current.GetStandardOutput()instead ofTestContext.Current.GetStandardOutput().Test plan
TestBuildContextOutputCaptureTestspasses on all frameworks locally