Skip to content

fix(relay): skip the reconnect catch-up for a channel whose replay was complete - #2206

Merged
loganj merged 23 commits into
mainfrom
larry/reconnect-no-duplicate-catchup
Nov 7, 2026
Merged

loganj merged 23 commits into
mainfrom
larry/reconnect-no-duplicate-catchup

Conversation

@loganj

@loganj loganj commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • After every reconnect, each open channel read its newest page of messages from the relay again (plus up to five older pages and a notices read), and each open thread re-read its replies, even after a short network blip.
  • The app's live subscription for those channels already replays what it missed: it resumes from just before the time the previous connection last heard the relay. When a channel's own replay is not cut short by the relay's 500-event limit, and the session can apply every event in it by itself, the re-read adds nothing.
  • Now an open channel that was up to date when the connection dropped, and whose newest page was read in the last 15 minutes before the drop, skips those reads when its replay was complete, and is marked up to date at once. The live subscription's 15-minute refresh re-reads that page while connected, so most reconnects qualify. An open thread does the same. Every other case keeps today's catch-up: a replay that reached the limit; a replay that carried a reply, a deletion, or an edit or reaction that arrived before its target; a channel with no earlier connection; a channel opened, restored or read while offline; a channel still owed an earlier catch-up; a thread that was purged or read while offline; a batch re-sent for a grant, which includes any reconnect within about 16 minutes of a join, add, create or DM open; and any reconnect in the first 11 to 13 minutes of a session.
  • Builds on fix(relay): skip full profile and roster re-reads after a continuous reconnect #2203 (merged), which added the same "replay was complete" signal for the profile and channel-list subscriptions.
  • src/features/relay/session.ts and src/features/relay/store.ts are marked FOUNDATION. Logan asked for this change directly.

Details

  • Per-channel signal (live.ts). A channel batch subscription has one filter per channel, each with its own 500-event limit. The route counts replayed events per channel (an event that names no single channel of the batch counts against each of them) and reports, at end of replay, which channels resumed from an earlier connection's cursor and stayed under the limit. Continuity also requires coverage of the full 960-second resume lag before the cursor. The startup floor can truncate that interval until the cursor is 11 minutes into the session, so channel and global routes keep their repair reads during that period. A cursor ends where its connection last heard the relay, as of the recovery tick before the latest (ticks are 60 s apart), so it can trail the drop by up to two minutes: a drop 11 to 13 minutes into a session can still catch up. A live-only replacement reports none. That includes a batch re-sent for a grant (44100, sent when the user joins, is added to, creates or unarchives a channel, or opens a DM), and the batch re-sent when the membership replay re-delivers a grant of its last 16 minutes. So a reconnect within about 16 minutes of a grant catches up that batch as it does today. A grant that arrives while its batch is not live yet (its first reconnect REQ waits to retry, is queued or is in flight) takes that channel out of the batch's resumed set, so the channel catches up at its EOSE: its replay starts from a cursor of a stream the relay may have left the channel out of. A grant also ends the channel's cursor, and no connection sets it again until a REQ sent after the grant reaches EOSE, so a rebuilt batch or a later connection cannot restore it. That REQ is judged by order, not by the clock: an EOSE clears the debt only if no grant reached its route while its REQ was in flight.
  • Batches over 10 channels (live.ts). Production sends up to 2048 channels in one batch. A batch over 10 channels sends its REQ live-only and replays through separate history routes, so its EOSE establishes only the channels no history route still replays. Each other channel establishes when its replay ends, with that replay's continuity: a combined replay counts a channel only on the page where its paging ends below the limit, a split replay by its own filter's count, and the same repairs apply. A replay that fails or is stopped establishes its channels at once with nothing continuous. A connection that drops before a channel's replay ends sets no cursor for it, so the next connection resumes from the older cursor. A channel whose live batch is not live when its replay ends (the batch failed or is being retried) establishes at that batch's next EOSE, with nothing continuous. A grant re-sends the batch live-only, so it also ends the continuity of every channel of that batch whose history replay is still running.
  • Planned renewals (live.ts). A planned renewal's carried batch stays quiet when its replay ends below the limit, even for a channel granted before its REQ (a grant ends that channel's cursor, so a carried route cannot hold one), unless that replay carried a reply, deletion or other repair above. Then it establishes, and those channels catch up.
  • Grant after a revoke (store.ts). When access ends, the relay leaves the channel out of its batch with no frame, and the channel's cursor stays. A grant that restores access can reach the broker after the batch's EOSE (for example in the membership replay of the same reconnect), so the batch can first report that channel continuous. The grant then re-sends the batch, whose EOSE reports it again with nothing continuous, and the session starts a catch-up for it. The re-sent batch is live-only, so it can deliver a new row before that report, and the browser broker can hold a report back under backpressure while it writes live events at once. So the target is taken at the grant, not at the report: live.ts hands the 44100 to the session before it re-sends the batch, and the session tells the store (granted). The window keeps the rows it holds then. While the relay left the channel out, the stream delivered nothing for it and a read was denied, so those rows all precede the gap. Until a catch-up started after the grant takes them, a continuous report does not resume the window; the next catch-up, or the next drop's, pages back to them. A catch-up already running when the report arrives may have read its head before the grant, so the rows stay owed past it. A report with nothing continuous and no grant since takes the rows held when it arrives, so a routine batch re-send after a resume reads only the head.
  • Events the session cannot apply alone (live.ts). A channel is not continuous when its replay carries a reply or a deletion (each changes a thread summary, kind 39005, which the relay never stores, so the replay cannot carry the new one), an edit or reaction that arrives before the message it targets (the replay is newest first, and the session drops an auxiliary event whose target it does not hold), an undecodable event, or an event whose h tag names none of the route's channels. An edit or reaction to a message held before the drop, a new top-level message and a membership notice keep it continuous. A global route (profiles, membership) with an undecodable replay event is not continuous either, and that event counts against its limit.
  • Broker (relay-broker.mjs, broker-live.ts). The dev broker carries the list across its HTTP boundary; a malformed list counts as none. When a batch narrows during its replay, the broker drops that batch's untagged traffic, so it now reports no channel of that batch continuous.
  • Channel windows (store.ts, session.ts). A window records at the first drop whether it was verified and the head read whose rows it holds started at most 15 minutes before (RESUME_READ_AGE, from LIVE_RESUME_LAG). A head read the window never took (a hover prefetch, a cancelled catch-up) does not count. An open thread uses the same age rule, from its last full refresh. The replay starts 16 minutes before the drop, so a live frame the relay dropped before that is missing from it; the recent head read holds it. A window that skipped the read on one reconnect needs a new head read before it can skip again. resume() clears any outage read error and marks it verified with no head read only if it still holds that state: a head or page read while offline, or a queued catch-up, clears it. resume() also finishes a membership notice read that failed or was cut before the drop; it reads nothing when none is owed.
  • Threads (threads.ts). A thread view records at the first drop whether it was settled. A purge or any read after the drop clears that, a later drop keeps it, and the skip also needs the view to be ready now.
  • Browser fixture. policy-relay.mjs gains replayNext(), which models a reconnect replay (fixture events are dated before the client's resume time, so the relay would not replay them otherwise). Specs that test the catch-up now use a replay that reaches the limit; the startup reconnect spec checks that an under-limit replay near startup still repairs while preserving history and reading position.

Known gaps

An event that neither the live stream nor the replay carries is no longer repaired by the reconnect's re-read for a channel whose replay was complete. In general, a replay with fewer events than its limit is complete only if the relay applies every filter before the limit. Known cases, all relay-side:

  • Channel deleted while offline. The relay leaves it out of the replay with no message, so it looks complete. The 60-second channel-list read on the recovery tick removes it.
  • Filters after the limit. The relay checks some per-reader visibility (event_visible_to_reader) after the limit, so a replay that the limit cut can arrive with fewer than 500 events. It needs a busy channel. The relay fix is to apply those filters in SQL before the limit.
  • Silent send-buffer drop. When a connection's send buffer is full (BUZZ_SEND_BUFFER, try_send_ws_message), the relay discards a live frame without notice. The 15-minute head read age bounds what a skip can miss: a frame dropped before the latest head read is in that read, and one dropped after it is newer than the replay's start. The remaining case is such a frame whose author's clock is more than a minute slow. A live connection has the same gap until the next 15-minute refresh, with no reconnect at all. The relay fix is to end the subscription or the connection when it drops a frame.
  • The replay count requires a relay that sends CLOSED, not EOSE, after a failed history read (block/buzz #8692, 76cd4294). Deployed relays must include it.

Rollout

No flag. The change keeps no persisted state, so rollback is a plain revert. A skipped catch-up that misses an event shows as a message that appears only after a reload. Watch for such reports in the first days after release; revert if one traces to a skipped catch-up.

Testing

  • live.test.ts: per-channel continuity with a cut channel, a channel with no cursor, a live-only renewal, and an event counted against two channels; replies, deletions and an edit or reaction before its target are not continuous, while edits and reactions to held messages and top-level messages are; an undecodable event or an event tagged for another channel makes no channel of its batch continuous; an address-only deletion is not continuous; a channel granted while its batch's first reconnect REQ waits to retry is not continuous at the retry's EOSE, nor after that batch is rebuilt for narrower interests or re-created on the next connection; a granted channel resumes on the next connection only after its re-send reached EOSE; a channel granted while that re-send is in flight, in the same millisecond as its REQ or after a clock rollback, stays owed; a global route with an undecodable replay event is not continuous, and that event counts against its limit; full-lag boundary at session ages 0, 659, 660 and 661 s.
  • live.test.ts, batches at the production size (2048) with 11 channels: the batch EOSE establishes no channel; the history replay's end establishes all of them, with a channel that carried a reply excluded; a reaction whose target is on the next combined page is not continuous; a channel the limit cut establishes at once, the rest when their paging ends; a failed history replay establishes all at once with nothing continuous; a drop before the replay ends keeps the older cursor for the next connection. Four of the five fail on the previous head. A grant that arrives while a retried batch REQ is in flight re-sends the batch at EOSE, and no channel whose history replay was running is reported continuous (both grant re-sends share regrant(); fails at 716622c).
  • live.test.ts, found by the model test below: a grant during a large batch's history replay reports no channel continuous; a channel whose batch fails during its replay establishes only when the batch is live again, with nothing continuous; a planned renewal whose replay carried a reply establishes the batch with that channel not continuous. Each fails on the previous head.
  • live-continuity.model.test.ts (new): a seeded randomized model of the relay checks one rule: a channel reported continuous holds every stored message of that channel, is streamed by an open subscription, and its replay carried no reply. The model relay replays newest first up to the limit, accepts events backdated up to 900 s, fans out live events, and silently leaves out channels the viewer cannot read, at REQ time and after a revoke. It runs 40 random steps (posts and replies, backdated posts, outages short and long, restarts, planned renewals, CLOSED, grants and revokes, interest changes, bursts) for 3 and 12 channels, 12 seeds each (about 40 s). Whenever a stream gap opens, it posts into the affected channels inside the gap, and it injects disruptions while a replay is in flight. A claim that missed a message passes only while that channel's grant is stored but not yet delivered (the grant-after-revoke case above), and a later report with the channel not continuous must retract it before the run ends. The model's catch-up is ideal: it does not run the store, so the store side of that case has its own test above. It models only messages and replies, not reactions, edits or deletions; the unit tests cover those repairs. LIVE_MODEL_SEEDS raises the seed count: 80 and 150 seeds pass, and 40 seeds pass at the final head. It found the three holes above. Mutation check, each removing one guard or reverting one fix in live.ts: the unit suites (live, live-session, live-recovery, live-restriction, live-demand) fail for all 14 mutants; the model alone, at the default 12 seeds, fails for 8 of them, including the original head before these fixes.
  • live-session.test.ts: after a continuous resume, a grant, then a live row from the re-sent batch before its report: the catch-up pages back past the head to the rows held at the grant. In the broker's order (grant, live row, then the delayed continuous report of the replay that left the channel out) the window is not resumed and catches up to the drop's rows; the re-sent batch's own report then reads only the head. With a second drop before the re-sent batch reports, the next catch-up still reaches the grant's rows. All three fail without the session's granted call. With an older catch-up still running at the re-sent batch's report (its head read before the grant), the next catch-up still reaches the grant's rows (fails at a7151b0). After a resume and three live messages with no grant, a routine re-send of the batch reads only the head (fails at 01d6486). An open thread whose last read is older than the replay recovers a reply the stream dropped.
  • live-session.test.ts: a window whose last head read started more than 15 minutes before the drop catches up after a continuous replay and shows a frame the live stream missed (fails on the previous head); a refresh while connected re-reads the head, so a reconnect 10 minutes later reads nothing; a hover prefetch more recent than the window's own head read does not let it skip (fails at 01d6486); an open thread skips its read at exactly 15 minutes since its last refresh and reads at 15 minutes plus 1 ms (fails without the thread age check).
  • live-session.test.ts: a verified window gets no head, page or notice read and shows backdated and missed events from the replay; an offline edit and reaction to a held message apply from the replay with no read; a cut replay catches up; a window read before the first connection, read again while offline, or still owed a catch-up, catches up; an owed notice read resumes with no head read; a thread refreshes only on a cut replay, and after a cache clear, a failed refresh, a refresh while offline, or a refresh between two drops. Each store and thread guard was removed in turn and a test failed, except the thread purge clear (see the review thread).
  • broker-live.test.ts and relay-broker-live.test.mjs: the list crosses the broker boundary; a batch with a channel no longer current reports no channel continuous.
  • Browser (live.spec.mjs, message-navigation.spec.mjs, relay-recovery.spec.mjs): catch-up after a cut replay keeps paged history, cursor and reading position; an under-limit replay near startup keeps them with a repair read.
  • Live relay at 2796e9b: a local buzz-relay behind a TLS proxy and a gate that closes the socket, with two browser users in the dev broker. A session aged 19.5 minutes has an 8-second outage, and the other user sends a message during it. After the reconnect, the open channel shows the missed message 0.8 s later with no head read (0 head reads, 10 reads in total after the drop). That scenario has no grant in the membership replay, so the grant change at the final head does not reach it.

🤖 Generated with Claude Code

Base automatically changed from larry/reconnect-no-full-rereads to main November 2, 2026 22:35
@loganj
loganj force-pushed the larry/reconnect-no-duplicate-catchup branch from 0f64cae to a942455 Compare November 2, 2026 22:49
@loganj
loganj marked this pull request as ready for review November 2, 2026 23:00
@loganj
loganj requested a review from a team as a code owner November 2, 2026 23:00
Comment thread src/features/relay/live.ts Resolved
Comment thread src/features/relay/store.ts Resolved
wesbillman

This comment was marked as resolved.

@loganj
loganj force-pushed the larry/reconnect-no-duplicate-catchup branch from ebf0c41 to 2778180 Compare November 3, 2026 13:24
kalvinnchau

This comment was marked as resolved.

wesbillman

This comment was marked as resolved.

kalvinnchau

This comment was marked as resolved.

kalvinnchau

This comment was marked as resolved.

kalvinnchau

This comment was marked as resolved.

kalvinnchau

This comment was marked as resolved.

kalvinnchau

This comment was marked as resolved.

wesbillman

This comment was marked as resolved.

@loganj

loganj commented Nov 4, 2026

Copy link
Copy Markdown
Contributor Author

🤖 Thanks. P3: removed the deployment sentence from "Known gaps"; the bullet keeps the prerequisite that relays include block/buzz #8692. The optional docs note is fixed in 279adf9 (reply in the thread).

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

🤖 Star Lord’s automated source-review follow-up via Wes’s account.

No new actionable findings in this documentation-only follow-up. Compared with the four-Guardian review at 37bc784c7ef2a6ac77343fce12e1dffd82f39304, the base and all implementation/test files are unchanged; the sole file change is one line in docs/relay-queries.md. This qualifies for the small-follow-up solo review exception.

The clarification accurately distinguishes ordinary reconnects from planned renewals: an under-limit carried batch can finish without calling establish(), including when a grant arrived before its REQ. The public description also removes the previously reported internal deployment inventory while retaining the public relay prerequisite.

The previously documented relay-side delivery limitations remain unresolved by this documentation change. This follow-up does not establish the deployed relay prerequisite or runtime correctness.

Reviewed head: 279adf9392d02bd56c9aaafb8fc900e8c3785bf4
Base: 90ce5e37a851909cc0e5fd4da2f469010e4eb7bf

Source-only review of the documentation diff, relevant grant/renewal lifecycle, public description and new commit text; no attachments were present. No tests, builds, app/live-relay workflows, focus/accessibility checks or performance measurements were run; CI was not assessed for this follow-up. COMMENT only, not approval or merge authorization.

kalvinnchau

This comment was marked as resolved.

wesbillman

This comment was marked as resolved.

TheSentinel454 added a commit to block/buzz that referenced this pull request Nov 5, 2026
Remove the repair-read sentence that cited open block/buzz-app#2206, say
the rules list includes non-review sources, cross-reference rule 4 for
backoff, and phrase the cache check as "assume a 0% hit rate".

Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Luke Tornquist <[email protected]>
TheSentinel454 added a commit that referenced this pull request Nov 5, 2026
Address review: block/buzz Review-Proven Rule 9 is not on main yet, so
the paragraph no longer cites it. Drop the open #2206 citation, write
the 306-filter cost in the past tense now that #2308 combined filters,
and link the cited buzz-app PRs explicitly.

Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Luke Tornquist <[email protected]>
bradseiler pushed a commit that referenced this pull request Nov 5, 2026
…2620)

## What

Adds one paragraph to the "Engineering standard" section of `AGENTS.md`:
design every relay-facing path (connect, reconnect, renewal, poll,
catch-up) for the whole fleet running it at once. It asks authors to
state relay reads per client, reconnect from a cursor, jitter and cap
backoff, and prefer event-driven refresh over short polls.

## Why

A relay rollout, restart, or network blip makes every client run these
paths in the same few seconds. Relay load tests keep finding the same
class of defect: a path that is cheap for one client but heavy for the
relay when every client runs it, for example per-channel replay filters
(#2308), re-reading channels whose replay already completed (#2206), and
a 60-second full channel-list poll (#2215).

This is the client-side half of the relay-side guidance proposed in
block/buzz#9106. The paragraph is self-contained and does not cite that
rule by number, so the two PRs can merge in either order.

## What got simpler

Nothing in code. Reviewers get one stated rule instead of rediscovering
the same reconnect cost in each review.

## Validation

Docs-only. Checked the diff and that the two PRs linked from `AGENTS.md`
(#2308, #2215) are merged.

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

---------

Signed-off-by: Luke Tornquist <[email protected]>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <[email protected]>
@loganj
loganj force-pushed the larry/reconnect-no-duplicate-catchup branch from 8eaff7a to bd88f95 Compare November 6, 2026 00:12

@wpfleger96 wpfleger96 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🤖 two things before this one's ready, details inline: the skip doesn't fire for joined batches over 10 channels, and skipping the head read removes the only recovery we have for a live frame the relay drops on a full send buffer. the rest of the guards (timestamp coverage, edits and reactions before their target, replies and deletions, owed notices, grant invalidation, broker narrowing, thread read/purge) look right to me

Comment thread src/features/relay/live.ts Resolved
Comment thread src/features/relay/session.ts Resolved

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

🤖 Star Lord’s automated source-review follow-up via Wes’s account, with independent Rocket, Mantis and Groot coverage.

No new material integration defects found in the rebase since 8eaff7a36bff2f8e2ddf41e470f26e8d6802346f.

The existing more-than-10-channel finding is supported by source: larger joined batches are live-only, and the separate history routes retire at EOSE without delivering continuity to the session. These batches still pay the reconnect catch-up. That limitation also exists in the earlier reviewed revision; it is not new in this rebase. Please resolve it in the existing thread or explicitly narrow the claimed optimization. No duplicate inline is added here.

The previously documented silent live-frame loss, filtering-after-limit limitations and required relay history-failure fix remain unresolved by this change. This review does not establish deployment readiness or close those concerns.

Reviewed head: bd88f95fbaa09235cb214351647721398e0836d4
Base: 8b908c8329616645db5432170498cb73c833be8c

Source-only review of replay paging, channel/thread recovery, broker integration, relevant callers/tests, public description and commit text; no attachments were present. No tests, builds, app/native/live-relay workflows, focus/accessibility checks or performance measurements were run; CI was not assessed. COMMENT only, not approval or runtime acceptance.

@loganj
loganj force-pushed the larry/reconnect-no-duplicate-catchup branch from bd88f95 to ec574ea Compare November 6, 2026 22:30

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

🤖 Star Lord’s automated source-review follow-up via Wes’s account, with independent Rocket, Mantis and Groot coverage.

No new actionable findings in this repair. Source supports closing the concurrent-fill P3 from review 5808730530, part of the existing overlap issue.

catchUpQueued() now leaves grantKept owed while an older fill finishes. The subsequent fill transfers it before capturing its overlap (store.ts:4207–4213,4396); same-channel serialization and head-request invalidation ensure that fill starts its own read (session.ts:3508–3537). Repeated grants, queued work, cancellation, access fencing and the existing bounded-settlement paths yielded no additional actionable finding in this focused pass.

The new regression holds A’s older head across grant → fresh row → report B, then finishes A and requires B to page below newer, recover hidden, and expose all four rows with verified freshness (live-session.test.ts:1961–1991). Its assertions distinguish the repaired behavior from the former head-only stop. The relay documentation now states that the consuming fill must start after the grant. Author reply 6699464246 matches this source trace; its fail-then-pass claim was not independently executed.

Reviewed head: c123327d0f47e7c27a6dc8b2d6f8fe534a3a94c5
Base: 269a3979a0eac854854c4c7c36484b62b79e209f

Focused source-only follow-up since a7151b03, retaining earlier coverage. Inspected the current public description and commit text; no attached images were present. No tests, builds, apps, native/live-relay workflows, rendered focus/accessibility checks or performance measurements were run, and CI was not assessed. Prior relay filtering, silent-frame/backdating, deleted-channel and deployment limitations remain; the model still uses ideal catch-up. COMMENT only, not approval or runtime acceptance.

@wpfleger96 wpfleger96 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🤖 re-checked at c123327. the session now records the grant's overlap before the re-sent batch can deliver rows, so neither a live row nor a late continuous report can resume past the missing history, and the concurrent catch-up fix keeps that boundary owed until a fill that started after the grant takes it. I reran our earlier probes plus the new tests: the hidden row comes back after one older page, and a routine refresh is still head-only. LGTM

nonblocking: the two red browser journeys are sidebar-geometry.spec.mjs:224 (Drafts & sent row), which fails the same way on other PRs since #2733 landed on main, so it's not from this change

loganj and others added 23 commits November 6, 2026 20:45
…s continuous

After every reconnect, each open channel window paid a head read, up to
five more pages and a notice fill, and each open thread re-read its
replies. The live channel route already resumes from the time its
connection last heard the relay (minus the drift lag), so when a
channel's own replay ends below the limit it has delivered every
channel event in the gap.

live.ts now counts replayed events per channel (each channel has its own
filter and limit; an event that names no single channel of a batch
counts against each of them) and reports, with each channel route's
establishment, the channels that resumed from a cursor and stayed below
the limit. The dev broker carries the list across the host boundary.

The session skips the catch-up only for a window that was verified when
the stream dropped and stays owed nothing since: it marks the window
verified at once, with no head, page or notice read. A thread view that
was settled at the drop, and was neither read nor purged since, skips
its refresh the same way. A replay at the
limit, a channel with no cursor, a window restored or read before the
stream was up, a window still owed an earlier catch-up, and a live-only
renewal keep today's catch-up.

Signed-off-by: Logan Johnson <[email protected]>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Keep reconnect repairs when the startup floor truncates the resume lag, for channel and global routes alike.

Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz>
Signed-off-by: Brad Seiler <[email protected]>
Reset the outage read error when a retained window becomes verified, so older history can load again.

Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz>
Signed-off-by: Brad Seiler <[email protected]>
Keep the broker continuity case past the startup floor, and assert the required startup repair in the paged browser journey.

Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz>
Signed-off-by: Brad Seiler <[email protected]>
A channel is no longer continuous when its replay carries a reply or a
deletion (they change a thread summary the relay never stores), an edit or
reaction that arrives before its target, an undecodable event, or a stray
h tag. Edits and reactions to held messages, new top-level messages and
notices keep the skip. The broker reports no continuous channel when a
batch narrowed during its replay. resume() finishes an owed notice read.

Adds tests for the outage-read guards in the store and thread views, and
documents the general filter-before-limit condition and the relay send
buffer gap.

Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Logan Johnson <[email protected]>
A 44100 grant re-sends the channel batch as a live-only replacement that
overlaps its live predecessor until EOSE. It reported no channel
continuous, so every channel in the batch caught up on any reconnect
within 16 minutes of a grant (the membership replay window). It now
reports every channel but the granted ones, and the session keeps the
decision from that connection's earlier establishment.

Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Logan Johnson <[email protected]>
A profiles or membership replay event the session cannot decode now
counts against the route's replay limit and makes the route report no
continuity, as it already did for channel batches. Adds regression
tests for the stray-channel and address-only deletion repairs and for
the broker's narrowed-batch rule.

Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Logan Johnson <[email protected]>
A refusal of a grant's re-send closes it and its live predecessor, but
the recovery-tick retry kept the granted set, so the retry reported the
other channels continuous across the gap. The overlap is now decided
when the REQ is sent: it holds only while the predecessor is live on its
wire. This replaces the clear in retry().

Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Logan Johnson <[email protected]>
…ld grant

The membership replay re-delivers the grant notices of its last 16 minutes.
Each one re-sent the channel's batch and marked the channel as owing a catch-up,
so a reconnect within 16 minutes of a join, create or add always re-read that
channel.

The relay stamps a 44100 with its own clock after the grant commits, so a
membership stream whose replay was complete acted on every notice stamped at
least a minute before it last heard the relay. A replayed notice stamped
before that is not a new grant. A later one, such as a grant stored during the
outage, still re-sends.

The replayed notice also hid a gap: a grant whose re-send the drop cut, or that
came while the batch's renewal waited unsent, left the channel with a cursor
from its predecessor, which the relay may have left the channel out of. A grant
now ends the channel's cursor before the session sees it, an unsent route
records the grant, and a predecessor does not restore the cursor for the
channels its replacement was granted.

Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Logan Johnson <[email protected]>
A renewal that waited to retry recorded a grant, but its dispatch dropped the
grant unless the route was a grant re-send. If the retry then dropped before
EOSE, the predecessor restored the channel's pre-grant cursor, and the next
connection skipped the replayed grant, so the channel missed its catch-up.

Keep the grants of a route with no overlap claim until its EOSE. At that EOSE,
report their channels as owing a catch-up, because the replay was planned from
the old cursor. Test the renewal through its retry, a first REQ retried after a
grant, and a membership replay that was cut or held an undecodable event.

Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Logan Johnson <[email protected]>
Reverts b604305, 0284b50, 90d9dc5 and 592dd4e.

Those commits let a batch re-sent for a grant (44100) report its other
channels continuous, and stopped a reconnect's membership replay from
re-granting a channel for a grant the dropped connection had acted on.
Each one saved reads near a grant, and each added route state (granted,
grants, the grant watermark, cursor resets). Two of them carried a P2
where a channel skipped a catch-up it needed.

A grant's re-send is live-only again, so it reports no channel
continuous, and a replayed grant re-sends the batch. A reconnect within
about 16 minutes of a join, add, create or DM open catches up that batch
as it does on main. The undecodable-event repair of 2796e9b stays.

Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Logan Johnson <[email protected]>
A grant that arrived while a batch's first reconnect REQ waited to retry did
nothing, so the retry replayed from the channel's old cursor and reported the
channel continuous. That cursor came from a stream the relay may have left the
channel out of, so the session skipped a catch-up it needed.

A grant now takes its channel out of the resumed set of every route that is not
live yet, so that channel catches up at the route's EOSE.

Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Logan Johnson <[email protected]>
…m holds it

Taking a granted channel out of one route's resumed set did not survive the
route being rebuilt: add() rebuilt the set from the channel's unchanged cursor,
and a reconnect restored it too. Once the grant left the membership replay,
nothing invalidated it again, so the channel resumed and skipped its catch-up.

The cursor owner now holds the obligation. A grant deletes the channel's cursor
and records the grant; no connection sets that cursor again until a REQ sent
after the grant reaches EOSE.

Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Logan Johnson <[email protected]>
A batch's EOSE cleared a channel's grant debt when its REQ's send time was not
earlier than the grant. A grant in the same millisecond as an in-flight REQ, or
after a clock rollback, then cleared at that REQ's EOSE although the REQ
predates the grant. A drop before the re-send's EOSE stamped the channel's
cursor from that predecessor, and the next connection resumed the channel.

A grant on an in-flight route already marks it regranted, so only an EOSE with
no such grant clears the debt; the re-send's EOSE clears it otherwise.

Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Logan Johnson <[email protected]>
A planned renewal's carried batch stays quiet when its replay ends
below the limit, as it did before this change, even for a channel
granted before its REQ.

Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Logan Johnson <[email protected]>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Logan Johnson <[email protected]>
…ch-up

A batch over 10 channels replays through history routes, so its live-only
EOSE reported nothing continuous and the skip never applied at the
production batch size. Each such channel now establishes when its history
replay ends, with that replay's continuity; a failed or stopped replay
establishes it at once to catch up, and a drop before the replay ends keeps
the older cursor.

A continuous replay starts 16 minutes before the drop, so a live frame the
relay dropped earlier is not in it. A window now skips the reconnect read
only when a head read started at most 15 minutes before the drop; the live
batch refresh re-reads heads about that often.

Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Logan Johnson <[email protected]>
… owed

A seeded model of the relay (outages, restarts, planned renewals, CLOSED,
grants and revokes, replies, bursts, and disruptions injected while a replay
is in flight) found three ways a channel was reported continuous while it
could have missed an event:

- A grant during a large batch's history replay re-sends the batch
  live-only, but the replay's end still reported the batch's other channels
  continuous. The grant now ends their continuity.
- A batch that failed during its history replay was reported continuous at
  the replay's end with nothing streaming it. Such a channel now establishes
  at the batch's next EOSE, with nothing continuous.
- A planned renewal's carried batch stayed quiet even when its replay carried
  a reply. It now establishes, so the session reads what it owes.

A grant that reaches the broker after a batch's EOSE (the relay left a
revoked channel out with no frame) retracts that batch's claim with a later
report. resume() dropped the rows the window held at the drop, so that
catch-up stopped at the head page; it now keeps them until the next drop.

The model test checks that a channel reported continuous holds every stored
message, is streamed, and replayed no reply. A claim that missed a message
passes only while the channel's grant is still in flight, and must be
retracted before the run ends.

Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Logan Johnson <[email protected]>
A resumed window reported again with nothing continuous owes a catch-up
from the rows it held at the drop. A second drop before that catch-up
settled took a new snapshot, so the next catch-up could stop at a newer
live row above the gap. Queueing a catch-up now ends the resume, so the
next drop keeps the first snapshot.

Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Logan Johnson <[email protected]>
…om the report's rows

A window's resume age now comes from the head read whose rows it took,
not any head read of the channel (a hover prefetch counted). Thread
views get the same age rule. A report with nothing continuous takes the
rows the window holds then as its catch-up target, instead of a resume
keeping the drop's rows: a routine batch re-send after a resume reads
only the head again.

Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Logan Johnson <[email protected]>
…ight path too

A grant that reaches the broker while the batch's REQ is in flight
re-sends the batch at its EOSE. That re-send now strips the batch's
channels from running history replays, as the live path's re-send
already did, through one shared helper.

Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Logan Johnson <[email protected]>
The browser broker writes live events at once but can hold an established
report under backpressure, so a live row from a grant's re-sent batch can
reach the window before that batch's report. The report-time snapshot then
held a row newer than the gap the relay left while access was gone, and the
catch-up stopped above it.

The session now tells the store when a 44100 grant for a channel arrives,
before anything the re-sent batch delivers. The window keeps the rows it
holds then; a continuous report does not resume it until a catch-up takes
them, and the next catch-up (or the next drop's) pages back to them. A report
with no grant since still takes its current rows, so a routine re-send after
a continuous resume reads only the head.

The stale-thread test now returns the dropped reply and asserts it.

Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Logan Johnson <[email protected]>
A report queued behind a catch-up already running took the grant's rows at
once. That older catch-up may have read its head before the grant; when it
reached its own kept rows it cleared them, and the queued catch-up then
started from the current rows, which include the re-sent batch's first row.

The grant's rows now stay owed until a catch-up starts and takes them.

Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Logan Johnson <[email protected]>
@loganj
loganj force-pushed the larry/reconnect-no-duplicate-catchup branch from c123327 to a20c396 Compare November 7, 2026 01:48

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

🤖 Star Lord’s automated source-review follow-up via Wes’s account, with independent Rocket, Mantis and Groot coverage.

No new actionable findings in this focused rebase/integration review. All 23 feature patches match the previously reviewed series at c123327d; the repaired findings and coverage from review 5808873831 remain applicable.

  • First-channel creation uses the existing signed-roster admission and live-interest paths. The new anyChannel() existence read does not admit events, refresh a window’s read age or supply continuity evidence, so opening the new channel still requires its normal head read.
  • Cached history remains visible without enabling older reads offline. The cached-to-online regression holds the new head response across the session handoff and requires verification before exactly one older-page request (ChannelTimeline.test.tsx:1453–1535).
  • The base’s omitted-preview denial reaches the existing access/version and window-generation fences. It does not bypass catch-up cancellation or the authorization checks on resume. The grant boundary still survives an older fill until a subsequent fill starts.

Reviewed head: a20c396beba7fd93f5736447cccf40747e6eff58
Target and merge-base: 7817418db9d390654c4c21257a79b5ee079664f8

Source-only, retaining earlier coverage rather than reviewing unrelated base changes. Inspected the public description and commit text; no attached images were present. Tests were read, not run. No builds, apps, native/live-relay workflows, rendered focus/accessibility checks or performance measurements were executed, and CI was not assessed. Other reviewers’ runtime and CI claims were not independently verified. Prior relay filtering, silent-frame/backdating, deleted-channel and deployment limitations remain; the model still uses ideal catch-up. COMMENT only, not approval or runtime acceptance.

@loganj
loganj merged commit 46185a9 into main Nov 7, 2026
144 of 145 checks passed
@loganj
loganj deleted the larry/reconnect-no-duplicate-catchup branch November 7, 2026 02:10
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.

5 participants