diff --git a/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift b/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift index e45b87ea8c..e4ff0061d0 100644 --- a/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift +++ b/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift @@ -397,7 +397,9 @@ public struct TTYCommandRunner { let dst = dest.bindMemory(to: UInt8.self) for idx in 0..= 65, byte <= 90 { byte += 32 } + if byte >= 65, byte <= 90 { + byte += 32 + } dst[idx] = byte } } @@ -424,7 +426,9 @@ public struct TTYCommandRunner { } static func drainReadResult(for data: Data, terminalRead: Int, errno err: Int32) -> DrainReadResult { - if !data.isEmpty { return .data(data) } + if !data.isEmpty { + return .data(data) + } if terminalRead == 0 { return .closed @@ -465,7 +469,9 @@ public struct TTYCommandRunner { } let mainURL = Bundle.main.bundleURL - if mainURL.pathExtension == "app", let found = candidate(inAppBundleURL: mainURL) { return found } + if mainURL.pathExtension == "app", let found = candidate(inAppBundleURL: mainURL) { + return found + } if let argv0 = CommandLine.arguments.first { var url = URL(fileURLWithPath: argv0) @@ -475,8 +481,12 @@ public struct TTYCommandRunner { var probe = url for _ in 0..<6 { let parent = probe.deletingLastPathComponent() - if parent.pathExtension == "app", let found = candidate(inAppBundleURL: parent) { return found } - if parent.path == probe.path { break } + if parent.pathExtension == "app", let found = candidate(inAppBundleURL: parent) { + return found + } + if parent.path == probe.path { + break + } probe = parent } } @@ -545,7 +555,9 @@ public struct TTYCommandRunner { retries = 0 continue } - if written == 0 { break } + if written == 0 { + break + } let err = errno if err == EAGAIN || err == EWOULDBLOCK { @@ -741,6 +753,7 @@ public struct TTYCommandRunner { var lastEnter = Date() var stoppedEarly = false var urlSeen = false + var ptyClosed = false var triggeredSends = Set() var recentText = "" var lastOutputAt = Date() @@ -802,11 +815,15 @@ public struct TTYCommandRunner { while Date() < deadline { try checkCancellation() let readResult = readDrainChunk() - let newData = switch readResult { + let newData: Data + switch readResult { case let .data(data): - data - case .wouldBlock, .closed: - Data() + newData = data + case .wouldBlock: + newData = Data() + case .closed: + ptyClosed = true + newData = Data() } if processNonCodexChunk(newData, allowSends: true, allowStop: true) { stoppedEarly = true @@ -826,11 +843,33 @@ public struct TTYCommandRunner { lastEnter = Date() } - if case .closed = readResult, !process.isRunning { break } - if !process.isRunning { break } + if ptyClosed, !process.isRunning { + break + } + if !process.isRunning { + break + } usleep(60000) } + let exitStatusBeforeDrain: Int32? = if !stoppedEarly, + let exitObservedAt = process.exitObservationDate, + exitObservedAt <= deadline + { + process.finishSynchronously() + } else { + nil + } + + func drainNonCodexOutput(for duration: TimeInterval) { + let drainFor = max(0, duration) + guard drainFor > 0 else { return } + Self.drainRemainingOutput( + until: Date().addingTimeInterval(drainFor), + readChunk: readDrainChunk, + processChunk: { _ = processNonCodexChunk($0, allowSends: false, allowStop: false) }) + } + if stoppedEarly { let settle = max(0, min(options.settleAfterStop, deadline.timeIntervalSinceNow)) if settle > 0 { @@ -849,21 +888,20 @@ public struct TTYCommandRunner { usleep(50000) } } - } else if !process.isRunning { + } else { // 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)) - if drainFor > 0 { - Self.drainRemainingOutput( - until: Date().addingTimeInterval(drainFor), - readChunk: readDrainChunk, - processChunk: { _ = processNonCodexChunk($0, allowSends: false, allowStop: false) }) - } + drainNonCodexOutput(for: min(0.5, max(0.2, options.settleAfterStop))) } let text = String(data: buffer, encoding: .utf8) ?? "" - let completion: Result.Completion = if !process.isRunning { - .processExited(status: process.finishSynchronously() ?? 1) + let exitStatus: Int32? = if stoppedEarly { + !process.isRunning ? process.finishSynchronously() : nil + } else { + exitStatusBeforeDrain + } + let completion: Result.Completion = if let exitStatus { + .processExited(status: exitStatus) } else if terminatedForIdle { .idleTimeout } else if stoppedEarly { @@ -1000,7 +1038,9 @@ public struct TTYCommandRunner { continue } } - if sawCodexStatus { break } + if sawCodexStatus { + break + } usleep(120_000) } @@ -1040,8 +1080,12 @@ public struct TTYCommandRunner { 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 } + if tool == "codex", let located = BinaryLocator.resolveCodexBinary() { + return located + } + if tool == "claude", let located = BinaryLocator.resolveClaudeBinary() { + return located + } return self.runWhich(tool) } diff --git a/Sources/CodexBarCore/Host/Process/SpawnedProcessGroup.swift b/Sources/CodexBarCore/Host/Process/SpawnedProcessGroup.swift index 96acc3d262..863918950c 100644 --- a/Sources/CodexBarCore/Host/Process/SpawnedProcessGroup.swift +++ b/Sources/CodexBarCore/Host/Process/SpawnedProcessGroup.swift @@ -25,6 +25,7 @@ package final class SpawnedProcessGroup: @unchecked Sendable { private final class TerminationState: @unchecked Sendable { private let condition = NSCondition() private var exitObserved = false + private var exitObservedAt: Date? private var reapRequested = false private var status: Int32? @@ -36,9 +37,14 @@ package final class SpawnedProcessGroup: @unchecked Sendable { self.condition.withLock { self.status } } + var observationDate: Date? { + self.condition.withLock { self.exitObservedAt } + } + func observeExit() { self.condition.withLock { self.exitObserved = true + self.exitObservedAt = Date() self.condition.broadcast() } } @@ -530,6 +536,10 @@ package final class SpawnedProcessGroup: @unchecked Sendable { self.termination.value } + package var exitObservationDate: Date? { + self.termination.observationDate + } + package var hasResidualProcessGroup: Bool { Self.processGroupExists(self.processGroup) } diff --git a/Tests/CodexBarTests/TTYCommandRunnerTests.swift b/Tests/CodexBarTests/TTYCommandRunnerTests.swift index fdd2a0ae1f..239fd1f91d 100644 --- a/Tests/CodexBarTests/TTYCommandRunnerTests.swift +++ b/Tests/CodexBarTests/TTYCommandRunnerTests.swift @@ -354,7 +354,9 @@ struct TTYCommandRunnerEnvTests { TTYCommandRunner.drainRemainingOutput( until: Date().addingTimeInterval(1), readChunk: { - if reads.isEmpty { return .closed } + if reads.isEmpty { + return .closed + } return reads.removeFirst() }, processChunk: { data in @@ -382,7 +384,9 @@ struct TTYCommandRunnerEnvTests { until: Date().addingTimeInterval(1), readChunk: { readCount += 1 - if reads.isEmpty { return .closed } + if reads.isEmpty { + return .closed + } return reads.removeFirst() }, processChunk: { data in @@ -410,6 +414,59 @@ struct TTYCommandRunnerEnvTests { #expect(readCount == 1) } + @Test + func `deadline drain preserves timeout while collecting late output`() throws { + let fm = FileManager.default + let dir = fm.temporaryDirectory.appendingPathComponent("codexbar-tty-\(UUID().uuidString)", isDirectory: true) + try fm.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? fm.removeItem(at: dir) } + + let scriptURL = dir.appendingPathComponent("late-output.sh") + let script = """ + #!/bin/sh + /bin/sleep 0.12 + printf 'https://claude.ai/oauth/authorize?test=late\\n' + """ + try script.write(to: scriptURL, atomically: true, encoding: .utf8) + try fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: scriptURL.path) + + let runner = TTYCommandRunner() + let result = try runner.run( + binary: scriptURL.path, + send: "", + options: .init(timeout: 0.01, initialDelay: 0, settleAfterStop: 0.5)) + + #expect(result.completion == .deadlineExceeded) + #expect(result.text.contains("https://claude.ai/oauth/authorize?test=late")) + } + + @Test + func `PTY closure keeps waiting for child exit before deadline`() throws { + let fm = FileManager.default + let dir = fm.temporaryDirectory.appendingPathComponent("codexbar-tty-\(UUID().uuidString)", isDirectory: true) + try fm.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? fm.removeItem(at: dir) } + + let scriptURL = dir.appendingPathComponent("close-pty-exit.sh") + let script = """ + #!/bin/sh + exec /dev/null 2>/dev/null + /bin/sleep 2 + exit 0 + """ + try script.write(to: scriptURL, atomically: true, encoding: .utf8) + try fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: scriptURL.path) + + let runner = TTYCommandRunner() + let result = try runner.run( + binary: scriptURL.path, + send: "", + options: .init(timeout: 4, initialDelay: 0, returnOnEmptyProcessExit: true)) + + #expect(result.completion == .processExited(status: 0)) + #expect(result.text.isEmpty) + } + @Test func `interrupted drain reads are treated as retryable`() { let result = TTYCommandRunner.drainReadResult(for: Data(), terminalRead: -1, errno: EINTR)