Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 9 additions & 8 deletions TUnit.Core/Models/GlobalContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<GlobalContext?> Contexts = new();

public static new GlobalContext Current
{
// Factory overload — the parameterless one uses Activator.CreateInstance<T>(),
// 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)
Expand Down
16 changes: 16 additions & 0 deletions TUnit.Engine/TUnitInitializer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -27,6 +30,12 @@ public void Initialize(ExecuteRequestContext context)

private void SetUpExceptionListeners()
{
if (_traceListenersConfigured)
{
return;
}

_traceListenersConfigured = true;
Trace.Listeners.Insert(0, new ThrowListener());
}

Expand All @@ -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) =>
{
Expand Down
78 changes: 78 additions & 0 deletions TUnit.RpcTests/Tests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
}
}
}