Skip to content

fix(mobile): recover and pace live subscriptions - #3053

Open
brow wants to merge 8 commits into
mainfrom
mobile-relay-closed-recovery
Open

fix(mobile): recover and pace live subscriptions#3053
brow wants to merge 8 commits into
mainfrom
mobile-relay-closed-recovery

Conversation

@brow

@brow brow commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

What changed?

Mobile now recovers live subscriptions after retryable or rate-limited relay CLOSED responses. It ports the existing desktop model: classify terminal versus retryable closures, honor retry hints through a session-owned rate-limit gate, retry with bounded backoff, and replay visible-channel subscriptions first in bounded batches.

Channel refreshes also retain unchanged live subscriptions instead of clearing and recreating them. This is desktop parity, not a new relay policy.

Why?

On reconnect or resume, mobile replayed its retained live subscriptions while channelsProvider independently cleared and recreated roughly the same set, alongside unread catch-up and open-channel requests. The relay allows 50 REQs per 5 seconds, so users in many channels could predictably exceed the budget. In live reproduction, 55 subscriptions produced 9 rate-limit closures, 60 produced 18, and 80 produced 36.

Mobile then treated every live CLOSED as terminal, removed the affected subscription, and never restored it. Channel updates could remain dead until a later session reconstruction. This is the primary causal chain behind BOT-1449.

Desktop already handles this as normal transient pressure by classifying closures, gating and backing off retries, pacing reconnect replay, and retaining unchanged subscriptions. This change brings mobile to the same recovery model while removing the avoidable request burst.

How is it tested?

Full mobile suite: 721 passed, 1 skipped. Analyzer and formatting checks pass. Required CI checks pass.

Added and updated tests cover CLOSED classification, retry hints, rate-limit gating, bounded retry and reset behavior, terminal failures, timer cleanup, history gating, visible-first batched replay, and retention of unchanged subscriptions.

Co-authored-by: Tom Brow <tomb@block.xyz>
Signed-off-by: Tom Brow <tomb@block.xyz>
@brow

brow commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

@builderbot review

@brow

brow commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

@brow brow changed the title fix(mobile): recover subscriptions after relay CLOSED fix(mobile): recover and pace live subscriptions Jul 27, 2026
Co-authored-by: Tom Brow <tomb@block.xyz>
Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Codex <noreply@openai.com>
Ai-assisted: true
@brow
brow marked this pull request as ready for review July 27, 2026 03:01
@brow
brow requested a review from a team as a code owner July 27, 2026 03:01

@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.

P2 — Restore the previously visible channel when a nested channel route is popped.

ChannelDetailPage sets the session's singleton visible channel on mount, then unconditionally clears it on disposal. Channel links push a second ChannelDetailPage over the still-mounted first page. In an A → B → back-to-A flow, disposing B writes null; A becomes visible again but never remounts, so its effect does not rerun. A subsequent reconnect therefore loses A's first-batch replay priority in _replayLiveSubscriptions.

Please make visibility route-aware (or restore the prior active channel) rather than treating every page disposal as “no channel visible,” and add a navigation-stack regression test covering A → B → pop → reconnect ordering. The current relay test only calls setVisibleChannelId directly, so it cannot catch this lifecycle failure.

Reviewed at exact head fc2f4e5d928d89a1dc7b59de59d8527175af4c63. Mobile CI and all required checks are green; I did not duplicate CI locally.

npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp and others added 6 commits July 27, 2026 10:26
Co-authored-by: Tom Brow <tomb@block.xyz>
Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Tom Brow <tomb@block.xyz>
Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Tom Brow <tomb@block.xyz>
Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Tom Brow <tomb@block.xyz>
Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Tom Brow <tomb@block.xyz>
Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Tom Brow <tomb@block.xyz>
Signed-off-by: Tom Brow <tomb@block.xyz>
@brow

brow commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

🤖 Addressed the nested navigation lifecycle: visible-channel priority is now owner-scoped, so popping B releases only B and restores A for reconnect replay. Added regression coverage for A → B → pop → reconnect, route replacement, nested ownership, and stale or out-of-order disposal.

@brow
brow requested a review from wesbillman July 27, 2026 18:49
ss251 added a commit to ss251/buzz that referenced this pull request Jul 28, 2026
…relay session

Closes gaps 3, 4 and 5 of the mobile relay client's connection resilience
(block#3224), all in mobile/lib/shared/relay/relay_session.dart. Gap 1
(stall detection) is PR block#3231's and gap 2 (timeouts) needs a design decision;
neither is touched here.

Gap 3 — reconnect backoff had no jitter. Each scheduled wait is now randomised
by ±20% via `_jitteredDelay`, mirroring the desktop's `jittered_duration`
(crates/buzz-acp/src/relay.rs). The ladder position `_reconnectDelayMs` stays
un-jittered and jitter is applied per scheduled wait, matching the desktop's
split between `backoff_step` and the jitter applied at call time. Without this
a fleet of clients reconnects in lockstep after a relay blip.

Gap 4 — the backoff ladder reset on connect rather than on stability, so a
socket that died seconds after connecting reset to base on every cycle: the
doubling never accumulated and a flapping link hammered the relay indefinitely.
`_handleConnected` now arms a 60s timer (`_stableConnectionMs`, mirroring the
desktop's `STABLE_CONNECTION_SECS`) that resets the ladder only if the
connection survives; the timer is cancelled on disconnect, pause and dispose.

There are three reset sites and only one changed. The two left resetting
immediately are deliberate, and each now says why in a comment:
  - `_handleConnected` (was relay_session.dart:378) — CHANGED to stability-gated.
    This is the automatic reconnect path and the only one that can self-spin.
  - `reconnect()` (:311) — unchanged. Caller-driven, so it cannot loop on its own.
  - `onAppResumed()` (:344) — unchanged. The preceding disconnect was our own
    backgrounding, not relay trouble, and the user is looking at the app; making
    them wait out a ladder the relay never asked for would read as a hang.
The desktop has no equivalent of the latter two (no app-backgrounding concept),
so desktop parity does not decide them; this is the conservative reading, being
the smallest behavioural change that closes the gap.

Gap 5 — NOTICE frames were dropped by `_handleMessage`, so the relay's explicit
"slow down" was discarded. Since gaps 3+4+5 compound, the client could not hear
the rate limit it was provoking. NOTICE is now logged via debugPrint, and a
`rate-limited:` notice raises the reconnect backoff floor from the relay's own
`retry in Ns` hint: absent or sub-2s hints floor to 5s and the value is clamped
to `_maxReconnectDelayMs`, both mirroring the desktop's `set_rate_limit_gate`.
It takes the maximum against the current ladder, so overlapping notices can
never shorten a longer backoff already in place.

Gap 5 deliberately feeds the existing `_reconnectDelayMs` ladder rather than
introducing a gate object. PR block#3053 (open, unmerged) adds a RelayRateLimitGate
driven by CLOSED frames and rewrites `_handleConnected`; it contains no NOTICE
handling and no jitter, so these gaps are unclaimed. Routing NOTICE through the
ladder keeps this free of a hard dependency on block#3053 while pointing at the same
backpressure concept, so NOTICE can be redirected into that gate in a small
follow-up if block#3053 lands.

Deviation from the work order worth flagging to review: the order specified
±25% jitter as the desktop's value. The desktop's `jittered_duration` actually
uses a factor of [0.8, 1.2) — ±20% — so ±20% is implemented for real parity.

Tests: 11 new cases in mobile/test/shared/relay/relay_session_test.dart. Each of
the three fixes was negative-controlled by reverting only its lib/ change and
confirming the new tests fail — jitter neutralised gives a flat 1000ms, the
reset-on-connect restored collapses the ladder to 2000 where 4000 is expected,
and the NOTICE case removed leaves the backoff at 1000. `Random` is now
injectable through the constructor (alongside the existing `httpClient` and
`socketFactory`) so the jitter assertions are deterministic rather than timing
dependent.

Gate from mobile/: dart format --set-exit-if-changed clean, flutter analyze
clean, flutter test 834 passed / 1 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HU2jkzBjmJSBGz2ciGFdej
Signed-off-by: ss251 <ss251@uw.edu>
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.

2 participants