fix: dire le pari comme un pari, et distinguer deux manques d'inbox que rien ne distinguait

This commit is contained in:
Sylvain Duchesne
2026-08-20 10:47:39 +02:00
parent c507e79f8a
commit 6a99585efc
6 changed files with 370 additions and 41 deletions
+49 -10
View File
@@ -202,6 +202,15 @@ interface DocFanOut {
lastState: { resp: DocChange; type: DocChangeType } | null;
}
/**
* Every document with at least one local listener.
*
* An entry in this map ALWAYS has listeners — that is the invariant the rest of the module
* reads "is this fan-out still standing" off: a listener is added synchronously with the
* entry that holds it ({@link subscribeDocUnguarded}), and the last one out takes the entry
* with it ({@link releaseFanOut}, {@link resetDocSubscriptions}). So no caller asks after the
* listener count separately; `fanOuts.get(nuri) === entry` already answers it.
*/
const fanOuts = new Map<Nuri, DocFanOut>();
/** Hand one push to one listener, isolating a throwing handler from the others. */
@@ -333,15 +342,46 @@ function releaseFanOut(nuri: Nuri, entry: DocFanOut): void {
* 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.
* ── Why the guard sits BEFORE the call, and what it does NOT settle ────────
* What it settles is a fact: superseded while the session id was still resolving, this opens
* NO channel at all. That is the wider half of the window, and it closes it outright.
*
* What it leaves open is a BET, and it is written down here as one. For an attempt that DID
* place its call before being superseded, this code releases the channel that call opened —
* which is only ever releasing the upstream LOSER if the broker SERVED the two calls in the
* order they were ISSUED. Issuing is all this module can order; service is the broker's, and
* nothing found upstream promises the two agree:
*
* - `LOCAL_BROKER` is behind an `async_std::sync::RwLock` held for the whole call
* (`sdk/rust/src/local_broker.rs:3057`), and that type IS `async_lock::RwLock`
* (async-std 1.13.2 `src/sync/mod.rs:181`, a re-export);
* - `RwLock::write()` takes the lock's internal `async_lock::Mutex` first (async-lock 3.4.1
* `src/rwlock/raw.rs:163-168`), and that mutex is documented as "eventual fairness" —
* fair ON AVERAGE, not FIFO (`src/mutex.rs:22-24`). Its hot loop re-runs a bare
* `compare_exchange` on every poll, so a call arriving later can take the lock ahead of
* one already waiting;
* - and the anti-starvation fallback that would eventually force fairness is
* `#[cfg(all(feature = "std", not(target_family = "wasm")))]` (`src/mutex.rs:578-581`) —
* compiled out on wasm, which is where this SDK runs.
*
* So the lock SERIALISES the calls; it does not ORDER them. Two attempts on the same branch
* of the same verifier is a reachable state — the last listener leaving while an establish is
* in flight releases the fan-out, and the next joiner opens a fresh one against the same
* session — so this is not a cross-session-only concern.
*
* What breaks the bet is therefore a service order inverted relative to the issue order, and
* this is what it costs when it happens: the superseded call inserts LAST, so it wins the
* branch and closes the current attempt's sender (`verifier.rs:361`); this code then releases
* its own channel, leaving the branch holding a closed sender that `push_app_response` drops
* at the first push (`verifier.rs:258-261`). Nothing rejects and nothing logs. The fan-out
* holds a `realUnsub` and believes itself subscribed, `establishing` stays true so no later
* joiner re-opens it, and the document is silently dead for the rest of the session —
* `test/subscribe.test.ts` drives exactly that and pins it.
*
* Releasing is still what to do: DROPPING the superseded unsubscribe instead leaks a live
* subscription in the ordinary case, and buys nothing in the inverted one — the winner's own
* callback is gated on an attempt that is no longer current, so it delivers to nobody either
* way. The bet is on which failure mode is reachable, never on releasing being free.
*/
async function establish(
nuri: Nuri,
@@ -426,7 +466,6 @@ export function resubscribeDocs(): void {
return;
}
for (const [nuri, entry] of [...fanOuts]) {
if (entry.listeners.size === 0) continue;
const stale = entry.realUnsub;
entry.realUnsub = null;
// The barrier belonged to the session that is gone. Whoever joins next waits for a