From 895cfe7f7fd205f86ebe82ad22d1caab9c95d011 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 22 May 2026 17:08:31 +0000 Subject: [PATCH 1/3] Initial plan From 05351547c865d05b3418205af8df690fefdf2cb8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 22 May 2026 17:16:54 +0000 Subject: [PATCH 2/3] fix: serialize MTP server-mode concurrent sessions to prevent stochastic 0-result runs When multiple concurrent testing/runTests RPCs arrive at the same TUnit process, they race on shared static state (GlobalContext.Current, AppDomain exception handlers, Trace.Listeners). This causes stochastic 0-result sessions under concurrency. Changes: - Add SemaphoreSlim(1,1) in TUnitTestFramework.ExecuteRequestAsync to serialize concurrent requests - Make TUnitInitializer.Initialize idempotent with static flags to prevent repeated global handler/listener registration - Add multi-session RPC tests validating sequential and concurrent runTests RPCs produce consistent results --- TUnit.Engine/Framework/TUnitTestFramework.cs | 10 +++ TUnit.Engine/TUnitInitializer.cs | 16 ++++ TUnit.RpcTests/Tests.cs | 78 ++++++++++++++++++++ 3 files changed, 104 insertions(+) diff --git a/TUnit.Engine/Framework/TUnitTestFramework.cs b/TUnit.Engine/Framework/TUnitTestFramework.cs index aa4ec04454..7e95293ccb 100644 --- a/TUnit.Engine/Framework/TUnitTestFramework.cs +++ b/TUnit.Engine/Framework/TUnitTestFramework.cs @@ -18,6 +18,11 @@ internal sealed class TUnitTestFramework : ITestFramework, IDataProducer private readonly ITestFrameworkCapabilities _capabilities; private readonly ConcurrentDictionary _serviceProvidersPerSession = new(); private readonly IRequestHandler _requestHandler; + // Serialize ExecuteRequestAsync calls: concurrent sessions race on shared static state + // (GlobalContext.Current, AppDomain exception handlers, Trace.Listeners, etc.) and can + // produce stochastic 0-result sessions. See GitHub issue "MTP server mode: multi-session + // findings from a mutation-testing prototype". + private readonly SemaphoreSlim _executeRequestLock = new(1, 1); public TUnitTestFramework( IExtension extension, @@ -49,6 +54,10 @@ public Task CreateTestSessionAsync(CreateTestSessionCon #endif public async Task ExecuteRequestAsync(ExecuteRequestContext context) { + // Serialize concurrent requests: shared static state (GlobalContext.Current, + // AppDomain exception handlers, Trace.Listeners) is not safe for concurrent + // modification. Sequential execution is deterministic and correct. + await _executeRequestLock.WaitAsync(context.CancellationToken).ConfigureAwait(false); try { var serviceProvider = GetOrCreateServiceProvider(context); @@ -91,6 +100,7 @@ public async Task ExecuteRequestAsync(ExecuteRequestContext context) } finally { + _executeRequestLock.Release(); context.Complete(); } } diff --git a/TUnit.Engine/TUnitInitializer.cs b/TUnit.Engine/TUnitInitializer.cs index 4c071469f5..0c7394332f 100644 --- a/TUnit.Engine/TUnitInitializer.cs +++ b/TUnit.Engine/TUnitInitializer.cs @@ -10,6 +10,9 @@ namespace TUnit.Engine; internal class TUnitInitializer(ICommandLineOptions commandLineOptions, IHookRegistrar hookDiscoveryService) { + private static volatile bool _globalHandlersConfigured; + private static volatile bool _traceListenersConfigured; + public void Initialize(ExecuteRequestContext context) { ConfigureGlobalExceptionHandlers(context); @@ -27,6 +30,12 @@ public void Initialize(ExecuteRequestContext context) private void SetUpExceptionListeners() { + if (_traceListenersConfigured) + { + return; + } + + _traceListenersConfigured = true; Trace.Listeners.Insert(0, new ThrowListener()); } @@ -50,6 +59,13 @@ private void ParseParameters() private static void ConfigureGlobalExceptionHandlers(ExecuteRequestContext context) { + if (_globalHandlersConfigured) + { + return; + } + + _globalHandlersConfigured = true; + // Handle unhandled exceptions on any thread AppDomain.CurrentDomain.UnhandledException += (_, args) => { diff --git a/TUnit.RpcTests/Tests.cs b/TUnit.RpcTests/Tests.cs index e5d41b7208..51bf3db705 100644 --- a/TUnit.RpcTests/Tests.cs +++ b/TUnit.RpcTests/Tests.cs @@ -79,4 +79,82 @@ public async Task RunTests_WithSkippedTest_ReportsSkippedState(string framework, await Assert.That(skipped).Count().IsGreaterThan(0); } + + [Test] + [Timeout(300_000)] + [Retry(3)] + [MethodDataSource(nameof(Frameworks))] + public async Task RunTests_MultipleSequentialSessions_ProducesConsistentResults(string framework, CancellationToken cancellationToken) + { + await using var session = await TestHostSession.StartAsync(framework, cancellationToken); + + var discovered = await session.DiscoverAsync(cancellationToken); + + var basicTests = discovered + .Select(x => x.Node) + .Where(node => node.Uid.Contains(".BasicTests.")) + .ToArray(); + + await Assert.That(basicTests).Count().IsGreaterThan(0); + + // Run the same tests 3 times sequentially against the same server process. + // Each run should produce the same number of results — verifying that shared + // static state (Sources, GlobalContext, hooks) is not drained or corrupted. + for (var i = 0; i < 3; i++) + { + var runUpdates = await session.RunAsync(basicTests, cancellationToken); + + var executed = runUpdates.Where(x => x.Node.ExecutionState is not null and not "in-progress").ToArray(); + var passed = executed.Count(x => x.Node.ExecutionState == "passed"); + + using (Assert.Multiple()) + { + await Assert.That(executed).Count().IsEqualTo(basicTests.Length) + .Because($"Sequential run {i + 1} of 3 should execute all {basicTests.Length} tests"); + await Assert.That(passed).IsEqualTo(basicTests.Length) + .Because($"Sequential run {i + 1} of 3 should pass all tests"); + } + } + } + + [Test] + [Timeout(300_000)] + [Retry(3)] + [MethodDataSource(nameof(Frameworks))] + public async Task RunTests_ConcurrentSessions_ProducesResults(string framework, CancellationToken cancellationToken) + { + await using var session = await TestHostSession.StartAsync(framework, cancellationToken); + + var discovered = await session.DiscoverAsync(cancellationToken); + + var basicTests = discovered + .Select(x => x.Node) + .Where(node => node.Uid.Contains(".BasicTests.")) + .ToArray(); + + await Assert.That(basicTests).Count().IsGreaterThan(0); + + // Fire 3 concurrent runTests RPCs to the same server process. + // With the serialization fix, all should complete with correct results + // (they queue up and execute sequentially under the lock). + var tasks = Enumerable.Range(0, 3) + .Select(_ => session.RunAsync(basicTests, cancellationToken)) + .ToArray(); + + var results = await Task.WhenAll(tasks); + + for (var i = 0; i < results.Length; i++) + { + var executed = results[i].Where(x => x.Node.ExecutionState is not null and not "in-progress").ToArray(); + var passed = executed.Count(x => x.Node.ExecutionState == "passed"); + + using (Assert.Multiple()) + { + await Assert.That(executed).Count().IsEqualTo(basicTests.Length) + .Because($"Concurrent run {i + 1} of 3 should execute all {basicTests.Length} tests"); + await Assert.That(passed).IsEqualTo(basicTests.Length) + .Because($"Concurrent run {i + 1} of 3 should pass all tests"); + } + } + } } From 7563ae256b9b80d639d12a46ed5880dd00293a51 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 22 May 2026 17:43:02 +0000 Subject: [PATCH 3/3] fix: scope GlobalContext.Current to AsyncLocal for concurrent MTP sessions Replace the shared static field with AsyncLocal (matching TestSessionContext, BeforeTestDiscoveryContext, TestDiscoveryContext). A process-level DefaultInstance serves source-gen module initializers that run before any session sets the AsyncLocal value. This allows concurrent testing/runTests RPCs to each have their own GlobalContext without overwriting each other, enabling true concurrent multi-session support in MTP server mode. Removes the SemaphoreSlim serialization approach (band-aid) in favor of properly scoped state as requested. --- TUnit.Core/Models/GlobalContext.cs | 17 +++++++++-------- TUnit.Engine/Framework/TUnitTestFramework.cs | 10 ---------- 2 files changed, 9 insertions(+), 18 deletions(-) diff --git a/TUnit.Core/Models/GlobalContext.cs b/TUnit.Core/Models/GlobalContext.cs index 60931fb9db..021c732822 100644 --- a/TUnit.Core/Models/GlobalContext.cs +++ b/TUnit.Core/Models/GlobalContext.cs @@ -6,16 +6,17 @@ namespace TUnit.Core; public class GlobalContext : Context { - // Static, not AsyncLocal: a lazy-creating AsyncLocal getter poisons the first - // reading branch with a fresh empty instance, hiding the framework's later - // Current = ... assignment from that branch. - private static GlobalContext? _current; + // Process-level default instance used by source-gen module initializers that run + // before any test session starts (and thus before any AsyncLocal value is set). + private static readonly GlobalContext DefaultInstance = new(); + + // AsyncLocal so concurrent sessions each get their own context without races. + private static readonly AsyncLocal Contexts = new(); + public static new GlobalContext Current { - // Factory overload — the parameterless one uses Activator.CreateInstance(), - // which AOT trimming may not preserve for GlobalContext's internal ctor. - get => LazyInitializer.EnsureInitialized(ref _current, static () => new GlobalContext())!; - internal set => Volatile.Write(ref _current, value); + get => Contexts.Value ?? DefaultInstance; + internal set => Contexts.Value = value; } internal GlobalContext() : base(null) diff --git a/TUnit.Engine/Framework/TUnitTestFramework.cs b/TUnit.Engine/Framework/TUnitTestFramework.cs index 7e95293ccb..aa4ec04454 100644 --- a/TUnit.Engine/Framework/TUnitTestFramework.cs +++ b/TUnit.Engine/Framework/TUnitTestFramework.cs @@ -18,11 +18,6 @@ internal sealed class TUnitTestFramework : ITestFramework, IDataProducer private readonly ITestFrameworkCapabilities _capabilities; private readonly ConcurrentDictionary _serviceProvidersPerSession = new(); private readonly IRequestHandler _requestHandler; - // Serialize ExecuteRequestAsync calls: concurrent sessions race on shared static state - // (GlobalContext.Current, AppDomain exception handlers, Trace.Listeners, etc.) and can - // produce stochastic 0-result sessions. See GitHub issue "MTP server mode: multi-session - // findings from a mutation-testing prototype". - private readonly SemaphoreSlim _executeRequestLock = new(1, 1); public TUnitTestFramework( IExtension extension, @@ -54,10 +49,6 @@ public Task CreateTestSessionAsync(CreateTestSessionCon #endif public async Task ExecuteRequestAsync(ExecuteRequestContext context) { - // Serialize concurrent requests: shared static state (GlobalContext.Current, - // AppDomain exception handlers, Trace.Listeners) is not safe for concurrent - // modification. Sequential execution is deterministic and correct. - await _executeRequestLock.WaitAsync(context.CancellationToken).ConfigureAwait(false); try { var serviceProvider = GetOrCreateServiceProvider(context); @@ -100,7 +91,6 @@ public async Task ExecuteRequestAsync(ExecuteRequestContext context) } finally { - _executeRequestLock.Release(); context.Complete(); } }