From 99f681e383bb58e916f0e9e83dbb39a23150709c Mon Sep 17 00:00:00 2001 From: Kiran Magic Date: Mon, 15 Jun 2026 05:00:49 +0530 Subject: [PATCH 1/4] Fix Kiro usage command pipe hangs --- .../Providers/Kiro/KiroStatusProbe.swift | 132 ++++++++++-------- .../CodexBarTests/KiroStatusProbeTests.swift | 66 +++++++++ 2 files changed, 137 insertions(+), 61 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Kiro/KiroStatusProbe.swift b/Sources/CodexBarCore/Providers/Kiro/KiroStatusProbe.swift index 88952a1ca5..459e0c7d43 100644 --- a/Sources/CodexBarCore/Providers/Kiro/KiroStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Kiro/KiroStatusProbe.swift @@ -1,4 +1,9 @@ import Foundation +#if canImport(Darwin) +import Darwin +#else +import Glibc +#endif public struct KiroUsageSnapshot: Sendable { public let planName: String @@ -219,7 +224,15 @@ public enum KiroStatusProbeError: LocalizedError, Sendable { } public struct KiroStatusProbe: Sendable { - public init() {} + private let cliBinaryResolver: @Sendable () -> String? + + public init() { + self.cliBinaryResolver = { TTYCommandRunner.which("kiro-cli") } + } + + init(cliBinaryResolver: @escaping @Sendable () -> String?) { + self.cliBinaryResolver = cliBinaryResolver + } private static let logger = CodexBarLog.logger(LogCategories.kiro) @@ -388,7 +401,7 @@ public struct KiroStatusProbe: Sendable { timeout: TimeInterval, idleTimeout: TimeInterval = 5.0) async throws -> KiroCLIResult { - guard let binary = TTYCommandRunner.which("kiro-cli") else { + guard let binary = self.cliBinaryResolver() else { throw KiroStatusProbeError.cliNotFound } @@ -450,8 +463,6 @@ public struct KiroStatusProbe: Sendable { } let state = ActivityState() - - // Set up readability handlers to track activity stdoutPipe.fileHandleForReading.readabilityHandler = { handle in let data = handle.availableData if !data.isEmpty { @@ -465,67 +476,66 @@ public struct KiroStatusProbe: Sendable { } } - return try await withCheckedThrowingContinuation { continuation in - DispatchQueue.global().async { - do { - try process.run() - } catch { - stdoutPipe.fileHandleForReading.readabilityHandler = nil - stderrPipe.fileHandleForReading.readabilityHandler = nil - continuation.resume(throwing: error) - return - } - - let deadline = Date().addingTimeInterval(timeout) - var didHitDeadline = false - var didTerminateForIdle = false - - while process.isRunning { - if Date() >= deadline { - didHitDeadline = true - break - } - // Idle timeout: if we got output but then it went silent - if state.hasReceivedOutput, - Date().timeIntervalSince(state.lastActivityAt) >= idleTimeout - { - // Process went idle after producing output - likely done or stuck - didTerminateForIdle = true - break - } - Thread.sleep(forTimeInterval: 0.1) - } - - // Clean up handlers + do { + try process.run() + } catch { + stdoutPipe.fileHandleForReading.readabilityHandler = nil + stderrPipe.fileHandleForReading.readabilityHandler = nil + throw error + } + + let deadline = Date().addingTimeInterval(timeout) + var didHitDeadline = false + var didTerminateForIdle = false + + while process.isRunning { + if Date() >= deadline { + didHitDeadline = true + break + } + if state.hasReceivedOutput, + Date().timeIntervalSince(state.lastActivityAt) >= idleTimeout + { + didTerminateForIdle = true + break + } + try await Task.sleep(for: .milliseconds(100)) + } + + if process.isRunning { + Self.terminateProcess(process) + if didHitDeadline || !state.hasReceivedOutput { stdoutPipe.fileHandleForReading.readabilityHandler = nil stderrPipe.fileHandleForReading.readabilityHandler = nil - - if process.isRunning { - process.terminate() - process.waitUntilExit() - if didHitDeadline || !state.hasReceivedOutput { - continuation.resume(throwing: KiroStatusProbeError.timeout) - return - } - } - - // Read any remaining data - let remainingStdout = stdoutPipe.fileHandleForReading.readDataToEndOfFile() - let remainingStderr = stderrPipe.fileHandleForReading.readDataToEndOfFile() - - var output = state.getOutput() - output.stdout.append(remainingStdout) - output.stderr.append(remainingStderr) - - let stdoutOutput = String(data: output.stdout, encoding: .utf8) ?? "" - let stderrOutput = String(data: output.stderr, encoding: .utf8) ?? "" - continuation.resume(returning: KiroCLIResult( - stdout: stdoutOutput, - stderr: stderrOutput, - terminationStatus: process.terminationStatus, - terminatedForIdle: didTerminateForIdle)) + throw KiroStatusProbeError.timeout } } + + try await Task.sleep(for: .milliseconds(100)) + stdoutPipe.fileHandleForReading.readabilityHandler = nil + stderrPipe.fileHandleForReading.readabilityHandler = nil + + let output = state.getOutput() + let stdoutOutput = String(data: output.stdout, encoding: .utf8) ?? "" + let stderrOutput = String(data: output.stderr, encoding: .utf8) ?? "" + return KiroCLIResult( + stdout: stdoutOutput, + stderr: stderrOutput, + terminationStatus: process.terminationStatus, + terminatedForIdle: didTerminateForIdle) + } + + private static func terminateProcess(_ process: Process) { + guard process.isRunning else { return } + process.terminate() + let deadline = Date().addingTimeInterval(0.4) + while process.isRunning, Date() < deadline { + usleep(50_000) + } + if process.isRunning { + kill(process.processIdentifier, SIGKILL) + } + process.waitUntilExit() } func parse( diff --git a/Tests/CodexBarTests/KiroStatusProbeTests.swift b/Tests/CodexBarTests/KiroStatusProbeTests.swift index f56ab0b399..c926e3453f 100644 --- a/Tests/CodexBarTests/KiroStatusProbeTests.swift +++ b/Tests/CodexBarTests/KiroStatusProbeTests.swift @@ -1,8 +1,74 @@ import Foundation import Testing @testable import CodexBarCore +#if canImport(Darwin) +import Darwin +#else +import Glibc +#endif struct KiroStatusProbeTests { + @Test + func `fetch returns when usage helper leaves inherited pipes open`() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-kiro-pipe-\(UUID().uuidString)", isDirectory: true) + let childPIDFile = root.appendingPathComponent("child.pid") + let cliURL = root.appendingPathComponent("kiro-cli") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + defer { + if let text = try? String(contentsOf: childPIDFile, encoding: .utf8), + let childPID = pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines)) + { + _ = kill(childPID, SIGKILL) + } + } + + let script = """ + #!/bin/bash + set -e + if [ "$1" = "whoami" ]; then + printf 'Logged in with Google\\nEmail: person@example.com\\n' + exit 0 + fi + + if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then + python3 -c 'import os, time; open(os.environ["CODEXBAR_TEST_CHILD_PID_FILE"], "w").write(str(os.getpid())); time.sleep(5)' & + printf 'Estimated Usage | resets on 2026-06-01 | KIRO FREE\\n' + printf 'Credits (12.50 of 50 covered in plan)\\n' + printf '████████████████████ 25%%\\n' + exit 0 + fi + + if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then + exit 0 + fi + + exit 1 + """ + try script.write(to: cliURL, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: cliURL.path) + + let previousPIDFile = ProcessInfo.processInfo.environment["CODEXBAR_TEST_CHILD_PID_FILE"] + setenv("CODEXBAR_TEST_CHILD_PID_FILE", childPIDFile.path, 1) + defer { + if let previousPIDFile { + setenv("CODEXBAR_TEST_CHILD_PID_FILE", previousPIDFile, 1) + } else { + unsetenv("CODEXBAR_TEST_CHILD_PID_FILE") + } + } + + let probe = KiroStatusProbe(cliBinaryResolver: { cliURL.path }) + let start = Date() + let snapshot = try await probe.fetch() + let elapsed = Date().timeIntervalSince(start) + + #expect(snapshot.planName == "KIRO FREE") + #expect(snapshot.creditsUsed == 12.50) + #expect(elapsed < 3, "Kiro usage capture should not wait for inherited pipe EOF, took \(elapsed)s") + } + // MARK: - Happy Path Parsing @Test From 0066a988f4dd27780ff835e627b13e979bf85b39 Mon Sep 17 00:00:00 2001 From: kiranmagic7 <209323973+kiranmagic7@users.noreply.github.com> Date: Mon, 15 Jun 2026 06:13:10 +0530 Subject: [PATCH 2/4] Fix Kiro probe lint failures --- Sources/CodexBarCore/Providers/Kiro/KiroStatusProbe.swift | 2 +- Tests/CodexBarTests/KiroStatusProbeTests.swift | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Kiro/KiroStatusProbe.swift b/Sources/CodexBarCore/Providers/Kiro/KiroStatusProbe.swift index 459e0c7d43..7894334aba 100644 --- a/Sources/CodexBarCore/Providers/Kiro/KiroStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Kiro/KiroStatusProbe.swift @@ -530,7 +530,7 @@ public struct KiroStatusProbe: Sendable { process.terminate() let deadline = Date().addingTimeInterval(0.4) while process.isRunning, Date() < deadline { - usleep(50_000) + usleep(50000) } if process.isRunning { kill(process.processIdentifier, SIGKILL) diff --git a/Tests/CodexBarTests/KiroStatusProbeTests.swift b/Tests/CodexBarTests/KiroStatusProbeTests.swift index c926e3453f..6f216c6e39 100644 --- a/Tests/CodexBarTests/KiroStatusProbeTests.swift +++ b/Tests/CodexBarTests/KiroStatusProbeTests.swift @@ -33,7 +33,13 @@ struct KiroStatusProbeTests { fi if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then - python3 -c 'import os, time; open(os.environ["CODEXBAR_TEST_CHILD_PID_FILE"], "w").write(str(os.getpid())); time.sleep(5)' & + python3 - <<'PY' & + import os + import time + + open(os.environ["CODEXBAR_TEST_CHILD_PID_FILE"], "w").write(str(os.getpid())) + time.sleep(5) + PY printf 'Estimated Usage | resets on 2026-06-01 | KIRO FREE\\n' printf 'Credits (12.50 of 50 covered in plan)\\n' printf '████████████████████ 25%%\\n' From ad1453433cb2e659a1f6ab520406106558254767 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 01:02:15 -0400 Subject: [PATCH 3/4] fix: harden Kiro command cleanup --- CHANGELOG.md | 1 + .../Host/Process/ProcessPipeCapture.swift | 6 +- .../Host/Process/SubprocessRunner.swift | 6 +- .../Providers/Kiro/KiroStatusProbe.swift | 133 ++++++++---------- .../CodexBarTests/KiroStatusProbeTests.swift | 90 ++++++++++++ 5 files changed, 156 insertions(+), 80 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 287fe95f06..224417095f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ - Provider probes: stop waiting indefinitely for inherited output pipes after subprocesses or CLI version checks exit (fixes #1531). - 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). - 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). +- Kiro: keep usage refreshes bounded when CLI helpers retain output pipes, ignore termination, or are cancelled (fixes #1533). Thanks @kiranmagic7! - Xiaomi MiMo: cancel optional token-plan requests when the required balance request fails instead of delaying the error for up to 30 seconds. - Settings: make the cost history window directly editable by keyboard while preserving the existing stepper and 1–365 day bounds (fixes #1499). Thanks @kiranmagic7! diff --git a/Sources/CodexBarCore/Host/Process/ProcessPipeCapture.swift b/Sources/CodexBarCore/Host/Process/ProcessPipeCapture.swift index 32c82429a2..4b956a9193 100644 --- a/Sources/CodexBarCore/Host/Process/ProcessPipeCapture.swift +++ b/Sources/CodexBarCore/Host/Process/ProcessPipeCapture.swift @@ -2,6 +2,7 @@ import Foundation package final class ProcessPipeCapture: @unchecked Sendable { private let handle: FileHandle + private let onData: (@Sendable () -> Void)? private let condition = NSCondition() private var data = Data() private var activeCallbacks = 0 @@ -9,8 +10,9 @@ package final class ProcessPipeCapture: @unchecked Sendable { private var isStopping = false private var continuation: CheckedContinuation? - package init(pipe: Pipe) { + package init(pipe: Pipe, onData: (@Sendable () -> Void)? = nil) { self.handle = pipe.fileHandleForReading + self.onData = onData } package func start() { @@ -70,6 +72,8 @@ package final class ProcessPipeCapture: @unchecked Sendable { if chunk.isEmpty { handle.readabilityHandler = nil + } else { + self.onData?() } continuation?.resume() } diff --git a/Sources/CodexBarCore/Host/Process/SubprocessRunner.swift b/Sources/CodexBarCore/Host/Process/SubprocessRunner.swift index 7afabc8bd2..bc234ba83b 100644 --- a/Sources/CodexBarCore/Host/Process/SubprocessRunner.swift +++ b/Sources/CodexBarCore/Host/Process/SubprocessRunner.swift @@ -110,7 +110,7 @@ public enum SubprocessRunner { /// Terminates a process and its process group, escalating from SIGTERM to SIGKILL. /// Returns `true` if the process was actually killed, `false` if it had already exited. @discardableResult - private static func terminateProcess(_ process: Process, processGroup: pid_t?) -> Bool { + package static func terminateProcess(_ process: Process, processGroup: pid_t?) -> Bool { guard process.isRunning else { return false } process.terminate() if let pgid = processGroup { @@ -125,6 +125,10 @@ public enum SubprocessRunner { kill(-pgid, SIGKILL) } kill(process.processIdentifier, SIGKILL) + let reapDeadline = Date().addingTimeInterval(0.4) + while process.isRunning, Date() < reapDeadline { + usleep(50000) + } } return true } diff --git a/Sources/CodexBarCore/Providers/Kiro/KiroStatusProbe.swift b/Sources/CodexBarCore/Providers/Kiro/KiroStatusProbe.swift index 7894334aba..f6db4b888e 100644 --- a/Sources/CodexBarCore/Providers/Kiro/KiroStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Kiro/KiroStatusProbe.swift @@ -269,7 +269,7 @@ public struct KiroStatusProbe: Sendable { contextUsage: contextUsage) } - private struct KiroCLIResult { + struct KiroCLIResult { let stdout: String let stderr: String let terminationStatus: Int32 @@ -396,7 +396,7 @@ public struct KiroStatusProbe: Sendable { return self.parseContextUsage(output: output) } - private func runCommand( + func runCommand( arguments: [String], timeout: TimeInterval, idleTimeout: TimeInterval = 5.0) async throws -> KiroCLIResult @@ -419,123 +419,100 @@ public struct KiroStatusProbe: Sendable { env["TERM"] = "xterm-256color" process.environment = env - // Thread-safe state for activity tracking final class ActivityState: @unchecked Sendable { private let lock = NSLock() private var _lastActivityAt = Date() private var _hasReceivedOutput = false - private var _stdoutData = Data() - private var _stderrData = Data() var lastActivityAt: Date { - self.lock.lock() - defer { lock.unlock() } - return self._lastActivityAt + self.lock.withLock { self._lastActivityAt } } var hasReceivedOutput: Bool { - self.lock.lock() - defer { lock.unlock() } - return self._hasReceivedOutput + self.lock.withLock { self._hasReceivedOutput } } - func appendStdout(_ data: Data) { - self.lock.lock() - defer { lock.unlock() } - self._stdoutData.append(data) - self._lastActivityAt = Date() - self._hasReceivedOutput = true - } - - func appendStderr(_ data: Data) { - self.lock.lock() - defer { lock.unlock() } - self._stderrData.append(data) - self._lastActivityAt = Date() - self._hasReceivedOutput = true - } - - func getOutput() -> (stdout: Data, stderr: Data) { - self.lock.lock() - defer { lock.unlock() } - return (self._stdoutData, self._stderrData) + func markActivity() { + self.lock.withLock { + self._lastActivityAt = Date() + self._hasReceivedOutput = true + } } } let state = ActivityState() - stdoutPipe.fileHandleForReading.readabilityHandler = { handle in - let data = handle.availableData - if !data.isEmpty { - state.appendStdout(data) - } - } - stderrPipe.fileHandleForReading.readabilityHandler = { handle in - let data = handle.availableData - if !data.isEmpty { - state.appendStderr(data) - } - } + let stdoutCapture = ProcessPipeCapture(pipe: stdoutPipe, onData: { state.markActivity() }) + let stderrCapture = ProcessPipeCapture(pipe: stderrPipe, onData: { state.markActivity() }) do { try process.run() } catch { - stdoutPipe.fileHandleForReading.readabilityHandler = nil - stderrPipe.fileHandleForReading.readabilityHandler = nil + stdoutCapture.stop() + stderrCapture.stop() throw error } + stdoutCapture.start() + stderrCapture.start() + let pid = process.processIdentifier + let processGroup: pid_t? = setpgid(pid, pid) == 0 ? pid : nil let deadline = Date().addingTimeInterval(timeout) var didHitDeadline = false var didTerminateForIdle = false - while process.isRunning { - if Date() >= deadline { - didHitDeadline = true - break - } - if state.hasReceivedOutput, - Date().timeIntervalSince(state.lastActivityAt) >= idleTimeout - { - didTerminateForIdle = true - break + do { + while process.isRunning { + try Task.checkCancellation() + if Date() >= deadline { + didHitDeadline = true + break + } + if state.hasReceivedOutput, + Date().timeIntervalSince(state.lastActivityAt) >= idleTimeout + { + didTerminateForIdle = true + break + } + try await Task.sleep(for: .milliseconds(100)) } - try await Task.sleep(for: .milliseconds(100)) + } catch { + await Self.terminateProcess(process, processGroup: processGroup) + stdoutCapture.stop() + stderrCapture.stop() + throw error } if process.isRunning { - Self.terminateProcess(process) + await Self.terminateProcess(process, processGroup: processGroup) + guard !process.isRunning else { + stdoutCapture.stop() + stderrCapture.stop() + throw KiroStatusProbeError.timeout + } if didHitDeadline || !state.hasReceivedOutput { - stdoutPipe.fileHandleForReading.readabilityHandler = nil - stderrPipe.fileHandleForReading.readabilityHandler = nil + stdoutCapture.stop() + stderrCapture.stop() throw KiroStatusProbeError.timeout } } - try await Task.sleep(for: .milliseconds(100)) - stdoutPipe.fileHandleForReading.readabilityHandler = nil - stderrPipe.fileHandleForReading.readabilityHandler = nil - - let output = state.getOutput() - let stdoutOutput = String(data: output.stdout, encoding: .utf8) ?? "" - let stderrOutput = String(data: output.stderr, encoding: .utf8) ?? "" + async let stdoutData = stdoutCapture.finish(timeout: .seconds(1)) + async let stderrData = stderrCapture.finish(timeout: .seconds(1)) + let output = await (stdout: stdoutData, stderr: stderrData) return KiroCLIResult( - stdout: stdoutOutput, - stderr: stderrOutput, + stdout: String(data: output.stdout, encoding: .utf8) ?? "", + stderr: String(data: output.stderr, encoding: .utf8) ?? "", terminationStatus: process.terminationStatus, terminatedForIdle: didTerminateForIdle) } - private static func terminateProcess(_ process: Process) { - guard process.isRunning else { return } - process.terminate() - let deadline = Date().addingTimeInterval(0.4) - while process.isRunning, Date() < deadline { - usleep(50000) - } - if process.isRunning { - kill(process.processIdentifier, SIGKILL) + private static func terminateProcess(_ process: Process, processGroup: pid_t?) async { + await withCheckedContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + SubprocessRunner.terminateProcess(process, processGroup: processGroup) + continuation.resume() + } } - process.waitUntilExit() } func parse( diff --git a/Tests/CodexBarTests/KiroStatusProbeTests.swift b/Tests/CodexBarTests/KiroStatusProbeTests.swift index 6f216c6e39..e516243f95 100644 --- a/Tests/CodexBarTests/KiroStatusProbeTests.swift +++ b/Tests/CodexBarTests/KiroStatusProbeTests.swift @@ -75,6 +75,86 @@ struct KiroStatusProbeTests { #expect(elapsed < 3, "Kiro usage capture should not wait for inherited pipe EOF, took \(elapsed)s") } + @Test + func `run command hard stops a process that ignores SIGTERM`() async throws { + let cliURL = try self.makeCLI( + """ + #!/bin/sh + trap '' TERM + printf 'partial output\\n' + while true; do sleep 1; done + """) + defer { try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) } + + let probe = KiroStatusProbe(cliBinaryResolver: { cliURL.path }) + let start = Date() + let result = try await probe.runCommand(arguments: [], timeout: 2, idleTimeout: 0.1) + let elapsed = Date().timeIntervalSince(start) + + #expect(result.terminatedForIdle) + #expect(result.stdout.contains("partial output")) + #expect(result.terminationStatus != 0) + #expect(elapsed < 2, "Ignored SIGTERM should escalate to SIGKILL, took \(elapsed)s") + } + + @Test + func `run command preserves completed no-output failure status`() async throws { + let cliURL = try self.makeCLI( + """ + #!/bin/sh + exit 23 + """) + defer { try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) } + + let probe = KiroStatusProbe(cliBinaryResolver: { cliURL.path }) + let result = try await probe.runCommand(arguments: [], timeout: 2) + + #expect(result.stdout.isEmpty) + #expect(result.stderr.isEmpty) + #expect(result.terminationStatus == 23) + #expect(!result.terminatedForIdle) + } + + @Test + func `run command cancellation terminates the process`() async throws { + let pidFile = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-kiro-cancel-\(UUID().uuidString).pid") + let cliURL = try self.makeCLI( + """ + #!/bin/sh + printf '%s\\n' "$$" > "$1" + trap '' TERM + while true; do sleep 1; done + """) + defer { + try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) + try? FileManager.default.removeItem(at: pidFile) + } + + let probe = KiroStatusProbe(cliBinaryResolver: { cliURL.path }) + let task = Task { + try await probe.runCommand(arguments: [pidFile.path], timeout: 20) + } + defer { task.cancel() } + + var capturedProcessID: pid_t? + for _ in 0..<100 { + if let text = try? String(contentsOf: pidFile, encoding: .utf8) { + capturedProcessID = pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines)) + break + } + try await Task.sleep(for: .milliseconds(20)) + } + let processID = try #require(capturedProcessID) + defer { _ = kill(processID, SIGKILL) } + + task.cancel() + await #expect(throws: CancellationError.self) { + try await task.value + } + #expect(kill(processID, 0) == -1) + } + // MARK: - Happy Path Parsing @Test @@ -99,6 +179,16 @@ struct KiroStatusProbeTests { #expect(snapshot.resetsAt != nil) } + private func makeCLI(_ script: String) throws -> URL { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-kiro-cli-\(UUID().uuidString)", isDirectory: true) + let cliURL = root.appendingPathComponent("kiro-cli") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try script.write(to: cliURL, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: cliURL.path) + return cliURL + } + @Test func `parses output with bonus credits`() throws { let output = """ From 8976fa0656c8f4f67ca73b8e064ad99ce3dcb4ed Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 01:25:02 -0400 Subject: [PATCH 4/4] fix: terminate escaped subprocess descendants --- .../Host/PTY/TTYCommandRunner.swift | 46 ++++++++++++++++++- .../Host/Process/SubprocessRunner.swift | 27 +++++++---- .../CodexBarTests/SubprocessRunnerTests.swift | 45 ++++++++++++++++++ 3 files changed, 109 insertions(+), 9 deletions(-) diff --git a/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift b/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift index 16fed5bdfa..65a2a95350 100644 --- a/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift +++ b/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift @@ -105,6 +105,11 @@ private enum TTYCommandRunnerActiveProcessRegistry { } enum TTYProcessTreeTerminator { + struct ProcessIdentity: Equatable { + let pid: pid_t + let startToken: UInt64 + } + static func descendantPIDs( of rootPID: pid_t, childResolver: (pid_t) -> [pid_t] = Self.currentChildPIDs(of:)) -> [pid_t] @@ -134,10 +139,49 @@ enum TTYProcessTreeTerminator { guard childCount > 0 else { return [] } return Array(pids.prefix(min(Int(childCount), pids.count))).filter { $0 > 0 } #else - return [] + let taskPath = "/proc/\(parentPID)/task" + guard let taskIDs = try? FileManager.default.contentsOfDirectory(atPath: taskPath) else { return [] } + + var children: Set = [] + for taskID in taskIDs { + let childrenPath = "\(taskPath)/\(taskID)/children" + guard let text = try? String(contentsOfFile: childrenPath, encoding: .utf8) else { continue } + children.formUnion(text.split(whereSeparator: \.isWhitespace).compactMap { pid_t($0) }) + } + return children.sorted() + #endif + } + + static func processIdentity(for pid: pid_t) -> ProcessIdentity? { + guard pid > 0 else { return nil } + + #if canImport(Darwin) + var info = proc_bsdinfo() + let size = proc_pidinfo( + pid, + PROC_PIDTBSDINFO, + 0, + &info, + Int32(MemoryLayout.stride)) + guard size == Int32(MemoryLayout.stride) else { return nil } + let startToken = UInt64(info.pbi_start_tvsec) * 1_000_000 + UInt64(info.pbi_start_tvusec) + return ProcessIdentity(pid: pid, startToken: startToken) + #else + guard let stat = try? String(contentsOfFile: "/proc/\(pid)/stat", encoding: .utf8), + let commandEnd = stat.lastIndex(of: ")") + else { + return nil + } + let fields = stat[stat.index(after: commandEnd)...].split(whereSeparator: \.isWhitespace) + guard fields.count > 19, let startToken = UInt64(fields[19]) else { return nil } + return ProcessIdentity(pid: pid, startToken: startToken) #endif } + static func isCurrent(_ identity: ProcessIdentity) -> Bool { + self.processIdentity(for: identity.pid) == identity + } + static func terminateProcessTree( rootPID: pid_t, processGroup: pid_t?, diff --git a/Sources/CodexBarCore/Host/Process/SubprocessRunner.swift b/Sources/CodexBarCore/Host/Process/SubprocessRunner.swift index bc234ba83b..2a9a3c2a55 100644 --- a/Sources/CodexBarCore/Host/Process/SubprocessRunner.swift +++ b/Sources/CodexBarCore/Host/Process/SubprocessRunner.swift @@ -112,23 +112,34 @@ public enum SubprocessRunner { @discardableResult package static func terminateProcess(_ process: Process, processGroup: pid_t?) -> Bool { guard process.isRunning else { return false } - process.terminate() - if let pgid = processGroup { - kill(-pgid, SIGTERM) - } + let descendants = TTYProcessTreeTerminator.descendantPIDs(of: process.processIdentifier) + let descendantIdentities = descendants.compactMap(TTYProcessTreeTerminator.processIdentity(for:)) + TTYProcessTreeTerminator.terminateProcessTree( + rootPID: process.processIdentifier, + processGroup: processGroup, + signal: SIGTERM, + knownDescendants: descendants) let killDeadline = Date().addingTimeInterval(0.4) while process.isRunning, Date() < killDeadline { usleep(50000) } if process.isRunning { - if let pgid = processGroup { - kill(-pgid, SIGKILL) - } - kill(process.processIdentifier, SIGKILL) + let currentDescendants = descendantIdentities + .filter(TTYProcessTreeTerminator.isCurrent(_:)) + .map(\.pid) + TTYProcessTreeTerminator.terminateProcessTree( + rootPID: process.processIdentifier, + processGroup: processGroup, + signal: SIGKILL, + knownDescendants: currentDescendants) let reapDeadline = Date().addingTimeInterval(0.4) while process.isRunning, Date() < reapDeadline { usleep(50000) } + } else { + for identity in descendantIdentities where TTYProcessTreeTerminator.isCurrent(identity) { + kill(identity.pid, SIGKILL) + } } return true } diff --git a/Tests/CodexBarTests/SubprocessRunnerTests.swift b/Tests/CodexBarTests/SubprocessRunnerTests.swift index 019f4f1093..01dffc2233 100644 --- a/Tests/CodexBarTests/SubprocessRunnerTests.swift +++ b/Tests/CodexBarTests/SubprocessRunnerTests.swift @@ -98,6 +98,51 @@ struct SubprocessRunnerTests { #expect(elapsed < 3, "Timeout should fire in ~1s, not wait for process to exit naturally") } + @Test + func `timeout kills descendants that escape the process group`() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-subprocess-tree-\(UUID().uuidString)", isDirectory: true) + let childPIDFile = root.appendingPathComponent("child.pid") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + var environment = ProcessInfo.processInfo.environment + environment["CODEXBAR_TEST_CHILD_PID_FILE"] = childPIDFile.path + let script = """ + import os + import subprocess + import sys + import time + + child = subprocess.Popen( + [sys.executable, "-c", "import time; time.sleep(30)"], + start_new_session=True, + ) + with open(os.environ["CODEXBAR_TEST_CHILD_PID_FILE"], "w") as handle: + handle.write(str(child.pid)) + time.sleep(30) + """ + + await #expect(throws: SubprocessRunnerError.self) { + try await SubprocessRunner.run( + binary: "/usr/bin/python3", + arguments: ["-c", script], + environment: environment, + timeout: 0.5, + label: "escaped-descendant") + } + + let text = try String(contentsOf: childPIDFile, encoding: .utf8) + let childPID = try #require(pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines))) + defer { _ = kill(childPID, SIGKILL) } + + let deadline = Date().addingTimeInterval(1) + while kill(childPID, 0) == 0, Date() < deadline { + try await Task.sleep(for: .milliseconds(20)) + } + #expect(kill(childPID, 0) == -1) + } + /// Multiple concurrent hung subprocesses must all time out independently, proving that /// one blocked subprocess does not starve the timeout mechanism of others. /// This is the core scenario that caused the original permanent-refresh-stall bug.