1346 lines · 49.5 KB · Swift
RawDownload
#if canImport(Darwin)import Darwin#elseif canImport(Glibc)import Glibc#elseif canImport(Musl)import Musl#endifimport Foundation private enum TTYCommandRunnerActiveProcessRegistry {    private struct ProcessInfo {        let binary: String        var processGroup: pid_t?    }     private final class State: @unchecked Sendable {        private let condition = NSCondition()        private var processes: [pid_t: ProcessInfo] = [:]        private var isShuttingDown = false        private var launchesInProgress = 0         @discardableResult        func register(pid: pid_t, binary: String) -> Bool {            guard pid > 0 else { return false }            self.condition.lock()            defer { self.condition.unlock() }            guard !self.isShuttingDown else { return false }            self.processes[pid] = ProcessInfo(binary: binary, processGroup: nil)            return true        }         func beginLaunch() -> Bool {            self.condition.lock()            defer { self.condition.unlock() }            guard !self.isShuttingDown else { return false }            self.launchesInProgress += 1            return true        }         func endLaunch() {            self.condition.lock()            self.launchesInProgress = max(0, self.launchesInProgress - 1)            if self.launchesInProgress == 0 {                self.condition.broadcast()            }            self.condition.unlock()        }         func updateProcessGroup(pid: pid_t, processGroup: pid_t?) {            guard pid > 0 else { return }            self.condition.lock()            guard var existing = self.processes[pid] else {                self.condition.unlock()                return            }            existing.processGroup = processGroup            self.processes[pid] = existing            self.condition.unlock()        }         func unregister(pid: pid_t) {            guard pid > 0 else { return }            self.condition.lock()            self.processes.removeValue(forKey: pid)            self.condition.unlock()        }         func drainForShutdown(            onFenceSet: (() -> Void)? = nil)            -> [(pid: pid_t, binary: String, processGroup: pid_t?)]        {            self.condition.lock()            self.isShuttingDown = true            onFenceSet?()            while self.launchesInProgress > 0 {                self.condition.wait()            }            let drained = self.processes.map {                (pid: $0.key, binary: $0.value.binary, processGroup: $0.value.processGroup)            }            self.processes.removeAll()            self.condition.unlock()            return drained        }         func reset() {            self.condition.lock()            self.processes.removeAll()            self.isShuttingDown = false            self.launchesInProgress = 0            self.condition.broadcast()            self.condition.unlock()        }         func count() -> Int {            self.condition.lock()            let count = self.processes.count            self.condition.unlock()            return count        }         func testTrackProcess(pid: pid_t, binary: String, processGroup: pid_t?) {            guard pid > 0 else { return }            self.condition.lock()            self.processes[pid] = ProcessInfo(binary: binary, processGroup: processGroup)            self.condition.unlock()        }    }     private static let shared = State()    @TaskLocal private static var stateOverrideForTesting: State?     private static var current: State {        self.stateOverrideForTesting ?? self.shared    }     static func register(pid: pid_t, binary: String) -> Bool {        self.current.register(pid: pid, binary: binary)    }     static func beginLaunch() -> Bool {        self.current.beginLaunch()    }     static func endLaunch() {        self.current.endLaunch()    }     static func updateProcessGroup(pid: pid_t, processGroup: pid_t?) {        self.current.updateProcessGroup(pid: pid, processGroup: processGroup)    }     static func unregister(pid: pid_t) {        self.current.unregister(pid: pid)    }     static func drainForShutdown(onFenceSet: (() -> Void)? = nil)        -> [(pid: pid_t, binary: String, processGroup: pid_t?)]    {        self.current.drainForShutdown(onFenceSet: onFenceSet)    }     static func reset() {        self.current.reset()    }     static func count() -> Int {        self.current.count()    }     static func testTrackProcess(pid: pid_t, binary: String, processGroup: pid_t?) {        self.current.testTrackProcess(pid: pid, binary: binary, processGroup: processGroup)    }     static func withIsolatedStateForTesting<T>(_ operation: () throws -> T) rethrows -> T {        try self.$stateOverrideForTesting.withValue(State(), operation: operation)    }     static func makeDrainOperationForTesting(onFenceSet: (@Sendable () -> Void)? = nil)        -> @Sendable () -> [(pid: pid_t, binary: String, processGroup: pid_t?)]    {        let state = self.current        return { state.drainForShutdown(onFenceSet: onFenceSet) }    }} enum TTYProcessTreeTerminator {    struct ProcessIdentity: Hashable {        let pid: pid_t        let startToken: UInt64    }     static func descendantPIDs(        of rootPID: pid_t,        childResolver: (pid_t) -> [pid_t] = Self.currentChildPIDs(of:)) -> [pid_t]    {        guard rootPID > 0 else { return [] }         var seen: Set<pid_t> = [rootPID]        var pending = childResolver(rootPID)        var descendants: [pid_t] = []         while let pid = pending.popLast() {            guard pid > 0, seen.insert(pid).inserted else { continue }            descendants.append(pid)            pending.append(contentsOf: childResolver(pid))        }         return descendants    }     static func currentChildPIDs(of parentPID: pid_t) -> [pid_t] {        guard parentPID > 0 else { return [] }         #if canImport(Darwin)        var pids = [pid_t](repeating: 0, count: 128)        let byteCount = Int32(pids.count * MemoryLayout<pid_t>.stride)        let childCount = proc_listchildpids(parentPID, &pids, byteCount)        guard childCount > 0 else { return [] }        return Array(pids.prefix(min(Int(childCount), pids.count))).filter { $0 > 0 }        #else        let taskPath = "/proc/\(parentPID)/task"        guard let taskIDs = try? FileManager.default.contentsOfDirectory(atPath: taskPath) else { return [] }         var children: Set<pid_t> = []        for taskID in taskIDs {            let childrenPath = "\(taskPath)/\(taskID)/children"            guard let text = try? String(contentsOfFile: childrenPath, encoding: .utf8) else { continue }            children.formUnion(text.split(whereSeparator: \.isWhitespace).compactMap { pid_t($0) })        }        return children.sorted()        #endif    }     static func processIdentity(for pid: pid_t) -> ProcessIdentity? {        guard pid > 0 else { return nil }         #if canImport(Darwin)        var info = proc_bsdinfo()        let size = proc_pidinfo(            pid,            PROC_PIDTBSDINFO,            0,            &info,            Int32(MemoryLayout<proc_bsdinfo>.stride))        guard size == Int32(MemoryLayout<proc_bsdinfo>.stride) else { return nil }        let startToken = UInt64(info.pbi_start_tvsec) * 1_000_000 + UInt64(info.pbi_start_tvusec)        return ProcessIdentity(pid: pid, startToken: startToken)        #else        guard let stat = try? String(contentsOfFile: "/proc/\(pid)/stat", encoding: .utf8),              let commandEnd = stat.lastIndex(of: ")")        else {            return nil        }        let fields = stat[stat.index(after: commandEnd)...].split(whereSeparator: \.isWhitespace)        guard fields.count > 19, let startToken = UInt64(fields[19]) else { return nil }        return ProcessIdentity(pid: pid, startToken: startToken)        #endif    }     static func isCurrent(_ identity: ProcessIdentity) -> Bool {        self.processIdentity(for: identity.pid) == identity    }     static func terminateProcessTree(        rootPID: pid_t,        processGroup: pid_t?,        signal: Int32,        knownDescendants: [pid_t] = [],        childResolver: (pid_t) -> [pid_t] = Self.currentChildPIDs(of:),        signalSender: (pid_t, Int32) -> Void = { kill($0, $1) })    {        guard rootPID > 0 else { return }         var seen: Set<pid_t> = [rootPID]        let descendants = knownDescendants + self.descendantPIDs(of: rootPID, childResolver: childResolver)        for pid in descendants where pid > 0 && seen.insert(pid).inserted {            signalSender(pid, signal)        }        if let processGroup {            signalSender(-processGroup, signal)        }        signalSender(rootPID, signal)    }} private enum TTYCommandRunnerTestingOverrides {    @TaskLocal static var postDeadlineDrainDuration: TimeInterval?} /// Executes an interactive CLI inside a pseudo-terminal and returns all captured text./// Keeps it minimal so we can reuse for Codex and Claude without tmux.public struct TTYCommandRunner {    private static let log = CodexBarLog.logger(LogCategories.ttyRunner)     public struct Result: Sendable {        public enum Completion: Sendable, Equatable {            case processExited(status: Int32)            case idleTimeout            case outputCondition            case deadlineExceeded        }         public let text: String        public let completion: Completion    }     public struct Options: Sendable {        public var rows: UInt16 = 50        public var cols: UInt16 = 160        public var timeout: TimeInterval = 20.0        /// Stop early once output has been idle for this long (only for non-Codex flows).        /// Useful for interactive TUIs that render once and then wait for input indefinitely.        public var idleTimeout: TimeInterval?        public var workingDirectory: URL?        public var extraArgs: [String] = []        public var baseEnvironment: [String: String]?        public var initialDelay: TimeInterval = 0.4        public var sendEnterEvery: TimeInterval?        public var sendOnSubstrings: [String: String]        public var stopOnURL: Bool        public var stopOnSubstrings: [String]        public var settleAfterStop: TimeInterval        public var forceCodexStatusMode: Bool        public var useClaudeProbeWorkingDirectory: Bool        public var returnOnEmptyProcessExit: Bool        public var cancellationCheck: @Sendable () -> Bool         public init(            rows: UInt16 = 50,            cols: UInt16 = 160,            timeout: TimeInterval = 20.0,            idleTimeout: TimeInterval? = nil,            workingDirectory: URL? = nil,            extraArgs: [String] = [],            baseEnvironment: [String: String]? = nil,            initialDelay: TimeInterval = 0.4,            sendEnterEvery: TimeInterval? = nil,            sendOnSubstrings: [String: String] = [:],            stopOnURL: Bool = false,            stopOnSubstrings: [String] = [],            settleAfterStop: TimeInterval = 0.25,            forceCodexStatusMode: Bool = false,            useClaudeProbeWorkingDirectory: Bool = false,            returnOnEmptyProcessExit: Bool = false,            cancellationCheck: @escaping @Sendable () -> Bool = { Task<Never, Never>.isCancelled })        {            self.rows = rows            self.cols = cols            self.timeout = timeout            self.idleTimeout = idleTimeout            self.workingDirectory = workingDirectory            self.extraArgs = extraArgs            self.baseEnvironment = baseEnvironment            self.initialDelay = initialDelay            self.sendEnterEvery = sendEnterEvery            self.sendOnSubstrings = sendOnSubstrings            self.stopOnURL = stopOnURL            self.stopOnSubstrings = stopOnSubstrings            self.settleAfterStop = settleAfterStop            self.forceCodexStatusMode = forceCodexStatusMode            self.useClaudeProbeWorkingDirectory = useClaudeProbeWorkingDirectory            self.returnOnEmptyProcessExit = returnOnEmptyProcessExit            self.cancellationCheck = cancellationCheck        }    }     public enum Error: Swift.Error, LocalizedError, Sendable {        case binaryNotFound(String)        case launchFailed(String)        case timedOut        case outputTooLarge         public var errorDescription: String? {            switch self {            case let .binaryNotFound(bin):                "Missing CLI '\(bin)'. Install it (e.g. npm i -g @openai/codex) or add it to PATH."            case let .launchFailed(msg): "Failed to launch process: \(msg)"            case .timedOut: "PTY command timed out."            case .outputTooLarge: "PTY command produced more output than CodexBar can safely process."            }        }    }     public init() {}     public static func terminateActiveProcessesForAppShutdown() {        let targets = TTYCommandRunnerActiveProcessRegistry.drainForShutdown()        guard !targets.isEmpty else { return }         let resolvedTargets = self.resolveShutdownTargets(            targets,            hostProcessGroup: getpgrp(),            groupResolver: { getpgid($0) })         for target in resolvedTargets where target.pid > 0 {            TTYProcessTreeTerminator.terminateProcessTree(                rootPID: target.pid,                processGroup: target.processGroup,                signal: SIGTERM)        }         for target in resolvedTargets where target.pid > 0 {            TTYProcessTreeTerminator.terminateProcessTree(                rootPID: target.pid,                processGroup: target.processGroup,                signal: SIGKILL)        }    }     private static func resolveShutdownTargets(        _ targets: [(pid: pid_t, binary: String, processGroup: pid_t?)],        hostProcessGroup: pid_t,        groupResolver: (pid_t) -> pid_t) -> [(pid: pid_t, binary: String, processGroup: pid_t?)]    {        var resolvedTargets: [(pid: pid_t, binary: String, processGroup: pid_t?)] = []        resolvedTargets.reserveCapacity(targets.count)         for target in targets {            var resolvedGroup = target.processGroup            if resolvedGroup == nil {                let pgid = groupResolver(target.pid)                if pgid > 0, pgid != hostProcessGroup {                    resolvedGroup = pgid                }            } else if resolvedGroup == hostProcessGroup {                resolvedGroup = nil            }             resolvedTargets.append((pid: target.pid, binary: target.binary, processGroup: resolvedGroup))        }        return resolvedTargets    }     struct RollingBuffer {        private let maxNeedle: Int        private var tail = Data()         init(maxNeedle: Int) {            self.maxNeedle = max(0, maxNeedle)        }         mutating func append(_ data: Data) -> Data {            guard !data.isEmpty else { return Data() }             var combined = Data()            combined.reserveCapacity(self.tail.count + data.count)            combined.append(self.tail)            combined.append(data)             if self.maxNeedle > 1 {                if combined.count >= self.maxNeedle - 1 {                    self.tail = combined.suffix(self.maxNeedle - 1)                } else {                    self.tail = combined                }            } else {                self.tail.removeAll(keepingCapacity: true)            }             return combined        }         mutating func reset() {            self.tail.removeAll(keepingCapacity: true)        }    }     enum DrainReadResult {        case data(Data)        case wouldBlock        case closed    }     static func lowercasedASCII(_ data: Data) -> Data {        guard !data.isEmpty else { return data }        var out = Data(count: data.count)        out.withUnsafeMutableBytes { dest in            data.withUnsafeBytes { source in                let src = source.bindMemory(to: UInt8.self)                let dst = dest.bindMemory(to: UInt8.self)                for idx in 0..<src.count {                    var byte = src[idx]                    if byte >= 65, byte <= 90 {                        byte += 32                    }                    dst[idx] = byte                }            }        }        return out    }     static func drainRemainingOutput(        until drainDeadline: Date,        readChunk: () -> DrainReadResult,        processChunk: (Data) -> Void,        sleep: (UInt32) -> Void = { usleep($0) })    {        while Date() < drainDeadline {            switch readChunk() {            case let .data(newData):                processChunk(newData)            case .wouldBlock:                sleep(20000)            case .closed:                return            }        }    }     static func drainReadResult(for data: Data, terminalRead: Int, errno err: Int32) -> DrainReadResult {        if !data.isEmpty {            return .data(data)        }         if terminalRead == 0 {            return .closed        } 

Lines 1–500 of 1346.