Skip to content

perf(engine): collapse async forwarding wrappers in test execution (#5714)#5725

Merged
thomhurst merged 1 commit intomainfrom
perf/5714-async-forwarding
Apr 24, 2026
Merged

perf(engine): collapse async forwarding wrappers in test execution (#5714)#5725
thomhurst merged 1 commit intomainfrom
perf/5714-async-forwarding

Conversation

@thomhurst
Copy link
Copy Markdown
Owner

Summary

  • Collapses the pure-forward TestCoordinator.ExecuteTestAsyncExecuteTestInternalAsync wrapper into a single method, eliminating one async state machine per test.
  • Inlines the no-retry ExecuteTestLifecycleAsync body directly into ExecuteTestAsync. The retry branch still dispatches through ExecuteTestLifecycleAsync because RetryHelper.ExecuteWithRetry requires a Func<ValueTask>.
  • Net effect on the hot no-retry path: ~2 async state machines removed per test (consistent with the profiler evidence in perf: collapse 4-layer async forwarding chain in test execution #5714).

TestRunner.ExecuteTestAsync was also audited. It is not a pure forward — it owns the session-wide dedup ledger (ConcurrentDictionary<string, TCS<bool>>) and already uses a minimal WrapAsync helper that only spins a state machine on the non-synchronous path, so it is already optimized and left alone.

TestExecutor.ExecuteAsync was audited and performs substantial real work (hook orchestration, activity lifecycle, timeout wiring, exception aggregation) — not a forwarder; left alone.

Risks

  • Intentional duplication: the no-retry inlined body is a verbatim copy of ExecuteTestLifecycleAsync. Both locations carry cross-reference comments so future edits stay in sync. If the lifecycle gets any more complex, consider reverting to a single method and accepting the state-machine cost, or refactoring into AOT-safe non-async helpers.
  • Retry, cancellation, and exception semantics are preserved exactly (skip checks run in the same order as before; dispose-with-span still fires in finally).

Test plan

  • dotnet build TUnit.slnx -c Release — succeeds (0 errors).
  • TUnit.Core.SourceGenerator.Tests snapshot tests on net10.0 — 116 passed, 1 skipped (pre-existing).
  • TUnit.TestProject BasicTests smoke — 3/3 passed.
  • TUnit.TestProject RetryTests — retry counts exactly match expectations (AssertCounts passes; 3 intentional failure tests fail after the expected attempt counts: 2, 3, 4 — confirming retry wrapper path still works).
  • TUnit.TestProject DependsOnTests — 2/2 passed (dependency-chain path via TestRunner).

Closes #5714

…th (#5714)

Remove the pure-forward ExecuteTestAsync → ExecuteTestInternalAsync wrapper
in TestCoordinator, and inline the no-retry lifecycle body directly into
ExecuteTestAsync. The retry branch still dispatches through
ExecuteTestLifecycleAsync because RetryHelper takes a Func<ValueTask>.

Removes ~2 async state machines per test on the hot no-retry path.
Retry, cancellation, and exception semantics are unchanged.

Closes #5714
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.

Review: perf(engine) collapse async forwarding wrappers in test execution

Summary

The change is well-motivated, correctly scoped, and includes solid profiler evidence from the related issue. The execution semantics are preserved, the test plan is thorough, and the PR description is unusually detailed and honest about trade-offs. No correctness issues found.


Duplication Style: if/else vs. early-return

The only structural difference between the inlined no-retry body (lines 120–161) and the original ExecuteTestLifecycleAsync (lines 337–378) is that the inlined version uses nested if/else instead of early return. Both are functionally identical, but the if/else nesting adds one level of indentation for every skip path, which can grow if future skip conditions are added.

Consider whether the inlined version should use the same guard-clause / early-return style as ExecuteTestLifecycleAsync to make future divergence easier to spot:

// No-retry fast path (inlined)
test.Context.CurrentRetryAttempt = 0;

if (!string.IsNullOrEmpty(test.Context.SkipReason))
{
    _stateManager.MarkSkipped(test, test.Context.SkipReason!);
    await _eventReceiverOrchestrator.InvokeTestSkippedEventReceiversAsync(test.Context, cancellationToken).ConfigureAwait(false);
    await _eventReceiverOrchestrator.InvokeTestEndEventReceiversAsync(test.Context, cancellationToken).ConfigureAwait(false);
    return; // ← early return instead of wrapping the else branch
}
// … rest of lifecycle

This mirrors ExecuteTestLifecycleAsync exactly and makes the "these two must stay in sync" requirement much easier to verify visually. Right now you need to mentally flatten the nested if/else to confirm they are equivalent.


The real maintainability concern: comment-enforced synchronisation

The PR is clear-eyed about the risk:

"Both locations carry cross-reference comments so future edits stay in sync."

This works, but comment-enforced duplication is fragile under code review pressure — the comment is easy to miss when editing one site. A few approaches worth weighing against each other, in order of complexity:

  1. Keep the current approach (what the PR does) — acceptable for a small, stable lifecycle with low churn. The cross-reference comments and PR notes are better than average.

  2. Extract a non-async synchronous helper for the skip-before-instance-creation guard and the instance-creation-then-skip check:

    // Returns true and marks/fires skipped if the test should be skipped; false otherwise.
    private bool TryHandlePreExecutionSkip(AbstractExecutableTest test, CancellationToken ct) { ... }

    The actual hot-path allocation saving comes from eliminating the async wrapper, not from keeping the logic monolithic. A sync helper called by both paths does not add a state machine.

  3. Lift to a [MethodImpl(MethodImplOptions.AggressiveInlining)] non-async helper — the compiler will often inline small helpers anyway, but the attribute makes the intent explicit.

  4. AOT-safe iterator / pipeline approach — likely over-engineering for this file; not recommended here.

Option 2 is the most pragmatic upgrade: the skip-check logic is already clear enough that extracting TryHandlePreExecutionSkip would be a net reduction in duplication with no runtime cost, while keeping both paths readable without scrolling between them. This would eliminate the "must stay in sync" risk entirely.


Minor observations

  • The retryLimit == 0 fast-path skip check (lines 120–127) is now the third place in the same method that calls MarkSkipped + InvokeTestSkippedEventReceiversAsync + InvokeTestEndEventReceiversAsync. The first is the early-exit at lines 72–78 (pre-registration skip), the second is lines 120–127, and the third is lines 141–145. Extracting a HandleSkip(test, reason, ct) helper for that triple would reduce the total line count and make each use-site one line rather than four.

  • The RetryHelper.ExecuteWithRetry method re-reads testContext.Metadata.TestDetails.RetryLimit inside the loop (line 14 of RetryHelper.cs), while TestCoordinator already holds retryLimit in a local. This is a pre-existing minor issue unrelated to this PR, but worth noting if anyone is profiling allocations in that class.


Bottom line

The optimisation is sound, the implementation is correct, and the PR description does an excellent job of documenting the risk. The main suggestion is to adopt early-return style in the inlined block to match ExecuteTestLifecycleAsync, making future sync checks trivial. Extracting a HandleSkip helper is a worthwhile follow-up to reduce the triple-repetition of the skip-event firing sequence.

Approving with the above non-blocking suggestions.

@codacy-production
Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 4 complexity

Metric Results
Complexity 4

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@thomhurst thomhurst merged commit bbd36c7 into main Apr 24, 2026
15 checks passed
@thomhurst thomhurst deleted the perf/5714-async-forwarding branch April 24, 2026 12:20
@thomhurst
Copy link
Copy Markdown
Owner Author

Applied both non-blocking review suggestions in ea534fa:

  • Extracted HandleSkipAsync private helper (mark state + skipped receivers + end receivers) and routed all three skip sites through it (pre-registration, fast-path pre-instance, fast-path post-instance + the two matching sites in ExecuteTestLifecycleAsync).
  • Converted the inlined no-retry fast path to early-return style so its body now mirrors ExecuteTestLifecycleAsync shape for shape, making the "must stay in sync" maintenance contract trivial to verify by diff.

Semantics preserved: MarkCompleted is idempotent on an already-skipped test (MarkSkipped set test.Result, so Result ??= is a no-op), so the early return in the fast-path skip branches is observationally equivalent to the previous fall-through via MarkCompleted. Retry, cancellation, and exception handling paths are unchanged.

Validation:

  • dotnet build TUnit.slnx -c Release - 0 errors
  • TUnit.Core.SourceGenerator.Tests snapshots - 116/117 passed, 1 skipped (pre-existing flaky)
  • BasicTests/* smoke - 3/3 passed
  • Skip-related suites (SkipTests, SimpleSkipTest, SkipConstructorTest, SkipExceptionFixTest, DynamicSkipReasonTests, RunOnSkipTests, SkippedEventReceiverTests, RuntimeSkipEventReceiverTests) - 24 passed, 1 intentionally skipped
  • Retry suites (CustomRetryTests, ScopedRetryAttributePriorityTests, SimpleRetryPriorityTest) - failures match baseline on the pre-refactor commit (designed-to-fail tests that throw intentionally; retry-count AssertCounts passes)

This was referenced Apr 26, 2026
github-actions Bot pushed a commit to IntelliTect/CodingGuidelines that referenced this pull request Apr 27, 2026
Updated [TUnit.Core](https://github.com/thomhurst/TUnit) from 1.37.0 to
1.40.10.

<details>
<summary>Release notes</summary>

_Sourced from [TUnit.Core's
releases](https://github.com/thomhurst/TUnit/releases)._

## 1.40.10

<!-- Release notes generated using configuration in .github/release.yml
at v1.40.10 -->

## What's Changed
### Other Changes
* refactor(opentelemetry): depend on TUnit.Core instead of umbrella
TUnit by @​thomhurst in thomhurst/TUnit#5774
### Dependencies
* chore(deps): update tunit to 1.40.5 by @​thomhurst in
thomhurst/TUnit#5769


**Full Changelog**:
thomhurst/TUnit@v1.40.5...v1.40.10

## 1.40.5

<!-- Release notes generated using configuration in .github/release.yml
at v1.40.5 -->

## What's Changed
### Other Changes
* Fix reflection property injection reuse by @​thomhurst in
thomhurst/TUnit#5763
* fix(assertions): gate IsEqualTo<TValue, TOther> overload to net9+
(#​5765) by @​thomhurst in thomhurst/TUnit#5767
### Dependencies
* chore(deps): update tunit to 1.40.0 by @​thomhurst in
thomhurst/TUnit#5762


**Full Changelog**:
thomhurst/TUnit@v1.40.0...v1.40.5

## 1.40.0

<!-- Release notes generated using configuration in .github/release.yml
at v1.40.0 -->

## What's Changed
### Other Changes
* perf(engine): collapse async forwarding wrappers in test execution
(#​5714) by @​thomhurst in thomhurst/TUnit#5725
* perf(engine): skip Console.Out/Err FlushAsync when no output captured
(#​5712) by @​thomhurst in thomhurst/TUnit#5724
* perf(engine): collapse async state machines on hook cache-hit /
empty-hook path (#​5713) by @​thomhurst in
thomhurst/TUnit#5726
* perf: eliminate per-test closure + GetOrAdd factory alloc (#​5710) by
@​thomhurst in thomhurst/TUnit#5727
* perf(engine): replace global lock in EventReceiverRegistry with
lock-free CAS by @​thomhurst in
thomhurst/TUnit#5731
* perf(engine): batch per-test overhead cleanups (#​5719) by @​thomhurst
in thomhurst/TUnit#5730
* #​5733 handling all arguments for Fact and Theory by @​inyutin-maxim
in thomhurst/TUnit#5734
* fix(assertions): prefer string overload of Member() over
IEnumerable<char> (#​5702) by @​thomhurst in
thomhurst/TUnit#5721
* fix(migration): preserve comments/XML docs when removing sole
attributes (#​5698) by @​thomhurst in
thomhurst/TUnit#5739
* perf(build): trim test TFMs and skip viewer dump by default by
@​thomhurst in thomhurst/TUnit#5741
* fix(pipeline): skip TestBaseModule frameworks with missing binaries by
@​thomhurst in thomhurst/TUnit#5752
* feat(assertions): focused diff messages for IsEqualTo/IsEquivalentTo
(#​5732) by @​thomhurst in thomhurst/TUnit#5747
* fix(analyzers): remove incorrect AOT rules TUnit0300/0301/0302
(#​5722) by @​thomhurst in thomhurst/TUnit#5746
* perf(engine): lazy hook metadata registration (#​5448) by @​thomhurst
in thomhurst/TUnit#5750
* chore(templates): unify TUnit version pinning to 1.* (#​5709) by
@​thomhurst in thomhurst/TUnit#5743
* fix(templates): floating TUnit.Aspire version (#​5708) by @​thomhurst
in thomhurst/TUnit#5742
* fix(assertions): preserve specialised source in .Count(itemAssertion)
(#​5707) by @​thomhurst in thomhurst/TUnit#5749
* feat(assertions): IsEqualTo with implicitly-convertible wrappers
(#​5720) by @​thomhurst in thomhurst/TUnit#5751
* feat(aspire): add ability to manually remove resources by @​Odonno in
thomhurst/TUnit#5586
* fix(fscheck): register default CancellationToken arbitrary that
surfaces TestContext token by @​JohnVerheij in
thomhurst/TUnit#5758
* fix(engine): allow keyed NotInParallel tests to run alongside
unconstrained tests (#​5700) by @​thomhurst in
thomhurst/TUnit#5740
* perf: skip TimeoutHelper wrap when no explicit [Timeout] is set
(#​5711) by @​thomhurst in thomhurst/TUnit#5728
### Dependencies
* chore(deps): update tunit to 1.39.0 by @​thomhurst in
thomhurst/TUnit#5701
* chore(deps): update aspire to 13.2.4 by @​thomhurst in
thomhurst/TUnit#5735
* chore(deps): bump postcss from 8.5.6 to 8.5.10 in /docs by
@​dependabot[bot] in thomhurst/TUnit#5736
* chore(deps): update dependency fscheck to 3.3.3 by @​thomhurst in
thomhurst/TUnit#5760

## New Contributors
* @​inyutin-maxim made their first contribution in
thomhurst/TUnit#5734
* @​Odonno made their first contribution in
thomhurst/TUnit#5586

**Full Changelog**:
thomhurst/TUnit@v1.39.0...v1.40.0

## 1.39.0

<!-- Release notes generated using configuration in .github/release.yml
at v1.39.0 -->

## What's Changed
### Other Changes
* perf(mocks): shrink MethodSetup + cache stateless matchers by
@​thomhurst in thomhurst/TUnit#5669
* fix(mocks): handle base classes with explicit interface impls (#​5673)
by @​thomhurst in thomhurst/TUnit#5674
* fix(mocks): implement indexer in generated mock (#​5676) by
@​thomhurst in thomhurst/TUnit#5683
* fix(mocks): disambiguate IEquatable<T>.Equals from object.Equals
(#​5675) by @​thomhurst in thomhurst/TUnit#5680
* fix(mocks): escape C# keyword identifiers at all emit sites (#​5679)
by @​thomhurst in thomhurst/TUnit#5684
* fix(mocks): emit [SetsRequiredMembers] on generated mock ctor (#​5678)
by @​thomhurst in thomhurst/TUnit#5682
* fix(mocks): skip MockBridge for class targets with static-abstract
interfaces (#​5677) by @​thomhurst in
thomhurst/TUnit#5681
* chore(mocks): regenerate source generator snapshots by @​thomhurst in
thomhurst/TUnit#5691
* perf(engine): collapse async state-machine layers on hot test path
(#​5687) by @​thomhurst in thomhurst/TUnit#5690
* perf(engine): reduce lock contention in scheduling and hook caches
(#​5686) by @​thomhurst in thomhurst/TUnit#5693
* fix(assertions): prevent implicit-to-string op from NREing on null
(#​5692) by @​thomhurst in thomhurst/TUnit#5696
* perf(engine/core): reduce per-test allocations (#​5688) by @​thomhurst
in thomhurst/TUnit#5694
* perf(engine): reduce message-bus contention on test start (#​5685) by
@​thomhurst in thomhurst/TUnit#5695
### Dependencies
* chore(deps): update tunit to 1.37.36 by @​thomhurst in
thomhurst/TUnit#5667
* chore(deps): update verify to 31.16.2 by @​thomhurst in
thomhurst/TUnit#5699


**Full Changelog**:
thomhurst/TUnit@v1.37.36...v1.39.0

## 1.37.36

<!-- Release notes generated using configuration in .github/release.yml
at v1.37.36 -->

## What's Changed
### Other Changes
* fix(telemetry): remove duplicate HTTP client spans by @​thomhurst in
thomhurst/TUnit#5668


**Full Changelog**:
thomhurst/TUnit@v1.37.35...v1.37.36

## 1.37.35

<!-- Release notes generated using configuration in .github/release.yml
at v1.37.35 -->

## What's Changed
### Other Changes
* Add TUnit.TestProject.Library to the TUnit.Dev.slnx solution file by
@​Zodt in thomhurst/TUnit#5655
* fix(aspire): preserve user-supplied OTLP endpoint (#​4818) by
@​thomhurst in thomhurst/TUnit#5665
* feat(aspire): emit client spans for HTTP by @​thomhurst in
thomhurst/TUnit#5666
### Dependencies
* chore(deps): update dependency dotnet-sdk to v10.0.203 by @​thomhurst
in thomhurst/TUnit#5656
* chore(deps): update microsoft.aspnetcore to 10.0.7 by @​thomhurst in
thomhurst/TUnit#5657
* chore(deps): update tunit to 1.37.24 by @​thomhurst in
thomhurst/TUnit#5659
* chore(deps): update microsoft.extensions to 10.0.7 by @​thomhurst in
thomhurst/TUnit#5658
* chore(deps): update aspire to 13.2.3 by @​thomhurst in
thomhurst/TUnit#5661
* chore(deps): update dependency microsoft.net.test.sdk to 18.5.0 by
@​thomhurst in thomhurst/TUnit#5664

## New Contributors
* @​Zodt made their first contribution in
thomhurst/TUnit#5655

**Full Changelog**:
thomhurst/TUnit@v1.37.24...v1.37.35

## 1.37.24

<!-- Release notes generated using configuration in .github/release.yml
at v1.37.24 -->

## What's Changed
### Other Changes
* docs: add Tluma Ask AI widget to Docusaurus site by @​thomhurst in
thomhurst/TUnit#5638
* Revert "chore(deps): update dependency docusaurus-plugin-llms to
^0.4.0 (#​5637)" by @​thomhurst in
thomhurst/TUnit#5640
* fix(asp-net): forward disposal in FlowSuppressingHostedService
(#​5651) by @​JohnVerheij in
thomhurst/TUnit#5652
### Dependencies
* chore(deps): update dependency docusaurus-plugin-llms to ^0.4.0 by
@​thomhurst in thomhurst/TUnit#5637
* chore(deps): update tunit to 1.37.10 by @​thomhurst in
thomhurst/TUnit#5639
* chore(deps): update opentelemetry to 1.15.3 by @​thomhurst in
thomhurst/TUnit#5645
* chore(deps): update opentelemetry by @​thomhurst in
thomhurst/TUnit#5647
* chore(deps): update dependency dompurify to v3.4.1 by @​thomhurst in
thomhurst/TUnit#5648
* chore(deps): update dependency system.commandline to 2.0.7 by
@​thomhurst in thomhurst/TUnit#5650
* chore(deps): update dependency microsoft.entityframeworkcore to 10.0.7
by @​thomhurst in thomhurst/TUnit#5649
* chore(deps): update dependency microsoft.templateengine.authoring.cli
to v10.0.203 by @​thomhurst in
thomhurst/TUnit#5653
* chore(deps): update dependency
microsoft.templateengine.authoring.templateverifier to 10.0.203 by
@​thomhurst in thomhurst/TUnit#5654


**Full Changelog**:
thomhurst/TUnit@v1.37.10...v1.37.24

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

Commits viewable in [compare
view](thomhurst/TUnit@v1.37.0...v1.40.10).
</details>

[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=TUnit.Core&package-manager=nuget&previous-version=1.37.0&new-version=1.40.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>
This was referenced Apr 27, 2026
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.

perf: collapse 4-layer async forwarding chain in test execution

1 participant