question-mark
Stuck on an issue?

Lightrun Answers was designed to reduce the constant googling that comes with debugging 3rd party libraries. It collects links to all the places you might be looking at while hunting down a tough bug.

And, if you’re still stuck at the end, we’re happy to hop on a call to see how we can help out.

Confusing Cancellation in BackgroundService

See original GitHub issue

Describe 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 OperationCanceledExceptions 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:closed
  • Created 5 years ago
  • Reactions:1
  • Comments:22 (2 by maintainers)

github_iconTop GitHub Comments

3reactions
analogrelaycommented, Mar 20, 2019

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:

public async Task MyTest()
{
    var service = /* get my background service */
    await service.StartAsync();
    await Task.Delay(/* enough time for the service to run */);
    await service.StopAsync();
}

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.

2reactions
Zenexercommented, Jul 11, 2019

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.

Read more comments on GitHub >

github_iconTop Results From Across the Web

How to cancel manually a BackgroundService in ASP.net ...
To cancel the application, inject the IHostApplicationLifetime interface in the class that will force the cancellation and call StopApplication ...
Read more >
NET 6 breaking change: Exception handling in hosting - .NET
NET 6, when an exception is thrown from a BackgroundService.ExecuteAsync(CancellationToken) override, the exception is logged to the current ...
Read more >
.Net 6: Managing Exceptions in BackgroundService or ...
DotNet 6 introduces a welcome change to exceptions which is discussed here. A full discussion of background service exception handling in .
Read more >
How to await a Cancellation Token in C# | by Cillié Malan
The task must run until cancellation, after which something is disposed and the message queuing system will gracefully requeue and reprocess ...
Read more >
Implementing IHostedService in ASP.NET Core 2.0
Another question - how would one ask a cancellation of the background service through one of the api calls (the controller api)?. Steve...
Read more >

github_iconTop Related Medium Post

No results found

github_iconTop Related StackOverflow Question

No results found

github_iconTroubleshoot Live Code

Lightrun enables developers to add logs, metrics and snapshots to live code - no restarts or redeploys required.
Start Free

github_iconTop Related Reddit Thread

No results found

github_iconTop Related Hackernoon Post

No results found

github_iconTop Related Tweet

No results found

github_iconTop Related Dev.to Post

No results found

github_iconTop Related Hashnode Post

No results found