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
60 changes: 60 additions & 0 deletions TUnit.AspNetCore.Core/FlowSuppressingHostedService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
using Microsoft.Extensions.Hosting;

namespace TUnit.AspNetCore;

/// <summary>
/// Wraps an <see cref="IHostedService"/> so its <see cref="IHostedService.StartAsync"/>
/// runs on a thread-pool worker with a clean <see cref="ExecutionContext"/>.
/// Background tasks spawned anywhere inside <c>StartAsync</c> — synchronously or
/// after an <c>await</c> — inherit that clean context, so activities they later
/// emit do not inherit the test's ambient <see cref="System.Diagnostics.Activity.Current"/>
/// as their parent.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
internal sealed class FlowSuppressingHostedService(IHostedService inner) : IHostedLifecycleService
{
public Task StartAsync(CancellationToken cancellationToken) =>
RunOnCleanContext(inner.StartAsync, cancellationToken);

public Task StopAsync(CancellationToken cancellationToken) =>
inner.StopAsync(cancellationToken);

public Task StartingAsync(CancellationToken cancellationToken) =>
inner is IHostedLifecycleService lifecycle
? RunOnCleanContext(lifecycle.StartingAsync, cancellationToken)
: Task.CompletedTask;

public Task StartedAsync(CancellationToken cancellationToken) =>
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;

public Task StoppedAsync(CancellationToken cancellationToken) =>
inner is IHostedLifecycleService lifecycle
? lifecycle.StoppedAsync(cancellationToken)
: Task.CompletedTask;

// 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
// running under a clean context through awaits — every `Task.Run` inside `op` also
// captures clean context.
private static Task RunOnCleanContext(Func<CancellationToken, Task> op, CancellationToken ct)
{
using var _ = ExecutionContext.SuppressFlow();
return Task.Run(() => op(ct), ct);
}
}
77 changes: 77 additions & 0 deletions TUnit.AspNetCore.Core/TestWebApplicationFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,83 @@ protected override void ConfigureWebHost(IWebHostBuilder builder)
});
}

/// <summary>
/// Controls whether every registered <see cref="Microsoft.Extensions.Hosting.IHostedService"/>
/// has its <c>StartAsync</c> dispatched onto a thread-pool worker with a clean
/// <see cref="ExecutionContext"/>.
/// <para>
/// When enabled (the default), background work spawned inside a hosted service's
/// <c>StartAsync</c> — synchronously or after an <c>await</c> — captures a clean
/// execution context. Activities emitted later on background threads become orphan
/// roots rather than inheriting the first test's <see cref="System.Diagnostics.Activity.Current"/>.
/// Without this, spans from hosted-service work done during test B are attributed to
/// test A's <c>TraceId</c>.
/// </para>
/// <para>
/// Override and return <c>false</c> to preserve ambient context flow into hosted
/// services — only needed if the hosted service intentionally relies on
/// <c>Activity.Current</c> or other <see cref="System.Threading.AsyncLocal{T}"/>
/// values captured at factory-build time, or requires <c>StartAsync</c> to run on
/// the calling thread.
/// </para>
/// </summary>
protected virtual bool SuppressHostedServiceExecutionContextFlow => true;

protected override IHost CreateHost(IHostBuilder builder)
{
if (SuppressHostedServiceExecutionContextFlow)
{
builder.ConfigureServices(DecorateHostedServicesWithFlowSuppression);
}

return base.CreateHost(builder);
}

private static void DecorateHostedServicesWithFlowSuppression(IServiceCollection services)
{
for (var i = 0; i < services.Count; i++)
{
var descriptor = services[i];

if (descriptor.ServiceType != typeof(IHostedService))
{
continue;
}

services[i] = WrapHostedServiceDescriptor(descriptor);
}
}

private static ServiceDescriptor WrapHostedServiceDescriptor(ServiceDescriptor descriptor)
{
if (descriptor.ImplementationInstance is IHostedService instance)
{
return new ServiceDescriptor(
typeof(IHostedService),
_ => new FlowSuppressingHostedService(instance),
descriptor.Lifetime);
}

if (descriptor.ImplementationFactory is { } factory)
{
return new ServiceDescriptor(
typeof(IHostedService),
sp => new FlowSuppressingHostedService((IHostedService)factory(sp)),
descriptor.Lifetime);
}

if (descriptor.ImplementationType is { } implType)
{
return new ServiceDescriptor(
typeof(IHostedService),
sp => new FlowSuppressingHostedService(
(IHostedService)ActivatorUtilities.CreateInstance(sp, implType)),
descriptor.Lifetime);
}

return descriptor;
}

/// <summary>
/// Creates an <see cref="HttpClient"/> with <see cref="ActivityPropagationHandler"/> and
/// <see cref="TUnitTestIdHandler"/> automatically prepended to the handler chain.
Expand Down
170 changes: 170 additions & 0 deletions TUnit.AspNetCore.Tests/HostedServiceFlowSuppressionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
using System.Diagnostics;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using TUnit.AspNetCore;

namespace TUnit.AspNetCore.Tests;

/// <summary>
/// Tests for <see cref="TestWebApplicationFactory{TEntryPoint}"/>'s automatic
/// <see cref="ExecutionContext.SuppressFlow"/> wrapping of <see cref="IHostedService"/>
/// registrations. See issue #5589.
/// </summary>
public class HostedServiceFlowSuppressionTests
{
[Test]
public async Task StartAsync_SuppressesFlow_IntoSpawnedTasks()
{
using var outer = new Activity("outer-test").Start();

var probe = new FlowProbeHostedService();
await using var factory = new FlowSuppressTestFactory { Probe = probe };

_ = factory.Server;

await probe.SpawnedTaskCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5));

await Assert.That(probe.ActivityInSpawnedTask).IsNull();
}

[Test]
public async Task StartAsync_SuppressesFlow_WhenSpawnIsAfterAwait()
{
using var outer = new Activity("outer-test").Start();

var probe = new DeepAsyncFlowProbeHostedService();
await using var factory = new DeepAsyncFlowSuppressTestFactory { Probe = probe };

_ = factory.Server;

await probe.SpawnedTaskCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5));

await Assert.That(probe.ActivityInSpawnedTask).IsNull();
}

[Test]
public async Task OptOut_PreservesFlow_IntoSpawnedTasks()
{
using var outer = new Activity("outer-test").Start();

var probe = new FlowProbeHostedService();
await using var factory = new NoSuppressionFactory { Probe = probe };

_ = factory.Server;

await probe.SpawnedTaskCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5));

await Assert.That(probe.ActivityInSpawnedTask).IsEqualTo(outer);
}

[Test]
public async Task RegisteredHostedServices_AreWrapped()
{
var probe = new FlowProbeHostedService();
await using var factory = new FlowSuppressTestFactory { Probe = probe };

_ = factory.Server;

var hostedServices = factory.Services.GetServices<IHostedService>().ToList();

await Assert.That(hostedServices.Any(h => h is FlowSuppressingHostedService)).IsTrue();
}

[Test]
public async Task IsolatedFactory_HostedServices_AreWrapped()
{
await using var factory = new FlowSuppressTestFactory();

var isolated = factory.GetIsolatedFactory(
TestContext.Current!,
new WebApplicationTestOptions(),
services => services.AddSingleton<IHostedService>(new FlowProbeHostedService()),
(_, _) => { });

_ = isolated.Server;

var hostedServices = isolated.Services.GetServices<IHostedService>().ToList();

await Assert.That(hostedServices.Any(h => h is FlowSuppressingHostedService)).IsTrue();
}
}

internal class FlowSuppressTestFactory : TestWebApplicationFactory<Program>
{
public FlowProbeHostedService? Probe { get; set; }

protected override void ConfigureWebHost(IWebHostBuilder builder)
{
base.ConfigureWebHost(builder);

builder.ConfigureServices(services =>
{
if (Probe is not null)
{
services.AddSingleton<IHostedService>(Probe);
}
});
}
}

internal sealed class NoSuppressionFactory : FlowSuppressTestFactory
{
protected override bool SuppressHostedServiceExecutionContextFlow => false;
}

internal sealed class FlowProbeHostedService : IHostedService
{
public Activity? ActivityInSpawnedTask { get; private set; }
public TaskCompletionSource SpawnedTaskCompleted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);

public Task StartAsync(CancellationToken cancellationToken)
{
_ = Task.Run(() =>
{
ActivityInSpawnedTask = Activity.Current;
SpawnedTaskCompleted.TrySetResult();
});

return Task.CompletedTask;
}

public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}

internal class DeepAsyncFlowSuppressTestFactory : TestWebApplicationFactory<Program>
{
public DeepAsyncFlowProbeHostedService? Probe { get; set; }

protected override void ConfigureWebHost(IWebHostBuilder builder)
{
base.ConfigureWebHost(builder);

builder.ConfigureServices(services =>
{
if (Probe is not null)
{
services.AddSingleton<IHostedService>(Probe);
}
});
}
}

internal sealed class DeepAsyncFlowProbeHostedService : IHostedService
{
public Activity? ActivityInSpawnedTask { get; private set; }
public TaskCompletionSource SpawnedTaskCompleted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);

public async Task StartAsync(CancellationToken cancellationToken)
{
await Task.Yield();

_ = Task.Run(() =>
{
ActivityInSpawnedTask = Activity.Current;
SpawnedTaskCompleted.TrySetResult();
});
}

public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
2 changes: 1 addition & 1 deletion docs/docs/examples/opentelemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,7 @@ using (ExecutionContext.SuppressFlow())
}
```

Around `IHostedService` registrations the same idea applies. (Tracking automation: [#5589](https://github.com/thomhurst/TUnit/issues/5589).)
For `IHostedService` registrations inside ASP.NET Core integration tests, `TestWebApplicationFactory<T>` does this automatically — every registered hosted service has its `StartAsync` wrapped in `ExecutionContext.SuppressFlow()`, so background tasks it spawns capture a clean context. Override `SuppressHostedServiceExecutionContextFlow` and return `false` to opt out if you intentionally rely on `Activity.Current` flowing into a hosted service.

**Last resort**: run affected tests one at a time with `[NotInParallel]`.

Expand Down
2 changes: 1 addition & 1 deletion docs/docs/guides/distributed-tracing.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ Some libraries (message brokers like DotPulsar, EF providers, connection pools)
**You can't fix the parent chain after the fact.** What works:

- Add the [`TUnitTagProcessor`](/docs/examples/opentelemetry#spans-from-test-a-are-showing-up-under-test-b) so spans always carry `tunit.test.id` even when the trace ID is wrong, then filter by tag.
- Suppress `ExecutionContext` flow when starting hosted services so they capture a clean context — see the same troubleshooting section.
- For hosted services inside `TestWebApplicationFactory<T>`, this leak is auto-mitigated — each `IHostedService.StartAsync` runs under `ExecutionContext.SuppressFlow()`, so background tasks it spawns capture a clean context. Override `SuppressHostedServiceExecutionContextFlow` and return `false` to opt out. Third-party `ActivitySource` instances captured at class-load time remain a residual concern.

### `WebApplicationFactory` without TUnit's wrapper

Expand Down
Loading