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
@@ -376,7 +376,29 @@ export class CapRegistry {
};
}
/**
* How many times this registry has CHANGED - a monotonic counter, bumped wherever
* {@link notify} is.
*
* It exists because a listener is an EDGE and some readers need the LEVEL. A reader
* that starts an asynchronous piece of work whose outcome depends on what is held can
* sample this before and compare after: a change that landed while the work was in
* flight fired its listener when there was nothing yet to act on, so the edge is gone
* and only this comparison can recover it. `surface/use-shape-query.ts` is that reader,
* and it was stuck exactly there through two rounds of review.
*
* Cheap, and deliberately not a timestamp: what a caller needs is "did anything move
* between these two points", never when or what.
*/
version(): number {
return this.changes;
}
/** Bumped on every {@link notify} - see {@link version}. */
private changes = 0;
private notify(): void {
this.changes += 1;
for (const l of this.listeners) {
try {
l();
@@ -93,6 +93,14 @@ export type SyncState = "syncing" | "synced" | "timed-out";
const opened = new Set<Nuri>();
/** In-flight opens, so concurrent `ensureRepoOpen(nuri)` share one subscription. */
const inFlight = new Map<Nuri, Promise<void>>();
/**
* How to END an in-flight open from outside it - see {@link resetOpenedRepos}, the only
* caller. An open normally settles on the first `State` or on its own bounded fallback,
* and both of those live inside the subscription; a reset tears that subscription down,
* so without this the open is left with nothing that can ever finish it but a timer whose
* whole purpose has gone.
*/
const settleInFlight = new Map<Nuri, () => void>();
/** Live bootstrap subscriptions, kept for the session (this is what holds repos open). */
const held = new Map<Nuri, Unsubscribe>();
/** Explicit per-nuri sync state — the barrier signal, distinct from `opened`.
@@ -132,10 +140,26 @@ export function resetOpenedRepos(): void {
/* ignore */
}
}
// END the opens this reset abandons, BEFORE the maps go: a caller is already awaiting
// one, and the `State` it was waiting for cannot arrive now that the subscription above
// has been torn down. It used to wait out the full fallback - eight seconds of a read
// going nowhere - and then proceed with its sync state CLEARED, which reads as
// `"unknown"`; and `unknown` is the LAX answer (`emulated-verifier/index-deposit.ts`
// `noRowsMeansAbsent` accepts it), so a zero-row read taken right after a session change
// could be called a definitive absence. Marked `"timed-out"` instead, which is the exact
// truth - opened best-effort, sync unconfirmed - and the reading that refuses to call
// nothing an absence. The abandoned open's own fallback fires later and sets the same
// value, so the two cannot disagree.
const abandoned = [...settleInFlight.entries()];
opened.clear();
inFlight.clear();
settleInFlight.clear();
held.clear();
syncState.clear();
for (const [nuri, settle] of abandoned) {
syncState.set(nuri, "timed-out");
settle();
}
boundSessionId = null;
OPEN_TIMEOUT_MS = 8000;
// Whatever ELSE was still subscribed to a document - an inbox observation, an
@@ -259,6 +283,13 @@ export async function openRepoUnguarded(nuri: Nuri): Promise<void> {
const p = (async () => {
await new Promise<void>((resolve) => {
let settled = false;
// Handed to `resetOpenedRepos`, so an open this session abandons can be ended
// rather than left hanging on a subscription that no longer exists.
settleInFlight.set(nuri, () => {
if (settled) return;
settled = true;
resolve();
});
// Resolve on the first `State` (the sync BARRIER), marking the nuri "synced".
const onState = (): void => {
if (settled) return;
@@ -292,6 +323,7 @@ export async function openRepoUnguarded(nuri: Nuri): Promise<void> {
});
opened.add(nuri);
inFlight.delete(nuri);
settleInFlight.delete(nuri);
})();
inFlight.set(nuri, p);
return p;