810 lines
32 KiB
Swift
810 lines
32 KiB
Swift
#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
|
|
}
|
|
}
|