Skip to content

bug: SharedType.PerTestSession fixture initialization is cancelled by individual test timeouts #4772

@thomhurst

Description

@thomhurst

Problem

When a [ClassDataSource<T>(Shared = SharedType.PerTestSession)] fixture implements IAsyncInitializer, its InitializeAsync() is subject to the cancellation token of whichever individual test triggers it first. If that test has a short [Timeout], the fixture initialization gets cancelled — even though the fixture is shared across the entire session.

This causes all tests that depend on the fixture to fail, not just the one with the short timeout.

Reproduction

public class SlowFixture : IAsyncInitializer, IAsyncDisposable
{
    public async Task InitializeAsync()
    {
        // Takes 30+ seconds (e.g. starting Docker containers via Aspire)
        await StartDistributedApp();
    }
}

public class MyTests
{
    [ClassDataSource<SlowFixture>(Shared = SharedType.PerTestSession)]
    public required SlowFixture Fixture { get; init; }

    [Test, Timeout(5_000)] // 5s timeout — too short for fixture init
    public async Task Quick_Test(CancellationToken cancellationToken)
    {
        // If this test triggers the fixture initialization first,
        // the 5s timeout cancels the fixture init for ALL tests
        var response = await Fixture.Client.GetAsync("/health");
        await Assert.That(response.IsSuccessStatusCode).IsTrue();
    }

    [Test, Timeout(60_000)] // Even this test fails because the fixture is already cancelled
    public async Task Longer_Test(CancellationToken cancellationToken)
    {
        // ...
    }
}

Root Cause

In ObjectInitializer.InitializeCoreAsync():

var lazyTask = InitializationTasks.GetOrAdd(obj,
    static (_, asyncInitializer) => new Lazy<Task>(
        asyncInitializer.InitializeAsync,  // No cancellation token passed
        LazyThreadSafetyMode.ExecutionAndPublication)
    , asyncInitializer);

await lazyTask.Value.WaitAsync(cancellationToken);  // Test's timeout token

The InitializeAsync() is called without a cancellation token (line 110-111), but the WaitAsync on line 117 uses the test's cancellation token. So:

  1. First test triggers initialization → InitializeAsync() starts running
  2. Test timeout fires → WaitAsync throws OperationCanceledException
  3. InitializeAsync() continues running in the background (no token to cancel it)
  4. But the Lazy<Task> stores the still-running task, and subsequent tests also WaitAsync with their own tokens
  5. If the initialization eventually completes, later tests might succeed — but the first test and any that timed out are already failed

Expected Behavior

PerTestSession fixture initialization should NOT be constrained by individual test timeouts. Options:

  1. Use a separate, longer timeout for fixture initialization (configurable)
  2. Don't pass the test's cancellation token to WaitAsync for shared fixtures
  3. Use CancellationToken.None for PerTestSession fixtures and let them manage their own timeouts internally

Context

Discovered while building the CloudShop Aspire + TUnit example (#4761). The DistributedAppFixture starts an Aspire distributed app with Docker containers (PostgreSQL, Redis, RabbitMQ), which takes 30+ seconds. Tests with [Timeout(5_000)] would cancel the fixture initialization, causing all 207 tests to fail.

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