Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,26 @@ private static void GenerateStaticPropertyInitialization(SourceProductionContext
return;
}

var allStaticPropertiesList = new List<PropertyWithDataSource>();
// Use a dictionary to deduplicate static properties by their declaring type and name
// This prevents duplicate initialization when derived classes inherit static properties
var uniqueStaticProperties = new Dictionary<(INamedTypeSymbol DeclaringType, string Name), PropertyWithDataSource>(SymbolEqualityComparer.Default.ToTupleComparer());

foreach (var testClass in testClasses)
{
allStaticPropertiesList.AddRange(GetStaticPropertyDataSources(testClass));
var properties = GetStaticPropertyDataSources(testClass);
foreach (var prop in properties)
{
// Static properties belong to their declaring type, not derived types
// Only add if we haven't seen this exact property before
var key = (prop.Property.ContainingType, prop.Property.Name);
if (!uniqueStaticProperties.ContainsKey(key))
{
uniqueStaticProperties[key] = prop;
}
}
}
var allStaticProperties = allStaticPropertiesList.ToImmutableArray();

var allStaticProperties = uniqueStaticProperties.Values.ToImmutableArray();

if (allStaticProperties.IsEmpty)
{
Expand Down Expand Up @@ -165,6 +179,9 @@ private static void GeneratePropertyInitialization(CodeWriter writer, PropertyWi
writer.AppendLine("// Initialize the injected value");
writer.AppendLine("await global::TUnit.Core.ObjectInitializer.InitializeAsync(value);");

writer.AppendLine("// Track the static property for disposal");
writer.AppendLine("global::TUnit.Core.Tracking.ObjectTracker.TrackObject(global::TUnit.Core.TestSessionContext.GlobalStaticPropertyContext.Events, value);");

writer.Unindent();
writer.AppendLine("}");

Expand Down
33 changes: 32 additions & 1 deletion TUnit.Core.SourceGenerator/Extensions/SymbolExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Microsoft.CodeAnalysis;
using System.Collections.Generic;
using Microsoft.CodeAnalysis;

namespace TUnit.Core.SourceGenerator.Extensions;

Expand Down Expand Up @@ -27,4 +28,34 @@ public static bool IsConst(this ISymbol? symbol, out object? constantValue)
constantValue = null;
return false;
}

/// <summary>
/// Creates an IEqualityComparer for tuples that uses SymbolEqualityComparer for symbol comparison
/// </summary>
public static IEqualityComparer<(INamedTypeSymbol, string)> ToTupleComparer(this IEqualityComparer<ISymbol> comparer)
{
return new TupleSymbolComparer(comparer);
}

private class TupleSymbolComparer : IEqualityComparer<(INamedTypeSymbol, string)>
{
private readonly IEqualityComparer<ISymbol> _symbolComparer;

public TupleSymbolComparer(IEqualityComparer<ISymbol> symbolComparer)
{
_symbolComparer = symbolComparer;
}

public bool Equals((INamedTypeSymbol, string) x, (INamedTypeSymbol, string) y)
{
return _symbolComparer.Equals(x.Item1, y.Item1) && x.Item2 == y.Item2;
}

public int GetHashCode((INamedTypeSymbol, string) obj)
{
var hash1 = _symbolComparer.GetHashCode(obj.Item1);
var hash2 = obj.Item2?.GetHashCode() ?? 0;
return hash1 ^ hash2;
}
}
}
27 changes: 20 additions & 7 deletions TUnit.Core/Executors/DedicatedThreadExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@ private void ExecuteAsyncActionWithMessagePump(Func<ValueTask> action, TaskCompl
}, CancellationToken.None, TaskCreationOptions.None, taskScheduler).Unwrap();

// Try fast path first - many tests complete quickly
if (task.Wait(10))
// Use IsCompleted to avoid synchronous wait
if (task.IsCompleted)
{
HandleTaskCompletion(task, tcs);
return;
Expand Down Expand Up @@ -301,14 +302,26 @@ public override void Send(SendOrPostCallback d, object? state)
}
}, null);

// Wait with a timeout to prevent infinite hangs
if (!tcs.Task.Wait(TimeSpan.FromMinutes(30)))
// Use a more robust synchronous wait pattern to avoid deadlocks
// We use Task.Run to ensure we don't capture the current SynchronizationContext
// which is a common cause of deadlocks
var waitTask = Task.Run(async () =>
{
throw new TimeoutException("Synchronous operation on dedicated thread timed out after 30 minutes");
}
// For .NET Standard 2.0 compatibility, use Task.Delay for timeout
var timeoutTask = Task.Delay(TimeSpan.FromMinutes(30));
var completedTask = await Task.WhenAny(tcs.Task, timeoutTask).ConfigureAwait(false);

if (completedTask == timeoutTask)
{
throw new TimeoutException("Synchronous operation on dedicated thread timed out after 30 minutes");
}

// Await the actual task to get its result or exception
await tcs.Task.ConfigureAwait(false);
});

// Re-throw any exception that occurred
tcs.Task.GetAwaiter().GetResult();
// This wait is safe because it's on a Task.Run thread without SynchronizationContext
waitTask.GetAwaiter().GetResult();
}
}

Expand Down
80 changes: 50 additions & 30 deletions TUnit.Core/Helpers/Counter.cs
Original file line number Diff line number Diff line change
@@ -1,54 +1,74 @@
using System.Diagnostics;
using System.Diagnostics;
using System.Threading;

namespace TUnit.Core.Helpers;

[DebuggerDisplay("Count = {CurrentCount}")]
public class Counter
{
private readonly Lock _locker = new();

private int _count;


// Use volatile to ensure proper visibility of the event field across threads
private volatile EventHandler<int>? _onCountChanged;

public int Increment()
{
lock (_locker)
{
_count++;
OnCountChanged?.Invoke(this, _count);
return _count;
}
var newCount = Interlocked.Increment(ref _count);

// Get a snapshot of the event handler to avoid race conditions
var handler = _onCountChanged;
handler?.Invoke(this, newCount);

return newCount;
}

public int Decrement()
{
lock (_locker)
{
_count--;
OnCountChanged?.Invoke(this, _count);
return _count;
}
var newCount = Interlocked.Decrement(ref _count);

// Get a snapshot of the event handler to avoid race conditions
var handler = _onCountChanged;
handler?.Invoke(this, newCount);

return newCount;
}

public int Add(int value)
{
lock (_locker)
{
_count += value;
OnCountChanged?.Invoke(this, _count);
return _count;
}
var newCount = Interlocked.Add(ref _count, value);

// Get a snapshot of the event handler to avoid race conditions
var handler = _onCountChanged;
handler?.Invoke(this, newCount);

return newCount;
}

public int CurrentCount
public int CurrentCount => Interlocked.CompareExchange(ref _count, 0, 0);

public event EventHandler<int>? OnCountChanged
{
get
add
{
// Use Interlocked.CompareExchange for thread-safe event subscription
EventHandler<int>? current;
EventHandler<int>? newHandler;
do
{
current = _onCountChanged;
newHandler = (EventHandler<int>?)Delegate.Combine(current, value);
} while (Interlocked.CompareExchange(ref _onCountChanged, newHandler, current) != current);
}
remove
{
lock (_locker)
// Use Interlocked.CompareExchange for thread-safe event unsubscription
EventHandler<int>? current;
EventHandler<int>? newHandler;
do
{
return _count;
}
current = _onCountChanged;
newHandler = (EventHandler<int>?)Delegate.Remove(current, value);
} while (Interlocked.CompareExchange(ref _onCountChanged, newHandler, current) != current);
}
}

public EventHandler<int>? OnCountChanged;
}
}
25 changes: 13 additions & 12 deletions TUnit.Core/PropertyInjectionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,22 +84,23 @@ public static Task InjectPropertiesIntoObjectAsync(object instance, Dictionary<s
{
// Start with an empty visited set for cycle detection
#if NETSTANDARD2_0
var visitedObjects = new HashSet<object>();
var visitedObjects = new ConcurrentDictionary<object, byte>();
#else
var visitedObjects = new HashSet<object>(System.Collections.Generic.ReferenceEqualityComparer.Instance);
var visitedObjects = new ConcurrentDictionary<object, byte>(System.Collections.Generic.ReferenceEqualityComparer.Instance);
#endif
return InjectPropertiesIntoObjectAsyncCore(instance, objectBag, methodMetadata, events, visitedObjects);
}

private static async Task InjectPropertiesIntoObjectAsyncCore(object instance, Dictionary<string, object?>? objectBag, MethodMetadata? methodMetadata, TestContextEvents? events, HashSet<object> visitedObjects)
private static async Task InjectPropertiesIntoObjectAsyncCore(object instance, Dictionary<string, object?>? objectBag, MethodMetadata? methodMetadata, TestContextEvents? events, ConcurrentDictionary<object, byte> visitedObjects)
{
if (instance == null)
{
return;
}

// Prevent cycles - if we're already processing this object, skip it
if (!visitedObjects.Add(instance))
// TryAdd returns false if the key already exists (thread-safe)
if (!visitedObjects.TryAdd(instance, 0))
{
return;
}
Expand Down Expand Up @@ -249,7 +250,7 @@ private static PropertyInjectionPlan GetOrCreateInjectionPlan(Type type)
/// <summary>
/// Injects properties using a cached source-generated plan.
/// </summary>
private static async Task InjectPropertiesUsingPlanAsync(object instance, PropertyInjectionMetadata[] properties, Dictionary<string, object?> objectBag, MethodMetadata? methodMetadata, TestContextEvents events, HashSet<object> visitedObjects)
private static async Task InjectPropertiesUsingPlanAsync(object instance, PropertyInjectionMetadata[] properties, Dictionary<string, object?> objectBag, MethodMetadata? methodMetadata, TestContextEvents events, ConcurrentDictionary<object, byte> visitedObjects)
{
if (properties.Length == 0)
{
Expand All @@ -268,7 +269,7 @@ private static async Task InjectPropertiesUsingPlanAsync(object instance, Proper
/// Injects properties using a cached reflection plan.
/// </summary>
[UnconditionalSuppressMessage("Trimming", "IL2075:\'this\' argument does not satisfy \'DynamicallyAccessedMembersAttribute\' in call to target method. The return value of the source method does not have matching annotations.")]
private static async Task InjectPropertiesUsingReflectionPlanAsync(object instance, (PropertyInfo Property, IDataSourceAttribute DataSource)[] properties, Dictionary<string, object?> objectBag, MethodMetadata? methodMetadata, TestContextEvents events, HashSet<object> visitedObjects)
private static async Task InjectPropertiesUsingReflectionPlanAsync(object instance, (PropertyInfo Property, IDataSourceAttribute DataSource)[] properties, Dictionary<string, object?> objectBag, MethodMetadata? methodMetadata, TestContextEvents events, ConcurrentDictionary<object, byte> visitedObjects)
{
if (properties.Length == 0)
{
Expand All @@ -288,7 +289,7 @@ private static async Task InjectPropertiesUsingReflectionPlanAsync(object instan
/// </summary>
[UnconditionalSuppressMessage("Trimming", "IL2072:Target parameter argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method. The return value of the source method does not have matching annotations.")]
private static async Task ProcessPropertyMetadata(object instance, PropertyInjectionMetadata metadata, Dictionary<string, object?> objectBag, MethodMetadata? methodMetadata,
TestContextEvents events, HashSet<object> visitedObjects, TestContext? testContext = null)
TestContextEvents events, ConcurrentDictionary<object, byte> visitedObjects, TestContext? testContext = null)
{
var dataSource = metadata.CreateDataSource();
var propertyMetadata = new PropertyMetadata
Expand Down Expand Up @@ -339,7 +340,7 @@ private static async Task ProcessPropertyMetadata(object instance, PropertyInjec
/// Processes a property data source using reflection mode.
/// </summary>
[UnconditionalSuppressMessage("Trimming", "IL2072:Target parameter argument does not satisfy \'DynamicallyAccessedMembersAttribute\' in call to target method. The return value of the source method does not have matching annotations.")]
private static async Task ProcessReflectionPropertyDataSource(object instance, PropertyInfo property, IDataSourceAttribute dataSource, Dictionary<string, object?> objectBag, MethodMetadata? methodMetadata, TestContextEvents events, HashSet<object> visitedObjects, TestContext? testContext = null)
private static async Task ProcessReflectionPropertyDataSource(object instance, PropertyInfo property, IDataSourceAttribute dataSource, Dictionary<string, object?> objectBag, MethodMetadata? methodMetadata, TestContextEvents events, ConcurrentDictionary<object, byte> visitedObjects, TestContext? testContext = null)
{
// Use centralized factory for reflection mode
var dataGeneratorMetadata = DataGeneratorMetadataCreator.CreateForPropertyInjection(
Expand Down Expand Up @@ -379,7 +380,7 @@ private static async Task ProcessReflectionPropertyDataSource(object instance, P
/// <summary>
/// Processes a single injected property value: tracks it, initializes it, sets it on the instance.
/// </summary>
private static async Task ProcessInjectedPropertyValue(object instance, object? propertyValue, Action<object, object?> setProperty, Dictionary<string, object?> objectBag, MethodMetadata? methodMetadata, TestContextEvents events, HashSet<object> visitedObjects)
private static async Task ProcessInjectedPropertyValue(object instance, object? propertyValue, Action<object, object?> setProperty, Dictionary<string, object?> objectBag, MethodMetadata? methodMetadata, TestContextEvents events, ConcurrentDictionary<object, byte> visitedObjects)
{
if (propertyValue == null)
{
Expand Down Expand Up @@ -593,11 +594,11 @@ public static async Task InjectPropertiesAsync(
// Use the modern service for recursive injection and initialization
// Create a new visited set for this legacy call
#if NETSTANDARD2_0
var visitedObjects = new HashSet<object>();
var visitedObjects = new ConcurrentDictionary<object, byte>();
#else
var visitedObjects = new HashSet<object>(System.Collections.Generic.ReferenceEqualityComparer.Instance);
var visitedObjects = new ConcurrentDictionary<object, byte>(System.Collections.Generic.ReferenceEqualityComparer.Instance);
#endif
visitedObjects.Add(instance); // Add the current instance to prevent re-processing
visitedObjects.TryAdd(instance, 0); // Add the current instance to prevent re-processing
await ProcessInjectedPropertyValue(instance, value, propertyInjection.Setter, objectBag, testInformation, testContext.Events, visitedObjects);
// Add to TestClassInjectedPropertyArguments for tracking
testContext.TestDetails.TestClassInjectedPropertyArguments[propertyInjection.PropertyName] = value;
Expand Down
Loading
Loading