chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
#if canImport(Darwin)
|
||||
import Darwin
|
||||
#elseif canImport(Glibc)
|
||||
import Glibc
|
||||
#elseif canImport(Musl)
|
||||
import Musl
|
||||
#endif
|
||||
import Foundation
|
||||
|
||||
enum PosixSpawnFileActionsCloseFrom {
|
||||
enum CloseFromError: LocalizedError {
|
||||
case descriptorEnumerationFailed(String)
|
||||
case actionFailed(Int32)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case let .descriptorEnumerationFailed(details):
|
||||
"Could not enumerate /proc/self/fd: \(details)"
|
||||
case let .actionFailed(code):
|
||||
"Could not configure descriptor cleanup: \(String(cString: strerror(code))) (\(code))"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func descriptorsToClose(
|
||||
startingAt minimumFileDescriptor: Int32,
|
||||
contentsOfDirectory: (String) throws -> [String] = FileManager.default.contentsOfDirectory(atPath:)) throws
|
||||
-> [Int32]
|
||||
{
|
||||
let entries: [String]
|
||||
do {
|
||||
entries = try contentsOfDirectory("/proc/self/fd")
|
||||
} catch {
|
||||
throw CloseFromError.descriptorEnumerationFailed(error.localizedDescription)
|
||||
}
|
||||
return entries.compactMap(Int32.init)
|
||||
.filter { $0 >= minimumFileDescriptor }
|
||||
.sorted()
|
||||
}
|
||||
|
||||
#if canImport(Glibc) || canImport(Musl)
|
||||
static func addCloseFrom(
|
||||
_ fileActions: inout posix_spawn_file_actions_t,
|
||||
startingAt minimumFileDescriptor: Int32) throws
|
||||
{
|
||||
#if canImport(Glibc)
|
||||
try self.check(posix_spawn_file_actions_addclosefrom_np(&fileActions, minimumFileDescriptor))
|
||||
#else
|
||||
for descriptor in try self.descriptorsToClose(startingAt: minimumFileDescriptor) {
|
||||
try self.check(posix_spawn_file_actions_addclose(&fileActions, descriptor))
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private static func check(_ result: Int32) throws {
|
||||
guard result == 0 else {
|
||||
throw CloseFromError.actionFailed(result)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import Foundation
|
||||
|
||||
package final class ProcessPipeCapture: @unchecked Sendable {
|
||||
package static let defaultMaxBytes = 1 * 1024 * 1024
|
||||
|
||||
private let handle: FileHandle
|
||||
private let onData: (@Sendable () -> Void)?
|
||||
private let maxBytes: Int
|
||||
private let condition = NSCondition()
|
||||
private var data = Data()
|
||||
private var activeCallbacks = 0
|
||||
private var isFinished = false
|
||||
private var didReachEOF = false
|
||||
private var isStopping = false
|
||||
private var continuation: CheckedContinuation<Void, Never>?
|
||||
|
||||
package init(
|
||||
pipe: Pipe,
|
||||
maxBytes: Int = ProcessPipeCapture.defaultMaxBytes,
|
||||
onData: (@Sendable () -> Void)? = nil)
|
||||
{
|
||||
self.handle = pipe.fileHandleForReading
|
||||
self.maxBytes = max(0, maxBytes)
|
||||
self.onData = onData
|
||||
}
|
||||
|
||||
package func start() {
|
||||
self.handle.readabilityHandler = { [weak self] handle in
|
||||
self?.handleReadableData(from: handle)
|
||||
}
|
||||
}
|
||||
|
||||
package func finish(timeout: Duration) async -> Data {
|
||||
let drainTask = Task<Void, Error> {
|
||||
await self.waitUntilFinished()
|
||||
}
|
||||
let join = BoundedTaskJoin(sourceTask: drainTask)
|
||||
_ = await join.value(joinGrace: timeout)
|
||||
return self.stopAndSnapshot()
|
||||
}
|
||||
|
||||
package func finishSynchronously(timeout: TimeInterval) -> Data {
|
||||
let deadline = Date().addingTimeInterval(max(0, timeout))
|
||||
self.condition.lock()
|
||||
while !self.isFinished, !self.isStopping {
|
||||
guard self.condition.wait(until: deadline) else { break }
|
||||
}
|
||||
self.condition.unlock()
|
||||
return self.stopAndSnapshot()
|
||||
}
|
||||
|
||||
/// Waits only for the first complete output line. Useful for helpers whose descendants may inherit stdout
|
||||
/// after the helper itself exits, preventing EOF even though the caller already has its complete answer.
|
||||
package func finishFirstLineSynchronously(timeout: TimeInterval) -> Data {
|
||||
let deadline = Date().addingTimeInterval(max(0, timeout))
|
||||
self.condition.lock()
|
||||
while !self.isFinished, !self.isStopping, !self.data.contains(0x0A) {
|
||||
guard self.condition.wait(until: deadline) else { break }
|
||||
}
|
||||
self.condition.unlock()
|
||||
return self.stopAndSnapshot()
|
||||
}
|
||||
|
||||
package func stop() {
|
||||
_ = self.stopAndSnapshot()
|
||||
}
|
||||
|
||||
package var reachedEOF: Bool {
|
||||
self.condition.lock()
|
||||
defer { self.condition.unlock() }
|
||||
return self.didReachEOF
|
||||
}
|
||||
|
||||
package static func decodeUTF8(_ data: Data) -> String {
|
||||
// A byte cap can split the final scalar; lossy decoding preserves the valid captured prefix.
|
||||
// swiftlint:disable:next optional_data_string_conversion
|
||||
String(decoding: data, as: UTF8.self)
|
||||
}
|
||||
|
||||
private func handleReadableData(from handle: FileHandle) {
|
||||
self.condition.lock()
|
||||
guard !self.isStopping else {
|
||||
self.condition.unlock()
|
||||
return
|
||||
}
|
||||
self.activeCallbacks += 1
|
||||
self.condition.unlock()
|
||||
|
||||
let chunk = handle.availableData
|
||||
var continuation: CheckedContinuation<Void, Never>?
|
||||
|
||||
self.condition.lock()
|
||||
if chunk.isEmpty {
|
||||
self.isFinished = true
|
||||
self.didReachEOF = true
|
||||
continuation = self.continuation
|
||||
self.continuation = nil
|
||||
} else {
|
||||
let remainingBytes = max(0, self.maxBytes - self.data.count)
|
||||
if remainingBytes > 0 {
|
||||
self.data.append(chunk.prefix(remainingBytes))
|
||||
}
|
||||
}
|
||||
self.activeCallbacks -= 1
|
||||
if self.activeCallbacks == 0 {
|
||||
self.condition.broadcast()
|
||||
}
|
||||
self.condition.unlock()
|
||||
|
||||
if chunk.isEmpty {
|
||||
handle.readabilityHandler = nil
|
||||
} else {
|
||||
self.onData?()
|
||||
}
|
||||
continuation?.resume()
|
||||
}
|
||||
|
||||
private func waitUntilFinished() async {
|
||||
await withCheckedContinuation { continuation in
|
||||
self.condition.lock()
|
||||
if self.isFinished || self.isStopping {
|
||||
self.condition.unlock()
|
||||
continuation.resume()
|
||||
return
|
||||
}
|
||||
self.continuation = continuation
|
||||
self.condition.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
private func stopAndSnapshot() -> Data {
|
||||
self.handle.readabilityHandler = nil
|
||||
|
||||
let continuation: CheckedContinuation<Void, Never>?
|
||||
let snapshot: Data
|
||||
self.condition.lock()
|
||||
self.isStopping = true
|
||||
while self.activeCallbacks > 0 {
|
||||
self.condition.wait()
|
||||
}
|
||||
self.isFinished = true
|
||||
continuation = self.continuation
|
||||
self.continuation = nil
|
||||
snapshot = self.data
|
||||
self.condition.unlock()
|
||||
|
||||
continuation?.resume()
|
||||
return snapshot
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,809 @@
|
||||
#if canImport(Darwin)
|
||||
import Darwin
|
||||
#elseif canImport(Glibc)
|
||||
import Glibc
|
||||
#elseif canImport(Musl)
|
||||
import Musl
|
||||
#endif
|
||||
import Foundation
|
||||
|
||||
package final class SpawnedProcessGroup: @unchecked Sendable {
|
||||
package enum LaunchError: LocalizedError {
|
||||
case setupFailed(String)
|
||||
case spawnFailed(String)
|
||||
|
||||
package var errorDescription: String? {
|
||||
switch self {
|
||||
case let .setupFailed(details):
|
||||
"Failed to prepare process: \(details)"
|
||||
case let .spawnFailed(details):
|
||||
"Failed to launch process: \(details)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class TerminationState: @unchecked Sendable {
|
||||
private let condition = NSCondition()
|
||||
private var exitObserved = false
|
||||
private var reapRequested = false
|
||||
private var status: Int32?
|
||||
|
||||
var hasObservedExit: Bool {
|
||||
self.condition.withLock { self.exitObserved }
|
||||
}
|
||||
|
||||
var value: Int32? {
|
||||
self.condition.withLock { self.status }
|
||||
}
|
||||
|
||||
func observeExit() {
|
||||
self.condition.withLock {
|
||||
self.exitObserved = true
|
||||
self.condition.broadcast()
|
||||
}
|
||||
}
|
||||
|
||||
func requestReap() {
|
||||
self.condition.withLock {
|
||||
self.reapRequested = true
|
||||
self.condition.broadcast()
|
||||
}
|
||||
}
|
||||
|
||||
func waitForReapRequest(timeout: TimeInterval) {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
self.condition.lock()
|
||||
while !self.reapRequested, self.condition.wait(until: deadline) {}
|
||||
self.condition.unlock()
|
||||
}
|
||||
|
||||
func resolve(_ status: Int32) {
|
||||
self.condition.withLock {
|
||||
guard self.status == nil else { return }
|
||||
self.status = status
|
||||
self.condition.broadcast()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class ProcessIdentityState: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var identities: Set<TTYProcessTreeTerminator.ProcessIdentity> = []
|
||||
|
||||
var snapshot: Set<TTYProcessTreeTerminator.ProcessIdentity> {
|
||||
self.lock.withLock { self.identities }
|
||||
}
|
||||
|
||||
func formUnion(_ identities: Set<TTYProcessTreeTerminator.ProcessIdentity>) {
|
||||
self.lock.withLock {
|
||||
self.identities.formUnion(identities)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct OutputPipeIdentity: Hashable {
|
||||
#if canImport(Darwin)
|
||||
let firstHandle: UInt64
|
||||
let secondHandle: UInt64
|
||||
#else
|
||||
let inode: UInt64
|
||||
#endif
|
||||
|
||||
static func resolve(fileDescriptor: Int32) -> OutputPipeIdentity? {
|
||||
#if canImport(Darwin)
|
||||
var info = pipe_fdinfo()
|
||||
let byteCount = proc_pidfdinfo(
|
||||
getpid(),
|
||||
fileDescriptor,
|
||||
PROC_PIDFDPIPEINFO,
|
||||
&info,
|
||||
Int32(MemoryLayout<pipe_fdinfo>.size))
|
||||
guard byteCount == MemoryLayout<pipe_fdinfo>.size else { return nil }
|
||||
let handles = [info.pipeinfo.pipe_handle, info.pipeinfo.pipe_peerhandle].sorted()
|
||||
guard handles[0] != 0, handles[1] != 0 else { return nil }
|
||||
return OutputPipeIdentity(firstHandle: handles[0], secondHandle: handles[1])
|
||||
#else
|
||||
var info = stat()
|
||||
guard fstat(fileDescriptor, &info) == 0 else { return nil }
|
||||
return OutputPipeIdentity(inode: UInt64(info.st_ino))
|
||||
#endif
|
||||
}
|
||||
|
||||
static func holderPIDs(for pipes: Set<OutputPipeIdentity>) -> Set<pid_t> {
|
||||
guard !pipes.isEmpty else { return [] }
|
||||
#if canImport(Darwin)
|
||||
return Set(SpawnedProcessGroup.allProcessIDs().filter { self.process(pid: $0, holdsAny: pipes) })
|
||||
#else
|
||||
let targets = Set(pipes.map { "pipe:[\($0.inode)]" })
|
||||
return Set(SpawnedProcessGroup.allProcessIDs().filter { pid in
|
||||
let directory = "/proc/\(pid)/fd"
|
||||
guard let descriptors = try? FileManager.default.contentsOfDirectory(atPath: directory) else {
|
||||
return false
|
||||
}
|
||||
return descriptors.contains { descriptor in
|
||||
let path = "\(directory)/\(descriptor)"
|
||||
guard let target = try? FileManager.default.destinationOfSymbolicLink(atPath: path) else {
|
||||
return false
|
||||
}
|
||||
return targets.contains(target)
|
||||
}
|
||||
})
|
||||
#endif
|
||||
}
|
||||
|
||||
#if canImport(Darwin)
|
||||
private static func process(pid: pid_t, holdsAny pipes: Set<OutputPipeIdentity>) -> Bool {
|
||||
let requiredBytes = proc_pidinfo(pid, PROC_PIDLISTFDS, 0, nil, 0)
|
||||
guard requiredBytes > 0 else { return false }
|
||||
let stride = MemoryLayout<proc_fdinfo>.stride
|
||||
var descriptors = [proc_fdinfo](
|
||||
repeating: proc_fdinfo(),
|
||||
count: Int(requiredBytes) / stride + 8)
|
||||
let actualBytes = descriptors.withUnsafeMutableBytes { buffer in
|
||||
proc_pidinfo(
|
||||
pid,
|
||||
PROC_PIDLISTFDS,
|
||||
0,
|
||||
buffer.baseAddress,
|
||||
Int32(buffer.count))
|
||||
}
|
||||
guard actualBytes > 0 else { return false }
|
||||
|
||||
for descriptor in descriptors.prefix(Int(actualBytes) / stride)
|
||||
where descriptor.proc_fdtype == PROX_FDTYPE_PIPE
|
||||
{
|
||||
var info = pipe_fdinfo()
|
||||
let byteCount = proc_pidfdinfo(
|
||||
pid,
|
||||
descriptor.proc_fd,
|
||||
PROC_PIDFDPIPEINFO,
|
||||
&info,
|
||||
Int32(MemoryLayout<pipe_fdinfo>.size))
|
||||
guard byteCount == MemoryLayout<pipe_fdinfo>.size else { continue }
|
||||
let handles = [info.pipeinfo.pipe_handle, info.pipeinfo.pipe_peerhandle].sorted()
|
||||
let identity = OutputPipeIdentity(firstHandle: handles[0], secondHandle: handles[1])
|
||||
if pipes.contains(identity) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private struct OutputTTYIdentity: Hashable {
|
||||
let device: UInt64
|
||||
let inode: UInt64
|
||||
let rawDevice: UInt64
|
||||
|
||||
static func resolve(fileDescriptor: Int32) -> OutputTTYIdentity? {
|
||||
var info = stat()
|
||||
guard fstat(fileDescriptor, &info) == 0 else { return nil }
|
||||
#if canImport(Darwin)
|
||||
let device = SpawnedProcessGroup.darwinDeviceIdentifier(info.st_dev)
|
||||
let rawDevice = SpawnedProcessGroup.darwinDeviceIdentifier(info.st_rdev)
|
||||
#else
|
||||
let device = UInt64(info.st_dev)
|
||||
let rawDevice = UInt64(info.st_rdev)
|
||||
#endif
|
||||
return OutputTTYIdentity(
|
||||
device: device,
|
||||
inode: UInt64(info.st_ino),
|
||||
rawDevice: rawDevice)
|
||||
}
|
||||
|
||||
static func holderPIDs(for terminals: Set<OutputTTYIdentity>) -> Set<pid_t> {
|
||||
guard !terminals.isEmpty else { return [] }
|
||||
#if canImport(Darwin)
|
||||
return Set(SpawnedProcessGroup.allProcessIDs().filter { self.process(pid: $0, holdsAny: terminals) })
|
||||
#else
|
||||
return Set(SpawnedProcessGroup.allProcessIDs().filter { pid in
|
||||
let directory = "/proc/\(pid)/fd"
|
||||
guard let descriptors = try? FileManager.default.contentsOfDirectory(atPath: directory) else {
|
||||
return false
|
||||
}
|
||||
return descriptors.contains { descriptor in
|
||||
var info = stat()
|
||||
let path = "\(directory)/\(descriptor)"
|
||||
guard path.withCString({ fstatat(AT_FDCWD, $0, &info, 0) }) == 0 else { return false }
|
||||
let identity = OutputTTYIdentity(
|
||||
device: UInt64(info.st_dev),
|
||||
inode: UInt64(info.st_ino),
|
||||
rawDevice: UInt64(info.st_rdev))
|
||||
return terminals.contains(identity)
|
||||
}
|
||||
})
|
||||
#endif
|
||||
}
|
||||
|
||||
#if canImport(Darwin)
|
||||
private static func process(pid: pid_t, holdsAny terminals: Set<OutputTTYIdentity>) -> Bool {
|
||||
let requiredBytes = proc_pidinfo(pid, PROC_PIDLISTFDS, 0, nil, 0)
|
||||
guard requiredBytes > 0 else { return false }
|
||||
let stride = MemoryLayout<proc_fdinfo>.stride
|
||||
var descriptors = [proc_fdinfo](
|
||||
repeating: proc_fdinfo(),
|
||||
count: Int(requiredBytes) / stride + 8)
|
||||
let actualBytes = descriptors.withUnsafeMutableBytes { buffer in
|
||||
proc_pidinfo(
|
||||
pid,
|
||||
PROC_PIDLISTFDS,
|
||||
0,
|
||||
buffer.baseAddress,
|
||||
Int32(buffer.count))
|
||||
}
|
||||
guard actualBytes > 0 else { return false }
|
||||
|
||||
for descriptor in descriptors.prefix(Int(actualBytes) / stride)
|
||||
where descriptor.proc_fdtype == PROX_FDTYPE_VNODE
|
||||
{
|
||||
var info = vnode_fdinfo()
|
||||
let byteCount = proc_pidfdinfo(
|
||||
pid,
|
||||
descriptor.proc_fd,
|
||||
PROC_PIDFDVNODEINFO,
|
||||
&info,
|
||||
Int32(MemoryLayout<vnode_fdinfo>.size))
|
||||
guard byteCount == MemoryLayout<vnode_fdinfo>.size else { continue }
|
||||
let stats = info.pvi.vi_stat
|
||||
let identity = OutputTTYIdentity(
|
||||
device: UInt64(stats.vst_dev),
|
||||
inode: stats.vst_ino,
|
||||
rawDevice: UInt64(stats.vst_rdev))
|
||||
if terminals.contains(identity) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private static func allProcessIDs() -> [pid_t] {
|
||||
#if canImport(Darwin)
|
||||
return self.processIDs(type: UInt32(PROC_ALL_PIDS), typeInfo: 0)
|
||||
#else
|
||||
guard let entries = try? FileManager.default.contentsOfDirectory(atPath: "/proc") else { return [] }
|
||||
return entries.compactMap(pid_t.init)
|
||||
#endif
|
||||
}
|
||||
|
||||
#if canImport(Darwin)
|
||||
/// Darwin exposes `stat` device IDs as signed values while vnode inspection uses the same bits unsigned.
|
||||
package static func darwinDeviceIdentifier(_ value: Int32) -> UInt64 {
|
||||
UInt64(UInt32(bitPattern: value))
|
||||
}
|
||||
#endif
|
||||
|
||||
private static func processIDs(inProcessGroup processGroup: pid_t) -> [pid_t] {
|
||||
#if canImport(Darwin)
|
||||
return self.processIDs(type: UInt32(PROC_PGRP_ONLY), typeInfo: UInt32(bitPattern: processGroup))
|
||||
#else
|
||||
return self.allProcessIDs().filter { getpgid($0) == processGroup }
|
||||
#endif
|
||||
}
|
||||
|
||||
#if canImport(Darwin)
|
||||
private static func processIDs(type: UInt32, typeInfo: UInt32) -> [pid_t] {
|
||||
let requiredBytes = proc_listpids(type, typeInfo, nil, 0)
|
||||
guard requiredBytes > 0 else { return [] }
|
||||
let stride = MemoryLayout<pid_t>.stride
|
||||
var pids = [pid_t](repeating: 0, count: Int(requiredBytes) / stride + 32)
|
||||
let actualBytes = pids.withUnsafeMutableBytes { buffer in
|
||||
proc_listpids(
|
||||
type,
|
||||
typeInfo,
|
||||
buffer.baseAddress,
|
||||
Int32(buffer.count))
|
||||
}
|
||||
guard actualBytes > 0 else { return [] }
|
||||
return Array(pids.prefix(Int(actualBytes) / stride)).filter { $0 > 0 }
|
||||
}
|
||||
#endif
|
||||
|
||||
package let pid: pid_t
|
||||
package let processGroup: pid_t
|
||||
private let termination = TerminationState()
|
||||
private let observedProcessGroupMembers = ProcessIdentityState()
|
||||
private let outputPipes: Set<OutputPipeIdentity>
|
||||
private let outputTTYs: Set<OutputTTYIdentity>
|
||||
private let rootIdentity: TTYProcessTreeTerminator.ProcessIdentity?
|
||||
|
||||
private init(
|
||||
pid: pid_t,
|
||||
outputPipes: Set<OutputPipeIdentity>,
|
||||
outputTTYs: Set<OutputTTYIdentity> = [])
|
||||
{
|
||||
self.pid = pid
|
||||
self.processGroup = pid
|
||||
self.outputPipes = outputPipes
|
||||
self.outputTTYs = outputTTYs
|
||||
self.rootIdentity = TTYProcessTreeTerminator.processIdentity(for: pid)
|
||||
self.startWaiter()
|
||||
}
|
||||
|
||||
package static func launch(
|
||||
binary: String,
|
||||
arguments: [String],
|
||||
environment: [String: String],
|
||||
stdoutPipe: Pipe,
|
||||
stderrPipe: Pipe) throws -> SpawnedProcessGroup
|
||||
{
|
||||
#if canImport(Darwin)
|
||||
var fileActions: posix_spawn_file_actions_t?
|
||||
#else
|
||||
var fileActions = posix_spawn_file_actions_t()
|
||||
#endif
|
||||
guard posix_spawn_file_actions_init(&fileActions) == 0 else {
|
||||
throw LaunchError.setupFailed("posix_spawn_file_actions_init")
|
||||
}
|
||||
defer { posix_spawn_file_actions_destroy(&fileActions) }
|
||||
|
||||
let stdoutRead = stdoutPipe.fileHandleForReading.fileDescriptor
|
||||
let stdoutWrite = stdoutPipe.fileHandleForWriting.fileDescriptor
|
||||
let stderrRead = stderrPipe.fileHandleForReading.fileDescriptor
|
||||
let stderrWrite = stderrPipe.fileHandleForWriting.fileDescriptor
|
||||
let outputPipes = Set(
|
||||
[stdoutRead, stderrRead].compactMap(OutputPipeIdentity.resolve(fileDescriptor:)))
|
||||
var fileActionResults = [
|
||||
posix_spawn_file_actions_addopen(&fileActions, STDIN_FILENO, "/dev/null", O_RDONLY, 0),
|
||||
posix_spawn_file_actions_adddup2(&fileActions, stdoutWrite, STDOUT_FILENO),
|
||||
posix_spawn_file_actions_adddup2(&fileActions, stderrWrite, STDERR_FILENO),
|
||||
]
|
||||
for descriptor in Self.pipeDescriptorsToClose([stdoutRead, stdoutWrite, stderrRead, stderrWrite]) {
|
||||
fileActionResults.append(posix_spawn_file_actions_addclose(&fileActions, descriptor))
|
||||
}
|
||||
#if canImport(Glibc) || canImport(Musl)
|
||||
do {
|
||||
try PosixSpawnFileActionsCloseFrom.addCloseFrom(
|
||||
&fileActions,
|
||||
startingAt: STDERR_FILENO + 1)
|
||||
} catch {
|
||||
throw LaunchError.setupFailed(error.localizedDescription)
|
||||
}
|
||||
#endif
|
||||
guard fileActionResults.allSatisfy({ $0 == 0 }) else {
|
||||
throw LaunchError.setupFailed("posix_spawn file actions")
|
||||
}
|
||||
|
||||
#if canImport(Darwin)
|
||||
var attributes: posix_spawnattr_t?
|
||||
#else
|
||||
var attributes = posix_spawnattr_t()
|
||||
#endif
|
||||
guard posix_spawnattr_init(&attributes) == 0 else {
|
||||
throw LaunchError.setupFailed("posix_spawnattr_init")
|
||||
}
|
||||
defer { posix_spawnattr_destroy(&attributes) }
|
||||
|
||||
#if canImport(Darwin)
|
||||
let flags = POSIX_SPAWN_SETPGROUP | POSIX_SPAWN_CLOEXEC_DEFAULT
|
||||
#else
|
||||
let flags = POSIX_SPAWN_SETPGROUP
|
||||
#endif
|
||||
guard posix_spawnattr_setflags(&attributes, Int16(flags)) == 0,
|
||||
posix_spawnattr_setpgroup(&attributes, 0) == 0
|
||||
else {
|
||||
throw LaunchError.setupFailed("posix_spawn process group")
|
||||
}
|
||||
|
||||
var cArguments: [UnsafeMutablePointer<CChar>?] = ([binary] + arguments).map { strdup($0) }
|
||||
cArguments.append(nil)
|
||||
defer {
|
||||
for argument in cArguments {
|
||||
free(argument)
|
||||
}
|
||||
}
|
||||
|
||||
var cEnvironment: [UnsafeMutablePointer<CChar>?] = environment.map { key, value in
|
||||
strdup("\(key)=\(value)")
|
||||
}
|
||||
cEnvironment.append(nil)
|
||||
defer {
|
||||
for entry in cEnvironment {
|
||||
free(entry)
|
||||
}
|
||||
}
|
||||
|
||||
var pid: pid_t = 0
|
||||
let spawnResult = binary.withCString { path in
|
||||
posix_spawn(&pid, path, &fileActions, &attributes, cArguments, cEnvironment)
|
||||
}
|
||||
stdoutPipe.fileHandleForWriting.closeFile()
|
||||
stderrPipe.fileHandleForWriting.closeFile()
|
||||
guard spawnResult == 0 else {
|
||||
throw LaunchError.spawnFailed(String(cString: strerror(spawnResult)))
|
||||
}
|
||||
return SpawnedProcessGroup(pid: pid, outputPipes: outputPipes)
|
||||
}
|
||||
|
||||
package static func launchPTY(
|
||||
binary: String,
|
||||
arguments: [String],
|
||||
environment: [String: String],
|
||||
workingDirectory: URL?,
|
||||
fileDescriptors: (primary: Int32, secondary: Int32)) throws -> SpawnedProcessGroup
|
||||
{
|
||||
let primaryFD = fileDescriptors.primary
|
||||
let secondaryFD = fileDescriptors.secondary
|
||||
guard let outputTTY = OutputTTYIdentity.resolve(fileDescriptor: secondaryFD) else {
|
||||
throw LaunchError.setupFailed("resolve PTY identity")
|
||||
}
|
||||
#if canImport(Darwin)
|
||||
var fileActions: posix_spawn_file_actions_t?
|
||||
#else
|
||||
var fileActions = posix_spawn_file_actions_t()
|
||||
#endif
|
||||
guard posix_spawn_file_actions_init(&fileActions) == 0 else {
|
||||
throw LaunchError.setupFailed("posix_spawn_file_actions_init")
|
||||
}
|
||||
defer { posix_spawn_file_actions_destroy(&fileActions) }
|
||||
|
||||
var fileActionResults = [
|
||||
posix_spawn_file_actions_adddup2(&fileActions, secondaryFD, STDIN_FILENO),
|
||||
posix_spawn_file_actions_adddup2(&fileActions, secondaryFD, STDOUT_FILENO),
|
||||
posix_spawn_file_actions_adddup2(&fileActions, secondaryFD, STDERR_FILENO),
|
||||
]
|
||||
for descriptor in Self.pipeDescriptorsToClose([primaryFD, secondaryFD]) {
|
||||
fileActionResults.append(posix_spawn_file_actions_addclose(&fileActions, descriptor))
|
||||
}
|
||||
if let workingDirectory {
|
||||
fileActionResults.append(workingDirectory.path.withCString { path in
|
||||
posix_spawn_file_actions_addchdir_np(&fileActions, path)
|
||||
})
|
||||
}
|
||||
#if canImport(Glibc) || canImport(Musl)
|
||||
do {
|
||||
try PosixSpawnFileActionsCloseFrom.addCloseFrom(
|
||||
&fileActions,
|
||||
startingAt: STDERR_FILENO + 1)
|
||||
} catch {
|
||||
throw LaunchError.setupFailed(error.localizedDescription)
|
||||
}
|
||||
#endif
|
||||
guard fileActionResults.allSatisfy({ $0 == 0 }) else {
|
||||
throw LaunchError.setupFailed("posix_spawn PTY file actions")
|
||||
}
|
||||
|
||||
#if canImport(Darwin)
|
||||
var attributes: posix_spawnattr_t?
|
||||
#else
|
||||
var attributes = posix_spawnattr_t()
|
||||
#endif
|
||||
guard posix_spawnattr_init(&attributes) == 0 else {
|
||||
throw LaunchError.setupFailed("posix_spawnattr_init")
|
||||
}
|
||||
defer { posix_spawnattr_destroy(&attributes) }
|
||||
|
||||
#if canImport(Darwin)
|
||||
let flags = POSIX_SPAWN_SETPGROUP | POSIX_SPAWN_CLOEXEC_DEFAULT
|
||||
#else
|
||||
let flags = POSIX_SPAWN_SETPGROUP
|
||||
#endif
|
||||
guard posix_spawnattr_setflags(&attributes, Int16(flags)) == 0,
|
||||
posix_spawnattr_setpgroup(&attributes, 0) == 0
|
||||
else {
|
||||
throw LaunchError.setupFailed("posix_spawn PTY process group")
|
||||
}
|
||||
|
||||
var cArguments: [UnsafeMutablePointer<CChar>?] = ([binary] + arguments).map { strdup($0) }
|
||||
cArguments.append(nil)
|
||||
defer {
|
||||
for argument in cArguments {
|
||||
free(argument)
|
||||
}
|
||||
}
|
||||
|
||||
var cEnvironment: [UnsafeMutablePointer<CChar>?] = environment.map { key, value in
|
||||
strdup("\(key)=\(value)")
|
||||
}
|
||||
cEnvironment.append(nil)
|
||||
defer {
|
||||
for entry in cEnvironment {
|
||||
free(entry)
|
||||
}
|
||||
}
|
||||
|
||||
var pid: pid_t = 0
|
||||
let spawnResult = binary.withCString { path in
|
||||
posix_spawn(&pid, path, &fileActions, &attributes, cArguments, cEnvironment)
|
||||
}
|
||||
guard spawnResult == 0 else {
|
||||
throw LaunchError.spawnFailed(String(cString: strerror(spawnResult)))
|
||||
}
|
||||
return SpawnedProcessGroup(pid: pid, outputPipes: [], outputTTYs: [outputTTY])
|
||||
}
|
||||
|
||||
package var isRunning: Bool {
|
||||
!self.termination.hasObservedExit
|
||||
}
|
||||
|
||||
package var terminationStatus: Int32? {
|
||||
self.termination.value
|
||||
}
|
||||
|
||||
package var hasResidualProcessGroup: Bool {
|
||||
Self.processGroupExists(self.processGroup)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
package func terminateSynchronously(grace: TimeInterval = 0.4) -> Int32? {
|
||||
let deadline = Date().addingTimeInterval(max(0, grace))
|
||||
var processIdentities = self.currentResidualProcessIdentities(includeDescendants: true)
|
||||
processIdentities.formUnion(self.currentProcessGroupMemberIdentities())
|
||||
if let rootIdentity = self.rootIdentity {
|
||||
processIdentities.insert(rootIdentity)
|
||||
}
|
||||
Self.signal(processIdentities: processIdentities, signal: SIGTERM)
|
||||
|
||||
while processIdentities.contains(where: TTYProcessTreeTerminator.isCurrent(_:)),
|
||||
Date() < deadline
|
||||
{
|
||||
usleep(20000)
|
||||
}
|
||||
|
||||
processIdentities.formUnion(self.currentResidualProcessIdentities(includeDescendants: self.isRunning))
|
||||
processIdentities.formUnion(self.currentProcessGroupMemberIdentities())
|
||||
if self.isRunning, let rootIdentity = self.rootIdentity {
|
||||
processIdentities.insert(rootIdentity)
|
||||
}
|
||||
Self.signal(processIdentities: processIdentities, signal: SIGKILL)
|
||||
|
||||
let killDeadline = Date().addingTimeInterval(max(0, grace))
|
||||
while processIdentities.contains(where: TTYProcessTreeTerminator.isCurrent(_:)),
|
||||
Date() < killDeadline
|
||||
{
|
||||
usleep(20000)
|
||||
}
|
||||
return self.finishSynchronously()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
package func finishSynchronously(timeout: TimeInterval = 1) -> Int32? {
|
||||
self.termination.requestReap()
|
||||
let deadline = Date().addingTimeInterval(max(0, timeout))
|
||||
while self.terminationStatus == nil, Date() < deadline {
|
||||
usleep(10000)
|
||||
}
|
||||
return self.terminationStatus
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
package func terminate(grace: TimeInterval = 0.4) async -> Int32? {
|
||||
if self.isRunning {
|
||||
let killDeadline = Date().addingTimeInterval(max(0, grace))
|
||||
var processIdentities = self.currentResidualProcessIdentities(includeDescendants: true)
|
||||
processIdentities.formUnion(self.currentProcessGroupMemberIdentities())
|
||||
if let rootIdentity = TTYProcessTreeTerminator.processIdentity(for: self.pid) {
|
||||
processIdentities.insert(rootIdentity)
|
||||
}
|
||||
Self.signal(processIdentities: processIdentities, signal: SIGTERM)
|
||||
_ = await self.waitForExit(timeout: max(0, killDeadline.timeIntervalSinceNow))
|
||||
while processIdentities.contains(where: TTYProcessTreeTerminator.isCurrent(_:)),
|
||||
Date() < killDeadline
|
||||
{
|
||||
try? await Task.sleep(for: .milliseconds(20))
|
||||
}
|
||||
|
||||
processIdentities.formUnion(self.currentResidualProcessIdentities(includeDescendants: false))
|
||||
processIdentities.formUnion(self.currentProcessGroupMemberIdentities())
|
||||
if self.isRunning {
|
||||
processIdentities.formUnion(self.currentResidualProcessIdentities(includeDescendants: true))
|
||||
if let rootIdentity = TTYProcessTreeTerminator.processIdentity(for: self.pid) {
|
||||
processIdentities.insert(rootIdentity)
|
||||
}
|
||||
Self.signal(processIdentities: processIdentities, signal: SIGKILL)
|
||||
_ = await self.waitForExit(timeout: grace)
|
||||
} else {
|
||||
Self.signal(processIdentities: processIdentities, signal: SIGKILL)
|
||||
}
|
||||
_ = await self.waitForResidualProcessesExit(processIdentities, timeout: grace)
|
||||
await self.finish()
|
||||
return self.terminationStatus
|
||||
}
|
||||
await self.terminateResidualProcesses(grace: grace)
|
||||
await self.finish()
|
||||
return self.terminationStatus
|
||||
}
|
||||
|
||||
package func terminateResidualProcesses(grace: TimeInterval = 0.4) async {
|
||||
let deadline = Date().addingTimeInterval(max(0, grace))
|
||||
var processIdentities = self.currentResidualProcessIdentities(includeDescendants: false)
|
||||
processIdentities.formUnion(self.currentProcessGroupMemberIdentities())
|
||||
if self.isRunning {
|
||||
processIdentities.formUnion(self.currentResidualProcessIdentities(includeDescendants: true))
|
||||
if let rootIdentity = TTYProcessTreeTerminator.processIdentity(for: self.pid) {
|
||||
processIdentities.insert(rootIdentity)
|
||||
}
|
||||
}
|
||||
Self.signal(processIdentities: processIdentities, signal: SIGTERM)
|
||||
|
||||
while processIdentities.contains(where: TTYProcessTreeTerminator.isCurrent(_:)) {
|
||||
guard Date() < deadline else { break }
|
||||
try? await Task.sleep(for: .milliseconds(20))
|
||||
}
|
||||
|
||||
processIdentities.formUnion(self.currentResidualProcessIdentities(includeDescendants: false))
|
||||
processIdentities.formUnion(self.currentProcessGroupMemberIdentities())
|
||||
if self.isRunning {
|
||||
processIdentities.formUnion(self.currentResidualProcessIdentities(includeDescendants: true))
|
||||
if let rootIdentity = TTYProcessTreeTerminator.processIdentity(for: self.pid) {
|
||||
processIdentities.insert(rootIdentity)
|
||||
}
|
||||
}
|
||||
guard processIdentities.contains(where: TTYProcessTreeTerminator.isCurrent(_:)) else {
|
||||
return
|
||||
}
|
||||
Self.signal(processIdentities: processIdentities, signal: SIGKILL)
|
||||
_ = await self.waitForResidualProcessesExit(processIdentities, timeout: grace)
|
||||
}
|
||||
|
||||
package func finish() async {
|
||||
self.termination.requestReap()
|
||||
_ = await self.waitForTerminationStatus(timeout: 1)
|
||||
}
|
||||
|
||||
private func startWaiter() {
|
||||
let pid = self.pid
|
||||
let processGroup = self.processGroup
|
||||
let observedProcessGroupMembers = self.observedProcessGroupMembers
|
||||
let termination = self.termination
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
var info = siginfo_t()
|
||||
var waitResult: Int32
|
||||
repeat {
|
||||
waitResult = waitid(P_PID, id_t(pid), &info, WEXITED | WNOWAIT)
|
||||
} while waitResult == -1 && errno == EINTR
|
||||
guard waitResult == 0 else {
|
||||
termination.observeExit()
|
||||
termination.resolve(1)
|
||||
return
|
||||
}
|
||||
|
||||
// The exited root remains unreaped here, so its PID cannot yet be reused as
|
||||
// an unrelated process-group ID.
|
||||
observedProcessGroupMembers.formUnion(
|
||||
Self.processGroupMemberIdentities(processGroup: processGroup, excluding: pid))
|
||||
termination.observeExit()
|
||||
termination.waitForReapRequest(timeout: 30)
|
||||
|
||||
var rawStatus: Int32 = 0
|
||||
var result: pid_t
|
||||
repeat {
|
||||
result = waitpid(pid, &rawStatus, 0)
|
||||
} while result == -1 && errno == EINTR
|
||||
|
||||
let status = result == pid ? Self.exitStatus(from: rawStatus) : 1
|
||||
termination.resolve(status)
|
||||
}
|
||||
}
|
||||
|
||||
private func waitForExit(timeout: TimeInterval) async -> Int32? {
|
||||
let deadline = Date().addingTimeInterval(max(0, timeout))
|
||||
while self.isRunning, Date() < deadline {
|
||||
try? await Task.sleep(for: .milliseconds(20))
|
||||
}
|
||||
return self.terminationStatus
|
||||
}
|
||||
|
||||
private func waitForTerminationStatus(timeout: TimeInterval) async -> Int32? {
|
||||
let deadline = Date().addingTimeInterval(max(0, timeout))
|
||||
while self.terminationStatus == nil, Date() < deadline {
|
||||
try? await Task.sleep(for: .milliseconds(20))
|
||||
}
|
||||
return self.terminationStatus
|
||||
}
|
||||
|
||||
private func waitForResidualProcessesExit(
|
||||
_ processIdentities: Set<TTYProcessTreeTerminator.ProcessIdentity>,
|
||||
timeout: TimeInterval) async -> Bool
|
||||
{
|
||||
let deadline = Date().addingTimeInterval(max(0, timeout))
|
||||
while Date() < deadline {
|
||||
guard processIdentities.contains(where: TTYProcessTreeTerminator.isCurrent(_:)) else {
|
||||
return true
|
||||
}
|
||||
try? await Task.sleep(for: .milliseconds(20))
|
||||
}
|
||||
return !processIdentities.contains(where: TTYProcessTreeTerminator.isCurrent(_:))
|
||||
}
|
||||
|
||||
private func currentResidualProcessIdentities(
|
||||
includeDescendants: Bool) -> Set<TTYProcessTreeTerminator.ProcessIdentity>
|
||||
{
|
||||
var identities = self.currentOutputHolderIdentities()
|
||||
identities.formUnion(self.observedProcessGroupMembers.snapshot)
|
||||
if includeDescendants {
|
||||
identities.formUnion(
|
||||
TTYProcessTreeTerminator.descendantPIDs(of: self.pid)
|
||||
.compactMap(TTYProcessTreeTerminator.processIdentity(for:)))
|
||||
}
|
||||
return identities
|
||||
}
|
||||
|
||||
private func currentOutputHolderIdentities() -> Set<TTYProcessTreeTerminator.ProcessIdentity> {
|
||||
let excludedPIDs: Set<pid_t> = [getpid(), self.pid]
|
||||
var holderPIDs = OutputPipeIdentity.holderPIDs(for: self.outputPipes)
|
||||
holderPIDs.formUnion(OutputTTYIdentity.holderPIDs(for: self.outputTTYs))
|
||||
return Set(holderPIDs.subtracting(excludedPIDs).compactMap(TTYProcessTreeTerminator.processIdentity(for:)))
|
||||
}
|
||||
|
||||
private func currentProcessGroupMemberIdentities() -> Set<TTYProcessTreeTerminator.ProcessIdentity> {
|
||||
if self.termination.hasObservedExit, self.termination.value == nil {
|
||||
let identities = Self.processGroupMemberIdentities(
|
||||
processGroup: self.processGroup,
|
||||
excluding: self.pid)
|
||||
self.observedProcessGroupMembers.formUnion(identities)
|
||||
return identities
|
||||
}
|
||||
|
||||
guard let rootIdentity = self.rootIdentity
|
||||
else {
|
||||
return []
|
||||
}
|
||||
|
||||
let identities = Self.processGroupMemberIdentities(
|
||||
processGroup: self.processGroup,
|
||||
rootIdentity: rootIdentity,
|
||||
excluding: self.pid)
|
||||
self.observedProcessGroupMembers.formUnion(identities)
|
||||
return identities
|
||||
}
|
||||
|
||||
private static func processGroupMemberIdentities(
|
||||
processGroup: pid_t,
|
||||
rootIdentity: TTYProcessTreeTerminator.ProcessIdentity,
|
||||
excluding excludedPID: pid_t)
|
||||
-> Set<TTYProcessTreeTerminator.ProcessIdentity>
|
||||
{
|
||||
guard TTYProcessTreeTerminator.isCurrent(rootIdentity) else { return [] }
|
||||
|
||||
let identities = Self.processGroupMemberIdentities(
|
||||
processGroup: processGroup,
|
||||
excluding: excludedPID)
|
||||
guard TTYProcessTreeTerminator.isCurrent(rootIdentity) else { return [] }
|
||||
return identities
|
||||
}
|
||||
|
||||
private static func processGroupMemberIdentities(
|
||||
processGroup: pid_t,
|
||||
excluding excludedPID: pid_t)
|
||||
-> Set<TTYProcessTreeTerminator.ProcessIdentity>
|
||||
{
|
||||
Set(self.processIDs(inProcessGroup: processGroup)
|
||||
.compactMap { pid -> TTYProcessTreeTerminator.ProcessIdentity? in
|
||||
guard pid != getpid(),
|
||||
pid != excludedPID,
|
||||
let identity = TTYProcessTreeTerminator.processIdentity(for: pid),
|
||||
getpgid(pid) == processGroup,
|
||||
TTYProcessTreeTerminator.isCurrent(identity)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return identity
|
||||
})
|
||||
}
|
||||
|
||||
private static func processGroupExists(_ processGroup: pid_t) -> Bool {
|
||||
errno = 0
|
||||
return kill(-processGroup, 0) == 0 || errno == EPERM
|
||||
}
|
||||
|
||||
private static func signal(
|
||||
processIdentities: Set<TTYProcessTreeTerminator.ProcessIdentity>,
|
||||
signal: Int32)
|
||||
{
|
||||
for identity in processIdentities where TTYProcessTreeTerminator.isCurrent(identity) {
|
||||
_ = kill(identity.pid, signal)
|
||||
}
|
||||
}
|
||||
|
||||
package static func pipeDescriptorsToClose(_ descriptors: [Int32]) -> [Int32] {
|
||||
Array(Set(descriptors.filter { $0 > STDERR_FILENO })).sorted()
|
||||
}
|
||||
|
||||
private static func exitStatus(from rawStatus: Int32) -> Int32 {
|
||||
let signal = rawStatus & 0x7F
|
||||
return signal == 0 ? (rawStatus >> 8) & 0xFF : signal
|
||||
}
|
||||
}
|
||||
@@ -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