Skip to content

TokenCleanupHost.StopAsync throws when StartAsync never ran, faulting host shutdown #2394

Description

@drpeck

TokenCleanupHost.StopAsync treats "never started" as a programming error and throws, but IHostedService.StopAsync is called by the generic host on the way down whether or not that service's StartAsync ran. Any shutdown that begins before TokenCleanupHost.StartAsync runs therefore throws out of the shutdown path.

Affected: Duende.IdentityServer.EntityFramework 7.4.7; the code is unchanged on main as of 2026-08-03 (identity-server/src/EntityFramework/TokenCleanupHost.cs, line 69). Observed on .NET 10.

The asymmetry

public Task StartAsync(CancellationToken cancellationToken)
{
    if (_options.EnableTokenCleanup)
    {
        if (_source != null) { throw new InvalidOperationException("Already started. Call Stop first."); }
        _source = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
        ...
    }
    return Task.CompletedTask;
}

public async Task StopAsync(CancellationToken cancellationToken)
{
    if (_options.EnableTokenCleanup)
    {
        if (_source == null) { throw new InvalidOperationException("Not started. Call Start first."); }
        ...
    }
}

_source is only ever assigned by StartAsync, so StopAsync throws for any instance whose StartAsync did not run.

Repro

No host needed — the type and its constructor are public:

var host = new TokenCleanupHost(
    new ServiceCollection().BuildServiceProvider(),
    new OperationalStoreOptions(),                 // EnableTokenCleanup defaults to true
    NullLogger<TokenCleanupHost>.Instance);

await host.StopAsync(CancellationToken.None);
// System.InvalidOperationException: Not started. Call Start first.

Setting EnableTokenCleanup = false makes the same call return cleanly, which is what confirms the guard rather than the cleanup work is the issue.

How we hit it

Intermittently in ASP.NET Core integration tests, through WebApplicationFactory teardown — the exception surfaces from Dispose() after every assertion in the test has already passed, so it reads as an unrelated failure:

System.InvalidOperationException : Not started. Call Start first.
   at Microsoft.Extensions.DependencyInjection.TokenCleanupHost.StopAsync(CancellationToken)
      in /_/identity-server/src/EntityFramework/TokenCleanupHost.cs:line 69
   at Microsoft.Extensions.Hosting.Internal.Host.ForeachService[T](...)
   at Microsoft.Extensions.Hosting.Internal.Host.StopAsync(CancellationToken)
   at Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory`1.DisposeAsync()
   at Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory`1.Dispose()

Note Host.ForeachServiceHost.StopAsync: the host is stopping every registered hosted service, which is exactly the contract StopAsync is not tolerating. We have worked around it in our own tests by turning EnableTokenCleanup off, but the same call shape is reachable in a deployed host whose startup is aborted (a container replaced mid-boot, a SIGTERM before startup completes), where it would fault graceful shutdown and potentially mask the real cause.

Suggested fix

Make StopAsync tolerant of the un-started case, as .NET's own BackgroundService.StopAsync is (if (_executeTask == null) { return; }):

if (_source == null)
{
    return;
}

Happy to open a PR if that shape is agreeable.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions