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
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