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/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"); + } + } + } }