chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,321 @@
|
||||
#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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user