322 lines
12 KiB
Swift
322 lines
12 KiB
Swift
#if canImport(Darwin)
|
|
import Darwin
|
|
#elseif canImport(Glibc)
|
|
import Glibc
|
|
#elseif canImport(Musl)
|
|
import Musl
|
|
#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)
|
|
private static let timeoutQueue = DispatchQueue(
|
|
label: "com.steipete.codexbar.subprocess.timeout",
|
|
qos: .userInitiated,
|
|
attributes: .concurrent)
|
|
|
|
/// 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 TimeoutTimer: @unchecked Sendable {
|
|
private let timer: any DispatchSourceTimer
|
|
|
|
init(timer: any DispatchSourceTimer) {
|
|
self.timer = timer
|
|
}
|
|
|
|
func cancel() {
|
|
self.timer.cancel()
|
|
}
|
|
}
|
|
|
|
private static func timeoutInterval(_ timeout: TimeInterval) -> DispatchTimeInterval {
|
|
guard timeout.isFinite else {
|
|
return .seconds(Int.max)
|
|
}
|
|
let nanoseconds = max(0, min(timeout * 1_000_000_000, Double(Int.max)))
|
|
return .nanoseconds(Int(nanoseconds))
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
package static func terminateProcess(_ process: Process, processGroup: pid_t?) -> Bool {
|
|
guard process.isRunning else { return false }
|
|
let descendants = TTYProcessTreeTerminator.descendantPIDs(of: process.processIdentifier)
|
|
let descendantIdentities = descendants.compactMap(TTYProcessTreeTerminator.processIdentity(for:))
|
|
TTYProcessTreeTerminator.terminateProcessTree(
|
|
rootPID: process.processIdentifier,
|
|
processGroup: processGroup,
|
|
signal: SIGTERM,
|
|
knownDescendants: descendants)
|
|
let killDeadline = Date().addingTimeInterval(0.4)
|
|
while process.isRunning, Date() < killDeadline {
|
|
usleep(50000)
|
|
}
|
|
if process.isRunning {
|
|
let currentDescendants = descendantIdentities
|
|
.filter(TTYProcessTreeTerminator.isCurrent(_:))
|
|
.map(\.pid)
|
|
TTYProcessTreeTerminator.terminateProcessTree(
|
|
rootPID: process.processIdentifier,
|
|
processGroup: processGroup,
|
|
signal: SIGKILL,
|
|
knownDescendants: currentDescendants)
|
|
let reapDeadline = Date().addingTimeInterval(0.4)
|
|
while process.isRunning, Date() < reapDeadline {
|
|
usleep(50000)
|
|
}
|
|
} else {
|
|
for identity in descendantIdentities where TTYProcessTreeTerminator.isCurrent(identity) {
|
|
kill(identity.pid, SIGKILL)
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// MARK: - Public API
|
|
|
|
/// Runs a process to natural exit without letting caller cancellation or a
|
|
/// forced timeout interrupt an external mutation after launch.
|
|
public static func runToCompletion(
|
|
binary: String,
|
|
arguments: [String],
|
|
environment: [String: String],
|
|
currentDirectoryURL: URL? = nil,
|
|
acceptsNonZeroExit: Bool = false,
|
|
label: String) async throws -> SubprocessResult
|
|
{
|
|
let task = Task.detached(priority: .userInitiated) {
|
|
try await self.run(
|
|
binary: binary,
|
|
arguments: arguments,
|
|
environment: environment,
|
|
timeout: .infinity,
|
|
currentDirectoryURL: currentDirectoryURL,
|
|
acceptsNonZeroExit: acceptsNonZeroExit,
|
|
label: label)
|
|
}
|
|
return try await task.value
|
|
}
|
|
|
|
public static func run(
|
|
binary: String,
|
|
arguments: [String],
|
|
environment: [String: String],
|
|
timeout: TimeInterval,
|
|
standardInput: Any? = nil,
|
|
currentDirectoryURL: URL? = nil,
|
|
acceptsNonZeroExit: Bool = false,
|
|
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
|
|
process.currentDirectoryURL = currentDirectoryURL
|
|
|
|
let stdoutPipe = Pipe()
|
|
let stderrPipe = Pipe()
|
|
process.standardOutput = stdoutPipe
|
|
process.standardError = stderrPipe
|
|
process.standardInput = standardInput
|
|
let stdoutCapture = ProcessPipeCapture(pipe: stdoutPipe)
|
|
let stderrCapture = ProcessPipeCapture(pipe: stderrPipe)
|
|
|
|
let termination = ProcessTermination()
|
|
process.terminationHandler = { process in
|
|
termination.resolve(process.terminationStatus)
|
|
}
|
|
|
|
do {
|
|
try process.run()
|
|
} catch {
|
|
process.terminationHandler = nil
|
|
stdoutCapture.stop()
|
|
stdoutPipe.fileHandleForWriting.closeFile()
|
|
stderrCapture.stop()
|
|
stderrPipe.fileHandleForWriting.closeFile()
|
|
throw SubprocessRunnerError.launchFailed(error.localizedDescription)
|
|
}
|
|
stdoutCapture.start()
|
|
stderrCapture.start()
|
|
|
|
let pid = process.processIdentifier
|
|
let processGroup: pid_t? = setpgid(pid, pid) == 0 ? pid : nil
|
|
|
|
let exitCodeTask = Task<Int32, Never> {
|
|
await termination.wait()
|
|
}
|
|
|
|
let killedByTimeout = KillFlag()
|
|
let timeoutTimerBox: TimeoutTimer? = if timeout.isFinite {
|
|
{
|
|
let timeoutTimer = DispatchSource.makeTimerSource(queue: self.timeoutQueue)
|
|
timeoutTimer.schedule(deadline: .now() + self.timeoutInterval(timeout))
|
|
timeoutTimer.setEventHandler {
|
|
guard process.isRunning else { return }
|
|
killedByTimeout.set()
|
|
self.terminateProcess(process, processGroup: processGroup)
|
|
}
|
|
timeoutTimer.resume()
|
|
return TimeoutTimer(timer: timeoutTimer)
|
|
}()
|
|
} else {
|
|
nil
|
|
}
|
|
|
|
do {
|
|
let exitCode = try await withTaskCancellationHandler {
|
|
try Task.checkCancellation()
|
|
let code = await exitCodeTask.value
|
|
try Task.checkCancellation()
|
|
return code
|
|
} onCancel: {
|
|
timeoutTimerBox?.cancel()
|
|
self.terminateProcess(process, processGroup: processGroup)
|
|
}
|
|
timeoutTimerBox?.cancel()
|
|
|
|
let duration = Date().timeIntervalSince(start)
|
|
// Race guard: the timeout timer may kill the process just before the
|
|
// exit code arrives. Key off the explicit kill flag so a completed
|
|
// process is not misclassified when the awaiting task resumes late.
|
|
if killedByTimeout.isSet {
|
|
self.log.warning(
|
|
"Subprocess timed out",
|
|
metadata: [
|
|
"label": label,
|
|
"binary": binaryName,
|
|
"duration_ms": "\(Int(duration * 1000))",
|
|
])
|
|
throw SubprocessRunnerError.timedOut(label)
|
|
}
|
|
|
|
async let stdoutData = stdoutCapture.finish(timeout: .seconds(1))
|
|
async let stderrData = stderrCapture.finish(timeout: .seconds(1))
|
|
let stdout = await ProcessPipeCapture.decodeUTF8(stdoutData)
|
|
let stderr = await ProcessPipeCapture.decodeUTF8(stderrData)
|
|
|
|
if exitCode != 0, !acceptsNonZeroExit {
|
|
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)
|
|
}
|
|
|
|
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 timer).
|
|
self.terminateProcess(process, processGroup: processGroup)
|
|
exitCodeTask.cancel()
|
|
stdoutCapture.stop()
|
|
stderrCapture.stop()
|
|
throw error
|
|
}
|
|
}
|
|
}
|