169 lines · 5.7 KB · Swift
RawDownload
#if canImport(Darwin)import Darwin#elseimport Glibc#endifimport Foundation public enum SubprocessRunnerError: LocalizedError, Sendable {    case binaryNotFound(String)    case launchFailed(String)    case timedOut(String)    case nonZeroExit(code: Int32, stderr: String)     public var errorDescription: String? {        switch self {        case let .binaryNotFound(binary):            return "Missing CLI '\(binary)'. Install it and restart CodexBar."        case let .launchFailed(details):            return "Failed to launch process: \(details)"        case let .timedOut(label):            return "Command timed out: \(label)"        case let .nonZeroExit(code, stderr):            let trimmed = stderr.trimmingCharacters(in: .whitespacesAndNewlines)            if trimmed.isEmpty {                return "Command failed with exit code \(code)."            }            return "Command failed (\(code)): \(trimmed)"        }    }} public struct SubprocessResult: Sendable {    public let stdout: String    public let stderr: String} public enum SubprocessRunner {    private static let log = CodexBarLog.logger(LogCategories.subprocess)     public static func run(        binary: String,        arguments: [String],        environment: [String: String],        timeout: TimeInterval,        label: String) async throws -> SubprocessResult    {        guard FileManager.default.isExecutableFile(atPath: binary) else {            throw SubprocessRunnerError.binaryNotFound(binary)        }         let start = Date()        let binaryName = URL(fileURLWithPath: binary).lastPathComponent        self.log.debug(            "Subprocess start",            metadata: ["label": label, "binary": binaryName, "timeout": "\(timeout)"])         let process = Process()        process.executableURL = URL(fileURLWithPath: binary)        process.arguments = arguments        process.environment = environment         let stdoutPipe = Pipe()        let stderrPipe = Pipe()        process.standardOutput = stdoutPipe        process.standardError = stderrPipe        process.standardInput = nil         let stdoutTask = Task<Data, Never> {            stdoutPipe.fileHandleForReading.readDataToEndOfFile()        }        let stderrTask = Task<Data, Never> {            stderrPipe.fileHandleForReading.readDataToEndOfFile()        }         do {            try process.run()        } catch {            stdoutTask.cancel()            stderrTask.cancel()            stdoutPipe.fileHandleForReading.closeFile()            stderrPipe.fileHandleForReading.closeFile()            throw SubprocessRunnerError.launchFailed(error.localizedDescription)        }         var processGroup: pid_t?        let pid = process.processIdentifier        if setpgid(pid, pid) == 0 {            processGroup = pid        }         let exitCodeTask = Task<Int32, Never> {            process.waitUntilExit()            return process.terminationStatus        }         do {            let exitCode = try await withThrowingTaskGroup(of: Int32.self) { group in                group.addTask { await exitCodeTask.value }                group.addTask {                    try await Task.sleep(for: .seconds(timeout))                    throw SubprocessRunnerError.timedOut(label)                }                let code = try await group.next()!                group.cancelAll()                return code            }             let stdoutData = await stdoutTask.value            let stderrData = await stderrTask.value            let stdout = String(data: stdoutData, encoding: .utf8) ?? ""            let stderr = String(data: stderrData, encoding: .utf8) ?? ""             if exitCode != 0 {                let duration = Date().timeIntervalSince(start)                self.log.warning(                    "Subprocess failed",                    metadata: [                        "label": label,                        "binary": binaryName,                        "status": "\(exitCode)",                        "duration_ms": "\(Int(duration * 1000))",                    ])                throw SubprocessRunnerError.nonZeroExit(code: exitCode, stderr: stderr)            }             let duration = Date().timeIntervalSince(start)            self.log.debug(                "Subprocess exit",                metadata: [                    "label": label,                    "binary": binaryName,                    "status": "\(exitCode)",                    "duration_ms": "\(Int(duration * 1000))",                ])            return SubprocessResult(stdout: stdout, stderr: stderr)        } catch {            let duration = Date().timeIntervalSince(start)            self.log.warning(                "Subprocess error",                metadata: [                    "label": label,                    "binary": binaryName,                    "duration_ms": "\(Int(duration * 1000))",                ])            if process.isRunning {                process.terminate()                if let pgid = processGroup {                    kill(-pgid, SIGTERM)                }                let killDeadline = Date().addingTimeInterval(0.4)                while process.isRunning, Date() < killDeadline {                    usleep(50000)                }                if process.isRunning {                    if let pgid = processGroup {                        kill(-pgid, SIGKILL)                    }                    kill(process.processIdentifier, SIGKILL)                }            }            exitCodeTask.cancel()            stdoutTask.cancel()            stderrTask.cancel()            stdoutPipe.fileHandleForReading.closeFile()            stderrPipe.fileHandleForReading.closeFile()            throw error        }    }}