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]):

var tcs = new TaskCompletionSource<TestResult[]>();
ExecutionContextHelpers.RunOnContext(
    testMethodInfo.Parent.ExecutionContext ?? testMethodInfo.Parent.Parent.ExecutionContext,
    async () => { ... tcs.SetResult(...); });
return await tcs.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 (capturedContext is null)
{
    using (TestContextImplementation.SetCurrentTestContext(executionContext as TestContext))
    {
        testMethodInfo.TestContext = executionContext;
        return await _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):

Object Approx. 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)

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