Problem
When the fbuild CLI is force-killed (Ctrl+C, SIGKILL from an AI agent, terminal close), the daemon continues running the in-flight build to completion. This leaves zombie compiler/linker subprocesses running, holds the per-project lock, and blocks subsequent builds for the same project.
Repro:
- Start a long build via
fbuild build ... or fbuild deploy ....
- Kill the CLI process (
taskkill /F /PID <cli> on Windows, kill -9 <cli> on Unix).
- Observe:
gcc / clang / linker subprocesses keep running under the daemon. A new CLI invocation for the same project blocks on the still-held project lock until the orphaned build finishes naturally.
Root cause (investigation)
There is no cancellation infrastructure anywhere on the build path.
-
CLI -> daemon: DaemonClient::build_streaming (crates/fbuild-cli/src/daemon_client.rs:281-370) POSTs to /api/build and consumes a long-lived application/x-ndjson stream. One HTTP request, no separate cancel channel, no job ID.
-
Daemon handler: handlers::operations::build (crates/fbuild-daemon/src/handlers/operations/build.rs:99-762) acquires ctx.project_lock, then tokio::spawns an inner build_task that runs the platform orchestrator (build.rs:386-389). The spawned JoinHandle is detached from the HTTP request lifetime — only the 60-minute hard deadline aborts it (build.rs:399).
-
Disconnect detection: The streaming body is built with axum::body::Body::from_stream(stream::unfold(async_rx, ...)) (build.rs:569-579). When hyper drops the body, async_rx is dropped, but the build worker's let _ = async_tx.send(...) calls silently swallow SendError and keep going. Nothing signals the orchestrator.
-
Subprocesses: All compiler/linker invocations go through fbuild_core::subprocess::run_command (crates/fbuild-core/src/subprocess.rs:151) which uses tokio::process::Command via tokio_spawn::spawn_contained. kill_on_drop(true) is never set. Containment (Windows Job Object / Linux PR_SET_PDEATHSIG) only kills children when the daemon itself dies — daemon stays alive, so children stay alive.
-
DaemonContext (crates/fbuild-daemon/src/context.rs:128-190) has no active_builds map, no CancellationToken storage, no /api/cancel route exists.
Workspace grep for CancellationToken, kill_on_drop, /api/cancel: zero hits on the build path.
Why CLI kill currently does NOT propagate
SIGKILL CLI -> TCP socket closes -> hyper drops body stream -> async_rx dropped -> next async_tx.send(...) in worker returns Err -> let _ = swallows it -> orchestrator keeps .awaiting wait_with_output() on every subprocess -> processes are contained-but-not-kill_on_drop -> they run to completion -> next build queues behind held project_lock.
Fix
Minimal correct fix, in priority order:
-
Add per-build tokio_util::sync::CancellationToken stored in DaemonContext as active_builds: DashMap<String, CancellationToken> keyed by request_id. Insert at the start of build() in build.rs; remove via Drop guard.
-
Detect client disconnect via body drop: Replace the stream::unfold body (build.rs:569-579) with a wrapper whose Drop impl calls token.cancel(). When hyper drops the response body, the wrapper drops, cancellation fires. Axum has no built-in disconnect callback; body Drop is the canonical signal.
-
tokio::select! the build task against the token so when cancellation fires we build_task.abort() immediately instead of waiting for the orchestrator's next await point.
-
Plumb the token into BuildParams (crates/fbuild-build/src/lib.rs:155-211) so parallel::compile_sources_parallel (crates/fbuild-build/src/parallel.rs:53) can tokio::select! cancellation against child.wait() inside subprocess::wait_and_capture (subprocess.rs:390).
-
Set kill_on_drop(true) on every tokio::process::Command constructed in subprocess::build_command (subprocess.rs:455-508). Combined with (4), this guarantees that when cancellation ends an in-flight .await, dropping the child handle terminates the OS process.
-
(Optional) /api/cancel/{request_id} route + CLI Ctrl+C handler enhancement (fbuild-cli/src/cli/dispatch.rs:50-54) so explicit Ctrl+C can fire a best-effort cancel POST before exiting, as a fallback for platforms where body-drop propagation is slow.
Steps 1-3 alone cover the AI-agent SIGKILL case (the orchestrator's JoinHandle gets abort()ed and all currently-awaited subprocesses get dropped — with (5) they get killed; without (5) they'd only get killed when the daemon dies). Steps 4-5 make subprocess termination immediate rather than depending on Drop.
Acceptance
- Kill the CLI mid-build. Within ~500ms, all child compiler processes for that build are gone (
Get-Process gcc,clang,ld | Where-Object {...} empty on Windows; pgrep -f empty on Unix).
- The per-project lock is released so the next
fbuild build for the same project starts immediately.
- Test: spawn
fbuild build, kill CLI, immediately spawn a second fbuild build for same project; the second should start within 1 second.
Problem
When the
fbuildCLI is force-killed (Ctrl+C, SIGKILL from an AI agent, terminal close), the daemon continues running the in-flight build to completion. This leaves zombie compiler/linker subprocesses running, holds the per-project lock, and blocks subsequent builds for the same project.Repro:
fbuild build ...orfbuild deploy ....taskkill /F /PID <cli>on Windows,kill -9 <cli>on Unix).gcc/clang/ linker subprocesses keep running under the daemon. A new CLI invocation for the same project blocks on the still-held project lock until the orphaned build finishes naturally.Root cause (investigation)
There is no cancellation infrastructure anywhere on the build path.
CLI -> daemon:
DaemonClient::build_streaming(crates/fbuild-cli/src/daemon_client.rs:281-370) POSTs to/api/buildand consumes a long-livedapplication/x-ndjsonstream. One HTTP request, no separate cancel channel, no job ID.Daemon handler:
handlers::operations::build(crates/fbuild-daemon/src/handlers/operations/build.rs:99-762) acquiresctx.project_lock, thentokio::spawns an innerbuild_taskthat runs the platform orchestrator (build.rs:386-389). The spawnedJoinHandleis detached from the HTTP request lifetime — only the 60-minute hard deadline aborts it (build.rs:399).Disconnect detection: The streaming body is built with
axum::body::Body::from_stream(stream::unfold(async_rx, ...))(build.rs:569-579). When hyper drops the body,async_rxis dropped, but the build worker'slet _ = async_tx.send(...)calls silently swallowSendErrorand keep going. Nothing signals the orchestrator.Subprocesses: All compiler/linker invocations go through
fbuild_core::subprocess::run_command(crates/fbuild-core/src/subprocess.rs:151) which usestokio::process::Commandviatokio_spawn::spawn_contained.kill_on_drop(true)is never set. Containment (Windows Job Object / LinuxPR_SET_PDEATHSIG) only kills children when the daemon itself dies — daemon stays alive, so children stay alive.DaemonContext(crates/fbuild-daemon/src/context.rs:128-190) has noactive_buildsmap, noCancellationTokenstorage, no/api/cancelroute exists.Workspace grep for
CancellationToken,kill_on_drop,/api/cancel: zero hits on the build path.Why CLI kill currently does NOT propagate
SIGKILL CLI -> TCP socket closes -> hyper drops body stream ->
async_rxdropped -> nextasync_tx.send(...)in worker returnsErr->let _ =swallows it -> orchestrator keeps.awaitingwait_with_output()on every subprocess -> processes are contained-but-not-kill_on_drop-> they run to completion -> next build queues behind heldproject_lock.Fix
Minimal correct fix, in priority order:
Add per-build
tokio_util::sync::CancellationTokenstored inDaemonContextasactive_builds: DashMap<String, CancellationToken>keyed byrequest_id. Insert at the start ofbuild()in build.rs; remove via Drop guard.Detect client disconnect via body drop: Replace the
stream::unfoldbody (build.rs:569-579) with a wrapper whoseDropimpl callstoken.cancel(). When hyper drops the response body, the wrapper drops, cancellation fires. Axum has no built-in disconnect callback; body Drop is the canonical signal.tokio::select!the build task against the token so when cancellation fires webuild_task.abort()immediately instead of waiting for the orchestrator's nextawaitpoint.Plumb the token into
BuildParams(crates/fbuild-build/src/lib.rs:155-211) soparallel::compile_sources_parallel(crates/fbuild-build/src/parallel.rs:53) cantokio::select!cancellation againstchild.wait()insidesubprocess::wait_and_capture(subprocess.rs:390).Set
kill_on_drop(true)on everytokio::process::Commandconstructed insubprocess::build_command(subprocess.rs:455-508). Combined with (4), this guarantees that when cancellation ends an in-flight.await, dropping the child handle terminates the OS process.(Optional)
/api/cancel/{request_id}route + CLI Ctrl+C handler enhancement (fbuild-cli/src/cli/dispatch.rs:50-54) so explicit Ctrl+C can fire a best-effort cancel POST before exiting, as a fallback for platforms where body-drop propagation is slow.Steps 1-3 alone cover the AI-agent SIGKILL case (the orchestrator's
JoinHandlegetsabort()ed and all currently-awaited subprocesses get dropped — with (5) they get killed; without (5) they'd only get killed when the daemon dies). Steps 4-5 make subprocess termination immediate rather than depending on Drop.Acceptance
Get-Process gcc,clang,ld | Where-Object {...}empty on Windows;pgrep -fempty on Unix).fbuild buildfor the same project starts immediately.fbuild build, kill CLI, immediately spawn a secondfbuild buildfor same project; the second should start within 1 second.