Skip to content

Fix stale remote connected state after proxy disconnect - #4513

Merged
austinywang merged 26 commits into
mainfrom
issue-4509-remote-proxy-stale-connected
Jun 14, 2026
Merged

austinywang merged 26 commits into
mainfrom
issue-4509-remote-proxy-stale-connected

Conversation

@austinywang

@austinywang austinywang commented May 22, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #4509

Data-flow map

cmux vm ... / remote launch configures a WorkspaceRemoteConfiguration on the workspace. Workspace.configureRemoteConnection stores that config, seeds the active remote terminal surface, and starts WorkspaceRemoteSessionController when there is a daemon/proxy transport to observe.

Before this change, the sidebar read Workspace.remoteConnectionState, but that state was mostly driven by initial proxy/daemon startup success. The terminal lifecycle was separate: when the remote PTY process died, TabManager.closePanelAfterChildExited closed the panel and Workspace.createReplacementTerminalPanel spawned a local shell banner fallback. That let the visible terminal become local while remoteConnectionState stayed connected.

Now the shared signal is:

remote daemon WebSocket close / keepalive failure
remote terminal child exit
vm-pty WebSocket bridge ping failure
        |
        v
Workspace.remoteConnectionState + activeRemoteTerminalSurfaceIds
        |
        +--> sidebar remote row/status
        +--> browser remote status payload
        +--> terminal replacement behavior

Repro Used

I could not create a real Freestyle VM in this dev app because the tagged build reported Not signed in for cmux auth status. I used the equivalent forced-disconnect path in the running tagged app:

  1. Built and launched with CMUX_SKIP_ZIG_BUILD=1 ./scripts/reload.sh --tag issue-4509-remote-proxy-stale-connected --launch.
  2. Launched the same tagged app through LaunchServices after the wrapper's direct launcher left a stale socket file.
  3. Created a workspace and configured it through workspace.remote.configure as a WebSocket remote target.
  4. Confirmed workspace.remote.status reported state=connected, connected=true, active_terminal_sessions=1.
  5. Sent exit to the tracked terminal surface to simulate the remote PTY child dying.
  6. Confirmed workspace.remote.status reported state=disconnected, connected=false, active_terminal_sessions=0, and detail Remote terminal session disconnected.
  7. Confirmed the terminal displayed the reconnect placeholder instead of a local austinwang@mac ~ % prompt.

Architectural Change

  • TabManager.closePanelAfterChildExited now notifies the workspace when a tracked remote terminal child exits, so terminal process death is an input to the remote state machine.
  • Workspace.markRemoteTerminalSessionEnded now handles WebSocket/VM remote terminals that do not have an SSH relay port, transitions the workspace to disconnected, preserves the remote configuration for reconnect, and clears active remote terminal session state.
  • The old replacement local shell fallback is replaced with a visible disconnected placeholder: Press Enter to reconnect...; it optionally invokes workspace.remote.reconnect and then reruns the original remote startup command.
  • WebSocket daemon/proxy errors are no longer preserved as connected for WebSocket VM workspaces; that preservation is limited to SSH proxy-only retry cases while the SSH terminal is still alive.
  • The daemon WebSocket RPC client and vm-pty-connect bridge now send 5-second WebSocket pings so dead sleep/wake connections are detected without waiting for user input.
  • The sidebar remote row now renders for any configured remote workspace, not only while an active remote terminal session exists, so it can visibly show Disconnected after a remote PTY death.

Screenshots

Captured from the tagged dev app during the forced-disconnect verification:

  • Before forced disconnect, sidebar row shows Connected: /tmp/cmux-issue-4509-final-before.png
  • After forced disconnect, sidebar row shows Disconnected and the terminal shows the reconnect placeholder: /tmp/cmux-issue-4509-final-after.png

Test Approach

  • Added a regression test that drives a WebSocket remote workspace through a remote terminal session end with no relay port and asserts the workspace leaves connected within a bounded time.
  • Added coverage for WebSocket daemon transport errors clearing connected sidebar state.
  • Updated existing remote terminal child-exit tests to assert the workspace remains remote-configured but disconnected, instead of demoting to local.

Focused verification run:

CMUX_SKIP_ZIG_BUILD=1 ./scripts/test-unit.sh \
  -only-testing:cmuxTests/WorkspaceRemoteConnectionTests/testWebSocketRemoteTerminalEndLeavesConnectedStateWithinBoundedTime \
  -only-testing:cmuxTests/WorkspaceRemoteConnectionTests/testWebSocketDaemonTransportErrorClearsConnectedSidebarState \
  -only-testing:cmuxTests/TabManagerChildExitCloseTests/testChildExitOnLastRemotePanelKeepsWorkspaceDisconnected \
  test

Result: passed.

Also ran:

CMUX_SKIP_ZIG_BUILD=1 ./scripts/reload.sh --tag issue-4509-remote-proxy-stale-connected --launch

View with Codesmith Autofix with Codesmith
Need help on this PR? Tag @codesmith with what you need. Autofix is disabled.


Note

Medium Risk
Changes core remote workspace lifecycle, terminal replacement, and WebSocket teardown paths that users rely on for SSH/VM sessions; well-covered by tests but behavior shifts are broad.

Overview
Fixes stale Connected remote UI when the VM/SSH PTY or daemon WebSocket dies without tearing down the workspace config.

Remote state machine: Terminal child exit, pane/tab close, and relay callbacks now drive markRemoteTerminalSessionEnded, which clears active PTY tracking, sets disconnected (with a stable detail string), and can still run SSH control-master cleanup. WebSocket/VM paths no longer require a relay port; “stay connected” on proxy-only errors is limited to SSH while a live SSH terminal remains.

Terminal UX: Replacing the last panel no longer execs a local login shell. It launches a disconnect placeholder that shows a banner and Press Enter to reconnect (optional workspace.remote.reconnect RPC + original startup command). Closing the last remote tab/pane preserves/refreshes that placeholder.

Liveness: 5s WebSocket pings with timeout teardown were added to VMPtyWebSocketBridge and the workspace daemon RPC client so sleep/wake dead connections surface as disconnects.

UI/copy: Sidebar remote row shows for any configured remote workspace (isRemoteWorkspace), with SSH-specific strings broadened to Remote and expanded Localizable.xcstrings entries for status/help/disconnect banners.

Tests: Child-exit and remote-connection tests now expect remote-configured + disconnected instead of demotion to local; new WebSocket session-end and daemon error coverage.

Reviewed by Cursor Bugbot for commit ca8cc16. Bugbot is set up for automated code reviews on this repo. Configure here.


Summary by cubic

Fixes #4509 by clearing stale “Connected” when the VM/SSH PTY or daemon WebSocket drops. The workspace stays Remote, shows Disconnected, and terminals show a reconnect prompt that persists across reconnects and tab/pane closes.

  • Bug Fixes

    • Drive Workspace.remoteConnectionState from daemon WebSocket close/keepalive timeouts, VM‑PTY ping failures, terminal child exits (tracked/untracked), and manual tab/pane/terminal close.
    • Keep remote workspaces marked and Disconnected when the last remote terminal ends or is closed; preserve and refresh the reconnect placeholder on last tab/pane close; preserve it while reconnecting; skip placeholder panels during reconnect seed.
    • Clear “Connected” on VM/WebSocket errors; preserve it only for SSH proxy‑only retries while a live SSH terminal exists.
    • Sidebar shows for any remote workspace with unified “Remote” copy and fallback (“Remote workspace”), with broader locale coverage.
  • New Features

    • 5s WebSocket keepalives with timeout handling in the daemon RPC client and VMPtyWebSocketBridge to detect dead connections quickly.
    • Replace the local shell fallback with “Press Enter to reconnect…” that runs workspace.remote.reconnect and re‑runs the startup command; the RPC validates and accepts an optional surface_id to reconnect in place, and the placeholder is consumed once to avoid duplicates.

Written for commit 736ed37. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features

    • WebSocket keepalive pings for more reliable remote connections.
    • Reconnect-capable disconnect placeholder with in-terminal "Press Enter to reconnect…" prompt.
  • Bug Fixes

    • Unified UI wording from “SSH” to “Remote” across sidebar/status in many locales.
    • Workspaces remain marked as remote but show "disconnected" when terminal sessions end.
  • Tests

    • Updated and added tests for remote session end, WebSocket liveness, and reconnect flows.

Review Change Stack

@vercel

vercel Bot commented May 22, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Jun 15, 2026 12:34am
cmux-staging Building Building Preview, Comment Jun 15, 2026 12:34am

@coderabbitai

coderabbitai Bot commented May 22, 2026 •

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds WebSocket keepalives at CLI and RPC levels, a reconnect-capable terminal placeholder on remote-session end, preserves remote workspace state as disconnected (not local), updates UI/localization from “SSH” → “Remote”, and extends tests to validate the new disconnect/reconnect flows.

Changes

Remote Disconnect Detection and Reconnect Flow

Layer / File(s) Summary
WebSocket keepalive (CLI + RPC)
CLI/cmux.swift, Sources/Workspace.swift
CLI bridge and RPC client add repeating keepalive timers, send periodic pings, track in-flight pings, and cancel the WebSocket on ping timeout/error.
Disconnect placeholder and replacement terminal
Sources/Workspace.swift
Introduce PendingRemoteDisconnectReplacement and remoteDisconnectPlaceholderScript (base64 messages, ANSI coloring, optional reconnect command). createReplacementTerminalPanel() consumes and uses the placeholder as the replacement PTY command.
Session end lifecycle and state preservation
Sources/Workspace.swift
markRemoteTerminalSessionEnded arms placeholder only when the ended terminal matches current remote config, guards clearing remote config while placeholder is armed, clears pending placeholder on close flows, and ties proxy-preservation to SSH terminal liveness.
Panel lifecycle & TabManager integration
Sources/TabManager.swift, Sources/Workspace+PanelLifecycle.swift
closePanelAfterChildExited and panel-close flows detect remote terminal surfaces and call the session-closing/ended helpers before normal teardown.
UI text and localization updates
Sources/ContentView.swift, Resources/Localizable.xcstrings
Sidebar shows generic “Remote workspace” for remote tabs; help/default strings switched from “SSH …” to “Remote …”. Localization resources updated across many locales (connected/connecting/reconnecting/disconnected/terminalDisconnected and reconnect hints).
Unit and integration tests
cmuxTests/TabManagerUnitTests.swift, cmuxTests/WorkspaceRemoteConnectionTests.swift
TabManager tests updated to expect workspace remains remote but .disconnected. New/updated WorkspaceRemoteConnection tests validate WebSocket-driven transitions from connected→disconnected within a bounded time, control-master cleanup assertion, duplicate-relay guarding, and transport-error behavior clearing the sidebar “connected” flag.

Sequence Diagram

sequenceDiagram
  participant UI as ContentView/Sidebar
  participant RPC as WorkspaceRemoteDaemonRPCClient
  participant CLI as VMPtyWebSocketBridge
  participant WS as URLSessionWebSocketTask

  UI->>RPC: observe remoteStatusPayload
  RPC->>WS: open transport
  RPC->>RPC: start RPC keepalive timer
  CLI->>CLI: start bridge keepalive timer (on receive loop)
  loop periodic keepalive
    CLI->>WS: send ping
    alt pong received
      WS-->>CLI: pong
      CLI-->>RPC: confirm alive
    else timeout or error
      CLI->>WS: cancel WebSocket (goingAway)
      CLI->>RPC: mark bridge stopped
      RPC->>UI: update sidebar to disconnected
    end
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 A rabbit taps the websocket bell,
Sends tiny pings to guard the shell—
When proxies nap and sessions fall,
A friendly placeholder answers the call. 🥕✨


Important

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

❌ Failed checks (2 errors, 1 warning, 3 inconclusive)

Check name Status Explanation Resolution
Cmux Swift Blocking Runtime ❌ Error PR introduces asyncAfter keepalive timeouts in CMux terminal/socket paths violating the Swift blocking runtime rule against delayed dispatch in latency-sensitive code. Replace asyncAfter-based timeout handling with real signal mechanisms rather than scheduling delayed work items for keepalive ping timeouts in both CLI/cmux.swift and Sources/Workspace.swift.
Cmux Full Internationalization ❌ Error PR adds 10+ new user-facing hardcoded strings ("Connecting to", "Reconnecting to", "Bootstrapping remote daemon", error/notification titles) displayed in sidebar and errors, without localization. Localize new user-facing strings in Workspace.swift with String(localized:defaultValue:) and add translations for all 20 locales in Resources/Localizable.xcstrings.
Docstring Coverage ⚠️ Warning Docstring coverage is 8.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Cmux Swift @Concurrent ❓ Inconclusive No result was produced after verification. Marking as INCONCLUSIVE. Re-run the check or adjust instructions to produce a final result.
Cmux User-Facing Error Privacy ❓ Inconclusive No result was produced after verification. Marking as INCONCLUSIVE. Re-run the check or adjust instructions to produce a final result.
Cmux Swiftui State Layout ❓ Inconclusive No result was produced after verification. Marking as INCONCLUSIVE. Re-run the check or adjust instructions to produce a final result.
✅ Passed checks (11 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Fix stale remote connected state after proxy disconnect' directly addresses the main issue #4509, clearly summarizing the primary bug fix (stale UI Connected state after proxy disconnect).
Linked Issues check ✅ Passed The PR successfully implements all objectives from issue #4509: drives remoteConnectionState from terminal/proxy signals, detects proxy death via WebSocket keepalives (~5s), transitions disconnected workspaces, shows visible placeholder, renders sidebar for configured remotes, and adds regression tests.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fix #4509: WebSocket keepalive in bridges/daemon client, remote terminal session-end handling, disconnected placeholder, sidebar rendering for configured remotes, and corresponding test updates. No out-of-scope changes detected.
Cmux Swift Actor Isolation ✅ Passed Production code maintains proper actor isolation via queue-based serialization in private classes and @MainActor on public classes. No violations found.
Cmux No Hacky Sleeps ✅ Passed setTimeout in web/scripts/build-cloud-vm-images.ts uses cancellation-aware abstractions with AbortSignal and tests, meeting allowed exception criteria in runtime-no-hacky-sleeps.md.
Cmux Swift Concurrency ✅ Passed DispatchSourceTimer and DispatchWorkItem for WebSocket keepalive are justified OS API boundary patterns (URLSessionWebSocketTask callbacks), not ordinary async work that should use async/await.
Cmux Swift File And Package Boundaries ✅ Passed PR adds 71-210 lines to existing oversized files for bug fix. No 250+ line additions to any file; no new files created. Fits allowed case: focused bug fix touching existing files incidentally.
Cmux Swift Logging ✅ Passed No Swift logging violations found. All NSLog statements are DEBUG-guarded; print() is CLI output (allowed); new code sections (keepalive, remote disconnect) contain no logging statements.
Cmux Architecture Rethink ✅ Passed Terminal lifecycle and WebSocket liveness drive remoteConnectionState with single Workspace ownership. Timing mechanisms are required platform bridges, not symptom patches.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed PR introduces no new NSWindow/NSPanel/NSWindowController or SwiftUI Window code. Changes are remote session lifecycle, WebSocket keepalive, and test updates. Lint script passed with no violations.
Description check ✅ Passed The pull request description includes all required sections: Summary (what changed and why), Testing (manual verification with repro steps and unit test approach), and Checklist items completed. The description is comprehensive and well-documented.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch issue-4509-remote-proxy-stale-connected

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 and usage tips.

@greptile-apps

greptile-apps Bot commented May 22, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a stale "Connected" remote state that persisted after a VM PTY or daemon WebSocket died without tearing down the workspace configuration. The fix wires terminal child-exit, pane/tab close, and daemon WebSocket keepalive failures into a unified markRemoteTerminalSessionEnded path that transitions the workspace to disconnected (preserving the remote config for reconnect) instead of silently demoting it to a local workspace.

  • State machine: markRemoteTerminalSessionEnded now routes on activeRemoteTerminalSurfaceIds and directly transitions to disconnected; "stay connected on proxy error" is limited to SSH workspaces with a live terminal. The local-shell fallback is replaced by a tracked disconnect placeholder with a "Press Enter to reconnect" prompt that invokes workspace.remote.reconnect with surface_id.
  • Liveness: 5-second WebSocket pings with DispatchWorkItem-based timeout teardown are added to VMPtyWebSocketBridge and RemoteDaemonRPCClient so dead sleep/wake connections surface as disconnects rather than silently stalling.
  • UI/L10n: Sidebar remote row renders for any configured remote workspace; SSH-specific copy is generalised to "Remote"; 19 locales receive full translations for all new and migrated keys.

Confidence Score: 5/5

Safe to merge; the core state-machine logic is well-tested and the behavioural changes are all intentional and verified.

The only finding is a style-level note about using asyncAfter for keepalive timeout in two production files. The pattern is correct and cancellation is handled properly via DispatchWorkItem.cancel(); the concern is purely about conformance to the project's blocking-runtime rule, not about correctness. The state machine overhaul, localization additions, and new test coverage are all solid.

No files require special attention.

Important Files Changed

Filename Overview
Sources/Workspace.swift Core state-machine overhaul: markRemoteTerminalSessionEnded now routes by activeRemoteTerminalSurfaceIds (not relay port alone), directly transitions to disconnected without clearing config, replaces the local-shell fallback with a tracked placeholder panel, and limits proxy-only "stay connected" to SSH workspaces with live terminals.
Sources/TabManager.swift Child-exit path now calls markRemoteTerminalSessionEnded before the close routing, correctly funnelling both SSH and WebSocket terminal exits through the workspace state machine.
Sources/Workspace+PanelLifecycle.swift Tab/pane close path now transitions to disconnected via markRemoteTerminalSessionClosingIfLast and refreshes the placeholder when the last placeholder panel is closed, preventing silent demotion to a local workspace.
CLI/cmux.swift Adds 5-second WebSocket keepalive to VMPtyWebSocketBridge using DispatchSourceTimer + asyncAfter timeout; cancellation is correct via DispatchWorkItem.cancel(), but asyncAfter violates the blocking-runtime rule in production code.
Packages/CmuxRemoteDaemon/Sources/CmuxRemoteDaemon/Client/RemoteDaemonRPCClient+Events.swift Adds WebSocket keepalive (start/stop/send) with DispatchSourceTimer + asyncAfter timeout; same rule concern as CLI/cmux.swift. Keepalive is correctly started on transport open and stopped on close/teardown.
Resources/Localizable.xcstrings Adds full 19-locale coverage for new keys and updates remote.disconnectBanner.reconnectHint; remaining locales for reconnectHint are correctly marked state: "new" (pre-existing untranslated slots).
Sources/ContentView.swift Sidebar remote row now renders for any isRemoteWorkspace instead of requiring active sessions; reconnect affordance extended to disconnected state; SSH copy strings replaced with generic Remote strings.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Daemon WebSocket close / keepalive timeout] --> D
    B[VM-PTY WebSocket ping failure] --> D
    C[Remote terminal child exit\nTabManager.closePanelAfterChildExited] --> E
    F[Tab / pane close\nWorkspace+PanelLifecycle] --> E
    E[markRemoteTerminalSessionClosingIfLast\nor markRemoteTerminalSessionEnded] --> D
    D[disconnectRemoteConnectionAfterTerminalExit\nclearConfiguration: false] --> G
    G{SSH transport?}
    G -- Yes, relayPort set --> H[requestSSHControlMasterCleanup]
    G -- WebSocket / no relay --> I[skip cleanup]
    H --> J[remoteConnectionState = .disconnected\nremoteConfiguration preserved]
    I --> J
    J --> K[createReplacementTerminalPanel\nwith remoteDisconnectPlaceholderScript]
    K --> L[Press Enter to reconnect placeholder]
    L --> M{User presses Enter}
    M -- reconnect RPC available --> N[workspace.remote.reconnect\nconfigureRemoteConnection autoConnect=true]
    M -- reconnect unavailable --> O[reconnectUnavailableHint banner]
    N --> P[remoteConnectionState = .connected]
Loading

Reviews (19): Last reviewed commit: "Merge remote-tracking branch 'origin/mai..." | Re-trigger Greptile

Comment thread Sources/Workspace.swift Outdated
Comment thread Resources/Localizable.xcstrings Outdated
Comment thread CLI/cmux.swift
Comment thread Sources/Workspace.swift
coderabbitai[bot]
coderabbitai Bot previously requested changes May 22, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
Resources/Localizable.xcstrings (1)

91185-91383: ⚠️ Potential issue | 🟠 Major

Fix incomplete locale coverage in Resources/Localizable.xcstrings.

These sidebar.remote.* entries (sidebar.remote.subtitleFallback, sidebar.remote.help.connected/connecting/reconnecting/error/errorWithDetail/disconnected/targetFallback) only include localizations for en, ja, uk, and ko, but Resources/Localizable.xcstrings also supports: ar, bs, da, de, es, fr, it, km, nb, pl, pt-BR, ru, th, tr, zh-Hans, zh-Hant. Add translated values for every supported locale for these updated keys.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Resources/Localizable.xcstrings` around lines 91185 - 91383, Several
sidebar.remote localization keys (sidebar.remote.subtitleFallback,
sidebar.remote.help.connected, sidebar.remote.help.connecting,
sidebar.remote.help.reconnecting, sidebar.remote.help.error,
sidebar.remote.help.errorWithDetail, sidebar.remote.help.disconnected,
sidebar.remote.help.targetFallback) only have en/ja/uk/ko entries; add missing
translations for all supported locales (ar, bs, da, de, es, fr, it, km, nb, pl,
pt-BR, ru, th, tr, zh-Hans, zh-Hant) so each key’s "localizations" object
includes those locales with a stringUnit { state: "translated", value:
"<translated text>" }, preserving extractionState: "manual" and matching
placeholder patterns (e.g., %@ and %@: %@) from the en strings.
Sources/Workspace.swift (1)

12646-12665: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Force waitAfterCommand on the disconnect placeholder panel.

This replacement panel gets an initialCommand, but unlike the other remote startup paths it never flips waitAfterCommand. When the placeholder script exits—or after it execs a reconnect command that later terminates—Ghostty can still fall through to a local shell.

Proposed fix
-        let inheritedConfig = inheritedTerminalConfig(
+        var replacementConfig = inheritedTerminalConfig(
             preferredPanelId: focusedPanelId,
             inPane: bonsplitController.focusedPaneId
         )
         let pendingRemoteDisconnect = pendingRemoteDisconnectReplacement
         pendingRemoteDisconnectReplacement = nil
         let replacementInitialCommand: String? = pendingRemoteDisconnect.map {
             Self.remoteDisconnectPlaceholderScript(
                 target: $0.target,
                 reconnectCommand: $0.reconnectCommand
             )
         }
+        if replacementInitialCommand != nil {
+            var config = replacementConfig ?? CmuxSurfaceConfigTemplate()
+            config.waitAfterCommand = true
+            replacementConfig = config
+        }
         let newPanel = TerminalPanel(
             workspaceId: id,
             context: GHOSTTY_SURFACE_CONTEXT_TAB,
-            configTemplate: inheritedConfig,
+            configTemplate: replacementConfig,
             portOrdinal: portOrdinal,
             initialCommand: replacementInitialCommand
         )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/Workspace.swift` around lines 12646 - 12665, The replacement terminal
panel created in createReplacementTerminalPanel() sets an initialCommand (via
pendingRemoteDisconnect -> remoteDisconnectPlaceholderScript) but doesn't set
waitAfterCommand, so the shell can fall through after the placeholder exits;
update the TerminalPanel initialization for newPanel to force waitAfterCommand =
true (or set the equivalent property on newPanel) so the placeholder script
blocks heredity until reconnect, ensuring the panel does not fall through to a
local shell after the placeholder exits.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@CLI/cmux.swift`:
- Around line 7948-7973: The keepalive logic in sendKeepalive() can take nearly
2× the interval to detect a lost connection because detection relies on the next
timer tick seeing keepaliveInFlight; change the flow so that when task.sendPing
is invoked you immediately schedule a per-ping timeout (instead of waiting for
the next keepaliveTimer tick) that will trigger the same failure path if
keepaliveInFlight remains true after the configured interval. Concretely, in
sendKeepalive() (referencing keepaliveInFlight, keepaliveTimer, task.sendPing,
markStopped, and task.cancel) set keepaliveInFlight = true, start/schedule a
one-shot Dispatch/Timer timeout for the desired interval that checks
keepaliveInFlight and calls debugEvent?("websocket.keepalive.timeout"),
markStopped(), and task.cancel(...) if still in-flight, and clear that per-ping
timeout when the sendPing completion runs and resets keepaliveInFlight.

In `@Resources/Localizable.xcstrings`:
- Around line 91095-91123: The localization key
remote.status.terminalDisconnected is missing entries for several supported
locales; add entries for zh-Hans, zh-Hant, de, es, fr, it, da, pl, ru, bs, ar,
nb, pt-BR, th, and tr in the Resources/Localizable.xcstrings block for that key,
each with "stringUnit": { "state": "new", "value": "Remote terminal session
disconnected" } so they follow the catalog's convention (use the exact key
remote.status.terminalDisconnected to locate the block and append the missing
locale objects).

In `@Sources/Workspace.swift`:
- Around line 14585-14588: The code currently drops the disconnect sentinel by
setting pendingRemoteDisconnectReplacement = nil when a remote terminal exits,
which allows clearRemoteConfigurationIfWorkspaceBecameLocal() to demote the
workspace later; instead remove this unconditional nil assignment and ensure
pendingRemoteDisconnectReplacement is only cleared in the explicit
remote-reconnect/clear code paths (e.g., in the workspace reconnection handler
and wherever remote configuration is explicitly cleared). Locate the assignment
to pendingRemoteDisconnectReplacement and delete it, and add/ensure logic in the
methods that handle explicit remote workspace clearing or successful
reconnection to clear pendingRemoteDisconnectReplacement so the sentinel
persists until an explicit action.
- Around line 12616-12628: The current block always execs
cmux_disconnect_reconnect_command even if the workspace.remote.reconnect RPC
fails; modify the logic around
cmux_reconnect_cli/cmux_reconnect_socket/CMUX_WORKSPACE_ID so you run the RPC
(workspace.remote.reconnect) and check its exit status (or output) and only exec
/bin/sh -lc "$cmux_disconnect_reconnect_command" when that RPC call succeeded;
if the RPC failed or was unavailable, skip the exec (or surface an error) so the
PTY isn't re-launched out-of-sync. Ensure you reference and gate on the existing
symbols cmux_disconnect_reconnect_command, cmux_reconnect_cli,
cmux_reconnect_socket and CMUX_WORKSPACE_ID and use the RPC command name
workspace.remote.reconnect to determine success before exec.

---

Outside diff comments:
In `@Resources/Localizable.xcstrings`:
- Around line 91185-91383: Several sidebar.remote localization keys
(sidebar.remote.subtitleFallback, sidebar.remote.help.connected,
sidebar.remote.help.connecting, sidebar.remote.help.reconnecting,
sidebar.remote.help.error, sidebar.remote.help.errorWithDetail,
sidebar.remote.help.disconnected, sidebar.remote.help.targetFallback) only have
en/ja/uk/ko entries; add missing translations for all supported locales (ar, bs,
da, de, es, fr, it, km, nb, pl, pt-BR, ru, th, tr, zh-Hans, zh-Hant) so each
key’s "localizations" object includes those locales with a stringUnit { state:
"translated", value: "<translated text>" }, preserving extractionState: "manual"
and matching placeholder patterns (e.g., %@ and %@: %@) from the en strings.

In `@Sources/Workspace.swift`:
- Around line 12646-12665: The replacement terminal panel created in
createReplacementTerminalPanel() sets an initialCommand (via
pendingRemoteDisconnect -> remoteDisconnectPlaceholderScript) but doesn't set
waitAfterCommand, so the shell can fall through after the placeholder exits;
update the TerminalPanel initialization for newPanel to force waitAfterCommand =
true (or set the equivalent property on newPanel) so the placeholder script
blocks heredity until reconnect, ensuring the panel does not fall through to a
local shell after the placeholder exits.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 8970634c-7db4-4229-9610-1cb430fdae60

📥 Commits

Reviewing files that changed from the base of the PR and between 2a621a7 and 226e09e.

📒 Files selected for processing (7)
  • CLI/cmux.swift
  • Resources/Localizable.xcstrings
  • Sources/ContentView.swift
  • Sources/TabManager.swift
  • Sources/Workspace.swift
  • cmuxTests/TabManagerUnitTests.swift
  • cmuxTests/WorkspaceRemoteConnectionTests.swift
👮 Files not reviewed due to content moderation or server errors (1)
  • Sources/ContentView.swift

Comment thread CLI/cmux.swift
Comment thread Resources/Localizable.xcstrings Outdated
Comment thread Sources/Workspace.swift Outdated
Comment thread Sources/Workspace.swift

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

1 issue found across 7 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="CLI/cmux.swift">

<violation number="1" location="CLI/cmux.swift:7941">
P2: `stopKeepalive` uses `sendQueue.sync`, which can block shutdown if the send queue is busy in a blocking send path. Use async cancellation to avoid deadlocking teardown.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread CLI/cmux.swift Outdated
Comment thread Sources/Workspace.swift
Comment thread Sources/Workspace.swift Outdated
Comment thread Sources/Workspace.swift Outdated
coderabbitai[bot]
coderabbitai Bot previously requested changes May 22, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (1)
CLI/cmux.swift (1)

7963-7972: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Keep the existing ping timeout armed while a ping is still outstanding.

At Line 7963 the next keepalive tick cancels and replaces the in-flight timeout before it has a chance to fire. Because the repeating timer and the asyncAfter timeout share the same deadline on sendQueue, the timer callback can run first, which pushes disconnect detection out by another interval and recreates the stale-connected window this change is trying to remove.

🐛 Suggested fix
-            keepaliveTimeoutWorkItem?.cancel()
-            keepaliveInFlight = true
+            if keepaliveInFlight {
+                return
+            }
+            keepaliveInFlight = true
             let timeoutWorkItem = DispatchWorkItem { [weak self] in
                 guard let self, !self.isStopped, self.keepaliveInFlight else { return }
                 self.debugEvent?("websocket.keepalive.timeout")
                 self.markStopped()
                 self.task?.cancel(with: .goingAway, reason: nil)
             }
+            keepaliveTimeoutWorkItem?.cancel()
             keepaliveTimeoutWorkItem = timeoutWorkItem
             sendQueue.asyncAfter(deadline: .now() + Self.keepaliveInterval, execute: timeoutWorkItem)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CLI/cmux.swift` around lines 7963 - 7972, The keepalive timeout is being
cancelled and replaced even when a ping is still outstanding, which prevents the
original timeout from firing; update the logic around
keepaliveTimeoutWorkItem/keepaliveInFlight so you do not cancel or replace an
existing timeout while a ping is in flight (i.e., only create/set a new
DispatchWorkItem and call sendQueue.asyncAfter when keepaliveInFlight is false
or keepaliveTimeoutWorkItem is nil/finished); keep the existing timeout active
until it fires or the in-flight state is cleared and reference the existing
keepaliveTimeoutWorkItem, keepaliveInFlight, DispatchWorkItem,
sendQueue.asyncAfter and Self.keepaliveInterval symbols to locate and modify the
code.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Resources/Localizable.xcstrings`:
- Around line 91729-91756: Update the "sidebar.remote.subtitle" string entry to
remove the stale "SSH • %@" wording and use a generic remote label (e.g.,
"Remote • %@"), and add localized stringUnit entries for every locale supported
in this Localizable.xcstrings catalog so the touched key has full locale
coverage; ensure each localization has a translated state and appropriate
localized value for the new generic wording while keeping extractionState as
needed.

---

Duplicate comments:
In `@CLI/cmux.swift`:
- Around line 7963-7972: The keepalive timeout is being cancelled and replaced
even when a ping is still outstanding, which prevents the original timeout from
firing; update the logic around keepaliveTimeoutWorkItem/keepaliveInFlight so
you do not cancel or replace an existing timeout while a ping is in flight
(i.e., only create/set a new DispatchWorkItem and call sendQueue.asyncAfter when
keepaliveInFlight is false or keepaliveTimeoutWorkItem is nil/finished); keep
the existing timeout active until it fires or the in-flight state is cleared and
reference the existing keepaliveTimeoutWorkItem, keepaliveInFlight,
DispatchWorkItem, sendQueue.asyncAfter and Self.keepaliveInterval symbols to
locate and modify the code.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 6ceb45db-44b2-41d4-b8a6-98029c5440b6

📥 Commits

Reviewing files that changed from the base of the PR and between 226e09e and fca2e77.

📒 Files selected for processing (4)
  • CLI/cmux.swift
  • Resources/Localizable.xcstrings
  • Sources/Workspace.swift
  • cmuxTests/WorkspaceRemoteConnectionTests.swift
👮 Files not reviewed due to content moderation or server errors (1)
  • Sources/Workspace.swift

Comment thread Resources/Localizable.xcstrings Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

3 issues found across 7 files

Tip: cubic can generate docs of your entire codebase and keep them up to date. Try it here.

Re-trigger cubic

Comment thread CLI/cmux.swift Outdated
Comment thread Sources/TabManager.swift Outdated
Comment thread Sources/Workspace.swift Outdated
Comment thread Sources/Workspace.swift
Comment thread Sources/Workspace.swift
Comment thread Sources/Workspace.swift
Comment thread Sources/Workspace.swift
Comment thread Sources/Workspace.swift
Comment thread Sources/Workspace+PanelLifecycle.swift
Comment thread Sources/Workspace+PanelLifecycle.swift
Comment thread Sources/TabManager.swift Outdated
Comment thread Sources/Workspace.swift
…xy-stale-connected

# Conflicts:
#	Sources/Workspace.swift
#	cmuxTests/WorkspaceRemoteConnectionTests.swift
Comment thread Sources/Workspace.swift

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit ca8cc16. Configure here.

Comment thread Sources/Workspace.swift
@austinywang
austinywang dismissed stale reviews from coderabbitai[bot] and coderabbitai[bot] June 4, 2026 12:45

Stale bot review: all review threads resolved and current CodeRabbit check is green on latest head 3c1f1e1.

Comment thread Sources/TabManager.swift
…xy-stale-connected

# Conflicts:
#	.github/swift-file-length-budget.tsv
@austinywang
austinywang merged commit d6fb91d into main Jun 14, 2026
22 of 24 checks passed
hhsw2015 pushed a commit to hhsw2015/cmux that referenced this pull request Jun 15, 2026
Strategy: -X theirs + cherry-pick fork features back

Upstream changes pulled in (highlights):
- Stagger restored terminal surface spawns (manaflow-ai#6149)
- Configurable Dock max width (manaflow-ai#4385)
- Polish canvas minimap navigation (manaflow-ai#6105)
- Opt-in AI auto-naming workspaces (manaflow-ai#6071)
- Dissolve namespace-enums into value types (manaflow-ai#6126)
- Fix stale remote connected state after proxy disconnect (manaflow-ai#4513)
- Surface browser Safari (manaflow-ai#6113)
- Fix terminal top-row mouse (manaflow-ai#4391)
- iOS Shift key support (manaflow-ai#6104)

Fork-only features verified intact post-merge:
- cmux_term socket handlers: surface.snapshot, screen_text, screen_hash,
  wait_for_text, wait_for_idle, wait_for_screen_change, wait_for_kind,
  wait_for_cursor, tui_probe, expect, screen_region (6 handler funcs)
- agent-bus: notification.create with $bus dispatch
- TerminalSurface.visibleSnapshot() + processHasExited() helpers
- skills/cmux-terminal-control/* (13 Python lib files + ORCHESTRATOR_TEMPLATE.md)
- HerdrWorkspaceSync + CustomTitleSource.herdrInbound

Adapter changes (fork-side):
- Drop InternalImportsByDefault from CmuxFeedback/CmuxFeedbackUI packages
  (default-arg parameter types remained internal-only across modules,
   blocking FeedbackComposerBridge() construction from cmux app target)
- Strip .rawValue from SurfaceKind cases (became plain String)
- Add herdrInbound to CustomTitleSource enum
- Delete shadowing local enums in ContentView (CommandPaletteOverlayPromotionPolicy,
  ExtensionSidebarBrowserStackDropPlanner, SidebarDragFailsafePolicy,
  SidebarDragLifecycleNotification, SidebarMarkdownRenderer, SidebarOutsideDropResetPolicy,
  SidebarShortcutHintFreezePolicy, SidebarTrailingAccessoryWidthPolicy,
  SidebarWorkspaceSelectionSyncPolicy, ShortcutHintDebugSettings,
  ShortcutHintModifierPolicy, FeedbackComposerBridge, FeedbackComposerBridgeError,
  ExtensionSidebarBrowserStackDropRow, SidebarTabDropIndicatorPredicate,
  SidebarDropEdge, SidebarDropIndicator)
- Delete local TerminalSurfaceClaudeCommandShim (use package's via typealias)
- Delete local TerminalSurfaceRuntimeTeardownCoordinator + Request stubs
  (use package's enqueueRuntimeTeardown public API directly)
- Adapt static→instance: CmuxGhosttyConfigSettingEditor.X → ().X,
  CmuxApplicationSupportDirectories.userDirectories → init(env:).userDirectories
- newTerminalSurface/TerminalPanel: thread externalIo: parameter for herdr
- Add createReplacementTerminalPanel(in:) overload
- Add attachToViewForInputDemand/requestInputDemandSurfaceStartIfNeeded stubs
- TerminalSurfaceRuntimeFilesystem.live(): wrap installClaudeCommandShim sync→async
- AppDelegate.sortedMainWindowContextsForSessionSnapshot: add includeQuickTerminal param
- Wire BackgroundSessionStore + BackgroundSessionsSidebarSection + SidebarSectionDivider
  in pbxproj (PBXFileReference + PBXBuildFile entries were missing post-merge)
- Add SidebarSectionDivider stub View (was referenced but never created)

Rebuilt clean against /tmp/cmux-p52 derivedData.

This branch was successfully deployed

1 active deployment
Preview – cmux — 736ed37a Deployed Jun 15, 2026 by vercel[bot]
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.

Remote proxy disconnect not reflected in UI: sidebar still shows 'Connected' after sleep/wake while terminal silently fell back to local Mac shell

1 participant