Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 37 additions & 1 deletion TUnit.AspNetCore.Core/FlowSuppressingHostedService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,20 @@ namespace TUnit.AspNetCore;
/// as their parent.
/// </summary>
/// <remarks>
/// <para>
/// Implements <see cref="IHostedLifecycleService"/> so the Host's lifecycle hooks keep
/// firing for inner services that implement it — the Host uses an <c>is</c> check
/// against the registered instance, so without passthrough wrapping would silently
/// drop those hooks.
/// </para>
/// <para>
/// Also implements <see cref="IAsyncDisposable"/> and <see cref="IDisposable"/> 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.
/// </para>
/// </remarks>
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);
Expand Down Expand Up @@ -47,6 +55,34 @@ inner is IHostedLifecycleService lifecycle
? lifecycle.StoppedAsync(cancellationToken)
: Task.CompletedTask;

/// <inheritdoc />
public async ValueTask DisposeAsync()
{
if (inner is IAsyncDisposable asyncDisposable)
{
await asyncDisposable.DisposeAsync().ConfigureAwait(false);
}
else if (inner is IDisposable disposable)
{
disposable.Dispose();
}
}

/// <inheritdoc />
/// <remarks>
/// Forwards to the inner service's <see cref="IDisposable.Dispose"/> when implemented.
/// Inner services that only implement <see cref="IAsyncDisposable"/> are not disposed on
/// this synchronous path. Callers should use <see cref="DisposeAsync"/> (or
/// <c>await using</c> on the owning factory) to release async-only resources.
/// </remarks>
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
Expand Down
145 changes: 145 additions & 0 deletions TUnit.AspNetCore.Tests/HostedServiceDisposalForwardingTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
using Microsoft.Extensions.Hosting;
using TUnit.AspNetCore;

namespace TUnit.AspNetCore.Tests;

/// <summary>
/// Tests that <see cref="FlowSuppressingHostedService"/> forwards <see cref="IDisposable.Dispose"/>
/// and <see cref="IAsyncDisposable.DisposeAsync"/> 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.
/// </summary>
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;
}
Loading