From 6a37fe9c3c9463f6118d734dc6da9e8e7718b6da Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 12 Jul 2026 14:44:26 +0100 Subject: [PATCH 1/4] fix(aspnetcore): guard hosted-service stop against concurrent double Host.StopAsync Minimal-hosting SUTs park app.Run() in WaitForShutdownAsync, which calls Host.StopAsync when ApplicationStopping fires - concurrently with the Host.StopAsync already in flight from WebApplicationFactory.DisposeAsync (the trigger of that signal). Host.StopAsync has no concurrency guard, so every IHostedService.StopAsync ran twice in parallel, breaking services with non-thread-safe shutdown (e.g. Rebus bus dispose throws ObjectDisposedException from an After(Test) hook). FlowSuppressingHostedService already wraps every hosted service, so its stop-phase methods now run the inner stop exactly once; duplicate and concurrent callers observe the same task. Reproduced pre-fix by the new end-to-end probe (4-8 failures per 50 tests), clean across 6 runs post-fix. Fixes #6339 --- .../FlowSuppressingHostedService.cs | 80 ++++++++-- .../HostStopExactlyOnceProbeTests.cs | 72 +++++++++ .../HostedServiceStopOnceTests.cs | 150 ++++++++++++++++++ 3 files changed, 289 insertions(+), 13 deletions(-) create mode 100644 TUnit.AspNetCore.Tests/HostStopExactlyOnceProbeTests.cs create mode 100644 TUnit.AspNetCore.Tests/HostedServiceStopOnceTests.cs diff --git a/TUnit.AspNetCore.Core/FlowSuppressingHostedService.cs b/TUnit.AspNetCore.Core/FlowSuppressingHostedService.cs index 0f8f05a9ce9..0cfb019cc76 100644 --- a/TUnit.AspNetCore.Core/FlowSuppressingHostedService.cs +++ b/TUnit.AspNetCore.Core/FlowSuppressingHostedService.cs @@ -23,14 +23,36 @@ namespace TUnit.AspNetCore; /// Without this, wrapped services that own unmanaged resources leak silently because /// the container only sees the non-disposable wrapper. /// +/// +/// The stop-phase methods are additionally guarded to run the inner service's stop +/// exactly once. Minimal-hosting SUTs park their own app.Run() in +/// WaitForShutdownAsync, which calls Host.StopAsync when +/// ApplicationStopping fires — concurrently with the Host.StopAsync that +/// WebApplicationFactory.DisposeAsync already has in flight (the trigger of that +/// very ApplicationStopping). Host.StopAsync has no concurrency guard, so +/// every hosted service's StopAsync runs twice in parallel, breaking services +/// with non-thread-safe shutdown (e.g. Rebus' bus dispose throws +/// ). See https://github.com/thomhurst/TUnit/issues/6339. +/// All concurrent and repeated callers observe the single underlying stop task. +/// /// internal sealed class FlowSuppressingHostedService(IHostedService inner) : IHostedLifecycleService, IAsyncDisposable, IDisposable { + private readonly object _stopGate = new(); + private Task? _stopTask; + private Task? _stoppingTask; + private Task? _stoppedTask; + public Task StartAsync(CancellationToken cancellationToken) => RunOnCleanContext(inner.StartAsync, cancellationToken); - public Task StopAsync(CancellationToken cancellationToken) => - inner.StopAsync(cancellationToken); + public Task StopAsync(CancellationToken cancellationToken) + { + lock (_stopGate) + { + return _stopTask ??= InvokeOnce(inner.StopAsync, cancellationToken); + } + } public Task StartingAsync(CancellationToken cancellationToken) => inner is IHostedLifecycleService lifecycle @@ -42,18 +64,36 @@ inner is IHostedLifecycleService lifecycle ? RunOnCleanContext(lifecycle.StartedAsync, cancellationToken) : Task.CompletedTask; - // Stop lifecycle is intentionally not wrapped: stop methods typically signal - // cancellation and await shutdown rather than spawning new long-running background - // work, so context capture during Stop is not the span-leak vector that Start is. - public Task StoppingAsync(CancellationToken cancellationToken) => - inner is IHostedLifecycleService lifecycle - ? lifecycle.StoppingAsync(cancellationToken) - : Task.CompletedTask; + // Stop lifecycle is not flow-suppressed: stop methods typically signal cancellation + // and await shutdown rather than spawning new long-running background work, so + // context capture during Stop is not the span-leak vector that Start is. They are + // once-guarded, though — concurrent Host.StopAsync calls invoke these hooks twice + // (see the class remarks). + public Task StoppingAsync(CancellationToken cancellationToken) + { + if (inner is not IHostedLifecycleService lifecycle) + { + return Task.CompletedTask; + } - public Task StoppedAsync(CancellationToken cancellationToken) => - inner is IHostedLifecycleService lifecycle - ? lifecycle.StoppedAsync(cancellationToken) - : Task.CompletedTask; + lock (_stopGate) + { + return _stoppingTask ??= InvokeOnce(lifecycle.StoppingAsync, cancellationToken); + } + } + + public Task StoppedAsync(CancellationToken cancellationToken) + { + if (inner is not IHostedLifecycleService lifecycle) + { + return Task.CompletedTask; + } + + lock (_stopGate) + { + return _stoppedTask ??= InvokeOnce(lifecycle.StoppedAsync, cancellationToken); + } + } /// public async ValueTask DisposeAsync() @@ -83,6 +123,20 @@ public void Dispose() } } + // Captures a synchronous throw as a faulted task so it is cached in the once-guard — + // otherwise a second caller would re-invoke the inner stop method. + private static Task InvokeOnce(Func op, CancellationToken ct) + { + try + { + return op(ct); + } + catch (Exception ex) + { + return Task.FromException(ex); + } + } + // Dispatch onto a thread-pool worker with a clean captured ExecutionContext by // combining SuppressFlow + Task.Run. Unlike wrapping `using (SuppressFlow()) return op(ct);` // which only suppresses during the synchronous body, this keeps the inner operation diff --git a/TUnit.AspNetCore.Tests/HostStopExactlyOnceProbeTests.cs b/TUnit.AspNetCore.Tests/HostStopExactlyOnceProbeTests.cs new file mode 100644 index 00000000000..21ca344a95d --- /dev/null +++ b/TUnit.AspNetCore.Tests/HostStopExactlyOnceProbeTests.cs @@ -0,0 +1,72 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using TUnit.AspNetCore; + +namespace TUnit.AspNetCore.Tests; + +/// +/// End-to-end regression test for https://github.com/thomhurst/TUnit/issues/6339. +/// +/// With a minimal-hosting SUT, disposing the per-test factory races the SUT's own +/// app.Run() shutdown path: WebApplicationFactory.DisposeAsync calls +/// Host.StopAsync, whose ApplicationStopping signal wakes the app's parked +/// WaitForShutdownAsync, which calls Host.StopAsync again — concurrently. +/// Before the stop-once guard in , each hosted +/// service's StopAsync ran twice in parallel (observed here as 4–8 failures per +/// 50 tests), which is how Rebus' non-thread-safe bus dispose produced the reporter's +/// . +/// +/// +/// The probe throws — with both stack traces — if it ever observes a second +/// StopAsync entry, so a regression surfaces as loud test failures. +/// +/// +public class HostStopExactlyOnceProbeTests : WebApplicationTest +{ + protected override void ConfigureTestServices(IServiceCollection services) + { + services.AddSingleton(); + } + + [Test] + [Repeat(50)] + public async Task Host_Hosted_Service_Stops_Exactly_Once_Under_Parallel_Churn() + { + var client = Factory.CreateClient(); + using var response = await client.GetAsync("/"); + + // Small jitter so test completions (and After-hook disposals) overlap heavily. + await Task.Delay(Random.Shared.Next(0, 20)); + } +} + +/// +/// Throws on a second StopAsync entry — sequential re-stop or concurrent overlap — so the +/// offending call site's stack appears in the test output alongside the first stop's stack. +/// +internal sealed class StopProbeHostedService : IHostedService +{ + private int _stopEntries; + private volatile string? _firstStopStack; + + public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public async Task StopAsync(CancellationToken cancellationToken) + { + var entry = Interlocked.Increment(ref _stopEntries); + + if (entry > 1) + { + throw new InvalidOperationException( + $"StopAsync entered {entry} times on probe {GetHashCode():x8}.\n" + + $"--- first stop stack ---\n{_firstStopStack}\n" + + $"--- this stop stack ---\n{Environment.StackTrace}"); + } + + _firstStopStack = Environment.StackTrace; + + // Widen the window like a real bus shutdown (Rebus stops workers, waits on its + // cleanup task for up to 5s). A concurrent second stop lands inside this delay. + await Task.Delay(100, CancellationToken.None); + } +} diff --git a/TUnit.AspNetCore.Tests/HostedServiceStopOnceTests.cs b/TUnit.AspNetCore.Tests/HostedServiceStopOnceTests.cs new file mode 100644 index 00000000000..2c5e835954a --- /dev/null +++ b/TUnit.AspNetCore.Tests/HostedServiceStopOnceTests.cs @@ -0,0 +1,150 @@ +using Microsoft.Extensions.Hosting; +using TUnit.AspNetCore; + +namespace TUnit.AspNetCore.Tests; + +/// +/// Regression tests for https://github.com/thomhurst/TUnit/issues/6339. +/// +/// Minimal-hosting SUTs park app.Run() in WaitForShutdownAsync, which calls +/// Host.StopAsync when ApplicationStopping fires — concurrently with the +/// Host.StopAsync from WebApplicationFactory.DisposeAsync that triggered it. +/// Every hosted service's stop then runs twice in parallel, breaking services with +/// non-thread-safe shutdown (Rebus' bus dispose throws ). +/// must absorb the duplicate calls. +/// +/// +public class HostedServiceStopOnceTests +{ + [Test] + public async Task Concurrent_StopAsync_Invokes_Inner_Once() + { + var inner = new CountingHostedService(); + var wrapper = new FlowSuppressingHostedService(inner); + + var gate = new TaskCompletionSource(); + var callers = Enumerable.Range(0, 16) + .Select(_ => Task.Run(async () => + { + await gate.Task; + await wrapper.StopAsync(CancellationToken.None); + })) + .ToArray(); + gate.SetResult(); + await Task.WhenAll(callers); + + await Assert.That(inner.StopCalls).IsEqualTo(1); + } + + [Test] + public async Task Sequential_StopAsync_Invokes_Inner_Once() + { + var inner = new CountingHostedService(); + var wrapper = new FlowSuppressingHostedService(inner); + + await wrapper.StopAsync(CancellationToken.None); + await wrapper.StopAsync(CancellationToken.None); + + await Assert.That(inner.StopCalls).IsEqualTo(1); + } + + [Test] + public async Task Duplicate_Stop_Callers_Observe_The_Same_Task() + { + var inner = new CountingHostedService(); + var wrapper = new FlowSuppressingHostedService(inner); + + var first = wrapper.StopAsync(CancellationToken.None); + var second = wrapper.StopAsync(CancellationToken.None); + + await Assert.That(ReferenceEquals(first, second)).IsTrue(); + } + + [Test] + public async Task Synchronously_Throwing_Stop_Is_Cached_Not_Reinvoked() + { + var inner = new SyncThrowingStopHostedService(); + var wrapper = new FlowSuppressingHostedService(inner); + + await Assert.That(async () => await wrapper.StopAsync(CancellationToken.None)) + .Throws(); + await Assert.That(async () => await wrapper.StopAsync(CancellationToken.None)) + .Throws(); + + await Assert.That(inner.StopCalls).IsEqualTo(1); + } + + [Test] + public async Task Lifecycle_Stopping_And_Stopped_Are_Once_Guarded() + { + var inner = new CountingLifecycleHostedService(); + var wrapper = new FlowSuppressingHostedService(inner); + + await wrapper.StoppingAsync(CancellationToken.None); + await wrapper.StoppingAsync(CancellationToken.None); + await wrapper.StoppedAsync(CancellationToken.None); + await wrapper.StoppedAsync(CancellationToken.None); + + await Assert.That(inner.StoppingCalls).IsEqualTo(1); + await Assert.That(inner.StoppedCalls).IsEqualTo(1); + } +} + +internal class CountingHostedService : IHostedService +{ + private int _stopCalls; + + public int StopCalls => _stopCalls; + + public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public async Task StopAsync(CancellationToken cancellationToken) + { + Interlocked.Increment(ref _stopCalls); + + // Keep the stop in flight briefly so concurrent duplicate callers would + // overlap it rather than arrive after completion. + await Task.Delay(50, CancellationToken.None); + } +} + +internal sealed class SyncThrowingStopHostedService : IHostedService +{ + private int _stopCalls; + + public int StopCalls => _stopCalls; + + public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public Task StopAsync(CancellationToken cancellationToken) + { + Interlocked.Increment(ref _stopCalls); + throw new InvalidOperationException("stop failed synchronously"); + } +} + +internal sealed class CountingLifecycleHostedService : IHostedService, IHostedLifecycleService +{ + private int _stoppingCalls; + private int _stoppedCalls; + + public int StoppingCalls => _stoppingCalls; + public int StoppedCalls => _stoppedCalls; + + public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public Task StartingAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public Task StartedAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public Task StoppingAsync(CancellationToken cancellationToken) + { + Interlocked.Increment(ref _stoppingCalls); + return Task.CompletedTask; + } + + public Task StoppedAsync(CancellationToken cancellationToken) + { + Interlocked.Increment(ref _stoppedCalls); + return Task.CompletedTask; + } +} From f1ac3c735c367714ad99e7e1f23ebd18643688b5 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:20:47 +0100 Subject: [PATCH 2/4] fix(aspnetcore): preserve stop cancellation --- .../FlowSuppressingHostedService.cs | 18 ++++++-- .../HostedServiceStopOnceTests.cs | 41 +++++++++++++++++++ 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/TUnit.AspNetCore.Core/FlowSuppressingHostedService.cs b/TUnit.AspNetCore.Core/FlowSuppressingHostedService.cs index 0cfb019cc76..4758dde74ed 100644 --- a/TUnit.AspNetCore.Core/FlowSuppressingHostedService.cs +++ b/TUnit.AspNetCore.Core/FlowSuppressingHostedService.cs @@ -48,10 +48,13 @@ public Task StartAsync(CancellationToken cancellationToken) => public Task StopAsync(CancellationToken cancellationToken) { + Task task; lock (_stopGate) { - return _stopTask ??= InvokeOnce(inner.StopAsync, cancellationToken); + task = _stopTask ??= InvokeOnce(inner.StopAsync, cancellationToken); } + + return WaitWithCancellation(task, cancellationToken); } public Task StartingAsync(CancellationToken cancellationToken) => @@ -76,10 +79,13 @@ public Task StoppingAsync(CancellationToken cancellationToken) return Task.CompletedTask; } + Task task; lock (_stopGate) { - return _stoppingTask ??= InvokeOnce(lifecycle.StoppingAsync, cancellationToken); + task = _stoppingTask ??= InvokeOnce(lifecycle.StoppingAsync, cancellationToken); } + + return WaitWithCancellation(task, cancellationToken); } public Task StoppedAsync(CancellationToken cancellationToken) @@ -89,10 +95,13 @@ public Task StoppedAsync(CancellationToken cancellationToken) return Task.CompletedTask; } + Task task; lock (_stopGate) { - return _stoppedTask ??= InvokeOnce(lifecycle.StoppedAsync, cancellationToken); + task = _stoppedTask ??= InvokeOnce(lifecycle.StoppedAsync, cancellationToken); } + + return WaitWithCancellation(task, cancellationToken); } /// @@ -137,6 +146,9 @@ private static Task InvokeOnce(Func op, CancellationTok } } + private static Task WaitWithCancellation(Task task, CancellationToken cancellationToken) => + cancellationToken.CanBeCanceled ? task.WaitAsync(cancellationToken) : task; + // Dispatch onto a thread-pool worker with a clean captured ExecutionContext by // combining SuppressFlow + Task.Run. Unlike wrapping `using (SuppressFlow()) return op(ct);` // which only suppresses during the synchronous body, this keeps the inner operation diff --git a/TUnit.AspNetCore.Tests/HostedServiceStopOnceTests.cs b/TUnit.AspNetCore.Tests/HostedServiceStopOnceTests.cs index 2c5e835954a..f58646b828d 100644 --- a/TUnit.AspNetCore.Tests/HostedServiceStopOnceTests.cs +++ b/TUnit.AspNetCore.Tests/HostedServiceStopOnceTests.cs @@ -60,6 +60,26 @@ public async Task Duplicate_Stop_Callers_Observe_The_Same_Task() await Assert.That(ReferenceEquals(first, second)).IsTrue(); } + [Test] + public async Task Duplicate_Stop_Caller_Observes_Its_Own_Cancellation_Token() + { + var inner = new BlockingStopHostedService(); + var wrapper = new FlowSuppressingHostedService(inner); + + var first = wrapper.StopAsync(CancellationToken.None); + await inner.Entered; + + using var cancellationTokenSource = new CancellationTokenSource(); + var second = wrapper.StopAsync(cancellationTokenSource.Token); + cancellationTokenSource.Cancel(); + + await Assert.That(async () => await second).Throws(); + await Assert.That(inner.StopCalls).IsEqualTo(1); + + inner.Release(); + await first; + } + [Test] public async Task Synchronously_Throwing_Stop_Is_Cached_Not_Reinvoked() { @@ -123,6 +143,27 @@ public Task StopAsync(CancellationToken cancellationToken) } } +internal sealed class BlockingStopHostedService : IHostedService +{ + private readonly TaskCompletionSource _entered = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _release = new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _stopCalls; + + public Task Entered => _entered.Task; + public int StopCalls => _stopCalls; + + public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public async Task StopAsync(CancellationToken cancellationToken) + { + Interlocked.Increment(ref _stopCalls); + _entered.SetResult(); + await _release.Task; + } + + public void Release() => _release.SetResult(); +} + internal sealed class CountingLifecycleHostedService : IHostedService, IHostedLifecycleService { private int _stoppingCalls; From dfd6df6b7ff7b6e5ce80e113250602135140b293 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:31:51 +0100 Subject: [PATCH 3/4] fix(aspnetcore): start stop outside lock --- .../FlowSuppressingHostedService.cs | 76 +++++++++++-------- .../HostedServiceStopOnceTests.cs | 61 +++++++++++++++ 2 files changed, 107 insertions(+), 30 deletions(-) diff --git a/TUnit.AspNetCore.Core/FlowSuppressingHostedService.cs b/TUnit.AspNetCore.Core/FlowSuppressingHostedService.cs index 4758dde74ed..7e5116efbfb 100644 --- a/TUnit.AspNetCore.Core/FlowSuppressingHostedService.cs +++ b/TUnit.AspNetCore.Core/FlowSuppressingHostedService.cs @@ -46,16 +46,8 @@ internal sealed class FlowSuppressingHostedService(IHostedService inner) : IHost public Task StartAsync(CancellationToken cancellationToken) => RunOnCleanContext(inner.StartAsync, cancellationToken); - public Task StopAsync(CancellationToken cancellationToken) - { - Task task; - lock (_stopGate) - { - task = _stopTask ??= InvokeOnce(inner.StopAsync, cancellationToken); - } - - return WaitWithCancellation(task, cancellationToken); - } + public Task StopAsync(CancellationToken cancellationToken) => + InvokeOnce(ref _stopTask, inner.StopAsync, cancellationToken); public Task StartingAsync(CancellationToken cancellationToken) => inner is IHostedLifecycleService lifecycle @@ -79,13 +71,7 @@ public Task StoppingAsync(CancellationToken cancellationToken) return Task.CompletedTask; } - Task task; - lock (_stopGate) - { - task = _stoppingTask ??= InvokeOnce(lifecycle.StoppingAsync, cancellationToken); - } - - return WaitWithCancellation(task, cancellationToken); + return InvokeOnce(ref _stoppingTask, lifecycle.StoppingAsync, cancellationToken); } public Task StoppedAsync(CancellationToken cancellationToken) @@ -95,13 +81,7 @@ public Task StoppedAsync(CancellationToken cancellationToken) return Task.CompletedTask; } - Task task; - lock (_stopGate) - { - task = _stoppedTask ??= InvokeOnce(lifecycle.StoppedAsync, cancellationToken); - } - - return WaitWithCancellation(task, cancellationToken); + return InvokeOnce(ref _stoppedTask, lifecycle.StoppedAsync, cancellationToken); } /// @@ -132,17 +112,53 @@ public void Dispose() } } - // Captures a synchronous throw as a faulted task so it is cached in the once-guard — - // otherwise a second caller would re-invoke the inner stop method. - private static Task InvokeOnce(Func op, CancellationToken ct) + // Publish a placeholder before invoking the operation outside the lock. Duplicate callers + // can immediately wait with their own cancellation token, even when the operation blocks + // synchronously before returning its task. + private Task InvokeOnce( + ref Task? cachedTask, + Func operation, + CancellationToken cancellationToken) + { + TaskCompletionSource? completionSource = null; + Task task; + + lock (_stopGate) + { + if (cachedTask is null) + { + completionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + cachedTask = completionSource.Task; + } + + task = cachedTask; + } + + if (completionSource is not null) + { + _ = ExecuteAndCompleteAsync(completionSource, operation, cancellationToken); + } + + return WaitWithCancellation(task, cancellationToken); + } + + private static async Task ExecuteAndCompleteAsync( + TaskCompletionSource completionSource, + Func operation, + CancellationToken cancellationToken) { try { - return op(ct); + await operation(cancellationToken).ConfigureAwait(false); + completionSource.SetResult(); + } + catch (OperationCanceledException exception) + { + completionSource.SetCanceled(exception.CancellationToken); } - catch (Exception ex) + catch (Exception exception) { - return Task.FromException(ex); + completionSource.SetException(exception); } } diff --git a/TUnit.AspNetCore.Tests/HostedServiceStopOnceTests.cs b/TUnit.AspNetCore.Tests/HostedServiceStopOnceTests.cs index f58646b828d..97956d30831 100644 --- a/TUnit.AspNetCore.Tests/HostedServiceStopOnceTests.cs +++ b/TUnit.AspNetCore.Tests/HostedServiceStopOnceTests.cs @@ -80,6 +80,33 @@ public async Task Duplicate_Stop_Caller_Observes_Its_Own_Cancellation_Token() await first; } + [Test] + public async Task Duplicate_Stop_Caller_Does_Not_Wait_For_Synchronous_Startup() + { + using var inner = new SynchronouslyBlockingStopHostedService(); + var wrapper = new FlowSuppressingHostedService(inner); + + var first = Task.Run(() => wrapper.StopAsync(CancellationToken.None)); + inner.WaitUntilEntered(); + + using var cancellationTokenSource = new CancellationTokenSource(); + cancellationTokenSource.Cancel(); + + try + { + var second = Task.Run(() => wrapper.StopAsync(cancellationTokenSource.Token)); + await Assert.That(async () => await second.WaitAsync(TimeSpan.FromSeconds(1))) + .Throws(); + } + finally + { + inner.Release(); + } + + await first; + await Assert.That(inner.StopCalls).IsEqualTo(1); + } + [Test] public async Task Synchronously_Throwing_Stop_Is_Cached_Not_Reinvoked() { @@ -164,6 +191,40 @@ public async Task StopAsync(CancellationToken cancellationToken) public void Release() => _release.SetResult(); } +internal sealed class SynchronouslyBlockingStopHostedService : IHostedService, IDisposable +{ + private readonly ManualResetEventSlim _entered = new(); + private readonly ManualResetEventSlim _release = new(); + private int _stopCalls; + + public int StopCalls => _stopCalls; + + public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public Task StopAsync(CancellationToken cancellationToken) + { + Interlocked.Increment(ref _stopCalls); + _entered.Set(); + _release.Wait(); + return Task.CompletedTask; + } + + public void WaitUntilEntered() + { + if (!_entered.Wait(TimeSpan.FromSeconds(5))) + { + throw new TimeoutException("StopAsync was not entered within five seconds."); + } + } + public void Release() => _release.Set(); + + public void Dispose() + { + _entered.Dispose(); + _release.Dispose(); + } +} + internal sealed class CountingLifecycleHostedService : IHostedService, IHostedLifecycleService { private int _stoppingCalls; From ccaf8419e86c306c83ade4c0ea52abcf50712e9f Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:54:03 +0100 Subject: [PATCH 4/4] fix(aspnetcore): preserve owning stop wait --- .../FlowSuppressingHostedService.cs | 4 +++- .../HostedServiceStopOnceTests.cs | 23 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/TUnit.AspNetCore.Core/FlowSuppressingHostedService.cs b/TUnit.AspNetCore.Core/FlowSuppressingHostedService.cs index 7e5116efbfb..32ffc8867bc 100644 --- a/TUnit.AspNetCore.Core/FlowSuppressingHostedService.cs +++ b/TUnit.AspNetCore.Core/FlowSuppressingHostedService.cs @@ -122,6 +122,7 @@ private Task InvokeOnce( { TaskCompletionSource? completionSource = null; Task task; + var ownsInvocation = false; lock (_stopGate) { @@ -129,6 +130,7 @@ private Task InvokeOnce( { completionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); cachedTask = completionSource.Task; + ownsInvocation = true; } task = cachedTask; @@ -139,7 +141,7 @@ private Task InvokeOnce( _ = ExecuteAndCompleteAsync(completionSource, operation, cancellationToken); } - return WaitWithCancellation(task, cancellationToken); + return ownsInvocation ? task : WaitWithCancellation(task, cancellationToken); } private static async Task ExecuteAndCompleteAsync( diff --git a/TUnit.AspNetCore.Tests/HostedServiceStopOnceTests.cs b/TUnit.AspNetCore.Tests/HostedServiceStopOnceTests.cs index 97956d30831..d95f7212f96 100644 --- a/TUnit.AspNetCore.Tests/HostedServiceStopOnceTests.cs +++ b/TUnit.AspNetCore.Tests/HostedServiceStopOnceTests.cs @@ -80,6 +80,29 @@ public async Task Duplicate_Stop_Caller_Observes_Its_Own_Cancellation_Token() await first; } + [Test] + public async Task Owning_Stop_Caller_Awaits_Inner_After_Token_Cancellation() + { + var inner = new BlockingStopHostedService(); + var wrapper = new FlowSuppressingHostedService(inner); + + using var cancellationTokenSource = new CancellationTokenSource(); + var stop = wrapper.StopAsync(cancellationTokenSource.Token); + await inner.Entered; + + try + { + cancellationTokenSource.Cancel(); + await Assert.That(stop.IsCompleted).IsFalse(); + } + finally + { + inner.Release(); + } + + await stop; + } + [Test] public async Task Duplicate_Stop_Caller_Does_Not_Wait_For_Synchronous_Startup() {