Skip to content

First class-data fetch per class row creates an untracked instance that is never disposed (SharedType.None) #7157

Closed

Description

@thomhurst

Summary

Confirmed on main (04786b3) in both modes. For every class data row, TestBuilder calls the class data factory once on its own (to initialize class data before method data sources are evaluated) and then again for each test. The instance from that first call is never tracked by the object tracker. With SharedType.None the first call creates a separate instance, so every test method leaks one undisposed instance.

Repro

public class ClassCtorData : IDisposable
{
    private static int _next;
    public int Id { get; } = Interlocked.Increment(ref _next);
    public ClassCtorData() => Log($"CREATE #{Id}");
    public void Dispose() => Log($"DISPOSE #{Id}");
    public override string ToString() => $"#{Id}";
    private static void Log(string s) => File.AppendAllText("suspect.log", s + Environment.NewLine);
}

[ClassDataSource<ClassCtorData>(Shared = SharedType.None)]
public class ClassDataLeak(ClassCtorData data)
{
    [Test] public void One() => Console.WriteLine(data);
    [Test] public void Two() => Console.WriteLine(data);
}

Observed (same in source-gen and --reflection)

CREATE #1
CREATE #2
CREATE #3
CREATE #4
(One ran with #2, Two ran with #4)
DISPOSE #2
DISPOSE #4

Two tests create four instances. The two instances the tests actually use are disposed. The two from the first fetch (#1, #3) are never disposed.

Expected

Either the first fetch's instance is reused for the first test, or it is tracked and disposed. Ideally a SharedType.None class data source should not create an extra instance per test at all. Creating the extra instance also runs its constructor and its discovery-phase initialization (IAsyncDiscoveryInitializer), which costs extra for expensive fixtures.

Where

  • src/TUnit.Engine/Building/TestBuilder.cs:228-239: first classDataFactory() call, then InitializeClassDataAsync(classData). The result is used for CreateInitializedInstanceForMethodDataSources and as classInstanceArguments for method data sources, then replaced.
  • src/TUnit.Engine/Building/TestBuilder.cs:300-301: classDataFactory() is called again for every test and replaces classData. Only this instance reaches TestData.ClassData and is tracked.
  • The comment at lines 320-322 already notes that the second call "may return a different instance for non-shared objects".
  • The streaming path has the same pattern: TestBuilder.cs:1933 (first fetch) and TestBuilder.cs:2138 (BuildSingleTestAsync).

Both modes share this builder, so source-gen and reflection leak the same way.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingperformancePerformance optimization

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions