334 lines
13 KiB
Swift
334 lines
13 KiB
Swift
import Foundation
|
|
import Testing
|
|
@testable import CodexBarCore
|
|
|
|
#if canImport(Darwin)
|
|
import Darwin
|
|
#else
|
|
import Glibc
|
|
#endif
|
|
|
|
struct SubprocessRunnerTests {
|
|
@Test
|
|
func `reads large stdout without deadlock`() async throws {
|
|
let result = try await SubprocessRunner.run(
|
|
binary: "/usr/bin/python3",
|
|
arguments: ["-c", "print('x' * 1_000_000)"],
|
|
environment: ProcessInfo.processInfo.environment,
|
|
timeout: 15,
|
|
label: "python large stdout")
|
|
|
|
#expect(result.stdout.count >= 1_000_000)
|
|
#expect(result.stderr.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func `bounds oversized stdout while continuing to drain`() async throws {
|
|
let result = try await SubprocessRunner.run(
|
|
binary: "/usr/bin/python3",
|
|
arguments: ["-c", "print('x' * 2_000_000)"],
|
|
environment: ProcessInfo.processInfo.environment,
|
|
timeout: 5,
|
|
label: "python oversized stdout")
|
|
|
|
#expect(result.stdout.utf8.count == ProcessPipeCapture.defaultMaxBytes)
|
|
#expect(result.stderr.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func `preserves captured prefix when limit splits three byte scalar`() async throws {
|
|
let asciiCount = ProcessPipeCapture.defaultMaxBytes - 1
|
|
let script = "import sys; sys.stdout.buffer.write(b'x' * \(asciiCount) + bytes([0xe2, 0x82, 0xac]) + b'tail')"
|
|
let result = try await SubprocessRunner.run(
|
|
binary: "/usr/bin/python3",
|
|
arguments: ["-c", script],
|
|
environment: ProcessInfo.processInfo.environment,
|
|
timeout: 5,
|
|
label: "python split utf8 stdout")
|
|
|
|
#expect(result.stdout.count == ProcessPipeCapture.defaultMaxBytes)
|
|
#expect(result.stdout.first == "x")
|
|
#expect(result.stdout.last == "\u{FFFD}")
|
|
#expect(result.stdout.utf8.count == ProcessPipeCapture.defaultMaxBytes + 2)
|
|
}
|
|
|
|
@Test
|
|
func `bounds simultaneous oversized stdout and stderr while draining`() async throws {
|
|
let script = """
|
|
import sys
|
|
chunk = 2048
|
|
for _ in range(2000):
|
|
sys.stdout.write('o' * chunk)
|
|
sys.stdout.flush()
|
|
sys.stderr.write('e' * chunk)
|
|
sys.stderr.flush()
|
|
"""
|
|
let result = try await SubprocessRunner.run(
|
|
binary: "/usr/bin/python3",
|
|
arguments: ["-c", script],
|
|
environment: ProcessInfo.processInfo.environment,
|
|
timeout: 10,
|
|
label: "python simultaneous oversized output")
|
|
|
|
#expect(result.stdout.utf8.count == ProcessPipeCapture.defaultMaxBytes)
|
|
#expect(result.stderr.utf8.count == ProcessPipeCapture.defaultMaxBytes)
|
|
}
|
|
|
|
@Test
|
|
func `bounds oversized stderr on failure`() async throws {
|
|
do {
|
|
_ = try await SubprocessRunner.run(
|
|
binary: "/usr/bin/python3",
|
|
arguments: ["-c", "import sys; sys.stderr.write('e' * 2_000_000); sys.exit(7)"],
|
|
environment: ProcessInfo.processInfo.environment,
|
|
timeout: 5,
|
|
label: "python oversized stderr")
|
|
Issue.record("Expected non-zero exit")
|
|
} catch let error as SubprocessRunnerError {
|
|
guard case let .nonZeroExit(code, stderr) = error else {
|
|
Issue.record("Expected non-zero exit, got \(error)")
|
|
return
|
|
}
|
|
#expect(code == 7)
|
|
#expect(stderr.utf8.count == ProcessPipeCapture.defaultMaxBytes)
|
|
} catch {
|
|
Issue.record("Expected SubprocessRunnerError, got \(error)")
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `returns partial output when detached child keeps pipes open`() async throws {
|
|
let root = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("codexbar-subprocess-drain-\(UUID().uuidString)", isDirectory: true)
|
|
let childPIDFile = root.appendingPathComponent("child.pid")
|
|
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
|
defer { try? FileManager.default.removeItem(at: root) }
|
|
defer {
|
|
if let text = try? String(contentsOf: childPIDFile, encoding: .utf8),
|
|
let childPID = pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines))
|
|
{
|
|
_ = kill(childPID, SIGKILL)
|
|
}
|
|
}
|
|
|
|
var environment = ProcessInfo.processInfo.environment
|
|
environment["CODEXBAR_TEST_CHILD_PID_FILE"] = childPIDFile.path
|
|
let script = """
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
|
|
child = subprocess.Popen(
|
|
[sys.executable, "-c", "import time; time.sleep(5)"],
|
|
start_new_session=True,
|
|
)
|
|
with open(os.environ["CODEXBAR_TEST_CHILD_PID_FILE"], "w") as handle:
|
|
handle.write(str(child.pid))
|
|
print("parent-output", flush=True)
|
|
"""
|
|
|
|
let start = Date()
|
|
let result = try await SubprocessRunner.run(
|
|
binary: "/usr/bin/python3",
|
|
arguments: ["-c", script],
|
|
environment: environment,
|
|
timeout: 5,
|
|
label: "detached-output-holder")
|
|
let elapsed = Date().timeIntervalSince(start)
|
|
|
|
#expect(result.stdout.contains("parent-output"))
|
|
#expect(elapsed < 3, "Output drain should not wait for the detached child, took \(elapsed)s")
|
|
}
|
|
|
|
/// Regression test for #474: a hung subprocess must be killed and throw `.timedOut`
|
|
/// instead of blocking indefinitely.
|
|
///
|
|
/// This test was previously deleted (commit 3961770) because `waitUntilExit()` blocked
|
|
/// the cooperative thread pool, starving the timeout task. The fix moves blocking calls
|
|
/// to `DispatchQueue.global()`, making this test reliable.
|
|
@Test
|
|
func `throws timed out when process hangs`() async throws {
|
|
let start = Date()
|
|
do {
|
|
_ = try await SubprocessRunner.run(
|
|
binary: "/bin/sleep",
|
|
arguments: ["5"],
|
|
environment: ProcessInfo.processInfo.environment,
|
|
timeout: 1,
|
|
label: "hung-process-test")
|
|
Issue.record("Expected SubprocessRunnerError.timedOut but no error was thrown")
|
|
} catch let error as SubprocessRunnerError {
|
|
guard case let .timedOut(label) = error else {
|
|
Issue.record("Expected .timedOut, got \(error)")
|
|
return
|
|
}
|
|
#expect(label == "hung-process-test")
|
|
} catch {
|
|
Issue.record("Expected SubprocessRunnerError.timedOut, got unexpected error: \(error)")
|
|
}
|
|
|
|
let elapsed = Date().timeIntervalSince(start)
|
|
// Must complete in well under 5s (the sleep duration). Allow generous bound for CI.
|
|
#expect(elapsed < 3, "Timeout should fire in ~1s, not wait for process to exit naturally")
|
|
}
|
|
|
|
@Test
|
|
func `timeout kills descendants that escape the process group`() async throws {
|
|
let root = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("codexbar-subprocess-tree-\(UUID().uuidString)", isDirectory: true)
|
|
let childPIDFile = root.appendingPathComponent("child.pid")
|
|
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
|
defer { try? FileManager.default.removeItem(at: root) }
|
|
|
|
var environment = ProcessInfo.processInfo.environment
|
|
environment["CODEXBAR_TEST_CHILD_PID_FILE"] = childPIDFile.path
|
|
let script = """
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
child = subprocess.Popen(
|
|
[sys.executable, "-c", "import time; time.sleep(30)"],
|
|
start_new_session=True,
|
|
)
|
|
with open(os.environ["CODEXBAR_TEST_CHILD_PID_FILE"], "w") as handle:
|
|
handle.write(str(child.pid))
|
|
time.sleep(30)
|
|
"""
|
|
|
|
await #expect(throws: SubprocessRunnerError.self) {
|
|
try await SubprocessRunner.run(
|
|
binary: "/usr/bin/python3",
|
|
arguments: ["-c", script],
|
|
environment: environment,
|
|
timeout: 0.5,
|
|
label: "escaped-descendant")
|
|
}
|
|
|
|
let text = try String(contentsOf: childPIDFile, encoding: .utf8)
|
|
let childPID = try #require(pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines)))
|
|
defer { _ = kill(childPID, SIGKILL) }
|
|
|
|
let deadline = Date().addingTimeInterval(1)
|
|
while kill(childPID, 0) == 0, Date() < deadline {
|
|
try await Task.sleep(for: .milliseconds(20))
|
|
}
|
|
#expect(kill(childPID, 0) == -1)
|
|
}
|
|
|
|
/// Multiple concurrent hung subprocesses must all time out independently, proving that
|
|
/// one blocked subprocess does not starve the timeout mechanism of others.
|
|
/// This is the core scenario that caused the original permanent-refresh-stall bug.
|
|
@Test
|
|
func `concurrent hung processes all time out`() async {
|
|
let start = Date()
|
|
let count = 8
|
|
|
|
await withTaskGroup(of: Void.self) { group in
|
|
for i in 0..<count {
|
|
group.addTask {
|
|
do {
|
|
_ = try await SubprocessRunner.run(
|
|
binary: "/bin/sleep",
|
|
arguments: ["30"],
|
|
environment: ProcessInfo.processInfo.environment,
|
|
timeout: 2,
|
|
label: "concurrent-hung-\(i)")
|
|
Issue.record("Expected .timedOut for concurrent-hung-\(i)")
|
|
} catch let error as SubprocessRunnerError {
|
|
guard case .timedOut = error else {
|
|
Issue.record("Expected .timedOut for concurrent-hung-\(i), got \(error)")
|
|
return
|
|
}
|
|
} catch {
|
|
Issue.record("Unexpected error for concurrent-hung-\(i): \(error)")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let elapsed = Date().timeIntervalSince(start)
|
|
// Under release-load parallel tests the runner can be CPU-starved, but it should still finish far before
|
|
// the natural sleep exit and below a serial 8 * 2s timeout chain.
|
|
#expect(
|
|
elapsed < 15,
|
|
"All \(count) concurrent timeouts should fire in ~2s, took \(elapsed)s")
|
|
}
|
|
|
|
/// Stress-test the timeout race guard: with very short timeouts, the exit-code task
|
|
/// and the timeout task race tightly, exercising the KillFlag synchronization path.
|
|
@Test
|
|
func `timeout race stress`() async {
|
|
for i in 0..<20 {
|
|
do {
|
|
_ = try await SubprocessRunner.run(
|
|
binary: "/bin/sleep",
|
|
arguments: ["1"],
|
|
environment: ProcessInfo.processInfo.environment,
|
|
timeout: 0.1,
|
|
label: "race-stress-\(i)")
|
|
Issue.record("Expected .timedOut for iteration \(i)")
|
|
} catch let error as SubprocessRunnerError {
|
|
guard case .timedOut = error else {
|
|
Issue.record("Expected .timedOut, got \(error) at iteration \(i)")
|
|
continue
|
|
}
|
|
} catch {
|
|
Issue.record("Unexpected error at iteration \(i): \(error)")
|
|
}
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `cancellation terminates hung process promptly`() async throws {
|
|
let start = Date()
|
|
let task = Task {
|
|
try await SubprocessRunner.run(
|
|
binary: "/bin/sleep",
|
|
arguments: ["10"],
|
|
environment: ProcessInfo.processInfo.environment,
|
|
timeout: 30,
|
|
label: "cancelled-hung-process")
|
|
}
|
|
|
|
try await Task.sleep(for: .milliseconds(100))
|
|
task.cancel()
|
|
|
|
do {
|
|
_ = try await task.value
|
|
Issue.record("Expected CancellationError but subprocess completed")
|
|
} catch is CancellationError {
|
|
// Expected: cancellation should tear down the child process immediately.
|
|
} catch {
|
|
Issue.record("Expected CancellationError, got \(error)")
|
|
}
|
|
|
|
let elapsed = Date().timeIntervalSince(start)
|
|
#expect(elapsed < 5, "Cancelled subprocess should not wait for timeout or natural exit")
|
|
}
|
|
|
|
/// Verify that many concurrent SubprocessRunner calls complete without starving each other.
|
|
@Test
|
|
func `concurrent calls do not starve`() async throws {
|
|
try await withThrowingTaskGroup(of: SubprocessResult.self) { group in
|
|
for i in 0..<20 {
|
|
group.addTask {
|
|
try await SubprocessRunner.run(
|
|
binary: "/bin/sleep",
|
|
arguments: ["0.2"],
|
|
environment: ProcessInfo.processInfo.environment,
|
|
timeout: 10,
|
|
label: "concurrent-\(i)")
|
|
}
|
|
}
|
|
|
|
var count = 0
|
|
for try await _ in group {
|
|
count += 1
|
|
}
|
|
#expect(count == 20, "All 20 concurrent calls should complete")
|
|
}
|
|
}
|
|
}
|