fix(daemon): cancel in-flight build when CLI disconnects (#853) - #854
Conversation
Force-killing the fbuild CLI mid-build left the daemon worker detached: hyper dropped the response body, but `let _ = async_tx.send` silently swallowed the `SendError` and the orchestrator kept awaiting subprocesses to completion — holding the project lock and producing zombie compiler processes that queued every subsequent build for the same project behind them. Two coordinated changes: 1. `crates/fbuild-core/src/containment.rs` — set `kill_on_drop(true)` on every `tokio::process::Command` routed through `tokio_spawn::spawn_contained`. Daemon-death containment (Job Object / pdeathsig) only kills children when the daemon itself exits; for in-daemon cancellation we need tokio-level drop-kill so that aborting the build task's `JoinHandle` actually terminates the OS subprocesses it was awaiting. 2. `crates/fbuild-daemon/src/handlers/operations/build.rs` — wire a `tokio::sync::Notify` between the streaming response body and the build worker. A `CancelOnDrop` guard bundled into the `stream::unfold` body state fires `notify_waiters()` when hyper drops the body (client disconnect). The build worker's loop is restructured from `tokio::time::timeout(&mut build_task)` to a biased `tokio::select!` that races the cancel signal against build-task completion and a heartbeat tick — on cancel it `build_task.abort()`s, drains the JoinHandle so all child Drops run (and kill_on_drop fires), then returns a clean cancellation error. A `fired_normal_terminal` flag suppresses the guard on normal completion so a clean client read-then-disconnect isn't misread as a cancel. Tests cover both guard arms (fires on body-drop, silent after normal terminal event). Fixes #853
|
Caution Review failedThe pull request is closed. ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (2)
📝 WalkthroughWalkthroughAdds subprocess Client-Disconnect Build Cancellation
Sequence Diagram(s)sequenceDiagram
participant CLI
participant Hyper
participant StreamBodyState
participant Worker
participant BuildTask
participant Subprocess
CLI->>Hyper: TCP disconnect / force-kill
Hyper->>StreamBodyState: drop response body
StreamBodyState->>Worker: CancelOnDrop::drop → Notify::notify_waiters
Worker->>BuildTask: abort()
BuildTask->>Subprocess: Child dropped → kill_on_drop kills OS process
BuildTask-->>Worker: JoinHandle resolved
Worker-->>Worker: release project_lock
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related PRs
Poem
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Summary
Fixes #853 — force-killing the fbuild CLI mid-build no longer leaves zombie compiler subprocesses or a wedged project lock.
Two coordinated changes:
crates/fbuild-core/src/containment.rs— setkill_on_drop(true)on everytokio::process::Commandrouted throughtokio_spawn::spawn_contained. Daemon-death containment (Job Object on Windows,PR_SET_PDEATHSIGon Linux) only fires when the daemon itself exits; for in-daemon cancellation (CLI disconnect → body drop → build-task abort), we need tokio-level drop-kill so aborting the orchestrator'sJoinHandleactually terminates the in-flight subprocesses it was awaiting.crates/fbuild-daemon/src/handlers/operations/build.rs— wire atokio::sync::Notifybetween the streaming response body and the build worker. ACancelOnDropguard bundled into thestream::unfoldbody state firesnotify_waiters()when hyper drops the body (the canonical client-disconnect signal in axum — there's no built-in disconnect callback). The build worker's loop changes fromtokio::time::timeout(&mut build_task)to a biasedtokio::select!that races the cancel signal against build-task completion and the heartbeat tick. On cancel itbuild_task.abort()s, drains the JoinHandle so all child Drops run (andkill_on_dropfires), then returns a clean cancellation error. Afired_normal_terminalatomic flag suppresses the guard on normal completion so a clean client read-then-disconnect isn't misread as a cancel.Why these two together
Either change alone is insufficient:
kill_on_drop→ orchestrator'sJoinHandleaborts and drops the in-flightChildhandle, but the OS process keeps running (only daemon death kills it).kill_on_dropwithout a cancel signal → nothing ever drops theChildbecause the build worker is detached from the HTTP request lifetime; the only abort path was the 60-min hard deadline.Test plan
soldr cargo check -p fbuild-daemon -p fbuild-core --all-targets— cleansoldr cargo clippy -p fbuild-daemon -p fbuild-core --all-targets -- -D warnings— cleancancel_on_drop_fires_when_not_completed— guard firesnotify_waiterswhen body drops mid-build (deferred to CI — local Windows MSVC linker env was wedged at the time of authorship)cancel_on_drop_silent_when_completed— guard suppressed afterfired_normal_terminalset (same)fbuild build <large-project>,kill -9the CLI, verify compiler subprocesses gone within ~500ms and nextfbuild buildfor same project starts immediately (acceptance criteria from Daemon doesn't cancel in-flight builds when CLI is force-killed (zombie build processes) #853)fired_normal_terminalarm)🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes