perf(engine): collapse async forwarding wrappers in test execution (#5714)#5725
perf(engine): collapse async forwarding wrappers in test execution (#5714)#5725
Conversation
…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
There was a problem hiding this comment.
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 lifecycleThis 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:
-
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.
-
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.
-
Lift to a
[MethodImpl(MethodImplOptions.AggressiveInlining)]non-async helper — the compiler will often inline small helpers anyway, but the attribute makes the intent explicit. -
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 == 0fast-path skip check (lines 120–127) is now the third place in the same method that callsMarkSkipped+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 aHandleSkip(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.ExecuteWithRetrymethod re-readstestContext.Metadata.TestDetails.RetryLimitinside the loop (line 14 ofRetryHelper.cs), whileTestCoordinatoralready holdsretryLimitin 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.
Up to standards ✅🟢 Issues
|
| Metric | Results |
|---|---|
| Complexity | 4 |
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.
|
Applied both non-blocking review suggestions in ea534fa:
Semantics preserved: Validation:
|
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> [](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
TestCoordinator.ExecuteTestAsync→ExecuteTestInternalAsyncwrapper into a single method, eliminating one async state machine per test.ExecuteTestLifecycleAsyncbody directly intoExecuteTestAsync. The retry branch still dispatches throughExecuteTestLifecycleAsyncbecauseRetryHelper.ExecuteWithRetryrequires aFunc<ValueTask>.TestRunner.ExecuteTestAsyncwas also audited. It is not a pure forward — it owns the session-wide dedup ledger (ConcurrentDictionary<string, TCS<bool>>) and already uses a minimalWrapAsynchelper that only spins a state machine on the non-synchronous path, so it is already optimized and left alone.TestExecutor.ExecuteAsyncwas audited and performs substantial real work (hook orchestration, activity lifecycle, timeout wiring, exception aggregation) — not a forwarder; left alone.Risks
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.finally).Test plan
dotnet build TUnit.slnx -c Release— succeeds (0 errors).TUnit.Core.SourceGenerator.Testssnapshot tests on net10.0 — 116 passed, 1 skipped (pre-existing).TUnit.TestProjectBasicTestssmoke — 3/3 passed.TUnit.TestProjectRetryTests— retry counts exactly match expectations (AssertCountspasses; 3 intentional failure tests fail after the expected attempt counts: 2, 3, 4 — confirming retry wrapper path still works).TUnit.TestProjectDependsOnTests— 2/2 passed (dependency-chain path viaTestRunner).Closes #5714