Skip to content

Shared class data constructor invoked twice instead of once per test session #3834

@thomhurst

Description

@thomhurst

Problem Description

When using [ClassDataSource<T>(Shared = SharedType.PerTestSession)], the constructor is sometimes called twice instead of once per test session, especially when initialization takes significant time (e.g., spinning up Docker containers).

Code Example

public sealed class TestRabbitContainer : IAsyncInitializer
{
    public TestRabbitContainer()
    {
        Console.WriteLine("Constructor called"); // This gets logged TWICE
        // Initialize container that binds to specific ports
    }
    
    public async Task InitializeAsync() 
    {
        await _instance.StartAsync(); // Long-running operation (several seconds)
    }
}

// Usage in test
[ClassDataSource<TestRabbitContainer>(Shared = SharedType.PerTestSession)]
public required TestRabbitContainer TestRabbitContainer { get; init; }

Impact

This causes serious issues with resource-heavy shared data:

  • Port binding conflicts: Two containers trying to bind to the same port
  • Resource waste: Duplicate expensive operations (Docker container startup, database initialization)
  • Test failures: Second instance fails to initialize due to resource conflicts
  • Performance degradation: Unnecessary duplication of time-consuming operations

Root Cause Analysis

Based on codebase investigation:

  1. Shared Instance Management: In TestDataContainer.cs, shared instances are stored in static dictionaries:

    private static readonly ScopedDictionary<string> _globalContainer = new();
  2. GetOrCreate Pattern: In ScopedDictionary.cs (lines 10-22):

    public object? GetOrCreate(TScope scope, Type type, Func<Type, object?> factory)
    {
        var innerDictionary = _scopedContainers.GetOrAdd(scope, ...);
        var obj = innerDictionary.GetOrAdd(type, factory);
        // ...
    }
  3. Potential Race Condition: When multiple tests request the same shared instance simultaneously AND the factory function (constructor + initialization) takes significant time, there may be a race condition where:

    • Test A requests shared instance → starts creation
    • Test B requests shared instance before Test A completes → triggers second creation
    • Both constructors run to completion
  4. Initialization Tracking: The ObjectInitializer.cs uses a ConditionalWeakTable to track initialization, but the check in DataSourceInitializer.cs (line 45) may have a TOCTOU (Time-of-Check-Time-of-Use) issue:

    if (_initializationTasks.TryGetValue(dataSource, out var existingTcs) && existingTcs.Task.IsCompleted)

Expected vs Actual Behavior

  • Expected: Single instance created per test session, constructor runs exactly once
  • Actual: Constructor sometimes runs twice when initialization is resource-intensive

Regression Information

  • Worked correctly in: v0.67.19
  • Broken in: v1.0.78
  • Current status: Still experiencing issues in latest versions (reported fix in v1.1.0 didn't fully resolve)

Reproduction

Most likely to occur when:

  1. Using SharedType.PerTestSession
  2. Constructor or IAsyncInitializer.InitializeAsync() takes several seconds
  3. Multiple tests run in parallel attempting to access the shared resource
  4. Resource involves exclusive locks (ports, files, etc.)

Related

Originally reported in discussion #3803

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions