diff --git a/CLI/SSHPTYResizeCoordinator.swift b/CLI/SSHPTYResizeCoordinator.swift new file mode 100644 index 000000000000..565c8e71f3be --- /dev/null +++ b/CLI/SSHPTYResizeCoordinator.swift @@ -0,0 +1,257 @@ +import Foundation + +/// A resize send failed at the transport layer (timeout / socket error) before a +/// complete response was read. The shared control socket may now hold a pending +/// late reply, so the resize must not be retried on it. Terminal for the +/// coordinator's retry loop. +struct SSHPTYResizeTransportError: Error {} + +/// The remote/app rejected the resize with a complete response (`ok:false` or an +/// unparseable but fully-consumed line). The socket is in sync, so the retry loop +/// may safely re-send the latest size — this is the stale/blocked remote path +/// from issue #6306. +struct SSHPTYResizeProtocolRejection: Error { + let response: String +} + +/// Coalesces and retries SSH-PTY resize delivery (`workspace.remote.pty_resize`). +/// +/// A SIGWINCH burst during a split/divider drag would previously fire one +/// best-effort `try?` send per event, silently dropping any send that raced a +/// stale or blocked remote-session control path. Output kept flowing, so the +/// terminal looked alive while the remote PTY/TUI never received the new size — +/// and only a manual workspace reconnect restored it (issue #6306). +/// +/// This coordinator collapses rapid resize events into a single delivery of the +/// newest size and, when a delivery fails, retries the latest size with a +/// bounded backoff instead of dropping it. +/// +/// The coalescing/retry state (`pendingSize`, `lastDeliveredSize`, +/// `deliveryScheduled`, `retryCount`) is touched only from +/// `noteResize`/`deliver`, which in production run on a single serial dispatch +/// queue (the signal source's target queue), so that state needs no locking. +/// +/// `cancel()` is the exception: it must be callable synchronously from the +/// attach teardown thread (bridge EOF / `defer`) so an already-scheduled retry +/// cannot fire after teardown and send a `workspace.remote.pty_resize` on the +/// shared socket once the session is gone. The `cancelled` flag is therefore +/// guarded by `stateLock` and observed by every scheduling/delivery decision; a +/// retry block that has not yet started bails as soon as `cancel()` returns. +/// +/// The `scheduleAfter` and `send` seams are injected so the coalescing/retry +/// state machine can be driven deterministically in tests without real time or +/// a live socket. +final class SSHPTYResizeCoordinator { + /// Schedules `block` to run after `delay`. Production wires this to the + /// signal source's serial queue via `asyncAfter`. + typealias Scheduler = (_ delay: DispatchTimeInterval, _ block: @escaping () -> Void) -> Void + + private let scheduleAfter: Scheduler + private let send: (_ cols: Int, _ rows: Int) throws -> Void + private let sizeProvider: () -> (cols: Int, rows: Int) + private let log: (String) -> Void + + private var pendingSize: (cols: Int, rows: Int)? + private var lastDeliveredSize: (cols: Int, rows: Int)? + private var deliveryScheduled = false + private var retryCount = 0 + + // `cancelled` and `signalSource` are reachable from both the serial queue + // and the teardown thread, so they are guarded by `stateLock`. + private let stateLock = NSLock() + private var cancelled = false + private var signalSource: DispatchSourceSignal? + + let coalesceDelay: DispatchTimeInterval + let maxRetries: Int + + init( + sizeProvider: @escaping () -> (cols: Int, rows: Int), + send: @escaping (_ cols: Int, _ rows: Int) throws -> Void, + scheduleAfter: @escaping Scheduler, + log: @escaping (String) -> Void = { _ in }, + coalesceDelay: DispatchTimeInterval = .milliseconds(20), + maxRetries: Int = 6 + ) { + self.sizeProvider = sizeProvider + self.send = send + self.scheduleAfter = scheduleAfter + self.log = log + self.coalesceDelay = coalesceDelay + self.maxRetries = maxRetries + } + + /// Production convenience initializer wiring the send to a `SocketClient` + /// (serialized by `socketLock`) and scheduling on `queue`. + /// + /// The send is issued at the raw `send(command:)` layer rather than through + /// `sendV2` so the coordinator can tell a clean protocol rejection apart from + /// a transport failure. `send(command:)` only returns once it has consumed a + /// complete response line, so a thrown error (timeout / socket error) means + /// the shared control socket may still have a pending late reply. Retrying on + /// that socket could misattribute the late reply to a later request (the + /// control protocol does not match response ids), so transport failures are + /// surfaced as `SSHPTYResizeTransportError` and not retried. A returned line + /// means the socket is back in sync, so an `ok:false` rejection is safe to + /// retry — which is the stale/blocked remote path from issue #6306. + convenience init( + client: SocketClient, + baseParams: [String: Any], + socketLock: NSLock, + queue: DispatchQueue, + sizeProvider: @escaping () -> (cols: Int, rows: Int), + log: @escaping (String) -> Void + ) { + self.init( + sizeProvider: sizeProvider, + send: { cols, rows in + var params = baseParams + params["cols"] = cols + params["rows"] = rows + let request: [String: Any] = [ + "id": UUID().uuidString, + "method": "workspace.remote.pty_resize", + "params": params, + ] + guard let requestData = try? JSONSerialization.data(withJSONObject: request), + let requestLine = String(data: requestData, encoding: .utf8) else { + // Encoding can't fail for our own payload; if it somehow + // does, the socket was never touched — terminal, no retry. + throw SSHPTYResizeTransportError() + } + + let raw: String + socketLock.lock() + do { + raw = try client.send(command: requestLine) + socketLock.unlock() + } catch { + socketLock.unlock() + throw SSHPTYResizeTransportError() + } + + // A complete response line was consumed, so the socket is in + // sync. ok:true → delivered; anything else is a protocol-level + // rejection that is safe to retry on the same socket. + if let data = raw.data(using: .utf8), + let response = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let ok = response["ok"] as? Bool, ok { + return + } + throw SSHPTYResizeProtocolRejection(response: raw) + }, + scheduleAfter: { delay, block in + queue.asyncAfter(deadline: .now() + delay, execute: block) + }, + log: log + ) + } + + /// Owns the SIGWINCH source so `cancel()` can tear it down synchronously. + /// Wired once by `startSSHPTYResizeSource` right after the source is built. + func bindSignalSource(_ source: DispatchSourceSignal) { + stateLock.lock() + let alreadyCancelled = cancelled + if !alreadyCancelled { + signalSource = source + } + stateLock.unlock() + // If cancellation already raced ahead of binding, don't keep a live + // source around. + if alreadyCancelled { + source.cancel() + } + } + + private var isCancelled: Bool { + stateLock.lock() + defer { stateLock.unlock() } + return cancelled + } + + /// Record a new terminal size and schedule a coalesced delivery. + /// Invoked from the signal source event handler, which runs on the queue. + func noteResize() { + guard !isCancelled else { return } + let size = sizeProvider() + guard size.cols > 0, size.rows > 0 else { return } + pendingSize = size + // A fresh, user-driven resize resets the retry budget so we keep trying + // to deliver the newest size even after a prior burst gave up. + retryCount = 0 + scheduleDelivery(after: coalesceDelay) + } + + /// Stop further delivery. Safe to call synchronously from any thread; once it + /// returns, no not-yet-started retry will send another resize. Idempotent. + func cancel() { + stateLock.lock() + let already = cancelled + cancelled = true + let source = signalSource + signalSource = nil + stateLock.unlock() + if !already { + source?.cancel() + } + } + + private func scheduleDelivery(after delay: DispatchTimeInterval) { + guard !isCancelled, !deliveryScheduled else { return } + deliveryScheduled = true + scheduleAfter(delay) { [weak self] in + self?.deliver() + } + } + + private func deliver() { + deliveryScheduled = false + guard !isCancelled, let size = pendingSize else { return } + // The newest size already reached the remote; nothing to do. + if let last = lastDeliveredSize, last == size { + pendingSize = nil + retryCount = 0 + return + } + + let delivered: Bool + do { + try send(size.cols, size.rows) + delivered = true + } catch is SSHPTYResizeTransportError { + // The control socket may be desynced after a transport failure; + // retrying could misread a late reply as a later request's result. + // Drop this attempt but keep pendingSize so a fresh SIGWINCH retries + // once the socket is healthy again. + log("ssh-pty resize transport failure (\(size.cols)x\(size.rows)); not retrying on possibly-desynced socket") + retryCount = 0 + return + } catch { + delivered = false + log("ssh-pty resize delivery failed (\(size.cols)x\(size.rows), attempt \(retryCount + 1)): \(error)") + } + + if delivered { + lastDeliveredSize = size + retryCount = 0 + // A newer size may have arrived while the send was in flight. + if let newest = pendingSize, newest != size { + scheduleDelivery(after: coalesceDelay) + } else { + pendingSize = nil + } + return + } + + // Retry the latest size with a bounded exponential backoff. Keeping + // pendingSize set means a give-up still recovers on the next SIGWINCH. + if retryCount < maxRetries { + retryCount += 1 + let backoffMs = min(1000, 50 * (1 << min(retryCount - 1, 4))) + scheduleDelivery(after: .milliseconds(backoffMs)) + } else { + log("ssh-pty resize giving up after \(maxRetries) attempts (\(size.cols)x\(size.rows)); awaiting next resize") + retryCount = 0 + } + } +} diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 6f5d547ade51..f3c244f81129 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -11610,7 +11610,7 @@ struct CMUXCLI { let rawMode = TerminalRawMode() defer { rawMode?.restore() } - let resizeSource = startSSHPTYResizeSource( + let resizeCoordinator = startSSHPTYResizeSource( client: client, workspaceId: workspaceId, surfaceID: surfaceID, @@ -11619,7 +11619,7 @@ struct CMUXCLI { attachmentToken: attachmentToken, socketLock: controlSocketLock ) - defer { resizeSource.cancel() } + defer { resizeCoordinator.cancel() } DispatchQueue.global(qos: .userInteractive).async { var buffer = [UInt8](repeating: 0, count: 8192) @@ -11648,7 +11648,7 @@ struct CMUXCLI { if count > 0 { FileHandle.standardOutput.write(Data(outputBuffer.prefix(count))) } else if count == 0 { - resizeSource.cancel() + resizeCoordinator.cancel() try handleSSHPTYBridgeEOF( client: client, workspaceId: workspaceId, @@ -11661,7 +11661,7 @@ struct CMUXCLI { return } else if errno != EINTR { if sshPTYBridgeReadErrorIsEOF(errno) { - resizeSource.cancel() + resizeCoordinator.cancel() try handleSSHPTYBridgeEOF( client: client, workspaceId: workspaceId, @@ -11906,32 +11906,32 @@ struct CMUXCLI { attachmentID: String, attachmentToken: String, socketLock: NSLock - ) -> DispatchSourceSignal { + ) -> SSHPTYResizeCoordinator { signal(SIGWINCH, SIG_IGN) - let source = DispatchSource.makeSignalSource( - signal: SIGWINCH, - 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) + let queue = DispatchQueue(label: "com.cmux.ssh-pty.resize") + var baseParams: [String: Any] = [ + "workspace_id": workspaceId, + "session_id": sessionID, + "attachment_id": attachmentID, + "attachment_token": attachmentToken, + ] + if let surfaceID { + baseParams["surface_id"] = surfaceID + baseParams["allow_moved_surface"] = true } + let coordinator = SSHPTYResizeCoordinator( + client: client, + baseParams: baseParams, + socketLock: socketLock, + queue: queue, + sizeProvider: { self.currentCLITerminalSize() }, + log: { self.cliDebugLog($0) } + ) + let source = DispatchSource.makeSignalSource(signal: SIGWINCH, queue: queue) + source.setEventHandler { coordinator.noteResize() } + coordinator.bindSignalSource(source) // lets cancel() tear down retries synchronously source.resume() - return source + return coordinator } private func connectLoopbackTCP(host: String, port: Int) throws -> Int32 { diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 9643833a3545..0dece86a3420 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -768,6 +768,8 @@ C510C1E00000000000000002 /* SocketOperationTelemetry.swift in Sources */ = {isa = PBXBuildFile; fileRef = C510C1E00000000000000001 /* SocketOperationTelemetry.swift */; }; A5001230 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = A5001231 /* Sparkle */; }; E30780000000000000000012 /* SSHPTYAttachStartupCommandBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = E30780000000000000000011 /* SSHPTYAttachStartupCommandBuilder.swift */; }; + A6306000000000000000C001 /* SSHPTYResizeCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6306000000000000000C002 /* SSHPTYResizeCoordinator.swift */; }; + A6306000000000000000F003 /* SSHPTYResizeRetryIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6306000000000000000F004 /* SSHPTYResizeRetryIntegrationTests.swift */; }; F6355600A1B2C3D4E5F60718 /* SSHStartupSignalLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6355601A1B2C3D4E5F60718 /* SSHStartupSignalLifecycleTests.swift */; }; F20F85FC5900550685FA33AD /* StackAuth in Frameworks */ = {isa = PBXBuildFile; productRef = A8BD195031FC4B82B4354297 /* StackAuth */; }; D35B71010000000000000001 /* StartupBreadcrumbLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = D35B71010000000000000002 /* StartupBreadcrumbLog.swift */; }; @@ -1705,6 +1707,8 @@ F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketControlPasswordStoreTests.swift; sourceTree = ""; }; C510C1E00000000000000001 /* SocketOperationTelemetry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketOperationTelemetry.swift; sourceTree = ""; }; E30780000000000000000011 /* SSHPTYAttachStartupCommandBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHPTYAttachStartupCommandBuilder.swift; sourceTree = ""; }; + A6306000000000000000C002 /* SSHPTYResizeCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHPTYResizeCoordinator.swift; sourceTree = ""; }; + A6306000000000000000F004 /* SSHPTYResizeRetryIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHPTYResizeRetryIntegrationTests.swift; sourceTree = ""; }; F6355601A1B2C3D4E5F60718 /* SSHStartupSignalLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHStartupSignalLifecycleTests.swift; sourceTree = ""; }; D35B71010000000000000002 /* StartupBreadcrumbLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/StartupBreadcrumbLog.swift; sourceTree = ""; }; D7AB00000000000000B040 /* SupersededPhoneDismissBuffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupersededPhoneDismissBuffer.swift; sourceTree = ""; }; @@ -2715,6 +2719,7 @@ C510C1E00000000000000001 /* SocketOperationTelemetry.swift */, B9000030A1B2C3D4E5F60719 /* cmux_open.swift */, B9000040A1B2C3D4E5F60719 /* CMUXCLI+SSHCommandSupport.swift */, + A6306000000000000000C002 /* SSHPTYResizeCoordinator.swift */, D7AB0000000000000000000E /* CMUXCLI+MoveTabToNewWorkspace.swift */, B9000047A1B2C3D4E5F60719 /* CMUXCLI+ExecutableResolution.swift */, B9000045A1B2C3D4E5F60719 /* CMUXCLI+TmuxCompatSupport.swift */, @@ -2802,6 +2807,7 @@ F6100001A1B2C3D4E5F60718 /* WorkspaceRemoteConnectionTests.swift */, F6357301A1B2C3D4E5F60718 /* WorkspaceRemoteReconnectPolicyTests.swift */, F6355601A1B2C3D4E5F60718 /* SSHStartupSignalLifecycleTests.swift */, + A6306000000000000000F004 /* SSHPTYResizeRetryIntegrationTests.swift */, F6120001A1B2C3D4E5F60718 /* WorkspaceSSHFishShellTests.swift */, F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */, F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */, @@ -4035,6 +4041,7 @@ B9000027A1B2C3D4E5F60719 /* RemoteRelayZshBootstrap.swift in Sources */, 5E2701020000000000000003 /* SentryEventScrubber.swift in Sources */, C510C1E00000000000000002 /* SocketOperationTelemetry.swift in Sources */, + A6306000000000000000C001 /* SSHPTYResizeCoordinator.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4284,6 +4291,7 @@ C0DE56010000000000000001 /* SidebarWorkspaceSelectionAnchorPolicyTests.swift in Sources */, 62270F3DCECB4787D789CCE3 /* SidebarWorkspaceSnapshotRefreshPolicyTests.swift in Sources */, F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */, + A6306000000000000000F003 /* SSHPTYResizeRetryIntegrationTests.swift in Sources */, F6355600A1B2C3D4E5F60718 /* SSHStartupSignalLifecycleTests.swift in Sources */, 2BB56A710BB1FC50367E5BCF /* TabManagerSessionSnapshotTests.swift in Sources */, B6BF3DC98DB1495E57900199 /* TabManagerUnitTests.swift in Sources */, diff --git a/cmuxTests/SSHPTYResizeRetryIntegrationTests.swift b/cmuxTests/SSHPTYResizeRetryIntegrationTests.swift new file mode 100644 index 000000000000..97bf60ef059b --- /dev/null +++ b/cmuxTests/SSHPTYResizeRetryIntegrationTests.swift @@ -0,0 +1,189 @@ +import XCTest +import Darwin + +// Regression coverage for #6306, kept in its own file so the large +// CLINotifyProcessIntegrationRegressionTests.swift stays within the Swift +// file-length budget. It extends the same test class to reuse the existing +// mock-socket / bundled-CLI harness helpers. +extension CLINotifyProcessIntegrationRegressionTests { + // Regression for #6306: a failed workspace.remote.pty_resize must be retried + // (with the latest size) instead of silently dropped, otherwise the remote + // PTY/TUI can stay stuck at a stale geometry until a manual workspace + // reconnect. Here every resize RPC fails; a single user SIGWINCH must still + // produce a *second* resize attempt with no further signals. Before the fix + // (best-effort `try?`), the failed send was dropped and the second attempt + // never arrived. + func testSSHPTYAttachRetriesResizeAfterDeliveryFailure() throws { + let cliPath = try bundledCLIPath() + let socketPath = makeSocketPath("sshptyretry") + let listenerFD = try bindUnixSocket(at: socketPath) + let bridge = try bindLoopbackTCP() + let state = MockSocketServerState() + let workspaceId = "22222222-2222-2222-2222-222222222222" + let surfaceId = "33333333-3333-3333-3333-333333333333" + let sessionId = "ssh-\(workspaceId)-\(surfaceId)" + let token = "bridge-token" + let resizeReceived = DispatchSemaphore(value: 0) + let bridgeReady = DispatchSemaphore(value: 0) + let closeBridge = DispatchSemaphore(value: 0) + + defer { + Darwin.close(listenerFD) + Darwin.close(bridge.fd) + unlink(socketPath) + } + + let socketHandled = startMockServer(listenerFD: listenerFD, state: state) { line in + guard let payload = self.jsonObject(line), + let id = payload["id"] as? String, + let method = payload["method"] as? String else { + return self.malformedRequestResponse(raw: line) + } + switch method { + case "workspace.remote.pty_bridge": + return self.v2Response( + id: id, + ok: true, + result: [ + "host": "127.0.0.1", + "port": bridge.port, + "token": token, + "session_id": sessionId, + "attachment_id": surfaceId, + ] + ) + case "workspace.remote.pty_resize": + // Always fail delivery to simulate a stale/blocked remote + // control path so the CLI must retry the latest size. + resizeReceived.signal() + return self.v2Response( + id: id, + ok: false, + error: ["code": "remote_unavailable", "message": "remote connection is not active"] + ) + case "workspace.remote.pty_sessions": + return self.v2Response(id: id, ok: true, result: ["sessions": []]) + case "workspace.remote.pty_attach_end": + return self.v2Response( + id: id, + ok: true, + result: [ + "workspace_id": workspaceId, + "surface_id": surfaceId, + "session_id": sessionId, + "cleared_remote_pty_session": true, + ] + ) + default: + return self.v2Response( + id: id, + ok: false, + error: ["code": "unexpected_method", "message": "Unexpected method \(method)"] + ) + } + } + + let bridgeHandled = expectation(description: "controlled bridge handled") + DispatchQueue.global(qos: .userInitiated).async { + defer { bridgeHandled.fulfill() } + var clientAddr = sockaddr_in() + var clientAddrLen = socklen_t(MemoryLayout.size) + let clientFD = withUnsafeMutablePointer(to: &clientAddr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + Darwin.accept(bridge.fd, sockaddrPtr, &clientAddrLen) + } + } + guard clientFD >= 0 else { return } + defer { Darwin.close(clientFD) } + + var pending = Data() + var buffer = [UInt8](repeating: 0, count: 1024) + while !pending.contains(0x0A) { + let count = Darwin.read(clientFD, &buffer, buffer.count) + if count < 0 { + if errno == EINTR { continue } + return + } + if count == 0 { return } + pending.append(buffer, count: count) + } + + let ready = #"{"type":"ready","attachment_token":"attach-token"}"# + "\n" + _ = ready.withCString { ptr in + Darwin.write(clientFD, ptr, strlen(ptr)) + } + bridgeReady.signal() + _ = closeBridge.wait(timeout: .now() + 5) + } + + let process = Process() + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.executableURL = URL(fileURLWithPath: cliPath) + process.arguments = [ + "ssh-pty-attach", + "--workspace", workspaceId, + "--session-id", sessionId, + "--attachment-id", surfaceId, + ] + var environment = ProcessInfo.processInfo.environment + environment["CMUX_SOCKET_PATH"] = socketPath + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + process.environment = environment + process.standardInput = FileHandle.nullDevice + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + try process.run() + defer { + if process.isRunning { + process.terminate() + } + } + XCTAssertEqual(bridgeReady.wait(timeout: .now() + 5), .success) + + // Deliver SIGWINCH until the first resize RPC lands (the signal source + // may not be installed the instant the bridge reports ready), then stop. + var sawFirstResize = false + for _ in 0..<20 { + Darwin.kill(process.processIdentifier, SIGWINCH) + if resizeReceived.wait(timeout: .now() + 0.2) == .success { + sawFirstResize = true + break + } + } + XCTAssertTrue(sawFirstResize, "Expected ssh-pty-attach to issue an initial resize RPC after SIGWINCH") + + // No further SIGWINCH is sent. A second resize attempt can only arrive + // from the coalesce/retry coordinator re-sending the latest size after + // the first delivery failed. The pre-fix best-effort send would never + // retry, so this would time out. + XCTAssertEqual( + resizeReceived.wait(timeout: .now() + 3), + .success, + "Expected ssh-pty-attach to retry the resize after a delivery failure" + ) + + closeBridge.signal() + let exited = DispatchSemaphore(value: 0) + DispatchQueue.global(qos: .userInitiated).async { + process.waitUntilExit() + exited.signal() + } + _ = exited.wait(timeout: .now() + 5) + if process.isRunning { + process.terminate() + } + + wait(for: [socketHandled, bridgeHandled], timeout: 5) + let resizeCount = state.snapshot() + .compactMap { self.jsonObject($0)?["method"] as? String } + .filter { $0 == "workspace.remote.pty_resize" } + .count + XCTAssertGreaterThanOrEqual( + resizeCount, + 2, + "Expected at least one retry resize RPC, saw \(resizeCount)" + ) + } +}