From 3e545d3e5fb5165d6dffed108558581beb3d648c Mon Sep 17 00:00:00 2001 From: Eric StJohn Date: Tue, 3 Jun 2025 15:43:34 -0700 Subject: [PATCH 1/7] Make BackgroundService run ExecuteAsync as task --- .../src/BackgroundService.cs | 4 +- .../tests/UnitTests/BackgroundServiceTests.cs | 84 ++++++++++++++----- 2 files changed, 65 insertions(+), 23 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Hosting.Abstractions/src/BackgroundService.cs b/src/libraries/Microsoft.Extensions.Hosting.Abstractions/src/BackgroundService.cs index 49e5e64a968af0..1147b917536010 100644 --- a/src/libraries/Microsoft.Extensions.Hosting.Abstractions/src/BackgroundService.cs +++ b/src/libraries/Microsoft.Extensions.Hosting.Abstractions/src/BackgroundService.cs @@ -42,8 +42,8 @@ public virtual Task StartAsync(CancellationToken cancellationToken) // Create linked token to allow cancelling executing task from provided token _stoppingCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - // Store the task we're executing - _executeTask = ExecuteAsync(_stoppingCts.Token); + // Execute all of ExecuteAsync as a background thread, and store the task we're executing so that we can wait for it later. + _executeTask = Task.Run(async () => await ExecuteAsync(_stoppingCts.Token).ConfigureAwait(false), _stoppingCts.Token); // If the task is completed then return it, this will bubble cancellation and failure to the caller if (_executeTask.IsCompleted) diff --git a/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/BackgroundServiceTests.cs b/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/BackgroundServiceTests.cs index 42caec902b37f7..7c60702a240bfb 100644 --- a/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/BackgroundServiceTests.cs +++ b/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/BackgroundServiceTests.cs @@ -11,7 +11,7 @@ namespace Microsoft.Extensions.Hosting.Tests public class BackgroundServiceTests { [Fact] - public void StartReturnsCompletedTaskIfLongRunningTaskIsIncomplete() + public void StartReturnsCompletedTask() { var tcs = new TaskCompletionSource(); var service = new MyBackgroundService(tcs.Task); @@ -26,28 +26,12 @@ public void StartReturnsCompletedTaskIfLongRunningTaskIsIncomplete() } [Fact] - public void StartReturnsCompletedTaskIfCancelled() + public async Task StartCancelledThrowsTaskCanceledException() { - var tcs = new TaskCompletionSource(); - tcs.TrySetCanceled(); - var service = new MyBackgroundService(tcs.Task); - - var task = service.StartAsync(CancellationToken.None); - - Assert.True(task.IsCompleted); - Assert.Same(task, service.ExecuteTask); - } - - [Fact] - public async Task StartReturnsLongRunningTaskIfFailed() - { - var tcs = new TaskCompletionSource(); - tcs.TrySetException(new Exception("fail!")); - var service = new MyBackgroundService(tcs.Task); - - var exception = await Assert.ThrowsAsync(() => service.StartAsync(CancellationToken.None)); + var ct = new CancellationToken(true); + var service = new WaitForCancelledTokenService(); - Assert.Equal("fail!", exception.Message); + await Assert.ThrowsAsync(() => service.StartAsync(ct)); } [Fact] @@ -116,6 +100,7 @@ public async Task StartAsyncThenCancelShouldCancelExecutingTask() var service = new WaitForCancelledTokenService(); await service.StartAsync(tokenSource.Token); + await service.WaitForExecuteTask; tokenSource.Cancel(); @@ -130,13 +115,48 @@ public void CreateAndDisposeShouldNotThrow() service.Dispose(); } + [Fact] + public async Task StartSynchronousAndStop() + { + var tokenSource = new CancellationTokenSource(); + var service = new MySynchronousBackgroundService(); + + // should not block the start thread; + await service.StartAsync(tokenSource.Token); + await service.WaitForExecuteTask; + await service.StopAsync(CancellationToken.None); + + Assert.True(service.WaitForEndExecuteTask.IsCompleted); + } + + [Fact] + public async Task StartSynchronousExecuteShouldBeCancelable() + { + var tokenSource = new CancellationTokenSource(); + var service = new MySynchronousBackgroundService(); + + await service.StartAsync(tokenSource.Token); + await service.WaitForExecuteTask; + + tokenSource.Cancel(); + + await service.WaitForEndExecuteTask; + } + private class WaitForCancelledTokenService : BackgroundService { + private TaskCompletionSource _waitForExecuteTask = new TaskCompletionSource(); + public Task ExecutingTask { get; private set; } + public Task WaitForExecuteTask => _waitForExecuteTask.Task; + protected override Task ExecuteAsync(CancellationToken stoppingToken) { ExecutingTask = Task.Delay(Timeout.Infinite, stoppingToken); + + _waitForExecuteTask.TrySetResult(null); + return ExecutingTask; } } @@ -191,5 +211,27 @@ private async Task ExecuteCore(CancellationToken stoppingToken) await task; } } + + private class MySynchronousBackgroundService : BackgroundService + { + private TaskCompletionSource _waitForExecuteTask = new TaskCompletionSource(); + private TaskCompletionSource _waitForEndExecuteTask = new TaskCompletionSource(); + + public Task WaitForExecuteTask => _waitForExecuteTask.Task; + public Task WaitForEndExecuteTask => _waitForEndExecuteTask.Task; + +#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _waitForExecuteTask.TrySetResult(null); + while (!stoppingToken.IsCancellationRequested) + { + Thread.Sleep(100); // never await, just block the thread + } + _waitForEndExecuteTask.TrySetResult(null); + } +#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously + + } } } From 4f763b5730886b3fa8dfe759fa64b58bac460afd Mon Sep 17 00:00:00 2001 From: Eric StJohn Date: Thu, 5 Jun 2025 15:25:14 -0700 Subject: [PATCH 2/7] Simplify BackGroundService.StartAsync, fix tests --- .../src/BackgroundService.cs | 2 +- .../tests/UnitTests/BackgroundServiceTests.cs | 7 +++++++ .../tests/UnitTests/Internal/HostTests.cs | 9 +++++++-- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Hosting.Abstractions/src/BackgroundService.cs b/src/libraries/Microsoft.Extensions.Hosting.Abstractions/src/BackgroundService.cs index 1147b917536010..2909cbe26ef43d 100644 --- a/src/libraries/Microsoft.Extensions.Hosting.Abstractions/src/BackgroundService.cs +++ b/src/libraries/Microsoft.Extensions.Hosting.Abstractions/src/BackgroundService.cs @@ -43,7 +43,7 @@ public virtual Task StartAsync(CancellationToken cancellationToken) _stoppingCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); // Execute all of ExecuteAsync as a background thread, and store the task we're executing so that we can wait for it later. - _executeTask = Task.Run(async () => await ExecuteAsync(_stoppingCts.Token).ConfigureAwait(false), _stoppingCts.Token); + _executeTask = Task.Run(() => ExecuteAsync(_stoppingCts.Token), _stoppingCts.Token); // If the task is completed then return it, this will bubble cancellation and failure to the caller if (_executeTask.IsCompleted) diff --git a/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/BackgroundServiceTests.cs b/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/BackgroundServiceTests.cs index 7c60702a240bfb..d15ee39f353b73 100644 --- a/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/BackgroundServiceTests.cs +++ b/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/BackgroundServiceTests.cs @@ -75,6 +75,7 @@ public async Task StopAsyncThrowsIfCancellationCallbackThrows() var service = new ThrowOnCancellationService(); await service.StartAsync(CancellationToken.None); + await service.WaitForExecuteTask; var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1)); await Assert.ThrowsAsync(() => service.StopAsync(cts.Token)); @@ -163,6 +164,10 @@ protected override Task ExecuteAsync(CancellationToken stoppingToken) private class ThrowOnCancellationService : BackgroundService { + private TaskCompletionSource _waitForExecuteTask = new TaskCompletionSource(); + + public Task WaitForExecuteTask => _waitForExecuteTask.Task; + public int TokenCalls { get; set; } protected override Task ExecuteAsync(CancellationToken stoppingToken) @@ -178,6 +183,8 @@ protected override Task ExecuteAsync(CancellationToken stoppingToken) TokenCalls++; }); + _waitForExecuteTask.TrySetResult(null); + return new TaskCompletionSource().Task; } } diff --git a/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/Internal/HostTests.cs b/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/Internal/HostTests.cs index cd54f9f468ebd6..a7d63ccb5b1a01 100644 --- a/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/Internal/HostTests.cs +++ b/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/Internal/HostTests.cs @@ -688,7 +688,7 @@ public async Task WebHostStopAsyncUsesDefaultTimeoutIfNoTokenProvided() } [Fact] - public async Task HostPropagatesExceptionsThrownWithBackgroundServiceExceptionBehaviorOfStopHost() + public async Task HostStopsWithBackgroundServiceExceptionBehaviorOfStopHost() { using IHost host = CreateBuilder() .ConfigureServices( @@ -702,7 +702,12 @@ public async Task HostPropagatesExceptionsThrownWithBackgroundServiceExceptionBe }) .Build(); - await Assert.ThrowsAsync(() => host.StartAsync()); + await host.StartAsync(); + + // host is expected to catch exception, then trigger ApplicationStopping. + // give the host 1 minute to stop. + var lifetime = host.Services.GetRequiredService(); + Assert.True(lifetime.ApplicationStopping.WaitHandle.WaitOne(TimeSpan.FromMinutes(1))); } [Fact] From 6f554f4007cd6174082a647de0366e148be0ccc1 Mon Sep 17 00:00:00 2001 From: Eric StJohn Date: Fri, 6 Jun 2025 14:12:38 -0700 Subject: [PATCH 3/7] 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. --- .../src/BackgroundService.cs | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Hosting.Abstractions/src/BackgroundService.cs b/src/libraries/Microsoft.Extensions.Hosting.Abstractions/src/BackgroundService.cs index 2909cbe26ef43d..7952ed2c29e407 100644 --- a/src/libraries/Microsoft.Extensions.Hosting.Abstractions/src/BackgroundService.cs +++ b/src/libraries/Microsoft.Extensions.Hosting.Abstractions/src/BackgroundService.cs @@ -39,19 +39,15 @@ public abstract class BackgroundService : IHostedService, IDisposable /// A that represents the asynchronous Start operation. public virtual Task StartAsync(CancellationToken cancellationToken) { + cancellationToken.ThrowIfCancellationRequested(); + // Create linked token to allow cancelling executing task from provided token _stoppingCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); // Execute all of ExecuteAsync as a background thread, and store the task we're executing so that we can wait for it later. _executeTask = Task.Run(() => ExecuteAsync(_stoppingCts.Token), _stoppingCts.Token); - // If the task is completed then return it, this will bubble cancellation and failure to the caller - if (_executeTask.IsCompleted) - { - return _executeTask; - } - - // Otherwise it's running + // Always return a completed task. Any result from ExecuteAsync will be handled by the Host. return Task.CompletedTask; } From 931c59c270cc478133a6596d2b0ed1443f2ab002 Mon Sep 17 00:00:00 2001 From: Eric StJohn Date: Fri, 6 Jun 2025 14:12:52 -0700 Subject: [PATCH 4/7] Address feedback --- .../tests/UnitTests/BackgroundServiceTests.cs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/BackgroundServiceTests.cs b/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/BackgroundServiceTests.cs index d15ee39f353b73..882fa1fd5fa8a6 100644 --- a/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/BackgroundServiceTests.cs +++ b/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/BackgroundServiceTests.cs @@ -31,7 +31,7 @@ public async Task StartCancelledThrowsTaskCanceledException() var ct = new CancellationToken(true); var service = new WaitForCancelledTokenService(); - await Assert.ThrowsAsync(() => service.StartAsync(ct)); + await Assert.ThrowsAnyAsync(() => service.StartAsync(ct)); } [Fact] @@ -231,10 +231,7 @@ private class MySynchronousBackgroundService : BackgroundService protected override async Task ExecuteAsync(CancellationToken stoppingToken) { _waitForExecuteTask.TrySetResult(null); - while (!stoppingToken.IsCancellationRequested) - { - Thread.Sleep(100); // never await, just block the thread - } + stoppingToken.WaitHandle.WaitOne(); _waitForEndExecuteTask.TrySetResult(null); } #pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously From 38611af9412ecca60cf2515b8263ee2f2d3104c7 Mon Sep 17 00:00:00 2001 From: Eric StJohn Date: Mon, 9 Jun 2025 11:22:24 -0700 Subject: [PATCH 5/7] Address feedback --- .../src/BackgroundService.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/libraries/Microsoft.Extensions.Hosting.Abstractions/src/BackgroundService.cs b/src/libraries/Microsoft.Extensions.Hosting.Abstractions/src/BackgroundService.cs index 7952ed2c29e407..0f195cb729b6a1 100644 --- a/src/libraries/Microsoft.Extensions.Hosting.Abstractions/src/BackgroundService.cs +++ b/src/libraries/Microsoft.Extensions.Hosting.Abstractions/src/BackgroundService.cs @@ -39,7 +39,10 @@ public abstract class BackgroundService : IHostedService, IDisposable /// A that represents the asynchronous Start operation. public virtual Task StartAsync(CancellationToken cancellationToken) { - cancellationToken.ThrowIfCancellationRequested(); + if (cancellationToken.IsCancellationRequested) + { + return Task.FromCanceled(cancellationToken); + } // Create linked token to allow cancelling executing task from provided token _stoppingCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); From be7a44f70576158ddb43975aacb36258dc7cea68 Mon Sep 17 00:00:00 2001 From: Eric StJohn Date: Mon, 9 Jun 2025 14:49:58 -0700 Subject: [PATCH 6/7] Expose cancellation async --- .../src/BackgroundService.cs | 5 ----- .../tests/UnitTests/BackgroundServiceTests.cs | 4 +++- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Hosting.Abstractions/src/BackgroundService.cs b/src/libraries/Microsoft.Extensions.Hosting.Abstractions/src/BackgroundService.cs index 0f195cb729b6a1..686fc83f482960 100644 --- a/src/libraries/Microsoft.Extensions.Hosting.Abstractions/src/BackgroundService.cs +++ b/src/libraries/Microsoft.Extensions.Hosting.Abstractions/src/BackgroundService.cs @@ -39,11 +39,6 @@ public abstract class BackgroundService : IHostedService, IDisposable /// A that represents the asynchronous Start operation. public virtual Task StartAsync(CancellationToken cancellationToken) { - if (cancellationToken.IsCancellationRequested) - { - return Task.FromCanceled(cancellationToken); - } - // Create linked token to allow cancelling executing task from provided token _stoppingCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); diff --git a/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/BackgroundServiceTests.cs b/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/BackgroundServiceTests.cs index 882fa1fd5fa8a6..14766a617cbbc6 100644 --- a/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/BackgroundServiceTests.cs +++ b/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/BackgroundServiceTests.cs @@ -31,7 +31,9 @@ public async Task StartCancelledThrowsTaskCanceledException() var ct = new CancellationToken(true); var service = new WaitForCancelledTokenService(); - await Assert.ThrowsAnyAsync(() => service.StartAsync(ct)); + await service.StartAsync(ct); + + await Assert.ThrowsAnyAsync(() => service.ExecuteTask); } [Fact] From 0fbb2752e695a05db08c2568f546bfee6decb4fb Mon Sep 17 00:00:00 2001 From: Eric StJohn Date: Wed, 11 Jun 2025 08:23:25 -0700 Subject: [PATCH 7/7] Update src/libraries/Microsoft.Extensions.Hosting.Abstractions/src/BackgroundService.cs Co-authored-by: Stephen Toub --- .../src/BackgroundService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libraries/Microsoft.Extensions.Hosting.Abstractions/src/BackgroundService.cs b/src/libraries/Microsoft.Extensions.Hosting.Abstractions/src/BackgroundService.cs index 686fc83f482960..f2d1cea2fb25ad 100644 --- a/src/libraries/Microsoft.Extensions.Hosting.Abstractions/src/BackgroundService.cs +++ b/src/libraries/Microsoft.Extensions.Hosting.Abstractions/src/BackgroundService.cs @@ -42,7 +42,7 @@ public virtual Task StartAsync(CancellationToken cancellationToken) // Create linked token to allow cancelling executing task from provided token _stoppingCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - // Execute all of ExecuteAsync as a background thread, and store the task we're executing so that we can wait for it later. + // 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); // Always return a completed task. Any result from ExecuteAsync will be handled by the Host.