diff --git a/packages/polyfill/src/surface/subscribe.ts b/packages/polyfill/src/surface/subscribe.ts index 504edde..eb93f58 100644 --- a/packages/polyfill/src/surface/subscribe.ts +++ b/packages/polyfill/src/surface/subscribe.ts @@ -513,8 +513,20 @@ async function establish( // this fan-out's listeners nor its barrier are this call's to touch any more. if (!isCurrentAttempt(nuri, entry, attempt)) return; const type = docChangeType(resp); - // Remember the barrier for whoever joins next; a later `State` replaces it. - if (type === "State") entry.lastState = { resp, type }; + // Remember the barrier for whoever joins next; a later `State` replaces it - but ONLY + // while this attempt still stands. `isCurrentAttempt` above says this entry and this + // call are still the ones, and says nothing about whether the call SUCCEEDED: a broker + // dispatches before it answers, so a `State` can land after the call has already been + // reported as failed, and this module's own doctrine is that a push outliving its + // teardown is reachable (see the header, and the guard on this very callback). Recording + // it then re-created the dead barrier {@link reportSetupFailure} had just forgotten, with + // no channel behind it - and `getSyncState` answers `"synced"` to that, which is AUTHORITY. + // + // With this, `lastState !== null` IMPLIES an attempt stands, at every write as well as at + // every clear, so the two cannot disagree and no reader has to re-impose it. The push is + // still fanned out below: a listener may want the change signal, and only the BARRIER is + // a claim about the document. + if (type === "State" && entry.establishing) entry.lastState = { resp, type }; fanOut(nuri, entry, resp, type); })) as (() => void) | undefined; if (!isCurrentAttempt(nuri, entry, attempt)) { diff --git a/packages/polyfill/test/subscribe.test.ts b/packages/polyfill/test/subscribe.test.ts index 679399a..ec8fc5f 100644 --- a/packages/polyfill/test/subscribe.test.ts +++ b/packages/polyfill/test/subscribe.test.ts @@ -788,3 +788,34 @@ test("a doc_subscribe that answers without a cancel function is still an OPEN ch expect(docChannel(A).barrierCrossed).toBe(true); unsub(); }); + +// A BARRIER BELONGS TO THE ATTEMPT THAT PRODUCED IT, and the attempt can fail after the push. +// +// The broker dispatches before it answers the call, so a `State` can land after `doc_subscribe` +// has already rejected - and this module's own doctrine is that a push outliving its teardown is +// reachable, which is why the callback carries a guard at all. Recorded as a barrier, it +// re-creates the dead one `reportSetupFailure` has just forgotten, with no channel behind it, +// and `emulated-verifier/open-repo.ts` answers `"synced"` to that - which is AUTHORITY, so a +// zero-row read over the document becomes a definitive absence and a deposit is destroyed. +test("a State that lands AFTER the call was reported failed is not a barrier", async () => { + let dispatch: ((r: unknown) => void) | null = null; + const doc_subscribe = mock(async (_n: string, _s: unknown, cb: (r: unknown) => void) => { + dispatch = cb; // the broker keeps the channel it was handed... + throw new Error("RepoNotFound: the call did not take"); // ...and then refuses the call + }); + configure({ ng: { doc_subscribe } as any, useShape: (() => {}) as any }); + configureStoreRegistry({ getSession: async () => SESSION }); + const unsub = subscribeDoc(A, () => {}); + await tick(); + expect(doc_subscribe).toHaveBeenCalledTimes(1); + expect(docChannel(A).state).toBe("none"); + expect(docChannel(A).barrierCrossed).toBe(false); + + // A `State` the broker had already sent before it answered. + dispatch!({ V0: { State: {} } }); + // `lastState !== null` must IMPLY an attempt stands - at the write as well as at the clear, + // or the two disagree and a reader is left to re-impose it. + expect(docChannel(A).barrierCrossed).toBe(false); + expect(docChannel(A).state).toBe("none"); + unsub(); +});