From 758f757cb9b60dddf10efd5d37becfec9f4b5196 Mon Sep 17 00:00:00 2001 From: Tommaso Negri Date: Thu, 15 Oct 2026 11:01:01 +0200 Subject: drain command stdout and stderr concurrently --- Sources/bar/Models/Runner.swift | 20 ++++++++++++++------ Sources/bar/bar.docc/command.md | 6 +++--- 2 files changed, 17 insertions(+), 9 deletions(-) (limited to 'Sources') diff --git a/Sources/bar/Models/Runner.swift b/Sources/bar/Models/Runner.swift index 6c90d54..4b6694f 100644 --- a/Sources/bar/Models/Runner.swift +++ b/Sources/bar/Models/Runner.swift @@ -19,14 +19,15 @@ enum Runner { /// /// - Returns: Parsed stdout on success; ["ERROR"] on launch failure or /// nonzero exit, or [""] if successful output cannot be decoded as UTF-8. - /// Captured stderr is forwarded only after a nonzero exit. - static func run(_ command: String, env: [String: String] = [:]) -> [String] { + /// Captured stderr is forwarded regardless of exit status. + static func run(_ command: String, env: [String: String] = [:], + errorOutput: FileHandle = .standardError) -> [String] { // Detached tasks do not run inside AppKit's main event-loop pool. // Drain temporary Foundation objects at the end of each command. - autoreleasepool { execute(command, env: env) } + autoreleasepool { execute(command, env: env, errorOutput: errorOutput) } } - private static func execute(_ command: String, env: [String: String]) -> [String] { + private static func execute(_ command: String, env: [String: String], errorOutput: FileHandle) -> [String] { let process = Process() process.executableURL = URL(fileURLWithPath: "/bin/sh") process.arguments = ["-c", command] @@ -53,14 +54,21 @@ enum Runner { } timer.resume() + // Drain stderr alongside stdout so neither full pipe can stall the child. + // Use GCD for this blocking read, not Swift's cooperative executor. + let diagnostics = DispatchGroup() + DispatchQueue.global(qos: .utility).async(group: diagnostics) { + autoreleasepool { + write(stderr.fileHandleForReading.readDataToEndOfFile(), to: errorOutput) + } + } let data = stdout.fileHandleForReading.readDataToEndOfFile() - let errData = stderr.fileHandleForReading.readDataToEndOfFile() process.waitUntilExit() + diagnostics.wait() timer.cancel() guard process.terminationStatus == 0 else { - write(errData, to: .standardError) return ["ERROR"] } diff --git a/Sources/bar/bar.docc/command.md b/Sources/bar/bar.docc/command.md index 3196011..c2ad917 100644 --- a/Sources/bar/bar.docc/command.md +++ b/Sources/bar/bar.docc/command.md @@ -29,13 +29,13 @@ On the contrary, if you run `bar` from a launch agent, you'll probably not have ## Error handling If the command exits with a non-zero status or cannot be launched, the block displays a single cell containing the text `ERROR`. -After a non-zero exit, captured stderr is forwarded to the `bar` process stderr. -Stderr from a successful command is discarded, and launch failures do not forward a diagnostic. +Captured stderr is forwarded to the `bar` process stderr, including on successful exits. +Launch failures do not forward a diagnostic. Successful stdout that cannot be decoded as UTF-8 produces a blank cell. After 30 seconds, *bar* requests termination of the shell with SIGTERM. This is not a hard deadline: a shell can ignore the signal, and child processes can keep the output pipes open. -The current runner reads stdout and then stderr before waiting for exit, so commands with enough stderr output can also block while stdout is being read. +Stdout and stderr are drained concurrently so a full output pipe does not stall the command. ```shell # Example: simulate a command failure -- cgit v1.2.3