From b586e9cc1045b8060ac957b56bb46dc0d8535072 Mon Sep 17 00:00:00 2001 From: Kyle Caron Date: Fri, 12 Jun 2026 14:51:22 -0400 Subject: [PATCH 1/5] Reconcile remote PTY size after arming SIGWINCH (fix stale cmux ssh resize) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cmux ssh-pty-attach` captures the terminal size once for the bridge handshake and opens the remote PTY at that size, then arms its SIGWINCH DispatchSource only afterward. The window between the handshake-size capture and arming the source spans the entire remote `pty.attach` round-trip, so it is wide. Any SIGWINCH delivered in that window hits SIGWINCH's default disposition (ignore) and is lost, and nothing reconciles afterward — so when the surface's final grid size lands during attach/reattach (the common case, since SwiftUI lays the surface out after the helper spawns), the remote PTY stays frozen at the handshake size forever, corrupting full-screen TUIs (roborev, claude, htop, …). Only a later manual resize would fire SIGWINCH and correct it. Fix: after arming the SIGWINCH source, push the current size once to reconcile any resize missed during the attach window. Extract the send into a shared `sendSSHPTYResize` helper used by both the SIGWINCH handler and the reconcile so both read the freshest size and serialize on the same lock. The reconcile is a no-op on the daemon when the size already matches. Verified on macOS 15 / M4 Pro (the affected config): with the fix, a freshly opened remote workspace terminal reports the correct `stty size` immediately on attach and across reconnects, with no manual resize. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- CLI/cmux.swift | 74 +++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 58 insertions(+), 16 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 0b99b1502626..b197c2489c89 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -10590,6 +10590,25 @@ struct CMUXCLI { ) defer { resizeSource.cancel() } + // Reconcile the remote PTY size once now that the SIGWINCH source is + // armed. The handshake captured the terminal size before the source + // existed (see `currentCLITerminalSize()` above); any SIGWINCH that + // arrived in that window was delivered with SIGWINCH's default + // disposition (ignore) and lost, with no later correction. On + // attach/reattach the surface's final grid size frequently lands inside + // that window, leaving the remote PTY frozen at the handshake size and + // corrupting full-screen TUIs. Pushing the current size here closes the + // gap; it is a no-op on the daemon when the size already matches. + sendSSHPTYResize( + client: client, + workspaceId: workspaceId, + surfaceID: surfaceID, + sessionID: sessionID, + attachmentID: attachmentID, + attachmentToken: attachmentToken, + socketLock: controlSocketLock + ) + DispatchQueue.global(qos: .userInteractive).async { var buffer = [UInt8](repeating: 0, count: 8192) while true { @@ -10882,27 +10901,50 @@ struct CMUXCLI { queue: DispatchQueue(label: "com.cmux.ssh-pty.resize") ) source.setEventHandler { - let size = self.currentCLITerminalSize() - socketLock.lock() - defer { socketLock.unlock() } - var params: [String: Any] = [ - "workspace_id": workspaceId, - "session_id": sessionID, - "attachment_id": attachmentID, - "attachment_token": attachmentToken, - "cols": size.cols, - "rows": size.rows, - ] - if let surfaceID { - params["surface_id"] = surfaceID - params["allow_moved_surface"] = true - } - _ = try? client.sendV2(method: "workspace.remote.pty_resize", params: params) + self.sendSSHPTYResize( + client: client, + workspaceId: workspaceId, + surfaceID: surfaceID, + sessionID: sessionID, + attachmentID: attachmentID, + attachmentToken: attachmentToken, + socketLock: socketLock + ) } source.resume() return source } + /// Send the current terminal size to the remote PTY over the control + /// socket. Shared by the SIGWINCH handler and the post-attach reconcile so + /// both paths read the freshest size and serialize on `socketLock`. + private func sendSSHPTYResize( + client: SocketClient, + workspaceId: String, + surfaceID: String?, + sessionID: String, + attachmentID: String, + attachmentToken: String, + socketLock: NSLock + ) { + let size = currentCLITerminalSize() + socketLock.lock() + defer { socketLock.unlock() } + var params: [String: Any] = [ + "workspace_id": workspaceId, + "session_id": sessionID, + "attachment_id": attachmentID, + "attachment_token": attachmentToken, + "cols": size.cols, + "rows": size.rows, + ] + if let surfaceID { + params["surface_id"] = surfaceID + params["allow_moved_surface"] = true + } + _ = try? client.sendV2(method: "workspace.remote.pty_resize", params: params) + } + private func connectLoopbackTCP(host: String, port: Int) throws -> Int32 { guard host == "127.0.0.1" || host == "localhost" else { throw CLIError(message: "ssh-pty-attach: bridge host must be loopback") From 0cfeda68189934790f4466efc9b36eef9b5e60e3 Mon Sep 17 00:00:00 2001 From: Kyle Caron Date: Fri, 12 Jun 2026 15:19:51 -0400 Subject: [PATCH 2/5] Sample terminal size inside socketLock in sendSSHPTYResize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review: the ioctl(TIOCGWINSZ) was sampled before acquiring socketLock, so the SIGWINCH handler and the post-attach reconcile could sample different sizes and serialize only the sends — letting a stale sample win the lock last and overwrite a fresher size on the daemon, re-creating the frozen-PTY symptom. Move the sample inside the lock so it protects both the read and the send. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- CLI/cmux.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index b197c2489c89..7ac7b92d32bd 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -10927,9 +10927,15 @@ struct CMUXCLI { attachmentToken: String, socketLock: NSLock ) { - let size = currentCLITerminalSize() + // Sample the size *inside* the lock so the lock serializes the read as + // well as the send. The SIGWINCH handler and the post-attach reconcile + // both call this helper; if the ioctl ran outside the lock, two calls + // could sample different sizes and then serialize only the sends, + // letting a stale sample win the lock last and overwrite a fresher size + // on the daemon — re-creating the frozen-PTY symptom this fix targets. socketLock.lock() defer { socketLock.unlock() } + let size = currentCLITerminalSize() var params: [String: Any] = [ "workspace_id": workspaceId, "session_id": sessionID, From dca0743f0521f3de60f74d52719a7738f9b4611d Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 19 Jun 2026 01:00:00 -0700 Subject: [PATCH 3/5] chore: split SSH PTY resize helper --- CLI/CMUXCLI+SSHCommandSupport.swift | 61 ++++++++++++++++++++++++++ CLI/cmux.swift | 67 +---------------------------- 2 files changed, 62 insertions(+), 66 deletions(-) diff --git a/CLI/CMUXCLI+SSHCommandSupport.swift b/CLI/CMUXCLI+SSHCommandSupport.swift index 5253dd3df092..387833428124 100644 --- a/CLI/CMUXCLI+SSHCommandSupport.swift +++ b/CLI/CMUXCLI+SSHCommandSupport.swift @@ -1,4 +1,6 @@ import Foundation +import Darwin +import Dispatch extension CMUXCLI { internal func openSSHLocalCommandValue(shellScript: String?) -> String? { @@ -30,4 +32,63 @@ extension CMUXCLI { guard !filtered.isEmpty else { return nil } return filtered.joined(separator: " ") } + + func startSSHPTYResizeSource( + client: SocketClient, + workspaceId: String, + surfaceID: String?, + sessionID: String, + attachmentID: String, + attachmentToken: String, + socketLock: NSLock + ) -> DispatchSourceSignal { + signal(SIGWINCH, SIG_IGN) + let source = DispatchSource.makeSignalSource( + signal: SIGWINCH, + queue: DispatchQueue(label: "com.cmux.ssh-pty.resize") + ) + source.setEventHandler { + self.sendSSHPTYResize( + client: client, + workspaceId: workspaceId, + surfaceID: surfaceID, + sessionID: sessionID, + attachmentID: attachmentID, + attachmentToken: attachmentToken, + socketLock: socketLock + ) + } + source.resume() + return source + } + + /// Sends the current terminal size to the remote PTY over the control socket. + func sendSSHPTYResize( + client: SocketClient, + workspaceId: String, + surfaceID: String?, + sessionID: String, + attachmentID: String, + attachmentToken: String, + socketLock: NSLock + ) { + // Keep the sample and send under one lock so an older sample cannot + // overtake a fresher one and restore a stale remote PTY size. + socketLock.lock() + defer { socketLock.unlock() } + let size = currentCLITerminalSize() + var params: [String: Any] = [ + "workspace_id": workspaceId, + "session_id": sessionID, + "attachment_id": attachmentID, + "attachment_token": attachmentToken, + "cols": size.cols, + "rows": size.rows, + ] + if let surfaceID { + params["surface_id"] = surfaceID + params["allow_moved_surface"] = true + } + _ = try? client.sendV2(method: "workspace.remote.pty_resize", params: params) + } } diff --git a/CLI/cmux.swift b/CLI/cmux.swift index b1a052898d02..740f65137360 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -11630,71 +11630,6 @@ struct CMUXCLI { throw CLIError(message: "ssh-pty-attach: bridge status exceeded \(maxStatusBytes) bytes") } - private func startSSHPTYResizeSource( - client: SocketClient, - workspaceId: String, - surfaceID: String?, - sessionID: String, - attachmentID: String, - attachmentToken: String, - socketLock: NSLock - ) -> DispatchSourceSignal { - signal(SIGWINCH, SIG_IGN) - let source = DispatchSource.makeSignalSource( - signal: SIGWINCH, - queue: DispatchQueue(label: "com.cmux.ssh-pty.resize") - ) - source.setEventHandler { - self.sendSSHPTYResize( - client: client, - workspaceId: workspaceId, - surfaceID: surfaceID, - sessionID: sessionID, - attachmentID: attachmentID, - attachmentToken: attachmentToken, - socketLock: socketLock - ) - } - source.resume() - return source - } - - /// Send the current terminal size to the remote PTY over the control - /// socket. Shared by the SIGWINCH handler and the post-attach reconcile so - /// both paths read the freshest size and serialize on `socketLock`. - private func sendSSHPTYResize( - client: SocketClient, - workspaceId: String, - surfaceID: String?, - sessionID: String, - attachmentID: String, - attachmentToken: String, - socketLock: NSLock - ) { - // Sample the size *inside* the lock so the lock serializes the read as - // well as the send. The SIGWINCH handler and the post-attach reconcile - // both call this helper; if the ioctl ran outside the lock, two calls - // could sample different sizes and then serialize only the sends, - // letting a stale sample win the lock last and overwrite a fresher size - // on the daemon — re-creating the frozen-PTY symptom this fix targets. - socketLock.lock() - defer { socketLock.unlock() } - let size = currentCLITerminalSize() - var params: [String: Any] = [ - "workspace_id": workspaceId, - "session_id": sessionID, - "attachment_id": attachmentID, - "attachment_token": attachmentToken, - "cols": size.cols, - "rows": size.rows, - ] - if let surfaceID { - params["surface_id"] = surfaceID - params["allow_moved_surface"] = true - } - _ = try? client.sendV2(method: "workspace.remote.pty_resize", params: params) - } - private func connectLoopbackTCP(host: String, port: Int) throws -> Int32 { guard host == "127.0.0.1" || host == "localhost" else { throw CLIError(message: "ssh-pty-attach: bridge host must be loopback") @@ -32465,7 +32400,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { } } - private func currentCLITerminalSize() -> (cols: Int, rows: Int) { + func currentCLITerminalSize() -> (cols: Int, rows: Int) { var size = winsize() if ioctl(STDOUT_FILENO, TIOCGWINSZ, &size) == 0, size.ws_col > 0, From f5fef7d88cbaabf48165e52efadf2b69c100e386 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 19 Jun 2026 01:32:49 -0700 Subject: [PATCH 4/5] test: expect initial SSH PTY resize --- ...ifyProcessIntegrationRegressionTests.swift | 83 +++++++++---------- cmuxTests/CLINotifyProcessTestSupport.swift | 2 +- 2 files changed, 38 insertions(+), 47 deletions(-) diff --git a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift index 74fb0cb18e29..4155c1b4f8c3 100644 --- a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift +++ b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift @@ -3683,10 +3683,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertTrue(result.stdout.isEmpty, result.stdout) XCTAssertTrue(result.stderr.contains("ssh-pty-attach: remote PTY start failed"), result.stderr) let methods = state.snapshot().compactMap { self.jsonObject($0)?["method"] as? String } - XCTAssertEqual(methods, [ - "workspace.remote.pty_bridge", - "workspace.remote.pty_attach_end", - ]) + XCTAssertEqual(methods, ["workspace.remote.pty_bridge", "workspace.remote.pty_attach_end"]) } func testSSHPTYAttachBridgeEOFWhileSessionRunsExitsWithoutSSHRetryStatus() throws { @@ -3727,6 +3724,10 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { "attachment_id": surfaceId, ] ) + case "workspace.remote.pty_resize": + XCTAssertEqual(params["attachment_token"] as? String, "attach-token") + XCTAssertEqual(params["surface_id"] as? String, surfaceId) + return self.v2Response(id: id, ok: true, result: ["resized": true]) case "workspace.remote.pty_sessions": return self.v2Response( id: id, @@ -3791,11 +3792,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { result.stderr ) let methods = state.snapshot().compactMap { self.jsonObject($0)?["method"] as? String } - XCTAssertEqual(methods, [ - "workspace.remote.pty_bridge", - "workspace.remote.pty_sessions", - "workspace.remote.pty_detach", - ]) + XCTAssertEqual(methods, ["workspace.remote.pty_bridge", "workspace.remote.pty_resize", "workspace.remote.pty_sessions", "workspace.remote.pty_detach"]) } func testSSHPTYAttachBridgeEOFWhenSessionGoneClearsLocalState() throws { @@ -3834,6 +3831,11 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { "attachment_id": surfaceId, ] ) + case "workspace.remote.pty_resize": + let params = payload["params"] as? [String: Any] ?? [:] + XCTAssertEqual(params["attachment_token"] as? String, "attach-token") + XCTAssertEqual(params["surface_id"] as? String, surfaceId) + return self.v2Response(id: id, ok: true, result: ["resized": true]) case "workspace.remote.pty_sessions": return self.v2Response( id: id, @@ -3889,11 +3891,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertTrue(result.stdout.isEmpty, result.stdout) XCTAssertTrue(result.stderr.isEmpty, result.stderr) let methods = state.snapshot().compactMap { self.jsonObject($0)?["method"] as? String } - XCTAssertEqual(methods, [ - "workspace.remote.pty_bridge", - "workspace.remote.pty_sessions", - "workspace.remote.pty_attach_end", - ]) + XCTAssertEqual(methods, ["workspace.remote.pty_bridge", "workspace.remote.pty_resize", "workspace.remote.pty_sessions", "workspace.remote.pty_attach_end"]) } func testSSHPTYAttachWithoutSurfaceDoesNotSendLocalAttachEnd() throws { @@ -3937,6 +3935,10 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { "attachment_id": attachmentID ?? "attachment", ] ) + case "workspace.remote.pty_resize": + XCTAssertEqual(params["attachment_token"] as? String, "attach-token") + XCTAssertNil(params["surface_id"]) + return self.v2Response(id: id, ok: true, result: ["resized": true]) case "workspace.remote.pty_sessions": XCTAssertNil(params["surface_id"]) return self.v2Response( @@ -3978,10 +3980,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertTrue(result.stdout.isEmpty, result.stdout) XCTAssertTrue(result.stderr.isEmpty, result.stderr) let methods = state.snapshot().compactMap { self.jsonObject($0)?["method"] as? String } - XCTAssertEqual(methods, [ - "workspace.remote.pty_bridge", - "workspace.remote.pty_sessions", - ]) + XCTAssertEqual(methods, ["workspace.remote.pty_bridge", "workspace.remote.pty_resize", "workspace.remote.pty_sessions"]) } func testSSHPTYAttachBridgeResetWhenSessionGoneClearsLocalState() throws { @@ -4020,6 +4019,11 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { "attachment_id": surfaceId, ] ) + case "workspace.remote.pty_resize": + let params = payload["params"] as? [String: Any] ?? [:] + XCTAssertEqual(params["attachment_token"] as? String, "attach-token") + XCTAssertEqual(params["surface_id"] as? String, surfaceId) + return self.v2Response(id: id, ok: true, result: ["resized": true]) case "workspace.remote.pty_sessions": return self.v2Response( id: id, @@ -4075,11 +4079,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertTrue(result.stdout.isEmpty, result.stdout) XCTAssertTrue(result.stderr.isEmpty, result.stderr) let methods = state.snapshot().compactMap { self.jsonObject($0)?["method"] as? String } - XCTAssertEqual(methods, [ - "workspace.remote.pty_bridge", - "workspace.remote.pty_sessions", - "workspace.remote.pty_attach_end", - ]) + XCTAssertEqual(methods, ["workspace.remote.pty_bridge", "workspace.remote.pty_resize", "workspace.remote.pty_sessions", "workspace.remote.pty_attach_end"]) } func testSSHPTYAttachWaitUsesCurrentTerminalSizeForBridgeHandshake() throws { @@ -4151,6 +4151,12 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { "attachment_id": surfaceId, ] ) + case "workspace.remote.pty_resize": + let params = payload["params"] as? [String: Any] ?? [:] + XCTAssertEqual(params["attachment_token"] as? String, "attach-token") + XCTAssertEqual(params["cols"] as? Int, 132) + XCTAssertEqual(params["rows"] as? Int, 43) + return self.v2Response(id: id, ok: true, result: ["resized": true]) case "workspace.remote.pty_sessions": return self.v2Response(id: id, ok: true, result: ["sessions": []]) case "workspace.remote.pty_attach_end": @@ -4394,15 +4400,11 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { } XCTAssertEqual(bridgeReady.wait(timeout: .now() + 5), .success) - var sawResize = false - for _ in 0..<10 { - Darwin.kill(process.processIdentifier, SIGWINCH) - if resizeRequestReceived.wait(timeout: .now() + 0.2) == .success { - sawResize = true - break - } - } - XCTAssertTrue(sawResize, "Expected ssh-pty-attach to issue a resize RPC after SIGWINCH") + XCTAssertEqual( + resizeRequestReceived.wait(timeout: .now() + 5), + .success, + "Expected ssh-pty-attach to issue its initial resize RPC after bridge ready" + ) closeBridge.signal() RunLoop.current.run(until: Date().addingTimeInterval(0.05)) @@ -4422,12 +4424,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertEqual(stdout, "") XCTAssertEqual(stderr, "") let methods = state.snapshot().compactMap { self.jsonObject($0)?["method"] as? String } - XCTAssertEqual(methods, [ - "workspace.remote.pty_bridge", - "workspace.remote.pty_resize", - "workspace.remote.pty_sessions", - "workspace.remote.pty_attach_end", - ]) + XCTAssertEqual(methods, ["workspace.remote.pty_bridge", "workspace.remote.pty_resize", "workspace.remote.pty_sessions", "workspace.remote.pty_attach_end"]) } func testSSHSessionAttachCreatesSurfaceWithPersistedPTYSessionID() throws { @@ -4588,10 +4585,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertEqual(result.status, 1, result.stderr) XCTAssertTrue(result.stderr.contains("ssh-pty-attach: missing session"), result.stderr) let methods = state.snapshot().compactMap { self.jsonObject($0)?["method"] as? String } - XCTAssertEqual(methods, [ - "workspace.remote.pty_bridge", - "workspace.remote.pty_attach_end", - ]) + XCTAssertEqual(methods, ["workspace.remote.pty_bridge", "workspace.remote.pty_attach_end"]) } func testSSHPTYAttachRequireExistingSessionNotFoundFailsWithoutWaitRetry() throws { @@ -4675,10 +4669,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertEqual(result.status, 1, result.stderr) XCTAssertTrue(result.stderr.contains("persistent SSH PTY session is no longer running"), result.stderr) let methods = state.snapshot().compactMap { self.jsonObject($0)?["method"] as? String } - XCTAssertEqual(methods, [ - "workspace.remote.pty_bridge", - "workspace.remote.pty_attach_end", - ]) + XCTAssertEqual(methods, ["workspace.remote.pty_bridge", "workspace.remote.pty_attach_end"]) } func testSSHSessionListAllWorkspacesReportsQueryErrors() throws { diff --git a/cmuxTests/CLINotifyProcessTestSupport.swift b/cmuxTests/CLINotifyProcessTestSupport.swift index a0ae0ffac4c3..018ef47d2775 100644 --- a/cmuxTests/CLINotifyProcessTestSupport.swift +++ b/cmuxTests/CLINotifyProcessTestSupport.swift @@ -262,7 +262,7 @@ extension CLINotifyProcessIntegrationRegressionTests { pending.append(buffer, count: count) } - let payload: [String: Any] = ["type": "ready"] + let payload: [String: Any] = ["type": "ready", "attachment_token": "attach-token"] guard var data = try? JSONSerialization.data(withJSONObject: payload, options: []) else { return } data.append(0x0A) data.withUnsafeBytes { rawBuffer in From 7097d8aeb464632b7ffe4af4f7bfef3c0913d8cf Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 19 Jun 2026 02:03:03 -0700 Subject: [PATCH 5/5] chore: keep SSH PTY resize sender local --- CLI/CMUXCLI+SSHCommandSupport.swift | 61 ----------------------------- CLI/cmux.swift | 54 ++++++++++++++++--------- 2 files changed, 35 insertions(+), 80 deletions(-) diff --git a/CLI/CMUXCLI+SSHCommandSupport.swift b/CLI/CMUXCLI+SSHCommandSupport.swift index 387833428124..5253dd3df092 100644 --- a/CLI/CMUXCLI+SSHCommandSupport.swift +++ b/CLI/CMUXCLI+SSHCommandSupport.swift @@ -1,6 +1,4 @@ import Foundation -import Darwin -import Dispatch extension CMUXCLI { internal func openSSHLocalCommandValue(shellScript: String?) -> String? { @@ -32,63 +30,4 @@ extension CMUXCLI { guard !filtered.isEmpty else { return nil } return filtered.joined(separator: " ") } - - func startSSHPTYResizeSource( - client: SocketClient, - workspaceId: String, - surfaceID: String?, - sessionID: String, - attachmentID: String, - attachmentToken: String, - socketLock: NSLock - ) -> DispatchSourceSignal { - signal(SIGWINCH, SIG_IGN) - let source = DispatchSource.makeSignalSource( - signal: SIGWINCH, - queue: DispatchQueue(label: "com.cmux.ssh-pty.resize") - ) - source.setEventHandler { - self.sendSSHPTYResize( - client: client, - workspaceId: workspaceId, - surfaceID: surfaceID, - sessionID: sessionID, - attachmentID: attachmentID, - attachmentToken: attachmentToken, - socketLock: socketLock - ) - } - source.resume() - return source - } - - /// Sends the current terminal size to the remote PTY over the control socket. - func sendSSHPTYResize( - client: SocketClient, - workspaceId: String, - surfaceID: String?, - sessionID: String, - attachmentID: String, - attachmentToken: String, - socketLock: NSLock - ) { - // Keep the sample and send under one lock so an older sample cannot - // overtake a fresher one and restore a stale remote PTY size. - socketLock.lock() - defer { socketLock.unlock() } - let size = currentCLITerminalSize() - var params: [String: Any] = [ - "workspace_id": workspaceId, - "session_id": sessionID, - "attachment_id": attachmentID, - "attachment_token": attachmentToken, - "cols": size.cols, - "rows": size.rows, - ] - if let surfaceID { - params["surface_id"] = surfaceID - params["allow_moved_surface"] = true - } - _ = try? client.sendV2(method: "workspace.remote.pty_resize", params: params) - } } diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 740f65137360..8a3f78be72f8 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -11323,15 +11323,26 @@ struct CMUXCLI { let rawMode = TerminalRawMode() defer { rawMode?.restore() } - let resizeSource = startSSHPTYResizeSource( - client: client, - workspaceId: workspaceId, - surfaceID: surfaceID, - sessionID: sessionID, - attachmentID: attachmentID, - attachmentToken: attachmentToken, - socketLock: controlSocketLock - ) + func sendCurrentSSHPTYResize() { + controlSocketLock.lock() + defer { controlSocketLock.unlock() } + let size = currentCLITerminalSize() + var params: [String: Any] = [ + "workspace_id": workspaceId, + "session_id": sessionID, + "attachment_id": attachmentID, + "attachment_token": attachmentToken, + "cols": size.cols, + "rows": size.rows, + ] + if let surfaceID { + params["surface_id"] = surfaceID + params["allow_moved_surface"] = true + } + _ = try? client.sendV2(method: "workspace.remote.pty_resize", params: params) + } + + let resizeSource = startSSHPTYResizeSource(onResize: sendCurrentSSHPTYResize) defer { resizeSource.cancel() } // Reconcile the remote PTY size once now that the SIGWINCH source is @@ -11343,15 +11354,7 @@ struct CMUXCLI { // that window, leaving the remote PTY frozen at the handshake size and // corrupting full-screen TUIs. Pushing the current size here closes the // gap; it is a no-op on the daemon when the size already matches. - sendSSHPTYResize( - client: client, - workspaceId: workspaceId, - surfaceID: surfaceID, - sessionID: sessionID, - attachmentID: attachmentID, - attachmentToken: attachmentToken, - socketLock: controlSocketLock - ) + sendCurrentSSHPTYResize() DispatchQueue.global(qos: .userInteractive).async { var buffer = [UInt8](repeating: 0, count: 8192) @@ -11630,6 +11633,19 @@ struct CMUXCLI { throw CLIError(message: "ssh-pty-attach: bridge status exceeded \(maxStatusBytes) bytes") } + private func startSSHPTYResizeSource(onResize: @escaping () -> Void) -> DispatchSourceSignal { + signal(SIGWINCH, SIG_IGN) + let source = DispatchSource.makeSignalSource( + signal: SIGWINCH, + queue: DispatchQueue(label: "com.cmux.ssh-pty.resize") + ) + source.setEventHandler { + onResize() + } + source.resume() + return source + } + private func connectLoopbackTCP(host: String, port: Int) throws -> Int32 { guard host == "127.0.0.1" || host == "localhost" else { throw CLIError(message: "ssh-pty-attach: bridge host must be loopback") @@ -32400,7 +32416,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { } } - func currentCLITerminalSize() -> (cols: Int, rows: Int) { + private func currentCLITerminalSize() -> (cols: Int, rows: Int) { var size = winsize() if ioctl(STDOUT_FILENO, TIOCGWINSZ, &size) == 0, size.ws_col > 0,