Skip to content

Keep SSH sessions alive when closing a pane - #3566

Merged
austinywang merged 2 commits into
mainfrom
issue-3556-ssh-pane-close-kills-session
May 5, 2026
Merged

austinywang merged 2 commits into
mainfrom
issue-3556-ssh-pane-close-kills-session

Conversation

@austinywang

@austinywang austinywang commented May 5, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #3556.\n\n## Summary\n- adds a regression test proving SSH pane-close HUP/TERM signals must not emit ssh-session-end\n- keeps ssh-session-end cleanup for real SSH command completion, but suppresses it for signal-driven wrapper exit\n\n## Verification\n- git diff --check\n- local tests not run per task instruction


Note

Medium Risk
Changes SSH startup wrapper signal/trap behavior, which can affect remote session cleanup and exit status handling; covered by a new regression test but still impacts a user-critical connection lifecycle path.

Overview
Prevents pane-close signals from triggering ssh-session-end by splitting the SSH startup wrapper traps: EXIT still runs cmux_ssh_session_end, while HUP/INT/TERM now clear traps and exit with signal-derived statuses (129/130/143) to avoid tearing down shared SSH transports.

Adds SSHStartupSignalLifecycleTests (wired into the Xcode project) to assert that signal-driven exits do not emit ssh-session-end and that the wrapper returns the expected exit codes.

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


Summary by cubic

Keep the shared SSH transport alive when a pane closes by handling HUP/INT/TERM in the startup wrapper without emitting ssh-session-end; real SSH exits still run cleanup. Signal exits now return 129/130/143 and never tear down ControlMaster (fixes #3556).

  • Bug Fixes
    • Introduced cmux_ssh_signal_exit; EXIT runs cleanup, while HUP/INT/TERM clear traps and exit with 129/130/143 without calling ssh-session-end.
    • Added SSHStartupSignalLifecycleTests (wired into cmuxTests) using fake cmux/ssh to assert no ssh-session-end on pane-close signals and correct exit statuses.

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

Summary by CodeRabbit

  • Bug Fixes
    • Improved SSH session signal handling so termination signals are handled more reliably, ensuring graceful shutdown and correct exit status reporting.
  • Tests
    • Added integration tests covering SSH startup and signal lifecycle to prevent regressions around session-end reporting.

@vercel

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

@coderabbitai

coderabbitai Bot commented May 5, 2026 •

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Adds a shell helper cmux_ssh_signal_exit() and rewires traps so EXIT runs the existing session-end handler while HUP/INT/TERM call cmux_ssh_signal_exit with exit codes 129/130/143. Adds an XCTest that ensures those signals do not cause ssh-session-end calls.

Changes

Signal Exit Handling & Tests

Layer / File(s) Summary
Core Signal Logic
CLI/cmux.swift
Adds cmux_ssh_signal_exit() which stores a provided exit status, disables EXIT/HUP/INT/TERM traps, and exits with that status.
Trap Wiring
CLI/cmux.swift
Replaces the combined trap 'cmux_ssh_session_end' EXIT HUP INT TERM with EXIT wired to cmux_ssh_session_end and HUP/INT/TERM wired to cmux_ssh_signal_exit with exit codes 129/130/143.
Test Addition (project file)
GhosttyTabs.xcodeproj/project.pbxproj
Adds SSHStartupSignalLifecycleTests.swift as a PBXFileReference and registers it in the cmuxTests PBXSourcesBuildPhase and group children.
Test Implementation
cmuxTests/SSHStartupSignalLifecycleTests.swift
Adds testSSHPaneCloseSignalDoesNotReportSessionEndToSharedTransport() which installs stub cmux/ssh scripts, generates a reusable SSH startup command, runs it under /bin/sh -c for signals HUP/INT/TERM, and asserts no ssh-session-end calls are logged. Also adds generatedSSHStartupCommand() and writeShellFile(at:lines:) helpers.
sequenceDiagram
    participant Test
    participant Shell as "Shell Environment"
    participant Trap as "Signal Trap Handler"
    participant CMUX as "cmux (stub logger)"
    participant Server as "Mock JSON-RPC Server"

    Test->>Shell: Set env (CMUX_TEST_*, CMUX_TEST_SIGNAL)
    Test->>Server: Start mock workspace RPC
    Test->>CMUX: Start stub cmux (background)
    Test->>Shell: Execute SSH startup command
    Shell->>Trap: Receive signal (HUP/INT/TERM)
    Trap->>Trap: Call cmux_ssh_signal_exit(status 129/130/143)
    Trap->>Shell: Disable further traps and exit with recorded status
    CMUX->>Test: Log entries (if any)
    Test->>CMUX: Assert no "ssh-session-end" logged
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 A twitch, a trap, a gentle hop,
HUP and INT and TERM—no stop.
I log no end where none should be,
Exit with care, then bound from tee.
🥕 Quiet shells, hop on with me.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
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.
Description check ⚠️ Warning The PR description covers the main change and testing approach, but is missing key required sections from the template. Add the 'Testing' section with details on how the change was tested, and include the 'Review Trigger' block with bot review requests. Also include the completion checklist from the template.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title directly describes the main change: preventing SSH sessions from being terminated when closing a pane, which is the core objective of the pull request.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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-3556-ssh-pane-close-kills-session

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 5, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

Fixes #3556 by splitting the single trap 'cmux_ssh_session_end' EXIT HUP INT TERM into a dedicated EXIT trap for real SSH command completion and separate HUP/INT/TERM traps that clear all traps and exit with POSIX-derived status codes (129/130/143), ensuring the shared SSH ControlMaster is not torn down when a pane is closed by the terminal.

  • CLI/cmux.swift: Adds cmux_ssh_signal_exit() helper that atomically clears all traps then exits with the supplied status code; replaces the combined trap with four targeted traps so cleanup only runs on true SSH command completion.
  • cmuxTests/SSHStartupSignalLifecycleTests.swift: Adds an integration regression test that generates a real startup command, injects a fake cmux sentinel and a fake ssh that self-signals, and asserts that HUP, INT, and TERM each produce the correct exit status and never invoke ssh-session-end.

Confidence Score: 5/5

Safe to merge — the trap split is minimal and correct, the race window between subprocess exit and trap clearance is negligible, and the regression test exercises all three signal paths end-to-end.

The production change touches four lines of shell embedded in Swift string literals. The signal handler atomically clears all traps before calling exit, so the EXIT cleanup can never fire on a pane-close signal. The normal-exit path still calls cleanup explicitly then clears traps, so there is no double-invocation path. The new test generates the real startup command from the live CLI, injects a fake cmux sentinel, and confirms HUP/INT/TERM each produce the right status code without touching ssh-session-end.

No files require special attention.

Important Files Changed

Filename Overview
CLI/cmux.swift Splits the single combined trap into one EXIT trap for session cleanup and three separate signal traps (HUP/INT/TERM) that clear all traps and exit with POSIX-derived status codes, preventing ssh-session-end from being called on pane-close signals.
cmuxTests/SSHStartupSignalLifecycleTests.swift Adds SSHStartupSignalLifecycleTests as an extension on CLINotifyProcessIntegrationRegressionTests; generates a real startup command via the bundled CLI, injects a fake cmux and fake ssh, and asserts that pane-close signals (HUP, INT, TERM) never write ssh-session-end to the capture log.
GhosttyTabs.xcodeproj/project.pbxproj Registers SSHStartupSignalLifecycleTests.swift in both the file references and the cmuxTests Sources build phase; mechanical change with consistent UUIDs.

Sequence Diagram

sequenceDiagram
    participant P as Pane / tmux
    participant W as SSH Wrapper Script
    participant S as ssh subprocess
    participant C as cmux (ssh-session-end)

    Note over W: EXIT trap → cmux_ssh_session_end
    Note over W: HUP/INT/TERM → cmux_ssh_signal_exit

    W->>S: command ssh ...
    S-->>W: (normal exit, status N)
    W->>W: cmux_ssh_status=$?
    W->>W: trap - EXIT HUP INT TERM
    W->>C: cmux_ssh_session_end (cleanup runs)
    W-->>P: exit $cmux_ssh_status

    P->>W: TERM (pane closed)
    Note over W: Signal queued while ssh runs
    S-->>W: (fake ssh exits)
    W->>W: cmux_ssh_signal_exit 143
    W->>W: trap - EXIT HUP INT TERM
    Note over C: ssh-session-end NOT called
    W-->>P: exit 143
Loading

Reviews (6): Last reviewed commit: "Keep SSH transport alive on pane-close s..." | Re-trigger Greptile

Comment thread cmuxTests/WorkspaceRemoteConnectionTests.swift Outdated
@austinywang
austinywang force-pushed the issue-3556-ssh-pane-close-kills-session branch 2 times, most recently from 90a2058 to 30880d0 Compare May 5, 2026 18:37
coderabbitai[bot]
coderabbitai Bot previously requested changes May 5, 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`:
- Line 3675: The file exceeds the Swift file-length budget due to the added
test; extract the test function
testSSHPaneCloseSignalDoesNotReportSessionEndToSharedTransport (and any small
shared helpers it uses) into a new test file (e.g.,
SSHStartupWrapperTests.swift) or an existing SSH-focused test file, update
imports and test target membership, and ensure any shared fixtures/mocks
referenced by that function are either moved or made available via internal
helpers so the test compiles and runs in its new file.
- Around line 3685-3688: The fake CLI currently logs every cmux invocation (the
script written by writeShellFile uses CMUX_TEST_SESSION_END_LOG and prints
"$*"), but the test asserts recordedCalls.isEmpty with a message about "must not
call ssh-session-end"; change the assertion to filter recordedCalls for only
lines containing the literal "ssh-session-end" (e.g., let sessionEndCalls =
recordedCalls.filter { $0.contains("ssh-session-end") } and assert
sessionEndCalls.isEmpty) and update the failure message to reference
"ssh-session-end" specifically; alternatively, if you prefer to keep the
existing assertion semantics, rename CMUX_TEST_SESSION_END_LOG to
CMUX_TEST_ALL_CALLS_LOG and update the assertion message to "must not call cmux
at all".
- Line 3692: The test loop that verifies non-session-end trapping currently
iterates only over signals ["HUP", "TERM"] (the for loop in
WorkspaceRemoteConnectionTests.swift) and omits "INT"; update the loop to
include "INT" (i.e. iterate over ["HUP", "INT", "TERM"]) so the INT trap is also
tested and cannot regress to calling ssh-session-end.
- Around line 3685-3689: The call to writeShellFile from
CLINotifyProcessIntegrationTests fails because writeShellFile is declared
private inside WorkspaceRemoteConnectionTests; change the declaration of
writeShellFile to fileprivate so both WorkspaceRemoteConnectionTests and
CLINotifyProcessIntegrationTests in this file can call it (or alternatively add
a duplicate private helper inside CLINotifyProcessIntegrationTests if you prefer
separation). After making that visibility change, address the file-size budget
overflow (~4172 > 4122) by either trimming/reducing test content in this file or
requesting a repository/test budget increase so the file can remain this large.
🪄 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: 4f263db3-2c45-467c-89e9-62e417bbb195

📥 Commits

Reviewing files that changed from the base of the PR and between 5540043 and a5a9508.

📒 Files selected for processing (2)
  • CLI/cmux.swift
  • cmuxTests/WorkspaceRemoteConnectionTests.swift

Comment thread cmuxTests/WorkspaceRemoteConnectionTests.swift Outdated
Comment thread cmuxTests/WorkspaceRemoteConnectionTests.swift Outdated
Comment thread cmuxTests/WorkspaceRemoteConnectionTests.swift Outdated
try fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: fakeCLI.path)

let cli = CMUXCLI(args: [])
for signal in ["HUP", "TERM"] {

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 | 🟡 Minor | ⚡ Quick win

SIGINT omitted from the signal loop

The PR description, commit message, and AI summary all state that HUP/INT/TERM are now routed through the new non-session-end trap. The loop at line 3692 tests only ["HUP", "TERM"], leaving INT unverified. If the INT trap is ever accidentally reverted to call ssh-session-end, this test will not catch it.

✅ Proposed fix
-        for signal in ["HUP", "TERM"] {
+        for signal in ["HUP", "INT", "TERM"] {
📝 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
for signal in ["HUP", "TERM"] {
for signal in ["HUP", "INT", "TERM"] {
🤖 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` at line 3692, The test loop
that verifies non-session-end trapping currently iterates only over signals
["HUP", "TERM"] (the for loop in WorkspaceRemoteConnectionTests.swift) and omits
"INT"; update the loop to include "INT" (i.e. iterate over ["HUP", "INT",
"TERM"]) so the INT trap is also tested and cannot regress to calling
ssh-session-end.

@austinywang
austinywang force-pushed the issue-3556-ssh-pane-close-kills-session branch 2 times, most recently from ea2ebaf to ee3a6e5 Compare May 5, 2026 18:42
@austinywang
austinywang dismissed coderabbitai[bot]’s stale review May 5, 2026 18:43

Resolved in later commits; all inline threads addressed and stale review was on old commit a5a9508.

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

🤖 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/SSHStartupSignalLifecycleTests.swift`:
- Line 9: Add an explicit empty deinitializer to the
SSHStartupSignalLifecycleTests XCTestCase class to satisfy the required_deinit
SwiftLint rule; locate the class declaration (SSHStartupSignalLifecycleTests)
and add a deinit { } implementation (or include any necessary teardown logic
inside that deinit) so the class no longer triggers the rule.
🪄 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: 6fd07dcc-b732-4a5d-a0b4-281a372018f3

📥 Commits

Reviewing files that changed from the base of the PR and between a5a9508 and 30880d0.

📒 Files selected for processing (3)
  • CLI/cmux.swift
  • GhosttyTabs.xcodeproj/project.pbxproj
  • cmuxTests/SSHStartupSignalLifecycleTests.swift

Comment thread cmuxTests/SSHStartupSignalLifecycleTests.swift Outdated
@austinywang
austinywang force-pushed the issue-3556-ssh-pane-close-kills-session branch from ee3a6e5 to 66a62bc Compare May 5, 2026 18:46
coderabbitai[bot]
coderabbitai Bot previously requested changes May 5, 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

🤖 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/SSHStartupSignalLifecycleTests.swift`:
- Around line 42-58: The test should also assert the wrapper exited with the
signal-derived code (128 + signal) to ensure the cmux_ssh_signal_exit contract
is honored: after runProcess(...) capture result.status and assert it equals 128
+ the numeric value of the signal used in startupCommand (or use an explicit
mapping for signals 1->129, 2->130, 15->143), e.g. add an
XCTAssertEqual(result.status, expectedExitCode) alongside the existing
assertions so failures that swallow or incorrectly handle the signal are
detected; reference runProcess, startupCommand, result.status and the
cmux_ssh_signal_exit contract when implementing.
🪄 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: 92f63732-226c-4d3d-a2dc-4954501d5dad

📥 Commits

Reviewing files that changed from the base of the PR and between 30880d0 and 1015d7a.

📒 Files selected for processing (3)
  • CLI/cmux.swift
  • GhosttyTabs.xcodeproj/project.pbxproj
  • cmuxTests/SSHStartupSignalLifecycleTests.swift

Comment thread cmuxTests/SSHStartupSignalLifecycleTests.swift
Pane closes can deliver HUP or TERM to the SSH startup wrapper while sibling panes still depend on the same ProxyCommand-backed transport. This regression test executes the generated reusable SSH startup command with a fake cmux binary and records whether signal-driven teardown reports ssh-session-end.

Constraint: Do not run local tests; CI must prove this red commit fails before the fix.

Confidence: high

Scope-risk: narrow

Tested: git diff --check

Not-tested: Local XCTest execution intentionally skipped per task instruction
Pane close tears down the terminal process, not the shared SSH workspace transport. The SSH startup wrapper now distinguishes signal-driven wrapper exit from the SSH command finishing, so HUP/INT/TERM no longer emit ssh-session-end and cannot request ControlMaster cleanup while sibling panes still rely on the transport.

Constraint: Regression introduced by SSH lifecycle cleanup that treated pane-close signals as remote session completion.

Rejected: Disable remote session-end cleanup entirely | real SSH command exits still need workspace demotion and ControlMaster cleanup when the last session is actually gone.

Confidence: high

Scope-risk: narrow

Tested: git diff --check

Not-tested: Local XCTest execution intentionally skipped per task instruction
@austinywang
austinywang force-pushed the issue-3556-ssh-pane-close-kills-session branch from 1015d7a to d80e4b0 Compare May 5, 2026 19:09
@austinywang
austinywang dismissed stale reviews from coderabbitai[bot] and coderabbitai[bot] May 5, 2026 19:24

Stale CodeRabbit review on an older commit; the requested signal-exit-status assertion was added in the current commit and the latest CodeRabbit review approved it.

@austinywang
austinywang merged commit 1e82977 into main May 5, 2026
27 checks passed
@austinywang austinywang mentioned this pull request May 5, 2026

This branch was successfully deployed

1 active deployment
Preview – cmux — d80e4b02 Deployed May 5, 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.

Closing a pane in an ssh session kills the whole ssh session

1 participant