Skip to content

fix: auto-suppress ExecutionContext flow for hosted services (#5589)#5598

Merged
thomhurst merged 2 commits intomainfrom
fix/5589-suppress-hosted-service-flow
Apr 17, 2026
Merged

fix: auto-suppress ExecutionContext flow for hosted services (#5589)#5598
thomhurst merged 2 commits intomainfrom
fix/5589-suppress-hosted-service-flow

Conversation

@thomhurst
Copy link
Copy Markdown
Owner

Summary

  • TestWebApplicationFactory<T> now decorates every registered IHostedService so its StartAsync runs under ExecutionContext.SuppressFlow(). Background tasks spawned inside StartAsync capture a clean execution context, preventing spans from hosted-service work in test B being attributed to test A's TraceId.
  • The wrapper (FlowSuppressingHostedService) also implements IHostedLifecycleService so StartingAsync/StartedAsync/StoppingAsync/StoppedAsync hooks keep firing for inner services that implement it — the Host uses an is check against the registered instance, and a plain IHostedService wrapper would silently drop those hooks.
  • Opt-out via protected virtual bool SuppressHostedServiceExecutionContextFlow => true (override to return false).

How

Decoration runs in an override of CreateHost(IHostBuilder), which fires after all ConfigureWebHost / WithWebHostBuilder / ConfigureTestServices callbacks — so it catches hosted services registered via the isolated-factory path as well as the base factory.

Resolves #5589.

Test plan

  • StartAsync_SuppressesFlow_IntoSpawnedTasks — spawned Task.Run inside StartAsync sees Activity.Current == null even when an outer Activity is active at factory-build time.
  • OptOut_PreservesFlow_IntoSpawnedTasks — when override returns false, spawned task inherits the outer Activity.
  • RegisteredHostedServices_AreWrapped — resolved IHostedService instances are of the wrapper type.
  • IsolatedFactory_HostedServices_AreWrapped — services added via GetIsolatedFactory / ConfigureTestServices are also wrapped.
  • Full TUnit.AspNetCore.Tests suite (20 tests) green on net10.0.

Docs

  • docs/docs/examples/opentelemetry.md — "Spans from test A are showing up under test B" section updated to note the auto-fix.
  • docs/docs/guides/distributed-tracing.md — Static ActivitySource limitation updated.

`TestWebApplicationFactory<T>` now wraps every registered `IHostedService`
so its `StartAsync` runs under `ExecutionContext.SuppressFlow`. Background
tasks spawned inside `StartAsync` capture a clean execution context,
preventing spans from hosted-service work in test B from being attributed
to test A's `TraceId`.

The wrapper also implements `IHostedLifecycleService` so the Host's
`StartingAsync`/`StartedAsync`/`StoppingAsync`/`StoppedAsync` hooks keep
firing for inner services that implement it (the Host uses an `is` check
against the registered instance).

Override `SuppressHostedServiceExecutionContextFlow` and return `false` to
preserve ambient context flow.
Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

Overall this is a well-targeted fix with clean implementation and solid test coverage. The IHostedLifecycleService passthrough is a thoughtful detail that prevents silent hook drops. A few points worth considering:


Significant: Suppressed() only covers the synchronous portion of StartAsync

private static Task Suppressed(Func<CancellationToken, Task> op, CancellationToken ct)
{
    using var _ = ExecutionContext.SuppressFlow();
    return op(ct);     // ← using block ends here, before the returned Task completes
}

The using disposes before the returned Task completes. This means suppression only applies while op(ct) runs synchronously (up to its first await). The fix works for the common Task.Run(...) pattern, but fails for services that await before spawning:

public async Task StartAsync(CancellationToken ct)
{
    await _dbContext.EnsureCreatedAsync(ct);  // returns after this await
    _backgroundTask = Task.Run(ProcessQueue); // spawned here — NOT under suppression!
}

This is a real-world pattern (initialize something, then start a background loop). The current behavior is that suppression silently does nothing in this case, which could be confusing since users may assume the fix applies uniformly.

Suggestion: Document this as a known limitation in the XML doc on SuppressHostedServiceExecutionContextFlow. Something like:

Note: suppression applies to background work spawned synchronously inside StartAsync (e.g. Task.Run before any await). Work spawned after an await point is not covered; manually wrap such spawns with ExecutionContext.SuppressFlow().


Minor: Asymmetric suppression across lifecycle methods

StartingAsync and StartedAsync use Suppressed(), but StoppingAsync and StoppedAsync do not. This is probably intentional — stop methods rarely spawn long-running background tasks that need test isolation — but it is implicit. Worth a one-line comment in the class to signal the intent was deliberate.


Minor: Tests check internal implementation type rather than behavior

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

This couples the test to the class name of the wrapper. If the wrapping strategy changes (e.g. a different decorator), the behavioral tests will pass but the structural test will fail for no real reason. Since InternalsVisibleTo makes this compile, it's not a blocker — but a behavioral assertion (e.g. verifying that Activity.Current is null in a spawned task) would be more resilient. The StartAsync_SuppressesFlow_IntoSpawnedTasks test already does this correctly.


Positive notes

  • The IHostedLifecycleService passthrough is exactly right — omitting it would silently break services with those hooks and be very hard to diagnose.
  • WrapHostedServiceDescriptor correctly handles all three descriptor shapes (instance / factory / type).
  • The opt-out via protected virtual bool is the right API shape — non-breaking, discoverable, and the safe default is the right choice.
  • Test coverage is comprehensive including the isolated-factory path.
  • Docs updates are clear and accurate.

The async-limitation point is the only one that could genuinely surprise users, and a doc note would be sufficient. The rest are polish.

@codacy-production
Copy link
Copy Markdown

codacy-production Bot commented Apr 17, 2026

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 20 complexity

Metric Results
Complexity 20

View in Codacy

TIP This summary will be updated as you push new changes. Give us feedback

`using var _ = SuppressFlow(); return inner.StartAsync(ct);` only suppresses
context capture during the synchronous portion of StartAsync — `Task.Run`
after a prior `await` re-captures the test's Activity.Current.

Combine SuppressFlow with `Task.Run(() => inner.StartAsync(ct), ct)` so the
inner hosted service runs on a thread-pool worker whose ExecutionContext was
captured under suppression. Activity.Current starts null and stays null
through every await point, so background tasks spawned anywhere inside
StartAsync inherit a clean context.

Also drops the Limitation xmldoc since it no longer applies, and adds a
deep-async test (StartAsync_SuppressesFlow_WhenSpawnIsAfterAwait) proving
the fix holds past the first await.
@thomhurst
Copy link
Copy Markdown
Owner Author

Addressed in 81309cb:

#1 (sync-only suppression): Pushed further than the doc-note suggestion. Swapped the wrapper to combine SuppressFlow with Task.Run, so inner.StartAsync runs on a thread-pool worker whose ExecutionContext was captured under suppression. Activity.Current starts null on that thread and stays null through every await point — background tasks spawned anywhere inside StartAsync (before or after an await) inherit a clean context. Added StartAsync_SuppressesFlow_WhenSpawnIsAfterAwait covering the await ...; _ = Task.Run(...) pattern. Trade-off: always forces a thread-pool hop for hosted-service startup in tests — negligible vs. host build cost, and the thread-affinity case is vanishingly rare for hosted services.

#2 (asymmetric suppression): Added a comment on the stop-lifecycle methods explaining the intent — stop methods typically signal cancellation and await shutdown rather than spawning long-running background work, so Stop isn't the span-leak vector that Start is.

#3 (structural tests): Kept. The behavioral test (StartAsync_SuppressesFlow_IntoSpawnedTasks) is the primary assertion; the structural ones (RegisteredHostedServices_AreWrapped, IsolatedFactory_HostedServices_AreWrapped) verify a different question — did wrapping happen at all? — for the isolated-factory path where there's no outer Activity to assert against behaviorally. Both styles have value.

Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up Review

Previous review issues have all been addressed in commit 81309cb. Here's the updated assessment:


Previous Issues — Status

#1 (sync-only suppression) — Fully resolved

The fix went beyond the doc-note suggestion: RunOnCleanContext now uses Task.Run under SuppressFlow():

private static Task RunOnCleanContext(Func<CancellationToken, Task> op, CancellationToken ct)
{
    using var _ = ExecutionContext.SuppressFlow();
    return Task.Run(() => op(ct), ct);
}

This is the correct approach. Task.Run captures the execution context at scheduling time (when flow is suppressed), so the lambda and all its async continuations run with a clean context — covering the await ...; Task.Run(...) pattern that the previous version missed. The new StartAsync_SuppressesFlow_WhenSpawnIsAfterAwait test validates this.

#2 (asymmetric suppression) — Resolved

The comment on StoppingAsync/StoppedAsync is clear and states the intent explicitly.

#3 (structural tests) — Kept intentionally, reasoning is sound

The author's explanation is valid: the structural assertions verify wrapping actually happened in the isolated-factory path where there's no outer Activity to assert against behaviorally. Both test styles serve distinct purposes.


One New Minor Observation

FlowSuppressTestFactory and DeepAsyncFlowSuppressTestFactory have nearly identical ConfigureWebHost overrides — the only difference is the probe type. This is test-code duplication that has no real consequence, but if a third probe pattern is added later, it would be worth consolidating. Not a blocker.


Overall

The implementation is solid. The Task.Run-under-SuppressFlow approach is idiomatic and correctly handles all StartAsync shapes. The thread-pool hop for hosted service startup is the right trade-off in a test host: negligible cost, and hosted services with thread-affinity requirements are essentially nonexistent in practice. IHostedLifecycleService passthrough, the three descriptor-shape handling in WrapHostedServiceDescriptor, the opt-out mechanism, and the docs updates are all well-done. Ready to merge.

This was referenced Apr 22, 2026
BenjaminMichaelis pushed a commit to IntelliTect/EssentialCSharp.Web that referenced this pull request Apr 25, 2026
Updated [TUnit](https://github.com/thomhurst/TUnit) from 1.35.2 to
1.37.10.

<details>
<summary>Release notes</summary>

_Sourced from [TUnit's
releases](https://github.com/thomhurst/TUnit/releases)._

## 1.37.10

<!-- Release notes generated using configuration in .github/release.yml
at v1.37.10 -->

## What's Changed
### Other Changes
* docs(test-filters): add migration callout for --filter →
--treenode-filter by @​johnkattenhorn in
thomhurst/TUnit#5628
* fix: re-enable RPC tests and modernize harness (#​5540) by @​thomhurst
in thomhurst/TUnit#5632
* fix(mocks): propagate [Obsolete] and null-forgiving raise dispatch
(#​5626) by @​JohnVerheij in
thomhurst/TUnit#5631
* ci: use setup-dotnet built-in NuGet cache by @​thomhurst in
thomhurst/TUnit#5635
* feat(playwright): propagate W3C trace context into browser contexts by
@​thomhurst in thomhurst/TUnit#5636
### Dependencies
* chore(deps): update tunit to 1.37.0 by @​thomhurst in
thomhurst/TUnit#5625

## New Contributors
* @​johnkattenhorn made their first contribution in
thomhurst/TUnit#5628
* @​JohnVerheij made their first contribution in
thomhurst/TUnit#5631

**Full Changelog**:
thomhurst/TUnit@v1.37.0...v1.37.10

## 1.37.0

<!-- Release notes generated using configuration in .github/release.yml
at v1.37.0 -->

## What's Changed
### Other Changes
* fix: stabilize flaky tests across analyzer, OTel, and engine suites by
@​thomhurst in thomhurst/TUnit#5609
* perf: engine hot-path allocation wins (#​5528 B) by @​thomhurst in
thomhurst/TUnit#5610
* feat(analyzers): detect collection IsEqualTo reference equality
(TUnitAssertions0016) by @​thomhurst in
thomhurst/TUnit#5615
* perf: consolidate test dedup + hook register guards (#​5528 A) by
@​thomhurst in thomhurst/TUnit#5612
* perf: engine discovery/init path cleanup (#​5528 C) by @​thomhurst in
thomhurst/TUnit#5611
* fix(assertions): render collection contents in IsEqualTo failure
messages (#​5613 B) by @​thomhurst in
thomhurst/TUnit#5619
* feat(analyzers): code-fix for TUnit0015 to insert CancellationToken
(#​5613 D) by @​thomhurst in
thomhurst/TUnit#5621
* fix(assertions): add Task reference forwarders on
AsyncDelegateAssertion by @​thomhurst in
thomhurst/TUnit#5618
* test(asp-net): fix race in FactoryMethodOrderTests by @​thomhurst in
thomhurst/TUnit#5623
* feat(analyzers): code-fix for TUnit0049 to insert [MatrixDataSource]
(#​5613 C) by @​thomhurst in
thomhurst/TUnit#5620
* fix(pipeline): isolate AOT publish outputs to stop clobbering pack
DLLs (#​5622) by @​thomhurst in
thomhurst/TUnit#5624
### Dependencies
* chore(deps): update tunit to 1.36.0 by @​thomhurst in
thomhurst/TUnit#5608
* chore(deps): update modularpipelines to 3.2.8 by @​thomhurst in
thomhurst/TUnit#5614


**Full Changelog**:
thomhurst/TUnit@v1.36.0...v1.37.0

## 1.36.0

<!-- Release notes generated using configuration in .github/release.yml
at v1.36.0 -->

## What's Changed
### Other Changes
* fix: don't render test's own trace as Linked Trace in HTML report by
@​thomhurst in thomhurst/TUnit#5580
* fix(docs): benchmark index links 404 by @​thomhurst in
thomhurst/TUnit#5587
* docs: replace repeated benchmark link suffix with per-test
descriptions by @​thomhurst in
thomhurst/TUnit#5588
* docs: clearer distributed tracing setup and troubleshooting by
@​thomhurst in thomhurst/TUnit#5597
* fix: auto-suppress ExecutionContext flow for hosted services (#​5589)
by @​thomhurst in thomhurst/TUnit#5598
* feat: auto-align DistributedContextPropagator to W3C by @​thomhurst in
thomhurst/TUnit#5599
* feat: TUnit0064 analyzer + code fix for direct WebApplicationFactory
inheritance by @​thomhurst in
thomhurst/TUnit#5601
* feat: auto-propagate test trace context through IHttpClientFactory by
@​thomhurst in thomhurst/TUnit#5603
* feat: TUnit.OpenTelemetry zero-config tracing package by @​thomhurst
in thomhurst/TUnit#5602
* fix: restore [Obsolete] members removed in v1.27 (#​5539) by
@​thomhurst in thomhurst/TUnit#5605
* feat: generalize OTLP receiver for use outside TUnit.Aspire by
@​thomhurst in thomhurst/TUnit#5606
* feat: auto-configure OpenTelemetry in TestWebApplicationFactory SUT by
@​thomhurst in thomhurst/TUnit#5607
### Dependencies
* chore(deps): update tunit to 1.35.2 by @​thomhurst in
thomhurst/TUnit#5581
* chore(deps): update dependency typescript to ~6.0.3 by @​thomhurst in
thomhurst/TUnit#5582
* chore(deps): update dependency coverlet.collector to v10 by
@​thomhurst in thomhurst/TUnit#5600


**Full Changelog**:
thomhurst/TUnit@v1.35.2...v1.36.0

Commits viewable in [compare
view](thomhurst/TUnit@v1.35.2...v1.37.10).
</details>

[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=TUnit&package-manager=nuget&previous-version=1.35.2&new-version=1.37.10)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Auto-suppress ExecutionContext flow when starting IHostedService in TestWebApplicationFactory

1 participant