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
71 changes: 15 additions & 56 deletions TUnit.Engine/Events/EventReceiverRegistry.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using System.Runtime.CompilerServices;
using TUnit.Core.Interfaces;

Expand Down Expand Up @@ -26,42 +27,32 @@ private enum EventTypes

private volatile EventTypes _registeredEvents = EventTypes.None;
private readonly Dictionary<Type, object[]> _receiversByType = new();
private readonly Dictionary<Type, Array> _cachedTypedReceivers = new();
private readonly ReaderWriterLockSlim _lock = new();
private readonly ConcurrentDictionary<Type, Array> _cachedTypedReceivers = new();
private readonly object _lock = new();

/// <summary>
/// Register event receivers from a collection of objects
/// </summary>
public void RegisterReceivers(ReadOnlySpan<object> objects)
{
_lock.EnterWriteLock();
try
lock (_lock)
{
foreach (var obj in objects)
{
RegisterReceiverInternal(obj);
}
}
finally
{
_lock.ExitWriteLock();
}
}

/// <summary>
/// Register a single event receiver
/// </summary>
public void RegisterReceiver(object receiver)
{
_lock.EnterWriteLock();
try
lock (_lock)
{
RegisterReceiverInternal(receiver);
}
finally
{
_lock.ExitWriteLock();
}
}

private void RegisterReceiverInternal(object receiver)
Expand Down Expand Up @@ -143,64 +134,37 @@ public T[] GetReceiversOfType<T>() where T : class
{
var typeKey = typeof(T);

_lock.EnterReadLock();
try
{
if (_cachedTypedReceivers.TryGetValue(typeKey, out var cached))
{
return (T[])cached;
}
}
finally
// Lock-free fast path for cache hit (common case)
if (_cachedTypedReceivers.TryGetValue(typeKey, out var cached))
{
_lock.ExitReadLock();
return (T[])cached;
}

_lock.EnterUpgradeableReadLock();
try
// Simple lock for cache miss (rare, happens once per type)
lock (_lock)
{
if (_cachedTypedReceivers.TryGetValue(typeKey, out var cached))
// Double-check after acquiring lock
if (_cachedTypedReceivers.TryGetValue(typeKey, out cached))
{
return (T[])cached;
}

// Build and cache
if (_receiversByType.TryGetValue(typeKey, out var receivers))
{
var typedArray = new T[receivers.Length];
for (var i = 0; i < receivers.Length; i++)
{
typedArray[i] = (T)receivers[i];
}

_lock.EnterWriteLock();
try
{
_cachedTypedReceivers[typeKey] = typedArray;
}
finally
{
_lock.ExitWriteLock();
}

_cachedTypedReceivers[typeKey] = typedArray;
return typedArray;
}

T[] emptyArray = [];
_lock.EnterWriteLock();
try
{
_cachedTypedReceivers[typeKey] = emptyArray;
}
finally
{
_lock.ExitWriteLock();
}
_cachedTypedReceivers[typeKey] = emptyArray;
return emptyArray;
}
finally
{
_lock.ExitUpgradeableReadLock();
}
}

private void UpdateEventFlags(object receiver)
Expand Down Expand Up @@ -247,9 +211,4 @@ private void UpdateEventFlags(object receiver)
}
}


public void Dispose()
{
_lock?.Dispose();
}
}
2 changes: 1 addition & 1 deletion TUnit.Engine/Services/EventReceiverOrchestrator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,6 @@ public void InitializeTestCounts(IEnumerable<TestContext> allTestContexts)

public void Dispose()
{
_registry.Dispose();
// No longer need to dispose _registry - it no longer uses ReaderWriterLockSlim
}
}
18 changes: 9 additions & 9 deletions TUnit.Engine/Services/TestExecution/TestCoordinator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ await RetryHelper.ExecuteWithRetry(test.Context, async () =>
: null;

await TimeoutHelper.ExecuteWithTimeoutAsync(
ct => ExecuteTestLifecycleAsync(test, ct),
ct => ExecuteTestLifecycleAsync(test, ct).AsTask(),
testTimeout,
cancellationToken,
timeoutMessage).ConfigureAwait(false);
Expand All @@ -148,7 +148,7 @@ await TimeoutHelper.ExecuteWithTimeoutAsync(
}
finally
{
var cleanupExceptions = new List<Exception>();
List<Exception>? cleanupExceptions = null;

// Flush console interceptors to ensure all buffered output is captured
// This is critical for output from Console.Write() without newline
Expand All @@ -162,7 +162,7 @@ await TimeoutHelper.ExecuteWithTimeoutAsync(
await _logger.LogErrorAsync($"Error flushing console output for {test.TestId}: {flushEx}").ConfigureAwait(false);
}

await _objectTracker.UntrackObjects(test.Context, cleanupExceptions).ConfigureAwait(false);
await _objectTracker.UntrackObjects(test.Context, cleanupExceptions ??= []).ConfigureAwait(false);

var testClass = test.Metadata.TestClassType;
var testAssembly = testClass.Assembly;
Expand All @@ -174,7 +174,7 @@ await TimeoutHelper.ExecuteWithTimeoutAsync(
{
await _logger.LogErrorAsync($"Error executing After hooks for {test.TestId}: {ex}").ConfigureAwait(false);
}
cleanupExceptions.AddRange(hookExceptions);
(cleanupExceptions ??= []).AddRange(hookExceptions);
}

// Invoke Last event receivers for class and assembly
Expand All @@ -188,7 +188,7 @@ await _eventReceiverOrchestrator.InvokeLastTestInClassEventReceiversAsync(
catch (Exception ex)
{
await _logger.LogErrorAsync($"Error in last test in class event receiver for {test.TestId}: {ex}").ConfigureAwait(false);
cleanupExceptions.Add(ex);
(cleanupExceptions ??= []).Add(ex);
}

try
Expand All @@ -201,7 +201,7 @@ await _eventReceiverOrchestrator.InvokeLastTestInAssemblyEventReceiversAsync(
catch (Exception ex)
{
await _logger.LogErrorAsync($"Error in last test in assembly event receiver for {test.TestId}: {ex}").ConfigureAwait(false);
cleanupExceptions.Add(ex);
(cleanupExceptions ??= []).Add(ex);
}

try
Expand All @@ -214,11 +214,11 @@ await _eventReceiverOrchestrator.InvokeLastTestInSessionEventReceiversAsync(
catch (Exception ex)
{
await _logger.LogErrorAsync($"Error in last test in session event receiver for {test.TestId}: {ex}").ConfigureAwait(false);
cleanupExceptions.Add(ex);
(cleanupExceptions ??= []).Add(ex);
}

// If any cleanup exceptions occurred, mark the test as failed
if (cleanupExceptions.Count > 0)
if (cleanupExceptions is { Count: > 0 })
{
var aggregatedException = cleanupExceptions.Count == 1
? cleanupExceptions[0]
Expand Down Expand Up @@ -279,7 +279,7 @@ private void CollectAllDependencies(AbstractExecutableTest test, HashSet<TestDet
/// Core test lifecycle execution: instance creation, initialization, execution, and disposal.
/// Extracted to allow bypassing retry/timeout wrappers when not needed.
/// </summary>
private async Task ExecuteTestLifecycleAsync(AbstractExecutableTest test, CancellationToken cancellationToken)
private async ValueTask ExecuteTestLifecycleAsync(AbstractExecutableTest test, CancellationToken cancellationToken)
{
test.Context.Metadata.TestDetails.ClassInstance = await test.CreateInstanceAsync().ConfigureAwait(false);

Expand Down
2 changes: 1 addition & 1 deletion TUnit.Engine/TestDiscoveryService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ public async Task<TestDiscoveryResult> DiscoverTests(string testSessionId, ITest
}
}

filteredTests = testsToInclude.ToList();
filteredTests = [.. testsToInclude];
}

contextProvider.TestDiscoveryContext.AddTests(allTests.Select(static t => t.Context));
Expand Down
Loading