aboutsummaryrefslogtreecommitdiff
path: root/Sources
diff options
context:
space:
mode:
authorTommaso Negri <me@tommasongr.com>2026-10-15 11:01:01 +0200
committerTommaso Negri <me@tommasongr.com>2026-10-15 11:01:01 +0200
commit758f757cb9b60dddf10efd5d37becfec9f4b5196 (patch)
tree8c9a6ceea48a5981765e2150ea01890544bd231e /Sources
parent498ac51307412adc29d6d4a6240ae7b98d55621e (diff)
downloadbar-758f757cb9b60dddf10efd5d37becfec9f4b5196.tar.gz
bar-758f757cb9b60dddf10efd5d37becfec9f4b5196.zip
drain command stdout and stderr concurrently
Diffstat (limited to 'Sources')
-rw-r--r--Sources/bar/Models/Runner.swift20
-rw-r--r--Sources/bar/bar.docc/command.md6
2 files changed, 17 insertions, 9 deletions
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