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
4 changes: 4 additions & 0 deletions docs/docs/writing-tests/lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,10 @@ flowchart LR
| `PerTestSession` | After test session ends |
| `Keyed` | When all tests using that key complete |

:::note Finished test instances are released
Once a test has finished and its result has been reported, its `TestContext` drops its reference to the class instance and its per-test injected objects so they can be garbage collected. Reading `TestContext.Metadata.TestDetails.ClassInstance` for a finished test (for example from `ClassHookContext.Tests` in an `[After(Class)]` or `[After(TestSession)]` hook) no longer returns the test object. Keep your own reference if you need to inspect instances after their tests complete.
:::

## Property Injection Lifecycle

```mermaid
Expand Down
5 changes: 5 additions & 0 deletions src/TUnit.Core/PlaceholderInstance.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,9 @@ private PlaceholderInstance()
{
// Private constructor to ensure singleton pattern
}

// Surfaces in debuggers and logs when user code reads ClassInstance before it is created or after
// a finished test has released it (#7260).
public override string ToString() =>
"Test class instance not available: it has not been created yet, or it was released after the test finished.";
}
21 changes: 21 additions & 0 deletions src/TUnit.Core/TestContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,27 @@ internal void RemoveFromRegistry()
Volatile.Write(ref CachedReportingProperties, null);
}

/// <summary>
/// Drops this context's references to the disposed class instance and untracked objects once the
/// test has finished, so per-test state can be collected while the session keeps the context alive (#7260).
/// </summary>
internal void ReleaseFinishedTestState()
{
var details = Metadata.TestDetails;
if (details.ClassInstance is not (SkippedTestInstance or PlaceholderInstance))
{
details.ClassInstance = PlaceholderInstance.Instance;
}

// The cached receiver lists can hold the instance when it implements event receiver interfaces.
InvalidateEventReceiverCaches();

// Tracked objects were untracked (and disposed when no longer shared) during cleanup.
Volatile.Write(ref _trackedObjects, null);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
DisposalOnlyTrackedObjects = null;
details.ReleaseInjectedPropertyArguments();
}

internal static void ClearReportingCaches()
{
// Discovery-only sessions and failures before execution can leave contexts
Expand Down
4 changes: 4 additions & 0 deletions src/TUnit.Core/TestDetails.cs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@ public partial class TestDetails : ITestIdentity, ITestClass, ITestMethod, ITest
return Interlocked.CompareExchange(ref _testClassInjectedPropertyArguments, created, null) ?? created;
}

// The cache is only read while the test registers and executes; dropping it afterwards lets
// per-test injected objects be collected while the session keeps the context alive (#7260).
internal void ReleaseInjectedPropertyArguments() => _testClassInjectedPropertyArguments = null;

private static class EmptyInjectedPropertyArguments
{
public static readonly IDictionary<string, object?> Instance =
Expand Down
31 changes: 14 additions & 17 deletions src/TUnit.Engine/Services/ObjectLifecycleService.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using TUnit.Core;
using TUnit.Core.Discovery;
using TUnit.Core.Helpers;
Expand All @@ -8,7 +9,6 @@
using TUnit.Core.Tracking;
#if NET
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Threading;
#endif

Expand All @@ -33,10 +33,10 @@ internal sealed class ObjectLifecycleService : IObjectRegistry, IInitializationC
private readonly ObjectGraphDiscoveryService _objectGraphDiscoveryService;
private readonly ObjectTracker _objectTracker;

// Track initialization state per object
// Use ReferenceEqualityComparer to prevent objects with custom Equals from sharing initialization state
private readonly ConcurrentDictionary<object, TaskCompletionSource<bool>> _initializationTasks =
new(Core.Helpers.ReferenceEqualityComparer.Instance);
// Track initialization state per object. ConditionalWeakTable compares keys by reference, so objects
// with custom Equals never share initialization state, and it does not keep per-test objects alive
// for the rest of the session once their test has finished (#7260).
private readonly ConditionalWeakTable<object, TaskCompletionSource<bool>> _initializationTasks = new();

#if NET
// Gates span creation so only the first caller for a given object creates a trace span.
Expand Down Expand Up @@ -481,7 +481,15 @@ public async ValueTask<T> EnsureInitializedAsync<T>(

// Slow path: need to initialize
var tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
existingTcs = _initializationTasks.GetOrAdd(obj, tcs);
// Lock instead of GetValue(obj, _ => tcs) so the slow path does not allocate a closure.
lock (_initializationTasks)
{
if (!_initializationTasks.TryGetValue(obj, out existingTcs))
{
_initializationTasks.Add(obj, tcs);
existingTcs = tcs;
}
}

if (existingTcs == tcs)
{
Expand Down Expand Up @@ -622,15 +630,4 @@ private async Task InitializeNestedObjectsCoreAsync(
}

#endregion

/// <summary>
/// Clears the initialization cache. Called at end of test session.
/// </summary>
public void ClearCache()
{
_initializationTasks.Clear();
#if NET
_spannedObjects.Clear();
#endif
}
}
1 change: 1 addition & 0 deletions src/TUnit.Engine/Services/TestExecution/TestCoordinator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,7 @@ await _eventReceiverOrchestrator.InvokeLastTestInSessionEventReceiversAsync(
test.Context.TimeoutCancellationSource = null;

test.Context.RemoveFromRegistry();
test.Context.ReleaseFinishedTestState();
}
}
}
Expand Down
23 changes: 23 additions & 0 deletions tests/TUnit.Engine.Tests/Issue7260Tests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using Shouldly;
using TUnit.Engine.Tests.Enums;

namespace TUnit.Engine.Tests;

/// <summary>
/// A finished test's class instance is collectable before the session ends (#7260).
/// </summary>
public class Issue7260Tests(TestMode testMode) : InvokableTestBase(testMode)
{
[Test]
public async Task Finished_Test_Instance_Is_Collectable_Before_Session_Ends()
{
await RunTestsWithFilter(
"/*/TUnit.TestProject.Bugs._7260/*/*",
[
result => result.ResultSummary.Outcome.ShouldBe("Completed"),
result => result.ResultSummary.Counters.Total.ShouldBe(4),
result => result.ResultSummary.Counters.Passed.ShouldBe(4),
result => result.ResultSummary.Counters.Failed.ShouldBe(0)
]);
}
}
12 changes: 10 additions & 2 deletions tests/TUnit.TestProject/Bugs/2867/DisposalNotCalledTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,14 @@ public class PerClassSharedDisposalTest : IAsyncDisposable

private static readonly List<string> _webAppIds = new();
private static readonly List<string> _serviceIds = new();
// Finished tests release their class instance (#7260), so keep our own references to inspect.
private static readonly System.Collections.Concurrent.ConcurrentBag<PerClassSharedDisposalTest> _instances = [];
private bool _isDisposed;

public PerClassSharedDisposalTest()
{
_instances.Add(this);
}

[Before(Class)]
public static void BeforeClass()
Expand All @@ -115,6 +122,7 @@ public static void BeforeClass()
DisposalNotCalledTests.DisposableService.Reset();
_webAppIds.Clear();
_serviceIds.Clear();
_instances.Clear();
}

public ValueTask DisposeAsync()
Expand Down Expand Up @@ -215,8 +223,8 @@ public static async Task VerifyDisposalAfterClass(ClassHookContext context)
await Assert.That(_serviceIds.Distinct().Count()).IsEqualTo(1);

// Verify all test instances themselves are disposed
var testInstances = context.Tests.Select(t => t.Metadata.TestDetails.ClassInstance).OfType<PerClassSharedDisposalTest>().ToList();
foreach (var instance in testInstances)
await Assert.That(_instances).IsNotEmpty();
foreach (var instance in _instances)
{
await Assert.That(instance._isDisposed).IsTrue();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ public class ClassDataSourceRetryTests
{
private static bool _wasExecuted;
private static int _attemptCount;
// Finished tests release their class instance (#7260), so keep our own reference to inspect.
private static DataClass? _dataClass;

[ClassDataSource<DataClass>(Shared = SharedType.PerTestSession)]
public required DataClass DataClass { get; init; }
Expand All @@ -53,6 +55,7 @@ public async Task TestThatFailsAndRetries()
{
_wasExecuted = true;
_attemptCount++;
_dataClass = DataClass;

await Assert.That(DataClass.Value).IsEqualTo(42);

Expand All @@ -75,9 +78,7 @@ public async Task VerifyNotDisposedDuringTests()
[After(TestSession)]
public static async Task VerifyDisposalAfterTestSession(TestSessionContext context)
{
var classInstance = context.TestClasses.FirstOrDefault(x => x.ClassType == typeof(ClassDataSourceRetryTests))?.Tests.FirstOrDefault()?.Metadata.TestDetails.ClassInstance as ClassDataSourceRetryTests;

var dataClass = classInstance?.DataClass;
var dataClass = _dataClass;

if (dataClass != null)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using TUnit.TestProject.Attributes;

namespace TUnit.TestProject.Bugs._7260;

/// <summary>
/// A per-test injected object is tracked by the test's context; once the test has finished and the
/// object has been untracked, the context must not keep it reachable either (#7260).
/// </summary>
[EngineTest(ExpectedResult.Pass)]
public class FinishedTestInjectedObjectReleaseTests
{
private static WeakReference? _finishedInjectedObject;

[ClassDataSource<PerTestPayload>(Shared = SharedType.None)]
public required PerTestPayload Payload { get; init; }

[Test]
public void Holds_Injected_Object()
{
_finishedInjectedObject = new WeakReference(Payload);
}

[Test]
[DependsOn(nameof(Holds_Injected_Object))]
public async Task Finished_Injected_Object_Is_Collectable()
{
await Assert.That(_finishedInjectedObject).IsNotNull();

GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();

await Assert.That(_finishedInjectedObject!.IsAlive).IsFalse();
}

public class PerTestPayload
{
public byte[] Data { get; } = new byte[1024 * 1024];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using TUnit.TestProject.Attributes;

namespace TUnit.TestProject.Bugs._7260;

/// <summary>
/// A finished test's class instance must not stay reachable through its TestContext, which the
/// session keeps until it ends, so state held in instance fields can be collected (#7260).
/// </summary>
[EngineTest(ExpectedResult.Pass)]
public class FinishedTestInstanceReleaseTests
{
private static WeakReference? _finishedInstance;

private byte[]? _payload;

[Test]
public void Holds_State_In_Instance_Field()
{
_payload = new byte[1024 * 1024];
_finishedInstance = new WeakReference(this);
}

[Test]
[DependsOn(nameof(Holds_State_In_Instance_Field))]
public async Task Finished_Instance_Is_Collectable()
{
await Assert.That(_finishedInstance).IsNotNull();

GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();

await Assert.That(_finishedInstance!.IsAlive).IsFalse();
}
}
13 changes: 12 additions & 1 deletion tests/TUnit.TestProject/DisposalRegressionTests.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using TUnit.TestProject.Attributes;

namespace TUnit.TestProject;
Expand All @@ -9,6 +10,14 @@ namespace TUnit.TestProject;
[EngineTest(ExpectedResult.Pass)]
public class DisposalRegressionTests : IAsyncDisposable
{
// Finished tests release their class instance (#7260), so keep our own references to inspect.
private static readonly ConcurrentBag<DisposalRegressionTests> Instances = [];

public DisposalRegressionTests()
{
Instances.Add(this);
}

[ClassDataSource<DisposableTestData>(Shared = SharedType.PerTestSession)]
public required DisposableTestData InjectedData { get; init; }

Expand All @@ -32,7 +41,9 @@ public async Task TestExecutes()
public static async Task VerifyDisposal(ClassHookContext context)
{
// After the test class is done, both the test instance and injected property should be disposed
foreach (var testInstance in context.Tests.Select(x => x.Metadata.TestDetails.ClassInstance).OfType<DisposalRegressionTests>())
await Assert.That(Instances).IsNotEmpty();

foreach (var testInstance in Instances)
{
await Assert.That(testInstance.IsDisposed).IsTrue();
await Assert.That(testInstance.InjectedData.IsDisposed).IsTrue();
Expand Down
17 changes: 14 additions & 3 deletions tests/TUnit.TestProject/IDisposableTests.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
using TUnit.TestProject.Attributes;
using System.Collections.Concurrent;
using TUnit.TestProject.Attributes;

namespace TUnit.TestProject;

[EngineTest(ExpectedResult.Pass)]
public class DisposableTests : IDisposable
{
// Finished tests release their class instance (#7260), so keep our own references to inspect.
private static readonly ConcurrentBag<DisposableTests> Instances = [];

public DisposableTests()
{
Instances.Add(this);
}

[Test]
public void One()
{
Expand All @@ -26,9 +35,11 @@ public async Task Three()
[After(Class)]
public static async Task AssertDisposed(ClassHookContext context)
{
foreach (var disposableTestse in context.Tests.Select(x => x.Metadata.TestDetails.ClassInstance).OfType<DisposableTests>())
await Assert.That(Instances).IsNotEmpty();

foreach (var disposableTests in Instances)
{
await Assert.That(disposableTestse.IsDisposed).IsTrue();
await Assert.That(disposableTests.IsDisposed).IsTrue();
}
}

Expand Down
6 changes: 5 additions & 1 deletion tests/TUnit.TestProject/SharedDisposalTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,15 @@ public static void Reset()
[ClassDataSource<DisposableSharedInstance>(Shared = SharedType.PerClass)]
public class SharedDisposalTest
{
// Finished tests release their class instance (#7260), so keep our own references to inspect.
private static readonly System.Collections.Concurrent.ConcurrentBag<SharedDisposalTest> Instances = [];

private readonly DisposableSharedInstance sharedInstance;

public SharedDisposalTest(DisposableSharedInstance sharedInstance)
{
this.sharedInstance = sharedInstance;
Instances.Add(this);
}
[Test]
public async Task Test1_ShouldUseSharedInstance()
Expand Down Expand Up @@ -102,7 +106,7 @@ public static async Task VerifySharedInstanceDisposal(ClassHookContext context)
}

// Print the test instances to see if they were using the same shared instance
var testInstances = context.Tests.Select(t => t.Metadata.TestDetails.ClassInstance).OfType<SharedDisposalTest>().ToList();
var testInstances = Instances.ToList();
Console.WriteLine($"[AfterClass] Test instances: {testInstances.Count}");
for (var i = 0; i < testInstances.Count; i++)
{
Expand Down
Loading