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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
- Localizations: add Spanish and Catalan language packs and fill missing localization keys (#1041). Thanks @seifreed!

### Fixed
- Codex/Claude: terminate PTY child process trees during probe cleanup so wrapper-launched CLI descendants do not linger after sessions finish (#1085). Thanks @mickobizzle!
- OpenAI: parse Wednesday and Saturday dashboard reset lines so rate-limit reset times are not dropped on those days (#1080). Thanks @m1qaweb!
- Localization: translate provider-detail labels and empty states when Simplified Chinese is selected (#1051). Thanks @wang93wei!
- Antigravity: discover OAuth credentials from the bundled extension language server in newer IDE builds so Add Account works again (#1076). Thanks @xARSENICx!
Expand Down
94 changes: 80 additions & 14 deletions Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,62 @@ private enum TTYCommandRunnerActiveProcessRegistry {
}
}

enum TTYProcessTreeTerminator {
static func descendantPIDs(
of rootPID: pid_t,
childResolver: (pid_t) -> [pid_t] = Self.currentChildPIDs(of:)) -> [pid_t]
{
guard rootPID > 0 else { return [] }

var seen: Set<pid_t> = [rootPID]
var pending = childResolver(rootPID)
var descendants: [pid_t] = []

while let pid = pending.popLast() {
guard pid > 0, seen.insert(pid).inserted else { continue }
descendants.append(pid)
pending.append(contentsOf: childResolver(pid))
}

return descendants
}

static func currentChildPIDs(of parentPID: pid_t) -> [pid_t] {
guard parentPID > 0 else { return [] }

#if canImport(Darwin)
var pids = [pid_t](repeating: 0, count: 128)
let byteCount = Int32(pids.count * MemoryLayout<pid_t>.stride)
let childCount = proc_listchildpids(parentPID, &pids, byteCount)
guard childCount > 0 else { return [] }
return Array(pids.prefix(min(Int(childCount), pids.count))).filter { $0 > 0 }
Comment on lines +104 to +108

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle child PID lists larger than 128 entries

currentChildPIDs uses a fixed 128-element buffer and returns only that prefix, so when a root process has more than 128 direct children, the extra children are silently dropped and never traversed/terminated. In that case terminateProcessTree will miss part of the subtree and can still leave descendants running, which defeats the cleanup this change is meant to guarantee for high-fanout wrappers.

Useful? React with 👍 / 👎.

#else
return []
#endif
}

static func terminateProcessTree(
rootPID: pid_t,
processGroup: pid_t?,
signal: Int32,
knownDescendants: [pid_t] = [],
childResolver: (pid_t) -> [pid_t] = Self.currentChildPIDs(of:),
signalSender: (pid_t, Int32) -> Void = { kill($0, $1) })
{
guard rootPID > 0 else { return }

var seen: Set<pid_t> = [rootPID]
let descendants = knownDescendants + self.descendantPIDs(of: rootPID, childResolver: childResolver)
for pid in descendants where pid > 0 && seen.insert(pid).inserted {
signalSender(pid, signal)
}
if let processGroup {
signalSender(-processGroup, signal)
}
signalSender(rootPID, signal)
}
}

/// 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 @@ -167,17 +223,17 @@ public struct TTYCommandRunner {
groupResolver: { getpgid($0) })

for target in resolvedTargets where target.pid > 0 {
if let pgid = target.processGroup {
kill(-pgid, SIGTERM)
}
kill(target.pid, SIGTERM)
TTYProcessTreeTerminator.terminateProcessTree(
rootPID: target.pid,
processGroup: target.processGroup,
signal: SIGTERM)
}

for target in resolvedTargets where target.pid > 0 {
if let pgid = target.processGroup {
kill(-pgid, SIGKILL)
}
kill(target.pid, SIGKILL)
TTYProcessTreeTerminator.terminateProcessTree(
rootPID: target.pid,
processGroup: target.processGroup,
signal: SIGKILL)
}
}

Expand Down Expand Up @@ -472,21 +528,29 @@ public struct TTYCommandRunner {

guard didLaunch else { return }

let descendants = TTYProcessTreeTerminator.descendantPIDs(of: proc.processIdentifier)
if proc.isRunning {
proc.terminate()
}
if let pgid = processGroup {
kill(-pgid, SIGTERM)
}
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 {
if let pgid = processGroup {
kill(-pgid, SIGKILL)
TTYProcessTreeTerminator.terminateProcessTree(
rootPID: proc.processIdentifier,
processGroup: processGroup,
signal: SIGKILL,
knownDescendants: descendants)
} else {
for pid in descendants where pid > 0 {
kill(pid, SIGKILL)
}
kill(proc.processIdentifier, SIGKILL)
}
if didLaunch {
proc.waitUntilExit()
Expand Down Expand Up @@ -966,7 +1030,9 @@ public struct TTYCommandRunner {
}
return env
}
}

extension TTYCommandRunner {
static func _test_resetTrackedProcesses() {
TTYCommandRunnerActiveProcessRegistry.reset()
}
Expand Down
20 changes: 15 additions & 5 deletions Sources/CodexBarCore/Providers/Claude/ClaudeCLISession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -369,22 +369,32 @@ actor ClaudeCLISession {
try? self.primaryHandle?.close()
try? self.secondaryHandle?.close()

let descendants = self.process.map { TTYProcessTreeTerminator.descendantPIDs(of: $0.processIdentifier) } ?? []
if let proc = self.process, proc.isRunning {
proc.terminate()
}
if let pgid = self.processGroup {
kill(-pgid, SIGTERM)
if let proc = self.process {
TTYProcessTreeTerminator.terminateProcessTree(
rootPID: proc.processIdentifier,
processGroup: self.processGroup,
signal: SIGTERM,
knownDescendants: descendants)
}
let waitDeadline = Date().addingTimeInterval(1.0)
if let proc = self.process {
while proc.isRunning, Date() < waitDeadline {
usleep(100_000)
}
if proc.isRunning {
if let pgid = self.processGroup {
kill(-pgid, SIGKILL)
TTYProcessTreeTerminator.terminateProcessTree(
rootPID: proc.processIdentifier,
processGroup: self.processGroup,
signal: SIGKILL,
knownDescendants: descendants)
} else {
for pid in descendants where pid > 0 {
kill(pid, SIGKILL)
}
kill(proc.processIdentifier, SIGKILL)
}
TTYCommandRunner.unregisterActiveProcessForAppShutdown(pid: proc.processIdentifier)
}
Expand Down
20 changes: 15 additions & 5 deletions Sources/CodexBarCore/Providers/Codex/CodexCLISession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -347,22 +347,32 @@ actor CodexCLISession {
try? self.primaryHandle?.close()
try? self.secondaryHandle?.close()

let descendants = self.process.map { TTYProcessTreeTerminator.descendantPIDs(of: $0.processIdentifier) } ?? []
if let proc = self.process, proc.isRunning {
proc.terminate()
}
if let pgid = self.processGroup {
kill(-pgid, SIGTERM)
if let proc = self.process {
TTYProcessTreeTerminator.terminateProcessTree(
rootPID: proc.processIdentifier,
processGroup: self.processGroup,
signal: SIGTERM,
knownDescendants: descendants)
}
let waitDeadline = Date().addingTimeInterval(1.0)
if let proc = self.process {
while proc.isRunning, Date() < waitDeadline {
usleep(100_000)
}
if proc.isRunning {
if let pgid = self.processGroup {
kill(-pgid, SIGKILL)
TTYProcessTreeTerminator.terminateProcessTree(
rootPID: proc.processIdentifier,
processGroup: self.processGroup,
signal: SIGKILL,
knownDescendants: descendants)
} else {
for pid in descendants where pid > 0 {
kill(pid, SIGKILL)
}
kill(proc.processIdentifier, SIGKILL)
}
TTYCommandRunner.unregisterActiveProcessForAppShutdown(pid: proc.processIdentifier)
}
Expand Down
37 changes: 37 additions & 0 deletions Tests/CodexBarTests/TTYCommandRunnerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,43 @@ struct TTYCommandRunnerEnvTests {
#expect(resolved[2].processGroup == 7777)
}

@Test
func `descendant resolver walks process tree once`() {
let children: [pid_t: [pid_t]] = [
100: [101, 102],
101: [103],
102: [103],
103: [100],
]

let descendants = TTYProcessTreeTerminator.descendantPIDs(of: 100) { children[$0] ?? [] }

#expect(Set(descendants) == Set([101, 102, 103]))
#expect(descendants.count == 3)
}

@Test
func `process tree termination signals escaped descendants`() {
let children: [pid_t: [pid_t]] = [
100: [101, 102],
102: [103],
]
var signaled: [(pid: pid_t, signal: Int32)] = []

TTYProcessTreeTerminator.terminateProcessTree(
rootPID: 100,
processGroup: 200,
signal: 15,
childResolver: { children[$0] ?? [] },
signalSender: { pid, signal in
signaled.append((pid: pid, signal: signal))
})

#expect(Set(signaled.map(\.pid)) == Set([100, 101, 102, 103, -200]))
#expect(signaled.allSatisfy { $0.signal == 15 })
#expect(signaled.last?.pid == 100)
}

@Test
func `preserves environment and sets term`() {
let baseEnv: [String: String] = [
Expand Down