diff --git a/TUnit.Core/Discovery/ObjectGraphDiscoverer.cs b/TUnit.Core/Discovery/ObjectGraphDiscoverer.cs index 129a0679b5..24732c6b26 100644 --- a/TUnit.Core/Discovery/ObjectGraphDiscoverer.cs +++ b/TUnit.Core/Discovery/ObjectGraphDiscoverer.cs @@ -62,6 +62,17 @@ internal sealed class ObjectGraphDiscoverer : IObjectGraphTracker // Thread-safe collection of discovery errors for diagnostics private static readonly ConcurrentBag DiscoveryErrors = []; + // Memoize ShouldSkipType — Namespace.StartsWith("System") is expensive when repeated + // across every per-test initializer traversal. + private static readonly ConcurrentDictionary 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 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 FlattenedInitializerPropertiesCache = new(); + /// /// Gets all discovery errors that occurred during object graph traversal. /// Useful for debugging and diagnostics when property access fails. @@ -249,18 +260,20 @@ public static void ClearCache() { PropertyCacheManager.ClearCache(); TypeHierarchyCache.Clear(); + ShouldSkipTypeCache.Clear(); + FlattenedInitializerPropertiesCache.Clear(); ClearDiscoveryErrors(); } /// - /// 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. /// 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); /// /// Add to HashSet at specified depth. Returns true if added (not duplicate). @@ -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(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. + 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) + /// + /// 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. + /// + private static InitializerPropertyInfo[]? GetFlattenedInitializerProperties(Type type) + => FlattenedInitializerPropertiesCache.GetOrAdd(type, static t => + { + List? merged = null; + HashSet? 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(registered.Length); + seen ??= new HashSet(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(); + }); /// - /// 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 . /// - private static void TraverseRegisteredInitializerPropertiesWithTracking( + private static void TraverseFlattenedInitializerProperties( object obj, Type type, InitializerPropertyInfo[] properties, - HashSet processedPropertyNames, TryAddObjectFunc tryAdd, RecurseFunc recurse, int currentDepth, @@ -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); @@ -472,7 +496,6 @@ private static void TraverseRegisteredInitializerPropertiesWithTracking( continue; } - // Only discover IAsyncInitializer objects if (value is IAsyncInitializer && tryAdd(value, currentDepth)) { recurse(value, currentDepth + 1); @@ -480,15 +503,12 @@ private static void TraverseRegisteredInitializerPropertiesWithTracking( } 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. " + diff --git a/TUnit.Core/TestContext.cs b/TUnit.Core/TestContext.cs index df1f07dbfd..1c86956c39 100644 --- a/TUnit.Core/TestContext.cs +++ b/TUnit.Core/TestContext.cs @@ -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; } + /// + /// 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 ). + /// + internal bool EventReceiversBuilt { get; set; } + /// /// Invalidates all cached event receiver data. Called when class instance changes (e.g., on retry). /// @@ -421,6 +428,7 @@ internal void InvalidateEventReceiverCaches() CachedTestDiscoveryReceivers = null; CachedTestRegisteredReceivers = null; CachedClassInstance = null; + EventReceiversBuilt = false; } internal ConcurrentDictionary ObjectBag => _testBuilderContext.StateBag; diff --git a/TUnit.Engine/Extensions/TestContextExtensions.cs b/TUnit.Engine/Extensions/TestContextExtensions.cs index 381cfd2cf2..51099495f0 100644 --- a/TUnit.Engine/Extensions/TestContextExtensions.cs +++ b/TUnit.Engine/Extensions/TestContextExtensions.cs @@ -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; @@ -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. /// + // 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)) @@ -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(List? receivers) where T : class, IEventReceiver diff --git a/TUnit.Engine/Scheduling/TestScheduler.cs b/TUnit.Engine/Scheduling/TestScheduler.cs index 1ab31aa951..ea64e58369 100644 --- a/TUnit.Engine/Scheduling/TestScheduler.cs +++ b/TUnit.Engine/Scheduling/TestScheduler.cs @@ -27,7 +27,9 @@ internal sealed class TestScheduler : ITestScheduler private readonly StaticPropertyHandler _staticPropertyHandler; private readonly IDynamicTestQueue _dynamicTestQueue; private readonly Lazy _maxParallelism; +#if !NET private readonly Lazy _maxParallelismSemaphore; +#endif public TestScheduler( TUnitFrameworkLogger logger, @@ -57,11 +59,16 @@ public TestScheduler( _maxParallelism = new Lazy(() => 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(() => 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 ScheduleAndExecuteAsync( @@ -154,7 +161,7 @@ public async Task 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( @@ -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) @@ -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( @@ -330,19 +341,16 @@ 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) => @@ -350,8 +358,14 @@ await Parallel.ForEachAsync( 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++) @@ -372,8 +386,8 @@ await Parallel.ForEachAsync( }, CancellationToken.None); } await Task.WhenAll(tasks).ConfigureAwait(false); -#endif } +#endif private async Task WaitForTasksWithFailFastHandling(IEnumerable tasks, CancellationToken cancellationToken) {