From 9641d8a92f54a6b2bce12104e6d4e365bf9e7e5a Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 11 May 2026 19:26:18 -0700 Subject: [PATCH 01/21] Expose missing SSH reconnect lifecycle state The startup wrapper already retries transient SSH exits, but no regression coverage proves that the foreground transport reports retry and recovery transitions back to the app-owned workspace state. This test captures the sleep/network-drop path by making SSH exit 255 once, then requiring reconnecting and connected lifecycle helper calls before final session end. Constraint: Local tests are intentionally not run for this repo; CI owns execution. Rejected: Assert only terminal stderr reconnect text | acceptance requires the Cloud VM pane/app state to surface reconnecting, not just shell output. Confidence: medium Scope-risk: narrow Tested: Not run locally per instruction; expected to fail before the fix because the lifecycle helper commands do not exist yet. Not-tested: CI execution pending after fix commit. --- .../SSHStartupSignalLifecycleTests.swift | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/cmuxTests/SSHStartupSignalLifecycleTests.swift b/cmuxTests/SSHStartupSignalLifecycleTests.swift index 808d98bfefa4..56d086f2563f 100644 --- a/cmuxTests/SSHStartupSignalLifecycleTests.swift +++ b/cmuxTests/SSHStartupSignalLifecycleTests.swift @@ -124,6 +124,93 @@ extension CLINotifyProcessIntegrationRegressionTests { XCTAssertEqual(sessionEndCalls.count, 1, recordedCalls) } + func testSSHStartupReportsReconnectingAndConnectedLifecycleAroundTransientExit() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-ssh-reconnect-state-\(UUID().uuidString)", isDirectory: true) + let fakeCLI = root.appendingPathComponent("cmux") + let fakeSSH = root.appendingPathComponent("ssh") + let logFile = root.appendingPathComponent("ssh-lifecycle.log") + let attemptFile = root.appendingPathComponent("ssh-attempts.txt") + + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + try writeShellFile(at: fakeCLI, lines: [ + "#!/bin/sh", + "printf '%s\\n' \"$*\" >> \"${CMUX_TEST_LIFECYCLE_LOG}\"", + ]) + try writeShellFile(at: fakeSSH, lines: [ + "#!/bin/sh", + "count=0", + "if [ -r \"${CMUX_TEST_ATTEMPT_FILE}\" ]; then count=$(cat \"${CMUX_TEST_ATTEMPT_FILE}\"); fi", + "count=$((count + 1))", + "printf '%s\\n' \"$count\" > \"${CMUX_TEST_ATTEMPT_FILE}\"", + "if [ \"$count\" -eq 1 ]; then exit 255; fi", + "cmux_local_command=", + "cmux_previous=", + "for cmux_arg in \"$@\"; do", + " if [ \"$cmux_previous\" = \"-o\" ]; then", + " case \"$cmux_arg\" in LocalCommand=*) cmux_local_command=${cmux_arg#LocalCommand=} ;; esac", + " cmux_previous=", + " continue", + " fi", + " if [ \"$cmux_arg\" = \"-o\" ]; then cmux_previous=\"-o\"; fi", + "done", + "if [ -n \"$cmux_local_command\" ]; then", + " cmux_local_command=$(printf '%s' \"$cmux_local_command\" | sed 's/%%/%/g')", + " /bin/sh -c \"$cmux_local_command\"", + "fi", + "exit 0", + ]) + try fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: fakeCLI.path) + try fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: fakeSSH.path) + + let startupCommand = try generatedVMSSHInitialStartupCommand() + var environment = ProcessInfo.processInfo.environment + environment["PATH"] = "\(root.path):\(environment["PATH"] ?? "/usr/bin:/bin")" + environment["CMUX_BUNDLED_CLI_PATH"] = fakeCLI.path + environment["CMUX_SOCKET_PATH"] = "/tmp/cmux-debug-test.sock" + environment["CMUX_WORKSPACE_ID"] = "11111111-1111-1111-1111-111111111111" + environment["CMUX_SURFACE_ID"] = "22222222-2222-2222-2222-222222222222" + environment["CMUX_TEST_LIFECYCLE_LOG"] = logFile.path + environment["CMUX_TEST_ATTEMPT_FILE"] = attemptFile.path + environment["CMUX_SSH_RECONNECT_DELAY_SECONDS"] = "0" + environment["CMUX_SSH_RECONNECT_LIMIT"] = "2" + + let result = runProcess( + executablePath: "/bin/sh", + arguments: ["-c", startupCommand], + environment: environment, + timeout: 5 + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertEqual((try? String(contentsOf: attemptFile, encoding: .utf8))?.trimmingCharacters(in: .whitespacesAndNewlines), "2") + + let recordedCalls = ((try? String(contentsOf: logFile, encoding: .utf8)) ?? "") + .split(separator: "\n") + .map(String.init) + let reconnectingIndex = recordedCalls.firstIndex { $0.contains("ssh-session-reconnecting") } + let connectedIndex = recordedCalls.firstIndex { $0.contains("ssh-session-connected") } + let sessionEndIndex = recordedCalls.firstIndex { $0.contains("ssh-session-end") } + + XCTAssertNotNil(reconnectingIndex, "Transient SSH drops must mark the pane as reconnecting; recorded: \(recordedCalls)") + XCTAssertNotNil(connectedIndex, "Successful SSH redial must mark the pane as connected; recorded: \(recordedCalls)") + XCTAssertNotNil(sessionEndIndex, "The terminal still reports final session end on wrapper exit; recorded: \(recordedCalls)") + if let reconnectingIndex, let connectedIndex { + XCTAssertLessThan(reconnectingIndex, connectedIndex, recordedCalls) + } + if let connectedIndex, let sessionEndIndex { + XCTAssertLessThan(connectedIndex, sessionEndIndex, recordedCalls) + } + if let reconnectingIndex { + XCTAssertTrue(recordedCalls[reconnectingIndex].contains("--attempt 1"), recordedCalls.joined(separator: "\n")) + XCTAssertTrue(recordedCalls[reconnectingIndex].contains("--exit-status 255"), recordedCalls.joined(separator: "\n")) + } + } + func testSSHStartupStopsAtConfiguredReconnectLimit() throws { let fileManager = FileManager.default let root = fileManager.temporaryDirectory From c087e7a92fee2c15321cd9d3f634bbe0cb2c73d5 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 11 May 2026 19:32:07 -0700 Subject: [PATCH 02/21] Surface SSH reconnect lifecycle in workspace state The foreground SSH wrapper is the only process that can observe an interactive transport drop, while Workspace owns the visible remote state. The fix adds explicit terminal lifecycle helpers for reconnecting and connected transitions, routes them through TerminalController RPCs, and lets Workspace translate them into the existing remote connection state model. Constraint: Do not infer foreground SSH health from daemon/proxy failures; the terminal process owns foreground transport observations. Constraint: No local test runs for this repo; CI owns test execution. Rejected: Reuse ssh-session-end for retries | final-session cleanup would demote or clear state instead of preserving the active remote terminal during retry. Rejected: Poll the remote controller for foreground drops | proxy/daemon state is a different lifecycle and misses Cloud VM SSH-only fallback sessions. Confidence: medium Scope-risk: moderate Tested: git diff --check; python3 -m json.tool Resources/Localizable.xcstrings Not-tested: Local test suite per instruction; CI pending. --- CLI/cmux.swift | 140 ++++++++++++++++-- Resources/Localizable.xcstrings | 34 +++++ Sources/TerminalController.swift | 97 ++++++++++-- Sources/Workspace.swift | 55 +++++++ .../WorkspaceRemoteConnectionTests.swift | 39 +++++ docs/cli-contract.md | 2 + 6 files changed, 343 insertions(+), 24 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index a621d9fb52d1..35108834078d 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -2788,6 +2788,10 @@ struct CMUXCLI { try runSSH(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat) case "ssh-session-end": try runSSHSessionEnd(commandArgs: commandArgs, client: client) + case "ssh-session-reconnecting": + try runSSHSessionReconnecting(commandArgs: commandArgs, client: client) + case "ssh-session-connected": + try runSSHSessionConnected(commandArgs: commandArgs, client: client) case "vm-pty-attach": try runVMPtyAttach(commandArgs: commandArgs, client: client) case "vm-ssh-attach": @@ -4833,12 +4837,17 @@ struct CMUXCLI { localCLIPath: resolvedExecutableURL()?.path, foregroundAuthToken: deferredRemoteReconnectToken ) + let sshSessionConnectedCommandScript = sshSessionConnectedLocalCommandScript( + remoteRelayPort: sshOptions.remoteRelayPort, + localCLIPath: resolvedExecutableURL()?.path + ) let sshConnectionTimingCommandScript = sshConnectionTimingLocalCommandScript( target: sshOptions.displayDestination, relayPort: sshOptions.remoteRelayPort ) let combinedLocalCommandScript = combinedLocalShellScript([ deferredRemoteReconnectCommandScript, + sshSessionConnectedCommandScript, sshConnectionTimingCommandScript, ]) let configuredForegroundAuthToken = deferredRemoteReconnectCommandScript == nil ? nil : deferredRemoteReconnectToken @@ -5760,6 +5769,7 @@ struct CMUXCLI { ? "" : "export GHOSTTY_SHELL_FEATURES=\(shellQuote(trimmedFeatures))" let lifecycleCleanup = buildSSHSessionEndShellCommand(remoteRelayPort: remoteRelayPort) + let lifecycleReconnecting = buildSSHSessionReconnectingShellCommand(remoteRelayPort: remoteRelayPort) var scriptLines: [String] = [] if !shellFeaturesBootstrap.isEmpty { scriptLines.append(shellFeaturesBootstrap) @@ -5804,6 +5814,7 @@ struct CMUXCLI { " if [ \"$cmux_ssh_status\" -ne 255 ]; then break; fi", " if [ \"$cmux_ssh_retry\" -ge \"$cmux_ssh_reconnect_limit\" ]; then break; fi", " cmux_ssh_retry=$((cmux_ssh_retry + 1))", + " \(lifecycleReconnecting)", " cmux_ssh_note '\\n\\033[33m[cmux] ssh exited with status %s; reconnecting (attempt %s/%s).\\033[0m\\n\\033[2m[cmux] close this pane or press Ctrl-C to stop reconnecting.\\033[0m\\n' \"$cmux_ssh_status\" \"$cmux_ssh_retry\" \"$cmux_ssh_reconnect_limit\"", " if [ \"$cmux_ssh_reconnect_delay\" -gt 0 ]; then sleep \"$cmux_ssh_reconnect_delay\"; fi", " if [ -n \"${CMUX_SSH_PENDING_SIGNAL:-}\" ]; then cmux_ssh_session_end; trap - EXIT HUP INT TERM; exit \"$CMUX_SSH_PENDING_SIGNAL\"; fi", @@ -5859,17 +5870,41 @@ struct CMUXCLI { } private func buildSSHSessionEndShellCommand(remoteRelayPort: Int) -> String { + buildSSHSessionLifecycleShellCommand( + subcommand: "ssh-session-end", + remoteRelayPort: remoteRelayPort + ) + } + + private func buildSSHSessionReconnectingShellCommand(remoteRelayPort: Int) -> String { + buildSSHSessionLifecycleShellCommand( + subcommand: "ssh-session-reconnecting", + remoteRelayPort: remoteRelayPort, + extraArguments: [ + "--attempt \"$cmux_ssh_retry\"", + "--limit \"$cmux_ssh_reconnect_limit\"", + "--exit-status \"$cmux_ssh_status\"", + ] + ) + } + + private func buildSSHSessionLifecycleShellCommand( + subcommand: String, + remoteRelayPort: Int, + extraArguments: [String] = [] + ) -> String { + let suffix = extraArguments.isEmpty ? "" : " " + extraArguments.joined(separator: " ") [ "if [ -n \"${CMUX_BUNDLED_CLI_PATH:-}\" ]", "&& [ -x \"${CMUX_BUNDLED_CLI_PATH}\" ]", "&& [ -n \"${CMUX_SOCKET_PATH:-}\" ]", "&& [ -n \"${CMUX_WORKSPACE_ID:-}\" ]", "&& [ -n \"${CMUX_SURFACE_ID:-}\" ]; then", - "\"${CMUX_BUNDLED_CLI_PATH}\" --socket \"${CMUX_SOCKET_PATH}\" ssh-session-end --relay-port \(remoteRelayPort) --workspace \"${CMUX_WORKSPACE_ID}\" --surface \"${CMUX_SURFACE_ID}\" >/dev/null 2>&1 || true;", + "\"${CMUX_BUNDLED_CLI_PATH}\" --socket \"${CMUX_SOCKET_PATH}\" \(subcommand) --relay-port \(remoteRelayPort) --workspace \"${CMUX_WORKSPACE_ID}\" --surface \"${CMUX_SURFACE_ID}\"\(suffix) >/dev/null 2>&1 || true;", "elif command -v cmux >/dev/null 2>&1", "&& [ -n \"${CMUX_WORKSPACE_ID:-}\" ]", "&& [ -n \"${CMUX_SURFACE_ID:-}\" ]; then", - "cmux ssh-session-end --relay-port \(remoteRelayPort) --workspace \"${CMUX_WORKSPACE_ID}\" --surface \"${CMUX_SURFACE_ID}\" >/dev/null 2>&1 || true;", + "cmux \(subcommand) --relay-port \(remoteRelayPort) --workspace \"${CMUX_WORKSPACE_ID}\" --surface \"${CMUX_SURFACE_ID}\"\(suffix) >/dev/null 2>&1 || true;", "fi", ].joined(separator: " ") } @@ -6564,28 +6599,99 @@ struct CMUXCLI { } private func runSSHSessionEnd(commandArgs: [String], client: SocketClient) throws { + let identity = try parseSSHSessionLifecycleIdentity(commandArgs: commandArgs, client: client, commandName: "ssh-session-end") + _ = try client.sendV2(method: "workspace.remote.terminal_session_end", params: [ + "workspace_id": identity.workspaceId, + "surface_id": identity.surfaceId, + "relay_port": identity.relayPort, + ]) + } + + private func runSSHSessionReconnecting(commandArgs: [String], client: SocketClient) throws { + let identity = try parseSSHSessionLifecycleIdentity( + commandArgs: commandArgs, + client: client, + commandName: "ssh-session-reconnecting" + ) + guard let attempt = positiveIntOption(commandArgs, name: "--attempt") else { + throw CLIError(message: "ssh-session-reconnecting requires --attempt ") + } + guard let limit = positiveIntOption(commandArgs, name: "--limit") else { + throw CLIError(message: "ssh-session-reconnecting requires --limit ") + } + guard let exitStatus = nonNegativeIntOption(commandArgs, name: "--exit-status") else { + throw CLIError(message: "ssh-session-reconnecting requires --exit-status ") + } + _ = try client.sendV2(method: "workspace.remote.terminal_reconnecting", params: [ + "workspace_id": identity.workspaceId, + "surface_id": identity.surfaceId, + "relay_port": identity.relayPort, + "attempt": attempt, + "limit": limit, + "exit_status": exitStatus, + ]) + } + + private func runSSHSessionConnected(commandArgs: [String], client: SocketClient) throws { + let identity = try parseSSHSessionLifecycleIdentity( + commandArgs: commandArgs, + client: client, + commandName: "ssh-session-connected" + ) + _ = try client.sendV2(method: "workspace.remote.terminal_connected", params: [ + "workspace_id": identity.workspaceId, + "surface_id": identity.surfaceId, + "relay_port": identity.relayPort, + ]) + } + + private struct SSHSessionLifecycleIdentity { + let relayPort: Int + let workspaceId: String + let surfaceId: String + } + + private func parseSSHSessionLifecycleIdentity( + commandArgs: [String], + client: SocketClient, + commandName: String + ) throws -> SSHSessionLifecycleIdentity { guard let relayPortRaw = optionValue(commandArgs, name: "--relay-port"), let relayPort = Int(relayPortRaw), relayPort > 0 else { - throw CLIError(message: "ssh-session-end requires --relay-port ") + throw CLIError(message: "\(commandName) requires --relay-port ") } let workspaceRaw = optionValue(commandArgs, name: "--workspace") ?? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] let surfaceRaw = optionValue(commandArgs, name: "--surface") ?? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] guard let workspaceRaw, let workspaceId = try normalizeWorkspaceHandle(workspaceRaw, client: client), !workspaceId.isEmpty else { - throw CLIError(message: "ssh-session-end requires --workspace or CMUX_WORKSPACE_ID") + throw CLIError(message: "\(commandName) requires --workspace or CMUX_WORKSPACE_ID") } guard let surfaceRaw, let surfaceId = try normalizeSurfaceHandle(surfaceRaw, client: client, workspaceHandle: workspaceId), !surfaceId.isEmpty else { - throw CLIError(message: "ssh-session-end requires --surface or CMUX_SURFACE_ID") + throw CLIError(message: "\(commandName) requires --surface or CMUX_SURFACE_ID") } - _ = try client.sendV2(method: "workspace.remote.terminal_session_end", params: [ - "workspace_id": workspaceId, - "surface_id": surfaceId, - "relay_port": relayPort, - ]) + return SSHSessionLifecycleIdentity(relayPort: relayPort, workspaceId: workspaceId, surfaceId: surfaceId) + } + + private func positiveIntOption(_ commandArgs: [String], name: String) -> Int? { + guard let raw = optionValue(commandArgs, name: name), + let value = Int(raw), + value > 0 else { + return nil + } + return value + } + + private func nonNegativeIntOption(_ commandArgs: [String], name: String) -> Int? { + guard let raw = optionValue(commandArgs, name: name), + let value = Int(raw), + value >= 0 else { + return nil + } + return value } private func runRemoteDaemonStatus(commandArgs: [String], jsonOutput: Bool) throws { @@ -6794,6 +6900,20 @@ struct CMUXCLI { ].joined(separator: " ") } + private func sshSessionConnectedLocalCommandScript(remoteRelayPort: Int, localCLIPath: String?) -> String { + let preferredCLIPath = localCLIPath?.trimmingCharacters(in: .whitespacesAndNewlines) + return [ + preferredCLIPath.map { "cmux_connected_cli=\(shellQuote($0));" } ?? "cmux_connected_cli=\"\";", + "cmux_connected_socket=\"${CMUX_SOCKET_PATH:-${CMUX_SOCKET:-}}\";", + "if [ -z \"$cmux_connected_cli\" ] && [ -n \"${CMUX_BUNDLED_CLI_PATH:-}\" ]; then cmux_connected_cli=\"$CMUX_BUNDLED_CLI_PATH\"; fi;", + "if [ ! -x \"$cmux_connected_cli\" ]; then cmux_connected_cli=\"$(command -v cmux 2>/dev/null || true)\"; fi;", + "if [ -n \"${CMUX_WORKSPACE_ID:-}\" ] && [ -n \"${CMUX_SURFACE_ID:-}\" ] && [ -n \"$cmux_connected_socket\" ] && [ -n \"$cmux_connected_cli\" ] && [ -x \"$cmux_connected_cli\" ]; then", + "\"$cmux_connected_cli\" --socket \"$cmux_connected_socket\" ssh-session-connected --relay-port \(remoteRelayPort) --workspace \"$CMUX_WORKSPACE_ID\" --surface \"$CMUX_SURFACE_ID\" >/dev/null 2>&1 || true;", + "fi;", + "unset cmux_connected_socket cmux_connected_cli;", + ].joined(separator: " ") + } + private func sshConnectionTimingLocalCommandScript(target: String, relayPort: Int) -> String { let escapedTarget = target .replacingOccurrences(of: "\\", with: "\\\\") diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index f51105c327ad..3c4a08756f84 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -110580,6 +110580,40 @@ } } }, + "remote.state.reconnecting.terminal": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reconnecting to %@ (attempt %lld/%lld after SSH exit %lld)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ に再接続中 (試行 %lld/%lld、SSH 終了 %lld)" + } + } + } + }, + "remote.state.targetFallback": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "remote host" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リモートホスト" + } + } + } + }, "remote.disconnectBanner.sessionEnded": { "extractionState": "manual", "localizations": { diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index ae34bcf44827..58cb9c817ec6 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -2505,6 +2505,10 @@ class TerminalController { return v2Result(id: id, self.v2WorkspaceRemoteDisconnect(params: params)) case "workspace.remote.status": return v2Result(id: id, self.v2WorkspaceRemoteStatus(params: params)) + case "workspace.remote.terminal_reconnecting": + return v2Result(id: id, self.v2WorkspaceRemoteTerminalReconnecting(params: params)) + case "workspace.remote.terminal_connected": + return v2Result(id: id, self.v2WorkspaceRemoteTerminalConnected(params: params)) case "workspace.remote.terminal_session_end": return v2Result(id: id, self.v2WorkspaceRemoteTerminalSessionEnd(params: params)) case "session.restore_previous": @@ -2913,6 +2917,8 @@ class TerminalController { "workspace.remote.reconnect", "workspace.remote.disconnect", "workspace.remote.status", + "workspace.remote.terminal_reconnecting", + "workspace.remote.terminal_connected", "workspace.remote.terminal_session_end", "session.restore_previous", "settings.open", @@ -5033,42 +5039,105 @@ class TerminalController { return result } + private func v2WorkspaceRemoteTerminalReconnecting(params: [String: Any]) -> V2CallResult { + guard let identity = v2WorkspaceRemoteTerminalLifecycleIdentity(params: params) else { + return .err(code: "invalid_params", message: "Missing or invalid workspace_id, surface_id, or relay_port", data: nil) + } + guard let attempt = v2StrictInt(params, "attempt"), + attempt > 0 else { + return .err(code: "invalid_params", message: "Missing or invalid attempt", data: nil) + } + guard let limit = v2StrictInt(params, "limit"), + limit > 0 else { + return .err(code: "invalid_params", message: "Missing or invalid limit", data: nil) + } + guard let exitStatus = v2StrictInt(params, "exit_status"), + exitStatus >= 0 else { + return .err(code: "invalid_params", message: "Missing or invalid exit_status", data: nil) + } + + return v2ApplyWorkspaceRemoteTerminalLifecycle(identity: identity) { workspace in + workspace.markRemoteTerminalSessionReconnecting( + surfaceId: identity.surfaceId, + relayPort: identity.relayPort, + attempt: attempt, + limit: limit, + exitStatus: exitStatus + ) + } + } + + private func v2WorkspaceRemoteTerminalConnected(params: [String: Any]) -> V2CallResult { + guard let identity = v2WorkspaceRemoteTerminalLifecycleIdentity(params: params) else { + return .err(code: "invalid_params", message: "Missing or invalid workspace_id, surface_id, or relay_port", data: nil) + } + + return v2ApplyWorkspaceRemoteTerminalLifecycle(identity: identity) { workspace in + workspace.markRemoteTerminalSessionConnected( + surfaceId: identity.surfaceId, + relayPort: identity.relayPort + ) + } + } + private func v2WorkspaceRemoteTerminalSessionEnd(params: [String: Any]) -> V2CallResult { + guard let identity = v2WorkspaceRemoteTerminalLifecycleIdentity(params: params) else { + return .err(code: "invalid_params", message: "Missing or invalid workspace_id, surface_id, or relay_port", data: nil) + } + + return v2ApplyWorkspaceRemoteTerminalLifecycle(identity: identity) { workspace in + workspace.markRemoteTerminalSessionEnded(surfaceId: identity.surfaceId, relayPort: identity.relayPort) + } + } + + private struct RemoteTerminalLifecycleIdentity { + let workspaceId: UUID + let surfaceId: UUID + let relayPort: Int + } + + private func v2WorkspaceRemoteTerminalLifecycleIdentity(params: [String: Any]) -> RemoteTerminalLifecycleIdentity? { guard let workspaceId = v2UUID(params, "workspace_id") else { - return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) + return nil } guard let surfaceId = v2UUID(params, "surface_id") else { - return .err(code: "invalid_params", message: "Missing or invalid surface_id", data: nil) + return nil } guard let relayPort = v2StrictInt(params, "relay_port"), relayPort > 0, relayPort <= 65535 else { - return .err(code: "invalid_params", message: "Missing or invalid relay_port", data: nil) + return nil } + return RemoteTerminalLifecycleIdentity(workspaceId: workspaceId, surfaceId: surfaceId, relayPort: relayPort) + } + private func v2ApplyWorkspaceRemoteTerminalLifecycle( + identity: RemoteTerminalLifecycleIdentity, + apply: (Workspace) -> Void + ) -> V2CallResult { var result: V2CallResult = .err(code: "not_found", message: "Workspace not found", data: [ - "workspace_id": workspaceId.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), - "surface_id": surfaceId.uuidString, - "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), - "relay_port": relayPort, + "workspace_id": identity.workspaceId.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: identity.workspaceId), + "surface_id": identity.surfaceId.uuidString, + "surface_ref": v2Ref(kind: .surface, uuid: identity.surfaceId), + "relay_port": identity.relayPort, ]) v2MainSync { - guard let owner = AppDelegate.shared?.tabManagerFor(tabId: workspaceId), - let workspace = owner.tabs.first(where: { $0.id == workspaceId }) else { + guard let owner = AppDelegate.shared?.tabManagerFor(tabId: identity.workspaceId), + let workspace = owner.tabs.first(where: { $0.id == identity.workspaceId }) else { return } - workspace.markRemoteTerminalSessionEnded(surfaceId: surfaceId, relayPort: relayPort) + apply(workspace) let windowId = v2ResolveWindowId(tabManager: owner) result = .ok([ "window_id": v2OrNull(windowId?.uuidString), "window_ref": v2Ref(kind: .window, uuid: windowId), "workspace_id": workspace.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: workspace.id), - "surface_id": surfaceId.uuidString, - "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), - "relay_port": relayPort, + "surface_id": identity.surfaceId.uuidString, + "surface_ref": v2Ref(kind: .surface, uuid: identity.surfaceId), + "relay_port": identity.relayPort, "remote": workspace.remoteStatusPayload(), ]) } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 18950839e669..4b2ebe4260b3 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -9358,6 +9358,61 @@ final class Workspace: Identifiable, ObservableObject { untrackRemoteTerminalSurface(surfaceId) } + func markRemoteTerminalSessionReconnecting( + surfaceId: UUID, + relayPort: Int?, + attempt: Int, + limit: Int, + exitStatus: Int + ) { + guard remoteTerminalLifecycleMatches(surfaceId: surfaceId, relayPort: relayPort) else { return } + let target = remoteConfiguration?.displayTarget ?? String( + localized: "remote.state.targetFallback", + defaultValue: "remote host" + ) + let detailFormat = String( + localized: "remote.state.reconnecting.terminal", + defaultValue: "Reconnecting to %@ (attempt %lld/%lld after SSH exit %lld)" + ) + let detail = String(format: detailFormat, target, Int64(attempt), Int64(limit), Int64(exitStatus)) + applyRemoteConnectionStateUpdate(.reconnecting, detail: detail, target: target) + } + + func markRemoteTerminalSessionConnected(surfaceId: UUID, relayPort: Int?) { + guard remoteTerminalLifecycleMatches(surfaceId: surfaceId, relayPort: relayPort) else { return } + guard remoteConnectionState == .connecting || remoteConnectionState == .reconnecting else { return } + let target = remoteConfiguration?.displayTarget ?? String( + localized: "remote.state.targetFallback", + defaultValue: "remote host" + ) + if remoteConfiguration?.skipDaemonBootstrap == true, + remoteConfiguration?.daemonWebSocketEndpoint == nil { + let detailFormat = String( + localized: "remote.state.connected.vmNoProxy", + defaultValue: "Connected to %@ (VM, proxy disabled)" + ) + applyRemoteConnectionStateUpdate( + .connected, + detail: String(format: detailFormat, target), + target: target + ) + return + } + + guard remoteProxyEndpoint != nil || remoteDaemonStatus.state == .ready else { return } + applyRemoteConnectionStateUpdate(.connected, detail: nil, target: target) + } + + private func remoteTerminalLifecycleMatches(surfaceId: UUID, relayPort: Int?) -> Bool { + guard let relayPort, + relayPort > 0, + remoteConfiguration?.relayPort == relayPort else { + return false + } + return activeRemoteTerminalSurfaceIds.contains(surfaceId) + || pendingRemoteTerminalChildExitSurfaceIds.contains(surfaceId) + } + func teardownRemoteConnection() { disconnectRemoteConnection(clearConfiguration: true) } diff --git a/cmuxTests/WorkspaceRemoteConnectionTests.swift b/cmuxTests/WorkspaceRemoteConnectionTests.swift index 68646bc777f1..80513c084ed1 100644 --- a/cmuxTests/WorkspaceRemoteConnectionTests.swift +++ b/cmuxTests/WorkspaceRemoteConnectionTests.swift @@ -519,6 +519,45 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertFalse(workspace.isRemoteTerminalSurface(panelID)) } + @MainActor + func testRemoteTerminalLifecycleEventsDriveReconnectState() throws { + let workspace = Workspace() + let config = WorkspaceRemoteConfiguration( + destination: "cmux@gateway.freestyle.sh", + port: 2222, + identityFile: nil, + sshOptions: ["ControlMaster=no"], + localProxyPort: nil, + relayPort: 64041, + relayID: String(repeating: "a", count: 16), + relayToken: String(repeating: "b", count: 64), + localSocketPath: "/tmp/cmux-debug-test.sock", + terminalStartupCommand: "ssh cmux@gateway.freestyle.sh", + skipDaemonBootstrap: true + ) + + workspace.configureRemoteConnection(config, autoConnect: false) + let panelID = try XCTUnwrap(workspace.focusedTerminalPanel?.id) + + workspace.markRemoteTerminalSessionReconnecting( + surfaceId: panelID, + relayPort: 64041, + attempt: 1, + limit: 2, + exitStatus: 255 + ) + + XCTAssertEqual(workspace.remoteConnectionState, .reconnecting) + XCTAssertEqual(workspace.remoteStatusPayload()["state"] as? String, "reconnecting") + XCTAssertTrue(workspace.remoteConnectionDetail?.contains("attempt 1/2") == true) + + workspace.markRemoteTerminalSessionConnected(surfaceId: panelID, relayPort: 64041) + + XCTAssertEqual(workspace.remoteConnectionState, .connected) + XCTAssertEqual(workspace.remoteStatusPayload()["state"] as? String, "connected") + XCTAssertEqual(workspace.remoteConnectionDetail, "Connected to cmux@gateway.freestyle.sh (VM, proxy disabled)") + } + @MainActor func testForegroundSSHAuthReadyBeforeRemoteConfigureStartsDeferredConnect() { let workspace = Workspace() diff --git a/docs/cli-contract.md b/docs/cli-contract.md index ae170a6c84ac..5de07308c554 100644 --- a/docs/cli-contract.md +++ b/docs/cli-contract.md @@ -153,6 +153,8 @@ Environment: | `vm-ssh-attach` | Hidden compatibility alias for older VM workspaces. | | `vm-pty-connect` | Internal helper that connects to a VM PTY from a config file. | | `ssh-session-end` | Internal helper that clears remote SSH session state. | +| `ssh-session-reconnecting` | Internal helper that marks a remote SSH terminal as reconnecting. | +| `ssh-session-connected` | Internal helper that marks a remote SSH terminal as connected after redial. | | `__tmux-compat` | Internal tmux compatibility dispatcher. | ## Command Families From 07e5e705b51ec59f80906cf61eb816b1a38670e0 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 11 May 2026 21:01:46 -0700 Subject: [PATCH 03/21] test: cover ssh reconnect preserving errors --- .../WorkspaceRemoteConnectionTests.swift | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/cmuxTests/WorkspaceRemoteConnectionTests.swift b/cmuxTests/WorkspaceRemoteConnectionTests.swift index 80513c084ed1..aed193ebc374 100644 --- a/cmuxTests/WorkspaceRemoteConnectionTests.swift +++ b/cmuxTests/WorkspaceRemoteConnectionTests.swift @@ -558,6 +558,44 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertEqual(workspace.remoteConnectionDetail, "Connected to cmux@gateway.freestyle.sh (VM, proxy disabled)") } + @MainActor + func testRemoteTerminalReconnectingDoesNotOverwriteErrorState() throws { + let workspace = Workspace() + let config = WorkspaceRemoteConfiguration( + destination: "cmux@gateway.freestyle.sh", + port: 2222, + identityFile: nil, + sshOptions: ["ControlMaster=no"], + localProxyPort: nil, + relayPort: 64043, + relayID: String(repeating: "a", count: 16), + relayToken: String(repeating: "b", count: 64), + localSocketPath: "/tmp/cmux-debug-test.sock", + terminalStartupCommand: "ssh cmux@gateway.freestyle.sh", + skipDaemonBootstrap: true + ) + + workspace.configureRemoteConnection(config, autoConnect: false) + let panelID = try XCTUnwrap(workspace.focusedTerminalPanel?.id) + + workspace.applyRemoteConnectionStateUpdate( + .error, + detail: "SSH daemon connection refused", + target: "cmux@gateway.freestyle.sh" + ) + workspace.markRemoteTerminalSessionReconnecting( + surfaceId: panelID, + relayPort: 64043, + attempt: 1, + limit: 2, + exitStatus: 255 + ) + + XCTAssertEqual(workspace.remoteConnectionState, .error) + XCTAssertEqual(workspace.remoteConnectionDetail, "SSH daemon connection refused") + XCTAssertEqual(workspace.remoteStatusPayload()["state"] as? String, "error") + } + @MainActor func testForegroundSSHAuthReadyBeforeRemoteConfigureStartsDeferredConnect() { let workspace = Workspace() From 16c455f901afda77325146868ab078fb71ec100e Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 11 May 2026 21:34:14 -0700 Subject: [PATCH 04/21] fix: preserve remote ssh error during reconnect --- Sources/Workspace.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 4b2ebe4260b3..2e50deeeeaab 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -9366,6 +9366,7 @@ final class Workspace: Identifiable, ObservableObject { exitStatus: Int ) { guard remoteTerminalLifecycleMatches(surfaceId: surfaceId, relayPort: relayPort) else { return } + guard remoteConnectionState != .error else { return } let target = remoteConfiguration?.displayTarget ?? String( localized: "remote.state.targetFallback", defaultValue: "remote host" From 455d1d2f49e4a5b564f9c72244ae1a32b1e8715b Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 11 May 2026 21:37:45 -0700 Subject: [PATCH 05/21] fix: return ssh lifecycle shell command --- CLI/cmux.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 334aacc70055..cb6e9288a05c 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -5894,7 +5894,7 @@ struct CMUXCLI { extraArguments: [String] = [] ) -> String { let suffix = extraArguments.isEmpty ? "" : " " + extraArguments.joined(separator: " ") - [ + return [ "if [ -n \"${CMUX_BUNDLED_CLI_PATH:-}\" ]", "&& [ -x \"${CMUX_BUNDLED_CLI_PATH}\" ]", "&& [ -n \"${CMUX_SOCKET_PATH:-}\" ]", From 4ee080b7f30bf120cc6d0f5452eaadc325d0a3db Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 11 May 2026 22:26:31 -0700 Subject: [PATCH 06/21] test: fix ssh lifecycle assertion messages --- cmuxTests/SSHStartupSignalLifecycleTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmuxTests/SSHStartupSignalLifecycleTests.swift b/cmuxTests/SSHStartupSignalLifecycleTests.swift index 56d086f2563f..55b1989bd729 100644 --- a/cmuxTests/SSHStartupSignalLifecycleTests.swift +++ b/cmuxTests/SSHStartupSignalLifecycleTests.swift @@ -200,10 +200,10 @@ extension CLINotifyProcessIntegrationRegressionTests { XCTAssertNotNil(connectedIndex, "Successful SSH redial must mark the pane as connected; recorded: \(recordedCalls)") XCTAssertNotNil(sessionEndIndex, "The terminal still reports final session end on wrapper exit; recorded: \(recordedCalls)") if let reconnectingIndex, let connectedIndex { - XCTAssertLessThan(reconnectingIndex, connectedIndex, recordedCalls) + XCTAssertLessThan(reconnectingIndex, connectedIndex, recordedCalls.joined(separator: "\n")) } if let connectedIndex, let sessionEndIndex { - XCTAssertLessThan(connectedIndex, sessionEndIndex, recordedCalls) + XCTAssertLessThan(connectedIndex, sessionEndIndex, recordedCalls.joined(separator: "\n")) } if let reconnectingIndex { XCTAssertTrue(recordedCalls[reconnectingIndex].contains("--attempt 1"), recordedCalls.joined(separator: "\n")) From 0ddc0aba54149129479b40b39c31b02f2b76edfc Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 02:44:46 -0700 Subject: [PATCH 07/21] chore: retrigger ci From f887fb112853addb005dbe3b901951cfd2253a49 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 03:13:26 -0700 Subject: [PATCH 08/21] fix: mark ssh lifecycle apply closure main actor --- Sources/TerminalController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 2f1dc70d8ae5..2c5b6bf93124 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -5133,7 +5133,7 @@ class TerminalController { private func v2ApplyWorkspaceRemoteTerminalLifecycle( identity: RemoteTerminalLifecycleIdentity, - apply: (Workspace) -> Void + apply: @MainActor (Workspace) -> Void ) -> V2CallResult { var result: V2CallResult = .err(code: "not_found", message: "Workspace not found", data: [ "workspace_id": identity.workspaceId.uuidString, From 49a624431e6dca45a249a6e9e1b957084929b5ca Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 03:22:33 -0700 Subject: [PATCH 09/21] fix: demote exhausted ssh reconnect sessions --- Sources/Workspace.swift | 3 +- .../WorkspaceRemoteConnectionTests.swift | 41 +++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index f1ed0b69abae..2eb1fe396908 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -9311,8 +9311,7 @@ final class Workspace: Identifiable, ObservableObject { if !hasBrowserPanels { if remoteConnectionState == .error || remoteDaemonStatus.state == .error || - remoteConnectionState == .connecting || - remoteConnectionState == .reconnecting { + remoteConnectionState == .connecting { return } disconnectRemoteConnection(clearConfiguration: true) diff --git a/cmuxTests/WorkspaceRemoteConnectionTests.swift b/cmuxTests/WorkspaceRemoteConnectionTests.swift index 3fd0e4cd8349..314f8fd82f99 100644 --- a/cmuxTests/WorkspaceRemoteConnectionTests.swift +++ b/cmuxTests/WorkspaceRemoteConnectionTests.swift @@ -596,6 +596,47 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertEqual(workspace.remoteStatusPayload()["state"] as? String, "error") } + @MainActor + func testRemoteTerminalSessionEndWhileReconnectingDemotesWorkspace() throws { + let workspace = Workspace() + let config = WorkspaceRemoteConfiguration( + destination: "cmux@gateway.freestyle.sh", + port: 2222, + identityFile: nil, + sshOptions: ["ControlMaster=no"], + localProxyPort: nil, + relayPort: 64044, + relayID: String(repeating: "a", count: 16), + relayToken: String(repeating: "b", count: 64), + localSocketPath: "/tmp/cmux-debug-test.sock", + terminalStartupCommand: "ssh cmux@gateway.freestyle.sh", + skipDaemonBootstrap: true + ) + + Workspace.runSSHControlMasterCommandOverrideForTesting = { _ in } + defer { Workspace.runSSHControlMasterCommandOverrideForTesting = nil } + + workspace.configureRemoteConnection(config, autoConnect: false) + let panelID = try XCTUnwrap(workspace.focusedTerminalPanel?.id) + + workspace.markRemoteTerminalSessionReconnecting( + surfaceId: panelID, + relayPort: 64044, + attempt: 2, + limit: 2, + exitStatus: 255 + ) + XCTAssertEqual(workspace.remoteConnectionState, .reconnecting) + + workspace.markRemoteTerminalSessionEnded(surfaceId: panelID, relayPort: 64044) + + XCTAssertFalse(workspace.isRemoteWorkspace) + XCTAssertFalse(workspace.isRemoteTerminalSurface(panelID)) + XCTAssertEqual(workspace.activeRemoteTerminalSessionCount, 0) + XCTAssertEqual(workspace.remoteConnectionState, .disconnected) + XCTAssertEqual(workspace.remoteStatusPayload()["state"] as? String, "disconnected") + } + @MainActor func testForegroundSSHAuthReadyBeforeRemoteConfigureStartsDeferredConnect() { let workspace = Workspace() From 4647a5602f0a22f10f5afe8ad2483cbe3879e07a Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 13:18:55 -0700 Subject: [PATCH 10/21] docs: add cloud vm ssh reconnect smoke --- docs/remote-daemon-spec.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/docs/remote-daemon-spec.md b/docs/remote-daemon-spec.md index 3c8bb0c8a81b..80a5945cf3cb 100644 --- a/docs/remote-daemon-spec.md +++ b/docs/remote-daemon-spec.md @@ -1,6 +1,6 @@ # Remote SSH Living Spec -Last updated: March 12, 2026 +Last updated: May 12, 2026 Tracking issue: https://github.com/manaflow-ai/cmux/issues/151 Primary PR: https://github.com/manaflow-ai/cmux/pull/1296 CLI relay PR: https://github.com/manaflow-ai/cmux/pull/374 @@ -147,6 +147,7 @@ Recompute effective size on: | T-003 | no `--name` | DONE | | T-004 | reconnect API success/error paths | DONE | | T-005 | retry count visible in daemon error detail | DONE | +| T-006 | Cloud VM sleep/network interruption manual smoke | DOCUMENTED | ### 7.2 CLI Relay @@ -212,3 +213,13 @@ Before declaring browser proxying complete: 1. `CMUX_SSH_TEST_DOCKER_HOST` sets the SSH destination host/IP used by docker-backed SSH fixtures (default `127.0.0.1`). 2. `CMUX_SSH_TEST_DOCKER_BIND_ADDR` sets the bind address used in fixture container publish mappings (default `127.0.0.1`). 3. Defaults preserve loopback behavior on a single host; override both when docker runs on a different host (for example VM -> host OrbStack). + +### 10.4 Manual Cloud VM Sleep/Network Smoke +Use this smoke when validating Cloud VM SSH resilience against a real provider VM: + +1. Create or select a running Cloud VM and open it with `cmux vm ssh ` from the tagged app build under test. +2. In the remote terminal, start a durable sentinel command such as `while :; do date; sleep 2; done` and verify output is advancing. +3. Interrupt the local client network path without destroying the VM: sleep the Mac, disable Wi-Fi, or block outbound network briefly. +4. Verify the terminal pane and remote status payload enter `reconnecting`, with detail that includes the SSH retry attempt and exit status. +5. Restore the network or wake the Mac. Verify the same pane returns to `connected`, the sentinel command continues or the shell remains usable, and browser panels in the same remote workspace keep using the remote proxy path. +6. Repeat with the VM stopped or destroyed during reconnect. Verify retries exhaust into `disconnected` or `error`, the pane shows the final SSH status instead of falling back to a local prompt, and `workspace.remote.status` clearly reports that the remote session ended. From 2a2d1969e4a39e724553545d717cd5f92c3f20f2 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 13:22:06 -0700 Subject: [PATCH 11/21] fix: settle remote state after terminal reconnect ends --- Resources/Localizable.xcstrings | 17 ++++++ Sources/Workspace.swift | 35 +++++++++++++ .../WorkspaceRemoteConnectionTests.swift | 52 +++++++++++++++++++ 3 files changed, 104 insertions(+) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 20e0ef730cb3..ab4ae383a75a 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -110594,6 +110594,23 @@ } } }, + "remote.state.disconnected.terminalEnded": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "SSH session to %@ ended." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ への SSH セッションが終了しました。" + } + } + } + }, "remote.state.targetFallback": { "extractionState": "manual", "localizations": { diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 2eb1fe396908..33ec71309879 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -9315,7 +9315,42 @@ final class Workspace: Identifiable, ObservableObject { return } disconnectRemoteConnection(clearConfiguration: true) + return + } + settleRemoteConnectionStateAfterLastTerminalSessionEnded() + } + + private func settleRemoteConnectionStateAfterLastTerminalSessionEnded() { + guard activeRemoteTerminalSurfaceIds.isEmpty, remoteConfiguration != nil else { return } + guard remoteConnectionState != .error, + remoteDaemonStatus.state != .error, + remoteConnectionState != .connecting else { + return + } + + let target = remoteConfiguration?.displayTarget ?? String( + localized: "remote.state.targetFallback", + defaultValue: "remote host" + ) + if remoteProxyEndpoint != nil || remoteDaemonStatus.state == .ready { + if remoteConnectionState != .connected { + applyRemoteConnectionStateUpdate(.connected, detail: nil, target: target) + } + return + } + + guard remoteConnectionState == .connected || remoteConnectionState == .reconnecting else { + return } + let detailFormat = String( + localized: "remote.state.disconnected.terminalEnded", + defaultValue: "SSH session to %@ ended." + ) + applyRemoteConnectionStateUpdate( + .disconnected, + detail: String(format: detailFormat, target), + target: target + ) } @MainActor diff --git a/cmuxTests/WorkspaceRemoteConnectionTests.swift b/cmuxTests/WorkspaceRemoteConnectionTests.swift index 314f8fd82f99..7a463eb938ac 100644 --- a/cmuxTests/WorkspaceRemoteConnectionTests.swift +++ b/cmuxTests/WorkspaceRemoteConnectionTests.swift @@ -637,6 +637,47 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertEqual(workspace.remoteStatusPayload()["state"] as? String, "disconnected") } + @MainActor + func testRemoteTerminalSessionEndWhileReconnectingWithBrowserPanelSettlesDisconnected() throws { + let workspace = Workspace() + let paneID = try XCTUnwrap(workspace.bonsplitController.allPaneIds.first) + let config = WorkspaceRemoteConfiguration( + destination: "cmux@gateway.freestyle.sh", + port: 2222, + identityFile: nil, + sshOptions: ["ControlMaster=no"], + localProxyPort: nil, + relayPort: 64045, + relayID: String(repeating: "a", count: 16), + relayToken: String(repeating: "b", count: 64), + localSocketPath: "/tmp/cmux-debug-test.sock", + terminalStartupCommand: "ssh cmux@gateway.freestyle.sh", + skipDaemonBootstrap: true + ) + + workspace.configureRemoteConnection(config, autoConnect: false) + let panelID = try XCTUnwrap(workspace.focusedTerminalPanel?.id) + _ = workspace.newBrowserSurface(inPane: paneID, url: URL(string: "https://example.com"), focus: false) + + workspace.markRemoteTerminalSessionReconnecting( + surfaceId: panelID, + relayPort: 64045, + attempt: 2, + limit: 2, + exitStatus: 255 + ) + XCTAssertEqual(workspace.remoteConnectionState, .reconnecting) + + workspace.markRemoteTerminalSessionEnded(surfaceId: panelID, relayPort: 64045) + + XCTAssertTrue(workspace.isRemoteWorkspace) + XCTAssertFalse(workspace.isRemoteTerminalSurface(panelID)) + XCTAssertEqual(workspace.activeRemoteTerminalSessionCount, 0) + XCTAssertEqual(workspace.remoteConnectionState, .disconnected) + XCTAssertEqual(workspace.remoteStatusPayload()["state"] as? String, "disconnected") + XCTAssertEqual(workspace.remoteConnectionDetail, "SSH session to cmux@gateway.freestyle.sh ended.") + } + @MainActor func testForegroundSSHAuthReadyBeforeRemoteConfigureStartsDeferredConnect() { let workspace = Workspace() @@ -1209,6 +1250,14 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { workspace.configureRemoteConnection(config, autoConnect: false) _ = workspace.newBrowserSurface(inPane: paneID, url: URL(string: "https://example.com"), focus: false) + workspace.markRemoteTerminalSessionReconnecting( + surfaceId: initialTerminalID, + relayPort: 64013, + attempt: 1, + limit: 2, + exitStatus: 255 + ) + XCTAssertEqual(workspace.remoteConnectionState, .reconnecting) workspace.markRemoteTerminalSessionEnded(surfaceId: initialTerminalID, relayPort: 64013) @@ -1216,6 +1265,9 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertTrue(workspace.isRemoteWorkspace) XCTAssertEqual(workspace.activeRemoteTerminalSessionCount, 0) + XCTAssertEqual(workspace.remoteConnectionState, .disconnected) + XCTAssertEqual(workspace.remoteConnectionDetail, "SSH session to cmux-macmini ended.") + XCTAssertEqual(workspace.remoteStatusPayload()["state"] as? String, "disconnected") } func testRemoteDropPathUsesLowercasedExtensionAndProvidedUUID() throws { From df02c26f48b49353177be37b2716c2561df28cc4 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 14 May 2026 16:45:22 -0700 Subject: [PATCH 12/21] docs: fix cloud vm smoke heading spacing --- docs/remote-daemon-spec.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/remote-daemon-spec.md b/docs/remote-daemon-spec.md index 80a5945cf3cb..ddf10d8132ce 100644 --- a/docs/remote-daemon-spec.md +++ b/docs/remote-daemon-spec.md @@ -215,6 +215,7 @@ Before declaring browser proxying complete: 3. Defaults preserve loopback behavior on a single host; override both when docker runs on a different host (for example VM -> host OrbStack). ### 10.4 Manual Cloud VM Sleep/Network Smoke + Use this smoke when validating Cloud VM SSH resilience against a real provider VM: 1. Create or select a running Cloud VM and open it with `cmux vm ssh ` from the tagged app build under test. From 76298242f6795750c2f076e0fec249d6fb3e55b7 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 17 May 2026 23:24:16 -0700 Subject: [PATCH 13/21] fix: make ssh lifecycle actor isolation explicit --- Sources/Workspace.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index a2fa3c0d5253..92e7321f19e1 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -9700,6 +9700,7 @@ final class Workspace: Identifiable, ObservableObject { return true } + @MainActor func markRemoteTerminalSessionEnded(surfaceId: UUID, relayPort: Int?) { if cleanupTransferredRemoteConnectionIfNeeded(surfaceId: surfaceId, relayPort: relayPort) { return @@ -9720,6 +9721,7 @@ final class Workspace: Identifiable, ObservableObject { untrackRemoteTerminalSurface(surfaceId) } + @MainActor func markRemoteTerminalSessionReconnecting( surfaceId: UUID, relayPort: Int?, @@ -9741,6 +9743,7 @@ final class Workspace: Identifiable, ObservableObject { applyRemoteConnectionStateUpdate(.reconnecting, detail: detail, target: target) } + @MainActor func markRemoteTerminalSessionConnected(surfaceId: UUID, relayPort: Int?) { guard remoteTerminalLifecycleMatches(surfaceId: surfaceId, relayPort: relayPort) else { return } guard remoteConnectionState == .connecting || remoteConnectionState == .reconnecting else { return } From cf5d2917ec5a986d41029c3d738a1c911a686da2 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 00:02:08 -0700 Subject: [PATCH 14/21] fix: use runtime cli for ssh connected lifecycle --- CLI/cmux.swift | 45 +++++++++++++++++---------------------------- 1 file changed, 17 insertions(+), 28 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index b8c3e2510e75..244d3f491693 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -5303,9 +5303,8 @@ struct CMUXCLI { localCLIPath: resolvedExecutableURL()?.path, foregroundAuthToken: deferredRemoteReconnectToken ) - let sshSessionConnectedCommandScript = sshSessionConnectedLocalCommandScript( - remoteRelayPort: sshOptions.remoteRelayPort, - localCLIPath: resolvedExecutableURL()?.path + let sshSessionConnectedCommandScript = buildSSHSessionConnectedShellCommand( + remoteRelayPort: sshOptions.remoteRelayPort ) let sshConnectionTimingCommandScript = sshConnectionTimingLocalCommandScript( target: sshOptions.displayDestination, @@ -6413,6 +6412,13 @@ struct CMUXCLI { ) } + private func buildSSHSessionConnectedShellCommand(remoteRelayPort: Int) -> String { + buildSSHSessionLifecycleShellCommand( + subcommand: "ssh-session-connected", + remoteRelayPort: remoteRelayPort + ) + } + private func buildSSHSessionLifecycleShellCommand( subcommand: String, remoteRelayPort: Int, @@ -6420,17 +6426,14 @@ struct CMUXCLI { ) -> String { let suffix = extraArguments.isEmpty ? "" : " " + extraArguments.joined(separator: " ") return [ - "if [ -n \"${CMUX_BUNDLED_CLI_PATH:-}\" ]", - "&& [ -x \"${CMUX_BUNDLED_CLI_PATH}\" ]", - "&& [ -n \"${CMUX_SOCKET_PATH:-}\" ]", - "&& [ -n \"${CMUX_WORKSPACE_ID:-}\" ]", - "&& [ -n \"${CMUX_SURFACE_ID:-}\" ]; then", - "\"${CMUX_BUNDLED_CLI_PATH}\" --socket \"${CMUX_SOCKET_PATH}\" \(subcommand) --relay-port \(remoteRelayPort) --workspace \"${CMUX_WORKSPACE_ID}\" --surface \"${CMUX_SURFACE_ID}\"\(suffix) >/dev/null 2>&1 || true;", - "elif command -v cmux >/dev/null 2>&1", - "&& [ -n \"${CMUX_WORKSPACE_ID:-}\" ]", - "&& [ -n \"${CMUX_SURFACE_ID:-}\" ]; then", - "cmux \(subcommand) --relay-port \(remoteRelayPort) --workspace \"${CMUX_WORKSPACE_ID}\" --surface \"${CMUX_SURFACE_ID}\"\(suffix) >/dev/null 2>&1 || true;", - "fi", + "cmux_lifecycle_cli=\"\";", + "cmux_lifecycle_socket=\"${CMUX_SOCKET_PATH:-${CMUX_SOCKET:-}}\";", + "if [ -n \"${CMUX_BUNDLED_CLI_PATH:-}\" ] && [ -x \"${CMUX_BUNDLED_CLI_PATH}\" ]; then cmux_lifecycle_cli=\"$CMUX_BUNDLED_CLI_PATH\"; fi;", + "if [ -z \"$cmux_lifecycle_cli\" ]; then cmux_lifecycle_cli=\"$(command -v cmux 2>/dev/null || true)\"; fi;", + "if [ -n \"$cmux_lifecycle_cli\" ] && [ -x \"$cmux_lifecycle_cli\" ] && [ -n \"$cmux_lifecycle_socket\" ] && [ -n \"${CMUX_WORKSPACE_ID:-}\" ] && [ -n \"${CMUX_SURFACE_ID:-}\" ]; then", + "\"$cmux_lifecycle_cli\" --socket \"$cmux_lifecycle_socket\" \(subcommand) --relay-port \(remoteRelayPort) --workspace \"${CMUX_WORKSPACE_ID}\" --surface \"${CMUX_SURFACE_ID}\"\(suffix) >/dev/null 2>&1 || true;", + "fi;", + "unset cmux_lifecycle_socket cmux_lifecycle_cli;", ].joined(separator: " ") } @@ -7481,20 +7484,6 @@ struct CMUXCLI { ].joined(separator: " ") } - private func sshSessionConnectedLocalCommandScript(remoteRelayPort: Int, localCLIPath: String?) -> String { - let preferredCLIPath = localCLIPath?.trimmingCharacters(in: .whitespacesAndNewlines) - return [ - preferredCLIPath.map { "cmux_connected_cli=\(shellQuote($0));" } ?? "cmux_connected_cli=\"\";", - "cmux_connected_socket=\"${CMUX_SOCKET_PATH:-${CMUX_SOCKET:-}}\";", - "if [ -z \"$cmux_connected_cli\" ] && [ -n \"${CMUX_BUNDLED_CLI_PATH:-}\" ]; then cmux_connected_cli=\"$CMUX_BUNDLED_CLI_PATH\"; fi;", - "if [ ! -x \"$cmux_connected_cli\" ]; then cmux_connected_cli=\"$(command -v cmux 2>/dev/null || true)\"; fi;", - "if [ -n \"${CMUX_WORKSPACE_ID:-}\" ] && [ -n \"${CMUX_SURFACE_ID:-}\" ] && [ -n \"$cmux_connected_socket\" ] && [ -n \"$cmux_connected_cli\" ] && [ -x \"$cmux_connected_cli\" ]; then", - "\"$cmux_connected_cli\" --socket \"$cmux_connected_socket\" ssh-session-connected --relay-port \(remoteRelayPort) --workspace \"$CMUX_WORKSPACE_ID\" --surface \"$CMUX_SURFACE_ID\" >/dev/null 2>&1 || true;", - "fi;", - "unset cmux_connected_socket cmux_connected_cli;", - ].joined(separator: " ") - } - private func sshConnectionTimingLocalCommandScript(target: String, relayPort: Int) -> String { let escapedTarget = target .replacingOccurrences(of: "\\", with: "\\\\") From c4ed6ea3b3acb9e21399748a584b0ec4966edf18 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 00:05:03 -0700 Subject: [PATCH 15/21] fix: hide ssh exit code from reconnect copy --- Resources/Localizable.xcstrings | 4 ++-- Sources/Workspace.swift | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 5b632baca72c..e820fc5b5956 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -111723,13 +111723,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Reconnecting to %@ (attempt %lld/%lld after SSH exit %lld)" + "value": "Reconnecting to %@ (attempt %lld/%lld)" } }, "ja": { "stringUnit": { "state": "translated", - "value": "%@ に再接続中 (試行 %lld/%lld、SSH 終了 %lld)" + "value": "%@ に再接続中 (試行 %lld/%lld)" } } } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 92e7321f19e1..538b077f3f1a 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -9727,7 +9727,7 @@ final class Workspace: Identifiable, ObservableObject { relayPort: Int?, attempt: Int, limit: Int, - exitStatus: Int + exitStatus _: Int ) { guard remoteTerminalLifecycleMatches(surfaceId: surfaceId, relayPort: relayPort) else { return } guard remoteConnectionState != .error else { return } @@ -9737,9 +9737,9 @@ final class Workspace: Identifiable, ObservableObject { ) let detailFormat = String( localized: "remote.state.reconnecting.terminal", - defaultValue: "Reconnecting to %@ (attempt %lld/%lld after SSH exit %lld)" + defaultValue: "Reconnecting to %@ (attempt %lld/%lld)" ) - let detail = String(format: detailFormat, target, Int64(attempt), Int64(limit), Int64(exitStatus)) + let detail = String(format: detailFormat, target, Int64(attempt), Int64(limit)) applyRemoteConnectionStateUpdate(.reconnecting, detail: detail, target: target) } From 270c4cd430de570e8f8cc1ff9f363fcb4899f3fb Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 00:14:54 -0700 Subject: [PATCH 16/21] fix: annotate ssh lifecycle helpers --- Sources/Workspace.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 538b077f3f1a..c22deec7c6b6 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -9572,6 +9572,7 @@ final class Workspace: Identifiable, ObservableObject { settleRemoteConnectionStateAfterLastTerminalSessionEnded() } + @MainActor private func settleRemoteConnectionStateAfterLastTerminalSessionEnded() { guard activeRemoteTerminalSurfaceIds.isEmpty, remoteConfiguration != nil else { return } guard remoteConnectionState != .error, @@ -9769,6 +9770,7 @@ final class Workspace: Identifiable, ObservableObject { applyRemoteConnectionStateUpdate(.connected, detail: nil, target: target) } + @MainActor private func remoteTerminalLifecycleMatches(surfaceId: UUID, relayPort: Int?) -> Bool { guard let relayPort, relayPort > 0, From ab75b0b667f300b28631d278d9deb99e08f5e276 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 00:26:50 -0700 Subject: [PATCH 17/21] fix: preserve ssh lifecycle cli fallback --- CLI/cmux.swift | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 244d3f491693..701c0e39ff5e 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -6425,15 +6425,24 @@ struct CMUXCLI { extraArguments: [String] = [] ) -> String { let suffix = extraArguments.isEmpty ? "" : " " + extraArguments.joined(separator: " ") + let fallbackCLIPath = resolvedExecutableURL()?.path.trimmingCharacters(in: .whitespacesAndNewlines) + let fallbackCLILine: String + if let fallbackCLIPath, !fallbackCLIPath.isEmpty { + fallbackCLILine = "cmux_lifecycle_fallback_cli=\(shellQuote(fallbackCLIPath));" + } else { + fallbackCLILine = "cmux_lifecycle_fallback_cli=\"\";" + } return [ "cmux_lifecycle_cli=\"\";", + fallbackCLILine, "cmux_lifecycle_socket=\"${CMUX_SOCKET_PATH:-${CMUX_SOCKET:-}}\";", "if [ -n \"${CMUX_BUNDLED_CLI_PATH:-}\" ] && [ -x \"${CMUX_BUNDLED_CLI_PATH}\" ]; then cmux_lifecycle_cli=\"$CMUX_BUNDLED_CLI_PATH\"; fi;", + "if [ -z \"$cmux_lifecycle_cli\" ] && [ -n \"$cmux_lifecycle_fallback_cli\" ] && [ -x \"$cmux_lifecycle_fallback_cli\" ]; then cmux_lifecycle_cli=\"$cmux_lifecycle_fallback_cli\"; fi;", "if [ -z \"$cmux_lifecycle_cli\" ]; then cmux_lifecycle_cli=\"$(command -v cmux 2>/dev/null || true)\"; fi;", "if [ -n \"$cmux_lifecycle_cli\" ] && [ -x \"$cmux_lifecycle_cli\" ] && [ -n \"$cmux_lifecycle_socket\" ] && [ -n \"${CMUX_WORKSPACE_ID:-}\" ] && [ -n \"${CMUX_SURFACE_ID:-}\" ]; then", "\"$cmux_lifecycle_cli\" --socket \"$cmux_lifecycle_socket\" \(subcommand) --relay-port \(remoteRelayPort) --workspace \"${CMUX_WORKSPACE_ID}\" --surface \"${CMUX_SURFACE_ID}\"\(suffix) >/dev/null 2>&1 || true;", "fi;", - "unset cmux_lifecycle_socket cmux_lifecycle_cli;", + "unset cmux_lifecycle_socket cmux_lifecycle_fallback_cli cmux_lifecycle_cli;", ].joined(separator: " ") } From 45972bca74314c4dd0a1d004f8d41b76f4dc8a84 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 00:53:09 -0700 Subject: [PATCH 18/21] fix: avoid reconnecting stale connected state --- Sources/Workspace.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index c22deec7c6b6..ff6981575419 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -9585,7 +9585,8 @@ final class Workspace: Identifiable, ObservableObject { localized: "remote.state.targetFallback", defaultValue: "remote host" ) - if remoteProxyEndpoint != nil || remoteDaemonStatus.state == .ready { + if remoteConnectionState != .reconnecting, + remoteProxyEndpoint != nil || remoteDaemonStatus.state == .ready { if remoteConnectionState != .connected { applyRemoteConnectionStateUpdate(.connected, detail: nil, target: target) } From a3026dd784130badb215bdce20b4a4b0493b2be1 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 21:53:12 -0700 Subject: [PATCH 19/21] fix: preserve vm no-proxy browser connection state --- Sources/Workspace.swift | 6 ++- .../WorkspaceRemoteConnectionTests.swift | 42 +++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 6471d92ceb2a..ff4c80f76f7f 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -9590,8 +9590,10 @@ final class Workspace: Identifiable, ObservableObject { localized: "remote.state.targetFallback", defaultValue: "remote host" ) - if remoteConnectionState != .reconnecting, - remoteProxyEndpoint != nil || remoteDaemonStatus.state == .ready { + let vmNoProxyConnectionReady = remoteConnectionState != .reconnecting && + remoteConfiguration?.skipDaemonBootstrap == true && + remoteConfiguration?.daemonWebSocketEndpoint == nil + if remoteProxyEndpoint != nil || remoteDaemonStatus.state == .ready || vmNoProxyConnectionReady { if remoteConnectionState != .connected { applyRemoteConnectionStateUpdate(.connected, detail: nil, target: target) } diff --git a/cmuxTests/WorkspaceRemoteConnectionTests.swift b/cmuxTests/WorkspaceRemoteConnectionTests.swift index 107cce7dbf89..27cd432baab2 100644 --- a/cmuxTests/WorkspaceRemoteConnectionTests.swift +++ b/cmuxTests/WorkspaceRemoteConnectionTests.swift @@ -678,6 +678,48 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertEqual(workspace.remoteConnectionDetail, "SSH session to cmux@gateway.freestyle.sh ended.") } + @MainActor + func testRemoteTerminalSessionEndAfterVMNoProxyReconnectKeepsBrowserWorkspaceConnected() throws { + let workspace = Workspace() + let paneID = try XCTUnwrap(workspace.bonsplitController.allPaneIds.first) + let config = WorkspaceRemoteConfiguration( + destination: "cmux@gateway.freestyle.sh", + port: 2222, + identityFile: nil, + sshOptions: ["ControlMaster=no"], + localProxyPort: nil, + relayPort: 64046, + relayID: String(repeating: "a", count: 16), + relayToken: String(repeating: "b", count: 64), + localSocketPath: "/tmp/cmux-debug-test.sock", + terminalStartupCommand: "ssh cmux@gateway.freestyle.sh", + skipDaemonBootstrap: true + ) + + workspace.configureRemoteConnection(config, autoConnect: false) + let panelID = try XCTUnwrap(workspace.focusedTerminalPanel?.id) + _ = workspace.newBrowserSurface(inPane: paneID, url: URL(string: "https://example.com"), focus: false) + + workspace.markRemoteTerminalSessionReconnecting( + surfaceId: panelID, + relayPort: 64046, + attempt: 1, + limit: 2, + exitStatus: 255 + ) + workspace.markRemoteTerminalSessionConnected(surfaceId: panelID, relayPort: 64046) + XCTAssertEqual(workspace.remoteConnectionState, .connected) + + workspace.markRemoteTerminalSessionEnded(surfaceId: panelID, relayPort: 64046) + + XCTAssertTrue(workspace.isRemoteWorkspace) + XCTAssertFalse(workspace.isRemoteTerminalSurface(panelID)) + XCTAssertEqual(workspace.activeRemoteTerminalSessionCount, 0) + XCTAssertEqual(workspace.remoteConnectionState, .connected) + XCTAssertEqual(workspace.remoteStatusPayload()["state"] as? String, "connected") + XCTAssertEqual(workspace.remoteConnectionDetail, "Connected to cmux@gateway.freestyle.sh (VM, proxy disabled)") + } + @MainActor func testForegroundSSHAuthReadyBeforeRemoteConfigureStartsDeferredConnect() { let workspace = Workspace() From a3a32cbbb94b3ca92d2415de11f65d0649546875 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 22:08:20 -0700 Subject: [PATCH 20/21] fix: sequence ssh lifecycle state updates --- CLI/cmux.swift | 23 +- Resources/Localizable.xcstrings | 306 ++++++++++++++++++ Sources/TerminalController.swift | 14 +- Sources/Workspace.swift | 22 +- .../SSHStartupSignalLifecycleTests.swift | 4 + .../WorkspaceRemoteConnectionTests.swift | 121 ++++++- docs/cli-contract.md | 4 +- docs/remote-daemon-spec.md | 2 +- 8 files changed, 478 insertions(+), 18 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index e1c9253311e4..944165cf0c3a 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -6013,6 +6013,7 @@ struct CMUXCLI { "cmux_ssh_reconnect_delay=\"${CMUX_SSH_RECONNECT_DELAY_SECONDS:-2}\"", "case \"$cmux_ssh_reconnect_delay\" in ''|*[!0-9]*) cmux_ssh_reconnect_delay=2 ;; esac", "cmux_ssh_retry=0", + "cmux_ssh_lifecycle_sequence=0", "CMUX_SSH_CHILD_PID=", "CMUX_SSH_PENDING_SIGNAL=", "cmux_ssh_note() { if [ -t 2 ]; then printf \"$@\" >&2 || true; fi; }", @@ -6029,6 +6030,11 @@ struct CMUXCLI { if let trimmedControlPathPreflight, !trimmedControlPathPreflight.isEmpty { scriptLines.append(" cmux_ssh_preflight_control_path") } + scriptLines += [ + " cmux_ssh_lifecycle_sequence=$((cmux_ssh_lifecycle_sequence + 1))", + " CMUX_SSH_LIFECYCLE_SEQUENCE=\"$cmux_ssh_lifecycle_sequence\"", + " export CMUX_SSH_LIFECYCLE_SEQUENCE", + ] // POSIX sh redirects stdin of an async command (`&`) to /dev/null when // job control is off (the default for `/bin/sh -c …`), so ssh would // never receive keystrokes from the surface PTY. Inheriting fd 0 @@ -6053,6 +6059,9 @@ struct CMUXCLI { " if [ \"$cmux_ssh_status\" -ne 255 ]; then break; fi", " if [ \"$cmux_ssh_retry\" -ge \"$cmux_ssh_reconnect_limit\" ]; then break; fi", " cmux_ssh_retry=$((cmux_ssh_retry + 1))", + " cmux_ssh_lifecycle_sequence=$((cmux_ssh_lifecycle_sequence + 1))", + " CMUX_SSH_LIFECYCLE_SEQUENCE=\"$cmux_ssh_lifecycle_sequence\"", + " export CMUX_SSH_LIFECYCLE_SEQUENCE", " \(lifecycleReconnecting)", " cmux_ssh_note '\\n\\033[33m[cmux] ssh exited with status %s; reconnecting (attempt %s/%s).\\033[0m\\n\\033[2m[cmux] close this pane or press Ctrl-C to stop reconnecting.\\033[0m\\n' \"$cmux_ssh_status\" \"$cmux_ssh_retry\" \"$cmux_ssh_reconnect_limit\"", " if [ \"$cmux_ssh_reconnect_delay\" -gt 0 ]; then sleep \"$cmux_ssh_reconnect_delay\"; fi", @@ -6123,6 +6132,7 @@ struct CMUXCLI { "--attempt \"$cmux_ssh_retry\"", "--limit \"$cmux_ssh_reconnect_limit\"", "--exit-status \"$cmux_ssh_status\"", + "--sequence \"$cmux_ssh_lifecycle_sequence\"", ] ) } @@ -6130,7 +6140,10 @@ struct CMUXCLI { private func buildSSHSessionConnectedShellCommand(remoteRelayPort: Int) -> String { buildSSHSessionLifecycleShellCommand( subcommand: "ssh-session-connected", - remoteRelayPort: remoteRelayPort + remoteRelayPort: remoteRelayPort, + extraArguments: [ + "--sequence \"${CMUX_SSH_LIFECYCLE_SEQUENCE:-0}\"", + ] ) } @@ -6930,6 +6943,9 @@ struct CMUXCLI { guard let exitStatus = nonNegativeIntOption(commandArgs, name: "--exit-status") else { throw CLIError(message: "ssh-session-reconnecting requires --exit-status ") } + guard let sequence = positiveIntOption(commandArgs, name: "--sequence") else { + throw CLIError(message: "ssh-session-reconnecting requires --sequence ") + } _ = try client.sendV2(method: "workspace.remote.terminal_reconnecting", params: [ "workspace_id": identity.workspaceId, "surface_id": identity.surfaceId, @@ -6937,6 +6953,7 @@ struct CMUXCLI { "attempt": attempt, "limit": limit, "exit_status": exitStatus, + "sequence": sequence, ]) } @@ -6946,10 +6963,14 @@ struct CMUXCLI { client: client, commandName: "ssh-session-connected" ) + guard let sequence = positiveIntOption(commandArgs, name: "--sequence") else { + throw CLIError(message: "ssh-session-connected requires --sequence ") + } _ = try client.sendV2(method: "workspace.remote.terminal_connected", params: [ "workspace_id": identity.workspaceId, "surface_id": identity.surfaceId, "relay_port": identity.relayPort, + "sequence": sequence, ]) } diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 964acf4df177..c3bf1689b008 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -111979,51 +111979,357 @@ "remote.state.reconnecting.terminal": { "extractionState": "manual", "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "Reconnecting to %@ (attempt %lld/%lld)" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Reconnecting to %@ (attempt %lld/%lld)" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Reconnecting to %@ (attempt %lld/%lld)" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verbindung zu %@ wird erneut hergestellt (Versuch %lld/%lld)" + } + }, "en": { "stringUnit": { "state": "translated", "value": "Reconnecting to %@ (attempt %lld/%lld)" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Reconectando a %@ (intento %lld/%lld)" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Reconnexion a %@ (tentative %lld/%lld)" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Riconnessione a %@ (tentativo %lld/%lld)" + } + }, "ja": { "stringUnit": { "state": "translated", "value": "%@ に再接続中 (試行 %lld/%lld)" } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@에 다시 연결 중 (시도 %lld/%lld)" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Kobler til %@ pa nytt (forsok %lld/%lld)" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Reconnecting to %@ (attempt %lld/%lld)" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Reconectando a %@ (tentativa %lld/%lld)" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Повторное подключение к %@ (попытка %lld/%lld)" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "Reconnecting to %@ (attempt %lld/%lld)" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Reconnecting to %@ (attempt %lld/%lld)" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Повторне підключення до %@ (спроба %lld/%lld)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在重新连接到 %@(尝试 %lld/%lld)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在重新連線到 %@(嘗試 %lld/%lld)" + } } } }, "remote.state.disconnected.terminalEnded": { "extractionState": "manual", "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "SSH session to %@ ended." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "SSH session to %@ ended." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "SSH session to %@ ended." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "SSH-Sitzung mit %@ beendet." + } + }, "en": { "stringUnit": { "state": "translated", "value": "SSH session to %@ ended." } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La sesion SSH con %@ termino." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La session SSH vers %@ est terminee." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "La sessione SSH verso %@ e terminata." + } + }, "ja": { "stringUnit": { "state": "translated", "value": "%@ への SSH セッションが終了しました。" } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@에 대한 SSH 세션이 종료되었습니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "SSH-okten til %@ er avsluttet." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "SSH session to %@ ended." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "A sessao SSH com %@ terminou." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "SSH-сеанс с %@ завершен." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "SSH session to %@ ended." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "SSH session to %@ ended." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "SSH-сеанс із %@ завершено." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "到 %@ 的 SSH 会话已结束。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "到 %@ 的 SSH 工作階段已結束。" + } } } }, "remote.state.targetFallback": { "extractionState": "manual", "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "remote host" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "remote host" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "remote host" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Remote-Host" + } + }, "en": { "stringUnit": { "state": "translated", "value": "remote host" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "host remoto" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "hote distant" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "host remoto" + } + }, "ja": { "stringUnit": { "state": "translated", "value": "リモートホスト" } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "원격 호스트" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "ekstern vert" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "remote host" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "host remoto" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "удаленный хост" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "remote host" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "remote host" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "віддалений хост" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "远程主机" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "遠端主機" + } } } }, diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 2cdefe172454..b84a037f66d8 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -5160,6 +5160,10 @@ class TerminalController { exitStatus >= 0 else { return .err(code: "invalid_params", message: "Missing or invalid exit_status", data: nil) } + guard let sequence = v2StrictInt(params, "sequence"), + sequence > 0 else { + return .err(code: "invalid_params", message: "Missing or invalid sequence", data: nil) + } return v2ApplyWorkspaceRemoteTerminalLifecycle(identity: identity) { workspace in workspace.markRemoteTerminalSessionReconnecting( @@ -5167,7 +5171,8 @@ class TerminalController { relayPort: identity.relayPort, attempt: attempt, limit: limit, - exitStatus: exitStatus + exitStatus: exitStatus, + sequence: sequence ) } } @@ -5176,11 +5181,16 @@ class TerminalController { guard let identity = v2WorkspaceRemoteTerminalLifecycleIdentity(params: params) else { return .err(code: "invalid_params", message: "Missing or invalid workspace_id, surface_id, or relay_port", data: nil) } + guard let sequence = v2StrictInt(params, "sequence"), + sequence > 0 else { + return .err(code: "invalid_params", message: "Missing or invalid sequence", data: nil) + } return v2ApplyWorkspaceRemoteTerminalLifecycle(identity: identity) { workspace in workspace.markRemoteTerminalSessionConnected( surfaceId: identity.surfaceId, - relayPort: identity.relayPort + relayPort: identity.relayPort, + sequence: sequence ) } } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index ff4c80f76f7f..a0650872ba72 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -7317,6 +7317,7 @@ final class Workspace: Identifiable, ObservableObject { private var remoteLastPortConflictFingerprint: String? private var remoteDetectedSurfaceIds: Set = [] private var activeRemoteTerminalSurfaceIds: Set = [] + private var remoteTerminalLifecycleSequencesBySurfaceId: [UUID: Int] = [:] var pendingRemoteTerminalChildExitSurfaceIds: Set = [] /// Display target of the remote workspace that just disconnected. Set right before /// `createReplacementTerminalPanel()` so the replacement shell can print a banner @@ -9548,6 +9549,7 @@ final class Workspace: Identifiable, ObservableObject { private func trackRemoteTerminalSurface(_ panelId: UUID) { skipControlMasterCleanupAfterDetachedRemoteTransfer = false pendingRemoteTerminalChildExitSurfaceIds.remove(panelId) + remoteTerminalLifecycleSequencesBySurfaceId.removeValue(forKey: panelId) transferredRemoteCleanupConfigurationsByPanelId.removeValue(forKey: panelId) guard activeRemoteTerminalSurfaceIds.insert(panelId).inserted else { return } activeRemoteTerminalSessionCount = activeRemoteTerminalSurfaceIds.count @@ -9557,6 +9559,7 @@ final class Workspace: Identifiable, ObservableObject { func untrackRemoteTerminalSurface(_ panelId: UUID) { guard activeRemoteTerminalSurfaceIds.remove(panelId) != nil else { return } + remoteTerminalLifecycleSequencesBySurfaceId.removeValue(forKey: panelId) activeRemoteTerminalSessionCount = activeRemoteTerminalSurfaceIds.count guard !isDetachingCloseTransaction else { return } maybeDemoteRemoteWorkspaceAfterSSHSessionEnded() @@ -9590,7 +9593,7 @@ final class Workspace: Identifiable, ObservableObject { localized: "remote.state.targetFallback", defaultValue: "remote host" ) - let vmNoProxyConnectionReady = remoteConnectionState != .reconnecting && + let vmNoProxyConnectionReady = remoteConnectionState == .connected && remoteConfiguration?.skipDaemonBootstrap == true && remoteConfiguration?.daemonWebSocketEndpoint == nil if remoteProxyEndpoint != nil || remoteDaemonStatus.state == .ready || vmNoProxyConnectionReady { @@ -9736,10 +9739,12 @@ final class Workspace: Identifiable, ObservableObject { relayPort: Int?, attempt: Int, limit: Int, - exitStatus _: Int + exitStatus _: Int, + sequence: Int ) { guard remoteTerminalLifecycleMatches(surfaceId: surfaceId, relayPort: relayPort) else { return } guard remoteConnectionState != .error else { return } + guard acceptRemoteTerminalLifecycleSequence(surfaceId: surfaceId, sequence: sequence) else { return } let target = remoteConfiguration?.displayTarget ?? String( localized: "remote.state.targetFallback", defaultValue: "remote host" @@ -9753,9 +9758,10 @@ final class Workspace: Identifiable, ObservableObject { } @MainActor - func markRemoteTerminalSessionConnected(surfaceId: UUID, relayPort: Int?) { + func markRemoteTerminalSessionConnected(surfaceId: UUID, relayPort: Int?, sequence: Int) { guard remoteTerminalLifecycleMatches(surfaceId: surfaceId, relayPort: relayPort) else { return } guard remoteConnectionState == .connecting || remoteConnectionState == .reconnecting else { return } + guard acceptRemoteTerminalLifecycleSequence(surfaceId: surfaceId, sequence: sequence) else { return } let target = remoteConfiguration?.displayTarget ?? String( localized: "remote.state.targetFallback", defaultValue: "remote host" @@ -9786,7 +9792,15 @@ final class Workspace: Identifiable, ObservableObject { return false } return activeRemoteTerminalSurfaceIds.contains(surfaceId) - || pendingRemoteTerminalChildExitSurfaceIds.contains(surfaceId) + } + + @MainActor + private func acceptRemoteTerminalLifecycleSequence(surfaceId: UUID, sequence: Int) -> Bool { + guard sequence > 0 else { return false } + let lastSequence = remoteTerminalLifecycleSequencesBySurfaceId[surfaceId] ?? 0 + guard sequence > lastSequence else { return false } + remoteTerminalLifecycleSequencesBySurfaceId[surfaceId] = sequence + return true } func teardownRemoteConnection() { diff --git a/cmuxTests/SSHStartupSignalLifecycleTests.swift b/cmuxTests/SSHStartupSignalLifecycleTests.swift index 3d6ff28d35ba..ab2f8e50687a 100644 --- a/cmuxTests/SSHStartupSignalLifecycleTests.swift +++ b/cmuxTests/SSHStartupSignalLifecycleTests.swift @@ -294,6 +294,10 @@ extension CLINotifyProcessIntegrationRegressionTests { if let reconnectingIndex { XCTAssertTrue(recordedCalls[reconnectingIndex].contains("--attempt 1"), recordedCalls.joined(separator: "\n")) XCTAssertTrue(recordedCalls[reconnectingIndex].contains("--exit-status 255"), recordedCalls.joined(separator: "\n")) + XCTAssertTrue(recordedCalls[reconnectingIndex].contains("--sequence 2"), recordedCalls.joined(separator: "\n")) + } + if let connectedIndex { + XCTAssertTrue(recordedCalls[connectedIndex].contains("--sequence 3"), recordedCalls.joined(separator: "\n")) } } diff --git a/cmuxTests/WorkspaceRemoteConnectionTests.swift b/cmuxTests/WorkspaceRemoteConnectionTests.swift index 27cd432baab2..2eb270b4c4d0 100644 --- a/cmuxTests/WorkspaceRemoteConnectionTests.swift +++ b/cmuxTests/WorkspaceRemoteConnectionTests.swift @@ -544,14 +544,15 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { relayPort: 64041, attempt: 1, limit: 2, - exitStatus: 255 + exitStatus: 255, + sequence: 1 ) XCTAssertEqual(workspace.remoteConnectionState, .reconnecting) XCTAssertEqual(workspace.remoteStatusPayload()["state"] as? String, "reconnecting") XCTAssertTrue(workspace.remoteConnectionDetail?.contains("attempt 1/2") == true) - workspace.markRemoteTerminalSessionConnected(surfaceId: panelID, relayPort: 64041) + workspace.markRemoteTerminalSessionConnected(surfaceId: panelID, relayPort: 64041, sequence: 2) XCTAssertEqual(workspace.remoteConnectionState, .connected) XCTAssertEqual(workspace.remoteStatusPayload()["state"] as? String, "connected") @@ -588,7 +589,8 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { relayPort: 64043, attempt: 1, limit: 2, - exitStatus: 255 + exitStatus: 255, + sequence: 1 ) XCTAssertEqual(workspace.remoteConnectionState, .error) @@ -624,7 +626,8 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { relayPort: 64044, attempt: 2, limit: 2, - exitStatus: 255 + exitStatus: 255, + sequence: 1 ) XCTAssertEqual(workspace.remoteConnectionState, .reconnecting) @@ -664,7 +667,8 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { relayPort: 64045, attempt: 2, limit: 2, - exitStatus: 255 + exitStatus: 255, + sequence: 1 ) XCTAssertEqual(workspace.remoteConnectionState, .reconnecting) @@ -705,9 +709,10 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { relayPort: 64046, attempt: 1, limit: 2, - exitStatus: 255 + exitStatus: 255, + sequence: 1 ) - workspace.markRemoteTerminalSessionConnected(surfaceId: panelID, relayPort: 64046) + workspace.markRemoteTerminalSessionConnected(surfaceId: panelID, relayPort: 64046, sequence: 2) XCTAssertEqual(workspace.remoteConnectionState, .connected) workspace.markRemoteTerminalSessionEnded(surfaceId: panelID, relayPort: 64046) @@ -720,6 +725,105 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertEqual(workspace.remoteConnectionDetail, "Connected to cmux@gateway.freestyle.sh (VM, proxy disabled)") } + @MainActor + func testRemoteTerminalSessionEndFromDisconnectedVMNoProxyDoesNotPromoteBrowserWorkspace() throws { + let workspace = Workspace() + let paneID = try XCTUnwrap(workspace.bonsplitController.allPaneIds.first) + let config = WorkspaceRemoteConfiguration( + destination: "cmux@gateway.freestyle.sh", + port: 2222, + identityFile: nil, + sshOptions: ["ControlMaster=no"], + localProxyPort: nil, + relayPort: 64047, + relayID: String(repeating: "a", count: 16), + relayToken: String(repeating: "b", count: 64), + localSocketPath: "/tmp/cmux-debug-test.sock", + terminalStartupCommand: "ssh cmux@gateway.freestyle.sh", + skipDaemonBootstrap: true + ) + + workspace.configureRemoteConnection(config, autoConnect: false) + let panelID = try XCTUnwrap(workspace.focusedTerminalPanel?.id) + _ = workspace.newBrowserSurface(inPane: paneID, url: URL(string: "https://example.com"), focus: false) + XCTAssertEqual(workspace.remoteConnectionState, .disconnected) + + workspace.markRemoteTerminalSessionEnded(surfaceId: panelID, relayPort: 64047) + + XCTAssertTrue(workspace.isRemoteWorkspace) + XCTAssertFalse(workspace.isRemoteTerminalSurface(panelID)) + XCTAssertEqual(workspace.activeRemoteTerminalSessionCount, 0) + XCTAssertEqual(workspace.remoteConnectionState, .disconnected) + XCTAssertEqual(workspace.remoteStatusPayload()["state"] as? String, "disconnected") + } + + @MainActor + func testRemoteTerminalLifecycleIgnoresStaleSequenceAndEndedSurface() throws { + let workspace = Workspace() + let paneID = try XCTUnwrap(workspace.bonsplitController.allPaneIds.first) + let config = WorkspaceRemoteConfiguration( + destination: "cmux@gateway.freestyle.sh", + port: 2222, + identityFile: nil, + sshOptions: ["ControlMaster=no"], + localProxyPort: nil, + relayPort: 64048, + relayID: String(repeating: "a", count: 16), + relayToken: String(repeating: "b", count: 64), + localSocketPath: "/tmp/cmux-debug-test.sock", + terminalStartupCommand: "ssh cmux@gateway.freestyle.sh", + skipDaemonBootstrap: true + ) + + workspace.configureRemoteConnection(config, autoConnect: false) + let panelID = try XCTUnwrap(workspace.focusedTerminalPanel?.id) + _ = workspace.newBrowserSurface(inPane: paneID, url: URL(string: "https://example.com"), focus: false) + + workspace.markRemoteTerminalSessionReconnecting( + surfaceId: panelID, + relayPort: 64048, + attempt: 1, + limit: 2, + exitStatus: 255, + sequence: 1 + ) + workspace.markRemoteTerminalSessionConnected(surfaceId: panelID, relayPort: 64048, sequence: 2) + XCTAssertEqual(workspace.remoteConnectionState, .connected) + + workspace.markRemoteTerminalSessionReconnecting( + surfaceId: panelID, + relayPort: 64048, + attempt: 1, + limit: 2, + exitStatus: 255, + sequence: 1 + ) + XCTAssertEqual(workspace.remoteConnectionState, .connected) + + workspace.markRemoteTerminalSessionReconnecting( + surfaceId: panelID, + relayPort: 64048, + attempt: 1, + limit: 2, + exitStatus: 255, + sequence: 3 + ) + XCTAssertEqual(workspace.remoteConnectionState, .reconnecting) + + workspace.markRemoteTerminalSessionEnded(surfaceId: panelID, relayPort: 64048) + XCTAssertEqual(workspace.remoteConnectionState, .disconnected) + + workspace.markRemoteTerminalSessionReconnecting( + surfaceId: panelID, + relayPort: 64048, + attempt: 2, + limit: 2, + exitStatus: 255, + sequence: 4 + ) + XCTAssertEqual(workspace.remoteConnectionState, .disconnected) + } + @MainActor func testForegroundSSHAuthReadyBeforeRemoteConfigureStartsDeferredConnect() { let workspace = Workspace() @@ -1297,7 +1401,8 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { relayPort: 64013, attempt: 1, limit: 2, - exitStatus: 255 + exitStatus: 255, + sequence: 1 ) XCTAssertEqual(workspace.remoteConnectionState, .reconnecting) diff --git a/docs/cli-contract.md b/docs/cli-contract.md index 236c42634818..7563bf98948e 100644 --- a/docs/cli-contract.md +++ b/docs/cli-contract.md @@ -163,8 +163,8 @@ Environment: | `vm-ssh-attach` | Hidden compatibility alias for older VM workspaces. | | `vm-pty-connect` | Internal helper that connects to a VM PTY from a config file. | | `ssh-session-end` | Internal helper that clears remote SSH session state. | -| `ssh-session-reconnecting` | Internal helper that marks a remote SSH terminal as reconnecting. | -| `ssh-session-connected` | Internal helper that marks a remote SSH terminal as connected after redial. | +| `ssh-session-reconnecting` | Internal helper that marks a remote SSH terminal as reconnecting with a monotonic `--sequence`. | +| `ssh-session-connected` | Internal helper that marks a remote SSH terminal as connected after redial with a monotonic `--sequence`. | | `__tmux-compat` | Internal tmux compatibility dispatcher. | ## Command Families diff --git a/docs/remote-daemon-spec.md b/docs/remote-daemon-spec.md index ddf10d8132ce..21ccb79b9415 100644 --- a/docs/remote-daemon-spec.md +++ b/docs/remote-daemon-spec.md @@ -221,6 +221,6 @@ Use this smoke when validating Cloud VM SSH resilience against a real provider V 1. Create or select a running Cloud VM and open it with `cmux vm ssh ` from the tagged app build under test. 2. In the remote terminal, start a durable sentinel command such as `while :; do date; sleep 2; done` and verify output is advancing. 3. Interrupt the local client network path without destroying the VM: sleep the Mac, disable Wi-Fi, or block outbound network briefly. -4. Verify the terminal pane and remote status payload enter `reconnecting`, with detail that includes the SSH retry attempt and exit status. +4. Verify the terminal pane and remote status payload enter `reconnecting`, with detail that includes retry attempt context without exposing raw SSH exit status. 5. Restore the network or wake the Mac. Verify the same pane returns to `connected`, the sentinel command continues or the shell remains usable, and browser panels in the same remote workspace keep using the remote proxy path. 6. Repeat with the VM stopped or destroyed during reconnect. Verify retries exhaust into `disconnected` or `error`, the pane shows the final SSH status instead of falling back to a local prompt, and `workspace.remote.status` clearly reports that the remote session ended. From e4a400943eca24b360f716bf4f53982541a156cd Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 22:17:39 -0700 Subject: [PATCH 21/21] fix: localize ssh lifecycle fallback strings --- Resources/Localizable.xcstrings | 36 ++++++++++++++++----------------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index c3bf1689b008..c63dc1dad087 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -111982,19 +111982,19 @@ "ar": { "stringUnit": { "state": "translated", - "value": "Reconnecting to %@ (attempt %lld/%lld)" + "value": "جارٍ إعادة الاتصال بـ %@ (المحاولة %lld/%lld)" } }, "bs": { "stringUnit": { "state": "translated", - "value": "Reconnecting to %@ (attempt %lld/%lld)" + "value": "Ponovno povezivanje sa %@ (pokušaj %lld/%lld)" } }, "da": { "stringUnit": { "state": "translated", - "value": "Reconnecting to %@ (attempt %lld/%lld)" + "value": "Genopretter forbindelse til %@ (forsøg %lld/%lld)" } }, "de": { @@ -112048,7 +112048,7 @@ "pl": { "stringUnit": { "state": "translated", - "value": "Reconnecting to %@ (attempt %lld/%lld)" + "value": "Ponowne łączenie z %@ (próba %lld/%lld)" } }, "pt-BR": { @@ -112066,13 +112066,13 @@ "th": { "stringUnit": { "state": "translated", - "value": "Reconnecting to %@ (attempt %lld/%lld)" + "value": "กำลังเชื่อมต่อกับ %@ อีกครั้ง (ครั้งที่ %lld/%lld)" } }, "tr": { "stringUnit": { "state": "translated", - "value": "Reconnecting to %@ (attempt %lld/%lld)" + "value": "%@ ile yeniden bağlanılıyor (deneme %lld/%lld)" } }, "uk": { @@ -112101,19 +112101,19 @@ "ar": { "stringUnit": { "state": "translated", - "value": "SSH session to %@ ended." + "value": "انتهت جلسة SSH مع %@." } }, "bs": { "stringUnit": { "state": "translated", - "value": "SSH session to %@ ended." + "value": "SSH sesija sa %@ je završena." } }, "da": { "stringUnit": { "state": "translated", - "value": "SSH session to %@ ended." + "value": "SSH-sessionen til %@ er afsluttet." } }, "de": { @@ -112167,7 +112167,7 @@ "pl": { "stringUnit": { "state": "translated", - "value": "SSH session to %@ ended." + "value": "Sesja SSH z %@ zakończyła się." } }, "pt-BR": { @@ -112185,13 +112185,13 @@ "th": { "stringUnit": { "state": "translated", - "value": "SSH session to %@ ended." + "value": "เซสชัน SSH ไปยัง %@ สิ้นสุดแล้ว" } }, "tr": { "stringUnit": { "state": "translated", - "value": "SSH session to %@ ended." + "value": "%@ ile SSH oturumu sona erdi." } }, "uk": { @@ -112220,19 +112220,19 @@ "ar": { "stringUnit": { "state": "translated", - "value": "remote host" + "value": "المضيف البعيد" } }, "bs": { "stringUnit": { "state": "translated", - "value": "remote host" + "value": "udaljeni host" } }, "da": { "stringUnit": { "state": "translated", - "value": "remote host" + "value": "fjernvært" } }, "de": { @@ -112286,7 +112286,7 @@ "pl": { "stringUnit": { "state": "translated", - "value": "remote host" + "value": "host zdalny" } }, "pt-BR": { @@ -112304,13 +112304,13 @@ "th": { "stringUnit": { "state": "translated", - "value": "remote host" + "value": "โฮสต์ระยะไกล" } }, "tr": { "stringUnit": { "state": "translated", - "value": "remote host" + "value": "uzak ana makine" } }, "uk": {