Files
2026-07-13 12:22:33 +08:00

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