fix: un second doc_subscribe tuait le premier, et les inbox n'étaient lues qu'à la connexion

This commit is contained in:
Sylvain Duchesne
2026-08-17 11:11:04 +02:00
parent 6dfdf2f036
commit 935cce4d7b
14 changed files with 1284 additions and 115 deletions
@@ -0,0 +1,83 @@
/**
* ONE drain at a time per inbox — the serializer both things that drain an inbox go through.
*
* Two of them exist in this package, and they are not coordinated by anything else: the
* CONTINUOUS observation of the connected identity's own inboxes
* (`emulated-verifier/inbox-observer.ts`) and the deferred timer that stands in for an
* ABSENT owner (`emulated-verifier/inbox-processor.ts`). Both can be pointed at the same
* inbox in the same page — a deposit into an inbox whose owner is also connected here arms
* the timer AND pushes to the observation.
*
* Draining writes what it applies (`branch-registers.addLink`, on the owner's User branch),
* so two passes over one queue interleave their reads and writes and apply the same records
* twice. Sequencing them is the same reasoning `connect.connectedUser` drains its inboxes
* one after another for, and it belongs HERE rather than in either caller: an invariant that
* holds only while both callers remember it is not an invariant.
*
* It is a queue, not a lock: a run that arrives while another is going is not dropped, it
* follows. Dropping would be wrong — the second run exists because something new landed.
*/
import { accessLogPrefix, shortNuri } from "../shared-wallet/access-log";
import type { Nuri } from "../model/types";
/** Drains in flight or queued, per inbox — the chain a new run appends itself to. */
const running = new Map<Nuri, Promise<void>>();
/**
* Where a drain's failure goes.
*
* It cannot throw: nothing awaits these — neither the timer nor a push is a caller — and an
* unhandled rejection would take down whatever runtime it is in over work the application
* never requested. But "nowhere" is not the alternative. A drain that swallows its failure
* is indistinguishable from one that succeeded, which is precisely the defect family removed
* from this package on 2026-08-13, and a CONTINUOUS drain that swallowed would repeat that
* silence for as long as the session lasts. So it goes to the package's own log stream — the
* same `console.error` with the same `[<identity>][polyfill]` prefix `startConnect` and
* `inbox.watch` use — and it is NOT gated by the access-log flag: a diagnostic may be
* opt-in, a failure may not.
*
* One failed item denies nothing, here as at connection: the inbox is not consumed by
* failing, the deposit stays in it, and the next push or the next connection applies it.
*/
function reportFailure(inbox: Nuri, error: unknown): void {
console.error(
accessLogPrefix() + " could not apply what is in this inbox — its deposits stay in it " +
"and the next attempt will try again: " + shortNuri(inbox) + ":",
error,
);
}
/**
* Run `process` for `inbox`, behind any run of the same inbox still in flight.
*
* Resolves when THIS run has finished (or failed, having reported itself), so a caller
* that wants to wait can — while a caller that does not simply drops the promise.
*/
// @provenance drainInboxSerially kind=aligned level=1 ref=engine/verifier/src/verifier.rs:inbox — upstream one verifier owns an inbox and applies its messages one at a time as they arrive; this is that serialization, for the two paths this package drains from
export function drainInboxSerially(inbox: Nuri, process: () => Promise<void>): Promise<void> {
const previous = running.get(inbox) ?? Promise.resolve();
const next = previous
// A previous run that FAILED must not cancel this one: its failure was reported where
// failures go, and the deposits it did not apply are exactly what this run is for.
.catch(() => undefined)
.then(process)
.catch((error: unknown) => reportFailure(inbox, error))
.then(() => {
if (running.get(inbox) === next) running.delete(inbox);
});
running.set(inbox, next);
return next;
}
/**
* Wait until no drain is in flight. The seam a suite needs to observe convergence without
* waiting for it — and the one `runScheduledInboxProcessingNow` awaits after firing its
* windows. Loops, because a drain can be chained behind the one being awaited.
*/
// @provenance drainsSettled kind=invention level=none ref=none — a test/lifecycle seam over the queue above; upstream nothing exposes "is the verifier done applying"
export async function drainsSettled(): Promise<void> {
while (running.size > 0) {
await Promise.all([...running.values()]);
}
}