Skip to content

perf: consolidate test dedup + hook register guards (#5528 A)#5612

Merged
thomhurst merged 3 commits intomainfrom
perf/engine-dedup-hooks
Apr 18, 2026
Merged

perf: consolidate test dedup + hook register guards (#5528 A)#5612
thomhurst merged 3 commits intomainfrom
perf/engine-dedup-hooks

Conversation

@thomhurst
Copy link
Copy Markdown
Owner

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.

  • Deleted `TestExecutionGuard`
  • `TestCoordinator.ExecuteTestAsync` now calls `ExecuteTestInternalAsync` directly
  • Removed the DI registration

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

  • `dotnet build TUnit.Engine` — 0 errors
  • `TUnit.Engine.Tests` — 148/148 pass (99 env-gated skips)
  • `TUnit.UnitTests` — 180/180 pass

 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.
@codacy-production
Copy link
Copy Markdown

codacy-production Bot commented Apr 17, 2026

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 0 complexity

Metric Results
Complexity 0

View in Codacy

TIP This summary will be updated as you push new changes. Give us feedback

Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 proceed

Two 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.
Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 todayTestExecutor.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:

  • ITestCoordinator is an interface — any future caller (retry coordinator, test harness in tests) that calls ExecuteTestAsync directly 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 _sessionHookRegistered is a genuine race condition fix, not just a perf improvement. The old volatile bool check-then-set was a TOCTOU bug; CompareExchange is the correct pattern here.
  • The ConcurrentHashSet guards are clean and mirror the existing session pattern well. The Add-returns-bool idiom avoids a separate lookup.
  • The TestRunner _executingTests comment 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.
Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

intellitect-bot pushed a commit to IntelliTect/EssentialCSharp.Web that referenced this pull request Apr 21, 2026
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>

[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=TUnit&package-manager=nuget&previous-version=1.35.2&new-version=1.37.10)](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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant