Conversation
…t correctly (#5244) The "test discovery" span was orphaned because the session activity didn't exist yet during discovery — it was only created after Before(TestSession) hooks, which run later. Extract session activity creation into an idempotent TryStartSessionActivity() and call it after Before(TestDiscovery) hooks (for execution requests) so users who set up their TracerProvider early get a single unified trace.
There was a problem hiding this comment.
Code Review
Overall this is a well-targeted, clean fix for the orphaned discovery span issue. The root cause analysis in the PR description is accurate, the idempotency approach is solid, and the test suite is thorough. A few observations:
Architecture / Design
The two-phase idempotency pattern is the right call here.
Calling TryStartSessionActivity() from two sequential sites (after Before(TestDiscovery) and after Before(TestSession)) with an early-return guard is a simple, low-risk design. It doesn't require the caller to know which phase the listener was registered in—correct.
Thread-safety: low risk in practice, but worth noting.
TryStartSessionActivity has a classic TOCTOU shape:
if (sessionContext.Activity is not null) return; // check
// ... gap ...
sessionContext.Activity = TUnitActivitySource.StartActivity(...); // actIn practice, both call sites are sequential within the TUnit session lifecycle so a real race is unlikely today. But if the method is ever called from parallel hook execution or a concurrent path in the future, two threads could both pass the null check and create duplicate activities (with the loser's activity leaking). A one-liner lock or Interlocked.CompareExchange would make the intent explicit and guard against future callers:
// Safer — makes no-double-create intent explicit
lock (_activityLock)
{
if (sessionContext.Activity is not null) return;
if (TUnitActivitySource.Source.HasListeners())
sessionContext.Activity = TUnitActivitySource.StartActivity(...);
}Not blocking the PR on this, but worth considering given how easy it is to protect.
isForExecution guard in TestDiscoveryService.
Correct — skipping activity creation during pure discovery (IDE listing, --list-tests) avoids creating a dangling session span that would never be finished. Good defensive coding.
Test File: SessionActivityLifecycleTests.cs
Good: The ActivityListenerScope RAII helper is clean, and the stubs are minimal (only implement what TryStartSessionActivity actually touches). The hierarchy tests in FullSpanHierarchy_SessionParentsAllChildren and DiscoveryAndAssembly_ShareSameTrace directly validate the fix for #5244.
Missing negative test — the silent failure mode.
There's no test for the case where HasListeners() is false when TryStartSessionActivity is called. The important failure mode here is: user sets up their TracerProvider after discovery hooks, TryStartSessionActivity runs, HasListeners() is false, activity stays null, and the discovery span silently orphans (same bug as before, different trigger). A test that calls TryStartSessionActivity without an ActivityListenerScope and asserts sessionContext.Activity is null would make this contract explicit. However, the comment in the test file warns that TUnit's HTML reporter may keep HasListeners() == true at all times during test execution, which would make that test unreliable. If that's the case, the comment should be promoted to a more prominent warning.
null! in test setup is a code smell.
var executor = new HookExecutor(hookDelegateBuilder, contextProvider, null!);This works today because TryStartSessionActivity doesn't touch the third argument, but it's a maintenance trap — anyone who later adds a call to the orchestrator inside that method gets a NullReferenceException at runtime in a test rather than a compile-time signal. Consider extracting a StubEventReceiverOrchestrator (even if it only throw new NotSupportedException() like the other stubs), or adding an XML doc note on the constructor explaining which parameters are safe to null in tests.
Comment on HasListeners assumption.
// TUnit's own HTML reporter listener is active during test execution, so HasListeners() is always true here.
This assumption should be surfaced more prominently — ideally as a class-level doc comment — because it means these tests can never reliably test the "no listeners" path. It's not wrong, just worth calling out so future maintainers don't add a test for that path and wonder why it doesn't work.
Minor
- The
#if NETguards are consistent with the rest of the file — good. - The
/// \<inheritdoc cref="HookExecutor.TryStartSessionActivity"/\>on theTestExecutorpass-through is a nice touch for discoverability. - The XML doc on
TryStartSessionActivityaccurately describes the contract.
Summary: Fix is correct and well-tested. The main actionable items are (in priority order):
- Add a
StubEventReceiverOrchestratoror doc comment to remove thenull!fragility. - Consider a lock or note for thread safety (low urgency, high future-proofing value).
- Clarify the
HasListenerstest assumption at the class level.
Up to standards ✅🟢 Issues
|
| Metric | Results |
|---|---|
| Complexity | 20 |
TIP This summary will be updated as you push new changes. Give us feedback
Updated [TUnit](https://github.com/thomhurst/TUnit) from 1.33.0 to 1.35.2. <details> <summary>Release notes</summary> _Sourced from [TUnit's releases](https://github.com/thomhurst/TUnit/releases)._ ## 1.35.2 <!-- Release notes generated using configuration in .github/release.yml at v1.35.2 --> ## What's Changed ### Other Changes * fix: restore SourceLink and deterministic builds in published packages by @thomhurst in thomhurst/TUnit#5579 ### Dependencies * chore(deps): update tunit to 1.35.0 by @thomhurst in thomhurst/TUnit#5578 **Full Changelog**: thomhurst/TUnit@v1.35.0...v1.35.2 ## 1.35.0 <!-- Release notes generated using configuration in .github/release.yml at v1.35.0 --> ## What's Changed ### Other Changes * fix: support open generic transitive auto-mocks by @thomhurst in thomhurst/TUnit#5568 * refactor: separate test and lifecycle tracing by @thomhurst in thomhurst/TUnit#5572 * fix: expand nested And/Or expectations in failure messages (#5573) by @thomhurst in thomhurst/TUnit#5577 ### Dependencies * chore(deps): update tunit to 1.34.5 by @thomhurst in thomhurst/TUnit#5566 * chore(deps): bump follow-redirects from 1.15.11 to 1.16.0 in /docs by @dependabot[bot] in thomhurst/TUnit#5538 * chore(deps): update verify to 31.16.0 by @thomhurst in thomhurst/TUnit#5570 * chore(deps): update verify to 31.16.1 by @thomhurst in thomhurst/TUnit#5574 * chore(deps): update gittools/actions action to v4 by @thomhurst in thomhurst/TUnit#5575 **Full Changelog**: thomhurst/TUnit@v1.34.5...v1.35.0 ## 1.34.5 <!-- Release notes generated using configuration in .github/release.yml at v1.34.5 --> ## What's Changed ### Other Changes * fix: cap test output at 1M chars to prevent OOM by @thomhurst in thomhurst/TUnit#5561 * fix: handle explicit interface impl with different return types in mock generator by @thomhurst in thomhurst/TUnit#5564 * fix: include XML documentation files in NuGet packages by @thomhurst in thomhurst/TUnit#5565 ### Dependencies * chore(deps): update tunit to 1.34.0 by @thomhurst in thomhurst/TUnit#5562 **Full Changelog**: thomhurst/TUnit@v1.34.0...v1.34.5 ## 1.34.0 <!-- Release notes generated using configuration in .github/release.yml at v1.34.0 --> ## What's Changed ### Other Changes * refactor: move CorrelatedTUnitLogger to TUnit.Logging.Microsoft and auto-inject handlers by @thomhurst in thomhurst/TUnit#5532 * feat: add Dev Drive setup for Windows in CI workflow by @thomhurst in thomhurst/TUnit#5544 * fix: start session activity before discovery so discovery spans parent correctly by @thomhurst in thomhurst/TUnit#5534 * feat: cross-process test log correlation via OTLP receiver by @thomhurst in thomhurst/TUnit#5533 * refactor: use natural OTEL trace propagation instead of synthetic TraceIds by @thomhurst in thomhurst/TUnit#5557 * fix: route ITestOutput writes through synchronized ConcurrentStringWriter by @thomhurst in thomhurst/TUnit#5558 ### Dependencies * chore(deps): update tunit to 1.33.0 by @thomhurst in thomhurst/TUnit#5527 * chore(deps): update dependency dompurify to v3.4.0 by @thomhurst in thomhurst/TUnit#5537 * chore(deps): update dependency docusaurus-plugin-llms to ^0.3.1 by @thomhurst in thomhurst/TUnit#5541 * chore(deps): update dependency microsoft.sourcelink.github to 10.0.202 by @thomhurst in thomhurst/TUnit#5543 * chore(deps): update dependency microsoft.entityframeworkcore to 10.0.6 by @thomhurst in thomhurst/TUnit#5542 * chore(deps): update dependency microsoft.templateengine.authoring.templateverifier to 10.0.202 by @thomhurst in thomhurst/TUnit#5546 * chore(deps): update dependency microsoft.templateengine.authoring.cli to v10.0.202 by @thomhurst in thomhurst/TUnit#5545 * chore(deps): update dependency system.commandline to 2.0.6 by @thomhurst in thomhurst/TUnit#5547 * chore(deps): update microsoft.aspnetcore to 10.0.6 by @thomhurst in thomhurst/TUnit#5548 * chore(deps): update dependency nuget.protocol to 7.3.1 by @thomhurst in thomhurst/TUnit#5549 * chore(deps): update microsoft.extensions to 10.0.6 by @thomhurst in thomhurst/TUnit#5550 * chore(deps): update dependency dotnet-sdk to v10.0.202 by @thomhurst in thomhurst/TUnit#5551 * chore(deps): update opentelemetry by @thomhurst in thomhurst/TUnit#5552 * chore(deps): update microsoft.extensions to 10.5.0 by @thomhurst in thomhurst/TUnit#5554 **Full Changelog**: thomhurst/TUnit@v1.33.0...v1.34.0 Commits viewable in [compare view](thomhurst/TUnit@v1.33.0...v1.35.2). </details> [](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) --- <details> <summary>Dependabot commands and options</summary> <br /> You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show <dependency name> ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) </details> Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Summary
TryStartSessionActivity()method onHookExecutorBefore(TestDiscovery)hooks (for execution requests) so users who set up theirTracerProviderinBefore(TestDiscovery)get a single unified trace with discovery properly nested under the sessionRoot cause
The "test discovery" span tried to parent under the session activity, but the session activity didn't exist yet — it was only created after
Before(TestSession)hooks, which run after discovery. SosessionActivity?.Context ?? defaultalways evaluated todefault, producing an orphaned root span.How it works
TryStartSessionActivity()is called from two sites:Before(TestDiscovery)hooks — catches users who set up their TracerProvider earlyBefore(TestSession)hooks — catches users who set up their TracerProvider in the traditional locationThe method is idempotent: it checks
sessionContext.Activity is not nullandHasListeners()before creating anything, so whichever call site runs first with an active listener wins.Files changed
TUnit.Engine/Services/HookExecutor.csTryStartSessionActivity(), replace inline creationTUnit.Engine/TestExecutor.csHookExecutorTUnit.Engine/TestDiscoveryService.csTryStartSessionActivity()after discovery hooksTUnit.UnitTests/SessionActivityLifecycleTests.csTest plan
TUnit.UnitTests)TUnit.AspNetCore.Tests)net8.0andnet10.0Before(TestDiscovery)and sees a single unified traceCloses #5244