diff --git a/TUnit.AspNetCore.Core/FlowSuppressingHostedService.cs b/TUnit.AspNetCore.Core/FlowSuppressingHostedService.cs index 38c2ea7101..0f8f05a9ce 100644 --- a/TUnit.AspNetCore.Core/FlowSuppressingHostedService.cs +++ b/TUnit.AspNetCore.Core/FlowSuppressingHostedService.cs @@ -11,12 +11,20 @@ namespace TUnit.AspNetCore; /// as their parent. /// /// +/// /// Implements so the Host's lifecycle hooks keep /// firing for inner services that implement it — the Host uses an is check /// against the registered instance, so without passthrough wrapping would silently /// drop those hooks. +/// +/// +/// Also implements and so the +/// DI container forwards disposal to the inner service when the host is disposed. +/// Without this, wrapped services that own unmanaged resources leak silently because +/// the container only sees the non-disposable wrapper. +/// /// -internal sealed class FlowSuppressingHostedService(IHostedService inner) : IHostedLifecycleService +internal sealed class FlowSuppressingHostedService(IHostedService inner) : IHostedLifecycleService, IAsyncDisposable, IDisposable { public Task StartAsync(CancellationToken cancellationToken) => RunOnCleanContext(inner.StartAsync, cancellationToken); @@ -47,6 +55,34 @@ inner is IHostedLifecycleService lifecycle ? lifecycle.StoppedAsync(cancellationToken) : Task.CompletedTask; + /// + public async ValueTask DisposeAsync() + { + if (inner is IAsyncDisposable asyncDisposable) + { + await asyncDisposable.DisposeAsync().ConfigureAwait(false); + } + else if (inner is IDisposable disposable) + { + disposable.Dispose(); + } + } + + /// + /// + /// Forwards to the inner service's when implemented. + /// Inner services that only implement are not disposed on + /// this synchronous path. Callers should use (or + /// await using on the owning factory) to release async-only resources. + /// + public void Dispose() + { + if (inner is IDisposable disposable) + { + disposable.Dispose(); + } + } + // 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/HostedServiceDisposalForwardingTests.cs b/TUnit.AspNetCore.Tests/HostedServiceDisposalForwardingTests.cs new file mode 100644 index 0000000000..c557c7665a --- /dev/null +++ b/TUnit.AspNetCore.Tests/HostedServiceDisposalForwardingTests.cs @@ -0,0 +1,145 @@ +using Microsoft.Extensions.Hosting; +using TUnit.AspNetCore; + +namespace TUnit.AspNetCore.Tests; + +/// +/// Tests that forwards +/// and calls to the wrapped inner service. +/// Constructs the wrapper directly, without a host or DI container, to isolate the forwarding +/// contract from host-disposal ordering. +/// +public class HostedServiceDisposalForwardingTests +{ + [Test] + public async Task DisposeAsync_Forwards_To_IAsyncDisposable_Inner() + { + var inner = new AsyncDisposableProbeHostedService(); + var wrapper = new FlowSuppressingHostedService(inner); + + await ((IAsyncDisposable) wrapper).DisposeAsync(); + + await Assert.That(inner.DisposeAsyncCalled).IsTrue(); + } + + [Test] + public async Task Dispose_Forwards_To_IDisposable_Inner() + { + var inner = new DisposableProbeHostedService(); + var wrapper = new FlowSuppressingHostedService(inner); + + ((IDisposable) wrapper).Dispose(); + + await Assert.That(inner.DisposeCalled).IsTrue(); + } + + [Test] + public async Task DisposeAsync_On_SyncOnly_Inner_Falls_Back_To_Dispose() + { + var inner = new DisposableProbeHostedService(); + var wrapper = new FlowSuppressingHostedService(inner); + + await ((IAsyncDisposable) wrapper).DisposeAsync(); + + await Assert.That(inner.DisposeCalled).IsTrue(); + } + + [Test] + public async Task Dispose_On_AsyncOnly_Inner_Is_No_Op() + { + // Sync Dispose intentionally does not release an async-only inner: + // blocking on DisposeAsync would violate the "never block on async" rule. + // Callers with async-only inner services should use DisposeAsync. + var inner = new AsyncDisposableProbeHostedService(); + var wrapper = new FlowSuppressingHostedService(inner); + + ((IDisposable) wrapper).Dispose(); + + await Assert.That(inner.DisposeAsyncCalled).IsFalse(); + } + + [Test] + public async Task DisposeAsync_On_DualInterface_Inner_Prefers_Async_Path() + { + var inner = new DualDisposableProbeHostedService(); + var wrapper = new FlowSuppressingHostedService(inner); + + await ((IAsyncDisposable) wrapper).DisposeAsync(); + + await Assert.That(inner.DisposeAsyncCalled).IsTrue(); + await Assert.That(inner.DisposeCalled).IsFalse(); + } + + [Test] + public async Task Dispose_On_DualInterface_Inner_Prefers_Sync_Path() + { + var inner = new DualDisposableProbeHostedService(); + var wrapper = new FlowSuppressingHostedService(inner); + + ((IDisposable) wrapper).Dispose(); + + await Assert.That(inner.DisposeCalled).IsTrue(); + await Assert.That(inner.DisposeAsyncCalled).IsFalse(); + } + + [Test] + public async Task Wrapper_Does_Not_Throw_When_Inner_Is_Not_Disposable() + { + var inner = new NonDisposableProbeHostedService(); + var wrapper = new FlowSuppressingHostedService(inner); + + await Assert.That(() => ((IDisposable) wrapper).Dispose()).ThrowsNothing(); + await Assert.That(async () => await ((IAsyncDisposable) wrapper).DisposeAsync()).ThrowsNothing(); + } +} + +internal sealed class DisposableProbeHostedService : IHostedService, IDisposable +{ + public bool DisposeCalled { get; private set; } + + public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public void Dispose() => DisposeCalled = true; +} + +internal sealed class AsyncDisposableProbeHostedService : IHostedService, IAsyncDisposable +{ + public bool DisposeAsyncCalled { get; private set; } + + public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public ValueTask DisposeAsync() + { + DisposeAsyncCalled = true; + return ValueTask.CompletedTask; + } +} + +internal sealed class DualDisposableProbeHostedService : IHostedService, IDisposable, IAsyncDisposable +{ + public bool DisposeCalled { get; private set; } + public bool DisposeAsyncCalled { get; private set; } + + public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public void Dispose() => DisposeCalled = true; + + public ValueTask DisposeAsync() + { + DisposeAsyncCalled = true; + return ValueTask.CompletedTask; + } +} + +internal sealed class NonDisposableProbeHostedService : IHostedService +{ + public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +}