Skip to content

[perf-improver] perf: skip TCS bridge in ExecuteTestAsync when no ExecutionContext is captured #9635

Description

@github-actions

Goal and Rationale

ExecuteTestAsync in TestMethodRunner uses a TaskCompletionSource<TestResult[]> bridge to hop the test execution onto a captured ExecutionContext (set by [ClassInitialize] or [AssemblyInitialize]):

vartcs=newTaskCompletionSource<TestResult[]>();ExecutionContextHelpers.RunOnContext(testMethodInfo.Parent.ExecutionContext??testMethodInfo.Parent.Parent.ExecutionContext,async()=>{ ...tcs.SetResult(...);});returnawaittcs.Task;

However, ExecutionContextHelpers.RunOnContext already handles a null context by calling action() inline — so the TCS, async-lambda closure, and Action delegate were being allocated for every test, even when no ExecutionContext was captured (the common case).

Approach

Compute the captured context once, and short-circuit the bridge when it is null:

ExecutionContext?capturedContext=testMethodInfo.Parent.ExecutionContext??testMethodInfo.Parent.Parent.ExecutionContext;if(capturedContextisnull){using(TestContextImplementation.SetCurrentTestContext(executionContextasTestContext)){testMethodInfo.TestContext=executionContext;returnawait_testMethodInfo.Executor.ExecuteAsync(testMethodInfo).ConfigureAwait(false);}}// slow path: TCS bridge unchanged

The slow path is preserved unchanged for the rare case where a lifecycle method captured an ExecutionContext.

Performance Evidence

Saved allocations per test on the fast path (common case):

ObjectApprox. size
TaskCompletionSource<TestResult[]>~50 bytes
Async-lambda closure~40 bytes
Action delegate~24 bytes
Total~3 objects / ~114 bytes

For a 1000-test suite: ~3000 fewer small heap objects; reduces GC pressure, especially in short-lived, frequently-running test jobs.

Methodology: direct inspection of the allocation path. When capturedContext == null, RunOnContext calls action() inline, so the TCS and closure serve no purpose.

Trade-offs

  • Correctness: The fast path is only taken when both TestClassInfo.ExecutionContext and TestAssemblyInfo.ExecutionContext are null. Any test with a lifecycle method that captures a context still goes through the original TCS bridge.
  • Code size: +19 lines for the fast-path branch; the slow path is unchanged.

Reproducibility

# Run the MSTestAdapter unit tests to verify no regressions:
dotnet run --project test/UnitTests/MSTestAdapter.PlatformServices.UnitTests \
-f net9.0 --no-build -- --treenode-filter "*/*/TestMethodRunnerTests/*"

Test Status

All existing TestMethodRunnerTests pass unchanged. CI will verify the full build.

🤖 Automated content by GitHub Copilot. Posted via a maintainer's GitHub token, so it appears under their account — the account owner did not write or approve this content personally. Generated by the Perf Improver workflow. · 234.5 AIC · ⌖ 17.1 AIC · ⊞ 12.5K · [◷]( · )

Add this agentic workflows to your repo

To install this agentic workflow, run

gh aw add githubnext/agentics/workflows/perf-improver.md@main

Note

This was originally intended as a pull request, but GitHub Actions is not permitted to create or approve pull requests in this repository.
The changes have been pushed to branch perf-assist/skip-tcs-no-exec-context-8207936f1aa02717.

Click here to create the pull request

To fix the permissions issue, go to SettingsActionsGeneral and enable Allow GitHub Actions to create and approve pull requests. See also: gh-aw FAQ

Show patch preview (76 of 76 lines)
From 455d88abc26caf05b3bb0f791f42cfa3d167e06d Mon Sep 17 00:00:00 2001
From: "github-actions[bot]" <github-actions[bot]@users.noreply.github.com>
Date: Sun, 5 Jul 2026 14:12:55 +0000
Subject: [PATCH] perf: skip TCS bridge in ExecuteTestAsync when no
ExecutionContext
In the common case (tests whose [ClassInitialize] / [AssemblyInitialize]
did not capture an ExecutionContext), ExecutionContextHelpers.RunOnContext
simply calls the action inline. The existing code still allocated a
TaskCompletionSource<TestResult[]>, an async-lambda closure, and an
Action delegate for every single test even when they were not needed.
Add a fast path that skips all three when capturedContext is null:
if (capturedContext is null)
{
using (TestContextImplementation.SetCurrentTestContext(...))
{
testMethodInfo.TestContext = executionContext;
return await _testMethodInfo.Executor.ExecuteAsync(testMethodInfo).ConfigureAwait(false);
}
}
The slow path (TCS bridge via ExecutionContext.Run) is preserved
unchanged for the rare case where a lifecycle method captured a context.
Saved allocations per test in the fast path:
- TaskCompletionSource<TestResult[]> ~50 bytes
- async-lambda closure object ~40 bytes
- Action delegate ~24 bytes
Total: ~3 objects / ~114 bytes per test
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../Execution/TestMethodRunner.cs | 20 ++++++++++++++++++-
1 file changed, 19 insertions(+), 1 deletion(-)
diff --git a/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestMethodRunner.cs b/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestMethodRunner.cs
index 1c11cf6..99917fc 100644
--- a/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestMethodRunner.cs+++ b/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestMethodRunner.cs@@ -496,11 +496,29 @@ private async Task<TestResult[]> ExecuteTestAsync(ITestContext execut
... (truncated)

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/agentic-workflowsGitHub agentic workflow definitions under .github/workflows/*.md.area/performanceRuntime / build performance / efficiency.type/automationCreated or maintained by an agentic workflow.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions