fix: une barriere appartient a la tentative qui l'a produite

Seconde revue adverse, sur le correctif precedent - et elle a raison :
celui-ci MASQUAIT la barriere perimee au lieu de l'oublier.

`reportSetupFailure` effacait `establishing` et laissait `lastState`. Le
premier correctif avait ajoute « et un canal tient » du cote du LECTEUR,
ce qui ne cachait la barriere morte que tant que rien ne tenait. La
souscription suivante sur le meme fan-out remettait `establishing` a
vrai, le masque tombait - et le fan-out rejouait ce `State` au nouvel
arrivant. Mesure : l'ouverture bootstrap franchissait sa barriere en 2 ms
sur un `State` d'un appel qui avait ECHOUE, journalisait
`BARRIER ... synced`, et `getSyncState` repondait `"synced"` - donc
autorite, donc `noRowsMeansAbsent` prenait une lecture a zero ligne pour
une absence definitive. Le depot detruit que le commit precedent
pretendait empecher.

Corrige a la source : une tentative qui echoue oublie sa barriere, comme
le font deja `releaseFanOut` et `resubscribeDocs`. Le garde du lecteur
est RETIRE avec, et pas garde en ceinture-bretelles : il etait devenu
inatteignable, et c'est lui qui avait cache la faute. Apres : la barriere
est oubliee, l'ouverture attend la sienne (202 ms), `"timed-out"`.

Et le test que j'avais ecrit affirmait le defaut - `barrierCrossed`
vrai - au lieu de l'etat correct. Corrige, plus un second test qui
conduit le rejeu de bout en bout.
This commit is contained in:
Sylvain Duchesne
2026-09-24 13:32:44 +02:00
parent 434838110d
commit 226db18054
3 changed files with 85 additions and 23 deletions
+56 -12
View File
@@ -367,15 +367,15 @@ describe("ensureRepoOpen", () => {
expect(getSyncState(doc)).toBe("synced");
});
// A barrier must not outlive the attempt that produced it.
// A BARRIER BELONGS TO THE ATTEMPT THAT PRODUCED IT - and must be FORGOTTEN when that attempt
// fails, not merely ignored while nothing stands.
//
// `subscribe.ts` clears `establishing` on a refused establish and LEAVES the remembered
// `State`, so a call that pushed the barrier and then failed leaves the fan-out with a
// barrier and no channel. Reading `"synced"` off that is AUTHORITY - `index-deposit.ts`
// `noRowsMeansAbsent` calls a zero-row read over it a definitive absence, which destroys a
// deposit - and the registry this replaced answered `"timed-out"` there. Strict is the
// reading: the cost of it is latency, never loss.
it("a barrier pushed by a subscribe that then FAILED is not authority", async () => {
// The push can arrive and the call still fail: the callback is driven locally while the call's
// own answer travels the RPC. `subscribe.ts` cleared `establishing` on a refused establish and
// LEFT the remembered `State`, so the fan-out held a barrier with no subscription behind it.
// The first repair only made `getSyncState` ignore it while the channel read `"none"`, which
// MASKED it - see the next test for what the mask let through.
it("a barrier pushed by a subscribe that then FAILED is forgotten, not merely ignored", async () => {
const doc_subscribe = mock(async (_n: string, _s: string, cb: (r: unknown) => void) => {
cb({ V0: { State: {} } }); // the barrier arrives...
throw new Error("RepoNotFound: after the push"); // ...and the call then fails
@@ -390,18 +390,62 @@ describe("ensureRepoOpen", () => {
setOpenTimeoutForTests(20);
const doc = "did:ng:o:pushed-then-failed";
// Something else holds the fan-out, so the refusal does not take the entry with it and the
// remembered barrier survives the attempt that pushed it.
// Something else holds the fan-out, so the refusal does not take the entry with it - which is
// what would have cleared the barrier for the wrong reason.
const other = subscribeDoc(doc, () => {});
await ensureRepoOpen(doc);
await new Promise((r) => setTimeout(r, 10));
expect(docChannel(doc).barrierCrossed).toBe(true); // the barrier IS remembered...
expect(docChannel(doc).state).toBe("none"); // ...and nothing stands behind it
expect(docChannel(doc).barrierCrossed).toBe(false); // forgotten with the attempt
expect(docChannel(doc).state).toBe("none");
expect(getSyncState(doc)).toBe("timed-out");
other();
});
// What MASKING the stale barrier let through, and it is the destroyed deposit this whole family
// exists to prevent.
//
// A fan-out that kept the `State` of a FAILED call replays it to the next joiner. So the next
// subscribe restored `establishing`, the channel left `"none"`, the mask lifted - and a
// bootstrap open crossed its barrier in two milliseconds on a `State` from a call that had
// never taken, logging `BARRIER ... synced`. `getSyncState` then answers `"synced"`, which is
// AUTHORITY: `index-deposit.ts` `noRowsMeansAbsent` calls a zero-row read a definitive absence.
it("the next subscribe is not handed the barrier of a call that FAILED", async () => {
let calls = 0;
const doc_subscribe = mock(async (_n: string, _s: string, cb: (r: unknown) => void) => {
calls += 1;
if (calls === 1) {
cb({ V0: { State: {} } });
throw new Error("RepoNotFound: after the push");
}
return await new Promise<never>(() => {}); // the retry never answers
});
const ng = {
doc_subscribe,
sparql_query: mock(async () => ({ results: { bindings: [] } })),
doc_create: mock(async () => "did:ng:o:new"),
sparql_update: mock(async () => undefined),
};
inject(ng as unknown as ReturnType<typeof makeFakeNgWithSubscribe>);
setOpenTimeoutForTests(200);
const doc = "did:ng:o:replayed-corpse";
const app = subscribeDoc(doc, () => {}); // the application's own subscription fails
await new Promise((r) => setTimeout(r, 20));
// The bootstrap open joins the SAME fan-out and starts a second establish.
const at = Date.now();
await ensureRepoOpen(doc);
const paid = Date.now() - at;
expect(calls).toBe(2); // a real second attempt was made...
expect(paid).toBeGreaterThanOrEqual(150); // ...and this open waited its OWN barrier out
expect(docChannel(doc).barrierCrossed).toBe(false);
expect(getSyncState(doc)).toBe("timed-out"); // never authority over a corpse
app();
resetOpenedRepos();
});
// `release` does not answer "am I still listening", and treating it as though it did leaves the
// document held by somebody else's listener.
//