You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
ExecuteTestAsync is called for every test execution (including data-driven rows). It was unconditionally allocating:
A TaskCompletionSource<TestResult[]>
An async lambda closure (capturing executionContext, testMethodInfo, _testMethodInfo, tcs)
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 ExecuteTestAsync → ExecuteAsync 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.
To fix the permissions issue, go to Settings → Actions → General 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)
Goal and Rationale
ExecuteTestAsyncis called for every test execution (including data-driven rows). It was unconditionally allocating:TaskCompletionSource<TestResult[]>asynclambda closure (capturingexecutionContext,testMethodInfo,_testMethodInfo,tcs)Actiondelegate wrapping the lambdaThese allocations only exist to bridge the
ExecutionContext.RunAPI — but when neither[AssemblyInitialize]nor[ClassInitialize]has captured anExecutionContext(the common case in most test suites),RunOnContextwith anullcontext 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. WhencapturedContext is null, skip the TCS bridge and directlyawait _testMethodInfo.Executor.ExecuteAsync(testMethodInfo). The slow path (non-nullcapturedContext) is unchanged and still uses the TCS bridge to correctly restore the capturedExecutionContext.Performance Evidence
Before (every test):
new TaskCompletionSource<TestResult[]>()— heap allocationActiondelegate — heap allocationAfter (common case — no captured ExecutionContext):
Estimated saving: ~3 heap allocations per test in any test suite where
[AssemblyInitialize]and[ClassInitialize]do not capture anExecutionContext. For a suite with 1,000 tests this removes ~3,000 short-lived allocations per run.Trade-offs
None. The
nullcontext fast path inRunOnContextalready called the action directly on the same thread — there was no actualExecutionContextpropagation happening. The observable behavior is identical for both the success and exception paths.Reproducibility
Allocations can be verified with
dotnet-traceor PerfView on the MSTest performance runner (test/Performance/MSTest.Performance.Runner), looking atTaskCompletionSourceconstructor call counts in theExecuteTestAsync→ExecuteAsynccall stack.Test Status
The existing
RunTestMethodShouldPassWhenAttributeInvokesTestMethodOnExecutionContextUnsafeThreadtest covers the non-nullExecutionContextslow path. All otherTestMethodRunnertests exercise the common fast path. CI will verify build and test results.Add this agentic workflows to your repo
To install this agentic workflow, run
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 Settings → Actions → General and enable Allow GitHub Actions to create and approve pull requests. See also: gh-aw FAQ
Show patch preview (84 of 84 lines)