Skip to content

Commit 72d1f3d

Browse files
ericstjstephentoub
andauthored
Make BackgroundService run ExecuteAsync as task (#116283)
* Make BackgroundService run ExecuteAsync as task * Simplify BackGroundService.StartAsync, fix tests * Never return ExecuteAsync task from StartAsync It's possible the ExecuteAsync task completes in the time between it was started and StartAsync returns. If so, we were returning it's task. This leads to non-deterministic error behavior of the StartAsync API as well as inconsistently honoring `BackgroundServiceExceptionBehavior` based on this race. Instead always return a completed task. * Address feedback * Address feedback * Expose cancellation async * Update src/libraries/Microsoft.Extensions.Hosting.Abstractions/src/BackgroundService.cs Co-authored-by: Stephen Toub <[email protected]> --------- Co-authored-by: Stephen Toub <[email protected]>
1 parent 23297be commit 72d1f3d

File tree

3 files changed

+78
-31
lines changed

3 files changed

+78
-31
lines changed

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

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -42,16 +42,10 @@ public virtual Task StartAsync(CancellationToken cancellationToken)
4242
// Create linked token to allow cancelling executing task from provided token
4343
_stoppingCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
4444

45-
// Store the task we're executing
46-
_executeTask = ExecuteAsync(_stoppingCts.Token);
45+
// 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);
4747

48-
// If the task is completed then return it, this will bubble cancellation and failure to the caller
49-
if (_executeTask.IsCompleted)
50-
{
51-
return _executeTask;
52-
}
53-
54-
// Otherwise it's running
48+
// Always return a completed task. Any result from ExecuteAsync will be handled by the Host.
5549
return Task.CompletedTask;
5650
}
5751

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

Lines changed: 68 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ namespace Microsoft.Extensions.Hosting.Tests
1111
public class BackgroundServiceTests
1212
{
1313
[Fact]
14-
public void StartReturnsCompletedTaskIfLongRunningTaskIsIncomplete()
14+
public void StartReturnsCompletedTask()
1515
{
1616
var tcs = new TaskCompletionSource<object>();
1717
var service = new MyBackgroundService(tcs.Task);
@@ -26,28 +26,14 @@ public void StartReturnsCompletedTaskIfLongRunningTaskIsIncomplete()
2626
}
2727

2828
[Fact]
29-
public void StartReturnsCompletedTaskIfCancelled()
29+
public async Task StartCancelledThrowsTaskCanceledException()
3030
{
31-
var tcs = new TaskCompletionSource<object>();
32-
tcs.TrySetCanceled();
33-
var service = new MyBackgroundService(tcs.Task);
34-
35-
var task = service.StartAsync(CancellationToken.None);
36-
37-
Assert.True(task.IsCompleted);
38-
Assert.Same(task, service.ExecuteTask);
39-
}
40-
41-
[Fact]
42-
public async Task StartReturnsLongRunningTaskIfFailed()
43-
{
44-
var tcs = new TaskCompletionSource<object>();
45-
tcs.TrySetException(new Exception("fail!"));
46-
var service = new MyBackgroundService(tcs.Task);
31+
var ct = new CancellationToken(true);
32+
var service = new WaitForCancelledTokenService();
4733

48-
var exception = await Assert.ThrowsAsync<Exception>(() => service.StartAsync(CancellationToken.None));
34+
await service.StartAsync(ct);
4935

50-
Assert.Equal("fail!", exception.Message);
36+
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => service.ExecuteTask);
5137
}
5238

5339
[Fact]
@@ -91,6 +77,7 @@ public async Task StopAsyncThrowsIfCancellationCallbackThrows()
9177
var service = new ThrowOnCancellationService();
9278

9379
await service.StartAsync(CancellationToken.None);
80+
await service.WaitForExecuteTask;
9481

9582
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1));
9683
await Assert.ThrowsAsync<AggregateException>(() => service.StopAsync(cts.Token));
@@ -116,6 +103,7 @@ public async Task StartAsyncThenCancelShouldCancelExecutingTask()
116103
var service = new WaitForCancelledTokenService();
117104

118105
await service.StartAsync(tokenSource.Token);
106+
await service.WaitForExecuteTask;
119107

120108
tokenSource.Cancel();
121109

@@ -130,19 +118,58 @@ public void CreateAndDisposeShouldNotThrow()
130118
service.Dispose();
131119
}
132120

121+
[Fact]
122+
public async Task StartSynchronousAndStop()
123+
{
124+
var tokenSource = new CancellationTokenSource();
125+
var service = new MySynchronousBackgroundService();
126+
127+
// should not block the start thread;
128+
await service.StartAsync(tokenSource.Token);
129+
await service.WaitForExecuteTask;
130+
await service.StopAsync(CancellationToken.None);
131+
132+
Assert.True(service.WaitForEndExecuteTask.IsCompleted);
133+
}
134+
135+
[Fact]
136+
public async Task StartSynchronousExecuteShouldBeCancelable()
137+
{
138+
var tokenSource = new CancellationTokenSource();
139+
var service = new MySynchronousBackgroundService();
140+
141+
await service.StartAsync(tokenSource.Token);
142+
await service.WaitForExecuteTask;
143+
144+
tokenSource.Cancel();
145+
146+
await service.WaitForEndExecuteTask;
147+
}
148+
133149
private class WaitForCancelledTokenService : BackgroundService
134150
{
151+
private TaskCompletionSource<object> _waitForExecuteTask = new TaskCompletionSource<object>();
152+
135153
public Task ExecutingTask { get; private set; }
136154

155+
public Task WaitForExecuteTask => _waitForExecuteTask.Task;
156+
137157
protected override Task ExecuteAsync(CancellationToken stoppingToken)
138158
{
139159
ExecutingTask = Task.Delay(Timeout.Infinite, stoppingToken);
160+
161+
_waitForExecuteTask.TrySetResult(null);
162+
140163
return ExecutingTask;
141164
}
142165
}
143166

144167
private class ThrowOnCancellationService : BackgroundService
145168
{
169+
private TaskCompletionSource<object> _waitForExecuteTask = new TaskCompletionSource<object>();
170+
171+
public Task WaitForExecuteTask => _waitForExecuteTask.Task;
172+
146173
public int TokenCalls { get; set; }
147174

148175
protected override Task ExecuteAsync(CancellationToken stoppingToken)
@@ -158,6 +185,8 @@ protected override Task ExecuteAsync(CancellationToken stoppingToken)
158185
TokenCalls++;
159186
});
160187

188+
_waitForExecuteTask.TrySetResult(null);
189+
161190
return new TaskCompletionSource<object>().Task;
162191
}
163192
}
@@ -191,5 +220,24 @@ private async Task ExecuteCore(CancellationToken stoppingToken)
191220
await task;
192221
}
193222
}
223+
224+
private class MySynchronousBackgroundService : BackgroundService
225+
{
226+
private TaskCompletionSource<object> _waitForExecuteTask = new TaskCompletionSource<object>();
227+
private TaskCompletionSource<object> _waitForEndExecuteTask = new TaskCompletionSource<object>();
228+
229+
public Task WaitForExecuteTask => _waitForExecuteTask.Task;
230+
public Task WaitForEndExecuteTask => _waitForEndExecuteTask.Task;
231+
232+
#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously
233+
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
234+
{
235+
_waitForExecuteTask.TrySetResult(null);
236+
stoppingToken.WaitHandle.WaitOne();
237+
_waitForEndExecuteTask.TrySetResult(null);
238+
}
239+
#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously
240+
241+
}
194242
}
195243
}

src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/Internal/HostTests.cs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -688,7 +688,7 @@ public async Task WebHostStopAsyncUsesDefaultTimeoutIfNoTokenProvided()
688688
}
689689

690690
[Fact]
691-
public async Task HostPropagatesExceptionsThrownWithBackgroundServiceExceptionBehaviorOfStopHost()
691+
public async Task HostStopsWithBackgroundServiceExceptionBehaviorOfStopHost()
692692
{
693693
using IHost host = CreateBuilder()
694694
.ConfigureServices(
@@ -702,7 +702,12 @@ public async Task HostPropagatesExceptionsThrownWithBackgroundServiceExceptionBe
702702
})
703703
.Build();
704704

705-
await Assert.ThrowsAsync<Exception>(() => host.StartAsync());
705+
await host.StartAsync();
706+
707+
// host is expected to catch exception, then trigger ApplicationStopping.
708+
// give the host 1 minute to stop.
709+
var lifetime = host.Services.GetRequiredService<IHostApplicationLifetime>();
710+
Assert.True(lifetime.ApplicationStopping.WaitHandle.WaitOne(TimeSpan.FromMinutes(1)));
706711
}
707712

708713
[Fact]

0 commit comments

Comments
 (0)