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
@@ -300,19 +300,20 @@ export function resetOpenedRepos(): void {
// @provenance getSyncState kind=aligned level=1 ref=engine/net/src/app_protocol.rs:AppResponseV0 — reports whether the first `State` arrived; upstream this is not observable from JS
export function getSyncState(nuri: Nuri): SyncState | "unknown" {
const channel = docChannel(nuri);
// The first `State` on the channel STANDING NOW - both halves, and the second was missing in
// this repair's first draft. `barrierCrossed` alone said `"synced"` for a fan-out whose
// establish had been REFUSED after pushing a `State`: `reportSetupFailure` clears
// `establishing` and leaves `lastState`, so the barrier outlived the attempt that produced it,
// and `"synced"` is AUTHORITY - `index-deposit.ts` `noRowsMeansAbsent` then calls a zero-row
// read a definitive absence. The previous registry answered `"timed-out"` there, and strict is
// the reading this family is built on: the cost of it is latency, never loss.
// The first `State` on the channel standing NOW. {@link docChannel} guarantees both halves -
// it does not report a barrier while no channel stands - so this asks the one question, and
// that is deliberate: a first draft of this repair re-imposed the second half HERE instead,
// which only MASKED a stale barrier rather than forgetting it. The next subscribe on the same
// fan-out brought it straight back, was replayed it, crossed its own barrier in 2ms on a
// `State` from a call that had FAILED, and answered `"synced"` - which is AUTHORITY, so
// `index-deposit.ts` `noRowsMeansAbsent` called a zero-row read a definitive absence and the
// deposit was destroyed. A guard at the reader cannot fix a fact that is wrong at the source.
//
// Asked of the fan-out rather than remembered, because that is the one record of it: a barrier
// is forgotten when its channel is released and when its session changes. Answered even for a
// document this module never opened - an application's own subscription crosses the same
// barrier, and `"synced"` is then simply true.
if (channel.state !== "none" && channel.barrierCrossed) return "synced";
// is forgotten when its attempt fails, when its channel is released and when its session
// changes. Answered even for a document this module never opened - an application's own
// subscription crosses the same barrier, and `"synced"` is then simply true.
if (channel.barrierCrossed) return "synced";
const open = opens.get(nuri);
// Never opened here, or opened on the no-`doc_subscribe` path, which records nothing
// because there is no barrier to have missed.
@@ -299,6 +299,14 @@ export function docChannel(nuri: Nuri): DocChannel {
// two together are the only thing that separates "asked" from "open" - and recording the
// first as if it were the second is the defect this accessor exists to make impossible.
const state: DocChannelState = !entry.establishing ? "none" : entry.realUnsub !== null ? "open" : "opening";
// `lastState` alone, and deliberately NOT `&& state !== "none"`. Every path that stops an
// attempt standing forgets the barrier with it - {@link releaseFanOut}, {@link resubscribeDocs}
// and, since this was found the hard way, {@link reportSetupFailure} - so the two can no longer
// disagree, and a guard here would be unreachable. It would also be a MASK: the first repair of
// that defect put exactly this condition in the reader instead of forgetting the barrier at the
// source, and it hid the fault while the fan-out went on replaying a dead `State` to the next
// joiner. A fact that is wrong at the source has to be fixed at the source; a reader that
// papers over it only moves the lie.
return { state, barrierCrossed: entry.lastState !== null, incarnation: entry.incarnation };
}
@@ -383,6 +391,15 @@ function reportSetupFailure(nuri: Nuri, entry: DocFanOut, error: unknown, attemp
console.error("[subscribe] doc_subscribe failed for", nuri, error);
if (!isCurrentAttempt(nuri, entry, attempt)) return;
entry.establishing = false;
// A BARRIER BELONGS TO THE ATTEMPT THAT PRODUCED IT, and this one failed. The push may well
// have arrived - the callback is driven locally while the call's own answer travels the RPC -
// but the call did not take, so there is no subscription behind that `State` and nothing will
// follow it. Left here, the fan-out replayed it to the next joiner: a bootstrap open crossed
// its barrier in 2ms on a `State` from a call that had FAILED, logged `BARRIER ... synced`,
// and `getSyncState` answered `"synced"` - which is authority, so a zero-row read over the
// document became a definitive absence and a deposit was destroyed. {@link releaseFanOut}
// and {@link resubscribeDocs} both forget it for the same reason; this path did not.
entry.lastState = null;
for (const listener of [...entry.listeners]) {
const tell = listener.onSetupFailed;
if (tell === null || !entry.listeners.has(listener)) continue;