From bfb9d9f997055d31d3d185c15ac2e085c3a1694b Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:28:59 +0000 Subject: [PATCH 1/6] Fix DI row scope ownership and concurrent property registration (#7472) --- src/TUnit.Core/AsyncEvent.cs | 14 + ...dencyInjectionDataSourceSourceAttribute.cs | 16 +- src/TUnit.Core/DataRowScopeCollection.cs | 119 +++ src/TUnit.Core/TestBuilderContext.cs | 26 +- src/TUnit.Core/TestContextEvents.cs | 57 ++ src/TUnit.Core/Tracking/ObjectTracker.cs | 13 +- src/TUnit.Engine/Building/TestBuilder.cs | 431 +++++----- .../Services/StaticPropertyHandler.cs | 38 + .../Services/TestExecution/TestCoordinator.cs | 17 +- .../TestExecution/TestOnDisposeRaiser.cs | 18 +- .../DependencyInjectionScopeDisposalTests.cs | 7 +- .../ConcurrentDiPropertyTests.cs | 139 ++++ .../DataScopeOwnershipTests.cs | 272 +++++++ tests/TUnit.TestProject/MEDITest.cs | 6 + .../StateBagRowIsolationTests.cs | 36 + tests/TUnit.UnitTests/ClassRowCleanupTests.cs | 747 ++++++++++++++++++ .../DataRowScopeCollectionTests.cs | 123 +++ .../ExternallyOwnedSingletonTests.cs | 53 +- .../StaticScopeCleanupTests.cs | 127 +++ 19 files changed, 2018 insertions(+), 241 deletions(-) create mode 100644 src/TUnit.Core/DataRowScopeCollection.cs create mode 100644 tests/TUnit.TestProject/ConcurrentDiPropertyTests.cs create mode 100644 tests/TUnit.TestProject/DataScopeOwnershipTests.cs create mode 100644 tests/TUnit.TestProject/StateBagRowIsolationTests.cs create mode 100644 tests/TUnit.UnitTests/ClassRowCleanupTests.cs create mode 100644 tests/TUnit.UnitTests/DataRowScopeCollectionTests.cs create mode 100644 tests/TUnit.UnitTests/StaticScopeCleanupTests.cs diff --git a/src/TUnit.Core/AsyncEvent.cs b/src/TUnit.Core/AsyncEvent.cs index 34cedd51612..22167a52c0f 100644 --- a/src/TUnit.Core/AsyncEvent.cs +++ b/src/TUnit.Core/AsyncEvent.cs @@ -49,6 +49,20 @@ public class AsyncEvent { private List? _handlers; + internal AsyncEvent Clone() => new() + { + _handlers = _handlers is null ? null : [.. _handlers] + }; + + internal void AddMissingFrom(AsyncEvent source) + { + foreach (var invocation in source.InvocationList) + { + if (_handlers?.Contains(invocation) != true) + (_handlers ??= []).Insert(FindInsertionIndex(invocation.Order), invocation); + } + } + /// /// Represents a single invocation of an async event handler. /// diff --git a/src/TUnit.Core/Attributes/TestData/DependencyInjectionDataSourceSourceAttribute.cs b/src/TUnit.Core/Attributes/TestData/DependencyInjectionDataSourceSourceAttribute.cs index 0dcb3efaa38..aca1a9e4356 100644 --- a/src/TUnit.Core/Attributes/TestData/DependencyInjectionDataSourceSourceAttribute.cs +++ b/src/TUnit.Core/Attributes/TestData/DependencyInjectionDataSourceSourceAttribute.cs @@ -16,18 +16,9 @@ public abstract class DependencyInjectionDataSourceAttribute : UntypedDa // disposing the returned objects, as it does for any other data source. var scopeOwnsCreatedObjects = scope is IDisposable or IAsyncDisposable; - // Set up disposal for this specific scope in the current test context - dataGeneratorMetadata.TestBuilderContext.Current.Events.OnDispose += async (_, _) => - { - if (scope is IAsyncDisposable asyncDisposable) - { - await asyncDisposable.DisposeAsync().ConfigureAwait(false); - } - else if (scope is IDisposable disposable) - { - disposable.Dispose(); - } - }; + // Register before resolving so a failed factory still releases its scope. + var cleanup = dataGeneratorMetadata.TestBuilderContext.Current.Events.GetOrCreateDataRowScopes(); + var owner = cleanup.Register(scope); return dataGeneratorMetadata.MembersToGenerate .Select(m => m switch @@ -41,6 +32,7 @@ public abstract class DependencyInjectionDataSourceAttribute : UntypedDa .Select(x => { var created = Create(scope, x); + cleanup.RegisterObject(owner, created); if (scopeOwnsCreatedObjects) { diff --git a/src/TUnit.Core/DataRowScopeCollection.cs b/src/TUnit.Core/DataRowScopeCollection.cs new file mode 100644 index 00000000000..5ba1dcb6c27 --- /dev/null +++ b/src/TUnit.Core/DataRowScopeCollection.cs @@ -0,0 +1,119 @@ +namespace TUnit.Core; + +/// +/// Owns a row's scopes. Object lookup releases each scope at its first resolved object's graph +/// depth; completion releases scopes left by failed factories or untracked values. +/// +internal sealed class DataRowScopeCollection +{ + private readonly object _lock = new(); + private readonly List _owners = []; + private readonly Dictionary> _objects = new(Helpers.ReferenceEqualityComparer.Instance); + + internal ScopeOwner Register(object? scope) + { + var owner = new ScopeOwner(scope); + lock (_lock) + _owners.Add(owner); + return owner; + } + + internal void RegisterObject(ScopeOwner owner, object? instance) + { + if (instance is null) + { + return; + } + + lock (_lock) + { + if (!_objects.TryGetValue(instance, out var owners)) + _objects.Add(instance, owners = []); + if (!owners.Contains(owner)) + owners.Add(owner); + } + } + + internal DataRowScopeCollection Clone() + { + var clone = new DataRowScopeCollection(); + clone.AddRange(this); + return clone; + } + + internal void AddRange(DataRowScopeCollection scopes) + { + // Snapshot the source before locking the destination to avoid lock-order inversions. + ScopeOwner[] owners; + KeyValuePair[] objects; + lock (scopes._lock) + { + owners = scopes._owners.ToArray(); + objects = scopes._objects.Select(pair => + new KeyValuePair(pair.Key, pair.Value.ToArray())).ToArray(); + } + lock (_lock) + { + _owners.AddRange(owners); + foreach (var pair in objects) + foreach (var owner in pair.Value) + RegisterObject(owner, pair.Key); + } + } + + internal ValueTask?> ReleaseAsync(object instance, List? exceptions = null) + { + ScopeOwner[] owners; + lock (_lock) + owners = _objects.TryGetValue(instance, out var registered) ? registered.ToArray() : []; + return DisposeAsync(owners, exceptions); + } + + internal ValueTask?> CompleteAsync(List? exceptions = null) + { + ScopeOwner[] owners; + lock (_lock) + owners = _owners.ToArray(); + return DisposeAsync(owners, exceptions); + } + + private static async ValueTask?> DisposeAsync(ScopeOwner[] owners, List? exceptions) + { + foreach (var owner in owners) + { + try + { + await owner.DisposeAsync().ConfigureAwait(false); + } + catch (Exception exception) + { + (exceptions ??= []).Add(exception); + } + } + + return exceptions; + } + + /// Shared by cloned rows; scope disposal is attempted once, including when it throws. + internal sealed class ScopeOwner(object? scope) + { + private int _disposed; + + internal async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + if (scope is IAsyncDisposable asyncDisposable) + { + await asyncDisposable.DisposeAsync().ConfigureAwait(false); + } + else if (scope is IDisposable disposable) + { + disposable.Dispose(); + } + } + } +} diff --git a/src/TUnit.Core/TestBuilderContext.cs b/src/TUnit.Core/TestBuilderContext.cs index 64dcfbc9169..b0208095e96 100644 --- a/src/TUnit.Core/TestBuilderContext.cs +++ b/src/TUnit.Core/TestBuilderContext.cs @@ -91,7 +91,7 @@ public TestContextEvents Events { // A sharing context resolves the source's container on every read rather than caching it, so a // later assignment to the source's Events reaches this context too. - get => _events ?? _eventsSource?.Events ?? (_events = new TestContextEvents()); + get => _events ?? _eventsSource?.Events ?? LazyInitializer.EnsureInitialized(ref _events)!; set { _eventsSource = null; @@ -141,6 +141,30 @@ internal void ShareEventsWith(TestBuilderContext source) /// internal Attribute[]? InitializedAttributes { get; set; } + // Child rows start with isolated state and no event subscriptions. Callers that build an + // executable view can explicitly share the state/events with their factory context instead. + internal TestBuilderContext CreateChild(IDataSourceAttribute? classDataSource, IDataSourceAttribute? dataSource = null, bool shareFactoryContext = false) + { + var child = new TestBuilderContext + { + TestMetadata = TestMetadata, + InitializedAttributes = InitializedAttributes, + ClassConstructor = ClassConstructor, + ClassDataSourceAttribute = classDataSource, + DataSourceAttribute = dataSource + }; + if (shareFactoryContext) + { + child.ShareStateBagWith(this); + child.ShareEventsWith(this); + } + else + { + CopyStateBagTo(child); + } + return child; + } + public void RegisterForInitialization(object? obj) { Events.OnInitialize += async (sender, args) => diff --git a/src/TUnit.Core/TestContextEvents.cs b/src/TUnit.Core/TestContextEvents.cs index 9ba1587ab67..69afe218d41 100644 --- a/src/TUnit.Core/TestContextEvents.cs +++ b/src/TUnit.Core/TestContextEvents.cs @@ -7,6 +7,63 @@ namespace TUnit.Core; /// public class TestContextEvents : ITestEvents { + internal TestContextEvents Clone() => new() + { + DataRowScopes = DataRowScopes?.Clone(), + OnDispose = OnDispose?.Clone(), + OnTestRegistered = OnTestRegistered?.Clone(), + OnInitialize = OnInitialize?.Clone(), + OnTestStart = OnTestStart?.Clone(), + OnTestEnd = OnTestEnd?.Clone(), + OnTestSkipped = OnTestSkipped?.Clone(), + OnTestRetry = OnTestRetry?.Clone() + }; + + private object? _scopeLock; + + internal void AddDataRowScopes(DataRowScopeCollection scopes) + { + lock (LazyInitializer.EnsureInitialized(ref _scopeLock)!) + { + GetOrCreateDataRowScopes().AddRange(scopes); + } + } + + internal DataRowScopeCollection? DetachDataRowScopes() + { + lock (LazyInitializer.EnsureInitialized(ref _scopeLock)!) + { + var scopes = DataRowScopes; + DataRowScopes = null; + return scopes; + } + } + + internal void AddCleanupFrom(TestContextEvents source) + { + if (source.DataRowScopes is { } scopes) + AddDataRowScopes(scopes); + if (source.OnDispose is { } onDispose) + (OnDispose ??= new()).AddMissingFrom(onDispose); + } + + private DataRowScopeCollection? _dataRowScopes; + + internal DataRowScopeCollection GetOrCreateDataRowScopes() + { + lock (LazyInitializer.EnsureInitialized(ref _scopeLock)!) + { + return _dataRowScopes ??= new(); + } + } + + // Clones share once-only scope owners while keeping row ownership collections isolated. + internal DataRowScopeCollection? DataRowScopes + { + get => Volatile.Read(ref _dataRowScopes); + set => Volatile.Write(ref _dataRowScopes, value); + } + public AsyncEvent? OnDispose { get; set; } public AsyncEvent? OnTestRegistered { get; set; } public AsyncEvent? OnInitialize { get; set; } diff --git a/src/TUnit.Core/Tracking/ObjectTracker.cs b/src/TUnit.Core/Tracking/ObjectTracker.cs index aba391f19d4..f1b22caba90 100644 --- a/src/TUnit.Core/Tracking/ObjectTracker.cs +++ b/src/TUnit.Core/Tracking/ObjectTracker.cs @@ -269,6 +269,11 @@ private static void RemoveObjectsNotIn(SortedList>? tracked foreach (var obj in bucket) { + if (testContext?.InternalEventsIfCreated?.DataRowScopes is { } scopes) + { + cleanupExceptions = await scopes.ReleaseAsync(obj, cleanupExceptions).ConfigureAwait(false); + } + try { var task = UntrackObject(obj, testContext); @@ -718,15 +723,19 @@ internal static bool IsExternallyOwned(object obj) => s_hasExternallyOwned && s_externallyOwned.TryGetValue(obj, out _); // Objects whose lifetime another owner controls (a dependency injection scope, for example). - // The tracker counts references but leaves disposal to their owner. + // The tracker balances references but never disposes them; their owner controls disposal. private static readonly ConditionalWeakTable s_externallyOwned = new(); private static readonly object s_externallyOwnedMarker = new(); + // Ownership cannot live only on a row or counted entry: a container singleton can be + // supplied by another source before or after its DI row, including after its count reaches zero. + // A weak identity mark preserves that ownership without retaining the singleton. + // The flag deliberately stays set: live marks can outlast any individual row or test session. // Skips the table lookup for every tracked object until something is marked. private static volatile bool s_hasExternallyOwned; /// - /// Marks as disposed by another owner, so the tracker counts references without disposing it. + /// Marks as disposed by another owner, so the tracker never disposes it. /// Dependency injection data sources mark the services they resolve, because the scope that created /// them disposes them when the test ends (#7205). Existing tracker references are released /// without disposing the object, regardless of which data source supplied it first. diff --git a/src/TUnit.Engine/Building/TestBuilder.cs b/src/TUnit.Engine/Building/TestBuilder.cs index e724678ea80..905e245e2eb 100644 --- a/src/TUnit.Engine/Building/TestBuilder.cs +++ b/src/TUnit.Engine/Building/TestBuilder.cs @@ -129,6 +129,8 @@ public async ValueTask> BuildTestsFromMetada } var tests = new List(); + TestBuilderContextAccessor? contextAccessor = null; + UnclaimedClassRow? activeClassRow = null; try { @@ -195,7 +197,7 @@ public async ValueTask> BuildTestsFromMetada testBuilderContext.ClassConstructor = (IClassConstructor)Activator.CreateInstance(classConstructorAttribute.ClassConstructorType)!; } - var contextAccessor = new TestBuilderContextAccessor(testBuilderContext, publishBuilderContext); + contextAccessor = new TestBuilderContextAccessor(testBuilderContext, publishBuilderContext); // DeferEnumeration: emit a single placeholder node instead of enumerating the data source(s). // The placeholder is built during both discovery and the execution build (so it matches the @@ -210,6 +212,9 @@ public async ValueTask> BuildTestsFromMetada var classDataAttributeIndex = 0; foreach (var classDataSource in await GetDataSourcesAsync(metadata.ClassDataSources, cancellationToken)) { + activeClassRow = null; + contextAccessor.Current = testBuilderContext.CreateChild(classDataSource, null); + var classSourceContext = contextAccessor.Current; classDataAttributeIndex++; var classDataLoopIndex = 0; @@ -226,13 +231,22 @@ public async ValueTask> BuildTestsFromMetada hasAnyClassData = true; classDataLoopIndex++; - var (classData, firstClassRowMetadata, firstClassRowEvents) = await FetchFirstClassRowAsync( - classDataFactory, classDataSource, metadata, contextAccessor); + // Keep the first factory's subscriptions with its saved row, rather than + // leaving them on the previous test's per-row context. + contextAccessor.Current = classSourceContext.CreateChild(classDataSource, null); + + // A new factory owns its own cleanup, even if it throws before producing a row. + activeClassRow = null; + // Keep constructor-factory state on its saved row, outside the enumeration baseline. + var classRowEnumerationContext = contextAccessor.Current.CreateChild(classDataSource, null); + var (classData, firstClassRowMetadata) = DataUnwrapper.UnwrapWithMetadata(await classDataFactory() ?? []); // No test holds this first fetch yet. The first test built from this class row takes it // instead of calling the factory again, so a non-shared source does not create an extra // instance that nothing tracks or disposes (#7157). - var unclaimedClassRow = new UnclaimedClassRow(classData, firstClassRowMetadata, firstClassRowEvents); + var unclaimedClassRow = new UnclaimedClassRow(classData, firstClassRowMetadata, contextAccessor.Current, classRowEnumerationContext); + contextAccessor.Current = classRowEnumerationContext; + activeClassRow = unclaimedClassRow; #if NET // Register scope for class data objects from the simple path. @@ -246,6 +260,7 @@ public async ValueTask> BuildTestsFromMetada var needsInstanceForMethodDataSources = HasInstanceDataAccessor(metadata.DataSources); + var classEnumerationContext = contextAccessor.Current; object? instanceForMethodDataSources = null; try { @@ -266,7 +281,8 @@ public async ValueTask> BuildTestsFromMetada if (!instanceResult.Success) { - var failedTest = CreateFailedTestForInstanceDataSourceError(metadata, instanceResult.Exception!, classData); + unclaimedClassRow.TransferCleanupTo(contextAccessor.Current); + var failedTest = CreateFailedTestForInstanceDataSourceError(metadata, instanceResult.Exception!, classData, contextAccessor.Current); tests.Add(failedTest); continue; } @@ -293,20 +309,12 @@ public async ValueTask> BuildTestsFromMetada hasAnyMethodData = true; methodDataLoopIndex++; + var enumerationContext = contextAccessor.Current; for (var i = 0; i < repeatCount + 1; i++) { // Update context BEFORE calling data factories so they track objects in the right context // StateBag and Events are lazy-initialized for performance - contextAccessor.Current = new TestBuilderContext - { - TestMetadata = metadata.MethodMetadata, - DataSourceAttribute = methodDataSource, - ClassDataSourceAttribute = classDataSource, - InitializedAttributes = testBuilderContext.InitializedAttributes, - ClassConstructor = testBuilderContext.ClassConstructor - }; - - testBuilderContext.CopyStateBagTo(contextAccessor.Current); + contextAccessor.Current = enumerationContext.CreateChild(classDataSource, methodDataSource); var (classDataUnwrapped, classRowMetadata) = await unclaimedClassRow.TakeOrFetchAsync(classDataFactory, contextAccessor.Current); classData = classDataUnwrapped; @@ -353,9 +361,8 @@ public async ValueTask> BuildTestsFromMetada if (!IsDataCompatibleWithExpectedTypes(metadata, methodData)) { // Skip this data source as it's not compatible with the expected types. - // No test holds the class row's objects, so the next test takes them, - // with the events subscribed while this row was fetched. - unclaimedClassRow.Return(classData, classRowMetadata, contextAccessor.Current.EventsIfCreated); + // No test holds the class row's objects, so the next test takes them. + unclaimedClassRow.Return(classData, classRowMetadata, contextAccessor.Current); continue; } } @@ -473,21 +480,7 @@ public async ValueTask> BuildTestsFromMetada else { // Events is lazy-initialized; explicitly share StateBag from per-iteration context - testSpecificContext = new TestBuilderContext - { - TestMetadata = metadata.MethodMetadata, - ClassConstructor = testBuilderContext.ClassConstructor, - DataSourceAttribute = contextAccessor.Current.DataSourceAttribute, - ClassDataSourceAttribute = classDataSource, - InitializedAttributes = attributes - }; - - // Shares the per-iteration bag lazily; it is only allocated if something reads it. - testSpecificContext.ShareStateBagWith(contextAccessor.Current); - - // The data sources subscribed on the per-iteration context while this row was - // fetched (a DI scope's disposal, for example); the test raises them (#7205). - testSpecificContext.ShareEventsWith(contextAccessor.Current); + testSpecificContext = contextAccessor.Current.CreateChild(classDataSource, contextAccessor.Current.DataSourceAttribute, shareFactoryContext: true); } var test = await BuildTestAsync(metadata, testData, testSpecificContext, cancellationToken); @@ -498,9 +491,10 @@ public async ValueTask> BuildTestsFromMetada test.Context.SkipReason = basicSkipReason; } tests.Add(test); - - // Context already updated at the beginning of the loop before calling factories } + + // Resume enumeration against its own context, without factory-written row state. + contextAccessor.Current = enumerationContext; } // If no data was yielded and SkipIfEmpty is true, create a skipped test @@ -511,7 +505,7 @@ public async ValueTask> BuildTestsFromMetada // The test built below holds classData, which may be the unclaimed first fetch, // and tracks any objects that a temporary instance used. var lentClassRowObjects = unclaimedClassRow.TakeLentObjects(); - var classRowEvents = unclaimedClassRow.TakeEvents(); + var skippedRowContext = unclaimedClassRow.CreateEnumerationFailureContext(contextAccessor.Current); unclaimedClassRow.Clear(); Type[] resolvedClassGenericArgs; @@ -531,7 +525,7 @@ public async ValueTask> BuildTestsFromMetada // If generic type inference failed, create a failed test instead of skipped if (genericResolutionException != null) { - var failedTest = CreateFailedTestForDataGenerationError(metadata, genericResolutionException, new TestDataCombination(), classData, lentClassRowObjects); + var failedTest = CreateFailedTestForDataGenerationError(metadata, genericResolutionException, new TestDataCombination(), classData, lentClassRowObjects, skippedRowContext); tests.Add(failedTest); } else @@ -552,21 +546,8 @@ public async ValueTask> BuildTestsFromMetada UnreceivedClassRowObjects = lentClassRowObjects }; - var testSpecificContext = new TestBuilderContext - { - TestMetadata = metadata.MethodMetadata, - ClassConstructor = testBuilderContext.ClassConstructor, - DataSourceAttribute = methodDataSource, - ClassDataSourceAttribute = classDataSource, - InitializedAttributes = attributes - }; - - testBuilderContext.CopyStateBagTo(testSpecificContext); - - if (classRowEvents is not null) - { - testSpecificContext.Events = classRowEvents; - } + var testSpecificContext = skippedRowContext.CreateChild(classDataSource, methodDataSource); + testSpecificContext.Events = skippedRowContext.EventsIfCreated?.Clone() ?? new(); var test = await BuildTestAsync(metadata, testData, testSpecificContext, cancellationToken: cancellationToken); test.Context.SkipReason = skipReason; @@ -575,9 +556,24 @@ public async ValueTask> BuildTestsFromMetada } } } + catch (Exception ex) + { + // Enumeration may resume on a context whose class cleanup already belongs + // to a runnable row. Only the unclaimed row may lend cleanup to this failure. + var failureContext = ReferenceEquals(contextAccessor.Current, classEnumerationContext) + ? unclaimedClassRow.CreateEnumerationFailureContext(classEnumerationContext) + : contextAccessor.Current; + contextAccessor.Current = failureContext; + var failedTest = CreateFailedTestForDataGenerationError(metadata, ex, + new TestDataCombination(), methodData: unclaimedClassRow.TakeLentObjects(), + rowContext: failureContext); + tests.Add(failedTest); + return tests; + } finally { await DisposeInstanceForMethodDataSourcesAsync(instanceForMethodDataSources); + contextAccessor.Current = classSourceContext; } } @@ -632,15 +628,7 @@ public async ValueTask> BuildTestsFromMetada // enumerated — only ClassDataSourceAttribute is populated. The class attribute // is already present in the initialized attributes (it is declared on the class), // so nothing is lost by leaving the method slot empty. - var testSpecificContext = new TestBuilderContext - { - TestMetadata = metadata.MethodMetadata, - ClassConstructor = testBuilderContext.ClassConstructor, - ClassDataSourceAttribute = classDataSource, - InitializedAttributes = attributes - }; - - testBuilderContext.CopyStateBagTo(testSpecificContext); + var testSpecificContext = testBuilderContext.CreateChild(classDataSource, null); var test = await BuildTestAsync(metadata, testData, testSpecificContext, cancellationToken: cancellationToken); test.Context.SkipReason = skipReason; @@ -671,7 +659,15 @@ public async ValueTask> BuildTestsFromMetada // The build context was disposed on leaving the try block; stop routing output to it. TestBuildContext.Current = null; - var failedTest = CreateFailedTestForDataGenerationError(metadata, ex); + // Class-source MoveNext runs outside the per-class-row catch. Its context can + // still carry cleanup already claimed by a runnable row. + var failureContext = contextAccessor?.Current; + if (activeClassRow is not null && failureContext is not null) + { + failureContext = activeClassRow.CreateEnumerationFailureContext(failureContext); + } + var failedTest = CreateFailedTestForDataGenerationError(metadata, ex, new TestDataCombination(), + methodData: activeClassRow?.TakeLentObjects(), rowContext: failureContext); tests.Add(failedTest); return tests; } @@ -1041,47 +1037,6 @@ public ValueTask MoveNextAsync() } } - /// - /// Fetches a class row before its method data sources are evaluated, against a context of its own, - /// so the events the data source subscribes while it creates the row's objects can travel with them - /// to the test that takes them (#7205). The context shares the state bag of the context the fetch used - /// to run against, which is current again once the fetch returns. - /// - private static async ValueTask<(object?[] Data, TestDataRowMetadata? Metadata, TestContextEvents? Events)> FetchFirstClassRowAsync( - Func> classDataFactory, - IDataSourceAttribute classDataSource, - TestMetadata metadata, - TestBuilderContextAccessor contextAccessor) - { - if (classDataSource is NoDataSource) - { - // Yields one empty row and subscribes nothing. - var (emptyRow, emptyRowMetadata) = DataUnwrapper.UnwrapWithMetadata(await classDataFactory() ?? []); - return (emptyRow, emptyRowMetadata, null); - } - - var previousContext = contextAccessor.Current; - var fetchContext = new TestBuilderContext - { - TestMetadata = metadata.MethodMetadata, - ClassDataSourceAttribute = classDataSource, - InitializedAttributes = previousContext.InitializedAttributes, - ClassConstructor = previousContext.ClassConstructor - }; - fetchContext.ShareStateBagWith(previousContext); - - contextAccessor.Current = fetchContext; - try - { - var (data, rowMetadata) = DataUnwrapper.UnwrapWithMetadata(await classDataFactory() ?? []); - return (data, rowMetadata, fetchContext.EventsIfCreated); - } - finally - { - contextAccessor.Current = previousContext; - } - } - /// /// The objects of a class data row that were fetched but that no test holds yet. The builder fetches a /// class row once before it evaluates the method data sources, and its tests used to fetch it again, @@ -1093,86 +1048,177 @@ public ValueTask MoveNextAsync() /// from these objects and disposed once the method rows are read, so no test may receive them. They /// are lent to the instance instead, and the next test built from the row tracks them without /// receiving them, so they are still disposed. - /// - /// The events that the data source subscribed on the builder context while the row was first fetched - /// (such as the disposal of a dependency injection scope) travel with these objects, so the test - /// that takes them also raises those events (#7205). Lent objects do not carry their events: see - /// . - /// /// - private sealed class UnclaimedClassRow(object?[] data, TestDataRowMetadata? metadata, TestContextEvents? events) + internal sealed class UnclaimedClassRow(object?[] data, TestDataRowMetadata? metadata, TestBuilderContext rowContext, TestBuilderContext? enumerationContext = null) { private object?[]? _data = data; private TestDataRowMetadata? _metadata = metadata; private object?[]? _lent; - private TestContextEvents? _events = events; + + private readonly TestBuilderContext _enumerationContext = enumerationContext ?? rowContext; + // Compare each saved constructor row against its own pre-factory state. + // Method enumeration can change between constructor fetches. + private Dictionary? _constructorBaseline = enumerationContext?.HasStateBag == true + ? new(enumerationContext.StateBag) + : null; + private TestBuilderContext _rowContext = rowContext; + // Snapshot before any method factory can mutate the class subscriptions. + private TestContextEvents? _events = rowContext.EventsIfCreated?.Clone(); + private TestContextEvents? _rejectedCleanup; /// /// Takes the unclaimed objects, or fetches a new row from when a - /// test already holds them. Any events of the unclaimed objects move to - /// first, the context the row is fetched against, so a new fetch - /// adds its own subscriptions to the same container. + /// test already holds them. /// - public ValueTask<(object?[] Data, TestDataRowMetadata? Metadata)> TakeOrFetchAsync( - Func> classDataFactory, - TestBuilderContext builderContext) + public ValueTask<(object?[] Data, TestDataRowMetadata? Metadata)> TakeOrFetchAsync(Func> classDataFactory, TestBuilderContext context) { - if (TakeEvents() is { } events) - { - // The context is fresh, so assigning cannot drop a subscription made before the take. Fail - // loudly rather than silently drop subscriptions (a scope's disposal) if that ever changes. - if (builderContext.EventsIfCreated is not null) - { - throw new InvalidOperationException("A class row's events cannot replace events the builder context already has."); - } - - builderContext.Events = events; - } - if (_data is { } data) { + ApplySavedContextTo(context); var row = (data, _metadata); Clear(); return new ValueTask<(object?[] Data, TestDataRowMetadata? Metadata)>(row); } - return FetchAsync(classDataFactory); + return FetchAsync(classDataFactory, context); + + async ValueTask<(object?[] Data, TestDataRowMetadata? Metadata)> FetchAsync(Func> factory, TestBuilderContext target) + { + _constructorBaseline = target.HasStateBag ? new(target.StateBag) : null; + var row = DataUnwrapper.UnwrapWithMetadata(await factory() ?? []); + _rowContext = target with { }; + // Record cloning shares the bag, so even a materialized empty bag must + // be detached before a rejected method factory can mutate the target. + if (target.HasStateBag) + { + _rowContext.StateBag = new ConcurrentDictionary(target.StateBag); + } + _events = target.EventsIfCreated?.Clone(); + return row; + } + } - static async ValueTask<(object?[] Data, TestDataRowMetadata? Metadata)> FetchAsync(Func> factory) - => DataUnwrapper.UnwrapWithMetadata(await factory() ?? []); + private void ApplySavedContextTo(TestBuilderContext context) + { + // Keep method-enumeration state, but replay constructor-factory updates + // and removals of inherited keys as well as newly added constructor state. + if (_rowContext.HasStateBag) + { + if (_constructorBaseline is not null && context.HasStateBag) + { + foreach (var key in _constructorBaseline.Keys) + { + if (!_rowContext.StateBag.ContainsKey(key)) + { + context.StateBag.TryRemove(key, out _); + } + } + } + foreach (var entry in _rowContext.StateBag) + { + if (_constructorBaseline is null + || !_constructorBaseline.TryGetValue(entry.Key, out var inherited) + || !ReferenceEquals(inherited, entry.Value)) + { + context.StateBag[entry.Key] = entry.Value; + } + } + } + if (_events is { } savedEvents) + { + context.Events = savedEvents.Clone(); + } + if (_rejectedCleanup is { } cleanup) + { + context.Events.AddCleanupFrom(cleanup); + } } /// - /// Hands back objects, and the events of the context they were taken against, that no test ended up - /// holding, so the next test takes them. + /// Hands back objects that were taken but that no test ended up holding, so the next test takes them. /// - public void Return(object?[] data, TestDataRowMetadata? metadata, TestContextEvents? events) + public void Return(object?[] data, TestDataRowMetadata? metadata, TestBuilderContext rejectedContext) { _data = data; _metadata = metadata; - _events = events; + // Only cleanup follows a rejected method row. Its other lifecycle handlers must + // never become constructor subscriptions on later compatible rows. + if (rejectedContext.EventsIfCreated is { } rejectedEvents) + { + var cleanup = _rejectedCleanup ??= new(); + if (rejectedEvents.DataRowScopes is { } scopes) + cleanup.AddDataRowScopes(scopes); + + // Constructor callbacks already belong to the saved class row. Bind only + // method-row callbacks to their own state, rather than the next test's bag. + var callbacks = rejectedEvents.OnDispose?.InvocationList + .Where(invocation => _events?.OnDispose?.InvocationList.Contains(invocation) != true + && cleanup.OnDispose?.InvocationList.Contains(invocation) != true) + .ToArray(); + if (callbacks is { Length: > 0 }) + { + var saved = rejectedContext with { StateBag = new(rejectedContext.StateBag) }; + foreach (var callback in callbacks) + { + (cleanup.OnDispose ??= new()).Add(async (_, owner) => + { + // Rejected rows have no executable test. Give their cleanup a + // context with the owning state and the final test's metadata. + var context = new TestContext(owner.TestDetails.TestName, owner.ServiceProvider, + owner.ClassContext, saved, owner.CancellationToken); + context.Metadata.TestDetails = owner.TestDetails; + try + { + await callback.InvokeAsync(context, context); + } + finally + { + context.RemoveFromRegistry(); + context.Dispose(); + } + }, callback.Order); + } + } + } } /// - /// Takes the events subscribed while the unclaimed objects were fetched, for the test that holds - /// those objects. + /// Preserves row callbacks and state when a skipped or failed test takes ownership of the row. /// - public TestContextEvents? TakeEvents() + public void TransferCleanupTo(TestBuilderContext target) { - var events = _events; - _events = null; - return events; + if (_data is not null) + { + ApplySavedContextTo(target); + } } - /// - /// Marks the unclaimed objects as held by a test that did not take them through . - /// + internal TestBuilderContext CreateFailureContext(TestBuilderContext currentContext) + { + // A separate factory context owns its callbacks, including callbacks added + // before a factory failure. Only the saved enumeration context needs filtering. + return ReferenceEquals(currentContext, _enumerationContext) + ? CreateEnumerationFailureContext(currentContext) + : currentContext; + } + + internal TestBuilderContext CreateEnumerationFailureContext(TestBuilderContext enumerationContext) + { + var failureContext = enumerationContext with { }; + enumerationContext.CopyStateBagTo(failureContext); + failureContext.Events = enumerationContext.EventsIfCreated?.Clone() ?? new(); + failureContext.Events.DataRowScopes = null; + TransferCleanupTo(failureContext); + return failureContext; + } + + /// Marks the unclaimed objects as held by a test. public void Clear() { _data = null; _metadata = null; _lent = null; - _events = null; + _rejectedCleanup = null; } /// @@ -1182,13 +1228,11 @@ public void Clear() public void LendToTemporaryInstance() { _lent = _data; - _data = null; - _metadata = null; - - // Method rows may share services from the constructor row. Keep its scope alive until - // every test has finished, rather than attaching cleanup to the first method row. + // Method rows share this constructor row until session end. TestSessionContext.DeferOnDisposeToSessionEnd(_events); _events = null; + _data = null; + _metadata = null; } /// @@ -1573,6 +1617,7 @@ private AbstractExecutableTest CreateFailedTestForDataGenerationError(TestMetada /// /// The class row's objects, if the row produced them before it failed. /// The method row's objects, if the row produced them before it failed. + /// The owning row context whose scope cleanup follows the failed test. private AbstractExecutableTest CreateFailedTestForDataRowError( TestMetadata metadata, Exception exception, @@ -1583,7 +1628,7 @@ private AbstractExecutableTest CreateFailedTestForDataRowError( int repeatIndex, object?[]? classData = null, object?[]? methodData = null, - TestBuilderContext? builderContext = null) + TestBuilderContext? rowContext = null) { return CreateFailedTestForDataGenerationError(metadata, exception, new TestDataCombination { @@ -1592,7 +1637,7 @@ private AbstractExecutableTest CreateFailedTestForDataRowError( MethodDataSourceIndex = methodDataAttributeIndex, MethodLoopIndex = methodDataLoopIndex, RepeatIndex = repeatIndex - }, classData, methodData, builderContext); + }, classData, methodData, rowContext); } /// The test's metadata. @@ -1600,6 +1645,7 @@ private AbstractExecutableTest CreateFailedTestForDataRowError( /// The row's indexes, which go into the test id. /// The class row's objects, if the row produced them before it failed. /// The method row's objects, if the row produced them before it failed. + /// The owning row context whose scope cleanup follows the failed test. /// /// The failed test never receives the row's objects, but it carries them in /// , so the object tracker counts them when the test is @@ -1612,13 +1658,13 @@ private AbstractExecutableTest CreateFailedTestForDataGenerationError( TestDataCombination combination, object?[]? classData = null, object?[]? methodData = null, - TestBuilderContext? builderContext = null) + TestBuilderContext? rowContext = null) { exception = ToReportedException(exception); var testId = TestIdentifierService.GenerateFailedTestId(metadata, combination); var testDetails = CreateFailedTestDetails(metadata, testId, CombineRowObjects(classData, methodData)); - var context = CreateFailedTestContext(metadata, testDetails, builderContext); + var context = CreateFailedTestContext(metadata, testDetails, rowContext); var now = DateTimeOffset.UtcNow; return new FailedExecutableTest(exception) @@ -1861,15 +1907,13 @@ private async ValueTask InitializeAttributesCoreAsync(Attribute[] a return attributes; } - private TestContext CreateFailedTestContext(TestMetadata metadata, TestDetails testDetails, TestBuilderContext? rowContext = null) + private TestContext CreateFailedTestContext(TestMetadata metadata, TestDetails testDetails, TestBuilderContext? rowContext) { var builderContext = new TestBuilderContext { TestMetadata = metadata.MethodMetadata }; if (rowContext is not null) { - // A failed row still owns the scopes created while its factories ran. Keep their - // callbacks even though no test instance receives the row's arguments. - builderContext.ShareEventsWith(rowContext); - builderContext.ShareStateBagWith(rowContext); + rowContext.CopyStateBagTo(builderContext); + builderContext.Events = rowContext.EventsIfCreated?.Clone() ?? new(); } var context = _contextProvider.CreateTestContext( @@ -1883,9 +1927,9 @@ private TestContext CreateFailedTestContext(TestMetadata metadata, TestDetails t - private AbstractExecutableTest CreateFailedTestForInstanceDataSourceError(TestMetadata metadata, Exception exception, object?[] classData) + private AbstractExecutableTest CreateFailedTestForInstanceDataSourceError(TestMetadata metadata, Exception exception, object?[] classData, TestBuilderContext rowContext) { - return CreateFailedTestForDataGenerationError(metadata, exception, new TestDataCombination(), classData); + return CreateFailedTestForDataGenerationError(metadata, exception, new TestDataCombination(), classData, rowContext: rowContext); } private AbstractExecutableTest CreateFailedTestForClassDataSourceCircularDependency(TestMetadata metadata) @@ -2258,6 +2302,8 @@ public async IAsyncEnumerable BuildTestsStreamingAsync( var classDataAttributeIndex = 0; foreach (var classDataSource in await GetDataSourcesAsync(metadata.ClassDataSources, cancellationToken)) { + var classSourceContext = baseContext.CreateChild(classDataSource, null); + contextAccessor.Current = classSourceContext; classDataAttributeIndex++; var classDataLoopIndex = 0; @@ -2273,12 +2319,16 @@ public async IAsyncEnumerable BuildTestsStreamingAsync( cancellationToken.ThrowIfCancellationRequested(); classDataLoopIndex++; - var (classData, firstClassRowMetadata, firstClassRowEvents) = await FetchFirstClassRowAsync( - classDataFactory, classDataSource, metadata, contextAccessor); + contextAccessor.Current = classSourceContext.CreateChild(classDataSource, null); + + // Keep constructor-factory state on its saved row, outside the enumeration baseline. + var classRowEnumerationContext = contextAccessor.Current.CreateChild(classDataSource, null); + var (classData, firstClassRowMetadata) = DataUnwrapper.UnwrapWithMetadata(await classDataFactory() ?? []); // The first test built from this class row takes this fetch instead of calling the factory // again, so a non-shared source does not create an extra, untracked instance (#7157). - var unclaimedClassRow = new UnclaimedClassRow(classData, firstClassRowMetadata, firstClassRowEvents); + var unclaimedClassRow = new UnclaimedClassRow(classData, firstClassRowMetadata, contextAccessor.Current, classRowEnumerationContext); + contextAccessor.Current = classRowEnumerationContext; // Initialize objects before method data sources are evaluated (ObjectInitializer is phase-aware) await InitializeClassDataAsync(classData); @@ -2306,8 +2356,9 @@ public async IAsyncEnumerable BuildTestsStreamingAsync( if (!instanceResult.Success) { + unclaimedClassRow.TransferCleanupTo(contextAccessor.Current); // Yield a failed test instead of silently skipping - yield return CreateFailedTestForInstanceDataSourceError(metadata, instanceResult.Exception!, classData); + yield return CreateFailedTestForInstanceDataSourceError(metadata, instanceResult.Exception!, classData, contextAccessor.Current); continue; } @@ -2333,11 +2384,14 @@ public async IAsyncEnumerable BuildTestsStreamingAsync( cancellationToken.ThrowIfCancellationRequested(); methodDataLoopIndex++; + var enumerationContext = contextAccessor.Current; // Stream through repeat count for (var i = 0; i < repeatCount + 1; i++) { cancellationToken.ThrowIfCancellationRequested(); + contextAccessor.Current = enumerationContext.CreateChild(classDataSource, methodDataSource); + // Build and yield single test var test = await BuildSingleTestAsync( metadata, classDataFactory, unclaimedClassRow, methodDataFactory, @@ -2352,12 +2406,16 @@ public async IAsyncEnumerable BuildTestsStreamingAsync( yield return test; } } + + // Keep subsequent rows and repeats independent of each test's factory state. + contextAccessor.Current = enumerationContext; } } } finally { await DisposeInstanceForMethodDataSourcesAsync(instanceForMethodDataSources); + contextAccessor.Current = classSourceContext; } } } @@ -2496,23 +2554,9 @@ private async Task CreateInstanceForMethodDataSources( object?[]? classData = null; object?[]? methodData = null; - // Fetch this test's rows against a context of their own, so the events the data sources subscribe - // reach this test only (#7205). It shares the base context's state bag, as the streaming path always has. - var baseContext = contextAccessor.Current; - var iterationContext = new TestBuilderContext - { - TestMetadata = metadata.MethodMetadata, - DataSourceAttribute = methodDataSource ?? baseContext.DataSourceAttribute, - ClassDataSourceAttribute = classDataSource ?? baseContext.ClassDataSourceAttribute, - InitializedAttributes = baseContext.InitializedAttributes, - ClassConstructor = baseContext.ClassConstructor - }; - iterationContext.ShareStateBagWith(baseContext); - contextAccessor.Current = iterationContext; - try { - (classData, var classRowMetadata) = await unclaimedClassRow.TakeOrFetchAsync(classDataFactory, iterationContext); + (classData, var classRowMetadata) = await unclaimedClassRow.TakeOrFetchAsync(classDataFactory, contextAccessor.Current); (methodData, var methodRowMetadata) = DataUnwrapper.UnwrapWithTypesAndMetadata(await methodDataFactory() ?? [], metadata.MethodMetadata.Parameters); // Extract and merge metadata from data source attributes and TestDataRow wrappers @@ -2531,7 +2575,7 @@ private async Task CreateInstanceForMethodDataSources( if (!IsDataCompatibleWithExpectedTypes(metadata, methodData)) { // Skip incompatible data. No test holds the class row's objects, so the next test takes them. - unclaimedClassRow.Return(classData, classRowMetadata, iterationContext.EventsIfCreated); + unclaimedClassRow.Return(classData, classRowMetadata, contextAccessor.Current); return null; } } @@ -2571,12 +2615,12 @@ private async Task CreateInstanceForMethodDataSources( } catch (Exception ex) { - return CreateFailedTestForDataRowError(metadata, ex, classDataAttributeIndex, classDataLoopIndex, methodDataAttributeIndex, methodDataLoopIndex, repeatIndex, unclaimedClassRow.WithLentObjects(classData), methodData, iterationContext); + return CreateFailedTestForDataRowError(metadata, ex, classDataAttributeIndex, classDataLoopIndex, methodDataAttributeIndex, methodDataLoopIndex, repeatIndex, unclaimedClassRow.WithLentObjects(classData), methodData, contextAccessor.Current); } } catch (Exception ex) { - return CreateFailedTestForDataRowError(metadata, ex, classDataAttributeIndex, classDataLoopIndex, methodDataAttributeIndex, methodDataLoopIndex, repeatIndex, unclaimedClassRow.WithLentObjects(classData), methodData, iterationContext); + return CreateFailedTestForDataRowError(metadata, ex, classDataAttributeIndex, classDataLoopIndex, methodDataAttributeIndex, methodDataLoopIndex, repeatIndex, unclaimedClassRow.WithLentObjects(classData), methodData, contextAccessor.Current); } if (metadata.TestClassType.IsGenericTypeDefinition && resolvedClassGenericArgs.Length == 0) @@ -2621,18 +2665,7 @@ private async Task CreateInstanceForMethodDataSources( }; // Events is lazy-initialized; explicitly share StateBag from per-iteration context - var testSpecificContext = new TestBuilderContext - { - TestMetadata = metadata.MethodMetadata, - ClassConstructor = contextAccessor.Current.ClassConstructor, - DataSourceAttribute = methodDataSource ?? contextAccessor.Current.DataSourceAttribute, - ClassDataSourceAttribute = classDataSource ?? contextAccessor.Current.ClassDataSourceAttribute, - InitializedAttributes = attributes - }; - - // Shares the per-iteration bag lazily; it is only allocated if something reads it. - testSpecificContext.ShareStateBagWith(contextAccessor.Current); - testSpecificContext.ShareEventsWith(contextAccessor.Current); + var testSpecificContext = contextAccessor.Current.CreateChild(classDataSource ?? contextAccessor.Current.ClassDataSourceAttribute, methodDataSource ?? contextAccessor.Current.DataSourceAttribute, shareFactoryContext: true); var test = await BuildTestAsync(metadata, testData, testSpecificContext, cancellationToken: cancellationToken); @@ -2648,11 +2681,7 @@ private async Task CreateInstanceForMethodDataSources( // Includes a GenericConstraintViolationDataSource row whose inferred type arguments break the // method's constraints. The row's indexes keep its id distinct from the method's other rows. var (reportedException, rowData) = UnpackConstraintViolation(ex, metadata.MethodMetadata.Parameters); - return CreateFailedTestForDataRowError(metadata, reportedException, classDataAttributeIndex, classDataLoopIndex, methodDataAttributeIndex, methodDataLoopIndex, repeatIndex, unclaimedClassRow.WithLentObjects(classData), rowData ?? methodData, iterationContext); - } - finally - { - contextAccessor.Current = baseContext; + return CreateFailedTestForDataRowError(metadata, reportedException, classDataAttributeIndex, classDataLoopIndex, methodDataAttributeIndex, methodDataLoopIndex, repeatIndex, unclaimedClassRow.WithLentObjects(classData), rowData ?? methodData, contextAccessor.Current); } } diff --git a/src/TUnit.Engine/Services/StaticPropertyHandler.cs b/src/TUnit.Engine/Services/StaticPropertyHandler.cs index d294cf207b8..011ae6e80c8 100644 --- a/src/TUnit.Engine/Services/StaticPropertyHandler.cs +++ b/src/TUnit.Engine/Services/StaticPropertyHandler.cs @@ -241,6 +241,44 @@ internal async Task DisposeStaticPropertiesAsync(List cleanupExceptio } } + internal static async Task DisposeStaticPropertiesAsync( + IEnumerable staticProperties, + TestContextEvents events, + ObjectTracker objectTracker, + List cleanupExceptions) + { + // Detach before awaiting so a reused host cannot retain scopes from earlier sessions. + var scopes = events.DetachDataRowScopes(); + + foreach (var staticProperty in staticProperties) + { + // Scope-owned values must be released by their scope before tracker disposal is skipped. + if (scopes is not null) + { + await scopes.ReleaseAsync(staticProperty, cleanupExceptions).ConfigureAwait(false); + } + try + { + await objectTracker.UntrackObjectAsync(staticProperty).ConfigureAwait(false); + } + catch (Exception e) + { + cleanupExceptions.Add(e); + } + } + + // Also release scopes whose factory failed or whose returned value was not trackable. + if (scopes is not null) + { + await scopes.CompleteAsync(cleanupExceptions).ConfigureAwait(false); + } + + if (cleanupExceptions.Count > 0) + { + throw new AggregateException("Errors occurred while disposing static properties", cleanupExceptions); + } + } + /// /// Initializes nested objects depth-first (deepest first) before the parent object. /// This ensures that containers/resources are fully initialized before the parent diff --git a/src/TUnit.Engine/Services/TestExecution/TestCoordinator.cs b/src/TUnit.Engine/Services/TestExecution/TestCoordinator.cs index 459d648941c..de8cc0120b5 100644 --- a/src/TUnit.Engine/Services/TestExecution/TestCoordinator.cs +++ b/src/TUnit.Engine/Services/TestExecution/TestCoordinator.cs @@ -224,12 +224,25 @@ await RetryHelper.ExecuteWithRetry(test.Context, // Once per test, after its final attempt and on every skip or early-failure path, so objects // that every attempt reuses stay alive until the test is done with them (#7205). - await TestOnDisposeRaiser.RaiseOnceAsync(test, _logger).ConfigureAwait(false); + await TestOnDisposeRaiser.RaiseOnceAsync(test, _logger, disposeDataRow: false).ConfigureAwait(false); // Stay null on the success path — materializing the list only when something actually fails // saves ~56 bytes per passing test (and passing is the overwhelming majority). test.Context.StopHoldingSharedReferences(); - var cleanupExceptions = await _objectTracker.UntrackObjects(test.Context).ConfigureAwait(false); + List? cleanupExceptions = null; + + // Release roots before their dependencies. Scope cleanup is invoked at the resolved + // object's graph depth by the tracker; completion catches untracked scopes. + var trackingExceptions = await _objectTracker.UntrackObjects(test.Context).ConfigureAwait(false); + if (trackingExceptions is { Count: > 0 }) + { + (cleanupExceptions ??= []).AddRange(trackingExceptions); + } + + if (test.Context.InternalEventsIfCreated?.DataRowScopes is { } scopes) + { + cleanupExceptions = await scopes.CompleteAsync(cleanupExceptions).ConfigureAwait(false); + } #if NET // Per-test cleanup has completed. Keep the test activity open for final status diff --git a/src/TUnit.Engine/Services/TestExecution/TestOnDisposeRaiser.cs b/src/TUnit.Engine/Services/TestExecution/TestOnDisposeRaiser.cs index de6c54cc99e..e2eb41c548b 100644 --- a/src/TUnit.Engine/Services/TestExecution/TestOnDisposeRaiser.cs +++ b/src/TUnit.Engine/Services/TestExecution/TestOnDisposeRaiser.cs @@ -17,15 +17,15 @@ internal static class TestOnDisposeRaiser /// before they reach the coordinator: a test skipped because a dependency failed, and a test the /// run's cancellation stopped before it started. /// - public static async ValueTask RaiseOnceAsync(AbstractExecutableTest test, TUnitFrameworkLogger logger) + public static async ValueTask RaiseOnceAsync(AbstractExecutableTest test, TUnitFrameworkLogger logger, bool disposeDataRow = true) { var onDispose = test.Context.InternalEventsIfCreated?.OnDispose; - if (onDispose?.InvocationList is not { } invocations || !test.TryClaimOnDispose()) + if (!test.TryClaimOnDispose()) { return; } - foreach (var invocation in invocations) + foreach (var invocation in onDispose?.InvocationList ?? []) { try { @@ -39,6 +39,18 @@ public static async ValueTask RaiseOnceAsync(AbstractExecutableTest test, TUnitF await logger.LogErrorAsync($"Error during OnDispose for {test.TestId}: {disposeEx}").ConfigureAwait(false); } } + // Tests bypassing the coordinator have no tracked graph to release. Release their row + // scopes here; the coordinator dispatches these after its depth-ordered graph cleanup. + if (disposeDataRow && test.Context.InternalEventsIfCreated?.DataRowScopes is { } scopes) + { + if (await scopes.CompleteAsync().ConfigureAwait(false) is { } exceptions) + { + foreach (var exception in exceptions) + { + await logger.LogErrorAsync($"Error during row cleanup for {test.TestId}: {exception}").ConfigureAwait(false); + } + } + } } /// diff --git a/tests/TUnit.TestProject/Bugs/_7205/DependencyInjectionScopeDisposalTests.cs b/tests/TUnit.TestProject/Bugs/_7205/DependencyInjectionScopeDisposalTests.cs index 50ae9037653..acd9e0b26c0 100644 --- a/tests/TUnit.TestProject/Bugs/_7205/DependencyInjectionScopeDisposalTests.cs +++ b/tests/TUnit.TestProject/Bugs/_7205/DependencyInjectionScopeDisposalTests.cs @@ -5,9 +5,8 @@ namespace TUnit.TestProject.Bugs._7205; -// DependencyInjectionDataSourceAttribute disposes each scope it creates through an OnDispose -// handler that it subscribes on TestBuilderContext.Current.Events while its row is fetched. Those -// events never reached the test built from the row, so no scope was ever disposed. Every scope must be +// DependencyInjectionDataSourceAttribute registers cleanup on the builder context while +// fetching its row. Originally that cleanup never reached the executable test. Every scope must be // disposed once, when its test ends, in both engine modes: including the scope of the class row's first // fetch that the first test takes, a retried test's scope (which every attempt reuses), and the scopes // of tests that never run (skipped, skipped because a dependency failed, or removed by circular-dependency @@ -422,7 +421,7 @@ public async Task Every_Scope_Is_Disposed_When_Its_Test_Ends() await AssertScopes(nameof(ClassRowScopeWithMethodRowsTests), 4); // One scope for the temporary instance that reads the method rows, then one per test. Only the - // tests' scopes are disposed when they end; the temporary instance's scope is left to #7383. + // tests' scopes are disposed when they end; the temporary instance's scope lives until session cleanup. await AssertScopes(nameof(ClassRowScopeWithInstanceMethodDataSourceTests), 3, expectedDisposed: 2); // A method parameter scope per test run. diff --git a/tests/TUnit.TestProject/ConcurrentDiPropertyTests.cs b/tests/TUnit.TestProject/ConcurrentDiPropertyTests.cs new file mode 100644 index 00000000000..263a0c7144a --- /dev/null +++ b/tests/TUnit.TestProject/ConcurrentDiPropertyTests.cs @@ -0,0 +1,139 @@ +using System.Collections.Concurrent; +using System.Runtime.CompilerServices; +using TUnit.Core.Interfaces; +using TUnit.TestProject.Attributes; + +namespace TUnit.TestProject; + +public class ConcurrentDiPropertyTests : IDisposable +{ + [ConcurrentPropertySource] + public required ConcurrentPropertyService Property0 { get; init; } + + [ConcurrentPropertySource] + public required ConcurrentPropertyService Property1 { get; init; } + + [ConcurrentPropertySource] + public required ConcurrentPropertyService Property2 { get; init; } + + [ConcurrentPropertySource] + public required ConcurrentPropertyService Property3 { get; init; } + + [ConcurrentPropertySource] + public required ConcurrentPropertyService Property4 { get; init; } + + [ConcurrentPropertySource] + public required ConcurrentPropertyService Property5 { get; init; } + + [ConcurrentPropertySource] + public required ConcurrentPropertyService Property6 { get; init; } + + [ConcurrentPropertySource] + public required ConcurrentPropertyService Property7 { get; init; } + + internal static readonly ConcurrentBag Completed = []; + + private ConcurrentPropertyService[] Services => + [Property0, Property1, Property2, Property3, Property4, Property5, Property6, Property7]; + + [Test, Repeat(3), EngineTest(ExpectedResult.Pass)] + public async Task EveryPropertyIsInitializedAndAlive() + { + foreach (var service in Services) + { + await Assert.That(service.Initialized).IsTrue(); + await Assert.That(service.DisposeCount).IsEqualTo(0); + await Assert.That(service.Scope.DisposeCount).IsEqualTo(0); + } + } + + public void Dispose() + { + var services = Services; + if (services.Any(service => service.DisposeCount != 0)) + throw new InvalidOperationException("Property scope disposed before its root"); + Completed.Add(services); + } +} + +public class ConcurrentDiPropertyCleanup +{ + [After(TestSession)] + public static async Task EveryScopeAndServiceIsDisposedOnce() + { + foreach (var row in ConcurrentDiPropertyTests.Completed) + { + await Assert.That(row.Length).IsEqualTo(8); + foreach (var service in row) + { + await Assert.That(service.DisposeCount).IsEqualTo(1); + await Assert.That(service.Scope.DisposeCount).IsEqualTo(1); + } + } + } +} + +public sealed class ConcurrentPropertySourceAttribute : AsyncUntypedDataSourceGeneratorAttribute +{ + private static readonly ConditionalWeakTable Gates = new(); + + protected override async IAsyncEnumerable>> GenerateDataSourcesAsync(DataGeneratorMetadata metadata) + { + var gate = Gates.GetValue(metadata.TestBuilderContext.Current.Events, _ => new Gate()); + await foreach (var factory in new PropertyDiSource().GenerateAsync(metadata)) + { + yield return async () => + { + // All eight property resolutions must be in flight before DI registration starts. + await gate.ArriveAsync().WaitAsync(TimeSpan.FromSeconds(30)); + return await factory(); + }; + } + } + + private sealed class Gate + { + private int _arrivals; + private readonly TaskCompletionSource _ready = new(TaskCreationOptions.RunContinuationsAsynchronously); + internal Task ArriveAsync() + { + if (Interlocked.Increment(ref _arrivals) == 8) _ready.SetResult(); + return _ready.Task; + } + } + + private sealed class PropertyDiSource : DependencyInjectionDataSourceAttribute + { + public override ConcurrentPropertyScope CreateScope(DataGeneratorMetadata metadata) => new(); + public override object Create(ConcurrentPropertyScope scope, Type type) => scope.Service; + } +} + +public sealed class ConcurrentPropertyScope : IAsyncDisposable +{ + public ConcurrentPropertyScope() => Service = new ConcurrentPropertyService(this); + public ConcurrentPropertyService Service { get; } + public int DisposeCount; + public async ValueTask DisposeAsync() + { + Interlocked.Increment(ref DisposeCount); + await Service.DisposeAsync(); + } +} + +public sealed class ConcurrentPropertyService(ConcurrentPropertyScope scope) : IAsyncInitializer, IAsyncDisposable +{ + public ConcurrentPropertyScope Scope { get; } = scope; + public bool Initialized; + public int DisposeCount; + public async Task InitializeAsync() + { + await Task.Yield(); + Initialized = true; + } + public async ValueTask DisposeAsync() + { + await Task.Yield(); + Interlocked.Increment(ref DisposeCount); + } +} diff --git a/tests/TUnit.TestProject/DataScopeOwnershipTests.cs b/tests/TUnit.TestProject/DataScopeOwnershipTests.cs new file mode 100644 index 00000000000..8ea9e4b8b8f --- /dev/null +++ b/tests/TUnit.TestProject/DataScopeOwnershipTests.cs @@ -0,0 +1,272 @@ +using TUnit.TestProject.Attributes; + +namespace TUnit.TestProject; + +public class DataScopeRetryLifecycleTests +{ + public sealed class Scope : IDisposable + { + public int DisposeCount; + public Service? Service; + public void Dispose() + { + DisposeCount++; + Service?.Dispose(); + } + } + + public sealed record Service(Scope Scope) : IDisposable + { + public int DisposeCount; + public Dependency Dependency { get; } = new(); + + public void Dispose() + { + if (++DisposeCount != 1) + { + throw new InvalidOperationException("Scope-owned service disposed more than once"); + } + Dependency.Dispose(); + } + } + + public sealed class Dependency : TUnit.Core.Interfaces.IAsyncInitializer, IDisposable + { + public bool Initialized { get; private set; } + public int DisposeCount; + public Task InitializeAsync() + { + Initialized = true; + return Task.CompletedTask; + } + public void Dispose() + { + if (++DisposeCount != 1) + { + throw new InvalidOperationException("Scope-owned dependency disposed more than once"); + } + } + } + + public sealed class ScopeDataAttribute : DependencyInjectionDataSourceAttribute + { + public override Scope CreateScope(DataGeneratorMetadata metadata) => new(); + public override object Create(Scope scope, Type type) => scope.Service = new Service(scope); + } + + [Test, ScopeData, Retry(1), EngineTest(ExpectedResult.Pass)] + public async Task ScopeSurvivesRetry(Service service) + { + var context = TestContext.Current!; + context.StateBag.Items["scope"] = service.Scope; + await Assert.That(service.Scope.DisposeCount).IsEqualTo(0); + await Assert.That(service.DisposeCount).IsEqualTo(0); + await Assert.That(service.Dependency.Initialized).IsTrue(); + await Assert.That(service.Dependency.DisposeCount).IsEqualTo(0); + if (context.StateBag.Items.TryAdd("firstAttempt", true)) + { + throw new InvalidOperationException("Exercise scope lifetime across retries"); + } + } + + [After(Class)] + public static async Task Verify(ClassHookContext context) + { + foreach (var test in context.Tests) + { + await Assert.That(((Scope)test.StateBag.Items["scope"]!).DisposeCount).IsEqualTo(1); + await Assert.That(((Scope)test.StateBag.Items["scope"]!).Service!.DisposeCount).IsEqualTo(1); + await Assert.That(((Scope)test.StateBag.Items["scope"]!).Service!.Dependency.DisposeCount).IsEqualTo(1); + } + } +} + +public class NonDisposableDataScopeLifecycleTests +{ + public sealed class Scope; + + public sealed class Service : IDisposable + { + public int DisposeCount; + public void Dispose() => DisposeCount++; + } + + public sealed class ScopeDataAttribute : DependencyInjectionDataSourceAttribute + { + public override Scope CreateScope(DataGeneratorMetadata metadata) => new(); + public override object Create(Scope scope, Type type) => new Service(); + } + + [Test, ScopeData, EngineTest(ExpectedResult.Pass)] + public async Task TrackerOwnsServicesWithoutDisposableScope(Service service) + { + TestContext.Current!.StateBag.Items["service"] = service; + await Assert.That(service.DisposeCount).IsEqualTo(0); + } + + [After(Class)] + public static async Task Verify(ClassHookContext context) + { + foreach (var test in context.Tests) + { + await Assert.That(((Service)test.StateBag.Items["service"]!).DisposeCount).IsEqualTo(1); + } + } +} + +[ScopeOwnedConstructorLifecycleTests.ScopeData] +public class ScopeOwnedConstructorLifecycleTests(ScopeOwnedConstructorLifecycleTests.Service service) +{ + public sealed class Scope : IDisposable + { + public Service? Service; + public int DisposeCount; + public void Dispose() + { + if (++DisposeCount != 1) throw new InvalidOperationException("Scope disposed twice"); + Service?.Dispose(); + } + } + + public sealed class Service(Scope scope) : IDisposable + { + public Scope Scope => scope; + [ClassDataSource] + public required InjectedDependency Dependency { get; init; } + public void Dispose() + { + if (Dependency is { Disposed: true }) + throw new InvalidOperationException("Injected dependency disposed before its parent"); + } + } + + public sealed class InjectedDependency : IDisposable + { + public bool Disposed; + public void Dispose() => Disposed = true; + } + + public sealed class ScopeDataAttribute : DependencyInjectionDataSourceAttribute + { + public static readonly System.Collections.Concurrent.ConcurrentBag Scopes = []; + public override Scope CreateScope(DataGeneratorMetadata metadata) + { + var scope = new Scope(); + Scopes.Add(scope); + return scope; + } + public override object Create(Scope scope, Type type) + => scope.Service = new Service(scope) { Dependency = null! }; + } + + public IEnumerable Rows() => [1, 2]; + + [Test, InstanceMethodDataSource(nameof(Rows)), EngineTest(ExpectedResult.Pass)] + public async Task ScopeAndDependencyAreAlive(int row) + { + await Assert.That(service.Scope.DisposeCount).IsEqualTo(0); + await Assert.That(service.Dependency.Disposed).IsFalse(); + } + + [After(Class)] + public static async Task Verify() + { + // The temporary discovery instance can supply objects to later method rows. Its scope + // remains alive until session end; the two executable rows release their own scopes here. + await Assert.That(ScopeDataAttribute.Scopes.Count).IsEqualTo(3); + await Assert.That(ScopeDataAttribute.Scopes.Count(x => x.DisposeCount == 0)).IsEqualTo(1); + await Assert.That(ScopeDataAttribute.Scopes.Count(x => x.DisposeCount == 1)).IsEqualTo(2); + foreach (var scope in ScopeDataAttribute.Scopes.Where(x => x.DisposeCount == 1)) + { + if (scope.Service?.Dependency is { } dependency) + await Assert.That(dependency.Disposed).IsTrue(); + } + } +} + +public class InjectedScopeDisposalOrderTests +{ + public sealed class Parent : IAsyncDisposable + { + public static readonly System.Collections.Concurrent.ConcurrentBag Instances = []; + public Parent() => Instances.Add(this); + + [ScopeData] + public required Service Service { get; init; } + public int DisposeCount; + + public async ValueTask DisposeAsync() + { + await Task.Yield(); + if (++DisposeCount != 1 || Service.DisposeCount != 0) + throw new InvalidOperationException("Injected service must remain alive until its parent is disposed"); + } + } + + public sealed class Service : IDisposable + { + [ClassDataSource] + public required Dependency Dependency { get; init; } + public int DisposeCount; + public void Dispose() + { + if (++DisposeCount != 1 || Dependency.DisposeCount != 0) + throw new InvalidOperationException("Nested dependency must remain alive until its scope-owned parent is disposed"); + } + } + + public sealed class Dependency : IDisposable + { + public int DisposeCount; + public void Dispose() + { + if (++DisposeCount != 1) throw new InvalidOperationException("Dependency disposed twice"); + } + } + + public sealed class Scope : IDisposable + { + public Service? Service; + public int DisposeCount; + public void Dispose() + { + if (++DisposeCount != 1) throw new InvalidOperationException("Scope disposed twice"); + Service?.Dispose(); + } + } + + public sealed class ScopeDataAttribute : DependencyInjectionDataSourceAttribute + { + public static readonly System.Collections.Concurrent.ConcurrentBag Scopes = []; + public override Scope CreateScope(DataGeneratorMetadata metadata) + { + var scope = new Scope(); + Scopes.Add(scope); + return scope; + } + public override object Create(Scope scope, Type type) + => scope.Service = new Service { Dependency = null! }; + } + + [Test, ClassDataSource, Repeat(1), EngineTest(ExpectedResult.Pass)] + public async Task ParentAndDependenciesAreAlive(Parent parent) + { + await Assert.That(parent.DisposeCount).IsEqualTo(0); + await Assert.That(parent.Service.DisposeCount).IsEqualTo(0); + await Assert.That(parent.Service.Dependency.DisposeCount).IsEqualTo(0); + } + + [After(Class)] + public static async Task Verify() + { + await Assert.That(Parent.Instances.Count).IsEqualTo(2); + foreach (var parent in Parent.Instances) + { + await Assert.That(parent.DisposeCount).IsEqualTo(1); + await Assert.That(parent.Service.DisposeCount).IsEqualTo(1); + await Assert.That(parent.Service.Dependency.DisposeCount).IsEqualTo(1); + } + foreach (var scope in ScopeDataAttribute.Scopes) + await Assert.That(scope.DisposeCount).IsEqualTo(1); + } +} diff --git a/tests/TUnit.TestProject/MEDITest.cs b/tests/TUnit.TestProject/MEDITest.cs index c1bd61659c8..452ebf36405 100644 --- a/tests/TUnit.TestProject/MEDITest.cs +++ b/tests/TUnit.TestProject/MEDITest.cs @@ -66,6 +66,7 @@ public static async Task CheckDisposed(TestSessionContext testSessionContext) } await Assert.That(mediClass.IsDisposed).IsTrue(); + await Assert.That(mediClass.DisposeCount).IsEqualTo(1); } } @@ -73,6 +74,7 @@ public class MEDIClass : IAsyncInitializer, IAsyncDisposable { public bool IsInitialized { get; private set; } public bool IsDisposed { get; private set; } + public int DisposeCount { get; private set; } public Task InitializeAsync() { @@ -82,6 +84,10 @@ public Task InitializeAsync() public ValueTask DisposeAsync() { + if (++DisposeCount != 1) + { + throw new InvalidOperationException("Scope-owned service disposed more than once"); + } IsDisposed = true; return default(ValueTask); } diff --git a/tests/TUnit.TestProject/StateBagRowIsolationTests.cs b/tests/TUnit.TestProject/StateBagRowIsolationTests.cs new file mode 100644 index 00000000000..11579691d3f --- /dev/null +++ b/tests/TUnit.TestProject/StateBagRowIsolationTests.cs @@ -0,0 +1,36 @@ +using TUnit.TestProject.Attributes; + +namespace TUnit.TestProject; + +public class StateBagRowIsolationTests +{ + public sealed class RowStateAttribute : DataSourceGeneratorAttribute + { + protected override IEnumerable> GenerateDataSources(DataGeneratorMetadata metadata) + { + for (var i = 1; i <= 2; i++) + { + var row = i; + metadata.TestBuilderContext.Current.StateBag["enumerationRow"] = row; + yield return () => + { + var context = metadata.TestBuilderContext.Current; + if (!context.StateBag.TryAdd("factoryResource", new object())) + { + throw new InvalidOperationException("Factory inherited another test's resource"); + } + + context.StateBag.TryAdd("factoryRow", row); + return row; + }; + } + } + } + + [Test, RowState, Repeat(1), EngineTest(ExpectedResult.Pass)] + public async Task RowsAndRepeatsHaveIndependentState(int row) + { + await Assert.That(TestContext.Current!.StateBag["enumerationRow"]).IsEqualTo(row); + await Assert.That(TestContext.Current.StateBag["factoryRow"]).IsEqualTo(row); + } +} diff --git a/tests/TUnit.UnitTests/ClassRowCleanupTests.cs b/tests/TUnit.UnitTests/ClassRowCleanupTests.cs new file mode 100644 index 00000000000..fbee18abc1c --- /dev/null +++ b/tests/TUnit.UnitTests/ClassRowCleanupTests.cs @@ -0,0 +1,747 @@ +using TUnit.Core; +using TUnit.Engine.Building; +using TUnit.Engine.Building.Interfaces; + +namespace TUnit.UnitTests; + +public class ClassRowCleanupTests +{ + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task EmptyRefetchedConstructorStateDoesNotCaptureRejectedMethodState(bool removesInheritedKey) + { + var data = new object?[] { new object() }; + var row = new TestBuilder.UnclaimedClassRow(data, null, CreateContext()); + await row.TakeOrFetchAsync(() => throw new InvalidOperationException("Must reuse first row"), CreateContext()); + + var rejected = CreateContext(); + if (removesInheritedKey) + rejected.StateBag["removed-by-constructor"] = new object(); + await row.TakeOrFetchAsync(() => + { + // Materialize an empty bag, including the case where the constructor removes its last key. + rejected.StateBag.TryRemove("removed-by-constructor", out _); + return Task.FromResult(data); + }, rejected); + rejected.StateBag["method-row"] = new object(); + row.Return(data, null, rejected); + + var compatible = CreateContext(); + var methodValue = new object(); + compatible.StateBag["method-row"] = methodValue; + compatible.StateBag["removed-by-constructor"] = new object(); + await row.TakeOrFetchAsync(() => throw new InvalidOperationException("Must reuse rejected constructor"), compatible); + await Assert.That(compatible.StateBag["method-row"]).IsSameReferenceAs(methodValue); + await Assert.That(compatible.StateBag.ContainsKey("removed-by-constructor")).IsEqualTo(!removesInheritedKey); + } + + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task RefetchedRejectedConstructorReplaysOnlyItsOwnStateChanges(bool initialState) + { + var enumeration = CreateContext(); + if (initialState) + enumeration.StateBag["method-row"] = new object(); + var original = enumeration with { }; + enumeration.CopyStateBagTo(original); + var data = new object?[] { new object() }; + var row = new TestBuilder.UnclaimedClassRow(data, null, original, enumeration); + await row.TakeOrFetchAsync(() => throw new InvalidOperationException("Must reuse first row"), CreateContext()); + + var rejected = CreateContext(); + rejected.StateBag["method-row"] = new object(); + rejected.StateBag["removed-by-constructor"] = new object(); + rejected.StateBag["changed-by-constructor"] = new object(); + var constructorValue = new object(); + await row.TakeOrFetchAsync(() => + { + rejected.StateBag["added-by-constructor"] = constructorValue; + rejected.StateBag["changed-by-constructor"] = constructorValue; + rejected.StateBag.TryRemove("removed-by-constructor", out _); + return Task.FromResult(data); + }, rejected); + row.Return(data, null, rejected); + + var compatible = CreateContext(); + var methodValue = new object(); + compatible.StateBag["method-row"] = methodValue; + compatible.StateBag["removed-by-constructor"] = new object(); + compatible.StateBag["changed-by-constructor"] = new object(); + await row.TakeOrFetchAsync(() => throw new InvalidOperationException("Must reuse rejected constructor"), compatible); + await Assert.That(compatible.StateBag["method-row"]).IsSameReferenceAs(methodValue); + await Assert.That(compatible.StateBag["added-by-constructor"]).IsSameReferenceAs(constructorValue); + await Assert.That(compatible.StateBag["changed-by-constructor"]).IsSameReferenceAs(constructorValue); + await Assert.That(compatible.StateBag.ContainsKey("removed-by-constructor")).IsFalse(); + } + + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task RejectedMethodRowsRetainCleanupWithoutLifecycleSubscriptions(bool failedRow) + { + var original = CreateContext(); + var constructorDisposals = 0; + var rejectedDisposals = 0; + var scopeDisposals = 0; + var rejectedStarts = 0; + original.Events.OnDispose += (_, _) => { constructorDisposals++; return default; }; + var data = new object?[] { new object() }; + var row = new TestBuilder.UnclaimedClassRow(data, null, original); + // Two rejected method factories share the saved constructor, but register distinct cleanup. + for (var i = 0; i < 2; i++) + { + var rejected = CreateContext(); + await row.TakeOrFetchAsync(() => throw new InvalidOperationException("Must reuse constructor"), rejected); + var resource = new object(); + rejected.StateBag["rejected-resource"] = resource; + rejected.Events.OnDispose += async (_, context) => + { + await Assert.That(context.StateBag.Items["rejected-resource"]).IsSameReferenceAs(resource); + rejectedDisposals++; + }; + rejected.Events.OnTestStart += (_, _) => { rejectedStarts++; return default; }; + (rejected.Events.DataRowScopes ??= new()).Register(new CallbackScope(() => { scopeDisposals++; return default; })); + row.Return(data, null, rejected); + } + var owner = CreateContext(); + var ownerResource = new object(); + owner.StateBag["rejected-resource"] = ownerResource; + if (failedRow) + row.TransferCleanupTo(owner); + else + await row.TakeOrFetchAsync(() => throw new InvalidOperationException("Must reuse constructor"), owner); + var current = TestContext.Current!; + var executableContext = new TestContext("Cleanup owner", current.ServiceProvider, current.ClassContext, + owner, CancellationToken.None); + executableContext.Metadata.TestDetails = current.TestDetails; + try + { + foreach (var handler in owner.Events.OnDispose!.InvocationList) + await handler.InvokeAsync(executableContext, executableContext); + if (owner.Events.OnTestStart is { } starts) + foreach (var handler in starts.InvocationList) + await handler.InvokeAsync(executableContext, executableContext); + await DisposeRow(owner); + await Assert.That(executableContext.StateBag.Items["rejected-resource"]).IsSameReferenceAs(ownerResource); + } + finally + { + executableContext.RemoveFromRegistry(); + executableContext.Dispose(); + } + await Assert.That(constructorDisposals).IsEqualTo(1); + await Assert.That(rejectedDisposals).IsEqualTo(2); + await Assert.That(scopeDisposals).IsEqualTo(2); + await Assert.That(rejectedStarts).IsEqualTo(0); + } + + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task InstanceConstructorSubscriptionsReachExecutableRow(bool streaming) + { + var current = TestContext.Current!; + var builder = (ITestBuilder)current.ServiceProvider.GetService(typeof(ITestBuilder))!; + var metadata = new TestMetadata + { + TestClassType = typeof(ClassRowCleanupTests), + TestMethodName = nameof(InstanceConstructorSubscriptionsReachExecutableRow), + TestName = "Lazy constructor event sharing regression", + FilePath = "Unknown", + LineNumber = 0, + AttributeFactory = () => [new ClassConstructorAttribute()], + MethodMetadata = current.TestDetails.MethodMetadata, + DataSources = [new ArgumentsAttribute(streaming)], + ClassDataSources = [], + PropertyDataSources = [], + InstanceFactory = (_, _) => new ClassRowCleanupTests(), + InvokeTypedTest = (_, _, _) => ValueTask.CompletedTask + }; + var tests = new List(); + try + { + if (streaming) + { + await foreach (var test in builder.BuildTestsStreamingAsync(metadata)) + tests.Add(test); + } + else + { + tests.AddRange(await builder.BuildTestsFromMetadataAsync(metadata, new TestBuildingContext(false, null))); + } + await Assert.That(tests.Count).IsEqualTo(1); + var executable = tests[0]; + await executable.CreateInstanceAsync(); + await Assert.That(executable.Context.InternalEventsIfCreated?.OnDispose).IsNotNull(); + foreach (var invocation in executable.Context.InternalEvents.OnDispose!.InvocationList) + await invocation.InvokeAsync(this, executable.Context); + await Assert.That(executable.Context.StateBag.Items["constructor-disposals"]).IsEqualTo(1); + } + finally + { + foreach (var test in tests) + { + test.Context.RemoveFromRegistry(); + test.Context.Dispose(); + } + } + } + + public sealed class LateEventConstructor : TUnit.Core.Interfaces.IClassConstructor + { + public Task Create( + [System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)] Type type, + ClassConstructorMetadata metadata) + { + metadata.TestBuilderContext.Events.OnDispose += (_, context) => + { + context.StateBag.Items["constructor-disposals"] = 1; + return default; + }; + return Task.FromResult(new ClassRowCleanupTests()); + } + } + + [Test] + public async Task ClaimedRowDoesNotTransferCleanupToLaterSkippedTest() + { + var original = CreateContext(); + var disposals = 0; + (original.Events.DataRowScopes ??= new()).Register(new CallbackScope(() => + { + disposals++; + return default; + })); + var row = new TestBuilder.UnclaimedClassRow([new object()], null, original); + var runnable = CreateContext(); + await row.TakeOrFetchAsync(() => throw new InvalidOperationException("Must use saved row"), runnable); + + var skipped = CreateContext(); + row.TransferCleanupTo(skipped); + await DisposeRow(skipped); + await Assert.That(disposals).IsEqualTo(0); + await DisposeRow(runnable); + await Assert.That(disposals).IsEqualTo(1); + } + + [Test] + public async Task UnclaimedRowTransfersCleanupToSkippedTest() + { + var original = CreateContext(); + var disposals = 0; + (original.Events.DataRowScopes ??= new()).Register(new CallbackScope(() => + { + disposals++; + return default; + })); + var row = new TestBuilder.UnclaimedClassRow([new object()], null, original); + var skipped = CreateContext(); + row.TransferCleanupTo(skipped); + row.Clear(); + await DisposeRow(skipped); + await Assert.That(disposals).IsEqualTo(1); + } + + [Test] + public async Task LentRowKeepsScopeUntilSessionCleanupEvenWhenNextFactoryThrows() + { + var original = CreateContext(); + var disposals = 0; + (original.Events.DataRowScopes ??= new()).Register(new CallbackScope(() => + { + disposals++; + return default; + })); + var row = new TestBuilder.UnclaimedClassRow([new object()], null, original); + var session = CreateContext(); + row.LendToTemporaryInstance(session); + var failed = CreateContext(); + await Assert.That(async () => await row.TakeOrFetchAsync( + () => throw new InvalidOperationException("Factory failure"), failed)) + .Throws(); + + await DisposeRow(failed); + await Assert.That(disposals).IsEqualTo(0); + var later = CreateContext(); + row.TransferCleanupTo(later); + await DisposeRow(later); + await Assert.That(disposals).IsEqualTo(0); + await DisposeRow(session); + await Assert.That(disposals).IsEqualTo(1); + } + + [Test] + [Arguments(true)] + [Arguments(false)] + public async Task EnumerationFailureOnlyDisposesUnclaimedClassScope(bool claimed) + { + var enumeration = CreateContext(); + var disposals = 0; + (enumeration.Events.DataRowScopes ??= new()).Register(new CallbackScope(() => + { + disposals++; + return default; + })); + var row = new TestBuilder.UnclaimedClassRow([new object()], null, enumeration); + var runnable = CreateContext(); + if (claimed) + { + await row.TakeOrFetchAsync(() => throw new InvalidOperationException("Must use saved row"), runnable); + } + + // MoveNext throws after yielding a row (or before yielding any rows). + var failed = row.CreateFailureContext(enumeration); + await DisposeRow(failed); + await Assert.That(disposals).IsEqualTo(claimed ? 0 : 1); + await DisposeRow(runnable); + await Assert.That(disposals).IsEqualTo(1); + } + + [Test] + public async Task ClassEnumerationFailurePreservesSeparateFactoryCleanup() + { + var enumeration = CreateContext(); + var row = new TestBuilder.UnclaimedClassRow([new object()], null, enumeration); + var factoryContext = CreateContext(); + var disposals = 0; + (factoryContext.Events.DataRowScopes ??= new()).Register(new CallbackScope(() => + { + disposals++; + return default; + })); + + var failed = row.CreateFailureContext(factoryContext); + await DisposeRow(failed); + await Assert.That(disposals).IsEqualTo(1); + } + + [Test] + [Arguments(false, false)] + [Arguments(true, false)] + [Arguments(false, true)] + public async Task ClassIteratorFailureDoesNotDisposeRunnableScope(bool laterSource, bool emptyMethodSource) + { + var current = TestContext.Current!; + var builder = (ITestBuilder)current.ServiceProvider.GetService(typeof(ITestBuilder))!; + var source = new ThrowingClassSource { ThrowAfterRow = !laterSource }; + var metadata = new TestMetadata + { + TestClassType = typeof(ClassRowCleanupTests), + TestMethodName = nameof(ClassIteratorFailureDoesNotDisposeRunnableScope), + TestName = "Class iterator failure regression", + FilePath = "Unknown", + LineNumber = 0, + AttributeFactory = () => [], + MethodMetadata = current.TestDetails.MethodMetadata, + DataSources = emptyMethodSource ? [new EmptySource()] : [], + ClassDataSources = laterSource ? [source, new ThrowingBeforeRowSource()] : [source], + PropertyDataSources = [], + InstanceFactory = (_, _) => new ClassRowCleanupTests(), + InvokeTypedTest = (_, _, _) => ValueTask.CompletedTask + }; + + var tests = (await builder.BuildTestsFromMetadataAsync(metadata, new TestBuildingContext(false, null))).ToArray(); + try + { + await Assert.That(tests.Length).IsEqualTo(emptyMethodSource ? 1 : 2); + await Assert.That(tests[^1].Result!.Exception!.Message).IsEqualTo("Class iterator failure"); + await DisposeTestRow(tests[^1].Context); + await Assert.That(source.Disposals).IsEqualTo(emptyMethodSource ? 1 : 0); + if (!emptyMethodSource) + { + await DisposeTestRow(tests[0].Context); + } + await Assert.That(source.Disposals).IsEqualTo(1); + } + finally + { + foreach (var test in tests) + { + test.Context.RemoveFromRegistry(); + test.Context.Dispose(); + } + } + } + + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task NonRunnableRowsPreservePublicCallbacksAndState(bool factoryThrows) + { + var current = TestContext.Current!; + var builder = (ITestBuilder)current.ServiceProvider.GetService(typeof(ITestBuilder))!; + var source = new PublicCleanupSource { FactoryThrows = factoryThrows }; + var metadata = new TestMetadata + { + TestClassType = typeof(ClassRowCleanupTests), + TestMethodName = nameof(NonRunnableRowsPreservePublicCallbacksAndState), + TestName = "Non-runnable row callbacks regression", + FilePath = "Unknown", + LineNumber = 0, + AttributeFactory = () => [], + MethodMetadata = current.TestDetails.MethodMetadata, + DataSources = factoryThrows ? [] : [new EmptySource { SkipIfEmpty = true }], + ClassDataSources = [source], + PropertyDataSources = [], + InstanceFactory = (_, _) => new ClassRowCleanupTests(), + InvokeTypedTest = (_, _, _) => ValueTask.CompletedTask + }; + var tests = (await builder.BuildTestsFromMetadataAsync(metadata, new TestBuildingContext(false, null))).ToArray(); + try + { + await Assert.That(tests.Length).IsEqualTo(1); + var context = tests[0].Context; + await Assert.That(context.StateBag["resource"]).IsSameReferenceAs(source.Resource); + var events = context.InternalEvents; + foreach (var callback in new[] { events.OnTestRegistered, events.OnTestSkipped, events.OnTestEnd, events.OnDispose }) + { + await Assert.That(callback).IsNotNull(); + foreach (var handler in callback!.InvocationList) + { + await handler.InvokeAsync(context, context); + } + } + await Assert.That(source.Callbacks).IsEqualTo(4); + } + finally + { + foreach (var test in tests) + { + test.Context.RemoveFromRegistry(); + test.Context.Dispose(); + } + } + } + + private sealed class PublicCleanupSource : Attribute, IDataSourceAttribute + { + public readonly object Resource = new(); + public int Callbacks; + public bool FactoryThrows; + public bool SkipIfEmpty { get; set; } + public bool DeferEnumeration { get; set; } + public async IAsyncEnumerable>> GetDataRowsAsync(DataGeneratorMetadata metadata) + { + await Task.CompletedTask; + yield return () => + { + var context = metadata.TestBuilderContext.Current; + context.StateBag["resource"] = Resource; + ValueTask Callback(object sender, TestContext testContext) + { + if (!ReferenceEquals(testContext.StateBag["resource"], Resource)) + { + throw new InvalidOperationException("Cleanup lost its owning state"); + } + Callbacks++; + return default; + } + context.Events.OnTestRegistered += Callback; + context.Events.OnTestSkipped += Callback; + context.Events.OnTestEnd += Callback; + context.Events.OnDispose += Callback; + if (FactoryThrows) throw new InvalidOperationException("Factory failure"); + return Task.FromResult([Resource]); + }; + } + } + + [Test] + public async Task ConcurrentTemporaryRowsKeepEverySessionCleanup() + { + var session = CreateContext(); + session.Events = new(); // Match the eagerly initialized global session context. + var contexts = Enumerable.Range(0, 512).Select(_ => CreateContext()).ToArray(); + var disposals = 0; + foreach (var context in contexts) + { + (context.Events.DataRowScopes ??= new()).Register(new CallbackScope(() => + { + Interlocked.Increment(ref disposals); + return default; + })); + } + Parallel.ForEach(contexts, context => + new TestBuilder.UnclaimedClassRow([new object()], null, context).LendToTemporaryInstance(session)); + await DisposeRow(session); + await Assert.That(disposals).IsEqualTo(contexts.Length); + } + + private sealed class EmptySource : Attribute, IDataSourceAttribute + { + public bool SkipIfEmpty { get; set; } + public bool DeferEnumeration { get; set; } + public async IAsyncEnumerable>> GetDataRowsAsync(DataGeneratorMetadata metadata) + { + await Task.CompletedTask; + yield break; + } + } + + private sealed class ThrowingClassSource : Attribute, IDataSourceAttribute + { + public int Disposals; + public bool ThrowAfterRow = true; + public bool SkipIfEmpty { get; set; } + public bool DeferEnumeration { get; set; } + + public async IAsyncEnumerable>> GetDataRowsAsync(DataGeneratorMetadata metadata) + { + await Task.CompletedTask; + yield return () => + { + (metadata.TestBuilderContext.Current.Events.DataRowScopes ??= new()).Register(new CallbackScope(() => + { + Disposals++; + return default; + })); + return Task.FromResult([new object()]); + }; + if (ThrowAfterRow) + { + throw new InvalidOperationException("Class iterator failure"); + } + } + } + + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task ConstructorFactoryStateIsIsolatedAcrossRepeats(bool streaming) + { + var current = TestContext.Current!; + var builder = (ITestBuilder)current.ServiceProvider.GetService(typeof(ITestBuilder))!; + var source = new StatefulConstructorSource(); + var metadata = new TestMetadata + { + TestClassType = typeof(ClassRowCleanupTests), + TestMethodName = nameof(ConstructorFactoryStateIsIsolatedAcrossRepeats), + TestName = "Constructor factory state regression", + FilePath = "Unknown", + LineNumber = 0, + AttributeFactory = () => [], + MethodMetadata = current.TestDetails.MethodMetadata, + RepeatCount = 2, + DataSources = [], + ClassDataSources = [source], + PropertyDataSources = [], + InstanceFactory = (_, _) => new ClassRowCleanupTests(), + InvokeTypedTest = (_, _, _) => ValueTask.CompletedTask + }; + var tests = new List(); + try + { + if (streaming) + { + await foreach (var test in builder.BuildTestsStreamingAsync(metadata)) + { + tests.Add(test); + } + } + else + { + tests.AddRange(await builder.BuildTestsFromMetadataAsync(metadata, new TestBuildingContext(false, null))); + } + await Assert.That(tests.Count).IsEqualTo(3); + await Assert.That(source.Resources.Count).IsEqualTo(3); + for (var i = 0; i < tests.Count; i++) + { + await Assert.That(tests[i].Result?.Exception).IsNull(); + await Assert.That(ReferenceEquals(tests[i].Context.StateBag.Items["resource"], source.Resources[i])).IsTrue(); + } + } + finally + { + foreach (var test in tests) + { + test.Context.RemoveFromRegistry(); + test.Context.Dispose(); + } + } + } + + [Test] + [Arguments(false, false)] + [Arguments(false, true)] + [Arguments(true, false)] + [Arguments(true, true)] + public async Task ClassAndMethodEnumerationStateSurvivesFactories(bool streaming, bool classEnumeration) + { + var current = TestContext.Current!; + var builder = (ITestBuilder)current.ServiceProvider.GetService(typeof(ITestBuilder))!; + var metadata = new TestMetadata + { + TestClassType = typeof(ClassRowCleanupTests), + TestMethodName = nameof(ClassAndMethodEnumerationStateSurvivesFactories), + TestName = "Enumeration state regression", + FilePath = "Unknown", + LineNumber = 0, + AttributeFactory = () => [], + MethodMetadata = current.TestDetails.MethodMetadata, + RepeatCount = 1, + DataSources = [new EnumerationStateSource(false)], + ClassDataSources = [new EnumerationStateSource(true, classEnumeration)], + PropertyDataSources = [], + InstanceFactory = (_, _) => new ClassRowCleanupTests(), + InvokeTypedTest = (_, _, _) => ValueTask.CompletedTask + }; + var tests = new List(); + try + { + if (streaming) + { + await foreach (var test in builder.BuildTestsStreamingAsync(metadata)) + { + tests.Add(test); + } + } + else + { + tests.AddRange(await builder.BuildTestsFromMetadataAsync(metadata, new TestBuildingContext(false, null))); + } + + await Assert.That(tests.Count).IsEqualTo(4); + foreach (var test in tests) + { + await Assert.That(test.Result?.Exception).IsNull(); + await Assert.That(test.Context.StateBag.ContainsKey("removed-by-method")).IsFalse(); + await Assert.That(test.Context.StateBag.ContainsKey("removed")).IsFalse(); + await Assert.That(test.Context.StateBag.ContainsKey("removed-after-method-update")).IsFalse(); + await Assert.That(test.Context.StateBag.Items["method-overwrite"]).IsEqualTo(test.Context.StateBag.Items["method-enumeration"]); + await Assert.That(test.Context.StateBag.Items["overwritten"]).IsEqualTo(test.Context.StateBag.Items["class-factory"]); + if (classEnumeration) + { + await Assert.That(test.Context.StateBag.Items["class-enumeration"]).IsEqualTo(test.Context.StateBag.Items["class-factory"]); + } + await Assert.That(test.Context.StateBag.Items["method-enumeration"]).IsEqualTo(test.Context.StateBag.Items["method-factory"]); + } + } + finally + { + foreach (var test in tests) + { + test.Context.RemoveFromRegistry(); + test.Context.Dispose(); + } + } + } + + private sealed class EnumerationStateSource(bool constructor, bool enumerationState = true) : Attribute, IDataSourceAttribute + { + public bool SkipIfEmpty { get; set; } + public bool DeferEnumeration { get; set; } + + public async IAsyncEnumerable>> GetDataRowsAsync(DataGeneratorMetadata metadata) + { + await Task.CompletedTask; + var prefix = constructor ? "class" : "method"; + for (var i = 0; i < (constructor ? 2 : 1); i++) + { + var value = new object(); + if (enumerationState) + { + metadata.TestBuilderContext.Current.StateBag[prefix + "-enumeration"] = value; + } + metadata.TestBuilderContext.Current.StateBag["method-overwrite"] = value; + metadata.TestBuilderContext.Current.StateBag["removed-after-method-update"] = value; + if (constructor) + { + metadata.TestBuilderContext.Current.StateBag["overwritten"] = new object(); + metadata.TestBuilderContext.Current.StateBag["removed"] = new object(); + metadata.TestBuilderContext.Current.StateBag["removed-by-method"] = new object(); + } + else + { + metadata.TestBuilderContext.Current.StateBag.TryRemove("removed-by-method", out _); + } + yield return () => + { + var state = metadata.TestBuilderContext.Current.StateBag; + if (enumerationState && !ReferenceEquals(state[prefix + "-enumeration"], value)) + { + throw new InvalidOperationException("Enumeration state belongs to another row"); + } + if (!state.TryAdd(prefix + "-factory", value)) + { + throw new InvalidOperationException("Factory state leaked into enumeration"); + } + if (constructor) + { + state["overwritten"] = value; + state.TryRemove("removed", out _); + state.TryRemove("removed-after-method-update", out _); + } + return Task.FromResult([value]); + }; + if (metadata.TestBuilderContext.Current.StateBag.ContainsKey(prefix + "-factory")) + { + throw new InvalidOperationException("Factory state leaked when enumeration resumed"); + } + } + } + } + + private sealed class StatefulConstructorSource : Attribute, IDataSourceAttribute + { + public readonly List Resources = []; + public bool SkipIfEmpty { get; set; } + public bool DeferEnumeration { get; set; } + + public async IAsyncEnumerable>> GetDataRowsAsync(DataGeneratorMetadata metadata) + { + await Task.CompletedTask; + yield return () => + { + var resource = new object(); + if (!metadata.TestBuilderContext.Current.StateBag.TryAdd("resource", resource)) + { + throw new InvalidOperationException("Constructor factory inherited another row's resource"); + } + Resources.Add(resource); + return Task.FromResult([resource]); + }; + } + } + + private sealed class ThrowingBeforeRowSource : Attribute, IDataSourceAttribute + { + public bool SkipIfEmpty { get; set; } + public bool DeferEnumeration { get; set; } + + public async IAsyncEnumerable>> GetDataRowsAsync(DataGeneratorMetadata metadata) + { + await Task.FromException(new InvalidOperationException("Class iterator failure")); + yield break; + } + } + + private static async Task DisposeTestRow(TestContext context) + { + if (context.InternalEventsIfCreated?.DataRowScopes is { } scopes && + await scopes.CompleteAsync() is { } exceptions) + { + throw new AggregateException(exceptions); + } + } + + private static TestBuilderContext CreateContext() => new() + { + TestMetadata = TestContext.Current!.TestDetails.MethodMetadata + }; + + private static async Task DisposeRow(TestBuilderContext context) + { + if (context.EventsIfCreated?.DataRowScopes is { } scopes && + await scopes.CompleteAsync() is { } exceptions) + { + throw new AggregateException(exceptions); + } + } + + private sealed class CallbackScope(Func dispose) : IAsyncDisposable + { + public ValueTask DisposeAsync() => dispose(); + } +} diff --git a/tests/TUnit.UnitTests/DataRowScopeCollectionTests.cs b/tests/TUnit.UnitTests/DataRowScopeCollectionTests.cs new file mode 100644 index 00000000000..45fa6b98f7e --- /dev/null +++ b/tests/TUnit.UnitTests/DataRowScopeCollectionTests.cs @@ -0,0 +1,123 @@ +using TUnit.Core; + +namespace TUnit.UnitTests; + +public class DataRowScopeCollectionTests +{ + [Test] + public async Task ReleaseUsesReferenceIdentityAndLeavesUnrelatedScopesAlive() + { + var scopes = new DataRowScopeCollection(); + var first = new Scope(); + var second = new Scope(); + var firstValue = new EqualValue(); + scopes.RegisterObject(scopes.Register(first), firstValue); + scopes.RegisterObject(scopes.Register(second), new EqualValue()); + + await scopes.ReleaseAsync(firstValue); + await Assert.That(first.DisposeCount).IsEqualTo(1); + await Assert.That(second.DisposeCount).IsEqualTo(0); + await scopes.CompleteAsync(); + await Assert.That(first.DisposeCount).IsEqualTo(1); + await Assert.That(second.DisposeCount).IsEqualTo(1); + } + + [Test] + public async Task SharedResolvedObjectReleasesAllOwnersOnceAcrossClones() + { + var scopes = new DataRowScopeCollection(); + var first = new Scope(); + var second = new Scope(); + var value = new object(); + scopes.RegisterObject(scopes.Register(first), value); + scopes.RegisterObject(scopes.Register(second), value); + var clone = scopes.Clone(); + + await clone.ReleaseAsync(value); + await scopes.CompleteAsync(); + await clone.CompleteAsync(); + await Assert.That(first.DisposeCount).IsEqualTo(1); + await Assert.That(second.DisposeCount).IsEqualTo(1); + } + + [Test] + public async Task ThrowingScopeDoesNotPreventOtherOwnersFromCompleting() + { + var scopes = new DataRowScopeCollection(); + var failed = new Scope { ThrowOnDispose = true }; + var successful = new Scope(); + scopes.Register(failed); // A factory may fail before returning an object. + scopes.Register(successful); + + var errors = await scopes.CompleteAsync(); + await Assert.That(errors!.Count).IsEqualTo(1); + await Assert.That(errors[0].Message).IsEqualTo("Scope failed"); + await Assert.That(failed.DisposeCount).IsEqualTo(1); + await Assert.That(successful.DisposeCount).IsEqualTo(1); + await Assert.That(await scopes.CompleteAsync()).IsNull(); + } + + [Test] + public async Task ParallelDiFactoriesKeepEveryScopeAndObjectAcrossClones() + { + var context = new TestBuilderContext { TestMetadata = TestContext.Current!.TestDetails.MethodMetadata }; + var ready = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var sources = Enumerable.Range(0, 128).Select(_ => new ConcurrentSource()).ToArray(); + var tasks = sources.Select(source => Task.Run(async () => + { + await ready.Task; + return source.Resolve(context); + })).ToArray(); + ready.SetResult(); + var services = await Task.WhenAll(tasks); + var scopes = context.Events.DataRowScopes!; + var clone = scopes.Clone(); + + // Object lookup must retain every registration, not just the final completion sweep. + await Task.WhenAll(services.Select(async service => await clone.ReleaseAsync(service))); + foreach (var source in sources) + await Assert.That(source.Scope.DisposeCount).IsEqualTo(1); + await scopes.CompleteAsync(); + await clone.CompleteAsync(); + foreach (var source in sources) + await Assert.That(source.Scope.DisposeCount).IsEqualTo(1); + } + + private sealed class ConcurrentSource : DependencyInjectionDataSourceAttribute + { + internal Scope Scope { get; } = new(); + internal object Resolve(TestBuilderContext context) + { + var metadata = new DataGeneratorMetadata + { + TestBuilderContext = new TestBuilderContextAccessor(context), + MembersToGenerate = [context.TestMetadata.Class], + TestInformation = null, + Type = TUnit.Core.Enums.DataGeneratorType.Property, + TestSessionId = "concurrent-scope-registration", + TestClassInstance = null, + ClassInstanceArguments = [] + }; + return GenerateDataSources(metadata).Single()()![0]!; + } + public override Scope CreateScope(DataGeneratorMetadata metadata) => Scope; + public override object Create(Scope scope, Type type) => new object(); + } + + private sealed class Scope : IDisposable + { + public int DisposeCount; + public bool ThrowOnDispose; + + public void Dispose() + { + DisposeCount++; + if (ThrowOnDispose) + { + throw new InvalidOperationException("Scope failed"); + } + } + } + + private sealed record EqualValue; +} diff --git a/tests/TUnit.UnitTests/ExternallyOwnedSingletonTests.cs b/tests/TUnit.UnitTests/ExternallyOwnedSingletonTests.cs index 98d3583252f..d4676c409f6 100644 --- a/tests/TUnit.UnitTests/ExternallyOwnedSingletonTests.cs +++ b/tests/TUnit.UnitTests/ExternallyOwnedSingletonTests.cs @@ -15,6 +15,33 @@ namespace TUnit.UnitTests; public class ExternallyOwnedSingletonTests { // Synthetic contexts never reach the engine's cleanup, so each test removes its own from the registry. + [Test] + public async Task Later_Consumers_Do_Not_Release_The_First_Consumers_Reference() + { + var tracker = CreateTracker(); + var singleton = new TrackedDisposable(); + var first = CreateContext(singleton); + tracker.TrackObjects(first); + ObjectTracker.MarkExternallyOwned(singleton); + + var laterConsumers = Enumerable.Range(0, 16).Select(_ => CreateContext(singleton)).ToArray(); + foreach (var context in laterConsumers) + { + tracker.TrackObjects(context); + } + + await Assert.That(ObjectTracker.IsShared(singleton)).IsTrue(); + var errors = await Task.WhenAll(laterConsumers.Select(context => Task.Run(async () => await tracker.UntrackObjects(context)))); + await Assert.That(errors.All(x => x is null)).IsTrue(); + // Later consumers must leave the reference taken before the ownership mark intact. + await Assert.That(IsCounted(singleton)).IsTrue(); + await Assert.That(ObjectTracker.IsShared(singleton)).IsFalse(); + + await tracker.UntrackObjects(first); + await Assert.That(IsCounted(singleton)).IsFalse(); + await Assert.That(singleton.DisposeCount).IsEqualTo(0); + } + private readonly List _contexts = []; [After(Test)] @@ -80,7 +107,7 @@ public async Task Counted_Between_The_Check_And_The_Mark_Is_Released_Without_Bei // The count was released, so the session-end sweep will not dispose the object either. await Assert.That(IsCounted(singleton)).IsFalse(); - // A later source still balances its reference without disposing it. + // A later source balances its own reference without taking disposal ownership. var laterTest = CreateContext(singleton); tracker.TrackObjects(laterTest); await tracker.UntrackObjects(laterTest); @@ -89,28 +116,22 @@ public async Task Counted_Between_The_Check_And_The_Mark_Is_Released_Without_Bei } [Test] - public async Task Later_Consumers_Do_Not_Release_The_First_Consumers_Reference() + public async Task Consumers_After_Ownership_Mark_Hold_Their_Own_References() { var tracker = CreateTracker(); var singleton = new TrackedDisposable(); - var first = CreateContext(singleton); - tracker.TrackObjects(first); + var earlierTest = CreateContext(singleton); + tracker.TrackObjects(earlierTest); ObjectTracker.MarkExternallyOwned(singleton); + var laterTest = CreateContext(singleton); + tracker.TrackObjects(laterTest); - var laterConsumers = Enumerable.Range(0, 16).Select(_ => CreateContext(singleton)).ToArray(); - foreach (var context in laterConsumers) - { - tracker.TrackObjects(context); - } - + // Both consumers must hold a reference before either can release it. await Assert.That(ObjectTracker.IsShared(singleton)).IsTrue(); - var errors = await Task.WhenAll(laterConsumers.Select(context => Task.Run(async () => await tracker.UntrackObjects(context)))); - await Assert.That(errors.All(x => x is null)).IsTrue(); - // Later consumers must leave the reference taken before the ownership mark intact. - await Assert.That(IsCounted(singleton)).IsTrue(); - await Assert.That(ObjectTracker.IsShared(singleton)).IsFalse(); + await Task.WhenAll( + Task.Run(async () => await tracker.UntrackObjects(earlierTest)), + Task.Run(async () => await tracker.UntrackObjects(laterTest))); - await tracker.UntrackObjects(first); await Assert.That(IsCounted(singleton)).IsFalse(); await Assert.That(singleton.DisposeCount).IsEqualTo(0); } diff --git a/tests/TUnit.UnitTests/StaticScopeCleanupTests.cs b/tests/TUnit.UnitTests/StaticScopeCleanupTests.cs new file mode 100644 index 00000000000..e30f371cdd4 --- /dev/null +++ b/tests/TUnit.UnitTests/StaticScopeCleanupTests.cs @@ -0,0 +1,127 @@ +using TUnit.Core; +using TUnit.Core.Enums; +using TUnit.Core.Tracking; +using TUnit.Engine.Services; + +namespace TUnit.UnitTests; + +public class StaticScopeCleanupTests +{ + [Test] + public async Task StaticScopeOwnsServiceUntilSessionCleanup() + { + var events = new TestContextEvents(); + var source = new ScopeSource(); + var service = source.Resolve(events); + var tracker = new ObjectTracker(new TrackableObjectGraphProvider(), GlobalContext.Current.Disposer); + + await Assert.That(service.DisposeCount).IsEqualTo(0); + await Assert.That(source.Scope.DisposeCount).IsEqualTo(0); + + await StaticPropertyHandler.DisposeStaticPropertiesAsync([service], events, tracker, []); + // Matching-object notification and the final sweep must not dispose the same scope twice. + await Assert.That(source.Scope.DisposeCount).IsEqualTo(1); + await Assert.That(service.DisposeCount).IsEqualTo(1); + await Assert.That(events.DataRowScopes).IsNull(); + + var laterSource = new ScopeSource(); + var laterService = laterSource.Resolve(events); + await StaticPropertyHandler.DisposeStaticPropertiesAsync([laterService], events, tracker, []); + await Assert.That(laterSource.Scope.DisposeCount).IsEqualTo(1); + await Assert.That(source.Scope.DisposeCount).IsEqualTo(1); + await Assert.That(events.DataRowScopes).IsNull(); + } + + [Test] + public async Task FailedStaticFactoryScopeIsReleasedAndOtherScopesStillDispose() + { + var events = new TestContextEvents(); + var failed = new ScopeSource { ThrowOnCreate = true, ThrowOnDispose = true }; + try + { + failed.Resolve(events); + } + catch (InvalidOperationException) + { + } + var successful = new ScopeSource(); + var service = successful.Resolve(events); + var tracker = new ObjectTracker(new TrackableObjectGraphProvider(), GlobalContext.Current.Disposer); + var exceptions = new List(); + + try + { + await StaticPropertyHandler.DisposeStaticPropertiesAsync([service], events, tracker, exceptions); + } + catch (AggregateException) + { + } + + await Assert.That(failed.Scope.DisposeCount).IsEqualTo(1); + await Assert.That(successful.Scope.DisposeCount).IsEqualTo(1); + await Assert.That(service.DisposeCount).IsEqualTo(1); + await Assert.That(events.DataRowScopes).IsNull(); + await Assert.That(exceptions.Count).IsEqualTo(1); + await Assert.That(exceptions[0].Message).IsEqualTo("Scope cleanup failure"); + } + + private sealed class ScopeSource : DependencyInjectionDataSourceAttribute + { + public Scope Scope { get; } = new(); + public bool ThrowOnCreate; + public bool ThrowOnDispose; + + public Service Resolve(TestContextEvents events) + { + var method = TestContext.Current!.TestDetails.MethodMetadata; + var metadata = new DataGeneratorMetadata + { + TestBuilderContext = new TestBuilderContextAccessor(new TestBuilderContext + { + TestMetadata = method, + Events = events + }), + MembersToGenerate = [method.Class], + TestInformation = null, + Type = DataGeneratorType.Property, + TestSessionId = "static-scope-regression", + TestClassInstance = null, + ClassInstanceArguments = [] + }; + return (Service) GenerateDataSources(metadata).Single()()![0]!; + } + + public override Scope CreateScope(DataGeneratorMetadata metadata) + { + Scope.ThrowOnDispose = ThrowOnDispose; + return Scope; + } + + public override object Create(Scope scope, Type type) + => ThrowOnCreate ? throw new InvalidOperationException("Factory failure") : scope.Service = new Service(); + } + + private sealed class Scope : IAsyncDisposable + { + public int DisposeCount; + public bool ThrowOnDispose; + public Service? Service; + + public async ValueTask DisposeAsync() + { + await Task.Yield(); + DisposeCount++; + Service?.Dispose(); + if (ThrowOnDispose) throw new InvalidOperationException("Scope cleanup failure"); + } + } + + private sealed class Service : IDisposable + { + public int DisposeCount; + public void Dispose() + { + if (++DisposeCount > 1) throw new InvalidOperationException("Service disposed twice"); + } + } +} From 4e1009f05f2492ca5546f884f68ecabb4a6d9cd8 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:45:42 +0000 Subject: [PATCH 2/6] Synchronize concurrent cleanup handler merges (#7472) --- src/TUnit.Core/TestContextEvents.cs | 7 +++- .../DataRowScopeCollectionTests.cs | 32 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/TUnit.Core/TestContextEvents.cs b/src/TUnit.Core/TestContextEvents.cs index 69afe218d41..e59498ec4cf 100644 --- a/src/TUnit.Core/TestContextEvents.cs +++ b/src/TUnit.Core/TestContextEvents.cs @@ -44,7 +44,12 @@ internal void AddCleanupFrom(TestContextEvents source) if (source.DataRowScopes is { } scopes) AddDataRowScopes(scopes); if (source.OnDispose is { } onDispose) - (OnDispose ??= new()).AddMissingFrom(onDispose); + { + lock (LazyInitializer.EnsureInitialized(ref _scopeLock)!) + { + (OnDispose ??= new()).AddMissingFrom(onDispose); + } + } } private DataRowScopeCollection? _dataRowScopes; diff --git a/tests/TUnit.UnitTests/DataRowScopeCollectionTests.cs b/tests/TUnit.UnitTests/DataRowScopeCollectionTests.cs index 45fa6b98f7e..eb43c538cb3 100644 --- a/tests/TUnit.UnitTests/DataRowScopeCollectionTests.cs +++ b/tests/TUnit.UnitTests/DataRowScopeCollectionTests.cs @@ -83,6 +83,38 @@ public async Task ParallelDiFactoriesKeepEveryScopeAndObjectAcrossClones() await Assert.That(source.Scope.DisposeCount).IsEqualTo(1); } + [Test] + public async Task ParallelCleanupMergesRetainEveryHandlerOnce() + { + var destination = new TestContextEvents(); + var calls = new int[128]; + var sources = Enumerable.Range(0, calls.Length).Select(index => + { + var source = new TestContextEvents { OnDispose = new() }; + source.OnDispose.Add((_, _) => + { + Interlocked.Increment(ref calls[index]); + return default; + }, index); + return source; + }).ToArray(); + var ready = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var merges = sources.Select(source => Task.Run(async () => + { + await ready.Task; + destination.AddCleanupFrom(source); + destination.AddCleanupFrom(source); + })).ToArray(); + ready.SetResult(); + await Task.WhenAll(merges); + + await Assert.That(destination.OnDispose!.InvocationList.Count).IsEqualTo(calls.Length); + foreach (var handler in destination.OnDispose.InvocationList) + await handler.InvokeAsync(destination, TestContext.Current!); + foreach (var count in calls) + await Assert.That(count).IsEqualTo(1); + } + private sealed class ConcurrentSource : DependencyInjectionDataSourceAttribute { internal Scope Scope { get; } = new(); From e167fcf319fba85dc0112578f62341c568b27576 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:03:25 +0000 Subject: [PATCH 3/6] Preserve row context identities and session scope cleanup (#7472) --- src/TUnit.Core/Models/TestSessionContext.cs | 12 +++++ src/TUnit.Engine/Building/TestBuilder.cs | 15 +++++-- .../Services/StaticPropertyHandler.cs | 38 ---------------- .../TestExecution/TestOnDisposeRaiser.cs | 10 +++-- ...Has_No_API_Changes.DotNet10_0.verified.txt | 2 +- ..._Has_No_API_Changes.DotNet8_0.verified.txt | 2 +- ..._Has_No_API_Changes.DotNet9_0.verified.txt | 2 +- ...ary_Has_No_API_Changes.Net4_7.verified.txt | 2 +- .../DeferredDiscoveryCleanupTests.cs | 45 +++++++++++++++---- .../StaticScopeCleanupTests.cs | 22 ++++----- 10 files changed, 79 insertions(+), 71 deletions(-) diff --git a/src/TUnit.Core/Models/TestSessionContext.cs b/src/TUnit.Core/Models/TestSessionContext.cs index f9bb708893f..217e61b4728 100644 --- a/src/TUnit.Core/Models/TestSessionContext.cs +++ b/src/TUnit.Core/Models/TestSessionContext.cs @@ -110,6 +110,18 @@ internal static void DeferOnDisposeToSessionEnd(TestContextEvents? events) { lock (s_deferredOnDisposeLock) { + // Preserve session ownership for discovery rows now that DI cleanup is stored separately. + if (events?.DetachDataRowScopes() is { } scopes) + { + events.OnDispose += async (_, _) => + { + if (await scopes.CompleteAsync().ConfigureAwait(false) is { Count: > 0 } errors) + { + throw new AggregateException("Errors occurred while disposing discovery scopes", errors); + } + }; + } + if (events is not { OnDispose: { } onDispose }) { return; diff --git a/src/TUnit.Engine/Building/TestBuilder.cs b/src/TUnit.Engine/Building/TestBuilder.cs index 905e245e2eb..4569d2e408d 100644 --- a/src/TUnit.Engine/Building/TestBuilder.cs +++ b/src/TUnit.Engine/Building/TestBuilder.cs @@ -494,7 +494,7 @@ public async ValueTask> BuildTestsFromMetada } // Resume enumeration against its own context, without factory-written row state. - contextAccessor.Current = enumerationContext; + contextAccessor.Current = enumerationContext.CreateChild(classDataSource, methodDataSource); } // If no data was yielded and SkipIfEmpty is true, create a skipped test @@ -1225,11 +1225,18 @@ public void Clear() /// Marks the unclaimed objects as used by a temporary instance, so no test receives them, but the /// next test tracks them through or . /// - public void LendToTemporaryInstance() + public void LendToTemporaryInstance(TestBuilderContext? lifetimeContext = null) { _lent = _data; // Method rows share this constructor row until session end. - TestSessionContext.DeferOnDisposeToSessionEnd(_events); + if (lifetimeContext is not null && _events is not null) + { + lifetimeContext.Events.AddCleanupFrom(_events); + } + else + { + TestSessionContext.DeferOnDisposeToSessionEnd(_events); + } _events = null; _data = null; _metadata = null; @@ -2408,7 +2415,7 @@ public async IAsyncEnumerable BuildTestsStreamingAsync( } // Keep subsequent rows and repeats independent of each test's factory state. - contextAccessor.Current = enumerationContext; + contextAccessor.Current = enumerationContext.CreateChild(classDataSource, methodDataSource); } } } diff --git a/src/TUnit.Engine/Services/StaticPropertyHandler.cs b/src/TUnit.Engine/Services/StaticPropertyHandler.cs index 011ae6e80c8..d294cf207b8 100644 --- a/src/TUnit.Engine/Services/StaticPropertyHandler.cs +++ b/src/TUnit.Engine/Services/StaticPropertyHandler.cs @@ -241,44 +241,6 @@ internal async Task DisposeStaticPropertiesAsync(List cleanupExceptio } } - internal static async Task DisposeStaticPropertiesAsync( - IEnumerable staticProperties, - TestContextEvents events, - ObjectTracker objectTracker, - List cleanupExceptions) - { - // Detach before awaiting so a reused host cannot retain scopes from earlier sessions. - var scopes = events.DetachDataRowScopes(); - - foreach (var staticProperty in staticProperties) - { - // Scope-owned values must be released by their scope before tracker disposal is skipped. - if (scopes is not null) - { - await scopes.ReleaseAsync(staticProperty, cleanupExceptions).ConfigureAwait(false); - } - try - { - await objectTracker.UntrackObjectAsync(staticProperty).ConfigureAwait(false); - } - catch (Exception e) - { - cleanupExceptions.Add(e); - } - } - - // Also release scopes whose factory failed or whose returned value was not trackable. - if (scopes is not null) - { - await scopes.CompleteAsync(cleanupExceptions).ConfigureAwait(false); - } - - if (cleanupExceptions.Count > 0) - { - throw new AggregateException("Errors occurred while disposing static properties", cleanupExceptions); - } - } - /// /// Initializes nested objects depth-first (deepest first) before the parent object. /// This ensures that containers/resources are fully initialized before the parent diff --git a/src/TUnit.Engine/Services/TestExecution/TestOnDisposeRaiser.cs b/src/TUnit.Engine/Services/TestExecution/TestOnDisposeRaiser.cs index e2eb41c548b..de84825f057 100644 --- a/src/TUnit.Engine/Services/TestExecution/TestOnDisposeRaiser.cs +++ b/src/TUnit.Engine/Services/TestExecution/TestOnDisposeRaiser.cs @@ -65,12 +65,16 @@ public static async ValueTask RaiseOnceAsync(AbstractExecutableTest test, TUnitF /// public static async ValueTask RaiseForSessionAsync(TestBuilderContext context, List exceptions) { - if (TestSessionContext.TakeStaticOnDispose(context) is not { } onDispose) + if (TestSessionContext.TakeStaticOnDispose(context) is { } onDispose) { - return; + await RaiseDetachedAsync(context, onDispose.InvocationList, exceptions).ConfigureAwait(false); } - await RaiseDetachedAsync(context, onDispose.InvocationList, exceptions).ConfigureAwait(false); + // Session/static contexts also own scopes whose factories failed before returning a value. + if (context.EventsIfCreated?.DetachDataRowScopes() is { } scopes) + { + await scopes.CompleteAsync(exceptions).ConfigureAwait(false); + } } /// diff --git a/tests/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt b/tests/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt index b28f78bc7a2..dee651cb0a5 100644 --- a/tests/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt +++ b/tests/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt @@ -1485,7 +1485,7 @@ namespace public .AsyncEvent<.TestContext>? OnInitialize { get; set; } public .AsyncEvent<.TestContext>? OnTestEnd { get; set; } public .AsyncEvent<.TestContext>? OnTestRegistered { get; set; } - [.(new string[] { + [.(new string?[]?[] { "TestContext", "RetryAttempt"})] public .AsyncEvent<<.TestContext, int>>? OnTestRetry { get; set; } diff --git a/tests/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt b/tests/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt index 05c6f168e70..347662084fd 100644 --- a/tests/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt +++ b/tests/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt @@ -1485,7 +1485,7 @@ namespace public .AsyncEvent<.TestContext>? OnInitialize { get; set; } public .AsyncEvent<.TestContext>? OnTestEnd { get; set; } public .AsyncEvent<.TestContext>? OnTestRegistered { get; set; } - [.(new string[] { + [.(new string?[]?[] { "TestContext", "RetryAttempt"})] public .AsyncEvent<<.TestContext, int>>? OnTestRetry { get; set; } diff --git a/tests/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt b/tests/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt index bf1393fc44b..36aa00ca30c 100644 --- a/tests/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt +++ b/tests/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt @@ -1485,7 +1485,7 @@ namespace public .AsyncEvent<.TestContext>? OnInitialize { get; set; } public .AsyncEvent<.TestContext>? OnTestEnd { get; set; } public .AsyncEvent<.TestContext>? OnTestRegistered { get; set; } - [.(new string[] { + [.(new string?[]?[] { "TestContext", "RetryAttempt"})] public .AsyncEvent<<.TestContext, int>>? OnTestRetry { get; set; } diff --git a/tests/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt b/tests/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt index 4a5f86acc14..a3d0bd8e6d2 100644 --- a/tests/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt +++ b/tests/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt @@ -1428,7 +1428,7 @@ namespace public .AsyncEvent<.TestContext>? OnInitialize { get; set; } public .AsyncEvent<.TestContext>? OnTestEnd { get; set; } public .AsyncEvent<.TestContext>? OnTestRegistered { get; set; } - [.(new string[] { + [.(new string?[]?[] { "TestContext", "RetryAttempt"})] public .AsyncEvent<<.TestContext, int>>? OnTestRetry { get; set; } diff --git a/tests/TUnit.UnitTests/DeferredDiscoveryCleanupTests.cs b/tests/TUnit.UnitTests/DeferredDiscoveryCleanupTests.cs index 02d4829d05a..6ee86baee6b 100644 --- a/tests/TUnit.UnitTests/DeferredDiscoveryCleanupTests.cs +++ b/tests/TUnit.UnitTests/DeferredDiscoveryCleanupTests.cs @@ -98,16 +98,11 @@ public async Task Lent_Constructor_Row_Cleanup_Belongs_To_The_Session() { var session = CreateSession(); var count = 0; - var events = ContextWithOnDispose(() => count++).Events; - // Exercise the real ownership transition without constructing executable tests. The row holder - // is private; reflection here is confined to the test, never a runtime discovery path. - var rowType = typeof(TUnit.Engine.Building.TestBuilder).GetNestedType( - "UnclaimedClassRow", System.Reflection.BindingFlags.NonPublic)!; - var row = Activator.CreateInstance(rowType, new object?[] { Array.Empty(), null, events })!; - rowType.GetMethod("LendToTemporaryInstance")!.Invoke(row, null); + var context = ContextWithOnDispose(() => count++); + var row = new TUnit.Engine.Building.TestBuilder.UnclaimedClassRow([], null, context); + row.LendToTemporaryInstance(); await Assert.That(count).IsEqualTo(0); - await Assert.That(rowType.GetMethod("TakeEvents")!.Invoke(row, null)).IsNull(); await Assert.That(session.DeferredDiscoveryOnDispose?.InvocationList.Count).IsEqualTo(1); var logger = new TUnitFrameworkLogger(null!, null!, null!, new LogLevelProvider(new NoOptions())); await TestOnDisposeRaiser.RaiseDeferredForSessionAsync(session, logger); @@ -115,6 +110,40 @@ public async Task Lent_Constructor_Row_Cleanup_Belongs_To_The_Session() await Assert.That(count).IsEqualTo(1); } + [Test] + public async Task Deferred_Data_Row_Scopes_Belong_To_Their_Session() + { + var first = CreateSession(); + var firstScope = new CountingScope(); + var firstContext = ContextWithOnDispose(() => { }); + firstContext.Events.GetOrCreateDataRowScopes().Register(firstScope); + TestOnDisposeRaiser.DeferToSessionEnd(firstContext); + var second = CreateSession(); + var secondScope = new CountingScope(); + var secondContext = ContextWithOnDispose(() => { }); + secondContext.Events.GetOrCreateDataRowScopes().Register(secondScope); + TestOnDisposeRaiser.DeferToSessionEnd(secondContext); + var logger = new TUnitFrameworkLogger(null!, null!, null!, new LogLevelProvider(new NoOptions())); + + await TestOnDisposeRaiser.RaiseDeferredForSessionAsync(first, logger); + await TestOnDisposeRaiser.RaiseDeferredForSessionAsync(first, logger); + await Assert.That(firstScope.Count).IsEqualTo(1); + await Assert.That(secondScope.Count).IsEqualTo(0); + await Assert.That(firstContext.Events.DataRowScopes).IsNull(); + await TestOnDisposeRaiser.RaiseDeferredForSessionAsync(second, logger); + await Assert.That(secondScope.Count).IsEqualTo(1); + } + + private sealed class CountingScope : IAsyncDisposable + { + public int Count; + public ValueTask DisposeAsync() + { + Count++; + return default; + } + } + [Test] public async Task Throwing_Session_Callback_Does_Not_Stop_Later_Callbacks() { diff --git a/tests/TUnit.UnitTests/StaticScopeCleanupTests.cs b/tests/TUnit.UnitTests/StaticScopeCleanupTests.cs index e30f371cdd4..c9e8fd8acb1 100644 --- a/tests/TUnit.UnitTests/StaticScopeCleanupTests.cs +++ b/tests/TUnit.UnitTests/StaticScopeCleanupTests.cs @@ -1,7 +1,6 @@ using TUnit.Core; using TUnit.Core.Enums; -using TUnit.Core.Tracking; -using TUnit.Engine.Services; +using TUnit.Engine.Services.TestExecution; namespace TUnit.UnitTests; @@ -13,20 +12,21 @@ public async Task StaticScopeOwnsServiceUntilSessionCleanup() var events = new TestContextEvents(); var source = new ScopeSource(); var service = source.Resolve(events); - var tracker = new ObjectTracker(new TrackableObjectGraphProvider(), GlobalContext.Current.Disposer); + var context = new TestBuilderContext { TestMetadata = TestContext.Current!.TestDetails.MethodMetadata, Events = events }; await Assert.That(service.DisposeCount).IsEqualTo(0); await Assert.That(source.Scope.DisposeCount).IsEqualTo(0); - await StaticPropertyHandler.DisposeStaticPropertiesAsync([service], events, tracker, []); - // Matching-object notification and the final sweep must not dispose the same scope twice. + await TestOnDisposeRaiser.RaiseForSessionAsync(context, []); + // Session cleanup detaches the collection so repeated cleanup cannot dispose it twice. + await TestOnDisposeRaiser.RaiseForSessionAsync(context, []); await Assert.That(source.Scope.DisposeCount).IsEqualTo(1); await Assert.That(service.DisposeCount).IsEqualTo(1); await Assert.That(events.DataRowScopes).IsNull(); var laterSource = new ScopeSource(); var laterService = laterSource.Resolve(events); - await StaticPropertyHandler.DisposeStaticPropertiesAsync([laterService], events, tracker, []); + await TestOnDisposeRaiser.RaiseForSessionAsync(context, []); await Assert.That(laterSource.Scope.DisposeCount).IsEqualTo(1); await Assert.That(source.Scope.DisposeCount).IsEqualTo(1); await Assert.That(events.DataRowScopes).IsNull(); @@ -46,16 +46,10 @@ public async Task FailedStaticFactoryScopeIsReleasedAndOtherScopesStillDispose() } var successful = new ScopeSource(); var service = successful.Resolve(events); - var tracker = new ObjectTracker(new TrackableObjectGraphProvider(), GlobalContext.Current.Disposer); + var context = new TestBuilderContext { TestMetadata = TestContext.Current!.TestDetails.MethodMetadata, Events = events }; var exceptions = new List(); - try - { - await StaticPropertyHandler.DisposeStaticPropertiesAsync([service], events, tracker, exceptions); - } - catch (AggregateException) - { - } + await TestOnDisposeRaiser.RaiseForSessionAsync(context, exceptions); await Assert.That(failed.Scope.DisposeCount).IsEqualTo(1); await Assert.That(successful.Scope.DisposeCount).IsEqualTo(1); From 67737e2b1681ffbc7c1bf8d6510506b9c11f4be8 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:15:32 +0000 Subject: [PATCH 4/6] Preserve factory state for temporary constructors (#7472) --- src/TUnit.Core/TestContextEvents.cs | 10 ++++++- src/TUnit.Engine/Building/TestBuilder.cs | 10 ++++--- .../StateBagRowIsolationTests.cs | 27 +++++++++++++++++++ .../ExternallyOwnedSingletonTests.cs | 5 ++++ 4 files changed, 47 insertions(+), 5 deletions(-) diff --git a/src/TUnit.Core/TestContextEvents.cs b/src/TUnit.Core/TestContextEvents.cs index e59498ec4cf..1fa75485b6c 100644 --- a/src/TUnit.Core/TestContextEvents.cs +++ b/src/TUnit.Core/TestContextEvents.cs @@ -39,6 +39,8 @@ internal void AddDataRowScopes(DataRowScopeCollection scopes) } } + // Source row/enumeration contexts have finished registering cleanup before transfer. + // Only the destination may be receiving concurrent factory registrations here. internal void AddCleanupFrom(TestContextEvents source) { if (source.DataRowScopes is { } scopes) @@ -66,7 +68,13 @@ internal DataRowScopeCollection GetOrCreateDataRowScopes() internal DataRowScopeCollection? DataRowScopes { get => Volatile.Read(ref _dataRowScopes); - set => Volatile.Write(ref _dataRowScopes, value); + set + { + lock (LazyInitializer.EnsureInitialized(ref _scopeLock)!) + { + Volatile.Write(ref _dataRowScopes, value); + } + } } public AsyncEvent? OnDispose { get; set; } diff --git a/src/TUnit.Engine/Building/TestBuilder.cs b/src/TUnit.Engine/Building/TestBuilder.cs index 4569d2e408d..5abd560e0a1 100644 --- a/src/TUnit.Engine/Building/TestBuilder.cs +++ b/src/TUnit.Engine/Building/TestBuilder.cs @@ -238,7 +238,8 @@ public async ValueTask> BuildTestsFromMetada // A new factory owns its own cleanup, even if it throws before producing a row. activeClassRow = null; // Keep constructor-factory state on its saved row, outside the enumeration baseline. - var classRowEnumerationContext = contextAccessor.Current.CreateChild(classDataSource, null); + var classFactoryContext = contextAccessor.Current; + var classRowEnumerationContext = classFactoryContext.CreateChild(classDataSource, null); var (classData, firstClassRowMetadata) = DataUnwrapper.UnwrapWithMetadata(await classDataFactory() ?? []); // No test holds this first fetch yet. The first test built from this class row takes it @@ -276,7 +277,7 @@ public async ValueTask> BuildTestsFromMetada classDataAttributeIndex, classDataLoopIndex, classData, - testBuilderContext, + classFactoryContext, cancellationToken); if (!instanceResult.Success) @@ -2329,7 +2330,8 @@ public async IAsyncEnumerable BuildTestsStreamingAsync( contextAccessor.Current = classSourceContext.CreateChild(classDataSource, null); // Keep constructor-factory state on its saved row, outside the enumeration baseline. - var classRowEnumerationContext = contextAccessor.Current.CreateChild(classDataSource, null); + var classFactoryContext = contextAccessor.Current; + var classRowEnumerationContext = classFactoryContext.CreateChild(classDataSource, null); var (classData, firstClassRowMetadata) = DataUnwrapper.UnwrapWithMetadata(await classDataFactory() ?? []); // The first test built from this class row takes this fetch instead of calling the factory @@ -2358,7 +2360,7 @@ public async IAsyncEnumerable BuildTestsStreamingAsync( classDataAttributeIndex, classDataLoopIndex, classData, - contextAccessor.Current, + classFactoryContext, cancellationToken); if (!instanceResult.Success) diff --git a/tests/TUnit.TestProject/StateBagRowIsolationTests.cs b/tests/TUnit.TestProject/StateBagRowIsolationTests.cs index 11579691d3f..eafba996352 100644 --- a/tests/TUnit.TestProject/StateBagRowIsolationTests.cs +++ b/tests/TUnit.TestProject/StateBagRowIsolationTests.cs @@ -34,3 +34,30 @@ public async Task RowsAndRepeatsHaveIndependentState(int row) await Assert.That(TestContext.Current.StateBag["factoryRow"]).IsEqualTo(row); } } + +[ClassConstructor] +[StateBagRowIsolationTests.RowState] +public class TemporaryConstructorRowStateTests(int row) +{ + public sealed class RowConstructor : TUnit.Core.Interfaces.IClassConstructor + { + public Task Create( + [System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)] Type type, + ClassConstructorMetadata metadata) + { + var state = metadata.TestBuilderContext.StateBag; + if (!state.TryAdd("constructorVisited", true)) + throw new InvalidOperationException("Constructor inherited another instance's state"); + return Task.FromResult(new TemporaryConstructorRowStateTests((int)state["factoryRow"]!)); + } + } + + public IEnumerable Rows() => [row]; + + [Test, InstanceMethodDataSource(nameof(Rows)), Repeat(1), EngineTest(ExpectedResult.Pass)] + public async Task TemporaryConstructorSeesFactoryState(int methodRow) + { + await Assert.That(methodRow).IsEqualTo(row); + await Assert.That(TestContext.Current!.StateBag["factoryRow"]).IsEqualTo(row); + } +} diff --git a/tests/TUnit.UnitTests/ExternallyOwnedSingletonTests.cs b/tests/TUnit.UnitTests/ExternallyOwnedSingletonTests.cs index d4676c409f6..9506deb92db 100644 --- a/tests/TUnit.UnitTests/ExternallyOwnedSingletonTests.cs +++ b/tests/TUnit.UnitTests/ExternallyOwnedSingletonTests.cs @@ -109,9 +109,14 @@ public async Task Counted_Between_The_Check_And_The_Mark_Is_Released_Without_Bei // A later source balances its own reference without taking disposal ownership. var laterTest = CreateContext(singleton); + var secondLaterTest = CreateContext(singleton); tracker.TrackObjects(laterTest); + tracker.TrackObjects(secondLaterTest); + await Assert.That(ObjectTracker.IsShared(singleton)).IsTrue(); await tracker.UntrackObjects(laterTest); + await tracker.UntrackObjects(secondLaterTest); + await Assert.That(IsCounted(singleton)).IsFalse(); await Assert.That(singleton.DisposeCount).IsEqualTo(0); } From d7f5389aa4227171e860ec2c6bb319e8714abc5c Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:27:00 +0000 Subject: [PATCH 5/6] Fix reflection static scope cleanup and concurrent handler merging (#7472) --- src/TUnit.Core/AsyncEvent.cs | 50 ++++++++----- src/TUnit.Core/DataRowScopeCollection.cs | 2 + .../StaticPropertyReflectionInitializer.cs | 14 ++-- .../DataRowScopeCollectionTests.cs | 31 ++++++++ .../DeferredDiscoveryCleanupTests.cs | 8 +- .../StaticPropertyHandlerCleanupTests.cs | 73 +++++++++++++++++++ 6 files changed, 150 insertions(+), 28 deletions(-) diff --git a/src/TUnit.Core/AsyncEvent.cs b/src/TUnit.Core/AsyncEvent.cs index 22167a52c0f..34197ea16c9 100644 --- a/src/TUnit.Core/AsyncEvent.cs +++ b/src/TUnit.Core/AsyncEvent.cs @@ -47,19 +47,29 @@ namespace TUnit.Core; /// public class AsyncEvent { + // Published lists are immutable snapshots; readers and clones need no monitor. private List? _handlers; + private object? _handlerLock; internal AsyncEvent Clone() => new() { - _handlers = _handlers is null ? null : [.. _handlers] + _handlers = Volatile.Read(ref _handlers) }; internal void AddMissingFrom(AsyncEvent source) { - foreach (var invocation in source.InvocationList) + var sourceHandlers = source.InvocationList; + lock (LazyInitializer.EnsureInitialized(ref _handlerLock)) { - if (_handlers?.Contains(invocation) != true) - (_handlers ??= []).Insert(FindInsertionIndex(invocation.Order), invocation); + var handlers = _handlers is null ? [] : new List(_handlers); + foreach (var invocation in sourceHandlers) + { + // Clones share Invocation identities. Separate subscriptions, even of the same + // delegate, remain distinct and must both run. + if (!handlers.Contains(invocation)) + handlers.Insert(FindInsertionIndex(handlers, invocation.Order), invocation); + } + Volatile.Write(ref _handlers, handlers); } } @@ -106,8 +116,12 @@ public void Add(Func callback, int order = int.Ma } var invocation = new Invocation(callback, order); - var insertIndex = FindInsertionIndex(order); - (_handlers ??= []).Insert(insertIndex, invocation); + lock (LazyInitializer.EnsureInitialized(ref _handlerLock)) + { + var handlers = _handlers is null ? [] : new List(_handlers); + handlers.Insert(FindInsertionIndex(handlers, order), invocation); + Volatile.Write(ref _handlers, handlers); + } } /// @@ -129,9 +143,13 @@ public void AddAt(Func callback, int index, int o } var invocation = new Invocation(callback, order); - var handlers = _handlers ??= []; - var clampedIndex = index < 0 ? 0 : (index > handlers.Count ? handlers.Count : index); - handlers.Insert(clampedIndex, invocation); + lock (LazyInitializer.EnsureInitialized(ref _handlerLock)) + { + var handlers = _handlers is null ? [] : new List(_handlers); + var clampedIndex = index < 0 ? 0 : (index > handlers.Count ? handlers.Count : index); + handlers.Insert(clampedIndex, invocation); + Volatile.Write(ref _handlers, handlers); + } } /// @@ -145,13 +163,7 @@ public IReadOnlyList InvocationList { get { - if (_handlers == null) - { - return []; - } - - return _handlers; - + return Volatile.Read(ref _handlers) is { } handlers ? handlers : []; } } @@ -194,13 +206,13 @@ public AsyncEvent InsertAtFront(Func return e; } - private int FindInsertionIndex(int order) + private static int FindInsertionIndex(List handlers, int order) { - int left = 0, right = (_handlers ??= []).Count; + int left = 0, right = handlers.Count; while (left < right) { var mid = left + (right - left) / 2; - if (_handlers[mid].Order <= order) + if (handlers[mid].Order <= order) left = mid + 1; else right = mid; diff --git a/src/TUnit.Core/DataRowScopeCollection.cs b/src/TUnit.Core/DataRowScopeCollection.cs index 5ba1dcb6c27..4705bd90ab4 100644 --- a/src/TUnit.Core/DataRowScopeCollection.cs +++ b/src/TUnit.Core/DataRowScopeCollection.cs @@ -18,6 +18,8 @@ internal ScopeOwner Register(object? scope) return owner; } + // A scope resolving several objects is released at the first object's graph depth; + // subsequent releases share the same once-only owner. internal void RegisterObject(ScopeOwner owner, object? instance) { if (instance is null) diff --git a/src/TUnit.Core/StaticPropertyReflectionInitializer.cs b/src/TUnit.Core/StaticPropertyReflectionInitializer.cs index e33752f33b9..46bc51d6d2f 100644 --- a/src/TUnit.Core/StaticPropertyReflectionInitializer.cs +++ b/src/TUnit.Core/StaticPropertyReflectionInitializer.cs @@ -99,13 +99,8 @@ private async ValueTask ReleaseAsync() var onDispose = TestSessionContext.TakeStaticOnDispose(context); _context = null; _error = null; - if (onDispose is null) - { - return; - } - var exceptions = new List(); - foreach (var invocation in onDispose.InvocationList) + foreach (var invocation in onDispose?.InvocationList ?? []) { try { @@ -117,6 +112,13 @@ private async ValueTask ReleaseAsync() } } + // The private initialization context owns DI scopes independently of OnDispose. + // Drain even after failed initialization or a throwing callback. + if (context.EventsIfCreated?.DetachDataRowScopes() is { } scopes) + { + await scopes.CompleteAsync(exceptions).ConfigureAwait(false); + } + if (exceptions.Count > 0) { throw new AggregateException("Errors occurred while disposing reflection static properties", exceptions); diff --git a/tests/TUnit.UnitTests/DataRowScopeCollectionTests.cs b/tests/TUnit.UnitTests/DataRowScopeCollectionTests.cs index eb43c538cb3..46eb25dc12b 100644 --- a/tests/TUnit.UnitTests/DataRowScopeCollectionTests.cs +++ b/tests/TUnit.UnitTests/DataRowScopeCollectionTests.cs @@ -115,6 +115,37 @@ public async Task ParallelCleanupMergesRetainEveryHandlerOnce() await Assert.That(count).IsEqualTo(1); } + [Test] + public async Task ConcurrentEventSubscriptionsAndMergesPreserveDuplicatesAndSnapshots() + { + var destination = new TestContextEvents { OnDispose = new() }; + var source = new TestContextEvents { OnDispose = new() }; + var calls = 0; + Func callback = (_, _) => + { + Interlocked.Increment(ref calls); + return default; + }; + source.OnDispose.Add(callback); + source.OnDispose.Add(callback); + var originalSnapshot = destination.OnDispose.InvocationList; + await Task.WhenAll(Enumerable.Range(0, 128).Select(i => Task.Run(() => + { + if (i % 2 == 0) + destination.OnDispose.Add(callback); + else + destination.AddCleanupFrom(source); + }))); + await Assert.That(originalSnapshot.Count).IsEqualTo(0); + await Assert.That(destination.OnDispose.InvocationList.Count).IsEqualTo(66); + var clone = destination.OnDispose.Clone(); + destination.OnDispose.Add(callback); + await Assert.That(clone.InvocationList.Count).IsEqualTo(66); + foreach (var invocation in destination.OnDispose.InvocationList) + await invocation.InvokeAsync(destination, TestContext.Current!); + await Assert.That(calls).IsEqualTo(67); + } + private sealed class ConcurrentSource : DependencyInjectionDataSourceAttribute { internal Scope Scope { get; } = new(); diff --git a/tests/TUnit.UnitTests/DeferredDiscoveryCleanupTests.cs b/tests/TUnit.UnitTests/DeferredDiscoveryCleanupTests.cs index 6ee86baee6b..6ce4a7ee0b6 100644 --- a/tests/TUnit.UnitTests/DeferredDiscoveryCleanupTests.cs +++ b/tests/TUnit.UnitTests/DeferredDiscoveryCleanupTests.cs @@ -111,16 +111,18 @@ public async Task Lent_Constructor_Row_Cleanup_Belongs_To_The_Session() } [Test] - public async Task Deferred_Data_Row_Scopes_Belong_To_Their_Session() + [Arguments(false)] + [Arguments(true)] + public async Task Deferred_Data_Row_Scopes_Belong_To_Their_Session(bool includeCallbacks) { var first = CreateSession(); var firstScope = new CountingScope(); - var firstContext = ContextWithOnDispose(() => { }); + var firstContext = includeCallbacks ? ContextWithOnDispose(() => { }) : new TestBuilderContext { TestMetadata = TestContext.Current!.TestDetails.MethodMetadata }; firstContext.Events.GetOrCreateDataRowScopes().Register(firstScope); TestOnDisposeRaiser.DeferToSessionEnd(firstContext); var second = CreateSession(); var secondScope = new CountingScope(); - var secondContext = ContextWithOnDispose(() => { }); + var secondContext = includeCallbacks ? ContextWithOnDispose(() => { }) : new TestBuilderContext { TestMetadata = TestContext.Current!.TestDetails.MethodMetadata }; secondContext.Events.GetOrCreateDataRowScopes().Register(secondScope); TestOnDisposeRaiser.DeferToSessionEnd(secondContext); var logger = new TUnitFrameworkLogger(null!, null!, null!, new LogLevelProvider(new NoOptions())); diff --git a/tests/TUnit.UnitTests/StaticPropertyHandlerCleanupTests.cs b/tests/TUnit.UnitTests/StaticPropertyHandlerCleanupTests.cs index 6c5bacecac1..6008f8b837a 100644 --- a/tests/TUnit.UnitTests/StaticPropertyHandlerCleanupTests.cs +++ b/tests/TUnit.UnitTests/StaticPropertyHandlerCleanupTests.cs @@ -88,6 +88,79 @@ public async Task Property_Metadata_Uses_Explicit_Owner_Events_Without_Method_Me await Assert.That(ReferenceEquals(metadata.TestBuilderContext.Current.StateBag, bag)).IsTrue(); } + [Test] + [NotInParallel] + [Arguments(false, false)] + [Arguments(true, false)] + [Arguments(false, true)] + [Arguments(true, true)] + public async Task Reflection_Static_Scopes_Drain_Without_Handlers_And_After_Failures(bool failFactory, bool throwHandler) + { + var session = CreateSession(); + ReflectionScopeSourceAttribute.FailFactory = failFactory; + ReflectionScopeSourceAttribute.ThrowHandler = throwHandler; + if (failFactory) + { + await Assert.That(() => StaticPropertyReflectionInitializer.InitializeStaticPropertiesForType(typeof(ReflectionScopeFixture))) + .Throws(); + } + else + { + await StaticPropertyReflectionInitializer.InitializeStaticPropertiesForType(typeof(ReflectionScopeFixture)); + } + var scope = ReflectionScopeSourceAttribute.LastScope!; + var handler = CreateHandler(session); + await Assert.That(scope.DisposeCount).IsEqualTo(0); + if (throwHandler) + { + await Assert.That(() => handler.DisposeStaticPropertiesAsync([], [])) + .Throws(); + } + else + { + await handler.DisposeStaticPropertiesAsync([], []); + } + await handler.DisposeStaticPropertiesAsync([], []); + await Assert.That(scope.DisposeCount).IsEqualTo(1); + } + + internal sealed class ReflectionScopeFixture + { + [ReflectionScopeSource] + public static Resource? Resource { get; set; } + } + + public sealed class ReflectionScopeSourceAttribute : Attribute, IDataSourceAttribute + { + public bool SkipIfEmpty { get; set; } + public bool DeferEnumeration { get; set; } + internal static bool FailFactory; + internal static bool ThrowHandler; + internal static ReflectionScope? LastScope; + + public async IAsyncEnumerable>> GetDataRowsAsync(DataGeneratorMetadata metadata) + { + LastScope = new ReflectionScope(); + metadata.TestBuilderContext.Current.Events.GetOrCreateDataRowScopes().Register(LastScope); + if (ThrowHandler) + metadata.TestBuilderContext.Current.Events.OnDispose += (_, _) => throw new InvalidOperationException("callback failure"); + yield return () => FailFactory + ? throw new InvalidOperationException("factory failure") + : Task.FromResult([new Resource()]); + await Task.CompletedTask; + } + } + + internal sealed class ReflectionScope : IAsyncDisposable + { + internal int DisposeCount; + public async ValueTask DisposeAsync() + { + await Task.Yield(); + DisposeCount++; + } + } + [Test] public async Task Reflection_Static_Properties_Reinitialize_For_The_Next_Session() { From 5b924364b254e5092c818186925fb80800fcd4dc Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:53:37 +0000 Subject: [PATCH 6/6] Fix netstandard event lock nullability for #7472 --- src/TUnit.Core/AsyncEvent.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/TUnit.Core/AsyncEvent.cs b/src/TUnit.Core/AsyncEvent.cs index 34197ea16c9..e056c6a23f8 100644 --- a/src/TUnit.Core/AsyncEvent.cs +++ b/src/TUnit.Core/AsyncEvent.cs @@ -59,7 +59,7 @@ public class AsyncEvent internal void AddMissingFrom(AsyncEvent source) { var sourceHandlers = source.InvocationList; - lock (LazyInitializer.EnsureInitialized(ref _handlerLock)) + lock (LazyInitializer.EnsureInitialized(ref _handlerLock)!) { var handlers = _handlers is null ? [] : new List(_handlers); foreach (var invocation in sourceHandlers) @@ -116,7 +116,7 @@ public void Add(Func callback, int order = int.Ma } var invocation = new Invocation(callback, order); - lock (LazyInitializer.EnsureInitialized(ref _handlerLock)) + lock (LazyInitializer.EnsureInitialized(ref _handlerLock)!) { var handlers = _handlers is null ? [] : new List(_handlers); handlers.Insert(FindInsertionIndex(handlers, order), invocation); @@ -143,7 +143,7 @@ public void AddAt(Func callback, int index, int o } var invocation = new Invocation(callback, order); - lock (LazyInitializer.EnsureInitialized(ref _handlerLock)) + lock (LazyInitializer.EnsureInitialized(ref _handlerLock)!) { var handlers = _handlers is null ? [] : new List(_handlers); var clampedIndex = index < 0 ? 0 : (index > handlers.Count ? handlers.Count : index);