Skip to content

fix: deliver a Discord member viewport chosen while offline after the session resumes - #50

Merged
itz4blitz merged 2 commits into
mainfrom
fix/discord-resume-member-subscription
Oct 20, 2026
Merged

itz4blitz merged 2 commits into
mainfrom
fix/discord-resume-member-subscription

Conversation

@itz4blitz

@itz4blitz itz4blitz commented Oct 18, 2026 •

Copy link
Copy Markdown
Owner

Cause

members.rs::subscribe records Watched.sent when a subscription is queued, not when it is written. gateway.rs::run cleared the whole outbox on every new connection, so a subscription queued while offline, or still unwritten when the socket dropped, was thrown away. Because sent already named it, later identical Watches were deduplicated, and nothing on the resumed session said it again. The session resumed with Discord still holding the old viewport, and the member pane stayed pending.

Fix

Rebased onto main, on top of #52's pacing (the send queue owns the rolling minute) and #51's watch/place split. The whole change is now in gateway.rs's SendQueue:

  • SendQueue::reconnected(resuming): a connection that resumes the session keeps every subscription change Discord may not have heard, merged into one frame: the ones never written, and the ones written since the last heartbeat Discord answered. A frame written as the socket died may never have arrived; Discord reads in order, so a heartbeat ack means everything written before that heartbeat was heard. Commands are dropped as before, and a connection that identifies a new session still starts empty.
  • Every new connection starts a fresh window. Discord's 120 frames a minute are per connection, so fix: pace Discord member subscriptions so a scroll cannot spend the Gateway budget #52's sent minute and per-guild subscribed times are not carried over. Before this, a resume after 80 subscription frames held the kept snapshot back 59 s, after 100 commands the next command 59 s, and after an 8-frame scroll the snapshot 733 ms. After a suspend it would be worse, since Instant does not advance during sleep on macOS and Linux.
  • The heartbeat send calls beat() and op 11 calls answered() to track what has been heard. They only move entries between two small maps keyed by guild, newest state per guild.

Changes from the first version of this PR:

  • RESUMED no longer calls a separate resubscribe(). That re-sent only the guild on screen, so if {A: off, B: on} was written as the socket died and never arrived, nothing re-sent A's unsubscribe, and A stayed subscribed for the session. The queue now re-sends what was not heard, including that unsubscribe. members.rs is unchanged from main.
  • The unit test uses fix: pace Discord member subscriptions so a scroll cannot spend the Gateway budget #52's pop(now) (the first version called pop() and no longer built against main).

Nothing changes for READY or identify.

Validation

  • Integration test member_viewport_changed_while_offline_is_delivered_after_resume in crates/chatd/tests/discord.rs, not ignored. It subscribes to 4101, drops the socket, sends a Watch for 4105 while offline, waits for the resume, then asserts that an op 37 for 4105 arrives after the op 6 resume frame and that the member cache fills from a SYNC. It fails when every connection clears the queue (main's behaviour) and passes with this change. Run 6 times with fix: subscribe the Discord member viewport a Watch chose before READY #51's member_viewport_selected_before_ready_is_subscribed_after_connect: 6/6 passed.
  • Unit test a_resumed_connection_keeps_unheard_subscriptions_and_a_new_session_does_not. Changes heard before an answered heartbeat are not sent again. Changes written on either side of an unanswered heartbeat, plus the unwritten ones, go as one frame with the newest state per guild, including an earlier guild's unsubscribe. Commands are not carried. A new session keeps nothing.
  • Unit test a_resumed_connection_starts_its_own_minute, on fix: pace Discord member subscriptions so a scroll cannot spend the Gateway budget #52's test clock. The old connection spends its whole allowance (80 subscription frames, then commands up to 100), and the snapshot waiting behind them is due more than 30 s out. After reconnected(true) that snapshot goes at once with nothing else, commands are not carried, a new command goes at once, and the new connection has its own burst of 8.
  • Both unit tests fail with the old reconnected (only clearing commands) and pass with this one.
  • cargo test -p chatd --lib gateway: 6 passed. cargo test -p chatd --test discord: 32 passed.
  • cargo test -p chatd: 294 passed, 0 failed, 0 ignored (138 lib unit tests, plus blobs 14, calls 10, discord 32, e2e 25, messages 5, playback 4, polish 12, signal 10, slack 15, teams 7, telegram 17, upload 5).
  • cargo clippy -p chatd --all-targets: clean. A second commit turns the while beat.ready loop in gateway.rs::run (from fix: pace Discord member subscriptions so a scroll cannot spend the Gateway budget #52) into an if around a loop, because clippy denied it as while_immutable_condition and stopped a plain clippy run on main. It sends the same frames, and the queue's lock is still released before each write.

Fixes #42

🤖 Generated with Claude Code

@itz4blitz

Copy link
Copy Markdown
Owner Author

@vbmithr this one fixes the queue-loss case you reproduced in #42.

What changed:

  • gateway.rs: run now calls SendQueue::reconnected(resuming) instead of clear(). When the connection resumes the existing session, member subscriptions that were never written to the socket stay queued, including a guild unsubscribe. Only commands are dropped. A connection that identifies a new session still clears everything, as before.
  • members.rs / ready.rs: on RESUMED, a new resubscribe() (subscribe(true)) sends the current viewport once more. That covers a frame written just as the socket died that never arrived. It costs one frame per resume.

Tests:

  • member_viewport_changed_while_offline_is_delivered_after_resume in crates/chatd/tests/discord.rs. It runs in the normal suite, not ignored, and follows your steps: subscribe to channel A, drop the socket, wait for Offline, select channel B while offline, let the session resume. It then asserts that B's subscription arrives after the resume frame and that its member cache fills. It fails without the source change.
  • Unit test a_resumed_connection_keeps_unwritten_subscriptions_and_a_new_session_does_not.

Not covered: a guild unsubscribe that was already written but lost with the socket isn't sent again. Only the current subscription is repeated on resume. Fixing that would mean tracking per guild what Discord actually received.

To try it:

git fetch origin pull/50/head:pr-50 && git checkout pr-50
cargo test -p chatd --test discord member_viewport_changed_while_offline
cargo test -p chatd adapters::discord::gateway
cargo test -p chatd                                        # full daemon suite

If you still have your local audit test, running it against this branch would be a useful second check. So would a real suspend/resume with the member pane open.

… session resumes

A new gateway connection cleared the whole send queue, so a subscription queued while the socket was down was lost while Watched.sent already named it, and nothing said it again on the resumed session. A resumed connection now keeps every subscription change Discord may not have heard: those never written, and those written since the last heartbeat Discord answered, since a frame written as the socket died may never have arrived. They go as one frame once RESUMED comes. Commands are still dropped, and a new session still starts empty. Every connection also starts a minute of its own, as Discord counts the allowance per connection, so the old connection's sends, or a clock that stood still through a suspend, never hold the kept frame back.

Fixes #42

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@itz4blitz
itz4blitz force-pushed the fix/discord-resume-member-subscription branch from 2bdc2e4 to c7fa3cb Compare October 20, 2026 13:32
…lippy passes

The `while beat.ready` loop added with the send pacing never changes its condition and ends
only on the queue's `break`, which clippy denies as `while_immutable_condition`. An `if` around
a `loop` sends the same frames and still lets the queue go before each write.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@itz4blitz
itz4blitz merged commit e3c325c into main Oct 20, 2026
11 of 12 checks passed
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.

Reconcile queued Discord member subscriptions after gateway session resume

1 participant