diff --git a/packages/polyfill/src/surface/subscribe.ts b/packages/polyfill/src/surface/subscribe.ts index eb93f58..3edf5ab 100644 --- a/packages/polyfill/src/surface/subscribe.ts +++ b/packages/polyfill/src/surface/subscribe.ts @@ -697,11 +697,24 @@ export function subscribeDocUnguarded( // Joining a document somebody else already opened: hand this listener the `State` its // own `doc_subscribe` would have pushed it. Asynchronously, like the real push, so a // caller that sets itself up after this call still sees it. + // + // RE-READ inside the microtask, never captured out here, because the barrier can be forgotten + // in between: the establish can reject in that gap and {@link reportSetupFailure} disowns its + // `State` with it. One microtask hop is the whole window - measured, not reasoned - and this + // is the last path that could hand out a barrier after it had been forgotten. `isCurrentAttempt` + // does not cover it: that failure does not bump `attempt`, so the entry and the attempt are + // both still the current ones. + // + // Diagnostic rather than an authority leak: `getSyncState` reads the record and not the push, + // so it still answers `"timed-out"`. What the stale delivery broke is the bootstrap wait in + // `emulated-verifier/open-repo.ts`, which crossed its barrier on it and logged + // `BARRIER synced` for a barrier that no longer existed - and that trace line is the one + // this codebase uses to tell a genuine absence from a not-yet-synced read. if (joined.lastState) { - const { resp, type } = joined.lastState; queueMicrotask(() => { - if (fanOuts.get(nuri) === joined && joined.listeners.has(listener)) { - deliver(nuri, listener, resp, type); + const standing = joined.lastState; + if (standing && fanOuts.get(nuri) === joined && joined.listeners.has(listener)) { + deliver(nuri, listener, standing.resp, standing.type); } }); } diff --git a/packages/polyfill/test/subscribe.test.ts b/packages/polyfill/test/subscribe.test.ts index ec8fc5f..2167e28 100644 --- a/packages/polyfill/test/subscribe.test.ts +++ b/packages/polyfill/test/subscribe.test.ts @@ -819,3 +819,52 @@ test("a State that lands AFTER the call was reported failed is not a barrier", a expect(docChannel(A).state).toBe("none"); unsub(); }); + +// A JOINER IS NEVER HANDED A BARRIER THE FAN-OUT HAS ALREADY FORGOTTEN. +// +// The replay reads the remembered `State` when a joiner subscribes and delivers it one microtask +// later. Read once and CAPTURED, it was delivered even when the barrier was forgotten in +// between - and there is exactly one hop in which that happens, measured rather than reasoned: +// the call pushes its `State`, then rejects, and a second `subscribeDoc` on the same NURI lands +// after the rejection is dispatched and before `reportSetupFailure`'s continuation does. At zero +// hops the clear has not happened yet; at two or more `lastState` is already null, so no replay +// is queued at all and the joiner simply opens a fresh attempt. One hop is the whole window. +// +// DIAGNOSTIC, and scoped as such: `getSyncState` reads the record and not the push, so it still +// answers `"timed-out"` and no read gains authority. What the stale delivery breaks is +// `emulated-verifier/open-repo.ts`'s bootstrap wait, which crosses its barrier on it and logs +// `BARRIER synced` for a barrier that no longer exists - and that trace line is the one +// this codebase uses to tell a genuine absence from a not-yet-synced read. +test("a joiner is never replayed a barrier the fan-out has already forgotten", async () => { + let refuse: ((e: Error) => void) | null = null; + const doc_subscribe = mock(async (_n: string, _s: unknown, cb: (r: unknown) => void) => { + cb({ V0: { State: {} } }); // the barrier is remembered... + return await new Promise((_resolve, reject) => { + refuse = reject; // ...while the call itself is still unanswered + }); + }); + configure({ ng: { doc_subscribe } as any, useShape: (() => {}) as any }); + configureStoreRegistry({ getSession: async () => SESSION }); + const first = subscribeDoc(A, () => {}); + await tick(); + expect(docChannel(A).barrierCrossed).toBe(true); + + // The call fails, and the joiner arrives one microtask later - inside the window. + refuse!(new Error("RepoNotFound: the call did not take")); + const handedAForgottenBarrier: boolean[] = []; + let joiner: Unsubscribe = () => {}; + await new Promise((resolve) => { + queueMicrotask(() => { + joiner = subscribeDoc(A, () => { + handedAForgottenBarrier.push(!docChannel(A).barrierCrossed); + }); + resolve(); + }); + }); + await tick(); + + expect(docChannel(A).barrierCrossed).toBe(false); // the barrier is forgotten... + expect(handedAForgottenBarrier).toEqual([]); // ...so nobody is handed it + first(); + joiner(); +});