Repository navigation
CoreTests: 20 tests fail when run as a filtered subset — IntegrationContext disposes a class fixture it doesn't own #3423
Description
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 failedFailing 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":
NullReferenceExceptionatDefaultApp.ChainFor<T>()—IntegrationContext.cs:62, i.e.Hostis nullIndeterminateRoutesException : 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
- 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.
- 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.
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
jeremydmiller commentedon Jul 14, 2026
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 theIClassFixture<DefaultApp>it doesn't own, andRecycleIfNecessary()was silently substituting a differently-configuredWolverineHost.Basic(). That was genuinely causing theNullReferenceExceptions out ofDefaultApp.ChainFor<T>(), and it was also leaking every host built bywith(...). 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, soWolverineRuntime.findInvoker(WolverineRuntime.cs:306-311) falls through to routing and throws. TheIndeterminateRoutesExceptionthe tests report is a downstream symptom; the real event is logged and swallowed atWolverineRuntime.cs:315("Failed to create a message handler for {MessageType}").The trigger is specific and deterministic: running a
StorageActionCompliance-derived class before anotherIntegrationContextclass leaves the later host unable to discover handlers conventionally.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'sopts.Discovery.DisableConventionalDiscovery()leaking into hosts built later in the process. It does not. I built a host withDisableConventionalDiscovery(), disposed it, then built a plainUseWolverine(opts => {})host in the same process — conventional discovery worked normally and found the handler. SoDisableConventionalDiscoveryis not itself leaky, and there's no static state inHandlerDiscoverythat 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
StorageActionCompliancehost fail conventional discovery. Happy to keep digging if it's worth the time, but I didn't want to keep grinding without checking in.Pin the application assembly in the CoreTests harness (GH-3423)