Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
38 changes: 18 additions & 20 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.Diagnostics;
using TUnit.Core;
using TUnit.Core.Enums;
using TUnit.Core.Interfaces;
using TUnit.Engine.Utilities;
Expand All @@ -18,24 +19,16 @@ 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>
private static void EnsureEventReceiversCached(TestContext testContext)
public static void EnsureEventReceiversCached(this 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 &&
// Fast path: caches populated and class instance unchanged since last build.
if (testContext.EventReceiversBuilt &&
ReferenceEquals(testContext.CachedClassInstance, currentClassInstance))
{
return;
}
#else
if (testContext.CachedTestStartReceivers != null &&
ReferenceEquals(testContext.CachedClassInstance, currentClassInstance))
{
return;
}
#endif

// Invalidate stale caches if class instance changed
if (testContext.CachedClassInstance != null &&
Expand Down Expand Up @@ -138,6 +131,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 All @@ -157,7 +151,7 @@ private static T[] SortAndFilter<T>(List<T>? receivers) where T : class, IEventR
public static IEnumerable<object> GetEligibleEventObjects(this TestContext testContext)
{
// Use EnsureEventReceiversCached which builds eligible objects as part of cache initialization
EnsureEventReceiversCached(testContext);
testContext.EnsureEventReceiversCached();
return testContext.CachedEligibleEventObjects!;
}

Expand Down Expand Up @@ -265,19 +259,21 @@ private static int CountNonNullValues(IDictionary<string, object?> props)

/// <summary>
/// Gets pre-computed test start receivers (filtered, sorted, scoped-attribute filtered).
/// Assumes <see cref="EnsureEventReceiversCached"/> has been called by the lifecycle
/// coordinator — hot-path callers (per-test start/end/skipped) skip the guard.
/// </summary>
#if NET
public static ITestStartEventReceiver[] GetTestStartReceivers(this TestContext testContext, EventReceiverStage stage)
{
EnsureEventReceiversCached(testContext);
Debug.Assert(testContext.EventReceiversBuilt, "EnsureEventReceiversCached must run before GetTestStartReceivers — caller is on the hot path and skips the guard.");
return stage == EventReceiverStage.Early
? testContext.CachedTestStartReceiversEarly!
: testContext.CachedTestStartReceiversLate!;
}
#else
public static ITestStartEventReceiver[] GetTestStartReceivers(this TestContext testContext)
{
EnsureEventReceiversCached(testContext);
Debug.Assert(testContext.EventReceiversBuilt, "EnsureEventReceiversCached must run before GetTestStartReceivers — caller is on the hot path and skips the guard.");
return testContext.CachedTestStartReceivers!;
}
#endif
Expand All @@ -288,15 +284,15 @@ public static ITestStartEventReceiver[] GetTestStartReceivers(this TestContext t
#if NET
public static ITestEndEventReceiver[] GetTestEndReceivers(this TestContext testContext, EventReceiverStage stage)
{
EnsureEventReceiversCached(testContext);
Debug.Assert(testContext.EventReceiversBuilt, "EnsureEventReceiversCached must run before GetTestEndReceivers — caller is on the hot path and skips the guard.");
return stage == EventReceiverStage.Early
? testContext.CachedTestEndReceiversEarly!
: testContext.CachedTestEndReceiversLate!;
}
#else
public static ITestEndEventReceiver[] GetTestEndReceivers(this TestContext testContext)
{
EnsureEventReceiversCached(testContext);
Debug.Assert(testContext.EventReceiversBuilt, "EnsureEventReceiversCached must run before GetTestEndReceivers — caller is on the hot path and skips the guard.");
return testContext.CachedTestEndReceivers!;
}
#endif
Expand All @@ -306,25 +302,27 @@ public static ITestEndEventReceiver[] GetTestEndReceivers(this TestContext testC
/// </summary>
public static ITestSkippedEventReceiver[] GetTestSkippedReceivers(this TestContext testContext)
{
EnsureEventReceiversCached(testContext);
Debug.Assert(testContext.EventReceiversBuilt, "EnsureEventReceiversCached must run before GetTestSkippedReceivers — caller is on the hot path and skips the guard.");
return testContext.CachedTestSkippedReceivers!;
}

/// <summary>
/// Gets pre-computed test discovery receivers (filtered, sorted, scoped-attribute filtered).
/// Called from discovery / out-of-lifecycle paths, so the guard still builds on demand.
/// </summary>
public static ITestDiscoveryEventReceiver[] GetTestDiscoveryReceivers(this TestContext testContext)
{
EnsureEventReceiversCached(testContext);
testContext.EnsureEventReceiversCached();
return testContext.CachedTestDiscoveryReceivers!;
}

/// <summary>
/// Gets pre-computed test registered receivers (filtered, sorted, scoped-attribute filtered).
/// Called from discovery / out-of-lifecycle paths, so the guard still builds on demand.
/// </summary>
public static ITestRegisteredEventReceiver[] GetTestRegisteredReceivers(this TestContext testContext)
{
EnsureEventReceiversCached(testContext);
testContext.EnsureEventReceiversCached();
return testContext.CachedTestRegisteredReceivers!;
}
}
Loading
Loading