codeburn/mac/Tests/CodeBurnMenubarTests/DataClientProcessTests.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

259 lines
11 KiB
Swift

import Foundation
import Testing
@testable import CodeBurnMenubar
@Suite("DataClient process")
struct DataClientProcessTests {
@Test("status argv local omits scope")
func statusSubcommandLocalOmitsScope() {
let args = DataClient.statusSubcommand(
period: .today,
provider: .claude,
includeOptimize: false,
scope: .local
)
#expect(!args.contains("--scope"))
#expect(value(after: "--provider", in: args) == "claude")
#expect(args.contains("--no-optimize"))
}
@Test("status argv combined adds scope and forces provider all")
func statusSubcommandCombinedAddsScopeAndForcesAllProvider() {
let args = DataClient.statusSubcommand(
period: .today,
provider: .codex,
includeOptimize: true,
scope: .combined
)
#expect(value(after: "--scope", in: args) == "combined")
#expect(value(after: "--provider", in: args) == "all")
#expect(!args.contains("--no-optimize"))
}
@Test("status argv combined multi-day coerces to local")
func statusSubcommandCombinedMultiDayCoercesToLocal() {
let args = DataClient.statusSubcommand(
period: .today,
days: ["2026-06-01", "2026-06-02"],
provider: .codex,
includeOptimize: false,
scope: .combined
)
#expect(!args.contains("--scope"))
#expect(value(after: "--provider", in: args) == "codex")
#expect(value(after: "--days", in: args) == "2026-06-01,2026-06-02")
}
@Test("status argv local includes Claude config source")
func statusSubcommandLocalIncludesClaudeConfigSource() {
let args = DataClient.statusSubcommand(
period: .today,
provider: .all,
includeOptimize: false,
scope: .local,
claudeConfigSourceId: "claude-config:work"
)
#expect(value(after: "--claude-config-source", in: args) == "claude-config:work")
}
@Test("status argv combined omits Claude config source")
func statusSubcommandCombinedOmitsClaudeConfigSource() {
let args = DataClient.statusSubcommand(
period: .today,
provider: .all,
includeOptimize: false,
scope: .combined,
claudeConfigSourceId: "claude-config:work"
)
#expect(value(after: "--scope", in: args) == "combined")
#expect(!args.contains("--claude-config-source"))
}
@Test("status argv supports LingTai TUI provider")
func statusSubcommandSupportsLingTaiTUI() {
let args = DataClient.statusSubcommand(
period: .month,
provider: .lingtaiTui,
includeOptimize: false,
scope: .local
)
#expect(value(after: "--provider", in: args) == "lingtai-tui")
#expect(value(after: "--period", in: args) == "month")
}
/// Concurrency + timeout smoke test: launch more hung subprocesses than
/// there are cooperative threads, all at once, with a short timeout, and
/// assert every call returns because the timeout killed its sleep.
///
/// NOTE: this does NOT reproduce the production permanent deadlock (16/16
/// cooperative threads parked in waitUntilExit). The real deadlock built up
/// over ~2 days under the @MainActor refresh loop and is confirmed by the
/// live `sample`, not by this test. Kept as a guard that the off-pool wait
/// + timeout path stays correct under concurrency.
///
/// The body must stay `async` and await the group directly. It used to
/// block on a `DispatchSemaphore` with a 15s deadline, on the claim that a
/// test body runs on a real thread. It does not: Swift Testing invokes even
/// synchronous test bodies from a task on the cooperative pool, so the wait
/// parked one of the pool's `activeProcessorCount` workers on the very work
/// it was waiting for. A 16-core dev box has slack, a 3-core CI runner does
/// not, and the wait expired with the group making no progress at all.
/// Keeping it `async` also lets the compiler reject the blocking wait,
/// which is unavailable from asynchronous contexts.
@Test("concurrent timed-out processes all complete", .timeLimit(.minutes(1)))
func concurrentTimedOutProcessesAllComplete() async {
let count = ProcessInfo.processInfo.activeProcessorCount * 2 + 4
let codes = await withTaskGroup(of: Int32?.self) { group -> [Int32?] in
for _ in 0..<count {
group.addTask {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/sleep")
process.arguments = ["30"]
return try? await DataClient.runProcess(process, timeoutSeconds: 1, label: "sleep 30").exitCode
}
}
var out: [Int32?] = []
for await code in group { out.append(code) }
return out
}
#expect(codes.count == count)
// A `sleep 30` cannot end within a 1s timeout on its own, so a signal
// status is proof the timeout fired and killed it rather than the child
// racing the deadline and exiting normally.
#expect(codes.allSatisfy { $0 == SIGTERM || $0 == SIGKILL },
"every hung process should be killed by its own timeout, got \(codes)")
}
/// A decode failure surfaces the CLI's actual stdout/stderr so a stray banner
/// on stdout (see #515) is self-diagnosing instead of an opaque "not valid JSON".
@Test("decode failure surfaces output")
func decodeFailureSurfacesOutput() {
struct Boom: Error {}
let failure = CLIDecodeFailure(
underlying: Boom(),
stdoutByteCount: 13,
stdoutSnippet: "(node) banner",
stderr: "warn: x"
)
let text = String(describing: failure)
#expect(text.contains("(node) banner"), "should include the stdout snippet")
#expect(text.contains("13 bytes"), "should include the stdout byte count")
#expect(text.contains("warn: x"), "should include stderr")
}
/// Empty stdout is reported distinctly (the JSONDecoder-on-empty-Data case).
@Test("decode failure with empty stdout")
func decodeFailureWithEmptyStdout() {
struct Boom: Error {}
let failure = CLIDecodeFailure(underlying: Boom(), stdoutByteCount: 0, stdoutSnippet: "", stderr: "")
let text = String(describing: failure)
#expect(text.contains("0 bytes"))
#expect(text.contains("<empty>"))
}
/// A normally-exiting process returns its real output and exit code through
/// the off-pool wait path.
@Test("process returns output and exit code")
func processReturnsOutputAndExitCode() async throws {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/echo")
process.arguments = ["hello"]
let result = try await DataClient.runProcess(process, timeoutSeconds: 5, label: "echo hello")
#expect(result.exitCode == 0)
#expect(String(data: result.stdout, encoding: .utf8) == "hello\n")
}
/// Many NORMALLY-exiting processes, all at once, must every one complete
/// through the terminationHandler wait path. Guards against the wait path
/// leaking or wedging under concurrency (the production bug was the wait and
/// its timeout sharing one queue that saturated under sustained load).
@Test("many normal processes all complete")
func manyNormalProcessesAllComplete() async {
let count = 50
let codes = await withTaskGroup(of: Int32?.self) { group -> [Int32?] in
for _ in 0..<count {
group.addTask {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/echo")
process.arguments = ["ok"]
return try? await DataClient.runProcess(process, timeoutSeconds: 5, label: "echo ok").exitCode
}
}
var out: [Int32?] = []
for await code in group { out.append(code) }
return out
}
#expect(codes.count == count)
#expect(codes.allSatisfy { $0 == 0 },
"every concurrent process should exit 0 via the terminationHandler wait path")
}
/// #1117: the window bounds SILENCE, not runtime. A child that keeps talking
/// past its window runs to completion; the old fixed timeout killed it.
@Test("a chattering child outlives its window", .timeLimit(.minutes(1)))
func chatteringChildOutlivesItsWindow() async throws {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/sh")
process.arguments = ["-c", "for i in 1 2 3 4 5 6; do echo tick; sleep 0.4; done"]
let result = try await DataClient.runProcess(process, timeoutSeconds: 1, label: "chatty")
#expect(result.exitCode == 0, "a child emitting output every 0.4s must survive a 1s silence window")
}
/// The CLI's keepalive goes to STDERR, so stderr has to re-arm the watchdog
/// too - and must not then turn up as the error message.
@Test("stderr keepalives keep a stdout-silent child alive and stay out of the error text",
.timeLimit(.minutes(1)))
func stderrKeepalivesCountAsOutput() async throws {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/sh")
process.arguments = [
"-c",
#"for i in 1 2 3 4 5 6; do printf 'CODEBURN_PROGRESS {"kind":"keepalive"}\n' >&2; sleep 0.4; done; exit 3"#,
]
let result = try await DataClient.runProcess(process, timeoutSeconds: 1, label: "keepalive")
#expect(result.exitCode == 3, "stderr keepalives must restart the silence window")
#expect(result.stderr == "", "progress lines must never become the error message")
}
/// The async semaphore never lets more than its count run concurrently.
@Test("async semaphore caps concurrency")
func asyncSemaphoreCapsConcurrency() async {
let sem = AsyncSemaphore(2)
let peak = PeakCounter()
await withTaskGroup(of: Void.self) { group in
for _ in 0..<12 {
group.addTask {
await sem.acquire()
await peak.enter()
try? await Task.sleep(nanoseconds: 8_000_000)
await peak.leave()
await sem.release()
}
}
}
let observed = await peak.peak
#expect(observed <= 2, "semaphore should cap concurrency at 2, saw \(observed)")
#expect(observed > 0)
}
}
private func value(after flag: String, in args: [String]) -> String? {
guard let index = args.firstIndex(of: flag), args.indices.contains(index + 1) else {
return nil
}
return args[index + 1]
}
private actor PeakCounter {
private var current = 0
private(set) var peak = 0
func enter() { current += 1; peak = max(peak, current) }
func leave() { current -= 1 }
}