tv-anarchy/Sources/TVAnarchyCore/ProcessRunner.swift

93 lines
3.8 KiB
Swift
Raw Normal View History

import Foundation
public struct ProcessResult: Sendable {
public let status: Int32
public let stdout: String
public let stderr: String
public var ok: Bool { status == 0 }
}
/// Minimal blocking command runner. Outputs here are tiny (a line of JSON), so
/// the read-to-EOF pattern is safe. Always call off the main thread.
public enum ProcessRunner {
public static func run(_ launchPath: String, _ args: [String]) -> ProcessResult {
let p = Process()
p.executableURL = URL(fileURLWithPath: launchPath)
p.arguments = args
let out = Pipe()
let err = Pipe()
p.standardOutput = out
p.standardError = err
do {
try p.run()
} catch {
return ProcessResult(status: -1, stdout: "", stderr: "spawn failed: \(error.localizedDescription)")
}
let o = out.fileHandleForReading.readDataToEndOfFile()
let e = err.fileHandleForReading.readDataToEndOfFile()
p.waitUntilExit()
return ProcessResult(status: p.terminationStatus,
stdout: String(decoding: o, as: UTF8.self),
stderr: String(decoding: e, as: UTF8.self))
}
/// Run a command string under an interactive login shell (`/bin/zsh -ilc`) so
/// the user's full PATH (bun in ~/.bun/bin, uv, ) is available even from a GUI
/// app's minimal environment. `-i` is required because bun's PATH is exported
/// from ~/.zshrc, which a non-interactive login shell does NOT source.
/// Output is drained on background queues to avoid pipe-buffer deadlock, and
/// the process is terminated if it exceeds `timeout`.
public static func runShell(_ command: String, timeout: TimeInterval, cwd: String? = nil) -> ProcessResult {
let p = Process()
p.executableURL = URL(fileURLWithPath: "/bin/zsh")
p.arguments = ["-ilc", command]
if let cwd { p.currentDirectoryURL = URL(fileURLWithPath: cwd) }
let out = Pipe()
let err = Pipe()
p.standardOutput = out
p.standardError = err
let sem = DispatchSemaphore(value: 0)
p.terminationHandler = { _ in sem.signal() }
let group = DispatchGroup()
let q = DispatchQueue(label: "procrunner.read", attributes: .concurrent)
let box = OutputBox()
for (handle, isErr) in [(out.fileHandleForReading, false), (err.fileHandleForReading, true)] {
group.enter()
q.async {
let d = handle.readDataToEndOfFile()
box.set(d, isErr: isErr)
group.leave()
}
}
do { try p.run() } catch {
return ProcessResult(status: -1, stdout: "", stderr: "spawn failed: \(error.localizedDescription)")
}
var timedOut = false
if sem.wait(timeout: .now() + timeout) == .timedOut {
timedOut = true
p.terminate()
sem.wait()
}
group.wait()
let (o, e) = box.get()
let stderr = timedOut ? "timed out after \(Int(timeout))s\n" + String(decoding: e, as: UTF8.self)
: String(decoding: e, as: UTF8.self)
return ProcessResult(status: timedOut ? -2 : p.terminationStatus,
stdout: String(decoding: o, as: UTF8.self),
stderr: stderr)
}
}
/// Thread-safe collector for the two output streams drained concurrently.
private final class OutputBox: @unchecked Sendable {
private let lock = NSLock()
private var outData = Data()
private var errData = Data()
func set(_ d: Data, isErr: Bool) { lock.lock(); if isErr { errData = d } else { outData = d }; lock.unlock() }
func get() -> (Data, Data) { lock.lock(); defer { lock.unlock() }; return (outData, errData) }
}