fix: un second doc_subscribe tuait le premier, et les inbox n'étaient lues qu'à la connexion
This commit is contained in:
@@ -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
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user