Skip to content

[perf-improver] perf: skip TaskCompletionSource bridge in ExecuteTestAsync when no ExecutionContext #9603

Description

@github-actions

Goal and Rationale

ExecuteTestAsync is called for every test execution (including data-driven rows). It was unconditionally allocating:

  1. A TaskCompletionSource<TestResult[]>
  2. An async lambda closure (capturing executionContext, testMethodInfo, _testMethodInfo, tcs)
  3. An Action delegate wrapping the lambda

These allocations only exist to bridge the ExecutionContext.Run API — but when neither [AssemblyInitialize] nor [ClassInitialize] has captured an ExecutionContext (the common case in most test suites), RunOnContext with a null context just invokes the action directly on the same thread. The TCS bridge was paying the cost with no benefit.

Approach

Extract the context lookup into capturedContext. When capturedContext is null, skip the TCS bridge and directly await _testMethodInfo.Executor.ExecuteAsync(testMethodInfo). The slow path (non-null capturedContext) is unchanged and still uses the TCS bridge to correctly restore the captured ExecutionContext.

Performance Evidence

Before (every test):

  • new TaskCompletionSource<TestResult[]>() — heap allocation
  • async lambda closure object — heap allocation
  • Action delegate — heap allocation

After (common case — no captured ExecutionContext):

  • None of the above

Estimated saving: ~3 heap allocations per test in any test suite where [AssemblyInitialize] and [ClassInitialize] do not capture an ExecutionContext. For a suite with 1,000 tests this removes ~3,000 short-lived allocations per run.

Trade-offs

None. The null context fast path in RunOnContext already called the action directly on the same thread — there was no actual ExecutionContext propagation happening. The observable behavior is identical for both the success and exception paths.

Reproducibility

Allocations can be verified with dotnet-trace or PerfView on the MSTest performance runner (test/Performance/MSTest.Performance.Runner), looking at TaskCompletionSource constructor call counts in the ExecuteTestAsyncExecuteAsync call stack.

Test Status

The existing RunTestMethodShouldPassWhenAttributeInvokesTestMethodOnExecutionContextUnsafeThread test covers the non-null ExecutionContext slow path. All other TestMethodRunner tests exercise the common fast path. CI will verify build and test results.

🤖 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. · 218.5 AIC · ⌖ 19.8 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-a7d9fe093adebb28.

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 (84 of 84 lines)
From 22ff55dc0731235fa152863dc19055cd8c248c1c Mon Sep 17 00:00:00 2001
From: "github-actions[bot]" <github-actions[bot]@users.noreply.github.com>
Date: Sat, 4 Jul 2026 14:09:24 +0000
Subject: [PATCH] perf: skip TaskCompletionSource bridge in ExecuteTestAsync
 when no ExecutionContext
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

When neither [ClassInitialize] nor [AssemblyInitialize] captured an
ExecutionContext (the common case), ExecuteTestAsync was still creating
a TaskCompletionSource<TestResult[]>, an async-lambda closure, and an
Action delegate to bridge through RunOnContext — even though RunOnContext
with a null context simply calls the action directly.

Add a fast path that detects null capturedContext and directly awaits
ExecuteAsync, bypassing the TCS bridge entirely. This saves ~3 heap
allocations per test execution in the common case.

The slow path (non-null capturedContext) is unchanged and still uses the
TCS bridge to properly restore the captured ExecutionContext.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
 .../Execution/TestMethodRunner.cs             | 36 ++++++++++++++++++-
 1 file changed, 35 insertions(+), 1 deletion(-)

diff --git a/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestMethodRunner.cs b/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestMethodRunner.cs
index 96b3545..a593766 100644
--- a/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestMethodRunner.cs
+++ b/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestMethodRunner.cs
@@ -486,13 +486,47 @@ private async Task<TestResult[]> ExecuteTestWithDataRowAsync(ITestContext execut
 
     private async Task<TestResult[]> ExecuteTestAsync(ITestContext executionContext, TestMethodInfo testMethodInfo)
     {
+        ExecutionContext? capturedContext = testMethodInfo.Parent.ExecutionContext ?? testMethodInfo.Parent.Parent.ExecutionContext;
+
+        // Fast path: no ExecutionContext was cap
... (truncated)

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions