From 226db1805405ed0da7ee10c32757c1abb4f1bdb6 Mon Sep 17 00:00:00 2001 From: Sylvain Duchesne Date: Thu, 24 Sep 2026 13:32:44 +0200 Subject: [PATCH] fix: une barriere appartient a la tentative qui l'a produite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seconde revue adverse, sur le correctif precedent - et elle a raison : celui-ci MASQUAIT la barriere perimee au lieu de l'oublier. `reportSetupFailure` effacait `establishing` et laissait `lastState`. Le premier correctif avait ajoute « et un canal tient » du cote du LECTEUR, ce qui ne cachait la barriere morte que tant que rien ne tenait. La souscription suivante sur le meme fan-out remettait `establishing` a vrai, le masque tombait - et le fan-out rejouait ce `State` au nouvel arrivant. Mesure : l'ouverture bootstrap franchissait sa barriere en 2 ms sur un `State` d'un appel qui avait ECHOUE, journalisait `BARRIER ... synced`, et `getSyncState` repondait `"synced"` - donc autorite, donc `noRowsMeansAbsent` prenait une lecture a zero ligne pour une absence definitive. Le depot detruit que le commit precedent pretendait empecher. Corrige a la source : une tentative qui echoue oublie sa barriere, comme le font deja `releaseFanOut` et `resubscribeDocs`. Le garde du lecteur est RETIRE avec, et pas garde en ceinture-bretelles : il etait devenu inatteignable, et c'est lui qui avait cache la faute. Apres : la barriere est oubliee, l'ouverture attend la sienne (202 ms), `"timed-out"`. Et le test que j'avais ecrit affirmait le defaut - `barrierCrossed` vrai - au lieu de l'etat correct. Corrige, plus un second test qui conduit le rejeu de bout en bout. --- .../src/emulated-verifier/open-repo.ts | 23 ++++--- packages/polyfill/src/surface/subscribe.ts | 17 +++++ packages/polyfill/test/open-repo.test.ts | 68 +++++++++++++++---- 3 files changed, 85 insertions(+), 23 deletions(-) diff --git a/packages/polyfill/src/emulated-verifier/open-repo.ts b/packages/polyfill/src/emulated-verifier/open-repo.ts index 7364afb..68ef371 100644 --- a/packages/polyfill/src/emulated-verifier/open-repo.ts +++ b/packages/polyfill/src/emulated-verifier/open-repo.ts @@ -300,19 +300,20 @@ export function resetOpenedRepos(): void { // @provenance getSyncState kind=aligned level=1 ref=engine/net/src/app_protocol.rs:AppResponseV0 — reports whether the first `State` arrived; upstream this is not observable from JS export function getSyncState(nuri: Nuri): SyncState | "unknown" { const channel = docChannel(nuri); - // The first `State` on the channel STANDING NOW - both halves, and the second was missing in - // this repair's first draft. `barrierCrossed` alone said `"synced"` for a fan-out whose - // establish had been REFUSED after pushing a `State`: `reportSetupFailure` clears - // `establishing` and leaves `lastState`, so the barrier outlived the attempt that produced it, - // and `"synced"` is AUTHORITY - `index-deposit.ts` `noRowsMeansAbsent` then calls a zero-row - // read a definitive absence. The previous registry answered `"timed-out"` there, and strict is - // the reading this family is built on: the cost of it is latency, never loss. + // The first `State` on the channel standing NOW. {@link docChannel} guarantees both halves - + // it does not report a barrier while no channel stands - so this asks the one question, and + // that is deliberate: a first draft of this repair re-imposed the second half HERE instead, + // which only MASKED a stale barrier rather than forgetting it. The next subscribe on the same + // fan-out brought it straight back, was replayed it, crossed its own barrier in 2ms on a + // `State` from a call that had FAILED, and answered `"synced"` - which is AUTHORITY, so + // `index-deposit.ts` `noRowsMeansAbsent` called a zero-row read a definitive absence and the + // deposit was destroyed. A guard at the reader cannot fix a fact that is wrong at the source. // // Asked of the fan-out rather than remembered, because that is the one record of it: a barrier - // is forgotten when its channel is released and when its session changes. Answered even for a - // document this module never opened - an application's own subscription crosses the same - // barrier, and `"synced"` is then simply true. - if (channel.state !== "none" && channel.barrierCrossed) return "synced"; + // is forgotten when its attempt fails, when its channel is released and when its session + // changes. Answered even for a document this module never opened - an application's own + // subscription crosses the same barrier, and `"synced"` is then simply true. + if (channel.barrierCrossed) return "synced"; const open = opens.get(nuri); // Never opened here, or opened on the no-`doc_subscribe` path, which records nothing // because there is no barrier to have missed. diff --git a/packages/polyfill/src/surface/subscribe.ts b/packages/polyfill/src/surface/subscribe.ts index df57f76..504edde 100644 --- a/packages/polyfill/src/surface/subscribe.ts +++ b/packages/polyfill/src/surface/subscribe.ts @@ -299,6 +299,14 @@ export function docChannel(nuri: Nuri): DocChannel { // two together are the only thing that separates "asked" from "open" - and recording the // first as if it were the second is the defect this accessor exists to make impossible. const state: DocChannelState = !entry.establishing ? "none" : entry.realUnsub !== null ? "open" : "opening"; + // `lastState` alone, and deliberately NOT `&& state !== "none"`. Every path that stops an + // attempt standing forgets the barrier with it - {@link releaseFanOut}, {@link resubscribeDocs} + // and, since this was found the hard way, {@link reportSetupFailure} - so the two can no longer + // disagree, and a guard here would be unreachable. It would also be a MASK: the first repair of + // that defect put exactly this condition in the reader instead of forgetting the barrier at the + // source, and it hid the fault while the fan-out went on replaying a dead `State` to the next + // joiner. A fact that is wrong at the source has to be fixed at the source; a reader that + // papers over it only moves the lie. return { state, barrierCrossed: entry.lastState !== null, incarnation: entry.incarnation }; } @@ -383,6 +391,15 @@ function reportSetupFailure(nuri: Nuri, entry: DocFanOut, error: unknown, attemp console.error("[subscribe] doc_subscribe failed for", nuri, error); if (!isCurrentAttempt(nuri, entry, attempt)) return; entry.establishing = false; + // A BARRIER BELONGS TO THE ATTEMPT THAT PRODUCED IT, and this one failed. The push may well + // have arrived - the callback is driven locally while the call's own answer travels the RPC - + // but the call did not take, so there is no subscription behind that `State` and nothing will + // follow it. Left here, the fan-out replayed it to the next joiner: a bootstrap open crossed + // its barrier in 2ms on a `State` from a call that had FAILED, logged `BARRIER ... synced`, + // and `getSyncState` answered `"synced"` - which is authority, so a zero-row read over the + // document became a definitive absence and a deposit was destroyed. {@link releaseFanOut} + // and {@link resubscribeDocs} both forget it for the same reason; this path did not. + entry.lastState = null; for (const listener of [...entry.listeners]) { const tell = listener.onSetupFailed; if (tell === null || !entry.listeners.has(listener)) continue; diff --git a/packages/polyfill/test/open-repo.test.ts b/packages/polyfill/test/open-repo.test.ts index 6441788..3833926 100644 --- a/packages/polyfill/test/open-repo.test.ts +++ b/packages/polyfill/test/open-repo.test.ts @@ -367,15 +367,15 @@ describe("ensureRepoOpen", () => { expect(getSyncState(doc)).toBe("synced"); }); - // A barrier must not outlive the attempt that produced it. + // A BARRIER BELONGS TO THE ATTEMPT THAT PRODUCED IT - and must be FORGOTTEN when that attempt + // fails, not merely ignored while nothing stands. // - // `subscribe.ts` clears `establishing` on a refused establish and LEAVES the remembered - // `State`, so a call that pushed the barrier and then failed leaves the fan-out with a - // barrier and no channel. Reading `"synced"` off that is AUTHORITY - `index-deposit.ts` - // `noRowsMeansAbsent` calls a zero-row read over it a definitive absence, which destroys a - // deposit - and the registry this replaced answered `"timed-out"` there. Strict is the - // reading: the cost of it is latency, never loss. - it("a barrier pushed by a subscribe that then FAILED is not authority", async () => { + // The push can arrive and the call still fail: the callback is driven locally while the call's + // own answer travels the RPC. `subscribe.ts` cleared `establishing` on a refused establish and + // LEFT the remembered `State`, so the fan-out held a barrier with no subscription behind it. + // The first repair only made `getSyncState` ignore it while the channel read `"none"`, which + // MASKED it - see the next test for what the mask let through. + it("a barrier pushed by a subscribe that then FAILED is forgotten, not merely ignored", async () => { const doc_subscribe = mock(async (_n: string, _s: string, cb: (r: unknown) => void) => { cb({ V0: { State: {} } }); // the barrier arrives... throw new Error("RepoNotFound: after the push"); // ...and the call then fails @@ -390,18 +390,62 @@ describe("ensureRepoOpen", () => { setOpenTimeoutForTests(20); const doc = "did:ng:o:pushed-then-failed"; - // Something else holds the fan-out, so the refusal does not take the entry with it and the - // remembered barrier survives the attempt that pushed it. + // Something else holds the fan-out, so the refusal does not take the entry with it - which is + // what would have cleared the barrier for the wrong reason. const other = subscribeDoc(doc, () => {}); await ensureRepoOpen(doc); await new Promise((r) => setTimeout(r, 10)); - expect(docChannel(doc).barrierCrossed).toBe(true); // the barrier IS remembered... - expect(docChannel(doc).state).toBe("none"); // ...and nothing stands behind it + expect(docChannel(doc).barrierCrossed).toBe(false); // forgotten with the attempt + expect(docChannel(doc).state).toBe("none"); expect(getSyncState(doc)).toBe("timed-out"); other(); }); + // What MASKING the stale barrier let through, and it is the destroyed deposit this whole family + // exists to prevent. + // + // A fan-out that kept the `State` of a FAILED call replays it to the next joiner. So the next + // subscribe restored `establishing`, the channel left `"none"`, the mask lifted - and a + // bootstrap open crossed its barrier in two milliseconds on a `State` from a call that had + // never taken, logging `BARRIER ... synced`. `getSyncState` then answers `"synced"`, which is + // AUTHORITY: `index-deposit.ts` `noRowsMeansAbsent` calls a zero-row read a definitive absence. + it("the next subscribe is not handed the barrier of a call that FAILED", async () => { + let calls = 0; + const doc_subscribe = mock(async (_n: string, _s: string, cb: (r: unknown) => void) => { + calls += 1; + if (calls === 1) { + cb({ V0: { State: {} } }); + throw new Error("RepoNotFound: after the push"); + } + return await new Promise(() => {}); // the retry never answers + }); + const ng = { + doc_subscribe, + sparql_query: mock(async () => ({ results: { bindings: [] } })), + doc_create: mock(async () => "did:ng:o:new"), + sparql_update: mock(async () => undefined), + }; + inject(ng as unknown as ReturnType); + setOpenTimeoutForTests(200); + const doc = "did:ng:o:replayed-corpse"; + + const app = subscribeDoc(doc, () => {}); // the application's own subscription fails + await new Promise((r) => setTimeout(r, 20)); + + // The bootstrap open joins the SAME fan-out and starts a second establish. + const at = Date.now(); + await ensureRepoOpen(doc); + const paid = Date.now() - at; + + expect(calls).toBe(2); // a real second attempt was made... + expect(paid).toBeGreaterThanOrEqual(150); // ...and this open waited its OWN barrier out + expect(docChannel(doc).barrierCrossed).toBe(false); + expect(getSyncState(doc)).toBe("timed-out"); // never authority over a corpse + app(); + resetOpenedRepos(); + }); + // `release` does not answer "am I still listening", and treating it as though it did leaves the // document held by somebody else's listener. //