fix!: la reprise cesse de dependre d-un front, et une ouverture abandonnee se termine

Quatrieme tour. Ce n'est pas un drapeau de plus : c'est la forme qui change.

Les tours 2 et 3 construisaient tous deux la reprise ainsi - noter qu'on a
refuse, puis reagir a un evenement ulterieur - et c'est la note qui etait
fautive les deux fois : une fois conditionnee a quelque chose qui ne pouvait pas
devenir vrai, une fois ecrite apres la fenetre ou arrive l'evenement decisif. Un
front manque n'est pas rattrapable par construction. Donc :

- LA DECISION EST UN NIVEAU. Les documents nommes sont reinterroges a chaque
  publication, jamais reportes depuis la barriere. Une capacite qui arrive change
  `isError` en document par le canal qui existait deja, et rien n'etant memorise,
  il n'y a aucun evenement a manquer. C'est aussi ce qui rend sans objet la
  question du porteur : une requete ouverte pendant qu'un autre etait connecte ne
  peut pas revenir a cet abonne en succes auquel il n'a pas droit.
- UNE TENTATIVE QUI A COURU CONTRE UN CHANGEMENT S'EN APERCOIT au lieu
  d'attendre. `open()` echantillonne `CapRegistry.version()` avant la barriere et
  compare apres son echec ; un ecart signifie qu'elle a decide sur un terrain
  perime, et elle retente d'elle-meme. Cela termine : une tentative ne se repete
  que parce que le trousseau a bouge, les mouvements sont finis, et ceux qu'une
  tentative provoque elle-meme sont les capacites qu'elle depose - au plus une par
  document nomme.

La comparaison de porteur disparait : `holderKey()` vaut "" tant que personne
n'est connecte, donc un refus pris avant que l'identite se fixe - la ou les
composants se montent - ne correspondait plus jamais ensuite.

Un instantane d'echec inchange n'est plus republie : la surface documente sa
stabilite, et huit arrivees sans rapport produisaient huit objets porteurs de huit
`Error` distinctes pour un etat identique.

Enfin, un defaut a moi, trouve en mesurant : le changement rendait la suite
intermittente. `resetOpenedRepos()` abandonnait les ouvertures en vol sans les
terminer, donc un appelant attendait les 8s du repli sur un abonnement demonte,
puis repartait avec son etat de synchro efface - et « unknown » est la lecture
LAXE, celle qui accepte qu'une lecture a zero ligne soit une absence averee. Une
absence-qui-cache-un-echec latente, de la famille meme de cette tache. Une
reinitialisation termine desormais ce qu'elle abandonne, en « timed-out ».

Deux fixtures qui se couraient apres sont corrigees, raison ecrite a cote.
This commit is contained in:
Sylvain Duchesne
2026-09-21 17:10:20 +02:00
parent f875047099
commit 8c890411bb
7 changed files with 430 additions and 82 deletions
+38 -2
View File
@@ -20,7 +20,7 @@
*/
import { describe, it, expect, mock, beforeEach, afterAll } from "bun:test";
import { ensureRepoOpen, resetOpenedRepos } from "../src/emulated-verifier/open-repo";
import { ensureRepoOpen, getSyncState, resetOpenedRepos } from "../src/emulated-verifier/open-repo";
import { readDocumentSubjects } from "../src/surface/read-document";
import { configure } from "../src/index";
import { configureStoreRegistry, setCurrentUser } from "../src/shared-wallet/bootstrap";
@@ -55,7 +55,11 @@ const FP = "http://festipod.org/";
function makeFakeNgWithSubscribe(
triplesByDoc: Record<string, Array<[string, string]>>,
options: { pushState?: boolean } = {},
) {
// A broker that accepts the subscription and never pushes is the state the bounded
// fallback exists for, and the only way to hold an open genuinely IN FLIGHT.
const pushState = options.pushState !== false;
const subscribed = new Set<string>();
const subscribeCallOrder: string[] = [];
@@ -65,7 +69,7 @@ function makeFakeNgWithSubscribe(
subscribeCallOrder.push(nuri);
// Simulate initial State push (synchronously deferred so the subscription
// setup promise path in ensureRepoOpen can resolve it).
setTimeout(() => cb({ V0: { State: {} } }), 0);
if (pushState) setTimeout(() => cb({ V0: { State: {} } }), 0);
return () => {}; // unsubscribe fn
});
@@ -168,6 +172,38 @@ describe("ensureRepoOpen", () => {
await expect(ensureRepoOpen("did:ng:o:c")).resolves.toBeUndefined();
});
// An open ENDS, whatever happens to the registry underneath it.
//
// A reset - which is what a session change does, `syncSession` - tears down the
// subscription an in-flight open is waiting on. Until 2026-09-21 it then left that open
// hanging: its `State` could no longer arrive, so the caller waited out the whole
// 8-second fallback and only then proceeded, with its sync state CLEARED. Cleared reads
// as `"unknown"`, and `unknown` is the LAX answer - `emulated-verifier/index-deposit.ts`
// accepts it as "a zero-row read means the document is empty" - so a read taken right
// after a session change could have an absence read into it. Both halves are the point:
// it must END, and it must end as `"timed-out"`.
it("a reset ENDS the opens it abandons, and marks them timed-out rather than unknown", async () => {
const ng = makeFakeNgWithSubscribe({}, { pushState: false });
inject(ng);
const inFlight = ensureRepoOpen("did:ng:o:abandoned");
await new Promise((r) => setTimeout(r, 5)); // let the subscribe land
expect(ng.doc_subscribe).toHaveBeenCalledTimes(1);
resetOpenedRepos();
// Ends at once. Without the fix it resolves only when the 8s fallback fires, which is
// longer than any test's patience and longer than any page's.
await expect(
Promise.race([
inFlight.then(() => "ended"),
new Promise((r) => setTimeout(() => r("still waiting"), 200)),
]),
).resolves.toBe("ended");
// ...and NOT as a barrier that was reached.
expect(getSyncState("did:ng:o:abandoned")).toBe("timed-out");
});
it("the anchored document read triggers doc_subscribe then returns data (integration path)", async () => {
const ng = makeFakeNgWithSubscribe({
"did:ng:o:p": [[TYPE, `${FP}Participation`], [`${FP}event`, "did:ng:o:e"]],
+183 -50
View File
@@ -72,7 +72,14 @@ function makeFake(options: { pushState?: boolean } = {}) {
let docCounter = 0;
const subs: SubRec[] = [];
const doc_create = mock(async () => `did:ng:o:doc${++docCounter}`);
// A NAMESPACE OF ITS OWN, and it is not cosmetic. `wallet-fake.ts` mints into
// `did:ng:o:doc<n>` too, and the bootstrap open registry (`emulated-verifier/open-repo.ts`)
// is process-global - so the two fakes aliased each other's repos across test files, and
// an in-flight open abandoned here could be awaited there against a broker that no longer
// answers, hanging it until the 8s fallback. `wallet-fake` carries the same lesson at its
// own scale ("a broker never mints a repo id twice - an id is a public key - so neither
// does this"); this is that lesson one scope wider.
const doc_create = mock(async () => `did:ng:o:usq${++docCounter}`);
const sparql_update = mock(async (...a: unknown[]) => {
const query = a[1] as string;
@@ -713,52 +720,49 @@ describe("useShapeQuery", () => {
unsub();
});
it("(s) a re-attempt that cannot succeed spends NO round-trip: an absence is memoised", async () => {
it("(s) a re-attempt re-asks the network only for the class a re-ask can repair", async () => {
const ng = makeFake();
const orm = makeOrm();
inject(ng, orm);
setCurrentUser("alice");
const note = await createEntityDoc("alice", "protected");
seedEvent(orm, note, `${note}:q:one`, "Shared");
// Two public documents, and what separates them is the BROKER, not the library: one
// answers (and, being alice's, exposes a cap bob will not be given), the other cannot
// be asked at all. The first is an ANSWER and is memoised; the second is a failure
// and deliberately is not (`public-store.ts`).
const answers = await createEntityDoc("alice", "protected");
const cannotBeAsked = await createEntityDoc("alice", "public");
setCurrentUser("bob");
const obs = useShapeQuery(EventShape, { graphs: [note] });
let notes = 0;
const unsub = obs.subscribe(() => {
notes += 1;
});
for (let i = 0; i < 10 && !obs.getSnapshot().isError; i++) await tick();
expect(obs.getSnapshot().isError).toBe(true);
ng._unreachable.add(cannotBeAsked);
const asks = (doc: string): number =>
ng.sparql_query.mock.calls.filter((c) => c[3] === doc).length;
/** How many times the network was asked ABOUT this document - anchored reads only. */
const asks = (): number =>
ng.sparql_query.mock.calls.filter((c) => c[3] === note).length;
const afterRefusal = asks();
const notesAfterRefusal = notes;
const memoised = useShapeQuery(EventShape, { graphs: [answers] });
const unsubA = memoised.subscribe(() => {});
const outage = useShapeQuery(EventShape, { graphs: [cannotBeAsked] });
const unsubB = outage.subscribe(() => {});
for (let i = 0; i < 20 && !(memoised.getSnapshot().isError && outage.getSnapshot().isError); i++) {
await tick();
}
expect(memoised.getSnapshot().isError).toBe(true);
expect(outage.getSnapshot().isError).toBe(true);
const answeredAt = asks(answers);
const outageAt = asks(cannotBeAsked);
// What this replaced, and why. An earlier version gated the re-attempt on "does the
// holder hold every named document's cap yet", so that an unrelated cap would not
// buy a round-trip. That gate was CIRCULAR for a document in a public store - the
// only channel that can ever file its cap is the very fetch the gate was blocking -
// and an adversarial pass proved the class unrecoverable. The gate is gone: every
// keyring change re-attempts, for the holder that was refused.
//
// What makes that affordable is the memo, and it is worth pinning rather than
// assuming: the network ANSWERED here (this document exposes no cap), and an answer
// is remembered. So the re-attempts below re-run the barrier and ask nobody. A
// failure is the other case - deliberately not memoised, so it IS re-asked, which is
// exactly the repair for it (see (v)).
// Three unrelated arrivals. Both scopes re-attempt on each - there is deliberately no
// predicate deciding which is worth it, because every predicate tried so far left a
// class permanently stuck. The MEMO is what makes that affordable, and the two
// counts below are the proof, each the other's guard against vacuity: a re-ask that
// cannot repair anything costs nothing, and the one that can is actually made.
for (let round = 0; round < 3; round++) {
getCaps().learn(mintCap(`did:ng:o:unrelated-${round}`));
for (let i = 0; i < 5; i++) await tick();
for (let i = 0; i < 6; i++) await tick();
}
// BOTH halves, or this test is vacuous: a re-attempt that never ran would also leave
// the count untouched. The republication is what proves the barrier was re-run.
expect(notes).toBeGreaterThan(notesAfterRefusal);
expect(obs.getSnapshot().isError).toBe(true);
expect(asks()).toBe(afterRefusal);
unsub();
expect(asks(answers)).toBe(answeredAt); // an absence was read, and is remembered
expect(asks(cannotBeAsked)).toBeGreaterThan(outageAt); // a failure is asked again
unsubA();
unsubB();
});
it("(t) named documents cross the barrier TOGETHER, so N that never sync cost one wait, not N", async () => {
@@ -799,7 +803,13 @@ describe("useShapeQuery", () => {
const ng = makeFake();
const orm = makeOrm();
inject(ng, orm);
setCurrentUser("rex");
// NOBODY is signed in, and that is what keeps the registry inert for the whole test.
// Signing in provisions an account and provisioning MINTS, so `setCurrentUser` arms
// the emulation a tick or two later: a fixture that asserts `isEnforcing() === false`
// and then subscribes is racing itself, and it won that race only until the refusal
// began being re-read on every publication - at which point a scope named by a holder
// of nothing started answering, correctly, `isError`. The inert case is worth pinning;
// a fixture that stops being inert halfway through pins the schedule instead.
const early = "did:ng:o:inert-named";
seedEvent(orm, early, `${early}:q:one`, "Early");
expect(getCaps().isEnforcing()).toBe(false);
@@ -858,7 +868,7 @@ describe("useShapeQuery", () => {
unsub();
});
it("(w) a refused scope never re-opens under a DIFFERENT identity", async () => {
it("(w) no snapshot claims success for a document the CURRENT holder cannot reach", async () => {
const ng = makeFake();
const orm = makeOrm();
inject(ng, orm);
@@ -869,26 +879,44 @@ describe("useShapeQuery", () => {
setCurrentUser("bob");
const obs = useShapeQuery(EventShape, { graphs: [hers] });
const unsub = obs.subscribe(() => {});
for (let i = 0; i < 10 && !obs.getSnapshot().isError; i++) await tick();
for (let i = 0; i < 20 && !obs.getSnapshot().isError; i++) await tick();
expect(obs.getSnapshot().isError).toBe(true);
// The page becomes Alice. No application can do this - `ensureIdentity` is the only
// published call that settles an identity and it returns the one already set
// (`access-gate.resolveIdentity` reads `getCurrentUser()` first) - but the re-open is
// a NEW access, not a re-filtering of an existing one, and a new access must never be
// issued on behalf of somebody who did not ask for it.
// The page becomes Alice, which a live page CAN do: `shared-wallet/access-gate.ts`
// adopts the identifier a revived barrier confirms. The re-attempt is deliberately
// not keyed on the holder - that rule left a refusal taken before the identity
// settled, and one taken before an identity change, both permanently stuck - so a
// query may well be opened while she is the one connected.
setCurrentUser("alice");
getCaps().learn(mintCap("did:ng:o:unrelated-arrival"));
for (let i = 0; i < 10; i++) await tick();
expect(orm.openCount()).toBe(0); // nothing was subscribed or queried as Alice
for (let i = 0; i < 12; i++) await tick();
// ...and Bob's view must not come back as a SUCCESS holding nothing, which is the lie
// this whole task exists to remove, arriving by the repair's own door.
// ...and back under Bob the answer has to be the refusal. What guarantees it is that
// the named documents are re-checked on every publication rather than remembered
// from the barrier: whoever opened the query, this subscriber is told what IT may
// read. `isSuccess` holding nothing here would be the exact lie this task removes,
// arriving through the repair's own door.
setCurrentUser("bob");
getCaps().learn(mintCap("did:ng:o:unrelated-arrival-2"));
for (let i = 0; i < 10; i++) await tick();
expect(obs.getSnapshot().isSuccess).toBe(false);
expect(obs.getSnapshot().isError).toBe(true);
for (let i = 0; i < 12; i++) await tick();
const snap = obs.getSnapshot();
expect(snap.isSuccess).toBe(false);
expect(snap.isError).toBe(true);
// `data` is deliberately NOT asserted empty here. Under `isError` this surface keeps
// the last read that ANSWERED - an observable cannot un-emit - and what answered was
// the publication made while Alice was connected. That an observable follows whoever
// is connected rather than whoever subscribed is older than this work and untouched
// by it: at `5cbcabe`, before any of this, `readable()` already consulted the current
// holder and `caps.onChange` already re-published through it. Reported as a
// follow-up, not repaired here. What this test pins is the STATE, which is what an
// application branches on and what the refusal is about.
// And Alice, connected again, is not refused her own document - the check is about
// the holder of the moment, not a grudge held from the first attempt.
setCurrentUser("alice");
getCaps().learn(mintCap("did:ng:o:unrelated-arrival-3"));
for (let i = 0; i < 20 && !obs.getSnapshot().isSuccess; i++) await tick();
expect(titles(obs.getSnapshot().data)).toEqual(["Hers"]);
unsub();
});
@@ -916,6 +944,111 @@ describe("useShapeQuery", () => {
unsub();
});
// -- the edge that is MISSED, which is the shape of the whole problem -----
//
// Recovery had been built twice as "record that we refused, then react to a later
// event". Both times the record was the fault: once gated on a condition that could
// not become true, once set too late to catch the event that caused the refusal. An
// edge that is missed is not recoverable by construction, so these pin the races
// rather than the flags.
it("(y) a refusal caused BY the last keyring event still converges - the missed edge", async () => {
const ng = makeFake();
const orm = makeOrm();
inject(ng, orm);
setCurrentUser("alice");
const pub = await createEntityDoc("alice", "public");
seedEvent(orm, pub, `${pub}:q:one`, "Public");
// A fresh page over the same broker: the document and its exposed cap are on the
// wire, this registry holds nothing, and nothing is enforcing yet.
setCurrentUser("bob");
resetCaps();
expect(getCaps().isEnforcing()).toBe(false);
const obs = useShapeQuery(EventShape, { graphs: [pub] });
const unsub = obs.subscribe(() => {});
// The barrier is IN FLIGHT: inert, so it skipped the public-store ask entirely and is
// waiting on the sync barrier. The connection burst files this holder's first cap
// right now, which flips the registry to enforcing - and it is that very event that
// makes the walk refuse a moment later. There is no keyring event after it.
getCaps().mint("did:ng:o:the-first-cap-of-the-session");
for (let i = 0; i < 40 && !obs.getSnapshot().isSuccess; i++) await tick();
// `refetch()` would answer at once, so the error was for nothing: the state was
// recoverable the whole time and only the RECOVERY was missing.
const snap = obs.getSnapshot();
expect(snap.isError).toBe(false);
expect(snap.isSuccess).toBe(true);
expect(titles(snap.data)).toEqual(["Public"]);
unsub();
});
it("(z) a refusal taken while nobody is connected converges once the identity settles", async () => {
const ng = makeFake();
const orm = makeOrm();
inject(ng, orm);
setCurrentUser("alice");
const hers = await createEntityDoc("alice", "protected");
seedEvent(orm, hers, `${hers}:q:one`, "Hers");
// Refused with nobody connected - the state a page is in before its identity has
// settled, which is where components mount. The holder is the anonymous ring.
setCurrentUser(null);
const obs = useShapeQuery(EventShape, { graphs: [hers] });
const unsub = obs.subscribe(() => {});
for (let i = 0; i < 20 && !obs.getSnapshot().isError; i++) await tick();
expect(obs.getSnapshot().isError).toBe(true);
// ...and the identity settles, holding the cap. This is the ORDINARY sign-in path, not
// an identity swap: `access-gate` adopts the settled identifier and the connection
// work files what this holder holds. A recovery keyed on "the same holder as the one
// refused" never fires here, because the refusal was recorded for nobody.
setCurrentUser("alice");
getCaps().learn(mintCap(hers));
for (let i = 0; i < 20 && !obs.getSnapshot().isSuccess; i++) await tick();
const snap = obs.getSnapshot();
expect(snap.isError).toBe(false);
expect(titles(snap.data)).toEqual(["Hers"]);
unsub();
});
it("(aa) a standing refusal that has not changed does not re-publish a new snapshot", async () => {
const ng = makeFake();
const orm = makeOrm();
inject(ng, orm);
setCurrentUser("alice");
const pub = await createEntityDoc("alice", "public");
seedEvent(orm, pub, `${pub}:q:one`, "Public");
setCurrentUser("bob");
ng._unreachable.add(pub); // the fetch FAILS, which is the class that is re-asked
const obs = useShapeQuery(EventShape, { graphs: [pub] });
let notes = 0;
const unsub = obs.subscribe(() => {
notes += 1;
});
for (let i = 0; i < 20 && !obs.getSnapshot().isError; i++) await tick();
const refusedSnapshot = obs.getSnapshot();
const notesAfterRefusal = notes;
// Eight unrelated arrivals, the broker still down. Each re-attempt genuinely re-asks
// - that is the repair for this class and it is not a cost to avoid - but the ANSWER
// has not moved, and this surface documents its snapshot as stable until it actually
// changes. Handing subscribers eight fresh objects carrying eight fresh `Error`s for
// an unchanged state breaks that, and makes every bound view re-render for nothing.
for (let round = 0; round < 8; round++) {
getCaps().learn(mintCap(`did:ng:o:unrelated-${round}`));
for (let i = 0; i < 4; i++) await tick();
}
expect(obs.getSnapshot().isError).toBe(true);
expect(obs.getSnapshot()).toBe(refusedSnapshot); // the SAME object, not an equal one
expect(notes).toBe(notesAfterRefusal);
unsub();
});
// -- teardown ------------------------------------------------------------
it("(k) the last unsubscribe closes the shape query", async () => {