Description
Run 33937167117 (PR #3332, head 4c98498a) failed two tests in two different shards. Both are teardown/disposal races, neither is reachable from that PR's diff, and the base rate says they are not routine — so they want an owner rather than a re-run.
The two failures
1. MeshWeaver.PluginTester.Test.BakeEquivalenceTest.MeshDrivenAndCompilerDrivenBakes_ProduceTheSameArtifacts (shard 3, check)
Assert.Null() Failure: Value is not null
Actual: "InvalidOperationException: bake: 'Widget/Thing' cl"···
at BakeEquivalenceTest.MeshDrivenAndCompilerDrivenBakes_ProduceTheSameArtifacts() … line 225
The message is truncated by xUnit, but the job log says what happened. The compiler-driven bake completed both packages. The mesh-driven bake wrote Lib.zip and then died on Widget, and the next line in the log is the cause:
warn: MeshWeaver.Messaging.MessageService[0] Message delivery failed for CreateNodeResponse
(ID: KFydCE9clUadxTFrgWsW8Q) in portal/nodeops-O0ixdCgnbkawTtQz-bUBQw:
Hub portal/nodeops-O0ixdCgnbkawTtQz-bUBQw cannot route CreateNodeResponse to Widget/Thing —
its parent hub mesh/O0ixdCgnbkawTtQz-bUBQw is shutting down (RunLevel=DisposeHostedHubs).
The mesh began tearing down while the bake still had work in flight. That is the same hub and the same phase as #2543 ("Bake wedge: portal/nodeops stops draining mid-bake") — likely the same defect surfacing as a failed assertion instead of a wedge.
2. MeshWeaver.Hosting.Test.ChangeFeedResetReleasesUpstreamTest.ControlArm_ReleaseIfUnwatched_OfTheSameFaultedEntry_DisposesAnUpstream (shard 4, check)
AssertionException : Expected value to be true because nothing is subscribed to the faulted
entry, so the release must claim and tear it down, but found False.
at ChangeFeedResetReleasesUpstreamTest…() … line 178
The control arm of the test added in 32714b2 (2026-09-02). It asserts that releasing an unwatched faulted entry claims and disposes its upstream; something was still holding it. Same family — a release/dispose that did not happen when it was supposed to.
Why this is not the PR that surfaced it
PR #3332 changes probe endpoints, three files under memex/, the Helm chart, docs, and adds two test files. It contains zero src/**.cs changes, and neither failing project references anything it touches:
| failing project | its ProjectReferences |
|---|---|
MeshWeaver.PluginTester.Test |
PluginTester, Fixture, ComboVerifier, Security.MeshTest, Plugin.Build |
MeshWeaver.Hosting.Test |
MeshWeaver.Hosting, Fixture, Hosting.Monolith.TestBase |
Neither references Memex.Portal.ServiceDefaults or Memex.Portal.Shared, the only assemblies whose code changed. The PR's own 7 new tests all passed (shard 5, +7 ✅).
Base rate — this is not a routine flake
The 13 completed MeshWeaver Build and Test runs immediately before this one were all success (main, three gh-readonly-queue builds, and three other PR branches, 2026-09-04T23:19Z → 2026-09-05T00:21Z). .github/known-flakes.json is empty, so the merge-queue steward will not re-queue on either signature, and correctly so: nothing here is catalogued.
Two independent teardown races failing in the same run against a 0/13 base rate is worth a look, not a re-run.
What I deliberately did not do
I did not re-run the tests and I did not re-push to get a clean run — a flake is a real race, and clearing the red would have destroyed the evidence above. PR #3332 stays unmerged with auto-merge armed.
Refs #2543 · surfaced by #3332 · commit 32714b2 added the control arm
Activity
rbuergi commentedon Sep 5, 2026
Half 1 (
ChangeFeedResetReleasesUpstreamTest) is root-caused and fixed — and half 2's stated cause is backwardsThe change-feed arm: the test measured its own assertion
ReleaseIfUnwatchedrefuses an entry that still has a live subscriber (Entry.TryMarkIdleEvictedreturns false while
subscribers > 0). At line 178 the only subscriber left was the assertion online 124 — the one that had just returned.
ReactiveWait.Firstdisposed its subscription as a continuation on the settled task, and acontinuation structurally cannot get there first:
RunContinuationsAsynchronously, soTrySetResultqueues the awaitingtest immediately;
…Take(1).ToList().Timeout(…), andTake(1)disposes its upstream frominside
ForwardOnCompleted— i.e. after it has pushed the value down into the handler.So the unsubscribe was always last, and the test resumed while still attached. On a quiet machine the
producer's remaining few instructions win; under six concurrent shards they don't. That is the whole
flake, and it was latent for every assertion in the fleet whose subject is "is anything still
watching this?" — not just this test.
Fix:
ReactiveWait.Firstnow disposes inside the handler, ahead of the settle, so "the waitsettled" implies "the wait unsubscribed". A source that emits synchronously during
Subscribeiscovered by construction — the caller has no task to await until
Firstreturns, andSingleAssignmentDisposabledisposes on assignment before that.Guarded by
AssertionUnsubscribesBeforeItSettlesTest, which fails on the unpatched code(
Expected value to be 0 … but found 1) and passes on the fix. It widens the window deliberately —the real one is a handful of instructions, so a guard written without that would have passed while
the defect was present.
Verified:
MeshWeaver.Hosting.Test350/350.The bake arm: the shutdown is a CONSEQUENCE, ~110 ms too late to be the cause
The quote above (
… mesh/O0ixdCgnbkawTtQz-bUBQw is shutting down (RunLevel=DisposeHostedHubs)) is theharness's own
.Finally(harness.Dispose)firing because the run had already faulted. From theshard-3 job log:
01:51:46.593ok Widget/Thing [compile:Passed render:Passed tests:Skipped]01:51:46.606Actual: "InvalidOperationException: bake: 'Widget/Thing' cl"···← the test has already failed01:51:46.716… is shutting down (RunLevel=DisposeHostedHubs)01:51:48.719[QUIESCE-TIMEOUT] Widget/Thing: 2 callback(s) still pendingTeardown starts 110 ms after the assertion failed. So this is not #2543, and anyone starting
from "a mesh shutting down mid-bake" will chase a symptom. Sorry — that was my reading, and the
timestamps do not support it.
What actually happened is a read-before-write, 13 ms wide:
BakeOutput.CollectOnedoesworkspace.GetMeshNodeStream(typePath).Where(n => n is not null).Take(1),reads
LastCompiledVersion, then asksIAssemblyStore.TryGetAssemblyPath(path, version)and throws… claims a usable build at v{version} but the run's assembly store has NO bytes for it. The nodeadvertised a version the store did not yet hold. The same log shows the post-compile chain still in
flight at that instant —
CREATE_CHAIN_SUBSCRIBED path=Widget/Thing/Release/20260905015146-qRmi8PqMwas one of the two callbacks the quiesce timeout later named, and
[ReleasePostCondition] Widget/Thing: … this compile succeeded, yet the node names NO release at allfired for the same node.
Note the shape is the same family as the arm above: something read a state it had not observed
settle.
Take(1)on a live node stream takes whatever emission is in hand, which is not necessarilythe one the compile just produced.
Two things a fix has to decide, and I have not:
LastCompiledVersionmay become visible on the node before the store holds the bytesfor it (an ordering defect in the compile chain), or
CollectOnemust wait for the version it was told to bake rather thanTake(1)-ing thefirst non-null node (a read defect in the bake).
FileSystemAssemblyStore.TryGetAssemblyPathalso keys onv{version}-{FrameworkTag}-*.dll, so athird possibility is a tag mismatch — but both bakes in this run printed the same
framework=gc4e2d62fdbadec41fd25bdacce82572243a2e911, so that one is ruled out for this failure.Leaving the bake half open with the corrected reading. The change-feed half is fixed.
rbuergi commentedon Sep 5, 2026
Bake arm — two more hypotheses ruled OUT, and the one that survives
Recording the negative results so nobody re-walks them.
❌ Cross-test eviction through the shared pid-scoped store. This looked compelling: the default
IAssemblyStore(PersistenceExtensions.RegisterDefaultAssemblyStore) roots at{temp}/MeshWeaver-AssemblyStore-pid{ProcessId}— per process, not per mesh — and shard 3 runsfive tests that each compile a node called
Widget/Thingin the same host(
BakeAgainstPlatformHostTest,BakeOutputTest, twoBakeGateSplitTestmethods,BakeEquivalenceTest), all landing in the sameWidget/Thingdirectory with low, colliding versionnumbers.
FileSystemAssemblyStore.EvictSupersededVersionskeeps only the top 3 versions per(type, framework tag) and protects only the file the current
Putjust published — so one test'sPutdeleting another's still-referenced bytes is exactly the right shape.It is not what happened:
PluginGateRunnerreplaces that store per run —so each gate run has its own root and cannot see another test's versions.
❌ Framework-tag mismatch.
TryGetAssemblyPathmatchesv{version}-{FrameworkTag}-*.dll, so atag change between
PutandGetwould miss with bytes present — but both bakes in this run printedthe same
framework=gc4e2d62fdbadec41fd25bdacce82572243a2e911.✅ What survives: a stale node read meeting the store's own retention, both inside one run.
BakeOutput.CollectOnereadsworkspace.GetMeshNodeStream(typePath).Where(n => n is not null).Take(1)..Take(1)takes whatever the cache'sReplay(1)has in hand, which is not necessarily therevision the compile that just finished produced.
ApplyCompileSuccessstampsLastCompiledVersion = result.Version ?? currentNodeVersion— its own comment warns that amismatch here "would point activation at a store key that has no bytes".
Putpublished is protected. A type that gets four or morePuts in a run loses its earliest.A stale revision naming an evicted version produces exactly
claims a usable build at v{version} but the run's assembly store has NO bytes for it, and needs enough compiles of one type in one run tobite — which fits how rarely it does.
That points the fix at (1) rather than at teardown:
CollectOneshould wait for the node revisionwhose compile it is baking, not
Take(1)the first non-null one. Same family as the arm fixed in#3336 — something read a state it had not observed settle — but a separate defect in separate code,
and I have not proved step 3 fires here (it needs a
Putcount per run, which I have not measured).Not claiming it as root-caused.
rbuergi commentedon Sep 5, 2026
Bake arm — eviction is out too. Measured, not reasoned.
Ran
BakeEquivalenceTestlocally (green) and looked at what the gate's own per-run store actuallycontains afterwards:
Exactly one version per type.
EvictSupersededVersionskeeps 3, so with a singlePutper typeper run it never fires — the retention hypothesis from my previous comment is ruled out, in-run as
well as cross-test.
That is actually the useful result, because it removes the last way "bytes existed but were not
found" could be true. In the failing run the store held one version for
Widget/Thing, and thenode named a different one. So:
which is the exact hazard
ApplyCompileSuccessdocuments against itself:The
??is the whole exposure. Whenresult.Versionis present it is the kickoff version and matchesthe
Put. When it is null, the stamp falls back tocurrentNodeVersion— the node's version atstamp time — and the node has been written since (the failing run shows
Widget: installed (9 written…)plus aCreateOrUpdateNodeRequestfromCompileStateMirrorand aCreateNodeRequestforthe Release node, both still pending at teardown). A node that has moved on produces a stamp naming a
key the store never held.
So the question for whoever picks this up is narrow: under what conditions does
UploadToStoreIfNeededreturn a result with noVersion, and is falling back tocurrentNodeVersionever correct? Given the comment's own reasoning — the fallback is only right if the node has not
been written since kickoff, which nothing enforces — a stamp with no store version looks like it
should be a failure to stamp Ok, not a guess.
Note the bake's postcondition is behaving correctly throughout: it refuses to write a bundle that
ships less than the gate verdict claims. It is reporting a real inconsistency, not inventing one, and
the same inconsistency would send a portal's activation to a store key with no bytes.
Summary of what is now excluded, with evidence:
RemoveAll<IAssemblyStore>()and roots per runframework=g c4e2d62…LastCompiledVersion≠ the versionPutusedrbuergi commentedon Sep 5, 2026
Both halves are owned, fixed and guarded on
mainThis issue asked for "an owner rather than a re-run" for two teardown/disposal races. Both have
one now, each with a root cause, a merged fix and a guard that fails without it.
1.
ChangeFeedResetReleasesUpstreamTest— the test measured its own assertion.ReactiveWait.Firstdisposed its subscription as a continuation on the settled task, so the unsubscribe was structurally
always last and the test resumed while still attached. It now disposes inside the handler, ahead
of the settle, so "the wait settled" implies "the wait unsubscribed". Guarded by
AssertionUnsubscribesBeforeItSettlesTest(present onmain), which widens the window deliberately— the real one is a handful of instructions, and a guard written without that would pass while the
defect was present. This was latent for every assertion in the fleet whose subject is "is
anything still watching this?", not just this test.
2.
BakeEquivalenceTest— the store address was not moving as one. #3341, merged. The threefields that address one blob each fall back to the definition's previous value — except
LastCompiledVersion, which fell back to the node's version at stamp time:result.Versionis set in exactly one place — the projection over a successfulPutWithLocation—so a null means nothing was stored, and the old fallback produced old collection + old path +
new version: a key the store never held.
NodeTypeBatchBakestates the invariant it broke in asmany words ("LastCompiledVersion always names a store key that has bytes"). Because the stamp
reads from the same projection that performs the
Put, this was the only way the recordedversion could disagree with the stored one — the single row this issue's elimination table left
standing after teardown, cross-test eviction, in-run retention and framework-tag mismatch were each
excluded by measurement.
The existing test had pinned the defect: named
…AndFallsBackToTheNodeVersion, asserting theversion jumps while asserting two lines below that the collection and path do not. Rewritten, plus a
case for "nothing ever uploaded" staying null. Mutation-proved. Committed to
Doc/Architecture/NodeTypeCompilationas "The store address is a TRIPLE, and it moves together ornot at all".
What is NOT proven, stated plainly
That the store-address fix is what made that particular run fail. The failing run's store is
gone, so whether it took the no-upload path cannot be established post-hoc. What is established is
that the shape existed, that it was the only remaining mechanism for the mismatch this issue
narrowed to, and that it is now closed and guarded.
Closing on the issue's own terms — both races have owners and merged fixes — rather than on a clean
run, which at this base rate would prove nothing either way. Reopen if
BakeEquivalenceTestfailsagain with a
bake: '<Type>' cl…refusal: that would mean a second mechanism exists, and theelimination table above is the right place for whoever picks it up to start.
rbuergi commentedon Sep 5, 2026
Recurrence — second instance, and this one is blocking a release PR
BakeEquivalenceTest.MeshDrivenAndCompilerDrivenBakes_ProduceTheSameArtifactsfailed again on#3366 ("release: 3.0.0 is cut first, then the line moves to 3.1.0"), shard 3, with the
identical signature:
Widget/Thing, same truncated message, same assertionTwo instances ~20 h apart on unrelated PRs (a probe-endpoint change and a release cut) means this is
a standing flake in
MeshWeaver.PluginTester.Test, not something either diff reached. It is now onthe critical path: a release PR cannot merge through it.
Where the diagnosis already stands
From the earlier comments on this issue, four hypotheses are eliminated by measurement, not
argument:
RemoveAll<IAssemblyStore>()and roots per runframework=gc4e2d62…What survives:
NodeTypeDefinition.LastCompiledVersionnames a version the assembly store neverheld. The exposure is one line in
ApplyCompileSuccess:Its own comment says the stamp must MATCH the version
UploadToStoreIfNeededused. Whenresult.Versionis present it does. When it is null, the fallback takes the node's version atstamp time — and the node has been written since (the failing run shows
Widget: installed (9 written…)plus aCompileStateMirrorCreateOrUpdateNodeRequestand theRelease-node
CreateNodeRequeststill pending at teardown). A node that has moved on yields a stampnaming a store key that was never written.
The question to answer first: under what conditions does
UploadToStoreIfNeededreturn a resultwith no
Version? If the answer is "when the upload did not happen", then falling back tocurrentNodeVersionis stamping success onto bytes that do not exist, and the correct behaviour isto fail the compile rather than guess a version — the bake's postcondition is right to refuse it.
I have not proved that last step, which is why this is still open rather than fixed.
rbuergi commentedon Sep 5, 2026
The bake arm is fixed on
main—8614af280, "The store address moves together: stamp the versionthe upload used, or keep the previous one (#3333)". Recording it here so nobody re-derives it from
my earlier comments, which stopped one step short.
My narrowing was right about where —
result.Versionis set in exactly one place, the projectionover a successful
PutWithLocation, so a null means nothing was stored rather than "we forgotthe version". But I framed the damage as "the version names a key with no bytes." The commit's
reasoning is sharper:
The three fields that address a blob (
LatestAssemblyCollection,LatestAssemblyPath,LastCompiledVersion) all fall back to their previous value except the version, which fell back tothe node's version at stamp time. So two thirds of the address stayed put while the third moved.
Keeping the previous value keeps the triple coherent, and a null stays honest because every reader
already applies its own
?? node.Version— the guess lives at the read, where it is a heuristic,instead of frozen into the node as a claim.
Timeline consistent with the sightings: the two failures (#3332 ~01:52Z, #3366 ~21:26Z) both ran
against trees predating this, and #3366's re-run went green afterwards.
Worth keeping from the investigation, since the eliminations cost real measurement:
RemoveAll<IAssemblyStore>()and roots per runLastCompiledVersionnaming a key never written8614af280Suggest closing this arm.
BakeEquivalenceTestshould now be watched only for a third sighting ona tree that contains the fix; that would mean a different cause.
rbuergi commentedon Sep 5, 2026
I said the bake arm was fixed by
8614af280and suggested closing. A third sighting says otherwise,and it is the exact case I named as the discriminator.
#3369 (a one-file docs PR —
docs: a head-truncated view of ordered output is biased) failedBakeEquivalenceTest.MeshDrivenAndCompilerDrivenBakes_ProduceTheSameArtifactson shard 3 at22:31:55Zwith the identical message:And its tree contains the fix:
So
LastCompiledVersion = result.Version ?? def.LastCompiledVersiondid not resolve it. Either theincoherent-triple defect was real but not the only cause, or it was never the cause of this test's
failure at all.
What I got wrong. I inferred the fix worked from #3366's green re-run. That was a single passing
run of an intermittent test — the same "a flake's passing re-run proves nothing on its own" trap I
have been careful about all night, and I walked into it because the fix's reasoning was persuasive
and matched my own narrowing. A passing run of a flaky test is not evidence a fix landed; only a
failing run on a fixed tree is decisive, and that is what #3369 now provides.
Corrected state — three sightings:
8614af280?Unrelated diffs each time (probe endpoints, a release cut, a docs page), so nothing about those PRs
reaches it. It is a standing flake in
MeshWeaver.PluginTester.Test, still open, and it has nowtouched a release PR once.
The four eliminations in my earlier comments still hold and are still worth not re-deriving. What is
no longer eliminated is the store-key question: the fix addressed one way for the triple to go
incoherent, and the test says there is another. The next decisive step is the one I never took —
capture the full exception text (xUnit truncates at
'Widget/Thing' cl), which names the versionit claims and lets that be compared against what the run's store actually holds. The message exists
in the run's own log; it just needs reading rather than inferring from the assertion summary.