Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions TUnit.Core/Data/ScopedDictionary.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,9 @@ public class ScopedDictionary<TScope>

return innerDictionary.GetOrAdd(type, factory);
}

/// <summary>
/// Removes all scopes and their cached instances.
/// </summary>
public void Clear() => _scopedContainers.Clear();
}
5 changes: 5 additions & 0 deletions TUnit.Core/Data/ThreadSafeDictionary.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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");

/// <summary>
/// Removes all keys and values from the dictionary.
/// </summary>
public void Clear() => _innerDictionary.Clear();
}
13 changes: 13 additions & 0 deletions TUnit.Core/TestDataContainer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,17 @@ internal static class TestDataContainer
{
return _keyContainer.GetOrCreate(key, type, func);
}

/// <summary>
/// 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.
/// </summary>
public static void Reset()
{
_globalContainer.Clear();
_classContainer.Clear();
_assemblyContainer.Clear();
_keyContainer.Clear();
}
}
46 changes: 42 additions & 4 deletions TUnit.Core/Tracking/ObjectTracker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ namespace TUnit.Core.Tracking;
/// </summary>
/// <remarks>
/// The static <c>s_trackedObjects</c> dictionary is shared across all tests.
/// Call <see cref="ClearStaticTracking"/> at the end of a test session to release memory.
/// Call <see cref="DisposeAndClearStaticTrackingAsync"/> at the end of a run session to
/// dispose any leftovers and release memory.
/// </remarks>
internal class ObjectTracker(TrackableObjectGraphProvider trackableObjectGraphProvider, Disposer disposer)
{
Expand All @@ -28,12 +29,40 @@ internal class ObjectTracker(TrackableObjectGraphProvider trackableObjectGraphPr
public static IReadOnlyCollection<Exception> GetAsyncCallbackErrors() => s_asyncCallbackErrors.ToArray();

/// <summary>
/// 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 <see cref="Disposer"/> (for error logging), which only instances carry.
/// </summary>
public static void ClearStaticTracking()
/// <returns>Any exceptions thrown by the disposals, or null if none.</returns>
public async ValueTask<List<Exception>?> DisposeAndClearStaticTrackingAsync()
{
s_trackedObjects.Clear();
List<Exception>? 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;
}

/// <summary>
Expand Down Expand Up @@ -189,6 +218,15 @@ private void TrackObject(object? obj)
counter.Increment();
}

/// <summary>
/// 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.
/// </summary>
public ValueTask UntrackObjectAsync(object? obj) => UntrackObject(obj);

private async ValueTask UntrackObject(object? obj)
{
if (obj == null || ShouldSkipTracking(obj))
Expand Down
76 changes: 76 additions & 0 deletions TUnit.Engine.Tests/FilteredSharedFixtureDisposalTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
using Shouldly;
using TUnit.Engine.Tests.Enums;

namespace TUnit.Engine.Tests;

/// <summary>
/// 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.
/// </summary>
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);
}
}
}
}
33 changes: 25 additions & 8 deletions TUnit.Engine.Tests/InvokableTestBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,7 @@ private async Task RunWithoutAot(string filter,
]
)
.WithWorkingDirectory(testProject.DirectoryName!)
.WithEnvironmentVariables(new Dictionary<string, string?>
{
["TUNIT_DISABLE_HTML_REPORTER"] = "true"
})
.WithEnvironmentVariables(BuildEnvironmentVariables(runOptions))
.WithValidation(CommandResultValidation.None);

await RunWithFailureLogging(command, runOptions, trxFilename, assertions, assertionExpression);
Expand Down Expand Up @@ -105,15 +102,27 @@ private async Task RunWithAot(string filter, List<Action<TestRun>> assertions,
..runOptions.AdditionalArguments
]
)
.WithEnvironmentVariables(new Dictionary<string, string?>
{
["TUNIT_DISABLE_HTML_REPORTER"] = "true"
})
.WithEnvironmentVariables(BuildEnvironmentVariables(runOptions))
.WithValidation(CommandResultValidation.None);

await RunWithFailureLogging(command, runOptions, trxFilename, assertions, assertionExpression);
}

private static Dictionary<string, string?> BuildEnvironmentVariables(RunOptions runOptions)
{
var environmentVariables = new Dictionary<string, string?>
{
["TUNIT_DISABLE_HTML_REPORTER"] = "true"
};

foreach (var (key, value) in runOptions.EnvironmentVariables)
{
environmentVariables[key] = value;
}

return environmentVariables;
}

protected static FileInfo? FindFile(Func<FileInfo, bool> predicate)
{
return FileSystemHelpers.FindFile(predicate);
Expand Down Expand Up @@ -176,6 +185,8 @@ public record RunOptions

public List<string> AdditionalArguments { get; init; } = [];

public Dictionary<string, string?> EnvironmentVariables { get; init; } = [];

public List<Func<CommandTask<BufferedCommandResult>, Task>> OnExecutingDelegates { get; init; } = [];

public RunOptions WithArgument(string argument)
Expand All @@ -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;
Expand Down
47 changes: 8 additions & 39 deletions TUnit.Engine/Building/TestBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -32,15 +31,13 @@ public TestBuilder(
IContextProvider contextProvider,
ObjectLifecycleService objectLifecycleService,
Discovery.IHookRegistrar hookDiscoveryService,
TestArgumentRegistrationService testArgumentRegistrationService,
IMetadataFilterMatcher filterMatcher)
{
_sessionId = sessionId;
_hookDiscoveryService = hookDiscoveryService;
_eventReceiverOrchestrator = eventReceiverOrchestrator;
_contextProvider = contextProvider;
_objectLifecycleService = objectLifecycleService;
_testArgumentRegistrationService = testArgumentRegistrationService;
_filterMatcher = filterMatcher ?? throw new ArgumentNullException(nameof(filterMatcher));
}

Expand Down Expand Up @@ -934,25 +931,14 @@ public async Task<AbstractExecutableTest> 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;
}
Expand Down Expand Up @@ -1102,23 +1088,6 @@ private async ValueTask<TestContext> CreateTestContextAsync(string testId, TestM
return context;
}

/// <summary>
/// Registers test arguments for property injection and reference counting.
/// Called during test building, before dependencies are resolved.
/// </summary>
private async Task RegisterTestArgumentsAsync(TestContext context, CancellationToken cancellationToken = default)
{
var discoveredTest = new DiscoveredTest<object>
{
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
Expand Down
9 changes: 5 additions & 4 deletions TUnit.Engine/Framework/TUnitServiceProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ public TUnitServiceProvider(IExtension extension,
var dependencyExpander = Register(new MetadataDependencyExpander(filterMatcher));

var testBuilder = Register<ITestBuilder>(
new TestBuilder(TestSessionId, EventReceiverOrchestrator, ContextProvider, ObjectLifecycleService, hookDiscoveryService, testArgumentRegistrationService, filterMatcher));
new TestBuilder(TestSessionId, EventReceiverOrchestrator, ContextProvider, ObjectLifecycleService, hookDiscoveryService, filterMatcher));

TestBuilderPipeline = Register(
new TestBuilderPipeline(
Expand Down Expand Up @@ -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<IDynamicTestQueue>(new DynamicTestQueue(MessageBus));

Expand All @@ -285,9 +285,10 @@ public TUnitServiceProvider(IExtension extension,
ContextProvider,
lifecycleCoordinator,
MessageBus,
staticPropertyInitializer));
staticPropertyInitializer,
objectTracker));

Register<ITestRegistry>(new TestRegistry(TestBuilderPipeline, testCoordinator, dynamicTestQueue, TestSessionId, CancellationToken.Token));
Register<ITestRegistry>(new TestRegistry(TestBuilderPipeline, testCoordinator, dynamicTestQueue, TestFilterService, TestSessionId, CancellationToken.Token));

InitializeConsoleInterceptors();
}
Expand Down
Loading
Loading