Repository navigation
fix(ui): reload an open tab when River's room contract or delegate re-keys (#580) - #792
Conversation
…-keys (#580) An open tab runs the build it loaded, with the room contract and chat delegate WASM compiled in. After a re-key it stays on the old contract key, so its rooms go quiet and what it posts reaches nobody, and it keeps writing to the old delegate, whose changes the new build no longer imports. The UI now watches River's two pointer records (100 bytes each, at fixed addresses): GET + subscribe on every connect, with a 15-minute GET backstop. build.rs bakes in which pointer version this build is, from pointer-records.toml; a PointerResolver seeded with that floor reports a newer generation only for a signature-verified record above it. On a newer generation the tab reloads its iframe into the new build, keeping the ?__sandbox=1 query. Loop bounds live in the URL, not storage: at most 4 tries per target with 2/5/15-minute backoff (the 1.2 MB web container can reach a node after the pointer does), at most 6 automatic reloads per hour, then a banner with a manual Reload. A visible tab gets a 15 s countdown with "Not now"; a hidden tab waits 30-120 s; nothing reloads over unsent text. Docs: river-publish.md step 7 now says the pointer publish is what reloads open tabs, so it must follow the UI publish (already enforced by its pre-flight) and should wait ~10 minutes; publish-river prints a reminder. Claude-Session: https://claude.ai/code/session_01SzES9BQh5PhrJhHdh9FSFw
) End-to-end on a local node: when both pointers move, the room pointer's answer lands ~7 ms before the delegate's. The first names a partial target (r2d1) that has spent no tries, so the plan started with no delay and kept it when the real target (r2d2) arrived. A tab whose node lacked the new bundle then retried every 15 s and spent all four tries in under a minute. replan() now runs on every target move: refused -> manual-only, otherwise the running plan takes the later of its wait and the new target's backoff, and never gets shorter. Regression test replays the sequence; it fails with the old keep-the-plan behaviour. Also: log the cap decision when a plan is made, move the banner below the conversation header (it covered the room title), and refuse publish-river when RIVER_POINTER_RECORDS is set (dx build hides the cargo warning). Claude-Session: https://claude.ai/code/session_01SzES9BQh5PhrJhHdh9FSFw
…ation (#580) Production telemetry over River UI publishes 30000397-403 (41 sampled deliveries of the ~1.2 MB web container): median ~20 s, 71% within 60 s, 80% within 5 min, p90 ~10 min, max ~2 h; the 100-byte pointer reaches subscribers in under 0.5 s. So the guessed numbers change: the publish gap before publish-pointer-records goes from ~10 to ~2 minutes (clears the fast cluster; longer buys little), and the retries gain a fifth try at +45 min, spanning ~67 minutes instead of ~22, past the p90 with margin. Claude-Session: https://claude.ai/code/session_01SzES9BQh5PhrJhHdh9FSFw
sanity
left a comment
There was a problem hiding this comment.
Comprehensive PR Review: #792
Summary
- PR Title: fix(ui): reload an open tab when River's room contract or delegate re-keys (#580)
- Type: fix
- CI Status: pending at review time
- Linked Issues: #580 (related: #522, #694, #695)
- Review tier: Full. It touches publish/release config and adds UI behaviour that reloads users' tabs unattended.
- Reviewers run:
- Claude lenses: code-first, testing, skeptical, big-picture, and a change-specific lens covering self-reload failure modes (signal safety, data loss, the shell/iframe contract).
- External:
codex review --base origin/main, which completed.
- HEAD SHA reviewed: 3cb07d7
Every finding below was checked against the code. The shim ID collision was confirmed in freenet-core websocket_shim.js (the counter restarts per document) and shell_bridge.js:1544-1606.
Code-First Analysis
Alignment: matches the stated intent: pointer-record detection, a resolver floored at the build's own record, iframe reload with URL-held caps.
Gaps: the loop bounds are weaker than the docs claim (Must Fix 1 and 4).
Testing Assessment
Coverage: the pure logic is adequate. The runtime driver, response routing and draft detection have no automated coverage.
| Test Type | Status | Notes |
|---|---|---|
| Unit | Verdicts, caps, URL and timing are well tested, and none of the tests is vacuous. The call site of the re-plan fix is unpinned. deliver_state/deliver_update are untested. The wiring pin is positional only: deleting an early return still passes. |
|
| Integration | ❌ | None for the wasm runtime. |
| E2E | Manual Playwright run only. It did not check node client counts after a reload. |
Skeptical Findings
Risk Level: high, until the Must Fix items are addressed.
Big Picture
Goal Alignment: yes, with no scope creep.
Stale docs: AGENTS.md (pointer section), Makefile.toml (publish-river header), river-publish.md step 3b, and delegate-migration.md still say pointer records serve third parties only.
Recommendations
Must Fix (Blocking)
- The driver re-navigates every second once a reload fires.
generation_watch.rsdrive()does not stop afterauto_reload. The past-due phase reloads again on every tick, and each newlocation.replaceaborts the navigation in flight. On a slow node the reload never commits, and the node takes one iframe request per second. The repeats also bypass the caps, becauseexisting_param()still reads the old URL. (skeptical, failure-mode, code-first) - A self-navigation leaks the shell-side WebSocket, and the new page's socket shares its ID.
- The shell closes proxied sockets only on its own navigations. The shim restarts
__fws_IDs per document, so the new page's__fws_1overwrites the old map entry. - The old socket's stale notifications reach the new build. When it finally closes,
connections.delete('__fws_1')kills the new socket's entry. - One more node client leaks per reload. (skeptical, failure-mode)
- The shell closes proxied sockets only on its own navigations. The shim restarts
- The reload replays a stale fragment. The iframe's
location.hashis never updated after River posts#river-processed=to the shell (receive_invitation_modal.rs:19-28). A reload that keeps?invitation=with the stale fragment shows an already-handled invitation again. The fragment must be rebuilt fromPROCESSED_CACHE. (failure-mode) - A partial target resets the per-target tries cap.
- Tries are keyed on the exact target string, so an answer that names only one pointer (
r2d1) has zero tries on a page that was reloaded forr2d2. - If the second pointer's answer is late or fails, the targets alternate and
TriesExhaustedis never reached. Only the 6-per-hour ceiling holds, and it renews every hour. (code-first, skeptical)
- Tries are keyed on the exact target string, so an answer that names only one pointer (
Should Fix
- The draft guard is incomplete. It misses the invite-via-DM message box (
invite_via_dm_picker_modal.rs:451), the edit-room, create-room, join-with-code and invite-member forms, nickname edits, and in-flight invitation accepts (PENDING_INVITES). (failure-mode, codex, code-first) - The reload can cut off a send or a pending delegate write.
- When the countdown is waiting on a draft, it reloads within about 1 s of Send, while the update may still be in flight.
- Debounced room snapshots (up to 5 min) are flushed only on
visibilitychange. - Fix: add a settle period and wait for
NEEDS_SYNCto empty, then callflush_rooms_to_delegate()before navigating. (skeptical, failure-mode)
- The
RIVER_POINTER_RECORDSguard runs too late, and checks the wrong moment. It runs aftersign-webapp, which has already bumped the forward-only counter. It also checks the publishing shell's environment rather than what was built. Check the built archive before signing instead. (codex, code-first, big-picture, skeptical) - A 15 s visible countdown on a freshly loaded page lands inside the node's 30 s refresh TTL, so that first try is guaranteed wasted. Floor the first reload at page age > 30 s. (skeptical)
- Tests:
- pin the re-plan at its call site through a pure function
- test
deliver_updateacrossState,DeltaandStateAndDelta, including a forged delta - make the wiring pins catch a deleted early return and the
is_pointerskip - pin the composer selectors to their components
- test the archive guard in
publish-guards-test.sh(testing)
- Stale docs that still say pointers serve third parties only (listed above). (big-picture)
Consider
- "Not now" is lost on a quick tab switch: Snoozed → Hidden → Countdown.
- A long retry wait shows as "Reloading in 45 min so you keep receiving messages", and the 1 s tick runs for that whole time.
- Cross-reference #522, River's other self-navigation of its iframe.
- Log the cap decision when a plan is first made. (Done in 68ef67b.)
Verdict
State: Needs Changes — Re-review Required After Fix (Must Fix 1–4).
HEAD SHA reviewed: 3cb07d7
[AI-assisted - Claude]
Must-fix findings from the Full-tier review of #792: - One reload per page. The driver re-issued location.replace every tick until the page went, each aborting the last. A reload now moves the page to Phase::Reloading, which never issues another; one that has not landed after 20 s falls back to the manual banner. - Reload through the shell's same-contract `navigate`. A self-navigating iframe left its proxied WebSocket open in the shell, and the shim numbers sockets from 1 per document, so the new page's socket collided with it. The shell closes every proxied socket before replacing the frame. The synchronizer does not reconnect while a reload is under way. - Rebuild the fragment from PROCESSED_CACHE. The iframe's own hash is stale after an invitation is processed; reloading with it replayed the invitation. - Compare reload targets per artifact. Tries were keyed on the exact target string, so a page that had heard one pointer (r2d1) reset the count for r2d2 and only the hourly ceiling held. A target no newer in any artifact carries the tries; a newer one starts afresh with the max recorded. Should-fix: - Busy check covers any visible `fixed inset-0` overlay (every modal), a focused field with text, pending invitation accepts, the composer and an edit in progress, plus a 5 s settle; pending syncs and the delegate flush get up to 5 s before navigating. - No reload before the page is 35 s old (the node's 30 s bundle-refresh window would serve this page's bundle again). - "Not now" sticks across tab switches. - A competing record at the build's own version is not "newer". - The test-pointer guard moved from publish-time env to the built archive, checked in sign-webapp before the counter bump (scripts/ check-webapp-pointer-author.sh, tested in publish-guards-test.sh, and verified on a real release build and on the E2E test-key archive). - Banner explains a long retry wait; docs no longer say pointers serve only third parties. Routing is now a pure function (on_pointer_state) so the response-to-plan path is tested; wiring pins check each hook returns; selector pins cover the busy detection. Mutations of the per-artifact compare, the call-site re-plan, a hook's return and the reconnect guard each turn a test red. Claude-Session: https://claude.ai/code/session_01SzES9BQh5PhrJhHdh9FSFw
`cargo install cargo-make` now fails on every job: cargo-make 0.37.24 requires `zip = "^2"` and every zip 2.x release was yanked on 2026-10-16, so the unlocked install cannot resolve. --locked builds from cargo-make's own Cargo.lock (zip 2.2.2), which cargo still accepts. Verified locally. main is affected the same way on its next run. Claude-Session: https://claude.ai/code/session_01SzES9BQh5PhrJhHdh9FSFw
main fixed the same breakage in #794 (cargo-make as a prebuilt binary), which conflicts with this. Drop ours and take main's. Claude-Session: https://claude.ai/code/session_01SzES9BQh5PhrJhHdh9FSFw
Should-fix findings from the second Full-tier review of #792: - A Connect skipped during a reload now still clears the reconnect latch, and a reload that does not happen re-sends Connect. Before, a socket lost during a reload that then failed left the tab disconnected. - The stuck clock starts when the reload is posted, not before the flush, and a flush that outlives the plan never posts a stray navigate. - A posted reload that never lands ends in ReloadFailed: the banner asks the reader to refresh the tab instead of offering the same reload again. - Reload/Not now cannot start a second reload or show a Reload button mid-reload. - A user action that queues a sync (mark_needs_sync) starts the settle period at once, so a quick send between ticks is not reloaded over; no automatic reload while disconnected; only in-flight invitation accepts count as busy. Tests: the driver's reload-due transition is the pure on_reload_due; the deliver_* return contracts are pinned; wiring pins are scoped to each hook's own block (the NotFound pin could be satisfied by the next statement), including the latch clear; all 13 overlay sites and the mark_needs_sync call are pinned; the archive guard test asserts exit 2 for an unreadable archive and that the call keeps `|| exit 1`. Docs: the remaining "pointers serve third parties only" lines; the history entry each reload adds is freenet/freenet-core#5910. Claude-Session: https://claude.ai/code/session_01SzES9BQh5PhrJhHdh9FSFw
sanity
left a comment
There was a problem hiding this comment.
Re-review (round 2): #792
- HEAD SHA reviewed: 175a787. Fixes then landed in c7e2eb5 and 2e26bc8, listed below.
- Review tier: Full.
- Reviewers run:
- Claude lenses, each blind to the others: code-first, testing, skeptical, big-picture, and a change-specific lens on self-reload failure modes (signal safety, data loss, the shell contract).
- External:
codex review --base origin/mainwas tried and failed with "You've hit your usage limit … try again at 6:38 AM". Following the review rules, the five distinct Claude lenses above stand in for it, and the review did not wait for Codex.
Prior Must Fix: all confirmed resolved
- Repeated navigation:
Phase::Reloadingmeans one reload per page. - WebSocket leak and ID collision: the reload goes through the shell's same-contract
navigate, which closes the proxied sockets. Connect is suppressed while the reload is under way. End to end, with 7 reloads: socket count unchanged, and subscriptions moved to the new client. - Stale fragment: the reload's fragment is rebuilt from
PROCESSED_CACHE. End to end, the invitation did not reappear. - Partial target resetting the tries: targets are compared per artifact. Unit tested, and checked end to end in both answer orders.
New in round 2. No Must Fix; Should Fix items addressed in 2e26bc8
- Reconnect latch (code-first, skeptical, failure-mode): a skipped Connect left
reconnect_pendingset, so a reload that then failed left the tab disconnected. Fixed: the latch is cleared on skip, and an abandoned reload sends Connect again. Pinned. - Stuck clock started before the flush (skeptical, failure-mode): a slow flush could give up the reload and then post it anyway. Fixed: the clock starts once the reload is posted, and a flush that outlives the plan posts nothing.
- A failed reload offered the same failing Reload button (skeptical). Fixed: a new
ReloadFailedstate asks the reader to refresh the tab. - A quick send between driver ticks escaped the settle period (failure-mode). Fixed:
mark_needs_syncrecords user activity. No automatic reload while disconnected. Only in-flight invitation accepts count as busy (skeptical: finished entries linger). - Double reload and "Not now" mid-reload (code-first, skeptical, failure-mode): guarded.
- Tests (testing):
- The NotFound wiring pin could be satisfied by the next statement. Pins are now scoped to each hook's own block; a mutation that deletes the hook's
returngoes red. - The
deliver_*return contracts are now pinned. - The driver's reload-due transition is now the pure
on_reload_due. - All 13 overlay sites are pinned.
- The archive-guard test asserts exit 2 for an unreadable archive and requires
|| exit 1.
- The NotFound wiring pin could be satisfied by the next statement. Pins are now scoped to each hook's own block; a mutation that deletes the hook's
- Docs (big-picture): fixed the remaining "pointers serve third parties only" lines in
delegate-migration.md,wasm-safety.mdand the Makefile header.
Not fixed here
- Every reload adds a browser history entry, because the shell's
navigatealways usespushState. Back reloads the previous frame path along with its older cap state. That takes a reader's press each round, so it cannot loop. A replacingnavigateneeds freenet-core: filed as freenet/freenet-core#5910 and noted in the module docs. - A hidden tab holding a draft never reloads automatically. This is deliberate (never reload over unsent text). The banner appears when the tab is shown again.
- CI: the
cargo-makeinstall broke for every PR when everyzip2.x was yanked. main fixed this in #794, so this branch dropped its own fix (c7e2eb5) to avoid the conflict that had blocked CI. The same breakage in freenet/web's deploy is freenet/web#204.
Verdict
State: Needs Changes — Re-review Required After Fix. The fixes changed more than 30 lines, so round 3 will review 2e26bc8.
[AI-assisted - Claude]
) SYNC_STATUS is also set to Error(..) on a live socket (an unsupported operation), which would have held every reload forever. Wait only on Disconnected / Connecting. Claude-Session: https://claude.ai/code/session_01SzES9BQh5PhrJhHdh9FSFw
…offline (#580) Must-fix findings from the third Full-tier review of #792: - "Offline" now means no live socket (WEB_API unset), not a status value. SYNC_STATUS reads Error through most of an outage, so a tab could reload into a dead node and land on the browser's error page. Nothing reloads while disconnected; the banner says it will reload once it reconnects, and the manual Reload is withheld. - A posted reload never reconnects. The shell closes the old page's sockets when it accepts `navigate`, and the new frame can take a 30 s container GET to arrive; a page that gave up at 20 s and reconnected leaked a socket into the next one. RELOAD_STUCK_MS is now 60 s, and ReloadFailed carries `reconnect`: true only if the socket is still open (the shell ignored the request). Only an unposted reload, which times out on its own after 30 s, calls abandon_reload. Should-fix: RELOADING is set synchronously with the phase change; busy and connection are re-checked just before posting (the plan is put back if not); abandon_reload retries reaching the synchronizer instead of dropping the Connect; the driver ticks every 5 s unless a reload is due within a minute; the hook is renamed note_sync_queued (network callers trigger it too); the NEEDS_SYNC wait is documented as the cheap check it is. Tests and pins: disconnected never reloads; a page being replaced never reconnects; unposted vs posted timeouts; drive() may call abandon_reload only for an unposted reload; the latch clear precedes the skip; the subscribe hook returns false; the guard script's exit-2 branches. Each new guard was mutated and a test went red. Claude-Session: https://claude.ai/code/session_01SzES9BQh5PhrJhHdh9FSFw
sanity
left a comment
There was a problem hiding this comment.
Re-review (round 3): #792
- HEAD SHA reviewed: 51aa298. Fixes landed in c72dabe, listed below.
- Review tier: Full.
- Reviewers run: five Claude lenses, each blind to the others: code-first, testing, skeptical, big-picture, and the self-reload failure-mode lens.
- Codex: still unavailable after its earlier "usage limit" failure. As the review rules require, the five Claude lenses stand in for it, and the review did not wait.
Round-2 Should Fix items: confirmed implemented
All of them: the reconnect latch, the stuck clock, ReloadFailed, the Reload and Not-now guards, the settle period on queued syncs, the in-flight invitation rule, and the block-scoped pins.
Round 3 found two Must Fix items, both fixed in c72dabe
- The offline hold used the wrong signal (skeptical, code-first).
- Through most of an outage
SYNC_STATUSreadsError, so a tab could reload into a dead node and land on the browser's error page. - "Offline" now means no live socket (
WEB_APIunset). Nothing reloads while offline, the banner says it will reload once it reconnects, and manual Reload is withheld. - Pinned, and a mutation that ignores the connection goes red.
- Through most of an outage
- A posted reload could reconnect while the shell was replacing the page (skeptical, code-first, failure-mode).
- The shell closes the old page's sockets when it accepts
navigate, and the new frame can take a 30 s container GET to arrive. - At the old 20 s stuck threshold, the page gave up and reconnected, which leaked a socket into the next page and reopened round-1 Must Fix 2.
- Fix: the stuck threshold is now 60 s.
ReloadFailedreconnects only if the socket is still open, meaning the shell ignored the request. Only an unposted reload, which has its own 30 s timeout, may callabandon_reload. - Pinned, and mutations of both rules go red.
- The shell closes the old page's sockets when it accepts
Round-3 Should Fix: fixed in c72dabe
- The reloading flag is now set synchronously with the phase change.
- Busy and connection are re-checked just before posting.
abandon_reloadretries reaching the synchronizer instead of dropping the Connect.- The driver ticks every 5 s unless a reload is due within a minute.
note_user_activityis renamednote_sync_queued, since network callers trigger it too.- The NEEDS_SYNC wait is documented honestly: it is a cheap first check, and the settle period is the real protection.
- New pins: the subscribe hook returns
false, and the latch clear precedes the skip. - The guard script's exit-2 branches are tested.
- The PR description is rewritten: it no longer describes the rejected
location.replacedesign or the old env guard.
Verdict
State: Needs Changes — Re-review Required After Fix. Must Fix items were addressed, so round 4 will review c72dabe.
[AI-assisted - Claude]
…#580) Findings from the fourth Full-tier review of #792: - A posted reload now never reconnects, failed or not. The "socket still open means the shell ignored it" escape only helped shells without `navigate`, which no compatible node serves, while a slow closing handshake can make a closed socket read open for up to a minute and reopen the socket leak. ReloadFailed loses its `reconnect` field; drive() never clears RELOADING. - The pre-post check is the pure `ready_to_post`, tagged with the attempt's start so a stale flush task never posts, and it lets a manual Reload through a busy reader (it used to bounce the click back to a countdown). Offline still waits. - One Offline banner (no buttons) for every state that would otherwise offer a Reload that cannot work while disconnected. - ConnectionLost with a reconnect already pending now also drops WEB_API: a failed attempt left the dead socket there, so "WEB_API set" did not mean connected. - The refresh banner warns that messages sent before refreshing may not be delivered (the page no longer reconnects). - UNPOSTED_TIMEOUT_MS 3 min: a long-hidden tab's timers run about once a minute. The driver no longer ticks every second past a due time. Tests: ready_to_post (stale tags, click through busy, offline waits); offline banners carry no Reload; a posted reload fails without reconnecting whether or not the socket looks open; drive() never clears RELOADING. Mutations of the manual exemption and the attempt tag each go red. Docs: "Reload now" -> "Reload"; river-publish no longer duplicates the telemetry figures (they live with RETRY_DELAYS_MS). The shell's late-close id race is freenet/freenet-core#5912. Claude-Session: https://claude.ai/code/session_01SzES9BQh5PhrJhHdh9FSFw
sanity
left a comment
There was a problem hiding this comment.
Re-review (round 4): #792
- HEAD SHA reviewed: c72dabe. Fixes are in db7968d, listed below.
- Review tier: Full.
- Reviewers run: five Claude lenses, each blind to the others: code-first, testing, skeptical, big-picture, and self-reload failure modes.
- Codex: retried at 03:46 and failed again with "usage limit … try again at 6:38 AM". The Claude lenses substitute for it; the review does not wait.
Round-3 Must Fix items: confirmed resolved
- Offline is now defined as
WEB_APIunset. - The 60 s stuck threshold and the unposted timeout are in place.
- Only an unposted reload calls
abandon_reload. - The description matches the code (big-picture lens).
Round 4: no new Must Fix. Should Fix items addressed in db7968d
- A posted reload could still reconnect. Raised by the failure-mode lens, and by the skeptical lens in reverse.
- The "socket still open means the shell ignored it" escape only helped shells that lack
navigate, and no compatible node serves one. - A slow closing handshake can make a closed socket read as open for up to a minute, which reopens the leak.
- Fix: a posted reload now never reconnects.
ReloadFailedloses itsreconnectfield, anddrive()never clears the reloading flag. Pinned, and the pin goes red when the flag is cleared.
- The "socket still open means the shell ignored it" escape only helped shells that lack
- Manual Reload was bounced by the busy re-check (code-first, skeptical). A click with a draft open flashed "Reloading…" and fell back to a countdown, repeatedly.
- Fix: a pure
ready_to_post(phase, started_ms, manual, inputs). A manual Reload passes through busy but never posts while offline.
- Fix: a pure
- Stale flush tasks (code-first, skeptical). Each attempt is now tagged with its start time, so a stale task never posts. Mutations of both the manual exemption and the tag go red.
- Reload offered while offline did nothing (code-first, skeptical, testing). One Offline banner with no buttons now covers every state that would otherwise offer Reload.
WEB_APIcould hold a dead socket (skeptical).- A failed reconnect left the old socket in place, and a later
ConnectionLosthit the "reconnect pending" arm, which did not clear it. - Fix: that arm now drops
WEB_APItoo.
- A failed reconnect left the old socket in place, and a later
- A stranded page accepted input (skeptical). The refresh banner now warns that messages sent before refreshing may not be delivered.
- Throttled hidden tabs (skeptical). The unposted timeout is now 3 min, and the driver no longer ticks every second past a due time.
- Tests (testing):
ready_to_post, offline banners with no buttons, a posted failure in both socket states, and the "drive never clears RELOADING" pin. - Docs (big-picture): fixed the "Reload now" label, rewrapped the long lines, and the telemetry figures now live only with
RETRY_DELAYS_MS.
Filed, not fixed here
freenet/freenet-core#5912 (failure-mode lens). The shell's ws.onclose deletes a connection entry without checking its identity. A slow close of the old page's socket can therefore drop the new page's entry, because the ids are shared (__fws_N).
Verdict
State: Needs Changes — Re-review Required After Fix. Round 4 found no Must Fix, but the fixes changed more than 30 lines, so round 5 will review db7968d.
[AI-assisted - Claude]
Findings from the fifth Full-tier review of #792 (no Must Fix): - The banner now follows the connection: the synchronizer calls generation_watch::on_connection_changed on ConnectionLost and on a failed Connect, and on_connected does too. A manual-only banner no longer keeps a Reload button after the node drops (the driver has stopped there), and an offline banner no longer outlives the outage. A click while offline shows the offline banner instead of doing nothing. - The offline banner is now "Waiting to reconnect to your node.", true for every state that shows it (Not now and a refused cap do not reload on their own). - A manual click that finds the node gone mid-flush goes back to the manual banner rather than becoming an automatic countdown. - A manual click waits out the settle period after a send before flushing. - A reload URL over the shell's 4096-byte limit counts as never posted (the shell would ignore it silently, and the page would never reconnect). - connected() treats a contended WEB_API borrow as connected (a send holds the socket). - Reverted the previous round's WEB_API take() in the ConnectionLost "reconnect pending" arm: it could close a healthy socket in a narrow sequence, a regression for every user, to avoid a rare reload into a dead node. Pins: RELOADING is cleared in one place, reached only before a post; nothing reconnects after a successful post; drive() passes manual=false with its attempt tag and reload_now passes manual=true; the banner hooks are wired. Claude-Session: https://claude.ai/code/session_01SzES9BQh5PhrJhHdh9FSFw
sanity
left a comment
There was a problem hiding this comment.
Re-review (round 5): #792
- HEAD SHA reviewed: db7968d. Fixes are in d5db224, listed below.
- Review tier: Full.
- Reviewers run: five Claude lenses, each blind to the others: code-first, testing, skeptical, big-picture, and self-reload failure modes.
- Codex: unavailable ("usage limit … try again at 6:38 AM"). The Claude lenses stand in for it.
Result: no Must Fix in round 5
Round 4's fixes are confirmed. In particular, a posted reload never reconnects, the attempt tag works, and a manual Reload passes a busy check but not an offline one.
Should Fix, addressed in d5db224
- The banner went stale on connection changes (failure-mode, skeptical, code-first). The driver stops in the manual-only state, so a Reload button survived a dropped node and did nothing when clicked. Fixed: the banner is now recomputed on connect, on
ConnectionLostand on a failed Connect. Pinned. - The offline wording promised an automatic reload (code-first, skeptical, failure-mode). That is false after "Not now" or a refused cap. The banner now reads "Waiting to reconnect to your node."
- Missing pins (testing). Pins now cover:
- the call-site flags:
drive()passesmanual = falsewith its attempt tag, andreload_nowpassestrue; - RELOADING being cleared in one place, reachable only before a post;
- nothing reconnecting after a successful post;
- the banner hooks.
- the call-site flags:
Considers taken
- A manual click that finds the node gone mid-flush now returns to the manual banner.
- A manual click now waits out the settle period after a send.
- An href over the shell's 4096-byte limit now counts as never posted.
- A contended
WEB_APIborrow now reads as connected.
Reverted
Round 4 added a WEB_API take() in the "reconnect pending" arm of ConnectionLost. The skeptical lens showed it can close a healthy socket in a narrow sequence: a non-close socket error, then a failed second connect. That is a regression for every user, traded against a rare reload into a dead node during a re-key. It is reverted, and the residual is documented under Known limitations.
Verdict
State: Needs Changes — Re-review Required After Fix. Round 5 found no Must Fix, but the fixes changed more than 30 lines, so round 6 reviews d5db224.
[AI-assisted - Claude]
…lan (#580) Findings from the sixth Full-tier review of #792 (no Must Fix): - "Connected" now also requires READY, set by on_connected (called after the synchronizer re-registers the delegate and processes the rooms) and cleared by on_disconnected (ConnectionLost, failed Connect). Before, a due reload could fire in the up-to-15 s after a socket opened but before what the reader sent offline had gone out. - A Reload click that finds the node gone mid-flush puts back exactly the phase it was clicked from: an automatic countdown stays automatic, the manual banner stays manual (reload_after_flush takes `clicked_from`). - A countdown that runs out while offline keeps a full countdown in hand, so reconnecting shows 15 s rather than reloading at once. - ReloadFailed carries `posted`: only a posted reload stops the page reconnecting, so only then does the banner warn that messages may not be delivered (a URL too long for the shell was never posted). - The shell-href limit applies only to the framed path, in UTF-16 terms. - A put-back refreshes the banner at once; comments no longer claim WEB_API is always cleared on loss. Pins: Connect is sent from abandon_reload only; the failed-Connect arm and ConnectionLost mark not-ready; READY is set only in on_connected, which runs after process_rooms; call sites pass None / Some(before). Claude-Session: https://claude.ai/code/session_01SzES9BQh5PhrJhHdh9FSFw
sanity
left a comment
There was a problem hiding this comment.
Re-review (round 6): #792
- HEAD SHA reviewed: d5db224. Fixes are in 9650065.
- Review tier: Full.
- Reviewers run: five Claude lenses, each blind to the others: code-first, testing, skeptical, big-picture and the failure-mode lens.
- Codex: unavailable until 06:38, so the Claude lenses substitute for it.
Result: no Must Fix
Should Fix items, addressed in 9650065
- A reload could fire before a reconnect had caught up (skeptical).
- Problem: "connected" became true as soon as the socket opened. The synchronizer then spends up to 15 s re-registering the delegate before it processes rooms.
- Fix: a
READYflag.on_connectedsets it, afterprocess_rooms.on_disconnectedclears it, onConnectionLostand on a failed Connect.connected()now requires it. - This also fixes the Offline banner outliving a reconnect (code-first, failure-mode).
- Pinned.
- A click from a countdown could lose the automatic plan (skeptical).
reload_after_flushnow takesclicked_from, and a click that finds the node gone puts back exactly that phase. - Stale comments (code-first, failure-mode). The comments no longer say
WEB_APIis always cleared on loss; that is listed under known limitations.
Considers taken
- A countdown that ran out while offline now restarts at 15 s when the connection comes back.
ReloadFailed { posted }: only a posted reload shows the "may not be delivered" warning.- The href limit now applies only on the framed path.
- A put-back now refreshes the banner immediately.
- The failed-Connect pin is scoped to its own arm.
- New pin: Connect is sent only from
abandon_reload.
Verdict
State: Needs Changes — Re-review Required After Fix. The fixes changed more than 30 lines, so round 7 reviews 9650065. A third end-to-end run on a real node runs alongside it.
[AI-assisted - Claude]
…es (#580) Findings from the seventh Full-tier review of #792 (no Must Fix): - on_disconnected moved from the top of ConnectionLost into the teardown (armed) arm: a coalesced duplicate loss tore nothing down, but cleared READY, and a following Connect skipped as "already connected" never set it again, leaving the Offline banner on a live socket. - That skip path now calls mark_ready (the connect that made the live connection has finished; the synchronizer handles one message at a time). - A Connect that proceeds marks the page not ready before it starts: with no loss before it (the status can read Error on a live socket) it replaces the socket and spends up to 15 s catching up, and READY stayed true. - The unposted refresh banner says "Refresh this tab to get the update" (the page keeps working). Pins: on_disconnected is called in the teardown arm, before a Connect proceeds and on a failed Connect, never in the duplicate-loss arm; mark_ready in the skip path; on_disconnected clears READY; on_connected sets it before any early return; connected() requires READY && a socket; reload_now captures the pre-click phase before replacing it. The offline-hold test again pins non-countdown phases as unchanged. Claude-Session: https://claude.ai/code/session_01SzES9BQh5PhrJhHdh9FSFw
sanity
left a comment
There was a problem hiding this comment.
Re-review (round 7): #792
- HEAD SHA reviewed: 9650065. Fixes are in 29175d5.
- Review tier: Full.
- Reviewers run: three Claude lenses, blind to each other: skeptical, code-first and testing.
- Big-picture and the failure-mode lens were clean in round 6 and the changes since were narrow, so this round ran three lenses (the rule's minimum).
- Codex: still unavailable. The Claude lenses substitute for it.
- End-to-end: a third run on an isolated gateway is in progress and will be reported separately.
Result: no Must Fix
Addressed in 29175d5
- READY could be cleared by a coalesced duplicate loss and never set again (code-first; skeptical gave the mirror case).
- When that happened, the Offline banner stayed up on a live socket.
- Fix:
on_disconnectednow runs only where a connection is actually torn down. The "already connected" skip path callsmark_ready.
- READY stayed true through a Connect that ran with no loss before it (skeptical).
- This happens when the status reads Error on a live socket.
- Fix: a Connect that proceeds now marks the page not ready first.
- Pin tightening (testing). New or stronger pins check that:
on_disconnectedclears READY;on_connectedsets READY before any early return;connected()requires READY and a socket (whitespace-insensitive);reload_nowcaptures the pre-click phase before replacing it;- none of the non-countdown phases change while offline.
- Unposted refresh banner (code-first). The text is now "Refresh this tab to get the update", since that page keeps working.
- PR description (code-first). The "connected" rule and the counts are updated.
Not changed
- Hidden tabs. A hidden tab whose due time passed offline still reloads once it is ready again. The tab is hidden, so no reader sees a countdown; the description now says "visible countdown".
- The
API not availablebranch. Unreachable in practice, as the skeptical lens noted. Left as is.
Verdict
State: Needs Changes — Re-review Required After Fix. The fixes touch the synchronizer, so round 8 reviews 29175d5 once the end-to-end run reports.
[AI-assisted - Claude]
Round-8 review of #792: hook_block(&sync, "None =>") matched the first `None =>` in freenet_synchronizer.rs (an unrelated function), so the "a coalesced duplicate loss must not clear READY" assertion could never fail. Scoped to the ConnectionLost arm and checked it is the right arm; moving on_disconnected into that arm now turns the test red. The Connect-proceeds pin is now whitespace-insensitive. Claude-Session: https://claude.ai/code/session_01SzES9BQh5PhrJhHdh9FSFw
sanity
left a comment
There was a problem hiding this comment.
Re-review (round 8): #792
- HEAD SHA reviewed: 29175d5, plus a light re-check of the test-only follow-up 46f0bc0.
- Review tier: Full.
- Reviewers run: three Claude lenses, each blind to the others:
- code-first;
- skeptical, on the READY state across every message the synchronizer handles;
- regression risk for users who never see a re-key.
- Codex: unavailable. The Claude lenses substitute for it.
- End-to-end: the third run passed on both 9650065 and 29175d5, on an isolated gateway with Playwright. It covered:
- one reload, after which the old client was gone;
- READY set only after room processing;
- an outage, with the offline hold and no reload attempts;
- a fresh 15 s countdown after reconnect;
- a manual Reload with a draft open;
- no button while offline;
- a stuck posted reload, which showed the refresh banner at 60 s and did not reconnect.
Result: no Must Fix, and no Should Fix in the code
- Skeptical: found no path that leaves READY false on a live, caught-up connection, or true before a catch-up has run.
- Regression lens: on a normal day every synchronizer and response-handler hunk is inert, and the banner renders nothing. The one standing cost is the pointer subscriptions and GETs, now stated in the description.
Fixed in 46f0bc0 (test only)
The duplicate-loss pin matched the first None => in the file, which is in an unrelated function, so it could never fail (code-first, skeptical). It is now scoped to the ConnectionLost arm and checks that it found the right arm. A mutation that moves on_disconnected into that arm turns it red.
Considered, not changed
- READY is set even when post-connect room processing errors. This predates the PR. Gating READY on success could switch the feature off silently after a transient error, which is the worse failure.
- The AUTH_TOKEN_INVALID path does not clear READY. Cosmetic: the reload it allows would fetch a fresh token anyway.
- Pointer-response inputs are computed eagerly. It costs a few DOM queries per connect.
Verdict
State: Ready to Merge, pending CI on 46f0bc0. 29175d5 was fully green, and 46f0bc0 changes only tests.
[AI-assisted - Claude]
…ab reload River PR freenet/river#792 went through eight review rounds and three end-to-end runs. This adds: wait for a reconnect to catch up, never reconnect after posting, manual Reload passes busy but not offline, banner follows the connection, countdown restarts after an outage. Claude-Session: https://claude.ai/code/session_01SzES9BQh5PhrJhHdh9FSFw
|
Coordination note from #797, which re-keys the room contract and chat delegate to fix #796.
[AI-assisted - Claude] |
* docs(dapp-builder): reload an open tab safely after a re-key An already-open tab keeps the old contract and delegate keys after a re-key and never refreshes on its own. Document River's approach (freenet/river#580, #792): watch pointer records, reload via the shell's navigate message, and guard against reload loops and lost user work. Bumps the plugin to 1.38.6. Claude-Session: https://claude.ai/code/session_01SzES9BQh5PhrJhHdh9FSFw * docs(dapp-builder): fold in lessons from River's review of the open-tab reload River PR freenet/river#792 went through eight review rounds and three end-to-end runs. This adds: wait for a reconnect to catch up, never reconnect after posting, manual Reload passes busy but not offline, banner follows the connection, countdown restarts after an outage. Claude-Session: https://claude.ai/code/session_01SzES9BQh5PhrJhHdh9FSFw * docs(dapp-builder): address review of the open-tab reload section Accuracy review against River's code: adds the shell's silent href limits, names the hashed bytes and a shipped-build test, clarifies per-artifact targets and Not now, and qualifies the local-GET premise. Claude-Session: https://claude.ai/code/session_01SzES9BQh5PhrJhHdh9FSFw * docs(dapp-builder): point at River main now #792 has merged; tighten sourcing River #792 merged, so drop the 'read it on the PR branch' instruction and the review-round counts. Attribute the GET-answered-locally and freenet-local observations to River's testing, state the backstop interval, and qualify the unbounded-coexistence line for dApps that reload open tabs. Claude-Session: https://claude.ai/code/session_013RQ4JECVdfutMYeZakAtvB
Brings in #792 and the published-contract counter bump. No conflicts. Claude-Session: https://claude.ai/code/session_019J28r7v39Pf1XZDNUUAidG
Closes #580.
Problem
An open River tab runs the build it loaded, with the room contract and chat delegate WASM compiled in. After a publish re-keys either one:
What it does
Signal. The UI watches River's two pointer records,
river.room-contractandriver.chat-delegate. riverctl'smessage streamhas used the same signal since #694. The records are 100 bytes each and move only on a re-key. The alternative, watching the web container, would push ~1.2 MB to every open tab on every publish.Detection (
ui/src/components/app/freenet_api/generation_watch.rs):build.rsbakes in this build's pointerversionandcode_hashfrompointer-records.toml. At runtime, the embedded WASM must hash to that record. If it doesn't (a local rebuild), the watch for that pointer is off.freenet_migrate::PointerResolveris seeded with the build's own record as its floor. Only a signature-verified record with a strictly higher version means "reload". A pointer behind the build is normal and does nothing. So does a competing record at the build's own version.StateandDeltanotifications are both handled.How it reloads. It reloads rather than follow the new key in place, for the reason riverctl restarts: a new generation can change the state format and validation, and a delegate can't be followed without the new build's migration.
navigate. The shell closes every WebSocket it proxied for the old document and then replaces the frame. A self-location.replacewould leak the old socket, and the shim restarts__fws_NIDs per document, so the new page's socket would collide with the leaked one. The query (?__sandbox=1) is kept.PROCESSED_CACHE, because the iframe's own hash is stale once an invitation has been processed.Loop guards. The cap state is held in an
_rgenURL parameter, not storage. Storage in the sandboxed iframe is unreliable, and a cap that resets fails open.When it reloads.
fixed inset-0overlay), a focused field with text, or an invitation accept in flight. The same holds for 5 s after any queued sync.Standing cost. Every River tab's node now GETs and subscribes to the two 100-byte pointer contracts (one-time fetch of the ~130 KB pointer WASM), plus a pair of GETs every ~15 min and on becoming visible (at most every 2 min). With no newer pointer, everything else in the PR is inert.
Sizing. Production telemetry over UI publishes 30000397–403 (41 sampled web-container deliveries) gave: median ~20 s, 71% within 60 s, 80% within 5 min, p90 ~10 min, max ~2 h. The pointer reached subscribers in under 0.5 s.
Publish
publish-pointer-records' pre-flight already refuses unless the live UI carries the new WASM.river-publish.mdstep 7 now says this step is what reloads open tabs, that the order is checked only from the publishing node, and to wait ~2 minutes after the UI publish.publish-riverprints a reminder.sign-webappchecks the built archive for River's pointer author key (scripts/check-webapp-pointer-author.sh) before bumping the version counter. A UI built withRIVER_POINTER_RECORDS(the end-to-end test override) is refused. This was verified on a real release build (accepted) and on a test-key build (refused).Testing
generation_watch/update_banner, plus 27 publish-guard checks. They cover:return, the reconnect-latch clear, the never-reconnect-after-posting rule, the offline hold, the manual-Reload exemption, the attempt tag, the tries cap and the draft guard.cargo test -p river-ui --binspasses with and withoutexample-data,no-sync. The wasm build passesclippy.fdev update.Known limitations
navigatealways usespushState. Back reloads the previous frame path along with its older caps; this takes a press each round and cannot loop on its own. A replacingnavigateis shell_bridge: let a same-contract navigate replace the history entry instead of pushing one freenet-core#5910.navigate.WEB_APIset. After a failed reconnect attempt the old, dead socket can stay inWEB_APIuntil its close arrives, so in that rare window a due reload could post into a down node (an error page in the frame; refreshing the tab recovers). Clearing it in the synchronizer was tried and reverted, because it could close a healthy socket in another rare sequence, which would affect every user.__fws_Nid): shell_bridge: a late close of an old proxied socket deletes the new page's connection with the same id freenet-core#5912.Also changed
freenet_synchronizer.rs:generation_watch::on_disconnected(not ready) is called where a connection is actually torn down (the armedConnectionLostarm, before a Connect proceeds, a failed Connect), andon_connected(after the rooms are processed) ormark_ready(a Connect skipped as already connected) mark it ready again. A reload waits for ready, and the banner follows it.Deploy notes
No contract or delegate WASM change, so no migration entry.
[AI-assisted - Claude]
https://claude.ai/code/session_01SzES9BQh5PhrJhHdh9FSFw