fix: dire le pari comme un pari, et distinguer deux manques d'inbox que rien ne distinguait
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user