chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:22:33 +08:00
commit fc4fcbab58
1848 changed files with 472303 additions and 0 deletions
@@ -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
}
}
}