feat!: une seule reponse a « ce document est-il ouvert dans cette session »
Trois reponses coexistaient et pouvaient se contredire : un ensemble de souscriptions bootstrap et une carte d'etat de barriere dans `open-repo`, et les auditeurs du fan-out dans `surface/subscribe`. Les deux premieres survivaient a la disparition de la troisieme, parce que `resetDocSubscriptions()` - ce que fait le `configure()` publie - largue ses auditeurs sans rien dire a personne. Il n'en reste qu'une, l'etat de l'unique souscription reelle du document, exposee par `docChannel(nuri)`. `open-repo` n'enregistre plus que l'auditeur qu'il a pose - « est-ce que j'ecoute encore », rien de plus - et derive `getSyncState` a chaque lecture, si bien qu'aucune reponse ne peut survivre au canal qu'elle decrit. Disparaissent : `held`, `syncState`, `inFlight`, `settleInFlight` et le compteur de generation, remplace par l'identite de l'enregistrement. Les deux echecs prouves deviennent des tests qui echouent sans le changement : un second `configure()` apres une synchro reussie (`"synced"` au lieu de `"timed-out"`, zero re-souscription, et `isSuccess` cote application), et un etablissement encore en cours quand la couche de souscription est demontee - cas que le canal `onSetupFailed` ne peut pas rattraper, puisqu'il ne rapporte que la tentative encore attendue. La ligne qui ne casse pas : un document synchronise qui ne contient rien repond toujours un succes a zero objet. `1.0.0-dev.10`, les deux non-garanties nommees sont retirees et la garantie livree porte le test qui l'epingle.
This commit is contained in:
@@ -45,26 +45,31 @@
|
||||
* later establishes for change signals.
|
||||
*
|
||||
* ── Idempotence / perf (once per session, no polling) ─────────────────────
|
||||
* The registry opens each repo at most ONCE per session: `opened` records completed
|
||||
* opens (a hit skips everything), `inFlight` de-dupes concurrent opens of the same
|
||||
* repo. A brand-new page / module instance starts with an empty registry; and when
|
||||
* the injected session id CHANGES within the same page (an in-page re-login /
|
||||
* `session_stop`+`session_start`, whose new verifier has an empty `self.repos`) the
|
||||
* registry auto-resets (`syncSession`) so repos are re-opened against the new session
|
||||
* rather than wrongly skipped as "already open". No polling: we wait on the first
|
||||
* `State` push, with a bounded fallback timeout so a missing push can't hang.
|
||||
* A repo is opened once per session, and what makes that safe is ASKING rather than
|
||||
* remembering: {@link opens} records the bootstrap listener this module took out, and
|
||||
* {@link docChannel} says whether the subscription behind it stands. A caller skips the work
|
||||
* only when both hold - see the invariant below, and `openRepoUnguarded`, where the two
|
||||
* halves are named. Concurrent callers join one wait ({@link BootstrapOpen.done}). A
|
||||
* brand-new page / module instance starts empty; and when the injected session id CHANGES
|
||||
* within the same page (an in-page re-login / `session_stop`+`session_start`, whose new
|
||||
* verifier has an empty `self.repos`) the registry auto-resets (`syncSession`) so repos are
|
||||
* re-opened against the new session rather than wrongly skipped as "already open". No
|
||||
* polling: we wait on the first `State` push, with a bounded fallback timeout so a missing
|
||||
* push can't hang.
|
||||
*
|
||||
* ── Sync state (per-nuri, lib-internal) ───────────────────────────────────
|
||||
* Each nuri carries an explicit sync state, readable via {@link getSyncState}:
|
||||
* `"syncing"` — subscribed, no `State` yet (barrier not reached);
|
||||
* `"synced"` — first `State` received (barrier reached — the TRUTH signal);
|
||||
* `"timed-out"` — the bounded fallback fired with NO `State` (open proceeded so
|
||||
* the read is never blocked, but this is NOT `"synced"`: a future
|
||||
* "ready" signal must not mistake a timeout for a real barrier);
|
||||
* `"unknown"` — never requested (or the fake-ng no-op path: no `State` semantics).
|
||||
* This distinction is the point — `"synced"` and `"timed-out"` are kept apart so a
|
||||
* later reactive readiness layer can trust `"synced"` and treat `"timed-out"` as
|
||||
* "opened best-effort, sync unconfirmed".
|
||||
* ── Sync state (per-nuri, lib-internal, DERIVED) ──────────────────────────
|
||||
* {@link getSyncState} computes a nuri's barrier state; nothing stores it:
|
||||
* `"syncing"` - this module is inside its bounded wait, no `State` yet;
|
||||
* `"synced"` - a `State` has arrived on the channel standing NOW (barrier reached,
|
||||
* the TRUTH signal: a zero-row read over it MEANS zero);
|
||||
* `"timed-out"` - opened here, and no barrier to show for it: the wait gave up, the
|
||||
* subscribe was refused, or the channel was taken away. The read is never
|
||||
* blocked, but this is NOT `"synced"` - a readiness signal must not
|
||||
* mistake either for a real barrier;
|
||||
* `"unknown"` - never opened here (or the fake-ng no-op path: no `State` semantics).
|
||||
* `"synced"` and `"timed-out"` are kept apart for that reason, and `"unknown"` is the one
|
||||
* value that is deliberately treated as authoritative, because where there are no `State`
|
||||
* semantics there is no barrier to have missed.
|
||||
*
|
||||
* ── Migration ─────────────────────────────────────────────────────────────
|
||||
* At the real multi-store migration this becomes "open the user's store repo by
|
||||
@@ -75,69 +80,104 @@
|
||||
import { mustNotAttempt } from "./reach";
|
||||
import { fetchReadCap } from "./public-store";
|
||||
import { getConfig, getStoreRegistryDeps } from "../shared-wallet/bootstrap";
|
||||
import { resubscribeDocs, subscribeDocUnguarded, type Unsubscribe } from "../surface/subscribe";
|
||||
import { docChannel, resubscribeDocs, subscribeDocUnguarded, type Unsubscribe } from "../surface/subscribe";
|
||||
import { accessLogPrefix, logStage, shortNuri } from "../shared-wallet/access-log";
|
||||
import type { Nuri } from "../model/types";
|
||||
|
||||
/**
|
||||
* The per-nuri bootstrap sync state (lib-internal). See the module header:
|
||||
* - `"syncing"` subscribed, first `State` not yet received;
|
||||
* - `"synced"` first `State` received — the real sync barrier (CONTRACT 3);
|
||||
* - `"timed-out"` fallback fired without a `State` — open proceeded, sync UNconfirmed;
|
||||
* `"unknown"` (from {@link getSyncState}) means "never requested / no `State` semantics".
|
||||
* The per-nuri bootstrap sync state (lib-internal), DERIVED on every read by
|
||||
* {@link getSyncState} - nothing stores it. See the module header:
|
||||
* - `"syncing"` inside the bounded wait, first `State` not yet received;
|
||||
* - `"synced"` a `State` arrived on the channel standing NOW - the real sync barrier
|
||||
* (CONTRACT 3);
|
||||
* - `"timed-out"` opened here with no barrier to show for it - the wait gave up, the
|
||||
* subscribe was refused, or the channel was taken away. Sync UNconfirmed;
|
||||
* `"unknown"` (from {@link getSyncState}) means "never opened here / no `State` semantics".
|
||||
*/
|
||||
// @provenance SyncState kind=aligned level=1 ref=engine/net/src/app_protocol.rs:AppResponseV0 — the first `State` push IS the barrier; `timed-out` names the bounded fallback and is deliberately not `synced`
|
||||
export type SyncState = "syncing" | "synced" | "timed-out";
|
||||
|
||||
/*
|
||||
* WHAT THIS REGISTRY MEANS, and it is the thing that went wrong five rounds running.
|
||||
* THE INVARIANT, and it is the thing that went wrong six rounds running.
|
||||
*
|
||||
* `held` is the ONE record of openness: **this session holds a live bootstrap subscription
|
||||
* to this document**. Nothing else records it, because two records of the same fact can
|
||||
* disagree and this pair did. There was an `opened` set beside it meaning "we once called
|
||||
* `doc_subscribe` and stopped waiting", and that looser meaning is what lied three ways:
|
||||
* it survived a subscribe the broker REJECTED (so the retry `subscribe.ts` offers was
|
||||
* blocked for ever), it survived a registry reset (so a new session short-circuited on the
|
||||
* previous one's record), and it let a document report a barrier it had never crossed.
|
||||
* **"Is this document open in this session" is answered in ONE place - the state of the
|
||||
* document's one real subscription (`surface/subscribe.ts`, {@link docChannel}) - and this
|
||||
* module records only the bootstrap listener it TOOK OUT, never whether that listener ever
|
||||
* became a live channel or crossed a barrier.** So no answer here can outlive the channel
|
||||
* it describes, because none is kept.
|
||||
*
|
||||
* The three consequences, each now true by construction:
|
||||
* - a subscribe that FAILED records nothing, so the next caller subscribes again;
|
||||
* - a reset clears the record AND downgrades the sync state, so nothing claims authority
|
||||
* the new session has not earned;
|
||||
* - `syncState` says `"unknown"` for one reason only: no barrier was ever expected here
|
||||
* (never requested, or an `ng` with no `doc_subscribe` and so no `State` semantics).
|
||||
* It never means "we forgot".
|
||||
* What that replaces, and why each had to go rather than be repaired:
|
||||
* - a `held` set of "live bootstrap subscriptions". It was written the moment
|
||||
* `subscribeDocUnguarded` returned, which is BEFORE the `doc_subscribe` behind it has
|
||||
* been placed - so it meant "we asked", the looser meaning an `opened` set had been
|
||||
* deleted for one round earlier. And `resetDocSubscriptions` (which PUBLISHED
|
||||
* `configure()` calls) drops every listener without a word, so it also survived the
|
||||
* teardown of the very subscription it named.
|
||||
* - a `syncState` map of barrier states. Same fate, one step worse: it kept saying
|
||||
* `"synced"` for a document whose channel had gone, and `"synced"` is AUTHORITY - a
|
||||
* zero-row read over it is a definitive absence. A read that had been correctly
|
||||
* withheld came back a success, for a document this session had never subscribed.
|
||||
*
|
||||
* Both facts live in the fan-out already and are forgotten there at exactly the right
|
||||
* moments, which is what makes asking better than remembering: a channel released, a
|
||||
* session changed under it, an establish refused.
|
||||
*
|
||||
* {@link getSyncState}'s four values are therefore DERIVED, and `"unknown"` still means one
|
||||
* thing only: no barrier was ever expected here (never requested, or an `ng` with no
|
||||
* `doc_subscribe` and so no `State` semantics). It never means "we forgot".
|
||||
*/
|
||||
/** In-flight opens... see {@link inFlight} below; `held` is the record of openness. */
|
||||
/** 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.
|
||||
* The bootstrap open this module ASKED FOR - never a record of whether it succeeded.
|
||||
*
|
||||
* One per document, and its presence is the whole of what this module knows: *`open-repo`
|
||||
* has opened this document in this session*. Everything else about it is a question for
|
||||
* {@link docChannel}.
|
||||
*/
|
||||
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`.
|
||||
* `synced` and `timed-out` are NOT merged (see module header). */
|
||||
const syncState = new Map<Nuri, SyncState>();
|
||||
/** The session id the current `opened`/`held` entries belong to. A change means a
|
||||
interface BootstrapOpen {
|
||||
/**
|
||||
* The bounded wait this open exposes to its callers, resolved on the first `State`, on the
|
||||
* fallback, on a refused subscribe, or by a reset. Concurrent callers join it rather than
|
||||
* opening a second time - which is what an `inFlight` map used to do.
|
||||
*/
|
||||
done: Promise<void>;
|
||||
/**
|
||||
* Release the bootstrap listener this open took out. `null` once it has been released -
|
||||
* by a refused subscribe, or by {@link resetOpenedRepos} - and that is ALL it says: it
|
||||
* answers "am I still listening", never "is this document open". The second question needs
|
||||
* {@link docChannel} as well, because a listener can be dropped from under this module
|
||||
* (`resetDocSubscriptions`) and nothing here would be told.
|
||||
*/
|
||||
release: Unsubscribe | null;
|
||||
/**
|
||||
* How to END the wait from outside it - {@link resetOpenedRepos}, the only caller; `null`
|
||||
* once the wait is over. 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.
|
||||
*/
|
||||
settle: (() => void) | null;
|
||||
/** Inside the bounded wait: no `State` yet, and the fallback has not fired. */
|
||||
waiting: boolean;
|
||||
}
|
||||
/** Every document this module has opened in this session. See {@link BootstrapOpen}. */
|
||||
const opens = new Map<Nuri, BootstrapOpen>();
|
||||
/**
|
||||
* A document this module opened and no longer holds anything for - what a reset leaves
|
||||
* behind. It is deliberately NOT a deletion: forgetting it entirely would make
|
||||
* {@link getSyncState} answer `"unknown"`, which means "no barrier was ever expected here"
|
||||
* and is therefore read as authoritative. A document whose subscription a reset took away is
|
||||
* the opposite of that.
|
||||
*
|
||||
* It is also the generation guard. A reset REPLACES the record rather than mutating it, so
|
||||
* an open still running against the previous one compares `opens.get(nuri) !== mine` and
|
||||
* writes nothing - object identity doing what a counter used to.
|
||||
*/
|
||||
function disowned(): BootstrapOpen {
|
||||
return { done: Promise.resolve(), release: null, settle: null, waiting: false };
|
||||
}
|
||||
/** The session id the current {@link opens} entries belong to. A change means a
|
||||
* new verifier session (fresh `self.repos`) → the registry must be invalidated. */
|
||||
let boundSessionId: string | number | null = null;
|
||||
/**
|
||||
* Which GENERATION of the registry an open belongs to, bumped by {@link resetOpenedRepos}.
|
||||
*
|
||||
* An open's continuation records the repo as opened, and it runs one microtask after the
|
||||
* open settles - a window a reset can land in. Without this it wrote that record into the
|
||||
* registry that had just replaced its own, and every later open of the same nuri
|
||||
* short-circuited on it: the repo was never subscribed in the new session, and a read over
|
||||
* it answered as though it had been. A reset must INVALIDATE what it abandons, not only
|
||||
* end it.
|
||||
*/
|
||||
let registryEpoch = 0;
|
||||
|
||||
/**
|
||||
* Max wait (ms) for the initial-state push before proceeding with the read anyway.
|
||||
@@ -162,40 +202,30 @@ export function setOpenTimeoutForTests(ms: number): void {
|
||||
* held bootstrap subscriptions so a subsequent open re-subscribes cleanly. */
|
||||
// @provenance resetOpenedRepos kind=invention level=none ref=none — per-session bookkeeping reset; upstream the verifier owns `self.repos` and nothing resets it from outside
|
||||
export function resetOpenedRepos(): void {
|
||||
for (const unsub of held.values()) {
|
||||
for (const [nuri, open] of [...opens]) {
|
||||
// Let the bootstrap listener go first: it is what held the repo open for the session
|
||||
// that is ending, and a document nobody else follows is simply released.
|
||||
try {
|
||||
unsub();
|
||||
open.release?.();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
// END the open this reset abandons: a caller is already awaiting it, and the `State` it
|
||||
// was waiting for cannot arrive now that the subscription has been torn down. It used to
|
||||
// wait out the full fallback - eight seconds of a read going nowhere. Taken before the
|
||||
// record is replaced, and called after, so the waiter's continuation finds the record
|
||||
// already gone and writes nothing.
|
||||
const settle = open.settle;
|
||||
open.settle = null;
|
||||
// DISOWNED rather than forgotten - see {@link disowned}. The barrier needs no downgrading
|
||||
// of its own any more: it was never recorded here, and the fan-out forgets it on exactly
|
||||
// these events.
|
||||
opens.set(nuri, disowned());
|
||||
// The wait's own end, not a shortcut past it: clearing `waiting` here instead would make
|
||||
// this call a no-op and leave the caller hanging on a subscription that no longer exists -
|
||||
// the eight-second read the abandoned-open fix exists to prevent.
|
||||
settle?.();
|
||||
}
|
||||
// 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()];
|
||||
// Every document this reset takes a subscription away from, in flight or long settled.
|
||||
// Their sync state is DOWNGRADED rather than cleared: a barrier crossed in the session
|
||||
// that just went away is not one this session has crossed, and clearing would leave them
|
||||
// reading `"unknown"` - which means "no barrier was ever expected here" and is therefore
|
||||
// treated as authoritative. That conflation is how a read that had been correctly
|
||||
// withheld came back claiming a success, against a query nothing had re-subscribed.
|
||||
const disowned = new Set<Nuri>([...held.keys(), ...abandoned.map(([nuri]) => nuri)]);
|
||||
// Everything in flight now belongs to a registry that no longer exists, so its
|
||||
// continuation must write nothing - see {@link registryEpoch}.
|
||||
registryEpoch += 1;
|
||||
inFlight.clear();
|
||||
settleInFlight.clear();
|
||||
held.clear();
|
||||
syncState.clear();
|
||||
for (const nuri of disowned) syncState.set(nuri, "timed-out");
|
||||
for (const [, settle] of abandoned) settle();
|
||||
boundSessionId = null;
|
||||
OPEN_TIMEOUT_MS = 8000;
|
||||
// Whatever ELSE was still subscribed to a document - an inbox observation, an
|
||||
@@ -204,8 +234,8 @@ export function resetOpenedRepos(): void {
|
||||
// ones above no longer closes those channels; they are re-opened against the new session,
|
||||
// with their barrier forgotten. Without this a bootstrap open would join a surviving
|
||||
// fan-out, be handed the PREVIOUS session's `State`, and call a repo the new session has
|
||||
// never synced "synced". After the held teardown, so a document nobody else follows is
|
||||
// simply released.
|
||||
// never synced "synced". After the listeners above are released, so a document nobody else
|
||||
// follows is simply released.
|
||||
resubscribeDocs();
|
||||
}
|
||||
|
||||
@@ -220,7 +250,22 @@ 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" {
|
||||
return syncState.get(nuri) ?? "unknown";
|
||||
const channel = docChannel(nuri);
|
||||
// The first `State` on the channel standing NOW. Asked first and asked of the fan-out,
|
||||
// because that is the one record of it: a barrier is forgotten when its channel is released
|
||||
// or its session changes, so this cannot report one that has gone. 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.
|
||||
if (open === undefined) return "unknown";
|
||||
// Opened here, and no channel stands: the subscribe was refused, or a reset took it away,
|
||||
// or `resetDocSubscriptions` dropped it from under us. Opened best-effort, sync UNconfirmed -
|
||||
// never `"unknown"`, which is the reading that lets a zero-row read be called an absence.
|
||||
if (channel.state === "none") return "timed-out";
|
||||
return open.waiting ? "syncing" : "timed-out";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -294,69 +339,71 @@ export async function ensureRepoOpen(nuri: Nuri, holderKey?: string): Promise<vo
|
||||
export async function openRepoUnguarded(nuri: Nuri): Promise<void> {
|
||||
// Drop the registry if the session changed (in-page re-login → fresh verifier).
|
||||
await syncSession();
|
||||
// IN FLIGHT first, so a concurrent caller joins the open and waits on the same barrier
|
||||
// rather than stepping past it: `held` is set the moment the subscription is taken out,
|
||||
// which is before the `State` that makes it authoritative.
|
||||
const pending = inFlight.get(nuri);
|
||||
if (pending) return pending;
|
||||
if (held.has(nuri)) return;
|
||||
const standing = opens.get(nuri);
|
||||
// A caller arriving while the bounded wait is still running JOINS it, and waits on the same
|
||||
// barrier rather than stepping past it.
|
||||
if (standing?.waiting === true) return standing.done;
|
||||
// A caller arriving when this module's own listener is still in place AND the channel behind
|
||||
// it has ANSWERED has nothing to do. Both halves, and each catches a defect the other
|
||||
// cannot: without the listener half, a document whose subscription a reset took away would
|
||||
// never be re-held for the new session; without the channel half - which is the whole of
|
||||
// this task - a document whose `doc_subscribe` has not answered, or whose fan-out was torn
|
||||
// down by a `configure()`, reads as open for ever and nothing re-subscribes it.
|
||||
if (standing !== undefined && standing.release !== null && docChannel(nuri).state === "open") return;
|
||||
|
||||
// No reactive primitive on the injected ng (fake-ng unit suite): nothing to open,
|
||||
// no `State` to await → preserve the old immediate-resolve behaviour so `bun test`
|
||||
// does not regress. No sync state is recorded (getSyncState → "unknown"): the fake
|
||||
// path has no barrier semantics, and claiming "synced" here would be a lie.
|
||||
// does not regress. No record is kept (getSyncState → "unknown"): the fake
|
||||
// path has no barrier semantics, and claiming a barrier here would be a lie.
|
||||
const ng = getConfig().ng as { doc_subscribe?: unknown };
|
||||
if (typeof ng.doc_subscribe !== "function") {
|
||||
// Nothing to open and nothing to record: there is no subscription to hold, so `held`
|
||||
// must stay empty for this document or it would claim one. No sync state either -
|
||||
// `"unknown"` is exactly right here, and it is the one place that MEANS it.
|
||||
// Nothing to open and nothing to record: there is no subscription to hold, so this
|
||||
// document must stay out of `opens` or it would claim one - and `"unknown"` is exactly
|
||||
// right here, the one place that MEANS "no barrier was ever expected".
|
||||
return;
|
||||
}
|
||||
|
||||
const myEpoch = registryEpoch;
|
||||
// Subscribed, first `State` not yet seen.
|
||||
syncState.set(nuri, "syncing");
|
||||
// Barrier clock: how long the subscribe→first-State (or fallback) round-trip
|
||||
// took, surfaced on the BARRIER trace line below — the most important line in
|
||||
// the whole low-level data-path trace: it distinguishes a genuine absence
|
||||
// (`synced` → a 0-row read means it) from a not-yet-synced read (`timed-out`).
|
||||
const barrierStartedAt = Date.now();
|
||||
// Published BEFORE the subscribe, so a concurrent caller joins this wait instead of opening
|
||||
// a second time - and carrying nothing but the wait, because at this instant nothing is
|
||||
// true yet. What the previous registry put here was a claim of openness.
|
||||
const mine: BootstrapOpen = { done: Promise.resolve(), release: null, settle: null, waiting: true };
|
||||
opens.set(nuri, mine);
|
||||
|
||||
const p = (async () => {
|
||||
mine.done = (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 => {
|
||||
// The first `State` IS the barrier, WHENEVER it arrives. Recorded before the
|
||||
// already-settled check on purpose: the bounded fallback below only means "this
|
||||
// open stopped waiting", never "the barrier will not be crossed" - the
|
||||
// subscription is still held, so a `State` can still land afterwards. It used to
|
||||
// be dropped, which left `timed-out` standing for ever on a repo that had since
|
||||
// synced, and a reader consulting that state (`surface/use-shape-query.ts`) could
|
||||
// never learn better. `timed-out` -> `synced` is the only transition this adds,
|
||||
// and it is the one that goes from less authority to more.
|
||||
syncState.set(nuri, "synced");
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
logStage("BARRIER " + shortNuri(nuri) + " synced (" + (Date.now() - barrierStartedAt) + "ms)");
|
||||
// Stop waiting, once. Handed to `resetOpenedRepos` as well, so an open this session
|
||||
// abandons can be ended rather than left hanging on a subscription that no longer
|
||||
// exists.
|
||||
const stopWaiting = (): void => {
|
||||
if (!mine.waiting) return;
|
||||
mine.waiting = false;
|
||||
mine.settle = null;
|
||||
resolve();
|
||||
};
|
||||
// Bounded fallback: proceed WITHOUT a `State`, but mark "timed-out" — NOT
|
||||
// "synced". A genuinely-absent doc reads 0 rows anyway (same as before, never
|
||||
// a hang); the distinct state keeps a future "ready" signal from lying.
|
||||
mine.settle = stopWaiting;
|
||||
// The first `State` IS the barrier, WHENEVER it arrives - and it is the FAN-OUT that
|
||||
// records it, not this module, so a `State` landing long after this wait gave up still
|
||||
// counts and still turns `getSyncState` to `"synced"`. That used to need a write placed
|
||||
// carefully above the already-settled check; there is nothing to place now.
|
||||
const onState = (): void => {
|
||||
if (mine.waiting) {
|
||||
logStage("BARRIER " + shortNuri(nuri) + " synced (" + (Date.now() - barrierStartedAt) + "ms)");
|
||||
}
|
||||
stopWaiting();
|
||||
};
|
||||
// Bounded fallback: proceed WITHOUT a `State`. A genuinely-absent doc reads 0 rows
|
||||
// anyway (same as before, never a hang); giving up the WAIT is not crossing the
|
||||
// barrier, and `getSyncState` says so because the fan-out has no `State` to show.
|
||||
const onTimeout = (): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
syncState.set(nuri, "timed-out");
|
||||
logStage("BARRIER " + shortNuri(nuri) + " timed-out (" + (Date.now() - barrierStartedAt) + "ms)");
|
||||
resolve();
|
||||
if (mine.waiting) {
|
||||
logStage("BARRIER " + shortNuri(nuri) + " timed-out (" + (Date.now() - barrierStartedAt) + "ms)");
|
||||
}
|
||||
stopWaiting();
|
||||
};
|
||||
// The bootstrap subscription is kept ALIVE for the session — holding it open
|
||||
// is the whole point. We wait for the FIRST `State` event specifically (the
|
||||
@@ -366,43 +413,53 @@ export async function openRepoUnguarded(nuri: Nuri): Promise<void> {
|
||||
// rule 2 above; `ensurePhysicalRepoOpen` is the machinery's door and is not
|
||||
// subject to the boundary at all (see physical.ts).
|
||||
// A failed establish is REPORTED, and `subscribe.ts` offers this channel precisely
|
||||
// so that a caller can retry. Ignoring it is what made a refused subscribe look like
|
||||
// an open repo: the fallback below fired, the open was recorded, and every later
|
||||
// attempt short-circuited on a subscription that had never existed.
|
||||
// so that a caller can retry. Let the listener go, so the next caller opens a NEW
|
||||
// subscription rather than joining a fan-out that is not going to push. There is no
|
||||
// barrier state to correct: none was ever written here, and the fan-out has no `State`
|
||||
// to show for a call that failed.
|
||||
//
|
||||
// NOT a substitute for the channel question above, and that is the lesson of this
|
||||
// task: `subscribe.ts` reports a failure only for the attempt it is still WAITING ON,
|
||||
// so a rejection arriving after `resetDocSubscriptions` released the fan-out is logged
|
||||
// and nothing else. When the record was a claim of openness, that claim then stood for
|
||||
// the whole session and no retry could ever get past it.
|
||||
const onSetupFailed = (error: unknown): void => {
|
||||
if (myEpoch !== registryEpoch) return;
|
||||
if (opens.get(nuri) !== mine) return;
|
||||
console.error(accessLogPrefix() + " ensureRepoOpen: subscribe failed for " + shortNuri(nuri) + ":", error);
|
||||
// Record NOTHING, so the next caller subscribes again - and say plainly that no
|
||||
// barrier was crossed, rather than leaving a state that reads as authority.
|
||||
const taken = held.get(nuri);
|
||||
if (taken) {
|
||||
held.delete(nuri);
|
||||
try {
|
||||
taken();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const listening = mine.release;
|
||||
mine.release = null;
|
||||
try {
|
||||
listening?.();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
syncState.set(nuri, "timed-out");
|
||||
settleInFlight.get(nuri)?.();
|
||||
stopWaiting();
|
||||
};
|
||||
const unsub = subscribeDocUnguarded(
|
||||
// The bootstrap subscription is kept ALIVE for the session - holding it open is the
|
||||
// whole point. It is a LISTENER on the document's one real subscription, which is why
|
||||
// it says nothing about whether that subscription exists.
|
||||
mine.release = subscribeDocUnguarded(
|
||||
nuri,
|
||||
(_r, type) => {
|
||||
if (type === "State") onState();
|
||||
},
|
||||
onSetupFailed,
|
||||
);
|
||||
held.set(nuri, unsub);
|
||||
// A record this open REPLACED still holds a listener, and its wait is over - so nothing
|
||||
// will ever release it. Done after the new listener has joined, so the fan-out keeps its
|
||||
// entry and its establish, and this does not turn into a second `doc_subscribe` on a
|
||||
// branch that already has one (which upstream would EVICT the first).
|
||||
if (standing !== undefined && standing.release !== null) {
|
||||
const superseded = standing.release;
|
||||
standing.release = null;
|
||||
try {
|
||||
superseded();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
setTimeout(onTimeout, OPEN_TIMEOUT_MS);
|
||||
});
|
||||
// Only if this open still belongs to the registry it started in. After a reset these
|
||||
// maps are somebody else's, and writing into them is how a new session came to
|
||||
// short-circuit on the previous one's record.
|
||||
if (myEpoch !== registryEpoch) return;
|
||||
inFlight.delete(nuri);
|
||||
settleInFlight.delete(nuri);
|
||||
})();
|
||||
inFlight.set(nuri, p);
|
||||
return p;
|
||||
return mine.done;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user