From 5e14bfe08750d73b2d5b215ee687850c303c5262 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 21 Feb 2026 02:10:39 -0800 Subject: [PATCH] Add remote workspace reconnect actions and error surfacing --- Sources/ContentView.swift | 22 +++ Sources/TerminalController.swift | 44 +++++ Sources/Workspace.swift | 198 ++++++++++++++++++++--- tests_v2/test_ssh_remote_cli_metadata.py | 14 ++ 4 files changed, 256 insertions(+), 22 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index fbfcf9c7..a6dd9174 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -2444,6 +2444,10 @@ private struct TabItemView: View { } .contextMenu { let targetIds = contextTargetIds() + let targetWorkspaces = targetIds.compactMap { id in tabManager.tabs.first(where: { $0.id == id }) } + let remoteTargetWorkspaces = targetWorkspaces.filter { $0.isRemoteWorkspace } + let reconnectLabel = remoteTargetWorkspaces.count > 1 ? "Reconnect Workspaces" : "Reconnect Workspace" + let disconnectLabel = remoteTargetWorkspaces.count > 1 ? "Disconnect Workspaces" : "Disconnect Workspace" let shouldPin = !tab.isPinned let pinLabel = targetIds.count > 1 ? (shouldPin ? "Pin Workspaces" : "Unpin Workspaces") @@ -2470,6 +2474,24 @@ private struct TabItemView: View { } } + if !remoteTargetWorkspaces.isEmpty { + Divider() + + Button(reconnectLabel) { + for workspace in remoteTargetWorkspaces { + workspace.reconnectRemoteConnection() + } + } + .disabled(remoteTargetWorkspaces.allSatisfy { $0.remoteConnectionState == .connecting }) + + Button(disconnectLabel) { + for workspace in remoteTargetWorkspaces { + workspace.disconnectRemoteConnection(clearConfiguration: false) + } + } + .disabled(remoteTargetWorkspaces.allSatisfy { $0.remoteConnectionState == .disconnected }) + } + Divider() Button("Move Up") { diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index f5673452..66c3b6d4 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -703,6 +703,8 @@ class TerminalController { return v2Result(id: id, self.v2WorkspaceLast(params: params)) case "workspace.remote.configure": return v2Result(id: id, self.v2WorkspaceRemoteConfigure(params: params)) + case "workspace.remote.reconnect": + return v2Result(id: id, self.v2WorkspaceRemoteReconnect(params: params)) case "workspace.remote.disconnect": return v2Result(id: id, self.v2WorkspaceRemoteDisconnect(params: params)) case "workspace.remote.status": @@ -1024,6 +1026,7 @@ class TerminalController { "workspace.previous", "workspace.last", "workspace.remote.configure", + "workspace.remote.reconnect", "workspace.remote.disconnect", "workspace.remote.status", "surface.list", @@ -2050,6 +2053,47 @@ class TerminalController { return result } + private func v2WorkspaceRemoteReconnect(params: [String: Any]) -> V2CallResult { + let requestedWorkspaceId = v2UUID(params, "workspace_id") + let fallbackTabManager = v2ResolveTabManager(params: params) + let workspaceId = requestedWorkspaceId ?? fallbackTabManager?.selectedTabId + guard let workspaceId else { + return .err(code: "invalid_params", message: "Missing workspace_id", data: nil) + } + + var result: V2CallResult = .err(code: "not_found", message: "Workspace not found", data: [ + "workspace_id": workspaceId.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), + ]) + + v2MainSync { + guard let owner = AppDelegate.shared?.tabManagerFor(tabId: workspaceId), + let workspace = owner.tabs.first(where: { $0.id == workspaceId }) else { + return + } + + guard workspace.remoteConfiguration != nil else { + result = .err(code: "invalid_state", message: "Remote workspace is not configured", data: [ + "workspace_id": workspaceId.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), + ]) + return + } + + workspace.reconnectRemoteConnection() + 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), + "remote": workspace.remoteStatusPayload(), + ]) + } + + return result + } + private func v2WorkspaceRemoteStatus(params: [String: Any]) -> V2CallResult { let requestedWorkspaceId = v2UUID(params, "workspace_id") let fallbackTabManager = v2ResolveTabManager(params: params) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index d16488d8..4ece795b 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -207,8 +207,9 @@ private final class WorkspaceRemoteSessionController { publishPortsSnapshotLocked() let statusCode = process.terminationStatus - let detail = Self.lastNonEmptyLine(in: probeStderrBuffer) ?? "SSH probe exited with status \(statusCode)" - publishState(.error, detail: detail) + let rawDetail = Self.bestErrorLine(stderr: probeStderrBuffer, stdout: probeStdoutBuffer) + let detail = rawDetail ?? "SSH probe exited with status \(statusCode)" + publishState(.error, detail: "SSH probe to \(configuration.displayTarget) failed: \(detail)") scheduleProbeRestartLocked(delay: 3.0) } @@ -316,7 +317,7 @@ private final class WorkspaceRemoteSessionController { forwardEntries[port] = ForwardEntry(process: process, stderrPipe: stderrPipe) return true } catch { - publishState(.error, detail: "Failed to forward :\(port): \(error.localizedDescription)") + publishState(.error, detail: "Failed to forward local :\(port) to \(configuration.displayTarget): \(error.localizedDescription)") return false } } @@ -331,6 +332,11 @@ private final class WorkspaceRemoteSessionController { publishPortsSnapshotLocked() guard desiredRemotePorts.contains(port) else { return } + let rawDetail = Self.bestErrorLine(stderr: probeStderrBuffer) + if process.terminationReason != .exit || process.terminationStatus != 0 { + let detail = rawDetail ?? "process exited with status \(process.terminationStatus)" + publishState(.error, detail: "SSH port-forward :\(port) dropped for \(configuration.displayTarget): \(detail)") + } guard Self.isLoopbackPortAvailable(port: port) else { portConflicts.insert(port) publishPortsSnapshotLocked() @@ -354,8 +360,11 @@ private final class WorkspaceRemoteSessionController { private func publishState(_ state: WorkspaceRemoteConnectionState, detail: String?) { DispatchQueue.main.async { [weak workspace] in guard let workspace else { return } - workspace.remoteConnectionState = state - workspace.remoteConnectionDetail = detail + workspace.applyRemoteConnectionStateUpdate( + state, + detail: detail, + target: workspace.remoteDisplayTarget ?? "remote host" + ) } } @@ -377,7 +386,10 @@ private final class WorkspaceRemoteSessionController { ) DispatchQueue.main.async { [weak workspace] in guard let workspace else { return } - workspace.remoteDaemonStatus = status + workspace.applyRemoteDaemonStatusUpdate( + status, + target: workspace.remoteDisplayTarget ?? "remote host" + ) } } @@ -387,10 +399,12 @@ private final class WorkspaceRemoteSessionController { let conflicts = portConflicts.sorted() DispatchQueue.main.async { [weak workspace] in guard let workspace else { return } - workspace.remoteDetectedPorts = detected - workspace.remoteForwardedPorts = forwarded - workspace.remotePortConflicts = conflicts - workspace.recomputeListeningPorts() + workspace.applyRemotePortsSnapshot( + detected: detected, + forwarded: forwarded, + conflicts: conflicts, + target: workspace.remoteDisplayTarget ?? "remote host" + ) } } @@ -526,7 +540,7 @@ private final class WorkspaceRemoteSessionController { let command = "sh -lc \(Self.shellSingleQuoted(script))" let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 10) guard result.status == 0 else { - let detail = Self.lastNonEmptyLine(in: result.stderr) ?? "ssh exited \(result.status)" + let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "ssh exited \(result.status)" throw NSError(domain: "cmux.remote.daemon", code: 10, userInfo: [ NSLocalizedDescriptionKey: "failed to query remote platform: \(detail)", ]) @@ -600,7 +614,7 @@ private final class WorkspaceRemoteSessionController { timeout: 90 ) guard result.status == 0 else { - let detail = Self.lastNonEmptyLine(in: result.stderr) ?? "go build failed with status \(result.status)" + let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "go build failed with status \(result.status)" throw NSError(domain: "cmux.remote.daemon", code: 23, userInfo: [ NSLocalizedDescriptionKey: "failed to build cmuxd-remote: \(detail)", ]) @@ -621,7 +635,7 @@ private final class WorkspaceRemoteSessionController { let mkdirCommand = "sh -lc \(Self.shellSingleQuoted(mkdirScript))" let mkdirResult = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, mkdirCommand], timeout: 12) guard mkdirResult.status == 0 else { - let detail = Self.lastNonEmptyLine(in: mkdirResult.stderr) ?? "ssh exited \(mkdirResult.status)" + let detail = Self.bestErrorLine(stderr: mkdirResult.stderr, stdout: mkdirResult.stdout) ?? "ssh exited \(mkdirResult.status)" throw NSError(domain: "cmux.remote.daemon", code: 30, userInfo: [ NSLocalizedDescriptionKey: "failed to create remote daemon directory: \(detail)", ]) @@ -643,7 +657,7 @@ private final class WorkspaceRemoteSessionController { scpArgs += [localBinary.path, "\(configuration.destination):\(remoteTempPath)"] let scpResult = try scpExec(arguments: scpArgs, timeout: 45) guard scpResult.status == 0 else { - let detail = Self.lastNonEmptyLine(in: scpResult.stderr) ?? "scp exited \(scpResult.status)" + let detail = Self.bestErrorLine(stderr: scpResult.stderr, stdout: scpResult.stdout) ?? "scp exited \(scpResult.status)" throw NSError(domain: "cmux.remote.daemon", code: 31, userInfo: [ NSLocalizedDescriptionKey: "failed to upload cmuxd-remote: \(detail)", ]) @@ -656,7 +670,7 @@ private final class WorkspaceRemoteSessionController { let finalizeCommand = "sh -lc \(Self.shellSingleQuoted(finalizeScript))" let finalizeResult = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, finalizeCommand], timeout: 12) guard finalizeResult.status == 0 else { - let detail = Self.lastNonEmptyLine(in: finalizeResult.stderr) ?? "ssh exited \(finalizeResult.status)" + let detail = Self.bestErrorLine(stderr: finalizeResult.stderr, stdout: finalizeResult.stdout) ?? "ssh exited \(finalizeResult.status)" throw NSError(domain: "cmux.remote.daemon", code: 32, userInfo: [ NSLocalizedDescriptionKey: "failed to install remote daemon binary: \(detail)", ]) @@ -669,7 +683,7 @@ private final class WorkspaceRemoteSessionController { let command = "sh -lc \(Self.shellSingleQuoted(script))" let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 12) guard result.status == 0 else { - let detail = Self.lastNonEmptyLine(in: result.stderr) ?? "ssh exited \(result.status)" + let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "ssh exited \(result.status)" throw NSError(domain: "cmux.remote.daemon", code: 40, userInfo: [ NSLocalizedDescriptionKey: "failed to start remote daemon: \(detail)", ]) @@ -839,16 +853,38 @@ private final class WorkspaceRemoteSessionController { return nil } - private static func lastNonEmptyLine(in text: String) -> String? { - for line in text.split(separator: "\n").reversed() { - let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmed.isEmpty { - return trimmed - } + private static func bestErrorLine(stderr: String, stdout: String = "") -> String? { + if let stderrLine = meaningfulErrorLine(in: stderr) { + return stderrLine + } + if let stdoutLine = meaningfulErrorLine(in: stdout) { + return stdoutLine } return nil } + private static func meaningfulErrorLine(in text: String) -> String? { + let lines = text + .split(separator: "\n") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + + for line in lines.reversed() where !isNoiseLine(line) { + return line + } + return lines.last + } + + private static func isNoiseLine(_ line: String) -> Bool { + let lowered = line.lowercased() + if lowered.hasPrefix("warning: permanently added") { return true } + if lowered.hasPrefix("debug") { return true } + if lowered.hasPrefix("transferred:") { return true } + if lowered.hasPrefix("openbsd_") { return true } + if lowered.contains("pseudo-terminal will not be allocated") { return true } + return false + } + private static func isLoopbackPortAvailable(port: Int) -> Bool { guard port > 0 && port <= 65535 else { return false } @@ -1013,6 +1049,12 @@ final class Workspace: Identifiable, ObservableObject { @Published var listeningPorts: [Int] = [] var surfaceTTYNames: [UUID: String] = [:] private var remoteSessionController: WorkspaceRemoteSessionController? + private var remoteLastErrorFingerprint: String? + private var remoteLastDaemonErrorFingerprint: String? + private var remoteLastPortConflictFingerprint: String? + + private static let remoteErrorStatusKey = "remote.error" + private static let remotePortConflictStatusKey = "remote.port_conflicts" var focusedSurfaceId: UUID? { focusedPanelId } var surfaceDirectories: [UUID: String] { @@ -1533,6 +1575,11 @@ final class Workspace: Identifiable, ObservableObject { remotePortConflicts = [] remoteConnectionDetail = nil remoteDaemonStatus = WorkspaceRemoteDaemonStatus() + statusEntries.removeValue(forKey: Self.remoteErrorStatusKey) + statusEntries.removeValue(forKey: Self.remotePortConflictStatusKey) + remoteLastErrorFingerprint = nil + remoteLastDaemonErrorFingerprint = nil + remoteLastPortConflictFingerprint = nil recomputeListeningPorts() remoteSessionController?.stop() @@ -1563,6 +1610,11 @@ final class Workspace: Identifiable, ObservableObject { remoteConnectionState = .disconnected remoteConnectionDetail = nil remoteDaemonStatus = WorkspaceRemoteDaemonStatus() + statusEntries.removeValue(forKey: Self.remoteErrorStatusKey) + statusEntries.removeValue(forKey: Self.remotePortConflictStatusKey) + remoteLastErrorFingerprint = nil + remoteLastDaemonErrorFingerprint = nil + remoteLastPortConflictFingerprint = nil if clearConfiguration { remoteConfiguration = nil } @@ -1573,6 +1625,108 @@ final class Workspace: Identifiable, ObservableObject { disconnectRemoteConnection(clearConfiguration: true) } + fileprivate func applyRemoteConnectionStateUpdate( + _ state: WorkspaceRemoteConnectionState, + detail: String?, + target: String + ) { + remoteConnectionState = state + remoteConnectionDetail = detail + + let trimmedDetail = detail?.trimmingCharacters(in: .whitespacesAndNewlines) + if state == .error, let trimmedDetail, !trimmedDetail.isEmpty { + statusEntries[Self.remoteErrorStatusKey] = SidebarStatusEntry( + key: Self.remoteErrorStatusKey, + value: "SSH error (\(target)): \(trimmedDetail)", + icon: "network.slash", + color: nil, + timestamp: Date() + ) + + let fingerprint = "connection:\(trimmedDetail)" + if remoteLastErrorFingerprint != fingerprint { + remoteLastErrorFingerprint = fingerprint + appendSidebarLog( + message: "SSH error (\(target)): \(trimmedDetail)", + level: .error, + source: "remote" + ) + AppDelegate.shared?.notificationStore?.addNotification( + tabId: id, + surfaceId: nil, + title: "Remote SSH Error", + subtitle: target, + body: trimmedDetail + ) + } + return + } + + if state != .error { + statusEntries.removeValue(forKey: Self.remoteErrorStatusKey) + remoteLastErrorFingerprint = nil + } + } + + fileprivate func applyRemoteDaemonStatusUpdate(_ status: WorkspaceRemoteDaemonStatus, target: String) { + remoteDaemonStatus = status + guard status.state == .error else { + remoteLastDaemonErrorFingerprint = nil + return + } + let trimmedDetail = status.detail?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "remote daemon error" + let fingerprint = "daemon:\(trimmedDetail)" + guard remoteLastDaemonErrorFingerprint != fingerprint else { return } + remoteLastDaemonErrorFingerprint = fingerprint + appendSidebarLog( + message: "Remote daemon error (\(target)): \(trimmedDetail)", + level: .error, + source: "remote-daemon" + ) + } + + fileprivate func applyRemotePortsSnapshot(detected: [Int], forwarded: [Int], conflicts: [Int], target: String) { + remoteDetectedPorts = detected + remoteForwardedPorts = forwarded + remotePortConflicts = conflicts + recomputeListeningPorts() + + if conflicts.isEmpty { + statusEntries.removeValue(forKey: Self.remotePortConflictStatusKey) + remoteLastPortConflictFingerprint = nil + return + } + + let conflictsList = conflicts.map { ":\($0)" }.joined(separator: ", ") + statusEntries[Self.remotePortConflictStatusKey] = SidebarStatusEntry( + key: Self.remotePortConflictStatusKey, + value: "SSH port conflicts (\(target)): \(conflictsList)", + icon: "exclamationmark.triangle.fill", + color: nil, + timestamp: Date() + ) + + let fingerprint = conflicts.map(String.init).joined(separator: ",") + guard remoteLastPortConflictFingerprint != fingerprint else { return } + remoteLastPortConflictFingerprint = fingerprint + appendSidebarLog( + message: "Port conflicts while forwarding \(target): \(conflictsList)", + level: .warning, + source: "remote-forward" + ) + } + + private func appendSidebarLog(message: String, level: SidebarLogLevel, source: String?) { + let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + logEntries.append(SidebarLogEntry(message: trimmed, level: level, source: source, timestamp: Date())) + let configuredLimit = UserDefaults.standard.object(forKey: "sidebarMaxLogEntries") as? Int ?? 50 + let limit = max(1, min(500, configuredLimit)) + if logEntries.count > limit { + logEntries.removeFirst(logEntries.count - limit) + } + } + // MARK: - Panel Operations /// Create a new split with a terminal panel diff --git a/tests_v2/test_ssh_remote_cli_metadata.py b/tests_v2/test_ssh_remote_cli_metadata.py index ded9cf91..de7f102c 100644 --- a/tests_v2/test_ssh_remote_cli_metadata.py +++ b/tests_v2/test_ssh_remote_cli_metadata.py @@ -182,6 +182,13 @@ def main() -> int: _must(bool(disconnected_remote.get("enabled")) is False, f"remote config should be cleared: {disconnected}") _must(str(disconnected_remote.get("state") or "") == "disconnected", f"remote state should be disconnected: {disconnected}") _must(str(disconnected_daemon.get("state") or "") == "unavailable", f"daemon state should reset to unavailable: {disconnected}") + try: + client._call("workspace.remote.reconnect", {"workspace_id": workspace_id}) + raise cmuxError("workspace.remote.reconnect should fail when remote config was cleared") + except cmuxError as exc: + text = str(exc).lower() + _must("invalid_state" in text, f"workspace.remote.reconnect missing invalid_state for cleared config: {exc}") + _must("not configured" in text, f"workspace.remote.reconnect should explain missing remote config: {exc}") # Regression: --name is optional. payload2 = _run_cli_json( @@ -215,6 +222,13 @@ def main() -> int: break _must(row2 is not None, f"workspace created without --name missing from workspace.list: {workspace_id_without_name}") _must(bool(str((row2 or {}).get("title") or "").strip()), f"workspace title should not be empty without --name: {row2}") + reconnected = client._call("workspace.remote.reconnect", {"workspace_id": workspace_id_without_name}) or {} + reconnected_remote = reconnected.get("remote") or {} + _must(bool(reconnected_remote.get("enabled")) is True, f"workspace.remote.reconnect should keep remote enabled: {reconnected}") + _must( + str(reconnected_remote.get("state") or "") in {"connecting", "connected", "error"}, + f"workspace.remote.reconnect should transition into an active state: {reconnected}", + ) payload3 = _run_cli_json( cli,