Skip to content

Recover stale restored SSH PTY sessions - #5689

Closed
lawrencecchen wants to merge 3 commits into
mainfrom
task-reproduce-stale-ssh-pty-docker
Closed

lawrencecchen wants to merge 3 commits into
mainfrom
task-reproduce-stale-ssh-pty-docker

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Jun 9, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Treat missing restored SSH PTY sessions as a distinct attach outcome.
  • Fall back to a fresh restored remote shell when a persisted PTY is gone after app restart/update.
  • Keep existing retry behavior for transient bridge closes and preserve same-shell reattach when the PTY still exists.

Testing

  • xcodebuild -project cmux.xcodeproj -scheme cmux-unit -configuration Debug -destination 'platform=macOS' -derivedDataPath /tmp/cmux-stalepty-tests test -only-testing:cmuxTests/TabManagerSessionSnapshotTests/testSessionSnapshotRestoresSplitPersistentSSHPTYWithoutDefaultAttachScaffold -only-testing:cmuxTests/CLINotifyProcessIntegrationRegressionTests/testSSHPTYAttachRequireExistingSessionNotFoundFailsWithoutWaitRetry passed.
  • xcodebuild -project cmux.xcodeproj -scheme cmux-unit -configuration Debug -destination 'platform=macOS' -derivedDataPath /tmp/cmux-stalepty-tests test -only-testing:cmuxTests/TabManagerSessionSnapshotTests/testSessionSnapshotRestoresPersistentSSHPTYSessionAfterRelaunch passed.
  • Docker-backed tests_v2/test_ssh_remote_detachable_pty.py passed against tagged build spty and /tmp/cmux-debug-spty.sock.

Issues

  • Related: user-reported stale persistent SSH PTY after restarting/updating cmux Nightly.

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


Note

Medium Risk
Touches remote SSH PTY attach exit semantics, restore startup scripts, and reverse-relay recovery—important for session continuity but scoped with tests and distinct exit codes.

Overview
Recovers stale persistent SSH PTY sessions after restart/update by treating a missing PTY as a distinct outcome and optionally starting a fresh remote shell instead of failing or looping on reconnect.

ssh-pty-attach now exits with 253 when the persisted PTY is gone (via remotePTYErrorIndicatesMissingSession), and restored startup scripts handle that code: they show a user message and run a fallback attach without --require-existing, using an embedded --command-b64 remote shell when a relay port is available. Transient bridge closes still retry on 254/255 only.

Restored panel attach commands now pass restoredRemoteShellCommand(relayPort:) as remoteCommand so the fallback can bootstrap a new interactive shell on the same relay.

Reverse SSH relay startup detects “remote port forwarding failed for listen port N” as a stale remote listener, runs cleanupStaleRemoteRelayListenerLocked, uses a shorter retry when cleanup succeeds, and skips publishing a noisy error status when cleanup handled the stale case.

Tests and the detachable-PTY integration test were updated for exit 253, fallback shell wiring, stale-listener detection, and more reliable surface_id resolution after attach.

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


Summary by cubic

Detects and recovers from stale restored SSH PTY sessions and stale SSH relay listeners. Falls back to a new remote shell when the persisted PTY is gone, cleans stuck relay ports on relaunch, and keeps the retry loop for transient bridge closures with same-session reattach when the PTY still exists.

  • Bug Fixes
    • ssh-pty-attach returns exit code 253 when the PTY session is missing and shows a clearer error.
    • Startup attach script falls back to a new remote shell on 253 using --command-b64; retries only on 254/255.
    • Detects stale SSH relay listeners (“remote port forwarding failed for listen port X”), cleans the remote listener, shortens the retry delay, and avoids duplicate error toasts.
    • Tests updated to cover missing-session fallback, stale relay listener detection, and more robust surface_id resolution.

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

Review in cubic

Summary by CodeRabbit

  • Bug Fixes

    • Enhanced error handling for SSH PTY session attachment with distinct exit codes (253 for missing sessions, 1 for other errors).
    • Improved detection and automatic recovery of stale SSH relay listeners.
    • Added fallback attach mechanism when persistent SSH PTY sessions are unavailable.
  • Tests

    • Updated test expectations to reflect new exit codes and session recovery behavior.

@vercel

vercel Bot commented Jun 9, 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 9, 2026 6:51pm
cmux-staging Building Building Preview, Comment Jun 9, 2026 6:51pm

@coderabbitai

coderabbitai Bot commented Jun 9, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Missing persistent SSH PTY session errors now exit with code 253 and trigger a fallback attach command. CLI detects missing-session patterns, the command builder generates an optional fallback when enabled, and the retry loop conditionally runs it on 253 failure and warns the user.

Changes

Missing PTY Session Fallback Attach Flow

Layer / File(s) Summary
CLI Missing PTY Session Error Detection
CLI/cmux.swift
PTY attachment catch block computes userFacingMessage and sets exit code 253 when remotePTYErrorIndicatesMissingSession matches normalized error text.
Fallback Attach Command Generation and Retry
Sources/WorkspaceRemoteConfiguration.swift
SSHPTYAttachStartupCommandBuilder optionally emits a base64 remoteCommand fallback when requireExisting and --command-b64 exist; retryingAttachLines accepts the optional fallback, detects exit 253, prints a TTY notice, runs the fallback, and exits with its status.
Remote Command Configuration
Sources/Workspace.swift
Workspace supplies remoteCommand (from restoredRemoteShellCommand(relayPort:)) into the attach startup builder so restored relay ports are embedded in the fallback payload.
Reverse Relay Startup Detection & Cleanup
Sources/Workspace.swift
New helper detects exact stale remote listener by matching listen port <relayPort> in stderr; startup handling may clean the stale listener, choose a shorter retry delay, and suppress the "Remote SSH relay unavailable" daemon publish when cleaned.
Tests and Integration Updates
cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift, cmuxTests/TabManagerSessionSnapshotTests.swift, cmuxTests/WorkspaceRemoteConnectionTests.swift, tests_v2/test_ssh_remote_detachable_pty.py
Integration test expected exit code updated to 253; session snapshot tests verify restored initial command contains the 253 marker and --command-b64 payload (with decoded CMUX_SOCKET_PATH); new unit test for stale-listener detection; Python test adds _resolve_surface_id and uses a pre-attach surface snapshot to resolve the reattached surface.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • manaflow-ai/cmux#4807: Modifies SSH PTY attach/startup command plumbing and SSHPTYAttachStartupCommandBuilder, related to the new fallback/253 handling.

"I hopped through logs and matched a port,
A missing PTY no longer leaves me short,
Exit two-five-three sings out the clue,
A fallback command hops in to rescue,
I nibble bugs and cheer the retry report."


Important

Pre-merge checks failed

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

❌ Failed checks (2 errors, 1 warning)

Check name Status Explanation Resolution
Cmux Full Internationalization ❌ Error New user-facing shell messages "[cmux] persisted SSH PTY session is gone" and "[cmux] remote PTY bridge closed" lack localized entries in Resources/Localizable.xcstrings across 20 supported locales. Add localization keys with translations for both new terminal messages to Resources/Localizable.xcstrings for all 20 supported locales before generating shell scripts.
Cmux Source Artifacts ❌ Error PR adds prohibited source control artifacts: .claude/scheduled_tasks.lock (local tool runtime state with session/PID data) and .cursor/rules/main.mdc violate source-control-artifacts.md rule. Remove .claude/ and .cursor/ directory files from commit; add specific patterns to .gitignore if intentional documentation is needed outside version control.
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (18 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely summarizes the primary change: recovering stale restored SSH PTY sessions after restart/update, which is the main objective of the PR.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Cmux Swift Actor Isolation ✅ Passed No actor isolation issues detected. Private helpers in struct CMUXCLI, static helper in final WorkspaceRemoteSessionController, and explicit nonisolated enum properly follow Swift 6 rules.
Cmux Swift Blocking Runtime ✅ Passed PR changes introduce no blocking/timing primitives; error handling, retry detection, and SSH command building use only string matching and synchronous value passing.
Cmux Expensive Synchronous Load ✅ Passed PR adds only lightweight string-checking helpers with no expensive sync loads. New code executes off-main via queue.async, avoiding main actor and interactive paths.
Cmux Cache Substitution Correctness ✅ Passed Cold cache handled via optional chaining on snapshot.remote. Stale cache handled via event-driven recovery with reverseRelayStartupFailureIndicatesStaleRemoteListener detection and cleanup.
Cmux No Hacky Sleeps ✅ Passed PR contains no new or worsened hacky sleeps in production non-Swift code. Swift changes are out of scope per rule. No sleep patterns added to in-scope TypeScript/JavaScript/Python/shell files.
Cmux Algorithmic Complexity ✅ Passed Error detection functions analyze single error strings, not collections. Test helper loops over small fixed-size surface lists. No algorithmic complexity rule violations identified.
Cmux Swift Concurrency ✅ Passed PR introduces no new legacy async patterns: changes are pure string parsing, error detection, and retry logic with no DispatchQueue.global(), Combine, or fire-and-forget Tasks.
Cmux Swift @Concurrent ✅ Passed All new Swift functions are synchronous helpers; no @concurrent violations, no nonisolated async work, and all operations remain properly isolated.
Cmux Swift File And Package Boundaries ✅ Passed PR adds small, focused bug fix changes (<30 lines each) to existing oversized files, well under 250-line violation threshold, without mixing responsibilities or violating boundaries.
Cmux Swift Logging ✅ Passed PR introduces no violations of swift-logging.md: no new unguarded print/NSLog/debugPrint/dump in runtime code; printf statements are shell script user-facing messages with no sensitive data.
Cmux User-Facing Error Privacy ✅ Passed User-facing errors are generic, mapped from upstream sources. Help text documents cmux config variables. No credentials, vendor names, provider flags, raw messages, or sensitive details exposed.
Cmux Swiftui State Layout ✅ Passed PR adds CLI error handlers and session logic without new SwiftUI state (@Published, @StateObject, @EnvironmentObject, @Observable, GeometryReader, or lazy list store issues).
Cmux Architecture Rethink ✅ Passed Small correctness fixes with clear invariants. Exit code 253 distinguishes missing PTY from transient failures via pure helper functions. Shell retry logic is required platform bridge code.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed PR changes SSH PTY session recovery logic with no new NSWindow, NSPanel, NSWindowController, or SwiftUI Window/WindowGroup code in modified Swift files.
Description check ✅ Passed PR description covers all required sections with clear summary, comprehensive testing details, and issue reference.
✨ 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 task-reproduce-stale-ssh-pty-docker

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 Jun 9, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR recovers stale restored SSH PTY sessions by adding exit code 253 for "session not found" errors, branching the startup shell script to fall back to a fresh remote shell on 253, and cleaning up stale remote relay listeners on port-forwarding startup failures.

  • ssh-pty-attach now exits 253 (instead of 1) when the remote daemon reports a missing session, letting startup scripts distinguish a definitive gone-session from a transient bridge drop (254/255).
  • retryingAttachLines gains a 253 handler that prints a user-visible warning and runs a fresh attach (without --require-existing) then exits; 254/255 transient retries are unchanged.
  • startReverseRelayLocked detects "remote port forwarding failed for listen port X" as a stale-listener error, triggers SSH-based cleanup, and uses a 0.25 s retry instead of 2 s when cleanup returns true \u2014 suppressing error status publication in that branch.

Confidence Score: 4/5

Safe to merge for the happy path; the stale-listener cleanup branch silently suppresses relay errors in an edge case where cleanup completes as a noop.

The core SSH PTY recovery flow (exit 253, fallback command, 253 shell handler) is correct and well-tested. The one real defect is in startReverseRelayLocked: cleanupStaleRemoteRelayListenerLocked returns true for both killed-a-process and SSH-succeeded-but-nothing-to-kill (noop), and the caller unconditionally suppresses publishDaemonStatus for both. A port that stays occupied after a noop cleanup causes an infinite silent retry loop with no user-visible error. This affects only the stale-listener code path, not the primary PTY restore path.

Sources/Workspace.swift — specifically the didCleanStaleListener branch that gates publishDaemonStatus.

Important Files Changed

Filename Overview
CLI/cmux.swift Adds exit-code 253 for missing-session errors and string-matching helper remotePTYErrorIndicatesMissingSession; logic is straightforward and tests confirm the new exit code.
Sources/Workspace.swift Adds stale-relay-listener detection and cleanup on startup failure; cleanupStaleRemoteRelayListenerLocked returns true for both killed-a-process and noop, causing publishDaemonStatus to be suppressed indefinitely when the port stays stuck.
Sources/WorkspaceRemoteConfiguration.swift Extends retryingAttachLines with a 253-exit fallback that launches a fresh remote shell; fallback runs once without a retry loop so a transient 254/255 on the fallback exits the shell permanently.
cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift Updates expected exit code from 1 to 253 for require-existing-not-found test; correct and minimal.
cmuxTests/TabManagerSessionSnapshotTests.swift Asserts restored SSH PTY commands include --command-b64, the 253 handler, and stale-session warning; matches new production behavior.
cmuxTests/WorkspaceRemoteConnectionTests.swift Adds unit tests for reverseRelayStartupFailureIndicatesStaleRemoteListener covering matching port, mismatched port, and unrelated errors.
tests_v2/test_ssh_remote_detachable_pty.py Adds _resolve_surface_id helper for more robust surface resolution after ssh-session-attach; clear improvement over fragile direct lookup.

Reviews (2): Last reviewed commit: "Recover stale SSH relay listeners on rel..." | Re-trigger Greptile

Comment thread CLI/cmux.swift
Comment on lines +10449 to +10455
private func remotePTYErrorIndicatesMissingSession(_ message: String) -> Bool {
let lowered = message.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !lowered.isEmpty else { return false }
return lowered.contains("pty_session_not_found") ||
(lowered.contains("persistent ssh pty session") && lowered.contains("not running")) ||
(lowered.contains("persistent pty session") && lowered.contains("not running"))
}

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 Duplicated string patterns between detection and messaging functions

remotePTYErrorIndicatesMissingSession repeats the exact same three contains clauses already present in userFacingRemotePTYErrorMessage (lines 10467–10470). If a new server error pattern is added to userFacingRemotePTYErrorMessage to emit the "no longer running" message but the same pattern is not mirrored here, exit code 253 won't fire, the fallback shell won't trigger, and the regression is silent. Consider refactoring so remotePTYErrorIndicatesMissingSession delegates to (or shares a constant with) userFacingRemotePTYErrorMessage to keep the two in sync.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment thread CLI/cmux.swift

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 81fb9fb6cc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

let attachCommand = "\"$cmux_ssh_attach_cli\" --socket \"$CMUX_SOCKET_PATH\" ssh-pty-attach --wait\(requireExistingFlag) --workspace \"$CMUX_WORKSPACE_ID\" --session-id \"$cmux_ssh_attach_session_id\" --attachment-id \"${CMUX_SURFACE_ID:-}\"\(commandB64Flag)"
lines += retryingAttachLines(command: attachCommand)
let fallbackCommand = requireExisting && !commandB64Flag.isEmpty
? "\"$cmux_ssh_attach_cli\" --socket \"$CMUX_SOCKET_PATH\" ssh-pty-attach --wait --workspace \"$CMUX_WORKSPACE_ID\" --session-id \"$cmux_ssh_attach_session_id\" --attachment-id \"${CMUX_SURFACE_ID:-}\"\(commandB64Flag)"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep recovered PTY surfaces tracked after fallback

When a restored attach exits 253, the first ssh-pty-attach process still runs its failed-attach cleanup and sends workspace.remote.pty_attach_end; that path calls Workspace.markRemotePTYAttachEnded, which removes the surface from activeRemoteTerminalSurfaceIds and clears remotePTYSessionIDsByPanelId. This fallback then starts a new PTY on the same surface, but there is no corresponding path that re-adds the restored session ID, so the recovered terminal is no longer treated as a remote PTY and the next session snapshot will drop the persistent PTY state.

Useful? React with 👍 / 👎.

@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 bdf5944. Configure here.

" if [ \"$cmux_ssh_attach_status\" -eq 253 ]; then",
" if [ -t 2 ]; then printf '\\n\\033[33m[cmux] persisted SSH PTY session is gone; starting a new remote shell.\\033[0m\\n' >&2 || true; fi",
" \(missingSessionFallbackCommand)",
" exit \"$?\"",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Fallback attach skips bridge retries

Medium Severity

After exit code 253, the stale-session fallback runs a second ssh-pty-attach and immediately exits with its status. That bypasses the surrounding retry loop that handles exit codes 254 and 255 for the primary attach. A recovered remote shell can therefore stop reattaching on transient bridge closes that the main path would retry.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit bdf5944. Configure here.

Comment thread Sources/Workspace.swift
Comment on lines +7089 to 7095
if !didCleanStaleListener {
publishDaemonStatus(
.error,
detail: "Remote SSH relay unavailable: \(startupFailure) (retry in \(retrySeconds)s)"
)
}
scheduleReverseRelayRestartLocked(remotePath: remotePath, delay: retryDelay)

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 Silent error suppression when cleanup finds nothing to kill

cleanupStaleRemoteRelayListenerLocked returns true for two distinct cases: (1) it killed a stale process, and (2) the SSH exec succeeded but found no process to kill (noop — empty stdout, logged as remoteListener.cleanupNoop). In the noop case didCleanStaleListener is still true, so publishDaemonStatus(.error, ...) is skipped and the 0.25 s retry fires. On the next attempt the same "remote port forwarding failed for listen port X" error triggers reverseRelayStartupFailureIndicatesStaleRemoteListener again → cleanup runs again → noop again → error suppressed again. There is no retry limit in scheduleReverseRelayRestartLocked, so a port that stays occupied by something the cleanup script can’t find produces an infinite silent retry loop: the relay never connects but the user sees no error and has no way to diagnose the failure.

The fix is to distinguish the noop case — only suppress status publication (and use the fast 0.25 s delay) when the cleanup actually terminated a process (non-empty stdout / a richer boolean signal from the helper), and fall back to publishing the status normally for the noop case.

This branch was successfully deployed

1 active deployment
Preview – cmux — bdf59442 Deployed Jun 9, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

stale-revisit Closed after 30+ days without activity; preserved for possible revisit or reopening.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants