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
+125 -24
View File
@@ -118,7 +118,27 @@
* surface, it is a worse one - and it broke a guarantee this package publishes, which
* `emulated-verifier/read-filter.ts` and `surface/inbox.ts` both restate.
*
* So the signal re-ATTEMPTS a refused scope, for the holder that was refused.
* So the signal re-ATTEMPTS a refused scope. Two things make that sound, and both were
* arrived at by getting them wrong first:
*
* - **the decision is a LEVEL, not a memory.** Which documents the holder may reach is
* re-read on every publication ({@link publish}), never carried over from the barrier.
* So a cap arriving turns a refusal into the document through the channel that
* already existed, and a query opened while somebody else was connected cannot come
* back to this subscriber as a success it is not entitled to. Nothing is remembered,
* so there is no event to miss.
* - **an attempt that RACED a change notices, rather than waiting.** A keyring change
* landing while the barrier is crossing fires its listener when there is no query to
* re-publish and no refusal on record - and that same change is often what makes the
* walk refuse. The edge is simply gone. So the attempt samples `CapRegistry.version()`
* before it starts and compares after it fails; a mismatch means it decided on stale
* ground and it makes another attempt itself.
*
* This is the third shape this recovery has had, and the first two failed the same way -
* each recorded that it had refused and then waited for a later event. Round 2 gated the
* record on a condition that could not become true; round 3 set the record after the
* window in which the deciding event arrives. An edge that is missed is not recoverable
* by construction, which is why the answer here is to stop depending on edges.
*
* -- Why there is no cleverer gate than "attempt again" ----------------------------
* The obvious one is to re-open only once the refusal has been lifted - "does the holder
@@ -240,6 +260,19 @@ function namedDocuments(scope: Scope): Nuri[] {
return out;
}
/**
* Are these two failures the same one, for the purpose of deciding whether a subscriber
* has anything new to look at?
*
* By MESSAGE, because every attempt builds its own `Error` object and identity would
* therefore always answer "different". A message is what an application shows and what a
* maintainer greps for, so two failures that read alike are alike here.
*/
function sameFailure(a: unknown, b: unknown): boolean {
if (a === b) return true;
return a instanceof Error && b instanceof Error && a.message === b.message;
}
/**
* Create a reactive, `useQuery`-shaped observable over one SHEX `shapeType` in one
* `scope` - the SDK's own `{ graphs, subjects }`, passed through untouched. See the
@@ -280,24 +313,24 @@ export function useShapeQuery<T extends BaseType = BaseType>(
* a change signal that beats `readyPromise` must not be read as an answer. */
let ready = false;
/**
* WHOSE refusal is standing, or `null` when the barrier is not what failed.
* Is the BARRIER what left this observable without a query - rather than the shape
* query failing to open, or nothing having been tried yet?
*
* It carries two things the keyring signal needs, and the second is what a first
* attempt at this got wrong.
* Only the first is worth attempting again: a `configure` with no `ormSubscription` is
* not repaired by a cap arriving, and retrying it on every keyring change would churn
* for a misconfiguration no amount of retrying repairs.
*
* - **That the BARRIER is what failed**, rather than the shape query failing to open
* or nothing having been tried yet. A `configure` with no `ormSubscription` is not
* going to be repaired by a cap arriving, so retrying it on every keyring change
* would churn for a misconfiguration no amount of retrying repairs.
* - **For WHICH holder.** A re-open is not a re-publication: it issues a subscribe
* and a public-store fetch for a document THIS subscriber named. Deciding to make
* that request from whatever identity happens to be connected when an unrelated cap
* lands would perform an access on behalf of somebody who never asked for it - and
* leave the original subscriber reading a query opened in another name, which comes
* back as `isSuccess` holding nothing once its own filter runs. That is the exact
* lie this barrier exists to remove, arriving through the repair's own door.
* It records no HOLDER, and an earlier version did. Keying the retry on "the same
* holder that was refused" looks like the way to stop a re-attempt acting for somebody
* who never asked, and it is a trap twice over: the anonymous ring is a holder like any
* other, so a refusal taken before the identity settles - which is where components
* mount - never matched again once it did; and an identity CAN change under a live page
* (`shared-wallet/access-gate.ts` adopts the identifier a revived barrier confirms), so
* the rule left that case terminal too. What actually has to hold is that no snapshot
* ever claims success for a document the CURRENT holder cannot reach, and that is
* enforced where it belongs, on every publication - see {@link publish}.
*/
let refusedFor: string | null = null;
let barrierRefused = false;
function emit(): void {
for (const l of listeners) {
@@ -328,6 +361,16 @@ export function useShapeQuery<T extends BaseType = BaseType>(
* initial value, published under `isError`, never under `isSuccess`.
*/
function setUnknown(error: unknown): void {
// A standing failure that has not changed is not news. This surface documents its
// snapshot as STABLE until it actually changes, and a re-attempt that lands on the
// same refusal changes nothing an application renders - so handing out a fresh object
// carrying a fresh `Error` would break that promise and re-render every bound view
// for nothing. Measured: eight unrelated cap arrivals over a failing fetch produced
// eight notifications and eight distinct `Error`s for one unchanged state.
//
// Compared by MESSAGE rather than by identity, because each attempt builds its own
// `Error`. A failure that says something different IS news and is published.
if (snapshot.isError && sameFailure(snapshot.error, error)) return;
setSnapshot({
data: snapshot.data,
isPending: false,
@@ -355,10 +398,49 @@ export function useShapeQuery<T extends BaseType = BaseType>(
return kept as T[];
}
/** Publish what the subscription holds right now. Only ever called on a READY
* generation, so this snapshot is always an answer. */
/**
* The refusal standing against the CURRENT holder for the first document the scope
* NAMES that it cannot reach, or `null` when it reaches all of them.
*
* It goes through `assertMayReach` and catches, rather than deciding again: the refusal
* is worded in exactly one place and a second wording here would drift from it. Scope
* order, like the barrier.
*/
function namedRefusal(): unknown {
for (const doc of namedDocuments(scope)) {
try {
assertMayReach(doc, "useShapeQuery");
} catch (error) {
return error;
}
}
return null;
}
/**
* Publish what the subscription holds right now. Only ever called on a READY
* generation, so this snapshot is always an answer.
*
* **The named documents are re-checked HERE, every time, and that is what makes the
* refusal self-healing.** The barrier decides once, before the query opens; this
* decides on each publication, against what the holder holds at that instant - so a cap
* arriving turns `isError` into the document through the channel that already existed,
* and a query opened while somebody else was connected can never come back to this
* subscriber as `isSuccess` holding nothing. A level, not an edge: there is no event to
* miss, because nothing is remembered.
*
* The capability FILTER below still applies on top, and the two are not the same
* question: the filter bounds what a scope may show per `@graph`, this refuses a scope
* the caller NAMED and cannot read. Under `did:ng:i` nothing is named and the filter is
* the whole of the answer, exactly as before.
*/
function publish(handle: OrmSubscriptionHandle, gen: number): void {
if (gen !== generation || !ready) return;
const refusal = namedRefusal();
if (refusal !== null) {
setUnknown(refusal);
return;
}
setSnapshot({
data: readable(handle),
isPending: false,
@@ -461,13 +543,31 @@ export function useShapeQuery<T extends BaseType = BaseType>(
function open(): void {
closeQuery();
const gen = ++generation;
refusedFor = null;
barrierRefused = false;
// Sampled SYNCHRONOUSLY, before anything is awaited: what the keyring looked like when
// this attempt decided to trust it. See the comparison in the catch below.
const keyringAtStart = getCaps().version();
void (async () => {
try {
await reachNamedDocuments();
} catch (error) {
if (gen !== generation) return;
refusedFor = getCaps().holderKey();
// THE EDGE THAT WOULD OTHERWISE BE MISSED, and it is the one that matters most: a
// keyring change landing WHILE the barrier was crossing fired its listener at a
// moment when there was no query to re-publish and no refusal on record yet, so
// the listener did nothing - and that same change is what made the walk refuse.
// Waiting for another event would be waiting for one that may never come; this
// attempt simply notices it raced and makes another, which re-samples.
//
// It terminates: an attempt is repeated only because the keyring moved, moves are
// finite events, and the moves an attempt causes itself are the caps it files -
// at most one per named document, after which that document is held and asks
// nothing.
if (getCaps().version() !== keyringAtStart) {
open();
return;
}
barrierRefused = true;
setUnknown(error);
return;
}
@@ -522,10 +622,11 @@ export function useShapeQuery<T extends BaseType = BaseType>(
// like that would be TERMINAL - the very case this channel was added to prevent,
// arriving by a door that did not exist when the channel was written.
//
// So ATTEMPT AGAIN, for the holder that was refused. There is deliberately no
// further test of whether the refusal has been lifted: see the note below on why
// the obvious one is circular, and why an attempt is the cheaper question anyway.
if (refusedFor !== null && refusedFor === getCaps().holderKey()) open();
// So ATTEMPT AGAIN. There is deliberately no further test of whether the refusal
// has been lifted, and none of who is asking: see the module header for why the
// obvious gate is circular, why an attempt is the cheaper question anyway, and why
// the holder belongs in {@link publish} rather than here.
if (barrierRefused) open();
});
open();
}