Skip SSH cleanup after moving the last remote surface (#2123)
* Add regression test for detached remote cleanup * Skip SSH cleanup after remote surface transfer * Add SSH lifecycle regression coverage * Add SSH detach cleanup transfer regressions * Transfer SSH cleanup ownership with detached remote terminals * Document intentional SSH workspace focus --------- Co-authored-by: Lawrence Chen <lawrencecchen@users.noreply.github.com>
This commit is contained in:
parent
58dc932248
commit
0a1d8c2289
3 changed files with 437 additions and 8 deletions
|
|
@ -3746,6 +3746,9 @@ struct CMUXCLI {
|
|||
if let workspaceWindowId, !workspaceWindowId.isEmpty {
|
||||
selectParams["window_id"] = workspaceWindowId
|
||||
}
|
||||
// `cmux ssh` is an explicit "open this remote workspace now" action,
|
||||
// so we intentionally select the newly created workspace after wiring
|
||||
// up the remote connection.
|
||||
_ = try client.sendV2(method: "workspace.select", params: selectParams)
|
||||
let remoteState = ((configuredPayload["remote"] as? [String: Any])?["state"] as? String) ?? "unknown"
|
||||
cliDebugLog(
|
||||
|
|
|
|||
|
|
@ -5837,12 +5837,39 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
let manuallyUnread: Bool
|
||||
let isRemoteTerminal: Bool
|
||||
let remoteRelayPort: Int?
|
||||
let remoteCleanupConfiguration: WorkspaceRemoteConfiguration?
|
||||
|
||||
func withRemoteCleanupConfiguration(_ configuration: WorkspaceRemoteConfiguration?) -> Self {
|
||||
Self(
|
||||
panelId: panelId,
|
||||
panel: panel,
|
||||
title: title,
|
||||
icon: icon,
|
||||
iconImageData: iconImageData,
|
||||
kind: kind,
|
||||
isLoading: isLoading,
|
||||
isPinned: isPinned,
|
||||
directory: directory,
|
||||
ttyName: ttyName,
|
||||
cachedTitle: cachedTitle,
|
||||
customTitle: customTitle,
|
||||
manuallyUnread: manuallyUnread,
|
||||
isRemoteTerminal: isRemoteTerminal,
|
||||
remoteRelayPort: remoteRelayPort,
|
||||
remoteCleanupConfiguration: configuration
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var detachingTabIds: Set<TabID> = []
|
||||
private var pendingDetachedSurfaces: [TabID: DetachedSurfaceTransfer] = [:]
|
||||
private var activeDetachCloseTransactions: Int = 0
|
||||
private var isDetachingCloseTransaction: Bool { activeDetachCloseTransactions > 0 }
|
||||
// When the last live remote terminal is detached out, the source workspace may be
|
||||
// closed immediately after the move succeeds. That teardown must not shut down the
|
||||
// shared SSH control master that is still serving the moved terminal.
|
||||
private var skipControlMasterCleanupAfterDetachedRemoteTransfer = false
|
||||
private var transferredRemoteCleanupConfigurationsByPanelId: [UUID: WorkspaceRemoteConfiguration] = [:]
|
||||
|
||||
#if DEBUG
|
||||
private func debugElapsedMs(since start: TimeInterval) -> String {
|
||||
|
|
@ -6797,6 +6824,7 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
}
|
||||
|
||||
func configureRemoteConnection(_ configuration: WorkspaceRemoteConfiguration, autoConnect: Bool = true) {
|
||||
skipControlMasterCleanupAfterDetachedRemoteTransfer = false
|
||||
remoteConfiguration = configuration
|
||||
seedInitialRemoteTerminalSessionIfNeeded(configuration: configuration)
|
||||
remoteDetectedPorts = []
|
||||
|
|
@ -6847,7 +6875,10 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
|
||||
func disconnectRemoteConnection(clearConfiguration: Bool = false) {
|
||||
let shouldCleanupControlMaster =
|
||||
clearConfiguration && !isDetachingCloseTransaction && pendingDetachedSurfaces.isEmpty
|
||||
clearConfiguration
|
||||
&& !isDetachingCloseTransaction
|
||||
&& pendingDetachedSurfaces.isEmpty
|
||||
&& !skipControlMasterCleanupAfterDetachedRemoteTransfer
|
||||
let configurationForCleanup = shouldCleanupControlMaster ? remoteConfiguration : nil
|
||||
let previousController = remoteSessionController
|
||||
activeRemoteSessionControllerID = nil
|
||||
|
|
@ -6871,6 +6902,7 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
remoteLastPortConflictFingerprint = nil
|
||||
if clearConfiguration {
|
||||
remoteConfiguration = nil
|
||||
skipControlMasterCleanupAfterDetachedRemoteTransfer = false
|
||||
}
|
||||
applyRemoteProxyEndpointUpdate(nil)
|
||||
applyBrowserRemoteWorkspaceStatusToPanels()
|
||||
|
|
@ -6898,7 +6930,9 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
}
|
||||
|
||||
private func trackRemoteTerminalSurface(_ panelId: UUID) {
|
||||
skipControlMasterCleanupAfterDetachedRemoteTransfer = false
|
||||
pendingRemoteTerminalChildExitSurfaceIds.remove(panelId)
|
||||
transferredRemoteCleanupConfigurationsByPanelId.removeValue(forKey: panelId)
|
||||
guard activeRemoteTerminalSurfaceIds.insert(panelId).inserted else { return }
|
||||
activeRemoteTerminalSessionCount = activeRemoteTerminalSurfaceIds.count
|
||||
}
|
||||
|
|
@ -6921,7 +6955,22 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
private func cleanupTransferredRemoteConnectionIfNeeded(surfaceId: UUID, relayPort: Int?) -> Bool {
|
||||
guard let relayPort,
|
||||
relayPort > 0,
|
||||
let cleanupConfiguration = transferredRemoteCleanupConfigurationsByPanelId[surfaceId],
|
||||
cleanupConfiguration.relayPort == relayPort else {
|
||||
return false
|
||||
}
|
||||
transferredRemoteCleanupConfigurationsByPanelId.removeValue(forKey: surfaceId)
|
||||
Self.requestSSHControlMasterCleanupIfNeeded(configuration: cleanupConfiguration)
|
||||
return true
|
||||
}
|
||||
|
||||
func markRemoteTerminalSessionEnded(surfaceId: UUID, relayPort: Int?) {
|
||||
if cleanupTransferredRemoteConnectionIfNeeded(surfaceId: surfaceId, relayPort: relayPort) {
|
||||
return
|
||||
}
|
||||
guard let relayPort,
|
||||
relayPort > 0,
|
||||
remoteConfiguration?.relayPort == relayPort else {
|
||||
|
|
@ -8189,6 +8238,9 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
func detachSurface(panelId: UUID) -> DetachedSurfaceTransfer? {
|
||||
guard let tabId = surfaceIdFromPanelId(panelId) else { return nil }
|
||||
guard panels[panelId] != nil else { return nil }
|
||||
let shouldSkipControlMasterCleanupAfterDetach =
|
||||
activeRemoteTerminalSurfaceIds.contains(panelId)
|
||||
&& activeRemoteTerminalSurfaceIds.count == 1
|
||||
#if DEBUG
|
||||
let detachStart = ProcessInfo.processInfo.systemUptime
|
||||
dlog(
|
||||
|
|
@ -8215,7 +8267,13 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
return nil
|
||||
}
|
||||
|
||||
let detached = pendingDetachedSurfaces.removeValue(forKey: tabId)
|
||||
var detached = pendingDetachedSurfaces.removeValue(forKey: tabId)
|
||||
if shouldSkipControlMasterCleanupAfterDetach, let detachedTransfer = detached, detachedTransfer.isRemoteTerminal {
|
||||
skipControlMasterCleanupAfterDetachedRemoteTransfer = true
|
||||
if detachedTransfer.remoteCleanupConfiguration == nil {
|
||||
detached = detachedTransfer.withRemoteCleanupConfiguration(remoteConfiguration)
|
||||
}
|
||||
}
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"split.detach.end ws=\(id.uuidString.prefix(5)) panel=\(panelId.uuidString.prefix(5)) " +
|
||||
|
|
@ -8330,11 +8388,21 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
}
|
||||
|
||||
surfaceIdToPanelId[newTabId] = detached.panelId
|
||||
if detached.isRemoteTerminal,
|
||||
let detachedRelayPort = detached.remoteRelayPort,
|
||||
detachedRelayPort == remoteConfiguration?.relayPort {
|
||||
let didAdoptWorkspaceRemoteTracking =
|
||||
detached.isRemoteTerminal
|
||||
&& detached.remoteRelayPort == remoteConfiguration?.relayPort
|
||||
if didAdoptWorkspaceRemoteTracking {
|
||||
trackRemoteTerminalSurface(detached.panelId)
|
||||
}
|
||||
if let cleanupConfiguration = detached.remoteCleanupConfiguration {
|
||||
if didAdoptWorkspaceRemoteTracking {
|
||||
transferredRemoteCleanupConfigurationsByPanelId.removeValue(forKey: detached.panelId)
|
||||
} else {
|
||||
transferredRemoteCleanupConfigurationsByPanelId[detached.panelId] = cleanupConfiguration
|
||||
}
|
||||
} else {
|
||||
transferredRemoteCleanupConfigurationsByPanelId.removeValue(forKey: detached.panelId)
|
||||
}
|
||||
if let index {
|
||||
_ = bonsplitController.reorderTab(newTabId, toIndex: index)
|
||||
}
|
||||
|
|
@ -10171,6 +10239,7 @@ extension Workspace: BonsplitDelegate {
|
|||
#endif
|
||||
|
||||
let panel = panels[panelId]
|
||||
let transferredRemoteCleanupConfiguration = transferredRemoteCleanupConfigurationsByPanelId.removeValue(forKey: panelId)
|
||||
|
||||
if isDetaching, let panel {
|
||||
let browserPanel = panel as? BrowserPanel
|
||||
|
|
@ -10193,7 +10262,8 @@ extension Workspace: BonsplitDelegate {
|
|||
isRemoteTerminal: activeRemoteTerminalSurfaceIds.contains(panelId),
|
||||
remoteRelayPort: activeRemoteTerminalSurfaceIds.contains(panelId)
|
||||
? remoteConfiguration?.relayPort
|
||||
: nil
|
||||
: nil,
|
||||
remoteCleanupConfiguration: transferredRemoteCleanupConfiguration
|
||||
)
|
||||
} else {
|
||||
if let closedBrowserRestoreSnapshot {
|
||||
|
|
@ -10224,6 +10294,9 @@ extension Workspace: BonsplitDelegate {
|
|||
lastTerminalConfigInheritancePanelId = nil
|
||||
}
|
||||
clearRemoteConfigurationIfWorkspaceBecameLocal()
|
||||
if !isDetaching, let transferredRemoteCleanupConfiguration {
|
||||
Self.requestSSHControlMasterCleanupIfNeeded(configuration: transferredRemoteCleanupConfiguration)
|
||||
}
|
||||
AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: id, surfaceId: panelId)
|
||||
|
||||
// Keep the workspace invariant for normal close paths.
|
||||
|
|
|
|||
|
|
@ -399,6 +399,62 @@ final class WorkspaceRemoteConnectionTests: XCTestCase {
|
|||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testClosingRemoteWorkspaceRequestsControlMasterCleanup() throws {
|
||||
let manager = TabManager()
|
||||
let remainingWorkspace = try XCTUnwrap(manager.selectedWorkspace)
|
||||
let remoteWorkspace = manager.addWorkspace()
|
||||
let config = WorkspaceRemoteConfiguration(
|
||||
destination: "cmux-macmini",
|
||||
port: 2222,
|
||||
identityFile: "/Users/test/.ssh/id_ed25519",
|
||||
sshOptions: [
|
||||
"ControlMaster=auto",
|
||||
"ControlPersist=600",
|
||||
"ControlPath=/tmp/cmux-ssh-%C",
|
||||
"StrictHostKeyChecking=accept-new",
|
||||
],
|
||||
localProxyPort: nil,
|
||||
relayPort: 64018,
|
||||
relayID: String(repeating: "a", count: 16),
|
||||
relayToken: String(repeating: "b", count: 64),
|
||||
localSocketPath: "/tmp/cmux-debug-test.sock",
|
||||
terminalStartupCommand: "ssh cmux-macmini"
|
||||
)
|
||||
let cleanupRequested = expectation(description: "control master cleanup requested")
|
||||
var capturedArguments: [String] = []
|
||||
|
||||
Workspace.runSSHControlMasterCommandOverrideForTesting = { arguments in
|
||||
capturedArguments = arguments
|
||||
cleanupRequested.fulfill()
|
||||
}
|
||||
defer { Workspace.runSSHControlMasterCommandOverrideForTesting = nil }
|
||||
|
||||
remoteWorkspace.configureRemoteConnection(config, autoConnect: false)
|
||||
|
||||
manager.closeWorkspace(remoteWorkspace)
|
||||
|
||||
wait(for: [cleanupRequested], timeout: 1.0)
|
||||
|
||||
XCTAssertEqual(manager.tabs.count, 1)
|
||||
XCTAssertEqual(manager.tabs.first?.id, remainingWorkspace.id)
|
||||
XCTAssertFalse(manager.tabs.contains(where: { $0.id == remoteWorkspace.id }))
|
||||
XCTAssertFalse(remoteWorkspace.isRemoteWorkspace)
|
||||
XCTAssertEqual(
|
||||
capturedArguments,
|
||||
[
|
||||
"-o", "BatchMode=yes",
|
||||
"-o", "ControlMaster=no",
|
||||
"-p", "2222",
|
||||
"-i", "/Users/test/.ssh/id_ed25519",
|
||||
"-o", "ControlPath=/tmp/cmux-ssh-%C",
|
||||
"-o", "StrictHostKeyChecking=accept-new",
|
||||
"-O", "exit",
|
||||
"cmux-macmini",
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testDetachLastRemoteSurfacePreservesRemoteSessionWithoutCleanup() throws {
|
||||
let workspace = Workspace()
|
||||
|
|
@ -432,7 +488,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase {
|
|||
let panelID = try XCTUnwrap(workspace.focusedTerminalPanel?.id)
|
||||
let detached = try XCTUnwrap(workspace.detachSurface(panelId: panelID))
|
||||
|
||||
wait(for: [cleanupRequested], timeout: 0.2)
|
||||
wait(for: [cleanupRequested], timeout: 1.0)
|
||||
|
||||
XCTAssertTrue(detached.isRemoteTerminal)
|
||||
XCTAssertTrue(workspace.isRemoteWorkspace)
|
||||
|
|
@ -446,6 +502,169 @@ final class WorkspaceRemoteConnectionTests: XCTestCase {
|
|||
XCTAssertTrue(workspace.isRemoteTerminalSurface(detached.panelId))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testClosingSourceWorkspaceAfterDetachingRemoteSurfaceSkipsControlMasterCleanup() throws {
|
||||
let manager = TabManager()
|
||||
let sourceWorkspace = try XCTUnwrap(manager.selectedWorkspace)
|
||||
let destinationWorkspace = manager.addWorkspace()
|
||||
let config = WorkspaceRemoteConfiguration(
|
||||
destination: "cmux-macmini",
|
||||
port: nil,
|
||||
identityFile: nil,
|
||||
sshOptions: [
|
||||
"ControlMaster=auto",
|
||||
"ControlPersist=600",
|
||||
"ControlPath=/tmp/cmux-ssh-%C",
|
||||
],
|
||||
localProxyPort: nil,
|
||||
relayPort: 64017,
|
||||
relayID: String(repeating: "a", count: 16),
|
||||
relayToken: String(repeating: "b", count: 64),
|
||||
localSocketPath: "/tmp/cmux-debug-test.sock",
|
||||
terminalStartupCommand: "ssh cmux-macmini"
|
||||
)
|
||||
let cleanupRequested = expectation(description: "control master cleanup requested")
|
||||
cleanupRequested.isInverted = true
|
||||
|
||||
Workspace.runSSHControlMasterCommandOverrideForTesting = { _ in
|
||||
cleanupRequested.fulfill()
|
||||
}
|
||||
defer { Workspace.runSSHControlMasterCommandOverrideForTesting = nil }
|
||||
|
||||
sourceWorkspace.configureRemoteConnection(config, autoConnect: false)
|
||||
|
||||
let panelID = try XCTUnwrap(sourceWorkspace.focusedTerminalPanel?.id)
|
||||
let detached = try XCTUnwrap(sourceWorkspace.detachSurface(panelId: panelID))
|
||||
let destinationPaneID = try XCTUnwrap(destinationWorkspace.bonsplitController.allPaneIds.first)
|
||||
|
||||
let restoredPanelID = destinationWorkspace.attachDetachedSurface(
|
||||
detached,
|
||||
inPane: destinationPaneID,
|
||||
focus: false
|
||||
)
|
||||
|
||||
XCTAssertNotNil(restoredPanelID)
|
||||
XCTAssertTrue(destinationWorkspace.panels.keys.contains(detached.panelId))
|
||||
XCTAssertTrue(sourceWorkspace.panels.isEmpty)
|
||||
|
||||
manager.closeWorkspace(sourceWorkspace)
|
||||
|
||||
wait(for: [cleanupRequested], timeout: 1.0)
|
||||
|
||||
XCTAssertFalse(manager.tabs.contains(where: { $0.id == sourceWorkspace.id }))
|
||||
XCTAssertTrue(destinationWorkspace.panels.keys.contains(detached.panelId))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testClosingMixedSourceWorkspaceAfterDetachingLastRemoteSurfaceSkipsControlMasterCleanup() throws {
|
||||
let manager = TabManager()
|
||||
let sourceWorkspace = try XCTUnwrap(manager.selectedWorkspace)
|
||||
let destinationWorkspace = manager.addWorkspace()
|
||||
let sourcePaneID = try XCTUnwrap(sourceWorkspace.bonsplitController.allPaneIds.first)
|
||||
let config = WorkspaceRemoteConfiguration(
|
||||
destination: "cmux-macmini",
|
||||
port: nil,
|
||||
identityFile: nil,
|
||||
sshOptions: [
|
||||
"ControlMaster=auto",
|
||||
"ControlPersist=600",
|
||||
"ControlPath=/tmp/cmux-ssh-%C",
|
||||
],
|
||||
localProxyPort: nil,
|
||||
relayPort: 64018,
|
||||
relayID: String(repeating: "a", count: 16),
|
||||
relayToken: String(repeating: "b", count: 64),
|
||||
localSocketPath: "/tmp/cmux-debug-test.sock",
|
||||
terminalStartupCommand: "ssh cmux-macmini"
|
||||
)
|
||||
let cleanupRequested = expectation(description: "control master cleanup requested")
|
||||
cleanupRequested.isInverted = true
|
||||
|
||||
Workspace.runSSHControlMasterCommandOverrideForTesting = { _ in
|
||||
cleanupRequested.fulfill()
|
||||
}
|
||||
defer { Workspace.runSSHControlMasterCommandOverrideForTesting = nil }
|
||||
|
||||
sourceWorkspace.configureRemoteConnection(config, autoConnect: false)
|
||||
_ = sourceWorkspace.newBrowserSurface(inPane: sourcePaneID, url: URL(string: "https://example.com"), focus: false)
|
||||
|
||||
let panelID = try XCTUnwrap(sourceWorkspace.focusedTerminalPanel?.id)
|
||||
let detached = try XCTUnwrap(sourceWorkspace.detachSurface(panelId: panelID))
|
||||
let destinationPaneID = try XCTUnwrap(destinationWorkspace.bonsplitController.allPaneIds.first)
|
||||
|
||||
let restoredPanelID = destinationWorkspace.attachDetachedSurface(
|
||||
detached,
|
||||
inPane: destinationPaneID,
|
||||
focus: false
|
||||
)
|
||||
|
||||
XCTAssertNotNil(restoredPanelID)
|
||||
XCTAssertEqual(sourceWorkspace.panels.count, 1)
|
||||
XCTAssertTrue(destinationWorkspace.panels.keys.contains(detached.panelId))
|
||||
|
||||
manager.closeWorkspace(sourceWorkspace)
|
||||
|
||||
wait(for: [cleanupRequested], timeout: 1.0)
|
||||
|
||||
XCTAssertFalse(manager.tabs.contains(where: { $0.id == sourceWorkspace.id }))
|
||||
XCTAssertTrue(destinationWorkspace.panels.keys.contains(detached.panelId))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testTransferredRemoteSurfaceCleansUpControlMasterWhenSessionEndsInLocalWorkspace() throws {
|
||||
let manager = TabManager()
|
||||
let sourceWorkspace = try XCTUnwrap(manager.selectedWorkspace)
|
||||
let destinationWorkspace = manager.addWorkspace()
|
||||
let config = WorkspaceRemoteConfiguration(
|
||||
destination: "cmux-macmini",
|
||||
port: nil,
|
||||
identityFile: nil,
|
||||
sshOptions: [
|
||||
"ControlMaster=auto",
|
||||
"ControlPersist=600",
|
||||
"ControlPath=/tmp/cmux-ssh-%C",
|
||||
],
|
||||
localProxyPort: nil,
|
||||
relayPort: 64019,
|
||||
relayID: String(repeating: "a", count: 16),
|
||||
relayToken: String(repeating: "b", count: 64),
|
||||
localSocketPath: "/tmp/cmux-debug-test.sock",
|
||||
terminalStartupCommand: "ssh cmux-macmini"
|
||||
)
|
||||
let cleanupRequested = expectation(description: "control master cleanup requested")
|
||||
var cleanupArguments: [[String]] = []
|
||||
|
||||
Workspace.runSSHControlMasterCommandOverrideForTesting = { arguments in
|
||||
cleanupArguments.append(arguments)
|
||||
cleanupRequested.fulfill()
|
||||
}
|
||||
defer { Workspace.runSSHControlMasterCommandOverrideForTesting = nil }
|
||||
|
||||
sourceWorkspace.configureRemoteConnection(config, autoConnect: false)
|
||||
|
||||
let panelID = try XCTUnwrap(sourceWorkspace.focusedTerminalPanel?.id)
|
||||
let detached = try XCTUnwrap(sourceWorkspace.detachSurface(panelId: panelID))
|
||||
let destinationPaneID = try XCTUnwrap(destinationWorkspace.bonsplitController.allPaneIds.first)
|
||||
|
||||
let restoredPanelID = destinationWorkspace.attachDetachedSurface(
|
||||
detached,
|
||||
inPane: destinationPaneID,
|
||||
focus: false
|
||||
)
|
||||
|
||||
XCTAssertNotNil(restoredPanelID)
|
||||
XCTAssertFalse(destinationWorkspace.isRemoteWorkspace)
|
||||
XCTAssertEqual(destinationWorkspace.activeRemoteTerminalSessionCount, 0)
|
||||
|
||||
manager.closeWorkspace(sourceWorkspace)
|
||||
destinationWorkspace.markRemoteTerminalSessionEnded(surfaceId: detached.panelId, relayPort: config.relayPort)
|
||||
|
||||
wait(for: [cleanupRequested], timeout: 1.0)
|
||||
|
||||
XCTAssertEqual(cleanupArguments.count, 1)
|
||||
XCTAssertEqual(cleanupArguments.first?.suffix(2), ["exit", "cmux-macmini"])
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testRemoteTerminalSessionEndSkipsControlMasterCleanupWhenBrowserPanelsKeepWorkspaceRemote() throws {
|
||||
let workspace = Workspace()
|
||||
|
|
@ -480,7 +699,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase {
|
|||
|
||||
workspace.markRemoteTerminalSessionEnded(surfaceId: initialTerminalID, relayPort: 64013)
|
||||
|
||||
wait(for: [cleanupRequested], timeout: 0.2)
|
||||
wait(for: [cleanupRequested], timeout: 1.0)
|
||||
|
||||
XCTAssertTrue(workspace.isRemoteWorkspace)
|
||||
XCTAssertEqual(workspace.activeRemoteTerminalSessionCount, 0)
|
||||
|
|
@ -1246,6 +1465,140 @@ final class CLINotifyProcessIntegrationTests: XCTestCase {
|
|||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testSSHCommandCreatesConfiguresAndSelectsRemoteWorkspaceViaCLI() throws {
|
||||
let cliPath = try bundledCLIPath()
|
||||
let socketPath = makeSocketPath("ssh")
|
||||
let listenerFD = try bindUnixSocket(at: socketPath)
|
||||
let state = MockSocketServerState()
|
||||
let workspaceID = "11111111-1111-1111-1111-111111111111"
|
||||
let workspaceRef = "workspace:7"
|
||||
let windowID = "22222222-2222-2222-2222-222222222222"
|
||||
|
||||
defer {
|
||||
Darwin.close(listenerFD)
|
||||
unlink(socketPath)
|
||||
}
|
||||
|
||||
let serverHandled = startMockServer(listenerFD: listenerFD, state: state) { line in
|
||||
guard let data = line.data(using: .utf8),
|
||||
let payload = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
|
||||
let id = payload["id"] as? String,
|
||||
let method = payload["method"] as? String else {
|
||||
return self.v2Response(
|
||||
id: "unknown",
|
||||
ok: false,
|
||||
error: ["code": "unexpected", "message": "Unexpected payload"]
|
||||
)
|
||||
}
|
||||
|
||||
switch method {
|
||||
case "workspace.create":
|
||||
return self.v2Response(
|
||||
id: id,
|
||||
ok: true,
|
||||
result: [
|
||||
"workspace_id": workspaceID,
|
||||
"window_id": windowID,
|
||||
]
|
||||
)
|
||||
case "workspace.rename":
|
||||
return self.v2Response(id: id, ok: true, result: ["workspace_id": workspaceID])
|
||||
case "workspace.remote.configure":
|
||||
return self.v2Response(
|
||||
id: id,
|
||||
ok: true,
|
||||
result: [
|
||||
"workspace_id": workspaceID,
|
||||
"workspace_ref": workspaceRef,
|
||||
"remote": [
|
||||
"enabled": true,
|
||||
"state": "connecting",
|
||||
],
|
||||
]
|
||||
)
|
||||
case "workspace.select":
|
||||
return self.v2Response(id: id, ok: true, result: ["workspace_id": workspaceID])
|
||||
default:
|
||||
return self.v2Response(
|
||||
id: id,
|
||||
ok: false,
|
||||
error: ["code": "unexpected", "message": "Unexpected method \(method)"]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var environment = ProcessInfo.processInfo.environment
|
||||
environment["CMUX_SOCKET_PATH"] = socketPath
|
||||
environment["CMUX_CLI_SENTRY_DISABLED"] = "1"
|
||||
environment["CMUX_CLAUDE_HOOK_SENTRY_DISABLED"] = "1"
|
||||
|
||||
let result = runProcess(
|
||||
executablePath: cliPath,
|
||||
arguments: [
|
||||
"ssh",
|
||||
"--name", "SSH Workspace",
|
||||
"--port", "2222",
|
||||
"--identity", "/Users/test/.ssh/id_ed25519",
|
||||
"--ssh-option", "ControlPath=/tmp/cmux-ssh-%C",
|
||||
"--ssh-option", "StrictHostKeyChecking=accept-new",
|
||||
"cmux-macmini",
|
||||
],
|
||||
environment: environment,
|
||||
timeout: 5
|
||||
)
|
||||
|
||||
wait(for: [serverHandled], timeout: 5)
|
||||
|
||||
XCTAssertFalse(result.timedOut, result.stderr)
|
||||
XCTAssertEqual(result.status, 0, result.stderr)
|
||||
XCTAssertEqual(result.stdout, "OK workspace=\(workspaceRef) target=cmux-macmini state=connecting\n")
|
||||
XCTAssertTrue(result.stderr.isEmpty, result.stderr)
|
||||
|
||||
let requests = try state.commands.map { line -> [String: Any] in
|
||||
let data = try XCTUnwrap(line.data(using: .utf8))
|
||||
return try XCTUnwrap(JSONSerialization.jsonObject(with: data, options: []) as? [String: Any])
|
||||
}
|
||||
XCTAssertEqual(
|
||||
requests.compactMap { $0["method"] as? String },
|
||||
["workspace.create", "workspace.rename", "workspace.remote.configure", "workspace.select"]
|
||||
)
|
||||
|
||||
let createParams = try XCTUnwrap(requests[0]["params"] as? [String: Any])
|
||||
let initialCommand = try XCTUnwrap(createParams["initial_command"] as? String)
|
||||
XCTAssertFalse(initialCommand.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
|
||||
let renameParams = try XCTUnwrap(requests[1]["params"] as? [String: Any])
|
||||
XCTAssertEqual(renameParams["workspace_id"] as? String, workspaceID)
|
||||
XCTAssertEqual(renameParams["title"] as? String, "SSH Workspace")
|
||||
|
||||
let configureParams = try XCTUnwrap(requests[2]["params"] as? [String: Any])
|
||||
XCTAssertEqual(configureParams["workspace_id"] as? String, workspaceID)
|
||||
XCTAssertEqual(configureParams["destination"] as? String, "cmux-macmini")
|
||||
XCTAssertEqual(configureParams["port"] as? Int, 2222)
|
||||
XCTAssertEqual(configureParams["identity_file"] as? String, "/Users/test/.ssh/id_ed25519")
|
||||
XCTAssertEqual(configureParams["local_socket_path"] as? String, socketPath)
|
||||
XCTAssertEqual(configureParams["auto_connect"] as? Bool, true)
|
||||
let relayPort = try XCTUnwrap(configureParams["relay_port"] as? Int)
|
||||
XCTAssertGreaterThan(relayPort, 0)
|
||||
let relayID = try XCTUnwrap(configureParams["relay_id"] as? String)
|
||||
XCTAssertFalse(relayID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
let relayToken = try XCTUnwrap(configureParams["relay_token"] as? String)
|
||||
XCTAssertEqual(relayToken.count, 64)
|
||||
let terminalStartupCommand = try XCTUnwrap(configureParams["terminal_startup_command"] as? String)
|
||||
XCTAssertFalse(terminalStartupCommand.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
let sshOptions = try XCTUnwrap(configureParams["ssh_options"] as? [String])
|
||||
XCTAssertTrue(sshOptions.contains("ControlMaster=auto"))
|
||||
XCTAssertTrue(sshOptions.contains("ControlPersist=600"))
|
||||
XCTAssertTrue(sshOptions.contains("ControlPath=/tmp/cmux-ssh-%C"))
|
||||
XCTAssertTrue(sshOptions.contains("StrictHostKeyChecking=accept-new"))
|
||||
|
||||
// `cmux ssh` should land the user in the new SSH workspace immediately.
|
||||
let selectParams = try XCTUnwrap(requests[3]["params"] as? [String: Any])
|
||||
XCTAssertEqual(selectParams["workspace_id"] as? String, workspaceID)
|
||||
XCTAssertEqual(selectParams["window_id"] as? String, windowID)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testNotifyPrefersCallerTTYOverFocusedSurfaceWhenCallerIDsAreStale() throws {
|
||||
let cliPath = try bundledCLIPath()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue