fix!: une lecture qui n'atteint pas son document echoue, elle ne repond plus vide

`fetchReadCap` rendait `false` pour trois situations sans rapport : l'emulation
inerte, le document qui n'expose aucune capacite, et le reseau qu'on n'a pas pu
interroger. Les trois appelants vivants lisaient ce `false` comme « pas de
capacite » et transformaient une panne en affirmation sur le document :
`ensureRepoOpen` renoncait en silence et laissait la lecture suivante accuser le
lecteur de ne rien detenir, et `documentInboxAddress` repondait « ce document n'a
pas d'inbox », ce qu'une application lit comme zero depot.

Le telechargement rejette desormais jusqu'a l'appelant, avec le document nomme et
la cause d'origine conservee. `false` ne veut plus dire qu'une chose : le document
n'expose rien, et c'est une reponse. Le memo garde la meme asymetrie qu'avant.

`documentInboxAddress` propage aussi l'echec de sa propre lecture d'adresse : le
`catch` qui rendait `undefined` se justifiait par un refus que `mustNotAttempt` a
deja ecarte une ligne plus haut, donc il n'avalait que des pannes.

`useShapeQuery` franchit la barriere sur chaque document NOMME par `scope.graphs`
avant d'ouvrir la requete : capacite demandee au store public, refus a voix haute
si elle n'est pas detenue, ouverture et synchronisation. Un document nomme
qu'on ne peut pas lire donnait `isSuccess` avec `data: []` apres que le filtre de
capacites l'eut vide - la seule reponse que cette surface existe pour reserver a
« ce scope ne contient rien ». Restent intacts et succes : `did:ng:i`, `graphs:
[]`, un document joignable et vide, et tout le cas inerte.

`fetchReadCaps` supprime : aucun appelant, et une politique de tolerance par lot
sans proprietaire au-dessus d'un appel qui rejette desormais.
This commit is contained in:
Sylvain Duchesne
2026-09-21 15:00:01 +02:00
parent 6cd96b0182
commit 75313f75a2
8 changed files with 463 additions and 60 deletions
+45 -8
View File
@@ -201,6 +201,14 @@ test("resetting the caps forgets the memo — a stale yes would hand back what i
// transient outage recorded "no capability" for that document until the session ended.
// Deposits being consumed, the deposit was retired before its arm read anything — and
// every re-deposit sent to repair it met the same memoised `null` and was destroyed too.
//
// SECOND HALF (`1.0.0-dev.9`). That repair kept the two negatives apart in the MEMO and
// still handed the caller one value for both: a failure and an absence were both `false`.
// The tests below said so, and what they were really asserting was the memo's arithmetic
// - which is why every `asksAbout` count here is unchanged. What moved is the ANSWER: a
// download that could not happen now rejects, because three call sites read `false` as
// "this document exposes no cap" and turned a breakdown into a statement about the
// document. A genuine absence still answers `false`, and still only asks once.
/** How many times the network was asked ABOUT `nuri` — anchored reads only, so the
* connection work happening in the background on other documents does not count. */
@@ -216,7 +224,8 @@ test("a fetch that FAILED is asked again, and the cap arrives once the broker re
setCurrentUser("bob");
unreachable.add(PUB); // the broker cannot answer for this document
expect(await fetchReadCap(PUB)).toBe(false);
// NOT `false`: "the store could not be asked" is not "the document exposes no cap".
await expect(fetchReadCap(PUB)).rejects.toThrow(/UNKNOWN/);
expect(asksAbout(sparql_query, PUB)).toBe(1);
expect(getCaps().capFor(PUB)).toBeUndefined();
@@ -234,14 +243,16 @@ test("an absence is remembered, a failure is not — the two negatives are not t
setCurrentUser("bob");
const absent = "did:ng:o:exposes-nothing" as Nuri;
expect(await fetchReadCap(absent)).toBe(false);
expect(await fetchReadCap(absent)).toBe(false); // an ANSWER, and `false` is what it is
expect(await fetchReadCap(absent)).toBe(false);
expect(asksAbout(sparql_query, absent)).toBe(1); // read once, and the answer stands
const unreached = "did:ng:o:cannot-ask" as Nuri;
unreachable.add(unreached);
expect(await fetchReadCap(unreached)).toBe(false);
expect(await fetchReadCap(unreached)).toBe(false);
// Not an answer at all, so it does not get to look like one. The two negatives now
// differ in what the caller SEES, not only in what the memo keeps.
await expect(fetchReadCap(unreached)).rejects.toThrow(/UNKNOWN/);
await expect(fetchReadCap(unreached)).rejects.toThrow(/UNKNOWN/);
expect(asksAbout(sparql_query, unreached)).toBe(2); // never answered, so asked every time
});
@@ -252,11 +263,18 @@ test("concurrent askers share ONE failing round-trip, and exactly one re-ask fol
setCurrentUser("bob");
unreachable.add(PUB);
const answers = await Promise.all([fetchReadCap(PUB), fetchReadCap(PUB), fetchReadCap(PUB)]);
expect(answers).toEqual([false, false, false]);
// `allSettled`, so all three outcomes are asserted rather than only the first: a
// failure reaching one asker and not the others would be a worse bug than the one this
// test was written for.
const answers = await Promise.allSettled([
fetchReadCap(PUB),
fetchReadCap(PUB),
fetchReadCap(PUB),
]);
expect(answers.map((a) => a.status)).toEqual(["rejected", "rejected", "rejected"]);
expect(asksAbout(sparql_query, PUB)).toBe(1); // one in-flight fetch served all three
expect(await fetchReadCap(PUB)).toBe(false);
await expect(fetchReadCap(PUB)).rejects.toThrow(/UNKNOWN/);
expect(asksAbout(sparql_query, PUB)).toBe(2); // and the failure left nothing behind
});
@@ -271,7 +289,7 @@ test("a cap obtained after a failure is filed for whoever is connected THEN", as
setCurrentUser("bob");
unreachable.add(PUB);
expect(await fetchReadCap(PUB)).toBe(false);
await expect(fetchReadCap(PUB)).rejects.toThrow(/UNKNOWN/);
unreachable.delete(PUB);
setCurrentUser("carol");
@@ -281,3 +299,22 @@ test("a cap obtained after a failure is filed for whoever is connected THEN", as
setCurrentUser("bob");
expect(getCaps().capFor(PUB)).toBeUndefined(); // bob only ever met the outage
});
// The rejection has to be USABLE, or a caller meeting it learns no more than it did from
// `false`: it must name the document it is about, and it must not lose what actually went
// wrong underneath. `cause` is where the broker's own words survive.
test("the rejection names the document and keeps the original failure on `cause`", async () => {
const { unreachable } = inject();
armEmulation();
setCurrentUser("bob");
const doc = "did:ng:o:outage" as Nuri;
unreachable.add(doc);
const error = await fetchReadCap(doc).then(
() => null,
(e: unknown) => e,
);
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toContain(doc);
expect(String((error as Error).cause)).toContain("cannot serve that overlay");
});