fix: un abonnement en échec n'empoisonne plus le document, et un dépôt ne change plus de destinataire

This commit is contained in:
Sylvain Duchesne
2026-08-17 11:47:21 +02:00
parent 520c8c59a8
commit 33212a8b00
7 changed files with 493 additions and 48 deletions
+97 -1
View File
@@ -1,5 +1,11 @@
import { test, expect, mock, afterAll } from "bun:test";
import { docChangeType, subscribeDoc, subscribeDocs } from "../src/surface/subscribe";
import {
docChangeType,
subscribeDoc,
subscribeDocReportingSetupFailure,
subscribeDocs,
type Unsubscribe,
} from "../src/surface/subscribe";
import { configure } from "../src/index";
import { configureStoreRegistry } from "../src/shared-wallet/bootstrap";
import { resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
@@ -225,3 +231,93 @@ test("the real subscription is released only when the LAST subscriber leaves", a
stopSecond();
expect(ng.isSubscribed(A)).toBe(false); // now nobody is
});
// --- what sharing one subscription must not COST a caller --------------------
//
// Three things a per-caller `doc_subscribe` gave for free. The fan-out took each of them
// away when it was introduced, and each is invisible from the caller's side: nothing
// rejects, nothing logs, the document simply stops speaking to somebody.
test("two subscriptions with the SAME handler are two subscriptions", async () => {
const ng = inject();
const seen: unknown[] = [];
// One function, two callers. A module-level handler, a bound method or a shared arrow
// makes this ordinary rather than exotic — and keyed by the function, the second caller
// was never registered at all, so the first one's departure took it with it.
const handler = (r: unknown): void => {
seen.push(r);
};
const stopFirst = subscribeDoc(A, handler);
const stopSecond = subscribeDoc(A, handler);
await tick();
const afterInitial = seen.length;
stopFirst(); // only the FIRST caller has left
expect(ng.isSubscribed(A)).toBe(true);
ng.push(A);
expect(seen.length).toBeGreaterThan(afterInitial); // the second one is still listening
stopSecond();
expect(ng.isSubscribed(A)).toBe(false); // …and now the last one has gone
});
test("a subscriber torn down inside another's handler does not receive that push", async () => {
const ng = inject();
const seen: unknown[] = [];
let stopSecond: Unsubscribe | null = null;
let armed = false;
// The first handler tears the second one down mid-push. Which of the two runs first is an
// ordering no application controls, and `subscribeDoc` publishes that no `onChange` fires
// after unsubscribe — so the answer must not depend on it.
const stopFirst = subscribeDoc(A, () => {
if (armed) stopSecond?.();
});
stopSecond = subscribeDoc(A, (r) => seen.push(r));
await tick();
seen.length = 0;
armed = true;
ng.push(A);
expect(seen).toEqual([]);
stopFirst();
});
test("a setup that FAILED does not poison the document for the next subscriber", async () => {
const failFor = new Set([A]);
const ng = inject(failFor);
const stopFirst = subscribeDoc(A, () => {});
await tick();
expect(ng.doc_subscribe).toHaveBeenCalledTimes(1); // …and it rejected
// The document syncs; the broker can serve it now. A caller arriving after a transient
// rejection must get a real subscription, exactly as its own `doc_subscribe` would have.
failFor.delete(A);
const seen: unknown[] = [];
const stopSecond = subscribeDoc(A, (r) => seen.push(r));
await tick();
expect(ng.doc_subscribe).toHaveBeenCalledTimes(2); // attempted again, not joined to a corpse
expect(seen.length).toBeGreaterThan(0); // its initial State
ng.push(A);
expect(seen.length).toBeGreaterThan(1); // and the changes that follow
stopFirst();
stopSecond();
});
test("a caller that asks to be told learns its subscription could not be opened", async () => {
inject(new Set([A]));
const failures: unknown[] = [];
const stop = subscribeDocReportingSetupFailure(
A,
() => {},
(error) => failures.push(error),
);
await tick();
// Upstream `doc_subscribe` is async and rejects, so its caller learns. This wrapper
// returns synchronously, and a caller whose job depends on the subscription (the inbox
// observation) cannot otherwise tell "quiet" from "never opened".
expect(failures).toHaveLength(1);
expect(String(failures[0])).toContain("RepoNotFound");
stop();
});