fix: un second doc_subscribe tuait le premier, et les inbox n'étaient lues qu'à la connexion

This commit is contained in:
Sylvain Duchesne
2026-08-17 11:11:04 +02:00
parent 6dfdf2f036
commit 935cce4d7b
14 changed files with 1284 additions and 115 deletions
+99 -17
View File
@@ -1,5 +1,5 @@
import { test, expect, mock, afterAll } from "bun:test";
import { subscribeDoc, subscribeDocs } from "../src/surface/subscribe";
import { docChangeType, subscribeDoc, subscribeDocs } from "../src/surface/subscribe";
import { configure } from "../src/index";
import { configureStoreRegistry } from "../src/shared-wallet/bootstrap";
import { resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
@@ -17,29 +17,41 @@ afterAll(() => {
const SESSION: RegistrySession = { sessionId: "sid-1", privateStoreId: "PRIV" };
/**
* A fake reactive `ng`: `doc_subscribe(nuri, sid, cb)` registers `cb` for `nuri`,
* fires it once (initial State push), and returns an unsubscribe. `push(nuri)`
* drives a later change to that doc's subscribers. A per-doc `failFor` set makes
* `doc_subscribe` reject for chosen NURIs (a not-yet-synced doc).
* A fake reactive `ng`: `doc_subscribe(nuri, sid, cb)` takes over `nuri`, fires `cb` once
* (initial State push), and returns an unsubscribe. `push(nuri)` drives a later change to
* that doc's subscriber. A per-doc `failFor` set makes `doc_subscribe` reject for chosen
* NURIs (a not-yet-synced doc).
*
* ── ONE subscriber per document, and a second one EVICTS it ───────────────
* Not a simplification — it is what the broker does. A branch holds a single sender
* (`branch_subscriptions: HashMap<BranchId, Sender<AppResponse>>`) and
* `create_branch_subscription` closes whatever it displaces, silently: the evicted
* unsubscribe still returns cleanly and nothing anywhere errors. Confirmed against the real
* broker on 2026-08-17 — with two subscriptions on one document, a write fired the second
* callback and the first, which had been firing moments earlier, went quiet for good.
*
* A Set of callbacks here would model a world where every subscriber coexists. It is
* exactly the assumption that cost this package a view that never re-read and an inbox that
* never notified, and a fake that holds it cannot fail on either.
*/
function makeFakeNg(failFor: Set<string> = new Set()) {
const subs = new Map<string, Set<(r: unknown) => void>>();
const subs = new Map<string, (r: unknown) => void>();
const doc_subscribe = mock(async (nuri: string, _sid: unknown, cb: (r: unknown) => void) => {
if (failFor.has(nuri)) throw new Error(`RepoNotFound: ${nuri}`);
let set = subs.get(nuri);
if (!set) {
set = new Set();
subs.set(nuri, set);
}
set.add(cb);
// Initial State push, delivered async (as the real RPC does).
queueMicrotask(() => cb({ V0: { State: { doc: nuri } } }));
return () => set!.delete(cb);
subs.set(nuri, cb); // whoever held this branch is dropped, without a word
// Initial State push, delivered async (as the real RPC does) — and only while this
// callback still holds the branch.
queueMicrotask(() => {
if (subs.get(nuri) === cb) cb({ V0: { State: { doc: nuri } } });
});
return () => {
if (subs.get(nuri) === cb) subs.delete(nuri);
};
});
const push = (nuri: string): void => {
for (const cb of subs.get(nuri) ?? []) cb({ V0: { Patch: { doc: nuri } } });
subs.get(nuri)?.({ V0: { Patch: { doc: nuri } } });
};
const isSubscribed = (nuri: string): boolean => (subs.get(nuri)?.size ?? 0) > 0;
const isSubscribed = (nuri: string): boolean => subs.has(nuri);
return { doc_subscribe, push, isSubscribed, _subs: subs };
}
@@ -143,3 +155,73 @@ test("subscribeDocs deduplicates repeated NURIs", async () => {
await tick();
expect(ng.doc_subscribe).toHaveBeenCalledTimes(1);
});
// --- two subscribers on ONE document ---------------------------------------
//
// A branch has room for exactly one subscriber and a second `doc_subscribe` evicts the
// first (see `makeFakeNg`). Everything inside this package subscribes — `ensureRepoOpen`
// holds a bootstrap subscription per document for the session, `watchShape` follows the
// documents of a scope, `inbox.watch` follows an inbox, and the inbox observation follows
// every inbox — so any two of them on the same document used to silence one another.
// Nothing rejected and nothing logged; the view simply stopped re-reading.
//
// So the package opens ONE real subscription per document and fans it out. These are the
// tests that say so.
test("two subscribers on one document BOTH keep firing", async () => {
const ng = inject();
const first: unknown[] = [];
const second: unknown[] = [];
subscribeDoc(A, (r) => first.push(r));
await tick();
expect(first).toHaveLength(1); // its initial State
subscribeDoc(A, (r) => second.push(r));
await tick();
ng.push(A);
// The one that was there first is not silenced by the one that came second.
expect(first).toHaveLength(2);
expect(second.length).toBeGreaterThanOrEqual(1);
});
test("one real doc_subscribe serves every subscriber of a document", async () => {
const ng = inject();
subscribeDoc(A, () => {});
subscribeDoc(A, () => {});
subscribeDoc(A, () => {});
await tick();
// Three callers, one branch taken. A second call would have evicted the first caller.
expect(ng.doc_subscribe).toHaveBeenCalledTimes(1);
});
test("a subscriber that joins LATE still gets its initial State", async () => {
inject();
subscribeDoc(A, () => {});
await tick(); // the initial State has come and gone
const late: unknown[] = [];
subscribeDoc(A, (r) => late.push(r));
await tick();
// Its own `doc_subscribe` would have pushed it a State; joining an open one owes it the
// same, or "fires once immediately" quietly stops being true for whoever arrives second.
expect(late).toHaveLength(1);
expect(docChangeType(late[0])).toBe("State");
});
test("the real subscription is released only when the LAST subscriber leaves", async () => {
const ng = inject();
const seen: unknown[] = [];
const stopFirst = subscribeDoc(A, () => {});
const stopSecond = subscribeDoc(A, (r) => seen.push(r));
await tick();
stopFirst();
expect(ng.isSubscribed(A)).toBe(true); // somebody is still listening
ng.push(A);
expect(seen.length).toBeGreaterThanOrEqual(2); // and still hearing
stopSecond();
expect(ng.isSubscribed(A)).toBe(false); // now nobody is
});