fix: un abonnement en échec n'empoisonne plus le document, et un dépôt ne change plus de destinataire
This commit is contained in:
@@ -669,16 +669,47 @@ export async function readSyncedForDocument(docLike: NuriLike): Promise<Deposit[
|
||||
* Idempotent: `addLink` ignores a Link it already holds, so processing twice (a
|
||||
* second tab, a reconnect) costs nothing. Returns the consumer deposits, exactly as
|
||||
* {@link read} does — Links are never surfaced.
|
||||
*
|
||||
* ── The holder is captured, not re-resolved after the read ────────────────
|
||||
* The ownership guard runs at entry (inside {@link readSynced}) and the FILING happens a
|
||||
* broker round-trip later, so re-asking `getCurrentUser()` at that point asks a different
|
||||
* question: not "whose inbox is this" but "who is holding the page NOW". A page that
|
||||
* switched identity in that window filed CAROL's cap, deposited for ALICE, durably into
|
||||
* BOB's ring — `documentsGivenTo("bob")` returns Carol's document, Alice gets nothing, and
|
||||
* Bob's next sign-in restores it as a capability he holds. Not a near-miss: one user is
|
||||
* given another's document and the real recipient is left with none.
|
||||
*
|
||||
* So the holder the guard authorised is the one the filing is checked against, and a switch
|
||||
* ABANDONS — the same answer {@link read} gives for its own half of this window, and the
|
||||
* same one `connect.connectedUser` gives for a drain it started. Nothing is lost: an inbox
|
||||
* is not consumed by being read, so the deposit is still there for its owner's next
|
||||
* connection or its next push. Filing it FOR the absent owner instead would be a write on
|
||||
* somebody else's store from a published call — which is the deferred processor's
|
||||
* deliberate, unpublished privilege (`emulated-verifier/inbox-processor.ts`), not this one's.
|
||||
*/
|
||||
// @provenance inbox.processInbox kind=aligned level=1 ref=engine/verifier/src/inbox_processor.rs:process_inbox — unseal-and-apply: Links are filed durably and never surfaced. Returning the remaining deposits is the divergent half it shares with `read`
|
||||
export async function processInbox(targetInboxLike: NuriLike): Promise<Deposit[]> {
|
||||
const targetInbox = toNuri(targetInboxLike, "inbox.processInbox");
|
||||
// WHO this processing is for, captured before the read that authorises it — see above.
|
||||
const holder = getCurrentUser();
|
||||
const deposits = await readSynced(targetInbox);
|
||||
// `readSynced` already put every Link in memory for this session; now make
|
||||
// them durable. Reading the raw deposits again would mean re-parsing, so the caps
|
||||
// are taken from what the read just observed.
|
||||
for (const cap of capsSeenIn(targetInbox)) await addLink(cap);
|
||||
const seen = capsSeenIn(targetInbox);
|
||||
seenByInbox.delete(targetInbox);
|
||||
for (const cap of seen) {
|
||||
// Re-checked per cap, not once: `addLink` reads and writes, so the identity can move
|
||||
// between two of them just as easily as during the read.
|
||||
if (getCurrentUser() !== holder) {
|
||||
logStage(
|
||||
"ABANDONED " + shortNuri(targetInbox) + " — the identity changed while it was being " +
|
||||
"processed; its deposits stay for their owner",
|
||||
);
|
||||
return deposits;
|
||||
}
|
||||
await addLink(cap, holder ?? undefined);
|
||||
}
|
||||
return deposits;
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,19 @@
|
||||
* it — without the replay, joining a document somebody else already opened would never
|
||||
* fire, and `inbox.watch`'s "fires once immediately" would silently stop being true.
|
||||
* The real subscription is torn down when the LAST listener leaves.
|
||||
*
|
||||
* ── What SHARING one subscription must not cost a caller ───────────────────
|
||||
* Three things a per-caller `doc_subscribe` gave for free, and that the fan-out has to give
|
||||
* back deliberately — each of them was lost when it was introduced on 2026-08-17:
|
||||
*
|
||||
* - a caller is ITSELF, not its handler. One record per CALL, so passing the same function
|
||||
* twice is two subscriptions and the first unsubscribe does not silence the second;
|
||||
* - a caller that has LEFT hears nothing more, including from the push during which it
|
||||
* left — a handler may tear another one down, and the pushes are re-checked against the
|
||||
* live set rather than a copy taken before the first handler ran;
|
||||
* - a setup that FAILED is not the end of the document. The attempt is forgotten so the
|
||||
* next joiner makes its own (upstream, each caller's own `doc_subscribe` retried), and
|
||||
* it is reported rather than left as silence — see {@link reportSetupFailure}.
|
||||
*/
|
||||
|
||||
import { getConfig, getStoreRegistryDeps } from "../shared-wallet/bootstrap";
|
||||
@@ -122,16 +135,51 @@ async function sessionId(): Promise<string | number> {
|
||||
/** What a listener is handed on every push. */
|
||||
type Listener = (r: DocChange, type: DocChangeType) => void;
|
||||
|
||||
/**
|
||||
* ONE call to {@link subscribeDocUnguarded}, and what that caller asked for.
|
||||
*
|
||||
* Identified by this record and never by the `onChange` function: two callers may
|
||||
* legitimately pass the SAME function — a module-level handler, a bound method, an arrow
|
||||
* that closes over nothing — and they are two subscriptions with two independent lifetimes.
|
||||
* Keyed by the function, the second call was swallowed by the set and the FIRST caller's
|
||||
* unsubscribe silenced the second, which had never asked to leave.
|
||||
*/
|
||||
interface DocListener {
|
||||
/** What this caller is handed on every push. */
|
||||
onChange: Listener;
|
||||
/**
|
||||
* Told when the one real `doc_subscribe` behind this listener could not be OPENED.
|
||||
*
|
||||
* Upstream `doc_subscribe` is async and REJECTS on a setup failure, so its caller learns.
|
||||
* This wrapper returns synchronously, so without this channel a caller cannot tell "this
|
||||
* document is quiet" from "this document is not subscribed at all" — the failure-as-absence
|
||||
* this package keeps closing. `null` for a caller that did not ask (the published
|
||||
* {@link subscribeDoc}, whose contract has no such argument); the failure is logged either
|
||||
* way.
|
||||
*/
|
||||
onSetupFailed: ((error: unknown) => void) | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The single real `doc_subscribe` behind every local listener on one document.
|
||||
* See the module header for why there can only be one.
|
||||
*/
|
||||
interface DocFanOut {
|
||||
/** Every local listener on this document. The last one to leave tears it down. */
|
||||
listeners: Set<Listener>;
|
||||
listeners: Set<DocListener>;
|
||||
/** The platform's unsubscribe, once the async setup resolved. */
|
||||
realUnsub: (() => void) | null;
|
||||
/** True from the moment setup is kicked off — a later joiner must not kick off a second. */
|
||||
/**
|
||||
* True from the moment a setup is kicked off — a later joiner must not kick off a second,
|
||||
* because a second `doc_subscribe` on a branch EVICTS the first (see the module header).
|
||||
* It therefore stays true once the setup has SUCCEEDED: the attempt still stands.
|
||||
*
|
||||
* Cleared on the two ways the attempt stops standing: the last listener leaving
|
||||
* ({@link releaseFanOut}), and a setup that FAILED ({@link reportSetupFailure}). Left true
|
||||
* on failure it stopped meaning "one is already running" and started meaning "this NURI is
|
||||
* finished" — no later joiner ever attempted it again, for the whole session, over one
|
||||
* transient rejection.
|
||||
*/
|
||||
establishing: boolean;
|
||||
/**
|
||||
* The most recent `State` push, replayed to a listener that joins later.
|
||||
@@ -143,14 +191,57 @@ interface DocFanOut {
|
||||
const fanOuts = new Map<Nuri, DocFanOut>();
|
||||
|
||||
/** Hand one push to one listener, isolating a throwing handler from the others. */
|
||||
function deliver(nuri: Nuri, listener: Listener, resp: DocChange, type: DocChangeType): void {
|
||||
function deliver(nuri: Nuri, listener: DocListener, resp: DocChange, type: DocChangeType): void {
|
||||
try {
|
||||
listener(resp, type);
|
||||
listener.onChange(resp, type);
|
||||
} catch (error) {
|
||||
console.error("[subscribe] onChange handler threw for", nuri, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand one push to every listener of `entry` — the fan-out itself.
|
||||
*
|
||||
* Over a COPY, because a handler may unsubscribe itself or another from inside the push; and
|
||||
* re-checking each listener against the live set, because a copy alone only stops the walk
|
||||
* from breaking — it still delivers to whoever left DURING it. That contradicted what
|
||||
* {@link subscribeDoc} publishes ("no further `onChange` fires after unsubscribe"), on the
|
||||
* one ordering an application cannot control: whether its handler runs before or after the
|
||||
* one that tore it down.
|
||||
*/
|
||||
function fanOut(nuri: Nuri, entry: DocFanOut, resp: DocChange, type: DocChangeType): void {
|
||||
for (const listener of [...entry.listeners]) {
|
||||
if (!entry.listeners.has(listener)) continue;
|
||||
deliver(nuri, listener, resp, type);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The one real subscription could not be OPENED: forget the attempt, and say so.
|
||||
*
|
||||
* Two halves, and the shape of every "failure disguised as an absence" this package has
|
||||
* closed. **Retryable** — `establishing` goes back to false, so the next `subscribeDoc` on
|
||||
* this NURI attempts it again instead of joining a fan-out that will never push. Left true,
|
||||
* one transient rejection made the document silent for every later joiner in the session.
|
||||
* **Visible** — the log always, plus the callers who asked to be told, so a caller whose job
|
||||
* depends on the subscription (the inbox observation) can report it and try again rather
|
||||
* than sit on a dead entry.
|
||||
*/
|
||||
function reportSetupFailure(nuri: Nuri, entry: DocFanOut, error: unknown): void {
|
||||
console.error("[subscribe] doc_subscribe failed for", nuri, error);
|
||||
entry.establishing = false;
|
||||
if (fanOuts.get(nuri) !== entry) return;
|
||||
for (const listener of [...entry.listeners]) {
|
||||
const tell = listener.onSetupFailed;
|
||||
if (tell === null || !entry.listeners.has(listener)) continue;
|
||||
try {
|
||||
tell(error);
|
||||
} catch (thrown) {
|
||||
console.error("[subscribe] onSetupFailed handler threw for", nuri, thrown);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop a fan-out: forget the remembered state and release the real subscription. */
|
||||
function releaseFanOut(nuri: Nuri, entry: DocFanOut): void {
|
||||
if (fanOuts.get(nuri) === entry) fanOuts.delete(nuri);
|
||||
@@ -187,8 +278,7 @@ async function establish(nuri: Nuri, entry: DocFanOut, ng: { doc_subscribe?: unk
|
||||
const type = docChangeType(resp);
|
||||
// Remember the barrier for whoever joins next; a later `State` replaces it.
|
||||
if (type === "State") entry.lastState = { resp, type };
|
||||
// A copy: a handler may unsubscribe itself (or another) from inside the push.
|
||||
for (const listener of [...entry.listeners]) deliver(nuri, listener, 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.
|
||||
@@ -197,7 +287,7 @@ async function establish(nuri: Nuri, entry: DocFanOut, ng: { doc_subscribe?: unk
|
||||
}
|
||||
entry.realUnsub = typeof unsub === "function" ? unsub : null;
|
||||
} catch (error) {
|
||||
console.error("[subscribe] doc_subscribe failed for", nuri, error);
|
||||
reportSetupFailure(nuri, entry, error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,6 +381,30 @@ export function subscribeDoc(
|
||||
return subscribeDocUnguarded(nuri, onChange);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link subscribeDoc}, for a caller that must be TOLD when the subscription could not be
|
||||
* opened. Same guard, same fan-out; the only difference is that a setup failure reaches
|
||||
* `onSetupFailed` instead of only the log.
|
||||
*
|
||||
* Internal, and deliberately not the published shape: upstream `doc_subscribe` is async and
|
||||
* rejects, so a failure has a caller to reach; ours returns synchronously, and the published
|
||||
* signature has no room for it (`.project/concepts/app-contract/polyfill-surface/`). An
|
||||
* application uses a subscription as a change SIGNAL and has nothing to do with the answer,
|
||||
* so it keeps the two-argument call. The inbox observation does have something to do with it
|
||||
* — an inbox it believes it is watching and is not leaves every deposit unapplied for the
|
||||
* session — so it asks.
|
||||
*/
|
||||
// @provenance subscribeDocReportingSetupFailure kind=aligned level=2 ref=sdk/js/lib-wasm/src/lib.rs:doc_subscribe — restores to an internal caller what upstream's async `doc_subscribe` gives every caller: the setup failure. One delta remains, the synchronous unsubscribe
|
||||
export function subscribeDocReportingSetupFailure(
|
||||
nuriLike: NuriLike,
|
||||
onChange: (r: DocChange, type: DocChangeType) => void,
|
||||
onSetupFailed: (error: unknown) => void,
|
||||
): Unsubscribe {
|
||||
const nuri = toNuri(nuriLike, "subscribeDoc");
|
||||
assertMayReach(nuri, "subscribeDoc");
|
||||
return subscribeDocUnguarded(nuri, onChange, onSetupFailed);
|
||||
}
|
||||
|
||||
/**
|
||||
* The unguarded core. Exported for ONE importer — `shared-wallet/physical.ts`, which
|
||||
* owns the machinery's entire privileged door — and for nobody else. It is not
|
||||
@@ -300,6 +414,7 @@ export function subscribeDoc(
|
||||
export function subscribeDocUnguarded(
|
||||
nuri: Nuri,
|
||||
onChange: (r: DocChange, type: DocChangeType) => void,
|
||||
onSetupFailed?: (error: unknown) => void,
|
||||
): Unsubscribe {
|
||||
// Resolved here, synchronously, so calling this before `configure()` still throws at
|
||||
// the call rather than inside a background task nobody awaits.
|
||||
@@ -311,7 +426,9 @@ export function subscribeDocUnguarded(
|
||||
fanOuts.set(nuri, entry);
|
||||
}
|
||||
const joined = entry;
|
||||
joined.listeners.add(onChange);
|
||||
// THIS call's subscription — see {@link DocListener} for why it is not the function.
|
||||
const listener: DocListener = { onChange, onSetupFailed: onSetupFailed ?? null };
|
||||
joined.listeners.add(listener);
|
||||
|
||||
// Joining a document somebody else already opened: hand this listener the `State` its
|
||||
// own `doc_subscribe` would have pushed it. Asynchronously, like the real push, so a
|
||||
@@ -319,8 +436,8 @@ export function subscribeDocUnguarded(
|
||||
if (joined.lastState) {
|
||||
const { resp, type } = joined.lastState;
|
||||
queueMicrotask(() => {
|
||||
if (fanOuts.get(nuri) === joined && joined.listeners.has(onChange)) {
|
||||
deliver(nuri, onChange, resp, type);
|
||||
if (fanOuts.get(nuri) === joined && joined.listeners.has(listener)) {
|
||||
deliver(nuri, listener, resp, type);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -334,7 +451,7 @@ export function subscribeDocUnguarded(
|
||||
return () => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
joined.listeners.delete(onChange);
|
||||
joined.listeners.delete(listener);
|
||||
// The LAST one out releases the real subscription — while anybody is still
|
||||
// listening, tearing it down would silence them (and re-opening it later is not
|
||||
// free: a second `doc_subscribe` evicts whoever else has the branch by then).
|
||||
|
||||
Reference in New Issue
Block a user