Files
steipete--codexbar/Tests/CodexBarTests/SpawnedProcessGroupTests.swift
T
2026-07-13 12:22:33 +08:00

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)
}
}