Skip to content
Open
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
4 changes: 4 additions & 0 deletions Sources/CodexBar/CodexbarApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
}

func applicationWillTerminate(_ notification: Notification) {
TTYCommandRunner.terminateActiveProcessesForAppShutdown()
}

/// Use the classic (non-Liquid Glass) app icon on macOS versions before 26.
private func configureAppIconForMacOSVersion() {
if #unavailable(macOS 26) {
Expand Down
147 changes: 145 additions & 2 deletions Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,72 @@ import Glibc
#endif
import Foundation

private enum TTYCommandRunnerActiveProcessRegistry {
private static let lock = NSLock()
private nonisolated(unsafe) static var processes: [pid_t: ProcessInfo] = [:]

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

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

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

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

static func drain() -> [(pid: pid_t, binary: String, processGroup: pid_t?)] {
self.lock.lock()
let drained = self.processes.map {
(pid: $0.key, binary: $0.value.binary, processGroup: $0.value.processGroup)
}
self.processes.removeAll()
self.lock.unlock()
return drained
}

static func reset() {
self.lock.lock()
self.processes.removeAll()
self.lock.unlock()
}

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

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

/// 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 @@ -76,6 +142,54 @@ public struct TTYCommandRunner {

public init() {}

public static func terminateActiveProcessesForAppShutdown() {
let targets = TTYCommandRunnerActiveProcessRegistry.drain()
guard !targets.isEmpty else { return }

let resolvedTargets = self.resolveShutdownTargets(
targets,
hostProcessGroup: getpgrp(),
groupResolver: { getpgid($0) })

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

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

private static func resolveShutdownTargets(
_ targets: [(pid: pid_t, binary: String, processGroup: pid_t?)],
hostProcessGroup: pid_t,
groupResolver: (pid_t) -> pid_t) -> [(pid: pid_t, binary: String, processGroup: pid_t?)]
{
var resolvedTargets: [(pid: pid_t, binary: String, processGroup: pid_t?)] = []
resolvedTargets.reserveCapacity(targets.count)

for target in targets {
var resolvedGroup = target.processGroup
if resolvedGroup == nil {
let pgid = groupResolver(target.pid)
if pgid > 0, pgid != hostProcessGroup {
resolvedGroup = pgid
}
} else if resolvedGroup == hostProcessGroup {
resolvedGroup = nil
}

resolvedTargets.append((pid: target.pid, binary: target.binary, processGroup: resolvedGroup))
}
return resolvedTargets
}

struct RollingBuffer: Sendable {
private let maxNeedle: Int
private var tail = Data()
Expand Down Expand Up @@ -269,7 +383,6 @@ public struct TTYCommandRunner {
/// while bootstrapping the CLI (e.g. when it prompts for login/telemetry).
func cleanup() {
guard !cleanedUp else { return }
cleanedUp = true

if didLaunch, proc.isRunning {
Self.log.debug("PTY stopping", metadata: ["binary": binaryName])
Expand Down Expand Up @@ -301,6 +414,11 @@ public struct TTYCommandRunner {
if didLaunch {
proc.waitUntilExit()
}

cleanedUp = true
if didLaunch {
TTYCommandRunnerActiveProcessRegistry.unregister(pid: proc.processIdentifier)
}
}

// Ensure the PTY process is always torn down, even when we throw early (e.g. login prompt).
Expand All @@ -309,6 +427,7 @@ public struct TTYCommandRunner {
do {
try proc.run()
didLaunch = true
TTYCommandRunnerActiveProcessRegistry.register(pid: proc.processIdentifier, binary: binaryName)
Self.log.debug("PTY launched", metadata: ["binary": binaryName])
} catch {
Self.log.warning(
Expand All @@ -323,6 +442,7 @@ public struct TTYCommandRunner {
let pid = proc.processIdentifier
if setpgid(pid, pid) == 0 {
processGroup = pid
TTYCommandRunnerActiveProcessRegistry.updateProcessGroup(pid: pid, processGroup: pid)
}

func send(_ text: String) throws {
Expand All @@ -332,7 +452,7 @@ public struct TTYCommandRunner {

let deadline = Date().addingTimeInterval(options.timeout)
let trimmed = script.trimmingCharacters(in: .whitespacesAndNewlines)
let isCodex = (binary == "codex")
let isCodex = (binaryName == "codex")
let isCodexStatus = isCodex && trimmed == "/status"

var buffer = Data()
Expand Down Expand Up @@ -700,4 +820,27 @@ public struct TTYCommandRunner {
}
return env
}

static func _test_resetTrackedProcesses() {
TTYCommandRunnerActiveProcessRegistry.reset()
}

static func _test_trackProcess(pid: pid_t, binary: String, processGroup: pid_t?) {
TTYCommandRunnerActiveProcessRegistry.testTrackProcess(
pid: pid,
binary: binary,
processGroup: processGroup)
}

static func _test_trackedProcessCount() -> Int {
TTYCommandRunnerActiveProcessRegistry.count()
}

static func _test_resolveShutdownTargets(
_ targets: [(pid: pid_t, binary: String, processGroup: pid_t?)],
hostProcessGroup: pid_t,
groupResolver: (pid_t) -> pid_t) -> [(pid: pid_t, binary: String, processGroup: pid_t?)]
{
self.resolveShutdownTargets(targets, hostProcessGroup: hostProcessGroup, groupResolver: groupResolver)
}
}
43 changes: 43 additions & 0 deletions Tests/CodexBarTests/TTYCommandRunnerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,49 @@ import Testing

@Suite
struct TTYCommandRunnerEnvTests {
@Test
func shutdownCleanupDrainsTrackedTTYProcesses() {
TTYCommandRunner._test_resetTrackedProcesses()
defer { TTYCommandRunner._test_resetTrackedProcesses() }

TTYCommandRunner._test_trackProcess(pid: pid_t(Int32.max), binary: "codex", processGroup: nil)
#expect(TTYCommandRunner._test_trackedProcessCount() == 1)

TTYCommandRunner.terminateActiveProcessesForAppShutdown()
#expect(TTYCommandRunner._test_trackedProcessCount() == 0)
}

@Test
func trackedProcessHelpersIgnoreInvalidPID() {
TTYCommandRunner._test_resetTrackedProcesses()
defer { TTYCommandRunner._test_resetTrackedProcesses() }

TTYCommandRunner._test_trackProcess(pid: 0, binary: "codex", processGroup: nil)
#expect(TTYCommandRunner._test_trackedProcessCount() == 0)
}

@Test
func shutdownResolverSkipsHostProcessGroupFallback() {
let hostGroup: pid_t = 4242
let targets: [(pid: pid_t, binary: String, processGroup: pid_t?)] = [
(pid: 100, binary: "codex", processGroup: nil),
(pid: 101, binary: "codex", processGroup: hostGroup),
(pid: 102, binary: "codex", processGroup: 7777),
]

let resolved = TTYCommandRunner._test_resolveShutdownTargets(
targets,
hostProcessGroup: hostGroup,
groupResolver: { pid in
pid == 100 ? hostGroup : -1
})

#expect(resolved.count == 3)
#expect(resolved[0].processGroup == nil)
#expect(resolved[1].processGroup == nil)
#expect(resolved[2].processGroup == 7777)
}

@Test
func preservesEnvironmentAndSetsTerm() {
let baseEnv: [String: String] = [
Expand Down