Description
From the version 10.0.0, BackgroundService.StartAsync schedules the worker via:
_executeTask = Task.Run(() => ExecuteAsync(_stoppingCts.Token), _stoppingCts.Token);
Passing _stoppingCts.Token as the second argument of Task.Run couples "should this delegate start at all" to the stopping token. If StopAsync (or Dispose) triggers _stoppingCts.Cancel() before the thread pool has begun executing the queued delegate, the delegate is never invoked — the task transitions directly to the Canceled state and the body of ExecuteAsync does not run.
Previous implementation started the work inline:
_executeTask = ExecuteAsync(_stoppingCts.Token);
This ran ExecuteAsync synchronously up to its first await on the caller's thread, guaranteeing the method body always began executing before any Cancel() could occur. The change to Task.Run(..., stoppingToken) introduced a start-time race that did not previously exist.
The change was introduced by the Commit Make BackgroundService run ExecuteAsync as task (#116283) · dotnet/runtime@72d1f3d.
Reproduction Steps
General Idea
-
Implement a BackgroundService whose ExecuteAsync performs setup/teardown work that must run (e.g. graceful shutdown logic in a finally/catch after await Task.Delay(-1, stoppingToken)).
-
Call StartAsync(CancellationToken.None) and, without yielding, await StopAsync(CancellationToken.None).
-
Repeat in a loop (e.g. 1000 iterations).
Observed issue
Intermittently, ExecuteAsync's body never executes, so any work it was responsible for (including cleanup) is silently skipped. Because StopAsync awaits with ConfigureAwaitOptions.SuppressThrowing, the resulting cancellation is swallowed and no error surfaces.
Sample code for reproducing the issue
Paste the following code into Program.cs of a new Console App and run it twice with Microsoft.Extensions.Hosting >= version 10.0.0 installed.
using Repro;
for (var i = 0; i < 1000; ++i) await Operation.Run(i);
Console.WriteLine("Passed!");
namespace Repro
{
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
public static class Operation
{
public static async Task Run(int i)
{
using var hostedService = new DerviedBackgroundService();
Task startTask = hostedService.StartAsync(CancellationToken.None);
await hostedService.StopAsync(CancellationToken.None).ConfigureAwait(false);
if (!hostedService.ExecuteAsyncCancelled)
{
throw new Exception($"ExecuteAsync was not cancelled for iteration #{i}.");
}
}
}
public class DerviedBackgroundService : BackgroundService
{
private static readonly TimeSpan ShutdownTimeout = TimeSpan.FromSeconds(10);
public bool ExecuteAsyncCancelled { get; private set; }
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
try
{
await Task.Delay(-1, stoppingToken).ConfigureAwait(false);
}
catch (TaskCanceledException)
{
}
using var cancellationTokenSource = new CancellationTokenSource(ShutdownTimeout);
this.ExecuteAsyncCancelled = true;
}
}
}
Expected behavior
ExecuteAsync is always at least entered so its cancellation-handling / shutdown path runs, consistent with the prior inline behavior.
Actual behavior
We can see that the code runs into Exception when the version of Microsoft.Extensions.Hosting is >= 10.0.0, but it can always pass when the version of Microsoft.Extensions.Hosting is < 10.0.0, which means that ExecuteAsync is not granted to be entered before StartAsync being cancelled.
Regression?
Yes.
Known Workarounds
No.
Configuration
This issue is unrelated to the .NET version.
Other information
Impact
• Silent skipping of ExecuteAsync: For short-lived services, or services stopped promptly after start (fast startup/shutdown cycles, health probes, failed host startup that triggers immediate shutdown, tests), the worker body may never run.
• Missed cleanup / shutdown logic: Any teardown, flush, drain, or graceful-shutdown code placed in ExecuteAsync (a common pattern) can be skipped, potentially leaving resources unreleased, data unflushed, or downstream systems un-notified.
• Failure is silent and non-deterministic: StopAsync suppresses the cancellation, so there is no exception or log. The behavior is timing-dependent, making it hard to reproduce and diagnose in production.
• Scope: Affects any consumer of the base BackgroundService following the documented Worker Service pattern; not limited to a single service.
Description
From the version
10.0.0,BackgroundService.StartAsyncschedules the worker via:Passing
_stoppingCts.Tokenas the second argument ofTask.Runcouples "should this delegate start at all" to the stopping token. IfStopAsync(orDispose) triggers_stoppingCts.Cancel()before the thread pool has begun executing the queued delegate, the delegate is never invoked — the task transitions directly to theCanceledstate and the body ofExecuteAsyncdoes not run.Previous implementation started the work inline:
This ran
ExecuteAsyncsynchronously up to its firstawaiton the caller's thread, guaranteeing the method body always began executing before anyCancel()could occur. The change toTask.Run(..., stoppingToken)introduced a start-time race that did not previously exist.The change was introduced by the Commit Make BackgroundService run ExecuteAsync as task (#116283) · dotnet/runtime@72d1f3d.
Reproduction Steps
General Idea
Implement a
BackgroundServicewhoseExecuteAsyncperforms setup/teardown work that must run (e.g. graceful shutdown logic in a finally/catch afterawait Task.Delay(-1, stoppingToken)).Call
StartAsync(CancellationToken.None)and, without yielding,await StopAsync(CancellationToken.None).Repeat in a loop (e.g. 1000 iterations).
Observed issue
Intermittently,
ExecuteAsync's body never executes, so any work it was responsible for (including cleanup) is silently skipped. BecauseStopAsyncawaits withConfigureAwaitOptions.SuppressThrowing, the resulting cancellation is swallowed and no error surfaces.Sample code for reproducing the issue
Paste the following code into
Program.csof a new Console App and run it twice withMicrosoft.Extensions.Hosting>= version10.0.0installed.Expected behavior
ExecuteAsyncis always at least entered so its cancellation-handling / shutdown path runs, consistent with the prior inline behavior.Actual behavior
We can see that the code runs into Exception when the version of
Microsoft.Extensions.Hostingis >=10.0.0, but it can always pass when the version ofMicrosoft.Extensions.Hostingis <10.0.0, which means thatExecuteAsyncis not granted to be entered beforeStartAsyncbeing cancelled.Regression?
Yes.
Known Workarounds
No.
Configuration
This issue is unrelated to the .NET version.
Other information
Impact
• Silent skipping of
ExecuteAsync: For short-lived services, or services stopped promptly after start (fast startup/shutdown cycles, health probes, failed host startup that triggers immediate shutdown, tests), the worker body may never run.• Missed cleanup / shutdown logic: Any teardown, flush, drain, or graceful-shutdown code placed in
ExecuteAsync(a common pattern) can be skipped, potentially leaving resources unreleased, data unflushed, or downstream systems un-notified.• Failure is silent and non-deterministic:
StopAsyncsuppresses the cancellation, so there is no exception or log. The behavior is timing-dependent, making it hard to reproduce and diagnose in production.• Scope: Affects any consumer of the base
BackgroundServicefollowing the documented Worker Service pattern; not limited to a single service.