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
|
Good diagnosis: The fix itself is sensible and cheap: a pid-named marker file toggled off Two things worth thinking about before this lands for real, even if they don't block the idea:
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
|
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 I went with clearing at spawn rather than stamping an identity into the marker. At the point 1. Race window — the ordering is the other way round. The sequence in const stagedBinary = await stageBinary(...) // staging
await waitForRunIdle(runningProcess.pid) // then wait
term.clearLine()
runningProcess.removeListener('exit', exitListener)
await stopRunningProcess(runningProcess) // then stopStaging happens before 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. |
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.