#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) /// Thread-safe flag for communicating between concurrent tasks (e.g. timeout → caller). private final class KillFlag: @unchecked Sendable { private let lock = NSLock() private var value = false func set() { self.lock.withLock { self.value = true } } var isSet: Bool { self.lock.withLock { self.value } } } private final class ProcessTermination: @unchecked Sendable { private let lock = NSLock() private var status: Int32? private var continuation: CheckedContinuation<Int32, Never>? func resolve(_ status: Int32) { let continuation: CheckedContinuation<Int32, Never>? self.lock.lock() self.status = status continuation = self.continuation self.continuation = nil self.lock.unlock() continuation?.resume(returning: status) } func wait() async -> Int32 { await withCheckedContinuation { continuation in let status: Int32? self.lock.lock() status = self.status if status == nil { self.continuation = continuation } self.lock.unlock() if let status { continuation.resume(returning: status) } } } } // MARK: - Helpers to move blocking calls off the cooperative thread pool /// Reads pipe data on a GCD thread so it does not block the Swift cooperative pool. private static func readDataOffPool(_ fileHandle: FileHandle) async -> Data { await withCheckedContinuation { continuation in DispatchQueue.global().async { var output = Data() while true { do { guard let data = try fileHandle.read(upToCount: 64 * 1024), data.isEmpty == false else { break } output.append(data) } catch { break } } continuation.resume(returning: output) } } } /// Terminates a process and its process group, escalating from SIGTERM to SIGKILL. /// Returns `true` if the process was actually killed, `false` if it had already exited. @discardableResult private static func terminateProcess(_ process: Process, processGroup: pid_t?) -> Bool { guard process.isRunning else { return false } 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) } return true } // MARK: - Public API 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 termination = ProcessTermination() process.terminationHandler = { process in termination.resolve(process.terminationStatus) } do { try process.run() } catch { process.terminationHandler = nil stdoutPipe.fileHandleForReading.closeFile() stdoutPipe.fileHandleForWriting.closeFile() stderrPipe.fileHandleForReading.closeFile() stderrPipe.fileHandleForWriting.closeFile() throw SubprocessRunnerError.launchFailed(error.localizedDescription) } let pid = process.processIdentifier let processGroup: pid_t? = setpgid(pid, pid) == 0 ? pid : nil let stdoutTask = Task<Data, Never> { await self.readDataOffPool(stdoutPipe.fileHandleForReading) } let stderrTask = Task<Data, Never> { await self.readDataOffPool(stderrPipe.fileHandleForReading) } let exitCodeTask = Task<Int32, Never> { await termination.wait() } let killedByTimeout = KillFlag() 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)) // Kill the process BEFORE throwing so the exit-code task can complete // and withThrowingTaskGroup can exit promptly. Only throw if we // actually killed the process; if it already exited, let the exit // code win the race naturally. guard self.terminateProcess(process, processGroup: processGroup) else { return await exitCodeTask.value } killedByTimeout.set() throw SubprocessRunnerError.timedOut(label) } let code = try await group.next()! group.cancelAll() return code } // Race guard: our timeout task killed the process, but the exit code // arrived at group.next() before the .timedOut throw. Use the explicit // flag instead of wall-clock heuristics to avoid misclassifying processes // that crash or are killed externally. if killedByTimeout.isSet { let duration = Date().timeIntervalSince(start) self.log.warning( "Subprocess timed out (race)", metadata: [ "label": label, "binary": binaryName, "duration_ms": "\(Int(duration * 1000))", ]) stdoutTask.cancel() stderrTask.cancel() throw SubprocessRunnerError.timedOut(label) } 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))", ]) // Safety net: ensure the process is dead (may already be killed by timeout task). self.terminateProcess(process, processGroup: processGroup) exitCodeTask.cancel() stdoutTask.cancel() stderrTask.cancel() throw error } }}
#if canImport(Darwin)
import Darwin
#else
import Glibc
#endif
import 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)
/// Thread-safe flag for communicating between concurrent tasks (e.g. timeout → caller).
private final class KillFlag: @unchecked Sendable {
private let lock = NSLock()
private var value = false
func set() {
self.lock.withLock { self.value = true }
}
var isSet: Bool {
self.lock.withLock { self.value }
}
}
private final class ProcessTermination: @unchecked Sendable {
private let lock = NSLock()
private var status: Int32?
private var continuation: CheckedContinuation<Int32, Never>?
func resolve(_ status: Int32) {
let continuation: CheckedContinuation<Int32, Never>?
self.lock.lock()
self.status = status
continuation = self.continuation
self.continuation = nil
self.lock.unlock()
continuation?.resume(returning: status)
}
func wait() async -> Int32 {
await withCheckedContinuation { continuation in
let status: Int32?
self.lock.lock()
status = self.status
if status == nil {
self.continuation = continuation
}
self.lock.unlock()
if let status {
continuation.resume(returning: status)
}
}
}
}
// MARK: - Helpers to move blocking calls off the cooperative thread pool
/// Reads pipe data on a GCD thread so it does not block the Swift cooperative pool.
private static func readDataOffPool(_ fileHandle: FileHandle) async -> Data {
await withCheckedContinuation { continuation in
DispatchQueue.global().async {
var output = Data()
while true {
do {
guard let data = try fileHandle.read(upToCount: 64 * 1024), data.isEmpty == false else {
break
}
output.append(data)
} catch {
break
}
}
continuation.resume(returning: output)
}
}
}
/// Terminates a process and its process group, escalating from SIGTERM to SIGKILL.
/// Returns `true` if the process was actually killed, `false` if it had already exited.
@discardableResult
private static func terminateProcess(_ process: Process, processGroup: pid_t?) -> Bool {
guard process.isRunning else { return false }
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)
}
return true
}
// MARK: - Public API
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 termination = ProcessTermination()
process.terminationHandler = { process in
termination.resolve(process.terminationStatus)
}
do {
try process.run()
} catch {
process.terminationHandler = nil
stdoutPipe.fileHandleForReading.closeFile()
stdoutPipe.fileHandleForWriting.closeFile()
stderrPipe.fileHandleForReading.closeFile()
stderrPipe.fileHandleForWriting.closeFile()
throw SubprocessRunnerError.launchFailed(error.localizedDescription)
}
let pid = process.processIdentifier
let processGroup: pid_t? = setpgid(pid, pid) == 0 ? pid : nil
let stdoutTask = Task<Data, Never> {
await self.readDataOffPool(stdoutPipe.fileHandleForReading)
}
let stderrTask = Task<Data, Never> {
await self.readDataOffPool(stderrPipe.fileHandleForReading)
}
let exitCodeTask = Task<Int32, Never> {
await termination.wait()
}
let killedByTimeout = KillFlag()
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))
// Kill the process BEFORE throwing so the exit-code task can complete
// and withThrowingTaskGroup can exit promptly. Only throw if we
// actually killed the process; if it already exited, let the exit
// code win the race naturally.
guard self.terminateProcess(process, processGroup: processGroup) else {
return await exitCodeTask.value
}
killedByTimeout.set()
throw SubprocessRunnerError.timedOut(label)
}
let code = try await group.next()!
group.cancelAll()
return code
}
// Race guard: our timeout task killed the process, but the exit code
// arrived at group.next() before the .timedOut throw. Use the explicit
// flag instead of wall-clock heuristics to avoid misclassifying processes
// that crash or are killed externally.
if killedByTimeout.isSet {
let duration = Date().timeIntervalSince(start)
self.log.warning(
"Subprocess timed out (race)",
metadata: [
"label": label,
"binary": binaryName,
"duration_ms": "\(Int(duration * 1000))",
])
stdoutTask.cancel()
stderrTask.cancel()
throw SubprocessRunnerError.timedOut(label)
}
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))",
])
// Safety net: ensure the process is dead (may already be killed by timeout task).
self.terminateProcess(process, processGroup: processGroup)
exitCodeTask.cancel()
stdoutTask.cancel()
stderrTask.cancel()
throw error
}
}
}