From 81ef51becbdb6ee5ec3698ee245f1c94222a55ad Mon Sep 17 00:00:00 2001 From: Philip Niedertscheider Date: Thu, 24 Sep 2026 20:44:00 +0200 Subject: [PATCH 1/2] test(cli): Drain subprocess output concurrently Keep stderr separate without deadlocking on full pipes, disable telemetry in child processes, and use macOS 13-compatible synchronization. Exercise large stderr, output separation, and exit failures with local subprocess fixtures. --- .../AppleDocsCommand.swift | 63 +++++++++++++++++-- .../AppleDocsCommandTests.swift | 59 +++++++++++++++++ 2 files changed, 118 insertions(+), 4 deletions(-) create mode 100644 Tests/CLIIntegrationTests/AppleDocsCommandTests.swift diff --git a/Tests/CLIIntegrationTests/AppleDocsCommand.swift b/Tests/CLIIntegrationTests/AppleDocsCommand.swift index 9b63fa4..677f73b 100644 --- a/Tests/CLIIntegrationTests/AppleDocsCommand.swift +++ b/Tests/CLIIntegrationTests/AppleDocsCommand.swift @@ -1,9 +1,17 @@ +import Dispatch import Foundation +#if canImport(Darwin) + import Darwin +#else + import Glibc +#endif + enum AppleDocsCommandError: Error, LocalizedError { case failed(status: Int32, stderr: String) case invalidUTF8(stream: String) case missingExecutable + case readFailed(code: Int32) var errorDescription: String? { switch self { @@ -13,12 +21,18 @@ enum AppleDocsCommandError: Error, LocalizedError { return "apple-docs returned invalid UTF-8 on \(stream)." case .missingExecutable: return "APPLE_DOCS_EXECUTABLE is not set. Run the tests with make test-integration." + case .readFailed(let code): + return "Could not read apple-docs output: errno \(code)." } } } -func runAppleDocs(_ arguments: [String]) throws -> String { - guard let executablePath = ProcessInfo.processInfo.environment["APPLE_DOCS_EXECUTABLE"] else { +func runAppleDocs( + _ arguments: [String], + executablePath: String? = ProcessInfo.processInfo.environment["APPLE_DOCS_EXECUTABLE"], + captureStandardError: (String) -> Void = { _ in } +) throws -> String { + guard let executablePath else { throw AppleDocsCommandError.missingExecutable } @@ -27,13 +41,27 @@ func runAppleDocs(_ arguments: [String]) throws -> String { let standardError = Pipe() process.executableURL = URL(fileURLWithPath: executablePath) process.arguments = arguments + process.standardInput = FileHandle.nullDevice + var environment = ProcessInfo.processInfo.environment + environment["TELEMETRY_DISABLED"] = "true" + process.environment = environment process.standardOutput = standardOutput process.standardError = standardError try process.run() - let outputData = standardOutput.fileHandleForReading.readDataToEndOfFile() - let errorData = standardError.fileHandleForReading.readDataToEndOfFile() + let errorBuffer = CommandErrorBuffer() + let draining = DispatchGroup() + draining.enter() + DispatchQueue.global().async { + let result = Result { try readCommandOutput(standardError.fileHandleForReading) } + errorBuffer.store(result) + draining.leave() + } + let outputResult = Result { try readCommandOutput(standardOutput.fileHandleForReading) } process.waitUntilExit() + draining.wait() + let outputData = try outputResult.get() + let errorData = try errorBuffer.load().get() guard let output = String(data: outputData, encoding: .utf8) else { throw AppleDocsCommandError.invalidUTF8(stream: "standard output") @@ -41,8 +69,35 @@ func runAppleDocs(_ arguments: [String]) throws -> String { guard let error = String(data: errorData, encoding: .utf8) else { throw AppleDocsCommandError.invalidUTF8(stream: "standard error") } + captureStandardError(error) guard process.terminationStatus == 0 else { throw AppleDocsCommandError.failed(status: process.terminationStatus, stderr: error) } return output } + +// NSLock keeps the integration harness usable on the package's macOS 13 deployment target. +private final class CommandErrorBuffer: @unchecked Sendable { + private let lock = NSLock() + private var result: Result = .success(Data()) + + func store(_ result: Result) { + lock.withLock { self.result = result } + } + + func load() -> Result { + lock.withLock { result } + } +} + +private func readCommandOutput(_ handle: FileHandle) throws -> Data { + var result = Data() + var buffer = [UInt8](repeating: 0, count: 16384) + while true { + let count = read(handle.fileDescriptor, &buffer, buffer.count) + if count < 0 && errno == EINTR { continue } + guard count >= 0 else { throw AppleDocsCommandError.readFailed(code: errno) } + if count == 0 { return result } + result.append(contentsOf: buffer.prefix(count)) + } +} diff --git a/Tests/CLIIntegrationTests/AppleDocsCommandTests.swift b/Tests/CLIIntegrationTests/AppleDocsCommandTests.swift new file mode 100644 index 0000000..9286257 --- /dev/null +++ b/Tests/CLIIntegrationTests/AppleDocsCommandTests.swift @@ -0,0 +1,59 @@ +import Foundation +import Testing + +@Suite("CLI subprocess capture") +struct AppleDocsCommandTests { + @Test("captures stdout and stderr separately and disables telemetry") + func capturesSeparateStreams() throws { + // -- Arrange -- + let script = "printf '%s' \"$TELEMETRY_DISABLED\"; printf '%s' diagnostics >&2" + var diagnostics = "" + + // -- Act -- + let output = try runAppleDocs( + ["-c", script], executablePath: "/bin/sh", captureStandardError: { diagnostics = $0 }) + + // -- Assert -- + #expect(output == "true") + #expect(diagnostics == "diagnostics") + } + + @Test("drains large stderr while stdout is still open") + func drainsBothPipes() throws { + // -- Arrange -- + // The watchdog turns a pipe deadlock into an ordinary subprocess failure. + let script = """ + parent=$$ + (sleep 10; kill -TERM "$parent") >/dev/null 2>&1 & + watchdog=$! + trap 'kill "$watchdog" 2>/dev/null' EXIT + dd if=/dev/zero bs=1024 count=256 >&2 2>/dev/null + printf complete + """ + var diagnostics = "" + + // -- Act -- + let output = try runAppleDocs( + ["-c", script], executablePath: "/bin/sh", captureStandardError: { diagnostics = $0 }) + + // -- Assert -- + #expect(output == "complete") + #expect(diagnostics.utf8.count == 262_144) + } + + @Test("reports nonzero exit status and stderr") + func reportsFailure() throws { + // -- Arrange -- + let script = "printf '%s' failure >&2; exit 7" + + // -- Act -- + do { + _ = try runAppleDocs(["-c", script], executablePath: "/bin/sh") + Issue.record("Expected a subprocess failure") + } catch AppleDocsCommandError.failed(let status, let stderr) { + // -- Assert -- + #expect(status == 7) + #expect(stderr == "failure") + } + } +} From 9a6524b47fad544524f51c803c7b4bd74d68af3f Mon Sep 17 00:00:00 2001 From: Philip Niedertscheider Date: Thu, 24 Sep 2026 21:12:13 +0200 Subject: [PATCH 2/2] fix(test): Avoid cooperative-pool starvation in pipe capture Drain stderr on a dedicated thread so synchronous test callers cannot occupy every worker needed by the reader. Exercise 32 concurrent subprocesses to reproduce the macOS CI deadlock before the fix. --- Tests/CLIIntegrationTests/AppleDocsCommand.swift | 4 +++- Tests/CLIIntegrationTests/AppleDocsCommandTests.swift | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Tests/CLIIntegrationTests/AppleDocsCommand.swift b/Tests/CLIIntegrationTests/AppleDocsCommand.swift index 677f73b..02925ce 100644 --- a/Tests/CLIIntegrationTests/AppleDocsCommand.swift +++ b/Tests/CLIIntegrationTests/AppleDocsCommand.swift @@ -52,7 +52,9 @@ func runAppleDocs( let errorBuffer = CommandErrorBuffer() let draining = DispatchGroup() draining.enter() - DispatchQueue.global().async { + // Synchronous callers can occupy every cooperative worker. The pipe reader must not + // depend on that same dispatch pool to unblock them. + Thread.detachNewThread { let result = Result { try readCommandOutput(standardError.fileHandleForReading) } errorBuffer.store(result) draining.leave() diff --git a/Tests/CLIIntegrationTests/AppleDocsCommandTests.swift b/Tests/CLIIntegrationTests/AppleDocsCommandTests.swift index 9286257..3a63441 100644 --- a/Tests/CLIIntegrationTests/AppleDocsCommandTests.swift +++ b/Tests/CLIIntegrationTests/AppleDocsCommandTests.swift @@ -18,8 +18,8 @@ struct AppleDocsCommandTests { #expect(diagnostics == "diagnostics") } - @Test("drains large stderr while stdout is still open") - func drainsBothPipes() throws { + @Test("drains large stderr while stdout is still open", arguments: 0..<32) + func drainsBothPipes(iteration _: Int) throws { // -- Arrange -- // The watchdog turns a pipe deadlock into an ordinary subprocess failure. let script = """