diff --git a/TUnit.Core/Data/ScopedDictionary.cs b/TUnit.Core/Data/ScopedDictionary.cs index e5766792b0..1345ec46ba 100644 --- a/TUnit.Core/Data/ScopedDictionary.cs +++ b/TUnit.Core/Data/ScopedDictionary.cs @@ -11,4 +11,9 @@ public class ScopedDictionary return innerDictionary.GetOrAdd(type, factory); } + + /// + /// Removes all scopes and their cached instances. + /// + public void Clear() => _scopedContainers.Clear(); } diff --git a/TUnit.Core/Data/ThreadSafeDictionary.cs b/TUnit.Core/Data/ThreadSafeDictionary.cs index aa68486d57..717404f09d 100644 --- a/TUnit.Core/Data/ThreadSafeDictionary.cs +++ b/TUnit.Core/Data/ThreadSafeDictionary.cs @@ -103,4 +103,9 @@ public bool TryGetValue(TKey key, [NotNullWhen(true)] out TValue? value) public TValue this[TKey key] => _innerDictionary.TryGetValue(key, out var lazy) ? lazy.Value : throw new KeyNotFoundException($"Key '{key}' not found in dictionary"); + + /// + /// Removes all keys and values from the dictionary. + /// + public void Clear() => _innerDictionary.Clear(); } diff --git a/TUnit.Core/TestDataContainer.cs b/TUnit.Core/TestDataContainer.cs index 49636ca0dd..38fbc72e9f 100644 --- a/TUnit.Core/TestDataContainer.cs +++ b/TUnit.Core/TestDataContainer.cs @@ -29,4 +29,17 @@ internal static class TestDataContainer { return _keyContainer.GetOrCreate(key, type, func); } + + /// + /// Clears all cached shared instances. Called at the end of a run session so that a + /// subsequent run request in the same process (e.g. IDE server mode) creates fresh + /// instances instead of reusing already-disposed ones. + /// + public static void Reset() + { + _globalContainer.Clear(); + _classContainer.Clear(); + _assemblyContainer.Clear(); + _keyContainer.Clear(); + } } diff --git a/TUnit.Core/Tracking/ObjectTracker.cs b/TUnit.Core/Tracking/ObjectTracker.cs index 85e2c930de..53f3aa651d 100644 --- a/TUnit.Core/Tracking/ObjectTracker.cs +++ b/TUnit.Core/Tracking/ObjectTracker.cs @@ -10,7 +10,8 @@ namespace TUnit.Core.Tracking; /// /// /// The static s_trackedObjects dictionary is shared across all tests. -/// Call at the end of a test session to release memory. +/// Call at the end of a run session to +/// dispose any leftovers and release memory. /// internal class ObjectTracker(TrackableObjectGraphProvider trackableObjectGraphProvider, Disposer disposer) { @@ -28,12 +29,40 @@ internal class ObjectTracker(TrackableObjectGraphProvider trackableObjectGraphPr public static IReadOnlyCollection GetAsyncCallbackErrors() => s_asyncCallbackErrors.ToArray(); /// - /// Clears all static tracking state. Call at the end of a test session to release memory. + /// Disposes any objects still tracked with a positive reference count, then clears all + /// static tracking state. Call at the end of a run session: every executed test has + /// already decremented its references by then, so anything still alive would otherwise + /// leak (e.g. its remaining consumers were cancelled or a path miscounted). Clearing also + /// ensures a subsequent run request in the same process starts with fresh state. + /// Intentionally an instance method despite operating on static state: disposal needs the + /// injected (for error logging), which only instances carry. /// - public static void ClearStaticTracking() + /// Any exceptions thrown by the disposals, or null if none. + public async ValueTask?> DisposeAndClearStaticTrackingAsync() { - s_trackedObjects.Clear(); + List? exceptions = null; + + foreach (var kvp in s_trackedObjects) + { + // TryRemove guards against racing disposals (e.g. a late UntrackObject call) + if (!s_trackedObjects.TryRemove(kvp.Key, out _)) + { + continue; + } + + try + { + await disposer.DisposeAsync(kvp.Key).ConfigureAwait(false); + } + catch (Exception ex) + { + (exceptions ??= []).Add(ex); + } + } + s_asyncCallbackErrors.Clear(); + + return exceptions; } /// @@ -189,6 +218,15 @@ private void TrackObject(object? obj) counter.Increment(); } + /// + /// Decrements a single object's reference count, disposing it (and removing it from the + /// static tracking dictionary) when the count reaches zero. Used by owners that hold their + /// own +1 reference (e.g. static properties) so disposal stays consistent with ref counting + /// instead of bypassing it — a direct dispose would leave a stale entry that the session-end + /// sweep would dispose a second time. + /// + public ValueTask UntrackObjectAsync(object? obj) => UntrackObject(obj); + private async ValueTask UntrackObject(object? obj) { if (obj == null || ShouldSkipTracking(obj)) diff --git a/TUnit.Engine.Tests/FilteredSharedFixtureDisposalTests.cs b/TUnit.Engine.Tests/FilteredSharedFixtureDisposalTests.cs new file mode 100644 index 0000000000..e189d1d11c --- /dev/null +++ b/TUnit.Engine.Tests/FilteredSharedFixtureDisposalTests.cs @@ -0,0 +1,76 @@ +using Shouldly; +using TUnit.Engine.Tests.Enums; + +namespace TUnit.Engine.Tests; + +/// +/// End-to-end regression tests for GitHub discussion #6151. +/// A PerTestSession shared fixture must be disposed when only a subset of the tests that +/// consume it executes. The [Explicit] sibling TestB is built (incrementing the fixture's +/// ref count at build time) but excluded from execution — the same built-but-not-run shape +/// an IDE's uid filter produces when running a single [Arguments] case. Previously the +/// never-executed test's ref count kept the fixture alive forever, so DisposeAsync never ran. +/// +public class FilteredSharedFixtureDisposalTests(TestMode testMode) : InvokableTestBase(testMode) +{ + [Test] + public async Task Running_Subset_Of_Fixture_Consumers_Disposes_PerTestSession_Fixture() + { + // "/*" matches both tests, so both are built; TestB is then dropped post-build + // because it is [Explicit] and a non-explicit test also matched. + var markerPath = Path.Combine(Path.GetTempPath(), $"tunit-bug-6151-{Guid.NewGuid():N}.txt"); + + try + { + await RunTestsWithFilter( + "/*/*/Bug6151FilteredDisposalTests/*", + [ + result => result.ResultSummary.Outcome.ShouldBe("Completed"), + result => result.ResultSummary.Counters.Total.ShouldBe(1, + $"Expected only TestA to run (TestB is [Explicit]). Test names: {string.Join(", ", result.Results.Select(r => r.TestName))}"), + result => result.ResultSummary.Counters.Passed.ShouldBe(1), + result => result.ResultSummary.Counters.Failed.ShouldBe(0), + _ => File.Exists(markerPath).ShouldBeTrue($"Marker file '{markerPath}' was not written by the After(TestSession) hook"), + _ => File.ReadAllText(markerPath).ShouldBe("Created=1;Disposed=1") + ], + new RunOptions().WithEnvironmentVariable("TUNIT_BUG_6151_MARKER_PATH", markerPath)); + } + finally + { + if (File.Exists(markerPath)) + { + File.Delete(markerPath); + } + } + } + + [Test] + public async Task Running_Single_Fixture_Consumer_Directly_Disposes_PerTestSession_Fixture() + { + // Sanity check — a literal method filter pre-filters at the metadata level, so only + // TestA is ever built. This path was already green; guard against regression. + var markerPath = Path.Combine(Path.GetTempPath(), $"tunit-bug-6151-{Guid.NewGuid():N}.txt"); + + try + { + await RunTestsWithFilter( + "/*/*/Bug6151FilteredDisposalTests/TestA", + [ + result => result.ResultSummary.Outcome.ShouldBe("Completed"), + result => result.ResultSummary.Counters.Total.ShouldBe(1), + result => result.ResultSummary.Counters.Passed.ShouldBe(1), + result => result.ResultSummary.Counters.Failed.ShouldBe(0), + _ => File.Exists(markerPath).ShouldBeTrue($"Marker file '{markerPath}' was not written by the After(TestSession) hook"), + _ => File.ReadAllText(markerPath).ShouldBe("Created=1;Disposed=1") + ], + new RunOptions().WithEnvironmentVariable("TUNIT_BUG_6151_MARKER_PATH", markerPath)); + } + finally + { + if (File.Exists(markerPath)) + { + File.Delete(markerPath); + } + } + } +} diff --git a/TUnit.Engine.Tests/InvokableTestBase.cs b/TUnit.Engine.Tests/InvokableTestBase.cs index fb87893db5..328bd059d7 100644 --- a/TUnit.Engine.Tests/InvokableTestBase.cs +++ b/TUnit.Engine.Tests/InvokableTestBase.cs @@ -69,10 +69,7 @@ private async Task RunWithoutAot(string filter, ] ) .WithWorkingDirectory(testProject.DirectoryName!) - .WithEnvironmentVariables(new Dictionary - { - ["TUNIT_DISABLE_HTML_REPORTER"] = "true" - }) + .WithEnvironmentVariables(BuildEnvironmentVariables(runOptions)) .WithValidation(CommandResultValidation.None); await RunWithFailureLogging(command, runOptions, trxFilename, assertions, assertionExpression); @@ -105,15 +102,27 @@ private async Task RunWithAot(string filter, List> assertions, ..runOptions.AdditionalArguments ] ) - .WithEnvironmentVariables(new Dictionary - { - ["TUNIT_DISABLE_HTML_REPORTER"] = "true" - }) + .WithEnvironmentVariables(BuildEnvironmentVariables(runOptions)) .WithValidation(CommandResultValidation.None); await RunWithFailureLogging(command, runOptions, trxFilename, assertions, assertionExpression); } + private static Dictionary BuildEnvironmentVariables(RunOptions runOptions) + { + var environmentVariables = new Dictionary + { + ["TUNIT_DISABLE_HTML_REPORTER"] = "true" + }; + + foreach (var (key, value) in runOptions.EnvironmentVariables) + { + environmentVariables[key] = value; + } + + return environmentVariables; + } + protected static FileInfo? FindFile(Func predicate) { return FileSystemHelpers.FindFile(predicate); @@ -176,6 +185,8 @@ public record RunOptions public List AdditionalArguments { get; init; } = []; + public Dictionary EnvironmentVariables { get; init; } = []; + public List, Task>> OnExecutingDelegates { get; init; } = []; public RunOptions WithArgument(string argument) @@ -184,6 +195,12 @@ public RunOptions WithArgument(string argument) return this; } + public RunOptions WithEnvironmentVariable(string key, string? value) + { + EnvironmentVariables[key] = value; + return this; + } + public RunOptions WithGracefulCancellationToken(CancellationToken token) { GracefulCancellationToken = token; diff --git a/TUnit.Engine/Building/TestBuilder.cs b/TUnit.Engine/Building/TestBuilder.cs index 3b64c01ffe..42cf5a438b 100644 --- a/TUnit.Engine/Building/TestBuilder.cs +++ b/TUnit.Engine/Building/TestBuilder.cs @@ -23,7 +23,6 @@ internal sealed class TestBuilder : ITestBuilder private readonly IContextProvider _contextProvider; private readonly ObjectLifecycleService _objectLifecycleService; private readonly Discovery.IHookRegistrar _hookDiscoveryService; - private readonly TestArgumentRegistrationService _testArgumentRegistrationService; private readonly IMetadataFilterMatcher _filterMatcher; public TestBuilder( @@ -32,7 +31,6 @@ public TestBuilder( IContextProvider contextProvider, ObjectLifecycleService objectLifecycleService, Discovery.IHookRegistrar hookDiscoveryService, - TestArgumentRegistrationService testArgumentRegistrationService, IMetadataFilterMatcher filterMatcher) { _sessionId = sessionId; @@ -40,7 +38,6 @@ public TestBuilder( _eventReceiverOrchestrator = eventReceiverOrchestrator; _contextProvider = contextProvider; _objectLifecycleService = objectLifecycleService; - _testArgumentRegistrationService = testArgumentRegistrationService; _filterMatcher = filterMatcher ?? throw new ArgumentNullException(nameof(filterMatcher)); } @@ -934,25 +931,14 @@ public async Task BuildTestAsync(TestMetadata metadata, // Set InternalExecutableTest so it's available during registration for error handling context.InternalExecutableTest = test; - // Register test arguments for property injection and reference counting - // Note: ITestRegisteredEventReceiver and ITestDiscoveryEventReceiver are invoked later - // in InvokePostResolutionEventsAsync after dependencies are resolved - try - { - await RegisterTestArgumentsAsync(context, cancellationToken); - } - catch (Exception ex) - { - // Property registration failed - mark the test as failed immediately - test.SetResult(TestState.Failed, ex); - } - finally - { - // Clear TestContext.Current so subsequent build operations use TestBuildContext.Current - // for output capture. This ensures console output during data source evaluation - // goes to the shared build context, not a previous test's context. - TestContext.Current = null; - } + // Test argument registration (property injection + reference counting) is NOT done here. + // It happens post-filter in TestFilterService.RegisterTest so that shared-object reference + // counts only include tests that will actually execute. Registering at build time inflated + // the counts with built-but-filtered-out tests (e.g. [Explicit] siblings or single + // [Arguments] cases selected by an IDE uid filter), so the count never drained to zero and + // shared fixtures were never disposed (#6151). + // ITestRegisteredEventReceiver and ITestDiscoveryEventReceiver are invoked later in + // InvokePostResolutionEventsAsync after dependencies are resolved. return test; } @@ -1102,23 +1088,6 @@ private async ValueTask CreateTestContextAsync(string testId, TestM return context; } - /// - /// Registers test arguments for property injection and reference counting. - /// Called during test building, before dependencies are resolved. - /// - private async Task RegisterTestArgumentsAsync(TestContext context, CancellationToken cancellationToken = default) - { - var discoveredTest = new DiscoveredTest - { - TestContext = context - }; - - context.InternalDiscoveredTest = discoveredTest; - - // Invoke the global test argument registration service to register shared instances - await _testArgumentRegistrationService.RegisterTestArgumentsAsync(context, cancellationToken); - } - #if NET8_0_OR_GREATER [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode("Scoped attribute filtering uses Type.GetInterfaces and reflection")] #endif diff --git a/TUnit.Engine/Framework/TUnitServiceProvider.cs b/TUnit.Engine/Framework/TUnitServiceProvider.cs index a3be2e2ba8..cd937f037d 100644 --- a/TUnit.Engine/Framework/TUnitServiceProvider.cs +++ b/TUnit.Engine/Framework/TUnitServiceProvider.cs @@ -203,7 +203,7 @@ public TUnitServiceProvider(IExtension extension, var dependencyExpander = Register(new MetadataDependencyExpander(filterMatcher)); var testBuilder = Register( - new TestBuilder(TestSessionId, EventReceiverOrchestrator, ContextProvider, ObjectLifecycleService, hookDiscoveryService, testArgumentRegistrationService, filterMatcher)); + new TestBuilder(TestSessionId, EventReceiverOrchestrator, ContextProvider, ObjectLifecycleService, hookDiscoveryService, filterMatcher)); TestBuilderPipeline = Register( new TestBuilderPipeline( @@ -259,7 +259,7 @@ public TUnitServiceProvider(IExtension extension, Logger, hashSetPool)); - var staticPropertyHandler = Register(new StaticPropertyHandler(Logger, objectTracker, trackableObjectGraphProvider, disposer, lazyPropertyInjector, objectGraphDiscoveryService)); + var staticPropertyHandler = Register(new StaticPropertyHandler(Logger, objectTracker, trackableObjectGraphProvider, lazyPropertyInjector, objectGraphDiscoveryService)); var dynamicTestQueue = Register(new DynamicTestQueue(MessageBus)); @@ -285,9 +285,10 @@ public TUnitServiceProvider(IExtension extension, ContextProvider, lifecycleCoordinator, MessageBus, - staticPropertyInitializer)); + staticPropertyInitializer, + objectTracker)); - Register(new TestRegistry(TestBuilderPipeline, testCoordinator, dynamicTestQueue, TestSessionId, CancellationToken.Token)); + Register(new TestRegistry(TestBuilderPipeline, testCoordinator, dynamicTestQueue, TestFilterService, TestSessionId, CancellationToken.Token)); InitializeConsoleInterceptors(); } diff --git a/TUnit.Engine/Services/StaticPropertyHandler.cs b/TUnit.Engine/Services/StaticPropertyHandler.cs index a1881de0d9..271988c5aa 100644 --- a/TUnit.Engine/Services/StaticPropertyHandler.cs +++ b/TUnit.Engine/Services/StaticPropertyHandler.cs @@ -15,7 +15,6 @@ internal sealed class StaticPropertyHandler private readonly TUnitFrameworkLogger _logger; private readonly ObjectTracker _objectTracker; private readonly TrackableObjectGraphProvider _trackableObjectGraphProvider; - private readonly Disposer _disposer; private readonly Lazy _propertyInjector; private readonly ObjectGraphDiscoveryService _objectGraphDiscoveryService; private readonly ConcurrentDictionary _sessionObjectBag = new(); @@ -24,14 +23,12 @@ internal sealed class StaticPropertyHandler public StaticPropertyHandler(TUnitFrameworkLogger logger, ObjectTracker objectTracker, TrackableObjectGraphProvider trackableObjectGraphProvider, - Disposer disposer, Lazy propertyInjector, ObjectGraphDiscoveryService objectGraphDiscoveryService) { _logger = logger; _objectTracker = objectTracker; _trackableObjectGraphProvider = trackableObjectGraphProvider; - _disposer = disposer; _propertyInjector = propertyInjector; _objectGraphDiscoveryService = objectGraphDiscoveryService; } @@ -96,7 +93,10 @@ public void TrackStaticProperties() } /// - /// Dispose all tracked static properties at session end + /// Dispose all tracked static properties at session end. + /// Goes through the ObjectTracker (decrementing the +1 reference TrackStaticProperties took) + /// rather than disposing directly, so the tracking entry is removed and the session-end + /// sweep in TestSessionCoordinator does not dispose the same object a second time. /// public async Task DisposeStaticPropertiesAsync(List cleanupExceptions) { @@ -106,7 +106,7 @@ public async Task DisposeStaticPropertiesAsync(List cleanupExceptions { try { - await _disposer.DisposeAsync(staticProperty).ConfigureAwait(false); + await _objectTracker.UntrackObjectAsync(staticProperty).ConfigureAwait(false); } catch (Exception e) { diff --git a/TUnit.Engine/Services/TestFilterService.cs b/TUnit.Engine/Services/TestFilterService.cs index 24391f450d..42e6caa8d8 100644 --- a/TUnit.Engine/Services/TestFilterService.cs +++ b/TUnit.Engine/Services/TestFilterService.cs @@ -61,7 +61,7 @@ public IReadOnlyCollection FilterTests(ITestExecutionFil return filteredTests; } - private async Task RegisterTest(AbstractExecutableTest test) + private async Task RegisterTest(AbstractExecutableTest test, bool isForExecution) { var discoveredTest = new DiscoveredTest { @@ -102,14 +102,21 @@ private async Task RegisterTest(AbstractExecutableTest test) return; } - try + // Argument registration creates shared data-source objects and increments their + // reference counts. Only do this for tests that will actually execute — those counts + // are only ever decremented on test completion, so registering during discovery-only + // requests would create fixtures as a side effect and leak them forever (#6151). + if (isForExecution) { - await testArgumentRegistrationService.RegisterTestArgumentsAsync(test.Context); - } - catch (Exception ex) - { - // Mark the test as failed - event receivers have already run above - test.SetResult(TestState.Failed, ex); + try + { + await testArgumentRegistrationService.RegisterTestArgumentsAsync(test.Context); + } + catch (Exception ex) + { + // Mark the test as failed - event receivers have already run above + test.SetResult(TestState.Failed, ex); + } } // Clear the cached display name after registration events @@ -125,7 +132,7 @@ private async Task RegisterTest(AbstractExecutableTest test) /// TestArgumentRegistrationService) use ConcurrentDictionary internally. /// ITestRegisteredEventReceiver implementations must be thread-safe. /// - public async Task RegisterTestsAsync(IEnumerable tests) + public async Task RegisterTestsAsync(IEnumerable tests, bool isForExecution) { var testList = tests as IReadOnlyList ?? tests.ToList(); @@ -133,7 +140,7 @@ public async Task RegisterTestsAsync(IEnumerable tests) { foreach (var test in testList) { - await RegisterTest(test); + await RegisterTest(test, isForExecution); } return; } @@ -141,7 +148,7 @@ public async Task RegisterTestsAsync(IEnumerable tests) await Parallel.ForEachAsync( testList, new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount }, - async (test, _) => await RegisterTest(test).ConfigureAwait(false) + async (test, _) => await RegisterTest(test, isForExecution).ConfigureAwait(false) ).ConfigureAwait(false); } diff --git a/TUnit.Engine/Services/TestRegistry.cs b/TUnit.Engine/Services/TestRegistry.cs index 7621a6bba2..0b5c26d742 100644 --- a/TUnit.Engine/Services/TestRegistry.cs +++ b/TUnit.Engine/Services/TestRegistry.cs @@ -20,18 +20,21 @@ internal sealed class TestRegistry : ITestRegistry private readonly TestBuilderPipeline? _testBuilderPipeline; private readonly ITestCoordinator _testCoordinator; private readonly IDynamicTestQueue _dynamicTestQueue; + private readonly TestFilterService _testFilterService; private readonly CancellationToken _sessionCancellationToken; private readonly string? _sessionId; public TestRegistry(TestBuilderPipeline testBuilderPipeline, ITestCoordinator testCoordinator, IDynamicTestQueue dynamicTestQueue, + TestFilterService testFilterService, string sessionId, CancellationToken sessionCancellationToken) { _testBuilderPipeline = testBuilderPipeline; _testCoordinator = testCoordinator; _dynamicTestQueue = dynamicTestQueue; + _testFilterService = testFilterService; _sessionId = sessionId; _sessionCancellationToken = sessionCancellationToken; } @@ -99,6 +102,10 @@ private async Task ProcessPendingDynamicTests() var buildingContext = new Building.TestBuildingContext(IsForExecution: false, Filter: null); var builtTests = await _testBuilderPipeline!.BuildTestsFromMetadataAsync(testMetadataList, buildingContext); + // Dynamic tests bypass the discovery pipeline's post-filter registration, so register them + // here (event receivers + argument registration/reference counting) before they execute. + await _testFilterService.RegisterTestsAsync(builtTests, isForExecution: true); + foreach (var test in builtTests) { _dynamicTestQueue.Enqueue(test); @@ -265,6 +272,10 @@ public async Task CreateTestVariant( var builtTest = builtTests.FirstOrDefault() ?? throw new InvalidOperationException("Failed to build test variant"); + // Variants bypass the discovery pipeline's post-filter registration, so register here + // (event receivers + argument registration/reference counting) before execution. + await _testFilterService.RegisterTestsAsync([builtTest], isForExecution: true); + _dynamicTestQueue.Enqueue(builtTest); return new TestVariantInfo(builtTest.TestId, builtTest.Context.GetDisplayName()); diff --git a/TUnit.Engine/TestDiscoveryService.cs b/TUnit.Engine/TestDiscoveryService.cs index 348993f378..e3981a7aea 100644 --- a/TUnit.Engine/TestDiscoveryService.cs +++ b/TUnit.Engine/TestDiscoveryService.cs @@ -103,7 +103,7 @@ public async Task DiscoverTests(string testSessionId, ITest filteredTests = [.. testsToInclude]; } - await _testFilterService.RegisterTestsAsync(filteredTests).ConfigureAwait(false); + await _testFilterService.RegisterTestsAsync(filteredTests, isForExecution).ConfigureAwait(false); var finalContext = ExecutionContext.Capture(); return new TestDiscoveryResult(filteredTests, finalContext); @@ -232,6 +232,11 @@ private async IAsyncEnumerable DiscoverTestsStreamAsync( } } + // NOTE: This streaming path never calls _testFilterService.RegisterTestsAsync, so yielded + // tests have NOT had argument registration (shared-object creation + reference counting). + // It currently has no callers; if it is ever wired into an execution path, tests must be + // registered with isForExecution: true before they run, or property injection and shared + // fixture disposal will silently break. #if NET8_0_OR_GREATER [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode("Generic test instantiation requires MakeGenericType")] #endif diff --git a/TUnit.Engine/TestSessionCoordinator.cs b/TUnit.Engine/TestSessionCoordinator.cs index 24f409af40..c53a47ddfb 100644 --- a/TUnit.Engine/TestSessionCoordinator.cs +++ b/TUnit.Engine/TestSessionCoordinator.cs @@ -4,6 +4,7 @@ using TUnit.Core; using TUnit.Core.Exceptions; using TUnit.Core.Services; +using TUnit.Core.Tracking; using TUnit.Engine.Framework; using TUnit.Engine.Logging; using TUnit.Engine.Scheduling; @@ -22,6 +23,7 @@ internal sealed class TestSessionCoordinator : ITestExecutor, IDisposable, IAsyn private readonly TestLifecycleCoordinator _lifecycleCoordinator; private readonly ITUnitMessageBus _messageBus; private readonly IStaticPropertyInitializer _staticPropertyInitializer; + private readonly ObjectTracker _objectTracker; public TestSessionCoordinator(EventReceiverOrchestrator eventReceiverOrchestrator, TUnitFrameworkLogger logger, @@ -30,7 +32,8 @@ public TestSessionCoordinator(EventReceiverOrchestrator eventReceiverOrchestrato IContextProvider contextProvider, TestLifecycleCoordinator lifecycleCoordinator, ITUnitMessageBus messageBus, - IStaticPropertyInitializer staticPropertyInitializer) + IStaticPropertyInitializer staticPropertyInitializer, + ObjectTracker objectTracker) { _eventReceiverOrchestrator = eventReceiverOrchestrator; _logger = logger; @@ -40,6 +43,7 @@ public TestSessionCoordinator(EventReceiverOrchestrator eventReceiverOrchestrato _messageBus = messageBus; _testScheduler = testScheduler; _staticPropertyInitializer = staticPropertyInitializer; + _objectTracker = objectTracker; } public async Task ExecuteTests( @@ -59,6 +63,23 @@ public async Task ExecuteTests( } finally { + // Dispose anything still ref-counted (e.g. consumers were cancelled or filtered out) + // and reset the static shared-instance caches so a subsequent run request in the same + // process (IDE server mode) creates fresh fixtures instead of reusing disposed ones. + // Runs after After(TestSession) hooks and static property disposal (both inside + // ExecuteTestsCore via the scheduler), preserving existing disposal ordering. + var sweepExceptions = await _objectTracker.DisposeAndClearStaticTrackingAsync(); + + if (sweepExceptions is { Count: > 0 }) + { + foreach (var exception in sweepExceptions) + { + await _logger.LogErrorAsync($"Error disposing tracked object at session end: {exception}"); + } + } + + TestDataContainer.Reset(); + foreach (var artifact in _contextProvider.TestSessionContext.Artifacts) { await _messageBus.SessionArtifact(artifact); diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt index e6a854ae81..0c948f5566 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt @@ -1857,6 +1857,7 @@ namespace .Data where TScope : notnull { public ScopedDictionary() { } + public void Clear() { } public object? GetOrCreate(TScope scope, type, <, object?> factory) { } } public class ThreadSafeDictionary @@ -1866,6 +1867,7 @@ namespace .Data public TValue this[TKey key] { get; } public . Keys { get; } public . Values { get; } + public void Clear() { } public TValue GetOrAdd(TKey key, func) { } public TValue GetOrAdd(TKey key, func, TArg arg) { } public TValue? Remove(TKey key) { } diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt index 1257216841..c63d5e723c 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt @@ -1857,6 +1857,7 @@ namespace .Data where TScope : notnull { public ScopedDictionary() { } + public void Clear() { } public object? GetOrCreate(TScope scope, type, <, object?> factory) { } } public class ThreadSafeDictionary @@ -1866,6 +1867,7 @@ namespace .Data public TValue this[TKey key] { get; } public . Keys { get; } public . Values { get; } + public void Clear() { } public TValue GetOrAdd(TKey key, func) { } public TValue GetOrAdd(TKey key, func, TArg arg) { } public TValue? Remove(TKey key) { } diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt index 3ab33b0d2f..61ab10d179 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt @@ -1857,6 +1857,7 @@ namespace .Data where TScope : notnull { public ScopedDictionary() { } + public void Clear() { } public object? GetOrCreate(TScope scope, type, <, object?> factory) { } } public class ThreadSafeDictionary @@ -1866,6 +1867,7 @@ namespace .Data public TValue this[TKey key] { get; } public . Keys { get; } public . Values { get; } + public void Clear() { } public TValue GetOrAdd(TKey key, func) { } public TValue GetOrAdd(TKey key, func, TArg arg) { } public TValue? Remove(TKey key) { } diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt index 33b624363a..0da677c22b 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt @@ -1795,6 +1795,7 @@ namespace .Data where TScope : notnull { public ScopedDictionary() { } + public void Clear() { } public object? GetOrCreate(TScope scope, type, <, object?> factory) { } } public class ThreadSafeDictionary @@ -1804,6 +1805,7 @@ namespace .Data public TValue this[TKey key] { get; } public . Keys { get; } public . Values { get; } + public void Clear() { } public TValue GetOrAdd(TKey key, func) { } public TValue GetOrAdd(TKey key, func, TArg arg) { } public TValue? Remove(TKey key) { } diff --git a/TUnit.TestProject/Bugs/6151/Bug6151FilteredDisposalTests.cs b/TUnit.TestProject/Bugs/6151/Bug6151FilteredDisposalTests.cs new file mode 100644 index 0000000000..9146a29140 --- /dev/null +++ b/TUnit.TestProject/Bugs/6151/Bug6151FilteredDisposalTests.cs @@ -0,0 +1,69 @@ +using TUnit.TestProject.Attributes; + +namespace TUnit.TestProject.Bugs._6151; + +public sealed class SessionSharedFixture : IAsyncDisposable +{ + public static int CreatedCount; + public static int DisposedCount; + + public SessionSharedFixture() + { + Interlocked.Increment(ref CreatedCount); + } + + public ValueTask DisposeAsync() + { + Interlocked.Increment(ref DisposedCount); + return default; + } +} + +/// +/// Repro for https://github.com/thomhurst/TUnit/discussions/6151 — a PerTestSession shared +/// fixture must be disposed even when only a subset of the tests that consume it executes. +/// The [Explicit] sibling is built (incrementing the fixture's ref count) but excluded from +/// execution, mirroring how an IDE's uid filter selects a single [Arguments] case: the +/// never-executed test's ref count previously kept the fixture alive forever. +/// +[EngineTest(ExpectedResult.Pass)] +public class Bug6151FilteredDisposalTests +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required SessionSharedFixture Fixture { get; init; } + + [Test] + public void TestA() + { + } + + [Test] + [Explicit] + public void TestB() + { + } +} + +public static class Bug6151SessionMarker +{ + // Shared-object disposal is ref-counted and fires when the last consuming test completes, + // which is before After(TestSession) hooks run — so the counts are final here. + // No-op unless the engine test opted in via the environment variable, so this hook is + // inert for every other TestProject invocation. + [After(TestSession)] + public static void WriteDisposalMarker() + { + var markerPath = Environment.GetEnvironmentVariable("TUNIT_BUG_6151_MARKER_PATH"); + if (string.IsNullOrEmpty(markerPath)) + { + return; + } + + File.WriteAllText(markerPath, $"Created={SessionSharedFixture.CreatedCount};Disposed={SessionSharedFixture.DisposedCount}"); + + // Reset so a later run session in the same process (IDE server mode) reports + // per-session counts instead of cumulative ones. + Interlocked.Exchange(ref SessionSharedFixture.CreatedCount, 0); + Interlocked.Exchange(ref SessionSharedFixture.DisposedCount, 0); + } +}