Skip to content

[Regression] BackgroundService.StartAsync may never invoke ExecuteAsync when the service is stopped immediately after start (regression from Task.Run(delegate, stoppingToken)) #131249

Description

@gbamqzkdyg

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

  1. 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)).

  2. Call StartAsync(CancellationToken.None) and, without yielding, await StopAsync(CancellationToken.None).

  3. 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.

Image

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.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions