# DisplayLink Dual-Path & Watchdog Orphan Detection

> Two unrelated but equally subtle runtime quirks shape how CodexBar animates its icon and manages child processes. DisplayLinkDriver uses NSScreen.displayLink (macOS 15+) when available and falls back to CVDisplayLink (macOS 14), bridging the two via a scheduleTick() dispatch to the main actor since CVDisplayLink fires on a background thread. CADisplayLink ticks are rate-limited by a manual timestamp comparison so the 12 fps target is honored regardless of the display's native refresh rate. Separately, CodexBarClaudeWatchdog is a standalone POSIX executable that spawns claude as a child process group leader and polls waitpid every 200ms. Its critical invariant: if getppid() returns 1, the macOS app has been killed without sending SIGTERM, so the watchdog kills the child process tree (SIGTERM → 500ms grace → SIGKILL) and exits. The watchdog also manually decodes waitpid's raw status integer because Swift cannot import function-like C macros (WIFEXITED/WEXITSTATUS). Evidence: DisplayLink.swift startCVDisplayLink(), CodexBarClaudeWatchdog/main.swift getppid()==1 branch, exitCode(fromWaitStatus:) bit-manipulation.

- Repository: steipete/CodexBar
- GitHub: https://github.com/steipete/CodexBar
- Human wiki: https://grok-wiki.com/public/wiki/steipete-codexbar-3494bea25492
- Complete Markdown: https://grok-wiki.com/public/wiki/steipete-codexbar-3494bea25492/llms-full.txt

## Source Files

- `Sources/CodexBar/DisplayLink.swift`
- `Sources/CodexBarClaudeWatchdog/main.swift`
- `Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift`
- `Sources/CodexBarCore/Host/Process/SubprocessRunner.swift`
- `Tests/CodexBarTests/TTYCommandRunnerTests.swift`

---

<details>
<summary>Relevant source files</summary>
The following files were used as context for generating this wiki page:

- [Sources/CodexBar/DisplayLink.swift](Sources/CodexBar/DisplayLink.swift)
- [Sources/CodexBarClaudeWatchdog/main.swift](Sources/CodexBarClaudeWatchdog/main.swift)
- [Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift](Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift)
- [Sources/CodexBarCore/Host/Process/SubprocessRunner.swift](Sources/CodexBarCore/Host/Process/SubprocessRunner.swift)
- [Tests/CodexBarTests/TTYCommandRunnerTests.swift](Tests/CodexBarTests/TTYCommandRunnerTests.swift)
</details>

# DisplayLink Dual-Path & Watchdog Orphan Detection

Two separately-motivated but equally subtle runtime mechanisms keep CodexBar correct under conditions that most apps never encounter. `DisplayLinkDriver` must animate a menu-bar icon at a fixed 12 fps across macOS versions that offer incompatible display-link APIs, one of which fires on a background thread and must be safely bridged to the `@MainActor`. `CodexBarClaudeWatchdog` is a POSIX helper process that must clean up a `claude` child even when the macOS parent app is force-killed without sending `SIGTERM` — a scenario that requires POSIX orphan detection and hand-written wait-status bit manipulation that Swift's type system cannot supply.

Together these components demonstrate the kind of defensive engineering that becomes necessary when you wrap long-lived, interactive Unix processes inside a Cocoa application: neither the display refresh contract nor the process lifecycle contract can be assumed to hold under all OS versions and shutdown paths.

---

## DisplayLink Dual-Path

### The API split between macOS 14 and macOS 15

Apple introduced `NSScreen.displayLink(target:selector:)` in macOS 15 and deprecated the C-level `CVDisplayLink` API family alongside it. CodexBar must run on macOS 14, so `DisplayLinkDriver` contains an explicit version fork.

```swift
// Sources/CodexBar/DisplayLink.swift  lines 29-41
if #available(macOS 15, *), let screen = NSScreen.main {
    let displayLink = screen.displayLink(target: self, selector: #selector(self.step))
    let rate = Float(clampedFps)
    displayLink.preferredFrameRateRange = CAFrameRateRange(
        minimum: rate, maximum: rate, preferred: rate)
    displayLink.add(to: .main, forMode: .common)
    self.displayLink = displayLink
} else {
    self.startCVDisplayLink()
}
```

On macOS 15 the resulting `CADisplayLink` is added to the main run loop; its callback `step(_:)` fires on the main thread, and the class's `@MainActor` isolation means no additional dispatch is needed.

Sources: [Sources/CodexBar/DisplayLink.swift:29-41]()

### CVDisplayLink and the background-thread bridge

`CVDisplayLink` callbacks arrive on an arbitrary background thread, not the main thread. Because `DisplayLinkDriver` is `@MainActor`-isolated, calling `handleTick()` directly from the callback would violate Swift concurrency rules and could produce data races on `tick` and `lastTickTimestamp`.

The fix is `scheduleTick()`, which is marked `nonisolated` so it can be called from outside the actor, and dispatches to `@MainActor` via a `Task`:

```swift
// Sources/CodexBar/DisplayLink.swift  lines 74-89
let callback: CVDisplayLinkOutputCallback = { _, _, _, _, _, userInfo in
    guard let userInfo else { return kCVReturnSuccess }
    let driver = Unmanaged<DisplayLinkDriver>.fromOpaque(userInfo).takeUnretainedValue()
    driver.scheduleTick()
    return kCVReturnSuccess
}
...
private nonisolated func scheduleTick() {
    Task { @MainActor [weak self] in
        self?.handleTick()
    }
}
```

The `Unmanaged.passUnretained` / `takeUnretainedValue` bridge avoids a retain cycle — the C callback holds a raw opaque pointer, not a strong reference.

Sources: [Sources/CodexBar/DisplayLink.swift:74-89]()

### Rate limiting via manual timestamp comparison

`CADisplayLink.preferredFrameRateRange` only hints at the desired rate on macOS 15; the underlying display may run at 60 Hz, 120 Hz, or ProMotion 240 Hz. On macOS 14 (`CVDisplayLink`) there is no built-in rate clamping at all.

`handleTick()` solves this with a single manual guard:

```swift
// Sources/CodexBar/DisplayLink.swift  lines 57-66
private func handleTick() {
    let now = CACurrentMediaTime()
    if self.lastTickTimestamp > 0, now - self.lastTickTimestamp < self.targetInterval {
        return
    }
    self.lastTickTimestamp = now
    self.tick &+= 1
    self.onTick?()
}
```

`targetInterval` is set in `start(fps:)` as `1.0 / clampedFps`. At the default 12 fps that is ≈83 ms. Any display callback that fires before 83 ms have elapsed is silently dropped. This ensures the `tick` counter that drives SwiftUI animations increments at most 12 times per second regardless of the panel's native refresh rate.

`tick` is incremented with `&+=` (wrapping addition) so the `Int` can never trap on overflow, which would otherwise crash the app after ~292 years of continuous use or a counter reset.

Sources: [Sources/CodexBar/DisplayLink.swift:24-27, 57-66]()

### Component summary

| Concern | macOS 15 path | macOS 14 path |
|---|---|---|
| API | `NSScreen.displayLink` → `CADisplayLink` | `CVDisplayLinkCreateWithActiveCGDisplays` |
| Thread | Main (run loop) | Background (C callback) |
| Bridge needed | No | Yes — `scheduleTick()` + `Task @MainActor` |
| Rate clamping | `preferredFrameRateRange` + `handleTick` guard | `handleTick` guard only |
| Storage field | `displayLink: CADisplayLink?` | `cvDisplayLink: CVDisplayLink?` |

---

## Watchdog Orphan Detection

### Why a separate process is needed

When macOS force-kills an app (via Activity Monitor "Force Quit", `kill -9`, or OOM), it sends `SIGKILL`, which cannot be caught. Any child process spawned with `Process` or `posix_spawn` from within the app becomes an orphan whose parent PID is immediately reparented to `launchd` (PID 1). A `claude` session left running in this state holds a PTY, consumes tokens, and may interact with a working directory the user no longer wants touched.

`CodexBarClaudeWatchdog` is a standalone Mach-O helper bundled inside `CodexBar.app/Contents/Helpers/` that addresses this by running as its own PID and polling `getppid()`.

### Spawn path and process group isolation

`TTYCommandRunner.run(binary:send:options:)` detects when the target binary is `claude` and transparently substitutes the watchdog:

```swift
// Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift  lines 426-434
if resolvedURL.lastPathComponent == "claude",
   let watchdog = Self.locateBundledHelper("CodexBarClaudeWatchdog")
{
    proc.executableURL = URL(fileURLWithPath: watchdog)
    proc.arguments = ["--", resolved] + options.extraArgs
}
```

The watchdog then spawns `claude` via `posix_spawnp` and immediately moves it into its own process group:

```swift
// Sources/CodexBarClaudeWatchdog/main.swift  lines 71-85
let rc: Int32 = childBinary.withCString { childPath in
    posix_spawnp(&pid, childPath, nil, nil, cBuffer.baseAddress, environ)
}
if rc == 0, pid > 0 {
    globalChildPID = pid
}
...
_ = setpgid(globalChildPID, globalChildPID)
```

Setting `pgid = pid` creates a new process group with `claude` as the leader. This lets the watchdog later send `kill(-pgid, ...)` to target the entire subtree (e.g. shell children spawned by `claude` itself), not just the top-level process.

Sources: [Sources/CodexBarClaudeWatchdog/main.swift:62-85]()

### The orphan detection loop

The main poll loop runs every 200 ms (`usleep(200_000)`):

```swift
// Sources/CodexBarClaudeWatchdog/main.swift  lines 101-122
while true {
    let rc = waitpid(globalChildPID, &status, WNOHANG)
    if rc == globalChildPID {
        Darwin.exit(exitCode(fromWaitStatus: status))   // child exited naturally
    }

    if globalShouldTerminate != 0 {                     // SIGTERM/SIGINT/SIGHUP received
        terminateChild()
        _ = waitpid(globalChildPID, &status, 0)
        Darwin.exit(128 + sig)
    }

    if getppid() == 1 {                                 // parent was force-killed
        terminateChild()
        _ = waitpid(globalChildPID, &status, 0)
        Darwin.exit(exitCode(fromWaitStatus: status))
    }

    usleep(200_000)
}
```

Three distinct exit conditions are checked in order:

1. **Natural exit**: `waitpid` with `WNOHANG` returns the child's PID — forward the exit code.
2. **Signal forwarding**: `SIGTERM`, `SIGINT`, or `SIGHUP` set `globalShouldTerminate` asynchronously. The main loop detects this and propagates the signal to the child tree.
3. **Orphan detection**: `getppid() == 1` means the watchdog's parent (the Cocoa app) no longer exists; it was killed hard. The watchdog kills the child tree and exits.

Sources: [Sources/CodexBarClaudeWatchdog/main.swift:101-122]()

### `killProcessTree`: SIGTERM → grace period → SIGKILL

```swift
// Sources/CodexBarClaudeWatchdog/main.swift  lines 17-38
private func killProcessTree(childPID: pid_t, graceSeconds: TimeInterval = 0.5) {
    let pgid = getpgid(childPID)
    if pgid > 0 {
        kill(-pgid, SIGTERM)
    } else {
        kill(childPID, SIGTERM)
    }
    let deadline = Date().addingTimeInterval(graceSeconds)
    var status: Int32 = 0
    while Date() < deadline {
        let rc = waitpid(childPID, &status, WNOHANG)
        if rc == childPID { return }
        usleep(50000)
    }
    if pgid > 0 {
        kill(-pgid, SIGKILL)
    } else {
        kill(childPID, SIGKILL)
    }
}
```

The grace window is 500 ms by default. During this window the helper polls `waitpid` every 50 ms — if `claude` exits voluntarily after `SIGTERM`, the `SIGKILL` is never sent. If the process does not exit within 500 ms, `SIGKILL` fires unconditionally.

Sources: [Sources/CodexBarClaudeWatchdog/main.swift:17-38]()

### Hand-written wait-status decoding

Swift cannot import function-like C macros (`WIFEXITED`, `WEXITSTATUS`, `WIFSIGNALED`), because the preprocessor expands them into bit-manipulation expressions that are invisible to the Swift importer. `exitCode(fromWaitStatus:)` reimplements the POSIX encoding directly:

```swift
// Sources/CodexBarClaudeWatchdog/main.swift  lines 40-52
private func exitCode(fromWaitStatus status: Int32) -> Int32 {
    // Swift can't import wait(2) macros (function-like macros). Use the classic encoding:
    // - low 7 bits: signal number (0 means exited)
    // - high byte: exit status (when exited)
    let low = status & 0x7F
    if low == 0 {
        return (status >> 8) & 0xFF   // normal exit — extract high byte
    }
    if low != 0x7F {
        return 128 + low              // killed by signal — 128 + signal number
    }
    return 1                          // stopped/continued — treat as error
}
```

This matches the POSIX `wait(2)` encoding: bits 0–6 are the terminating signal number (0 if the process called `exit()`), bits 8–15 hold the exit status when the low byte is zero. The `128 + signal` convention mirrors shell semantics so callers can distinguish normal exits from signal deaths.

Sources: [Sources/CodexBarClaudeWatchdog/main.swift:40-52]()

### Watchdog lifecycle diagram

```mermaid
sequenceDiagram
    participant App as CodexBar.app
    participant WD as CodexBarClaudeWatchdog
    participant Claude as claude (child)

    App->>WD: posix_spawn via TTYCommandRunner
    WD->>Claude: posix_spawnp
    WD->>WD: setpgid(claude_pid, claude_pid)
    loop Every 200 ms
        WD->>WD: waitpid(WNOHANG)
        WD->>WD: check globalShouldTerminate
        WD->>WD: getppid() == 1?
    end
    alt App killed with SIGKILL
        WD->>WD: getppid() returns 1
        WD->>Claude: kill(-pgid, SIGTERM)
        WD->>WD: 500 ms grace window
        WD->>Claude: kill(-pgid, SIGKILL) if still running
        WD->>WD: Darwin.exit(...)
    else Claude exits naturally
        WD->>WD: waitpid returns claude_pid
        WD->>WD: exitCode(fromWaitStatus:)
        WD->>App: Darwin.exit(code)
    end
```

---

## Interaction with TTYCommandRunner's Shutdown Registry

`TTYCommandRunner` also maintains its own `TTYCommandRunnerActiveProcessRegistry` for the normal (non-force-kill) shutdown path. When the app quits gracefully, `terminateActiveProcessesForAppShutdown()` drains the registry and sends `SIGTERM` + `SIGKILL` to every tracked PID and its process group. The watchdog path complements this: graceful shutdown is handled by the registry, while hard kills are handled by the watchdog's `getppid() == 1` branch. They cover disjoint failure modes and do not conflict.

The registry protects against a subtle race: once `drainForShutdown()` sets `isShuttingDown = true`, any new `register(pid:binary:)` call returns `false`, and `TTYCommandRunner.run` throws `launchFailed("App shutdown in progress")` immediately after launching, so the newly-spawned watchdog is cleaned up by the `defer { cleanup() }` block before becoming permanently untracked.

Sources: [Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift:157-179, 509-519](), [Tests/CodexBarTests/TTYCommandRunnerTests.swift:62-70]()

---

These two mechanisms — the display-link API fork with main-actor bridging, and the POSIX watchdog with orphan detection and hand-rolled wait-status decoding — are representative of CodexBar's approach to correctness at the OS boundary: each quirk targets a specific failure mode (API unavailability, hard process kill) that higher-level Swift or Cocoa abstractions cannot address on their own. The watchdog's `getppid() == 1` check is the critical invariant; without it, any force-quit of CodexBar leaves a running `claude` session that the operating system will never clean up automatically. Sources: [Sources/CodexBarClaudeWatchdog/main.swift:115-119]()
