Skip to content

Prevent stale Cloud agent-chat reconnects - #15920

Merged
teamleaderleo merged 3 commits into
mainfrom
fix/cloud-reconnect-single-flight
Sep 30, 2026
Merged

teamleaderleo merged 3 commits into
mainfrom
fix/cloud-reconnect-single-flight

Conversation

@teamleaderleo

@teamleaderleo teamleaderleo commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

Cloud agent chat could leave a reconnect timer or stale WebSocket alive after the view was disposed. A delayed retry could reopen the chat after teardown, and callbacks from an older socket could overwrite the current connection's history or state.

The connection lifecycle is now owned by a small helper that cancels retries during cleanup, clears the active socket as soon as it closes, and gates open/message/close callbacks to the current mounted socket. The regression covers disposal with a queued retry, replacement sockets, stale callbacks, duplicate close events, and idempotent cleanup.

Changelog

  • Fixed: prevent stale Cloud agent-chat reconnects from resurrecting or replacing a session.

Validation:

  • bun run check from agent-chat
  • bun test/connection.test.ts
  • git diff --check

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.


Summary by cubic

Prevents stale Cloud agent-chat reconnects from resurrecting or replacing a session after the view is disposed. A delayed retry could reopen the chat after teardown, and callbacks from an older socket could overwrite the current connection's history or state.

The connection lifecycle now lives in a small helper that cancels retries during cleanup, clears the active socket as soon as it closes, and gates open/message/close callbacks to the current mounted socket. It also re-arms the retry when the socket constructor throws (avoiding a stranded view with no socket and no pending timer) and detaches handlers before closing, so a disposed view isn't retained until the close handshake finishes. A regression test covers disposal with a queued retry, replacement sockets, stale callbacks, duplicate close events, constructor throws, handler detachment, and idempotent cleanup.

Written for commit fe10853. Summary will update on new commits.

Review in cubic

@coderabbitai

coderabbitai Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 3 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Repository: manaflow-ai/cmux/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: bb9ce35b-f173-45ef-9fbe-757a4aba32b9

📥 Commits

Reviewing files that changed from the base of the PR and between 6d2b5d1 and fe10853.

📒 Files selected for processing (3)
  • agent-chat/src/connection.ts
  • agent-chat/src/session.ts
  • agent-chat/test/connection.test.ts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

…anup

A review of this branch found two ways the connection helper can strand or
retain a view, both of which this now covers.

A throw from `createSocket()` was unhandled. The retry timer is the only thing
that calls `connect()`, and `connect()` clears `retry` before constructing, so
a throw inside the timer left no socket and no pending timer: nothing remained
to reconnect. `new WebSocket()` throws on a bad URL and on an opaque origin,
and agent-chat renders inside a workspace tab, so the view would sit at
`ready` with every send silently returning false and no way back. The throw is
caught and re-arms the retry, which is the behaviour the PR already claimed.

Cleanup closed the socket without detaching its handlers. They capture the
whole `useSession` closure graph, every setState and every ref, and the browser
keeps the socket alive until the close handshake finishes, which a server that
never answers stretches to a TCP timeout. The identity gates would ignore the
callbacks anyway, so nothing was holding that state except the disposed
socket. They are nulled before `close()` now.

Test changes, all confirmed to fail against the previous source:
- The disposed socket's handlers are null after cleanup, and it is closed
  exactly once across two `disconnect()` calls.
- A constructor that throws once publishes no socket, arms a retry, and the
  retry connects.
- The fake timer records delays instead of throwing on an unexpected one, and
  the 800 ms constant is asserted once at the end. Whoever adds backoff should
  get a named assertion, not a thrown string from a timer factory.
- Dropped two assertions from the first block. `disposed.sockets.length` could
  not fail there, because `disconnect()` had already cleared the timer, so
  there was nothing for `flushTimers()` to drain and the comment claiming
  otherwise was wrong. `disposed.current()` could not fail either, since the
  close had already pushed null through `onSocket`. Both properties are
  genuinely covered by the queued-retry and mounted blocks.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

Review subagent on 0a3f36bca4a, then fixes pushed as fe10853109.

Review. It verified the core claim properly rather than reading the diff. The single-socket and single-timer invariant does hold: connect() has exactly two call sites, a retry is only ever armed from the current socket's onclose, and that handler is identity-gated and nulls socket before arming, so at most one socket and one pending timer exist at any instant and two reconnects cannot race. Close-during-connect is safe because cleanup nulls socket before ws.close(), so a CONNECTING socket's abort-close is rejected twice over. Connect-during-close is unreachable, since there is no await between createSocket() and installing the handlers. It also confirmed the bug being fixed is not theoretical: without isCurrent(), a stale socket's history frame reaches a wholesale setBlocks(...reduce(foldEvent, [])), replacing the transcript. And it checked that leaving onerror unset costs nothing, because the spec fires error then close, so a failed handshake always reaches onclose.

It then found two ways to strand or retain a view, and I agree with both.

Fixed.

A throw from createSocket() was unhandled, and that is the one failure that strands the view permanently. The retry timer is the only thing that calls connect(), and connect() clears retry before constructing, so a throw inside the timer left no socket and no pending timer, with nothing remaining to reconnect. new WebSocket() throws on a bad URL and on an opaque origin, and agent-chat renders inside a workspace tab, so the result is a view sitting at ready with every send silently returning false. Caught now, and it re-arms the retry, which is the property the description already claimed.

Cleanup closed the socket without detaching its handlers. They capture the whole useSession closure graph, and the browser keeps the socket alive until the close handshake finishes, which a server that never answers stretches to a TCP timeout. The gates would ignore the callbacks anyway, so the only thing holding that state was the disposed socket. Nulled before close().

It also went through the new test block by block and told me which assertions had no power, which is what I asked for. Two in the first block could not fail: disposed.sockets.length because disconnect() had already cleared the timer, so flushTimers() had nothing to drain and the comment claiming otherwise was wrong, and disposed.current() because the close had already pushed null through onSocket. Both properties are genuinely covered by the queued-retry and mounted blocks, so I dropped the dead pair rather than leave assertions that read like coverage. The doubled disconnect() was only observed as "does not throw", so it now asserts the socket is closed exactly once. The fake timer records delays instead of throwing on an unexpected one, and the 800 ms constant is asserted once at the end, so whoever adds backoff gets a named assertion instead of a thrown string from a timer factory.

Three new or strengthened assertions, each confirmed to fail against the previous source and pass now.

Left, deliberately, both pre-existing and unchanged by this diff.

onOpen re-sends start with the same requestId after a reconnect. If the first start reached the server before the socket died, the server may already have a session for that id, and nothing client-side dedupes the second. This diff reduces the blast radius rather than causing it, since previously a stale socket's onopen could fire the same path.

ready is never set back to false, so the reconnect gap renders as a live chat and Stop does nothing silently, because most callers ignore sendRaw's return. Behaviourally identical to before: the old code left the closed socket in wsRef, which sendRaw's readyState === OPEN check rejected the same way. The new onSocket(null) callback is the obvious hook for surfacing a reconnecting state, and that is worth a separate change with its own UI decision.

There is also no backoff or jitter, so a down server is polled at 1.25 Hz forever by every open tab. Pre-existing, and not something to slip into a correctness fix.

Fixes skip team review, so this merges on green.

— Raindrop g2 🫧 / Run: run_worker_20260930_3fc64ba6

@teamleaderleo
teamleaderleo merged commit 747aa96 into main Sep 30, 2026
57 checks passed
@teamleaderleo
teamleaderleo deleted the fix/cloud-reconnect-single-flight branch September 30, 2026 10:39
@github-actions

Copy link
Copy Markdown
Contributor

Merge receipt for fe10853109: every check was green at merge (12 verified; 16 skipped by policy). Full suite runs on main after merge.

rustybret pushed a commit to rustybret/bmux that referenced this pull request Sep 30, 2026
5eda931 fix(ios): keep auth operations from missing token store (manaflow-ai#14302)
4d9bec3 fix: restore per-label Blacksmith macOS capacity
572beb6 fix: preserve non-transient cooldown reasons (manaflow-ai#15885)
747aa96 Prevent stale Cloud agent-chat reconnects (manaflow-ai#15920)
33ad0b1 Hibernate only agents whose wake can relaunch the original launcher (manaflow-ai#15287)
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.

1 participant