Skip to content

Commit 1fb02ff

Browse files
authored
Ensure BackgroundService invokes ExecuteAsync after start (#132241)
Fixes #131249. `BackgroundService.StartAsync` currently passes its stopping token to `Task.Run` as the scheduling token. If `StopAsync` or `Dispose` cancels that token before the queued delegate begins, the task transitions to `Canceled` without invoking `ExecuteAsync`. This makes invocation timing-dependent: after a non-canceled start is accepted, `ExecuteAsync` may or may not run depending on whether the thread pool dequeues it before cancellation. This change: - Preserves the .NET 10 change that all `ExecuteAsync` work runs asynchronously on a thread-pool thread. - Makes invocation deterministic after a non-canceled start is accepted by using `CancellationToken.None` as the `Task.Run` scheduling token. - Passes the linked stopping token to `ExecuteAsync`, so an immediate stop or dispose invokes it with cancellation already requested. - Preserves pre-canceled `StartAsync` behavior by explicitly assigning `Task.FromCanceled(cancellationToken)` to `ExecuteTask` without invoking `ExecuteAsync`. Regression tests deterministically occupy the sole thread-pool worker and verify that immediate stop and dispose still invoke `ExecuteAsync` exactly once on a thread-pool thread with an already-canceled stopping token. Pre-canceled startup coverage verifies that `ExecuteTask` is canceled and `ExecuteAsync` is not invoked. > [!NOTE] > This pull request description was generated with GitHub Copilot. --------- Copilot-Session: 3dfd3a15-eb10-455b-8c1c-16ecd87fd841
1 parent 9ac179f commit 1fb02ff

2 files changed

Lines changed: 93 additions & 3 deletions

File tree

src/libraries/Microsoft.Extensions.Hosting.Abstractions/src/BackgroundService.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,12 @@ public virtual Task StartAsync(CancellationToken cancellationToken)
4141
{
4242
// Create linked token to allow cancelling executing task from provided token
4343
_stoppingCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
44+
CancellationToken stoppingToken = _stoppingCts.Token;
4445

4546
// Execute all of ExecuteAsync asynchronously, and store the task we're executing so that we can wait for it later.
46-
_executeTask = Task.Run(() => ExecuteAsync(_stoppingCts.Token), _stoppingCts.Token);
47+
_executeTask = cancellationToken.IsCancellationRequested
48+
? Task.FromCanceled(cancellationToken)
49+
: Task.Run(() => ExecuteAsync(stoppingToken), CancellationToken.None);
4750

4851
// Always return a completed task. Any result from ExecuteAsync will be handled by the Host.
4952
return Task.CompletedTask;

src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/BackgroundServiceTests.cs

Lines changed: 89 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,16 @@
44
using System;
55
using System.Threading;
66
using System.Threading.Tasks;
7+
using Microsoft.DotNet.RemoteExecutor;
78
using Xunit;
89

910
namespace Microsoft.Extensions.Hosting.Tests
1011
{
1112
public class BackgroundServiceTests
1213
{
14+
public static bool IsThreadingAndRemoteExecutorSupported =>
15+
PlatformDetection.IsMultithreadingSupported && RemoteExecutor.IsSupported;
16+
1317
[Fact]
1418
public void StartReturnsCompletedTask()
1519
{
@@ -29,11 +33,14 @@ public void StartReturnsCompletedTask()
2933
public async Task StartCancelledThrowsTaskCanceledException()
3034
{
3135
var ct = new CancellationToken(true);
32-
var service = new WaitForCancelledTokenService();
36+
var service = new TrackingBackgroundService();
3337

34-
await service.StartAsync(ct);
38+
Task startTask = service.StartAsync(ct);
3539

40+
Assert.True(startTask.IsCompleted);
3641
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => service.ExecuteTask);
42+
Assert.True(service.ExecuteTask.IsCanceled);
43+
Assert.False(service.ExecuteInvocation.IsCompleted);
3744
}
3845

3946
[Fact]
@@ -146,6 +153,86 @@ public async Task StartSynchronousExecuteShouldBeCancelable()
146153
await service.WaitForEndExecuteTask;
147154
}
148155

156+
[ConditionalTheory(typeof(BackgroundServiceTests), nameof(IsThreadingAndRemoteExecutorSupported))]
157+
[InlineData(false)]
158+
[InlineData(true)]
159+
public void ExecuteAsyncRunsWhenImmediatelyStoppedOrDisposed(bool dispose)
160+
{
161+
var options = new RemoteInvokeOptions();
162+
options.StartInfo.EnvironmentVariables["DOTNET_ThreadPool_UseWindowsThreadPool"] = "0";
163+
164+
using var _ = RemoteExecutor.Invoke((string disposeString) =>
165+
{
166+
ThreadPool.GetMinThreads(out int originalMinWorkerThreads, out int originalMinCompletionPortThreads);
167+
ThreadPool.GetMaxThreads(out int originalMaxWorkerThreads, out int originalMaxCompletionPortThreads);
168+
Assert.True(ThreadPool.SetMinThreads(1, originalMinCompletionPortThreads));
169+
Assert.True(ThreadPool.SetMaxThreads(1, originalMaxCompletionPortThreads));
170+
171+
using var blockerEntered = new ManualResetEventSlim();
172+
using var releaseBlocker = new ManualResetEventSlim();
173+
174+
try
175+
{
176+
ThreadPool.QueueUserWorkItem(_ =>
177+
{
178+
blockerEntered.Set();
179+
releaseBlocker.Wait();
180+
});
181+
Assert.True(blockerEntered.Wait(RemoteExecutor.FailWaitTimeoutMilliseconds));
182+
183+
int startThreadId = Environment.CurrentManagedThreadId;
184+
var service = new TrackingBackgroundService();
185+
service.StartAsync(CancellationToken.None).GetAwaiter().GetResult();
186+
187+
Task stopTask;
188+
if (bool.Parse(disposeString))
189+
{
190+
service.Dispose();
191+
stopTask = service.ExecuteTask;
192+
}
193+
else
194+
{
195+
stopTask = service.StopAsync(CancellationToken.None);
196+
}
197+
198+
releaseBlocker.Set();
199+
stopTask.GetAwaiter().GetResult();
200+
201+
(int invocationCount, int threadId, bool isThreadPoolThread, bool isCancellationRequested) =
202+
service.ExecuteInvocation.GetAwaiter().GetResult();
203+
Assert.Equal(1, invocationCount);
204+
Assert.NotEqual(startThreadId, threadId);
205+
Assert.True(isThreadPoolThread);
206+
Assert.True(isCancellationRequested);
207+
}
208+
finally
209+
{
210+
releaseBlocker.Set();
211+
ThreadPool.SetMaxThreads(originalMaxWorkerThreads, originalMaxCompletionPortThreads);
212+
ThreadPool.SetMinThreads(originalMinWorkerThreads, originalMinCompletionPortThreads);
213+
}
214+
}, dispose.ToString(), options);
215+
}
216+
217+
private sealed class TrackingBackgroundService : BackgroundService
218+
{
219+
private readonly TaskCompletionSource<(int InvocationCount, int ThreadId, bool IsThreadPoolThread, bool IsCancellationRequested)> _executeInvocation =
220+
new(TaskCreationOptions.RunContinuationsAsynchronously);
221+
private int _invocationCount;
222+
223+
public Task<(int InvocationCount, int ThreadId, bool IsThreadPoolThread, bool IsCancellationRequested)> ExecuteInvocation => _executeInvocation.Task;
224+
225+
protected override Task ExecuteAsync(CancellationToken stoppingToken)
226+
{
227+
_executeInvocation.SetResult((
228+
Interlocked.Increment(ref _invocationCount),
229+
Environment.CurrentManagedThreadId,
230+
Thread.CurrentThread.IsThreadPoolThread,
231+
stoppingToken.IsCancellationRequested));
232+
return Task.CompletedTask;
233+
}
234+
}
235+
149236
private class WaitForCancelledTokenService : BackgroundService
150237
{
151238
private TaskCompletionSource<object> _waitForExecuteTask = new TaskCompletionSource<object>();

0 commit comments

Comments
 (0)