fix(desktop): stop rate-limited reconnect backfill from tearing down the authenticated socket - #4990
fix(desktop): stop rate-limited reconnect backfill from tearing down the authenticated socket#4990wesbillman wants to merge 5 commits into
Conversation
Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@buzz.block.builderlab.xyz> Signed-off-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@buzz.block.builderlab.xyz>
…s rate-limited A CLOSED rate-limited: on a paged history REQ during reconnect replay rejected the history promise, escaped replayLiveSubscriptions, and made the session resetConnection() a healthy, authenticated socket. The client then redialed straight into the same rate-limit window: the 'briefly connected -> can't reach the relay' flap loop users hit on v0.5.5. Contain backfill failures inside the replay: retry each subscription's paged backfill behind the rate-limit gate up to PAGE_REPLAY_MAX_ATTEMPTS, then degrade to live-only for this connection. Socket health no longer depends on backfill success. The missed window is not lost: the replay cursor (lastSeenCreatedAt) only advances on delivered events, so the next reconnect replays it. Red test by Pinky (previous commit) proved the double-dial on main; it passes unchanged with this fix. New unit tests cover containment, gate-aware retry, and abort on subscription replacement. Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@buzz.block.builderlab.xyz> Signed-off-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@buzz.block.builderlab.xyz>
423a248 to
e825c9c
Compare
wesbillman
left a comment
There was a problem hiding this comment.
Reviewing exact head e825c9ceef872560184aecf49b89e37ab5affd90 on Wes Billman's behalf.
[P1] Preserve the original replay cursor after a failed backfill
The claim that a failed backfill will retry the same missed window on the next reconnect is not true once any live event arrives on the restored subscription. replaySince is captured from the old cursor (relayReconnectReplay.ts:184-196), but the live REQ is restored before paging begins (:216-244). Every event delivered on that live REQ immediately advances subscription.lastSeenCreatedAt (relayClosedRecovery.ts:128-145), independently of whether the history backfill succeeds.
Concrete failure sequence:
- Cursor is
1000; reconnect capturesreplaySince = 995. - Live REQ is restored and receives a new event at
2000, advancinglastSeenCreatedAtto2000. - All three history attempts for
[995, reconnectTime]are rate-limited and the new loop degrades to live-only (relayReconnectReplay.ts:262-281). - On the next reconnect, replay starts at
1995, permanently skipping the missing995..1994window.
That turns the socket-flap fix into silent history loss—the dungeon has merely replaced the alarm with a trapdoor. Please retain a pending replay floor/cursor until the backfill succeeds (or otherwise ensure a later reconnect starts from the earliest unresolved boundary), and add a regression where a live event arrives between replay setup and exhausted history retries, followed by another reconnect that must request the original missed window.
The containment itself is directionally correct, and I found no other blocker in the changed paths. Existing CI is still running Desktop Core at this head; the completed Desktop E2E checks are green. I did not duplicate the broad suite locally.
|
The socket-containment direction is right, but there is a correctness hole in the live-only fallback: Blocking: after the third backfill failure, the claimed “next reconnect replays the same missed window” is not true once this healthy live subscription receives another event. Backfill delivery calls Example: cursor=1000, disconnected until 2000, all three backfills fail, then a live event at 2100 arrives. The next reconnect starts at 2095, not 995, so events 1001…1999 are never recovered. This changes the failure mode from a visible flap to silent message loss. Please preserve a separate unresolved-backfill cursor (or otherwise keep the replay lower bound pinned until a complete backfill succeeds). Add a regression test that exhausts retries, delivers a newer live event through the normal cursor path, and verifies the next replay still requests the original missed window. |
Review blocker on #4990: after backfill retries exhaust, events on the restored live REQ still advance lastSeenCreatedAt, so the next reconnect computed replaySince from the newer cursor and permanently skipped the unresolved older window — trading the visible flap for silent message loss. Pin pendingReplaySince on the subscription when paging starts; clear it only when a backfill pass completes. Replay windows start from min(pinned floor, cursor window), so an exhausted backfill followed by live traffic still requests the original missed window on the next reconnect, and a completed backfill returns control to the cursor. Regression test follows the review's exact scenario: cursor=1000, all attempts rate-limited, live event at 2100 advances the cursor via prepareSubscriptionEvent, next replay must request since=995, and a completed pass must clear the floor. Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@buzz.block.builderlab.xyz> Signed-off-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@buzz.block.builderlab.xyz>
|
Posted by Brain (agent) on Wes Billman's behalf. Fixed in The fix pins
Regression test follows the review's exact scenario: cursor=1000, all attempts rate-limited, live event at 2100 delivered via |
wesbillman
left a comment
There was a problem hiding this comment.
Reviewing exact head b70a6716d36cc84e55aa433a9087b36a418de8fd on Wes Billman's behalf.
The pinned replay floor fixes the reported exhausted-retry + newer-live-event sequence, and the new regression covers that sequence well. One remaining race needs correction before the floor is safe:
[P1] Do not clear the floor when paging aborted because the connection became stale
replayReconnectHistoryPages returns void both when paging completes and when isActive() becomes false (relayReconnectReplay.ts:113,124). The caller therefore unconditionally clears pendingReplaySince after the promise resolves (:278-286).
A concrete sequence:
- Connection A pins the old floor and starts a history request.
- A is superseded while that request is in flight; the same live subscription object survives for connection B.
- A's history request resolves. The post-request
isActive()check returns false, so paging exits without completing the window. - A's caller interprets that return as success and clears the shared subscription's floor. B (or the next reconnect) can now use the advanced live cursor and skip the unresolved gap.
Please distinguish completion from stale cancellation (for example, return a completion boolean/result and clear only on true), and add a focused race regression where generation becomes stale during the history request while the subscription remains in the map. The original silent-gap scenario itself is otherwise addressed.
Re-review blocker: replayReconnectHistoryPages returned the same void for a completed window and for an abort due to a superseded connection. The caller cleared pendingReplaySince after both, so a stale connection A returning mid-flight could erase the floor that superseding connection B still needs — reopening the silent-gap risk on the shared subscription object. Return a completion boolean (false on stale abort, true on a genuinely finished window) and clear the floor only when true. Regression test supersedes the connection while a history REQ is in flight (subscription object kept alive, re-registered under a new subId) and asserts paging stops and the floor stays pinned. Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@buzz.block.builderlab.xyz> Signed-off-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@buzz.block.builderlab.xyz>
|
Posted by Brain (agent) on Wes Billman's behalf. Race fixed in Regression added per the re-review: the subscription object is kept alive and re-registered under a new subId while a history REQ is in flight (a full 500-event page, so paging would otherwise continue); asserts paging stops after one call and the floor stays pinned at 995. Focused file 21/21, tsc clean; broad verification via hooks/CI. |
wesbillman
left a comment
There was a problem hiding this comment.
Reviewing exact head c493d378bcfd8809cb79f04c88bce39faac66616 on Wes Billman's behalf.
The completion boolean is correct, but the new regression models subscription replacement rather than the actual generation-stale race, so the blocker remains:
[P1] Include connection generation in the paging isActive predicate
The outer replayLiveSubscriptions receives the session-generation guard as isActive, but the paging call still passes only:
isActive: () => subscriptions.get(subId) === subscription(relayReconnectReplay.ts:293 at this head). On a normal socket supersession, connectionGeneration changes while the subscription remains in the map under the same subId and as the same object. Therefore this predicate stays true, stale connection A can finish paging, completed is true, and A clears the floor needed by connection B.
The test avoids that production path by deleting live-1 and re-registering as live-1b; that makes the map-only predicate false and proves only subscription replacement, which was already guarded.
Please pass isActive: () => outerIsActive() && subscriptions.get(subId) === subscription (renaming the outer callback if needed), and change the regression to keep the same map key/object while flipping a generation-active boolean during requestHistory. Then assert paging aborts and the floor remains pinned.
Re-review of c493d37: production connection supersession bumps the generation while the subscription keeps its key AND object in the map, so the paging guard 'subscriptions.get(subId) === subscription' stayed true for a superseded connection. Its pass could run to completion and clear the pendingReplaySince floor the superseding connection needs. Combine the outer generation guard with the identity guard in the paging isActive. The regression now models real supersession: the generation flips false mid-flight while key and object survive (asserted as a precondition); paging must stop after one call and the floor must stay pinned. Against the identity-only guard this test does not merely fail — it loops forever, which is how the gap could silently persist in production. Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@buzz.block.builderlab.xyz> Signed-off-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@buzz.block.builderlab.xyz>
|
Posted by Brain (agent) on Wes Billman's behalf. Corrected in The regression now models real supersession per the re-review: |
wesbillman
left a comment
There was a problem hiding this comment.
Reviewing exact head 6cebdfbd6b2daf99c9537aab426f9cf29c7a9564 on Wes Billman's behalf.
Re-review complete: the combined generation + subscription-identity predicate now closes the stale-pass race. The revised regression preserves the same map key/object, flips only generation activity mid-request, and verifies the stale pass stops after one request without clearing pendingReplaySince. That matches the production supersession shape that the prior test missed.
I found no remaining blocker in the changed replay, cursor-floor, and regression paths at this head. I did not duplicate broad suites locally; CI for this new head is in progress.
Problem
Users on v0.5.5 report "Can't reach the relay" toggling with brief "connected" flashes (field reports; also the macOS confirmation in #4908). #4737 closed the stuck-reconnect gaps; this is the opposite failure: the client redials fine, but then kills its own healthy socket.
Mechanism (all on
main):connected(relayClientSession.ts:583), then awaitsreplayLiveSubscriptions().relayReconnectReplay.ts, page limit 500).CLOSED rate-limited:on a history REQ arms the rate-limit gate but still rejects the history promise (relayClosedRecovery.ts:38-51).replayLiveSubscriptions()→resetConnection()tears down the authenticated socket.Fix
Contain backfill failures inside the replay. Each subscription's paged backfill now retries behind the rate-limit gate up to
PAGE_REPLAY_MAX_ATTEMPTS(3), then degrades to live-only for this connection. Socket health no longer depends on backfill success. Nothing is lost: the replay cursor (lastSeenCreatedAt) only advances on delivered events, so the next reconnect replays the same missed window.Red/green proof
CLOSED rate-limited:into the mid-replay history REQ — red on main (expected 1 reconnect dial, observed 2; connected-flash then teardown).connectedthrough the rate-limit hint plus the next backoff window.Why existing coverage missed it: the prior rate-limit e2e pre-armed the gate before replay (replay politely waits), and the CLOSED-injection test targeted a live subscription (which has its own retry path). Nobody injected back-pressure from the history REQ itself.
Verification
pnpm test: 4374/4374 pass.playwright test tests/e2e/relay-reconnect.spec.ts: 14/14 pass, including the new spec.tsc --noEmitclean; Biome clean on touched files (pre-existing warnings on main inpersonaCatalogRelay.test.mjs/terminal.cssuntouched).Not addressed here (follow-ups from the same field reports)
error:rejections (3 strikes during a relay bad window → stuck until click/relaunch; Windows Desktop: cannot-connect-to-relay state does not self-recover; full relaunch required #4908).relay.drainJitterMs(relay: fuzz WebSocket 1012 restart-close timing on graceful drain (BUZZ_DRAIN_JITTER_MS) #4542) defaults to 0 — enabling it on the hosted relay removes the deploy thundering herd that triggers these rate-limit storms.