11import Foundation
2+ #if canImport(Darwin)
3+ import Darwin
4+ #else
5+ import Glibc
6+ #endif
27
38public struct KiroUsageSnapshot : Sendable {
49 public let planName : String
@@ -219,7 +224,15 @@ public enum KiroStatusProbeError: LocalizedError, Sendable {
219224}
220225
221226public struct KiroStatusProbe : Sendable {
222- public init ( ) { }
227+ private let cliBinaryResolver : @Sendable ( ) -> String ?
228+
229+ public init ( ) {
230+ self . cliBinaryResolver = { TTYCommandRunner . which ( " kiro-cli " ) }
231+ }
232+
233+ init ( cliBinaryResolver: @escaping @Sendable ( ) -> String ? ) {
234+ self . cliBinaryResolver = cliBinaryResolver
235+ }
223236
224237 private static let logger = CodexBarLog . logger ( LogCategories . kiro)
225238
@@ -256,7 +269,7 @@ public struct KiroStatusProbe: Sendable {
256269 contextUsage: contextUsage)
257270 }
258271
259- private struct KiroCLIResult {
272+ struct KiroCLIResult {
260273 let stdout : String
261274 let stderr : String
262275 let terminationStatus : Int32
@@ -383,12 +396,12 @@ public struct KiroStatusProbe: Sendable {
383396 return self . parseContextUsage ( output: output)
384397 }
385398
386- private func runCommand(
399+ func runCommand(
387400 arguments: [ String ] ,
388401 timeout: TimeInterval ,
389402 idleTimeout: TimeInterval = 5.0 ) async throws -> KiroCLIResult
390403 {
391- guard let binary = TTYCommandRunner . which ( " kiro-cli " ) else {
404+ guard let binary = self . cliBinaryResolver ( ) else {
392405 throw KiroStatusProbeError . cliNotFound
393406 }
394407
@@ -406,124 +419,98 @@ public struct KiroStatusProbe: Sendable {
406419 env [ " TERM " ] = " xterm-256color "
407420 process. environment = env
408421
409- // Thread-safe state for activity tracking
410422 final class ActivityState : @unchecked Sendable {
411423 private let lock = NSLock ( )
412424 private var _lastActivityAt = Date ( )
413425 private var _hasReceivedOutput = false
414- private var _stdoutData = Data ( )
415- private var _stderrData = Data ( )
416426
417427 var lastActivityAt : Date {
418- self . lock. lock ( )
419- defer { lock. unlock ( ) }
420- return self . _lastActivityAt
428+ self . lock. withLock { self . _lastActivityAt }
421429 }
422430
423431 var hasReceivedOutput : Bool {
424- self . lock. lock ( )
425- defer { lock. unlock ( ) }
426- return self . _hasReceivedOutput
427- }
428-
429- func appendStdout( _ data: Data ) {
430- self . lock. lock ( )
431- defer { lock. unlock ( ) }
432- self . _stdoutData. append ( data)
433- self . _lastActivityAt = Date ( )
434- self . _hasReceivedOutput = true
432+ self . lock. withLock { self . _hasReceivedOutput }
435433 }
436434
437- func appendStderr( _ data: Data ) {
438- self . lock. lock ( )
439- defer { lock. unlock ( ) }
440- self . _stderrData. append ( data)
441- self . _lastActivityAt = Date ( )
442- self . _hasReceivedOutput = true
443- }
444-
445- func getOutput( ) -> ( stdout: Data , stderr: Data ) {
446- self . lock. lock ( )
447- defer { lock. unlock ( ) }
448- return ( self . _stdoutData, self . _stderrData)
435+ func markActivity( ) {
436+ self . lock. withLock {
437+ self . _lastActivityAt = Date ( )
438+ self . _hasReceivedOutput = true
439+ }
449440 }
450441 }
451442
452443 let state = ActivityState ( )
444+ let stdoutCapture = ProcessPipeCapture ( pipe: stdoutPipe, onData: { state. markActivity ( ) } )
445+ let stderrCapture = ProcessPipeCapture ( pipe: stderrPipe, onData: { state. markActivity ( ) } )
453446
454- // Set up readability handlers to track activity
455- stdoutPipe. fileHandleForReading. readabilityHandler = { handle in
456- let data = handle. availableData
457- if !data. isEmpty {
458- state. appendStdout ( data)
459- }
460- }
461- stderrPipe. fileHandleForReading. readabilityHandler = { handle in
462- let data = handle. availableData
463- if !data. isEmpty {
464- state. appendStderr ( data)
465- }
447+ do {
448+ try process. run ( )
449+ } catch {
450+ stdoutCapture. stop ( )
451+ stderrCapture. stop ( )
452+ throw error
466453 }
454+ stdoutCapture. start ( )
455+ stderrCapture. start ( )
456+ let pid = process. processIdentifier
457+ let processGroup : pid_t ? = setpgid ( pid, pid) == 0 ? pid : nil
467458
468- return try await withCheckedThrowingContinuation { continuation in
469- DispatchQueue . global ( ) . async {
470- do {
471- try process. run ( )
472- } catch {
473- stdoutPipe. fileHandleForReading. readabilityHandler = nil
474- stderrPipe. fileHandleForReading. readabilityHandler = nil
475- continuation. resume ( throwing: error)
476- return
477- }
459+ let deadline = Date ( ) . addingTimeInterval ( timeout)
460+ var didHitDeadline = false
461+ var didTerminateForIdle = false
478462
479- let deadline = Date ( ) . addingTimeInterval ( timeout)
480- var didHitDeadline = false
481- var didTerminateForIdle = false
482-
483- while process. isRunning {
484- if Date ( ) >= deadline {
485- didHitDeadline = true
486- break
487- }
488- // Idle timeout: if we got output but then it went silent
489- if state. hasReceivedOutput,
490- Date ( ) . timeIntervalSince ( state. lastActivityAt) >= idleTimeout
491- {
492- // Process went idle after producing output - likely done or stuck
493- didTerminateForIdle = true
494- break
495- }
496- Thread . sleep ( forTimeInterval: 0.1 )
463+ do {
464+ while process. isRunning {
465+ try Task . checkCancellation ( )
466+ if Date ( ) >= deadline {
467+ didHitDeadline = true
468+ break
497469 }
498-
499- // Clean up handlers
500- stdoutPipe. fileHandleForReading. readabilityHandler = nil
501- stderrPipe. fileHandleForReading. readabilityHandler = nil
502-
503- if process. isRunning {
504- process. terminate ( )
505- process. waitUntilExit ( )
506- if didHitDeadline || !state. hasReceivedOutput {
507- continuation. resume ( throwing: KiroStatusProbeError . timeout)
508- return
509- }
470+ if state. hasReceivedOutput,
471+ Date ( ) . timeIntervalSince ( state. lastActivityAt) >= idleTimeout
472+ {
473+ didTerminateForIdle = true
474+ break
510475 }
476+ try await Task . sleep ( for: . milliseconds( 100 ) )
477+ }
478+ } catch {
479+ await Self . terminateProcess ( process, processGroup: processGroup)
480+ stdoutCapture. stop ( )
481+ stderrCapture. stop ( )
482+ throw error
483+ }
484+
485+ if process. isRunning {
486+ await Self . terminateProcess ( process, processGroup: processGroup)
487+ guard !process. isRunning else {
488+ stdoutCapture. stop ( )
489+ stderrCapture. stop ( )
490+ throw KiroStatusProbeError . timeout
491+ }
492+ if didHitDeadline || !state. hasReceivedOutput {
493+ stdoutCapture. stop ( )
494+ stderrCapture. stop ( )
495+ throw KiroStatusProbeError . timeout
496+ }
497+ }
498+
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)
502+ return KiroCLIResult (
503+ stdout: String ( data: output. stdout, encoding: . utf8) ?? " " ,
504+ stderr: String ( data: output. stderr, encoding: . utf8) ?? " " ,
505+ terminationStatus: process. terminationStatus,
506+ terminatedForIdle: didTerminateForIdle)
507+ }
511508
512- // Read any remaining data
513- let remainingStdout = stdoutPipe. fileHandleForReading. readDataToEndOfFile ( )
514- let remainingStderr = stderrPipe. fileHandleForReading. readDataToEndOfFile ( )
515-
516- var output = state. getOutput ( )
517- output. stdout. append ( remainingStdout)
518- output. stderr. append ( remainingStderr)
519-
520- let stdoutOutput = String ( data: output. stdout, encoding: . utf8) ?? " "
521- let stderrOutput = String ( data: output. stderr, encoding: . utf8) ?? " "
522- continuation. resume ( returning: KiroCLIResult (
523- stdout: stdoutOutput,
524- stderr: stderrOutput,
525- terminationStatus: process. terminationStatus,
526- terminatedForIdle: didTerminateForIdle) )
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 ( )
527514 }
528515 }
529516 }
0 commit comments