Fix SSH control master cleanup on remote teardown#2104
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Caution Review failedPull request was closed or merged during review Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (2)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 WalkthroughWalkthroughTabManager routes child-exit closes for the SSH "last" panel through Changes
Sequence Diagram(s)sequenceDiagram
participant TM as TabManager
participant WS as Workspace
participant SSH as SSH Process (/usr/bin/ssh)
TM->>WS: closePanelAfterChildExited(tabId, surfaceId)
alt keepsRemoteWorkspaceOpen && surface is SSH-last
WS->>WS: shouldDemoteWorkspaceAfterChildExit(surfaceId) == true
TM->>WS: closeRuntimeSurface(tabId, surfaceId)
WS->>WS: demote workspace state (clear remote flags)
WS->>SSH: requestSSHControlMasterCleanupIfNeeded(args)
Note right of SSH: run `ssh ... -O exit` (async) or call test override
else normal child-exit close
TM->>WS: existing collapse / close-window flow
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 1 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (1 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
Greptile SummaryThis PR closes the SSH control-master socket whenever a remote workspace is torn down or demoted by capturing Key points:
Confidence Score: 3/5
Important Files Changed
Sequence DiagramsequenceDiagram
participant Caller
participant Workspace
participant CleanupQueue as sshControlMasterCleanupQueue
participant SSH as /usr/bin/ssh
Caller->>Workspace: disconnectRemoteConnection(clearConfiguration: true)
Workspace->>Workspace: configurationForCleanup = remoteConfiguration
Workspace->>Workspace: remoteConfiguration = nil
Workspace->>Workspace: applyBrowserRemoteWorkspaceStatusToPanels()
Workspace->>Workspace: recomputeListeningPorts()
Workspace->>Workspace: requestSSHControlMasterCleanupIfNeeded(configuration)
alt test override set
Workspace->>Caller: runSSHControlMasterCommandOverrideForTesting(arguments)
else production path
Workspace->>CleanupQueue: async { ssh -O exit ... }
CleanupQueue->>SSH: launch process
SSH-->>CleanupQueue: exit (control master closed)
end
Reviews (1): Last reviewed commit: "fix: close SSH control master on remote ..." | Re-trigger Greptile |
| process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") | ||
| process.arguments = arguments | ||
| process.standardInput = FileHandle.nullDevice | ||
| process.standardOutput = FileHandle.nullDevice |
There was a problem hiding this comment.
Unread stderr
Pipe may deadlock
process.standardError is assigned a Pipe() but its read end is never consumed. If ssh -O exit writes more data to stderr than the kernel pipe buffer (~64 KB on macOS), the ssh process will block in its write() syscall and waitUntilExit() will never return, hanging the cleanup queue worker.
Since the error output isn't needed for correctness here, redirecting to FileHandle.nullDevice is the safer choice:
| process.standardOutput = FileHandle.nullDevice | |
| process.standardError = FileHandle.nullDevice |
| "-o", "BatchMode=yes", | ||
| "-o", "ControlMaster=no", | ||
| "-o", "ControlPath=/tmp/cmux-ssh-%C", | ||
| "-o", "StrictHostKeyChecking=accept-new", |
There was a problem hiding this comment.
Expected argument mismatch —
StrictHostKeyChecking not in this test's config
This test's sshOptions does not include a StrictHostKeyChecking entry, but the expected-arguments array on this line asserts that one will be present in the captured output. sshControlMasterCleanupArguments only emits options that appear in the config, so this pair will never be produced and the XCTAssertEqual will fail.
The entry was likely carried over by copy-paste from the first test, which does include that option in its sshOptions. Remove this line (and its paired -o flag above it) from the expected array.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 502b677634
ℹ️ 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".
| "-o", "BatchMode=yes", | ||
| "-o", "ControlMaster=no", | ||
| "-o", "ControlPath=/tmp/cmux-ssh-%C", | ||
| "-o", "StrictHostKeyChecking=accept-new", |
There was a problem hiding this comment.
Fix teardown assertion to match configured SSH options
This test config does not include a StrictHostKeyChecking SSH option, but the expected cleanup argv still requires "-o", "StrictHostKeyChecking=accept-new". In sshControlMasterCleanupArguments, options are built from configuration.sshOptions, so this expectation is impossible for the provided input and the regression test will fail once executed, obscuring whether the teardown cleanup behavior is actually correct.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f241aa00e5
ℹ️ 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".
| process.standardError = Pipe() | ||
|
|
||
| do { | ||
| try process.run() | ||
| process.waitUntilExit() |
There was a problem hiding this comment.
Redirect or consume ssh stderr before waitUntilExit
This cleanup path sets process.standardError to a Pipe and then blocks on waitUntilExit() without reading from that pipe. If ssh -O exit emits enough stderr (for example from verbose or misconfigured SSH options), the child can block on a full pipe buffer and never exit, which then stalls the serial cleanup queue and prevents later teardown cleanups from running.
Useful? React with 👍 / 👎.
| guard sshOptions.contains(where: { sshOptionKeyForControlCleanup($0) == "controlpath" }) else { | ||
| return nil |
There was a problem hiding this comment.
Don’t skip cleanup when ControlPath comes from ssh config
sshControlMasterCleanupArguments returns nil unless configuration.sshOptions explicitly contains ControlPath, so teardown silently skips cleanup for sessions where the control socket is configured via ~/.ssh/config (or any caller that omits ControlPath from ssh_options). In that case remote teardown still clears workspace state, but the local ControlMaster process/socket is left orphaned.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
Sources/Workspace.swift (1)
6720-6762: Extract SSH option normalization into one shared helper.This is now the third copy of the trim/key/filter logic in this file. Pulling it into one fileprivate utility would keep cleanup, bootstrap, and relay behavior from drifting apart.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@Sources/Workspace.swift` around lines 6720 - 6762, Extract the repeated SSH option trimming/key/filter logic into a single fileprivate utility (e.g., fileprivate func normalizeSSHOptions(_:) and fileprivate func sshOptionKey(_:) ) and replace the existing implementations used by sshControlMasterCleanupArguments, normalizedSSHControlCleanupOptions, and sshOptionKeyForControlCleanup to call the new helpers; update the cleanup/bootstrap/relay callers to use the shared helpers so trimming, key extraction, and disallowed-key filtering (currently done via disallowedKeys = ["controlmaster","controlpersist"]) are centralized and the duplicated logic is removed.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@Sources/Workspace.swift`:
- Around line 6703-6717: The sshControlMasterCleanupQueue task can block forever
because Process.standardError is a Pipe with no reader and waitUntilExit() can
hang; change the cleanup to avoid a blocking wait by either redirecting
standardError to FileHandle.nullDevice or attach a
terminationHandler/onBackgroundThread to observe process termination instead of
calling waitUntilExit(), and ensure you still call try process.run() inside the
sshControlMasterCleanupQueue closure (refer to sshControlMasterCleanupQueue,
Process, process.run(), process.waitUntilExit(), and process.standardError) so
the queue never wedges on a single noisy/stuck ssh process.
---
Nitpick comments:
In `@Sources/Workspace.swift`:
- Around line 6720-6762: Extract the repeated SSH option trimming/key/filter
logic into a single fileprivate utility (e.g., fileprivate func
normalizeSSHOptions(_:) and fileprivate func sshOptionKey(_:) ) and replace the
existing implementations used by sshControlMasterCleanupArguments,
normalizedSSHControlCleanupOptions, and sshOptionKeyForControlCleanup to call
the new helpers; update the cleanup/bootstrap/relay callers to use the shared
helpers so trimming, key extraction, and disallowed-key filtering (currently
done via disallowedKeys = ["controlmaster","controlpersist"]) are centralized
and the duplicated logic is removed.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: d2fb0e1f-789d-4634-b13f-fb309da11735
📒 Files selected for processing (4)
Sources/TabManager.swiftSources/Workspace.swiftcmuxTests/TabManagerUnitTests.swiftcmuxTests/WorkspaceRemoteConnectionTests.swift
There was a problem hiding this comment.
♻️ Duplicate comments (1)
Sources/Workspace.swift (1)
6711-6722:⚠️ Potential issue | 🟠 MajorCleanup worker can still wedge the serial queue.
process.standardError = Pipe()without a reader plus blockingwaitUntilExit()can stall this queue indefinitely and block all later ControlMaster cleanup requests.Suggested fix
sshControlMasterCleanupQueue.async { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") process.arguments = arguments process.standardInput = FileHandle.nullDevice process.standardOutput = FileHandle.nullDevice - process.standardError = Pipe() + process.standardError = FileHandle.nullDevice + let exitSemaphore = DispatchSemaphore(value: 0) + process.terminationHandler = { _ in + exitSemaphore.signal() + } do { try process.run() - process.waitUntilExit() + if exitSemaphore.wait(timeout: .now() + 5.0) == .timedOut, process.isRunning { + process.terminate() + } } catch { return } }#!/bin/bash # Verify the problematic pattern still exists in Sources/Workspace.swift rg -n -C2 'process\.standardError = Pipe\(\)|process\.waitUntilExit\(\)' Sources/Workspace.swift🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@Sources/Workspace.swift` around lines 6711 - 6722, The cleanup task can wedge sshControlMasterCleanupQueue because process.standardError = Pipe() has no reader and waitUntilExit() blocks the serial queue; change the code in the sshControlMasterCleanupQueue.async block so the Process does not create an unread Pipe and does not synchronously wait: either set process.standardError = FileHandle.nullDevice or, if you need stderr, create a Pipe and asynchronously read its fileHandleForReading on a background queue, then replace the blocking try { process.run(); process.waitUntilExit() } with try process.run() plus a process.terminationHandler { _ in /* handle exit */ } so the queue is not blocked waiting for the child to exit.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Duplicate comments:
In `@Sources/Workspace.swift`:
- Around line 6711-6722: The cleanup task can wedge sshControlMasterCleanupQueue
because process.standardError = Pipe() has no reader and waitUntilExit() blocks
the serial queue; change the code in the sshControlMasterCleanupQueue.async
block so the Process does not create an unread Pipe and does not synchronously
wait: either set process.standardError = FileHandle.nullDevice or, if you need
stderr, create a Pipe and asynchronously read its fileHandleForReading on a
background queue, then replace the blocking try { process.run();
process.waitUntilExit() } with try process.run() plus a
process.terminationHandler { _ in /* handle exit */ } so the queue is not
blocked waiting for the child to exit.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 05991187-9047-4b06-83f2-827c5de44712
📒 Files selected for processing (3)
Sources/TabManager.swiftSources/Workspace.swiftcmuxTests/TabManagerUnitTests.swift
✅ Files skipped from review due to trivial changes (1)
- cmuxTests/TabManagerUnitTests.swift
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
Sources/Workspace.swift (1)
6617-6647:⚠️ Potential issue | 🟠 MajorSkip ControlMaster cleanup while the last remote panel is only being detached.
When the last remote terminal is detached from a workspace,
closePanel()stores it inpendingDetachedSurfacesfor later attachment elsewhere, then callsclearRemoteConfigurationIfWorkspaceBecameLocal(). If the workspace becomes empty, this triggersdisconnectRemoteConnection(clearConfiguration: true), which schedulesssh -O exitat line 6645. The detached panel's active SSH session is still needed after attachment, so the ControlMaster should not be torn down during a detach transaction.🛠️ Minimal guard in this path
func disconnectRemoteConnection(clearConfiguration: Bool = false) { - let configurationForCleanup = clearConfiguration ? remoteConfiguration : nil + let shouldCleanupControlMaster = + clearConfiguration && !isDetachingCloseTransaction + let configurationForCleanup = shouldCleanupControlMaster ? remoteConfiguration : nil let previousController = remoteSessionController activeRemoteSessionControllerID = nil remoteSessionController = nil🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@Sources/Workspace.swift` around lines 6617 - 6647, disconnectRemoteConnection currently always schedules SSH ControlMaster cleanup when clearConfiguration is true, which tears down an SSH master even if the last remote terminal was only detached for reattachment; change the final cleanup call so it only runs when there are no pending detached panels: in disconnectRemoteConnection(...) guard on pendingDetachedSurfaces.isEmpty before calling Self.requestSSHControlMasterCleanupIfNeeded(configuration:), referencing the pendingDetachedSurfaces collection and the requestSSHControlMasterCleanupIfNeeded(...) helper so a detach/reattach transaction preserves the active ControlMaster; no other behavior changes.
♻️ Duplicate comments (1)
Sources/Workspace.swift (1)
6711-6725:⚠️ Potential issue | 🟠 MajorKeep the cleanup worker bounded.
Line 6721 can still block the serial
sshControlMasterCleanupQueueindefinitely if onessh -O exitwedges, so every later cleanup request stalls behind it. This is the same queue-wedge risk called out earlier; the stderr pipe fix is in place now, but the unboundedwaitUntilExit()still reintroduces it.🛠️ Bound the wait instead of blocking forever
sshControlMasterCleanupQueue.async { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") process.arguments = arguments process.standardInput = FileHandle.nullDevice process.standardOutput = FileHandle.nullDevice process.standardError = FileHandle.nullDevice + let exitSemaphore = DispatchSemaphore(value: 0) + process.terminationHandler = { _ in + exitSemaphore.signal() + } do { try process.run() - process.waitUntilExit() + if exitSemaphore.wait(timeout: .now() + 5) == .timedOut, process.isRunning { + process.terminate() + } } catch { return } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@Sources/Workspace.swift` around lines 6711 - 6725, The cleanup closure currently calls process.run() then process.waitUntilExit() on the serial sshControlMasterCleanupQueue which can wedge the queue; replace the blocking wait with a non‑blocking pattern: after try process.run(), do not call process.waitUntilExit(); instead set process.terminationHandler to handle completion and return immediately from the queue closure, and schedule a timeout (e.g., DispatchQueue.global().asyncAfter) that checks process.isRunning and calls process.terminate() if it exceeds the bound. Locate the code around sshControlMasterCleanupQueue, the Process() instance and the lines that call process.run() and process.waitUntilExit(), and implement the terminationHandler + timeout + process.terminate() approach so the serial queue never blocks indefinitely.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Outside diff comments:
In `@Sources/Workspace.swift`:
- Around line 6617-6647: disconnectRemoteConnection currently always schedules
SSH ControlMaster cleanup when clearConfiguration is true, which tears down an
SSH master even if the last remote terminal was only detached for reattachment;
change the final cleanup call so it only runs when there are no pending detached
panels: in disconnectRemoteConnection(...) guard on
pendingDetachedSurfaces.isEmpty before calling
Self.requestSSHControlMasterCleanupIfNeeded(configuration:), referencing the
pendingDetachedSurfaces collection and the
requestSSHControlMasterCleanupIfNeeded(...) helper so a detach/reattach
transaction preserves the active ControlMaster; no other behavior changes.
---
Duplicate comments:
In `@Sources/Workspace.swift`:
- Around line 6711-6725: The cleanup closure currently calls process.run() then
process.waitUntilExit() on the serial sshControlMasterCleanupQueue which can
wedge the queue; replace the blocking wait with a non‑blocking pattern: after
try process.run(), do not call process.waitUntilExit(); instead set
process.terminationHandler to handle completion and return immediately from the
queue closure, and schedule a timeout (e.g., DispatchQueue.global().asyncAfter)
that checks process.isRunning and calls process.terminate() if it exceeds the
bound. Locate the code around sshControlMasterCleanupQueue, the Process()
instance and the lines that call process.run() and process.waitUntilExit(), and
implement the terminationHandler + timeout + process.terminate() approach so the
serial queue never blocks indefinitely.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: fa9d66f8-76fd-4a4a-9a40-453280006cd3
📒 Files selected for processing (2)
Sources/Workspace.swiftcmuxTests/WorkspaceRemoteConnectionTests.swift
✅ Files skipped from review due to trivial changes (1)
- cmuxTests/WorkspaceRemoteConnectionTests.swift
There was a problem hiding this comment.
♻️ Duplicate comments (1)
Sources/Workspace.swift (1)
6711-6721:⚠️ Potential issue | 🟠 MajorBound the serial cleanup worker.
sshControlMasterCleanupQueueis serial, andprocess.waitUntilExit()has no timeout. One stuckssh -O exitwill block every later ControlMaster cleanup request on that queue.🛠️ One way to keep the worker bounded
sshControlMasterCleanupQueue.async { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") process.arguments = arguments process.standardInput = FileHandle.nullDevice process.standardOutput = FileHandle.nullDevice process.standardError = FileHandle.nullDevice + let exitSemaphore = DispatchSemaphore(value: 0) + process.terminationHandler = { _ in + exitSemaphore.signal() + } do { try process.run() - process.waitUntilExit() + if exitSemaphore.wait(timeout: .now() + 5) == .timedOut, process.isRunning { + process.terminate() + if exitSemaphore.wait(timeout: .now() + 1) == .timedOut, process.isRunning { + _ = Darwin.kill(process.processIdentifier, SIGKILL) + } + } } catch { return } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@Sources/Workspace.swift` around lines 6711 - 6721, The serial cleanup worker uses sshControlMasterCleanupQueue and blocks on process.waitUntilExit(), so a stuck "ssh -O exit" can stall the entire queue; modify the cleanup task around Process.run()/process.waitUntilExit() to enforce a bounded timeout: after starting the Process (in the sshControlMasterCleanupQueue.async block) start a short timer (or use DispatchSemaphore/DispatchGroup with wait(timeout:)) and if the wait times out, call process.terminate() (and if needed process.kill via SIGKILL) and then wait for exit with a short grace period, and always return from the queue task—ensure the change touches the code that creates Process, calls try process.run(), and currently uses process.waitUntilExit() so that waiting is bounded and the queue cannot be indefinitely blocked.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Duplicate comments:
In `@Sources/Workspace.swift`:
- Around line 6711-6721: The serial cleanup worker uses
sshControlMasterCleanupQueue and blocks on process.waitUntilExit(), so a stuck
"ssh -O exit" can stall the entire queue; modify the cleanup task around
Process.run()/process.waitUntilExit() to enforce a bounded timeout: after
starting the Process (in the sshControlMasterCleanupQueue.async block) start a
short timer (or use DispatchSemaphore/DispatchGroup with wait(timeout:)) and if
the wait times out, call process.terminate() (and if needed process.kill via
SIGKILL) and then wait for exit with a short grace period, and always return
from the queue task—ensure the change touches the code that creates Process,
calls try process.run(), and currently uses process.waitUntilExit() so that
waiting is bounded and the queue cannot be indefinitely blocked.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: c8d952f0-6064-400a-a715-de1ad953f10e
📒 Files selected for processing (2)
Sources/Workspace.swiftcmuxTests/WorkspaceRemoteConnectionTests.swift
🚧 Files skipped from review as they are similar to previous changes (1)
- cmuxTests/WorkspaceRemoteConnectionTests.swift
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a24f90aec6
ℹ️ 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 shouldCleanupControlMaster = | ||
| clearConfiguration && !isDetachingCloseTransaction && pendingDetachedSurfaces.isEmpty | ||
| let configurationForCleanup = shouldCleanupControlMaster ? remoteConfiguration : nil |
There was a problem hiding this comment.
Skip cleanup after moving the last remote surface away
When a remote terminal is detached to another workspace/window, cleanupEmptySourceWorkspaceAfterSurfaceMove closes the now-empty source workspace, which calls teardownRemoteConnection(). At this check, shouldCleanupControlMaster becomes true because the detach transaction has already ended and pendingDetachedSurfaces has been drained, so the source workspace still issues ssh -O exit for that host. If SSH multiplexing is in use, that can shut down the ControlMaster still serving the moved terminal and kill the session right after a successful move.
Useful? React with 👍 / 👎.
* test: add SSH control master cleanup regressions * fix: close SSH control master on remote teardown * test: keep SSH workspace after child exit * fix: keep SSH workspace after child exit * fix: keep connecting SSH workspaces after child exit * test: add SSH child-exit demotion regression * fix: keep SSH workspace after connected shell exit * fix: address SSH cleanup review feedback * test: cover SSH cleanup without explicit controlpath * fix: clean up SSH control masters without explicit controlpath * test: cover remote detach cleanup edge cases * fix: preserve SSH sessions during remote detach --------- Co-authored-by: Lawrence Chen <lawrencecchen@users.noreply.github.com>
Summary
Verification
xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux-unit -destination 'platform=macOS' -derivedDataPath /tmp/cmux-feat-ssh-session-end-controlmaster-cleanup-unit4 build-for-testing -only-testing:cmuxTests/WorkspaceRemoteConnectionTests/testRemoteTerminalSessionEndRequestsControlMasterCleanupWhenWorkspaceDemotes -only-testing:cmuxTests/WorkspaceRemoteConnectionTests/testTeardownRemoteConnectionRequestsControlMasterCleanupWhileStillConnecting -only-testing:cmuxTests/WorkspaceRemoteConnectionTests/testRemoteTerminalSessionEndSkipsControlMasterCleanupWhenBrowserPanelsKeepWorkspaceRemote\n-./scripts/reload.sh --tag feat-ssh-session-end-controlmaster-cleanup\n- runtime probe:cmux ssh cmux-macmini --name cleanup-probe -- sh -lc 'exit 0'followed by checking the relay files oncmux-macminiand confirmingpgrep -fal /tmp/cmux-ssh-<uid>-<relayPort>returns nothing\n\n## Notes\n-cmux-unit teststill hits the existing local AppKit test-host crash here, so I usedbuild-for-testingplus the tagged runtime repro for fresh evidence.Summary by cubic
Closes the local SSH ControlMaster on remote teardown or demotion, and keeps the workspace by demoting to local when the last SSH panel exits. Handles connecting/ended sessions and skips cleanup during remote detach or when browser panels keep the workspace remote to prevent orphaned sockets and accidental closes.
ssh -O exitin background withBatchMode=yes; ignoreControlMaster/ControlPersist; keep-p/-i; includeControlPathif set; discard stdio.runSSHControlMasterCommandOverrideForTestinghook; works without explicitControlPath.Written for commit a24f90a. Summary will update on new commits.
Summary by CodeRabbit
Bug Fixes
Tests