| CRITICAL |
crates/fbuild-packages/src/downloader.rs:113 |
reqwest::get(url).await (async) — primary toolchain/framework/library downloader |
reqwest::get builds a default Client with no connect/read/total timeout. A stalled CDN socket hangs forever and the retry loop above it never fires. Hit on every cache miss (toolchains, frameworks, libraries). |
Build a module-level OnceCell<reqwest::Client> with Client::builder().connect_timeout(Duration::from_secs(15)).timeout(Duration::from_secs(300)).build() and route both get_with_retry and open_with_retry through it. For the streaming path use a separate client without .timeout() but with read_timeout so the chunk loop self-trips on stalls. |
| CRITICAL |
crates/fbuild-packages/src/downloader.rs:178 |
reqwest::get(url).await (async, streaming progress variant open_with_retry) |
Same default-client-no-timeout bug. Worse here: once the response opens, chunk().await at L253 has no read timeout — a stalled mid-download will hang the entire toolchain install. |
Use the shared Client above with a read_timeout (reqwest 0.12 supports per-request .timeout() and a body read timeout via ClientBuilder::read_timeout). Alternative: wrap the while let Some(chunk) = stream.chunk().await in tokio::time::timeout(Duration::from_secs(60), stream.chunk()).await so a stuck socket fails the attempt; lift the retry loop in open_with_retry to cover body reads too. |
| CRITICAL |
crates/fbuild-packages/src/downloader.rs:253 |
stream.chunk().await reads a streaming body chunk-by-chunk inside download_file_with_progress |
The 15-second progress callback gives the operator a sign of life, but the await itself has no deadline — if the remote stops sending bytes, this future never wakes. |
Replace with match tokio::time::timeout(Duration::from_secs(60), stream.chunk()).await { Ok(Ok(Some(chunk))) => ..., Ok(Ok(None)) => break, Ok(Err(e)) => return Err(...), Err(_) => return Err(PackageError("body read stalled > 60s")) }. |
| HIGH |
crates/fbuild-packages/src/library/arduino_api.rs:51 |
reqwest::blocking::get(ARDUINO_API_URL) — fetches ArduinoCore-API submodule tarball synchronously from GitHub |
reqwest::blocking::get also uses a default client with no timeout. Called from Arduino-core install paths (megaavr, renesas) — typically inside the install thread, but still on a CRITICAL surface (first build for those boards). |
Replace with a lazy reqwest::blocking::Client built via Client::builder().connect_timeout(...).timeout(Duration::from_secs(120)).build(). Same module-level OnceCell pattern. |
| HIGH |
crates/fbuild-packages/src/library/arduino_api.rs:63 |
response.bytes() (blocking) reading entire GitHub archive body |
Inherits the no-timeout client. Body read can stall after a successful 200 response. |
Covered by the client-level .timeout() once the Client above is wired up — .timeout() on the blocking client is a total per-request deadline including body read. |
| HIGH |
crates/fbuild-packages/src/toolchain/clang.rs:147 |
reqwest::get(&url).await fetching the clang manifest from Espressif's S3 bucket |
No timeout. Failure mode: clang toolchain (used by clang-tidy/iwyu) hangs on first use; daemon thread blocked. |
Route through the shared client introduced for downloader.rs. |
| HIGH |
crates/fbuild-packages/src/toolchain/clang.rs:163 |
resp.json().await parses the manifest body |
The body read after a successful response status has no deadline. |
Covered by the per-request .timeout() once the shared client is in use. |
| HIGH |
crates/fbuild-packages/src/library/registry.rs:24 |
reqwest::get(&url).await — search_library on api.registry.platformio.org/v3/search |
No timeout. Library resolution is on the critical-path of every first build that uses a registry library. |
Use the shared client. |
| HIGH |
crates/fbuild-packages/src/library/registry.rs:32 |
response.json().await — registry search body parse |
Body-read tail of the same call; no read deadline. |
Covered by shared client's .timeout(). |
| HIGH |
crates/fbuild-packages/src/library/registry.rs:77 |
reqwest::get(&url).await — resolve_library GET v3/packages/{owner}/library/{name} |
No timeout. Same risk profile as search_library. |
Use shared client. |
| HIGH |
crates/fbuild-packages/src/library/registry.rs:89 |
response.json().await — package-details JSON body |
Body-read tail. |
Covered by shared client's .timeout(). |
| HIGH |
crates/fbuild-packages/src/library/registry.rs:189 |
reqwest::get(&url).await — resolve_library_via_search fallback |
No timeout. |
Use shared client. |
| HIGH |
crates/fbuild-packages/src/library/registry.rs:196 |
response.json().await — fallback search body |
Body-read tail. |
Covered by shared client's .timeout(). |
| MEDIUM |
crates/fbuild-packages/src/install_lock.rs:46-119 |
acquire_install_lock_at poll-loop waiting for a peer install to finish |
No wall-clock guard for the waiter. The only escape is INSTALL_LOCK_STALE_AFTER = 2 * 60 * 60 = 7200 s, checked against the lock directory's mtime. If a peer holds the lock while wedged on a no-timeout reqwest::get (see CRITICAL findings above), every other process waits up to 2 hours before recovery kicks in — perceived as a daemon hang. |
Once the CRITICAL reqwest fixes land the 2 h ceiling matches the worst-case legit install, but expose a shorter ceiling via env var for CI (where 2 h is greater than the job timeout). Also publish wait-elapsed in the install-status payload so the daemon can surface it. |
| LOW |
crates/fbuild-packages/src/toolchain/avr.rs:365 |
reqwest::get(&url).await inside #[test] #[ignore] fn test_download_and_verify_checksum |
Test-only (#[ignore]), exercised manually. Already inside a tokio::runtime::Runtime::new() block. |
No fix required; listed for completeness so a future auditor doesn't re-flag it. |
Sub-issue of #802. Area audited:
crates/fbuild-packages/— URL-based package downloads (toolchains, frameworks, libraries from GitHub + PlatformIO registry), archive extraction, install-lock arbitration, and per-platform framework downloaders.Intro
fbuild-packagesis the dominant network surface in the workspace. Every cache-miss for a toolchain (xtensa-esp-elf,arm-none-eabi-gcc,avr-gcc,riscv32-unknown-elf,clang,esp-qemu, ...), every framework download (ESP32 Arduino, ESP8266, ArduinoCore-API submodule), and every library resolved againstapi.registry.platformio.orgflows through this crate. Downloads are tracked towarddl.registry.platformio.org,github.com/<org>/archive/refs/..., anddl.espressif.com— all of which periodically degrade or rate-limit. The crate'sdownloader.rsdoes have an attempt-level retry loop (MAX_ATTEMPTS=3with 1s/3s backoff), but the per-request HTTP client isreqwest::get/reqwest::blocking::get, which constructs an anonymous default client with NO connect timeout, NO read timeout, NO total timeout. A slow CDN can hang a single attempt indefinitely; the retry loop never fires because the prior attempt never returns.Combined with the install-lock waiter (250 ms poll, 2-hour stale guard) downstream of the download, a single stuck download on a first-build behind a flaky network can wedge the entire toolchain install for hours before the stale-lock recovery path triggers. This is exactly the daemon-side hang vector #802 is hunting.
Findings
crates/fbuild-packages/src/downloader.rs:113reqwest::get(url).await(async) — primary toolchain/framework/library downloaderreqwest::getbuilds a defaultClientwith no connect/read/total timeout. A stalled CDN socket hangs forever and the retry loop above it never fires. Hit on every cache miss (toolchains, frameworks, libraries).OnceCell<reqwest::Client>withClient::builder().connect_timeout(Duration::from_secs(15)).timeout(Duration::from_secs(300)).build()and route bothget_with_retryandopen_with_retrythrough it. For the streaming path use a separate client without.timeout()but withread_timeoutso the chunk loop self-trips on stalls.crates/fbuild-packages/src/downloader.rs:178reqwest::get(url).await(async, streaming progress variantopen_with_retry)chunk().awaitat L253 has no read timeout — a stalled mid-download will hang the entire toolchain install.Clientabove with aread_timeout(reqwest 0.12 supports per-request.timeout()and a body read timeout viaClientBuilder::read_timeout). Alternative: wrap thewhile let Some(chunk) = stream.chunk().awaitintokio::time::timeout(Duration::from_secs(60), stream.chunk()).awaitso a stuck socket fails the attempt; lift the retry loop inopen_with_retryto cover body reads too.crates/fbuild-packages/src/downloader.rs:253stream.chunk().awaitreads a streaming body chunk-by-chunk insidedownload_file_with_progressmatch tokio::time::timeout(Duration::from_secs(60), stream.chunk()).await { Ok(Ok(Some(chunk))) => ..., Ok(Ok(None)) => break, Ok(Err(e)) => return Err(...), Err(_) => return Err(PackageError("body read stalled > 60s")) }.crates/fbuild-packages/src/library/arduino_api.rs:51reqwest::blocking::get(ARDUINO_API_URL)— fetchesArduinoCore-APIsubmodule tarball synchronously from GitHubreqwest::blocking::getalso uses a default client with no timeout. Called from Arduino-core install paths (megaavr, renesas) — typically inside the install thread, but still on a CRITICAL surface (first build for those boards).reqwest::blocking::Clientbuilt viaClient::builder().connect_timeout(...).timeout(Duration::from_secs(120)).build(). Same module-levelOnceCellpattern.crates/fbuild-packages/src/library/arduino_api.rs:63response.bytes()(blocking) reading entire GitHub archive body.timeout()once theClientabove is wired up —.timeout()on the blocking client is a total per-request deadline including body read.crates/fbuild-packages/src/toolchain/clang.rs:147reqwest::get(&url).awaitfetching the clang manifest from Espressif's S3 bucketdownloader.rs.crates/fbuild-packages/src/toolchain/clang.rs:163resp.json().awaitparses the manifest body.timeout()once the shared client is in use.crates/fbuild-packages/src/library/registry.rs:24reqwest::get(&url).await—search_libraryonapi.registry.platformio.org/v3/searchcrates/fbuild-packages/src/library/registry.rs:32response.json().await— registry search body parse.timeout().crates/fbuild-packages/src/library/registry.rs:77reqwest::get(&url).await—resolve_libraryGETv3/packages/{owner}/library/{name}search_library.crates/fbuild-packages/src/library/registry.rs:89response.json().await— package-details JSON body.timeout().crates/fbuild-packages/src/library/registry.rs:189reqwest::get(&url).await—resolve_library_via_searchfallbackcrates/fbuild-packages/src/library/registry.rs:196response.json().await— fallback search body.timeout().crates/fbuild-packages/src/install_lock.rs:46-119acquire_install_lock_atpoll-loop waiting for a peer install to finishINSTALL_LOCK_STALE_AFTER = 2 * 60 * 60 = 7200 s, checked against the lock directory's mtime. If a peer holds the lock while wedged on a no-timeoutreqwest::get(see CRITICAL findings above), every other process waits up to 2 hours before recovery kicks in — perceived as a daemon hang.reqwestfixes land the 2 h ceiling matches the worst-case legit install, but expose a shorter ceiling via env var for CI (where 2 h is greater than the job timeout). Also publish wait-elapsed in the install-status payload so the daemon can surface it.crates/fbuild-packages/src/toolchain/avr.rs:365reqwest::get(&url).awaitinside#[test] #[ignore] fn test_download_and_verify_checksum#[ignore]), exercised manually. Already inside atokio::runtime::Runtime::new()block.Notes on the workspace-level reqwest config
Workspace
Cargo.tomlpinsreqwest = { version = "0.12", default-features = false, features = ["json", "blocking", "stream", "rustls-tls"] }. There is no shared client construction helper — every site calls the barereqwest::get/reqwest::blocking::getfree functions. The fix is one shared helper module (e.g.fbuild-packages/src/http_client.rs) exposingpub fn shared_async() -> &'static reqwest::Clientandpub fn shared_blocking() -> &'static reqwest::blocking::Client, both built with timeouts andUser-Agent: fbuild/<version>. Re-export fromlib.rsso the toolchain and library modules can drop theirreqwest::getcalls without churning every call site shape.What was searched
Grep on
crates/fbuild-packages/src/:reqwest::— every call site (caught the 9reqwest::get/reqwest::blocking::getcallers above)Client::default/Client::builder/ClientBuilder— zero hits (no shared client anywhere)\.timeout\(— zero hits in the crate\.bytes\(\)/\.text\(\)/\.copy_to\(/bytes_stream/\.chunk\(\)— to find streaming body readsflate2::/tar::/zip::/xz2::/bzip2::/zstd::/Archive::new/GzDecoder|XzDecoder|BzDecoder|ZstdDecoder— confirmed extraction reads from on-disk file, not from a network stream (extract happens afterdownload_filehas written the full body)Command::new/\.output\(\)/\.wait\(\)/\.spawn\(\)/\.status\(\)— no subprocess invocations infbuild-packages(extraction is pure-Rust)JoinHandle/\.join\(\)— one hit inlibrary_compiler.rs(rayon scope joining local compile workers, not a network surface)\.recv\(\)/recv_timeout/mpsc::/crossbeam— no matches (no channel-based pipeline lives in this crate; the parallel install pipeline audit: blocking operations with no timeout (meta) #802 refers to lives infbuild-build)loop {/thread::sleep/std::thread::sleep— caught theinstall_lockpoll loop (above) and the per-worker compile loop (local CPU work, not blocking I/O)\.head\(/Method::HEAD/reqwest::Client— no HEAD probes anywhereOut-of-scope notes
pipelinemodule infbuild-packages; the orchestrating pipeline lives infbuild-build. That belongs to a separate sub-issue.disk_cache/— GC and lease management are purely local fs operations, no network or unbounded waits.lnk/— link-time descriptor materialization. Local fs / zip work; no network surface beyond delegating downloads back throughdownloader.rs(already covered).esp32_framework/,avr_framework.rs,cmsis_framework.rs,esp32_platform.rs, etc.) — all delegate tocrate::downloader::download_file{,_with_progress}. Fixing the shared client indownloader.rsfixes all of them transitively.extractor.rs— reads fromstd::fs::File, not from a network stream. The download-to-disk-then-extract sequence is the correct shape; no finding here.library_compiler.rs:259handle.join().unwrap()— joins rayon worker threads running local C/C++ compiles. Not a network surface; covered by per-process timeouts that belong infbuild-build.Tagging #802.