Skip to content

live queries: subscription-driven re-render from the browser peer, and @fluree/react - #1733

Open
aaj3f wants to merge 94 commits into
mainfrom
wasm/lq-react
Open

live queries: subscription-driven re-render from the browser peer, and @fluree/react#1733
aaj3f wants to merge 94 commits into
mainfrom
wasm/lq-react

Conversation

@aaj3f

@aaj3f aaj3f commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

This is the third and final PR of the wasm program, and the one with a product behind it. #1714 made the engine run in a browser; #1715 made it a peer that reads a real server. This makes it live: a commit on the server re-renders exactly the affected React component, in a browser tab, with no polling code anywhere in the application.

The demo is the argument. Two tabs on one ledger, a vote cast in tab B, and in the untouched tab A only the changed row re-renders — its counter goes while both sibling rows sit at , and a second panel querying the same ledger re-runs and does not re-render at all, because its answer did not change. fluree-react/demo/WALKTHROUGH.md is a five-step script for running exactly that, written so someone can present it cold.

What's in it, bottom to top

Engine (fluree-db-api). Head-change catch-up now prefetches the novelty translation leaves it is about to need, so a subscription's re-query after a commit is a resident hit rather than a fetch round. The incremental catch-up ceiling is raised on wasm (5 → 64 commits) with a measured rationale — the parent-chain walk is one sequential fetch per commit, so at browser RTTs ~64 is roughly where catch-up stops beating a full reload — and the beyond-ceiling reload path was verified to still emit the "queryable at t" signal, so a sleeping tab that wakes far behind still fires exactly one cycle.

Live-query driver (fluree-db-browser). A subscription registry and an advance-cycle: on a head change, take ONE frozen snapshot, hold ONE cycle-level query guard, re-run every subscription against that single view, hash each formatted result (xxh3) as a change gate, and emit ONE batch outcome — {ledger, t, changed, unchanged, errored}. Unchanged subscriptions ship zero payload. Mid-cycle head events fold into exactly one follow-up cycle at the latest head, never a queue. Sibling components therefore cannot disagree about which commit they are showing, which is the invariant everything above depends on. The v2 invalidation filter has a seam here (affected(&flakes) -> bool, default true, never consulted for a subscription that has not yet produced a result) but v1 deliberately re-runs everything and diffs — correct by construction, and the diff gate is what keeps it cheap.

Shell (fluree-db-wasm + @fluree/db-wasm). Peer mode: connect(url, {getToken, …}), an unsolicited worker→main event channel (versioned), and subscribe/unsubscribe verbs that surface each advance-cycle as one batched cycleOutcome event with payloads transferred rather than cloned. Credentials never ride the init message — the worker asks for a token over the event channel and the main thread answers — which matters because a crash recycle replays init. Live subscriptions work in playground mode too, driven after local commits, so the no-server demo is live as well.

@fluree/react — a new top-level npm package, not a cargo workspace member, with its own path-gated CI job. A framework-agnostic core (client → query cache → handles → observers) with a ~100-line React adapter on useSyncExternalStore; React context injects the client only, never data. Two transports behind one hooks API: remote (HTTP queries, SSE-triggered cycles — no wasm anywhere, server-renderable) and peer (the in-browser engine). connect is injected rather than imported, so the package has zero dependency on @fluree/db-wasm and remote mode bundles without wasm. Unchanged rows keep object identity across an advance, which is what makes React.memo work down the tree.

Decisions worth scrutiny

  • The cycle is the unit, not the subscription. Per-subscription callbacks would be simpler, but they de-batch the cycle — which destroys version coherence — and drop unchanged, which the cache needs for error→ready recovery. Hence onCycle/onEngineState as shell API additions.
  • v1 re-runs every subscription per cycle. Fine on the peer (local CPU) and the diff gate means an unchanged result costs zero renders and zero payload. Not fine in remote mode, where it is N HTTP round-trips per commit — and there is no multi-query endpoint to fold them into (verified). Documented as a known cost with a bounded fan-out and request cancellation, not hidden.
  • Two refusals rather than silent wrong answers. In peer mode opts.at (historical views) and non-language-matched opts.format surface as typed unsupported errors, because the engine has no such path and quietly serving something else would be worse.
  • Structural sharing is reconstructed main-thread side, since postMessage clones. The worker's xxh3 gate decides whether anything changed; identity is preserved against the previous result after the boundary.

Two wasm cache guards that look redundant and are not

Worth stating because the natural review instinct is to collapse them. ledger_manager::temp_cache_dir returns a synthetic path on wasm instead of calling env::temp_dir(), which prevents a panictemp_dir() aborts with "no filesystem on this platform". fluree_db_core::disk_cache::ensure_cache_dir makes create_dir_all on that path a no-op on wasm, which prevents an error that would otherwise abort the index load before any CAS fetch is attempted. Different failure, different layer; removing either one reintroduces a distinct bug. Verified that no other create_dir_all sits on the wasm read path — the remainder are import and server-defaults paths, native-only.

Performance, stated honestly

Peer mode is not a speed-up on first paint, and the README says so in a section with that title. Measured: remote mode reaches first rendered data in 141 ms on loopback; a peer cold open is ~3.4 s. Profiling the peer cold open found something worth the team's attention: ~3.3 s of it is fixed overhead, invariant to data size — a 2-row ledger costs 3,318 ms and a 200-row ledger 3,547 ms — and the worker is 90% on-CPU throughout (3,538 ms busy vs 327 ms idle, with 23 ms of network). That number is bug-shaped rather than tuning-shaped, it is not addressable by any transport work, and finding its root cause is now the top perf item for the browser peer. It is localized to load_and_attach_binary_store, Fluree::db, and execute_formatted, but not root-caused; nobody should read this PR as claiming otherwise.

Choose peer mode for update latency and offline-capable local re-query, not time-to-first-paint. A warm re-query is 49–182 ms. One useful lower bound fell out of the hang investigation: with IndexedDB entirely out of the picture (a wedged database, so the peer runs fully cache-less) it serves in 252 ms, and two engines in two tabs serve in 252/262 ms — so the cache is not what the cold open is waiting on.

Native impact is unchanged from #1715's posture: the engine changes are cfg-gated or behind default-None trait methods, and @fluree/react is not in the cargo workspace, so no Rust job builds because of it.

What live runs found that the test suites could not

178 vitest tests, all mocked, all green — and running the thing in a real browser against a real server found six defects they were structurally incapable of seeing. Every one was silent:

  • fetch held as an instance property and invoked as a method — every browser rejects that as an illegal invocation; Node does not check, and every test injected a fetch impl.
  • A subscription under a bare ledger name never matching the server's canonical name:main, so head events were discarded as unwatched: stream open, nothing erroring, nothing updating.
  • The reverse of the same mismatch inside the package, filing cycles under the engine's spelling so ledgerHead() read as unknown forever.
  • The demo's own row mapping discarding the object identity the package had just preserved — everything still "worked", every row just re-rendered forever. A single .map() in userland silently defeats the entire point of the package, which is why it now leads both READMEs.
  • Vite's dev server refusing to serve files outside the project root, surfacing as engine_crashed with an empty page console.
  • GET /events?ledger= returning 400 for every value — shipped separately as fix(server): accept per-ledger SSE subscriptions on GET /events #1730, since it breaks any peer subscribed to explicit ledgers and has nothing to do with wasm.

The end-to-end gate that catches this class now runs in CI: it launches a real fluree-server, mints a scoped storage-proxy token, drives the shipped npm package in headless Chrome, and asserts rows — plus a cold, head-tracking-off phase in its own fresh profile. Its traffic assertions are the interesting part, and they are shape assertions rather than magic numbers: every recorded GET /storage/objects/{cid} succeeded (a partial failure fails the gate rather than degrading quietly), exactly one SSE stream, and zero requests to /query — that last one is what proves the engine computed locally instead of shipping the query to the server. A run records around three dozen CAS objects; that count is reported, not asserted, since it moves with the data.

Known blockers and open items

  • Peer hang on an unopenable cache — fixed. The driver awaited the IndexedDB open before entering its job loop, so if that open never returned, no job was ever dispatched: no HTTP request, no error, and no timeout (the per-request timeouts live inside the job that never ran). The trigger turned out not to be "cold" at all — a wedged database whose open() fires no event whatsoever, not even blocked, which is durable per browser profile and is why fresh-profile testing never reproduced it. The driver no longer depends on the cache opening; a wedged or unavailable IndexedDB now degrades to cache-less operation, exactly as the module's doc comment always claimed. Verified before/after on the same wedged profile with the failing precondition re-probed during the passing run.
  • Enqueued jobs had no deadline — closed. The configured request timeout is applied inside the job, so anything preventing dispatch hung forever with nothing to surface it. There is now an outer deadline that needs no new dependency: the timer is !Send because it holds JS handles, so it runs in its own spawn_local task and is observed over a channel — this module's founding trick, and deliberately not routed through the driver, since a deadline that depends on dispatch cannot detect dispatch failing. Native is byte-unchanged; the error names the URL and says "did not dispatch", so "never started" is distinguishable from "timed out". Two tests guard it: one holds the receiver without draining (a live-but-not-dispatching driver) and asserts the typed error, the other asserts a reply inside the deadline still wins so the guard cannot mask real behavior.
  • Remote mode requires fix(server): accept per-ledger SSE subscriptions on GET /events #1730, which is not in any release. The README names the symptom so nobody debugs their own application first.
  • Peer mode needs a token our own CLI cannot mintfluree-events-token issues only fluree.events.*, while the storage proxy requires fluree.storage.*. A --storage-all flag would fix that and is not in this PR. Follow-up: events-token CLI: option to mint fluree.storage.* scopes for the browser peer #1773.
  • The ~3.3 s cold-open floor, above. Follow-up: Browser peer cold-open floor (~3.3s): root-cause the on-CPU time #1774.

Pre-review remediation (before this leaves draft)

This PR went through a blind internal pre-review first — a reviewer with no context on the work, applying the repo's own rubric, checking the PR out like any colleague's. Verdict was request changes over 32 findings, and all 32 are now closed: fixed, or rebutted with mechanism. Highlights, because several are more interesting than the fixes:

The critical one falsified this PR's own headline claim. prime bypassed the coalescer while advance went through it, and nothing downstream ordered by watermark — so a slow prime landing after a fast advance pinned a component on pre-commit data and left its stored hash at the stale result, meaning a later commit restoring that result reported "unchanged" with no payload and the component never recovered. "Sibling components cannot disagree about which commit they're showing" was true within a cycle and false across two. Fixed structurally rather than patched: advance and prime are now one serialized path, so there is one concurrency regime per ledger, and Coalescer::begin returns an RAII lease that releases the slot on drop.

Two findings turned out worse than the review stated. A failed block fetch during result formatting was answering 400 err:system/FormatError — telling a caller its data was malformed on a condition a retry fixes; it is now a 503 err:storage/ReadFailure, with the same shape one frame over flagged rather than quietly left. And the events parser's all flag accepted only the literal strings true/1, so ?all=TRUE, ?all=yes and every typo silently meant false — a 200 SSE stream matching nothing, forever. That second fix ships in #1730, which owns that parser (see the note below).

A "never" timer that fired immediately. Five sites clamped a duration with u32::try_from(..).unwrap_or(u32::MAX); a setTimeout delay above i32::MAX overflows and fires at once, so the value meaning "effectively never" meant "now". Found while mutation-checking something else, when a deliberately-wedged open resolved instantly.

Two documentation claims were checked and could not be stood behind, and that matters here because this README's whole framing is separating claims by evidence: a coverage row claiming the remote suite proved per-subscription delivery ordering (the reviewer neutered the entire ticket mechanism and all tests still passed — the missing test has been added rather than the belt removed), and "thirty subscriptions do not fire thirty simultaneous requests", which was false at mount because the limiter only applied inside cycles. The demo also contradicted its own headline — App.tsx said "search this file for a timer, an interval; there are none" and contained a setInterval. Fixed the code, not the claim.

One finding was declined, with reasoning: a wasm-paths CI gate analogous to bench-paths. bench-compare measures a known crate set, but wasm-smoke runs the engine and its failures are runtime ones any engine crate can cause — so an honest gate would match nearly everything, and a narrow one is the same silent-skip trap as the react-sdk path-gate bug fixed in this PR. Reducing that job's feature set was the saving that doesn't trade correctness for it.

Test counts after remediation: fluree-db-browser 62, it_residency_retry 8, fluree-db-server lib 151, @fluree/react 200, headless-browser suites 11, plus the real-server peer gate passing end to end.

Overlap with #1730, deliberate

The two commits here touching fluree-db-server/src/routes/events.rs are byte-identical to #1730's first commit. That fix was extracted so it could land early and independently — it breaks any peer subscribed to explicit ledgers, which has nothing to do with wasm — and this stack needs it because the peer gate subscribes with ?ledger=. When #1730 merges, these two become patch-identical to main and drop on rebase; the ?all= fix lives only in #1730.

Named gaps, not absorbed

The shell index.ts surface is now covered by a vitest suite IN this PR (fluree-db-wasm/js/test/, wired into the react-sdk CI job): globalThis.Worker is stubbed with a message-recording double and connect({ workerUrl })/playground({ workerUrl }) drive the real Channel, toLiveCycle, recycle ladder and LiveRegistry with zero production changes. Seven tests pin the cycle-decode memo (one parse per payload, shared object identity), the fatal-re-init single-recycle guard, init-failure worker cleanup, the 30s init timeout, per-query timeoutMs passthrough on both query surfaces, and the setToken wire contract — each proven non-vacuous by mutating the committed guard it pins and watching it fail. The peer.rs drain is covered by the peer gate plus the coalescer tests in fluree-db-browser::live. On tokens: review found that BrowserPeer::set_token existed (tested) but nothing above it called it, and two docs contradicted each other about it. It is now wired through end to end — Peer.setToken(token) → a setToken op → a peer-only worker dispatch → the shared TokenCell — so a long-lived tab can refresh its bearer proactively with no teardown; getToken remains the pull at connect/crash-recycle, and both docs now agree.

External adversarial-review remediation (2026-09) + caught up to main

Since the pre-review, this stack was rebased onto current main (49 commits; semantically verified at each level, not just textually) and put through a second, independent adversarial review of the surface the internal pre-review and the human reviewer had NOT covered — the wasm read-path, the two new browser crates, and this PR's Rust + TypeScript. Every agreed finding is fixed in-tree, each verified by execution (mutation-checked where a test pins behavior), not by description:

  • Correctness. The change-gate hash is now committed only when a cycle is actually delivered (an auto-prime racing listener registration previously wedged a subscription at "unchanged, no payload"); onLedgerHead's detach is idempotent (a stale cleanup no longer evicts a re-subscribed component's head listener); a peer cycle spanning two spellings of one ledger is delivered under each spelling; a query first observed after close() returns a typed error instead of loading forever.
  • Performance (the maintainers' first-ranked axis). The SSE drain now spawns each advance so the coalescer folds a head-change burst into one cycle instead of running one full re-query per event; each live cycle is decoded once, not once per listener; and the residency read-ahead is bounded so a LIMIT-shaped scan no longer fetches the whole predicate run (with an airtight retry-termination gate, in wasm32 groundwork: the engine stack compiles and runs on wasm32-unknown-unknown, native untouched #1714).
  • Security / robustness. A persistent-cache hit is re-verified against its CID before the engine trusts it (IndexedDB was the one unverified admission path); the bearer token is redacted in IoJob's Debug; the SSE connect is bounded (a hung server no longer silently kills head-tracking); and an oversized/flooding response is rejected/backpressured before it can trap the wasm instance.
  • CI. Three host-target clippy errors that would have gone red on merge are fixed.

Deferred, deliberately and tracked (not silent backlog): the per-block CORS preflight is its own follow-up PR — #1772, a batch object endpoint (a maintainer decision, since it is a cold-profile-only perf item, not correctness); a fluree-events-token --storage-all scope flag — #1773 (a separate server-CLI change the peer needs to self-serve a storage token); the ~3.3 s cold-open floor root-cause — #1774; and the dataset-residency formatting seam — #1775 (the hydration branch now refuses typed on a residency-backed target instead of dying on an unrecoverable miss; the real fix is hoisting the retry loop with per-graph store routing).

aaj3f added a commit that referenced this pull request Aug 28, 2026
…nding in limbo

Three shell-side holes from the #1733 review.

1. `LiveRegistry`/`LiveSubscription` carried no `channel.generation`
   stamp, unlike `Ledger` and `Snapshot`, which stamp it precisely
   because a fresh engine re-mints the same small handle numbers.
   `LiveQuerySet::next_id` restarts at 1, so: subscribe -> subId 1;
   worker crashes and recycles; the consumer re-subscribes as the docs
   instruct and the new engine also returns 1, overwriting `subs[1]`;
   the consumer then tidies its stale handle with `oldSub.unsubscribe()`
   — which deletes the NEW callback and posts
   `{op:"unsubscribe", subId:1}`, killing a live subscription silently.
   The registry now stamps its generation, drops the map when it moves
   (checked on subscribe, after the round trip, and on every cycle
   event), and an unsubscribe from a previous generation is a no-op.

2. The post-recycle re-init did `.then(res => { if (res.ok) … })` with
   no else. A NON-fatally failed re-init — exactly what a `getToken`
   rejection produces, since that mints `unauthorized`/401 and only
   RuntimeError/RangeError are fatalized — replied `ok: false`, so
   nothing fired, `recycle()` was not re-entered, and no further respawn
   was scheduled. Listeners saw the crash and then silence, forever: not
   ready, not terminal, the one state a consumer cannot act on. It now
   re-enters the backoff ladder, so it either recovers or spends the
   budget and becomes a terminal error the consumer is told about.

3. `smoke-peer-server.mjs` asserted only `sse.length !== 0`, so a
   reconnect bug opening five streams stayed green — and a
   stream-per-commit is the shape peer mode exists to avoid. Now
   `check("SSE streams", sse.length, 1)`.

And the README's peer-credentials paragraph said "There is no mid-session
re-auth hook in `fluree-db-browser` yet". This stack ADDS one:
`BrowserPeer::set_token` with a shared `TokenCell` through every I/O
surface and a passing native test. It is real, it is documented as
absent, and it is unreachable — `grep -rn "set_token\|setToken"
fluree-db-wasm/src fluree-db-wasm/js/src` finds nothing.

I did not wire the protocol op, and the README now says why rather than
saying the hook does not exist. It is not a size question: `getToken` is
currently the single source of truth (asked at connect, re-asked on every
recycle, never embedded in a replayable init), so a pushed `setToken`
would be silently superseded by the next recycle's `getToken` unless the
two are reconciled first. Which side owns the token is a protocol
decision, not a fix.

No automated test for (1) or (2): this package has no unit-test harness
(scripts are `tsc` and the two browser smokes), and `Channel` — whose
injectable `spawn` would make both testable in ~40 lines of `node:test`
— is not exported, with the worker constructed from a literal expression
on purpose (review H-6, bundler static analysis). Both changes
type-check; standing up a harness here is a separate call.
aaj3f added a commit that referenced this pull request Aug 28, 2026
…nding in limbo

Three shell-side holes from the #1733 review.

1. `LiveRegistry`/`LiveSubscription` carried no `channel.generation`
   stamp, unlike `Ledger` and `Snapshot`, which stamp it precisely
   because a fresh engine re-mints the same small handle numbers.
   `LiveQuerySet::next_id` restarts at 1, so: subscribe -> subId 1;
   worker crashes and recycles; the consumer re-subscribes as the docs
   instruct and the new engine also returns 1, overwriting `subs[1]`;
   the consumer then tidies its stale handle with `oldSub.unsubscribe()`
   — which deletes the NEW callback and posts
   `{op:"unsubscribe", subId:1}`, killing a live subscription silently.
   The registry now stamps its generation, drops the map when it moves
   (checked on subscribe, after the round trip, and on every cycle
   event), and an unsubscribe from a previous generation is a no-op.

2. The post-recycle re-init did `.then(res => { if (res.ok) … })` with
   no else. A NON-fatally failed re-init — exactly what a `getToken`
   rejection produces, since that mints `unauthorized`/401 and only
   RuntimeError/RangeError are fatalized — replied `ok: false`, so
   nothing fired, `recycle()` was not re-entered, and no further respawn
   was scheduled. Listeners saw the crash and then silence, forever: not
   ready, not terminal, the one state a consumer cannot act on. It now
   re-enters the backoff ladder, so it either recovers or spends the
   budget and becomes a terminal error the consumer is told about.

3. `smoke-peer-server.mjs` asserted only `sse.length !== 0`, so a
   reconnect bug opening five streams stayed green — and a
   stream-per-commit is the shape peer mode exists to avoid. Now
   `check("SSE streams", sse.length, 1)`.

And the README's peer-credentials paragraph said "There is no mid-session
re-auth hook in `fluree-db-browser` yet". This stack ADDS one:
`BrowserPeer::set_token` with a shared `TokenCell` through every I/O
surface and a passing native test. It is real, it is documented as
absent, and it is unreachable — `grep -rn "set_token\|setToken"
fluree-db-wasm/src fluree-db-wasm/js/src` finds nothing.

I did not wire the protocol op, and the README now says why rather than
saying the hook does not exist. It is not a size question: `getToken` is
currently the single source of truth (asked at connect, re-asked on every
recycle, never embedded in a replayable init), so a pushed `setToken`
would be silently superseded by the next recycle's `getToken` unless the
two are reconciled first. Which side owns the token is a protocol
decision, not a fix.

No automated test for (1) or (2): this package has no unit-test harness
(scripts are `tsc` and the two browser smokes), and `Channel` — whose
injectable `spawn` would make both testable in ~40 lines of `node:test`
— is not exported, with the worker constructed from a literal expression
on purpose (review H-6, bundler static analysis). Both changes
type-check; standing up a harness here is a separate call.
@aaj3f
aaj3f marked this pull request as ready for review August 28, 2026 17:28
aaj3f added a commit that referenced this pull request Aug 28, 2026
…nding in limbo

Three shell-side holes from the #1733 review.

1. `LiveRegistry`/`LiveSubscription` carried no `channel.generation`
   stamp, unlike `Ledger` and `Snapshot`, which stamp it precisely
   because a fresh engine re-mints the same small handle numbers.
   `LiveQuerySet::next_id` restarts at 1, so: subscribe -> subId 1;
   worker crashes and recycles; the consumer re-subscribes as the docs
   instruct and the new engine also returns 1, overwriting `subs[1]`;
   the consumer then tidies its stale handle with `oldSub.unsubscribe()`
   — which deletes the NEW callback and posts
   `{op:"unsubscribe", subId:1}`, killing a live subscription silently.
   The registry now stamps its generation, drops the map when it moves
   (checked on subscribe, after the round trip, and on every cycle
   event), and an unsubscribe from a previous generation is a no-op.

2. The post-recycle re-init did `.then(res => { if (res.ok) … })` with
   no else. A NON-fatally failed re-init — exactly what a `getToken`
   rejection produces, since that mints `unauthorized`/401 and only
   RuntimeError/RangeError are fatalized — replied `ok: false`, so
   nothing fired, `recycle()` was not re-entered, and no further respawn
   was scheduled. Listeners saw the crash and then silence, forever: not
   ready, not terminal, the one state a consumer cannot act on. It now
   re-enters the backoff ladder, so it either recovers or spends the
   budget and becomes a terminal error the consumer is told about.

3. `smoke-peer-server.mjs` asserted only `sse.length !== 0`, so a
   reconnect bug opening five streams stayed green — and a
   stream-per-commit is the shape peer mode exists to avoid. Now
   `check("SSE streams", sse.length, 1)`.

And the README's peer-credentials paragraph said "There is no mid-session
re-auth hook in `fluree-db-browser` yet". This stack ADDS one:
`BrowserPeer::set_token` with a shared `TokenCell` through every I/O
surface and a passing native test. It is real, it is documented as
absent, and it is unreachable — `grep -rn "set_token\|setToken"
fluree-db-wasm/src fluree-db-wasm/js/src` finds nothing.

I did not wire the protocol op, and the README now says why rather than
saying the hook does not exist. It is not a size question: `getToken` is
currently the single source of truth (asked at connect, re-asked on every
recycle, never embedded in a replayable init), so a pushed `setToken`
would be silently superseded by the next recycle's `getToken` unless the
two are reconciled first. Which side owns the token is a protocol
decision, not a fix.

No automated test for (1) or (2): this package has no unit-test harness
(scripts are `tsc` and the two browser smokes), and `Channel` — whose
injectable `spawn` would make both testable in ~40 lines of `node:test`
— is not exported, with the worker constructed from a literal expression
on purpose (review H-6, bundler static analysis). Both changes
type-check; standing up a harness here is a separate call.
aaj3f added a commit that referenced this pull request Sep 2, 2026
…nding in limbo

Three shell-side holes from the #1733 review.

1. `LiveRegistry`/`LiveSubscription` carried no `channel.generation`
   stamp, unlike `Ledger` and `Snapshot`, which stamp it precisely
   because a fresh engine re-mints the same small handle numbers.
   `LiveQuerySet::next_id` restarts at 1, so: subscribe -> subId 1;
   worker crashes and recycles; the consumer re-subscribes as the docs
   instruct and the new engine also returns 1, overwriting `subs[1]`;
   the consumer then tidies its stale handle with `oldSub.unsubscribe()`
   — which deletes the NEW callback and posts
   `{op:"unsubscribe", subId:1}`, killing a live subscription silently.
   The registry now stamps its generation, drops the map when it moves
   (checked on subscribe, after the round trip, and on every cycle
   event), and an unsubscribe from a previous generation is a no-op.

2. The post-recycle re-init did `.then(res => { if (res.ok) … })` with
   no else. A NON-fatally failed re-init — exactly what a `getToken`
   rejection produces, since that mints `unauthorized`/401 and only
   RuntimeError/RangeError are fatalized — replied `ok: false`, so
   nothing fired, `recycle()` was not re-entered, and no further respawn
   was scheduled. Listeners saw the crash and then silence, forever: not
   ready, not terminal, the one state a consumer cannot act on. It now
   re-enters the backoff ladder, so it either recovers or spends the
   budget and becomes a terminal error the consumer is told about.

3. `smoke-peer-server.mjs` asserted only `sse.length !== 0`, so a
   reconnect bug opening five streams stayed green — and a
   stream-per-commit is the shape peer mode exists to avoid. Now
   `check("SSE streams", sse.length, 1)`.

And the README's peer-credentials paragraph said "There is no mid-session
re-auth hook in `fluree-db-browser` yet". This stack ADDS one:
`BrowserPeer::set_token` with a shared `TokenCell` through every I/O
surface and a passing native test. It is real, it is documented as
absent, and it is unreachable — `grep -rn "set_token\|setToken"
fluree-db-wasm/src fluree-db-wasm/js/src` finds nothing.

I did not wire the protocol op, and the README now says why rather than
saying the hook does not exist. It is not a size question: `getToken` is
currently the single source of truth (asked at connect, re-asked on every
recycle, never embedded in a replayable init), so a pushed `setToken`
would be silently superseded by the next recycle's `getToken` unless the
two are reconciled first. Which side owns the token is a protocol
decision, not a fix.

No automated test for (1) or (2): this package has no unit-test harness
(scripts are `tsc` and the two browser smokes), and `Channel` — whose
injectable `spawn` would make both testable in ~40 lines of `node:test`
— is not exported, with the worker constructed from a literal expression
on purpose (review H-6, bundler static analysis). Both changes
type-check; standing up a harness here is a separate call.
aaj3f added a commit that referenced this pull request Sep 2, 2026
…nding in limbo

Three shell-side holes from the #1733 review.

1. `LiveRegistry`/`LiveSubscription` carried no `channel.generation`
   stamp, unlike `Ledger` and `Snapshot`, which stamp it precisely
   because a fresh engine re-mints the same small handle numbers.
   `LiveQuerySet::next_id` restarts at 1, so: subscribe -> subId 1;
   worker crashes and recycles; the consumer re-subscribes as the docs
   instruct and the new engine also returns 1, overwriting `subs[1]`;
   the consumer then tidies its stale handle with `oldSub.unsubscribe()`
   — which deletes the NEW callback and posts
   `{op:"unsubscribe", subId:1}`, killing a live subscription silently.
   The registry now stamps its generation, drops the map when it moves
   (checked on subscribe, after the round trip, and on every cycle
   event), and an unsubscribe from a previous generation is a no-op.

2. The post-recycle re-init did `.then(res => { if (res.ok) … })` with
   no else. A NON-fatally failed re-init — exactly what a `getToken`
   rejection produces, since that mints `unauthorized`/401 and only
   RuntimeError/RangeError are fatalized — replied `ok: false`, so
   nothing fired, `recycle()` was not re-entered, and no further respawn
   was scheduled. Listeners saw the crash and then silence, forever: not
   ready, not terminal, the one state a consumer cannot act on. It now
   re-enters the backoff ladder, so it either recovers or spends the
   budget and becomes a terminal error the consumer is told about.

3. `smoke-peer-server.mjs` asserted only `sse.length !== 0`, so a
   reconnect bug opening five streams stayed green — and a
   stream-per-commit is the shape peer mode exists to avoid. Now
   `check("SSE streams", sse.length, 1)`.

And the README's peer-credentials paragraph said "There is no mid-session
re-auth hook in `fluree-db-browser` yet". This stack ADDS one:
`BrowserPeer::set_token` with a shared `TokenCell` through every I/O
surface and a passing native test. It is real, it is documented as
absent, and it is unreachable — `grep -rn "set_token\|setToken"
fluree-db-wasm/src fluree-db-wasm/js/src` finds nothing.

I did not wire the protocol op, and the README now says why rather than
saying the hook does not exist. It is not a size question: `getToken` is
currently the single source of truth (asked at connect, re-asked on every
recycle, never embedded in a replayable init), so a pushed `setToken`
would be silently superseded by the next recycle's `getToken` unless the
two are reconciled first. Which side owns the token is a protocol
decision, not a fix.

No automated test for (1) or (2): this package has no unit-test harness
(scripts are `tsc` and the two browser smokes), and `Channel` — whose
injectable `spawn` would make both testable in ~40 lines of `node:test`
— is not exported, with the worker constructed from a literal expression
on purpose (review H-6, bundler static analysis). Both changes
type-check; standing up a harness here is a separate call.

@bplatz bplatz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving — the design holds up. One snapshot + one nested cycle guard + a hash gate on formatted bytes is the right shape, the RAII lease means cancellation can't wedge a ledger, the IndexedDB CID re-verification (with cache_rejections and its poison-and-heal test) closes a real hole, and structuralShare's __proto__ handling on both the read and the write side is more careful than most implementations. The timer_millis clamp is a good catch, pinned by mechanism rather than symptom.

Please look at the inline points before merging. The one I'd actually act on is set_token: it ships with a test and has no caller anywhere in the stack, and two doc comments in this PR contradict each other about whether it exists.

Verified locally: fluree-db-browser 66 tests pass, @fluree/react 205 pass under tsc + vitest + build, cargo fmt --all --check clean. The wasm32 clippy gate I could not cross-build here.

Two housekeeping items: the PR has no labels, so .github/release.yml files it under "Other Changes"; and none of the four named deferrals (CORS batch endpoint, --storage-all, shell vitest, the ~3.3s cold-open floor) carry a Follow-up: #N. fluree-db-wasm/js/src/index.ts is ~1100 lines with no test runner — the PR shows it's testable with zero production changes, so I'd rather see that suite here than deferred.

Comment thread fluree-db-wasm/src/peer.rs Outdated
//! - **No raw token in `init`** (recycle replays init): the token arrives
//! from the main thread over the event channel just before this
//! constructor runs; it is then held inside the transports for the life of
//! the peer. There is no mid-session re-auth hook in `fluree-db-browser`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BrowserPeer::set_token does exist — it's added in this PR with a passing test (connect.rs:279). But nothing calls it: no set_token/setToken in fluree-db-wasm/src, js/src, or fluree-react/src, and getToken is only pulled at connect/reconnect.

So a peer tab whose token expires 401s on every block fetch with no recovery — nothing in PeerTransport triggers a close+connect on that. bridge.rs:22 also claims "the shell's auth flow refreshes the bearer mid-session", which no shell does.

Either wire it through (one Peer method + a setToken op) or drop the capability. Either way both comments need fixing.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wired through, the shape you suggested: Peer::setToken (wasm export) → a setToken protocol op → a requirePeer dispatch in the worker (playground answers with the same typed unsupported the transact ops use in peer mode) → Peer.setToken(token) on the JS surface. (77c469b)
You were right that both comments were wrong — peer.rs claimed no re-auth hook exists while bridge.rs claimed the shell already refreshes. Both now say the same true thing: proactive refresh needs no teardown, and getToken stays the pull at connect/crash-recycle — the two compose (refresh before expiry via setToken; a 401 that lands anyway still surfaces typed and recovers through recycle/reconnect with a fresh getToken pull).
The Rust half was already pinned by set_token_refreshes_the_bearer_without_teardown; a shell-side protocol test rides the new vitest suite in this PR.

Comment thread fluree-db-api/src/format/mod.rs Outdated
"Hydration only supports JSON-LD and TypedJson output formats".to_string(),
));
}
// SEAM: this branch calls hydration directly and is NOT wrapped in the

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A comment is the only thing between a multi-ledger peer and an unrecoverable formatting miss here. Worth hoisting the loop into a helper both paths call, or returning a typed refusal on a residency target — and a tracked follow-up rather than prose.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Took the typed-refusal option and filed the tracked follow-up you asked for: #1775. The prose guard is now a live assertion — if ANY graph in the dataset is residency-backed, the hydration branch returns a typed error naming the issue instead of dying on an unrecoverable mid-hydration miss. Fires never today (the browser peer is single-ledger), which is exactly the invariant the old comment narrated. (098ad55)
I went refusal-over-hoist deliberately: wrapping this branch in the existing loop would give FALSE recovery for a multi-ledger dataset — misses can come from a secondary graph's store and the loop only fetches against one store, so it would spin to a non-progress failure rather than recover. The real fix (hoist into a shared helper with per-graph store routing) is what #1775 tracks.

#[cfg(any(target_arch = "wasm32", feature = "residency"))]
if let Some(cs) = crate::residency::content_store(db.snapshot) {
let _guard = cs.query_guard();
let mut budget = fluree_db_binary_index::read::need_fetch::RetryBudget::default();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Each round re-runs the body against the same tracker. Formatting is pure w.r.t. resident state but not w.r.t. the fuel counter, so a query near budget can fail FuelExceeded purely for having taken N rounds. Same shape as the execution loop, so not a regression — but on a peer, misses are the normal path, so it's likelier to bite. Snapshot/restore per round, or state the trade.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — snapshot/restore, your first option. The loop snapshots the counter once (Tracker::current_micro_fuel) and rolls back before each retry via a new Tracker::restore_micro_fuel, so N rounds charge like one and the successful round's charge stands. (098ad55)
It's sound in this frame specifically because execution has already finished — formatting is the tracker's sole fuel writer, which is now a documented caller invariant on the API (the rollback would erase a concurrent consume_fuel, which is also why I left the execution loop exactly as you read it: its rounds run concurrent consumers, and you're right that it's not a regression there).
Two unit tests pin the API (rollback semantics; inert when fuel tracking is off).

/// drops any signal folded into the abandoned cycle: nothing is left to
/// run it, and the next signal opens a fresh cycle at the latest head
/// anyway.
fn abandon(&self, ledger: &str) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clearing running is the point; clearing pending isn't. A head change that folded into a cycle which then got cancelled is dropped entirely, and nothing re-runs it until the next commit. "The next signal opens a fresh cycle anyway" only holds if there is a next signal.

The lease exists so safety doesn't rest on caller behavior — leaving pending set costs nothing and makes the code match the doc.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — abandon() now clears only running. Tracing the consumer made your point sharper than "costs nothing": pending is only ever consumed by the NEXT cycle's finish(), which then forces one more FULL cycle — and that upgrade is load-bearing when the next cycle is a prime() (deliberately solo, one subscription). Dropping pending on abandon meant a prime racing a cancelled cycle served only itself and stranded every sibling subscription on stale data until some unrelated commit arrived. (4d5ddeb)
Two new tests pin it (Coalescer-level: a dropped lease still owes a follow-up; public-API level: an abandoned cycle's folded commit reaches an earlier subscription via the follow-up a later prime forces), both mutation-verified; monotone emission order is untouched — the abandoned cycle never emitted.

// broken server returning a multi-GiB object can exhaust the linear-memory
// ceiling and trap the instance. A declared length past the residency
// budget cannot be a legitimate block.
if let Some(len) = crate::config::declared_length_over_cap(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only guards a declared Content-Length. A chunked response with no header skips the pre-check and is fully materialized into linear memory — the exact thing this prevents. Fine against our own proxy; not a defense against the "hostile or broken server" the comment invokes. Narrow the claim or cap incrementally.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented the incremental cap rather than narrowing the claim. Body reads now stream through a ReadableStreamDefaultReader with a running total gated per chunk (config::body_cap_step — one saturating add + one compare, same shape and cfg-gating as declared_length_over_cap, natively unit-tested including the two-chunks-under-declare-nothing case); on overflow it aborts the controller + cancels the reader and returns typed. The declared-length fast-reject stays. Two details worth noting: the whole drain races ONE timeout (a trickle of small chunks can't reset the deadline per read), and each chunk is copied out of JS memory exactly once — which also retires the 2x materialization array_buffer() had on this path. (4d5ddeb)
The streaming loop itself is wasm-only; it runs green in the headless-Chrome suite (fetch round-trip tests), and the threshold logic is pinned natively.

return this.liveHandleFor(key, spec, gcTime).state;
}

private liveHandleFor(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

storeFor/observe/snapshotFor all short-circuit on closed, but public handleFor doesn't — a call after close() inserts into byKey/bySubId and skips armJanitor(), so it's never collected. One-line guard.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed, with one refinement to the framing: the branch was already half-defended — liveHandleFor guarded armJanitor() on closed but still unconditionally inserted the fresh handle into byKey/bySubId, which is the actual leak (no janitor AND no possible observer, since observe short-circuits on closed, so collect() can never fire). It now returns a standalone untracked handle when closed instead of touching the maps. (846a265)
Kept handleFor public rather than privatizing: the whitebox cache tests drive it directly, and the guard is the smaller surface change. New regression test asserts a post-close handleFor call caches nothing (two calls for a fresh key return distinct handles); mutation-verified (reverting the guard fails it).

return;
}
reg.sub = sub;
this.byEngineId.set(sub.subId, subId);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

byEngineId is only populated after this await, while the engine auto-primes the moment it registers (wasm/src/live.rs:55). It works because the subscribe reply and the cycle event are separate postMessages and the reply's microtasks drain first.

If that ever inverts, applyCycle drops the payload while Rust has already committed the change-gate hash — and the next cycle reports unchanged on a handle with hasResult === false, i.e. the transport-contract error path. The Rust side guards the analogous race explicitly (deliverable in run_serialized); this one has no guard, comment, or test.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed the microtask-ordering assumption deserved a real guard, not prose — it now mirrors the Rust side's deliverable gate. A pendingRegistrations counter brackets register()'s await (try/finally), and applyCycle stashes an entry it cannot place — keyed by raw engine id — only while a registration is in flight; when the subscribe reply lands, the stash is replayed as that subscription's own CycleUpdate (watermark included). The stash clears whenever the pending count returns to zero and on crash-recycle (a fresh worker can reissue a dead worker's ids — same hazard the existing byEngineId.clear() there guards). Normal delivered path pays one counter check. (846a265)
New test holds the subscribe reply open, emits the auto-prime cycle first, and asserts it is delivered (not dropped) once the reply resolves; mutation-verified (no-oping the stash fails it).

aaj3f added a commit that referenced this pull request Sep 3, 2026
…JS surface

Review #1733 (peer.rs thread): BrowserPeer::set_token existed — tested,
end-to-end sound at the Rust level (shared TokenCell; fetch transports stamp
per request, SSE resolves per connect) — but nothing above it called it, so a
peer tab whose token expired 401'd on every block fetch with no recovery
short of close()+connect(), and two doc comments contradicted each other
about whether the capability existed at all (bridge.rs claimed the shell
refreshes mid-session; peer.rs claimed no re-auth hook exists).

Wire it through, exactly the shape the review suggested: Peer::setToken
(wasm export) -> a `setToken` protocol op -> worker `requirePeer` dispatch ->
`Peer.setToken(token)` on the JS surface. Peer-only: the playground answers
with the same typed `unsupported` pattern the transact ops use in peer mode
(memory ledgers carry no bearer). Both contradictory docs are now true and
say the same thing: proactive refresh needs no teardown; getToken is still
the pull at connect/crash-recycle — the two compose.

Rust side already pinned by connect.rs's
set_token_refreshes_the_bearer_without_teardown; a shell-side protocol test
lands with the vitest harness (in flight on this PR).
aaj3f added a commit that referenced this pull request Sep 3, 2026
…dataset seam

Two #1733 review findings on the formatting frame's residency behavior
(format/mod.rs threads):

1. Fuel re-charge per retry round. The drain/fetch/re-run loop re-ran
   formatting against the same Tracker, so N rounds charged N x one round's
   fuel and a near-budget query could fail FuelExceeded merely for missing
   residency N times — and on a peer, misses are the NORMAL first-query path.
   Formatting is pure and a failed round's work is discarded, so: snapshot
   the fuel counter once before the loop (Tracker::current_micro_fuel) and
   roll back before each retry via the new Tracker::restore_micro_fuel; the
   successful round's charge stands. Sound here because execution has
   finished — this frame is the tracker's sole fuel writer (the caller
   invariant is documented on the API). The execution loop shares the shape
   but runs concurrent consumers; left as the reviewer read it (explicitly
   not a regression there).

2. Dataset-hydration seam (#1775). The multi-ledger hydration branch is not
   wrapped by the drain/fetch/re-run loop, so on a residency-backed target a
   formatting miss would be unrecoverable — previously guarded only by a
   prose comment. The invariant is now enforced: a typed refusal if ANY
   graph in the dataset is residency-backed (live assertion; fires never
   today — the browser peer is single-ledger). The real fix (hoist the loop
   into a shared helper with per-graph store routing) is tracked in #1775.

New: DataSetDb::graphs() iterator; two fluree-db-core unit tests pinning
restore_micro_fuel (rollback semantics + inert when fuel tracking is off).
Both changes live entirely inside the residency-gated cfg block — the
default native path is untouched.
@aaj3f aaj3f added the enhancement New feature or request label Sep 3, 2026
Two live-query enablers in the head-change path (SDK program A2/A5):

Incremental catch-up now gives its new novelty entries the same F8
treatment the load and index-apply paths already get: after the caught-up
commits are applied (outside the write guard), the reverse-dict leaves
their overlay translation will touch are prewarmed through
`prefetch_novelty_translation`, so a head-change → re-query on a
residency-mode peer is a pure hit instead of spending a retry round per
cold translation leaf. When an index update follows in the same plan, the
apply path's own prefetch covers the swapped store and this pass is a
resident no-op. Same cfg discipline as the rest of the residency arms —
default native builds compile the pre-existing path unchanged.

`MAX_INCREMENTAL_COMMITS` becomes target-split: native keeps 5; wasm32
gets 64. A browser tab's SSE reconnect after sleeping routinely wakes
tens of commits behind, catch-up preserves warm per-store state (the
overlay-translation cache is store-id-keyed) that a re-open drops, and
the parent-chain walk is one sequential fetch per commit — 64 is where
~gap × RTT stops beating a re-open's concurrent artifact loads. Past the
cap the reload fallback still ends in the same "queryable at t" signal:
the browser head sink fires its callbacks unconditionally after notify,
with the record's watermarks.
Two additions to the real-path recovery suite, driving the SSE head-sink
path (`LedgerManager::notify` with the record in hand) against a
residency-mode instance whose ledger is cached in the manager:

- beyond the incremental cap (8 commits > native 5): notify must take the
  reload fallback (`NotifyResult::Reloaded`), land the manager exactly at
  the record's commit watermark, and the re-opened state must answer a
  production-loop query with every pre- and post-gap row — the engine
  half of the "queryable at t" contract the browser head sink's
  unconditional callback relies on;
- within the cap (2 commits): notify must catch up incrementally
  (`CommitsApplied { count: 2 }`) at the record watermark and serve the
  caught-up novelty rows through the production loop with the miss
  register drained. The translation-prefetch call-site wiring runs here;
  the prefetcher itself is unit-pinned in fluree-db-binary-index (this
  fixture's dictionaries are too small to make the api-level prefetch
  observable, as the suite's novelty test already documents).
LiveQuerySet: per-ledger subscriptions (SPARQL or JSON-LD) re-run on head
change through ONE advance-cycle: one frozen GraphDb snapshot, one
cycle-level query guard (nests with the retry loop's per-query guard and
keeps early fetches resident for later subscriptions), every subscription
executed via the production formatted query entry, xxh3 over the formatted
bytes as the change gate, and ONE batch CycleOutcome
{t, changed, unchanged, errored}. Per-subscription errors never hold the
barrier. Mid-cycle head signals coalesce into exactly one follow-up cycle
at the latest head. prime() runs a newly-mounted subscription solo at the
current head before it joins the barrier.

Invalidation is v1 (re-run all, diff before notify) with the v2 seam in
place: FootprintFilter::affected(&commit_flakes) per subscription,
consulted only for subscriptions that have produced a result and only
when the host supplies the cycle's novelty flakes; the default filter
reports everything affected.

Native tests cover the batch split (changed/unchanged/errored + monotone
t), coalescing (state machine + a mid-cycle fold observed through the
public API), solo priming, the footprint seam's never-skip-unprimed rule,
unsubscribe mid-flight, and the cycle guard genuinely wrapping the cycle
(a filter probe observes queries_in_flight == 1 inside, 0 after).
SelectedSub replaces the four-way tuple the registry snapshot produced
(clippy type_complexity), and the lockfile picks up the crate's new
serde_json/xxhash-rust dependencies.
… surface

Long-lived subscribed sessions outlive their tokens; baking the bearer
into the transports at build_peer time forced a full reconnect on expiry,
dropping warm residency/cache/ledger state. Now one shared TokenCell
(Arc<RwLock<String>>, Debug-redacted) feeds every surface:

- WasmFetchTransport::with_token stamps the cell's CURRENT bearer on
  every request, replacing the authorization header the proxy clients
  baked in (per-request cost: one read-lock + the header allocation the
  request needed anyway).
- ChannelSseSource resolves the bearer from the cell PER CONNECT — the
  same semantics as the native ReqwestSseSource's per-connect token
  provider — so reconnects after a refresh carry the fresh token.
- BrowserPeer::set_token / token_cell expose the refresh to the shell;
  rustdoc spells out the expiry story (401 -> storage error, 403 ->
  not-found parity, SSE 401/403 -> fatal pump stop; refresh, retry
  queries, restart head tracking — proactive refresh needs no restart).

Tests: transport stamps-and-replaces without duplication + Debug
redaction; end-to-end set_token through the engine's nameservice path on
one peer (old bearer before, new bearer after, no teardown); SSE source
resolves per connect (Bearer a then Bearer b across two connects).
…(A1)

The shell's second engine mode: connect(url, { getToken, subscribe }) runs
fluree-db-browser's BrowserPeer in the same worker protocol as the
playground. Rust side: the snapshot slab + query surface extracted into a
mode-agnostic EngineCore (src/engine.rs); Playground delegates; the new
wasm-only src/peer.rs wires connectPeer (BrowserIoConfig::from_max_memory -
the ONE ceiling derives residency/write-behind/fetch-width, and the
per-query budget is a quarter of it, inside the governor's engine-headroom
split), head-change fan-out (the Send+Sync engine callback forwards through
a channel; a spawn_local drain task is what touches JS), SSE head tracking,
and shutdown. Peer is read-only: no transact exports exist; the worker
answers transact/createLedger/debugCrash in peer mode with typed
unsupported (501).

Protocol: a versioned EVENT message kind (unsolicited worker-to-main,
disjoint from responses by shape; consumers ignore unknown kinds/versions)
carrying headChange fan-out and tokenRequest. Credentials never ride init -
init is replayed verbatim on crash recycle, and a replayed message must not
be a credential replay - so the worker asks over the event channel and the
main-thread getToken callback answers (fire-and-forget tokenResponse op),
at connect AND at every recycle re-connect (reason: "reconnect"). No
mid-session re-auth hook exists in fluree-db-browser yet; documented: token
expiry means reconnect with a fresh token.

Verified without a server (smoke phase 3, CI): the token event round-trip,
peer init/close, and typed non-fatal failure against an unreachable remote;
plus 6/6 wasm-bindgen tests and the existing crash/recycle phases still
green. The full against-a-real-server flow (real blocks, real SSE) is
covered natively by fluree-db-browser's mock-driver suites and stated as
not-yet-automated in the README's verification-status section.

Size: 9.32 MB raw / 2.37 MB brotli (-Os) - +0.33/+0.09 over PR-2 for the
whole browser-io stack now linked in.
aaj3f added 22 commits September 3, 2026 10:51
…t-violation tests

Counts 194 -> 200, and the two rows that earned new coverage say what it is.
The job's path filter listed only fluree-react/ and ci.yml, but three of
its steps reach into fluree-db-wasm/js: protocolCompat.test.ts imports
../../fluree-db-wasm/js/src/index.js and .../protocol.js by relative
path and tsconfig's include covers test/**, so Type-check compiles that
tree, Test loads it, and Demo type-checks it again through its own
tsconfig.

The failure mode is the one that erodes trust in a path-gated job: a PR
changing connect()'s signature or the protocol types merges green
because the job never ran, and then the next unrelated fluree-react PR
goes red for a change its author did not make. Filter verified against
sample paths the way bench-paths is — it newly matches
fluree-db-wasm/js/** and still skips fluree-db-wasm/src/, js-extra/,
some-fluree-react/, and other workflows.
CI's workspace clippy job runs --all --all-features --all-targets -D warnings
on the HOST target; these three were red there and invisible only because
this PR's base is not main (the job never fired on it yet):

- config.rs timer_millis: dead on a host LIB build — its production callers
  are the wasm driver modules, and only the clamp test (host-test) uses it
  otherwise. cfg(any(wasm32, test)) so the host lib no longer sees it as dead.
- the clamp test name carried a capital T (setTimeout) → non_snake_case.
- live.rs a_prime_racing test: Arc<Mutex<Vec<(i64, Vec<SubId>)>>> tripped
  type_complexity; factored the inner Vec into a SeenOutcomes alias.

Verified clean on host (--all-targets) and wasm32.
… trusting it

IndexedDB is the one admission path into the engine the proxy client does not
verify: the network path checks bytes against the CID inside ProxyStorage, but
a cache hit was handed straight to make_resident. Because CAS blocks are
immutable and never revalidated, a same-origin writer (a second app on the
origin, an XSS, a compromised third-party bundle) could plant arbitrary bytes
under a well-formed CID key and have them trusted as that block for the life of
the database, producing silently wrong query results with no observable network
request.

Re-verify on the cache-hit path (fluree_db_nameservice_sync::verify_object_integrity,
already shared and wasm-safe). A row that fails is discarded, not served: the
read falls through to the origin fetch, which verifies and — via make_resident's
write-behind CachePut — overwrites the poison under the same key, healing the
cache. Rejections increment a CasStats.cache_rejections counter (a nonzero value
is a security signal) and log a warning.

Test seeds the cache with attacker bytes under a valid CID and the origin with
the real bytes: the read returns the verified bytes, a fetch fires, the counter
increments, and write-behind overwrites the poisoned row. Mutation-checked
(bypassing the verify serves the poison and fails the test).
…cycle is delivered

cycle_over wrote each changed subscription's new xxh3 hash into the registry
before run_serialized emitted the outcome. If that emit reached no callback —
the shell's auto-prime (fluree-db-wasm/src/live.rs:61 `let _ = set.prime()`)
runs detached and delivery is only via the callback installed later when JS
calls onCycleOutcome — the hash was committed for a payload the SDK never
received. The next cycle computes the identical result, sees the committed
hash, and reports the subscription `unchanged` with no payload: it renders
nothing, forever, with no error.

Gate the hash commit on deliverability. cycle_over takes a `commit_hashes`
flag; the emit path passes whether a callback is currently registered (checked
per cycle, so a listener attaching between cycles is picked up), and the pure
run_cycle family passes true because it hands the outcome straight back. An
undelivered cycle leaves last_hash untouched, so the first delivered cycle
re-sends the payload. (The throw-mid-cycle variant self-heals: a closing port
recycles the worker, and re-subscribe builds a fresh LiveQuerySet with cleared
hashes.)

New test: a prime with no listener, then a listener attaches and advances —
the sub is delivered its payload, not wedged unchanged. Mutation-checked
(forcing deliverable=true reproduces the wedge, [([],[1])]). The existing
prime-then-unchanged test now registers a listener, matching production where
one always exists.
IoJob derives Debug, and its SseOpen variant carried headers as a bare
Vec<(&str, String)> holding ("authorization", "Bearer {token}"). One
tracing::debug!(?job) or panic!("unexpected job {other:?}") in the driver
would write the user's bearer token to the browser console, readable by any
extension or error-reporting SDK on the page. The sibling TransportRequest
already hand-redacts for exactly this reason.

Wrap the headers in an SseHeaders newtype whose Debug redacts the
authorization value (non-sensitive headers stay visible). IoJob keeps its
derived Debug and stays correct for any future variant that carries the type.
Test asserts the token never reaches the debug string.
…lds bursts

The head-change drain awaited each advance to completion before pulling the
next event. The driver's coalescer only folds when a second advance for a
ledger arrives WHILE the first is running (begin returns None), so a
sequential drain never folded anything: a five-event burst ran five full
advance cycles — five re-queries and five IndexedDB round trips on the peer —
and a slow cycle on one ledger blocked every other ledger behind it. The
comment even claimed the opposite was happening.

Spawn each advance instead of awaiting it. Overlapping advances are what the
coalescer was built for: a same-ledger burst folds into one follow-up cycle at
the latest head, and different ledgers advance in parallel. The coalescer's
folding is covered by live.rs (mid_cycle_head_signals_coalesce_into_one_followup_cycle);
this change is what lets the drain reach it. The has_ledger gate still runs
before the spawn, so unsubscribed ledgers cost nothing.
… the stream

sse::run awaited the fetch promise for response headers with nothing racing
it. The module correctly keeps no timeout on the long-lived STREAM, but a
server that accepts the connection and never sends headers is hung, not
long-lived: the head-tracking future then parks forever inside until_stopped,
the reconnect backoff never runs, no Disconnected event is emitted, and the
peer looks connected while silently never seeing another commit.

Race the headers await against a connect deadline (config.nameservice_timeout,
clamped through timer_millis). On expiry, abort the fetch and surface
SseConnectError::Retryable, which drops onto the existing backoff/reconnect
path. The stream itself remains unbounded in time once headers arrive.
fetch::execute read the whole body via array_buffer() with no size check,
then copied it into wasm linear memory. With max_concurrent_fetches of these
in flight, a hostile or broken server answering a block request with a
multi-GiB body can exhaust the linear-memory ceiling and trap the wasm
instance (recovered only by discarding all engine state). The residency
tier's ObjectExceedsBudget check runs AFTER the bytes are already in memory,
so it cannot prevent the trap.

Pre-check the declared Content-Length against the residency budget (plumbed in
as max_body_bytes) and abort+reject before the body read. The size decision is
a pure helper in config.rs, unit-tested off-wasm (the driver is wasm-only).

Bounds the honest-oversized case; a lying or absent Content-Length is not
caught here — the residency budget still rejects such a block after the fact,
and a fully streaming byte-cap would be the complete defense.
…essures

The driver forwarded SSE body chunks over an UNBOUNDED channel to a consumer
that awaits LedgerManager::notify per event. A server streaming faster than
the consumer drains would accumulate chunks without limit. Switch to a bounded
channel (depth 256): a full channel makes the driver's stream-read send await,
which pauses the ReadableStream read and so the TCP read — backpressure to the
server instead of unbounded buffering. Head-change events are small and
infrequent, so the bound is never reached in normal operation; consumer-drop
is still detected (send returns Err) and cancels the stream.

Also gates config::declared_length_over_cap (the #6a body-cap helper) on
cfg(any(wasm32, test)): its only production caller is the wasm driver, so a
host lib build would otherwise flag it dead under -D warnings.
- onLedgerHead detach was not idempotent: it captured the listener set at
  subscribe time and deleted the ledger entry whenever that set emptied, so a
  defensive double-cleanup (or a late unmount after another component
  re-subscribed the same ledger) evicted the new component's head listener,
  which then silently stopped updating. Guard with a "removed" flag and only
  delete when the registered set is still the captured one. (mutation-checked)

- A query first observed AFTER close() rendered "loading" forever: snapshotFor
  minted INITIAL_STATE for an unknown key on a closed cache, and a closed
  cache never observes or fetches, so nothing could ever move it. Return a
  typed "client-closed" error (a frozen singleton, so getSnapshot stays
  referentially stable) instead.

- RemoteTransport left the connection indicator stale when nothing was live to
  watch: refresh() aborted the stream and returned without a state change, so
  a page that dropped its last subscription kept showing "live", and a
  time-travel-only page showed "connecting" forever. Add an "idle" state
  (non-breaking: the package is unreleased) and emit it when the URL resolves
  to null.

- A peer cycle spanning two spellings of one ledger (useQuery("demo/board")
  and useQuery("demo/board:main") on one page) was filed under whichever
  spelling claimed first, so client.ledgerHead for the other never fired.
  Group by each subscribing spec's ledger and emit one CycleUpdate per
  spelling, as failAll already does. (regression test added)
…ener

The channel dispatches the same cycleOutcome event object to every listener,
and PeerTransport registers two that both call toLiveCycle on it — one for
per-subscription fan-out (LiveRegistry), one for delivery (Peer.onCycle). So
every changed payload was UTF-8-decoded and JSON.parsed twice per commit, on
the main thread, in a package whose whole argument is that you pay only when
the answer changes. Memoize the decoded LiveCycle in a WeakMap keyed on the
event; the event is unreferenced after dispatch, so entries collect with it.
The WALKTHROUGH and README told the reader they needed a server built from PR
#1730, which reads as an external dependency. This branch already carries the
per-ledger ?ledger= SSE fix (the same two commits are up standalone as #1730
for landing on main), so `cargo build -p fluree-db-server` from this checkout
is all the demo needs. Corrected the setup note, the requirements table, and
the known-blockers entry to say so.
Formatting only (assert_eq! wrapping, a long send() call, a stray blank line)
across the cache-verify, SSE-timeout and buffer-bound changes. No logic
change; native 4382, binary-index 394, wasm clippy, and both tsc suites are
green at this head.
… paths

Found by an independent second adversarial pass over the shell:

- F1 (High): a FATAL re-init reply double-recycled. onmessage recycles a fatal
  reply synchronously, and the reinit .then recycled ALL non-ok replies —
  fatal included — as a microtask, arming two respawn timers. The later one
  fired an orphan worker (live wasm + SSE stream) after the first recovered,
  splitting subscriptions across two engines and leaking past close(). This
  is a flaw inside the first-round #7 remediation ("treat any non-ok re-init
  as a recycle trigger"): the correct predicate is non-fatal-non-ok. Gate the
  .then on !fatal, and make recycle() re-entrancy-safe (clear any armed
  respawn timer before re-arming).

- F2 (Medium): playground()/connect() leaked the spawned worker (and, in peer
  mode, its wasm instance) on a NON-fatal init failure — bad url, a rejecting
  getToken (401), unsupported mode — because onmessage doesn't recycle those
  and the caller never received the Channel to close it. Wrap init in
  try/catch → channel.close() + rethrow.

- F4 (Medium): a getToken that never settles wedged init forever (the worker's
  `initing` never resolves, every later op awaits it). Bound the init
  round-trip with a 30s timeout in Channel.call that rejects typed; the F2
  cleanup then closes the channel.
…y fire; de-vacuum the head-detach test

An independent second adversarial pass found two first-round fixes that were
correct in isolation but dead through the real code paths, and one test that
could not pin the fix it guarded:

- Finding 1 (Medium): the query-after-close CLOSED_STATE never fired through
  useQuery. watch()→handleFor→liveHandleFor minted a real handle into byKey
  even when closed, so snapshotFor's byKey hit returned loading before the
  closed-check — a permanent spinner. watch() now returns a shared frozen
  CLOSED_STORE (stable subscribe/getSnapshot, getSnapshot→client-closed error)
  via cache.storeFor, minting nothing. New liveClient test; mutation-verified.

- Finding 2 (Medium): the idle connection state was unreachable for the two
  cases its docstring named. refresh() ran only when the live-ledger set
  changed, so a query-less or all-time-anchored page sat at `connecting`
  forever. start() now calls the (debounced) refresh() once, which coalesces
  with initial subscribes and settles the final state — idle when nothing
  live subscribes. Documented that idle is remote-only (peer has no
  "nothing to connect"). Two new RemoteTransport tests; mutation-verified.

- Finding 3 (Low, test-quality): the head-detach regression test passed with
  either guard removed (the `removed` flag and the `=== set` identity check
  are mutually redundant for the only reachable threat, a double-detach), so
  it pinned neither. The `=== set` check alone is sufficient and principled
  (this path has no counter to keep idempotent, unlike addObserver); dropped
  the redundant `removed` flag, so the existing test now pins the one guard
  that matters (mutation-verified: removing `=== set` fails it).
…emo 4/5)

Non-behavioral fixes from the second adversarial pass — all comment/CSS only:

- browser S1 (Should-fix): document that residency_budget_bytes is a HARD
  requirement (must exceed the combined per-ledger concurrent working set), not
  just a high-water mark — exceeding it makes the tipping fetch wait the full
  budget_wait for a release that cannot come, then fail typed, and repeat every
  cycle. A fast-fail that skips the futile wait is deferred to the browser
  reproduction (human-validation H2): the production retry loop takes a nested
  per-query guard, so "only the cycle's own guard is live" cannot be read from
  the in-flight count without confirming the guard nesting in a real browser
  first — and speculatively changing the live hot path is the perf risk we most
  want to avoid.

- shell F5 (Low): document LiveCycle as READ-ONLY. One decoded cycle is
  memoized and handed — same instance, arrays and errors included — to every
  onCycle listener so a commit decodes once; a consumer that mutates it corrupts
  what the other listeners and the cache see. Copy before mutating.

- react demo finding 4 (Low): the "only the changed row re-renders" headline is
  true only for non-reordering commits. Note in the demo docstring that a vote
  crossing a neighbor legitimately re-renders the band it jumped (positional
  structural sharing — correct, not a regression), and how to see the clean
  single-row case.

- react demo finding 5 (Low): give `idle` and `connecting` distinct dot colors
  so a settled page is visually distinct from a connecting one (they shared the
  default grey).
The second review's one remaining gap (F3, Medium): the only bound on a query
was the retained-memory budget, so a query that is slow but not memory-hungry
ran unbounded on the worker's event loop with no recourse short of close()
(which discards the whole engine). Add an optional per-call timeout.

`QueryOptions.timeoutMs` (and the wasm `query_sparql`/`query_jsonld` gain an
`Option<f64>`) arms a `TimeoutFuture` on the worker event loop, raced against
the query. If it elapses first, the query's shared QueryCancellation is tripped
with `Timeout`; the engine's cooperative checkpoints unwind it into
`QueryError::Cancelled { reason: Timeout }`, which maps to a NEW typed JS code
`timeout` (HTTP 408, already the api-crate status for a cancelled query — only
the code is new). This reuses the existing external-signaller cancellation
design (the handle was built for exactly this); a timeout is a normal typed
reply, so it does NOT poison/recycle the worker.

Scope and honesty:
- Zero cost on the default path. With no `timeoutMs` (and no memory budget) the
  query runs with default options and no cancellation handle — byte-for-byte
  the pre-F3 path, so no non-timeout query pays for this. The timer is dropped
  (clearing its setTimeout) the moment the query wins, so no lingering timer.
- The worker's query methods are async and yield to the event loop at every
  await (peer residency fetches from IndexedDB / network), so the timer fires
  and an I/O-bound query aborts promptly. A purely compute-bound stretch with
  no awaits is bounded only by reaching the next cooperative checkpoint;
  hard-preempting pure compute needs a cross-thread SharedArrayBuffer signal —
  a separate, larger change, documented in run_query_with_timeout, not silently
  implied to work.
- The ms value is clamped to i32::MAX so a huge input cannot wrap negative and
  be treated by setTimeout as "fire immediately" (the browser timer-overflow
  trap).

Tests: the reason->code mapping (Timeout -> `timeout`, Cancelled/disconnect ->
`cancelled`) is pinned by a native unit test (`cargo test -p fluree-db-wasm`).
The end-to-end timer race needs a real worker and is a human-validation item.
Verified: native test green; wasm32 clippy -D warnings clean; wasm-bindgen glue
regenerated and `build:ts` clean against the new 3-arg signature.
- rustfmt reflowed the exec_options_with_cancel return tuple and the F3
  error-mapping test's assert (post-hoc, per the fmt-after-last-edit rule).
- S1's `ResidencyError::EvictionDeferred` link needed a full path
  (crate::residency::ResidencyError::EvictionDeferred) — the type is not in
  scope in live.rs. (The crate has pre-existing broken intra-doc links, so CI
  does not gate `cargo doc -D warnings`; this just makes the new link render.)
…ry tests

F3 added a third `timeout_ms` arg to query_sparql/query_jsonld; the wasm-bindgen
playground tests still called them with two. These compile only for wasm32
under `--tests` (wasm-pack test), which the F3 verification pass missed — so the
break was invisible to the lib-only wasm clippy run. Pass None (no timeout) at
all nine call sites. Verified: wasm32 clippy --tests -D warnings clean.
…JS surface

Review #1733 (peer.rs thread): BrowserPeer::set_token existed — tested,
end-to-end sound at the Rust level (shared TokenCell; fetch transports stamp
per request, SSE resolves per connect) — but nothing above it called it, so a
peer tab whose token expired 401'd on every block fetch with no recovery
short of close()+connect(), and two doc comments contradicted each other
about whether the capability existed at all (bridge.rs claimed the shell
refreshes mid-session; peer.rs claimed no re-auth hook exists).

Wire it through, exactly the shape the review suggested: Peer::setToken
(wasm export) -> a `setToken` protocol op -> worker `requirePeer` dispatch ->
`Peer.setToken(token)` on the JS surface. Peer-only: the playground answers
with the same typed `unsupported` pattern the transact ops use in peer mode
(memory ledgers carry no bearer). Both contradictory docs are now true and
say the same thing: proactive refresh needs no teardown; getToken is still
the pull at connect/crash-recycle — the two compose.

Rust side already pinned by connect.rs's
set_token_refreshes_the_bearer_without_teardown; a shell-side protocol test
lands with the vitest harness (in flight on this PR).
…dataset seam

Two #1733 review findings on the formatting frame's residency behavior
(format/mod.rs threads):

1. Fuel re-charge per retry round. The drain/fetch/re-run loop re-ran
   formatting against the same Tracker, so N rounds charged N x one round's
   fuel and a near-budget query could fail FuelExceeded merely for missing
   residency N times — and on a peer, misses are the NORMAL first-query path.
   Formatting is pure and a failed round's work is discarded, so: snapshot
   the fuel counter once before the loop (Tracker::current_micro_fuel) and
   roll back before each retry via the new Tracker::restore_micro_fuel; the
   successful round's charge stands. Sound here because execution has
   finished — this frame is the tracker's sole fuel writer (the caller
   invariant is documented on the API). The execution loop shares the shape
   but runs concurrent consumers; left as the reviewer read it (explicitly
   not a regression there).

2. Dataset-hydration seam (#1775). The multi-ledger hydration branch is not
   wrapped by the drain/fetch/re-run loop, so on a residency-backed target a
   formatting miss would be unrecoverable — previously guarded only by a
   prose comment. The invariant is now enforced: a typed refusal if ANY
   graph in the dataset is residency-backed (live assertion; fires never
   today — the browser peer is single-ledger). The real fix (hoist the loop
   into a shared helper with per-graph store routing) is tracked in #1775.

New: DataSetDb::graphs() iterator; two fluree-db-core unit tests pinning
restore_micro_fuel (rollback semantics + inert when fuel tracking is off).
Both changes live entirely inside the residency-gated cfg block — the
default native path is untouched.
…r transport

handleFor is public and bypasses the closed-cache guard that
storeFor/observe/snapshotFor share: a call straight to it after close() was
minting a QueryHandle and inserting it into byKey/bySubId with no janitor
ever armed to collect it, since observe() (the only path that can attach an
observer and un-stick GC) also no-ops when closed. liveHandleFor now hands
back a standalone, untracked handle on that path instead.

PeerTransport.applyCycle could silently drop a cycle entry for an engine id
whose subscribe() reply hadn't resolved yet (byEngineId not populated), since
the engine primes a subscription the moment it registers and the reply/cycle
are separate postMessages with no ordering guarantee between them. A dropped
entry left the cache's hasResult false, so the next "unchanged" cycle would
misreport as a transport-contract violation. Unresolved entries are now
stashed by raw engine id while any registration is in flight and replayed
once that id's mapping lands; the stash is cleared whenever no registration
is pending (so it can't accumulate ids this transport will never own) and
again on a crash recycle (a fresh worker can reissue an id a dead one used).
…ked bodies

Coalescer::abandon() cleared both `running` and `pending` when a cycle
lease was dropped mid-await, silently discarding a head change some
other task had already folded in. Only `running` needs releasing;
leaving `pending` set lets the next begin()/finish() pair still force
the one owed follow-up cycle instead of stranding sibling
subscriptions on stale data until an unrelated signal happens to
arrive.

fetch::execute only rejected an oversized body via a declared
Content-Length; a chunked response with no such header skipped the
check and was fully materialized into linear memory regardless of
size. Body reads now stream through ReadableStreamDefaultReader and
enforce the same cap incrementally per chunk (config::body_cap_step),
aborting via the existing AbortController once the running total
crosses it.
Adds the first test runner for fluree-db-wasm/js: a Worker-stub harness
(test/helpers.ts) that lets connect()/playground() run against a scripted
double instead of a real worker, no wasm build required. Pins six
already-shipped behaviors: the toLiveCycle decode-once memo (ported from
the round-2 adversarial-review prototype), the crash-recycle ladder's
guard against double-firing on a fatal re-init reply, connect()'s cleanup
on a non-fatal init failure, the 30s init timeout, and per-query
timeoutMs passthrough for both Ledger.query and Snapshot.query.

Also wires a step into the react-sdk CI job (npm ci + npm test in
fluree-db-wasm/js) — that job already reruns on any fluree-db-wasm/js
change per the react-paths filter, since fluree-react's own suite
compiles against this package's src directly.
Completes the harness item the suite's author correctly declined: their
branch base predated the setToken wiring, so writing it there would have
meant fabricating the feature. At this tip the op exists — pin that
Peer.setToken puts {op:"setToken", token} on the wire verbatim and resolves
on the ok reply. (The worker-side peer-only gate is behind the stub; the
wire contract is the pinnable layer here.) Mutation-verified: blanking the
token at the call site fails the test.
@aaj3f

aaj3f commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

All seven inline points are addressed in code (each has a thread reply with the commit), plus the two housekeeping items: both PRs now carry the enhancement label for release.yml, and the four named deferrals have tracked follow-ups — CORS batch endpoint #1772, --storage-all #1773, the ~3.3s cold-open floor #1774, and the dataset-residency formatting seam #1775.
On the shell suite: agreed, and it's in this PR now rather than deferred — a vitest harness over fluree-db-wasm/js (worker stubbed via connect({workerUrl}), zero production changes) covering the decode-once memo, the crash-recycle re-init ladder, init failure/timeout cleanup, and the setToken/timeoutMs protocol surface, wired into the react-sdk CI job. (a07f48b + aff437c: 7 tests, each mutation-verified against the committed guard it pins)
Also from re-running the browser gates while closing these out: the wasm-smoke job unmasked a main-inherited runtime trap — the commit cancellation-shield's bare tokio::spawn (from the #1662-adjacent shield work) panics on wasm32, which took out every playground transact in a real browser. Fixed at the same seam pattern as spawn_detached (wasm_compat::spawn_shielded: native path unchanged, wasm runs the window on the event loop with the result over a oneshot, spawn stays eager so the shield property holds). Proven by the previously-failing suite: 6/6 in headless Chrome. (5b1a5d9 on the base PR, #1715)

Base automatically changed from wasm/pr2 to main September 3, 2026 20:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants