Skip to content

Commit ad14534

Browse files
committed
fix: harden Kiro command cleanup
1 parent 0066a98 commit ad14534

5 files changed

Lines changed: 156 additions & 80 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
- 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).
24+
- Kiro: keep usage refreshes bounded when CLI helpers retain output pipes, ignore termination, or are cancelled (fixes #1533). Thanks @kiranmagic7!
2425
- Xiaomi MiMo: cancel optional token-plan requests when the required balance request fails instead of delaying the error for up to 30 seconds.
2526
- Settings: make the cost history window directly editable by keyboard while preserving the existing stepper and 1–365 day bounds (fixes #1499). Thanks @kiranmagic7!
2627

Sources/CodexBarCore/Host/Process/ProcessPipeCapture.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,17 @@ import Foundation
22

33
package final class ProcessPipeCapture: @unchecked Sendable {
44
private let handle: FileHandle
5+
private let onData: (@Sendable () -> Void)?
56
private let condition = NSCondition()
67
private var data = Data()
78
private var activeCallbacks = 0
89
private var isFinished = false
910
private var isStopping = false
1011
private var continuation: CheckedContinuation<Void, Never>?
1112

12-
package init(pipe: Pipe) {
13+
package init(pipe: Pipe, onData: (@Sendable () -> Void)? = nil) {
1314
self.handle = pipe.fileHandleForReading
15+
self.onData = onData
1416
}
1517

1618
package func start() {
@@ -70,6 +72,8 @@ package final class ProcessPipeCapture: @unchecked Sendable {
7072

7173
if chunk.isEmpty {
7274
handle.readabilityHandler = nil
75+
} else {
76+
self.onData?()
7377
}
7478
continuation?.resume()
7579
}

Sources/CodexBarCore/Host/Process/SubprocessRunner.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ public enum SubprocessRunner {
110110
/// Terminates a process and its process group, escalating from SIGTERM to SIGKILL.
111111
/// Returns `true` if the process was actually killed, `false` if it had already exited.
112112
@discardableResult
113-
private static func terminateProcess(_ process: Process, processGroup: pid_t?) -> Bool {
113+
package static func terminateProcess(_ process: Process, processGroup: pid_t?) -> Bool {
114114
guard process.isRunning else { return false }
115115
process.terminate()
116116
if let pgid = processGroup {
@@ -125,6 +125,10 @@ public enum SubprocessRunner {
125125
kill(-pgid, SIGKILL)
126126
}
127127
kill(process.processIdentifier, SIGKILL)
128+
let reapDeadline = Date().addingTimeInterval(0.4)
129+
while process.isRunning, Date() < reapDeadline {
130+
usleep(50000)
131+
}
128132
}
129133
return true
130134
}

Sources/CodexBarCore/Providers/Kiro/KiroStatusProbe.swift

Lines changed: 55 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,7 @@ public struct KiroStatusProbe: Sendable {
269269
contextUsage: contextUsage)
270270
}
271271

272-
private struct KiroCLIResult {
272+
struct KiroCLIResult {
273273
let stdout: String
274274
let stderr: String
275275
let terminationStatus: Int32
@@ -396,7 +396,7 @@ public struct KiroStatusProbe: Sendable {
396396
return self.parseContextUsage(output: output)
397397
}
398398

399-
private func runCommand(
399+
func runCommand(
400400
arguments: [String],
401401
timeout: TimeInterval,
402402
idleTimeout: TimeInterval = 5.0) async throws -> KiroCLIResult
@@ -419,123 +419,100 @@ public struct KiroStatusProbe: Sendable {
419419
env["TERM"] = "xterm-256color"
420420
process.environment = env
421421

422-
// Thread-safe state for activity tracking
423422
final class ActivityState: @unchecked Sendable {
424423
private let lock = NSLock()
425424
private var _lastActivityAt = Date()
426425
private var _hasReceivedOutput = false
427-
private var _stdoutData = Data()
428-
private var _stderrData = Data()
429426

430427
var lastActivityAt: Date {
431-
self.lock.lock()
432-
defer { lock.unlock() }
433-
return self._lastActivityAt
428+
self.lock.withLock { self._lastActivityAt }
434429
}
435430

436431
var hasReceivedOutput: Bool {
437-
self.lock.lock()
438-
defer { lock.unlock() }
439-
return self._hasReceivedOutput
432+
self.lock.withLock { self._hasReceivedOutput }
440433
}
441434

442-
func appendStdout(_ data: Data) {
443-
self.lock.lock()
444-
defer { lock.unlock() }
445-
self._stdoutData.append(data)
446-
self._lastActivityAt = Date()
447-
self._hasReceivedOutput = true
448-
}
449-
450-
func appendStderr(_ data: Data) {
451-
self.lock.lock()
452-
defer { lock.unlock() }
453-
self._stderrData.append(data)
454-
self._lastActivityAt = Date()
455-
self._hasReceivedOutput = true
456-
}
457-
458-
func getOutput() -> (stdout: Data, stderr: Data) {
459-
self.lock.lock()
460-
defer { lock.unlock() }
461-
return (self._stdoutData, self._stderrData)
435+
func markActivity() {
436+
self.lock.withLock {
437+
self._lastActivityAt = Date()
438+
self._hasReceivedOutput = true
439+
}
462440
}
463441
}
464442

465443
let state = ActivityState()
466-
stdoutPipe.fileHandleForReading.readabilityHandler = { handle in
467-
let data = handle.availableData
468-
if !data.isEmpty {
469-
state.appendStdout(data)
470-
}
471-
}
472-
stderrPipe.fileHandleForReading.readabilityHandler = { handle in
473-
let data = handle.availableData
474-
if !data.isEmpty {
475-
state.appendStderr(data)
476-
}
477-
}
444+
let stdoutCapture = ProcessPipeCapture(pipe: stdoutPipe, onData: { state.markActivity() })
445+
let stderrCapture = ProcessPipeCapture(pipe: stderrPipe, onData: { state.markActivity() })
478446

479447
do {
480448
try process.run()
481449
} catch {
482-
stdoutPipe.fileHandleForReading.readabilityHandler = nil
483-
stderrPipe.fileHandleForReading.readabilityHandler = nil
450+
stdoutCapture.stop()
451+
stderrCapture.stop()
484452
throw error
485453
}
454+
stdoutCapture.start()
455+
stderrCapture.start()
456+
let pid = process.processIdentifier
457+
let processGroup: pid_t? = setpgid(pid, pid) == 0 ? pid : nil
486458

487459
let deadline = Date().addingTimeInterval(timeout)
488460
var didHitDeadline = false
489461
var didTerminateForIdle = false
490462

491-
while process.isRunning {
492-
if Date() >= deadline {
493-
didHitDeadline = true
494-
break
495-
}
496-
if state.hasReceivedOutput,
497-
Date().timeIntervalSince(state.lastActivityAt) >= idleTimeout
498-
{
499-
didTerminateForIdle = true
500-
break
463+
do {
464+
while process.isRunning {
465+
try Task.checkCancellation()
466+
if Date() >= deadline {
467+
didHitDeadline = true
468+
break
469+
}
470+
if state.hasReceivedOutput,
471+
Date().timeIntervalSince(state.lastActivityAt) >= idleTimeout
472+
{
473+
didTerminateForIdle = true
474+
break
475+
}
476+
try await Task.sleep(for: .milliseconds(100))
501477
}
502-
try await Task.sleep(for: .milliseconds(100))
478+
} catch {
479+
await Self.terminateProcess(process, processGroup: processGroup)
480+
stdoutCapture.stop()
481+
stderrCapture.stop()
482+
throw error
503483
}
504484

505485
if process.isRunning {
506-
Self.terminateProcess(process)
486+
await Self.terminateProcess(process, processGroup: processGroup)
487+
guard !process.isRunning else {
488+
stdoutCapture.stop()
489+
stderrCapture.stop()
490+
throw KiroStatusProbeError.timeout
491+
}
507492
if didHitDeadline || !state.hasReceivedOutput {
508-
stdoutPipe.fileHandleForReading.readabilityHandler = nil
509-
stderrPipe.fileHandleForReading.readabilityHandler = nil
493+
stdoutCapture.stop()
494+
stderrCapture.stop()
510495
throw KiroStatusProbeError.timeout
511496
}
512497
}
513498

514-
try await Task.sleep(for: .milliseconds(100))
515-
stdoutPipe.fileHandleForReading.readabilityHandler = nil
516-
stderrPipe.fileHandleForReading.readabilityHandler = nil
517-
518-
let output = state.getOutput()
519-
let stdoutOutput = String(data: output.stdout, encoding: .utf8) ?? ""
520-
let stderrOutput = String(data: output.stderr, encoding: .utf8) ?? ""
499+
async let stdoutData = stdoutCapture.finish(timeout: .seconds(1))
500+
async let stderrData = stderrCapture.finish(timeout: .seconds(1))
501+
let output = await (stdout: stdoutData, stderr: stderrData)
521502
return KiroCLIResult(
522-
stdout: stdoutOutput,
523-
stderr: stderrOutput,
503+
stdout: String(data: output.stdout, encoding: .utf8) ?? "",
504+
stderr: String(data: output.stderr, encoding: .utf8) ?? "",
524505
terminationStatus: process.terminationStatus,
525506
terminatedForIdle: didTerminateForIdle)
526507
}
527508

528-
private static func terminateProcess(_ process: Process) {
529-
guard process.isRunning else { return }
530-
process.terminate()
531-
let deadline = Date().addingTimeInterval(0.4)
532-
while process.isRunning, Date() < deadline {
533-
usleep(50000)
534-
}
535-
if process.isRunning {
536-
kill(process.processIdentifier, SIGKILL)
509+
private static func terminateProcess(_ process: Process, processGroup: pid_t?) async {
510+
await withCheckedContinuation { continuation in
511+
DispatchQueue.global(qos: .userInitiated).async {
512+
SubprocessRunner.terminateProcess(process, processGroup: processGroup)
513+
continuation.resume()
514+
}
537515
}
538-
process.waitUntilExit()
539516
}
540517

541518
func parse(

Tests/CodexBarTests/KiroStatusProbeTests.swift

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,86 @@ struct KiroStatusProbeTests {
7575
#expect(elapsed < 3, "Kiro usage capture should not wait for inherited pipe EOF, took \(elapsed)s")
7676
}
7777

78+
@Test
79+
func `run command hard stops a process that ignores SIGTERM`() async throws {
80+
let cliURL = try self.makeCLI(
81+
"""
82+
#!/bin/sh
83+
trap '' TERM
84+
printf 'partial output\\n'
85+
while true; do sleep 1; done
86+
""")
87+
defer { try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) }
88+
89+
let probe = KiroStatusProbe(cliBinaryResolver: { cliURL.path })
90+
let start = Date()
91+
let result = try await probe.runCommand(arguments: [], timeout: 2, idleTimeout: 0.1)
92+
let elapsed = Date().timeIntervalSince(start)
93+
94+
#expect(result.terminatedForIdle)
95+
#expect(result.stdout.contains("partial output"))
96+
#expect(result.terminationStatus != 0)
97+
#expect(elapsed < 2, "Ignored SIGTERM should escalate to SIGKILL, took \(elapsed)s")
98+
}
99+
100+
@Test
101+
func `run command preserves completed no-output failure status`() async throws {
102+
let cliURL = try self.makeCLI(
103+
"""
104+
#!/bin/sh
105+
exit 23
106+
""")
107+
defer { try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) }
108+
109+
let probe = KiroStatusProbe(cliBinaryResolver: { cliURL.path })
110+
let result = try await probe.runCommand(arguments: [], timeout: 2)
111+
112+
#expect(result.stdout.isEmpty)
113+
#expect(result.stderr.isEmpty)
114+
#expect(result.terminationStatus == 23)
115+
#expect(!result.terminatedForIdle)
116+
}
117+
118+
@Test
119+
func `run command cancellation terminates the process`() async throws {
120+
let pidFile = FileManager.default.temporaryDirectory
121+
.appendingPathComponent("codexbar-kiro-cancel-\(UUID().uuidString).pid")
122+
let cliURL = try self.makeCLI(
123+
"""
124+
#!/bin/sh
125+
printf '%s\\n' "$$" > "$1"
126+
trap '' TERM
127+
while true; do sleep 1; done
128+
""")
129+
defer {
130+
try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent())
131+
try? FileManager.default.removeItem(at: pidFile)
132+
}
133+
134+
let probe = KiroStatusProbe(cliBinaryResolver: { cliURL.path })
135+
let task = Task {
136+
try await probe.runCommand(arguments: [pidFile.path], timeout: 20)
137+
}
138+
defer { task.cancel() }
139+
140+
var capturedProcessID: pid_t?
141+
for _ in 0..<100 {
142+
if let text = try? String(contentsOf: pidFile, encoding: .utf8) {
143+
capturedProcessID = pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines))
144+
break
145+
}
146+
try await Task.sleep(for: .milliseconds(20))
147+
}
148+
let processID = try #require(capturedProcessID)
149+
defer { _ = kill(processID, SIGKILL) }
150+
151+
task.cancel()
152+
await #expect(throws: CancellationError.self) {
153+
try await task.value
154+
}
155+
#expect(kill(processID, 0) == -1)
156+
}
157+
78158
// MARK: - Happy Path Parsing
79159

80160
@Test
@@ -99,6 +179,16 @@ struct KiroStatusProbeTests {
99179
#expect(snapshot.resetsAt != nil)
100180
}
101181

182+
private func makeCLI(_ script: String) throws -> URL {
183+
let root = FileManager.default.temporaryDirectory
184+
.appendingPathComponent("codexbar-kiro-cli-\(UUID().uuidString)", isDirectory: true)
185+
let cliURL = root.appendingPathComponent("kiro-cli")
186+
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
187+
try script.write(to: cliURL, atomically: true, encoding: .utf8)
188+
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: cliURL.path)
189+
return cliURL
190+
}
191+
102192
@Test
103193
func `parses output with bonus credits`() throws {
104194
let output = """

0 commit comments

Comments
 (0)