fix: trois chemins vers une inbox en double, et la lecture qui manquait
Une application a rapporté quatre appels simultanés sur un même document enregistrant trois inboxes. Le contrat garantissait l'inverse. En cherchant, on en a trouvé DEUX autres, indépendantes, qui produisent le même dégât durable : le propriétaire surveille une inbox pendant que les dépôts arrivent dans une autre. La concurrence. openDocumentInbox ne partageait rien avec userInbox — module différent, registre propre, aucune coalescence. Reproduit pire que rapporté : quatre appels donnaient QUATRE inboxes. Une carte en vol par (détenteur, document), et le corps déplacé pour que l'invariant soit porté par la composition plutôt que par la position d'une vérification. La limite est nommée plutôt que cachée : deux onglets ne partagent aucune carte, chacun lit, chacun ne trouve rien, chacun frappe. Ce n'est pas réparable ici — une branche est en ajout seul, et ça ne se réconcilie pas après coup, le propriétaire lisant sa branche User quand un déposant lit l'adresse publiée du document. Le contrat porte donc une garantie positive ET une non-garantie. La page froide. readInboxCapPairs était le seul lecteur de store sans barrière, correct uniquement parce qu'une autre fonction s'exécutait avant lui à la connexion. Une dépendance d'ordre, pas une garantie portée par la lecture : sur une page froide il lisait le store privé non synchronisé, répondait « aucune inbox » et en frappait une seconde. Un seul appel, aucune concurrence. La barrière est désormais dans la lecture, et elle ne coûte rien aux chemins connectés, la connexion ayant déjà ouvert les trois stores. Et la lecture qui manquait. readSynced donnait la garantie, readForDocument l'adressage, pas leur intersection — si bien que matérialiser des dépôts obligeait une application à résoudre une adresse d'inbox elle-même, ce que le contrat lui interdit explicitement. inbox.readSyncedForDocument la lui épargne. Elle traverse deux dépôts, l'adresse vivant sur l'en-tête du document et les dépôts sur l'inbox — franchir la barrière sur la seule inbox ne réparait rien. Au passage, le compteur d'identifiants de la doublure était par page : une page rechargée refrappait le même identifiant PAR-DESSUS une inbox existante, aliasant deux dépôts en silence. Il est monotone.
This commit is contained in:
@@ -25,7 +25,11 @@ import {
|
||||
resolveWriteGraph,
|
||||
userInbox,
|
||||
} from "../src/shared-wallet/account-registry";
|
||||
import { documentInboxAddress, openDocumentInbox } from "../src/emulated-verifier/branch-registers";
|
||||
import {
|
||||
documentInboxAddress,
|
||||
openDocumentInbox,
|
||||
readInboxCapPairs,
|
||||
} from "../src/emulated-verifier/branch-registers";
|
||||
import type { RegistrySession } from "../src/shared-wallet/account-registry";
|
||||
import { configure } from "../src/index";
|
||||
import { configureStoreRegistry, setCurrentUser } from "../src/shared-wallet/bootstrap";
|
||||
@@ -611,6 +615,88 @@ test("opening an inbox publishes ONE address, and re-opening does not accumulate
|
||||
expect((await readInbox(dedicated)).map((d) => d.payload)).toEqual([{ signingUp: true }]);
|
||||
});
|
||||
|
||||
// CONCURRENCY, and it is NOT the "a failure resolved like a success" family this suite is
|
||||
// otherwise full of: nothing here fails. `openDocumentInbox` reads "have I already opened
|
||||
// one" and mints when the answer is no, with a dozen awaits between the two — so callers
|
||||
// that ask at the same time each look, each honestly finds nothing, and each mints. The
|
||||
// sequential test above passes because the first call's write has landed before the second
|
||||
// one reads. Reported by a consuming application: four simultaneous calls on one document
|
||||
// registered THREE inboxes in 0.3s, after which the owner drained one while deposits
|
||||
// arrived in another — the same end state as a fork, reached without an error anywhere.
|
||||
test("concurrent opens on ONE document converge on ONE inbox", async () => {
|
||||
inject();
|
||||
setCurrentUser("alice");
|
||||
const doc = await createEntityDoc("alice", "public");
|
||||
|
||||
// Four at once — no await between them, which is what an application does when four
|
||||
// components mount together and each opens the inbox of the document it renders.
|
||||
const handed = await Promise.all([
|
||||
openDocumentInbox(doc),
|
||||
openDocumentInbox(doc),
|
||||
openDocumentInbox(doc),
|
||||
openDocumentInbox(doc),
|
||||
]);
|
||||
expect(new Set(handed).size).toBe(1);
|
||||
|
||||
// …and the DURABLE record has to agree, which is the half that actually bites: a second
|
||||
// `AddInboxCap` pair means `readInboxCapsFor` picks one of two afterwards, and the
|
||||
// published address is whichever write landed last. One pair, one address, and the
|
||||
// address is what every caller was handed.
|
||||
const pairs = (await readInboxCapPairs()).filter((p) => p.doc === doc);
|
||||
expect(pairs.map((p) => p.inbox)).toEqual([handed[0]!]);
|
||||
|
||||
resetRegistryCache(); // a depositor's session, not a warmed cache
|
||||
setCurrentUser("bob");
|
||||
expect(await documentInboxAddress(doc)).toBe(handed[0]!);
|
||||
await postToDocument(doc, { payload: { racing: true } });
|
||||
setCurrentUser("alice");
|
||||
expect((await readInbox(handed[0]!)).map((d) => d.payload)).toEqual([{ racing: true }]);
|
||||
});
|
||||
|
||||
// The coalescing must not outlive the call that needed it, nor answer for a DIFFERENT
|
||||
// document: a map keyed too coarsely (or never emptied) would pass the test above while
|
||||
// handing document B the inbox opened for A.
|
||||
test("concurrent opens on DIFFERENT documents get their own inbox each", async () => {
|
||||
inject();
|
||||
setCurrentUser("alice");
|
||||
const a = await createEntityDoc("alice", "public");
|
||||
const b = await createEntityDoc("alice", "public");
|
||||
|
||||
const [inboxA, inboxB] = await Promise.all([openDocumentInbox(a), openDocumentInbox(b)]);
|
||||
expect(inboxA).not.toBe(inboxB);
|
||||
expect((await readInboxCapPairs()).filter((p) => p.doc === a).map((p) => p.inbox)).toEqual([inboxA!]);
|
||||
expect((await readInboxCapPairs()).filter((p) => p.doc === b).map((p) => p.inbox)).toEqual([inboxB!]);
|
||||
|
||||
// …and a LATER burst, once the record exists, still answers with the recorded inbox
|
||||
// rather than treating "the in-flight map is empty" as "nobody opened one".
|
||||
const again = await Promise.all([openDocumentInbox(a), openDocumentInbox(a)]);
|
||||
expect(again).toEqual([inboxA!, inboxA!]);
|
||||
});
|
||||
|
||||
// A refusal must not be cached as an answer, and must not leave a poisoned entry behind
|
||||
// for the callers that follow: Bob asking four times at once gets four refusals, and
|
||||
// Alice's own record is untouched.
|
||||
test("concurrent opens by a NON-owner are all refused, and leave no residue", async () => {
|
||||
inject();
|
||||
setCurrentUser("alice");
|
||||
const doc = await createEntityDoc("alice", "public");
|
||||
const aliceInbox = await openDocumentInbox(doc);
|
||||
|
||||
resetRegistryCache(); // another session, not a warmed cache
|
||||
setCurrentUser("bob");
|
||||
const outcomes = await Promise.allSettled([
|
||||
openDocumentInbox(doc),
|
||||
openDocumentInbox(doc),
|
||||
openDocumentInbox(doc),
|
||||
openDocumentInbox(doc),
|
||||
]);
|
||||
expect(outcomes.map((o) => o.status)).toEqual(["rejected", "rejected", "rejected", "rejected"]);
|
||||
|
||||
setCurrentUser("alice");
|
||||
expect((await readInboxCapPairs()).filter((p) => p.doc === doc).map((p) => p.inbox)).toEqual([aliceInbox]);
|
||||
expect(await documentInboxAddress(doc)).toBe(aliceInbox);
|
||||
});
|
||||
|
||||
test("the inbox address is machinery: it never surfaces as the document's data", async () => {
|
||||
inject();
|
||||
setCurrentUser("alice");
|
||||
|
||||
Reference in New Issue
Block a user