612 lines
23 KiB
Swift
612 lines
23 KiB
Swift
import Foundation
|
|
import Testing
|
|
@testable import CodexBarCore
|
|
#if canImport(Darwin)
|
|
import Darwin
|
|
#else
|
|
import Glibc
|
|
#endif
|
|
|
|
struct SpawnedProcessGroupTests {
|
|
@Test
|
|
func `pipe cleanup preserves standard descriptors`() {
|
|
let descriptors = SpawnedProcessGroup.pipeDescriptorsToClose([0, 1, 2, 3, 4, 3])
|
|
|
|
#expect(descriptors == [3, 4])
|
|
}
|
|
|
|
#if canImport(Darwin)
|
|
@Test
|
|
func `Darwin device identifier preserves signed bit pattern`() {
|
|
#expect(SpawnedProcessGroup.darwinDeviceIdentifier(-805_306_367) == 3_489_660_929)
|
|
}
|
|
#endif
|
|
|
|
@Test
|
|
func `musl close-from selects numeric descriptors at or above minimum`() throws {
|
|
let descriptors = try PosixSpawnFileActionsCloseFrom.descriptorsToClose(startingAt: 4) { path in
|
|
#expect(path == "/proc/self/fd")
|
|
return ["8", "cwd", "3", "4"]
|
|
}
|
|
|
|
#expect(descriptors == [4, 8])
|
|
}
|
|
|
|
@Test
|
|
func `musl close-from fails when descriptor enumeration fails`() {
|
|
#expect(throws: PosixSpawnFileActionsCloseFrom.CloseFromError.self) {
|
|
try PosixSpawnFileActionsCloseFrom.descriptorsToClose(startingAt: 3) { _ in
|
|
throw CocoaError(.fileReadNoPermission)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `launch captures child output`() async throws {
|
|
let stdoutPipe = Pipe()
|
|
let stderrPipe = Pipe()
|
|
let stdoutCapture = ProcessPipeCapture(pipe: stdoutPipe)
|
|
let stderrCapture = ProcessPipeCapture(pipe: stderrPipe)
|
|
stdoutCapture.start()
|
|
stderrCapture.start()
|
|
|
|
let process = try SpawnedProcessGroup.launch(
|
|
binary: "/bin/sh",
|
|
arguments: ["-c", "printf stdout-value; printf stderr-value >&2"],
|
|
environment: ProcessInfo.processInfo.environment,
|
|
stdoutPipe: stdoutPipe,
|
|
stderrPipe: stderrPipe)
|
|
while process.isRunning {
|
|
try await Task.sleep(for: .milliseconds(20))
|
|
}
|
|
await process.terminateResidualProcesses()
|
|
await process.finish()
|
|
|
|
async let stdout = stdoutCapture.finish(timeout: .seconds(1))
|
|
async let stderr = stderrCapture.finish(timeout: .seconds(1))
|
|
let output = await (stdout, stderr)
|
|
|
|
#expect(process.terminationStatus == 0)
|
|
#expect(String(data: output.0, encoding: .utf8) == "stdout-value")
|
|
#expect(String(data: output.1, encoding: .utf8) == "stderr-value")
|
|
}
|
|
|
|
@Test
|
|
func `launch closes unrelated parent descriptors`() async throws {
|
|
let sourceFD = open("/dev/null", O_RDONLY)
|
|
let inheritedFD = fcntl(sourceFD, F_DUPFD, 200)
|
|
close(sourceFD)
|
|
let resolvedFD = try #require(inheritedFD >= 200 ? inheritedFD : nil)
|
|
defer { close(resolvedFD) }
|
|
_ = fcntl(resolvedFD, F_SETFD, 0)
|
|
|
|
#if canImport(Darwin)
|
|
let descriptorPath = "/dev/fd/\(resolvedFD)"
|
|
#else
|
|
let descriptorPath = "/proc/self/fd/\(resolvedFD)"
|
|
#endif
|
|
let stdoutPipe = Pipe()
|
|
let stderrPipe = Pipe()
|
|
let process = try SpawnedProcessGroup.launch(
|
|
binary: "/bin/sh",
|
|
arguments: ["-c", "test ! -e \(descriptorPath)"],
|
|
environment: ProcessInfo.processInfo.environment,
|
|
stdoutPipe: stdoutPipe,
|
|
stderrPipe: stderrPipe)
|
|
|
|
while process.isRunning {
|
|
try await Task.sleep(for: .milliseconds(20))
|
|
}
|
|
await process.terminateResidualProcesses()
|
|
await process.finish()
|
|
|
|
#expect(process.terminationStatus == 0)
|
|
}
|
|
|
|
@Test
|
|
func `termination waits for grace before killing escaped descendants`() async throws {
|
|
let childPIDFile = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("codexbar-process-group-\(UUID().uuidString).pid")
|
|
defer { try? FileManager.default.removeItem(at: childPIDFile) }
|
|
|
|
let script = """
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
child = subprocess.Popen(
|
|
[
|
|
sys.executable,
|
|
"-c",
|
|
"import signal,time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(30)",
|
|
],
|
|
start_new_session=True,
|
|
)
|
|
with open(sys.argv[1], "w") as handle:
|
|
handle.write(str(child.pid))
|
|
time.sleep(30)
|
|
"""
|
|
let stdoutPipe = Pipe()
|
|
let stderrPipe = Pipe()
|
|
let process = try SpawnedProcessGroup.launch(
|
|
binary: "/usr/bin/python3",
|
|
arguments: ["-c", script, childPIDFile.path],
|
|
environment: ProcessInfo.processInfo.environment,
|
|
stdoutPipe: stdoutPipe,
|
|
stderrPipe: stderrPipe)
|
|
|
|
var childPID: pid_t?
|
|
for _ in 0..<500 {
|
|
if let text = try? String(contentsOf: childPIDFile, encoding: .utf8),
|
|
let parsedPID = pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines))
|
|
{
|
|
childPID = parsedPID
|
|
break
|
|
}
|
|
try await Task.sleep(for: .milliseconds(20))
|
|
}
|
|
guard let escapedPID = childPID else {
|
|
await process.terminate(grace: 0)
|
|
Issue.record("Timed out waiting for escaped child PID")
|
|
return
|
|
}
|
|
defer { _ = kill(escapedPID, SIGKILL) }
|
|
|
|
let start = Date()
|
|
await process.terminate(grace: 0.3)
|
|
let elapsed = Date().timeIntervalSince(start)
|
|
|
|
#expect(elapsed >= 0.25, "Termination should honor the grace period before SIGKILL")
|
|
#expect(kill(escapedPID, 0) == -1)
|
|
}
|
|
|
|
@Test
|
|
func `termination kills reparented process group members after root exit`() async throws {
|
|
let childPIDFile = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("codexbar-process-group-member-\(UUID().uuidString).pid")
|
|
defer { try? FileManager.default.removeItem(at: childPIDFile) }
|
|
|
|
let script = """
|
|
import os
|
|
import signal
|
|
import sys
|
|
import time
|
|
|
|
intermediate = os.fork()
|
|
if intermediate == 0:
|
|
child = os.fork()
|
|
if child > 0:
|
|
os._exit(0)
|
|
os.close(1)
|
|
os.close(2)
|
|
signal.signal(signal.SIGTERM, signal.SIG_IGN)
|
|
with open(sys.argv[1], "w") as handle:
|
|
handle.write(str(os.getpid()))
|
|
time.sleep(30)
|
|
os._exit(0)
|
|
|
|
os.waitpid(intermediate, 0)
|
|
time.sleep(30)
|
|
"""
|
|
let stdoutPipe = Pipe()
|
|
let stderrPipe = Pipe()
|
|
let process = try SpawnedProcessGroup.launch(
|
|
binary: "/usr/bin/python3",
|
|
arguments: ["-c", script, childPIDFile.path],
|
|
environment: ProcessInfo.processInfo.environment,
|
|
stdoutPipe: stdoutPipe,
|
|
stderrPipe: stderrPipe)
|
|
|
|
var childPID: pid_t?
|
|
for _ in 0..<100 {
|
|
if let text = try? String(contentsOf: childPIDFile, encoding: .utf8),
|
|
let parsedPID = pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines))
|
|
{
|
|
childPID = parsedPID
|
|
break
|
|
}
|
|
try await Task.sleep(for: .milliseconds(20))
|
|
}
|
|
let reparentedPID = try #require(childPID)
|
|
defer { _ = kill(reparentedPID, SIGKILL) }
|
|
|
|
await process.terminate(grace: 0.2)
|
|
|
|
#expect(kill(reparentedPID, 0) == -1)
|
|
}
|
|
|
|
@Test
|
|
func `termination gives reparented process group members grace`() async throws {
|
|
let childPIDFile = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("codexbar-process-group-grace-\(UUID().uuidString).pid")
|
|
let termReceivedFile = childPIDFile.appendingPathExtension("term")
|
|
let gracefulExitFile = childPIDFile.appendingPathExtension("graceful")
|
|
defer {
|
|
try? FileManager.default.removeItem(at: childPIDFile)
|
|
try? FileManager.default.removeItem(at: termReceivedFile)
|
|
try? FileManager.default.removeItem(at: gracefulExitFile)
|
|
}
|
|
|
|
let script = """
|
|
import os
|
|
import signal
|
|
import sys
|
|
import time
|
|
|
|
intermediate = os.fork()
|
|
if intermediate == 0:
|
|
child = os.fork()
|
|
if child > 0:
|
|
os._exit(0)
|
|
os.close(1)
|
|
os.close(2)
|
|
def handle_term(_signal, _frame):
|
|
with open(sys.argv[2], "w") as handle:
|
|
handle.write("term")
|
|
time.sleep(0.1)
|
|
with open(sys.argv[3], "w") as handle:
|
|
handle.write("graceful")
|
|
os._exit(0)
|
|
signal.signal(signal.SIGTERM, handle_term)
|
|
signal.pthread_sigmask(signal.SIG_UNBLOCK, {signal.SIGTERM})
|
|
with open(sys.argv[1], "w") as handle:
|
|
handle.write(str(os.getpid()))
|
|
time.sleep(30)
|
|
os._exit(0)
|
|
|
|
os.waitpid(intermediate, 0)
|
|
time.sleep(30)
|
|
"""
|
|
let stdoutPipe = Pipe()
|
|
let stderrPipe = Pipe()
|
|
let process = try SpawnedProcessGroup.launch(
|
|
binary: "/usr/bin/python3",
|
|
arguments: ["-c", script, childPIDFile.path, termReceivedFile.path, gracefulExitFile.path],
|
|
environment: ProcessInfo.processInfo.environment,
|
|
stdoutPipe: stdoutPipe,
|
|
stderrPipe: stderrPipe)
|
|
|
|
var childPID: pid_t?
|
|
for _ in 0..<100 {
|
|
if let text = try? String(contentsOf: childPIDFile, encoding: .utf8),
|
|
let parsedPID = pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines))
|
|
{
|
|
childPID = parsedPID
|
|
break
|
|
}
|
|
try await Task.sleep(for: .milliseconds(20))
|
|
}
|
|
let reparentedPID = try #require(childPID)
|
|
defer { _ = kill(reparentedPID, SIGKILL) }
|
|
for _ in 0..<100
|
|
where TTYProcessTreeTerminator.descendantPIDs(of: process.pid).contains(reparentedPID)
|
|
{
|
|
try await Task.sleep(for: .milliseconds(20))
|
|
}
|
|
#expect(!TTYProcessTreeTerminator.descendantPIDs(of: process.pid).contains(reparentedPID))
|
|
#expect(getpgid(reparentedPID) == process.processGroup)
|
|
|
|
await process.terminate(grace: 0.3)
|
|
|
|
#expect(FileManager.default.fileExists(atPath: termReceivedFile.path))
|
|
#expect(FileManager.default.fileExists(atPath: gracefulExitFile.path))
|
|
#expect(kill(reparentedPID, 0) == -1)
|
|
}
|
|
|
|
@Test
|
|
func `residual termination cleans same group helpers spawned during SIGTERM`() async throws {
|
|
let readyFile = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("codexbar-process-group-term-\(UUID().uuidString).ready")
|
|
let childPIDFile = readyFile.appendingPathExtension("pid")
|
|
let heartbeatFile = readyFile.appendingPathExtension("heartbeat")
|
|
defer {
|
|
try? FileManager.default.removeItem(at: readyFile)
|
|
try? FileManager.default.removeItem(at: childPIDFile)
|
|
try? FileManager.default.removeItem(at: heartbeatFile)
|
|
}
|
|
|
|
let script = """
|
|
import os
|
|
import signal
|
|
import sys
|
|
import time
|
|
|
|
def handle_term(_signal, _frame):
|
|
reader, writer = os.pipe()
|
|
child = os.fork()
|
|
if child == 0:
|
|
os.close(reader)
|
|
os.close(1)
|
|
os.close(2)
|
|
signal.signal(signal.SIGTERM, signal.SIG_IGN)
|
|
with open(sys.argv[2], "w") as handle:
|
|
handle.write(str(os.getpid()))
|
|
with open(sys.argv[3], "w") as heartbeat:
|
|
heartbeat.write("1")
|
|
heartbeat.flush()
|
|
os.write(writer, b"1")
|
|
os.close(writer)
|
|
counter = 1
|
|
while True:
|
|
counter += 1
|
|
heartbeat.seek(0)
|
|
heartbeat.write(str(counter))
|
|
heartbeat.truncate()
|
|
heartbeat.flush()
|
|
time.sleep(0.02)
|
|
os.close(writer)
|
|
os.read(reader, 1)
|
|
os.close(reader)
|
|
os._exit(0)
|
|
|
|
signal.signal(signal.SIGTERM, handle_term)
|
|
signal.pthread_sigmask(signal.SIG_UNBLOCK, {signal.SIGTERM})
|
|
with open(sys.argv[1], "w") as handle:
|
|
handle.write("ready")
|
|
time.sleep(30)
|
|
"""
|
|
let stdoutPipe = Pipe()
|
|
let stderrPipe = Pipe()
|
|
let process = try SpawnedProcessGroup.launch(
|
|
binary: "/usr/bin/python3",
|
|
arguments: ["-c", script, readyFile.path, childPIDFile.path, heartbeatFile.path],
|
|
environment: ProcessInfo.processInfo.environment,
|
|
stdoutPipe: stdoutPipe,
|
|
stderrPipe: stderrPipe)
|
|
|
|
for _ in 0..<100 where !FileManager.default.fileExists(atPath: readyFile.path) {
|
|
try await Task.sleep(for: .milliseconds(20))
|
|
}
|
|
#expect(FileManager.default.fileExists(atPath: readyFile.path))
|
|
|
|
await process.terminateResidualProcesses(grace: 0.2)
|
|
await process.finish()
|
|
|
|
var childPID: pid_t?
|
|
for _ in 0..<100 {
|
|
if let text = try? String(contentsOf: childPIDFile, encoding: .utf8),
|
|
let parsedPID = pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines))
|
|
{
|
|
childPID = parsedPID
|
|
break
|
|
}
|
|
try await Task.sleep(for: .milliseconds(20))
|
|
}
|
|
let resolvedChildPID = try #require(childPID)
|
|
defer { _ = kill(resolvedChildPID, SIGKILL) }
|
|
let heartbeatAfterCleanup = try String(contentsOf: heartbeatFile, encoding: .utf8)
|
|
try await Task.sleep(for: .milliseconds(200))
|
|
let heartbeatAfterSettle = try String(contentsOf: heartbeatFile, encoding: .utf8)
|
|
#expect(heartbeatAfterSettle == heartbeatAfterCleanup)
|
|
}
|
|
|
|
@Test
|
|
func `normal exit cleans a session escaped output holder`() async throws {
|
|
let childPIDFile = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("codexbar-process-holder-\(UUID().uuidString).pid")
|
|
defer { try? FileManager.default.removeItem(at: childPIDFile) }
|
|
|
|
let script = """
|
|
import subprocess
|
|
import sys
|
|
|
|
child = subprocess.Popen(
|
|
[
|
|
sys.executable,
|
|
"-c",
|
|
"import signal,time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(30)",
|
|
],
|
|
start_new_session=True,
|
|
)
|
|
with open(sys.argv[1], "w") as handle:
|
|
handle.write(str(child.pid))
|
|
print("parent complete", flush=True)
|
|
"""
|
|
let stdoutPipe = Pipe()
|
|
let stderrPipe = Pipe()
|
|
let process = try SpawnedProcessGroup.launch(
|
|
binary: "/usr/bin/python3",
|
|
arguments: ["-c", script, childPIDFile.path],
|
|
environment: ProcessInfo.processInfo.environment,
|
|
stdoutPipe: stdoutPipe,
|
|
stderrPipe: stderrPipe)
|
|
|
|
while process.isRunning {
|
|
try await Task.sleep(for: .milliseconds(20))
|
|
}
|
|
var childPID: pid_t?
|
|
for _ in 0..<100 {
|
|
if let text = try? String(contentsOf: childPIDFile, encoding: .utf8),
|
|
let parsedPID = pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines))
|
|
{
|
|
childPID = parsedPID
|
|
break
|
|
}
|
|
try await Task.sleep(for: .milliseconds(20))
|
|
}
|
|
let resolvedChildPID = try #require(childPID)
|
|
defer { _ = kill(resolvedChildPID, SIGKILL) }
|
|
#expect(kill(resolvedChildPID, 0) == 0)
|
|
|
|
await process.terminateResidualProcesses(grace: 0.2)
|
|
await process.finish()
|
|
|
|
#expect(kill(resolvedChildPID, 0) == -1)
|
|
}
|
|
|
|
@Test
|
|
func `normal exit cleans a same group helper without output pipes`() async throws {
|
|
let childPIDFile = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("codexbar-process-group-holder-\(UUID().uuidString).pid")
|
|
let heartbeatFile = childPIDFile.appendingPathExtension("heartbeat")
|
|
defer {
|
|
try? FileManager.default.removeItem(at: childPIDFile)
|
|
try? FileManager.default.removeItem(at: heartbeatFile)
|
|
}
|
|
|
|
let script = """
|
|
import os
|
|
import signal
|
|
import sys
|
|
import time
|
|
|
|
child = os.fork()
|
|
if child == 0:
|
|
os.close(1)
|
|
os.close(2)
|
|
signal.signal(signal.SIGTERM, signal.SIG_IGN)
|
|
with open(sys.argv[1], "w") as handle:
|
|
handle.write(str(os.getpid()))
|
|
counter = 0
|
|
with open(sys.argv[2], "w") as heartbeat:
|
|
while True:
|
|
counter += 1
|
|
heartbeat.seek(0)
|
|
heartbeat.write(str(counter))
|
|
heartbeat.truncate()
|
|
heartbeat.flush()
|
|
time.sleep(0.02)
|
|
os._exit(0)
|
|
"""
|
|
let stdoutPipe = Pipe()
|
|
let stderrPipe = Pipe()
|
|
let process = try SpawnedProcessGroup.launch(
|
|
binary: "/usr/bin/python3",
|
|
arguments: ["-c", script, childPIDFile.path, heartbeatFile.path],
|
|
environment: ProcessInfo.processInfo.environment,
|
|
stdoutPipe: stdoutPipe,
|
|
stderrPipe: stderrPipe)
|
|
|
|
while process.isRunning {
|
|
try await Task.sleep(for: .milliseconds(20))
|
|
}
|
|
var childPID: pid_t?
|
|
for _ in 0..<100 {
|
|
if let text = try? String(contentsOf: childPIDFile, encoding: .utf8),
|
|
let parsedPID = pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines))
|
|
{
|
|
childPID = parsedPID
|
|
break
|
|
}
|
|
try await Task.sleep(for: .milliseconds(20))
|
|
}
|
|
let resolvedChildPID = try #require(childPID)
|
|
defer { _ = kill(resolvedChildPID, SIGKILL) }
|
|
#expect(getpgid(resolvedChildPID) == process.processGroup)
|
|
#expect(kill(resolvedChildPID, 0) == 0)
|
|
for _ in 0..<100 where !FileManager.default.fileExists(atPath: heartbeatFile.path) {
|
|
try await Task.sleep(for: .milliseconds(20))
|
|
}
|
|
let heartbeatBefore = try String(contentsOf: heartbeatFile, encoding: .utf8)
|
|
var heartbeatWhileRunning = heartbeatBefore
|
|
for _ in 0..<100 where heartbeatWhileRunning == heartbeatBefore {
|
|
try await Task.sleep(for: .milliseconds(20))
|
|
heartbeatWhileRunning = try String(contentsOf: heartbeatFile, encoding: .utf8)
|
|
}
|
|
#expect(heartbeatWhileRunning != heartbeatBefore)
|
|
|
|
await process.terminateResidualProcesses(grace: 0.2)
|
|
await process.finish()
|
|
|
|
try await Task.sleep(for: .milliseconds(100))
|
|
let heartbeatAfterCleanup = try String(contentsOf: heartbeatFile, encoding: .utf8)
|
|
try await Task.sleep(for: .milliseconds(200))
|
|
let heartbeatAfterSettle = try String(contentsOf: heartbeatFile, encoding: .utf8)
|
|
#expect(heartbeatAfterSettle == heartbeatAfterCleanup)
|
|
}
|
|
|
|
@Test
|
|
func `normal exit cleanup catches helper spawned during SIGTERM`() async throws {
|
|
let readyFile = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("codexbar-process-group-post-exit-\(UUID().uuidString).ready")
|
|
let childPIDFile = readyFile.appendingPathExtension("pid")
|
|
let heartbeatFile = readyFile.appendingPathExtension("heartbeat")
|
|
defer {
|
|
try? FileManager.default.removeItem(at: readyFile)
|
|
try? FileManager.default.removeItem(at: childPIDFile)
|
|
try? FileManager.default.removeItem(at: heartbeatFile)
|
|
}
|
|
|
|
let script = """
|
|
import os
|
|
import signal
|
|
import sys
|
|
import time
|
|
|
|
helper = os.fork()
|
|
if helper == 0:
|
|
os.close(1)
|
|
os.close(2)
|
|
def handle_term(_signal, _frame):
|
|
reader, writer = os.pipe()
|
|
child = os.fork()
|
|
if child == 0:
|
|
os.close(reader)
|
|
signal.signal(signal.SIGTERM, signal.SIG_IGN)
|
|
with open(sys.argv[2], "w") as handle:
|
|
handle.write(str(os.getpid()))
|
|
with open(sys.argv[3], "w") as heartbeat:
|
|
heartbeat.write("1")
|
|
heartbeat.flush()
|
|
os.write(writer, b"1")
|
|
os.close(writer)
|
|
counter = 1
|
|
while True:
|
|
counter += 1
|
|
heartbeat.seek(0)
|
|
heartbeat.write(str(counter))
|
|
heartbeat.truncate()
|
|
heartbeat.flush()
|
|
time.sleep(0.02)
|
|
os.close(writer)
|
|
os.read(reader, 1)
|
|
os.close(reader)
|
|
os._exit(0)
|
|
|
|
signal.signal(signal.SIGTERM, handle_term)
|
|
signal.pthread_sigmask(signal.SIG_UNBLOCK, {signal.SIGTERM})
|
|
with open(sys.argv[1], "w") as handle:
|
|
handle.write("ready")
|
|
time.sleep(30)
|
|
os._exit(0)
|
|
|
|
while not os.path.exists(sys.argv[1]):
|
|
time.sleep(0.01)
|
|
os._exit(0)
|
|
"""
|
|
let stdoutPipe = Pipe()
|
|
let stderrPipe = Pipe()
|
|
let process = try SpawnedProcessGroup.launch(
|
|
binary: "/usr/bin/python3",
|
|
arguments: ["-c", script, readyFile.path, childPIDFile.path, heartbeatFile.path],
|
|
environment: ProcessInfo.processInfo.environment,
|
|
stdoutPipe: stdoutPipe,
|
|
stderrPipe: stderrPipe)
|
|
|
|
while process.isRunning {
|
|
try await Task.sleep(for: .milliseconds(20))
|
|
}
|
|
#expect(FileManager.default.fileExists(atPath: readyFile.path))
|
|
|
|
await process.terminateResidualProcesses(grace: 0.2)
|
|
await process.finish()
|
|
|
|
var childPID: pid_t?
|
|
for _ in 0..<100 {
|
|
if let text = try? String(contentsOf: childPIDFile, encoding: .utf8),
|
|
let parsedPID = pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines))
|
|
{
|
|
childPID = parsedPID
|
|
break
|
|
}
|
|
try await Task.sleep(for: .milliseconds(20))
|
|
}
|
|
let resolvedChildPID = try #require(childPID)
|
|
defer { _ = kill(resolvedChildPID, SIGKILL) }
|
|
let heartbeatAfterCleanup = try String(contentsOf: heartbeatFile, encoding: .utf8)
|
|
try await Task.sleep(for: .milliseconds(200))
|
|
let heartbeatAfterSettle = try String(contentsOf: heartbeatFile, encoding: .utf8)
|
|
#expect(heartbeatAfterSettle == heartbeatAfterCleanup)
|
|
}
|
|
}
|