Skip to content

perf(mobile): stop replaying stored read state on every resume - #9090

Open
brow wants to merge 10 commits into
mainfrom
tomb/mobile-read-state-live-no-replay
Open

brow wants to merge 10 commits into
mainfrom
tomb/mobile-read-state-live-no-replay

Conversation

@brow

@brow brow commented Nov 5, 2026 •

Copy link
Copy Markdown
Collaborator

Every time the mobile app reconnects (for example after more than 5 seconds in the background), the read-state live subscription asked the relay for up to 500 stored read-state events. That re-downloaded every read-state slot on the account, then the snapshot fetched the same events again right after. On a heavy account (28 slots) that replay alone was about 1.4 MB per resume.

The live subscription now uses limit: 0, so it only delivers changes. It still opens before the snapshot, so the snapshot covers anything published in between.

The subscription can also go live after subscribe has returned: on a cold start with slow AUTH, subscribe returns on its 500 ms fallback before the REQ is registered, after a transient CLOSED (rate limit, capacity) the session reopens it on the same socket, and a reconnect replays it. With limit: 0 nothing replays marks published in that gap, so the manager reads the snapshot every time the subscription reports ready, as the channel list's limit: 0 subscription already does.

The session keeps live subscriptions across reconnects and replays them on the new socket, so a reconnect no longer resubscribes: the replay's ready reads the snapshot, once per reconnect. The manager opens a new subscription only after losing one (a terminal CLOSED or a failed subscribe), on the next reconnect.

All snapshot reads outside a publish go through one sync loop. Initialize and each ready ask that loop for a snapshot. A request made before the current pass starts its read joins that read, and a request made during the read runs one more pass, so a ready can't be swallowed by a read already in flight. This needs no generation or ready-transition checks, at the cost of an occasional extra read (for example a cold start whose ready lands after the 500 ms fallback, when the first read has already started).

Snapshot reads are numbered in start order, including a publish's own read, and only a read newer than every settled one decides whether the last snapshot succeeded. An older read finishing late cannot clear the retry a newer failed read needs. A failed read waits for the next trigger: the publish it schedules, which reads the snapshot in full after a failure, or a later ready, resume or reconnect.

A publish with nothing to send skips its snapshot (#9093 skip redundant publish) only when the last snapshot succeeded, so after a failed resume snapshot the next publish still retries it. A publish snapshot now notifies readers of the marks it merged, since unchanged slots publish nothing whose echo would.

Accepted gap

If the snapshot fails (timeout, discovery or HTTP error) on a device with no local journal, read state stays local until the next successful snapshot, which every resume, reconnect, subscription recovery and publish retries. Falling back to a stored replay on failure would close the gap, but it adds a late subscription path for a rare failure that already heals on its own.

Validation

  • Added test: live subscription replays nothing and opens before the snapshot. It fails without the change (limit 500).
  • Added test: a clean reconnect through the real session replays one REQ and reads the snapshot once, and a lost subscription is reopened drives the real RelaySessionNotifier with a recording socket: the connected listener runs, then the session's replay, then EOSE. It fails against the previous revision of this PR (two l-2 REQs and two snapshot reads per reconnect). After a terminal CLOSED, the next reconnect opens a new subscription and reads the snapshot.
  • Added test: readies during a held snapshot read share one more read.
  • Added test: a mark published while the live subscription retries is read once it recovers puts the subscription into retry, lets another device publish a mark, then reports it ready. It fails without the read on ready.
  • Added test: a failed late-ready read is retried by the next publish. It fails without the retry.
  • Added test: a reconnect while the first snapshot downloads is not swallowed by the cold-start connect lets the cold-start connect queue behind initialize, then reconnects while the first read is still downloading and has a peer publish a mark. It fails against the previous revision of this PR (the queued pass swallowed the reconnect) and passes with the sync loop.
  • Added test: an older snapshot finishing late keeps a newer failed read's retry holds a publish's snapshot read, fails a newer read, then lets the older one finish. It fails against the previous revision of this PR (the older success cleared the retry) and passes with the read ordering.
  • Added test: a mark published before a slow cold-start REQ goes live is read then lets subscribe return before ready, publishes a mark, then reports ready. It fails without the read on ready.
  • a publish after a failed snapshot retries it, then skips again replaces the earlier skipped-publish test and fails without the snapshot check. It also checks that the retried snapshot's new marks notify readers, which fails without the notify.
  • Read-state suite (95 tests), flutter analyze and the full mobile suite pass.
  • iPhone 12 mini, Profile build with #9089 gzip snapshot applied, Home backgrounded 12 s and resumed, websocket bytes received in the 15 s after resume:
Websocket in Read-state replay
Before (gzip fix only) 2,087 KB 1,458 KB (28 events)
After (gzip fix + this) 575 KB none

The table counts websocket bytes only. Each resume reads the read-state snapshot once over HTTP, before and after this change.

Automated app run on the same phone: Home, a large channel and search resumed after the reconnect grace period, with no read-state errors logged. Read marks were not compared across devices.

🤖 Generated with Claude Code

@brow

brow commented Nov 5, 2026

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 5, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-05T16:18:56.912228Z 2f6c7a0 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2f6c7a0dca

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@brow
brow marked this pull request as ready for review November 5, 2026 16:16
@brow
brow requested a review from a team as a code owner November 5, 2026 16:16
@github-actions github-actions Bot added the codex-security-review-current The posted Codex security review matches its recorded range. label Nov 5, 2026
@github-actions

github-actions Bot commented Nov 5, 2026 •

Copy link
Copy Markdown

🔐 Codex Security Review

Status: review required for the current range.

The current range is 8436d226c8d174c3d83385d32abe3cf7613c8dc9...b7f17da674f0a819a73b34aaafe93fb3d4916ed9.
A new review must complete for this exact range. When manual authorization
is required, a user with write access must comment exactly
@buzz-security-review b7f17da674f0a819a73b34aaafe93fb3d4916ed9 to authorize a new review.
Any previous review applies only to its recorded range.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2f6c7a0dca

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

limit: readStateFetchLimit,
// Change notifications only: the snapshot that follows reads the
// stored blobs, so a stored replay here would download them twice.
limit: 0,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Wait for live readiness before taking the snapshot

When initialization starts before WebSocket authentication completes, or EOSE takes over 500 ms, RelaySessionNotifier.subscribe returns via its timeout while the REQ may still be held (relay_session.dart lines 382–390). The subsequent HTTP snapshot can therefore finish before the live subscription is installed, and a connection arriving during initialization may skip another resync because _initialSyncSucceeded was set. Any read-state event published in that gap is absent from both the snapshot and this limit: 0 replay, leaving stale unread state until a later resync; require confirmed live readiness or run another snapshot after delayed readiness.

AGENTS.md reference: AGENTS.md:L235-L238

Useful? React with 👍 / 👎.

limit: readStateFetchLimit,
// Change notifications only: the snapshot that follows reads the
// stored blobs, so a stored replay here would download them twice.
limit: 0,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Backfill after retryable subscription closures

When the relay sends a retryable CLOSED while the socket remains connected—for example a deadline or rate-limit closure—RelaySessionNotifier retries the same filter after backoff (relay_session.dart lines 901–921), while the read-state provider only requests a snapshot on a transition to SessionStatus.connected. With limit: 0, read-state events published during that backoff are not replayed by the retry and are silently missed; trigger a fresh snapshot when this subscription recovers or retain a bounded replay for this recovery path.

AGENTS.md reference: AGENTS.md:L235-L238

Useful? React with 👍 / 👎.

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 Carl, an automated reviewer, commenting via Wes’s GitHub account.

Changes requested: one blocker, inline. Reviewed 2f6c7a0dca against main bc99726f5c.

With limit: 0, the stored replay was the only thing covering a same-socket CLOSED retry of this subscription. This PR removes it without adding the catch-up that other limit: 0 subscribers in this codebase use. Merge criteria: a fresh snapshot after the subscription recovers, plus one journey test where a peer publishes during the retry window.

Relay side is confirmed fine: limit: 0 reaches SQL as LIMIT 0, still registers live fan-out and still sends EOSE. CI is green. The combined #9089/#9090/#9093 read-state and relay suites pass locally (403 tests).

limit: readStateFetchLimit,
// Change notifications only: the snapshot that follows reads the
// stored blobs, so a stored replay here would download them twice.
limit: 0,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 Blocker: read marks published while this subscription is CLOSED-and-retrying are never received.

RelaySession retries a retryable CLOSED (rate limit, deadline, capacity) on the same socket after backoff (relay_session.dart _handleClosed). Its own comment says the design relies on the retry replaying stored events: "since is not advanced, so events missed while closed aren't skipped." With limit: 0 the retry replays nothing. The manager uses plain subscribe, so it never learns that the stream was interrupted. No snapshot runs until the next session transition to connected.

Scenario: the relay closes this REQ with a rate-limit CLOSED, and the phone's socket stays up. During the backoff, the desktop marks a channel read. The retry registers with LIMIT 0, so that mark never arrives. The phone shows the channel as unread until the next background/resume or reconnect. A closely related window: if subscribe() completes on its 500 ms fallback rather than EOSE, the HTTP snapshot can be read before the relay has registered the REQ.

Fix: follow the existing pattern for limit: 0 subscribers (channel_list_changes.dart _subscribeChannelListNotices). Use subscribeWithStatus, and on retrying → ready, run _fetchAndMerge() (serialized with reinitializeRemote). Please add one journey test: a CLOSED retry with a peer write during the backoff, then the merged state after the retry is ready.

brow added a commit that referenced this pull request Nov 5, 2026
…ng (#9093)

Every mobile resume re-syncs read state and then schedules a read-state
publish, whether or not anything changed. Each publish starts by
downloading the full read-state snapshot (about 1 MB compressed on a
heavy account) and only then finds nothing to send. Together with the
re-sync's own snapshot, that is two full downloads per resume, and a
third when a publish from before backgrounding retries.

The publish now checks first, using the same packing it would publish,
and returns before the snapshot when nothing is pending and no slot
differs from what was last published. When something did change it
behaves exactly as before, snapshot included.

Today the snapshot fails before downloading on gzip-serving relays, so
this only pays off once [#9089 read-state gzip
fix](#9089) lands; without this PR,
[#9089 read-state gzip fix](#9089)
roughly doubles resume download on heavy accounts. It has no merge-order
dependency.

### Validation

- Added test: `a publish with nothing new skips the snapshot`. It
publishes a change (one snapshot), publishes again with nothing new (no
snapshot, no event), then publishes another change (snapshot and an
event carrying both marks). It fails without the early return.
- Existing read-state suite passes.
- iPhone 12 mini, Profile build, Home backgrounded 12 s and resumed, all
socket bytes received in the 15 s after resume:

| Build | Download per resume | Snapshot fetches |
| --- | --- | --- |
| main (snapshot broken) | 3.07 MB | 0 completed |
| [#9089 read-state gzip fix](#9089) |
6.85 MB | 3 |
| [#9089 read-state gzip fix](#9089) +
[#9090 no read-state replay](#9090) |
6.08 MB | 3 |
| [#9089 read-state gzip fix](#9089) +
[#9090 no read-state replay](#9090) +
this | 2.71 MB | 1 |

These are single runs on one account with 28 read-state slots, so treat
the numbers as indicative.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Tom Brow <[email protected]>
Co-authored-by: Tom Brow <[email protected]>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
square-tomb and others added 2 commits November 5, 2026 11:23
The read-state live subscription asked the relay for up to 500 stored
blobs, so every reconnect re-downloaded all of this account's slots
(about 1.4 MB on a heavy account) right before the snapshot fetched the
same blobs again. Subscribe with limit 0 for change notifications only.
The subscription still opens before the snapshot, so the snapshot covers
any gap.

Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Signed-off-by: Tom Brow <[email protected]>
With limit 0 a REQ the session reopens after a transient CLOSED replays
nothing, so marks another device published during the backoff never
arrived. Watch the subscription's status and read the snapshot once it
is ready again, after any reconnect pass already running. A publish
with nothing to send now skips its snapshot only after a good one, so a
failed resume snapshot is still retried by the next publish.

Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Signed-off-by: Tom Brow <[email protected]>
@brow
brow force-pushed the tomb/mobile-read-state-live-no-replay branch from 2f6c7a0 to 36204c2 Compare November 5, 2026 19:30
@github-actions github-actions Bot removed the codex-security-review-current The posted Codex security review matches its recorded range. label Nov 5, 2026
if (_lastSnapshotSucceeded) syncError = null;
// After a failed snapshot, run the full publish anyway: its own
// snapshot is the retry, which the change notifications can't replace.
if (_lastSnapshotSucceeded && !_hasUnpublishedState()) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🤖 P2 · Notify readers after the publish-only snapshot retry

After a resume snapshot fails, the new condition lets the scheduled publish retry it even when all local slots are already acknowledged. If that successful retry reads newer peer marks, _mergeEvents() updates and persists the manager, but _publishOnce() never calls _onChanged(). Unchanged local slots send no event, so no relay echo supplies the missing notification.

User/system effect: ReadStateNotifier keeps its copied contexts, leaving unread badges and highlights stale until another notification or its one-minute timer, despite the snapshot having recovered.

Test: With acknowledged local slots, fail the resume snapshot, then let the scheduled publish snapshot return a newer peer mark without outgoing writes. Check that consumers receive the recovered mark immediately.

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 Carl, an automated reviewer, commenting via Wes’s GitHub account.

Changes requested: one remaining blocker, inline. Reviewed 36204c2d95 against main 6793cbd25b.

The earlier CLOSED-retry blocker is resolved. retrying → ready now reads the snapshot, and that includes a CLOSED before the first EOSE. The new retry test fails once the catch-up is removed. The read-state suite passes locally (91 tests), and CI is green.

The remaining issue is the readiness-timeout window that the previous review mentioned only as "closely related." An independent pass (Mordecai) confirmed it in source. On cold start, subscribe can return on its 500 ms fallback before the REQ is registered. With limit: 0, nothing covers marks published in that gap any more. Merge criteria: read the snapshot when the first ready arrives after the subscribe call has already returned, and add one journey test for that ordering.

// RelaySession reopens a REQ the relay closed for a transient reason
// on the same socket. With limit 0 the reopened REQ replays nothing,
// so read the snapshot once it is live again to cover the gap.
onStatusChanged: (status) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 Blocker: on cold start, a peer mark published before the held REQ registers is never received.

If initialize() runs before WS AUTH completes, _subscribe holds the REQ and returns on its 500 ms fallback (relay_session.dart _subscribe). The HTTP snapshot does not wait for the socket (relay_session_snapshot.dart), so it can read before the REQ registers. initialize() then sets _initialSyncSucceeded, so the connect that lands mid-initialize skips its resync (reinitializeRemote). When the held REQ's EOSE finally arrives, this callback ignores the ready because nothing set interrupted. On main, the 500-event stored replay covered this window. With limit: 0, nothing covers it.

Scenario: the user cold-launches the phone with slow AUTH. The snapshot reads old state, then the desktop marks a channel read, then AUTH completes and the REQ registers. The phone shows that channel unread until the next resume or a publish that reads the snapshot. The same 500 ms fallback applies to the subscription _resyncRemote opens if EOSE is slow, but that window is much smaller.

Fix: also catch up when the first ready arrives after subscribeWithStatus has returned. That is the case where the call fell back to its timeout instead of reaching EOSE. Normal starts get their EOSE inside the await, so they keep a single snapshot. For example, set a returned flag after the await and track seenReady in the callback, then call _catchUpAfterRetry() when interrupted || (returned && !seenReady). Please add one journey test where the fake withholds ready until after initialize() finishes, a peer mark lands, and the late ready merges it. The current ordering test's fake returns immediately, so it can't show relay registration.

Covers a cold start whose subscribe returns on its fallback before the REQ
is registered, alongside the CLOSED retry. Notify readers after a publish
snapshot merges marks.

Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Signed-off-by: Tom Brow <[email protected]>
@github-actions github-actions Bot added the codex-security-review-current The posted Codex security review matches its recorded range. label Nov 5, 2026

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 Carl, an automated reviewer, commenting via Wes’s GitHub account.

No remaining blockers. Reviewed f0cc23bab6 (the delta since 36204c2d95) against main 6793cbd25b.

The cold-start blocker is resolved. Any ready that arrives after subscribeWithStatus returns now reads the snapshot, which covers both the 500 ms fallback and a CLOSED retry. Normal starts still read one snapshot, because their EOSE lands inside the await. If a reconnect pass has already replaced the subscription, _liveGeneration skips the extra read. The added _onChanged() after the publish snapshot merges correctly notifies readers about marks that a retried snapshot brings in.

Validation: the read-state suite passes locally at this head (92 tests), and CI is green. I removed each change in turn and confirmed its test fails: the late-ready catch-up, the generation check, and the publish notify.

This review doesn't approve. The earlier changes-requested review stays on record until a human approves or dismisses it.

@brow brow changed the title fix(mobile): stop replaying stored read state on every resume perf(mobile): stop replaying stored read state on every resume Nov 5, 2026

@wesbillman wesbillman left a comment •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 Carl, an automated reviewer, commenting via Wes's GitHub account.

Changes requested at f0cc23bab6a1026727fff4ae2f7037c03045fc12 (base 8a113ed99e).

The limit: 0 change itself checks out: the relay registers live delivery, returns no stored history and still sends EOSE, and NostrFilter.toJson always serializes limit. The blocker is in the late-ready catch-up. On an ordinary reconnect it fires a second full snapshot, so each blip re-downloads the read state twice over HTTP. Details are inline.

The before/after table only counts websocket bytes. The snapshot goes over HTTP (relay_session_snapshot.dart), so the table can't show this regression.

Merge criteria

  • Each plain reconnect reads exactly one snapshot. The late-ready catch-up still works after a slow cold-start REQ and after a CLOSED retry.
  • The fake relay can emit ready before subscribe returns, and it can send the same REQ twice on reconnect. Extending the existing reconnect test to cover both is enough; a new test isn't needed.

CI is green at this head. The probe below was run locally; the full suite was not re-run here.

// (slow AUTH on a cold start) or after a transient CLOSED's retry.
// With limit 0 nothing replays the gap, so read the snapshot then.
onStatusChanged: (status) {
if (status == RelaySubscriptionStatus.ready && returned) {

@wesbillman wesbillman Nov 5, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 Blocker: every plain reconnect now reads two full snapshots instead of one.

On reconnect, _handleConnected sets state = connected before _replayLiveSubscriptions. The provider's listener runs synchronously, so reinitializeRemote → _resyncRemote has already opened the replacement subscription (and sent its REQ) when the replay collects _liveSubscriptions and sends that same REQ again. The new socket sees REQ l-2 twice:

  1. The first EOSE fires ready with returned == false, then resolves subscribe.
  2. The second EOSE fires ready with returned == true and the current _liveGeneration. _catchUpAfterLateReady waits for _runningResync, the generation still matches, and it reads the whole snapshot again.

Repro: real RelaySessionNotifier with a controlled socket, real ReadStateManager, and the provider's connected listener. One socket reconnect logs REQs on new socket: [l-2, l-2]. Snapshots per reconnect: 1 on base, 2 on this head. On the 28-slot account in the description that's roughly an extra 1.4 MB per reconnect, which is the cost this PR sets out to remove. The PR's tests miss it because _FakeRelaySession.subscribeWithStatus never emits ready before returning.

Suggested fix: catch up only on the first ready after the subscription was interrupted, not on every ready. For example:

var live = false;
onStatusChanged: (status) {
  if (status == RelaySubscriptionStatus.retrying) {
    live = false;
    return;
  }
  final wasLive = live;
  live = true;
  if (returned && !wasLive) unawaited(_catchUpAfterLateReady(generation));
},

With that change the repro reads one snapshot per reconnect, and the read-state suite passes, including the slow cold-start and CLOSED-retry tests added here.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 Fixed in 29be4ea. The catch-up now runs only when the subscription becomes ready after it was not (a ready after subscribe returned on its fallback, or after a CLOSED retry). A second ready while it is already live, as from the duplicate REQ on reconnect, reads nothing, so each reconnect reads one snapshot. The reconnect test's fake now reports ready before subscribe returns and repeats it; the test fails (4 snapshots instead of 3) without the check. The PR text now notes the table counts websocket bytes only and that each resume reads the snapshot once over HTTP.

if (pass != null) await pass;
if (_disposed || !_servesThisCommunity) return;
if (generation != _liveGeneration) return;
await _fetchAndMerge();

@wesbillman wesbillman Nov 5, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 Non-blocking: this path is less careful than _resyncRemote, the other snapshot-after-subscribe path:

  • If this _fetchAndMerge fails, nothing schedules a retry, so a mark missed during the gap stays missing until the next publish, pause or reconnect. _resyncRemote calls _schedulePublish() after its fetch. Since a failed snapshot now forces the publish to run in full, calling _schedulePublish() here would make that publish the retry. This sits close to your accepted gap, so treat it as optional.
  • It doesn't wait for an in-flight publish (_resyncRemote awaits _publishCompleter). A snapshot fetched before that publish lands can merge our own older blob. That costs an extra full snapshot on the next publish and can set syncError transiently. The journal is kept, so nothing is lost.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 Took the first point in 29be4ea: a failed catch-up now calls _schedulePublish(), so that publish retries the snapshot in full. Covered by a failed late-ready read is retried by the next publish. Left the second as is: the journal keeps the mark, and the next publish heals it.

A reconnect can send the read-state REQ twice, and the second ready looked
like a late one, so each reconnect read the snapshot twice. Catch up only
when the subscription becomes ready after it was not, and let the next
publish retry a failed catch-up.

Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Signed-off-by: Tom Brow <[email protected]>
@brow
brow requested a review from wesbillman November 5, 2026 23:09
@github-actions github-actions Bot removed the codex-security-review-current The posted Codex security review matches its recorded range. label Nov 5, 2026
@github-actions github-actions Bot added the codex-security-review-current The posted Codex security review matches its recorded range. label Nov 5, 2026

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 Carl, an automated reviewer, commenting via Wes's GitHub account.

Changes requested: one blocker, inline. Reviewed 29be4ea901 (the delta since f0cc23bab6) against main c848b6dcad.

The previous blocker is resolved. A duplicate REQ on reconnect no longer reads a second snapshot, and the reconnect test fails without the live check. A failed catch-up now schedules a publish that retries the snapshot, and that test fails without it too. But the new guard can't tell a duplicate ready on the same socket from the replay's ready on a new socket, and that opens a narrower gap. An independent pass (Mordecai) found it in source, and I reproduced it against the test fake.

Merge criteria

  • If the socket reconnects while the initial snapshot is still in flight, the manager reads the snapshot once the replayed subscription is live.
  • A duplicate REQ on one socket still reads nothing extra.
  • One added step in the existing reconnect journey covers both.

Validation: the read-state suite passes locally at this head (93 tests), and CI is green.

// same REQ twice) leaves no gap, so it reads nothing.
onStatusChanged: (status) {
if (status == RelaySubscriptionStatus.ready) {
if (!live && returned) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 Blocker: a socket reconnect during the initial snapshot leaves a gap that nothing reads.

live never resets when the socket drops. RelaySession emits no status on disconnect (_handleDisconnected), only retrying on a CLOSED. So the replay's EOSE on a new socket looks exactly like the duplicate-REQ ready this guard ignores.

Ordering:

  1. initialize() subscribes and EOSE arrives in time, so live = true. The HTTP snapshot is still in flight (megabytes on a busy account).
  2. The socket drops and reconnects. The provider's connected listener calls reinitializeRemote, which waits for initialize. Because _initialSyncSucceeded is set, it returns early (line 313, "it already used this connection"), and that assumption no longer holds.
  3. The session replays this REQ with limit: 0, and this callback ignores its ready.
  4. The snapshot's fence checks the session context, not the socket (relay_session_snapshot.dart checkContext), so the manager accepts the snapshot it started before the drop.

Another device's mark published while the socket was down is never read. On main the 500-event replay covered it, and at f0cc23bab6 this ready queued the catch-up. With nothing unpublished, the scheduled publish also skips its snapshot. The channel stays unread until the next resume or reconnect.

Repro (throwaway, existing fake): set readyOnSubscribe = true and hold fetchGate. Call initialize() and pump, then call reinitializeRemote(). Fire statusListeners.last(ready) and release the gate. Snapshot reads: 1 at this head, 2 at f0cc23bab6.

Fix: the manager needs to learn that the socket changed. For example, skip the _initialSyncSucceeded shortcut when the connection changed during initialize, or clear live when the socket drops. Add this ordering as a step in a connect during a successful initialize does not sync again rather than a new test.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 Fixed in 4eff9e4. reinitializeRemote now skips a connect during initialize only when the live subscription had not yet reported ready, meaning that connect is the one initialize was waiting for. A connect after the subscription went ready is a reconnect, so it reads the snapshot again. New test a reconnect during the first snapshot syncs again fails with the old skip (red-checked) and passes now. The duplicate-ready test still holds.

A connect after the live subscription went ready is a reconnect, so it
reads the snapshot again even while initialize is still running.

Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Tom Brow <[email protected]>
@brow
brow requested a review from wesbillman November 6, 2026 02:08
@github-actions github-actions Bot added codex-security-review-current The posted Codex security review matches its recorded range. and removed codex-security-review-current The posted Codex security review matches its recorded range. labels Nov 6, 2026
if (_initialSyncSucceeded) return;
// Connected while the first sync waited for its connection: it
// already used this one.
if (_initialSyncSucceeded && !reconnected) return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🤖 P2 · A queued cold-start pass loses a later reconnect

There is a residual ordering after the reconnect fix: the first connected callback queues this pass while _liveReady is false; EOSE then arrives and the initial HTTP snapshot starts; a socket reconnect during that fetch returns the existing _queuedResync without updating its captured reconnected. After successful initialization this guard skips the new snapshot. The replayed EOSE also skips catch-up because disconnect does not reset _liveReady.

User/system effect: A peer read mark published during the outage, after the initial snapshot's cut, reaches neither that snapshot nor the limit: 0 replay. Unread state stays incorrect until another independent sync despite successful recovery.

Test: Queue the initial connected pass before EOSE, hold the first snapshot, then reconnect and publish a peer mark before releasing it. Recovery should fetch and merge that mark.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Preserve reconnect demand while a cold-start pass is queued

The queued-future return happens before _liveReady is read, so a second connection cannot upgrade the first pass's captured reconnected = false. Initialization then skips recovery, while the already-ready subscription suppresses catch-up. Could the queued pass retain a sticky reconnect requirement or connection generation?

Posted by Codex (gpt-6.1-sol) on behalf of kylez

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 Fixed in 824494224a. The reconnect and generation logic is gone. Initialize, every reconnect and every live-subscription ready now ask one sync loop for a snapshot. A request made before the current pass starts its read joins that read, and a request made while the read is in flight runs one more pass, so a queued cold-start pass can no longer swallow a later reconnect. The cost is an occasional extra snapshot read.

New test a reconnect while the first snapshot downloads is not swallowed by the cold-start connect reconnects while the first read is held and has a peer publish a mark after its cut. It fails against the previous head and passes now.

if (generation != _liveGeneration) return;
// A failed read is retried by the next publish, which reads the
// snapshot in full after a failure.
if (!await _fetchAndMerge()) _schedulePublish();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🤖 P2 · An older catch-up can suppress a newer recovery retry

This generation check runs only before the awaited fetch, and catch-up is not registered in _runningResync. A CLOSED-recovery catch-up can therefore overlap a reconnect snapshot. If the newer snapshot fails and the older catch-up then succeeds, _fetchAndMerge restores _lastSnapshotSucceeded = true. The reconnect's scheduled publish sees no unpublished local changes, skips its retry snapshot and clears the error. The HTTP transport fences community/session changes, but not socket reconnects.

User/system effect: Peer read marks missed during the newer outage remain stale while the manager considers itself synchronized. This loses the retry intended after the failed recovery, beyond the documented temporary HTTP-failure gap.

Test: Hold an old catch-up response, reconnect and fail the new snapshot, then release the old response before the scheduled publish. The publish should still retry the newer recovery.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Keep older snapshot completion from clearing newer retry debt

The generation check precedes the awaited fetch, and catch-up is not registered as a running resync. An older successful response can therefore follow a newer failure, restore _lastSnapshotSucceeded, and make the scheduled publish skip recovery. Could completion be generation-fenced or these fetches share serialization?

Posted by Codex (gpt-6.1-sol) on behalf of kylez

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 Fixed in 824494224a. Snapshot reads are now numbered in start order, and only a read newer than every settled one decides whether the last snapshot succeeded, so an older read finishing late can't clear the retry a newer failed read needs. Catch-up reads also go through the single sync loop now, so they're serialized with initialize and reconnect passes.

New test an older snapshot finishing late keeps a newer failed read's retry holds an older read, fails a newer one, lets the older one succeed, and checks that the next publish still reads a newer peer mark. It fails against the previous head and passes now.

@github-actions github-actions Bot added codex-security-review-current The posted Codex security review matches its recorded range. and removed codex-security-review-current The posted Codex security review matches its recorded range. labels Nov 8, 2026

@kazemek21 kazemek21 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Kyle reviewed the PR and the Nexus review-tracker notes. Approved with the two non-blocking P2 comments above.

Posted by Codex (gpt-6.1-sol) on behalf of kylez

@jedwards27 jedwards27 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🤖 :bot: Jude’s code review agent — integrated review of 8cce12f591988f29cc2beab5ff0c89d51862f31b..eefce94ba01b04ac0a3f43560d61825f8bbed46e.

Recommendation: changes needed before approval. Risk: high (cross-device unread correctness during reconnect). The product/UI pass found the local-first behavior, limit: 0 live subscription, post-subscribe snapshot, late-ready catch-up, provider notification, and normal-path tests consistent with VISION_MOBILE.md’s unread-sync contract. The systems pass and independent integration trace found two unresolved ordering races. These are code defects, not requests for additional device evidence:

  1. A second connection can be swallowed by a queued cold-start pass (mobile/lib/shared/read_state/read_state_manager.dart:304–319; existing inline discussion: #9090 (comment)). reinitializeRemote() returns the queued future before recording the second connect; that pass captured _liveReady == false and can skip resync once initialization completes. If a second socket connection occurs while the first snapshot is held and a peer mark follows its snapshot cut, the limit: 0 subscription does not replay the mark. Author action: retain the later reconnect demand/connection generation and add a production-manager test with two connects (first before EOSE, second during a held snapshot), asserting the peer mark is merged after reconnect.
  2. An older catch-up can erase newer snapshot retry debt (read_state_manager.dart:521–528,384–399,642–646; existing inline discussion: #9090 (comment)). The generation check precedes the HTTP await. Catch-up A can finish after reconnect snapshot B fails, set _lastSnapshotSucceeded = true, and make the debounce publish skip the only retry when nothing is unpublished. The snapshot transport fences account/community, not connection generation (mobile/lib/shared/relay/relay_session_snapshot.dart:17–24). Author action: serialize or fence the completion against newer recovery and test held A → failed B → A succeeds → retry still reads a newer peer mark.

Verification owner: author for fixes and failing-before/passing-after regression tests; reviewer for exact-new-head integration review and native journey if practical. At this exact clean head, git diff --check 8cce12f...HEAD passed and GitHub checks report success. Local full just mobile-check/just mobile-test and native resume/cross-device behavior were not completed: Flutter bootstrap/dependency resolution timed out in reviewer checkouts. The PR documents a profile iPhone 12 mini resume byte comparison and app run, but read marks were not compared across devices. These are confidence gaps, not further author-actionable findings. A new head requires review of its delta.

Posted as a comment per repository AGENTS.md (agent reviews are comments; humans decide review state).

Initialize, reconnects and every live-subscription ready now request a
snapshot through one loop. A request before a pass starts its read joins
that read; a request during the read runs one more pass, so a queued
cold-start pass can no longer swallow a later reconnect. Snapshot reads
are numbered in start order and only the newest settled read decides
whether the last snapshot succeeded, so an older read finishing late
cannot clear a newer failure's retry.

Drops the live generation and ready-transition checks, accepting an
occasional extra snapshot read.

Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Tom Brow <[email protected]>
@brow

brow commented Nov 10, 2026

Copy link
Copy Markdown
Collaborator Author

🤖 Both findings from this review are fixed in 824494224a, with the regression tests you asked for. Each test fails against eefce94 and passes now:

  1. Reconnect swallowed by a queued cold-start pass: replied here. One serialized sync loop now handles initialize, reconnects and every ready, and a request made mid-read runs one more pass.
  2. An older read clearing newer retry debt: replied here. Snapshot reads are ordered by start, and only the newest settled read decides retry state.

The generation and ready-transition logic is removed, at the cost of an occasional extra snapshot read. The read-state suite, flutter analyze and the full mobile suite pass.

@brow
brow requested a review from jedwards27 November 10, 2026 15:18
@github-actions github-actions Bot removed the codex-security-review-current The posted Codex security review matches its recorded range. label Nov 10, 2026
@brow
brow enabled auto-merge (squash) November 11, 2026 12:12

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 Carl, an automated reviewer, commenting via Wes's GitHub account.

Changes requested: one blocker, inline. Reviewed 824494224a (the delta since eefce94ba0) against main 41587e0284.

The sync loop does fix both findings from the previous round. A reconnect during a read now runs another pass, and an older read can no longer clear a newer failure's retry. The new _onChanged() after the publish merge is a real badge fix too.

The blocker: removing the ready-while-live guard brings back a second full snapshot read on every reconnect. This was the round-4 blocker, which 29be4ea901 fixed. Details and a repro are inline.

Validation gap. The new tests all drive _FakeRelaySession, which fires ready by hand and never runs the session's replay or EOSE handling. That's why they pass at a head that reads twice. One journey test through the real RelaySessionNotifier with a fake socket would cover this PR's claims better than more per-scenario fakes: cold start, a peer mark during the gap, reconnect, a CLOSED retry, then a failed read and recovery, asserting unread state and the read count. Non-blocking, but it would have caught the blocker.

Merge criteria: one snapshot read per clean reconnect through the real session replay, and the PR text updated so the HTTP cost per resume matches what the code does.

_unsubscribeLive = null;
await _startLiveSubscription();
await _fetchAndMerge();
await _requestSync(resubscribe: true);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Blocker: every clean reconnect reads the full snapshot twice.

The provider's connected listener runs synchronously inside state = in _handleConnected, before _replayLiveSubscriptions. So this resubscribe sends REQ l-2 on the new socket, and the replay right after it sends l-2 again (marked ambiguous in _sendLiveReq). Both EOSEs emit ready. The first one releases subscribe and the loop starts snapshot A. The second lands while A downloads, sets _snapshotWanted, and the loop reads the whole snapshot again.

Repro: a real RelaySessionNotifier with a recording socket and a gated fake fetchReadStateSnapshot. Call reinitializeRemote(), then debugReplayLiveSubscriptions(), then EOSE each REQ, the second while the read is held.

Head REQs on the new socket Snapshot reads per reconnect
eefce94ba0 [l-2, l-2] 1
824494224a [l-2, l-2] 2

A separate reviewer probe got the same result through the provider listener. With only this resubscribe removed, it got one REQ ([l-1]) and one read.

That is a full read-state download (discovery plus POST) on every resume, on the accounts this PR is meant to help. The PR text's "Each resume reads the read-state snapshot once over HTTP" no longer holds.

Suggested fix (simplest): let the session own resubscription. It already keeps live subscriptions across reconnects and replays them, and a subscription opened before AUTH is held and sent on connect. Pass onClosed to mark the subscription lost, and resubscribe here only when it is lost (terminal CLOSED, or a failed subscribe). Otherwise the replay's ready already asks the loop for the read. That also removes _resubscribeWanted, the wait for the publish before resubscribing, and the cold start's extra pass, and it stops a terminal CLOSED from dropping the subscription silently. The alternative is to restore "a repeated ready while already live reads nothing", reset on retrying and on each new subscribe. That works, but it keeps the duplicate REQ.

Please cover it with a test through the real session replay. The _FakeRelaySession path can't show the duplicate EOSE.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 Fixed in c620e9d4b5, with your suggested fix. A reconnect no longer resubscribes: the session's replay of the live subscription is the one REQ on the new socket, and its ready asks the sync loop for the one snapshot read. The manager opens a new subscription only after losing one (onClosed on a terminal CLOSED, or a failed subscribe), on the next reconnect. That also removed the resubscribe flag and the wait for an in-flight publish; the sync loop now just opens the subscription when it is missing. No generation or ready-while-live check came back.

New journey test a clean reconnect through the real session replays one REQ and reads the snapshot once, and a lost subscription is reopened drives the real RelaySessionNotifier with a recording socket: reinitializeRemote(), then debugReplayLiveSubscriptions(), then EOSE while the read is held. It fails against 824494224a ([l-2, l-2], two reads) and passes now ([l-1], one read). It also checks that after a terminal CLOSED the next reconnect opens l-2 and reads the snapshot. In that rare lost case the replay can still send the new REQ a second time.

The cold start keeps its occasional extra read when ready lands after the 500 ms fallback.

_snapshotWanted = false;
// A failed read is retried by the next publish, which reads the
// snapshot in full after a failure.
if (!await _fetchAndMerge()) _schedulePublish();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Non-blocking, wording only. The retry chain here is one publish deep. If that publish's snapshot also fails, _publishOnce's catch records syncError and schedules nothing. A mark another device published while the subscription was down then stays unread here until the next local mark, pause flush, resume or reconnect. That fits the PR's "Accepted gap" (it heals on its own, and syncError reaches no UI), so I'm not asking for backoff. But "A failed read is retried by the next publish" reads as a guarantee. Please say instead that it waits for the next trigger.

Related nit: the publish read at _publishOnce increments _snapshotsStarted but never settles a failure, so the invariant "only the newest settled read decides" has one unrecorded path. I can't build a user-visible failure from it, because a failed publish leaves unpublished state that forces the next full publish anyway. Settling the failure there would keep the accounting honest.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 Both done in c620e9d4b5. The comment now says a failed read waits for the next trigger (the publish it schedules, or a later ready, resume or reconnect), and _publishOnce settles a failed snapshot read too.

…read

A reconnect resubscribed the live read-state subscription before the
session replayed it, so the new socket carried two identical REQs. Both
EOSEs reported ready, and the second, landing while the first snapshot
downloaded, read the whole snapshot again on every clean reconnect.

The session already keeps live subscriptions across reconnects and
replays them, so a reconnect now opens a subscription only when the
previous one was lost (terminal CLOSED or failed subscribe), and the
replay's ready requests the single read. This drops the resubscribe
flag and the wait for an in-flight publish. A publish's failed
snapshot read now settles its slot in the read ordering.

Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Signed-off-by: Tom Brow <[email protected]>
@brow
brow deployed to codex-review November 13, 2026 16:28 — with GitHub Actions Active
@brow
brow requested a review from wesbillman November 13, 2026 16:28
@github-actions github-actions Bot added the codex-security-review-current The posted Codex security review matches its recorded range. label Nov 13, 2026
@github-actions github-actions Bot removed the codex-security-review-current The posted Codex security review matches its recorded range. label Nov 13, 2026

This branch was successfully deployed

No deployments
codex-review — b7f17da6 Deployed Nov 13, 2026 by brow via Run Codex Security Review #14967
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.

6 participants