Skip to content

Commit b3f74ca

Browse files
authored
fix: bound CLI version output draining (#1532)
* fix: bound CLI version output draining * docs: link version probe hang issue * test: stabilize inherited pipe regression
1 parent 605ab5f commit b3f74ca

6 files changed

Lines changed: 96 additions & 47 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
- Command Code: keep showing available credits after the bounded optional subscription grace, including when the transport ignores cancellation (fixes #1131).
1919
- DeepSeek: keep balance refreshes responsive when optional usage-summary work ignores cancellation.
2020
- OpenRouter: keep credit refreshes responsive when optional key-quota enrichment ignores cancellation.
21-
- Provider probes: stop waiting indefinitely for inherited output pipes after a subprocess exits.
21+
- Provider probes: stop waiting indefinitely for inherited output pipes after subprocesses or CLI version checks exit (fixes #1531).
2222
- Menu bar: update visible usage values in place when a manual refresh completes instead of leaving the open provider card stale until the menu is reopened (fixes #1516).
2323
- Gemini: recognize the current `gemini-api-key` CLI auth setting so API-key sessions show the supported OAuth guidance instead of a misleading not-logged-in error (fixes #1511).
2424
- Xiaomi MiMo: cancel optional token-plan requests when the required balance request fails instead of delaying the error for up to 30 seconds.

Sources/CodexBarCore/Host/Process/ProcessPipeCapture.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,16 @@ package final class ProcessPipeCapture: @unchecked Sendable {
2828
return self.stopAndSnapshot()
2929
}
3030

31+
package func finishSynchronously(timeout: TimeInterval) -> Data {
32+
let deadline = Date().addingTimeInterval(max(0, timeout))
33+
self.condition.lock()
34+
while !self.isFinished, !self.isStopping {
35+
guard self.condition.wait(until: deadline) else { break }
36+
}
37+
self.condition.unlock()
38+
return self.stopAndSnapshot()
39+
}
40+
3141
package func stop() {
3242
_ = self.stopAndSnapshot()
3343
}

Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift

Lines changed: 14 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -66,30 +66,20 @@ public struct GrokStatusProbe: Sendable {
6666

6767
public static func detectVersion(env: [String: String] = ProcessInfo.processInfo.environment) -> String? {
6868
guard let binary = BinaryLocator.resolveGrokBinary(env: env) else { return nil }
69-
let process = Process()
70-
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
71-
process.arguments = [binary, "--version"]
72-
let pipe = Pipe()
73-
process.standardOutput = pipe
74-
process.standardError = pipe
75-
do {
76-
try process.run()
77-
process.waitUntilExit()
78-
let data = pipe.fileHandleForReading.readDataToEndOfFile()
79-
guard let output = String(data: data, encoding: .utf8) else { return nil }
80-
// Output is like "grok 0.1.210 (8b63e9068c)" — strip the leading "grok " so
81-
// callers can prefix the CLI name themselves without duplicating it.
82-
let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines)
83-
let firstLine = trimmed.split(separator: "\n").first.map(String.init) ?? trimmed
84-
let withoutPrefix = firstLine.replacingOccurrences(
85-
of: #"^grok\s+"#,
86-
with: "",
87-
options: [.regularExpression])
88-
.trimmingCharacters(in: .whitespacesAndNewlines)
89-
return withoutPrefix.isEmpty ? nil : withoutPrefix
90-
} catch {
91-
return nil
92-
}
69+
guard let output = ProviderVersionDetector.run(
70+
path: binary,
71+
args: ["--version"],
72+
environment: env,
73+
mergeStandardError: true)
74+
else { return nil }
75+
// Output is like "grok 0.1.210 (8b63e9068c)" — strip the leading "grok " so
76+
// callers can prefix the CLI name themselves without duplicating it.
77+
let withoutPrefix = output.replacingOccurrences(
78+
of: #"^grok\s+"#,
79+
with: "",
80+
options: [.regularExpression])
81+
.trimmingCharacters(in: .whitespacesAndNewlines)
82+
return withoutPrefix.isEmpty ? nil : withoutPrefix
9383
}
9484

9585
public func fetch(env: [String: String] = ProcessInfo.processInfo.environment) async throws -> GrokUsageSnapshot {

Sources/CodexBarCore/Providers/Kiro/KiroStatusProbe.swift

Lines changed: 12 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -224,27 +224,20 @@ public struct KiroStatusProbe: Sendable {
224224
private static let logger = CodexBarLog.logger(LogCategories.kiro)
225225

226226
public static func detectVersion() -> String? {
227-
let process = Process()
228-
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
229-
process.arguments = ["kiro-cli", "--version"]
230-
let pipe = Pipe()
231-
process.standardOutput = pipe
232-
process.standardError = pipe
233-
do {
234-
try process.run()
235-
process.waitUntilExit()
236-
let data = pipe.fileHandleForReading.readDataToEndOfFile()
237-
guard let output = String(data: data, encoding: .utf8) else { return nil }
238-
// Output is like "kiro-cli 1.23.1"
239-
let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines)
240-
if trimmed.hasPrefix("kiro-cli ") {
241-
return String(trimmed.dropFirst("kiro-cli ".count))
242-
}
243-
return trimmed.isEmpty ? nil : trimmed
244-
} catch {
245-
self.logger.debug("kiro-cli version detection failed: \(error.localizedDescription)")
227+
guard let path = TTYCommandRunner.which("kiro-cli"),
228+
let output = ProviderVersionDetector.run(
229+
path: path,
230+
args: ["--version"],
231+
mergeStandardError: true)
232+
else {
233+
self.logger.debug("kiro-cli version detection failed")
246234
return nil
247235
}
236+
// Output is like "kiro-cli 1.23.1"
237+
if output.hasPrefix("kiro-cli ") {
238+
return String(output.dropFirst("kiro-cli ".count))
239+
}
240+
return output
248241
}
249242

250243
public func fetch() async throws -> KiroUsageSnapshot {

Sources/CodexBarCore/Providers/ProviderVersionDetector.swift

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,14 +51,23 @@ public enum ProviderVersionDetector {
5151
return nil
5252
}
5353

54-
static func run(path: String, args: [String], timeout: TimeInterval = 2.0) -> String? {
54+
static func run(
55+
path: String,
56+
args: [String],
57+
timeout: TimeInterval = 2.0,
58+
environment: [String: String]? = nil,
59+
mergeStandardError: Bool = false) -> String?
60+
{
5561
let proc = Process()
5662
proc.executableURL = URL(fileURLWithPath: path)
5763
proc.arguments = args
64+
proc.environment = environment
5865
let out = Pipe()
5966
proc.standardOutput = out
60-
proc.standardError = Pipe()
67+
proc.standardError = mergeStandardError ? out : FileHandle.nullDevice
6168
proc.standardInput = nil
69+
let outputCapture = ProcessPipeCapture(pipe: out)
70+
outputCapture.start()
6271

6372
let exitSemaphore = DispatchSemaphore(value: 0)
6473
proc.terminationHandler = { _ in
@@ -68,15 +77,17 @@ public enum ProviderVersionDetector {
6877
do {
6978
try proc.run()
7079
} catch {
80+
outputCapture.stop()
7181
return nil
7282
}
7383

7484
let didExit = exitSemaphore.wait(timeout: .now() + timeout) == .success
7585
if !didExit, !Self.forceExit(proc, exitSemaphore: exitSemaphore) {
86+
outputCapture.stop()
7687
return nil
7788
}
7889

79-
let data = out.fileHandleForReading.readDataToEndOfFile()
90+
let data = outputCapture.finishSynchronously(timeout: 0.25)
8091
guard proc.terminationStatus == 0,
8192
let text = String(data: data, encoding: .utf8)?
8293
.split(whereSeparator: \.isNewline).first

Tests/CodexBarTests/ProviderVersionDetectorTests.swift

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import XCTest
22
@testable import CodexBarCore
33

4+
#if canImport(Darwin)
5+
import Darwin
6+
#else
7+
import Glibc
8+
#endif
9+
410
final class ProviderVersionDetectorTests: XCTestCase {
511
func test_run_returnsFirstLineForSuccessfulCommand() {
612
let version = ProviderVersionDetector.run(
@@ -22,4 +28,43 @@ final class ProviderVersionDetectorTests: XCTestCase {
2228
XCTAssertNil(version)
2329
XCTAssertLessThan(duration, 2.0)
2430
}
31+
32+
func test_run_returnsOutputWhenDetachedChildKeepsPipeOpen() throws {
33+
let root = FileManager.default.temporaryDirectory
34+
.appendingPathComponent("codexbar-version-drain-\(UUID().uuidString)", isDirectory: true)
35+
let childPIDFile = root.appendingPathComponent("child.pid")
36+
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
37+
defer { try? FileManager.default.removeItem(at: root) }
38+
defer {
39+
if let text = try? String(contentsOf: childPIDFile, encoding: .utf8),
40+
let childPID = pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines))
41+
{
42+
_ = kill(childPID, SIGKILL)
43+
}
44+
}
45+
46+
var environment = ProcessInfo.processInfo.environment
47+
environment["CODEXBAR_TEST_CHILD_PID_FILE"] = childPIDFile.path
48+
let script = """
49+
(trap '' HUP; sleep 5) &
50+
child=$!
51+
printf '%s' "$child" > "$CODEXBAR_TEST_CHILD_PID_FILE"
52+
printf 'grok 1.2.3\\n'
53+
"""
54+
55+
let start = Date()
56+
let version = ProviderVersionDetector.run(
57+
path: "/bin/sh",
58+
args: ["-c", script],
59+
timeout: 1.0,
60+
environment: environment)
61+
let duration = Date().timeIntervalSince(start)
62+
63+
XCTAssertEqual(version, "grok 1.2.3")
64+
XCTAssertLessThan(duration, 2.0)
65+
let childPID = try XCTUnwrap(
66+
pid_t(String(contentsOf: childPIDFile, encoding: .utf8)
67+
.trimmingCharacters(in: .whitespacesAndNewlines)))
68+
XCTAssertEqual(kill(childPID, 0), 0, "Descendant should still hold the inherited pipe open")
69+
}
2570
}

0 commit comments

Comments
 (0)