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
212 changes: 147 additions & 65 deletions Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,101 +8,159 @@ import Musl
import Foundation

private enum TTYCommandRunnerActiveProcessRegistry {
private static let condition = NSCondition()
private nonisolated(unsafe) static var processes: [pid_t: ProcessInfo] = [:]
private nonisolated(unsafe) static var isShuttingDown = false
private nonisolated(unsafe) static var launchesInProgress = 0

private struct ProcessInfo {
let binary: String
var processGroup: pid_t?
}

@discardableResult
private final class State: @unchecked Sendable {
private let condition = NSCondition()
private var processes: [pid_t: ProcessInfo] = [:]
private var isShuttingDown = false
private var launchesInProgress = 0

@discardableResult
func register(pid: pid_t, binary: String) -> Bool {
guard pid > 0 else { return false }
self.condition.lock()
defer { self.condition.unlock() }
guard !self.isShuttingDown else { return false }
self.processes[pid] = ProcessInfo(binary: binary, processGroup: nil)
return true
}

func beginLaunch() -> Bool {
self.condition.lock()
defer { self.condition.unlock() }
guard !self.isShuttingDown else { return false }
self.launchesInProgress += 1
return true
}

func endLaunch() {
self.condition.lock()
self.launchesInProgress = max(0, self.launchesInProgress - 1)
if self.launchesInProgress == 0 {
self.condition.broadcast()
}
self.condition.unlock()
}

func updateProcessGroup(pid: pid_t, processGroup: pid_t?) {
guard pid > 0 else { return }
self.condition.lock()
guard var existing = self.processes[pid] else {
self.condition.unlock()
return
}
existing.processGroup = processGroup
self.processes[pid] = existing
self.condition.unlock()
}

func unregister(pid: pid_t) {
guard pid > 0 else { return }
self.condition.lock()
self.processes.removeValue(forKey: pid)
self.condition.unlock()
}

func drainForShutdown(
onFenceSet: (() -> Void)? = nil)
-> [(pid: pid_t, binary: String, processGroup: pid_t?)]
{
self.condition.lock()
self.isShuttingDown = true
onFenceSet?()
while self.launchesInProgress > 0 {
self.condition.wait()
}
let drained = self.processes.map {
(pid: $0.key, binary: $0.value.binary, processGroup: $0.value.processGroup)
}
self.processes.removeAll()
self.condition.unlock()
return drained
}

func reset() {
self.condition.lock()
self.processes.removeAll()
self.isShuttingDown = false
self.launchesInProgress = 0
self.condition.broadcast()
self.condition.unlock()
}

func count() -> Int {
self.condition.lock()
let count = self.processes.count
self.condition.unlock()
return count
}

func testTrackProcess(pid: pid_t, binary: String, processGroup: pid_t?) {
guard pid > 0 else { return }
self.condition.lock()
self.processes[pid] = ProcessInfo(binary: binary, processGroup: processGroup)
self.condition.unlock()
}
}

private static let shared = State()
@TaskLocal private static var stateOverrideForTesting: State?

private static var current: State {
self.stateOverrideForTesting ?? self.shared
}

static func register(pid: pid_t, binary: String) -> Bool {
guard pid > 0 else { return false }
self.condition.lock()
defer { self.condition.unlock() }
guard !self.isShuttingDown else { return false }
self.processes[pid] = ProcessInfo(binary: binary, processGroup: nil)
return true
self.current.register(pid: pid, binary: binary)
}

static func beginLaunch() -> Bool {
self.condition.lock()
defer { self.condition.unlock() }
guard !self.isShuttingDown else { return false }
self.launchesInProgress += 1
return true
self.current.beginLaunch()
}

static func endLaunch() {
self.condition.lock()
self.launchesInProgress = max(0, self.launchesInProgress - 1)
if self.launchesInProgress == 0 {
self.condition.broadcast()
}
self.condition.unlock()
self.current.endLaunch()
}

static func updateProcessGroup(pid: pid_t, processGroup: pid_t?) {
guard pid > 0 else { return }
self.condition.lock()
guard var existing = self.processes[pid] else {
self.condition.unlock()
return
}
existing.processGroup = processGroup
self.processes[pid] = existing
self.condition.unlock()
self.current.updateProcessGroup(pid: pid, processGroup: processGroup)
}

static func unregister(pid: pid_t) {
guard pid > 0 else { return }
self.condition.lock()
self.processes.removeValue(forKey: pid)
self.condition.unlock()
self.current.unregister(pid: pid)
}

static func drainForShutdown(
onFenceSet: (() -> Void)? = nil)
static func drainForShutdown(onFenceSet: (() -> Void)? = nil)
-> [(pid: pid_t, binary: String, processGroup: pid_t?)]
{
self.condition.lock()
self.isShuttingDown = true
onFenceSet?()
while self.launchesInProgress > 0 {
self.condition.wait()
}
let drained = self.processes.map {
(pid: $0.key, binary: $0.value.binary, processGroup: $0.value.processGroup)
}
self.processes.removeAll()
self.condition.unlock()
return drained
self.current.drainForShutdown(onFenceSet: onFenceSet)
}

static func reset() {
self.condition.lock()
self.processes.removeAll()
self.isShuttingDown = false
self.launchesInProgress = 0
self.condition.broadcast()
self.condition.unlock()
self.current.reset()
}

static func count() -> Int {
self.condition.lock()
let count = self.processes.count
self.condition.unlock()
return count
self.current.count()
}

static func testTrackProcess(pid: pid_t, binary: String, processGroup: pid_t?) {
guard pid > 0 else { return }
self.condition.lock()
self.processes[pid] = ProcessInfo(binary: binary, processGroup: processGroup)
self.condition.unlock()
self.current.testTrackProcess(pid: pid, binary: binary, processGroup: processGroup)
}

static func withIsolatedStateForTesting<T>(_ operation: () throws -> T) rethrows -> T {
try self.$stateOverrideForTesting.withValue(State(), operation: operation)
}

static func makeDrainOperationForTesting(onFenceSet: (@Sendable () -> Void)? = nil)
-> @Sendable () -> [(pid: pid_t, binary: String, processGroup: pid_t?)]
{
let state = self.current
return { state.drainForShutdown(onFenceSet: onFenceSet) }
}
}

Expand Down Expand Up @@ -206,6 +264,10 @@ enum TTYProcessTreeTerminator {
}
}

private enum TTYCommandRunnerTestingOverrides {
@TaskLocal static var postDeadlineDrainDuration: TimeInterval?
}

/// Executes an interactive CLI inside a pseudo-terminal and returns all captured text.
/// Keeps it minimal so we can reuse for Codex and Claude without tmux.
public struct TTYCommandRunner {
Expand Down Expand Up @@ -910,7 +972,9 @@ public struct TTYCommandRunner {
} 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.
drainNonCodexOutput(for: min(0.5, max(0.2, options.settleAfterStop)))
let defaultDrainDuration = min(0.5, max(0.2, options.settleAfterStop))
let drainDuration = TTYCommandRunnerTestingOverrides.postDeadlineDrainDuration ?? defaultDrainDuration
drainNonCodexOutput(for: drainDuration)
}

try checkOutputLimit()
Expand Down Expand Up @@ -1102,6 +1166,17 @@ public struct TTYCommandRunner {
}

extension TTYCommandRunner {
static func withIsolatedActiveProcessRegistryForTesting<T>(_ operation: () throws -> T) rethrows -> T {
try TTYCommandRunnerActiveProcessRegistry.withIsolatedStateForTesting(operation)
}

static func withPostDeadlineDrainDurationOverrideForTesting<T>(
_ duration: TimeInterval,
operation: () throws -> T) rethrows -> T
{
try TTYCommandRunnerTestingOverrides.$postDeadlineDrainDuration.withValue(duration, operation: operation)
}

public static func which(_ tool: String) -> String? {
if tool == "codex", let located = BinaryLocator.resolveCodexBinary() {
return located
Expand Down Expand Up @@ -1254,6 +1329,13 @@ extension TTYCommandRunner {
TTYCommandRunnerActiveProcessRegistry.drainForShutdown(onFenceSet: onFenceSet)
}

static func _test_makeDrainTrackedProcessesForShutdownOperation(
onFenceSet: (@Sendable () -> Void)? = nil)
-> @Sendable () -> [(pid: pid_t, binary: String, processGroup: pid_t?)]
{
TTYCommandRunnerActiveProcessRegistry.makeDrainOperationForTesting(onFenceSet: onFenceSet)
}

static func _test_resolveShutdownTargets(
_ targets: [(pid: pid_t, binary: String, processGroup: pid_t?)],
hostProcessGroup: pid_t,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ enum ClaudeCLIAuthStatusProbe {
}

@TaskLocal private static var resultOverrideForTesting: Bool?
@TaskLocal private static var timeoutOverrideForTesting: TimeInterval?

static func withResultOverrideForTesting<T>(
_ result: Bool?,
Expand All @@ -16,6 +17,15 @@ enum ClaudeCLIAuthStatusProbe {
}
}

static func withTimeoutOverrideForTesting<T>(
_ timeout: TimeInterval,
operation: () async throws -> T) async rethrows -> T
{
try await self.$timeoutOverrideForTesting.withValue(timeout) {
try await operation()
}
}

static func isLoggedIn(
binary: String,
environment: [String: String],
Expand All @@ -29,7 +39,7 @@ enum ClaudeCLIAuthStatusProbe {
binary: binary,
arguments: ["auth", "status", "--json"],
environment: ClaudeCLISession.launchEnvironment(baseEnv: environment),
timeout: timeout,
timeout: self.timeoutOverrideForTesting ?? timeout,
standardInput: FileHandle.nullDevice,
label: "claude-auth-status")
return self.parseLoggedIn(result.stdout)
Expand Down
36 changes: 20 additions & 16 deletions Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -207,17 +207,19 @@ struct ClaudeBaselineCharacterizationTests {
let stubCLIPath = try self.makeStubClaudeCLI(loggedIn: false, invocationLog: invocationLog)
let env = ["CLAUDE_CLI_PATH": stubCLIPath]

await self.withBackgroundKeychainAccess {
await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) {
await self.withNoOAuthCredentials {
let outcome = await self.fetchOutcome(
runtime: .app,
sourceMode: .auto,
env: env,
settings: settings)

#expect(outcome.attempts.map(\.strategyID) == ["claude.oauth", "claude.cli", "claude.web"])
#expect(outcome.attempts.map(\.wasAvailable) == [false, false, false])
await ClaudeCLIAuthStatusProbe.withTimeoutOverrideForTesting(20) {
await self.withBackgroundKeychainAccess {
await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) {
await self.withNoOAuthCredentials {
let outcome = await self.fetchOutcome(
runtime: .app,
sourceMode: .auto,
env: env,
settings: settings)

#expect(outcome.attempts.map(\.strategyID) == ["claude.oauth", "claude.cli", "claude.web"])
#expect(outcome.attempts.map(\.wasAvailable) == [false, false, false])
}
}
}
}
Expand Down Expand Up @@ -323,11 +325,13 @@ struct ClaudeBaselineCharacterizationTests {
rawText: nil)
}

let outcome = await self.withBackgroundKeychainAccess {
await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) {
await self.withNoOAuthCredentials {
await ClaudeWebFetchStrategy.$usageLoaderOverrideForTesting.withValue(usageLoader) {
await self.fetchOutcome(runtime: .app, sourceMode: .auto, env: env, settings: settings)
let outcome = await ClaudeCLIAuthStatusProbe.withTimeoutOverrideForTesting(20) {
await self.withBackgroundKeychainAccess {
await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) {
await self.withNoOAuthCredentials {
await ClaudeWebFetchStrategy.$usageLoaderOverrideForTesting.withValue(usageLoader) {
await self.fetchOutcome(runtime: .app, sourceMode: .auto, env: env, settings: settings)
}
}
}
}
Expand Down
Loading