codeburn/mac/Sources/CodeBurnMenubar/Data/CLIWatchdog.swift
iamtoruk 272da806e4 fix(menubar): no-output watchdog, lock signal cleanup, dead-pid takeover, serve orphan reaping
The 0.9.20 menubar never completed a fetch on a large corpus: the cache
version bump forced a full rehydration, DataClient's fixed 45s kill ended
it mid-transaction, cache-refresh-lock had no signal cleanup so the dead
holder's lock survived, and DEFAULT_WAIT_MS (30s) < DEFAULT_STALE_MS (90s)
meant no waiter could ever recover it.

A. Swift port of #1096's watchdog. CLIWatchdog holds the constants and the
   pure verdict; spawns and the resident serve child set CODEBURN_PROGRESS=1;
   the window restarts on any stdout/stderr byte; 45s silence, 10min cold
   floor until the first payload, 15min ceiling, SIGTERM then SIGKILL after
   5s. ServeConnection's fixed 60s warm cap becomes the same silence window,
   re-armed by each progress frame, and a spent death budget is now a
   5-minute cooldown instead of disabling the resident for the app run.

B. cache-refresh-lock arms SIGINT/SIGTERM cleanup the way session-cache does
   for hydrating.lock, so a SIGTERMed holder unlinks its own lock.

C. Staleness also opens on a dead holder pid, and the waiter budget derives
   from staleMs so it can never expire before the gate it waits for. A live
   holder - fresh heartbeat, pid answers signal 0 - is never taken from.
   parser.ts heartbeats through the lock wait, the one silent stretch left.

D. The app closes its end of a retired child's stdin (dropping the handle
   left the pipe alive inside the Process), reaps every serve child
   synchronously at quit, and records pid+argv so a crash-orphaned child is
   reaped next launch. serve's final exit no longer runs through a
   monkeypatched process.exit, and its post-drain cleanup is bounded.

Closes #1117
2026-08-23 05:35:17 -07:00

98 lines
4.1 KiB
Swift

import Foundation
/// Swift port of the desktop app's no-output watchdog (`app/electron/cli.ts`, #1096).
///
/// The CLI timeout is a NO-OUTPUT window, not a total-runtime cap: every byte the
/// child writes on stdout or stderr restarts it. Spawns set `CODEBURN_PROGRESS=1`,
/// under which the parser heartbeats every 10s for the whole duration of a parse
/// (`PROGRESS_KEEPALIVE_MS` in `src/parser.ts`), so a legitimately slow cold parse
/// on a large corpus is never killed while a genuinely wedged child still is.
///
/// The pure `verdict` exists so the arithmetic is testable without spawning a
/// child and waiting minutes for a real ceiling.
enum CLIWatchdog {
/// Silence a live child cannot produce: 4.5x the CLI's 10s keepalive cadence.
/// Matches `DEFAULT_TIMEOUT_MS` in app/electron/cli.ts.
static let silenceSeconds: Double = 45
/// Until this app has seen one successful payload the on-disk cache may be
/// empty, and a full hydration has genuinely silent stretches before the
/// first keepalive can be armed. Matches `DESKTOP_COLD_TIMEOUT_MS`. Finite
/// on purpose: a child that never emits a byte still dies here, it just gets
/// the cold budget to prove itself first.
static let coldSilenceSeconds: Double = 10 * 60
/// Backstop for the watchdog: a livelocked child that chatters forever
/// without finishing is still reaped. Matches `MAX_RUNTIME_MS`.
static let ceilingSeconds: Double = 15 * 60
/// SIGTERM is catchable, so the CLI unlinks its own refresh lock before dying
/// (`armSignalCleanup` in src/session-cache.ts and src/cache-refresh-lock.ts).
/// Only a child that ignores it gets SIGKILL. Matches `KILL_GRACE_MS`.
static let killGraceSeconds: Double = 5
enum Verdict: Equatable {
case wait
case silent
case ceiling
}
/// `now`, `startedAt` and `lastOutputAt` are seconds on one monotonic scale.
static func verdict(now: Double,
startedAt: Double,
lastOutputAt: Double,
silenceSeconds: Double) -> Verdict {
if now - lastOutputAt >= silenceSeconds { return .silent }
if now - startedAt >= ceilingSeconds { return .ceiling }
return .wait
}
/// The silence window for a request: the cold floor applies until this app
/// has completed one payload, exactly as the electron client floors every
/// request admitted before its resident child is warm.
static func silenceWindow(warm: Bool) -> Double {
warm ? silenceSeconds : max(silenceSeconds, coldSilenceSeconds)
}
/// Progress heartbeats share stderr with real diagnostics, and every read
/// spawn now enables them, so they must never become the error message.
static func withoutProgressLines(_ stderr: String) -> String {
stderr
.split(separator: "\n", omittingEmptySubsequences: false)
.filter { !$0.hasPrefix(progressLinePrefix) }
.joined(separator: "\n")
.trimmingCharacters(in: .whitespacesAndNewlines)
}
/// Wire marker for CLI scan-progress lines (`PROGRESS_LINE_PREFIX`, src/parser.ts).
static let progressLinePrefix = "CODEBURN_PROGRESS "
/// Environment for a spawn that should heartbeat through the watchdog.
static func withProgressHeartbeat(_ environment: [String: String]?) -> [String: String] {
var env = environment ?? ProcessInfo.processInfo.environment
env["CODEBURN_PROGRESS"] = "1"
return env
}
}
/// Monotonic last-output marker shared by a process's two drain tasks and its
/// watchdog timer. `uptimeNanoseconds` is unaffected by wall-clock jumps.
final class OutputActivity: @unchecked Sendable {
private let lock = NSLock()
private var lastOutput: Double
init() { lastOutput = OutputActivity.now() }
static func now() -> Double {
Double(DispatchTime.now().uptimeNanoseconds) / 1_000_000_000
}
func touch() {
lock.lock()
lastOutput = OutputActivity.now()
lock.unlock()
}
var lastOutputAt: Double {
lock.lock()
defer { lock.unlock() }
return lastOutput
}
}