-
Notifications
You must be signed in to change notification settings - Fork 1.3k
fix(cli): wait for the current turn to finish before an auto-update restart - #1258
fix(cli): wait for the current turn to finish before an auto-update restart #1258kavish-19 wants to merge 2 commits into
Conversation
...estart main() schedules checkForUpdates 100ms after spawning the binary. When it finds a newer version it stages the download and then unconditionally SIGTERMs (SIGKILL after 5s) the running process to install it -- with no way to know whether the user is mid-turn, because the wrapper is a separate process that only sees the child's exit event, not its React state. A download that lands a few seconds into a session therefore kills a turn that is still running, which is what CodebuffAI#994 reports. Adds a small cross-process signal in the spirit of the existing terminal-watchdog marker files: the binary writes an activity marker for the duration of a turn (subscribed once to the store's isChainInProgress, so every current and future call site is covered) and removes it when idle or on exit. The wrapper waits for that marker to clear before stopping the process for an update. Best-effort and bounded in both directions: a missing marker (already idle, an older binary that predates this file, a process that died without cleaning up) resolves immediately and preserves today's restart-right-away behavior, and a turn that never ends stops blocking the update after 10 minutes. Tests: three for the marker's write/remove/idempotence, three for waitForRunIdle's immediate, waits-then-clears, and gives-up-at-the-bound paths, plus the existing checkForUpdates source-order check extended to require the wait between staging and stopping. Confirmed red against the unfixed code, green after; the full cli suite shows the same 34 pre-existing failures before and after. Claude-Session: https://claude.ai/code/session_018vPhyqaaoKa8cgs7GEnyq5
codebuff-team
commented
Sep 4, 2026
Good diagnosis: checkForUpdates in launcher.js really does call stopRunningProcess unconditionally after staging, and the wrapper genuinely has no visibility into the child's internal state, so this is a real bug with a clear cause (issue #994).
The fix itself is sensible and cheap: a pid-named marker file toggled off isChainInProgress, polled by the wrapper with a bounded 10-minute fallback so a stuck turn can never permanently block an update. Subscribing once to the store instead of touching every site that sets isChainInProgress is the right layer for this — it stays correct as new call sites appear. Tests cover the marker's write/remove/no-duplicate-listener behavior and the three waitForRunIdle cases (no marker, clears mid-wait, gives up at the bound), which is more than most launcher changes in this repo get.
Two things worth thinking about before this lands for real, even if they don't block the idea:
- Race window:
waitForRunIdleis only called once, right after staging. If a turn starts between that check and the eventualstopRunningProcesscall, it still gets killed. Given staging can take a while this window isn't negligible. A loop that re-checks right before the actual kill (or checks isChainInProgress again just before SIGTERM) would close this. - Marker staleness on hard kill: if the binary dies via SIGKILL,
process.on('exit', clear)never runs and the marker leaks inos.tmpdir(). Pid reuse is rare but not impossible, and a leaked marker would falsely stall the next run's updates for up to 10 minutes. Worth stamping the marker with a start-time or session id you can also cross-check.
Neither is disqualifying — both degrade gracefully to "restart eventually happens" rather than silent breakage — but they're the kind of edge case a maintainer will ask about.
Review feedback on CodebuffAI#1258: if the binary dies via SIGKILL or a native crash, `process.on('exit', clear)` never runs and the marker outlives it in tmpdir. Reach that pid again and the leaked file stalls the new run's updates for the whole RUN_IDLE_MAX_WAIT_MS bound. Clear it at spawn rather than stamping the marker with an identity to cross-check. At the moment spawnInstalledBinary has the child's pid, the binary has not booted, let alone started a turn -- so a marker at that path is definitionally someone else's, and no session id is needed to tell the two apart. Claude-Session: https://claude.ai/code/session_018vPhyqaaoKa8cgs7GEnyq5
kavish-19
commented
Sep 4, 2026
Thanks — took both. Point 2 is fixed in 3940411; point 1 I think rests on a misread of the ordering, details below.
2. Marker staleness on hard kill — real, fixed.
Agreed: SIGKILL or a native crash means process.on('exit', clear) never runs and the marker outlives the process, so a reused pid stalls the next run's updates for the full bound.
I went with clearing at spawn rather than stamping an identity into the marker. At the point spawnInstalledBinary has child.pid, the binary has not booted, let alone started a turn — so any marker at that path is definitionally an earlier process's, and no session id or start-time is needed to tell them apart. One rmSync on a path we already compute, versus a write format plus a parse-and-compare on the wrapper side. Covered by three tests: the clear itself, the no-marker case, and a source-order assertion that spawnInstalledBinary clears after it has a pid and before it returns the child.
1. Race window — the ordering is the other way round.
The sequence in checkForUpdates is:
const stagedBinary = await stageBinary(...) // staging await waitForRunIdle(runningProcess.pid) // then wait term.clearLine() runningProcess.removeListener('exit', exitListener) await stopRunningProcess(runningProcess) // then stop
Staging happens before waitForRunIdle, not between it and the kill — so "given staging can take a while this window isn't negligible" doesn't apply. What actually sits in the gap is term.clearLine() and removeListener(): two synchronous calls, no await. The existing test in wrapper-safety.test.ts pins that order (stageIndex < waitIndex < stopIndex) precisely so a later edit can't reintroduce the window you're describing.
There is still a sub-millisecond TOCTOU gap, since these are separate processes and the user could hit Enter inside it. But that gap is irreducible without a handshake: re-checking immediately before SIGTERM moves the window, it doesn't close it, because the binary can always start a turn after the wrapper's last look. Closing it properly means the wrapper asking the binary to stop accepting turns and waiting for an ack — which is a bidirectional protocol, and this repo deliberately avoids pipes between these two processes.
For a bug whose current behavior is "kill unconditionally, mid-turn, every time", trading that for a sub-millisecond window seemed like the right amount of machinery. Happy to add the extra re-check if you'd still prefer it — it's cheap and harmless, I just don't want to claim it fixes something it doesn't.
Full suite is unchanged at 39 failures, identical to the pre-change baseline. tsc --noEmit clean on the touched files. launcher.js still fails prettier --check, but only on a pre-existing guard clause in checkForUpdates that fails on unmodified main too — my lines are clean, and I've left that one alone rather than bury the diff in an unrelated reformat.
Uh oh!
There was an error while loading. Please reload this page.
Fixes #994.
The bug
main()schedulescheckForUpdates100ms after spawning the binary. When it finds a newer version it stages the download and then unconditionally stops the running process to install it:The wrapper has no way to know whether the user is mid-turn — it's a separate process that only sees the child's exit event, not its state. So a download that lands a few seconds into a session kills a turn that is still running, which is exactly what the reporter describes: "once the download finishes, the CLI automatically restarts, even if I'm in the middle of a session."
The fix
A small cross-process signal, in the spirit of the marker files
terminal-watchdog.tsalready uses for the same kind of wrapper/binary coordination:cli/src/utils/run-activity-marker.ts): writes a marker file for the duration of a turn and removes it when idle or on exit. It subscribes once to the store'sisChainInProgress, so every current and future site that toggles that flag is covered without touching any of them. Named by the process's own pid, which the wrapper already has from spawning it — no handshake needed.cli/release-core/launcher.js):waitForRunIdle(pid)polls for that marker to clear, andcheckForUpdatesawaits it after staging and before stopping the process.Bounded and best-effort in both directions, so it can only ever delay a restart, never prevent one:
The staging download itself is unchanged and still happens up front; only the stop-and-swap waits.
Testing
New tests:
waitForRunIdle: returns immediately with no marker, waits and returns once the marker clears, and gives up atmaxWaitMswithout touching the marker.checkForUpdatessource-order check to require the wait between staging and stopping.Confirmed red against the unfixed code (all four new launcher assertions fail —
waitForRunIdledoesn't exist) and green after.Also ran the full
cli/srcsuite before and after: identical 34 pre-existing failures and 32 errors either way (they reproduce on unmodifiedmain— an OSC 52 clipboard test plus missing@types/react-dom/tarin the local environment), with 6 new passing tests and no regressions.tsc --noEmiton theclipackage reports nothing new in any touched file.