fix(app): give the mutation-restart and destroy paths the same SIGTERM grace

A settings mutation replaces the resident serve child, which may be mid-write:
the same lock hazard a timed-out read has, so it gets the same treatment.
restartAfterMutation and destroy now go through killGracefully instead of
SIGKILL. reapAll's one-shot loop stays a hard kill and now says why - quit has a
1.5s flush budget, shorter than the grace, so waiting one out would wedge the
quit, and a one-shot read holds no lock worth releasing.
This commit is contained in:
iamtoruk 2026-08-22 09:26:29 -07:00
parent d4b7b48dc8
commit d8bd428054
3 changed files with 38 additions and 4 deletions

View file

@ -33,7 +33,7 @@
- **The snap asks for the log directories it reads, not each tool's whole home.** The first Snap Store submission declared a `personal-files` read of every AI tool's root — `$HOME/.claude`, `$HOME/.codex`, `$HOME/.cursor` and the rest — and that interface is recursive, so it granted read of every credential file those roots hold. Each entry now names the subdirectory the provider actually opens (`.claude/projects`, `.codex/sessions`, `.cline/data`, `.vibe/logs/session`, `.dsh/sessions`, `.kiro/sessions`, `.quickwork/{profiles.json,sessions,metrics}`, `.config/Claude/local-agent-mode-sessions`, `.config/Open Design/{runs,data/runs,namespaces}`), two are single files (`.forge/.forge.db`, `.zcode/cli/db/db.sqlite`), and the editor entries name only the extension folders holding transcripts instead of the editor's whole configuration. Five providers that were missing entirely and would have shown no data are declared — opencode, crush, goose, kilo, kimi-code — and four roots stay roots only because the file the provider opens sits directly in them (`.config/github-copilot`, `.local/share/{opencode,crush,kilo}`). One credential file is now requested openly rather than implicitly: `.claude/.credentials.json`, read-only, for the live plan gauge. Codex's equivalent would need write access to the Codex CLI's own `auth.json` to rotate the token, so neither it nor a Codex root is declared and the Codex live gauge is disabled under `$SNAP`; Codex usage and cost are unaffected, they come from the session rollouts. Two consequences inside the snap: `.lingtai` is dropped, because its per-agent log directory needs a wildcard the interface has no form for, and `optimize`, `context-budget` and `act` no longer see the user-scope `~/.claude/settings.json`, `agents/`, `skills/` and `commands/` — project-scope copies still work through the `home` plug. Nothing outside the snap changes.
### Fixed (Desktop & Menubar)
- **A long panel query is no longer killed for being slow.** The desktop app capped every read at 45 seconds of TOTAL runtime, so on a slow machine `optimize`, `yield`, `models`, `sessions`, `spend`, `audit`, `act report` and `plan` were SIGKILLed mid-parse and the panel painted a red "timed out" that a 60-second poll then reproduced forever. That cap is now a no-output watchdog: the window restarts on every byte the child writes, so only a genuinely silent child times out, and every read spawn sets `CODEBURN_PROGRESS=1` so a multi-minute parse heartbeats through it (a 15-minute absolute ceiling still catches a livelocked child). The resident `codeburn serve` requests follow the same rule, resetting on each frame of their own response. Alongside it: the cold-cache floor now covers EVERY read while the first hydration is still running, not just the overview, so a section that starts polling the moment the app is ready is not killed waiting behind that parse; a timed-out read is killed with SIGTERM first and SIGKILL only after a 5-second grace, letting a mid-write parse publish its partial cache and release the refresh lock instead of leaving both stale; and a read that times out while the hydration is still going keeps the indexing splash instead of painting an error panel. The app also records the resident child's pid and reaps a serve orphaned by a previous crash on the next launch, after confirming the pid still belongs to a codeburn serve.
- **A long panel query is no longer killed for being slow.** The desktop app capped every read at 45 seconds of TOTAL runtime, so on a slow machine `optimize`, `yield`, `models`, `sessions`, `spend`, `audit`, `act report` and `plan` were SIGKILLed mid-parse and the panel painted a red "timed out" that a 60-second poll then reproduced forever. That cap is now a no-output watchdog: the window restarts on every byte the child writes, so only a genuinely silent child times out, and every read spawn sets `CODEBURN_PROGRESS=1` so a multi-minute parse heartbeats through it (a 15-minute absolute ceiling still catches a livelocked child). The resident `codeburn serve` requests follow the same rule, resetting on each frame of their own response. Alongside it: the cold-cache floor now covers EVERY read while the first hydration is still running, not just the overview, so a section that starts polling the moment the app is ready is not killed waiting behind that parse; a read killed for timing out, and a resident child replaced by a settings mutation, are both sent SIGTERM first and SIGKILL only after a 5-second grace, letting a mid-write parse publish its partial cache and release the refresh lock instead of leaving both stale (quit stays a hard kill, since its flush budget is shorter than the grace); and a read that times out while the hydration is still going keeps the indexing splash instead of painting an error panel. The app also records the resident child's pid and reaps a serve orphaned by a previous crash on the next launch, after confirming the pid still belongs to a codeburn serve.
- **The menubar's copies of your Claude and Codex credentials move out of Application Support and into the login Keychain.** Connecting a provider used to leave the copied OAuth material in `~/Library/Application Support/CodeBurn/*-credentials.v1.json`, written world-readable (0644) because macOS ignores `.completeFileProtection` outside iOS. The copy now lives in a CodeBurn-owned login-Keychain item, and the first read after upgrading migrates the old file: it is reopened with `O_NOFOLLOW`, refused if it is a symlink or not owned by you, repaired to 0600 before a single secret byte is read, written to the Keychain, read back and compared, and only then unlinked — a failed or unverified write leaves the (now 0600) file in place so a retry can still find it, and the next read retries the cleanup. Where both a Keychain item and an old file exist, the one that expires later wins before anything is removed, so an item left behind by a much older build cannot displace a fresher token. Claude's entry no longer stores a refresh token at all — the CLI owns that grant and the menubar never spends it — and any refresh token in a historical blob is dropped on read. Disconnect only reports success once the material is actually gone; if the delete fails it says so and leaves the provider connected so you can retry. Keychain reads are non-interactive and are skipped outright while the login Keychain is locked, so a background quota refresh can never raise an unlock panel. (#1037)
- **First launch no longer asks to control System Events.** The macOS menubar registered its login item by driving System Events over AppleScript, which made macOS put up an Automation consent dialog the first time the app ran. It now registers itself through `SMAppService.mainApp`, an in-process call that needs no Automation grant; there is no AppleScript fallback, so a failure logs and leaves the login item unset rather than bringing the prompt back. The same `codeburn.loginItemRegistered` guard still limits this to the first launch, so a login item you removed by hand stays removed. (#1026)
- **The resident `codeburn serve` child.** The first real panel request is also the cache warm-up, so startup never runs an artificial warm-up query beside a duplicate one-shot child; each served command carries its own read-only option allowlist, and anything outside it falls back to a normal spawn; the child exits when its stdin closes, so it can never outlive the app. Requests whose response exceeds the 16 MiB frame limit still replace the child, but that deliberate kill no longer spends the resident's unexpected-death budget. (#972)

View file

@ -940,6 +940,34 @@ describe('resident serve single-flight', () => {
expect(readMaybe(files.actionsFile)).toBe('a')
})
it('SIGTERMs the outgoing resident on a mutation restart instead of hard-killing it', async () => {
// A settings mutation replaces a child that may be mid-write. Same lock
// hazard as a timeout, so it gets the same grace: SIGKILL here strands the
// cache refresh lock the outgoing parse is holding.
const signalFile = join(dir, 'restart-signals')
fakeBin(
'sigterm-aware-resident.js',
`const fs = require('node:fs'); const readline = require('node:readline');
const command = process.argv[2];
if (command === 'serve') {
process.on('SIGTERM', () => { fs.appendFileSync(${JSON.stringify(signalFile)}, 'TERM'); process.exit(0); });
const rl = readline.createInterface({ input: process.stdin });
rl.on('line', line => {
const request = JSON.parse(line);
process.stdout.write(JSON.stringify({ id: request.id, ok: true, output: JSON.stringify({ via: 'serve' }) }) + '\\n');
});
} else {
process.stdout.write('currency updated');
}`,
)
startServe()
await expect(spawnCli(['status'], { timeoutMs: 5_000 })).resolves.toEqual({ via: 'serve' })
await expect(spawnCliAction(['currency', 'EUR'], { timeoutMs: 5_000 })).resolves.toMatchObject({ ok: true })
await waitFor(() => readMaybe(signalFile) === 'TERM')
})
it('preserves the unexpected-death budget across mutation restarts', async () => {
const startsFile = join(dir, 'serve-starts')
const oneShotsFile = join(dir, 'one-shot-reads')

View file

@ -123,6 +123,10 @@ function releaseSlot(): void {
function reapAll(): void {
serveClient?.destroy()
serveClient = null
// Deliberately harder than every other kill path: quit has a 1.5s flush budget,
// shorter than the SIGTERM grace, so waiting one out would just wedge the quit.
// One-shot reads hold no lock worth releasing; the resident child (destroyed
// above) does, and gets the grace.
for (const child of activeChildren) child.kill('SIGKILL')
activeChildren.clear()
// A queued waiter has no child to reap, so releaseSlot never fires for it;
@ -629,9 +633,11 @@ class ServeClient {
const child = this.child
if (child) {
// This is an intentional replacement, not a crash. Detach first so the
// later exit event cannot consume the unexpected-death budget.
// later exit event cannot consume the unexpected-death budget. The
// outgoing child may be mid-write, so it gets the same SIGTERM grace a
// timed-out one does — a hard kill here strands the refresh lock.
this.onDeath(child, false)
child.kill('SIGKILL')
killGracefully(child)
}
this.start()
}
@ -692,7 +698,7 @@ class ServeClient {
const child = this.child
if (!child) return
this.onDeath(child, false)
child.kill('SIGKILL')
killGracefully(child)
}
}