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.ForeachService → Host.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.
TokenCleanupHost.StopAsynctreats "never started" as a programming error and throws, butIHostedService.StopAsyncis called by the generic host on the way down whether or not that service'sStartAsyncran. Any shutdown that begins beforeTokenCleanupHost.StartAsyncruns therefore throws out of the shutdown path.Affected:
Duende.IdentityServer.EntityFramework7.4.7; the code is unchanged onmainas of 2026-08-03 (identity-server/src/EntityFramework/TokenCleanupHost.cs, line 69). Observed on .NET 10.The asymmetry
_sourceis only ever assigned byStartAsync, soStopAsyncthrows for any instance whoseStartAsyncdid not run.Repro
No host needed — the type and its constructor are public:
Setting
EnableTokenCleanup = falsemakes 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
WebApplicationFactoryteardown — the exception surfaces fromDispose()after every assertion in the test has already passed, so it reads as an unrelated failure:Note
Host.ForeachService→Host.StopAsync: the host is stopping every registered hosted service, which is exactly the contractStopAsyncis not tolerating. We have worked around it in our own tests by turningEnableTokenCleanupoff, 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
StopAsynctolerant of the un-started case, as .NET's ownBackgroundService.StopAsyncis (if (_executeTask == null) { return; }):Happy to open a PR if that shape is agreeable.