perf: consolidate test dedup + hook register guards (#5528 A)#5612
perf: consolidate test dedup + hook register guards (#5528 A)#5612
Conversation
A) Items 1-2 from the perf audit in #5528. 1. Remove TestExecutionGuard TestRunner already dedups via ConcurrentDictionary<string, TCS<bool>>, and it's the sole entry point for both the scheduler and dependency recursion. The second layer in TestCoordinator was purely redundant — each test was paying for two TCS allocs and two dict ops when one suffices. TestCoordinator.ExecuteTestAsync now calls ExecuteTestInternalAsync directly. 2. Guard per-test assembly/class hook registrations RegisterAfterAssemblyHook and RegisterAfterClassHook were invoked on every test, allocating a CancellationTokenRegistration each time even though only the first per-assembly / per-class registration matters. Added ConcurrentHashSet guards mirroring the existing _sessionHookRegistered pattern. For 10k tests across 5 assemblies / 200 classes: ~19,795 fewer registrations.
Up to standards ✅🟢 Issues
|
| Metric | Results |
|---|---|
| Complexity | 0 |
TIP This summary will be updated as you push new changes. Give us feedback
There was a problem hiding this comment.
Code Review
Overall this is a solid, well-reasoned performance PR. The analysis in the description is accurate and the implementation is clean. A couple of things worth flagging.
✅ TestExecutionGuard removal — correct
The claim holds up. All callers of ITestCoordinator.ExecuteTestAsync go exclusively through TestRunner.ExecuteTestAsync (confirmed: TestScheduler, ConstraintKeyScheduler, and recursive dependency calls in TestRunner.ExecuteTestInternalAsync all route through TestRunner). The ConcurrentDictionary-based dedup in TestRunner covers every path, making the second guard genuinely redundant. Good catch.
✅ ConcurrentHashSet guard for assembly/class hooks — correct and actually more correct than the session hook pattern
The new ConcurrentHashSet<T>.Add() guard is atomically safe. The existing _sessionHookRegistered pattern it's modeled after has a subtle TOCTOU race:
// AfterHookPairTracker.cs:46-51
if (_sessionHookRegistered) // Thread A reads false
return;
_sessionHookRegistered = true; // Thread B also reads false, both proceedTwo threads racing on RegisterAfterTestSessionHook could both pass the if check before either writes true, resulting in double registration of the session hook callback. This doesn't affect the hook execution itself (which uses GetOrCreateAfterTestSessionTask with its lock guard), but it wastes a CancellationTokenRegistration. The new ConcurrentHashSet.Add() approach is strictly better. Consider updating the session hook to use the same pattern for consistency:
private readonly ConcurrentHashSet<int> _sessionHookRegistered = new(); // or Interlocked.CompareExchange on an int flag⚠️ Single cancellation token registration per assembly/class
With the guard in place, the After Assembly/Class hook cancellation callback is registered only on the first test's per-test cancellation token. If that test completes before a mid-run session cancellation, whether the hook fires depends on whether the first test's CT is linked to the session CT. If per-test CancellationTokenSources are derived via CreateLinkedTokenSource from the session token, cancellation cascades correctly. If they're independent (e.g. per-test timeout tokens), a session cancellation after the first test completes would bypass the cancellation path for that assembly.
The normal-completion path (GetOrCreateAfterAssemblyTask direct call) handles the non-cancellation case, so this is only a risk when execution is aborted abnormally. Worth verifying that per-test tokens are always linked to the session token, and if so, adding a brief comment here to explain why the single registration is safe.
Minor: TestRunner._executingTests never removes entries
This isn't introduced by this PR, but the PR now relies on it as the correctness foundation: the dictionary grows O(test count) across the run and is never pruned. For very large suites (100k+ tests), this is meaningful resident memory. Not a blocker, but worth a // intentional: dedup across re-entrancy comment or a future TryRemove after tasks complete (once the re-entry window closes).
Summary: Both core changes are correct and achieve their stated goals. The main actionable item is the _sessionHookRegistered TOCTOU inconsistency — the new code is more correct than the pattern it claims to mirror, so either fix the session hook too or document the known difference.
- Replace volatile bool double-check with Interlocked.CompareExchange in RegisterAfterTestSessionHook to close the TOCTOU race where two threads could both observe false and both register the cancellation callback. - Document CT safety invariant for assembly/class single-registration: the passed token is always session-scoped (or linked via Parallel.ForEachAsync); per-test timeout tokens are applied inside TestExecutor scoped to the test body only, never to hook registration. - Document TestRunner._executingTests retention intent: entries are intentionally kept for the session so late dependency lookups see the in-flight/completed TCS. Bounded by test count.
There was a problem hiding this comment.
Code Review
Good, well-scoped performance PR with clear reasoning. Two genuine concerns worth discussing.
1. The CT-is-session-scoped assumption is load-bearing and unenforced
In AfterHookPairTracker, the assembly/class guards now ensure only the first registered cancellation token fires the After hook. The comment explains why this is safe:
"The CancellationToken passed in is always the session-scoped token..."
This is correct today — TestExecutor.cs never surfaces per-test CTs to these methods. But the type signature accepts any CancellationToken, so there's nothing preventing a future caller from passing a narrower token. If that happens, subsequent tests in the same assembly/class would silently skip registration, and their After hooks would not fire when that CT cancels.
Suggestion: Either encode the invariant in the API (e.g., rename the parameter to sessionCancellationToken) or add a debug-only assertion at the call site in TestExecutor.cs verifying the token is session-scoped. The comment is good, but the invariant is silently broken by callers who don't read it.
2. Removing TestExecutionGuard makes dedup an implicit TestRunner invariant
The old guard was a named, DI-registered abstraction with a clear SRP contract. Now the dedup guarantee is an implicit consequence of TestRunner being the sole ITestCoordinator caller. This is accurate today, but:
ITestCoordinatoris an interface — any future caller (retry coordinator, test harness in tests) that callsExecuteTestAsyncdirectly would silently lose dedup protection.- The invariant is only discoverable by reading the comment on
TestCoordinator.ExecuteTestAsync.
Suggestion: Consider adding an XML doc note on ITestCoordinator.ExecuteTestAsync stating callers must go through TestRunner to preserve dedup semantics. This is lower priority than concern 1 — the performance gain is real and the current call graph is clear.
What's good
- Interlocked fix for
_sessionHookRegisteredis a genuine race condition fix, not just a perf improvement. The oldvolatile boolcheck-then-set was a TOCTOU bug;CompareExchangeis the correct pattern here. - The
ConcurrentHashSetguards are clean and mirror the existing session pattern well. TheAdd-returns-bool idiom avoids a separate lookup. - The TestRunner
_executingTestscomment clearly explains the intentional retention-for-session design. - PR description is excellent — quantified impact, clear reasoning per change, solid test plan.
The changes are correct and the performance improvement is real. The concerns above are about future maintenance fragility, not current correctness.
…e API Addresses review maintainability concerns: - AfterHookPairTracker.Register* methods now take `sessionCancellationToken` (renamed from `cancellationToken`) with XML docs spelling out that only a session-scoped token is valid. This closes the silent-correctness hole where a future caller could pass a narrower per-test token and have later tests skip registration. - ITestCoordinator gains a remarks section noting callers must go through TestRunner to retain the dedup guarantee (previously held by the removed TestExecutionGuard). No behavior change — documentation + parameter rename only.
There was a problem hiding this comment.
Code Review — Follow-up
All items raised in the previous two reviews have been addressed. Here's the checklist:
Previous concerns resolved
| Concern | Status |
|---|---|
TOCTOU race on _sessionHookRegistered (volatile bool check-then-set) |
✅ Fixed — Interlocked.CompareExchange on int flag |
| CT-is-session-scoped assumption unenforced (Review 2, concern 1) | ✅ Fixed — parameter renamed to sessionCancellationToken on all three methods |
ITestCoordinator.ExecuteTestAsync callers must go through TestRunner (Review 2, concern 2) |
✅ Fixed — <remarks> block added to the interface documenting the invariant |
_executingTests retention unexplained minor note (Review 1) |
✅ Fixed — comment added explaining intentional session-scoped growth and the O(test count) bound |
| CT invariant needs call-site documentation | ✅ Fixed — <param> XML docs added to RegisterAfterAssemblyHook and RegisterAfterClassHook |
Remaining observations (informational, not blocking)
ConcurrentHashSet<Assembly> / ConcurrentHashSet<Type> sizing: Both sets are sized at default capacity and will hold one entry per distinct assembly/class for the session lifetime. For typical suites this is negligible. Worth being aware of if a suite has thousands of distinct test classes, though the per-entry overhead is far smaller than the CancellationTokenRegistration allocations it replaces.
_registrations bag: Now populated far less (one entry per assembly/class + one for session, vs. one per test). The cleanup path downstream is unchanged — this is purely a win.
Verdict
The changes are correct, well-documented, and the PR description is exemplary. The dedup consolidation is architecturally sound (single entry-point owns the ledger), and the Interlocked fix for the session hook is a genuine race correction that improves on the original pattern.
Approving.
Updated [TUnit](https://github.com/thomhurst/TUnit) from 1.35.2 to 1.37.10. <details> <summary>Release notes</summary> _Sourced from [TUnit's releases](https://github.com/thomhurst/TUnit/releases)._ ## 1.37.10 <!-- Release notes generated using configuration in .github/release.yml at v1.37.10 --> ## What's Changed ### Other Changes * docs(test-filters): add migration callout for --filter → --treenode-filter by @johnkattenhorn in thomhurst/TUnit#5628 * fix: re-enable RPC tests and modernize harness (#5540) by @thomhurst in thomhurst/TUnit#5632 * fix(mocks): propagate [Obsolete] and null-forgiving raise dispatch (#5626) by @JohnVerheij in thomhurst/TUnit#5631 * ci: use setup-dotnet built-in NuGet cache by @thomhurst in thomhurst/TUnit#5635 * feat(playwright): propagate W3C trace context into browser contexts by @thomhurst in thomhurst/TUnit#5636 ### Dependencies * chore(deps): update tunit to 1.37.0 by @thomhurst in thomhurst/TUnit#5625 ## New Contributors * @johnkattenhorn made their first contribution in thomhurst/TUnit#5628 * @JohnVerheij made their first contribution in thomhurst/TUnit#5631 **Full Changelog**: thomhurst/TUnit@v1.37.0...v1.37.10 ## 1.37.0 <!-- Release notes generated using configuration in .github/release.yml at v1.37.0 --> ## What's Changed ### Other Changes * fix: stabilize flaky tests across analyzer, OTel, and engine suites by @thomhurst in thomhurst/TUnit#5609 * perf: engine hot-path allocation wins (#5528 B) by @thomhurst in thomhurst/TUnit#5610 * feat(analyzers): detect collection IsEqualTo reference equality (TUnitAssertions0016) by @thomhurst in thomhurst/TUnit#5615 * perf: consolidate test dedup + hook register guards (#5528 A) by @thomhurst in thomhurst/TUnit#5612 * perf: engine discovery/init path cleanup (#5528 C) by @thomhurst in thomhurst/TUnit#5611 * fix(assertions): render collection contents in IsEqualTo failure messages (#5613 B) by @thomhurst in thomhurst/TUnit#5619 * feat(analyzers): code-fix for TUnit0015 to insert CancellationToken (#5613 D) by @thomhurst in thomhurst/TUnit#5621 * fix(assertions): add Task reference forwarders on AsyncDelegateAssertion by @thomhurst in thomhurst/TUnit#5618 * test(asp-net): fix race in FactoryMethodOrderTests by @thomhurst in thomhurst/TUnit#5623 * feat(analyzers): code-fix for TUnit0049 to insert [MatrixDataSource] (#5613 C) by @thomhurst in thomhurst/TUnit#5620 * fix(pipeline): isolate AOT publish outputs to stop clobbering pack DLLs (#5622) by @thomhurst in thomhurst/TUnit#5624 ### Dependencies * chore(deps): update tunit to 1.36.0 by @thomhurst in thomhurst/TUnit#5608 * chore(deps): update modularpipelines to 3.2.8 by @thomhurst in thomhurst/TUnit#5614 **Full Changelog**: thomhurst/TUnit@v1.36.0...v1.37.0 ## 1.36.0 <!-- Release notes generated using configuration in .github/release.yml at v1.36.0 --> ## What's Changed ### Other Changes * fix: don't render test's own trace as Linked Trace in HTML report by @thomhurst in thomhurst/TUnit#5580 * fix(docs): benchmark index links 404 by @thomhurst in thomhurst/TUnit#5587 * docs: replace repeated benchmark link suffix with per-test descriptions by @thomhurst in thomhurst/TUnit#5588 * docs: clearer distributed tracing setup and troubleshooting by @thomhurst in thomhurst/TUnit#5597 * fix: auto-suppress ExecutionContext flow for hosted services (#5589) by @thomhurst in thomhurst/TUnit#5598 * feat: auto-align DistributedContextPropagator to W3C by @thomhurst in thomhurst/TUnit#5599 * feat: TUnit0064 analyzer + code fix for direct WebApplicationFactory inheritance by @thomhurst in thomhurst/TUnit#5601 * feat: auto-propagate test trace context through IHttpClientFactory by @thomhurst in thomhurst/TUnit#5603 * feat: TUnit.OpenTelemetry zero-config tracing package by @thomhurst in thomhurst/TUnit#5602 * fix: restore [Obsolete] members removed in v1.27 (#5539) by @thomhurst in thomhurst/TUnit#5605 * feat: generalize OTLP receiver for use outside TUnit.Aspire by @thomhurst in thomhurst/TUnit#5606 * feat: auto-configure OpenTelemetry in TestWebApplicationFactory SUT by @thomhurst in thomhurst/TUnit#5607 ### Dependencies * chore(deps): update tunit to 1.35.2 by @thomhurst in thomhurst/TUnit#5581 * chore(deps): update dependency typescript to ~6.0.3 by @thomhurst in thomhurst/TUnit#5582 * chore(deps): update dependency coverlet.collector to v10 by @thomhurst in thomhurst/TUnit#5600 **Full Changelog**: thomhurst/TUnit@v1.35.2...v1.36.0 Commits viewable in [compare view](thomhurst/TUnit@v1.35.2...v1.37.10). </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>
Part A of the perf work catalogued in #5528. Tackles the two P0 architectural items.
Changes
1. Remove duplicate test-execution dedup layer
`TestRunner` already holds a `ConcurrentDictionary<string, TaskCompletionSource>` to prevent double-execution, and it's the sole entry point for both the scheduler and dependency recursion. `TestExecutionGuard` wrapped every call with a second identical mechanism — every test paid for two TCS allocations and two dictionary ops when one suffices.
2. Guard per-test assembly/class hook registrations
`RegisterAfterAssemblyHook` and `RegisterAfterClassHook` were called for every test but only need to register once per assembly / per class. Each redundant call heap-allocated a `CancellationTokenRegistration` into an unbounded `ConcurrentBag`.
Added `ConcurrentHashSet` and `ConcurrentHashSet` guards mirroring the existing `_sessionHookRegistered` pattern. For 10k tests across 5 assemblies / 200 classes: ~19,795 fewer registrations.
Test plan