From 40452b7854b0ac77dcf00067adb35f3b94b26d43 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 13 Jun 2026 18:25:55 -0700 Subject: [PATCH 01/18] test: cover ssh pty queued terminal replies --- ...ifyProcessIntegrationRegressionTests.swift | 108 ++++++++++++++++++ cmuxTests/CLINotifyProcessTestSupport.swift | 86 ++++++++++++++ 2 files changed, 194 insertions(+) diff --git a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift index 1df2d0acfc2..67868bb93db 100644 --- a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift +++ b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift @@ -3837,6 +3837,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { } switch method { case "workspace.remote.pty_bridge": + XCTAssertEqual((payload["params"] as? [String: Any])?["require_existing"] as? Bool, true) return self.v2Response( id: id, ok: true, @@ -3889,6 +3890,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { executablePath: cliPath, arguments: [ "ssh-pty-attach", + "--require-existing", "--workspace", workspaceId, "--session-id", sessionId, "--attachment-id", surfaceId, @@ -4271,6 +4273,112 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertEqual(capturedHandshake?["rows"] as? Int, 43) } + func testSSHPTYAttachDropsQueuedTerminalProbeRepliesBeforeForwardingInput() throws { + let cliPath = try bundledCLIPath() + let socketPath = makeSocketPath("sshptyprobe") + 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 bridgeInput = MockBridgeInputCapture() + + 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_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 = startBridgeReadyCapturingInputUntilEOF( + listenerFD: bridge.fd, + capture: bridgeInput + ) + + var environment = ProcessInfo.processInfo.environment + environment["CMUX_SOCKET_PATH"] = socketPath + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + + let queuedProbeReplies = + "\u{1B}]11;rgb:e5e5/e9e9/f0f0\u{1B}\\" + + "\u{1B}]10;rgb:4141/4848/5858\u{07}" + + "\u{1B}[1;1R" + + "\u{1B}[?1;2c" + + "\u{1B}[?0u" + + "\u{1B}[?12;2$y" + let forwardedInput = "printf keep\n" + let result = runProcess( + executablePath: cliPath, + arguments: [ + "ssh-pty-attach", + "--workspace", workspaceId, + "--session-id", sessionId, + "--attachment-id", surfaceId, + ], + environment: environment, + standardInput: queuedProbeReplies + forwardedInput, + timeout: 5 + ) + + wait(for: [socketHandled, bridgeHandled], timeout: 5) + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertTrue(result.stderr.isEmpty, result.stderr) + let forwardedBridgeInput = bridgeInput.snapshot() + XCTAssertEqual( + String(data: forwardedBridgeInput, encoding: .utf8), + forwardedInput, + "Terminal probe replies queued during reconnect must not be forwarded into the remote PTY." + ) + 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", + ]) + } + func testSSHPTYAttachSerializesResizeBeforeEOFLocalCleanup() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("sshptyresize") diff --git a/cmuxTests/CLINotifyProcessTestSupport.swift b/cmuxTests/CLINotifyProcessTestSupport.swift index a0ae0ffac4c..727e37f3227 100644 --- a/cmuxTests/CLINotifyProcessTestSupport.swift +++ b/cmuxTests/CLINotifyProcessTestSupport.swift @@ -27,6 +27,24 @@ extension CLINotifyProcessIntegrationRegressionTests { } } + final class MockBridgeInputCapture: @unchecked Sendable { + private let lock = NSLock() + private var input = Data() + + func append(_ data: Data) { + lock.lock() + input.append(data) + lock.unlock() + } + + func snapshot() -> Data { + lock.lock() + let value = input + lock.unlock() + return value + } + } + struct LoopbackTCPListener { let fd: Int32 let port: Int @@ -235,6 +253,74 @@ extension CLINotifyProcessIntegrationRegressionTests { return handled } + func startBridgeReadyCapturingInputUntilEOF( + listenerFD: Int32, + capture: MockBridgeInputCapture + ) -> XCTestExpectation { + let handled = expectation(description: "pty bridge ready input capture server handled") + DispatchQueue.global(qos: .userInitiated).async { + defer { handled.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(listenerFD, 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 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 + guard let base = rawBuffer.bindMemory(to: UInt8.self).baseAddress else { return } + var remaining = rawBuffer.count + var cursor = base + while remaining > 0 { + let written = Darwin.write(clientFD, cursor, remaining) + if written > 0 { + remaining -= written + cursor = cursor.advanced(by: written) + } else if written < 0 && errno == EINTR { + continue + } else { + return + } + } + } + + while true { + let count = Darwin.read(clientFD, &buffer, buffer.count) + if count > 0 { + capture.append(Data(buffer.prefix(count))) + continue + } + if count == 0 { + return + } + if errno == EINTR { + continue + } + return + } + } + return handled + } + func startBridgeReadyThenResetAfterClientEOFServer(listenerFD: Int32) -> XCTestExpectation { let handled = expectation(description: "pty bridge ready reset server handled") DispatchQueue.global(qos: .userInitiated).async { From 0ad325860cecdb42ccef27f31fd41549ea1ac59f Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 13 Jun 2026 18:28:31 -0700 Subject: [PATCH 02/18] fix: drop queued ssh pty probe replies on restore --- CLI/cmux.swift | 225 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 224 insertions(+), 1 deletion(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 2f6da318f07..814ac074a34 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -10075,6 +10075,215 @@ struct CMUXCLI { } } + // Confined to the single ssh-pty-attach stdin pump thread; the class is + // captured by DispatchQueue's @Sendable closure but never shared elsewhere. + private final class SSHPTYAttachReconnectInputFilter: @unchecked Sendable { + private enum SequenceMatch { + case strip(length: Int) + case incomplete + case passThrough + } + + private static let escape: UInt8 = 0x1B + private static let bell: UInt8 = 0x07 + private static let leftBracket: UInt8 = 0x5B + private static let rightBracket: UInt8 = 0x5D + private static let backslash: UInt8 = 0x5C + private static let semicolon: UInt8 = 0x3B + private static let questionMark: UInt8 = 0x3F + private static let greaterThan: UInt8 = 0x3E + private static let dollar: UInt8 = 0x24 + + private var isFiltering: Bool + private var pending = [UInt8]() + + init(enabled: Bool) { + isFiltering = enabled + } + + func filter(_ data: Data) -> Data { + guard isFiltering, !data.isEmpty else { + return data + } + + var bytes = pending + pending.removeAll(keepingCapacity: true) + bytes.append(contentsOf: data) + + var output = Data() + var index = 0 + while index < bytes.count { + guard bytes[index] == Self.escape else { + isFiltering = false + output.append(contentsOf: bytes[index...]) + return output + } + + switch Self.reconnectProbeReplySequence(in: bytes, at: index) { + case .strip(let length): + index += length + case .incomplete: + pending.append(contentsOf: bytes[index...]) + return output + case .passThrough: + isFiltering = false + output.append(contentsOf: bytes[index...]) + return output + } + } + + return output + } + + func finish() -> Data { + guard !pending.isEmpty else { + return Data() + } + let data = Data(pending) + pending.removeAll(keepingCapacity: false) + return data + } + + private static func reconnectProbeReplySequence(in bytes: [UInt8], at start: Int) -> SequenceMatch { + guard start < bytes.count, bytes[start] == escape else { + return .passThrough + } + guard start + 1 < bytes.count else { + return .incomplete + } + + switch bytes[start + 1] { + case rightBracket: + return oscColorReplySequence(in: bytes, at: start) + case leftBracket: + return csiProbeReplySequence(in: bytes, at: start) + default: + return .passThrough + } + } + + private static func oscColorReplySequence(in bytes: [UInt8], at start: Int) -> SequenceMatch { + var cursor = start + 2 + var command = [UInt8]() + + while cursor < bytes.count { + let byte = bytes[cursor] + if byte == semicolon { + break + } + if byte < 0x30 || byte > 0x39 || command.count >= 2 { + return .passThrough + } + command.append(byte) + cursor += 1 + } + + guard cursor < bytes.count else { + return oscCommandCouldBecomeColorReply(command) ? .incomplete : .passThrough + } + guard bytes[cursor] == semicolon else { + return .passThrough + } + guard command == [0x31, 0x30] || command == [0x31, 0x31] else { + return .passThrough + } + + cursor += 1 + while cursor < bytes.count { + let byte = bytes[cursor] + if byte == bell { + return .strip(length: cursor - start + 1) + } + if byte == escape { + guard cursor + 1 < bytes.count else { + return .incomplete + } + if bytes[cursor + 1] == backslash { + return .strip(length: cursor - start + 2) + } + } + cursor += 1 + } + return .incomplete + } + + private static func oscCommandCouldBecomeColorReply(_ command: [UInt8]) -> Bool { + if command.isEmpty { + return true + } + if command == [0x31] { + return true + } + return command == [0x31, 0x30] || command == [0x31, 0x31] + } + + private static func csiProbeReplySequence(in bytes: [UInt8], at start: Int) -> SequenceMatch { + var cursor = start + 2 + while cursor < bytes.count { + let byte = bytes[cursor] + if byte >= 0x40, byte <= 0x7E { + return shouldStripCSIReply(bytes: bytes, bodyStart: start + 2, finalIndex: cursor) + ? .strip(length: cursor - start + 1) + : .passThrough + } + guard byte >= 0x20, byte <= 0x3F else { + return .passThrough + } + cursor += 1 + } + return .incomplete + } + + private static func shouldStripCSIReply(bytes: [UInt8], bodyStart: Int, finalIndex: Int) -> Bool { + var parameterEnd = bodyStart + while parameterEnd < finalIndex, bytes[parameterEnd] >= 0x30, bytes[parameterEnd] <= 0x3F { + parameterEnd += 1 + } + guard bytes[parameterEnd..= 0x20 && $0 <= 0x2F }) else { + return false + } + + let parameters = bytes[bodyStart..) -> Int? { + var value = 0 + var sawDigit = false + for byte in parameters { + if byte >= 0x30, byte <= 0x39 { + sawDigit = true + value = min((value * 10) + Int(byte - 0x30), 1_000_000) + continue + } + if byte == semicolon { + break + } + if byte == questionMark || byte == greaterThan { + continue + } + return nil + } + return sawDigit ? value : nil + } + } + private func runSSHSessionList( commandArgs: [String], client: SocketClient, @@ -10590,18 +10799,32 @@ struct CMUXCLI { ) defer { resizeSource.cancel() } + let reconnectInputFilter = SSHPTYAttachReconnectInputFilter(enabled: requireExisting && command == nil) DispatchQueue.global(qos: .userInteractive).async { var buffer = [UInt8](repeating: 0, count: 8192) while true { let count = Darwin.read(STDIN_FILENO, &buffer, buffer.count) if count > 0 { + let input = reconnectInputFilter.filter(Data(buffer.prefix(count))) + guard !input.isEmpty else { + continue + } do { - try self.writeAll(fd: fd, data: Data(buffer.prefix(count))) + try self.writeAll(fd: fd, data: input) } catch { _ = shutdown(fd, SHUT_WR) return } } else if count == 0 { + let input = reconnectInputFilter.finish() + if !input.isEmpty { + do { + try self.writeAll(fd: fd, data: input) + } catch { + _ = shutdown(fd, SHUT_WR) + return + } + } _ = shutdown(fd, SHUT_WR) return } else if errno != EINTR { From c225d16cd7ab20a9ee52bbdb99bef2db298281cc Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 13 Jun 2026 18:40:25 -0700 Subject: [PATCH 03/18] fix: keep ssh pty reconnect filter out of long files --- .github/swift-file-length-budget.tsv | 2 +- CLI/SSHPTYAttachReconnectInputFilter.swift | 242 +++++++++++++++++ CLI/cmux.swift | 244 +----------------- cmux.xcodeproj/project.pbxproj | 12 + ...ifyProcessIntegrationRegressionTests.swift | 108 -------- cmuxTests/CLINotifyProcessTestSupport.swift | 86 ------ ...SHPTYAttachProbeReplyRegressionTests.swift | 113 ++++++++ cmuxTests/MockBridgeInputCapture.swift | 92 +++++++ 8 files changed, 461 insertions(+), 438 deletions(-) create mode 100644 CLI/SSHPTYAttachReconnectInputFilter.swift create mode 100644 cmuxTests/CLISSHPTYAttachProbeReplyRegressionTests.swift create mode 100644 cmuxTests/MockBridgeInputCapture.swift diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index b71d404a532..84fce7ad789 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -1,7 +1,7 @@ # cmux-owned Swift file length budget. # Format: max_linesrelative path # Reduce counts as files shrink. CI fails if tracked files exceed this budget. -33285 CLI/cmux.swift +33266 CLI/cmux.swift 19213 Sources/ContentView.swift 17894 Sources/AppDelegate.swift 15182 Sources/GhosttyTerminalView.swift diff --git a/CLI/SSHPTYAttachReconnectInputFilter.swift b/CLI/SSHPTYAttachReconnectInputFilter.swift new file mode 100644 index 00000000000..e7630bdbc18 --- /dev/null +++ b/CLI/SSHPTYAttachReconnectInputFilter.swift @@ -0,0 +1,242 @@ +import Darwin +import Foundation + +// Confined to the single ssh-pty-attach stdin pump thread; the class is +// captured by DispatchQueue's @Sendable closure but never shared elsewhere. +final class SSHPTYAttachReconnectInputFilter: @unchecked Sendable { + private enum SequenceMatch { + case strip(length: Int) + case incomplete + case passThrough + } + + private static let escape: UInt8 = 0x1B + private static let bell: UInt8 = 0x07 + private static let leftBracket: UInt8 = 0x5B + private static let rightBracket: UInt8 = 0x5D + private static let backslash: UInt8 = 0x5C + private static let semicolon: UInt8 = 0x3B + private static let questionMark: UInt8 = 0x3F + private static let dollar: UInt8 = 0x24 + + private var isFiltering: Bool + private var pending = [UInt8]() + + init(enabled: Bool) { + isFiltering = enabled + } + + static func startStdinPump(fd: Int32, filterEnabled: Bool) { + let reconnectInputFilter = SSHPTYAttachReconnectInputFilter(enabled: filterEnabled) + DispatchQueue.global(qos: .userInteractive).async { + var buffer = [UInt8](repeating: 0, count: 8192) + while true { + let count = Darwin.read(STDIN_FILENO, &buffer, buffer.count) + if count > 0 { + let input = reconnectInputFilter.filter(Data(buffer.prefix(count))) + guard !input.isEmpty else { + continue + } + do { + try Self.writeAll(fd: fd, data: input) + } catch { + _ = shutdown(fd, SHUT_WR) + return + } + } else if count == 0 { + let input = reconnectInputFilter.finish() + if !input.isEmpty { + do { + try Self.writeAll(fd: fd, data: input) + } catch { + _ = shutdown(fd, SHUT_WR) + return + } + } + _ = shutdown(fd, SHUT_WR) + return + } else if errno != EINTR { + _ = shutdown(fd, SHUT_WR) + return + } + } + } + } + + func filter(_ data: Data) -> Data { + guard isFiltering, !data.isEmpty else { + return data + } + + var bytes = pending + pending.removeAll(keepingCapacity: true) + bytes.append(contentsOf: data) + + var output = Data() + var index = 0 + while index < bytes.count { + guard bytes[index] == Self.escape else { + isFiltering = false + output.append(contentsOf: bytes[index...]) + return output + } + + switch Self.reconnectProbeReplySequence(in: bytes, at: index) { + case .strip(let length): + index += length + case .incomplete: + pending.append(contentsOf: bytes[index...]) + return output + case .passThrough: + isFiltering = false + output.append(contentsOf: bytes[index...]) + return output + } + } + + return output + } + + func finish() -> Data { + guard !pending.isEmpty else { + return Data() + } + let data = Data(pending) + pending.removeAll(keepingCapacity: false) + return data + } + + private static func reconnectProbeReplySequence(in bytes: [UInt8], at start: Int) -> SequenceMatch { + guard start < bytes.count, bytes[start] == escape else { + return .passThrough + } + guard start + 1 < bytes.count else { + return .incomplete + } + + switch bytes[start + 1] { + case rightBracket: + return oscColorReplySequence(in: bytes, at: start) + case leftBracket: + return csiProbeReplySequence(in: bytes, at: start) + default: + return .passThrough + } + } + + private static func oscColorReplySequence(in bytes: [UInt8], at start: Int) -> SequenceMatch { + var cursor = start + 2 + var command = [UInt8]() + + while cursor < bytes.count { + let byte = bytes[cursor] + if byte == semicolon { + break + } + if byte < 0x30 || byte > 0x39 || command.count >= 2 { + return .passThrough + } + command.append(byte) + cursor += 1 + } + + guard cursor < bytes.count else { + return oscCommandCouldBecomeColorReply(command) ? .incomplete : .passThrough + } + guard bytes[cursor] == semicolon else { + return .passThrough + } + guard command == [0x31, 0x30] || command == [0x31, 0x31] else { + return .passThrough + } + + cursor += 1 + while cursor < bytes.count { + let byte = bytes[cursor] + if byte == bell { + return .strip(length: cursor - start + 1) + } + if byte == escape { + guard cursor + 1 < bytes.count else { + return .incomplete + } + if bytes[cursor + 1] == backslash { + return .strip(length: cursor - start + 2) + } + } + cursor += 1 + } + return .incomplete + } + + private static func oscCommandCouldBecomeColorReply(_ command: [UInt8]) -> Bool { + if command.isEmpty { + return true + } + if command == [0x31] { + return true + } + return command == [0x31, 0x30] || command == [0x31, 0x31] + } + + private static func csiProbeReplySequence(in bytes: [UInt8], at start: Int) -> SequenceMatch { + var cursor = start + 2 + while cursor < bytes.count { + let byte = bytes[cursor] + if byte >= 0x40, byte <= 0x7E { + return shouldStripCSIReply(bytes: bytes, bodyStart: start + 2, finalIndex: cursor) + ? .strip(length: cursor - start + 1) + : .passThrough + } + guard byte >= 0x20, byte <= 0x3F else { + return .passThrough + } + cursor += 1 + } + return .incomplete + } + + private static func shouldStripCSIReply(bytes: [UInt8], bodyStart: Int, finalIndex: Int) -> Bool { + var parameterEnd = bodyStart + while parameterEnd < finalIndex, bytes[parameterEnd] >= 0x30, bytes[parameterEnd] <= 0x3F { + parameterEnd += 1 + } + guard bytes[parameterEnd..= 0x20 && $0 <= 0x2F }) else { + return false + } + + let parameters = bytes[bodyStart.. 0 { + let written = Darwin.write(fd, cursor, remaining) + if written > 0 { + remaining -= written + cursor = cursor.advanced(by: written) + } else if written < 0 && errno == EINTR { + continue + } else { + throw CLIError(message: "ssh-pty-attach: bridge write failed") + } + } + } + } +} diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 814ac074a34..74e80c1f64f 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -10075,215 +10075,6 @@ struct CMUXCLI { } } - // Confined to the single ssh-pty-attach stdin pump thread; the class is - // captured by DispatchQueue's @Sendable closure but never shared elsewhere. - private final class SSHPTYAttachReconnectInputFilter: @unchecked Sendable { - private enum SequenceMatch { - case strip(length: Int) - case incomplete - case passThrough - } - - private static let escape: UInt8 = 0x1B - private static let bell: UInt8 = 0x07 - private static let leftBracket: UInt8 = 0x5B - private static let rightBracket: UInt8 = 0x5D - private static let backslash: UInt8 = 0x5C - private static let semicolon: UInt8 = 0x3B - private static let questionMark: UInt8 = 0x3F - private static let greaterThan: UInt8 = 0x3E - private static let dollar: UInt8 = 0x24 - - private var isFiltering: Bool - private var pending = [UInt8]() - - init(enabled: Bool) { - isFiltering = enabled - } - - func filter(_ data: Data) -> Data { - guard isFiltering, !data.isEmpty else { - return data - } - - var bytes = pending - pending.removeAll(keepingCapacity: true) - bytes.append(contentsOf: data) - - var output = Data() - var index = 0 - while index < bytes.count { - guard bytes[index] == Self.escape else { - isFiltering = false - output.append(contentsOf: bytes[index...]) - return output - } - - switch Self.reconnectProbeReplySequence(in: bytes, at: index) { - case .strip(let length): - index += length - case .incomplete: - pending.append(contentsOf: bytes[index...]) - return output - case .passThrough: - isFiltering = false - output.append(contentsOf: bytes[index...]) - return output - } - } - - return output - } - - func finish() -> Data { - guard !pending.isEmpty else { - return Data() - } - let data = Data(pending) - pending.removeAll(keepingCapacity: false) - return data - } - - private static func reconnectProbeReplySequence(in bytes: [UInt8], at start: Int) -> SequenceMatch { - guard start < bytes.count, bytes[start] == escape else { - return .passThrough - } - guard start + 1 < bytes.count else { - return .incomplete - } - - switch bytes[start + 1] { - case rightBracket: - return oscColorReplySequence(in: bytes, at: start) - case leftBracket: - return csiProbeReplySequence(in: bytes, at: start) - default: - return .passThrough - } - } - - private static func oscColorReplySequence(in bytes: [UInt8], at start: Int) -> SequenceMatch { - var cursor = start + 2 - var command = [UInt8]() - - while cursor < bytes.count { - let byte = bytes[cursor] - if byte == semicolon { - break - } - if byte < 0x30 || byte > 0x39 || command.count >= 2 { - return .passThrough - } - command.append(byte) - cursor += 1 - } - - guard cursor < bytes.count else { - return oscCommandCouldBecomeColorReply(command) ? .incomplete : .passThrough - } - guard bytes[cursor] == semicolon else { - return .passThrough - } - guard command == [0x31, 0x30] || command == [0x31, 0x31] else { - return .passThrough - } - - cursor += 1 - while cursor < bytes.count { - let byte = bytes[cursor] - if byte == bell { - return .strip(length: cursor - start + 1) - } - if byte == escape { - guard cursor + 1 < bytes.count else { - return .incomplete - } - if bytes[cursor + 1] == backslash { - return .strip(length: cursor - start + 2) - } - } - cursor += 1 - } - return .incomplete - } - - private static func oscCommandCouldBecomeColorReply(_ command: [UInt8]) -> Bool { - if command.isEmpty { - return true - } - if command == [0x31] { - return true - } - return command == [0x31, 0x30] || command == [0x31, 0x31] - } - - private static func csiProbeReplySequence(in bytes: [UInt8], at start: Int) -> SequenceMatch { - var cursor = start + 2 - while cursor < bytes.count { - let byte = bytes[cursor] - if byte >= 0x40, byte <= 0x7E { - return shouldStripCSIReply(bytes: bytes, bodyStart: start + 2, finalIndex: cursor) - ? .strip(length: cursor - start + 1) - : .passThrough - } - guard byte >= 0x20, byte <= 0x3F else { - return .passThrough - } - cursor += 1 - } - return .incomplete - } - - private static func shouldStripCSIReply(bytes: [UInt8], bodyStart: Int, finalIndex: Int) -> Bool { - var parameterEnd = bodyStart - while parameterEnd < finalIndex, bytes[parameterEnd] >= 0x30, bytes[parameterEnd] <= 0x3F { - parameterEnd += 1 - } - guard bytes[parameterEnd..= 0x20 && $0 <= 0x2F }) else { - return false - } - - let parameters = bytes[bodyStart..) -> Int? { - var value = 0 - var sawDigit = false - for byte in parameters { - if byte >= 0x30, byte <= 0x39 { - sawDigit = true - value = min((value * 10) + Int(byte - 0x30), 1_000_000) - continue - } - if byte == semicolon { - break - } - if byte == questionMark || byte == greaterThan { - continue - } - return nil - } - return sawDigit ? value : nil - } - } - private func runSSHSessionList( commandArgs: [String], client: SocketClient, @@ -10799,40 +10590,7 @@ struct CMUXCLI { ) defer { resizeSource.cancel() } - let reconnectInputFilter = SSHPTYAttachReconnectInputFilter(enabled: requireExisting && command == nil) - DispatchQueue.global(qos: .userInteractive).async { - var buffer = [UInt8](repeating: 0, count: 8192) - while true { - let count = Darwin.read(STDIN_FILENO, &buffer, buffer.count) - if count > 0 { - let input = reconnectInputFilter.filter(Data(buffer.prefix(count))) - guard !input.isEmpty else { - continue - } - do { - try self.writeAll(fd: fd, data: input) - } catch { - _ = shutdown(fd, SHUT_WR) - return - } - } else if count == 0 { - let input = reconnectInputFilter.finish() - if !input.isEmpty { - do { - try self.writeAll(fd: fd, data: input) - } catch { - _ = shutdown(fd, SHUT_WR) - return - } - } - _ = shutdown(fd, SHUT_WR) - return - } else if errno != EINTR { - _ = shutdown(fd, SHUT_WR) - return - } - } - } + SSHPTYAttachReconnectInputFilter.startStdinPump(fd: fd, filterEnabled: requireExisting && command == nil) var outputBuffer = [UInt8](repeating: 0, count: 32768) while true { diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 323c20b38f7..3956fa0b47a 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -132,6 +132,7 @@ C0F16B000000000000000001 /* CLIRemoteShellStartupPerformanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0F16B000000000000000002 /* CLIRemoteShellStartupPerformanceTests.swift */; }; A5D41209A1B2C3D4E5F60718 /* CLIRovoDevHookPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5D4120AA1B2C3D4E5F60718 /* CLIRovoDevHookPersistenceTests.swift */; }; B900004AA1B2C3D4E5F60719 /* CLISocketPathResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = B900004BA1B2C3D4E5F60719 /* CLISocketPathResolver.swift */; }; + A8D837B3AA85F4353C493DE1 /* CLISSHPTYAttachProbeReplyRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CB1737C956A80E5F98E9DC5 /* CLISSHPTYAttachProbeReplyRegressionTests.swift */; }; C10D51700000000000000002 /* ClosedItemHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10D51700000000000000001 /* ClosedItemHistory.swift */; }; B9000025A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000026A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift */; }; B9000023A1B2C3D4E5F60719 /* CloseWorkspaceCmdDUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000022A1B2C3D4E5F60719 /* CloseWorkspaceCmdDUITests.swift */; }; @@ -486,6 +487,7 @@ C9CFB398DF94D6DDEAF42D67 /* MobileTerminalRenderObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = F51B54FAD18160281FC1C2B6 /* MobileTerminalRenderObserver.swift */; }; F6448F377504F33956E7E03B /* MobileWorkspaceListFidelityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86544CEFA1CA33CA9225FB6E /* MobileWorkspaceListFidelityTests.swift */; }; 00C3AA443F6DA8D94E28CE17 /* MobileWorkspaceListObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9F14137954A34DFFA05C88C /* MobileWorkspaceListObserver.swift */; }; + 13AB1A2E5DA6AD02DCAE971D /* MockBridgeInputCapture.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD3B5F249DECAB446CC71D32 /* MockBridgeInputCapture.swift */; }; B9000015A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000016A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift */; }; 8B1B2AB6EC6ACDB2CD39A9BB /* NewBrowserWorkspaceShortcutUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04698069140539D8495D6B9B /* NewBrowserWorkspaceShortcutUITests.swift */; }; 734F49D37E543DD01C2F4FEF /* NotificationAndMenuBarTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2C075029771815DD5DA1332 /* NotificationAndMenuBarTests.swift */; }; @@ -653,6 +655,7 @@ F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */; }; C510C1E00000000000000002 /* SocketOperationTelemetry.swift in Sources */ = {isa = PBXBuildFile; fileRef = C510C1E00000000000000001 /* SocketOperationTelemetry.swift */; }; A5001230 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = A5001231 /* Sparkle */; }; + B816E948EC5E42AF967648AC /* SSHPTYAttachReconnectInputFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E4DB33FA55F1B13C60EFFC8 /* SSHPTYAttachReconnectInputFilter.swift */; }; E30780000000000000000012 /* SSHPTYAttachStartupCommandBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = E30780000000000000000011 /* SSHPTYAttachStartupCommandBuilder.swift */; }; F6355600A1B2C3D4E5F60718 /* SSHStartupSignalLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6355601A1B2C3D4E5F60718 /* SSHStartupSignalLifecycleTests.swift */; }; F20F85FC5900550685FA33AD /* StackAuth in Frameworks */ = {isa = PBXBuildFile; productRef = A8BD195031FC4B82B4354297 /* StackAuth */; }; @@ -1027,6 +1030,7 @@ C0F16B000000000000000002 /* CLIRemoteShellStartupPerformanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLIRemoteShellStartupPerformanceTests.swift; sourceTree = ""; }; A5D4120AA1B2C3D4E5F60718 /* CLIRovoDevHookPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLIRovoDevHookPersistenceTests.swift; sourceTree = ""; }; B900004BA1B2C3D4E5F60719 /* CLISocketPathResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLISocketPathResolver.swift; sourceTree = ""; }; + 8CB1737C956A80E5F98E9DC5 /* CLISSHPTYAttachProbeReplyRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLISSHPTYAttachProbeReplyRegressionTests.swift; sourceTree = ""; }; C10D51700000000000000001 /* ClosedItemHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClosedItemHistory.swift; sourceTree = ""; }; B9000026A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseWindowConfirmDialogUITests.swift; sourceTree = ""; }; B9000022A1B2C3D4E5F60719 /* CloseWorkspaceCmdDUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseWorkspaceCmdDUITests.swift; sourceTree = ""; }; @@ -1320,6 +1324,7 @@ F51B54FAD18160281FC1C2B6 /* MobileTerminalRenderObserver.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MobileTerminalRenderObserver.swift; sourceTree = ""; }; 86544CEFA1CA33CA9225FB6E /* MobileWorkspaceListFidelityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileWorkspaceListFidelityTests.swift; sourceTree = ""; }; C9F14137954A34DFFA05C88C /* MobileWorkspaceListObserver.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MobileWorkspaceListObserver.swift; sourceTree = ""; }; + CD3B5F249DECAB446CC71D32 /* MockBridgeInputCapture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBridgeInputCapture.swift; sourceTree = ""; }; B9000016A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiWindowNotificationsUITests.swift; sourceTree = ""; }; 04698069140539D8495D6B9B /* NewBrowserWorkspaceShortcutUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewBrowserWorkspaceShortcutUITests.swift; sourceTree = ""; }; D2C075029771815DD5DA1332 /* NotificationAndMenuBarTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationAndMenuBarTests.swift; sourceTree = ""; }; @@ -1479,6 +1484,7 @@ A5001225 /* SocketControlMode+Display.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SocketControlMode+Display.swift"; sourceTree = ""; }; F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketControlPasswordStoreTests.swift; sourceTree = ""; }; C510C1E00000000000000001 /* SocketOperationTelemetry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketOperationTelemetry.swift; sourceTree = ""; }; + 1E4DB33FA55F1B13C60EFFC8 /* SSHPTYAttachReconnectInputFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHPTYAttachReconnectInputFilter.swift; sourceTree = ""; }; E30780000000000000000011 /* SSHPTYAttachStartupCommandBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHPTYAttachStartupCommandBuilder.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 = ""; }; @@ -2364,6 +2370,7 @@ isa = PBXGroup; children = ( B9000001A1B2C3D4E5F60719 /* cmux.swift */, + 1E4DB33FA55F1B13C60EFFC8 /* SSHPTYAttachReconnectInputFilter.swift */, C0DECAFE0000000000000002 /* CodexTeamsApprovalBridge.swift */, FEEDC1A50000000000000002 /* FeedEventClassifier.swift */, B900004BA1B2C3D4E5F60719 /* CLISocketPathResolver.swift */, @@ -2573,6 +2580,8 @@ A5D41204A1B2C3D4E5F60718 /* CLINotifyProcessIntegrationRegressionTests.swift */, C0F16B000000000000000002 /* CLIRemoteShellStartupPerformanceTests.swift */, A5D41206A1B2C3D4E5F60718 /* CLINotifyProcessTestSupport.swift */, + 8CB1737C956A80E5F98E9DC5 /* CLISSHPTYAttachProbeReplyRegressionTests.swift */, + CD3B5F249DECAB446CC71D32 /* MockBridgeInputCapture.swift */, A5D41208A1B2C3D4E5F60718 /* CLIGenericHookPersistenceTests.swift */, A5D41212A1B2C3D4E5F60718 /* CLIHookNoResponseTests.swift */, A5D4120AA1B2C3D4E5F60718 /* CLIRovoDevHookPersistenceTests.swift */, @@ -3548,6 +3557,7 @@ B9000027A1B2C3D4E5F60719 /* RemoteRelayZshBootstrap.swift in Sources */, 5E2701020000000000000003 /* SentryEventScrubber.swift in Sources */, C510C1E00000000000000002 /* SocketOperationTelemetry.swift in Sources */, + B816E948EC5E42AF967648AC /* SSHPTYAttachReconnectInputFilter.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -3647,6 +3657,7 @@ A5D41205A1B2C3D4E5F60718 /* CLINotifyProcessTestSupport.swift in Sources */, C0F16B000000000000000001 /* CLIRemoteShellStartupPerformanceTests.swift in Sources */, A5D41209A1B2C3D4E5F60718 /* CLIRovoDevHookPersistenceTests.swift in Sources */, + A8D837B3AA85F4353C493DE1 /* CLISSHPTYAttachProbeReplyRegressionTests.swift in Sources */, C0DE31390000000000000105 /* CMUXCLIErrorOutputRegressionTests.swift in Sources */, C0DEF0A40000000000000001 /* CmuxConfigContextMenuTests.swift in Sources */, E30750000000000000000006 /* CmuxConfigNamedColorTests.swift in Sources */, @@ -3717,6 +3728,7 @@ 4C1A7E10B2D34F56A8C90013 /* MobileHostStatusVerificationLimiterTests.swift in Sources */, 9B08F916D7FF7626C6820727 /* MobilePairingConnectionTransitionTests.swift in Sources */, F6448F377504F33956E7E03B /* MobileWorkspaceListFidelityTests.swift in Sources */, + 13AB1A2E5DA6AD02DCAE971D /* MockBridgeInputCapture.swift in Sources */, 734F49D37E543DD01C2F4FEF /* NotificationAndMenuBarTests.swift in Sources */, D7AB00000000000000B021 /* NotificationDismissSyncTests.swift in Sources */, 4E5F60720000000000000001 /* NotificationSoundSettingsTests.swift in Sources */, diff --git a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift index 67868bb93db..1df2d0acfc2 100644 --- a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift +++ b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift @@ -3837,7 +3837,6 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { } switch method { case "workspace.remote.pty_bridge": - XCTAssertEqual((payload["params"] as? [String: Any])?["require_existing"] as? Bool, true) return self.v2Response( id: id, ok: true, @@ -3890,7 +3889,6 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { executablePath: cliPath, arguments: [ "ssh-pty-attach", - "--require-existing", "--workspace", workspaceId, "--session-id", sessionId, "--attachment-id", surfaceId, @@ -4273,112 +4271,6 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertEqual(capturedHandshake?["rows"] as? Int, 43) } - func testSSHPTYAttachDropsQueuedTerminalProbeRepliesBeforeForwardingInput() throws { - let cliPath = try bundledCLIPath() - let socketPath = makeSocketPath("sshptyprobe") - 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 bridgeInput = MockBridgeInputCapture() - - 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_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 = startBridgeReadyCapturingInputUntilEOF( - listenerFD: bridge.fd, - capture: bridgeInput - ) - - var environment = ProcessInfo.processInfo.environment - environment["CMUX_SOCKET_PATH"] = socketPath - environment["CMUX_CLI_SENTRY_DISABLED"] = "1" - - let queuedProbeReplies = - "\u{1B}]11;rgb:e5e5/e9e9/f0f0\u{1B}\\" + - "\u{1B}]10;rgb:4141/4848/5858\u{07}" + - "\u{1B}[1;1R" + - "\u{1B}[?1;2c" + - "\u{1B}[?0u" + - "\u{1B}[?12;2$y" - let forwardedInput = "printf keep\n" - let result = runProcess( - executablePath: cliPath, - arguments: [ - "ssh-pty-attach", - "--workspace", workspaceId, - "--session-id", sessionId, - "--attachment-id", surfaceId, - ], - environment: environment, - standardInput: queuedProbeReplies + forwardedInput, - timeout: 5 - ) - - wait(for: [socketHandled, bridgeHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertTrue(result.stderr.isEmpty, result.stderr) - let forwardedBridgeInput = bridgeInput.snapshot() - XCTAssertEqual( - String(data: forwardedBridgeInput, encoding: .utf8), - forwardedInput, - "Terminal probe replies queued during reconnect must not be forwarded into the remote PTY." - ) - 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", - ]) - } - func testSSHPTYAttachSerializesResizeBeforeEOFLocalCleanup() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("sshptyresize") diff --git a/cmuxTests/CLINotifyProcessTestSupport.swift b/cmuxTests/CLINotifyProcessTestSupport.swift index 727e37f3227..a0ae0ffac4c 100644 --- a/cmuxTests/CLINotifyProcessTestSupport.swift +++ b/cmuxTests/CLINotifyProcessTestSupport.swift @@ -27,24 +27,6 @@ extension CLINotifyProcessIntegrationRegressionTests { } } - final class MockBridgeInputCapture: @unchecked Sendable { - private let lock = NSLock() - private var input = Data() - - func append(_ data: Data) { - lock.lock() - input.append(data) - lock.unlock() - } - - func snapshot() -> Data { - lock.lock() - let value = input - lock.unlock() - return value - } - } - struct LoopbackTCPListener { let fd: Int32 let port: Int @@ -253,74 +235,6 @@ extension CLINotifyProcessIntegrationRegressionTests { return handled } - func startBridgeReadyCapturingInputUntilEOF( - listenerFD: Int32, - capture: MockBridgeInputCapture - ) -> XCTestExpectation { - let handled = expectation(description: "pty bridge ready input capture server handled") - DispatchQueue.global(qos: .userInitiated).async { - defer { handled.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(listenerFD, 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 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 - guard let base = rawBuffer.bindMemory(to: UInt8.self).baseAddress else { return } - var remaining = rawBuffer.count - var cursor = base - while remaining > 0 { - let written = Darwin.write(clientFD, cursor, remaining) - if written > 0 { - remaining -= written - cursor = cursor.advanced(by: written) - } else if written < 0 && errno == EINTR { - continue - } else { - return - } - } - } - - while true { - let count = Darwin.read(clientFD, &buffer, buffer.count) - if count > 0 { - capture.append(Data(buffer.prefix(count))) - continue - } - if count == 0 { - return - } - if errno == EINTR { - continue - } - return - } - } - return handled - } - func startBridgeReadyThenResetAfterClientEOFServer(listenerFD: Int32) -> XCTestExpectation { let handled = expectation(description: "pty bridge ready reset server handled") DispatchQueue.global(qos: .userInitiated).async { diff --git a/cmuxTests/CLISSHPTYAttachProbeReplyRegressionTests.swift b/cmuxTests/CLISSHPTYAttachProbeReplyRegressionTests.swift new file mode 100644 index 00000000000..a20591c4e3d --- /dev/null +++ b/cmuxTests/CLISSHPTYAttachProbeReplyRegressionTests.swift @@ -0,0 +1,113 @@ +import Darwin +import Foundation +import XCTest + +extension CLINotifyProcessIntegrationRegressionTests { + func testSSHPTYAttachDropsQueuedTerminalProbeRepliesBeforeForwardingInput() throws { + let cliPath = try bundledCLIPath() + let socketPath = makeSocketPath("sshptyprobe") + 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 bridgeInput = MockBridgeInputCapture() + + 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": + XCTAssertEqual((payload["params"] as? [String: Any])?["require_existing"] as? Bool, true) + 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_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 = startBridgeReadyCapturingInputUntilEOF( + listenerFD: bridge.fd, + capture: bridgeInput + ) + + var environment = ProcessInfo.processInfo.environment + environment["CMUX_SOCKET_PATH"] = socketPath + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + + let queuedProbeReplies = + "\u{1B}]11;rgb:e5e5/e9e9/f0f0\u{1B}\\" + + "\u{1B}]10;rgb:4141/4848/5858\u{07}" + + "\u{1B}[1;1R" + + "\u{1B}[?1;2c" + + "\u{1B}[?0u" + + "\u{1B}[?12;2$y" + let forwardedInput = "\u{1B}[13;2uprintf keep\n" + let result = runProcess( + executablePath: cliPath, + arguments: [ + "ssh-pty-attach", + "--require-existing", + "--workspace", workspaceId, + "--session-id", sessionId, + "--attachment-id", surfaceId, + ], + environment: environment, + standardInput: queuedProbeReplies + forwardedInput, + timeout: 5 + ) + + wait(for: [socketHandled, bridgeHandled], timeout: 5) + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertTrue(result.stderr.isEmpty, result.stderr) + let forwardedBridgeInput = bridgeInput.snapshot() + XCTAssertEqual( + String(data: forwardedBridgeInput, encoding: .utf8), + forwardedInput, + "Terminal probe replies queued during reconnect must not be forwarded into the remote PTY." + ) + 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", + ]) + } +} diff --git a/cmuxTests/MockBridgeInputCapture.swift b/cmuxTests/MockBridgeInputCapture.swift new file mode 100644 index 00000000000..91c393fa119 --- /dev/null +++ b/cmuxTests/MockBridgeInputCapture.swift @@ -0,0 +1,92 @@ +import Darwin +import Foundation +import XCTest + +// Protects test capture bytes written from a bridge thread and read by XCTest assertions. +final class MockBridgeInputCapture: @unchecked Sendable { + private let lock = NSLock() + private var input = Data() + + func append(_ data: Data) { + lock.lock() + input.append(data) + lock.unlock() + } + + func snapshot() -> Data { + lock.lock() + let value = input + lock.unlock() + return value + } +} + +extension CLINotifyProcessIntegrationRegressionTests { + func startBridgeReadyCapturingInputUntilEOF( + listenerFD: Int32, + capture: MockBridgeInputCapture + ) -> XCTestExpectation { + let handled = expectation(description: "pty bridge ready input capture server handled") + DispatchQueue.global(qos: .userInitiated).async { + defer { handled.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(listenerFD, 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 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 + guard let base = rawBuffer.bindMemory(to: UInt8.self).baseAddress else { return } + var remaining = rawBuffer.count + var cursor = base + while remaining > 0 { + let written = Darwin.write(clientFD, cursor, remaining) + if written > 0 { + remaining -= written + cursor = cursor.advanced(by: written) + } else if written < 0 && errno == EINTR { + continue + } else { + return + } + } + } + + while true { + let count = Darwin.read(clientFD, &buffer, buffer.count) + if count > 0 { + capture.append(Data(buffer.prefix(count))) + continue + } + if count == 0 { + return + } + if errno == EINTR { + continue + } + return + } + } + return handled + } +} From ba6dca700e0fb3f74a17323012155dfa45a943db Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 13 Jun 2026 18:42:15 -0700 Subject: [PATCH 04/18] fix: avoid unchecked sendable in ssh pty filter --- CLI/SSHPTYAttachReconnectInputFilter.swift | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/CLI/SSHPTYAttachReconnectInputFilter.swift b/CLI/SSHPTYAttachReconnectInputFilter.swift index e7630bdbc18..008692db066 100644 --- a/CLI/SSHPTYAttachReconnectInputFilter.swift +++ b/CLI/SSHPTYAttachReconnectInputFilter.swift @@ -1,9 +1,7 @@ import Darwin import Foundation -// Confined to the single ssh-pty-attach stdin pump thread; the class is -// captured by DispatchQueue's @Sendable closure but never shared elsewhere. -final class SSHPTYAttachReconnectInputFilter: @unchecked Sendable { +final class SSHPTYAttachReconnectInputFilter { private enum SequenceMatch { case strip(length: Int) case incomplete @@ -27,8 +25,8 @@ final class SSHPTYAttachReconnectInputFilter: @unchecked Sendable { } static func startStdinPump(fd: Int32, filterEnabled: Bool) { - let reconnectInputFilter = SSHPTYAttachReconnectInputFilter(enabled: filterEnabled) DispatchQueue.global(qos: .userInteractive).async { + let reconnectInputFilter = SSHPTYAttachReconnectInputFilter(enabled: filterEnabled) var buffer = [UInt8](repeating: 0, count: 8192) while true { let count = Darwin.read(STDIN_FILENO, &buffer, buffer.count) From 5202422397a8f9a05f3cb71dc729b8c16ee31793 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 13 Jun 2026 18:48:52 -0700 Subject: [PATCH 05/18] fix: pass through ambiguous ssh pty escape input --- CLI/SSHPTYAttachReconnectInputFilter.swift | 25 ++++++++++------------ 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/CLI/SSHPTYAttachReconnectInputFilter.swift b/CLI/SSHPTYAttachReconnectInputFilter.swift index 008692db066..15274857cc3 100644 --- a/CLI/SSHPTYAttachReconnectInputFilter.swift +++ b/CLI/SSHPTYAttachReconnectInputFilter.swift @@ -16,6 +16,7 @@ final class SSHPTYAttachReconnectInputFilter { private static let semicolon: UInt8 = 0x3B private static let questionMark: UInt8 = 0x3F private static let dollar: UInt8 = 0x24 + private static let maxPendingProbeBytes = 512 private var isFiltering: Bool private var pending = [UInt8]() @@ -83,7 +84,13 @@ final class SSHPTYAttachReconnectInputFilter { case .strip(let length): index += length case .incomplete: - pending.append(contentsOf: bytes[index...]) + let suffix = bytes[index...] + guard suffix.count <= Self.maxPendingProbeBytes else { + isFiltering = false + output.append(contentsOf: suffix) + return output + } + pending.append(contentsOf: suffix) return output case .passThrough: isFiltering = false @@ -109,7 +116,7 @@ final class SSHPTYAttachReconnectInputFilter { return .passThrough } guard start + 1 < bytes.count else { - return .incomplete + return .passThrough } switch bytes[start + 1] { @@ -139,7 +146,7 @@ final class SSHPTYAttachReconnectInputFilter { } guard cursor < bytes.count else { - return oscCommandCouldBecomeColorReply(command) ? .incomplete : .passThrough + return .passThrough } guard bytes[cursor] == semicolon else { return .passThrough @@ -164,17 +171,7 @@ final class SSHPTYAttachReconnectInputFilter { } cursor += 1 } - return .incomplete - } - - private static func oscCommandCouldBecomeColorReply(_ command: [UInt8]) -> Bool { - if command.isEmpty { - return true - } - if command == [0x31] { - return true - } - return command == [0x31, 0x30] || command == [0x31, 0x31] + return .passThrough } private static func csiProbeReplySequence(in bytes: [UInt8], at start: Int) -> SequenceMatch { From d32cbb0588f61fcf0d6b8677633aed4636638e86 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 13 Jun 2026 18:57:40 -0700 Subject: [PATCH 06/18] test: cover ssh pty reconnect filter boundaries --- CLI/SSHPTYAttachReconnectInputFilter.swift | 5 +-- cmux.xcodeproj/project.pbxproj | 6 ++++ ...SHPTYAttachReconnectInputFilterTests.swift | 35 +++++++++++++++++++ 3 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 cmuxTests/SSHPTYAttachReconnectInputFilterTests.swift diff --git a/CLI/SSHPTYAttachReconnectInputFilter.swift b/CLI/SSHPTYAttachReconnectInputFilter.swift index 15274857cc3..e7eb1e7ac70 100644 --- a/CLI/SSHPTYAttachReconnectInputFilter.swift +++ b/CLI/SSHPTYAttachReconnectInputFilter.swift @@ -99,6 +99,7 @@ final class SSHPTYAttachReconnectInputFilter { } } + isFiltering = false return output } @@ -171,7 +172,7 @@ final class SSHPTYAttachReconnectInputFilter { } cursor += 1 } - return .passThrough + return .incomplete } private static func csiProbeReplySequence(in bytes: [UInt8], at start: Int) -> SequenceMatch { @@ -229,7 +230,7 @@ final class SSHPTYAttachReconnectInputFilter { } else if written < 0 && errno == EINTR { continue } else { - throw CLIError(message: "ssh-pty-attach: bridge write failed") + throw POSIXError(.EIO) } } } diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 3956fa0b47a..5b5480a374e 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -655,7 +655,9 @@ F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */; }; C510C1E00000000000000002 /* SocketOperationTelemetry.swift in Sources */ = {isa = PBXBuildFile; fileRef = C510C1E00000000000000001 /* SocketOperationTelemetry.swift */; }; A5001230 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = A5001231 /* Sparkle */; }; + 6E8B6A69A468EF938B9AD8C2 /* SSHPTYAttachReconnectInputFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E4DB33FA55F1B13C60EFFC8 /* SSHPTYAttachReconnectInputFilter.swift */; }; B816E948EC5E42AF967648AC /* SSHPTYAttachReconnectInputFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E4DB33FA55F1B13C60EFFC8 /* SSHPTYAttachReconnectInputFilter.swift */; }; + B6285B71BA6F82AF8F945E00 /* SSHPTYAttachReconnectInputFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C4AD348F196FBBEE52D53A7 /* SSHPTYAttachReconnectInputFilterTests.swift */; }; E30780000000000000000012 /* SSHPTYAttachStartupCommandBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = E30780000000000000000011 /* SSHPTYAttachStartupCommandBuilder.swift */; }; F6355600A1B2C3D4E5F60718 /* SSHStartupSignalLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6355601A1B2C3D4E5F60718 /* SSHStartupSignalLifecycleTests.swift */; }; F20F85FC5900550685FA33AD /* StackAuth in Frameworks */ = {isa = PBXBuildFile; productRef = A8BD195031FC4B82B4354297 /* StackAuth */; }; @@ -1485,6 +1487,7 @@ F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketControlPasswordStoreTests.swift; sourceTree = ""; }; C510C1E00000000000000001 /* SocketOperationTelemetry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketOperationTelemetry.swift; sourceTree = ""; }; 1E4DB33FA55F1B13C60EFFC8 /* SSHPTYAttachReconnectInputFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHPTYAttachReconnectInputFilter.swift; sourceTree = ""; }; + 0C4AD348F196FBBEE52D53A7 /* SSHPTYAttachReconnectInputFilterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHPTYAttachReconnectInputFilterTests.swift; sourceTree = ""; }; E30780000000000000000011 /* SSHPTYAttachStartupCommandBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHPTYAttachStartupCommandBuilder.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 = ""; }; @@ -2454,6 +2457,7 @@ F6001001A1B2C3D4E5F60718 /* ShortcutUnbindingTests.swift */, F6100001A1B2C3D4E5F60718 /* WorkspaceRemoteConnectionTests.swift */, F6357301A1B2C3D4E5F60718 /* WorkspaceRemoteReconnectPolicyTests.swift */, + 0C4AD348F196FBBEE52D53A7 /* SSHPTYAttachReconnectInputFilterTests.swift */, F6355601A1B2C3D4E5F60718 /* SSHStartupSignalLifecycleTests.swift */, F6120001A1B2C3D4E5F60718 /* WorkspaceSSHFishShellTests.swift */, F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */, @@ -3778,6 +3782,8 @@ C0DE56010000000000000001 /* SidebarWorkspaceSelectionAnchorPolicyTests.swift in Sources */, 62270F3DCECB4787D789CCE3 /* SidebarWorkspaceSnapshotRefreshPolicyTests.swift in Sources */, F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */, + 6E8B6A69A468EF938B9AD8C2 /* SSHPTYAttachReconnectInputFilter.swift in Sources */, + B6285B71BA6F82AF8F945E00 /* SSHPTYAttachReconnectInputFilterTests.swift in Sources */, F6355600A1B2C3D4E5F60718 /* SSHStartupSignalLifecycleTests.swift in Sources */, 2BB56A710BB1FC50367E5BCF /* TabManagerSessionSnapshotTests.swift in Sources */, B6BF3DC98DB1495E57900199 /* TabManagerUnitTests.swift in Sources */, diff --git a/cmuxTests/SSHPTYAttachReconnectInputFilterTests.swift b/cmuxTests/SSHPTYAttachReconnectInputFilterTests.swift new file mode 100644 index 00000000000..6a9b235c8ca --- /dev/null +++ b/cmuxTests/SSHPTYAttachReconnectInputFilterTests.swift @@ -0,0 +1,35 @@ +import Foundation +import XCTest + +final class SSHPTYAttachReconnectInputFilterTests: XCTestCase { + func testDropsInitialProbeRepliesThenDisablesFiltering() { + let filter = SSHPTYAttachReconnectInputFilter(enabled: true) + XCTAssertEqual( + filter.filter(Data("\u{1B}[1;1R\u{1B}[?1;2c\u{1B}[?0u".utf8)), + Data() + ) + + let legitimateReply = Data("\u{1B}[2;2R".utf8) + XCTAssertEqual(filter.filter(legitimateReply), legitimateReply) + } + + func testBuffersRecognizedSplitOSCColorReplyWithinInitialDrain() { + let filter = SSHPTYAttachReconnectInputFilter(enabled: true) + XCTAssertEqual(filter.filter(Data("\u{1B}]11;rgb:e5e5/e9e9".utf8)), Data()) + + let normalInput = Data("printf keep\n".utf8) + XCTAssertEqual( + filter.filter(Data("/f0f0\u{1B}\\".utf8) + normalInput), + normalInput + ) + } + + func testPassesThroughAmbiguousEscapeInput() { + let filter = SSHPTYAttachReconnectInputFilter(enabled: true) + let escape = Data([0x1B]) + XCTAssertEqual(filter.filter(escape), escape) + + let keyInput = Data("\u{1B}[13;2u".utf8) + XCTAssertEqual(filter.filter(keyInput), keyInput) + } +} From 804a5e09c35ead6f46bbea1023e0d9862ba36ec8 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 13 Jun 2026 19:03:56 -0700 Subject: [PATCH 07/18] fix: keep reconnect probe filter active across reads --- CLI/SSHPTYAttachReconnectInputFilter.swift | 10 ++++- ...SHPTYAttachReconnectInputFilterTests.swift | 43 +++++++++++-------- 2 files changed, 33 insertions(+), 20 deletions(-) diff --git a/CLI/SSHPTYAttachReconnectInputFilter.swift b/CLI/SSHPTYAttachReconnectInputFilter.swift index e7eb1e7ac70..d96cb477f09 100644 --- a/CLI/SSHPTYAttachReconnectInputFilter.swift +++ b/CLI/SSHPTYAttachReconnectInputFilter.swift @@ -99,7 +99,6 @@ final class SSHPTYAttachReconnectInputFilter { } } - isFiltering = false return output } @@ -147,7 +146,7 @@ final class SSHPTYAttachReconnectInputFilter { } guard cursor < bytes.count else { - return .passThrough + return isOSCColorReplyCommandPrefix(command) ? .incomplete : .passThrough } guard bytes[cursor] == semicolon else { return .passThrough @@ -192,6 +191,13 @@ final class SSHPTYAttachReconnectInputFilter { return .incomplete } + private static func isOSCColorReplyCommandPrefix(_ command: [UInt8]) -> Bool { + command.isEmpty || + command == [0x31] || + command == [0x31, 0x30] || + command == [0x31, 0x31] + } + private static func shouldStripCSIReply(bytes: [UInt8], bodyStart: Int, finalIndex: Int) -> Bool { var parameterEnd = bodyStart while parameterEnd < finalIndex, bytes[parameterEnd] >= 0x30, bytes[parameterEnd] <= 0x3F { diff --git a/cmuxTests/SSHPTYAttachReconnectInputFilterTests.swift b/cmuxTests/SSHPTYAttachReconnectInputFilterTests.swift index 6a9b235c8ca..d17001db9b1 100644 --- a/cmuxTests/SSHPTYAttachReconnectInputFilterTests.swift +++ b/cmuxTests/SSHPTYAttachReconnectInputFilterTests.swift @@ -1,35 +1,42 @@ import Foundation -import XCTest +import Testing -final class SSHPTYAttachReconnectInputFilterTests: XCTestCase { - func testDropsInitialProbeRepliesThenDisablesFiltering() { +@Suite struct SSHPTYAttachReconnectInputFilterTests { + @Test func keepsFilteringAcrossProbeOnlyReadsUntilFirstNormalInput() { let filter = SSHPTYAttachReconnectInputFilter(enabled: true) - XCTAssertEqual( - filter.filter(Data("\u{1B}[1;1R\u{1B}[?1;2c\u{1B}[?0u".utf8)), - Data() - ) + #expect(filter.filter(Data("\u{1B}[1;1R\u{1B}[?1;2c\u{1B}[?0u".utf8)) == Data()) + #expect(filter.filter(Data("\u{1B}]11;rgb:e5e5/e9e9/f0f0\u{07}".utf8)) == Data()) - let legitimateReply = Data("\u{1B}[2;2R".utf8) - XCTAssertEqual(filter.filter(legitimateReply), legitimateReply) + let normalInput = Data("printf keep\n".utf8) + #expect(filter.filter(normalInput) == normalInput) + + let laterReply = Data("\u{1B}[2;2R".utf8) + #expect(filter.filter(laterReply) == laterReply) + } + + @Test func buffersRecognizedSplitOSCColorReplyWithinInitialDrain() { + let filter = SSHPTYAttachReconnectInputFilter(enabled: true) + #expect(filter.filter(Data("\u{1B}]11;rgb:e5e5/e9e9".utf8)) == Data()) + + let normalInput = Data("printf keep\n".utf8) + #expect(filter.filter(Data("/f0f0\u{1B}\\".utf8) + normalInput) == normalInput) } - func testBuffersRecognizedSplitOSCColorReplyWithinInitialDrain() { + @Test func buffersOSCColorReplySplitBeforeCommandSeparator() { let filter = SSHPTYAttachReconnectInputFilter(enabled: true) - XCTAssertEqual(filter.filter(Data("\u{1B}]11;rgb:e5e5/e9e9".utf8)), Data()) + #expect(filter.filter(Data("\u{1B}]1".utf8)) == Data()) + #expect(filter.filter(Data("1".utf8)) == Data()) let normalInput = Data("printf keep\n".utf8) - XCTAssertEqual( - filter.filter(Data("/f0f0\u{1B}\\".utf8) + normalInput), - normalInput - ) + #expect(filter.filter(Data(";rgb:e5e5/e9e9/f0f0\u{07}".utf8) + normalInput) == normalInput) } - func testPassesThroughAmbiguousEscapeInput() { + @Test func passesThroughAmbiguousEscapeInput() { let filter = SSHPTYAttachReconnectInputFilter(enabled: true) let escape = Data([0x1B]) - XCTAssertEqual(filter.filter(escape), escape) + #expect(filter.filter(escape) == escape) let keyInput = Data("\u{1B}[13;2u".utf8) - XCTAssertEqual(filter.filter(keyInput), keyInput) + #expect(filter.filter(keyInput) == keyInput) } } From 7b19617b041456356137bf34409ad1a5ec65f402 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 13 Jun 2026 19:10:12 -0700 Subject: [PATCH 08/18] fix: buffer reconnect probe escape prefix --- CLI/SSHPTYAttachReconnectInputFilter.swift | 3 ++- .../SSHPTYAttachReconnectInputFilterTests.swift | 14 ++++++++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/CLI/SSHPTYAttachReconnectInputFilter.swift b/CLI/SSHPTYAttachReconnectInputFilter.swift index d96cb477f09..8cf97dc7dc1 100644 --- a/CLI/SSHPTYAttachReconnectInputFilter.swift +++ b/CLI/SSHPTYAttachReconnectInputFilter.swift @@ -116,7 +116,8 @@ final class SSHPTYAttachReconnectInputFilter { return .passThrough } guard start + 1 < bytes.count else { - return .passThrough + // read() can split immediately after ESC; wait for one more byte before deciding. + return .incomplete } switch bytes[start + 1] { diff --git a/cmuxTests/SSHPTYAttachReconnectInputFilterTests.swift b/cmuxTests/SSHPTYAttachReconnectInputFilterTests.swift index d17001db9b1..bbd6ff61839 100644 --- a/cmuxTests/SSHPTYAttachReconnectInputFilterTests.swift +++ b/cmuxTests/SSHPTYAttachReconnectInputFilterTests.swift @@ -31,10 +31,20 @@ import Testing #expect(filter.filter(Data(";rgb:e5e5/e9e9/f0f0\u{07}".utf8) + normalInput) == normalInput) } - @Test func passesThroughAmbiguousEscapeInput() { + @Test func buffersInitialEscapeUntilProbeContinuationArrives() { let filter = SSHPTYAttachReconnectInputFilter(enabled: true) let escape = Data([0x1B]) - #expect(filter.filter(escape) == escape) + #expect(filter.filter(escape) == Data()) + + let normalInput = Data("printf keep\n".utf8) + #expect(filter.filter(Data("]11;rgb:e5e5/e9e9/f0f0\u{07}".utf8) + normalInput) == normalInput) + } + + @Test func passesThroughAmbiguousEscapeAfterNonProbeContinuation() { + let filter = SSHPTYAttachReconnectInputFilter(enabled: true) + let escape = Data([0x1B]) + #expect(filter.filter(escape) == Data()) + #expect(filter.filter(Data("x".utf8)) == Data("\u{1B}x".utf8)) let keyInput = Data("\u{1B}[13;2u".utf8) #expect(filter.filter(keyInput) == keyInput) From 850a0bf73b8e21cf53e82a81a17f422f83ac25a3 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 13 Jun 2026 19:16:33 -0700 Subject: [PATCH 09/18] fix: flush bare escape after reconnect drain --- CLI/SSHPTYAttachReconnectInputFilter.swift | 39 ++++++++++++++++++- ...SHPTYAttachReconnectInputFilterTests.swift | 11 ++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/CLI/SSHPTYAttachReconnectInputFilter.swift b/CLI/SSHPTYAttachReconnectInputFilter.swift index 8cf97dc7dc1..83c3b667c13 100644 --- a/CLI/SSHPTYAttachReconnectInputFilter.swift +++ b/CLI/SSHPTYAttachReconnectInputFilter.swift @@ -17,6 +17,8 @@ final class SSHPTYAttachReconnectInputFilter { private static let questionMark: UInt8 = 0x3F private static let dollar: UInt8 = 0x24 private static let maxPendingProbeBytes = 512 + // Terminal ESC disambiguation: enough for split probe replies, bounded for real Escape keys. + private static let ambiguousEscapeTimeoutMilliseconds: Int32 = 25 private var isFiltering: Bool private var pending = [UInt8]() @@ -32,7 +34,12 @@ final class SSHPTYAttachReconnectInputFilter { while true { let count = Darwin.read(STDIN_FILENO, &buffer, buffer.count) if count > 0 { - let input = reconnectInputFilter.filter(Data(buffer.prefix(count))) + var input = reconnectInputFilter.filter(Data(buffer.prefix(count))) + if input.isEmpty, + reconnectInputFilter.hasPendingAmbiguousEscape, + !Self.stdinHasReadyInput(timeoutMilliseconds: Self.ambiguousEscapeTimeoutMilliseconds) { + input = reconnectInputFilter.flushPendingAmbiguousEscape() + } guard !input.isEmpty else { continue } @@ -111,6 +118,19 @@ final class SSHPTYAttachReconnectInputFilter { return data } + var hasPendingAmbiguousEscape: Bool { + isFiltering && pending.count == 1 && pending[0] == Self.escape + } + + func flushPendingAmbiguousEscape() -> Data { + guard hasPendingAmbiguousEscape else { + return Data() + } + pending.removeAll(keepingCapacity: true) + isFiltering = false + return Data([Self.escape]) + } + private static func reconnectProbeReplySequence(in bytes: [UInt8], at start: Int) -> SequenceMatch { guard start < bytes.count, bytes[start] == escape else { return .passThrough @@ -224,6 +244,23 @@ final class SSHPTYAttachReconnectInputFilter { } } + private static func stdinHasReadyInput(timeoutMilliseconds: Int32) -> Bool { + var stdinPoll = pollfd(fd: STDIN_FILENO, events: Int16(POLLIN), revents: 0) + while true { + let result = Darwin.poll(&stdinPoll, 1, timeoutMilliseconds) + if result > 0 { + return (stdinPoll.revents & Int16(POLLIN)) != 0 + } + if result == 0 { + return false + } + if errno == EINTR { + continue + } + return false + } + } + private static func writeAll(fd: Int32, data: Data) throws { try data.withUnsafeBytes { rawBuffer in guard let base = rawBuffer.bindMemory(to: UInt8.self).baseAddress else { return } diff --git a/cmuxTests/SSHPTYAttachReconnectInputFilterTests.swift b/cmuxTests/SSHPTYAttachReconnectInputFilterTests.swift index bbd6ff61839..6b558302c4b 100644 --- a/cmuxTests/SSHPTYAttachReconnectInputFilterTests.swift +++ b/cmuxTests/SSHPTYAttachReconnectInputFilterTests.swift @@ -49,4 +49,15 @@ import Testing let keyInput = Data("\u{1B}[13;2u".utf8) #expect(filter.filter(keyInput) == keyInput) } + + @Test func flushesPendingBareEscapeWhenNoContinuationArrives() { + let filter = SSHPTYAttachReconnectInputFilter(enabled: true) + let escape = Data([0x1B]) + #expect(filter.filter(escape) == Data()) + #expect(filter.hasPendingAmbiguousEscape) + #expect(filter.flushPendingAmbiguousEscape() == escape) + + let keyInput = Data("\u{1B}[13;2u".utf8) + #expect(filter.filter(keyInput) == keyInput) + } } From 2d3069cd28de52c3c57b649a0ddeebec93dea5e7 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 13 Jun 2026 19:23:10 -0700 Subject: [PATCH 10/18] fix: bound reconnect probe reply drain --- CLI/SSHPTYAttachReconnectInputFilter.swift | 43 ++++++++++++++----- ...SHPTYAttachReconnectInputFilterTests.swift | 16 +++++-- 2 files changed, 45 insertions(+), 14 deletions(-) diff --git a/CLI/SSHPTYAttachReconnectInputFilter.swift b/CLI/SSHPTYAttachReconnectInputFilter.swift index 83c3b667c13..e2d5c761147 100644 --- a/CLI/SSHPTYAttachReconnectInputFilter.swift +++ b/CLI/SSHPTYAttachReconnectInputFilter.swift @@ -17,8 +17,8 @@ final class SSHPTYAttachReconnectInputFilter { private static let questionMark: UInt8 = 0x3F private static let dollar: UInt8 = 0x24 private static let maxPendingProbeBytes = 512 - // Terminal ESC disambiguation: enough for split probe replies, bounded for real Escape keys. - private static let ambiguousEscapeTimeoutMilliseconds: Int32 = 25 + // Terminal reconnect drain: enough for split queued replies, bounded for live input. + private static let initialProbeDrainTimeoutMilliseconds: Int32 = 25 private var isFiltering: Bool private var pending = [UInt8]() @@ -30,15 +30,24 @@ final class SSHPTYAttachReconnectInputFilter { static func startStdinPump(fd: Int32, filterEnabled: Bool) { DispatchQueue.global(qos: .userInteractive).async { let reconnectInputFilter = SSHPTYAttachReconnectInputFilter(enabled: filterEnabled) + if filterEnabled, + !Self.stdinHasReadyInput(timeoutMilliseconds: Self.initialProbeDrainTimeoutMilliseconds) { + reconnectInputFilter.stopFilteringAtProbeBoundary() + } var buffer = [UInt8](repeating: 0, count: 8192) while true { let count = Darwin.read(STDIN_FILENO, &buffer, buffer.count) if count > 0 { var input = reconnectInputFilter.filter(Data(buffer.prefix(count))) - if input.isEmpty, - reconnectInputFilter.hasPendingAmbiguousEscape, - !Self.stdinHasReadyInput(timeoutMilliseconds: Self.ambiguousEscapeTimeoutMilliseconds) { - input = reconnectInputFilter.flushPendingAmbiguousEscape() + if input.isEmpty { + if reconnectInputFilter.hasPendingInput { + if !Self.stdinHasReadyInput(timeoutMilliseconds: Self.initialProbeDrainTimeoutMilliseconds) { + input = reconnectInputFilter.flushPendingInput() + } + } else if reconnectInputFilter.isFilteringAtProbeBoundary, + !Self.stdinHasReadyInput(timeoutMilliseconds: Self.initialProbeDrainTimeoutMilliseconds) { + reconnectInputFilter.stopFilteringAtProbeBoundary() + } } guard !input.isEmpty else { continue @@ -118,17 +127,29 @@ final class SSHPTYAttachReconnectInputFilter { return data } - var hasPendingAmbiguousEscape: Bool { - isFiltering && pending.count == 1 && pending[0] == Self.escape + var hasPendingInput: Bool { + isFiltering && !pending.isEmpty } - func flushPendingAmbiguousEscape() -> Data { - guard hasPendingAmbiguousEscape else { + var isFilteringAtProbeBoundary: Bool { + isFiltering && pending.isEmpty + } + + func flushPendingInput() -> Data { + guard hasPendingInput else { return Data() } + let data = Data(pending) pending.removeAll(keepingCapacity: true) isFiltering = false - return Data([Self.escape]) + return data + } + + func stopFilteringAtProbeBoundary() { + guard isFilteringAtProbeBoundary else { + return + } + isFiltering = false } private static func reconnectProbeReplySequence(in bytes: [UInt8], at start: Int) -> SequenceMatch { diff --git a/cmuxTests/SSHPTYAttachReconnectInputFilterTests.swift b/cmuxTests/SSHPTYAttachReconnectInputFilterTests.swift index 6b558302c4b..9013c32bef8 100644 --- a/cmuxTests/SSHPTYAttachReconnectInputFilterTests.swift +++ b/cmuxTests/SSHPTYAttachReconnectInputFilterTests.swift @@ -14,6 +14,16 @@ import Testing #expect(filter.filter(laterReply) == laterReply) } + @Test func stopsFilteringAtIdleProbeBoundary() { + let filter = SSHPTYAttachReconnectInputFilter(enabled: true) + #expect(filter.filter(Data("\u{1B}[1;1R".utf8)) == Data()) + #expect(filter.isFilteringAtProbeBoundary) + + filter.stopFilteringAtProbeBoundary() + let liveReply = Data("\u{1B}[2;2R".utf8) + #expect(filter.filter(liveReply) == liveReply) + } + @Test func buffersRecognizedSplitOSCColorReplyWithinInitialDrain() { let filter = SSHPTYAttachReconnectInputFilter(enabled: true) #expect(filter.filter(Data("\u{1B}]11;rgb:e5e5/e9e9".utf8)) == Data()) @@ -50,12 +60,12 @@ import Testing #expect(filter.filter(keyInput) == keyInput) } - @Test func flushesPendingBareEscapeWhenNoContinuationArrives() { + @Test func flushesPendingInputWhenNoContinuationArrives() { let filter = SSHPTYAttachReconnectInputFilter(enabled: true) let escape = Data([0x1B]) #expect(filter.filter(escape) == Data()) - #expect(filter.hasPendingAmbiguousEscape) - #expect(filter.flushPendingAmbiguousEscape() == escape) + #expect(filter.hasPendingInput) + #expect(filter.flushPendingInput() == escape) let keyInput = Data("\u{1B}[13;2u".utf8) #expect(filter.filter(keyInput) == keyInput) From 78052cbfc0c42a8127b86c8ed48177a508a54996 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 13 Jun 2026 19:29:55 -0700 Subject: [PATCH 11/18] fix: limit reconnect probe filter to terminal stdin --- CLI/cmux.swift | 2 +- cmuxTests/CLISSHPTYAttachProbeReplyRegressionTests.swift | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 74e80c1f64f..5da9d58c105 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -10590,7 +10590,7 @@ struct CMUXCLI { ) defer { resizeSource.cancel() } - SSHPTYAttachReconnectInputFilter.startStdinPump(fd: fd, filterEnabled: requireExisting && command == nil) + SSHPTYAttachReconnectInputFilter.startStdinPump(fd: fd, filterEnabled: requireExisting && command == nil && isatty(STDIN_FILENO) == 1) var outputBuffer = [UInt8](repeating: 0, count: 32768) while true { diff --git a/cmuxTests/CLISSHPTYAttachProbeReplyRegressionTests.swift b/cmuxTests/CLISSHPTYAttachProbeReplyRegressionTests.swift index a20591c4e3d..af960fca7ea 100644 --- a/cmuxTests/CLISSHPTYAttachProbeReplyRegressionTests.swift +++ b/cmuxTests/CLISSHPTYAttachProbeReplyRegressionTests.swift @@ -3,7 +3,7 @@ import Foundation import XCTest extension CLINotifyProcessIntegrationRegressionTests { - func testSSHPTYAttachDropsQueuedTerminalProbeRepliesBeforeForwardingInput() throws { + func testSSHPTYAttachPreservesPipedProbeLikeInputBeforeForwardingInput() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("sshptyprobe") let listenerFD = try bindUnixSocket(at: socketPath) @@ -100,8 +100,8 @@ extension CLINotifyProcessIntegrationRegressionTests { let forwardedBridgeInput = bridgeInput.snapshot() XCTAssertEqual( String(data: forwardedBridgeInput, encoding: .utf8), - forwardedInput, - "Terminal probe replies queued during reconnect must not be forwarded into the remote PTY." + queuedProbeReplies + forwardedInput, + "Probe-like bytes from piped stdin must stay byte-for-byte because reconnect filtering only applies to terminal stdin." ) let methods = state.snapshot().compactMap { self.jsonObject($0)?["method"] as? String } XCTAssertEqual(methods, [ From 944128dbd90b8b4bd640c3ab33f2226abd5a8965 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 13 Jun 2026 19:39:15 -0700 Subject: [PATCH 12/18] test: clean reconnect filter policy findings --- CLI/SSHPTYAttachReconnectInputFilter.swift | 21 ++++++++------- ...achReconnectInputFilterSequenceMatch.swift | 5 ++++ cmux.xcodeproj/project.pbxproj | 26 ++++++++++++------- ...SHPTYAttachProbeReplyRegressionTests.swift | 21 +++++++-------- cmuxTests/MockBridgeInputCapture.swift | 10 +++---- 5 files changed, 47 insertions(+), 36 deletions(-) create mode 100644 CLI/SSHPTYAttachReconnectInputFilterSequenceMatch.swift diff --git a/CLI/SSHPTYAttachReconnectInputFilter.swift b/CLI/SSHPTYAttachReconnectInputFilter.swift index e2d5c761147..6f22a6d4f2d 100644 --- a/CLI/SSHPTYAttachReconnectInputFilter.swift +++ b/CLI/SSHPTYAttachReconnectInputFilter.swift @@ -2,12 +2,6 @@ import Darwin import Foundation final class SSHPTYAttachReconnectInputFilter { - private enum SequenceMatch { - case strip(length: Int) - case incomplete - case passThrough - } - private static let escape: UInt8 = 0x1B private static let bell: UInt8 = 0x07 private static let leftBracket: UInt8 = 0x5B @@ -152,7 +146,10 @@ final class SSHPTYAttachReconnectInputFilter { isFiltering = false } - private static func reconnectProbeReplySequence(in bytes: [UInt8], at start: Int) -> SequenceMatch { + private static func reconnectProbeReplySequence( + in bytes: [UInt8], + at start: Int + ) -> SSHPTYAttachReconnectInputFilterSequenceMatch { guard start < bytes.count, bytes[start] == escape else { return .passThrough } @@ -171,7 +168,10 @@ final class SSHPTYAttachReconnectInputFilter { } } - private static func oscColorReplySequence(in bytes: [UInt8], at start: Int) -> SequenceMatch { + private static func oscColorReplySequence( + in bytes: [UInt8], + at start: Int + ) -> SSHPTYAttachReconnectInputFilterSequenceMatch { var cursor = start + 2 var command = [UInt8]() @@ -216,7 +216,10 @@ final class SSHPTYAttachReconnectInputFilter { return .incomplete } - private static func csiProbeReplySequence(in bytes: [UInt8], at start: Int) -> SequenceMatch { + private static func csiProbeReplySequence( + in bytes: [UInt8], + at start: Int + ) -> SSHPTYAttachReconnectInputFilterSequenceMatch { var cursor = start + 2 while cursor < bytes.count { let byte = bytes[cursor] diff --git a/CLI/SSHPTYAttachReconnectInputFilterSequenceMatch.swift b/CLI/SSHPTYAttachReconnectInputFilterSequenceMatch.swift new file mode 100644 index 00000000000..229881a90a3 --- /dev/null +++ b/CLI/SSHPTYAttachReconnectInputFilterSequenceMatch.swift @@ -0,0 +1,5 @@ +enum SSHPTYAttachReconnectInputFilterSequenceMatch { + case strip(length: Int) + case incomplete + case passThrough +} diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 5b5480a374e..abddf38bf6d 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -657,6 +657,8 @@ A5001230 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = A5001231 /* Sparkle */; }; 6E8B6A69A468EF938B9AD8C2 /* SSHPTYAttachReconnectInputFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E4DB33FA55F1B13C60EFFC8 /* SSHPTYAttachReconnectInputFilter.swift */; }; B816E948EC5E42AF967648AC /* SSHPTYAttachReconnectInputFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E4DB33FA55F1B13C60EFFC8 /* SSHPTYAttachReconnectInputFilter.swift */; }; + E60610100000000000000002 /* SSHPTYAttachReconnectInputFilterSequenceMatch.swift in Sources */ = {isa = PBXBuildFile; fileRef = E60610100000000000000001 /* SSHPTYAttachReconnectInputFilterSequenceMatch.swift */; }; + E60610100000000000000003 /* SSHPTYAttachReconnectInputFilterSequenceMatch.swift in Sources */ = {isa = PBXBuildFile; fileRef = E60610100000000000000001 /* SSHPTYAttachReconnectInputFilterSequenceMatch.swift */; }; B6285B71BA6F82AF8F945E00 /* SSHPTYAttachReconnectInputFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C4AD348F196FBBEE52D53A7 /* SSHPTYAttachReconnectInputFilterTests.swift */; }; E30780000000000000000012 /* SSHPTYAttachStartupCommandBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = E30780000000000000000011 /* SSHPTYAttachStartupCommandBuilder.swift */; }; F6355600A1B2C3D4E5F60718 /* SSHStartupSignalLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6355601A1B2C3D4E5F60718 /* SSHStartupSignalLifecycleTests.swift */; }; @@ -1487,6 +1489,7 @@ F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketControlPasswordStoreTests.swift; sourceTree = ""; }; C510C1E00000000000000001 /* SocketOperationTelemetry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketOperationTelemetry.swift; sourceTree = ""; }; 1E4DB33FA55F1B13C60EFFC8 /* SSHPTYAttachReconnectInputFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHPTYAttachReconnectInputFilter.swift; sourceTree = ""; }; + E60610100000000000000001 /* SSHPTYAttachReconnectInputFilterSequenceMatch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHPTYAttachReconnectInputFilterSequenceMatch.swift; sourceTree = ""; }; 0C4AD348F196FBBEE52D53A7 /* SSHPTYAttachReconnectInputFilterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHPTYAttachReconnectInputFilterTests.swift; sourceTree = ""; }; E30780000000000000000011 /* SSHPTYAttachStartupCommandBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHPTYAttachStartupCommandBuilder.swift; sourceTree = ""; }; F6355601A1B2C3D4E5F60718 /* SSHStartupSignalLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHStartupSignalLifecycleTests.swift; sourceTree = ""; }; @@ -2374,6 +2377,7 @@ children = ( B9000001A1B2C3D4E5F60719 /* cmux.swift */, 1E4DB33FA55F1B13C60EFFC8 /* SSHPTYAttachReconnectInputFilter.swift */, + E60610100000000000000001 /* SSHPTYAttachReconnectInputFilterSequenceMatch.swift */, C0DECAFE0000000000000002 /* CodexTeamsApprovalBridge.swift */, FEEDC1A50000000000000002 /* FeedEventClassifier.swift */, B900004BA1B2C3D4E5F60719 /* CLISocketPathResolver.swift */, @@ -3557,12 +3561,13 @@ C0DECAFE0000000000000001 /* CodexTeamsApprovalBridge.swift in Sources */, FEEDC1A50000000000000001 /* FeedEventClassifier.swift in Sources */, C0DEF0B10000000000000003 /* JSONCParser.swift in Sources */, - B9000028A1B2C3D4E5F60719 /* RemoteInteractiveShellBootstrapBuilder.swift in Sources */, - B9000027A1B2C3D4E5F60719 /* RemoteRelayZshBootstrap.swift in Sources */, - 5E2701020000000000000003 /* SentryEventScrubber.swift in Sources */, - C510C1E00000000000000002 /* SocketOperationTelemetry.swift in Sources */, - B816E948EC5E42AF967648AC /* SSHPTYAttachReconnectInputFilter.swift in Sources */, - ); + B9000028A1B2C3D4E5F60719 /* RemoteInteractiveShellBootstrapBuilder.swift in Sources */, + B9000027A1B2C3D4E5F60719 /* RemoteRelayZshBootstrap.swift in Sources */, + 5E2701020000000000000003 /* SentryEventScrubber.swift in Sources */, + C510C1E00000000000000002 /* SocketOperationTelemetry.swift in Sources */, + B816E948EC5E42AF967648AC /* SSHPTYAttachReconnectInputFilter.swift in Sources */, + E60610100000000000000002 /* SSHPTYAttachReconnectInputFilterSequenceMatch.swift in Sources */, + ); runOnlyForDeploymentPostprocessing = 0; }; D1320AA0D1320AA0D1320AB0 /* Sources */ = { @@ -3780,10 +3785,11 @@ C9A57303C9A57303C9A57303 /* SidebarWorkspaceGroupHeaderMetricsTests.swift in Sources */, C9A57505C9A57505C9A57505 /* SidebarWorkspaceScrollLayoutTests.swift in Sources */, C0DE56010000000000000001 /* SidebarWorkspaceSelectionAnchorPolicyTests.swift in Sources */, - 62270F3DCECB4787D789CCE3 /* SidebarWorkspaceSnapshotRefreshPolicyTests.swift in Sources */, - F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */, - 6E8B6A69A468EF938B9AD8C2 /* SSHPTYAttachReconnectInputFilter.swift in Sources */, - B6285B71BA6F82AF8F945E00 /* SSHPTYAttachReconnectInputFilterTests.swift in Sources */, + 62270F3DCECB4787D789CCE3 /* SidebarWorkspaceSnapshotRefreshPolicyTests.swift in Sources */, + F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */, + 6E8B6A69A468EF938B9AD8C2 /* SSHPTYAttachReconnectInputFilter.swift in Sources */, + E60610100000000000000003 /* SSHPTYAttachReconnectInputFilterSequenceMatch.swift in Sources */, + B6285B71BA6F82AF8F945E00 /* SSHPTYAttachReconnectInputFilterTests.swift in Sources */, F6355600A1B2C3D4E5F60718 /* SSHStartupSignalLifecycleTests.swift in Sources */, 2BB56A710BB1FC50367E5BCF /* TabManagerSessionSnapshotTests.swift in Sources */, B6BF3DC98DB1495E57900199 /* TabManagerUnitTests.swift in Sources */, diff --git a/cmuxTests/CLISSHPTYAttachProbeReplyRegressionTests.swift b/cmuxTests/CLISSHPTYAttachProbeReplyRegressionTests.swift index af960fca7ea..639b6d827e5 100644 --- a/cmuxTests/CLISSHPTYAttachProbeReplyRegressionTests.swift +++ b/cmuxTests/CLISSHPTYAttachProbeReplyRegressionTests.swift @@ -1,6 +1,6 @@ import Darwin import Foundation -import XCTest +import Testing extension CLINotifyProcessIntegrationRegressionTests { func testSSHPTYAttachPreservesPipedProbeLikeInputBeforeForwardingInput() throws { @@ -29,7 +29,7 @@ extension CLINotifyProcessIntegrationRegressionTests { } switch method { case "workspace.remote.pty_bridge": - XCTAssertEqual((payload["params"] as? [String: Any])?["require_existing"] as? Bool, true) + #expect((payload["params"] as? [String: Any])?["require_existing"] as? Bool == true) return self.v2Response( id: id, ok: true, @@ -93,18 +93,15 @@ extension CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [socketHandled, bridgeHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertTrue(result.stderr.isEmpty, result.stderr) + wait(for: [socketHandled], timeout: 5) + #expect(bridgeHandled.wait(timeout: .now() + 5) == .success) + #expect(!result.timedOut) + #expect(result.status == 0) + #expect(result.stderr.isEmpty) let forwardedBridgeInput = bridgeInput.snapshot() - XCTAssertEqual( - String(data: forwardedBridgeInput, encoding: .utf8), - queuedProbeReplies + forwardedInput, - "Probe-like bytes from piped stdin must stay byte-for-byte because reconnect filtering only applies to terminal stdin." - ) + #expect(String(data: forwardedBridgeInput, encoding: .utf8) == queuedProbeReplies + forwardedInput) let methods = state.snapshot().compactMap { self.jsonObject($0)?["method"] as? String } - XCTAssertEqual(methods, [ + #expect(methods == [ "workspace.remote.pty_bridge", "workspace.remote.pty_sessions", "workspace.remote.pty_attach_end", diff --git a/cmuxTests/MockBridgeInputCapture.swift b/cmuxTests/MockBridgeInputCapture.swift index 91c393fa119..0c093c9d0b3 100644 --- a/cmuxTests/MockBridgeInputCapture.swift +++ b/cmuxTests/MockBridgeInputCapture.swift @@ -1,8 +1,7 @@ import Darwin import Foundation -import XCTest -// Protects test capture bytes written from a bridge thread and read by XCTest assertions. +// Protects test capture bytes written from a bridge thread and read by assertions. final class MockBridgeInputCapture: @unchecked Sendable { private let lock = NSLock() private var input = Data() @@ -25,10 +24,11 @@ extension CLINotifyProcessIntegrationRegressionTests { func startBridgeReadyCapturingInputUntilEOF( listenerFD: Int32, capture: MockBridgeInputCapture - ) -> XCTestExpectation { - let handled = expectation(description: "pty bridge ready input capture server handled") + ) -> DispatchGroup { + let handled = DispatchGroup() + handled.enter() DispatchQueue.global(qos: .userInitiated).async { - defer { handled.fulfill() } + defer { handled.leave() } var clientAddr = sockaddr_in() var clientAddrLen = socklen_t(MemoryLayout.size) From 798a1052679b4c0931dd43f9f514b8f37bb0ce07 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 13 Jun 2026 20:27:55 -0700 Subject: [PATCH 13/18] fix: filter OSC 12 reconnect probe replies --- CLI/SSHPTYAttachReconnectInputFilter.swift | 5 +++-- cmuxTests/CLISSHPTYAttachProbeReplyRegressionTests.swift | 1 + cmuxTests/SSHPTYAttachReconnectInputFilterTests.swift | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/CLI/SSHPTYAttachReconnectInputFilter.swift b/CLI/SSHPTYAttachReconnectInputFilter.swift index 6f22a6d4f2d..c9b738b4d22 100644 --- a/CLI/SSHPTYAttachReconnectInputFilter.swift +++ b/CLI/SSHPTYAttachReconnectInputFilter.swift @@ -193,7 +193,7 @@ final class SSHPTYAttachReconnectInputFilter { guard bytes[cursor] == semicolon else { return .passThrough } - guard command == [0x31, 0x30] || command == [0x31, 0x31] else { + guard command == [0x31, 0x30] || command == [0x31, 0x31] || command == [0x31, 0x32] else { return .passThrough } @@ -240,7 +240,8 @@ final class SSHPTYAttachReconnectInputFilter { command.isEmpty || command == [0x31] || command == [0x31, 0x30] || - command == [0x31, 0x31] + command == [0x31, 0x31] || + command == [0x31, 0x32] } private static func shouldStripCSIReply(bytes: [UInt8], bodyStart: Int, finalIndex: Int) -> Bool { diff --git a/cmuxTests/CLISSHPTYAttachProbeReplyRegressionTests.swift b/cmuxTests/CLISSHPTYAttachProbeReplyRegressionTests.swift index 639b6d827e5..38a784c2c07 100644 --- a/cmuxTests/CLISSHPTYAttachProbeReplyRegressionTests.swift +++ b/cmuxTests/CLISSHPTYAttachProbeReplyRegressionTests.swift @@ -74,6 +74,7 @@ extension CLINotifyProcessIntegrationRegressionTests { let queuedProbeReplies = "\u{1B}]11;rgb:e5e5/e9e9/f0f0\u{1B}\\" + "\u{1B}]10;rgb:4141/4848/5858\u{07}" + + "\u{1B}]12;rgb:ffff/ffff/ffff\u{07}" + "\u{1B}[1;1R" + "\u{1B}[?1;2c" + "\u{1B}[?0u" + diff --git a/cmuxTests/SSHPTYAttachReconnectInputFilterTests.swift b/cmuxTests/SSHPTYAttachReconnectInputFilterTests.swift index 9013c32bef8..1dde3a7a44d 100644 --- a/cmuxTests/SSHPTYAttachReconnectInputFilterTests.swift +++ b/cmuxTests/SSHPTYAttachReconnectInputFilterTests.swift @@ -6,6 +6,7 @@ import Testing let filter = SSHPTYAttachReconnectInputFilter(enabled: true) #expect(filter.filter(Data("\u{1B}[1;1R\u{1B}[?1;2c\u{1B}[?0u".utf8)) == Data()) #expect(filter.filter(Data("\u{1B}]11;rgb:e5e5/e9e9/f0f0\u{07}".utf8)) == Data()) + #expect(filter.filter(Data("\u{1B}]12;rgb:ffff/ffff/ffff\u{07}".utf8)) == Data()) let normalInput = Data("printf keep\n".utf8) #expect(filter.filter(normalInput) == normalInput) @@ -35,7 +36,7 @@ import Testing @Test func buffersOSCColorReplySplitBeforeCommandSeparator() { let filter = SSHPTYAttachReconnectInputFilter(enabled: true) #expect(filter.filter(Data("\u{1B}]1".utf8)) == Data()) - #expect(filter.filter(Data("1".utf8)) == Data()) + #expect(filter.filter(Data("2".utf8)) == Data()) let normalInput = Data("printf keep\n".utf8) #expect(filter.filter(Data(";rgb:e5e5/e9e9/f0f0\u{07}".utf8) + normalInput) == normalInput) From a82728886e47e7921ebd778e0e588c7df66ef8ab Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 13 Jun 2026 21:04:20 -0700 Subject: [PATCH 14/18] fix: drain reconnect probes before relaying output --- .github/swift-file-length-budget.tsv | 2 +- CLI/SSHPTYAttachReconnectInputFilter.swift | 98 +++++++++++++--------- CLI/cmux.swift | 9 +- 3 files changed, 67 insertions(+), 42 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 6f5507b9139..1a6cb8654b6 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -1,7 +1,7 @@ # cmux-owned Swift file length budget. # Format: max_linesrelative path # Reduce counts as files shrink. CI fails if tracked files exceed this budget. -33266 CLI/cmux.swift +33273 CLI/cmux.swift 19197 Sources/ContentView.swift 17899 Sources/AppDelegate.swift 14629 Sources/TerminalController.swift diff --git a/CLI/SSHPTYAttachReconnectInputFilter.swift b/CLI/SSHPTYAttachReconnectInputFilter.swift index c9b738b4d22..8e081008dd0 100644 --- a/CLI/SSHPTYAttachReconnectInputFilter.swift +++ b/CLI/SSHPTYAttachReconnectInputFilter.swift @@ -21,53 +21,71 @@ final class SSHPTYAttachReconnectInputFilter { isFiltering = enabled } - static func startStdinPump(fd: Int32, filterEnabled: Bool) { + static func startStdinPump(fd: Int32, filterEnabled: Bool) throws { + if filterEnabled { + // Must complete before bridge output is relayed; after that, probe replies are live input. + try drainQueuedProbeReplies(fd: fd) + } DispatchQueue.global(qos: .userInteractive).async { - let reconnectInputFilter = SSHPTYAttachReconnectInputFilter(enabled: filterEnabled) - if filterEnabled, - !Self.stdinHasReadyInput(timeoutMilliseconds: Self.initialProbeDrainTimeoutMilliseconds) { + pumpStdin(fd: fd) + } + } + + private static func drainQueuedProbeReplies(fd: Int32) throws { + let reconnectInputFilter = SSHPTYAttachReconnectInputFilter(enabled: true) + var buffer = [UInt8](repeating: 0, count: 8192) + while true { + guard stdinHasReadyInput(timeoutMilliseconds: initialProbeDrainTimeoutMilliseconds) else { + let input = reconnectInputFilter.flushPendingInput() + if !input.isEmpty { + try writeAll(fd: fd, data: input) + } reconnectInputFilter.stopFilteringAtProbeBoundary() + return } - var buffer = [UInt8](repeating: 0, count: 8192) - while true { - let count = Darwin.read(STDIN_FILENO, &buffer, buffer.count) - if count > 0 { - var input = reconnectInputFilter.filter(Data(buffer.prefix(count))) - if input.isEmpty { - if reconnectInputFilter.hasPendingInput { - if !Self.stdinHasReadyInput(timeoutMilliseconds: Self.initialProbeDrainTimeoutMilliseconds) { - input = reconnectInputFilter.flushPendingInput() - } - } else if reconnectInputFilter.isFilteringAtProbeBoundary, - !Self.stdinHasReadyInput(timeoutMilliseconds: Self.initialProbeDrainTimeoutMilliseconds) { - reconnectInputFilter.stopFilteringAtProbeBoundary() - } - } - guard !input.isEmpty else { - continue - } - do { - try Self.writeAll(fd: fd, data: input) - } catch { - _ = shutdown(fd, SHUT_WR) - return - } - } else if count == 0 { - let input = reconnectInputFilter.finish() - if !input.isEmpty { - do { - try Self.writeAll(fd: fd, data: input) - } catch { - _ = shutdown(fd, SHUT_WR) - return - } - } - _ = shutdown(fd, SHUT_WR) + + let count = Darwin.read(STDIN_FILENO, &buffer, buffer.count) + if count > 0 { + let input = reconnectInputFilter.filter(Data(buffer.prefix(count))) + if !input.isEmpty { + try writeAll(fd: fd, data: input) + } + if !reconnectInputFilter.hasPendingInput, + !reconnectInputFilter.isFilteringAtProbeBoundary { return - } else if errno != EINTR { + } + } else if count == 0 { + let input = reconnectInputFilter.finish() + if !input.isEmpty { + try writeAll(fd: fd, data: input) + } + _ = shutdown(fd, SHUT_WR) + return + } else if errno != EINTR { + _ = shutdown(fd, SHUT_WR) + return + } + } + } + + private static func pumpStdin(fd: Int32) { + var buffer = [UInt8](repeating: 0, count: 8192) + while true { + let count = Darwin.read(STDIN_FILENO, &buffer, buffer.count) + if count > 0 { + let input = Data(buffer.prefix(count)) + do { + try Self.writeAll(fd: fd, data: input) + } catch { _ = shutdown(fd, SHUT_WR) return } + } else if count == 0 { + _ = shutdown(fd, SHUT_WR) + return + } else if errno != EINTR { + _ = shutdown(fd, SHUT_WR) + return } } } diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 5da9d58c105..0850074cf18 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -10590,7 +10590,14 @@ struct CMUXCLI { ) defer { resizeSource.cancel() } - SSHPTYAttachReconnectInputFilter.startStdinPump(fd: fd, filterEnabled: requireExisting && command == nil && isatty(STDIN_FILENO) == 1) + do { + try SSHPTYAttachReconnectInputFilter.startStdinPump( + fd: fd, + filterEnabled: requireExisting && command == nil && isatty(STDIN_FILENO) == 1 + ) + } catch { + throw CLIError(message: "ssh-pty-attach: bridge write failed") + } var outputBuffer = [UInt8](repeating: 0, count: 32768) while true { From 4e6ef6c900bed167f6ec92fb4768e4392869f884 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 13 Jun 2026 23:07:04 -0700 Subject: [PATCH 15/18] fix: keep reconnect probe filtering until bridge output --- .github/swift-file-length-budget.tsv | 2 +- CLI/SSHPTYAttachReconnectInputFilter.swift | 139 +++++++++++++----- CLI/cmux.swift | 5 +- ...SHPTYAttachReconnectInputFilterTests.swift | 99 ++++++++++++- 4 files changed, 208 insertions(+), 37 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 055f78250fc..a1f34e588df 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -1,7 +1,7 @@ # cmux-owned Swift file length budget. # Format: max_linesrelative path # Reduce counts as files shrink. CI fails if tracked files exceed this budget. -33442 CLI/cmux.swift +33445 CLI/cmux.swift 19204 Sources/ContentView.swift 17911 Sources/AppDelegate.swift 14610 Sources/TerminalController.swift diff --git a/CLI/SSHPTYAttachReconnectInputFilter.swift b/CLI/SSHPTYAttachReconnectInputFilter.swift index 8e081008dd0..de5faf85f20 100644 --- a/CLI/SSHPTYAttachReconnectInputFilter.swift +++ b/CLI/SSHPTYAttachReconnectInputFilter.swift @@ -11,8 +11,8 @@ final class SSHPTYAttachReconnectInputFilter { private static let questionMark: UInt8 = 0x3F private static let dollar: UInt8 = 0x24 private static let maxPendingProbeBytes = 512 - // Terminal reconnect drain: enough for split queued replies, bounded for live input. - private static let initialProbeDrainTimeoutMilliseconds: Int32 = 25 + // Terminal ESC disambiguation: bounded so a literal Escape key is not held indefinitely. + private static let pendingProbeContinuationTimeoutMilliseconds: Int32 = 25 private var isFiltering: Bool private var pending = [UInt8]() @@ -21,30 +21,39 @@ final class SSHPTYAttachReconnectInputFilter { isFiltering = enabled } - static func startStdinPump(fd: Int32, filterEnabled: Bool) throws { - if filterEnabled { - // Must complete before bridge output is relayed; after that, probe replies are live input. - try drainQueuedProbeReplies(fd: fd) - } + private init(state: FilterState) { + isFiltering = state.isFiltering + pending = state.pending + } + + @discardableResult + static func startStdinPump( + fd: Int32, + inputFD: Int32 = STDIN_FILENO, + filterEnabled: Bool + ) throws -> SSHPTYAttachReconnectInputFilterControl? { + let filterState = filterEnabled ? try drainQueuedProbeReplies(inputFD: inputFD, fd: fd) : nil + let filterControl = filterState == nil ? nil : SSHPTYAttachReconnectInputFilterControl() DispatchQueue.global(qos: .userInteractive).async { - pumpStdin(fd: fd) + pumpStdin( + inputFD: inputFD, + fd: fd, + reconnectInputFilterState: filterState, + filterControl: filterControl + ) } + return filterControl } - private static func drainQueuedProbeReplies(fd: Int32) throws { + private static func drainQueuedProbeReplies(inputFD: Int32, fd: Int32) throws -> FilterState? { let reconnectInputFilter = SSHPTYAttachReconnectInputFilter(enabled: true) var buffer = [UInt8](repeating: 0, count: 8192) while true { - guard stdinHasReadyInput(timeoutMilliseconds: initialProbeDrainTimeoutMilliseconds) else { - let input = reconnectInputFilter.flushPendingInput() - if !input.isEmpty { - try writeAll(fd: fd, data: input) - } - reconnectInputFilter.stopFilteringAtProbeBoundary() - return + guard stdinHasReadyInput(inputFD: inputFD, timeoutMilliseconds: 0) else { + return reconnectInputFilter.snapshotForPump() } - let count = Darwin.read(STDIN_FILENO, &buffer, buffer.count) + let count = Darwin.read(inputFD, &buffer, buffer.count) if count > 0 { let input = reconnectInputFilter.filter(Data(buffer.prefix(count))) if !input.isEmpty { @@ -52,7 +61,7 @@ final class SSHPTYAttachReconnectInputFilter { } if !reconnectInputFilter.hasPendingInput, !reconnectInputFilter.isFilteringAtProbeBoundary { - return + return nil } } else if count == 0 { let input = reconnectInputFilter.finish() @@ -60,24 +69,59 @@ final class SSHPTYAttachReconnectInputFilter { try writeAll(fd: fd, data: input) } _ = shutdown(fd, SHUT_WR) - return + return nil } else if errno != EINTR { _ = shutdown(fd, SHUT_WR) - return + return nil } } } - private static func pumpStdin(fd: Int32) { + private static func pumpStdin( + inputFD: Int32, + fd: Int32, + reconnectInputFilterState: FilterState?, + filterControl: SSHPTYAttachReconnectInputFilterControl? + ) { + var reconnectInputFilter = reconnectInputFilterState.map(SSHPTYAttachReconnectInputFilter.init(state:)) var buffer = [UInt8](repeating: 0, count: 8192) + + func writeOrShutdown(_ input: Data) -> Bool { + guard !input.isEmpty else { + return true + } + do { + try Self.writeAll(fd: fd, data: input) + return true + } catch { + _ = shutdown(fd, SHUT_WR) + return false + } + } + while true { - let count = Darwin.read(STDIN_FILENO, &buffer, buffer.count) + if let filter = reconnectInputFilter { + if filterControl?.shouldFilterReconnectInput == false { + guard writeOrShutdown(filter.stopFiltering()) else { + return + } + reconnectInputFilter = nil + } else if filter.hasPendingInput, + !stdinHasReadyInput( + inputFD: inputFD, + timeoutMilliseconds: pendingProbeContinuationTimeoutMilliseconds + ) { + guard writeOrShutdown(filter.flushPendingInput()) else { + return + } + } + } + + let count = Darwin.read(inputFD, &buffer, buffer.count) if count > 0 { - let input = Data(buffer.prefix(count)) - do { - try Self.writeAll(fd: fd, data: input) - } catch { - _ = shutdown(fd, SHUT_WR) + let rawInput = Data(buffer.prefix(count)) + let input = reconnectInputFilter?.filter(rawInput) ?? rawInput + guard writeOrShutdown(input) else { return } } else if count == 0 { @@ -139,6 +183,12 @@ final class SSHPTYAttachReconnectInputFilter { return data } + func stopFiltering() -> Data { + let input = finish() + isFiltering = false + return input + } + var hasPendingInput: Bool { isFiltering && !pending.isEmpty } @@ -157,11 +207,11 @@ final class SSHPTYAttachReconnectInputFilter { return data } - func stopFilteringAtProbeBoundary() { - guard isFilteringAtProbeBoundary else { - return + private func snapshotForPump() -> FilterState? { + guard isFiltering else { + return nil } - isFiltering = false + return FilterState(isFiltering: isFiltering, pending: pending) } private static func reconnectProbeReplySequence( @@ -287,8 +337,8 @@ final class SSHPTYAttachReconnectInputFilter { } } - private static func stdinHasReadyInput(timeoutMilliseconds: Int32) -> Bool { - var stdinPoll = pollfd(fd: STDIN_FILENO, events: Int16(POLLIN), revents: 0) + private static func stdinHasReadyInput(inputFD: Int32, timeoutMilliseconds: Int32) -> Bool { + var stdinPoll = pollfd(fd: inputFD, events: Int16(POLLIN), revents: 0) while true { let result = Darwin.poll(&stdinPoll, 1, timeoutMilliseconds) if result > 0 { @@ -304,6 +354,11 @@ final class SSHPTYAttachReconnectInputFilter { } } + private struct FilterState: Sendable { + let isFiltering: Bool + let pending: [UInt8] + } + private static func writeAll(fd: Int32, data: Data) throws { try data.withUnsafeBytes { rawBuffer in guard let base = rawBuffer.bindMemory(to: UInt8.self).baseAddress else { return } @@ -323,3 +378,21 @@ final class SSHPTYAttachReconnectInputFilter { } } } + +// Shared between the bridge-output thread and the stdin pump; the flag is NSLock-guarded. +final class SSHPTYAttachReconnectInputFilterControl: @unchecked Sendable { + private let lock = NSLock() + private var stopped = false + + var shouldFilterReconnectInput: Bool { + lock.lock() + defer { lock.unlock() } + return !stopped + } + + func stopFiltering() { + lock.lock() + stopped = true + lock.unlock() + } +} diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 8bc60f20f3c..9c53c66d371 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -10726,8 +10726,9 @@ struct CMUXCLI { ) defer { resizeSource.cancel() } + var reconnectInputFilterControl: SSHPTYAttachReconnectInputFilterControl? do { - try SSHPTYAttachReconnectInputFilter.startStdinPump( + reconnectInputFilterControl = try SSHPTYAttachReconnectInputFilter.startStdinPump( fd: fd, filterEnabled: requireExisting && command == nil && isatty(STDIN_FILENO) == 1 ) @@ -10739,6 +10740,8 @@ struct CMUXCLI { while true { let count = Darwin.read(fd, &outputBuffer, outputBuffer.count) if count > 0 { + reconnectInputFilterControl?.stopFiltering() + reconnectInputFilterControl = nil FileHandle.standardOutput.write(Data(outputBuffer.prefix(count))) } else if count == 0 { resizeSource.cancel() diff --git a/cmuxTests/SSHPTYAttachReconnectInputFilterTests.swift b/cmuxTests/SSHPTYAttachReconnectInputFilterTests.swift index 1dde3a7a44d..8a231f62a62 100644 --- a/cmuxTests/SSHPTYAttachReconnectInputFilterTests.swift +++ b/cmuxTests/SSHPTYAttachReconnectInputFilterTests.swift @@ -1,3 +1,4 @@ +import Darwin import Foundation import Testing @@ -15,12 +16,24 @@ import Testing #expect(filter.filter(laterReply) == laterReply) } - @Test func stopsFilteringAtIdleProbeBoundary() { + @Test func keepsFilteringAtIdleProbeBoundaryUntilNormalInput() { let filter = SSHPTYAttachReconnectInputFilter(enabled: true) #expect(filter.filter(Data("\u{1B}[1;1R".utf8)) == Data()) #expect(filter.isFilteringAtProbeBoundary) - filter.stopFilteringAtProbeBoundary() + let liveReply = Data("\u{1B}[2;2R".utf8) + #expect(filter.filter(liveReply) == Data()) + + let normalInput = Data("printf keep\n".utf8) + #expect(filter.filter(normalInput) == normalInput) + #expect(filter.filter(liveReply) == liveReply) + } + + @Test func stopFilteringPreservesLaterProbeLikeInput() { + let filter = SSHPTYAttachReconnectInputFilter(enabled: true) + #expect(filter.filter(Data("\u{1B}[1;1R".utf8)) == Data()) + #expect(filter.stopFiltering() == Data()) + let liveReply = Data("\u{1B}[2;2R".utf8) #expect(filter.filter(liveReply) == liveReply) } @@ -71,4 +84,86 @@ import Testing let keyInput = Data("\u{1B}[13;2u".utf8) #expect(filter.filter(keyInput) == keyInput) } + + @Test func stdinPumpKeepsFilteringLateProbeRepliesAfterInitialDrain() throws { + var inputPipe = [Int32](repeating: -1, count: 2) + try makePipe(&inputPipe) + var bridgePair = [Int32](repeating: -1, count: 2) + try makeSocketPair(&bridgePair) + defer { + closeIfOpen(inputPipe[0]) + closeIfOpen(inputPipe[1]) + closeIfOpen(bridgePair[0]) + closeIfOpen(bridgePair[1]) + } + + try writeAll(fd: inputPipe[1], data: Data("\u{1B}[1;1R".utf8)) + let control = try SSHPTYAttachReconnectInputFilter.startStdinPump( + fd: bridgePair[0], + inputFD: inputPipe[0], + filterEnabled: true + ) + #expect(control != nil) + + let lateProbeReply = Data("\u{1B}]11;rgb:e5e5/e9e9/f0f0\u{07}".utf8) + let forwardedInput = Data("printf keep\n".utf8) + try writeAll(fd: inputPipe[1], data: lateProbeReply + forwardedInput) + Darwin.close(inputPipe[1]) + inputPipe[1] = -1 + + #expect(try readUntilEOF(fd: bridgePair[1]) == forwardedInput) + } + + private func makePipe(_ fds: inout [Int32]) throws { + guard Darwin.pipe(&fds) == 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + } + + private func makeSocketPair(_ fds: inout [Int32]) throws { + guard Darwin.socketpair(AF_UNIX, SOCK_STREAM, 0, &fds) == 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + } + + private func writeAll(fd: Int32, data: Data) throws { + try data.withUnsafeBytes { rawBuffer in + guard let base = rawBuffer.bindMemory(to: UInt8.self).baseAddress else { return } + var remaining = rawBuffer.count + var cursor = base + while remaining > 0 { + let written = Darwin.write(fd, cursor, remaining) + if written > 0 { + remaining -= written + cursor = cursor.advanced(by: written) + } else if written < 0, errno == EINTR { + continue + } else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + } + } + } + + private func readUntilEOF(fd: Int32) throws -> Data { + var output = Data() + var buffer = [UInt8](repeating: 0, count: 1024) + while true { + let count = Darwin.read(fd, &buffer, buffer.count) + if count > 0 { + output.append(contentsOf: buffer.prefix(count)) + } else if count == 0 { + return output + } else if errno != EINTR { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + } + } + + private func closeIfOpen(_ fd: Int32) { + guard fd >= 0 else { + return + } + Darwin.close(fd) + } } From be8c2c628112c5e1d450c77bd3f4aab4fad0664b Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 14 Jun 2026 12:36:11 -0700 Subject: [PATCH 16/18] fix: stop reconnect input filtering after bridge output --- .github/swift-file-length-budget.tsv | 4 +-- CLI/SSHPTYAttachReconnectInputFilter.swift | 34 +++++++++++++++------- 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 34eb05427ef..73f29451a1a 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -118,12 +118,13 @@ 746 Sources/App/MenuBarExtraController.swift 738 Packages/CMUXProjectModel/Sources/CMUXProjectModel/XcodeProjectAdapter.swift 736 Packages/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthCoordinator.swift +726 cmuxTests/CLICodexHookTimeoutRegressionTests.swift 716 Sources/TaskManagerSnapshot.swift 715 Packages/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Input.swift 715 Sources/AppleScriptSupport.swift 710 Sources/TerminalSSHSessionDetector.swift -706 CLI/CMUXCLI+Config.swift 707 CLI/CMUXCLI+AgentHookDefinitions.swift +706 CLI/CMUXCLI+Config.swift 699 Sources/RightSidebarPanelView.swift 699 cmuxTests/TerminalNotificationClearAllTests.swift 698 cmuxTests/RestorableAgentHookProviderResumeTests.swift @@ -187,7 +188,6 @@ 528 cmuxTests/CLINotifyProcessTestSupport.swift 528 cmuxUITests/AutomationSocketUITests.swift 527 CLI/CLISocketPathResolver.swift -726 cmuxTests/CLICodexHookTimeoutRegressionTests.swift 523 Packages/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator+PortScan.swift 520 CLI/CMUXCLI+AmpExtension.swift 520 cmuxTests/MainWindowVisibilityControllerTests.swift diff --git a/CLI/SSHPTYAttachReconnectInputFilter.swift b/CLI/SSHPTYAttachReconnectInputFilter.swift index de5faf85f20..6fcac247160 100644 --- a/CLI/SSHPTYAttachReconnectInputFilter.swift +++ b/CLI/SSHPTYAttachReconnectInputFilter.swift @@ -99,18 +99,28 @@ final class SSHPTYAttachReconnectInputFilter { } } + func stopReconnectFilteringIfRequested() -> Bool { + guard let filter = reconnectInputFilter, + filterControl?.shouldFilterReconnectInput == false else { + return true + } + guard writeOrShutdown(filter.stopFiltering()) else { + return false + } + reconnectInputFilter = nil + return true + } + while true { + guard stopReconnectFilteringIfRequested() else { + return + } if let filter = reconnectInputFilter { - if filterControl?.shouldFilterReconnectInput == false { - guard writeOrShutdown(filter.stopFiltering()) else { - return - } - reconnectInputFilter = nil - } else if filter.hasPendingInput, - !stdinHasReadyInput( - inputFD: inputFD, - timeoutMilliseconds: pendingProbeContinuationTimeoutMilliseconds - ) { + if filter.hasPendingInput, + !stdinHasReadyInput( + inputFD: inputFD, + timeoutMilliseconds: pendingProbeContinuationTimeoutMilliseconds + ) { guard writeOrShutdown(filter.flushPendingInput()) else { return } @@ -120,6 +130,10 @@ final class SSHPTYAttachReconnectInputFilter { let count = Darwin.read(inputFD, &buffer, buffer.count) if count > 0 { let rawInput = Data(buffer.prefix(count)) + // Bridge output can stop filtering while read() is blocked. + guard stopReconnectFilteringIfRequested() else { + return + } let input = reconnectInputFilter?.filter(rawInput) ?? rawInput guard writeOrShutdown(input) else { return From 5ecc4f375947ca524c215f567480f3b618d24aa9 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 14 Jun 2026 12:57:54 -0700 Subject: [PATCH 17/18] chore: refresh swift file length budget --- .github/swift-file-length-budget.tsv | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 73f29451a1a..1c32e463bce 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -3,7 +3,7 @@ # Reduce counts as files shrink. CI fails if tracked files exceed this budget. 33848 CLI/cmux.swift 17914 Sources/AppDelegate.swift -16740 Sources/ContentView.swift +16709 Sources/ContentView.swift 14612 Sources/TerminalController.swift 13595 Sources/Panels/BrowserPanel.swift 12088 Sources/GhosttyTerminalView.swift @@ -13,8 +13,8 @@ 7911 Sources/Panels/BrowserPanelView.swift 7350 cmuxTests/WorkspaceUnitTests.swift 6944 cmuxTests/WorkspaceRemoteConnectionTests.swift +6363 cmuxTests/GhosttyConfigTests.swift 6317 cmuxTests/SessionPersistenceTests.swift -6299 cmuxTests/GhosttyConfigTests.swift 6153 CLI/cmux_open.swift 6074 Sources/TabManager.swift 6074 Sources/TextBoxInput.swift From 3c6f022a89ad001515f17660bf49168a64754825 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 14 Jun 2026 13:57:40 -0700 Subject: [PATCH 18/18] fix: signal reconnect filter stop without shared lock --- CLI/SSHPTYAttachReconnectInputFilter.swift | 132 ++++++++++++------ ...PTYAttachReconnectInputFilterControl.swift | 25 ++++ ...SHPTYAttachReconnectInputFilterState.swift | 4 + cmux.xcodeproj/project.pbxproj | 54 ++++--- 4 files changed, 149 insertions(+), 66 deletions(-) create mode 100644 CLI/SSHPTYAttachReconnectInputFilterControl.swift create mode 100644 CLI/SSHPTYAttachReconnectInputFilterState.swift diff --git a/CLI/SSHPTYAttachReconnectInputFilter.swift b/CLI/SSHPTYAttachReconnectInputFilter.swift index 6fcac247160..3310ba95a8d 100644 --- a/CLI/SSHPTYAttachReconnectInputFilter.swift +++ b/CLI/SSHPTYAttachReconnectInputFilter.swift @@ -21,7 +21,7 @@ final class SSHPTYAttachReconnectInputFilter { isFiltering = enabled } - private init(state: FilterState) { + private init(state: SSHPTYAttachReconnectInputFilterState) { isFiltering = state.isFiltering pending = state.pending } @@ -33,19 +33,32 @@ final class SSHPTYAttachReconnectInputFilter { filterEnabled: Bool ) throws -> SSHPTYAttachReconnectInputFilterControl? { let filterState = filterEnabled ? try drainQueuedProbeReplies(inputFD: inputFD, fd: fd) : nil - let filterControl = filterState == nil ? nil : SSHPTYAttachReconnectInputFilterControl() + var stopSignalFDs = [Int32](repeating: -1, count: 2) + let filterControl: SSHPTYAttachReconnectInputFilterControl? + let stopSignalReadFD: Int32? + if filterState == nil { + filterControl = nil + stopSignalReadFD = nil + } else { + guard Darwin.pipe(&stopSignalFDs) == 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + filterControl = SSHPTYAttachReconnectInputFilterControl(stopSignalWriteFD: stopSignalFDs[1]) + stopSignalReadFD = stopSignalFDs[0] + } DispatchQueue.global(qos: .userInteractive).async { pumpStdin( inputFD: inputFD, fd: fd, reconnectInputFilterState: filterState, - filterControl: filterControl + retainedFilterControl: filterControl, + stopSignalFD: stopSignalReadFD ) } return filterControl } - private static func drainQueuedProbeReplies(inputFD: Int32, fd: Int32) throws -> FilterState? { + private static func drainQueuedProbeReplies(inputFD: Int32, fd: Int32) throws -> SSHPTYAttachReconnectInputFilterState? { let reconnectInputFilter = SSHPTYAttachReconnectInputFilter(enabled: true) var buffer = [UInt8](repeating: 0, count: 8192) while true { @@ -80,11 +93,19 @@ final class SSHPTYAttachReconnectInputFilter { private static func pumpStdin( inputFD: Int32, fd: Int32, - reconnectInputFilterState: FilterState?, - filterControl: SSHPTYAttachReconnectInputFilterControl? + reconnectInputFilterState: SSHPTYAttachReconnectInputFilterState?, + retainedFilterControl: SSHPTYAttachReconnectInputFilterControl?, + stopSignalFD initialStopSignalFD: Int32? ) { + _ = retainedFilterControl var reconnectInputFilter = reconnectInputFilterState.map(SSHPTYAttachReconnectInputFilter.init(state:)) + var stopSignalFD = initialStopSignalFD var buffer = [UInt8](repeating: 0, count: 8192) + defer { + if let stopSignalFD { + Darwin.close(stopSignalFD) + } + } func writeOrShutdown(_ input: Data) -> Bool { guard !input.isEmpty else { @@ -99,41 +120,54 @@ final class SSHPTYAttachReconnectInputFilter { } } - func stopReconnectFilteringIfRequested() -> Bool { - guard let filter = reconnectInputFilter, - filterControl?.shouldFilterReconnectInput == false else { + func stopReconnectFiltering() -> Bool { + guard let filter = reconnectInputFilter else { return true } guard writeOrShutdown(filter.stopFiltering()) else { return false } reconnectInputFilter = nil + if let fd = stopSignalFD { + Darwin.close(fd) + stopSignalFD = nil + } return true } while true { - guard stopReconnectFilteringIfRequested() else { + let timeoutMilliseconds = reconnectInputFilter?.hasPendingInput == true + ? pendingProbeContinuationTimeoutMilliseconds + : -1 + guard let readiness = pollStdinPump( + inputFD: inputFD, + stopSignalFD: stopSignalFD, + timeoutMilliseconds: timeoutMilliseconds + ) else { + _ = shutdown(fd, SHUT_WR) return } - if let filter = reconnectInputFilter { - if filter.hasPendingInput, - !stdinHasReadyInput( - inputFD: inputFD, - timeoutMilliseconds: pendingProbeContinuationTimeoutMilliseconds - ) { - guard writeOrShutdown(filter.flushPendingInput()) else { - return - } + + if readiness.stopRequested { + guard stopReconnectFiltering() else { + return + } + if !readiness.inputReady { + continue + } + } + + if !readiness.inputReady { + if let filter = reconnectInputFilter, + filter.hasPendingInput { + guard writeOrShutdown(filter.flushPendingInput()) else { return } } + continue } let count = Darwin.read(inputFD, &buffer, buffer.count) if count > 0 { let rawInput = Data(buffer.prefix(count)) - // Bridge output can stop filtering while read() is blocked. - guard stopReconnectFilteringIfRequested() else { - return - } let input = reconnectInputFilter?.filter(rawInput) ?? rawInput guard writeOrShutdown(input) else { return @@ -221,11 +255,11 @@ final class SSHPTYAttachReconnectInputFilter { return data } - private func snapshotForPump() -> FilterState? { + private func snapshotForPump() -> SSHPTYAttachReconnectInputFilterState? { guard isFiltering else { return nil } - return FilterState(isFiltering: isFiltering, pending: pending) + return SSHPTYAttachReconnectInputFilterState(isFiltering: isFiltering, pending: pending) } private static func reconnectProbeReplySequence( @@ -368,11 +402,6 @@ final class SSHPTYAttachReconnectInputFilter { } } - private struct FilterState: Sendable { - let isFiltering: Bool - let pending: [UInt8] - } - private static func writeAll(fd: Int32, data: Data) throws { try data.withUnsafeBytes { rawBuffer in guard let base = rawBuffer.bindMemory(to: UInt8.self).baseAddress else { return } @@ -391,22 +420,35 @@ final class SSHPTYAttachReconnectInputFilter { } } } -} - -// Shared between the bridge-output thread and the stdin pump; the flag is NSLock-guarded. -final class SSHPTYAttachReconnectInputFilterControl: @unchecked Sendable { - private let lock = NSLock() - private var stopped = false - var shouldFilterReconnectInput: Bool { - lock.lock() - defer { lock.unlock() } - return !stopped - } + private static func pollStdinPump( + inputFD: Int32, + stopSignalFD: Int32?, + timeoutMilliseconds: Int32 + ) -> (inputReady: Bool, stopRequested: Bool)? { + let inputEvents = Int16(POLLIN | POLLHUP | POLLERR | POLLNVAL) + let stopEvents = Int16(POLLIN | POLLHUP | POLLERR | POLLNVAL) + var pollFDs = [pollfd(fd: inputFD, events: Int16(POLLIN), revents: 0)] + if let stopSignalFD { + pollFDs.append(pollfd(fd: stopSignalFD, events: Int16(POLLIN), revents: 0)) + } - func stopFiltering() { - lock.lock() - stopped = true - lock.unlock() + while true { + let result = pollFDs.withUnsafeMutableBufferPointer { buffer in + Darwin.poll(buffer.baseAddress, nfds_t(buffer.count), timeoutMilliseconds) + } + if result > 0 { + let inputReady = (pollFDs[0].revents & inputEvents) != 0 + let stopRequested = pollFDs.count > 1 && (pollFDs[1].revents & stopEvents) != 0 + return (inputReady: inputReady, stopRequested: stopRequested) + } + if result == 0 { + return (inputReady: false, stopRequested: false) + } + if errno == EINTR { + continue + } + return nil + } } } diff --git a/CLI/SSHPTYAttachReconnectInputFilterControl.swift b/CLI/SSHPTYAttachReconnectInputFilterControl.swift new file mode 100644 index 00000000000..3f1366c881e --- /dev/null +++ b/CLI/SSHPTYAttachReconnectInputFilterControl.swift @@ -0,0 +1,25 @@ +import Darwin + +final class SSHPTYAttachReconnectInputFilterControl: Sendable { + private let stopSignalWriteFD: Int32 + + init(stopSignalWriteFD: Int32) { + self.stopSignalWriteFD = stopSignalWriteFD + } + + deinit { + Darwin.close(stopSignalWriteFD) + } + + func stopFiltering() { + var byte: UInt8 = 1 + while true { + let written = withUnsafePointer(to: &byte) { pointer in + Darwin.write(stopSignalWriteFD, pointer, 1) + } + if written > 0 || errno != EINTR { + return + } + } + } +} diff --git a/CLI/SSHPTYAttachReconnectInputFilterState.swift b/CLI/SSHPTYAttachReconnectInputFilterState.swift new file mode 100644 index 00000000000..957691be164 --- /dev/null +++ b/CLI/SSHPTYAttachReconnectInputFilterState.swift @@ -0,0 +1,4 @@ +struct SSHPTYAttachReconnectInputFilterState: Sendable { + let isFiltering: Bool + let pending: [UInt8] +} diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 097f46cbf24..acf65b7c90f 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -684,11 +684,15 @@ F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */; }; C510C1E00000000000000002 /* SocketOperationTelemetry.swift in Sources */ = {isa = PBXBuildFile; fileRef = C510C1E00000000000000001 /* SocketOperationTelemetry.swift */; }; A5001230 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = A5001231 /* Sparkle */; }; - 6E8B6A69A468EF938B9AD8C2 /* SSHPTYAttachReconnectInputFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E4DB33FA55F1B13C60EFFC8 /* SSHPTYAttachReconnectInputFilter.swift */; }; - B816E948EC5E42AF967648AC /* SSHPTYAttachReconnectInputFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E4DB33FA55F1B13C60EFFC8 /* SSHPTYAttachReconnectInputFilter.swift */; }; - E60610100000000000000002 /* SSHPTYAttachReconnectInputFilterSequenceMatch.swift in Sources */ = {isa = PBXBuildFile; fileRef = E60610100000000000000001 /* SSHPTYAttachReconnectInputFilterSequenceMatch.swift */; }; - E60610100000000000000003 /* SSHPTYAttachReconnectInputFilterSequenceMatch.swift in Sources */ = {isa = PBXBuildFile; fileRef = E60610100000000000000001 /* SSHPTYAttachReconnectInputFilterSequenceMatch.swift */; }; - B6285B71BA6F82AF8F945E00 /* SSHPTYAttachReconnectInputFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C4AD348F196FBBEE52D53A7 /* SSHPTYAttachReconnectInputFilterTests.swift */; }; + 6E8B6A69A468EF938B9AD8C2 /* SSHPTYAttachReconnectInputFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E4DB33FA55F1B13C60EFFC8 /* SSHPTYAttachReconnectInputFilter.swift */; }; + B816E948EC5E42AF967648AC /* SSHPTYAttachReconnectInputFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E4DB33FA55F1B13C60EFFC8 /* SSHPTYAttachReconnectInputFilter.swift */; }; + E60610200000000000000002 /* SSHPTYAttachReconnectInputFilterControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = E60610200000000000000001 /* SSHPTYAttachReconnectInputFilterControl.swift */; }; + E60610200000000000000003 /* SSHPTYAttachReconnectInputFilterControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = E60610200000000000000001 /* SSHPTYAttachReconnectInputFilterControl.swift */; }; + E60610100000000000000002 /* SSHPTYAttachReconnectInputFilterSequenceMatch.swift in Sources */ = {isa = PBXBuildFile; fileRef = E60610100000000000000001 /* SSHPTYAttachReconnectInputFilterSequenceMatch.swift */; }; + E60610100000000000000003 /* SSHPTYAttachReconnectInputFilterSequenceMatch.swift in Sources */ = {isa = PBXBuildFile; fileRef = E60610100000000000000001 /* SSHPTYAttachReconnectInputFilterSequenceMatch.swift */; }; + E60610300000000000000002 /* SSHPTYAttachReconnectInputFilterState.swift in Sources */ = {isa = PBXBuildFile; fileRef = E60610300000000000000001 /* SSHPTYAttachReconnectInputFilterState.swift */; }; + E60610300000000000000003 /* SSHPTYAttachReconnectInputFilterState.swift in Sources */ = {isa = PBXBuildFile; fileRef = E60610300000000000000001 /* SSHPTYAttachReconnectInputFilterState.swift */; }; + B6285B71BA6F82AF8F945E00 /* SSHPTYAttachReconnectInputFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C4AD348F196FBBEE52D53A7 /* SSHPTYAttachReconnectInputFilterTests.swift */; }; E30780000000000000000012 /* SSHPTYAttachStartupCommandBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = E30780000000000000000011 /* SSHPTYAttachStartupCommandBuilder.swift */; }; F6355600A1B2C3D4E5F60718 /* SSHStartupSignalLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6355601A1B2C3D4E5F60718 /* SSHStartupSignalLifecycleTests.swift */; }; F20F85FC5900550685FA33AD /* StackAuth in Frameworks */ = {isa = PBXBuildFile; productRef = A8BD195031FC4B82B4354297 /* StackAuth */; }; @@ -1540,10 +1544,12 @@ F016B5C09357B3226FA2E014 /* SidebarWorkspaceSnapshotRefreshPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceSnapshotRefreshPolicyTests.swift; sourceTree = ""; }; A5001225 /* SocketControlMode+Display.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SocketControlMode+Display.swift"; sourceTree = ""; }; F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketControlPasswordStoreTests.swift; sourceTree = ""; }; - C510C1E00000000000000001 /* SocketOperationTelemetry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketOperationTelemetry.swift; sourceTree = ""; }; - 1E4DB33FA55F1B13C60EFFC8 /* SSHPTYAttachReconnectInputFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHPTYAttachReconnectInputFilter.swift; sourceTree = ""; }; - E60610100000000000000001 /* SSHPTYAttachReconnectInputFilterSequenceMatch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHPTYAttachReconnectInputFilterSequenceMatch.swift; sourceTree = ""; }; - 0C4AD348F196FBBEE52D53A7 /* SSHPTYAttachReconnectInputFilterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHPTYAttachReconnectInputFilterTests.swift; sourceTree = ""; }; + C510C1E00000000000000001 /* SocketOperationTelemetry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketOperationTelemetry.swift; sourceTree = ""; }; + 1E4DB33FA55F1B13C60EFFC8 /* SSHPTYAttachReconnectInputFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHPTYAttachReconnectInputFilter.swift; sourceTree = ""; }; + E60610200000000000000001 /* SSHPTYAttachReconnectInputFilterControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHPTYAttachReconnectInputFilterControl.swift; sourceTree = ""; }; + E60610100000000000000001 /* SSHPTYAttachReconnectInputFilterSequenceMatch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHPTYAttachReconnectInputFilterSequenceMatch.swift; sourceTree = ""; }; + E60610300000000000000001 /* SSHPTYAttachReconnectInputFilterState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHPTYAttachReconnectInputFilterState.swift; sourceTree = ""; }; + 0C4AD348F196FBBEE52D53A7 /* SSHPTYAttachReconnectInputFilterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHPTYAttachReconnectInputFilterTests.swift; sourceTree = ""; }; E30780000000000000000011 /* SSHPTYAttachStartupCommandBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHPTYAttachStartupCommandBuilder.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 = ""; }; @@ -2475,10 +2481,12 @@ B9000003A1B2C3D4E5F60719 /* CLI */ = { isa = PBXGroup; children = ( - B9000001A1B2C3D4E5F60719 /* cmux.swift */, - 1E4DB33FA55F1B13C60EFFC8 /* SSHPTYAttachReconnectInputFilter.swift */, - E60610100000000000000001 /* SSHPTYAttachReconnectInputFilterSequenceMatch.swift */, - C0DECAFE0000000000000002 /* CodexTeamsApprovalBridge.swift */, + B9000001A1B2C3D4E5F60719 /* cmux.swift */, + 1E4DB33FA55F1B13C60EFFC8 /* SSHPTYAttachReconnectInputFilter.swift */, + E60610200000000000000001 /* SSHPTYAttachReconnectInputFilterControl.swift */, + E60610100000000000000001 /* SSHPTYAttachReconnectInputFilterSequenceMatch.swift */, + E60610300000000000000001 /* SSHPTYAttachReconnectInputFilterState.swift */, + C0DECAFE0000000000000002 /* CodexTeamsApprovalBridge.swift */, FEEDC1A50000000000000002 /* FeedEventClassifier.swift */, B900004BA1B2C3D4E5F60719 /* CLISocketPathResolver.swift */, C510C1E00000000000000001 /* SocketOperationTelemetry.swift */, @@ -3708,10 +3716,12 @@ B9000028A1B2C3D4E5F60719 /* RemoteInteractiveShellBootstrapBuilder.swift in Sources */, B9000027A1B2C3D4E5F60719 /* RemoteRelayZshBootstrap.swift in Sources */, 5E2701020000000000000003 /* SentryEventScrubber.swift in Sources */, - C510C1E00000000000000002 /* SocketOperationTelemetry.swift in Sources */, - B816E948EC5E42AF967648AC /* SSHPTYAttachReconnectInputFilter.swift in Sources */, - E60610100000000000000002 /* SSHPTYAttachReconnectInputFilterSequenceMatch.swift in Sources */, - ); + C510C1E00000000000000002 /* SocketOperationTelemetry.swift in Sources */, + B816E948EC5E42AF967648AC /* SSHPTYAttachReconnectInputFilter.swift in Sources */, + E60610200000000000000002 /* SSHPTYAttachReconnectInputFilterControl.swift in Sources */, + E60610100000000000000002 /* SSHPTYAttachReconnectInputFilterSequenceMatch.swift in Sources */, + E60610300000000000000002 /* SSHPTYAttachReconnectInputFilterState.swift in Sources */, + ); runOnlyForDeploymentPostprocessing = 0; }; D1320AA0D1320AA0D1320AB0 /* Sources */ = { @@ -3937,10 +3947,12 @@ C9A57505C9A57505C9A57505 /* SidebarWorkspaceScrollLayoutTests.swift in Sources */, C0DE56010000000000000001 /* SidebarWorkspaceSelectionAnchorPolicyTests.swift in Sources */, 62270F3DCECB4787D789CCE3 /* SidebarWorkspaceSnapshotRefreshPolicyTests.swift in Sources */, - F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */, - 6E8B6A69A468EF938B9AD8C2 /* SSHPTYAttachReconnectInputFilter.swift in Sources */, - E60610100000000000000003 /* SSHPTYAttachReconnectInputFilterSequenceMatch.swift in Sources */, - B6285B71BA6F82AF8F945E00 /* SSHPTYAttachReconnectInputFilterTests.swift in Sources */, + F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */, + 6E8B6A69A468EF938B9AD8C2 /* SSHPTYAttachReconnectInputFilter.swift in Sources */, + E60610200000000000000003 /* SSHPTYAttachReconnectInputFilterControl.swift in Sources */, + E60610100000000000000003 /* SSHPTYAttachReconnectInputFilterSequenceMatch.swift in Sources */, + E60610300000000000000003 /* SSHPTYAttachReconnectInputFilterState.swift in Sources */, + B6285B71BA6F82AF8F945E00 /* SSHPTYAttachReconnectInputFilterTests.swift in Sources */, F6355600A1B2C3D4E5F60718 /* SSHStartupSignalLifecycleTests.swift in Sources */, 2BB56A710BB1FC50367E5BCF /* TabManagerSessionSnapshotTests.swift in Sources */, B6BF3DC98DB1495E57900199 /* TabManagerUnitTests.swift in Sources */,