Skip to content

fix(ui): reload an open tab when River's room contract or delegate re-keys (#580) - #792

Merged
sanity merged 14 commits into
mainfrom
fix/stale-tab-reload-580
Oct 17, 2026
Merged

sanity merged 14 commits into
mainfrom
fix/stale-tab-reload-580

Conversation

@sanity

@sanity sanity commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

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:

  • Room contract. The tab stays subscribed to the old contract key while everyone on the new build posts under the new one. Its rooms look quiet, and what it posts reaches nobody. Neither side sees an error. @sanity hit this after the fix(contract): stop an orphaned ban from forking a room permanently (#423) #702 re-key.
  • Chat delegate. The tab keeps writing to the old delegate after a new-build load has migrated from it. The top-up only fetches rooms the new build has never seen, so changes to known rooms (a leave, a re-invite's signing key, read markers) are stranded.

What it does

Signal. The UI watches River's two pointer records, river.room-contract and river.chat-delegate. riverctl's message stream has 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 identity. build.rs bakes in this build's pointer version and code_hash from pointer-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.
  • Verdict. A freenet_migrate::PointerResolver is 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.
  • Transport.
    • GET + subscribe on every (re)connect, because a client subscription doesn't survive a WebSocket reconnect.
    • A ~15 min GET backstop, and a re-check when the page becomes visible.
    • State and Delta notifications 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.

  • Framed. The page posts the node shell a same-contract navigate. The shell closes every WebSocket it proxied for the old document and then replaces the frame. A self-location.replace would leak the old socket, and the shim restarts __fws_N IDs per document, so the new page's socket would collide with the leaked one. The query (?__sandbox=1) is kept.
  • Unframed (a top-level dev build). It reloads itself.
  • Fragment. It is rebuilt from PROCESSED_CACHE, because the iframe's own hash is stale once an invitation has been processed.
  • No reconnecting during a reload. The page never reconnects once a reload is under way, and never at all once one has been posted, even if it fails: a socket opened then would outlive the page in the shell.

Loop guards. The cap state is held in an _rgen URL parameter, not storage. Storage in the sandboxed iframe is unreliable, and a cap that resets fails open.

  • Per target. At most 5 tries, with 2 / 5 / 15 / 45 min backoff. A target no newer in any artifact counts as the same target, so a page that has heard only one pointer can't reset the count.
  • Per hour. At most 6 automatic reloads, whatever the target.
  • One reload per page. If a posted reload hasn't replaced the page within 60 s, the banner asks the reader to refresh the tab.
  • Past any cap. Banner only.

When it reloads.

  • Visible tab. A 15 s countdown banner with Reload / Not now. "Not now" sticks for the page.
  • Hidden tab. Waits 30–120 s, jittered.
  • Never before the page is 35 s old, since the node's 30 s bundle-refresh window would serve this page's own bundle back.
  • Never while the node is unreachable (an error page) or a reconnect has not caught up yet: the page counts as connected only once the synchronizer has re-registered the delegate and processed the rooms. A visible countdown that ran out offline restarts at 15 s on reconnect.
  • Never while the reader is busy: unsent text in the composer, an edit, any open dialog (every modal renders a fixed inset-0 overlay), a focused field with text, or an invitation accept in flight. The same holds for 5 s after any queued sync.
  • Flush first. Pending delegate writes are flushed (up to 5 s) before posting, then busy and connection are checked once more.
  • Manual Reload skips the busy wait (the reader decided) but still gets the few seconds after a send, and never posts while offline; a click that finds the node gone puts back what was there before the click. Offline, the banner says it is waiting to reconnect and offers no button; it follows the connection both ways.

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

  • Order. publish-pointer-records' pre-flight already refuses unless the live UI carries the new WASM. river-publish.md step 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-river prints a reminder.
  • Test-build guard. sign-webapp checks the built archive for River's pointer author key (scripts/check-webapp-pointer-author.sh) before bumping the version counter. A UI built with RIVER_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

  • Unit tests. 52 in generation_watch / update_banner, plus 27 publish-guard checks. They cover:
    • verdicts against real signed records: current, newer, behind, forged, other app, withdrawn, competing
    • caps, partial targets, backoff and the hourly ceiling
    • the URL and fragment
    • the timing state machine, including busy, offline, "Not now", unposted versus posted timeouts, and the no-reconnect rule
    • routing from response to plan
    • wiring pins scoped to each hook's own block
    • selector pins for all 13 overlay sites
  • Mutation checks. Each guard was mutated and a test went red: the per-artifact target comparison, the call-site re-plan, a hook's 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.
  • Full suites. cargo test -p river-ui --bins passes with and without example-data,no-sync. The wasm build passes clippy.
  • End to end, two runs. Isolated local node and network-mode gateway, a throwaway pointer key, Playwright. All checks passed:
    • The push notification arrived 50 ms after fdev update.
    • Over 7 reloads the open-socket count was unchanged and subscriptions moved to the new client. Each reload made one document request.
    • The page-age floor held, and an open dialog or a draft held the reload.
    • "Not now" stuck across tab switches.
    • The retry backoff applied with both pointers, in either answer order. The invitation did not replay.
    • The first run found the partial-target backoff bug, which is fixed and has a regression test.
  • Not covered end to end.
    • The behaviour changes from the round-3 and round-4 reviews: offline hold, the 60 s stuck threshold, never reconnecting after posting, the pre-post check. These are covered by unit tests and pins.
    • The hidden-tab path with a real browser visibility change.

Known limitations

  • Browser history. Each reload adds a browser history entry, because the shell's navigate always uses pushState. Back reloads the previous frame path along with its older caps; this takes a press each round and cannot loop on its own. A replacing navigate is shell_bridge: let a same-contract navigate replace the history entry instead of pushing one freenet-core#5910.
  • Hidden draft. A hidden tab holding a draft never reloads automatically. This is deliberate. The banner appears when the tab is shown again.
  • Rollout. Tabs already open on 30000403 and earlier don't have this and need one manual reload. Tabs pick it up at the first UI publish after merge.
  • A posted reload that never lands (the reader cancels the frame load, or it fails) leaves the page on the "refresh this tab" banner without reconnecting; the banner warns that messages sent before refreshing may not be delivered. A reload URL over the shell's 4096-unit limit is never posted (the page keeps working and asks for a refresh). Every shell a compatible node serves has navigate.
  • "Connected" is READY and WEB_API set. After a failed reconnect attempt the old, dead socket can stay in WEB_API until 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.
  • Shell close race. A slow close of the old page's socket can drop the new page's connection entry (shared __fws_N id): 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:

  • the Connect handler skips (and clears the reconnect latch) while a reload is under way;
  • generation_watch::on_disconnected (not ready) is called where a connection is actually torn down (the armed ConnectionLost arm, before a Connect proceeds, a failed Connect), and on_connected (after the rooms are processed) or mark_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

…-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 sanity left a comment

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.

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)

  1. The driver re-navigates every second once a reload fires. generation_watch.rs drive() does not stop after auto_reload. The past-due phase reloads again on every tick, and each new location.replace aborts 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, because existing_param() still reads the old URL. (skeptical, failure-mode, code-first)
  2. 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_1 overwrites 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)
  3. The reload replays a stale fragment. The iframe's location.hash is 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 from PROCESSED_CACHE. (failure-mode)
  4. 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 for r2d2.
    • If the second pointer's answer is late or fails, the targets alternate and TriesExhausted is never reached. Only the 6-per-hour ceiling holds, and it renews every hour. (code-first, skeptical)

Should Fix

  1. 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)
  2. 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_SYNC to empty, then call flush_rooms_to_delegate() before navigating. (skeptical, failure-mode)
  3. The RIVER_POINTER_RECORDS guard runs too late, and checks the wrong moment. It runs after sign-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)
  4. 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)
  5. Tests:
    • pin the re-plan at its call site through a pure function
    • test deliver_update across State, Delta and StateAndDelta, including a forged delta
    • make the wiring pins catch a deleted early return and the is_pointer skip
    • pin the composer selectors to their components
    • test the archive guard in publish-guards-test.sh (testing)
  6. 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 sanity left a comment

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.

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/main was 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

  1. Repeated navigation: Phase::Reloading means one reload per page.
  2. 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.
  3. Stale fragment: the reload's fragment is rebuilt from PROCESSED_CACHE. End to end, the invitation did not reappear.
  4. 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_pending set, 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 ReloadFailed state asks the reader to refresh the tab.
  • A quick send between driver ticks escaped the settle period (failure-mode). Fixed: mark_needs_sync records 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 return goes 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.
  • Docs (big-picture): fixed the remaining "pointers serve third parties only" lines in delegate-migration.md, wasm-safety.md and the Makefile header.

Not fixed here

  • Every reload adds a browser history entry, because the shell's navigate always uses pushState. 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 replacing navigate needs 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-make install broke for every PR when every zip 2.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 sanity left a comment

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.

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

  1. The offline hold used the wrong signal (skeptical, code-first).
    • Through most of an outage SYNC_STATUS reads Error, so a tab could reload into a dead node and land on the browser's error page.
    • "Offline" now means no live socket (WEB_API unset). 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.
  2. 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. ReloadFailed reconnects 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 call abandon_reload.
    • Pinned, and mutations of both rules go red.

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_reload retries reaching the synchronizer instead of dropping the Connect.
  • The driver ticks every 5 s unless a reload is due within a minute.
  • note_user_activity is renamed note_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.replace design 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 sanity left a comment

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.

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_API unset.
  • 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. ReloadFailed loses its reconnect field, and drive() never clears the reloading flag. Pinned, and the pin goes red when the flag is cleared.
  • 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.
  • 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_API could hold a dead socket (skeptical).
    • A failed reconnect left the old socket in place, and a later ConnectionLost hit the "reconnect pending" arm, which did not clear it.
    • Fix: that arm now drops WEB_API too.
  • 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 sanity left a comment

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.

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 ConnectionLost and 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() passes manual = false with its attempt tag, and reload_now passes true;
    • RELOADING being cleared in one place, reachable only before a post;
    • nothing reconnecting after a successful post;
    • the banner hooks.

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_API borrow 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 sanity left a comment

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.

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 READY flag. on_connected sets it, after process_rooms. on_disconnected clears it, on ConnectionLost and 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_flush now takes clicked_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_API is 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 sanity left a comment

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.

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_disconnected now runs only where a connection is actually torn down. The "already connected" skip path calls mark_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_disconnected clears READY;
    • on_connected sets READY before any early return;
    • connected() requires READY and a socket (whitespace-insensitive);
    • reload_now captures 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 available branch. 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 sanity left a comment

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.

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]

sanity added a commit to freenet/freenet-agent-skills that referenced this pull request Oct 16, 2026
…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
@sanity
sanity marked this pull request as ready for review October 16, 2026 10:40
@sanity

sanity commented Oct 16, 2026

Copy link
Copy Markdown
Contributor Author

Coordination note from #797, which re-keys the room contract and chat delegate to fix #796.

[AI-assisted - Claude]

@sanity
sanity merged commit 08aed2d into main Oct 17, 2026
9 checks passed
@sanity
sanity deleted the fix/stale-tab-reload-580 branch October 17, 2026 08:14
sanity added a commit to freenet/freenet-agent-skills that referenced this pull request Oct 17, 2026
* 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
sanity added a commit that referenced this pull request Oct 17, 2026
Brings in #792 and the published-contract counter bump. No conflicts.

Claude-Session: https://claude.ai/code/session_019J28r7v39Pf1XZDNUUAidG
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Users stranded on the old room contract after a re-key have no in-app signal to update

1 participant