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:
@@ -34,6 +34,8 @@ import { getCaps, setCurrentUser } from "../src/shared-wallet/bootstrap";
|
||||
import { resolveAccount, userInbox } from "../src/shared-wallet/account-registry";
|
||||
import { observationSettled } from "../src/emulated-verifier/inbox-observer";
|
||||
import { cancelScheduledInboxProcessing } from "../src/emulated-verifier/inbox-processor";
|
||||
import { connectedUser } from "../src/emulated-verifier/connect";
|
||||
import { setOpenTimeoutForTests } from "../src/emulated-verifier/open-repo";
|
||||
import { bootPage, forgetEverything, signIn, type FakeWallet, type Quad } from "./wallet-fake";
|
||||
import type { Nuri } from "../src/model/types";
|
||||
|
||||
@@ -268,6 +270,42 @@ describe("switching identity", () => {
|
||||
expect(await documentsGivenTo("alice")).toEqual([]);
|
||||
});
|
||||
|
||||
test("MID-DRAIN files nothing for the identity that arrives", async () => {
|
||||
const { doc, inTransit } = await bobSharesWithAlice();
|
||||
// Test-side inspection only: the address is used to recognise the read in flight, and
|
||||
// is never handed to an actor.
|
||||
const aliceInbox = await userInbox("alice", "protected");
|
||||
await signIn("alice");
|
||||
|
||||
// The broker takes its time over the deposits read, and the page switches user INSIDE
|
||||
// that window — a person clicking "sign in as Bob" while a push is being applied. The
|
||||
// ownership guard has already passed by then; it ran at the start of the read.
|
||||
const answering = fake.sparql_query.getMockImplementation()!;
|
||||
let switched = false;
|
||||
fake.sparql_query.mockImplementation(async (...args: unknown[]) => {
|
||||
const answer = await answering(...args);
|
||||
if (!switched && args[3] === aliceInbox && String(args[1]).includes(`${INBOX}:payload`)) {
|
||||
switched = true;
|
||||
setCurrentUser("bob");
|
||||
}
|
||||
return answer;
|
||||
});
|
||||
|
||||
fake._deliver(inTransit);
|
||||
await converge();
|
||||
|
||||
// Bob was GIVEN nothing. (His own cap on the document is not evidence either way — he
|
||||
// made it; what would be evidence is a Link, and there must be none.) Filed here, the
|
||||
// cap addressed to Alice becomes a capability Bob holds at his next sign-in, and its
|
||||
// real recipient is left with nothing at all.
|
||||
expect(await documentsGivenTo("bob")).toEqual([]);
|
||||
// …and Alice has lost nothing: an inbox is not consumed by a drain that abandoned it,
|
||||
// so what was deposited for her is still there when she is the one connected.
|
||||
await signIn("alice");
|
||||
await converge();
|
||||
expect(getCaps().capForHolder("alice", doc)).toBeDefined();
|
||||
});
|
||||
|
||||
test("and Alice coming back finds the deposit still there to apply", async () => {
|
||||
const { doc, inTransit } = await bobSharesWithAlice();
|
||||
await signIn("alice");
|
||||
@@ -315,6 +353,50 @@ describe("a deposit that cannot be applied", () => {
|
||||
expect(getCaps().capForHolder("alice", first.doc)).toBeDefined();
|
||||
});
|
||||
|
||||
test("because its inbox could not be WATCHED is reported, and attempted again", async () => {
|
||||
const { doc, inTransit } = await bobSharesWithAlice();
|
||||
const aliceInbox = await userInbox("alice", "protected");
|
||||
|
||||
// A repo that never pushes its initial `State` is what a refused subscription looks like
|
||||
// from the bootstrap open's side, and it waits out its bounded fallback before giving up.
|
||||
// Eight seconds of it, twice, is the production wait and not a unit test's.
|
||||
setOpenTimeoutForTests(20);
|
||||
|
||||
// The broker will not open a channel on Alice's inbox. Everything else about her session
|
||||
// works — which is the point: the only symptom of a watch that was never established is
|
||||
// that shares stop arriving.
|
||||
const opening = fake.doc_subscribe!.getMockImplementation()!;
|
||||
let refusing = true;
|
||||
fake.doc_subscribe!.mockImplementation(async (...args: unknown[]) => {
|
||||
if (refusing && args[0] === aliceInbox) throw new Error(`RepoNotFound: ${String(args[0])}`);
|
||||
return opening(...args);
|
||||
});
|
||||
|
||||
const reported = await whileWatchingTheLog(async () => {
|
||||
await signIn("alice");
|
||||
await converge();
|
||||
});
|
||||
|
||||
// Said out loud, in this package's own words and under the connected identity — not left
|
||||
// as the absence of a push.
|
||||
expect(reported.filter((l) => /could not be watched/.test(l)).length).toBeGreaterThan(0);
|
||||
expect(reported.find((l) => /could not be watched/.test(l))).toContain("[alice][polyfill]");
|
||||
|
||||
// Nothing is watching, so the deposit that lands now cannot be applied — and is not.
|
||||
fake._deliver(inTransit);
|
||||
await converge();
|
||||
expect(getCaps().capForHolder("alice", doc)).toBeUndefined();
|
||||
|
||||
// The broker recovers and Alice does something ordinary. The inbox that could not be
|
||||
// opened was not written off for the session: it is subscribed to on the next
|
||||
// enumeration, and its initial push finds the deposit still waiting.
|
||||
refusing = false;
|
||||
await storeRegistry.createEntityDoc("protected");
|
||||
await converge();
|
||||
expect(getCaps().capForHolder("alice", doc)).toBeDefined();
|
||||
expect(await documentsGivenTo("alice")).toContain(doc);
|
||||
});
|
||||
|
||||
test("never rejects into the application — nobody asked for this work", async () => {
|
||||
const { inTransit } = await bobSharesWithAlice();
|
||||
const aliceInbox = await userInbox("alice", "protected");
|
||||
@@ -338,3 +420,41 @@ describe("a deposit that cannot be applied", () => {
|
||||
expect(unhandled).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a connection whose own work FAILED", () => {
|
||||
test("still leaves the identity watched — being connected is what is observed", async () => {
|
||||
const { doc, inTransit } = await bobSharesWithAlice();
|
||||
// The broker cannot answer for one of Alice's own stores, so the RESTORE fails and the
|
||||
// connection rejects. She is connected regardless: `setCurrentUser` is synchronous and
|
||||
// took effect before any of this ran, and nothing signs her back out.
|
||||
const store = (await resolveAccount("alice"))?.docPublic;
|
||||
if (store === undefined) throw new Error("the fixture did not give Alice a public store");
|
||||
fake._failReadsOn.add(store);
|
||||
|
||||
setCurrentUser("alice");
|
||||
let rejected = false;
|
||||
await whileWatchingTheLog(async () => {
|
||||
try {
|
||||
await connectedUser();
|
||||
} catch {
|
||||
rejected = true;
|
||||
}
|
||||
await converge();
|
||||
});
|
||||
// The caller is still TOLD, and that rule is not what changes here: failing to reach the
|
||||
// registers rejects, exactly as before.
|
||||
expect(rejected).toBe(true);
|
||||
|
||||
// The hiccup passes. Alice never touched the page.
|
||||
fake._failReadsOn.delete(store);
|
||||
fake._deliver(inTransit);
|
||||
await converge();
|
||||
|
||||
// What she is owed is not the restore she lost — it is that a deposit made while she sits
|
||||
// there converges. Watching used to be the LAST line of the connection work, so a restore
|
||||
// that rejected skipped it and left her connected with nothing observing her inboxes: one
|
||||
// hiccup at sign-in, and every share made afterwards was lost to her for the session.
|
||||
expect(getCaps().capForHolder("alice", doc)).toBeDefined();
|
||||
expect(await documentsGivenTo("alice")).toContain(doc);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user