Skip to content

CoreTests: 20 tests fail when run as a filtered subset — IntegrationContext disposes a class fixture it doesn't own #3423

Closed

Description

@jeremydmiller

Symptom

20 tests across 4 classes fail when CoreTests is run with a CoreTests.Persistence filter, but pass individually and in the full-assembly run. CI is green, so this is currently invisible there — it bites developers running a scoped subset locally.

# 20 failures
dotnet test src/Testing/CoreTests/CoreTests.csproj --framework net9.0 \
  --filter "FullyQualifiedName~CoreTests.Persistence"
# => Failed: 20, Passed: 136

# same 20 tests, in isolation: all green
# full assembly: 1931 passed, 0 failed

Failing classes:

  • Persistence.ClaimCheck.end_to_end_round_trip (4)
  • Persistence.ClaimCheck.local_queue_round_trip (2)
  • Persistence.Sagas.saga_action_discovery (10)
  • Persistence.Sagas.saga_identity_precedence (2)
  • Persistence.using_multiple_storage_actions (2)

Two failure signatures, both consistent with "the host under test is not the host the test expects":

  • NullReferenceException at DefaultApp.ChainFor<T>() — IntegrationContext.cs:62, i.e. Host is null
  • IndeterminateRoutesException : Could not determine any valid subscribers or local handlers for message type ...

Root cause (I'm confident about the defect; less so that it's the only contributor)

DefaultApp is an xUnit class fixture — xUnit owns its lifetime:

public class IntegrationContext : IDisposable, IClassFixture<DefaultApp>

But IntegrationContext.Dispose() disposes it anyway, after every test method (IntegrationContext.cs:85-88):

public virtual void Dispose()
{
    _default.Dispose();   // <-- disposing a fixture this class does not own
}

and DefaultApp.Dispose() nulls the host (IntegrationContext.cs:46-50):

public void Dispose()
{
    Host?.Dispose();
    Host = null!;
}

The existing guard papers over it — and that's the part that actually bites (IntegrationContext.cs:52-58):

public void RecycleIfNecessary()
{
    if (Host == null)
    {
        Host = WolverineHost.Basic();   // <-- NOT the DefaultApp configuration!
    }
}

WolverineHost.Basic() is not the host DefaultApp's constructor builds. It has none of IncludeType<MessageConsumer>(), IncludeType<InvokedMessageHandler>(), the RegisterMessageType(...) calls, or the IAdditionService / IIdentityService / ITrackedTaskRepository registrations. So once the fixture has been disposed once, every subsequent test in that class silently runs against a differently-configured host with fewer registered handlers — which is exactly what IndeterminateRoutesException and a null-chain NRE look like.

Whether a given test survives therefore depends on test ordering, which is why the full-assembly run is green and the filtered subset is not. The full run is passing by luck, not by construction.

Why it's worth fixing rather than ignoring

  1. Anyone running a scoped subset (very normal while working on persistence) gets 20 spurious failures and has to learn to disregard them — which is exactly how a real regression gets waved through. I hit this myself while working on Adaptive connection-budget awareness: probe server max_connections/numbackends and back off daemon polling/concurrency under pressure (CritterWatch-surfaced) #3397 and had to baseline against a clean tree to confirm the failures weren't mine.
  2. CI's green is order-dependent. Adding, removing, or reordering an unrelated test could turn these red with no related change, and the cause would be extremely non-obvious.
  3. RecycleIfNecessary() substituting a differently-configured host means some tests may currently be asserting against a host they were never meant to run on — i.e. they could be passing (or vacuously passing) for the wrong reason.

Suggested fix

Stop disposing the fixture from the test class. IntegrationContext.Dispose() should not call _default.Dispose() at all — xUnit disposes an IClassFixture when the class finishes. That should let RecycleIfNecessary() and the Host = null! dance be deleted outright, rather than kept as a band-aid over a lifetime violation.

Worth checking, once the fixture stops being torn down mid-class, whether any test was quietly depending on getting a fresh/basic host between methods.

Happy to put up the PR if the approach looks right.

Repro'd on main @ e617d39, net9.0, macOS.

Activity

  1. jeremydmiller commented on Jul 14, 2026

    @jeremydmiller
    MemberAuthor

    Correction: my root-cause analysis above was only partly right

    I implemented the suggested fix and it accounts for 1 of the 20 failures, not all of them. Correcting the record so nobody re-runs my dead end.

    What was right

    The fixture-ownership violation is real and is now fixed in #3427: IntegrationContext.Dispose() was disposing the IClassFixture<DefaultApp> it doesn't own, and RecycleIfNecessary() was silently substituting a differently-configured WolverineHost.Basic(). That was genuinely causing the NullReferenceExceptions out of DefaultApp.ChainFor<T>(), and it was also leaking every host built by with(...). Worth fixing on its own.

    What was wrong

    I assumed that explained all 20. It doesn't — 19 survive the fix, and they have a different cause.

    The actual remaining bug

    It is not a fixture/ordering problem. HandlerGraph.CanHandle(messageType) returns false, so WolverineRuntime.findInvoker (WolverineRuntime.cs:306-311) falls through to routing and throws. The IndeterminateRoutesException the tests report is a downstream symptom; the real event is logged and swallowed at WolverineRuntime.cs:315 ("Failed to create a message handler for {MessageType}").

    The trigger is specific and deterministic: running a StorageActionCompliance-derived class before another IntegrationContext class leaves the later host unable to discover handlers conventionally.

    # green — 2 passed
    dotnet test src/Testing/CoreTests/CoreTests.csproj --framework net9.0 \
      --filter "FullyQualifiedName~CoreTests.Persistence.using_multiple_storage_actions"
    
    # 2 failures
    dotnet test src/Testing/CoreTests/CoreTests.csproj --framework net9.0 \
      --filter "FullyQualifiedName~CoreTests.Persistence.using_multiple_storage_actions|FullyQualifiedName~CoreTests.Persistence.using_storage_return_types_and_entity_attributes"
    
    # still green when paired with a *non*-compliance IntegrationContext class
    dotnet test src/Testing/CoreTests/CoreTests.csproj --framework net9.0 \
      --filter "FullyQualifiedName~CoreTests.Persistence.using_multiple_storage_actions|FullyQualifiedName~CoreTests.Persistence.ClaimCheck"

    So it's the compliance class specifically, and the poisoning flows forward: the compliance class runs first and passes; the class after it fails.

    A hypothesis I disproved, so you don't retry it

    The obvious suspect is StorageActionCompliance's opts.Discovery.DisableConventionalDiscovery() leaking into hosts built later in the process. It does not. I built a host with DisableConventionalDiscovery(), disposed it, then built a plain UseWolverine(opts => {}) host in the same process — conventional discovery worked normally and found the handler. So DisableConventionalDiscovery is not itself leaky, and there's no static state in HandlerDiscovery that obviously explains it.

    Something else about the compliance class — it's IAsyncLifetime, so it builds and tears down a fresh host per test method (17 of them) — is the remaining difference I hadn't chased when I stopped.

    Status

  2. added this to the 6.18.1 milestone on Jul 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions