Add remote workspace reconnect actions and error surfacing

This commit is contained in:
Lawrence Chen 2026-02-21 02:10:39 -08:00
parent e0a7c32f62
commit 5e14bfe087
4 changed files with 256 additions and 22 deletions

View file

@ -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") {

View file

@ -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)

View file

@ -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

View file

@ -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,