Skip to content

Surface SSH reconnect lifecycle for Cloud VMs - #3930

Closed
lawrencecchen wants to merge 7 commits into
mainfrom
codex/issue-3776-cloud-vm-ssh-resilience
Closed

lawrencecchen wants to merge 7 commits into
mainfrom
codex/issue-3776-cloud-vm-ssh-resilience

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented May 12, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Reports SSH reconnecting and connected lifecycle from the foreground startup wrapper.
  • Routes those lifecycle events through workspace remote state so Cloud VM panes show reconnecting and connected accurately.
  • Keeps injected SSH LocalCommand helpers overridable by user-provided LocalCommand or PermitLocalCommand.

Testing

  • xcodebuild test -quiet -project GhosttyTabs.xcodeproj -scheme cmux-unit -configuration Debug -destination 'platform=macOS,arch=arm64' -derivedDataPath /tmp/cmux-issue-3776-tests -resultBundlePath /tmp/cmux-issue-3776-ssh-startup-suite.xcresult -only-testing:cmuxTests/CLINotifyProcessIntegrationRegressionTests/testSSHPaneCloseSignalDoesNotReportSessionEndToSharedTransport -only-testing:cmuxTests/CLINotifyProcessIntegrationRegressionTests/testSSHStartupRetriesTransientSSHExitBeforeReportingSessionEnd -only-testing:cmuxTests/CLINotifyProcessIntegrationRegressionTests/testSSHStartupReportsReconnectingAndConnectedLifecycleAroundTransientExit -only-testing:cmuxTests/CLINotifyProcessIntegrationRegressionTests/testSSHStartupStopsAtConfiguredReconnectLimit -only-testing:cmuxTests/CLINotifyProcessIntegrationRegressionTests/testSSHStartupDoesNotRetryNonTransientSSHExit -only-testing:cmuxTests/CLINotifyProcessIntegrationRegressionTests/testSSHSignalDerivedChildExitReportsSessionEnd -only-testing:cmuxTests/CLINotifyProcessIntegrationRegressionTests/testSSHSignalDuringReconnectDelayDoesNotStartAnotherSSH -only-testing:cmuxTests/CLINotifyProcessIntegrationRegressionTests/testSSHStartupPrintsFinalErrorBannerWhenStderrIsCaptured
  • xcodebuild test -quiet -project GhosttyTabs.xcodeproj -scheme cmux-unit -configuration Debug -destination 'platform=macOS,arch=arm64' -derivedDataPath /tmp/cmux-issue-3776-tests -resultBundlePath /tmp/cmux-issue-3776-workspace.xcresult -only-testing:cmuxTests/WorkspaceRemoteConnectionTests/testRemoteTerminalLifecycleEventsDriveReconnectState
  • python3 -m json.tool Resources/Localizable.xcstrings
  • git diff --check
  • ./scripts/reload.sh --tag issue-3776-ssh

Issues


Note

Medium Risk
Medium risk because it changes SSH startup command generation and remote-connection state transitions, which could impact connection reliability and user-visible status during SSH sessions.

Overview
Surfaces SSH session lifecycle into the app by adding internal CLI subcommands ssh-session-reconnecting and ssh-session-connected, emitting them from the SSH startup wrapper (and vm ssh-attach) during transient exit/retry and on successful connection.

Wires new RPCs workspace.remote.terminal_reconnecting/terminal_connected through TerminalController into Workspace, which now tracks pending lifecycle events per surface/relay_port, updates remote state to reconnecting with localized detail, and only flips to connected once proxy/daemon readiness (or VM no-proxy) conditions are met.

Tightens LocalCommand injection behavior by only installing helpers when safe (LocalCommand not already set and PermitLocalCommand allows it), threads --relay-port through split attach, adds new localizations and CLI contract docs, and expands unit/integration tests around these edge cases and validations.

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


Summary by cubic

Cloud VM panes now use the native reconnect lifecycle from CloudTuiManualMirrorSession and workspace remote-terminal liveness, addressing #3776 without restoring obsolete SSH LocalCommand status callbacks.

  • Preserves per-attempt ownership, pending readiness handling, and rejection of events for ended sessions.
  • The merged tree matches main; no SSH configuration or migration changes are required.

Written for commit 75220e3. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features

    • Remote terminal now reports reconnecting and connected lifecycle states (includes attempt/exit-status and relay-port handling). VM attach accepts a relay-port and can include local connected/timing scripts when allowed.
  • Bug Fixes / Reliability

    • More reliable application/retry of pending "connected" updates and cleanup of ended sessions to reduce stale states.
  • Localization

    • Added localized status messages for remote terminal connection states.
  • Tests

    • Expanded lifecycle and security tests covering reconnect, connected, transient exits, and edge cases.
  • Documentation

    • CLI lifecycle commands documented.

Review Change Stack

@vercel

vercel Bot commented May 12, 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 May 12, 2026 1:17pm
cmux-staging Building Building Preview, Comment May 12, 2026 1:17pm

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@coderabbitai

coderabbitai Bot commented May 12, 2026 •

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Adds client-side lifecycle shell fragments and CLI commands plus server RPC handlers and Workspace relay-port–scoped lifecycle state handling, with tests, localization, and CLI contract entries.

Changes

SSH Session Reconnecting & Connected Lifecycle

Layer / File(s) Summary
CLI startup, vm ssh-attach args & local-command injection
CLI/cmux.swift
Parse/validate --relay-port, add canInstallSSHLocalCommand(in:), guard LocalCommand injection, thread reportsConnectionLifecycle through startup builders, and build combined local-command script when permitted.
Lifecycle script builders & reconnect-loop injection
CLI/cmux.swift
Refactor lifecycle shell-command generation into shared builders, add reconnect-attempt builder, produce lifecycleReconnecting fragment, and inject it into the reconnect loop and startup script bodies when enabled.
CLI dispatch and lifecycle handlers
CLI/cmux.swift
Add ssh-session-reconnecting and ssh-session-connected dispatch; parse lifecycle identity; refactor runSSHSessionEnd to the shared lifecycle builder; add local fallback script for ssh-session-connected.
TerminalController v2 RPC dispatch & apply helper
Sources/TerminalController.swift
Advertise and handle workspace.remote.terminal_reconnecting and workspace.remote.terminal_connected; add RemoteTerminalLifecycleIdentity parsing and v2ApplyWorkspaceRemoteTerminalLifecycle to validate and apply Workspace mutations and return structured success payloads.
Workspace relay-port lifecycle tracking & APIs
Sources/Workspace.swift
Add per-surface relay-port tracking for pending-connected and pre-configured-ended ports; defer or apply connected updates; update seed/track/untrack/end flows; add markRemoteTerminalSessionReconnecting(...), markRemoteTerminalSessionConnected(...), and retry hooks on daemon/proxy readiness.
Tests & helpers
cmuxTests/*
Add tests exercising transient SSH exit and reconnect flows, PermitLocalCommand variations, split-attach lifecycle; extend test helpers (extraSSHOptions, VM split-attach tuple), and add multi-request mock server and socket-security tests.
Localization & docs
Resources/Localizable.xcstrings, docs/cli-contract.md
Add remote.state.reconnecting.terminal and remote.state.targetFallback strings and insert CLI contract rows for ssh-session-reconnecting / ssh-session-connected.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • manaflow-ai/cmux#239: Modifies the same SSH/remote-workspace plumbing (CLI/cmux.swift, TerminalController.swift, Workspace.swift).
  • manaflow-ai/cmux#2104: Modifies Workspace remote-terminal session state management and lifecycle tracking.
  • manaflow-ai/cmux#1296: Larger SSH remote-workspace changes touching CLI, TerminalController, and Workspace lifecycle plumbing.

Caution

Pre-merge checks failed

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

  • Ignore

❌ Failed checks (2 errors, 1 warning)

Check name Status Explanation Resolution
Cmux Swift File And Package Boundaries ❌ Error Adds protocol/session-lifecycle logic to Workspace which mixes UI, persistence, networking. Independently testable domain logic requires SwiftPM package boundary per rules. Extract remote terminal lifecycle state to SwiftPM package. Reduces Workspace by 130+ lines, satisfies protocol-logic boundary requirement.
Cmux Architecture Rethink ❌ Error Dual state ownership via pendingRemoteTerminalConnectedRelayPortsBySurfaceId cache with polling replay. Unapplied review fixes leave stale event replay and backward-compatibility bugs. Apply unapplied review fixes: check .connected before pending storage, add relayPort-nil fallback. Extract pending state into actor with generation tracking.
Docstring Coverage ⚠️ Warning Docstring coverage is 2.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (12 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Surface SSH reconnect lifecycle for Cloud VMs' clearly and concisely summarizes the main objective: surfacing SSH reconnection lifecycle information for Cloud VMs, which matches the core changes across the codebase.
Description check ✅ Passed The PR description includes all required sections: Summary explaining what changed and why, Testing section with specific xcodebuild commands and validation steps, though missing a demo video URL, and Checklist items. The description is comprehensive and mostly complete despite the missing video.
Linked Issues check ✅ Passed The PR comprehensively addresses issue #3776 requirements: surfaces reconnecting state via new ssh-session-reconnecting RPC and LocalCommand injection, implements automatic reconnect retry logic in SSH startup wrapper, preserves remote session state through workspace lifecycle tracking, and includes expanded unit/integration tests covering lifecycle scenarios.
Out of Scope Changes check ✅ Passed All changes are directly aligned with issue #3776 objectives. The additions to CLI commands, workspace state tracking, TerminalController RPC handling, localizations, documentation, and tests all support the core goal of surfacing and handling SSH reconnect lifecycle for Cloud VMs.
Cmux Swift Actor Isolation ✅ Passed No Swift 6 actor isolation violations. @MainActor access properly bounded. New private state uses Sendable-by-default types.
Cmux Swift Blocking Runtime ✅ Passed No new blocking/timing patterns in production code. New RPC handlers use pre-existing v2MainSync pattern. Workspace methods are pure state operations without semaphores, sleeps, or polling.
Cmux No Hacky Sleeps ✅ Passed PR has no TypeScript/JavaScript/shell script changes. Sleeps found are test-only scaffolding or existing configurable product retry behavior (guarded, bounded, cancellation-aware)—compliant with rule.
Cmux Swift Concurrency ✅ Passed DispatchQueue patterns limited to OS API boundaries and actor crossing. No new Combine app state added. All patterns comply with Swift concurrency modernization guidelines.
Cmux Swift @Concurrent ✅ Passed No @concurrent violations. All new methods are synchronous. Pattern: socket thread → sync handlers → v2MainSync blocking. No missing @concurrent, invalid @concurrent, or improper heavy work detected.
Cmux Swift Logging ✅ Passed No Swift logging violations detected. Production code adds no print/debugPrint/dump/NSLog calls. Shell diagnostics are user-facing CLI output (allowed). Auth tokens properly handled without logging.
Cmux Swiftui State Layout ✅ Passed PR introduces only private state variables, no @Published/@observable patterns, no GeometryReader/LazyVStack, no render-time mutations. Workspace changes are internal state management only.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed PR does not create or materially change standalone cmux-owned windows. Test-only NSWindow fixture has proper cmux.main.* identifier.
✨ 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 codex/issue-3776-cloud-vm-ssh-resilience

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.

coderabbitai[bot]
coderabbitai Bot previously requested changes May 12, 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: 3

🤖 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 6965-6967: canInstallSSHLocalCommand currently returns false when
either a LocalCommand or PermitLocalCommand key exists, which incorrectly
prevents injecting the LocalCommand helper if the user set -o
PermitLocalCommand=yes; change canInstallSSHLocalCommand (and any callers) to
only check for the presence of LocalCommand (use hasSSHOptionKey(options, key:
"LocalCommand")) so injection is gated solely on whether the user supplied
LocalCommand, and treat PermitLocalCommand separately (do not use
hasSSHOptionKey(options, key: "PermitLocalCommand") to disable injection) so a
user-supplied PermitLocalCommand still wins without turning off the helper.

In `@docs/cli-contract.md`:
- Line 157: The description for the `ssh-session-connected` event uses "after
retry" which is inconsistent with nearby wording "reconnecting"; update the
event description to use "after reconnect" or "after reconnecting" to match the
PR terminology and create parallel structure (change the text for
`ssh-session-connected` to "Internal helper that marks an SSH-backed workspace
as connected after reconnect" or similar).

In `@Sources/TerminalController.swift`:
- Around line 5058-5060: The guard that reads exit_status using
v2StrictInt(params, "exit_status") must also validate the upper bound; change
the check so exitStatus is an Int within 0...255 and return .err(code:
"invalid_params", message: "Missing or invalid exit_status", data: nil) if it's
nil or outside that range (i.e., not in 0...255) so impossible exit codes cannot
be persisted.
🪄 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: 0e5f6101-dd37-457b-a2c2-425b39875d23

📥 Commits

Reviewing files that changed from the base of the PR and between 57bc270 and 0cf42fa.

📒 Files selected for processing (7)
  • CLI/cmux.swift
  • Resources/Localizable.xcstrings
  • Sources/TerminalController.swift
  • Sources/Workspace.swift
  • cmuxTests/SSHStartupSignalLifecycleTests.swift
  • cmuxTests/WorkspaceRemoteConnectionTests.swift
  • docs/cli-contract.md

Comment thread CLI/cmux.swift Outdated
Comment thread docs/cli-contract.md Outdated
| `vm-pty-connect` | Internal helper that connects to a VM PTY from a config file. |
| `ssh-session-end` | Internal helper that clears remote SSH session state. |
| `ssh-session-reconnecting` | Internal helper that marks an SSH-backed workspace as reconnecting. |
| `ssh-session-connected` | Internal helper that marks an SSH-backed workspace as connected after retry. |

@coderabbitai coderabbitai Bot May 12, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial | 💤 Low value

Consider using "reconnect" for terminological consistency.

The description uses "after retry" while line 156 uses "reconnecting." Using "after reconnect" would create better parallel structure and align with the PR's terminology.

✏️ Suggested wording refinement
-| `ssh-session-connected` | Internal helper that marks an SSH-backed workspace as connected after retry. |
+| `ssh-session-connected` | Internal helper that marks an SSH-backed workspace as connected after reconnect. |
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
| `ssh-session-connected` | Internal helper that marks an SSH-backed workspace as connected after retry. |
| `ssh-session-connected` | Internal helper that marks an SSH-backed workspace as connected after reconnect. |
🤖 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 `@docs/cli-contract.md` at line 157, The description for the
`ssh-session-connected` event uses "after retry" which is inconsistent with
nearby wording "reconnecting"; update the event description to use "after
reconnect" or "after reconnecting" to match the PR terminology and create
parallel structure (change the text for `ssh-session-connected` to "Internal
helper that marks an SSH-backed workspace as connected after reconnect" or
similar).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in d6884b9. The CLI contract now says connected after reconnect for consistent terminology.

— Claude Code

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@lawrencecchen Thanks for the update! The terminology is now consistent — using "after reconnect" aligns well with the "reconnecting" language used for ssh-session-reconnecting. ✅


🧠 Learnings used
Learnt from: lawrencecchen
Repo: manaflow-ai/cmux PR: 3784
File: README.md:161-161
Timestamp: 2026-05-09T04:48:35.413Z
Learning: In the cmux project, the right-sidebar keyboard shortcut labels were intentionally swapped (per PR `#3784`). Reviewers should NOT flag the ⌘⇧E (Cmd+Shift+E) label as “Open file explorer.” Use these mappings consistently: ⌘⇧E → `focusRightSidebar` with the user-facing label “Toggle right sidebar focus”; ⌘⌥B (Cmd+Option+B) → `toggleFileExplorer` with the user-facing label “Open file explorer.”

If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

Comment thread Sources/TerminalController.swift Outdated
@greptile-apps

greptile-apps Bot commented May 12, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR surfaces SSH reconnect lifecycle events (reconnecting / connected) from the foreground SSH startup wrapper and routes them through workspace remote state so Cloud VM panes display accurate connection status during transient drops and after redial. It also adds --relay-port forwarding to vm ssh-attach and gates all LocalCommand injection behind a canInstallSSHLocalCommand helper that respects user-provided LocalCommand and PermitLocalCommand.

  • New CLI subcommands (ssh-session-reconnecting, ssh-session-connected) are emitted from within the wrapper loop and via SSH LocalCommand respectively; corresponding RPCs drive markRemoteTerminalSessionReconnecting/Connected on the Workspace, with pending-connected state held across the configure → proxy-ready → daemon-ready pipeline.
  • Split-attach gains relay awareness via --relay-port forwarding, enabling the lifecycle signal path for VM SSH-only panes; port range validation and canInstallSSHLocalCommand gating apply consistently.
  • Edge cases — out-of-order events, pre-configure session-end tombstones, ended surfaces, mismatched relay ports — are each handled explicitly with focused unit and integration tests.

Confidence Score: 5/5

The change is well-scoped and safe to merge; all new state transitions are guarded by relay-port matching and active-surface checks, and the tests cover the critical ordering invariants.

The lifecycle machinery is tightly gated: canInstallSSHLocalCommand prevents injection when LocalCommand or PermitLocalCommand=no is present; remoteTerminalLifecycleMatches scopes transitions to the active surface set; pending-connected state is cleaned up on disconnect, untrack, and pre-config session-end. The one inconsistency found — ssh-session-connected accepting CMUX_SOCKET as a fallback while ssh-session-end/ssh-session-reconnecting do not — is a minor style gap unlikely to surface in practice.

The socket env var fallback difference between sshSessionConnectedLocalCommandScript and buildSSHSessionLifecycleShellCommand in CLI/cmux.swift is worth a follow-up pass to make all three lifecycle scripts consistent.

Important Files Changed

Filename Overview
CLI/cmux.swift Adds ssh-session-reconnecting and ssh-session-connected CLI subcommands, canInstallSSHLocalCommand guard, relay-port forwarding in vm ssh-attach, and new LocalCommand scripts. Minor inconsistency in socket env var fallback between the connected script and the session-end/reconnecting scripts.
Sources/Workspace.swift Adds markRemoteTerminalSessionReconnecting/Connected, two new tombstone/pending dicts for lifecycle state, and plugs applyPendingRemoteTerminalConnectedIfNeeded into configure, track, daemon-ready, and proxy-ready callsites. Logic looks well-guarded; all paths call into @MainActor-isolated methods via v2MainSync/DispatchQueue.main.async.
Sources/TerminalController.swift Routes two new RPCs (workspace.remote.terminal_reconnecting and workspace.remote.terminal_connected) through the existing v2ApplyWorkspaceRemoteTerminalLifecycle helper; adds them to the allowed-methods list. Clean refactoring with good input validation.
cmuxTests/SSHStartupSignalLifecycleTests.swift Adds focused lifecycle tests including LocalCommand suppression, PermitLocalCommand=yes/no paths, split-attach relay, and exit-status range validation. Mock server pattern is clean and uses explicit socket handshake rather than timing.
cmuxTests/WorkspaceRemoteConnectionTests.swift Adds testRemoteTerminalLifecycleEventsDriveReconnectState covering the reconnecting/connected state machine. The new waitForRemoteConnectionState/waitForRemoteDaemonState helpers poll with a RunLoop.main.run(until:) spin loop, which is test-only timing-based synchronization acceptable in this context.
cmuxTests/TerminalControllerSocketSecurityTests.swift Adds testRemoteTerminalReconnectingRejectsAttemptAboveLimit which verifies the attempt > limit guard in the TerminalController RPC handler returns an error. Straightforward and correct.
Resources/Localizable.xcstrings Adds two new localized strings for reconnecting status and target fallback in English and Japanese. Format specifiers (%@ and %lld) match the Swift String(format:) call sites.
docs/cli-contract.md Documents the new ssh-session-reconnecting and ssh-session-connected CLI subcommands with argument tables and option constraints. Accurate reflection of the implementation.

Sequence Diagram

sequenceDiagram
    participant Wrapper as SSH Startup Wrapper
    participant SSH as ssh binary
    participant LocalCmd as LocalCommand script
    participant CLI as cmux CLI
    participant TC as TerminalController
    participant WS as Workspace

    Workspace->>Workspace: configureRemoteConnection() → .connecting
    Wrapper->>SSH: exec ssh (attempt 1)
    SSH-->>Wrapper: exit 255 (transient)
    Wrapper->>CLI: ssh-session-reconnecting --attempt 1 --limit N --exit-status 255
    CLI->>TC: workspace.remote.terminal_reconnecting RPC
    TC->>WS: markRemoteTerminalSessionReconnecting()
    WS->>WS: applyRemoteConnectionStateUpdate(.reconnecting)
    Wrapper->>SSH: exec ssh (attempt 2)
    SSH->>LocalCmd: LocalCommand fires on connect
    LocalCmd->>CLI: ssh-session-connected
    CLI->>TC: workspace.remote.terminal_connected RPC
    TC->>WS: markRemoteTerminalSessionConnected()
    WS->>WS: applyRemoteTerminalSessionConnectedIfReady() → .connected
    SSH-->>Wrapper: exit 0
    Wrapper->>CLI: ssh-session-end
    CLI->>TC: workspace.remote.terminal_session_end RPC
    TC->>WS: markRemoteTerminalSessionEnded()
Loading

Reviews (5): Last reviewed commit: "fix: replay pending ssh connected events" | Re-trigger Greptile

Comment thread CLI/cmux.swift Outdated
Comment on lines +4838 to +4848
localCLIPath: resolvedExecutableURL()?.path,
foregroundAuthToken: deferredRemoteReconnectToken
)
let sshConnectionTimingCommandScript = sshConnectionTimingLocalCommandScript(
target: sshOptions.displayDestination,
relayPort: sshOptions.remoteRelayPort
)
let sshSessionConnectedCommandScript = canInstallLocalCommand
? sshSessionConnectedLocalCommandScript(
remoteRelayPort: sshOptions.remoteRelayPort,
localCLIPath: resolvedExecutableURL()?.path
)
: nil
let sshConnectionTimingCommandScript = canInstallLocalCommand
? sshConnectionTimingLocalCommandScript(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Reconnecting state can get permanently stuck when LocalCommand is unavailable

When canInstallSSHLocalCommand returns false (user has LocalCommand or PermitLocalCommand in their SSH options), sshSessionConnectedCommandScript is nil — the LocalCommand that fires ssh-session-connected is never installed. However, lifecycleReconnecting is built unconditionally via buildSSHSessionReconnectingShellCommand and embedded directly in the wrapper shell script (not behind the same guard). For Cloud VM workspaces where skipDaemonBootstrap = true, there is no proxy-ready or daemon-ready signal to transition the state back to .connected, so the pane UI remains permanently in .reconnecting until the session ends. Either gate lifecycleReconnecting behind the same canInstallLocalCommand check, or accept that .reconnecting must not be set unless the connected path is also available.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in d6884b9. Reconnecting lifecycle is now gated by the same connected-lifecycle availability, so we do not enter reconnecting when LocalCommand cannot provide the connected callback.

— Claude Code

Comment thread Sources/Workspace.swift Outdated
Comment on lines +9406 to +9414
private func remoteTerminalLifecycleMatches(surfaceId: UUID, relayPort: Int?) -> Bool {
guard let relayPort,
relayPort > 0,
remoteConfiguration?.relayPort == relayPort else {
return false
}
return activeRemoteTerminalSurfaceIds.contains(surfaceId)
|| pendingRemoteTerminalChildExitSurfaceIds.contains(surfaceId)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 The remoteTerminalLifecycleMatches helper accepts surfaces in pendingRemoteTerminalChildExitSurfaceIds for the reconnecting and connected signals. A surface enters that set only after markRemoteTerminalSessionEnded runs (session already torn down). Accepting it for markRemoteTerminalSessionReconnecting means a ssh-session-reconnecting arriving after a spurious or duplicate ssh-session-end (e.g., during signal handling) would re-arm .reconnecting on a half-torn-down connection. The reconnecting path should only match surfaces still tracked as active.

Suggested change
private func remoteTerminalLifecycleMatches(surfaceId: UUID, relayPort: Int?) -> Bool {
guard let relayPort,
relayPort > 0,
remoteConfiguration?.relayPort == relayPort else {
return false
}
return activeRemoteTerminalSurfaceIds.contains(surfaceId)
|| pendingRemoteTerminalChildExitSurfaceIds.contains(surfaceId)
}
private func remoteTerminalLifecycleMatches(surfaceId: UUID, relayPort: Int?) -> Bool {
guard let relayPort,
relayPort > 0,
remoteConfiguration?.relayPort == relayPort else {
return false
}
return activeRemoteTerminalSurfaceIds.contains(surfaceId)
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in d6884b9. Lifecycle matching now only accepts active remote terminal surfaces, and a connected-then-ended pre-config race is tombstoned so it cannot publish connected after teardown.

— Claude Code

coderabbitai[bot]
coderabbitai Bot previously requested changes May 12, 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

🤖 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 `@cmuxTests/WorkspaceRemoteConnectionTests.swift`:
- Around line 605-635: The fixed 50ms RunLoop delay in
testRemoteTerminalEndBeforeConfigureClearsPendingConnectedEvent can cause
flakiness; replace it with a deterministic wait for the condition instead of
sleeping: after waitForRemoteDaemonState(.ready, in: workspace) poll or use an
XCTestExpectation that waits (with a reasonable timeout) for
workspace.remoteConnectionState to become .connecting or for
workspace.remoteStatusPayload()["state"] == "connecting" (or
workspace.remoteConnectionDetail to match the expected string) before asserting;
update the test to use this explicit wait mechanism (referencing the test method
name and the Workspace instance and waitForRemoteDaemonState helper) rather than
RunLoop.main.run(until:).
- Around line 537-574: The test
testRemoteTerminalLifecycleEventsDriveReconnectState is missing the final
cleanup call; add a call to
workspace.disconnectRemoteConnection(clearConfiguration: true) at the end of the
test (after the final XCTAssertEqual) to mirror other tests and ensure
consistent resource cleanup and configuration clearing.

In `@Sources/TerminalController.swift`:
- Around line 5042-5073: The handler v2WorkspaceRemoteTerminalReconnecting
currently validates attempt and limit independently but allows attempt > limit;
update the parameter validation to reject that by adding an additional guard
that ensures attempt <= limit (returning the same .err with "Missing or invalid
attempt" or "Missing or invalid limit" as appropriate) before calling
v2ApplyWorkspaceRemoteTerminalLifecycle so malformed pairs are not persisted;
keep references to v2StrictInt for parsing and
markRemoteTerminalSessionReconnecting for where the values are applied.

In `@Sources/Workspace.swift`:
- Around line 9491-9500: The handler
rememberPendingRemoteTerminalConnectedIfNeeded currently returns early when the
surface is already tracked (via activeRemoteTerminalSurfaceIds), causing
out-of-order ssh-session-connected events to be dropped; change the logic so
that for TerminalPanel surfaces (and not in
pendingRemoteTerminalChildExitSurfaceIds) you always record
pendingRemoteTerminalConnectedRelayPortsBySurfaceId[surfaceId] = relayPort
(subject to the relayPort/config check) even if activeRemoteTerminalSurfaceIds
contains surfaceId so later replay can apply; specifically update
rememberPendingRemoteTerminalConnectedIfNeeded to remove or relax the guard that
skips when activeRemoteTerminalSurfaceIds contains surfaceId and ensure the
relayPort mismatch check (remoteConfiguration?.relayPort) remains enforced.
🪄 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: fa296817-5cc1-43c8-9946-0a30d5c6b5f4

📥 Commits

Reviewing files that changed from the base of the PR and between 0cf42fa and d6884b9.

📒 Files selected for processing (6)
  • CLI/cmux.swift
  • Sources/TerminalController.swift
  • Sources/Workspace.swift
  • cmuxTests/SSHStartupSignalLifecycleTests.swift
  • cmuxTests/WorkspaceRemoteConnectionTests.swift
  • docs/cli-contract.md

Comment thread cmuxTests/WorkspaceRemoteConnectionTests.swift
Comment thread cmuxTests/WorkspaceRemoteConnectionTests.swift Outdated
Comment thread Sources/TerminalController.swift Outdated
Comment thread Sources/Workspace.swift Outdated

XCTAssertFalse(result.timedOut, result.stderr)
XCTAssertEqual(result.status, 0, result.stderr)
XCTAssertEqual((try? String(contentsOf: attemptFile, encoding: .utf8))?.trimmingCharacters(in: .whitespacesAndNewlines), "3")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Assertion "3" is unreachable with the current fake SSH

The test sets CMUX_SSH_RECONNECT_LIMIT=2 and uses a fake SSH that exits 255 only when count == 1, then exits 0 for every subsequent invocation. The retry loop breaks immediately on exit 0 (if [ "$cmux_ssh_status" -eq 0 ]; then break; fi), so the wrapper will invoke SSH exactly twice — once failing at count 1, once succeeding at count 2. The attempt file will contain "2", not "3", and this test will fail when run.

To reach count "3" with CMUX_SSH_RECONNECT_LIMIT=2, the fake SSH would need to exit 255 for count <= 2 (two consecutive failures) and 0 only at count 3. Either adjust the fake SSH's failure condition to [ "$count" -le 2 ], or change the assertion to "2" if only one transient failure is intended.

…d-vm-ssh-resilience

# Conflicts:
#	CLI/cmux.swift
#	cmuxTests/SSHStartupSignalLifecycleTests.swift

XCTAssertFalse(result.timedOut, result.stderr)
XCTAssertEqual(result.status, 0, result.stderr)
XCTAssertEqual((try? String(contentsOf: attemptFile, encoding: .utf8))?.trimmingCharacters(in: .whitespacesAndNewlines), "3")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Unreachable attempt count assertion will fail

The fake SSH exits 255 only when count == 1 and exits 0 for all subsequent invocations. With CMUX_SSH_RECONNECT_LIMIT=2, the retry loop runs SSH exactly twice (fail at count=1, succeed at count=2), so the attempt file contains "2" — not "3".

The wrapper loop:

  1. retry=0: SSH runs (count=1) → exit 255; retry(0) >= limit(2) is false; retry becomes 1.
  2. retry=1: SSH runs (count=2) → exit 0; status == 0 → break.

For the assertion to hold at "3", the fake SSH would need to exit 255 twice (i.e. if [ "$count" -le 2 ]; then exit 255; fi) or the limit would need to be 3. The same class of bug was flagged and fixed in testSSHStartupStopsAtConfiguredReconnectLimit; this test appears to have the same mistake. The count assertion should be "2" for one transient failure with RECONNECT_LIMIT=2.

Suggested change
XCTAssertEqual((try? String(contentsOf: attemptFile, encoding: .utf8))?.trimmingCharacters(in: .whitespacesAndNewlines), "3")
XCTAssertEqual((try? String(contentsOf: attemptFile, encoding: .utf8))?.trimmingCharacters(in: .whitespacesAndNewlines), "2")

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

♻️ Duplicate comments (3)
cmuxTests/WorkspaceRemoteConnectionTests.swift (2)

537-574: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Missing cleanup call for consistency.

Other similar remote connection tests in this file call workspace.disconnectRemoteConnection(clearConfiguration: true) at the end (e.g., lines 602, 634, 672). This test should follow the same pattern to ensure consistent resource cleanup between tests.

🧹 Proposed fix to add cleanup
     XCTAssertEqual(workspace.remoteConnectionState, .connected)
     XCTAssertEqual(workspace.remoteStatusPayload()["state"] as? String, "connected")
     XCTAssertEqual(workspace.remoteConnectionDetail, "Connected to cmux@gateway.freestyle.sh:2222 (VM, proxy disabled)")
+    workspace.disconnectRemoteConnection(clearConfiguration: true)
 }
🤖 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 `@cmuxTests/WorkspaceRemoteConnectionTests.swift` around lines 537 - 574, Add
the missing cleanup call at the end of
testRemoteTerminalLifecycleEventsDriveReconnectState: after the final
assertions, call workspace.disconnectRemoteConnection(clearConfiguration: true)
to match other tests' teardown. Locate the test function named
testRemoteTerminalLifecycleEventsDriveReconnectState and append the
disconnectRemoteConnection(clearConfiguration: true) invocation to ensure
consistent resource cleanup between tests.

605-635: 🧹 Nitpick | 🔵 Trivial | ⚖️ Poor tradeoff

Fixed delay after state wait may cause flakiness on slow systems.

Line 628 uses a fixed 50ms delay after waiting for daemon .ready state. While this pattern is common in async tests, it assumes async processing completes within 50ms, which may not hold on slower CI runners or under load. Consider whether the code under test can surface a synchronous completion signal, or increase the delay if 50ms proves insufficient in practice.

Note: This is an acceptable async test pattern and likely sufficient given the preceding state wait. Only flag if test proves flaky in CI.

🤖 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 `@cmuxTests/WorkspaceRemoteConnectionTests.swift` around lines 605 - 635, The
test testRemoteTerminalEndBeforeConfigureClearsPendingConnectedEvent uses a
fixed RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) after
waitForRemoteDaemonState(.ready), which can be flaky on slow CI; replace the
hard 50ms sleep with a deterministic wait for the condition the test needs
(e.g., use an XCTestExpectation that waits for workspace.remoteConnectionState
to become .connecting or for workspace.remoteStatusPayload() to reflect
"connecting", or extend the timeout to a larger value) and update the code
around waitForRemoteDaemonState and the RunLoop call to use that expectation so
the test only proceeds once the asynchronous work is observed.
Sources/Workspace.swift (1)

9567-9576: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Do not drop deferred connected events for already-tracked terminals.

At Line 9574, the active-surface guard drops ssh-session-connected in the exact out-of-order window where replay is needed. Then Lines 9739/9764 have nothing to apply, and state can remain .connecting/.reconnecting.

Suggested fix
 private func rememberPendingRemoteTerminalConnectedIfNeeded(surfaceId: UUID, relayPort: Int) {
     guard !pendingRemoteTerminalChildExitSurfaceIds.contains(surfaceId) else { return }
     guard panels[surfaceId] is TerminalPanel else { return }
     if let configuredRelayPort = remoteConfiguration?.relayPort,
        configuredRelayPort != relayPort {
         return
     }
-    guard remoteConfiguration == nil || !activeRemoteTerminalSurfaceIds.contains(surfaceId) else { return }
+    let awaitingReadiness =
+        remoteConnectionState == .connecting || remoteConnectionState == .reconnecting
+    guard
+        remoteConfiguration == nil ||
+        !activeRemoteTerminalSurfaceIds.contains(surfaceId) ||
+        awaitingReadiness
+    else {
+        return
+    }
     pendingRemoteTerminalConnectedRelayPortsBySurfaceId[surfaceId] = relayPort
 }
🤖 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 9567 - 9576, The function
rememberPendingRemoteTerminalConnectedIfNeeded currently returns early when
remoteConfiguration != nil and activeRemoteTerminalSurfaceIds contains
surfaceId, causing deferred "connected" events to be dropped; change the logic
in rememberPendingRemoteTerminalConnectedIfNeeded to stop dropping these events:
remove the guard that returns when
activeRemoteTerminalSurfaceIds.contains(surfaceId) and instead only avoid
overwriting existing pending entries (i.e., skip if
pendingRemoteTerminalConnectedRelayPortsBySurfaceId already has the surfaceId)
so that deferred ssh-session-connected events are recorded for later replay
while still preventing duplicate pending entries; keep the other guards
(pendingRemoteTerminalChildExitSurfaceIds and panels[surfaceId] is
TerminalPanel) and the relayPort mismatch check intact.
🤖 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.

Duplicate comments:
In `@cmuxTests/WorkspaceRemoteConnectionTests.swift`:
- Around line 537-574: Add the missing cleanup call at the end of
testRemoteTerminalLifecycleEventsDriveReconnectState: after the final
assertions, call workspace.disconnectRemoteConnection(clearConfiguration: true)
to match other tests' teardown. Locate the test function named
testRemoteTerminalLifecycleEventsDriveReconnectState and append the
disconnectRemoteConnection(clearConfiguration: true) invocation to ensure
consistent resource cleanup between tests.
- Around line 605-635: The test
testRemoteTerminalEndBeforeConfigureClearsPendingConnectedEvent uses a fixed
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) after
waitForRemoteDaemonState(.ready), which can be flaky on slow CI; replace the
hard 50ms sleep with a deterministic wait for the condition the test needs
(e.g., use an XCTestExpectation that waits for workspace.remoteConnectionState
to become .connecting or for workspace.remoteStatusPayload() to reflect
"connecting", or extend the timeout to a larger value) and update the code
around waitForRemoteDaemonState and the RunLoop call to use that expectation so
the test only proceeds once the asynchronous work is observed.

In `@Sources/Workspace.swift`:
- Around line 9567-9576: The function
rememberPendingRemoteTerminalConnectedIfNeeded currently returns early when
remoteConfiguration != nil and activeRemoteTerminalSurfaceIds contains
surfaceId, causing deferred "connected" events to be dropped; change the logic
in rememberPendingRemoteTerminalConnectedIfNeeded to stop dropping these events:
remove the guard that returns when
activeRemoteTerminalSurfaceIds.contains(surfaceId) and instead only avoid
overwriting existing pending entries (i.e., skip if
pendingRemoteTerminalConnectedRelayPortsBySurfaceId already has the surfaceId)
so that deferred ssh-session-connected events are recorded for later replay
while still preventing duplicate pending entries; keep the other guards
(pendingRemoteTerminalChildExitSurfaceIds and panels[surfaceId] is
TerminalPanel) and the relayPort mismatch check intact.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 07549b6f-c253-48de-be64-7bf523811e16

📥 Commits

Reviewing files that changed from the base of the PR and between d6884b9 and 678110e.

📒 Files selected for processing (7)
  • CLI/cmux.swift
  • Resources/Localizable.xcstrings
  • Sources/TerminalController.swift
  • Sources/Workspace.swift
  • cmuxTests/SSHStartupSignalLifecycleTests.swift
  • cmuxTests/WorkspaceRemoteConnectionTests.swift
  • docs/cli-contract.md

@lawrencecchen
lawrencecchen dismissed stale reviews from coderabbitai[bot] and coderabbitai[bot] May 12, 2026 10:13

Superseded by d6884b9/678110e42; CodeRabbit passed on the latest pushed commit.

@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: 3

🤖 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 `@Sources/Workspace.swift`:
- Around line 7245-7246: The remote-terminal lifecycle state (the dictionaries
pendingRemoteTerminalConnectedRelayPortsBySurfaceId and
preConfiguredRemoteTerminalEndedRelayPortsBySurfaceId plus their replay helpers
and associated transition logic) should be extracted out of Workspace into a
dedicated helper type (e.g., RemoteTerminalLifecycleManager as a struct/class)
that owns those maps and exposes clear methods for events (connectRequest,
connected(relayPort:), ended, replayPendingFor(surfaceId:), etc. Move all pure
state-transition code and replay helpers from Workspace to that new helper,
update Workspace to hold an instance (e.g., remoteTerminalLifecycleManager) and
call its methods where the current code reads/writes those maps, and add unit
tests for the manager to cover the transitions previously embedded in Workspace.
- Around line 9498-9506: The handler markRemoteTerminalSessionConnected
currently records late ssh-session-connected events as pending; change it to
return early when the workspace is already connected (don't call
rememberPendingRemoteTerminalConnectedIfNeeded) by checking the workspace's
current connection state (e.g., connectionState != .connected or a suitable
isConnected flag) before storing the event; update
markRemoteTerminalSessionConnected to consult that state and only call
applyRemoteTerminalSessionConnectedIfReady/rememberPendingRemoteTerminalConnectedIfNeeded
if the workspace is not already connected, so
applyPendingRemoteTerminalConnectedIfNeeded cannot replay stale entries.
- Around line 4487-4499: publishVMShellConnectedState currently calls
hasActiveRemoteTerminalSession(relayPort:) unconditionally which fails when
configuration.relayPort is missing; change the guard that checks active terminal
sessions to first check if relayPort != nil and call
workspace.hasActiveRemoteTerminalSession(relayPort: relayPort) in that case,
otherwise call the parameterless workspace.hasActiveRemoteTerminalSession() (or
equivalent SSH-only check) so older/restored SSH-only VM configs without a relay
port still allow publishing the .connected state from
publishVMShellConnectedState.
🪄 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: 9e1b4eb4-88c9-4133-aa08-89fb01c4b479

📥 Commits

Reviewing files that changed from the base of the PR and between 678110e and 9456c49.

📒 Files selected for processing (4)
  • Sources/TerminalController.swift
  • Sources/Workspace.swift
  • cmuxTests/TerminalControllerSocketSecurityTests.swift
  • cmuxTests/WorkspaceRemoteConnectionTests.swift

Comment thread Sources/Workspace.swift Outdated
Comment on lines +4487 to +4499
private func publishVMShellConnectedState(_ state: WorkspaceRemoteConnectionState, detail: String?) {
let controllerID = self.controllerID
let relayPort = configuration.relayPort
DispatchQueue.main.async { [weak workspace] in
guard let workspace else { return }
guard workspace.activeRemoteSessionControllerID == controllerID else { return }
guard workspace.hasActiveRemoteTerminalSession(relayPort: relayPort) else { return }
workspace.applyRemoteConnectionStateUpdate(
state,
detail: detail,
target: workspace.remoteDisplayTarget ?? "remote host"
)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Keep the proxy-disabled VM fallback working without relayPort.

hasActiveRemoteTerminalSession(relayPort:) hard-fails when configuration.relayPort is missing, so this helper never publishes .connected for restored or older SSH-only VM configs that don't carry a relay port yet. The shell is usable, but the workspace stays stuck in .connecting.

Suggested fix
 private func publishVMShellConnectedState(_ state: WorkspaceRemoteConnectionState, detail: String?) {
     let controllerID = self.controllerID
     let relayPort = configuration.relayPort
     DispatchQueue.main.async { [weak workspace] in
         guard let workspace else { return }
         guard workspace.activeRemoteSessionControllerID == controllerID else { return }
-        guard workspace.hasActiveRemoteTerminalSession(relayPort: relayPort) else { return }
+        if let relayPort {
+            guard workspace.hasActiveRemoteTerminalSession(relayPort: relayPort) else { return }
+        } else {
+            guard workspace.hasActiveRemoteTerminalSessions else { return }
+        }
         workspace.applyRemoteConnectionStateUpdate(
             state,
             detail: detail,
             target: workspace.remoteDisplayTarget ?? "remote host"
         )
🤖 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 4487 - 4499,
publishVMShellConnectedState currently calls
hasActiveRemoteTerminalSession(relayPort:) unconditionally which fails when
configuration.relayPort is missing; change the guard that checks active terminal
sessions to first check if relayPort != nil and call
workspace.hasActiveRemoteTerminalSession(relayPort: relayPort) in that case,
otherwise call the parameterless workspace.hasActiveRemoteTerminalSession() (or
equivalent SSH-only check) so older/restored SSH-only VM configs without a relay
port still allow publishing the .connected state from
publishVMShellConnectedState.

Comment thread Sources/Workspace.swift Outdated
Comment on lines +7245 to +7246
private var pendingRemoteTerminalConnectedRelayPortsBySurfaceId: [UUID: Int] = [:]
private var preConfiguredRemoteTerminalEndedRelayPortsBySurfaceId: [UUID: Int] = [:]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

Extract the remote terminal lifecycle state machine out of Workspace.

These new maps and replay helpers add more transport/session-protocol responsibility to a file that already owns UI state, persistence, and panel orchestration. This logic is mostly pure state transition code and would be much easier to test and evolve behind a dedicated helper.

As per coding guidelines Sources/**/*.swift: "Flag features implemented directly in the app target/module root Sources/ path when their core logic is independent of cmux app lifecycle and can compile/test without AppKit, SwiftUI view state, Ghostty globals, or process-wide singletons".

Also applies to: 9478-9591

🤖 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 7245 - 7246, The remote-terminal
lifecycle state (the dictionaries
pendingRemoteTerminalConnectedRelayPortsBySurfaceId and
preConfiguredRemoteTerminalEndedRelayPortsBySurfaceId plus their replay helpers
and associated transition logic) should be extracted out of Workspace into a
dedicated helper type (e.g., RemoteTerminalLifecycleManager as a struct/class)
that owns those maps and exposes clear methods for events (connectRequest,
connected(relayPort:), ended, replayPendingFor(surfaceId:), etc. Move all pure
state-transition code and replay helpers from Workspace to that new helper,
update Workspace to hold an instance (e.g., remoteTerminalLifecycleManager) and
call its methods where the current code reads/writes those maps, and add unit
tests for the manager to cover the transitions previously embedded in Workspace.

Comment thread Sources/Workspace.swift Outdated
Comment on lines +9498 to +9506
func markRemoteTerminalSessionConnected(surfaceId: UUID, relayPort: Int?) {
guard let relayPort,
relayPort > 0,
!pendingRemoteTerminalChildExitSurfaceIds.contains(surfaceId) else {
return
}
if !applyRemoteTerminalSessionConnectedIfReady(surfaceId: surfaceId, relayPort: relayPort) {
rememberPendingRemoteTerminalConnectedIfNeeded(surfaceId: surfaceId, relayPort: relayPort)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Ignore late ssh-session-connected events once the workspace is already connected.

If proxy/daemon readiness moves the workspace to .connected before the shell hook reports ssh-session-connected, this branch stores that old event as pending. On the next real reconnect, applyPendingRemoteTerminalConnectedIfNeeded() can replay the stale entry and mark the workspace connected before the shell actually comes back.

Suggested fix
 func markRemoteTerminalSessionConnected(surfaceId: UUID, relayPort: Int?) {
     guard let relayPort,
           relayPort > 0,
           !pendingRemoteTerminalChildExitSurfaceIds.contains(surfaceId) else {
         return
     }
+    if remoteTerminalLifecycleMatches(surfaceId: surfaceId, relayPort: relayPort),
+       remoteConnectionState == .connected {
+        pendingRemoteTerminalConnectedRelayPortsBySurfaceId.removeValue(forKey: surfaceId)
+        return
+    }
     if !applyRemoteTerminalSessionConnectedIfReady(surfaceId: surfaceId, relayPort: relayPort) {
         rememberPendingRemoteTerminalConnectedIfNeeded(surfaceId: surfaceId, relayPort: relayPort)
     }
 }
🤖 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 9498 - 9506, The handler
markRemoteTerminalSessionConnected currently records late ssh-session-connected
events as pending; change it to return early when the workspace is already
connected (don't call rememberPendingRemoteTerminalConnectedIfNeeded) by
checking the workspace's current connection state (e.g., connectionState !=
.connected or a suitable isConnected flag) before storing the event; update
markRemoteTerminalSessionConnected to consult that state and only call
applyRemoteTerminalSessionConnectedIfReady/rememberPendingRemoteTerminalConnectedIfNeeded
if the workspace is not already connected, so
applyPendingRemoteTerminalConnectedIfNeeded cannot replay stale entries.

Cloud reconnect and connected status now comes from CloudTuiManualMirrorSession and Workspace remote terminal liveness. Keep native per-attempt ownership, pending readiness, and ended-lifecycle rejection instead of restoring obsolete SSH LocalCommand status callbacks. The merged tree intentionally matches main because this PR is superseded.
@github-actions

Copy link
Copy Markdown
Contributor

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

@teamleaderleo

Copy link
Copy Markdown
Collaborator

cmux-reconcile: close-candidate

Proposed action: Close this empty PR without merging; preserve the branch.

Evidence checked September 18, 2026: GitHub reports 0 changed files, 0 additions, and 0 deletions. I independently fetched the PR diff and it is empty. Head: 75220e30daa883cd3df7aa38681b6fc1c631ee7e. Target branch: main.

There is no remaining patch in this PR against its target branch. This does not establish that the original feature shipped to main or that its idea should be discarded. If this is an intentional placeholder for future work, keep the underlying request and record that purpose; otherwise it is ready for queue cleanup.

Recheck the head/diff before acting in case new work arrives. Search "cmux-reconcile: close-candidate" in this repository for this cleanup batch.

@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Fleet instruction update for head 75220e30daa883cd3df7aa38681b6fc1c631ee7e: this PR is classified other. No macOS build tag is claimed. The current controller app recipe does not establish iOS/test readiness; that requires the appropriate validated recipe. Use cmux-ci for supported jobs, retain the returned ID and receipt, and wait on the same ID after any timeout. Do not use retired maclease allocation or post credentials. Exact-head tags will be posted only after the applicable build succeeds.

@teamleaderleo

Copy link
Copy Markdown
Collaborator

Closing as already on main: merging this branch into main at 8421357 produces main's own tree, so there's nothing left to land. The branch is kept; reopen if something here is still missing. Part of the backlog cleanup in manaflow-ai/cmuxterm-hq#563.

@github-project-automation github-project-automation Bot moved this from Todo to Done in cmux backlog Sep 24, 2026
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.

Make Cloud VM SSH sessions resilient to sleep and reconnects

2 participants