Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
505a1b9
Add regression for stale websocket remote state
austinywang May 22, 2026
226e09e
Handle remote PTY disconnects as workspace state
austinywang May 22, 2026
fca2e77
Address remote disconnect review feedback
austinywang May 22, 2026
d67736c
Merge remote-tracking branch 'origin/main' into issue-4509-remote-pro…
austinywang May 22, 2026
65ae027
fix: address remote disconnect review feedback
austinywang May 22, 2026
0d57b63
Avoid synchronous websocket keepalive teardown
austinywang May 22, 2026
e26b577
Keep manual remote terminal close disconnected
austinywang May 22, 2026
3858a6d
Preserve remote placeholder close state
austinywang May 22, 2026
9cd47af
Add pane close remote disconnect regression
austinywang May 22, 2026
c7fe35a
Preserve remote state on pane close
austinywang May 22, 2026
f3f3cc9
Add untracked remote PTY disconnect regression
austinywang May 22, 2026
78b77e6
Track focused remote terminal sessions
austinywang May 22, 2026
cab3b8b
Merge remote-tracking branch 'origin/main' into issue-4509-remote-pro…
austinywang May 26, 2026
9c020be
Fix remote reconnect placeholder localizations
austinywang May 26, 2026
c39689b
Add remote disconnect detail regression test
austinywang May 26, 2026
b6754db
Preserve remote disconnect detail before notifying
austinywang May 26, 2026
ca8cc16
merge: resolve main for remote disconnect PR
austinywang Jun 4, 2026
3c1f1e1
fix: consume remote disconnect placeholder payload
austinywang Jun 4, 2026
72beea0
merge: resolve conflicts with main
austinywang Jun 14, 2026
f124674
Merge remote-tracking branch 'origin/main' into issue-4509-remote-pro…
austinywang Jun 14, 2026
64de857
fix: address remote disconnect review blockers
austinywang Jun 14, 2026
7110e6a
fix: skip remote disconnect placeholders during reconnect seed
austinywang Jun 14, 2026
e250c31
Merge remote-tracking branch 'origin/main' into issue-4509-remote-pro…
austinywang Jun 14, 2026
276b983
fix: preserve reconnecting remote placeholder surface
austinywang Jun 14, 2026
cf58628
Merge remote-tracking branch 'origin/main' into issue-4509-remote-pro…
austinywang Jun 14, 2026
736ed37
Merge remote-tracking branch 'origin/main' into issue-4509-remote-pro…
austinywang Jun 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 9 additions & 8 deletions .github/swift-file-length-budget.tsv
Original file line number Diff line number Diff line change
@@ -1,22 +1,22 @@
# cmux-owned Swift file length budget.
# Format: max_lines<TAB>relative path
# Reduce counts as files shrink. CI fails if tracked files exceed this budget.
34136 CLI/cmux.swift
34207 CLI/cmux.swift
17914 Sources/AppDelegate.swift
16709 Sources/ContentView.swift
16705 Sources/ContentView.swift
14682 Sources/TerminalController.swift
13595 Sources/Panels/BrowserPanel.swift
12093 Sources/Workspace.swift
12223 Sources/Workspace.swift
12088 Sources/GhosttyTerminalView.swift
12046 cmuxTests/AppDelegateShortcutRoutingTests.swift
9331 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift
7911 Sources/Panels/BrowserPanelView.swift
7354 cmuxTests/WorkspaceUnitTests.swift
6944 cmuxTests/WorkspaceRemoteConnectionTests.swift
7221 cmuxTests/WorkspaceRemoteConnectionTests.swift
6363 cmuxTests/GhosttyConfigTests.swift
6317 cmuxTests/SessionPersistenceTests.swift
6153 CLI/cmux_open.swift
6078 Sources/TabManager.swift
6091 Sources/TabManager.swift
6074 Sources/TextBoxInput.swift
5925 cmuxTests/TerminalAndGhosttyTests.swift
5522 cmuxTests/BrowserConfigTests.swift
Expand All @@ -26,8 +26,8 @@
4400 cmuxTests/BrowserPanelTests.swift
4227 Sources/BrowserWindowPortal.swift
3937 Sources/Feed/FeedPanelView.swift
3926 cmuxTests/TabManagerUnitTests.swift
3903 cmuxTests/WindowAndDragTests.swift
3765 cmuxTests/TabManagerUnitTests.swift
3699 cmuxTests/CLIGenericHookPersistenceTests.swift
3664 Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift
3397 Sources/CmuxConfig.swift
Expand Down Expand Up @@ -100,7 +100,7 @@
893 Sources/WorkspaceContentView.swift
876 Sources/Panels/TerminalPanel.swift
868 Sources/Panels/BrowserScreenshotSnapshotter.swift
852 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlCommandCoordinator+Workspace.swift
859 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlCommandCoordinator+Workspace.swift
847 cmuxTests/AgentSessionAutoResumeSettingsTests.swift
845 cmuxTests/SSHStartupSignalLifecycleTests.swift
841 Sources/Panels/MarkdownWebRenderer.swift
Expand All @@ -113,7 +113,7 @@
762 Packages/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxNetworkByteTransport.swift
760 Packages/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchSanitizerTests.swift
756 Sources/Panels/AgentSessionWebRendererCoordinator.swift
752 Sources/TerminalController+ControlWorkspaceContext.swift
754 Sources/TerminalController+ControlWorkspaceContext.swift
752 cmuxUITests/CloseWorkspaceCmdDUITests.swift
746 Sources/App/MenuBarExtraController.swift
738 Packages/CMUXProjectModel/Sources/CMUXProjectModel/XcodeProjectAdapter.swift
Expand Down Expand Up @@ -205,4 +205,5 @@
505 cmuxUITests/DisplayResolutionRegressionUITests.swift
504 cmuxTests/TerminalNotificationSocketActionTests.swift
503 Sources/Settings/ConfigSource.swift
502 Packages/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift
502 Sources/CmuxEventPublishing.swift
71 changes: 71 additions & 0 deletions CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10416,12 +10416,16 @@ struct CMUXCLI {
}

private final class VMPtyWebSocketBridge {
private static let keepaliveInterval: TimeInterval = 5.0
private let config: VMPtyWebSocketConfig
private let debugEvent: ((String) -> Void)?
private let sendQueue = DispatchQueue(label: "com.cmux.vm-pty.websocket.send")
private let stopLock = NSLock()
private var stopped = false
private var task: URLSessionWebSocketTask?
private var keepaliveTimer: DispatchSourceTimer?
private var keepaliveTimeoutWorkItem: DispatchWorkItem?
private var keepaliveInFlight = false

init(config: VMPtyWebSocketConfig, debugEvent: ((String) -> Void)? = nil) {
self.config = config
Expand Down Expand Up @@ -10462,6 +10466,8 @@ struct CMUXCLI {
defer { rawMode?.restore() }
let resizeSource = startResizeSource()
defer { resizeSource.cancel() }
startKeepalive()
defer { stopKeepalive() }
startInputPump()
try receiveOutputLoop(delegate: delegate)
}
Expand Down Expand Up @@ -10575,6 +10581,71 @@ struct CMUXCLI {
}
}

private func startKeepalive() {
sendQueue.async { [weak self] in
guard let self else { return }
self.keepaliveTimer?.cancel()
self.keepaliveInFlight = false
let timer = DispatchSource.makeTimerSource(queue: self.sendQueue)
timer.schedule(deadline: .now() + Self.keepaliveInterval, repeating: Self.keepaliveInterval)
timer.setEventHandler { [weak self] in
self?.sendKeepalive()
}
self.keepaliveTimer = timer
timer.resume()
}
}

private func stopKeepalive() {
sendQueue.async { [weak self] in
guard let self else { return }
self.keepaliveTimer?.cancel()
self.keepaliveTimer = nil
self.keepaliveTimeoutWorkItem?.cancel()
self.keepaliveTimeoutWorkItem = nil
self.keepaliveInFlight = false
}
}

private func sendKeepalive() {
guard !isStopped, let task else {
keepaliveTimer?.cancel()
keepaliveTimer = nil
keepaliveTimeoutWorkItem?.cancel()
keepaliveTimeoutWorkItem = nil
keepaliveInFlight = false
return
}

if keepaliveInFlight {
return
}
keepaliveInFlight = true
let timeoutWorkItem = DispatchWorkItem { [weak self] in
guard let self, !self.isStopped, self.keepaliveInFlight else { return }
self.debugEvent?("websocket.keepalive.timeout")
self.markStopped()
self.task?.cancel(with: .goingAway, reason: nil)
}
keepaliveTimeoutWorkItem?.cancel()
keepaliveTimeoutWorkItem = timeoutWorkItem
sendQueue.asyncAfter(deadline: .now() + Self.keepaliveInterval, execute: timeoutWorkItem)

task.sendPing { [weak self] error in
self?.sendQueue.async {
guard let self, !self.isStopped else { return }
self.keepaliveTimeoutWorkItem?.cancel()
self.keepaliveTimeoutWorkItem = nil
self.keepaliveInFlight = false
if let error {
self.debugEvent?("websocket.keepalive.failed")
self.markStopped()
self.task?.cancel(with: .goingAway, reason: Data(error.localizedDescription.utf8))
}
}
}
}

private func sendAsync(_ message: URLSessionWebSocketTask.Message) {
sendQueue.async { [weak self] in
try? self?.sendSync(message)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -726,7 +726,14 @@ extension ControlCommandCoordinator {
guard let workspaceID = resolution.workspaceID else {
return .err(code: "invalid_params", message: "Missing workspace_id", data: nil)
}
return workspaceRemoteResult(context?.controlReconnectWorkspaceRemote(workspaceID: workspaceID))
let surfaceID = uuid(params, "surface_id")
if hasNonNull(params, "surface_id"), surfaceID == nil {
return .err(code: "invalid_params", message: "Missing or invalid surface_id", data: nil)
}
return workspaceRemoteResult(context?.controlReconnectWorkspaceRemote(
workspaceID: workspaceID,
surfaceID: surfaceID
))
}

/// `workspace.remote.foreground_auth_ready` — arm/continue a pending connect.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -215,9 +215,14 @@ public protocol ControlWorkspaceContext: AnyObject {

/// Reconnects a remote workspace for `workspace.remote.reconnect`.
///
/// - Parameter workspaceID: The resolved workspace id.
/// - Parameters:
/// - workspaceID: The resolved workspace id.
/// - surfaceID: The optional reconnecting placeholder surface id.
/// - Returns: The remote resolution (may signal `notConfigured`).
func controlReconnectWorkspaceRemote(workspaceID: UUID) -> ControlWorkspaceRemoteResolution
func controlReconnectWorkspaceRemote(
workspaceID: UUID,
surfaceID: UUID?
) -> ControlWorkspaceRemoteResolution

/// Notifies foreground-auth readiness for
/// `workspace.remote.foreground_auth_ready`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,10 @@ extension ControlWorkspaceContext {
clearConfiguration: Bool
) -> ControlWorkspaceRemoteResolution { .notFound(workspaceID: workspaceID) }

func controlReconnectWorkspaceRemote(workspaceID: UUID) -> ControlWorkspaceRemoteResolution {
func controlReconnectWorkspaceRemote(
workspaceID: UUID,
surfaceID: UUID?
) -> ControlWorkspaceRemoteResolution {
.notFound(workspaceID: workspaceID)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,62 @@ extension RemoteDaemonRPCClient {
}
}

func startWebSocketKeepaliveLocked() {
webSocketKeepaliveTimer?.cancel()
webSocketKeepaliveTimeoutWorkItem?.cancel()
webSocketKeepaliveTimeoutWorkItem = nil
webSocketKeepaliveInFlight = false
let timer = DispatchSource.makeTimerSource(queue: stateQueue)
timer.schedule(
deadline: .now() + Self.webSocketKeepaliveInterval,
repeating: Self.webSocketKeepaliveInterval
)
timer.setEventHandler { [weak self] in
self?.sendWebSocketKeepaliveLocked()
}
webSocketKeepaliveTimer = timer
timer.resume()
}

func stopWebSocketKeepaliveLocked() {
webSocketKeepaliveTimer?.cancel()
webSocketKeepaliveTimer = nil
webSocketKeepaliveTimeoutWorkItem?.cancel()
webSocketKeepaliveTimeoutWorkItem = nil
webSocketKeepaliveInFlight = false
}

func sendWebSocketKeepaliveLocked() {
guard !isClosed, let task = webSocketTask else {
stopWebSocketKeepaliveLocked()
return
}
if webSocketKeepaliveInFlight {
return
}

webSocketKeepaliveInFlight = true
let timeoutWorkItem = DispatchWorkItem { [weak self] in
guard let self, !self.isClosed, self.webSocketKeepaliveInFlight else { return }
self.handleWebSocketTermination("daemon websocket keepalive timed out")
}
webSocketKeepaliveTimeoutWorkItem?.cancel()
webSocketKeepaliveTimeoutWorkItem = timeoutWorkItem
stateQueue.asyncAfter(deadline: .now() + Self.webSocketKeepaliveInterval, execute: timeoutWorkItem)
task.sendPing { [weak self] error in
guard let self else { return }
self.stateQueue.async {
guard !self.isClosed else { return }
self.webSocketKeepaliveTimeoutWorkItem?.cancel()
self.webSocketKeepaliveTimeoutWorkItem = nil
self.webSocketKeepaliveInFlight = false
if let error {
self.handleWebSocketTermination("daemon websocket keepalive failed: \(error.localizedDescription)")
}
}
}
}

func consumeJSONPayload(_ data: Data) {
guard let payload = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else {
return
Expand Down Expand Up @@ -216,6 +272,7 @@ extension RemoteDaemonRPCClient {
let capturedSession = webSocketSession

isClosed = true
stopWebSocketKeepaliveLocked()
webSocketTask = nil
webSocketSession = nil
webSocketDelegate = nil
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ extension RemoteDaemonRPCClient {
self.webSocketTask = task
self.webSocketDelegate = delegate
self.markTransportOpenLocked()
self.startWebSocketKeepaliveLocked()
}

stateQueue.async {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ public final class RemoteDaemonRPCClient: @unchecked Sendable {
static let maxStdoutBufferBytes = 256 * 1024
static let bakedVMDaemonSocketPath = "/run/cmuxd-remote.sock"
static let socketForwardStartupGracePeriod: TimeInterval = 0.75
static let webSocketKeepaliveInterval: TimeInterval = 5.0
/// Wire capability required for push-based proxy streaming
/// (`proxy.stream.push`; value is test-pinned, do not change).
public static let requiredProxyStreamCapability = RemoteDaemonCapability.proxyStreamPush.rawValue
Expand Down Expand Up @@ -89,6 +90,9 @@ public final class RemoteDaemonRPCClient: @unchecked Sendable {
var webSocketSession: URLSession?
var webSocketTask: URLSessionWebSocketTask?
var webSocketDelegate: RemoteDaemonWebSocketDelegate?
var webSocketKeepaliveTimer: DispatchSourceTimer?
var webSocketKeepaliveTimeoutWorkItem: DispatchWorkItem?
var webSocketKeepaliveInFlight = false
var isClosed = true
var shouldReportTermination = true

Expand Down Expand Up @@ -226,6 +230,7 @@ public final class RemoteDaemonRPCClient: @unchecked Sendable {
let capturedWebSocketTask = webSocketTask
let capturedWebSocketSession = webSocketSession

stopWebSocketKeepaliveLocked()
process = nil
stdinPipe = nil
stdoutPipe = nil
Expand Down
Loading
Loading