Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 69 additions & 25 deletions Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -397,7 +397,9 @@ public struct TTYCommandRunner {
let dst = dest.bindMemory(to: UInt8.self)
for idx in 0..<src.count {
var byte = src[idx]
if byte >= 65, byte <= 90 { byte += 32 }
if byte >= 65, byte <= 90 {
byte += 32
}
dst[idx] = byte
}
}
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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
}
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -741,6 +753,7 @@ public struct TTYCommandRunner {
var lastEnter = Date()
var stoppedEarly = false
var urlSeen = false
var ptyClosed = false
var triggeredSends = Set<Data>()
var recentText = ""
var lastOutputAt = Date()
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -1000,7 +1038,9 @@ public struct TTYCommandRunner {
continue
}
}
if sawCodexStatus { break }
if sawCodexStatus {
break
}
usleep(120_000)
}

Expand Down Expand Up @@ -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)
}

Expand Down
10 changes: 10 additions & 0 deletions Sources/CodexBarCore/Host/Process/SpawnedProcessGroup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand All @@ -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()
}
}
Expand Down Expand Up @@ -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)
}
Expand Down
61 changes: 59 additions & 2 deletions Tests/CodexBarTests/TTYCommandRunnerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 >/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)
Expand Down