Sub-issue of #813. Audit area: the foundational crates that every higher-level crate depends on — fbuild-core, fbuild-paths, fbuild-config, fbuild-header-scan, fbuild-library-select.
Intro
The single highest-leverage change in this audit is in crates/fbuild-core/src/subprocess.rs. Every subprocess fbuild spawns — every compiler invocation, every linker call, every esptool / avrdude / addr2line / picotool / c++filt / clang-format / dumpversion probe — routes through run_command / run_command_with_stdin / run_command_passthrough. Today all three are synchronous wrappers around running_process::NativeProcess, whose start() + wait() calls block the calling thread. From inside the daemon (a tokio runtime) every one of these calls must go through spawn_blocking or risk starving the runtime — and most call sites today do not. Converting run_command to async fn and routing it through tokio::process::Command::output().await is the cascade event that unblocks tokio-console visibility (the #813 goal) across the entire compile pipeline: the daemon's HTTP handlers stop being interleaved with multi-second blocked threads, and every downstream callable becomes async fn by force.
There are 63 call sites of these three functions across 34 files (counted via grep -rE 'run_command(_with_stdin|_passthrough)?\(' crates/). They are concentrated in fbuild-build (compilers, linkers, per-platform orchestrators), fbuild-deploy (flashers), fbuild-daemon (emulator runners), and fbuild-packages (toolchain probes). All of them become async at the leaves of the call tree; the migration is mechanical but wide.
The run_command migration proposal
Today (sync, blocking, owns no runtime)
// crates/fbuild-core/src/subprocess.rs
pub fn run_command(
args: &[&str],
cwd: Option<&Path>,
env: Option<&[(&str, &str)]>,
timeout: Option<Duration>,
) -> Result<ToolOutput> {
let config = build_config(args, cwd, env, true, StdinMode::Null)?;
run_captured(config, args, timeout) // NativeProcess::start() + wait()
}
NativeProcess::start() spawns reader threads (a process-internal mini-runtime); wait(timeout) blocks until exit. From an async caller, the whole thing pegs a worker thread for the entire compile.
Proposed (async, uses caller's tokio runtime)
// crates/fbuild-core/src/subprocess.rs
pub async fn run_command(
args: &[&str],
cwd: Option<&Path>,
env: Option<&[(&str, &str)]>,
timeout: Option<Duration>,
) -> Result<ToolOutput> {
let mut cmd = tokio::process::Command::new(args[0]);
cmd.args(&args[1..]);
if let Some(d) = cwd { cmd.current_dir(d); }
apply_env(&mut cmd, args[0], env); // existing Windows PATH-prepend + MSYS-strip logic, lifted out of build_config
cmd.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
// Containment: re-uses the existing tokio path
crate::containment::tokio_spawn::configure(&mut cmd);
let mut child = cmd.spawn().map_err(|e| spawn_err(args, e.into()))?;
crate::containment::tokio_spawn::post_spawn(&child)?;
let fut = child.wait_with_output();
let output = match timeout {
Some(d) => match tokio::time::timeout(d, fut).await {
Ok(r) => r.map_err(|e| FbuildError::Other(format!("command {:?} failed: {}", args, e)))?,
Err(_) => {
// child.kill() needs &mut, but wait_with_output consumed it —
// use the alternate shape: spawn, then drive stdout/stderr/wait in select! with timeout
return Err(FbuildError::Timeout(format!("command timed out after {}s", d.as_secs())));
}
},
None => fut.await.map_err(|e| FbuildError::Other(format!("command {:?} failed: {}", args, e)))?,
};
Ok(ToolOutput {
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
exit_code: output.status.code().unwrap_or(-1),
})
}
Containment survives unchanged
crates/fbuild-core/src/containment.rs already has a tokio_spawn submodule with configure() + post_spawn() that does exactly what spawn_contained does for std::process::Command:
- Unix — installs
pre_exec(setpgid(0, 0) + PR_SET_PDEATHSIG(SIGKILL)) via command.pre_exec(...). tokio::process::Command re-exposes CommandExt::pre_exec, so the closure is identical to the std-path one.
- Windows —
post_spawn(&tokio::process::Child) reads child.raw_handle() and calls the same windows_job::assign(...) helper that spawn_contained uses. Same Job Object, same JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE flag, same kill-on-daemon-exit semantics.
In short, the Job Object / pgroup machinery already supports tokio. The migration is wire-only.
Cascade — caller-site count
grep -rE 'run_command(_with_stdin|_passthrough)?\(' crates/ | wc -l
# 63 occurrences across 34 files
Distribution (file → count):
fbuild-build/: ~22 sites (compiler, linker, per-platform orchestrators, symbol_analyzer, shrink probe, script_runtime, zccache embedded)
fbuild-deploy/: 6 sites (avr, lpc, esp32, teensy)
fbuild-daemon/: 3 sites (emulator runners, avr8js npm bootstrap)
fbuild-packages/: 4 sites (library compiler, disk cache budget)
fbuild-cli/: 6 sites (pio passthrough, daemon command, build)
fbuild-serial/: 1 site (crash decoder addr2line)
Every one of these becomes async fn once run_command is async fn. That's the goal of #813 — once propagation reaches the daemon's HTTP/WS handlers, they no longer hide blocked work behind spawn_blocking.
Suggested migration shape: land run_command_async as a parallel function first, migrate fbuild-build/src/compiler.rs + fbuild-build/src/linker.rs (the hot path), validate tokio-console no longer shows blocked workers, then sweep the rest and delete the sync versions. The _passthrough variant only has 6 sites and can be migrated last.
Findings table
| Severity |
Crate |
File:line |
Current pattern |
Proposed async replacement |
| CRITICAL |
fbuild-core |
subprocess.rs:81-89 run_command |
NativeProcess::new(...).start() + .wait(timeout) — blocking |
async fn run_command using tokio::process::Command::spawn + wait_with_output().await, containment via existing containment::tokio_spawn::{configure, post_spawn} |
| CRITICAL |
fbuild-core |
subprocess.rs:100-144 run_command_with_stdin |
Sync process.write_stdin(...) + .wait(...) |
async fn — use child.stdin.take().unwrap().write_all().await then child.wait_with_output().await. Tokio already drains stdout/stderr concurrently with the stdin write |
| CRITICAL |
fbuild-core |
subprocess.rs:151-174 run_command_passthrough |
Sync NativeProcess::start + wait |
async fn using inherited stdio + child.wait().await |
| HIGH |
fbuild-header-scan |
walker.rs:139 std::fs::read_to_string(p) inside par_iter() |
rayon parallel sync reads — one blocking read per source file, per LDF wave |
This is the per-compile hot path. Choices: (a) tokio::fs::read_to_string driven by futures::stream::iter(...).buffer_unordered(N), OR (b) keep rayon::par_iter (it's CPU+IO mixed — header tokenization is significant CPU work) and wrap the whole walk_with_state call in tokio::task::spawn_blocking at the async boundary. Recommend (b) — preserves rayon parallelism, just makes the boundary explicit |
| HIGH |
fbuild-library-select |
cache.rs:89,128-131,234-235,289 |
std::fs::read / std::fs::write / std::fs::rename for K/V cache |
tokio::fs::read / tokio::fs::write / tokio::fs::rename — called per-resolution and per-seed in cache_key construction. The seed-hash loop at cache.rs:231-241 reads every seed file synchronously to hash it; under tokio this should be futures::future::try_join_all over tokio::fs::read |
| MEDIUM |
fbuild-core |
response_file.rs:128,135,181 write_response_file |
std::fs::create_dir_all, std::fs::write, plus cleanup_stale_response_files walks read_dir synchronously |
tokio::fs::create_dir_all + tokio::fs::write. Called once per compile invocation (per-source-file) — same hot path as run_command, so worth migrating together. Cleanup loop is fine to keep sync inside spawn_blocking (rare path) |
| MEDIUM |
fbuild-config |
ini_parser/mod.rs:60 PlatformIOConfig::from_path |
std::fs::read_to_string |
async fn from_path → tokio::fs::read_to_string. Called once per build at boundary; lower priority but it's pure I/O and cheap to convert |
| MEDIUM |
fbuild-config |
board/loaders.rs:122 BoardConfig::from_boards_txt |
std::fs::read_to_string |
async fn + tokio::fs::read_to_string |
| MEDIUM |
fbuild-config |
board/db.rs:139 project-local board JSON read |
std::fs::read_to_string for <project>/boards/<id>.json |
tokio::fs::read_to_string. Called from get_board_defaults_with_project_dir per build |
| MEDIUM |
fbuild-config |
sdkconfig.rs:103,109 from_project_dir |
std::fs::read_to_string for sdkconfig / sdkconfig.defaults |
tokio::fs::read_to_string — called per ESP32 build |
| MEDIUM |
fbuild-paths |
lib.rs:184 read_port_from_file |
std::fs::read_to_string for daemon port discovery |
tokio::fs::read_to_string. Daemon-port discovery happens at every CLI invocation start — low-frequency but in the boot-time critical path |
| MEDIUM |
fbuild-core |
install_status.rs callback chain |
Sync Fn(InstallStatus) subscriber called from publisher thread; if subscriber blocks (e.g. acquiring a write lock or doing I/O), publisher blocks |
See dedicated section below |
| LOW |
fbuild-core |
subprocess.rs:53 link_env_for_build |
std::fs::create_dir_all for .lto-tmp |
tokio::fs::create_dir_all once caller is async. One-time-per-link, low cost — only worth converting alongside the larger sweep |
| LOW |
fbuild-core |
usb/data.rs:117,161 install_online_cache* |
std::fs::read_to_string / std::fs::read of USB vendor overlay JSON / proto.zstd |
One-shot startup config read for USB device naming. Out-of-scope per audit guidance, but listed for completeness — would become async if loaded from a network source in future |
| LOW |
fbuild-core |
emulator.rs:52 EmulatorArtifactBundle::from_build_dir |
std::fs::read_dir to discover firmware artifacts |
One-shot per emulator launch. tokio::fs::read_dir is straightforward but the call is rare and per-launch |
What is NOT a finding (already correct or out of scope)
containment.rs::spawn_contained — explicitly the sync std-path entry point for non-tokio callers (CLI, tests). Its tokio sibling tokio_spawn::spawn_contained already exists and is what the proposed run_command_async will use.
containment.rs Windows Job Object globals (JOB: OnceLock<JobHandle>) — process-lifetime singletons, not a sync I/O hazard.
board/db.rs::BOARD_DB: OnceLock<HashMap<...>> — populated from include_dir! (compile-time-embedded data, not runtime I/O). CPU-bound JSON parse on first access; correctly sync.
usb/embedded.rs::VENDOR_MAP: OnceLock<HashMap<u16, String>> — same: compile-time-embedded archive, sync extraction is correct.
symbol_analysis/ — pure in-memory graph analysis, no I/O.
- All
tests/ and #[cfg(test)] std::fs::* calls in fbuild-paths/lib.rs, fbuild-library-select, fbuild-header-scan/walker.rs, fbuild-core/{emulator,response_file,usb/data} — test fixtures, out of scope per audit guidance.
install_status callback chain (dedicated section)
crates/fbuild-core/src/install_status.rs is the publish/subscribe channel that drives the daemon's WebSocket install-progress stream and the CLI's TTY progress bar. The current shape:
type Subscriber = Arc<dyn Fn(InstallStatus) + Send + Sync + 'static>;
static SUBSCRIBER: OnceLock<RwLock<Option<Subscriber>>> = OnceLock::new();
pub fn set_install_status_subscriber<F>(subscriber: F)
where F: Fn(InstallStatus) + Send + Sync + 'static { ... }
pub fn publish_install_status(status: InstallStatus) {
// ... synchronously invokes the subscriber on the publisher's thread
subscriber(status);
}
Problem
The subscriber runs on whatever thread calls publish_install_status. Today the daemon's subscriber typically forwards events into a tokio::sync::broadcast channel via a try_send (non-blocking), which is fine — but the contract doesn't require the callback to be non-blocking, and historically subscribers have done synchronous lock acquisition. As we move toward async-everywhere this becomes a footgun: a future subscriber that does block_on(some_send) from inside publish_install_status would deadlock the same tokio worker that called the publisher. The single-slot RwLock<Option<Subscriber>> is also a fanout limitation — only one consumer can listen at a time.
Recommendation
Replace the subscriber callback with a tokio::sync::broadcast::Sender<InstallStatus> directly. Rationale:
- Subscribers are already (in practice) shaped like "forward into a channel" — making the channel the API removes the trampoline.
broadcast::Sender::send is non-blocking by design and returns the lagged-receiver count instead of blocking the publisher.
- Multiple async receivers (daemon WS handler, CLI progress bar, test observer) can subscribe independently — today only one subscriber slot exists, gated by
RwLock<Option<...>>.
- No
async fn callback signature is needed — the broadcast channel sidesteps the "async callback in a trait object" Rust pain entirely.
Proposed shape:
static INSTALL_STATUS_TX: OnceLock<tokio::sync::broadcast::Sender<InstallStatus>> = OnceLock::new();
pub fn subscribe_install_status() -> tokio::sync::broadcast::Receiver<InstallStatus> {
INSTALL_STATUS_TX
.get_or_init(|| tokio::sync::broadcast::channel(256).0)
.subscribe()
}
pub fn publish_install_status(status: InstallStatus) {
let tx = INSTALL_STATUS_TX.get_or_init(|| tokio::sync::broadcast::channel(256).0);
let _ = tx.send(status); // ignore RecvError: lagged subscribers are tolerated
}
Migration cost: the existing set_install_status_subscriber callers in the daemon and CLI become tokio::spawn loops over recv().await. Trivial migration, much cleaner contract.
Alternative (lower-cost, lower-payoff): keep Fn(InstallStatus) but stamp the documented contract as "must be non-blocking; send into a channel and return immediately." Subscriber discipline by docs only — strictly worse than the channel approach.
What was searched
- Crates audited (
crates/{fbuild-core,fbuild-paths,fbuild-config,fbuild-header-scan,fbuild-library-select}/src/**/*.rs)
- Patterns:
Command::new / .output() / .wait() — subprocess concerns (only matched inside subprocess.rs and containment.rs themselves; no rogue direct spawns in the foundational crates).
std::fs::(read|read_to_string|write|create_dir_all|read_dir|remove_file|rename|canonicalize|metadata) — sync file I/O.
OnceLock / OnceCell / Mutex::new / RwLock::new / mpsc:: — sync primitives where async equivalents exist (tokio::sync::*, OnceCell async-init).
std::thread::spawn / JoinHandle — manual thread spawning (none found in these crates; rayon is used by fbuild-header-scan instead).
block_on — async-from-sync (zero hits in these 5 crates; concentrated in fbuild-python, fbuild-packages/toolchain/*, which are out of scope for this audit area).
set_install_status_subscriber — the callback bridge (covered in dedicated section above).
- Caller-count grep for
run_command(_with_stdin|_passthrough)?\( across the whole workspace to size the cascade (63 sites / 34 files).
Out-of-scope notes — CPU-bound work to keep sync
crates/fbuild-header-scan/src/scanner.rs::scan() — pure tokenization of source text. CPU-bound. Stays sync; the caller (walker.rs::walk_with_state) can stay rayon-parallel and the whole walk gets wrapped in tokio::task::spawn_blocking at the async boundary.
crates/fbuild-config/src/board/db.rs::get_board_db() — JSON parse of compile-time-embedded board entries. CPU-bound. Already correctly OnceLock + sync.
crates/fbuild-core/src/usb/embedded.rs — zstd decompress + tar extract of embedded USB vendor archive. CPU-bound. Correctly sync.
crates/fbuild-core/src/symbol_analysis/ — in-memory ELF / call-graph analysis. CPU-bound. Correctly sync.
crates/fbuild-config/src/ini_parser/parser.rs — INI tokenizer + extends resolution. CPU-bound. The file read should be async (covered in findings table), but the parse itself stays sync.
Drop impls in running-process and containment.rs — must stay sync (Rust requires sync Drop).
- One-shot startup config reads in
fbuild-paths::read_port_from_file are arguably also "keep sync, just call from spawn_blocking at the boundary" — flagged MEDIUM rather than CRITICAL because they're not in the per-compile hot path.
Sub-issue of #813. Audit area: the foundational crates that every higher-level crate depends on —
fbuild-core,fbuild-paths,fbuild-config,fbuild-header-scan,fbuild-library-select.Intro
The single highest-leverage change in this audit is in
crates/fbuild-core/src/subprocess.rs. Every subprocess fbuild spawns — every compiler invocation, every linker call, everyesptool/avrdude/addr2line/picotool/c++filt/clang-format/dumpversionprobe — routes throughrun_command/run_command_with_stdin/run_command_passthrough. Today all three are synchronous wrappers aroundrunning_process::NativeProcess, whosestart()+wait()calls block the calling thread. From inside the daemon (a tokio runtime) every one of these calls must go throughspawn_blockingor risk starving the runtime — and most call sites today do not. Convertingrun_commandtoasync fnand routing it throughtokio::process::Command::output().awaitis the cascade event that unblocks tokio-console visibility (the #813 goal) across the entire compile pipeline: the daemon's HTTP handlers stop being interleaved with multi-second blocked threads, and every downstream callable becomesasync fnby force.There are 63 call sites of these three functions across 34 files (counted via
grep -rE 'run_command(_with_stdin|_passthrough)?\(' crates/). They are concentrated infbuild-build(compilers, linkers, per-platform orchestrators),fbuild-deploy(flashers),fbuild-daemon(emulator runners), andfbuild-packages(toolchain probes). All of them become async at the leaves of the call tree; the migration is mechanical but wide.The
run_commandmigration proposalToday (sync, blocking, owns no runtime)
NativeProcess::start()spawns reader threads (a process-internal mini-runtime);wait(timeout)blocks until exit. From an async caller, the whole thing pegs a worker thread for the entire compile.Proposed (async, uses caller's tokio runtime)
Containment survives unchanged
crates/fbuild-core/src/containment.rsalready has atokio_spawnsubmodule withconfigure()+post_spawn()that does exactly whatspawn_containeddoes forstd::process::Command:pre_exec(setpgid(0, 0) + PR_SET_PDEATHSIG(SIGKILL))viacommand.pre_exec(...).tokio::process::Commandre-exposesCommandExt::pre_exec, so the closure is identical to the std-path one.post_spawn(&tokio::process::Child)readschild.raw_handle()and calls the samewindows_job::assign(...)helper thatspawn_containeduses. Same Job Object, sameJOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSEflag, same kill-on-daemon-exit semantics.In short, the Job Object / pgroup machinery already supports tokio. The migration is wire-only.
Cascade — caller-site count
Distribution (file → count):
fbuild-build/: ~22 sites (compiler, linker, per-platform orchestrators, symbol_analyzer, shrink probe, script_runtime, zccache embedded)fbuild-deploy/: 6 sites (avr, lpc, esp32, teensy)fbuild-daemon/: 3 sites (emulator runners, avr8js npm bootstrap)fbuild-packages/: 4 sites (library compiler, disk cache budget)fbuild-cli/: 6 sites (pio passthrough, daemon command, build)fbuild-serial/: 1 site (crash decoder addr2line)Every one of these becomes
async fnoncerun_commandisasync fn. That's the goal of #813 — once propagation reaches the daemon's HTTP/WS handlers, they no longer hide blocked work behindspawn_blocking.Suggested migration shape: land
run_command_asyncas a parallel function first, migratefbuild-build/src/compiler.rs+fbuild-build/src/linker.rs(the hot path), validate tokio-console no longer shows blocked workers, then sweep the rest and delete the sync versions. The_passthroughvariant only has 6 sites and can be migrated last.Findings table
subprocess.rs:81-89run_commandNativeProcess::new(...).start() + .wait(timeout)— blockingasync fn run_commandusingtokio::process::Command::spawn+wait_with_output().await, containment via existingcontainment::tokio_spawn::{configure, post_spawn}subprocess.rs:100-144run_command_with_stdinprocess.write_stdin(...)+.wait(...)async fn— usechild.stdin.take().unwrap().write_all().awaitthenchild.wait_with_output().await. Tokio already drains stdout/stderr concurrently with the stdin writesubprocess.rs:151-174run_command_passthroughNativeProcess::start + waitasync fnusing inherited stdio +child.wait().awaitwalker.rs:139std::fs::read_to_string(p)insidepar_iter()rayonparallel sync reads — one blocking read per source file, per LDF wavetokio::fs::read_to_stringdriven byfutures::stream::iter(...).buffer_unordered(N), OR (b) keeprayon::par_iter(it's CPU+IO mixed — header tokenization is significant CPU work) and wrap the wholewalk_with_statecall intokio::task::spawn_blockingat the async boundary. Recommend (b) — preserves rayon parallelism, just makes the boundary explicitcache.rs:89,128-131,234-235,289std::fs::read/std::fs::write/std::fs::renamefor K/V cachetokio::fs::read/tokio::fs::write/tokio::fs::rename— called per-resolution and per-seed incache_keyconstruction. The seed-hash loop atcache.rs:231-241reads every seed file synchronously to hash it; under tokio this should befutures::future::try_join_allovertokio::fs::readresponse_file.rs:128,135,181write_response_filestd::fs::create_dir_all,std::fs::write, pluscleanup_stale_response_fileswalksread_dirsynchronouslytokio::fs::create_dir_all+tokio::fs::write. Called once per compile invocation (per-source-file) — same hot path asrun_command, so worth migrating together. Cleanup loop is fine to keep sync insidespawn_blocking(rare path)ini_parser/mod.rs:60PlatformIOConfig::from_pathstd::fs::read_to_stringasync fn from_path→tokio::fs::read_to_string. Called once per build at boundary; lower priority but it's pure I/O and cheap to convertboard/loaders.rs:122BoardConfig::from_boards_txtstd::fs::read_to_stringasync fn+tokio::fs::read_to_stringboard/db.rs:139project-local board JSON readstd::fs::read_to_stringfor<project>/boards/<id>.jsontokio::fs::read_to_string. Called fromget_board_defaults_with_project_dirper buildsdkconfig.rs:103,109from_project_dirstd::fs::read_to_stringforsdkconfig/sdkconfig.defaultstokio::fs::read_to_string— called per ESP32 buildlib.rs:184read_port_from_filestd::fs::read_to_stringfor daemon port discoverytokio::fs::read_to_string. Daemon-port discovery happens at every CLI invocation start — low-frequency but in the boot-time critical pathinstall_status.rscallback chainFn(InstallStatus)subscriber called from publisher thread; if subscriber blocks (e.g. acquiring a write lock or doing I/O), publisher blockssubprocess.rs:53link_env_for_buildstd::fs::create_dir_allfor.lto-tmptokio::fs::create_dir_allonce caller is async. One-time-per-link, low cost — only worth converting alongside the larger sweepusb/data.rs:117,161install_online_cache*std::fs::read_to_string/std::fs::readof USB vendor overlay JSON / proto.zstdemulator.rs:52EmulatorArtifactBundle::from_build_dirstd::fs::read_dirto discover firmware artifactstokio::fs::read_diris straightforward but the call is rare and per-launchWhat is NOT a finding (already correct or out of scope)
containment.rs::spawn_contained— explicitly the sync std-path entry point for non-tokio callers (CLI, tests). Its tokio siblingtokio_spawn::spawn_containedalready exists and is what the proposedrun_command_asyncwill use.containment.rsWindows Job Object globals (JOB: OnceLock<JobHandle>) — process-lifetime singletons, not a sync I/O hazard.board/db.rs::BOARD_DB: OnceLock<HashMap<...>>— populated frominclude_dir!(compile-time-embedded data, not runtime I/O). CPU-bound JSON parse on first access; correctly sync.usb/embedded.rs::VENDOR_MAP: OnceLock<HashMap<u16, String>>— same: compile-time-embedded archive, sync extraction is correct.symbol_analysis/— pure in-memory graph analysis, no I/O.tests/and#[cfg(test)]std::fs::*calls infbuild-paths/lib.rs,fbuild-library-select,fbuild-header-scan/walker.rs,fbuild-core/{emulator,response_file,usb/data}— test fixtures, out of scope per audit guidance.install_statuscallback chain (dedicated section)crates/fbuild-core/src/install_status.rsis the publish/subscribe channel that drives the daemon's WebSocket install-progress stream and the CLI's TTY progress bar. The current shape:Problem
The subscriber runs on whatever thread calls
publish_install_status. Today the daemon's subscriber typically forwards events into atokio::sync::broadcastchannel via atry_send(non-blocking), which is fine — but the contract doesn't require the callback to be non-blocking, and historically subscribers have done synchronous lock acquisition. As we move toward async-everywhere this becomes a footgun: a future subscriber that doesblock_on(some_send)from insidepublish_install_statuswould deadlock the same tokio worker that called the publisher. The single-slotRwLock<Option<Subscriber>>is also a fanout limitation — only one consumer can listen at a time.Recommendation
Replace the subscriber callback with a
tokio::sync::broadcast::Sender<InstallStatus>directly. Rationale:broadcast::Sender::sendis non-blocking by design and returns the lagged-receiver count instead of blocking the publisher.RwLock<Option<...>>.async fncallback signature is needed — the broadcast channel sidesteps the "async callback in a trait object" Rust pain entirely.Proposed shape:
Migration cost: the existing
set_install_status_subscribercallers in the daemon and CLI becometokio::spawnloops overrecv().await. Trivial migration, much cleaner contract.Alternative (lower-cost, lower-payoff): keep
Fn(InstallStatus)but stamp the documented contract as "must be non-blocking; send into a channel and return immediately." Subscriber discipline by docs only — strictly worse than the channel approach.What was searched
crates/{fbuild-core,fbuild-paths,fbuild-config,fbuild-header-scan,fbuild-library-select}/src/**/*.rs)Command::new/.output()/.wait()— subprocess concerns (only matched insidesubprocess.rsandcontainment.rsthemselves; no rogue direct spawns in the foundational crates).std::fs::(read|read_to_string|write|create_dir_all|read_dir|remove_file|rename|canonicalize|metadata)— sync file I/O.OnceLock/OnceCell/Mutex::new/RwLock::new/mpsc::— sync primitives where async equivalents exist (tokio::sync::*,OnceCellasync-init).std::thread::spawn/JoinHandle— manual thread spawning (none found in these crates; rayon is used byfbuild-header-scaninstead).block_on— async-from-sync (zero hits in these 5 crates; concentrated infbuild-python,fbuild-packages/toolchain/*, which are out of scope for this audit area).set_install_status_subscriber— the callback bridge (covered in dedicated section above).run_command(_with_stdin|_passthrough)?\(across the whole workspace to size the cascade (63 sites / 34 files).Out-of-scope notes — CPU-bound work to keep sync
crates/fbuild-header-scan/src/scanner.rs::scan()— pure tokenization of source text. CPU-bound. Stays sync; the caller (walker.rs::walk_with_state) can stay rayon-parallel and the whole walk gets wrapped intokio::task::spawn_blockingat the async boundary.crates/fbuild-config/src/board/db.rs::get_board_db()— JSON parse of compile-time-embedded board entries. CPU-bound. Already correctlyOnceLock+ sync.crates/fbuild-core/src/usb/embedded.rs— zstd decompress + tar extract of embedded USB vendor archive. CPU-bound. Correctly sync.crates/fbuild-core/src/symbol_analysis/— in-memory ELF / call-graph analysis. CPU-bound. Correctly sync.crates/fbuild-config/src/ini_parser/parser.rs— INI tokenizer +extendsresolution. CPU-bound. The file read should be async (covered in findings table), but the parse itself stays sync.Dropimpls inrunning-processandcontainment.rs— must stay sync (Rust requires syncDrop).fbuild-paths::read_port_from_fileare arguably also "keep sync, just call fromspawn_blockingat the boundary" — flagged MEDIUM rather than CRITICAL because they're not in the per-compile hot path.