Skip to content
Merged
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
53 changes: 45 additions & 8 deletions src/TUnit.Core/ContextProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ internal class ContextProvider(IServiceProvider serviceProvider, string testSess
{
private readonly ConcurrentDictionary<Assembly, AssemblyHookContext> _assemblyContexts = new();
private readonly ConcurrentDictionary<Type, ClassHookContext> _classContexts = new();
private readonly Lock _creationLock = new();

public GlobalContext GlobalContext { get; } = new()
{
Expand Down Expand Up @@ -51,11 +52,30 @@ internal class ContextProvider(IServiceProvider serviceProvider, string testSess
/// </summary>
public AssemblyHookContext GetOrCreateAssemblyContext(Assembly assembly)
{
return _assemblyContexts.GetOrAdd(assembly, static (assembly, context) =>
new AssemblyHookContext(context)
if (_assemblyContexts.TryGetValue(assembly, out var existing))
{
return existing;
}

// Not ConcurrentDictionary.GetOrAdd: the context constructor registers itself with its
// parent, so a factory that loses the GetOrAdd race would leave an orphaned, test-less
// context visible through TestSessionContext.Assemblies. Creation happens once per
// assembly, so the lock is off the hot path.
lock (_creationLock)
{
if (_assemblyContexts.TryGetValue(assembly, out existing))
{
return existing;
}

var created = new AssemblyHookContext(TestSessionContext)
{
Assembly = assembly
}, TestSessionContext);
};

_assemblyContexts[assembly] = created;
return created;
}
}

/// <summary>
Expand All @@ -67,14 +87,31 @@ public ClassHookContext GetOrCreateClassContext(
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicMethods)]
Type classType)
{
return _classContexts.GetOrAdd(classType, static ([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicMethods)]
type, state) =>
if (_classContexts.TryGetValue(classType, out var existing))
{
return new ClassHookContext(state.GetOrCreateAssemblyContext(type.Assembly))
return existing;
}

// See GetOrCreateAssemblyContext: a lost GetOrAdd race would leave an orphaned class
// context in AssemblyHookContext.TestClasses. The assembly context is resolved before
// taking _creationLock on purpose, so the assembly and class paths never nest the lock.
var assemblyContext = GetOrCreateAssemblyContext(classType.Assembly);

lock (_creationLock)
{
if (_classContexts.TryGetValue(classType, out existing))
{
ClassType = type
return existing;
}

var created = new ClassHookContext(assemblyContext)
{
ClassType = classType
};
}, this);

_classContexts[classType] = created;
return created;
}
}

/// <summary>
Expand Down
37 changes: 31 additions & 6 deletions src/TUnit.Core/Models/AssemblyHookContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,26 @@ internal AssemblyHookContext(TestSessionContext testSessionContext) : base(testS
private readonly List<ClassHookContext> _testClasses = [];
private TestContext[]? _cachedAllTests;

// Set when the last class was removed and the assembly was detached from the session. Guarded by _lock.
private bool _detached;

public void AddClass(ClassHookContext classHookContext)
{
lock (_lock)
{
_testClasses.Add(classHookContext);
InvalidateCache();

// A class reattached by a dynamic test after filtering emptied this assembly.
if (_detached)
{
_detached = false;
TestSessionContext.AddAssembly(this);
return;
}
}

TestSessionContext.InvalidateTestCaches();
}

public IReadOnlyList<ClassHookContext> TestClasses { get { lock (_lock) return [.. _testClasses]; } }
Expand All @@ -49,20 +62,32 @@ private void InvalidateCache()

internal bool FirstTestStarted { get; set; }

internal void RemoveClass(ClassHookContext classContext)
internal void InvalidateTestCaches()
{
bool empty;
lock (_lock)
{
_testClasses.Remove(classContext);
InvalidateCache();
empty = _testClasses.Count == 0;
}

if (empty)
TestSessionContext.InvalidateTestCaches();
}

internal void RemoveClass(ClassHookContext classContext)
{
lock (_lock)
{
TestSessionContext.RemoveAssembly(this);
_testClasses.Remove(classContext);
InvalidateCache();

if (_testClasses.Count == 0)
{
_detached = true;
TestSessionContext.RemoveAssembly(this);
return;
}
}

TestSessionContext.InvalidateTestCaches();
}

internal override void SetAsyncLocalContext()
Expand Down
56 changes: 52 additions & 4 deletions src/TUnit.Core/Models/ClassHookContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ internal ClassHookContext(AssemblyHookContext assemblyHookContext) : base(assemb
private readonly HashSet<TestContext> _testSet = new(ReferenceEqualityComparer<TestContext>.Instance);
private readonly List<TestContext> _tests = [];

// Set when the last test was removed and the class was detached from its assembly. Guarded by _lock.
private bool _detached;

public void AddTest(TestContext testContext)
{
lock (_lock)
Expand All @@ -38,6 +41,14 @@ public void AddTest(TestContext testContext)
return; // Prevent duplicates
}
_tests.Add(testContext);

// A dynamic test added at run time to a class whose built tests were all filtered out.
// Reattach under _lock so a concurrent detach cannot interleave with it.
if (_detached)
{
_detached = false;
AssemblyContext.AddClass(this);
}
}
}

Expand Down Expand Up @@ -76,20 +87,57 @@ public override int GetHashCode()
return ClassType.GetHashCode();
}

/// <summary>
/// Drops every test not in <paramref name="testsToKeep"/>. Used once filtering has decided which
/// built tests will run, so hooks only see participating tests. Removes the class from its assembly
/// when no tests remain.
/// </summary>
internal void RetainTests(HashSet<TestContext> testsToKeep)
{
lock (_lock)
{
if (_tests.RemoveAll(t => !testsToKeep.Contains(t)) == 0)
{
return;
}

_testSet.IntersectWith(testsToKeep);

if (_tests.Count is 0)
{
Detach();
return;
}
}

AssemblyContext.InvalidateTestCaches();
}

internal void RemoveTest(TestContext test)
{
bool empty;
lock (_lock)
{
_testSet.Remove(test);
_tests.Remove(test);
empty = _tests.Count is 0;

if (_tests.Count is 0)
{
Detach();
}
}
}

if (empty)
// Caller holds _lock. Lock order is class -> assembly -> session; the parents never call back
// into a class while holding their own lock.
private void Detach()
{
if (_detached)
{
AssemblyContext.RemoveClass(this);
return;
}

_detached = true;
AssemblyContext.RemoveClass(this);
}

internal override void SetAsyncLocalContext()
Expand Down
8 changes: 8 additions & 0 deletions src/TUnit.Core/Models/TestSessionContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,14 @@ private void InvalidateCaches()

internal bool FirstTestStarted { get; set; }

internal void InvalidateTestCaches()
{
lock (_lock)
{
InvalidateCaches();
}
}

private int _failureCount;

/// <summary>
Expand Down
41 changes: 40 additions & 1 deletion src/TUnit.Engine/Reporters/Html/ActivityCollector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,10 @@ private void OnActivityStopped(Activity activity)
// Enumerate* struct enumerators instead of LINQ ToArray over the IEnumerable
// properties: no boxed enumerator or intermediate array per span. Two passes over
// the (short) linked lists — count, then fill — keep the arrays exactly sized.
// Another thread can still add or remove tags/events/links on the activity between
// the passes, so the fill pass is bounded by the count and the array trimmed to what
// it actually saw. An exception here would escape through Activity.Stop() into
// whatever code stopped the activity.
var tags = ToReportKeyValues(activity.EnumerateTagObjects());

SpanEvent[]? events = null;
Expand All @@ -435,13 +439,20 @@ private void OnActivityStopped(Activity activity)
var i = 0;
foreach (ref readonly var evt in activity.EnumerateEvents())
{
if (i == events.Length)
{
break;
}

events[i++] = new SpanEvent
{
Name = evt.Name,
TimestampMs = evt.Timestamp.ToUnixTimeMilliseconds(),
Tags = ToReportKeyValues(evt.EnumerateTagObjects())
};
}

events = TrimToFilled(events, i);
}

var parentSpanId = activity.ParentSpanId != default ? activity.ParentSpanId.ToString() : null;
Expand All @@ -459,12 +470,19 @@ private void OnActivityStopped(Activity activity)
var i = 0;
foreach (ref readonly var link in activity.EnumerateLinks())
{
if (i == links.Length)
{
break;
}

links[i++] = new SpanLink
{
TraceId = link.Context.TraceId.ToString(),
SpanId = link.Context.SpanId.ToString()
};
}

links = TrimToFilled(links, i);
}

var statusStr = activity.Status switch
Expand Down Expand Up @@ -522,14 +540,35 @@ private void OnActivityStopped(Activity activity)
var i = 0;
foreach (ref readonly var tag in tagObjects)
{
if (i == result.Length)
{
break;
}

result[i++] = new ReportKeyValue
{
Key = tag.Key,
Value = tag.Value?.ToString() ?? ""
};
}

return result;
return TrimToFilled(result, i);
}

private static T[]? TrimToFilled<T>(T[] array, int filled)
{
if (filled == array.Length)
{
return array;
}

if (filled == 0)
{
return null;
}

Array.Resize(ref array, filled);
return array;
}

public void Dispose()
Expand Down
30 changes: 30 additions & 0 deletions src/TUnit.Engine/TestDiscoveryService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -116,12 +116,42 @@ public async Task<TestDiscoveryResult> DiscoverTests(string testSessionId, ITest

var finalTests = filteredTests as List<AbstractExecutableTest> ?? [.. filteredTests];

if (isForExecution && finalTests.Count != allTests.Count)
{
RemoveExcludedTestsFromHookContexts(allTests, finalTests);
}

await _testFilterService.RegisterTestsAsync(finalTests, isForExecution).ConfigureAwait(false);

var finalContext = ExecutionContext.Capture();
return new TestDiscoveryResult(finalTests, finalContext);
}

// Every built test was added to its ClassHookContext when its TestContext was created. Filters
// that can only be evaluated after building (e.g. on properties added at discovery time) leave
// tests in those contexts that will never run, so hooks would otherwise see them through
// TestClasses/AllTests/TestCount and fire work for classes that are not part of the run.
private static void RemoveExcludedTestsFromHookContexts(
List<AbstractExecutableTest> allTests,
List<AbstractExecutableTest> finalTests)
{
var testsToKeep = new HashSet<TestContext>(TUnit.Core.Helpers.ReferenceEqualityComparer<TestContext>.Instance);
foreach (var test in finalTests)
{
testsToKeep.Add(test.Context);
}

var visitedClasses = new HashSet<ClassHookContext>(TUnit.Core.Helpers.ReferenceEqualityComparer<ClassHookContext>.Instance);
foreach (var test in allTests)
{
var classContext = test.Context.ClassContext;
if (!testsToKeep.Contains(test.Context) && visitedClasses.Add(classContext))
{
classContext.RetainTests(testsToKeep);
}
}
}

// Project the resolved tests onto their contexts into a pre-sized list.
// TestDiscoveryContext.AddTests stores its argument directly when it is already an
// IReadOnlyList<TestContext> and only calls ToArray() otherwise. A List<TestContext>
Expand Down
Loading
Loading