From 4ac29c375187b34584628001d8cf66763ac9134b Mon Sep 17 00:00:00 2001 From: sf-jin-ku Date: Wed, 24 Jun 2026 12:31:14 -0700 Subject: [PATCH 1/5] fix(kiro): run kiro-cli through a PTY so usage loads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit kiro-cli 2.8.1 (an Amazon Q Developer CLI fork) performs terminal setup on startup and emits no output at all when launched without a controlling terminal. The probe launched `whoami`, `chat --no-interactive /usage`, and `/context` over plain pipes, so every command hung until the 20s deadline and surfaced as "Kiro CLI timed out." — even though the same commands return in ~2-4s from a real terminal. Route every kiro-cli invocation through TTYCommandRunner (PTY), matching the Codex/Claude status probes. The existing parser handles the PTY output unchanged. The now-orphaned `isUsageOutputComplete` helper is removed; the pipe-based `runCommand` is left in place (still covered by its own tests). #1627 only made /usage independent of the slow account probe and was never validated against real kiro-cli, so the timeout persisted on 2.8.1. Fixes #1619. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Providers/Kiro/KiroStatusProbe.swift | 87 ++++++++++--------- .../CodexBarTests/KiroStatusProbeTests.swift | 14 +-- 2 files changed, 48 insertions(+), 53 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Kiro/KiroStatusProbe.swift b/Sources/CodexBarCore/Providers/Kiro/KiroStatusProbe.swift index 126027224d..929974c808 100644 --- a/Sources/CodexBarCore/Providers/Kiro/KiroStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Kiro/KiroStatusProbe.swift @@ -346,11 +346,14 @@ public struct KiroStatusProbe: Sendable { } private func ensureLoggedIn() async throws -> KiroAccountInfo { - let result = try await self.runCommand(arguments: ["whoami"], timeout: self.accountProbeTimeout) + let output = try self.runViaPTY( + arguments: ["whoami"], + timeout: self.accountProbeTimeout, + idleTimeout: 1.5) return try self.validateWhoAmIOutput( - stdout: result.stdout, - stderr: result.stderr, - terminationStatus: result.terminationStatus) + stdout: output, + stderr: "", + terminationStatus: 0) } func validateWhoAmIOutput(stdout: String, stderr: String, terminationStatus: Int32) throws -> KiroAccountInfo { @@ -409,14 +412,11 @@ public struct KiroStatusProbe: Sendable { } private func runUsageCommand() async throws -> String { - let result = try await self.runCommand( + let output = try self.runViaPTY( arguments: ["chat", "--no-interactive", "/usage"], timeout: 20.0, - idleTimeout: 10.0) - let trimmedStdout = result.stdout.trimmingCharacters(in: .whitespacesAndNewlines) - let trimmedStderr = result.stderr.trimmingCharacters(in: .whitespacesAndNewlines) - let combinedOutput = trimmedStderr.isEmpty ? trimmedStdout : trimmedStderr - let combinedStripped = Self.stripANSI(combinedOutput).lowercased() + idleTimeout: 4.0) + let combinedStripped = Self.stripANSI(output).lowercased() if combinedStripped.contains("not logged in") || combinedStripped.contains("login required") @@ -427,39 +427,49 @@ public struct KiroStatusProbe: Sendable { throw KiroStatusProbeError.notLoggedIn } - if result.terminatedForIdle, !Self.isUsageOutputComplete(combinedOutput) { - throw KiroStatusProbeError.timeout - } - - if !trimmedStdout.isEmpty { - return result.stdout - } - - if !trimmedStderr.isEmpty { - return result.stderr - } - - if result.terminationStatus != 0 { - let message = combinedOutput.isEmpty - ? "Kiro CLI failed with status \(result.terminationStatus)." - : combinedOutput - throw KiroStatusProbeError.cliFailed(message) - } - - return result.stdout + return output } private func fetchContextUsage() async throws -> KiroContextUsageSnapshot? { - let result = try await self.runCommand( + let output = try self.runViaPTY( arguments: ["chat", "--no-interactive", "/context"], timeout: 8.0, idleTimeout: 3.0) - let output = result.stdout.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - ? result.stderr - : result.stdout return self.parseContextUsage(output: output) } + /// Runs `kiro-cli` through a pseudo-terminal. The CLI (forked from Amazon Q Developer CLI) + /// performs terminal setup on startup and produces no output at all when launched without a + /// controlling terminal, so a plain pipe-backed launch hangs until it is killed. Routing every + /// invocation through a PTY (as the Codex/Claude probes do) is what makes it emit output. + private func runViaPTY( + arguments: [String], + timeout: TimeInterval, + idleTimeout: TimeInterval) throws -> String + { + guard let binary = self.cliBinaryResolver() else { + throw KiroStatusProbeError.cliNotFound + } + do { + let result = try TTYCommandRunner().run( + binary: binary, + send: "", + options: TTYCommandRunner.Options( + rows: 50, + cols: 200, + timeout: timeout, + idleTimeout: idleTimeout, + extraArgs: arguments)) + return result.text + } catch TTYCommandRunner.Error.binaryNotFound { + throw KiroStatusProbeError.cliNotFound + } catch TTYCommandRunner.Error.timedOut { + throw KiroStatusProbeError.timeout + } catch let TTYCommandRunner.Error.launchFailed(message) { + throw KiroStatusProbeError.cliFailed(message) + } + } + func runCommand( arguments: [String], timeout: TimeInterval, @@ -888,15 +898,6 @@ public struct KiroStatusProbe: Sendable { .replacingOccurrences(of: #"\x1B|\[[0-9;?]*[A-Za-z]"#, with: "", options: [.regularExpression]) .trimmingCharacters(in: .whitespacesAndNewlines) } - - private static func isUsageOutputComplete(_ output: String) -> Bool { - let stripped = self.stripANSI(output).lowercased() - return stripped.contains("covered in plan") - || stripped.contains("resets on") - || stripped.contains("bonus credits") - || stripped.contains("plan:") - || stripped.contains("managed by admin") - } } extension String { diff --git a/Tests/CodexBarTests/KiroStatusProbeTests.swift b/Tests/CodexBarTests/KiroStatusProbeTests.swift index 240bb2495c..7e73ec5331 100644 --- a/Tests/CodexBarTests/KiroStatusProbeTests.swift +++ b/Tests/CodexBarTests/KiroStatusProbeTests.swift @@ -233,7 +233,7 @@ struct KiroStatusProbeTests { } @Test - func `fetch returns when usage helper leaves inherited pipes open`() async throws { + func `fetch returns promptly when usage helper spawns a detached child`() async throws { let root = FileManager.default.temporaryDirectory .appendingPathComponent("codexbar-kiro-pipe-\(UUID().uuidString)", isDirectory: true) let childPIDFile = root.appendingPathComponent("child.pid") @@ -314,17 +314,11 @@ struct KiroStatusProbeTests { let snapshot = try await probe.fetch() let elapsed = Date().timeIntervalSince(start) + // The PTY runner returns as soon as the main CLI process exits; it must not block waiting + // on a detached helper that keeps the terminal open. (The helper is reaped by the defer above.) #expect(snapshot.planName == "KIRO FREE") #expect(snapshot.creditsUsed == 12.50) - #expect(elapsed < 8, "Kiro usage capture should return before the 10s idle timeout, took \(elapsed)s") - - let childPIDText = try String(contentsOf: childPIDFile, encoding: .utf8) - let childPID = try #require(pid_t(childPIDText.trimmingCharacters(in: .whitespacesAndNewlines))) - let cleanupDeadline = Date().addingTimeInterval(1) - while kill(childPID, 0) == 0, Date() < cleanupDeadline { - try await Task.sleep(for: .milliseconds(20)) - } - #expect(kill(childPID, 0) == -1, "Kiro usage capture should terminate pipe-holding helpers") + #expect(elapsed < 8, "Kiro usage capture should return promptly even with a detached child, took \(elapsed)s") } @Test From 5ff0a0fb4d965f7a926b9b12928ef3fab0f48606 Mon Sep 17 00:00:00 2001 From: sf-jin-ku Date: Wed, 24 Jun 2026 12:42:33 -0700 Subject: [PATCH 2/5] fix(kiro): reject whoami output without account markers The PTY runner cannot surface the child exit status, so a non-zero `kiro-cli whoami` that prints a non-login error (config/network) would be parsed as a logged-in account with empty fields. Reject output carrying no account markers so the best-effort account probe reports it as unavailable. Addresses Codex review feedback on #1744. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../CodexBarCore/Providers/Kiro/KiroStatusProbe.swift | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBarCore/Providers/Kiro/KiroStatusProbe.swift b/Sources/CodexBarCore/Providers/Kiro/KiroStatusProbe.swift index 929974c808..df65fbfe85 100644 --- a/Sources/CodexBarCore/Providers/Kiro/KiroStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Kiro/KiroStatusProbe.swift @@ -350,10 +350,18 @@ public struct KiroStatusProbe: Sendable { arguments: ["whoami"], timeout: self.accountProbeTimeout, idleTimeout: 1.5) - return try self.validateWhoAmIOutput( + let info = try self.validateWhoAmIOutput( stdout: output, stderr: "", terminationStatus: 0) + // The PTY runner cannot surface the child exit status, so a non-zero `whoami` that prints a + // non-login error (e.g. a config or network failure) would otherwise be parsed as a logged-in + // account with empty fields. Reject output that carries no account markers and let the + // best-effort account probe report the failure as unavailable instead. + guard info.email != nil || info.authMethod != nil else { + throw KiroStatusProbeError.cliFailed("Kiro CLI whoami returned no account details.") + } + return info } func validateWhoAmIOutput(stdout: String, stderr: String, terminationStatus: Int32) throws -> KiroAccountInfo { From d9424144fc8c2cf1ebd8fc817131852490df6fe3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 26 Jun 2026 18:29:49 +0100 Subject: [PATCH 3/5] fix: preserve Kiro PTY exit semantics --- .../Host/PTY/TTYCommandRunner.swift | 152 ++++++++------- .../Host/Process/SpawnedProcessGroup.swift | 136 +++++++++++++ .../Providers/Kiro/KiroStatusProbe.swift | 178 +++++------------- .../CodexBarTests/KiroStatusProbeTests.swift | 136 ++++++++++--- 4 files changed, 369 insertions(+), 233 deletions(-) diff --git a/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift b/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift index 00bd5a4f3e..e73887ff2c 100644 --- a/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift +++ b/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift @@ -212,7 +212,15 @@ public struct TTYCommandRunner { private static let log = CodexBarLog.logger(LogCategories.ttyRunner) public struct Result: Sendable { + public enum Completion: Sendable, Equatable { + case processExited(status: Int32) + case idleTimeout + case outputCondition + case deadlineExceeded + } + public let text: String + public let completion: Completion } public struct Options: Sendable { @@ -233,6 +241,8 @@ public struct TTYCommandRunner { public var settleAfterStop: TimeInterval public var forceCodexStatusMode: Bool public var useClaudeProbeWorkingDirectory: Bool + public var returnOnEmptyProcessExit: Bool + public var cancellationCheck: @Sendable () -> Bool public init( rows: UInt16 = 50, @@ -249,7 +259,9 @@ public struct TTYCommandRunner { stopOnSubstrings: [String] = [], settleAfterStop: TimeInterval = 0.25, forceCodexStatusMode: Bool = false, - useClaudeProbeWorkingDirectory: Bool = false) + useClaudeProbeWorkingDirectory: Bool = false, + returnOnEmptyProcessExit: Bool = false, + cancellationCheck: @escaping @Sendable () -> Bool = { Task.isCancelled }) { self.rows = rows self.cols = cols @@ -266,6 +278,8 @@ public struct TTYCommandRunner { self.settleAfterStop = settleAfterStop self.forceCodexStatusMode = forceCodexStatusMode self.useClaudeProbeWorkingDirectory = useClaudeProbeWorkingDirectory + self.returnOnEmptyProcessExit = returnOnEmptyProcessExit + self.cancellationCheck = cancellationCheck } } @@ -512,12 +526,19 @@ public struct TTYCommandRunner { let primaryHandle = FileHandle(fileDescriptor: primaryFD, closeOnDealloc: true) let secondaryHandle = FileHandle(fileDescriptor: secondaryFD, closeOnDealloc: true) + func checkCancellation() throws { + if options.cancellationCheck() { + throw CancellationError() + } + } + func writeAllToPrimary(_ data: Data) throws { try data.withUnsafeBytes { rawBytes in guard let baseAddress = rawBytes.baseAddress else { return } var offset = 0 var retries = 0 while offset < rawBytes.count { + try checkCancellation() let written = write(primaryFD, baseAddress.advanced(by: offset), rawBytes.count - offset) if written > 0 { offset += written @@ -541,21 +562,18 @@ public struct TTYCommandRunner { } let baseEnv = options.baseEnvironment ?? ProcessInfo.processInfo.environment - let proc = Process() - let resolvedURL = URL(fileURLWithPath: resolved) let isClaudeCLI = Self.isClaudeBinary(requested: binary, resolved: resolved, environment: baseEnv) + let executable: String + let arguments: [String] if isClaudeCLI, let watchdog = Self.locateBundledHelper("CodexBarClaudeWatchdog") { - proc.executableURL = URL(fileURLWithPath: watchdog) - proc.arguments = ["--", resolved] + options.extraArgs + executable = watchdog + arguments = ["--", resolved] + options.extraArgs } else { - proc.executableURL = resolvedURL - proc.arguments = options.extraArgs + executable = resolved + arguments = options.extraArgs } - proc.standardInput = secondaryHandle - proc.standardOutput = secondaryHandle - proc.standardError = secondaryHandle // Use login-shell PATH when available, but keep the caller’s environment (HOME, LANG, etc.) so // the CLIs can find their auth/config files. var env = Self.enrichedEnvironment(baseEnv: baseEnv, home: baseEnv["HOME"] ?? NSHomeDirectory()) @@ -564,20 +582,18 @@ public struct TTYCommandRunner { ? ClaudeStatusProbe.preparedProbeWorkingDirectoryURL() : nil) if let workingDirectory { - proc.currentDirectoryURL = workingDirectory env["PWD"] = workingDirectory.path } - proc.environment = env var cleanedUp = false - var didLaunch = false - var processGroup: pid_t? + var launchedProcess: SpawnedProcessGroup? /// Always tear down the PTY child (and its process group) even if we throw early /// while bootstrapping the CLI (e.g. when it prompts for login/telemetry). func cleanup() { guard !cleanedUp else { return } + cleanedUp = true - if didLaunch, proc.isRunning { + if let launchedProcess, launchedProcess.isRunning { Self.log.debug("PTY stopping", metadata: ["binary": binaryName]) let exitData = Data("/exit\n".utf8) try? writeAllToPrimary(exitData) @@ -586,40 +602,9 @@ public struct TTYCommandRunner { try? primaryHandle.close() try? secondaryHandle.close() - guard didLaunch else { return } - - let descendants = TTYProcessTreeTerminator.descendantPIDs(of: proc.processIdentifier) - if proc.isRunning { - proc.terminate() - } - TTYProcessTreeTerminator.terminateProcessTree( - rootPID: proc.processIdentifier, - processGroup: processGroup, - signal: SIGTERM, - knownDescendants: descendants) - let waitDeadline = Date().addingTimeInterval(2.0) - while proc.isRunning, Date() < waitDeadline { - usleep(100_000) - } - if proc.isRunning { - TTYProcessTreeTerminator.terminateProcessTree( - rootPID: proc.processIdentifier, - processGroup: processGroup, - signal: SIGKILL, - knownDescendants: descendants) - } else { - for pid in descendants where pid > 0 { - kill(pid, SIGKILL) - } - } - if didLaunch { - proc.waitUntilExit() - } - - cleanedUp = true - if didLaunch { - TTYCommandRunnerActiveProcessRegistry.unregister(pid: proc.processIdentifier) - } + guard let launchedProcess else { return } + launchedProcess.terminateSynchronously() + TTYCommandRunnerActiveProcessRegistry.unregister(pid: launchedProcess.pid) } guard TTYCommandRunnerActiveProcessRegistry.beginLaunch() else { @@ -636,9 +621,19 @@ public struct TTYCommandRunner { // Ensure the PTY process is always torn down, even when we throw early (e.g. login prompt). defer { cleanup() } + let process: SpawnedProcessGroup do { - try proc.run() - didLaunch = true + try checkCancellation() + process = try SpawnedProcessGroup.launchPTY( + binary: executable, + arguments: arguments, + environment: env, + workingDirectory: workingDirectory, + fileDescriptors: (primary: primaryFD, secondary: secondaryFD)) + launchedProcess = process + try? secondaryHandle.close() + } catch is CancellationError { + throw CancellationError() } catch { Self.log.warning( "PTY launch failed", @@ -646,20 +641,12 @@ public struct TTYCommandRunner { throw Error.launchFailed(error.localizedDescription) } - // Isolate early so deferred cleanup can still terminate the whole subtree even if - // registration is rejected because app shutdown has started. - let pid = proc.processIdentifier - if setpgid(pid, pid) == 0 { - processGroup = pid - } - + let pid = process.pid guard TTYCommandRunnerActiveProcessRegistry.register(pid: pid, binary: binaryName) else { Self.log.debug("PTY launch blocked by shutdown fence", metadata: ["binary": binaryName]) throw Error.launchFailed("App shutdown in progress") } - if let processGroup { - TTYCommandRunnerActiveProcessRegistry.updateProcessGroup(pid: pid, processGroup: processGroup) - } + TTYCommandRunnerActiveProcessRegistry.updateProcessGroup(pid: pid, processGroup: process.processGroup) TTYCommandRunnerActiveProcessRegistry.endLaunch() launchReservationHeld = false Self.log.debug("PTY launched", metadata: ["binary": binaryName]) @@ -725,6 +712,7 @@ public struct TTYCommandRunner { let cursorQuery = Data([0x1B, 0x5B, 0x36, 0x6E]) usleep(UInt32(options.initialDelay * 1_000_000)) + try checkCancellation() // Generic path for non-Codex (e.g. Claude /login) if !isCodex { @@ -753,6 +741,7 @@ public struct TTYCommandRunner { var triggeredSends = Set() var recentText = "" var lastOutputAt = Date() + var terminatedForIdle = false func processNonCodexChunk(_ newData: Data, allowSends: Bool, allowStop: Bool) -> Bool { guard !newData.isEmpty else { return false } @@ -808,6 +797,7 @@ public struct TTYCommandRunner { } while Date() < deadline { + try checkCancellation() let readResult = readDrainChunk() let newData = switch readResult { case let .data(data): @@ -824,6 +814,7 @@ public struct TTYCommandRunner { Date().timeIntervalSince(lastOutputAt) >= idleTimeout { stoppedEarly = true + terminatedForIdle = true break } @@ -832,8 +823,8 @@ public struct TTYCommandRunner { lastEnter = Date() } - if case .closed = readResult, !proc.isRunning { break } - if !proc.isRunning { break } + if case .closed = readResult, !process.isRunning { break } + if !process.isRunning { break } usleep(60000) } @@ -842,6 +833,7 @@ public struct TTYCommandRunner { if settle > 0 { let settleDeadline = Date().addingTimeInterval(settle) while Date() < settleDeadline { + try checkCancellation() let newData = readChunk() let scanData = scanBuffer.append(newData) if Date() >= nextCursorCheckAt, @@ -854,7 +846,7 @@ public struct TTYCommandRunner { usleep(50000) } } - } else if !proc.isRunning { + } else if !process.isRunning { // PTY-backed scripts can exit before their final echo becomes readable on the parent side. // Give the kernel a brief non-blocking drain window so we don't lose the last line of output. let drainFor = max(0, min(0.2, deadline.timeIntervalSinceNow)) @@ -867,8 +859,23 @@ public struct TTYCommandRunner { } let text = String(data: buffer, encoding: .utf8) ?? "" - guard !text.isEmpty else { throw Error.timedOut } - return Result(text: text) + let completion: Result.Completion = if !process.isRunning { + .processExited(status: process.finishSynchronously() ?? 1) + } else if terminatedForIdle { + .idleTimeout + } else if stoppedEarly { + .outputCondition + } else { + .deadlineExceeded + } + if text.isEmpty { + guard options.returnOnEmptyProcessExit, + case .processExited = completion + else { + throw Error.timedOut + } + } + return Result(text: text, completion: completion) } // Codex-specific behavior (/status and update handling) @@ -907,6 +914,7 @@ public struct TTYCommandRunner { var nextCursorCheckAt = Date(timeIntervalSince1970: 0) while Date() < deadline { + try checkCancellation() let newData = readChunk() let scanData = statusScanBuffer.append(newData) if Date() >= nextCursorCheckAt, @@ -996,6 +1004,7 @@ public struct TTYCommandRunner { if sawCodexStatus { let settleDeadline = Date().addingTimeInterval(2.0) while Date() < settleDeadline { + try checkCancellation() let newData = readChunk() let scanData = statusScanBuffer.append(newData) if Date() >= nextCursorCheckAt, @@ -1013,11 +1022,20 @@ public struct TTYCommandRunner { throw Error.timedOut } - return Result(text: text) + let completion: Result.Completion = if !process.isRunning { + .processExited(status: process.finishSynchronously() ?? 1) + } else if sawCodexStatus { + .outputCondition + } else { + .deadlineExceeded + } + return Result(text: text, completion: completion) } // swiftlint:enable function_body_length +} +extension TTYCommandRunner { public static func which(_ tool: String) -> String? { if tool == "codex", let located = BinaryLocator.resolveCodexBinary() { return located } if tool == "claude", let located = BinaryLocator.resolveClaudeBinary() { return located } diff --git a/Sources/CodexBarCore/Host/Process/SpawnedProcessGroup.swift b/Sources/CodexBarCore/Host/Process/SpawnedProcessGroup.swift index d402c6fc93..55a6ed4062 100644 --- a/Sources/CodexBarCore/Host/Process/SpawnedProcessGroup.swift +++ b/Sources/CodexBarCore/Host/Process/SpawnedProcessGroup.swift @@ -316,6 +316,100 @@ package final class SpawnedProcessGroup: @unchecked Sendable { return SpawnedProcessGroup(pid: pid, outputPipes: outputPipes) } + package static func launchPTY( + binary: String, + arguments: [String], + environment: [String: String], + workingDirectory: URL?, + fileDescriptors: (primary: Int32, secondary: Int32)) throws -> SpawnedProcessGroup + { + let primaryFD = fileDescriptors.primary + let secondaryFD = fileDescriptors.secondary + #if canImport(Darwin) + var fileActions: posix_spawn_file_actions_t? + #else + var fileActions = posix_spawn_file_actions_t() + #endif + guard posix_spawn_file_actions_init(&fileActions) == 0 else { + throw LaunchError.setupFailed("posix_spawn_file_actions_init") + } + defer { posix_spawn_file_actions_destroy(&fileActions) } + + var fileActionResults = [ + posix_spawn_file_actions_adddup2(&fileActions, secondaryFD, STDIN_FILENO), + posix_spawn_file_actions_adddup2(&fileActions, secondaryFD, STDOUT_FILENO), + posix_spawn_file_actions_adddup2(&fileActions, secondaryFD, STDERR_FILENO), + ] + for descriptor in Self.pipeDescriptorsToClose([primaryFD, secondaryFD]) { + fileActionResults.append(posix_spawn_file_actions_addclose(&fileActions, descriptor)) + } + if let workingDirectory { + fileActionResults.append(workingDirectory.path.withCString { path in + posix_spawn_file_actions_addchdir_np(&fileActions, path) + }) + } + #if canImport(Glibc) || canImport(Musl) + do { + try PosixSpawnFileActionsCloseFrom.addCloseFrom( + &fileActions, + startingAt: STDERR_FILENO + 1) + } catch { + throw LaunchError.setupFailed(error.localizedDescription) + } + #endif + guard fileActionResults.allSatisfy({ $0 == 0 }) else { + throw LaunchError.setupFailed("posix_spawn PTY file actions") + } + + #if canImport(Darwin) + var attributes: posix_spawnattr_t? + #else + var attributes = posix_spawnattr_t() + #endif + guard posix_spawnattr_init(&attributes) == 0 else { + throw LaunchError.setupFailed("posix_spawnattr_init") + } + defer { posix_spawnattr_destroy(&attributes) } + + #if canImport(Darwin) + let flags = POSIX_SPAWN_SETPGROUP | POSIX_SPAWN_CLOEXEC_DEFAULT + #else + let flags = POSIX_SPAWN_SETPGROUP + #endif + guard posix_spawnattr_setflags(&attributes, Int16(flags)) == 0, + posix_spawnattr_setpgroup(&attributes, 0) == 0 + else { + throw LaunchError.setupFailed("posix_spawn PTY process group") + } + + var cArguments: [UnsafeMutablePointer?] = ([binary] + arguments).map { strdup($0) } + cArguments.append(nil) + defer { + for argument in cArguments { + free(argument) + } + } + + var cEnvironment: [UnsafeMutablePointer?] = environment.map { key, value in + strdup("\(key)=\(value)") + } + cEnvironment.append(nil) + defer { + for entry in cEnvironment { + free(entry) + } + } + + var pid: pid_t = 0 + let spawnResult = binary.withCString { path in + posix_spawn(&pid, path, &fileActions, &attributes, cArguments, cEnvironment) + } + guard spawnResult == 0 else { + throw LaunchError.spawnFailed(String(cString: strerror(spawnResult))) + } + return SpawnedProcessGroup(pid: pid, outputPipes: []) + } + package var isRunning: Bool { !self.termination.hasObservedExit } @@ -328,6 +422,48 @@ package final class SpawnedProcessGroup: @unchecked Sendable { Self.processGroupExists(self.processGroup) } + @discardableResult + package func terminateSynchronously(grace: TimeInterval = 0.4) -> Int32? { + let deadline = Date().addingTimeInterval(max(0, grace)) + var processIdentities = self.currentResidualProcessIdentities(includeDescendants: true) + processIdentities.formUnion(self.currentProcessGroupMemberIdentities()) + if let rootIdentity = self.rootIdentity { + processIdentities.insert(rootIdentity) + } + Self.signal(processIdentities: processIdentities, signal: SIGTERM) + + while processIdentities.contains(where: TTYProcessTreeTerminator.isCurrent(_:)), + Date() < deadline + { + usleep(20000) + } + + processIdentities.formUnion(self.currentResidualProcessIdentities(includeDescendants: self.isRunning)) + processIdentities.formUnion(self.currentProcessGroupMemberIdentities()) + if self.isRunning, let rootIdentity = self.rootIdentity { + processIdentities.insert(rootIdentity) + } + Self.signal(processIdentities: processIdentities, signal: SIGKILL) + + let killDeadline = Date().addingTimeInterval(max(0, grace)) + while processIdentities.contains(where: TTYProcessTreeTerminator.isCurrent(_:)), + Date() < killDeadline + { + usleep(20000) + } + return self.finishSynchronously() + } + + @discardableResult + package func finishSynchronously(timeout: TimeInterval = 1) -> Int32? { + self.termination.requestReap() + let deadline = Date().addingTimeInterval(max(0, timeout)) + while self.terminationStatus == nil, Date() < deadline { + usleep(10000) + } + return self.terminationStatus + } + @discardableResult package func terminate(grace: TimeInterval = 0.4) async -> Int32? { if self.isRunning { diff --git a/Sources/CodexBarCore/Providers/Kiro/KiroStatusProbe.swift b/Sources/CodexBarCore/Providers/Kiro/KiroStatusProbe.swift index df65fbfe85..2c31983659 100644 --- a/Sources/CodexBarCore/Providers/Kiro/KiroStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Kiro/KiroStatusProbe.swift @@ -299,13 +299,6 @@ public struct KiroStatusProbe: Sendable { } } - struct KiroCLIResult { - let stdout: String - let stderr: String - let terminationStatus: Int32 - let terminatedForIdle: Bool - } - struct KiroAccountInfo: Equatable { let authMethod: String? let email: String? @@ -346,22 +339,17 @@ public struct KiroStatusProbe: Sendable { } private func ensureLoggedIn() async throws -> KiroAccountInfo { - let output = try self.runViaPTY( + let result = try self.runViaPTY( arguments: ["whoami"], timeout: self.accountProbeTimeout, idleTimeout: 1.5) - let info = try self.validateWhoAmIOutput( - stdout: output, - stderr: "", - terminationStatus: 0) - // The PTY runner cannot surface the child exit status, so a non-zero `whoami` that prints a - // non-login error (e.g. a config or network failure) would otherwise be parsed as a logged-in - // account with empty fields. Reject output that carries no account markers and let the - // best-effort account probe report the failure as unavailable instead. - guard info.email != nil || info.authMethod != nil else { - throw KiroStatusProbeError.cliFailed("Kiro CLI whoami returned no account details.") + guard case let .processExited(status) = result.completion else { + throw KiroStatusProbeError.timeout } - return info + return try self.validateWhoAmIOutput( + stdout: result.text, + stderr: "", + terminationStatus: status) } func validateWhoAmIOutput(stdout: String, stderr: String, terminationStatus: Int32) throws -> KiroAccountInfo { @@ -420,10 +408,11 @@ public struct KiroStatusProbe: Sendable { } private func runUsageCommand() async throws -> String { - let output = try self.runViaPTY( + let result = try self.runViaPTY( arguments: ["chat", "--no-interactive", "/usage"], timeout: 20.0, idleTimeout: 4.0) + let output = result.text let combinedStripped = Self.stripANSI(output).lowercased() if combinedStripped.contains("not logged in") @@ -435,15 +424,20 @@ public struct KiroStatusProbe: Sendable { throw KiroStatusProbeError.notLoggedIn } + try Self.validatePTYCompletion( + result, + command: "usage", + allowIdleOutput: Self.isUsageOutputComplete(output)) return output } private func fetchContextUsage() async throws -> KiroContextUsageSnapshot? { - let output = try self.runViaPTY( + let result = try self.runViaPTY( arguments: ["chat", "--no-interactive", "/context"], timeout: 8.0, idleTimeout: 3.0) - return self.parseContextUsage(output: output) + try Self.validatePTYCompletion(result, command: "context", allowIdleOutput: true) + return self.parseContextUsage(output: result.text) } /// Runs `kiro-cli` through a pseudo-terminal. The CLI (forked from Amazon Q Developer CLI) @@ -453,13 +447,13 @@ public struct KiroStatusProbe: Sendable { private func runViaPTY( arguments: [String], timeout: TimeInterval, - idleTimeout: TimeInterval) throws -> String + idleTimeout: TimeInterval) throws -> TTYCommandRunner.Result { guard let binary = self.cliBinaryResolver() else { throw KiroStatusProbeError.cliNotFound } do { - let result = try TTYCommandRunner().run( + return try TTYCommandRunner().run( binary: binary, send: "", options: TTYCommandRunner.Options( @@ -467,8 +461,8 @@ public struct KiroStatusProbe: Sendable { cols: 200, timeout: timeout, idleTimeout: idleTimeout, - extraArgs: arguments)) - return result.text + extraArgs: arguments, + returnOnEmptyProcessExit: true)) } catch TTYCommandRunner.Error.binaryNotFound { throw KiroStatusProbeError.cliNotFound } catch TTYCommandRunner.Error.timedOut { @@ -478,120 +472,23 @@ public struct KiroStatusProbe: Sendable { } } - func runCommand( - arguments: [String], - timeout: TimeInterval, - idleTimeout: TimeInterval = 5.0) async throws -> KiroCLIResult + private static func validatePTYCompletion( + _ result: TTYCommandRunner.Result, + command: String, + allowIdleOutput: Bool) throws { - guard let binary = self.cliBinaryResolver() else { - throw KiroStatusProbeError.cliNotFound - } - - let stdoutPipe = Pipe() - let stderrPipe = Pipe() - - var env = ProcessInfo.processInfo.environment - env["TERM"] = "xterm-256color" - - final class ActivityState: @unchecked Sendable { - private let lock = NSLock() - private var _lastActivityAt = Date() - private var _hasReceivedOutput = false - - var lastActivityAt: Date { - self.lock.withLock { self._lastActivityAt } - } - - var hasReceivedOutput: Bool { - self.lock.withLock { self._hasReceivedOutput } - } - - func markActivity() { - self.lock.withLock { - self._lastActivityAt = Date() - self._hasReceivedOutput = true - } - } - } - - let state = ActivityState() - let stdoutCapture = ProcessPipeCapture(pipe: stdoutPipe, onData: { state.markActivity() }) - let stderrCapture = ProcessPipeCapture(pipe: stderrPipe, onData: { state.markActivity() }) - stdoutCapture.start() - stderrCapture.start() - - let process: SpawnedProcessGroup - do { - process = try SpawnedProcessGroup.launch( - binary: binary, - arguments: arguments, - environment: env, - stdoutPipe: stdoutPipe, - stderrPipe: stderrPipe) - } catch { - stdoutCapture.stop() - stderrCapture.stop() - throw error - } - - let deadline = Date().addingTimeInterval(timeout) - var didHitDeadline = false - var didTerminateForIdle = false - - 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)) + switch result.completion { + case let .processExited(status): + guard status == 0 else { + let message = Self.stripANSI(result.text).trimmingCharacters(in: .whitespacesAndNewlines) + throw KiroStatusProbeError.cliFailed( + message.isEmpty ? "Kiro CLI \(command) failed with status \(status)." : message) } - } catch { - await process.terminate() - stdoutCapture.stop() - stderrCapture.stop() - throw error - } - - if process.isRunning { - await process.terminate() - guard !process.isRunning else { - stdoutCapture.stop() - stderrCapture.stop() - throw KiroStatusProbeError.timeout - } - if didHitDeadline || !state.hasReceivedOutput { - stdoutCapture.stop() - stderrCapture.stop() - throw KiroStatusProbeError.timeout - } - } - if process.hasResidualProcessGroup { - await process.terminateResidualProcesses() - } - - async let stdoutData = stdoutCapture.finish(timeout: .seconds(1)) - async let stderrData = stderrCapture.finish(timeout: .seconds(1)) - let output = await (stdout: stdoutData, stderr: stderrData) - if !stdoutCapture.reachedEOF || !stderrCapture.reachedEOF { - await process.terminateResidualProcesses() - } - await process.finish() - guard let terminationStatus = process.terminationStatus else { + case .idleTimeout: + guard allowIdleOutput else { throw KiroStatusProbeError.timeout } + case .outputCondition, .deadlineExceeded: throw KiroStatusProbeError.timeout } - return KiroCLIResult( - stdout: ProcessPipeCapture.decodeUTF8(output.stdout), - stderr: ProcessPipeCapture.decodeUTF8(output.stderr), - terminationStatus: terminationStatus, - terminatedForIdle: didTerminateForIdle) } func parse( @@ -906,6 +803,15 @@ public struct KiroStatusProbe: Sendable { .replacingOccurrences(of: #"\x1B|\[[0-9;?]*[A-Za-z]"#, with: "", options: [.regularExpression]) .trimmingCharacters(in: .whitespacesAndNewlines) } + + private static func isUsageOutputComplete(_ output: String) -> Bool { + let stripped = self.stripANSI(output).lowercased() + return stripped.contains("covered in plan") + || stripped.contains("resets on") + || stripped.contains("bonus credits") + || stripped.contains("plan:") + || stripped.contains("managed by admin") + } } extension String { diff --git a/Tests/CodexBarTests/KiroStatusProbeTests.swift b/Tests/CodexBarTests/KiroStatusProbeTests.swift index 7e73ec5331..c4f0b83ed9 100644 --- a/Tests/CodexBarTests/KiroStatusProbeTests.swift +++ b/Tests/CodexBarTests/KiroStatusProbeTests.swift @@ -83,6 +83,71 @@ struct KiroStatusProbeTests { #expect(snapshot.authMethod == "Google") } + @Test + func `fetch rejects account markers from failed whoami`() async throws { + let cliURL = try self.makeCLI( + """ + #!/bin/sh + if [ "$1" = "whoami" ]; then + printf 'Logged in with Google\nEmail: person@example.com\n' + exit 23 + fi + + if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then + 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 + """) + defer { try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) } + + let snapshot = try await KiroStatusProbe(cliBinaryResolver: { cliURL.path }).fetch() + + #expect(snapshot.accountEmail == nil) + #expect(snapshot.authMethod == nil) + } + + @Test + func `fetch rejects valid-looking usage from failed command`() async throws { + let cliURL = try self.makeCLI( + """ + #!/bin/sh + if [ "$1" = "whoami" ]; then + printf 'Logged in with Google\nEmail: person@example.com\n' + exit 0 + fi + + if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then + 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 23 + fi + + if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then + exit 0 + fi + + exit 1 + """) + defer { try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) } + + let probe = KiroStatusProbe(cliBinaryResolver: { cliURL.path }) + await #expect { + _ = try await probe.fetch() + } throws: { error in + guard case KiroStatusProbeError.cliFailed = error else { return false } + return true + } + } + @Test func `fetch preserves not logged in when usage fails without auth detail`() async throws { let cliURL = try self.makeCLI( @@ -183,10 +248,12 @@ struct KiroStatusProbeTests { try await waitForFile(contextStarted) + let cancelledAt = Date() task.cancel() await #expect(throws: CancellationError.self) { try await task.value } + #expect(Date().timeIntervalSince(cancelledAt) < 4) } @Test @@ -226,10 +293,12 @@ struct KiroStatusProbeTests { try await waitForFile(accountStarted) + let cancelledAt = Date() task.cancel() await #expect(throws: CancellationError.self) { try await task.value } + #expect(Date().timeIntervalSince(cancelledAt) < 4) } @Test @@ -322,7 +391,7 @@ struct KiroStatusProbeTests { } @Test - func `run command hard stops a process that ignores SIGTERM`() async throws { + func `tty runner hard stops a process that ignores SIGTERM`() throws { let cliURL = try self.makeCLI( """ #!/bin/sh @@ -332,19 +401,20 @@ struct KiroStatusProbeTests { """) 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 result = try TTYCommandRunner().run( + binary: cliURL.path, + send: "", + options: .init(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") + #expect(result.completion == .idleTimeout) + #expect(result.text.contains("partial output")) + #expect(elapsed < 3, "Ignored SIGTERM should escalate to SIGKILL, took \(elapsed)s") } @Test - func `run command kills a pipe holder that escapes the process group`() async throws { + func `tty runner kills a pipe holder that escapes the process group`() async throws { let childPIDFile = FileManager.default.temporaryDirectory .appendingPathComponent("codexbar-kiro-escaped-\(UUID().uuidString).pid") let cliURL = try self.makeCLI( @@ -372,14 +442,16 @@ struct KiroStatusProbeTests { try? FileManager.default.removeItem(at: childPIDFile) } - let probe = KiroStatusProbe(cliBinaryResolver: { cliURL.path }) - let result = try await probe.runCommand( - arguments: [childPIDFile.path], - timeout: 2, - idleTimeout: 0.1) + let result = try TTYCommandRunner().run( + binary: cliURL.path, + send: "", + options: .init( + timeout: 2, + idleTimeout: 0.1, + extraArgs: [childPIDFile.path])) - #expect(result.terminatedForIdle) - #expect(result.stdout.contains("partial output")) + #expect(result.completion == .idleTimeout) + #expect(result.text.contains("partial output")) let childPIDText = try String(contentsOf: childPIDFile, encoding: .utf8) let childPID = try #require(pid_t(childPIDText.trimmingCharacters(in: .whitespacesAndNewlines))) @@ -393,7 +465,7 @@ struct KiroStatusProbeTests { } @Test - func `run command cleans a same group helper after normal exit`() async throws { + func `tty runner cleans a same group helper after normal exit`() throws { let childPIDFile = FileManager.default.temporaryDirectory .appendingPathComponent("codexbar-kiro-normal-exit-\(UUID().uuidString).pid") let cliURL = try self.makeCLI( @@ -424,11 +496,13 @@ struct KiroStatusProbeTests { try? FileManager.default.removeItem(at: childPIDFile) } - let probe = KiroStatusProbe(cliBinaryResolver: { cliURL.path }) - let result = try await probe.runCommand(arguments: [childPIDFile.path], timeout: 2) + let result = try TTYCommandRunner().run( + binary: cliURL.path, + send: "", + options: .init(timeout: 2, extraArgs: [childPIDFile.path])) - #expect(result.terminationStatus == 0) - #expect(result.stdout.contains("parent complete")) + #expect(result.completion == .processExited(status: 0)) + #expect(result.text.contains("parent complete")) let childPIDText = try String(contentsOf: childPIDFile, encoding: .utf8) let childPID = try #require(pid_t(childPIDText.trimmingCharacters(in: .whitespacesAndNewlines))) @@ -437,7 +511,7 @@ struct KiroStatusProbeTests { } @Test - func `run command preserves completed no-output failure status`() async throws { + func `tty runner preserves completed no-output failure status`() throws { let cliURL = try self.makeCLI( """ #!/bin/sh @@ -445,17 +519,17 @@ struct KiroStatusProbeTests { """) defer { try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) } - let probe = KiroStatusProbe(cliBinaryResolver: { cliURL.path }) - let result = try await probe.runCommand(arguments: [], timeout: 2) + let result = try TTYCommandRunner().run( + binary: cliURL.path, + send: "", + options: .init(timeout: 2, returnOnEmptyProcessExit: true)) - #expect(result.stdout.isEmpty) - #expect(result.stderr.isEmpty) - #expect(result.terminationStatus == 23) - #expect(!result.terminatedForIdle) + #expect(result.text.isEmpty) + #expect(result.completion == .processExited(status: 23)) } @Test - func `run command cancellation terminates the process`() async throws { + func `tty runner cancellation terminates the process`() async throws { let pidFile = FileManager.default.temporaryDirectory .appendingPathComponent("codexbar-kiro-cancel-\(UUID().uuidString).pid") let cliURL = try self.makeCLI( @@ -470,9 +544,11 @@ struct KiroStatusProbeTests { try? FileManager.default.removeItem(at: pidFile) } - let probe = KiroStatusProbe(cliBinaryResolver: { cliURL.path }) let task = Task { - try await probe.runCommand(arguments: [pidFile.path], timeout: 20) + try TTYCommandRunner().run( + binary: cliURL.path, + send: "", + options: .init(timeout: 20, extraArgs: [pidFile.path])) } defer { task.cancel() } From 40140a31810519bd0b20d74116f1da1684e3df11 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 26 Jun 2026 23:04:37 +0100 Subject: [PATCH 4/5] fix: preserve idle Kiro login markers --- .../Providers/Kiro/KiroStatusProbe.swift | 11 +++++-- .../CodexBarTests/KiroStatusProbeTests.swift | 32 +++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Kiro/KiroStatusProbe.swift b/Sources/CodexBarCore/Providers/Kiro/KiroStatusProbe.swift index 2c31983659..76eebff234 100644 --- a/Sources/CodexBarCore/Providers/Kiro/KiroStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Kiro/KiroStatusProbe.swift @@ -344,6 +344,9 @@ public struct KiroStatusProbe: Sendable { timeout: self.accountProbeTimeout, idleTimeout: 1.5) guard case let .processExited(status) = result.completion else { + if Self.isWhoAmILoginRequired(result.text) { + throw KiroStatusProbeError.notLoggedIn + } throw KiroStatusProbeError.timeout } return try self.validateWhoAmIOutput( @@ -356,9 +359,8 @@ public struct KiroStatusProbe: Sendable { let trimmedStdout = stdout.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedStderr = stderr.trimmingCharacters(in: .whitespacesAndNewlines) let combined = trimmedStderr.isEmpty ? trimmedStdout : trimmedStderr - let lowered = combined.lowercased() - if lowered.contains("not logged in") || lowered.contains("login required") { + if Self.isWhoAmILoginRequired(combined) { throw KiroStatusProbeError.notLoggedIn } @@ -376,6 +378,11 @@ public struct KiroStatusProbe: Sendable { return self.parseWhoAmIOutput(combined) } + private static func isWhoAmILoginRequired(_ output: String) -> Bool { + let lowered = Self.stripANSI(output).lowercased() + return lowered.contains("not logged in") || lowered.contains("login required") + } + func parseWhoAmIOutput(_ output: String) -> KiroAccountInfo { let stripped = Self.stripANSI(output) var authMethod: String? diff --git a/Tests/CodexBarTests/KiroStatusProbeTests.swift b/Tests/CodexBarTests/KiroStatusProbeTests.swift index c4f0b83ed9..50fe16ef70 100644 --- a/Tests/CodexBarTests/KiroStatusProbeTests.swift +++ b/Tests/CodexBarTests/KiroStatusProbeTests.swift @@ -179,6 +179,38 @@ struct KiroStatusProbeTests { } } + @Test + func `fetch preserves not logged in when whoami idles after login marker`() async throws { + let cliURL = try self.makeCLI( + """ + #!/bin/sh + if [ "$1" = "whoami" ]; then + printf 'Not logged in\n' + sleep 5 + exit 1 + fi + + if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then + exit 1 + fi + + if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then + exit 0 + fi + + exit 1 + """) + defer { try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) } + + let probe = KiroStatusProbe(cliBinaryResolver: { cliURL.path }, accountProbeTimeout: 2) + await #expect { + _ = try await probe.fetch() + } throws: { error in + guard case KiroStatusProbeError.notLoggedIn = error else { return false } + return true + } + } + @Test func `fetch preserves not logged in when usage output cannot be parsed`() async throws { let cliURL = try self.makeCLI( From 9c8a9a6673299f3069a7aef906d622233d666bb0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 26 Jun 2026 23:44:04 +0100 Subject: [PATCH 5/5] fix: clean up detached PTY helpers --- .../Host/PTY/TTYCommandRunner.swift | 7 +- .../Host/Process/SpawnedProcessGroup.swift | 101 +++++++++++++++++- .../CodexBarTests/KiroStatusProbeTests.swift | 10 +- 3 files changed, 109 insertions(+), 9 deletions(-) diff --git a/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift b/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift index e73887ff2c..e45b87ea8c 100644 --- a/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift +++ b/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift @@ -599,11 +599,14 @@ public struct TTYCommandRunner { try? writeAllToPrimary(exitData) } - try? primaryHandle.close() try? secondaryHandle.close() - guard let launchedProcess else { return } + guard let launchedProcess else { + try? primaryHandle.close() + return + } launchedProcess.terminateSynchronously() + try? primaryHandle.close() TTYCommandRunnerActiveProcessRegistry.unregister(pid: launchedProcess.pid) } diff --git a/Sources/CodexBarCore/Host/Process/SpawnedProcessGroup.swift b/Sources/CodexBarCore/Host/Process/SpawnedProcessGroup.swift index 55a6ed4062..d36d6ee526 100644 --- a/Sources/CodexBarCore/Host/Process/SpawnedProcessGroup.swift +++ b/Sources/CodexBarCore/Host/Process/SpawnedProcessGroup.swift @@ -171,6 +171,87 @@ package final class SpawnedProcessGroup: @unchecked Sendable { #endif } + private struct OutputTTYIdentity: Hashable { + let device: UInt64 + let inode: UInt64 + let rawDevice: UInt64 + + static func resolve(fileDescriptor: Int32) -> OutputTTYIdentity? { + var info = stat() + guard fstat(fileDescriptor, &info) == 0 else { return nil } + return OutputTTYIdentity( + device: UInt64(info.st_dev), + inode: UInt64(info.st_ino), + rawDevice: UInt64(info.st_rdev)) + } + + static func holderPIDs(for terminals: Set) -> Set { + guard !terminals.isEmpty else { return [] } + #if canImport(Darwin) + return Set(SpawnedProcessGroup.allProcessIDs().filter { self.process(pid: $0, holdsAny: terminals) }) + #else + return Set(SpawnedProcessGroup.allProcessIDs().filter { pid in + let directory = "/proc/\(pid)/fd" + guard let descriptors = try? FileManager.default.contentsOfDirectory(atPath: directory) else { + return false + } + return descriptors.contains { descriptor in + var info = stat() + let path = "\(directory)/\(descriptor)" + guard path.withCString({ fstatat(AT_FDCWD, $0, &info, 0) }) == 0 else { return false } + let identity = OutputTTYIdentity( + device: UInt64(info.st_dev), + inode: UInt64(info.st_ino), + rawDevice: UInt64(info.st_rdev)) + return terminals.contains(identity) + } + }) + #endif + } + + #if canImport(Darwin) + private static func process(pid: pid_t, holdsAny terminals: Set) -> Bool { + let requiredBytes = proc_pidinfo(pid, PROC_PIDLISTFDS, 0, nil, 0) + guard requiredBytes > 0 else { return false } + let stride = MemoryLayout.stride + var descriptors = [proc_fdinfo]( + repeating: proc_fdinfo(), + count: Int(requiredBytes) / stride + 8) + let actualBytes = descriptors.withUnsafeMutableBytes { buffer in + proc_pidinfo( + pid, + PROC_PIDLISTFDS, + 0, + buffer.baseAddress, + Int32(buffer.count)) + } + guard actualBytes > 0 else { return false } + + for descriptor in descriptors.prefix(Int(actualBytes) / stride) + where descriptor.proc_fdtype == PROX_FDTYPE_VNODE + { + var info = vnode_fdinfo() + let byteCount = proc_pidfdinfo( + pid, + descriptor.proc_fd, + PROC_PIDFDVNODEINFO, + &info, + Int32(MemoryLayout.size)) + guard byteCount == MemoryLayout.size else { continue } + let stats = info.pvi.vi_stat + let identity = OutputTTYIdentity( + device: UInt64(stats.vst_dev), + inode: stats.vst_ino, + rawDevice: UInt64(stats.vst_rdev)) + if terminals.contains(identity) { + return true + } + } + return false + } + #endif + } + private static func allProcessIDs() -> [pid_t] { #if canImport(Darwin) return self.processIDs(type: UInt32(PROC_ALL_PIDS), typeInfo: 0) @@ -211,12 +292,18 @@ package final class SpawnedProcessGroup: @unchecked Sendable { private let termination = TerminationState() private let observedProcessGroupMembers = ProcessIdentityState() private let outputPipes: Set + private let outputTTYs: Set private let rootIdentity: TTYProcessTreeTerminator.ProcessIdentity? - private init(pid: pid_t, outputPipes: Set) { + private init( + pid: pid_t, + outputPipes: Set, + outputTTYs: Set = []) + { self.pid = pid self.processGroup = pid self.outputPipes = outputPipes + self.outputTTYs = outputTTYs self.rootIdentity = TTYProcessTreeTerminator.processIdentity(for: pid) self.startWaiter() } @@ -325,6 +412,9 @@ package final class SpawnedProcessGroup: @unchecked Sendable { { let primaryFD = fileDescriptors.primary let secondaryFD = fileDescriptors.secondary + guard let outputTTY = OutputTTYIdentity.resolve(fileDescriptor: secondaryFD) else { + throw LaunchError.setupFailed("resolve PTY identity") + } #if canImport(Darwin) var fileActions: posix_spawn_file_actions_t? #else @@ -407,7 +497,7 @@ package final class SpawnedProcessGroup: @unchecked Sendable { guard spawnResult == 0 else { throw LaunchError.spawnFailed(String(cString: strerror(spawnResult))) } - return SpawnedProcessGroup(pid: pid, outputPipes: []) + return SpawnedProcessGroup(pid: pid, outputPipes: [], outputTTYs: [outputTTY]) } package var isRunning: Bool { @@ -607,7 +697,7 @@ package final class SpawnedProcessGroup: @unchecked Sendable { private func currentResidualProcessIdentities( includeDescendants: Bool) -> Set { - var identities = self.currentOutputPipeHolderIdentities() + var identities = self.currentOutputHolderIdentities() identities.formUnion(self.observedProcessGroupMembers.snapshot) if includeDescendants { identities.formUnion( @@ -617,9 +707,10 @@ package final class SpawnedProcessGroup: @unchecked Sendable { return identities } - private func currentOutputPipeHolderIdentities() -> Set { + private func currentOutputHolderIdentities() -> Set { let excludedPIDs: Set = [getpid(), self.pid] - let holderPIDs = OutputPipeIdentity.holderPIDs(for: self.outputPipes) + var holderPIDs = OutputPipeIdentity.holderPIDs(for: self.outputPipes) + holderPIDs.formUnion(OutputTTYIdentity.holderPIDs(for: self.outputTTYs)) return Set(holderPIDs.subtracting(excludedPIDs).compactMap(TTYProcessTreeTerminator.processIdentity(for:))) } diff --git a/Tests/CodexBarTests/KiroStatusProbeTests.swift b/Tests/CodexBarTests/KiroStatusProbeTests.swift index 50fe16ef70..1e65650f7c 100644 --- a/Tests/CodexBarTests/KiroStatusProbeTests.swift +++ b/Tests/CodexBarTests/KiroStatusProbeTests.swift @@ -415,11 +415,17 @@ struct KiroStatusProbeTests { let snapshot = try await probe.fetch() let elapsed = Date().timeIntervalSince(start) - // The PTY runner returns as soon as the main CLI process exits; it must not block waiting - // on a detached helper that keeps the terminal open. (The helper is reaped by the defer above.) + let childPIDText = try String(contentsOf: childPIDFile, encoding: .utf8) + let childPID = try #require(pid_t(childPIDText.trimmingCharacters(in: .whitespacesAndNewlines))) + for _ in 0..<50 where kill(childPID, 0) == 0 { + try await Task.sleep(for: .milliseconds(20)) + } + + // The PTY runner must return promptly and reap a detached helper that keeps the terminal open. #expect(snapshot.planName == "KIRO FREE") #expect(snapshot.creditsUsed == 12.50) #expect(elapsed < 8, "Kiro usage capture should return promptly even with a detached child, took \(elapsed)s") + #expect(kill(childPID, 0) == -1) } @Test