Repository navigation
StreamResyncGivesUpTest: a faulted stream reads USABLE for the whole of its own terminal delivery, so the cache hands the corpse to the consumer it just told — #4151's store-before-flag window (consolidates #4244; 6 occurrences) #4180
Description
The failure
MeshWeaver.Data.Test.StreamResyncGivesUpTest.AResyncThatNeverConverges_FaultsTheMirror_AndAFreshSubscriberRecovers, verbatim:
MeshWeaver.Reactive.Assertions.AssertionException :
Expected value to be false because a faulted mirror must be evicted from the stream cache,
not replayed to the next caller, but found True.
at StreamResyncGivesUpTest.cs(234,0)
Line 234 is ReferenceEquals(recovered, clientStream).Should().BeFalse(...): after the mirror faults, the test asks the workspace for the stream again and must get a NEW one. It got the same faulted instance back — the #2387 corpse-serving class, which StreamLiveness.IsUsable's own remarks describe as "the cache kept handing the corpse back for the whole process lifetime".
Where: merge-queue solo build of #4162, run 34750776579, job 103707204534 (Run tests (shard 1)), 2026-09-13 10:06:17Z. That build's tree is today's main (bd4314bc6, i.e. with #4151) plus #4162, whose diff touches neither streams nor the workspace cache.
Why this is #4151's territory, stated as mechanism rather than adjacency
Two facts, both read from main:
- The eviction contract keys on a FLAG.
StreamLiveness.IsUsablereturns false forIStreamLivenessSource { IsFaulted: true }, and onSynchronizationStreamthat isbool IStreamLivenessSource.IsFaulted => faulted— the private field.Workspace.GetRemoteStream's cache path hands a cached stream back wheneverIsUsable(stream)is true. - fix(layout): a faulted stream re-delivers its fault to a late GetStream subscriber (Plugins#1715) #4151 (
4ca53a078, 09:18Z) inverted when that flag is set relative to the terminal.FaultStorewasfaulted = true; Store.OnError(error);and is nowtry { Store.OnError(error); } finally { faulted = true; }— store-first, flag-second — for a stated reason: a late subscriber must find the terminal already in theReplaySubjectwhen the flag says "dead" (Plugins#1715).
ReplaySubject.OnError delivers to current subscribers synchronously, before returning, so between the terminal reaching a subscriber and the finally running, the stream reads faulted to its subscribers and USABLE to the cache. A consumer that reacts to the fault by asking for a fresh stream — which is exactly what this test does, and what the recovery path it pins exists for — can be handed the corpse in that window.
So #4151 may have traded one window for the other: flag-first left a subscriber inside the window with an open store and no terminal (#1715, closed); store-first leaves a reader inside the window with a "usable" corpse (#2387's shape, this failure).
What is NOT established — two live hypotheses
- (a) the production window above — the cache's eviction predicate was written for flag-first and fix(layout): a faulted stream re-delivers its fault to a late GetStream subscriber (Plugins#1715) #4151 made the flag trail the terminal.
- (b) the test reads at an instant the contract never promised — it fences on the terminal (
await faulted…Emit()), not on the eviction, so it may simply be asking one continuation too early. That is the same defect class as MeshWeaver#4162's own test, which CI caught this morning: an assertion pinning one side of a legal ordering.
They are not the same fix: (a) is a product change (set the flag before delivering AND keep the late-subscriber re-delivery, rather than choosing an order), (b) is a fence in the test.
The reading that decides it
At the failing ReferenceEquals, capture StreamLiveness.IsUsable(clientStream) (or ((IStreamLivenessSource)clientStream).IsFaulted) immediately before the GetRemoteStream call:
- reads usable / not faulted ⇒ (a): the cache was asked inside
FaultStore's store-before-flag window, and the fix is inFaultStore/ the liveness contract, not here; - reads unusable / faulted and the cache still returned the same instance ⇒ (a) at a different seam: the eviction path itself (
Workspace.GetRemoteStream's probe-then-lease, or whatever retains the entry), and the trail is that method; - the test can only ever observe usable at that point by construction ⇒ (b): the test must fence on the eviction rather than on the terminal.
One targeted run with that reading settles it; no further occurrence is needed.
Denominator, honestly
One observation, on one shard, in one queue build, since #4151 landed at 09:18Z. It passes locally on the same tree: --filter FullyQualifiedName~StreamResyncGivesUpTest 1/1, and the whole project MeshWeaver.Data.Test 512/512 (macOS, Release, origin/main bd4314bc6 merged into MeshWeaver#4162's branch). That makes it load-dependent — i.e. a race, i.e. a defect, not a flake to watch: it is filed rather than catalogued precisely because a green local run is what this shape looks like most of the time.
Related
- fix(layout): a faulted stream re-delivers its fault to a late GetStream subscriber (Plugins#1715) #4151 — the change whose ordering this questions; merged, so the candidate window is on
main. - A TYPED reply a tearing-down responder cannot send is dropped and the requester waits out its budget — two seams (post guard: 2 s; intake gate tier 2: 31 s) #4170 — the same family of "who is owed the terminal, and when" during teardown; different seam (a refused typed reply), listed so the two are not merged by resemblance.
- Default plugin install fails for one baseline package per pod at boot and defers repair to the next restart #2387 — the corpse-serving cache this assertion exists to prevent.
🤖 Generated with Claude Code
Activity
rbuergi commentedon Sep 13, 2026
2026-09-13 — reproduced locally, then a NULL result with the instrument. No hypothesis settled; correcting my own rate estimate.
1. It reproduces off CI, on pristine
mainDetached worktree at
origin/main5bf8bbadf, macOS, Release:AResyncThatNeverConvergesfailures-method "*StreamResyncGivesUp*"MeshWeaver.Data.Test(512 tests), one loop alone on the machineSo it is reproducible without CI and without any PR's diff, which also settles an attribution question: MeshWeaver#4191's red on this test was this defect, not its own change (#4191 touches
MessageServiceteardown classification and its docs — nothing near the stream cache). That tree did not contain #4191.2. The instrument, built to #4180's own specification
Rather than reading
IsUsablefrom the test — which sits inside the window it is measuring — the reading was moved to where the events happen:FaultStore: three monotonic stamps — entering,Store.OnErrorreturned, and thefinallywritingfaulted;StreamLiveness.IsUsable: every consult recorded on the stream (count, first/last timestamp, first/last result), so it covers all fourWorkspaceconsult sites and can also report "the cache never consulted at all", which would move the trail.Four readings were spelled out in advance so the first reproduction would settle it rather than start an argument: a consult stamped between
onErrorReturnedandflagWrittenreturningusable=trueis (a) atFaultStore's window;usable=falsewith the same instance still handed back is (a) at the eviction seam; consults only ever afterflagWrittenreturning true means the flag is not what decided it (IsUsablewalks the reduce chain, so an ancestor could); no consult at all means the cache path does not go through this predicate.3. The null, with both denominators
Instrumented tree and an uninstrumented control built from the identical commit
5bf8bbadf, run concurrently so the comparison is same-machine, same-minute:FaultStore+IsUsablestamps)🚨 The control did not reproduce either. That is the finding. It exonerates the instrument — the earlier worry that a probe might close the window it measures is not supported, because the tree without the probe behaved identically — but it means no reading was obtained and none of the four cases is settled.
4. What changed between the profile that reproduces and the one that does not
The only profile that has ever reproduced here is one full-project loop alone on the machine. The null came from two full-project loops running concurrently. Those are different load profiles, and load is the variable this race is known to turn on — so the concurrent pair, chosen to make the control fair, plausibly changed the very condition that drives it. I am flagging that as a hypothesis, not a conclusion: I did not re-test the single-loop profile with the instrument, because one implementation and one loop was the agreed budget and a third loop would be fishing.
5. 🚨 Correcting a number I published earlier
I described this as "~17%" off the 1-in-6 reading and reasoned from it that a merge-queue build clears it most of the time. 1 of 6 was too small a sample to carry that number. Pooling every uninstrumented full-project run today gives 1 failure in 46 (6 + 40) ≈ 2%, with a wide interval. The qualitative conclusion survives and in fact strengthens — at 2% a queue build almost always clears it, so being red on this defect does not reliably hold a PR (MeshWeaver#4191 merged on a queue build that passed) — but the figure I gave was wrong and anything reasoned from "17%" should be re-derived.
What the next person should do
The instrument is the right one and is cheap to rebuild from the description above (three field writes in
FaultStore, one recording call inIsUsable, a temporaryIDbg4180interface onSynchronizationStream<TStream>). Run it under the profile that actually reproduces — a single full-project loop, alone on the machine — rather than against a concurrent control. One reproduction gives the four-way reading and settles (a) versus (b) outright.The test is unchanged; I did not adapt it to pass, and I did not pick a hypothesis from silence.
rbuergi commentedon Sep 14, 2026
Settled: outcome 1 —
FaultStore's store-before-flag window. Reproduced deterministically, not sampled.#4244 is this issue and is closed into it; every occurrence either issue knows about is tabulated below.
The reading
The four outcomes were named in advance. It is the first: a consult inside the delivery, before the flag is written, returning
usable=true. It did not need a lucky loop — the same defect is reachable at 100% through the other production shape, a consumer whoseOnErrorarm runs on the delivering thread:That is
StreamResyncGivesUpTest's assertion, verbatim, off a test whose only act is: subscribe, and in theOnErrorarm ask the workspace for the stream again — the documented recovery path (#2387, "the next natural caller opens a fresh one"). It gets back the instance it was just told had died.Why the 2% version is provably the same defect
ReferenceEquals(recovered, clientStream)can only be TRUE byWorkspace.GetExternalClientSynchronizationStreamfindingStreamLiveness.IsUsable(stream)TRUE at its consult — every other route through that method mints a new instance (afalseverdict evicts and loops; a freshly-built dead stream is a different object). And once the terminal has been delivered — which the test awaited — the only state in which the flag still reads live is the window betweenStore.OnErrorand thefinallythat writes it. So the failure message is the reading.StreamResyncGivesUpTestlands in that window becauseReactiveWait.Firstsettles itsTaskCompletionSourcewithRunContinuationsAsynchronouslyfrom inside the delivery: the awaiting test thread is queued right there, and then races the producer's remaining few microseconds of unwinding to thefinally. The producer usually wins. Hence ~1 in 46, and hence it reading as a flake — it is not one, and hypothesis (b) ("the test reads at an instant the contract never promised") is refuted: the contract is what was wrong.Why it is not "swap the order back"
Two facts must hold at the same instant, and a
boolbeside the store can only hold one — each order is the other's window:completed, and the fault reached nobody — Plugins#171572c2ee2a56)The second is the larger window by construction: it is not an interleaving, it is the delivery itself, which is exactly when consumers act.
The fix publishes the terminal instead of a bit.
FaultStorerecords the exception (Interlocked.CompareExchange, so the record names the same FIRST error theReplaySubjectkeeps) and only then errors the store;IsFaultedis "that record is not null". One write, no ordering. A reader refused byIsUsablereadsstream.TerminalFault()and forwards that exact instance instead of probing a store that may not have terminated yet —LayoutExtensions.GetStream<T>now does, which closes Plugins#1715 without depending on an ordering at all.Every occurrence (this issue + #4244)
Plugin.Build/*,Plugin.Packaging/*, two Graph tests, one docA six-line localization diff cannot change whether a resync converges, which is what removed "the PR perturbed it" as a reading.
One correction to this issue's own framing
It did, and the resolution is neither order. Note also that #4151's pin
AFaultingStream_PublishesItsTerminal_BeforeItReadsAsDeadassertedIsUsable() == trueduring the delivery — i.e. the failing assertion here and that green pin were direct contradictions of each other, in the same tree. It is replaced byAFaultingStream_ReadsAsDead_AndNamesItsTerminal_FromTheFirstDelivery, which asserts both halves at that one instant.Fix: #4255. No
known-flakes.jsonentry — a race is a defect.[-]StreamResyncGivesUpTest: a faulted mirror was handed back by the stream cache — #4151's store-before-flag ordering opens a window where a faulted stream still reads USABLE (1 observation, load-dependent)[/-][+]StreamResyncGivesUpTest: a faulted stream reads USABLE for the whole of its own terminal delivery, so the cache hands the corpse to the consumer it just told — #4151's store-before-flag window (consolidates #4244; 6 occurrences)[/+]