fix!: la garde circulaire disparait, et une nouvelle tentative se fait au nom de qui a ete refuse
Troisieme tour de revue adversariale, trois constats, tous reproduits avant correction. 1. La garde etait CIRCULAIRE, et c'etait structurel. `namedDocumentsReachable` demandait si la capacite est DETENUE. Pour un document en store public, le seul canal qui puisse jamais la deposer est `fetchReadCap` - l'appel meme que la garde retenait. Elle attendait une reponse que seule l'action qu'elle bloquait pouvait produire, et toute cette classe restait irrecuperable. La bonne question n'est pas une meilleure garde, c'est de RETENTER. Les deux facons d'etre refuse se reparent par des choses opposees, et le memo les distingue deja : un echec n'est deliberement pas memoise, donc une nouvelle tentative redemande vraiment au reseau - c'est la reparation de cette classe, pas un cout a eviter ; une absence est memoise, donc une nouvelle tentative ne demande a personne et coute un instantane, jusqu'au jour ou la capacite arrive d'ailleurs, seule chose qui repare celle-la. La garde est supprimee : le module est plus petit qu'au tour precedent. 2. La reouverture faisait un ACCES sous la mauvaise identite. Republier REFILTRE une requete existante ; rouvrir EMET un abonnement et une requete au store public pour un document que cet abonne a nomme. C'est un acte neuf, pas un ancien qui ressort, et l'etat final etait `isSuccess` avec `data: []` pour un document illisible - le mensonge que cette tache supprime, par la porte du correctif. `refusedFor` retient QUI a ete refuse ; on ne retente que tant que c'est encore lui le connecte. Etabli plutot qu'affirme : une application ne peut pas changer d'identite en cours de session. `ensureIdentity` est le seul appel publie qui en etablit une, et `resolveIdentity` commence par `getCurrentUser()`. Corrige quand meme. 3. `Promise.all` court-circuite a la premiere rejection ARRIVEE, et un fetch qui rejette n'attend pas la barriere de synchronisation : il double un refus portant sur un document nomme avant lui. Le comportement est corrige plutot que le commentaire - `allSettled` puis parcours dans l'ordre du scope. Et le quatrieme controle e2e est retire, pas repare : il passait avec toute la machinerie de reprise supprimee. Un controle qui ne peut echouer pour aucune regression de ce qu'il garde se lit comme de la couverture et vaut moins que rien.
This commit is contained in:
@@ -118,11 +118,32 @@
|
||||
* 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-OPENS a refused scope, and does it only when the refusal has actually
|
||||
* been lifted ({@link namedDocumentsReachable}). Re-opening on any keyring change would
|
||||
* re-run the barrier for every cap a connection files - re-asking the network for any
|
||||
* document whose fetch had FAILED, since a failure is deliberately not memoised, and
|
||||
* re-publishing the same error to every subscriber each time.
|
||||
* So the signal re-ATTEMPTS a refused scope, for the holder that was refused.
|
||||
*
|
||||
* -- 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
|
||||
* hold every named document's cap now". It was written that way first, and it is CIRCULAR
|
||||
* for a whole class: for a document in a public store the only channel that can ever file
|
||||
* that cap is `fetchReadCap`, the very call the gate was there to withhold. A scope
|
||||
* refused because the store could not be ASKED then waited on an answer that only the
|
||||
* action it gated could produce, and no number of cap arrivals ever released it. A second
|
||||
* adversarial pass proved it with the broker healing and ten caps landing afterwards.
|
||||
*
|
||||
* Attempting is the cheaper question anyway, because the two ways to be refused are
|
||||
* repaired by opposite things and the memo already tells them apart:
|
||||
*
|
||||
* - **the store could not be asked.** A failure is deliberately NOT memoised
|
||||
* (`public-store.ts`), so a re-attempt genuinely re-asks the network - which is the
|
||||
* whole repair for this class, not a cost to avoid.
|
||||
* - **the store answered and there is no cap to be had.** An absence IS memoised, so a
|
||||
* re-attempt asks nobody: it re-runs the barrier against what is already known and
|
||||
* republishes the same refusal. It costs one snapshot, and it stops the day the cap
|
||||
* arrives from somewhere else, which is the only thing that can repair this class.
|
||||
*
|
||||
* It is self-limiting either way: the moment an attempt succeeds there is a query again,
|
||||
* and this branch is never reached. What it is NOT is a timer - nothing here polls, and
|
||||
* a scope refused in a session where the keyring never moves again stays `isError` until
|
||||
* the caller's own `refetch()`, which is what `isError` has always meant on this surface.
|
||||
*
|
||||
* -- An observable cannot un-emit: `data` survives an error -----------------
|
||||
* A one-shot call rejects and is done. An observable has already handed a list to a
|
||||
@@ -136,7 +157,7 @@
|
||||
import { getCaps, getConfig } from "../shared-wallet/bootstrap";
|
||||
import { filterReadable } from "../emulated-verifier/read-filter";
|
||||
import { ensureRepoOpen } from "../emulated-verifier/open-repo";
|
||||
import { assertMayReach, mayReach } from "../emulated-verifier/reach";
|
||||
import { assertMayReach } from "../emulated-verifier/reach";
|
||||
import { isNuri } from "../model/nuri";
|
||||
import type { Nuri, OrmSubscriptionHandle, OrmSubscriptionLike } from "../model/types";
|
||||
// The SDK's own types, imported as TYPES (erased at build, so no runtime `@ng-org`
|
||||
@@ -259,15 +280,24 @@ export function useShapeQuery<T extends BaseType = BaseType>(
|
||||
* a change signal that beats `readyPromise` must not be read as an answer. */
|
||||
let ready = false;
|
||||
/**
|
||||
* Why there is no query in force: the BARRIER refused this scope, rather than the
|
||||
* shape query failing to open or nothing having been tried yet.
|
||||
* WHOSE refusal is standing, or `null` when the barrier is not what failed.
|
||||
*
|
||||
* The distinction is what the keyring signal needs. A cap arriving can lift a refusal
|
||||
* and can lift nothing else - a `configure` with no `ormSubscription` is not going to
|
||||
* be fixed by one - so re-opening on any keyring change while any error stood would
|
||||
* churn for a misconfiguration that no amount of retrying repairs.
|
||||
* It carries two things the keyring signal needs, and the second is what a first
|
||||
* attempt at this got wrong.
|
||||
*
|
||||
* - **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.
|
||||
*/
|
||||
let barrierRefused = false;
|
||||
let refusedFor: string | null = null;
|
||||
|
||||
function emit(): void {
|
||||
for (const l of listeners) {
|
||||
@@ -391,36 +421,32 @@ export function useShapeQuery<T extends BaseType = BaseType>(
|
||||
* `assertMayReach` says it out loud immediately after - without that, an unheld
|
||||
* document would simply produce no objects and read as a document holding none.
|
||||
*
|
||||
* The opens run TOGETHER and the refusals are read afterwards, in scope order. A first
|
||||
* pass ran them one at a time so that the first unreachable document would be the one
|
||||
* reported; that determinism is preserved by the loop below, while the cost of the
|
||||
* serial form was not acceptable - each open waits on the sync barrier, bounded by
|
||||
* `OPEN_TIMEOUT_MS` (8s) for a document that never pushes a `State`, so a scope naming
|
||||
* N of them paid N timeouts before answering anything. `Promise.all` attaches a handler
|
||||
* to every one of them, so a rejection never escapes as an unhandled one, and the opens
|
||||
* that did succeed are memoised per session exactly as they would have been.
|
||||
* The opens run TOGETHER, and every outcome is then read in SCOPE ORDER, so the
|
||||
* document reported is the first one the caller named that failed - whatever it failed
|
||||
* of. Two earlier shapes were wrong about this and the difference is not cosmetic, it
|
||||
* is which document the error names:
|
||||
*
|
||||
* Each open is memoised per session, so a re-subscribe costs a map hit.
|
||||
* - one at a time: correct order, unacceptable cost. Each open waits on the sync
|
||||
* barrier, bounded by `OPEN_TIMEOUT_MS` (8s) for a document that never pushes a
|
||||
* `State`, so a scope naming N of them paid N timeouts before answering anything.
|
||||
* - `Promise.all`: right cost, wrong order. It SHORT-CIRCUITS on the first rejection
|
||||
* to arrive, which is a race - a fetch that rejects does not wait for the sync
|
||||
* barrier, so it overtakes a refusal on a document named before it, and the error
|
||||
* named the wrong document while a comment beside it claimed otherwise.
|
||||
*
|
||||
* `allSettled` has neither fault: nothing is discarded, nothing rejects early, and no
|
||||
* rejection escapes as an unhandled one. The opens that succeeded are memoised per
|
||||
* session exactly as they would have been, so a re-subscribe costs a map hit.
|
||||
*/
|
||||
async function reachNamedDocuments(): Promise<void> {
|
||||
const docs = namedDocuments(scope);
|
||||
if (docs.length === 0) return;
|
||||
await Promise.all(docs.map((doc) => ensureRepoOpen(doc)));
|
||||
for (const doc of docs) assertMayReach(doc, "useShapeQuery");
|
||||
}
|
||||
|
||||
/**
|
||||
* Does the holder now reach every document the scope NAMES? The precondition for
|
||||
* re-opening a scope the barrier refused - and the whole of it.
|
||||
*
|
||||
* Synchronous and cap-only, so it asks nothing of the network: it answers "has the
|
||||
* thing that blocked us been lifted", which is exactly what a keyring change can have
|
||||
* done and the only question worth spending a re-open on. A scope that is still
|
||||
* unreachable is left alone, so a connection filing a dozen caps does not buy a dozen
|
||||
* round-trips for a view none of them was about.
|
||||
*/
|
||||
function namedDocumentsReachable(): boolean {
|
||||
return namedDocuments(scope).every((doc) => mayReach(doc));
|
||||
const outcomes = await Promise.allSettled(docs.map((doc) => ensureRepoOpen(doc)));
|
||||
for (const [i, doc] of docs.entries()) {
|
||||
const outcome = outcomes[i];
|
||||
if (outcome && outcome.status === "rejected") throw outcome.reason;
|
||||
assertMayReach(doc, "useShapeQuery");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -435,13 +461,13 @@ export function useShapeQuery<T extends BaseType = BaseType>(
|
||||
function open(): void {
|
||||
closeQuery();
|
||||
const gen = ++generation;
|
||||
barrierRefused = false;
|
||||
refusedFor = null;
|
||||
void (async () => {
|
||||
try {
|
||||
await reachNamedDocuments();
|
||||
} catch (error) {
|
||||
if (gen !== generation) return;
|
||||
barrierRefused = true;
|
||||
refusedFor = getCaps().holderKey();
|
||||
setUnknown(error);
|
||||
return;
|
||||
}
|
||||
@@ -494,9 +520,12 @@ export function useShapeQuery<T extends BaseType = BaseType>(
|
||||
// is the one this signal exists for: the BARRIER refused the scope, so the query
|
||||
// was never created and there is nothing for `publish` to re-filter. A view left
|
||||
// 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 re-open,
|
||||
// and only when the refusal has actually been lifted.
|
||||
if (barrierRefused && namedDocumentsReachable()) open();
|
||||
// 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();
|
||||
});
|
||||
open();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user