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
112 changes: 66 additions & 46 deletions TUnit.Core/Discovery/ObjectGraphDiscoverer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,17 @@ internal sealed class ObjectGraphDiscoverer : IObjectGraphTracker
// Thread-safe collection of discovery errors for diagnostics
private static readonly ConcurrentBag<DiscoveryError> DiscoveryErrors = [];

// Memoize ShouldSkipType — Namespace.StartsWith("System") is expensive when repeated
// across every per-test initializer traversal.
private static readonly ConcurrentDictionary<Type, bool> ShouldSkipTypeCache = new();

// Cached flattened InitializerPropertyInfo[] for each type, including base-class
// properties with derived-first precedence. Traversal would otherwise walk the
// inheritance chain and allocate a HashSet<string> on every call.
// - non-null array: type has registered source-gen metadata (at this or an ancestor level).
// - null : no source-gen registration anywhere in hierarchy — use reflection fallback.
private static readonly ConcurrentDictionary<Type, InitializerPropertyInfo[]?> FlattenedInitializerPropertiesCache = new();

/// <summary>
/// Gets all discovery errors that occurred during object graph traversal.
/// Useful for debugging and diagnostics when property access fails.
Expand Down Expand Up @@ -249,18 +260,20 @@ public static void ClearCache()
{
PropertyCacheManager.ClearCache();
TypeHierarchyCache.Clear();
ShouldSkipTypeCache.Clear();
FlattenedInitializerPropertiesCache.Clear();
ClearDiscoveryErrors();
}

/// <summary>
/// Checks if a type should be skipped during discovery.
/// Checks if a type should be skipped during discovery. Result is memoized per type —
/// the underlying check (Namespace.StartsWith) is stable for any given type.
/// </summary>
private static bool ShouldSkipType(Type type)
{
return type.IsPrimitive ||
SkipTypes.Contains(type) ||
type.Namespace?.StartsWith("System") == true;
}
=> ShouldSkipTypeCache.GetOrAdd(type, static t =>
t.IsPrimitive ||
SkipTypes.Contains(t) ||
t.Namespace?.StartsWith("System", StringComparison.Ordinal) == true);

/// <summary>
/// Add to HashSet at specified depth. Returns true if added (not duplicate).
Expand Down Expand Up @@ -408,47 +421,64 @@ private static void TraverseInitializerProperties(
return;
}

// Track processed property names to handle overrides correctly
// (derived class properties take precedence over base class properties)
var processedPropertyNames = new HashSet<string>(StringComparer.Ordinal);
var hasAnySourceGenRegistration = false;
// Fetch the pre-flattened source-gen property list for this type. The flattening
// walks the inheritance chain once and caches the deduplicated result, so per-test
// traversal does not re-walk BaseType nor allocate a HashSet<string>.
var flattened = GetFlattenedInitializerProperties(type);

// Walk up the inheritance chain to find all IAsyncInitializer properties
// This ensures base class properties are discovered even when derived class has source-gen registration
var currentType = type;
while (currentType != null && currentType != typeof(object))
if (flattened != null)
{
cancellationToken.ThrowIfCancellationRequested();
TraverseFlattenedInitializerProperties(obj, type, flattened, tryAdd, recurse, currentDepth, cancellationToken);
return;
}

// No source-gen registration anywhere in the hierarchy → fall back to reflection.
TraverseInitializerPropertiesViaReflection(obj, type, tryAdd, recurse, currentDepth, cancellationToken);
}

var registeredProperties = InitializerPropertyRegistry.GetProperties(currentType);
if (registeredProperties != null)
/// <summary>
/// Returns the cached flattened InitializerPropertyInfo[] for a type (inheritance-walked,
/// derived-first precedence, deduplicated by property name). Returns null when no
/// source-gen registration exists in the type's inheritance chain.
/// </summary>
private static InitializerPropertyInfo[]? GetFlattenedInitializerProperties(Type type)
=> FlattenedInitializerPropertiesCache.GetOrAdd(type, static t =>
{
List<InitializerPropertyInfo>? merged = null;
HashSet<string>? seen = null;

for (var currentType = t; currentType != null && currentType != typeof(object); currentType = currentType.BaseType)
{
hasAnySourceGenRegistration = true;
TraverseRegisteredInitializerPropertiesWithTracking(
obj, currentType, registeredProperties, processedPropertyNames,
tryAdd, recurse, currentDepth, cancellationToken);
}
var registered = InitializerPropertyRegistry.GetProperties(currentType);
if (registered == null)
{
continue;
}

currentType = currentType.BaseType;
}
merged ??= new List<InitializerPropertyInfo>(registered.Length);
seen ??= new HashSet<string>(StringComparer.Ordinal);

// If no source-gen registration was found in the entire hierarchy, fall back to reflection
// Reflection path already handles inheritance correctly via GetProperties without DeclaredOnly
if (!hasAnySourceGenRegistration)
{
TraverseInitializerPropertiesViaReflection(obj, type, tryAdd, recurse, currentDepth, cancellationToken);
}
}
foreach (var p in registered)
{
if (seen.Add(p.PropertyName))
{
merged.Add(p);
}
}
}

return merged?.ToArray();
});

/// <summary>
/// Traverses source-generated IAsyncInitializer properties with property name tracking.
/// Skips properties that have already been processed (handles overrides in derived classes).
/// Traverses the pre-flattened source-generated IAsyncInitializer properties.
/// No per-call inheritance walk or HashSet allocation — that work was done once
/// during flattening and cached in <see cref="FlattenedInitializerPropertiesCache"/>.
/// </summary>
private static void TraverseRegisteredInitializerPropertiesWithTracking(
private static void TraverseFlattenedInitializerProperties(
object obj,
Type type,
InitializerPropertyInfo[] properties,
HashSet<string> processedPropertyNames,
TryAddObjectFunc tryAdd,
RecurseFunc recurse,
int currentDepth,
Expand All @@ -458,12 +488,6 @@ private static void TraverseRegisteredInitializerPropertiesWithTracking(
{
cancellationToken.ThrowIfCancellationRequested();

// Skip if already processed (overridden in derived class)
if (!processedPropertyNames.Add(propInfo.PropertyName))
{
continue;
}

try
{
var value = propInfo.GetValue(obj);
Expand All @@ -472,23 +496,19 @@ private static void TraverseRegisteredInitializerPropertiesWithTracking(
continue;
}

// Only discover IAsyncInitializer objects
if (value is IAsyncInitializer && tryAdd(value, currentDepth))
{
recurse(value, currentDepth + 1);
}
}
catch (OperationCanceledException)
{
throw; // Propagate cancellation
throw;
}
catch (Exception ex)
{
// Record error for diagnostics (still available via GetDiscoveryErrors())
DiscoveryErrors.Add(new DiscoveryError(type.Name, propInfo.PropertyName, ex.Message, ex));

// Propagate the exception with context about which property failed
// This ensures data source failures are reported as test failures
throw DataSourceException.FromNestedFailure(
$"Failed to access property '{propInfo.PropertyName}' on type '{type.Name}' during object graph discovery. " +
$"This may indicate that a data source or its nested dependencies failed to initialize. " +
Expand Down
8 changes: 8 additions & 0 deletions TUnit.Core/TestContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,13 @@ public void RegisterTrace(System.Diagnostics.ActivityTraceId traceId)
// Track the class instance used when building caches for invalidation on retry
internal object? CachedClassInstance { get; set; }

/// <summary>
/// Fast-path gate for EnsureEventReceiversCached. A single bool check replaces the
/// previous "cache-array is non-null" inspection that ran in every per-test receiver
/// getter (see <see cref="TUnit.Engine.Extensions.TestContextExtensions"/>).
/// </summary>
internal bool EventReceiversBuilt { get; set; }

/// <summary>
/// Invalidates all cached event receiver data. Called when class instance changes (e.g., on retry).
/// </summary>
Expand All @@ -421,6 +428,7 @@ internal void InvalidateEventReceiverCaches()
CachedTestDiscoveryReceivers = null;
CachedTestRegisteredReceivers = null;
CachedClassInstance = null;
EventReceiversBuilt = false;
}

internal ConcurrentDictionary<string, object?> ObjectBag => _testBuilderContext.StateBag;
Expand Down
30 changes: 19 additions & 11 deletions TUnit.Engine/Extensions/TestContextExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using TUnit.Core;
using System.Runtime.CompilerServices;
using TUnit.Core;
using TUnit.Core.Enums;
using TUnit.Core.Interfaces;
using TUnit.Engine.Utilities;
Expand All @@ -18,25 +19,31 @@ internal static class TestContextExtensions
/// When this happens, eligible event objects may include the new instance (if it implements
/// event receiver interfaces), so all caches must be invalidated and rebuilt.
/// </remarks>
// Fast-path gate: small enough (~10 bytes IL) for RyuJIT to reliably inline at every call
// site, collapsing the 99%+ cache-hit case into a field-load + branch + return. The heavy
// build work is outlined into BuildEventReceiverCaches so the JIT's inliner size budget
// never rejects this wrapper.
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void EnsureEventReceiversCached(TestContext testContext)
{
var currentClassInstance = testContext.Metadata.TestDetails.ClassInstance;

// Check if caches are valid (populated and class instance hasn't changed)
#if NET
if (testContext.CachedTestStartReceiversEarly != null &&
ReferenceEquals(testContext.CachedClassInstance, currentClassInstance))
{
return;
}
#else
if (testContext.CachedTestStartReceivers != null &&
// Fast path: caches populated and class instance unchanged since last build.
if (testContext.EventReceiversBuilt &&
ReferenceEquals(testContext.CachedClassInstance, currentClassInstance))
{
return;
}
#endif

BuildEventReceiverCaches(testContext, currentClassInstance);
}

// Explicitly NoInlining so the JIT keeps a single outlined copy instead of duplicating
// ~100 bytes of IL into every caller. currentClassInstance is threaded through from the
// gate to avoid a redundant field re-read.
[MethodImpl(MethodImplOptions.NoInlining)]
private static void BuildEventReceiverCaches(TestContext testContext, object? currentClassInstance)
{
// Invalidate stale caches if class instance changed
if (testContext.CachedClassInstance != null &&
!ReferenceEquals(testContext.CachedClassInstance, currentClassInstance))
Expand Down Expand Up @@ -138,6 +145,7 @@ private static void EnsureEventReceiversCached(TestContext testContext)

// Update cached class instance last
testContext.CachedClassInstance = currentClassInstance;
testContext.EventReceiversBuilt = true;
}

private static T[] SortAndFilter<T>(List<T>? receivers) where T : class, IEventReceiver
Expand Down
48 changes: 31 additions & 17 deletions TUnit.Engine/Scheduling/TestScheduler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ internal sealed class TestScheduler : ITestScheduler
private readonly StaticPropertyHandler _staticPropertyHandler;
private readonly IDynamicTestQueue _dynamicTestQueue;
private readonly Lazy<int> _maxParallelism;
#if !NET
private readonly Lazy<SemaphoreSlim> _maxParallelismSemaphore;
#endif

public TestScheduler(
TUnitFrameworkLogger logger,
Expand Down Expand Up @@ -57,11 +59,16 @@ public TestScheduler(

_maxParallelism = new Lazy<int>(() => GetMaxParallelism(logger, commandLineOptions));

#if !NET
// The .NET 8+ path uses Parallel.ForEachAsync which caps concurrency via
// ParallelOptions.MaxDegreeOfParallelism — the semaphore is only needed
// for the netstandard2.0 fallback path.
_maxParallelismSemaphore = new Lazy<SemaphoreSlim>(() =>
new SemaphoreSlim(_maxParallelism.Value, _maxParallelism.Value));
#endif
}

#if NET8_0_OR_GREATER
#if NET
[System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode("Test execution involves reflection for hooks and initialization")]
#endif
public async Task<bool> ScheduleAndExecuteAsync(
Expand Down Expand Up @@ -154,7 +161,7 @@ public async Task<bool> ScheduleAndExecuteAsync(
return true;
}

#if NET8_0_OR_GREATER
#if NET
[System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode("Test execution involves reflection for hooks and initialization")]
#endif
private async Task ExecuteGroupedTestsAsync(
Expand Down Expand Up @@ -230,7 +237,7 @@ private async Task ExecuteGroupedTestsAsync(
await dynamicTestProcessingTask.ConfigureAwait(false);
}

#if NET8_0_OR_GREATER
#if NET
[System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode("Test execution involves reflection for hooks and initialization")]
#endif
private async Task ProcessDynamicTestQueueAsync(CancellationToken cancellationToken)
Expand Down Expand Up @@ -303,20 +310,24 @@ private async Task ProcessDynamicTestQueueAsync(CancellationToken cancellationTo
}
}

#if NET8_0_OR_GREATER
#if NET
[System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode("Test execution involves reflection for hooks and initialization")]
#endif
private Task ExecuteTestsAsync(
AbstractExecutableTest[] tests,
CancellationToken cancellationToken)
{
// All paths run through the semaphore-backed limiter so the DOP cap is
// unified in GetMaxParallelism. "Unlimited" is resolved to the default
// cap (ProcessorCount * 4) there rather than being a separate code path.
// All paths run through the shared limiter so the DOP cap is unified in
// GetMaxParallelism. "Unlimited" is resolved to the default cap
// (ProcessorCount * 4) there rather than being a separate code path.
#if NET
return ExecuteWithGlobalLimitAsync(tests, cancellationToken);
#else
return ExecuteWithGlobalLimitAsync(tests, _maxParallelismSemaphore.Value, cancellationToken);
#endif
}

#if NET8_0_OR_GREATER
#if NET
[System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode("Test execution involves reflection for hooks and initialization")]
#endif
private async Task ExecuteSequentiallyAsync(
Expand All @@ -330,28 +341,31 @@ private async Task ExecuteSequentiallyAsync(
}
}

private async Task ExecuteWithGlobalLimitAsync(
#if NET
private Task ExecuteWithGlobalLimitAsync(
AbstractExecutableTest[] tests,
SemaphoreSlim globalSemaphore,
CancellationToken cancellationToken)
{
var maxParallelism = _maxParallelism.Value;

#if NET8_0_OR_GREATER
await Parallel.ForEachAsync(
return Parallel.ForEachAsync(
tests,
new ParallelOptions
{
MaxDegreeOfParallelism = maxParallelism,
MaxDegreeOfParallelism = _maxParallelism.Value,
CancellationToken = cancellationToken
},
async (test, ct) =>
{
test.ExecutionTask ??= _testRunner.ExecuteTestAsync(test, ct).AsTask();
await test.ExecutionTask.ConfigureAwait(false);
}
).ConfigureAwait(false);
);
}
#else
private async Task ExecuteWithGlobalLimitAsync(
AbstractExecutableTest[] tests,
SemaphoreSlim globalSemaphore,
CancellationToken cancellationToken)
{
// Fallback for netstandard2.0: Manual bounded concurrency using existing semaphore
var tasks = new Task[tests.Length];
for (var i = 0; i < tests.Length; i++)
Expand All @@ -372,8 +386,8 @@ await Parallel.ForEachAsync(
}, CancellationToken.None);
}
await Task.WhenAll(tasks).ConfigureAwait(false);
#endif
}
#endif

private async Task WaitForTasksWithFailFastHandling(IEnumerable<Task> tasks, CancellationToken cancellationToken)
{
Expand Down
Loading