From 061c2b38901cbc154a26e97b4f03148f3a5fddfa Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Wed, 6 May 2026 19:56:00 -0700 Subject: [PATCH] Split CLI helpers to restore main CI budget --- CLI/CMUXCLI+Events.swift | 197 +++++++++++++++++ CLI/CMUXCLI+Process.swift | 98 +++++++++ CLI/cmux.swift | 294 +------------------------- GhosttyTabs.xcodeproj/project.pbxproj | 4 + 4 files changed, 300 insertions(+), 293 deletions(-) create mode 100644 CLI/CMUXCLI+Process.swift diff --git a/CLI/CMUXCLI+Events.swift b/CLI/CMUXCLI+Events.swift index aa485a8dc515..2269c13a7595 100644 --- a/CLI/CMUXCLI+Events.swift +++ b/CLI/CMUXCLI+Events.swift @@ -1,8 +1,106 @@ +import CoreFoundation import Foundation struct EventStreamLimitReached: Error {} extension CMUXCLI { + private struct EventsCommandOptions { + var afterSeq: Int64? + var cursorFile: String? + var names: [String] = [] + var categories: [String] = [] + var reconnect = false + var limit: Int? + var printAck = true + var printHeartbeats = true + } + + func runEventsCommand( + commandArgs: [String], + socketPath: String, + explicitPassword: String? + ) throws { + var options = try parseEventsOptions(commandArgs) + if options.afterSeq == nil, let cursorFile = options.cursorFile { + options.afterSeq = try readEventCursor(from: cursorFile) + } + + var lastSeq = options.afterSeq + var emittedEvents = 0 + + while true { + let client = SocketClient(path: socketPath) + do { + try client.connect() + try authenticateClientIfNeeded( + client, + explicitPassword: explicitPassword, + socketPath: socketPath + ) + + var params: [String: Any] = [ + "include_heartbeats": true + ] + if let lastSeq { + params["after_seq"] = NSNumber(value: lastSeq) + } + if !options.names.isEmpty { + params["names"] = options.names + } + if !options.categories.isEmpty { + params["categories"] = options.categories + } + + try client.streamV2(method: "events.stream", params: params) { line in + guard !line.isEmpty else { return } + let frame = try parseEventStreamFrame(line) + let type = frame["type"] as? String ?? "" + + let eventSequence: Int64? + if type == "event" { + guard let seq = int64Value(frame["seq"]) else { + throw CLIError(message: "Invalid event stream frame: event missing numeric seq") + } + eventSequence = seq + } else { + eventSequence = nil + } + + if type == "ack", !options.printAck { + return + } + if type == "heartbeat", !options.printHeartbeats { + return + } + + print(line) + fflush(stdout) + + if let eventSequence { + if let cursorFile = options.cursorFile { + try writeEventCursor(eventSequence, to: cursorFile) + } + lastSeq = eventSequence + emittedEvents += 1 + if let limit = options.limit, emittedEvents >= limit { + throw EventStreamLimitReached() + } + } + } + } catch is EventStreamLimitReached { + client.close() + return + } catch { + client.close() + guard options.reconnect, isTransientEventStreamError(error) else { + throw error + } + waitBeforeReconnectingEventStream() + continue + } + } + } + func isTransientEventStreamError(_ error: Error) -> Bool { if let cliError = error as? CLIError { let message = cliError.message.lowercased() @@ -44,4 +142,103 @@ extension CMUXCLI { while !didFire, RunLoop.current.run(mode: .default, before: deadline) {} timer.invalidate() } + + private func parseEventsOptions(_ args: [String]) throws -> EventsCommandOptions { + var options = EventsCommandOptions() + var index = 0 + while index < args.count { + let arg = args[index] + func requireValue() throws -> String { + guard index + 1 < args.count else { + throw CLIError(message: "\(arg) requires a value") + } + index += 1 + return args[index] + } + + switch arg { + case "--after", "--after-seq": + let raw = try requireValue() + guard let seq = Int64(raw), seq >= 0 else { + throw CLIError(message: "\(arg) must be a non-negative integer") + } + options.afterSeq = seq + case "--cursor-file": + options.cursorFile = try requireValue() + case "--name": + options.names.append(try requireValue()) + case "--category": + options.categories.append(try requireValue()) + case "--reconnect": + options.reconnect = true + case "--limit": + let raw = try requireValue() + guard let limit = Int(raw), limit > 0 else { + throw CLIError(message: "--limit must be greater than 0") + } + options.limit = limit + case "--no-ack": + options.printAck = false + case "--no-heartbeat", "--no-heartbeats": + options.printHeartbeats = false + default: + throw CLIError(message: "Unknown events option: \(arg)") + } + index += 1 + } + return options + } + + private func parseEventStreamFrame(_ line: String) throws -> [String: Any] { + guard let data = line.data(using: .utf8), + let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw CLIError(message: "Invalid event stream frame: \(line)") + } + if let ok = object["ok"] as? Bool, ok == false { + let error = object["error"] as? [String: Any] + let message = error?["message"] as? String ?? "event stream error" + throw CLIError(message: message) + } + return object + } + + private func readEventCursor(from path: String) throws -> Int64? { + let url = URL(fileURLWithPath: (path as NSString).expandingTildeInPath) + guard FileManager.default.fileExists(atPath: url.path) else { + return nil + } + let text: String + do { + text = try String(contentsOf: url, encoding: .utf8) + } catch { + throw CLIError(message: "Failed to read events cursor file \(url.path): \(String(describing: error))") + } + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard let sequence = Int64(trimmed), sequence >= 0 else { + throw CLIError(message: "Malformed events cursor file \(url.path): expected a non-negative sequence number") + } + return sequence + } + + private func writeEventCursor(_ seq: Int64, to path: String) throws { + let url = URL(fileURLWithPath: (path as NSString).expandingTildeInPath) + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try "\(seq)\n".write(to: url, atomically: true, encoding: .utf8) + } + + private func int64Value(_ value: Any?) -> Int64? { + if let number = value as? NSNumber { + guard CFGetTypeID(number) != CFBooleanGetTypeID() else { return nil } + let type = String(cString: number.objCType) + guard ["c", "C", "s", "S", "i", "I", "l", "L", "q", "Q"].contains(type) else { return nil } + let int64 = number.int64Value + guard number.compare(NSNumber(value: int64)) == .orderedSame else { return nil } + return int64 + } + if let string = value as? String { return Int64(string) } + return nil + } } diff --git a/CLI/CMUXCLI+Process.swift b/CLI/CMUXCLI+Process.swift new file mode 100644 index 000000000000..37baadffedf1 --- /dev/null +++ b/CLI/CMUXCLI+Process.swift @@ -0,0 +1,98 @@ +import Darwin +import Foundation + +struct CLIProcessResult { + let status: Int32 + let stdout: String + let stderr: String + let timedOut: Bool +} + +enum CLIProcessRunner { + static func runProcess( + executablePath: String, + arguments: [String], + stdinText: String? = nil, + timeout: TimeInterval? = nil + ) -> CLIProcessResult { + let process = Process() + process.executableURL = URL(fileURLWithPath: executablePath) + process.arguments = arguments + + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + let stdinPipe: Pipe? + if stdinText != nil { + let pipe = Pipe() + process.standardInput = pipe + stdinPipe = pipe + } else { + stdinPipe = nil + } + + let finished = DispatchSemaphore(value: 0) + process.terminationHandler = { _ in + finished.signal() + } + + do { + try process.run() + } catch { + return CLIProcessResult(status: 1, stdout: "", stderr: String(describing: error), timedOut: false) + } + + if let stdinText, let stdinPipe { + if let data = stdinText.data(using: .utf8) { + stdinPipe.fileHandleForWriting.write(data) + } + stdinPipe.fileHandleForWriting.closeFile() + } + + let timedOut: Bool + if let timeout { + switch finished.wait(timeout: .now() + timeout) { + case .success: + timedOut = false + case .timedOut: + timedOut = true + terminate(process: process, finished: finished) + } + } else { + finished.wait() + timedOut = false + } + + let stdout = String(data: stdoutPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + var stderr = String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + if timedOut { + let timeoutMessage = "process timed out" + if stderr.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + stderr = timeoutMessage + } else if !stderr.contains(timeoutMessage) { + stderr += "\n\(timeoutMessage)" + } + } + + return CLIProcessResult( + status: timedOut ? 124 : process.terminationStatus, + stdout: stdout, + stderr: stderr, + timedOut: timedOut + ) + } + + private static func terminate(process: Process, finished: DispatchSemaphore) { + guard process.isRunning else { return } + process.terminate() + if finished.wait(timeout: .now() + 0.5) == .success { + return + } + if process.isRunning { + kill(process.processIdentifier, SIGKILL) + } + _ = finished.wait(timeout: .now() + 0.5) + } +} diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 880f87e71d5e..0fa14df70e63 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -1686,102 +1686,6 @@ final class SocketClient { } } -struct CLIProcessResult { - let status: Int32 - let stdout: String - let stderr: String - let timedOut: Bool -} - -enum CLIProcessRunner { - static func runProcess( - executablePath: String, - arguments: [String], - stdinText: String? = nil, - timeout: TimeInterval? = nil - ) -> CLIProcessResult { - let process = Process() - process.executableURL = URL(fileURLWithPath: executablePath) - process.arguments = arguments - - let stdoutPipe = Pipe() - let stderrPipe = Pipe() - process.standardOutput = stdoutPipe - process.standardError = stderrPipe - - let stdinPipe: Pipe? - if stdinText != nil { - let pipe = Pipe() - process.standardInput = pipe - stdinPipe = pipe - } else { - stdinPipe = nil - } - - let finished = DispatchSemaphore(value: 0) - process.terminationHandler = { _ in - finished.signal() - } - - do { - try process.run() - } catch { - return CLIProcessResult(status: 1, stdout: "", stderr: String(describing: error), timedOut: false) - } - - if let stdinText, let stdinPipe { - if let data = stdinText.data(using: .utf8) { - stdinPipe.fileHandleForWriting.write(data) - } - stdinPipe.fileHandleForWriting.closeFile() - } - - let timedOut: Bool - if let timeout { - switch finished.wait(timeout: .now() + timeout) { - case .success: - timedOut = false - case .timedOut: - timedOut = true - terminate(process: process, finished: finished) - } - } else { - finished.wait() - timedOut = false - } - - let stdout = String(data: stdoutPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" - var stderr = String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" - if timedOut { - let timeoutMessage = "process timed out" - if stderr.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - stderr = timeoutMessage - } else if !stderr.contains(timeoutMessage) { - stderr += "\n\(timeoutMessage)" - } - } - - return CLIProcessResult( - status: timedOut ? 124 : process.terminationStatus, - stdout: stdout, - stderr: stderr, - timedOut: timedOut - ) - } - - private static func terminate(process: Process, finished: DispatchSemaphore) { - guard process.isRunning else { return } - process.terminate() - if finished.wait(timeout: .now() + 0.5) == .success { - return - } - if process.isRunning { - kill(process.processIdentifier, SIGKILL) - } - _ = finished.wait(timeout: .now() + 0.5) - } -} - struct CMUXCLI { let args: [String] @@ -3885,7 +3789,7 @@ struct CMUXCLI { return client } - private func authenticateClientIfNeeded( + func authenticateClientIfNeeded( _ client: SocketClient, explicitPassword: String?, socketPath: String @@ -3902,202 +3806,6 @@ struct CMUXCLI { } } - private struct EventsCommandOptions { - var afterSeq: Int64? - var cursorFile: String? - var names: [String] = [] - var categories: [String] = [] - var reconnect = false - var limit: Int? - var printAck = true - var printHeartbeats = true - } - - private func runEventsCommand( - commandArgs: [String], - socketPath: String, - explicitPassword: String? - ) throws { - var options = try parseEventsOptions(commandArgs) - if options.afterSeq == nil, let cursorFile = options.cursorFile { - options.afterSeq = try readEventCursor(from: cursorFile) - } - - var lastSeq = options.afterSeq - var emittedEvents = 0 - - while true { - let client = SocketClient(path: socketPath) - do { - try client.connect() - try authenticateClientIfNeeded( - client, - explicitPassword: explicitPassword, - socketPath: socketPath - ) - - var params: [String: Any] = [ - "include_heartbeats": true - ] - if let lastSeq { - params["after_seq"] = NSNumber(value: lastSeq) - } - if !options.names.isEmpty { - params["names"] = options.names - } - if !options.categories.isEmpty { - params["categories"] = options.categories - } - - try client.streamV2(method: "events.stream", params: params) { line in - guard !line.isEmpty else { return } - let frame = try parseEventStreamFrame(line) - let type = frame["type"] as? String ?? "" - - let eventSequence: Int64? - if type == "event" { - guard let seq = int64Value(frame["seq"]) else { - throw CLIError(message: "Invalid event stream frame: event missing numeric seq") - } - eventSequence = seq - } else { - eventSequence = nil - } - - if type == "ack", !options.printAck { - return - } - if type == "heartbeat", !options.printHeartbeats { - return - } - - print(line) - fflush(stdout) - - if let eventSequence { - if let cursorFile = options.cursorFile { - try writeEventCursor(eventSequence, to: cursorFile) - } - lastSeq = eventSequence - emittedEvents += 1 - if let limit = options.limit, emittedEvents >= limit { - throw EventStreamLimitReached() - } - } - } - } catch is EventStreamLimitReached { - client.close() - return - } catch { - client.close() - guard options.reconnect, isTransientEventStreamError(error) else { - throw error - } - waitBeforeReconnectingEventStream() - continue - } - } - } - - private func parseEventsOptions(_ args: [String]) throws -> EventsCommandOptions { - var options = EventsCommandOptions() - var index = 0 - while index < args.count { - let arg = args[index] - func requireValue() throws -> String { - guard index + 1 < args.count else { - throw CLIError(message: "\(arg) requires a value") - } - index += 1 - return args[index] - } - - switch arg { - case "--after", "--after-seq": - let raw = try requireValue() - guard let seq = Int64(raw), seq >= 0 else { - throw CLIError(message: "\(arg) must be a non-negative integer") - } - options.afterSeq = seq - case "--cursor-file": - options.cursorFile = try requireValue() - case "--name": - options.names.append(try requireValue()) - case "--category": - options.categories.append(try requireValue()) - case "--reconnect": - options.reconnect = true - case "--limit": - let raw = try requireValue() - guard let limit = Int(raw), limit > 0 else { - throw CLIError(message: "--limit must be greater than 0") - } - options.limit = limit - case "--no-ack": - options.printAck = false - case "--no-heartbeat", "--no-heartbeats": - options.printHeartbeats = false - default: - throw CLIError(message: "Unknown events option: \(arg)") - } - index += 1 - } - return options - } - - private func parseEventStreamFrame(_ line: String) throws -> [String: Any] { - guard let data = line.data(using: .utf8), - let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { - throw CLIError(message: "Invalid event stream frame: \(line)") - } - if let ok = object["ok"] as? Bool, ok == false { - let error = object["error"] as? [String: Any] - let message = error?["message"] as? String ?? "event stream error" - throw CLIError(message: message) - } - return object - } - - private func readEventCursor(from path: String) throws -> Int64? { - let url = URL(fileURLWithPath: (path as NSString).expandingTildeInPath) - guard FileManager.default.fileExists(atPath: url.path) else { - return nil - } - let text: String - do { - text = try String(contentsOf: url, encoding: .utf8) - } catch { - throw CLIError(message: "Failed to read events cursor file \(url.path): \(String(describing: error))") - } - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard let sequence = Int64(trimmed), sequence >= 0 else { - throw CLIError(message: "Malformed events cursor file \(url.path): expected a non-negative sequence number") - } - return sequence - } - - private func writeEventCursor(_ seq: Int64, to path: String) throws { - let url = URL(fileURLWithPath: (path as NSString).expandingTildeInPath) - try FileManager.default.createDirectory( - at: url.deletingLastPathComponent(), - withIntermediateDirectories: true - ) - try "\(seq)\n".write(to: url, atomically: true, encoding: .utf8) - } - - private func int64Value(_ value: Any?) -> Int64? { - if let number = value as? NSNumber { - guard CFGetTypeID(number) != CFBooleanGetTypeID() else { return nil } - let type = String(cString: number.objCType) - guard ["c", "C", "s", "S", "i", "I", "l", "L", "q", "Q"].contains(type) else { return nil } - let int64 = number.int64Value - guard number.compare(NSNumber(value: int64)) == .orderedSame else { return nil } - return int64 - } - if let string = value as? String { return Int64(string) } - return nil - } - private func launchApp() throws { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/open") diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 7487d0810319..1e6674b36a50 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -309,6 +309,7 @@ D7AB00000000000000000011 /* WorkspaceAdjacentPaneMoveTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB00000000000000000012 /* WorkspaceAdjacentPaneMoveTests.swift */; }; B9000033A1B2C3D4E5F60719 /* CMUXCLI+TopRendering.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000032A1B2C3D4E5F60719 /* CMUXCLI+TopRendering.swift */; }; B9000052A1B2C3D4E5F60719 /* CMUXCLI+Events.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000053A1B2C3D4E5F60719 /* CMUXCLI+Events.swift */; }; + B9000054A1B2C3D4E5F60719 /* CMUXCLI+Process.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000055A1B2C3D4E5F60719 /* CMUXCLI+Process.swift */; }; B9000012A1B2C3D4E5F60719 /* AutomationSocketUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000011A1B2C3D4E5F60719 /* AutomationSocketUITests.swift */; }; FEED0000000000000000F009 /* FeedSidebarUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED0000000000000000F008 /* FeedSidebarUITests.swift */; }; B9000014A1B2C3D4E5F60719 /* JumpToUnreadUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000013A1B2C3D4E5F60719 /* JumpToUnreadUITests.swift */; }; @@ -761,6 +762,7 @@ D7AB00000000000000000012 /* WorkspaceAdjacentPaneMoveTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceAdjacentPaneMoveTests.swift; sourceTree = ""; }; B9000032A1B2C3D4E5F60719 /* CMUXCLI+TopRendering.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+TopRendering.swift"; sourceTree = ""; }; B9000053A1B2C3D4E5F60719 /* CMUXCLI+Events.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+Events.swift"; sourceTree = ""; }; + B9000055A1B2C3D4E5F60719 /* CMUXCLI+Process.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+Process.swift"; sourceTree = ""; }; B9000011A1B2C3D4E5F60719 /* AutomationSocketUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomationSocketUITests.swift; sourceTree = ""; }; FEED0000000000000000F008 /* FeedSidebarUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedSidebarUITests.swift; sourceTree = ""; }; B9000013A1B2C3D4E5F60719 /* JumpToUnreadUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JumpToUnreadUITests.swift; sourceTree = ""; }; @@ -1247,6 +1249,7 @@ B900002CA1B2C3D4E5F60719 /* CMUXCLI+Themes.swift */, B9000032A1B2C3D4E5F60719 /* CMUXCLI+TopRendering.swift */, B9000053A1B2C3D4E5F60719 /* CMUXCLI+Events.swift */, + B9000055A1B2C3D4E5F60719 /* CMUXCLI+Process.swift */, ); path = CLI; sourceTree = ""; @@ -1844,6 +1847,7 @@ B900002EA1B2C3D4E5F60719 /* CMUXCLI+Themes.swift in Sources */, B9000033A1B2C3D4E5F60719 /* CMUXCLI+TopRendering.swift in Sources */, B9000052A1B2C3D4E5F60719 /* CMUXCLI+Events.swift in Sources */, + B9000054A1B2C3D4E5F60719 /* CMUXCLI+Process.swift in Sources */, C0DEF0B10000000000000003 /* JSONCParser.swift in Sources */, B9000027A1B2C3D4E5F60719 /* RemoteRelayZshBootstrap.swift in Sources */, );