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..