Confusing Cancellation in BackgroundService
See original GitHub issueDescribe the bug
BackgroundService
(and really the IHostedService
interface) have kind of a confusing cancellation model that makes it tricky to test.
To Reproduce
Derive from BackgroundService
, e.g.:
public class DerpService : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken cancellationToken)
{
while (true) {
cancellationToken.ThrowIfCancellationRequested();
await Task.Delay(100, cancellationToken);
}
}
}
Write some code to test cancellation of the DerpService
, e.g., with NUnit:
[Test, Parallelizable]
public async Task SomeTest()
{
var derp = new DerpService();
var cts = new CancellationTokenSource(millisecondsDelay: 1000);
// Returns almost immediately and never throws
Assert.ThrowsAsync<OperationCanceledException>(() => derp.StartAsync(cts.Token));
}
Expected behavior
After 1 second, the CancellationTokenSource
in the test method should automatically cancel. At this point, I would expect the DerpService
to see the cancellation, throw an OperationCanceledException
, and thus pass the assertion. Instead, the call to StartAsync
just returns after one iteration of delay and never waits for the CancellationToken
.
Additional context
Looking at the source code of BackgroundService
, it’s clear that this happens because BackgroundService
defines its own CancellationTokenSource
, and passes a token from that source to ExecuteAsync
. I also don’t see any unit tests for BackgroundService
, which is perhaps why this issue hasn’t been encountered before.
So I guess awaiting StartAsync
doesn’t really mean awaiting the service, it just means awaiting the start of the service, which is kind of confusing. While I realize that BackgroundService
, like all implementations of IHostedService
, can be stopped via StopAsync
, I don’t really see the point of passing a CancellationToken
to StartAsync
that gets completely ignored. For that matter, I don’t see why IHostedService
declares a StopAsync
method at all. Any code that runs an IHostedService
should be able to stop it just by cancelling the token that was passed to StartAsync
, and implementers could put whatever “stopping” logic they need in a call to CancellationToken.Register()
. This would also allow OperationCanceledException
s raised in the service to bubble up, rather than getting squashed in StopAsync
.
Output of dotnet --info
:
.NET Core SDK (reflecting any global.json): Version: 2.2.103 Commit: 8edbc2570a
Runtime Environment: OS Name: Windows OS Version: 10.0.17763 OS Platform: Windows RID: win10-x64 Base Path: C:\Program Files\dotnet\sdk\2.2.103\
Host (useful for support): Version: 2.2.1 Commit: 878dd11e62
.NET Core SDKs installed: 1.0.0-preview2-1-003177 [C:\Program Files\dotnet\sdk] 2.1.202 [C:\Program Files\dotnet\sdk] 2.1.403 [C:\Program Files\dotnet\sdk] 2.1.500 [C:\Program Files\dotnet\sdk] 2.1.502 [C:\Program Files\dotnet\sdk] 2.1.503 [C:\Program Files\dotnet\sdk] 2.1.504 [C:\Program Files\dotnet\sdk] 2.1.600-preview-009426 [C:\Program Files\dotnet\sdk] 2.1.600-preview-009472 [C:\Program Files\dotnet\sdk] 2.1.600-preview-009497 [C:\Program Files\dotnet\sdk] 2.1.600 [C:\Program Files\dotnet\sdk] 2.2.103 [C:\Program Files\dotnet\sdk]
.NET Core runtimes installed: Microsoft.AspNetCore.All 2.1.5 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.All] Microsoft.AspNetCore.All 2.1.6 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.All] Microsoft.AspNetCore.All 2.1.7 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.All] Microsoft.AspNetCore.All 2.1.8 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.All] Microsoft.AspNetCore.All 2.2.1 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.All] Microsoft.AspNetCore.App 2.1.5 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App] Microsoft.AspNetCore.App 2.1.6 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App] Microsoft.AspNetCore.App 2.1.7 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App] Microsoft.AspNetCore.App 2.1.8 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App] Microsoft.AspNetCore.App 2.2.1 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App] Microsoft.NETCore.App 1.1.0 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App] Microsoft.NETCore.App 2.0.9 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App] Microsoft.NETCore.App 2.1.5 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App] Microsoft.NETCore.App 2.1.6 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App] Microsoft.NETCore.App 2.1.7 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App] Microsoft.NETCore.App 2.1.8 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App] Microsoft.NETCore.App 2.2.1 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App]
Issue Analytics
- State:
- Created 5 years ago
- Reactions:1
- Comments:22 (2 by maintainers)
Top GitHub Comments
It sounds like what we need here is to better clarify a pattern for testing background services. To me, the pattern is more like this:
Alternatively, rather than using a timer, you could have your background service signal something to tell your test that it has finished it’s work.
We should improve the background service doc to add some testing notes. We should also clarify the token usage in XML docs.
Additional note I’d like to make:
The existence of BackgroundService indicates that no assumption is made about the purpose of the task returned by StartAsync: the task may be exclusively responsible for starting the service, or it may be responsible for executing the service. This in turn means that the cancellationToken passed to StartAsync may cancel the start operation, or it may cancel the whole service, with a caller unable to determine which will happen.
This means IHostedService makes no guarantees about the effect of canceling the token passed to StartAsync. It’s essentially a useless parameter and should always be
default(CancellationToken)
, since a caller has no way of knowing what effect cancellation will have. This seems like a design flaw, and IHostedService should really be split into two different interfaces.In theory, BackgroundService could be modified to always return Task.CompletedTask for StartAsync instead of the result of ExecuteAsync. However, since BackgroundService has already been widely used as an example, the expectations for IHostedService.StartAsync’s return value couldn’t change. (Furthermore, it would mean the task from ExecuteAsync will never be awaited.) To avoid breaking existing expectations, two new interfaces should be introduced to replace IHostedService.