Repository navigation
Description
Sub-issue of #802.
Intro
Audit of crates/fbuild-cli/ and crates/fbuild-python/ for blocking operations without a wall-clock deadline. These two crates are the user-facing entry points: every interactive CLI invocation and every FastLED Python integration funnels through them, so an unbounded blocking op here translates to a hang in front of the user with no signal that anything is wrong.
Overall picture is good — the main DaemonClient in fbuild-cli/src/daemon_client.rs has per-endpoint .timeout(...) calls on every HTTP request, the broker / daemon-spawn poll loops are bounded to 100 iterations of 100 ms (= 10 s wall-clock cap), and outcome::send_op{,_async} in fbuild-python forward a caller-supplied timeout to reqwest. The findings below are the spots that fell out of that pattern.
Findings
| Severity | File:line (crate) | Operation | Why unbounded | Suggested fix |
|---|---|---|---|---|
| CRITICAL | crates/fbuild-cli/src/daemon_client.rs:265-271 + 582-604 (fbuild-cli) |
DaemonClient::build() calling post_operation("/api/build", req, None) |
post_operation only attaches .timeout(...) when the caller passes Some(_); build() passes None on purpose ("can legitimately wait behind another client's global package/toolchain install"). With no client-side bound the API/MCP build path waits forever if the daemon stalls. Note the matching streaming build_streaming() does have LONG_OPERATION_TIMEOUT (1800 s), so this is the non-streaming API/MCP path specifically. |
Apply LONG_OPERATION_TIMEOUT (1800 s) as a hard ceiling like the streaming variant does. The "wait behind a global install" rationale still tolerates a 30-minute cap. |
| HIGH | crates/fbuild-python/src/daemon.rs:339-344 (fbuild-python) |
Daemon::stop() — sync POST to /api/daemon/shutdown |
reqwest::blocking::Client::new().post(&url).headers(...).send() — no .timeout(...). Python callers of fbuild.Daemon.stop() hang forever if the daemon accepts the connection but never replies (e.g. mid-flush). The async AsyncDaemon::stop at line 509 has Duration::from_secs(10) — the sync sibling is the outlier. |
Add .timeout(std::time::Duration::from_secs(10)) to match the async surface. |
| HIGH | crates/fbuild-python/src/daemon.rs:347-362 (fbuild-python) |
Daemon::status() — reqwest::blocking::get(&url) + resp.text() |
Bare reqwest::blocking::get has no per-request timeout and no client builder, and the follow-up resp.text() is also unbounded. fbuild.Daemon.status() is what Python orchestrators poll — a hung daemon stalls every FastLED is_daemon_up() check. The async AsyncDaemon::status at line 394 uses .timeout(Duration::from_secs(10)) — same shape, just bring the sync one in line. |
Build a reqwest::blocking::Client once with .timeout(Duration::from_secs(10)), reuse for .get() + .text(). |
| HIGH | crates/fbuild-python/src/daemon.rs:291-295 (fbuild-python) |
Daemon::ensure_running() fast-path health probe |
reqwest::blocking::get(&url) immediately before spawning — no timeout. If the daemon socket accepts but stalls (rare but possible during shutdown), Daemon.ensure_running() blocks Python startup forever. |
Wrap with reqwest::blocking::Client::builder().timeout(Duration::from_secs(5)).build() so the fast path matches the async variant at line 451-460. |
| HIGH | crates/fbuild-python/src/daemon.rs:325-332 (fbuild-python) |
Daemon::ensure_running() post-spawn health poll |
Inside for _ in 0..100 { thread::sleep(100ms); reqwest::blocking::get(&url) ... }. The outer loop is bounded to ~10 s, but each individual reqwest::blocking::get(&url) has no timeout. A single slow-but-accepting socket can blow past the 10 s budget unbounded. |
Same fix — use a Client built once with a small (e.g. 1 s) per-request timeout so the wall-clock cap is actually 10 s. |
| HIGH | crates/fbuild-python/src/daemon.rs:193-201 (fbuild-python) |
ensure_running_via_broker_blocking() health poll |
Same pattern as above: thread::sleep(100ms) + reqwest::blocking::get(url) with no per-request timeout. Async sibling at line 235-247 properly uses .timeout(Duration::from_secs(5)). |
Build a sync reqwest::blocking::Client with per-request timeout. |
| HIGH | crates/fbuild-python/src/serial_monitor.rs:77-84 (fbuild-python) |
SerialMonitor.__enter__ — rt.block_on(tokio_tungstenite::connect_async(&ws_url)) |
WebSocket connect has no timeout wrap. If the daemon accepts the TCP socket but never completes the WebSocket handshake, every with SerialMonitor(...) in FastLED hangs forever — this is the primary binding FastLED depends on (CLAUDE.md PyO3 consumer contract). |
Wrap with tokio::time::timeout(Duration::from_secs(5), tokio_tungstenite::connect_async(&ws_url)). |
| HIGH | crates/fbuild-python/src/serial_monitor.rs:98-114 (fbuild-python) |
__enter__ initial attach: rt.block_on(write.send(...)) + rt.block_on(read.next()) |
After the WS connect succeeds, the attach send + the wait for Attached reply have no timeout. A daemon that hung mid-handshake (or a desync between WsSink/WsSource) wedges __enter__ forever. |
Wrap both block_ons in tokio::time::timeout(Duration::from_secs(5), ...) and map elapsed errors to PyConnectionError. |
| HIGH | crates/fbuild-python/src/async_serial_monitor.rs:100-138 (fbuild-python) |
AsyncSerialMonitor::__aenter__ — connect_async + attach send + attach read |
Mirror of the sync issue above on the native-async path. The handshake write.send(...).await and the attach-reply read.next().await are both unbounded — the daemon could accept the WS upgrade and then never respond. |
Same fix — tokio::time::timeout(Duration::from_secs(5), ...) on each await. |
| HIGH | crates/fbuild-cli/src/daemon_client.rs:399-419 (fbuild-cli) |
DaemonClient::shutdown() |
.post(...) then .send() — no .timeout(...). Used by ensure_direct_daemon_running() at line 777 when the binary is stale; if shutdown stalls, the auto-restart path is wedged. (The CLI daemon stop subcommand at daemon_cmd.rs:14-28 also calls this, so fbuild daemon stop can hang.) |
Add .timeout(std::time::Duration::from_secs(10)). |
| MEDIUM | crates/fbuild-cli/src/cli/clang_tools.rs:283-292, 537-546 (fbuild-cli) |
IWYU / clang-tidy parallel fan-out: tokio::process::Command::new(tool).output().await |
No per-process timeout. A wedged clang-tidy / IWYU subprocess hangs the entire pipeline forever (diagnostic subcommands run in-process per crates/CLAUDE.md "Diagnostic subcommand exception"). | Wrap each cmd.output().await with tokio::time::timeout(Duration::from_secs(120), ...); on elapsed, cmd.kill() and surface a per-file failure. |
| LOW | crates/fbuild-cli/src/cli/daemon_cmd.rs:558, 573, 602 (fbuild-cli) |
kill_process / find_daemon_pids -> subprocess::run_command(&["taskkill"|"kill"|"pgrep"|"tasklist"], ..., None) |
run_command accepts Option<Duration> but every call here passes None. A wedged tasklist/pgrep would hang fbuild daemon list/kill-all. |
Pass Some(Duration::from_secs(10)) — these are quick OS utilities. |
| LOW | crates/fbuild-cli/src/cli/build.rs:13 (fbuild-cli) |
open_in_browser -> run_command(&["cmd", "/c", "start", ...], None, None, None) |
start / xdg-open / open return fast in practice, but the call has no timeout. A wedged shell would hang --launch-browser after build. |
Pass Some(Duration::from_secs(10)). |
| LOW | crates/fbuild-cli/src/cli/pio.rs:11 (fbuild-cli) |
find_pio -> run_command(&[locator, "pio"], None, None, None) |
where / which should be instant but is not bounded. Pure compatibility path. |
Pass Some(Duration::from_secs(5)). |
Out-of-finding patterns confirmed safe
- All 14 named endpoints on
DaemonClient(health,health_full,list_devices,daemon_info,lock_status,device_status,device_lease,device_release,device_preempt,clear_locks,cache_stats,run_gc,deploy,monitor,test_emu,build_streaming) all carry.timeout(...). TheClientitself also has a 100 ms.connect_timeout(...)to fail fast when the daemon isn't running. fbuild-python/src/outcome.rs(send_op/send_op_async) forwards a caller-suppliedtimeout: f64toreqwest; bothDaemonConnectionandAsyncDaemonConnectiondefault to 1800 s for build/deploy/monitor — caller-tunable, properly bounded.fbuild-python/src/json_rpc.rs(read_lines_async/wait_for_remote_json_rpc_response_async/write_async): everysource.next()is wrapped intokio::time::timeout, and the outerwhile Instant::now() < deadlineenforces a wall-clock cap.fbuild-cli/src/daemon_client.rs::ensure_daemon_running(broker + direct paths): bothfor _ in 0..100 { sleep(100ms); ... }loops are wall-clock bounded to 10 s; the direct path also has a[0.0s, 0.5s, 2.0s]backoff over 3 attempts.fbuild-cli/src/cli/show.rs:39-57(tail -ffollow loop) is intentionally infinite (user Ctrl+C exit) — not a finding.fbuild-cli/src/mcp/server.rsstdin-driven loop is an interactive server lifecycle, not a hung-op risk.fbuild-cli/src/cli/serial_probe.rs:250(while Instant::now() < deadline) is properly bounded.
What was searched
Greps under crates/fbuild-cli/src/ and crates/fbuild-python/src/:
reqwest::/Client::default/Client::builder/\.timeout\(\.send\(\)/\.recv\(\)/connect_async/TcpStream::connectloop \{/while/for .* in/thread::sleep/tokio::time::sleepCommand::new/\.output\(\)/\.wait\(\)/\.spawn\(\)pyo3/pyo3_async_runtimes/block_onMutex::lock/RwLock/\.lock\(\)daemon_client/connect_daemon/spawn_daemon_process/ensure_daemon_running/ensure_running_via_broker_*- file-by-file read of
daemon_client.rs,serial_monitor.rs,async_serial_monitor.rs,daemon.rs,outcome.rs,json_rpc.rs,daemon_cmd.rs,show.rs,clang_tools.rs,port_scan.rs,serial_probe.rs,build.rs,pio.rs.
Out-of-scope notes
- The
running_process::broker::{BrokerSession,AsyncBrokerSession}::adopt(...)call in bothfbuild-cli/src/daemon_client.rs:701andfbuild-python/src/daemon.rs:191, 232is provided by an external crate (zackees/running-process) and is not part of this audit area. Ifadopt()itself can hang, that is an upstream concern and should be filed againstrunning-process. tokio_tungstenite::connect_asyncultimately reaches intotokio::net::TcpStream::connect— this audit treats theconnect_asyncsite as the boundary because that is where this crate's code can applytokio::time::timeout.fbuild_core::subprocess::run_commanditself accepts anOption<Duration>and already implements the timeout-and-kill protocol correctly — findings here are about call sites that passNone, not about the helper.
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsTriage
Activity
fix(timeout): close #802 audit family — workspace-wide timeouts on bl…
zackees commentedon Jun 29, 2026
Closed by PR #843 (workspace-wide timeout sweep).