unlock unlimited code runs for $79, limited time →
NOSSLETTER.techLIVE
back to OSSpedia
concurrencyerror-handlingtesting-strategy

Ensure BackgroundService invokes ExecuteAsync after start

Project: dotnet/runtime | Language: C# | Concepts: concurrency, error-handling, testing-strategy | Difficulty: mid
Link: https://github.com/dotnet/runtime/pull/132241

The problem

BackgroundService.StartAsync used to schedule ExecuteAsync with a cancellation token that could be canceled before the delegate actually ran. If cancellation happened before the thread pool dequeued the work, ExecuteAsync was never called at all, and the service task was marked as canceled. This made whether ExecuteAsync ran or not depend on thread pool timing, leading to inconsistent and hard-to-reason-about behavior.

Why it's non-trivial

A naive fix might just change how the cancellation token is passed, but could still allow timing-based races or break the guarantee that ExecuteAsync always runs after a non-canceled start. The solution needs to ensure deterministic invocation: after StartAsync is called without cancellation, ExecuteAsync must run exactly once, even if the stopping token is canceled before it starts. The implementation must also preserve async execution semantics and not break backwards compatibility.

The approach

1. Capture the stopping token locally and change how cancellation is handled in Task.Run:

-            _stoppingCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
-            // Execute all of ExecuteAsync asynchronously, and store the task we're executing so that we can wait for it later.
-            _executeTask = Task.Run(() => ExecuteAsync(_stoppingCts.Token), _stoppingCts.Token);
+            _stoppingCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+            CancellationToken stoppingToken = _stoppingCts.Token;
+            _executeTask = cancellationToken.IsCancellationRequested
+                ? Task.FromCanceled(cancellationToken)
+                : Task.Run(() => ExecuteAsync(stoppingToken), CancellationToken.None);
  • Annotation:
    • The stopping token is captured in a local variable to avoid races if _stoppingCts changes before the delegate runs.
    • If the startup cancellation token is already canceled, ExecuteAsync is not invoked, and the ExecuteTask is immediately marked as canceled.
    • Otherwise, Task.Run is called with CancellationToken.None as the scheduling token, ensuring the delegate always runs once scheduled. The linked stopping token is still passed to ExecuteAsync, so it can observe cancellation.

2. Add regression tests to verify deterministic invocation:

+        [ConditionalTheory(typeof(BackgroundServiceTests), nameof(IsThreadingAndRemoteExecutorSupported))]
+        [InlineData(false)]
+        [InlineData(true)]
+        public void ExecuteAsyncRunsWhenImmediatelyStoppedOrDisposed(bool dispose)
+        {
+            ...
+            var service = new TrackingBackgroundService();
+            service.StartAsync(CancellationToken.None).GetAwaiter().GetResult();
+
+            Task stopTask;
+            if (bool.Parse(disposeString))
+            {
+                service.Dispose();
+                stopTask = service.ExecuteTask;
+            }
+            else
+            {
+                stopTask = service.StopAsync(CancellationToken.None);
+            }
+
+            releaseBlocker.Set();
+            stopTask.GetAwaiter().GetResult();
+
+            (int invocationCount, int threadId, bool isThreadPoolThread, bool isCancellationRequested) =
+                service.ExecuteInvocation.GetAwaiter().GetResult();
+            Assert.Equal(1, invocationCount);
+            Assert.NotEqual(startThreadId, threadId);
+            Assert.True(isThreadPoolThread);
+            Assert.True(isCancellationRequested);
+            ...
+        }
  • Annotation:
    • This test ensures that even if StopAsync or Dispose is called immediately after StartAsync, ExecuteAsync is invoked exactly once, on a thread pool thread, and receives a cancellation token that is already canceled.
    • The test manipulates the thread pool to ensure deterministic scheduling and blocks the only worker to control timing.

3. Improve test coverage for pre-canceled startup:

-            var service = new WaitForCancelledTokenService();
-
-            await service.StartAsync(ct);
-
-            await Assert.ThrowsAnyAsync<OperationCanceledException>(() => service.ExecuteTask);
+            var service = new TrackingBackgroundService();
+            Task startTask = service.StartAsync(ct);
+
+            Assert.True(startTask.IsCompleted);
+            await Assert.ThrowsAnyAsync<OperationCanceledException>(() => service.ExecuteTask);
+            Assert.True(service.ExecuteTask.IsCanceled);
+            Assert.False(service.ExecuteInvocation.IsCompleted);
  • Annotation:
    • This test ensures that if the cancellation token is already canceled at startup, ExecuteAsync is never invoked and the execute task is marked as canceled.

From the review thread

Reviewer concern and author response about the trade-off:

steveisok:
@rosebyte before moving this forward I want to bring https://github.com/dotnet/runtime/issues/131249#issuecomment-5346925833 here. He's got a fair concern and there is a trade-off we are making as a result of the PR. We are trading “sometimes silently never invoke the service” for "always invoke it, requiring the service to honor an already-canceled token.”.
Thoughts on what you think is best?

rosebyte:
Passing stoppingToken to Task.Run does not guarantee that ExecuteAsync will not be invoked after cancellation, because the delegate may win the race. ExecuteAsync must therefore handle an already cancelled token anyway. Using that token as a scheduler gate merely adds a timing-dependent outcome where ExecuteAsync is never invoked. CancellationToken.None removes that ambiguity and avoids the small overhead of registering cancellation with the scheduled task.

Reviewer nit about token capture:

Copilot:
StartAsync currently reads _stoppingCts.Token inside the Task.Run delegate. If StartAsync is ever called more than once (or _stoppingCts is reassigned before the queued work item runs), the delegate could observe a different token than the one associated with this start. Capturing the token into a local before queueing avoids that race and matches the previous behavior (token read at scheduling time).

Takeaways

  • Always make cancellation and task scheduling behavior deterministic, especially in concurrent systems, to avoid subtle timing bugs.
  • When scheduling work for later execution, capture all necessary state (such as tokens) in local variables to avoid races and ensure consistency.
  • Tests for concurrency issues should control thread pool resources and explicitly verify edge cases (like immediate cancellation) to ensure correct, predictable behavior.