fix: une tentative dépassée n'ouvre plus rien, et libère ce qu'elle avait ouvert
This commit is contained in:
@@ -82,6 +82,17 @@ interface Observation {
|
||||
enumerating: boolean;
|
||||
/** A trigger that arrived mid-cycle: the running one repeats once rather than lose it. */
|
||||
enumerateAgain: boolean;
|
||||
/**
|
||||
* True while the shortfall reported by the last enumeration is still the SAME one — so a
|
||||
* register that stays unreadable is reported once, not once per cycle.
|
||||
*
|
||||
* Every signal this observation listens to runs a full cycle, and a persistent shortfall
|
||||
* is re-read by each of them, so the condition is reported as many times as the identity
|
||||
* is nudged: one deposit produced three copies of the same line. That is what buries the
|
||||
* report that matters under the report that repeats. Cleared the moment a list comes back
|
||||
* WHOLE, so a shortfall that goes away and returns is a new occurrence and says so.
|
||||
*/
|
||||
shortfallReported: boolean;
|
||||
/** Enumerations and applications in flight — what {@link observationSettled} waits on. */
|
||||
pending: Set<Promise<void>>;
|
||||
}
|
||||
@@ -178,6 +189,11 @@ async function applyWhatArrived(obs: Observation, inbox: Nuri): Promise<void> {
|
||||
* and the inboxes it is missing are watched on the next enumeration — which is what the
|
||||
* register push and the holdings signal are for. Applying what lands in a user inbox is
|
||||
* itself one of those signals (it files a cap), so the ordinary case repairs itself.
|
||||
*
|
||||
* Once per OCCURRENCE, though, not once per enumeration — see
|
||||
* {@link Observation.shortfallReported}. The retry that repairs it is also what re-reads it,
|
||||
* so a condition that persists is re-discovered by every signal; reporting each discovery
|
||||
* says "it happened again" about the single thing that never stopped happening.
|
||||
*/
|
||||
async function watchTheInboxes(obs: Observation): Promise<void> {
|
||||
if (!current(obs)) return;
|
||||
@@ -190,7 +206,10 @@ async function watchTheInboxes(obs: Observation): Promise<void> {
|
||||
return;
|
||||
}
|
||||
if (!current(obs)) return;
|
||||
if (listed.incomplete !== null) {
|
||||
if (listed.incomplete === null) {
|
||||
obs.shortfallReported = false;
|
||||
} else if (!obs.shortfallReported) {
|
||||
obs.shortfallReported = true;
|
||||
reportUnobserved("the inboxes to watch could not all be listed", listed.incomplete.error);
|
||||
}
|
||||
for (const inbox of listed.inboxes) {
|
||||
@@ -354,6 +373,7 @@ export async function startObservingInboxes(): Promise<void> {
|
||||
holdings: null,
|
||||
enumerating: false,
|
||||
enumerateAgain: false,
|
||||
shortfallReported: false,
|
||||
pending: new Set(),
|
||||
};
|
||||
observation = obs;
|
||||
|
||||
@@ -230,7 +230,15 @@ function fanOut(nuri: Nuri, entry: DocFanOut, resp: DocChange, type: DocChangeTy
|
||||
}
|
||||
}
|
||||
|
||||
/** Is `attempt` still the establish this fan-out is waiting on? */
|
||||
/**
|
||||
* Is `attempt` still the establish this fan-out is waiting on?
|
||||
*
|
||||
* Two independent ways to stop being it, and each is the only one that catches its own case:
|
||||
* the fan-out was RELEASED (last listener left, or `resetDocSubscriptions`), which drops it
|
||||
* from the map and leaves the counter untouched — so only the map half sees it; or it was
|
||||
* SUPERSEDED in place by {@link resubscribeDocs}, which keeps the very same entry in the map
|
||||
* and bumps the counter — so only the counter half sees it.
|
||||
*/
|
||||
function isCurrentAttempt(nuri: Nuri, entry: DocFanOut, attempt: number): boolean {
|
||||
return fanOuts.get(nuri) === entry && entry.attempt === attempt;
|
||||
}
|
||||
@@ -307,6 +315,33 @@ function releaseFanOut(nuri: Nuri, entry: DocFanOut): void {
|
||||
* Open the one real subscription for `entry` and route its pushes to every listener.
|
||||
* Errors are isolated to this document (they never reject a shared batch — see
|
||||
* {@link subscribeDocs}); a failed setup simply leaves the document silent.
|
||||
*
|
||||
* ── Everything here is gated on the ATTEMPT, never on the entry alone ──────
|
||||
* A superseded establish must not deliver, must not re-seed the barrier, and must not
|
||||
* install its unsubscribe: {@link resubscribeDocs} re-opens the channel ON THE SAME entry,
|
||||
* so entry identity says "this fan-out still stands" and says nothing about which call it is
|
||||
* waiting on. Gated on identity only, a first attempt that SUCCEEDS after a second one has
|
||||
* replaced it fans the PREVIOUS session's `State` out to every listener, re-seeds
|
||||
* `lastState` with it — the exact stale barrier {@link resubscribeDocs} cleared it to
|
||||
* prevent — and overwrites `realUnsub`, so the eventual teardown releases the dead channel
|
||||
* and leaves the live one subscribed upstream.
|
||||
*
|
||||
* ── A superseded attempt is RELEASED, not dropped ──────────────────────────
|
||||
* Dropping its unsubscribe would leak a live subscription. Upstream's `CancelFn` closes only
|
||||
* its OWN `tx` and never touches `branch_subscriptions` (`engine/verifier/src/verifier.rs:479-484`),
|
||||
* so releasing a loser cannot silence the winner — and cross-session there IS no winner to
|
||||
* silence: `resubscribeDocs` runs on a session change, the superseded call sits on the
|
||||
* previous session's verifier, and nothing else will ever close it.
|
||||
*
|
||||
* ── Why the guard sits BEFORE the call, and not only after it ──────────────
|
||||
* It is what makes the sentence above true rather than merely likely. Superseded while the
|
||||
* session id was still resolving, this opens NO channel at all — so every attempt that owns
|
||||
* one issued its `doc_subscribe` strictly before the current attempt existed, and by the
|
||||
* global broker lock held for the whole call (`sdk/rust/src/local_broker.rs:3057`) it was
|
||||
* served first. Whoever inserts last wins the branch (`verifier.rs:361`), so a superseded
|
||||
* attempt is always the upstream LOSER, never the live channel. Without this guard the two
|
||||
* calls could reach the broker in either order, and releasing the loser would be a coin flip
|
||||
* on whether the document goes silent.
|
||||
*/
|
||||
async function establish(
|
||||
nuri: Nuri,
|
||||
@@ -322,17 +357,25 @@ async function establish(
|
||||
const doc_subscribe = ng.doc_subscribe as (...a: unknown[]) => unknown;
|
||||
try {
|
||||
const sid = await sessionId();
|
||||
// Superseded (or torn down) while the session id was resolving: open NOTHING. Cheaper
|
||||
// than opening a channel to close it, and it is what keeps a superseded attempt from
|
||||
// ever reaching the broker AFTER the attempt that replaced it.
|
||||
if (!isCurrentAttempt(nuri, entry, attempt)) return;
|
||||
const unsub = (await doc_subscribe(nuri, sid, (resp: DocChange): void => {
|
||||
// A push that arrives after the last listener left belongs to a torn-down
|
||||
// fan-out — the real unsubscribe may not have taken effect yet.
|
||||
if (fanOuts.get(nuri) !== entry) return;
|
||||
// A push that belongs to a torn-down fan-out, or to an establish that has been
|
||||
// superseded — either way the real unsubscribe has not taken effect yet, and neither
|
||||
// this fan-out's listeners nor its barrier are this call's to touch any more.
|
||||
if (!isCurrentAttempt(nuri, entry, attempt)) return;
|
||||
const type = docChangeType(resp);
|
||||
// Remember the barrier for whoever joins next; a later `State` replaces it.
|
||||
if (type === "State") entry.lastState = { resp, type };
|
||||
fanOut(nuri, entry, resp, type);
|
||||
})) as (() => void) | undefined;
|
||||
if (fanOuts.get(nuri) !== entry || entry.listeners.size === 0) {
|
||||
// Everyone left (or the fan-out was reset) before setup resolved — cancel now.
|
||||
if (!isCurrentAttempt(nuri, entry, attempt)) {
|
||||
// Everyone left, the fan-out was reset, or a later establish replaced this one —
|
||||
// release the channel this call just opened, because nothing else holds it. (A
|
||||
// released fan-out is out of the map, which is why the listener count is not asked
|
||||
// about separately: the last one out already took the entry with it.)
|
||||
if (typeof unsub === "function") unsub();
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user