| CRITICAL |
avr.rs:123 |
run_command(&args_ref, None, None, Some(timeout))? — sync subprocess spawn for the full avrdude flash (up to timeout_secs, board JSON default 60s) |
run_command_async(...).await after Deployer::deploy becomes async fn |
avrdude |
| CRITICAL |
lpc.rs:217 |
run_command(&args_ref, ..., Some(Duration::from_secs(self.timeout_secs)))? — sync lpc21isp flash (default 60s) |
run_command_async(...).await |
lpc21isp |
| CRITICAL |
esp32/deployer.rs:330 |
run_command(&args_ref, ..., Some(Duration::from_secs(30)))? — sync esptool verify-flash (~6-10s) |
run_command_async(...).await |
esptool |
| CRITICAL |
esp32/deployer.rs:671 |
run_command(&args_ref, ..., Some(Duration::from_secs(120)))? — sync esptool write-flash for SelectiveFlash (~5-30s) |
run_command_async(...).await |
esptool |
| CRITICAL |
esp32/deployer.rs:746 |
run_command(&args_ref, ..., Some(Duration::from_secs(120)))? — sync esptool write-flash for FullFlash (~5-60s) |
run_command_async(...).await |
esptool |
| CRITICAL |
teensy/flash.rs:122 |
run_command(&args_ref, None, None, Some(attempt_timeout))? inside a retry loop of up to retries + 1 attempts — blocks one worker for (retries+1) * flash_timeout_secs worst case (default 6 × 30s = 3 min) |
run_command_async(...).await; surrounding loop becomes async-aware |
teensy_loader_cli |
| HIGH |
teensy/flash.rs:167 |
std::thread::sleep(Duration::from_millis(backoff_ms)) between retry attempts (default 1500 ms) |
tokio::time::sleep(...).await |
(loop control) |
| HIGH |
lib.rs:122-132 |
Deployer::post_deploy_recovery default impl: 3-second sync poll loop using serialport::new(...).open() + std::thread::sleep(Duration::from_millis(100)) |
Async port-availability probe + tokio::time::sleep(...).await |
(USB re-enum) |
| HIGH |
teensy/port_discovery.rs:97-124 |
wait_for_new_cdc_port polls serialport::available_ports() with std::thread::sleep(100ms) until timeout — called post-flash to discover the re-enumerated CDC ACM port (budget post_flash_port_discovery_secs, default 5s) |
tokio::task::spawn_blocking per snapshot + tokio::time::sleep(...).await between polls |
(USB re-enum) |
| HIGH |
teensy/halfkay_probe.rs:42-55 |
wait_for_disappearance polls available_ports() with std::thread::sleep(75ms) — 3s blocking after baud-134 trigger to confirm device left CDC class |
Same: blocking enumerate + tokio::time::sleep(...).await between polls |
(HID enum) |
| HIGH |
esp32_native/write.rs:88-148 |
espflash::flasher::Flasher::connect(...) + per-region flasher.write_bin_to_flash(...) are fully sync; entire native write path runs on a single spawn_blocking worker today |
Keep sync (espflash 4.x has no async API) but invoke each region inside its own tokio::task::spawn_blocking, so tokio-console sees per-region nested tasks under the parent deploy span |
espflash (native) |
| HIGH |
esp32_native/verify.rs:60-110 |
Same as write: sync Flasher::connect + sync verify, all on one blocking worker |
Wrap in spawn_blocking per call site after trait goes async |
espflash (native) |
| MEDIUM |
lib.rs:95-103 |
Deployer::deploy trait method is sync fn |
#[async_trait] async fn deploy(...) -> Result<DeploymentResult> — cascades to 4 production impls + 2 test impls + crates/fbuild-daemon/src/handlers/operations/deploy.rs:418 (drop spawn_blocking) |
(trait) |
| MEDIUM |
lib.rs:121-135 |
Deployer::post_deploy_recovery trait method is sync fn; daemon also wraps it in spawn_blocking (fbuild-daemon/.../deploy.rs:772) |
async fn post_deploy_recovery(...) -> Result<()> with body using tokio::time::sleep and an async port-availability probe; drop the daemon spawn_blocking |
(trait) |
| MEDIUM |
reset.rs:72,96,101,127,129 |
std::thread::sleep(Duration::from_millis(50-100)) in DTR/RTS toggle sequences for ESP32/AVR/Teensy/generic reset paths |
tokio::time::sleep(...).await after wrapping the serialport write calls in spawn_blocking (serialport has no async write_data_terminal_ready) |
(DTR/RTS) |
| MEDIUM |
reset.rs:64-69,84-108,117-129 |
serialport::new(port, ...).open() + sync write_data_terminal_ready / write_request_to_send calls for device reset |
Wrap port open + control-line writes in spawn_blocking (no async alternative in serialport), interleave with tokio::time::sleep for the sleep gaps |
(DTR/RTS) |
| MEDIUM |
teensy/first_byte_probe.rs:44-77 |
serialport::open + sync Read loop with 100ms internal serial.read() timeout for up to first_byte_timeout_secs (default 10s); runs post-flash |
Use tokio_serial::SerialStream for the open + tokio::io::AsyncReadExt::read with tokio::time::timeout; or keep spawn_blocking wrap with explicit tokio::time::sleep between polls |
(CDC byte probe) |
| MEDIUM |
teensy/soft_reboot.rs:40-69 |
serialport::new(port, 134).open() + std::thread::sleep(Duration::from_millis(100)) for baud-134 trigger |
tokio_serial::SerialStream::open or wrap open in spawn_blocking + tokio::time::sleep(...).await for the 100 ms hold |
(baud-134) |
| MEDIUM |
esp32_native/write.rs:69-76, esp32_native/verify.rs:60-65 |
serialport::new(port, 115_200).flow_control(...).timeout(...).open_native() — sync open_native() call to obtain the handle espflash will consume |
Cannot become async (espflash needs the sync TTYPort handle); leave but make the surrounding call inside a spawn_blocking from within an async fn so the rest of the deploy can proceed |
espflash (native) |
| MEDIUM |
esp32_native/write.rs:109, esp32_native/verify.rs:102 |
std::fs::read(&r.path)? for firmware/bootloader/partitions region bytes — can be 1-3 MB per region |
tokio::fs::read(&r.path).await (issued before entering the spawn_blocking that owns the espflash Flasher) |
(file I/O) |
| MEDIUM |
esp32/image.rs:36,56,260 |
std::fs::read(firmware_path)?, std::fs::read(elf_path)?, std::fs::File::open(input_path)? — used in QEMU-specific image patching + flash-image assembly |
tokio::fs::* once the calling deploy method is async; patch loop itself stays sync (CPU-bound) |
(QEMU image prep) |
| MEDIUM |
esp32/image.rs:46-50,243-260 |
std::fs::OpenOptions::new().write(true).open(...) + seek + write_all for the assembled flash image |
tokio::fs::OpenOptions + tokio::io::AsyncWriteExt (small overhead but consistent with the runtime) |
(QEMU image prep) |
| LOW |
size_check.rs:65 |
std::fs::metadata(artifact) (one stat call before deploy) |
tokio::fs::metadata(artifact).await for consistency once the entry point is async; impact is negligible |
(pre-deploy size guard) |
| LOW |
esp32/qemu.rs:63,66 |
std::fs::create_dir_all(parent)? + std::fs::File::create(output_path)? for QEMU image output |
tokio::fs::* for consistency |
(QEMU image output) |
Intro
This is the
fbuild-deployslice of the whole-app async migration meta trackedin #813 — the goal is to put every flash-tool subprocess, every USB
re-enumeration poll, and the post-deploy port-recovery loop on the daemon's
shared tokio runtime so tokio-console sees them and so a single shutdown signal
cancels them. This audit COMPLEMENTS #804 (the "blocking operations with no
timeout" audit for the same crate): every flash-tool spawn already has a
timeout via
fbuild_core::subprocess::run_command, but the spawn itself issynchronous and runs on a
spawn_blockingworker thread today, not on thetokio runtime. The two fixes compose; the ideal end state is "async with
deadline".
The end state for
fbuild-deployis:Deployer::deploy(...)returns anasync fnfuture, every flash tool is invoked throughtokio::process::Command::output().await, every poll loop istokio::time::sleep, and the daemon'scrates/fbuild-daemon/src/handlers/operations/deploy.rsdrops thetokio::task::spawn_blockingwrappers it currently uses to bridge to thesync trait (
deploy.rs:418fordeploy(...),deploy.rs:772forpost_deploy_recovery). Thosespawn_blockingcalls are the smoking gunthat the entire crate is sync.
The
Deployertrait questioncrates/fbuild-deploy/src/lib.rs:95-136defines a syncpub trait Deployerwith
fn deploy(...) -> Result<DeploymentResult>plusfn post_deploy_recovery(&self, port: &str) -> Result<()>whose default implis a 3-second
serialport::new(...).open()poll withstd::thread::sleep(100ms)between probes.Should this become
async fn? Yes — that's the highest-leverage singlechange in this crate.
Pros:
deploy.rs:418-748wraps the entiredeployer.deploy(...)call in
tokio::task::spawn_blocking, which means the work is invisible totokio-console (a single non-tokio worker, no per-region task spans, no
hierarchical trace context). Making the trait async lets every flash region
write be a
tokio::taskwhose name shows up in the console.spawn_blockingbudget pressure (default tokio blocking pool is 512threads, but a stuck flash tool holds one for ~60 s, and 4-8 simultaneous
flashes on a CI matrix would each block one).
teensy_loader_cliretry loop (
teensy/flash.rs:90-175) —spawn_blockingis uncancellable.Switching to
tokio::process::Command::output().awaitmakes the futurecancellable, so the daemon's
tokio::sync::watchshutdown signal collapsesin-flight deploys cleanly.
post_deploy_recovery's 3s syncserialport::openretry +std::thread::sleeppoll currently runs on a blocking thread; an asyncversion with
tokio::time::sleepwould yield to other handlers betweenprobes.
write_bin_to_flashcalls inside the native espflash path(
esp32_native/write.rs:108-148) could each be aspawn_blockingtask witha parent span — observable in tokio-console as nested work units.
Cons:
(
Esp32Deployer/esp32/deployer.rs:709,AvrDeployer/avr.rs:86,LpcDeployer/lpc.rs:165,TeensyDeployer/teensy/mod.rs:148), plus theObservableDeployerandDefaultRecoveryDeployertest types inlib.rs:199-247, all need to becomeasync fn. Total: 4 production implsasync_traitvs native: the crate already depends onasync-traitviaCargo.toml:28. Either route is viable; native
async fnin traits isstable as of 1.75 (MSRV 1.94.1 satisfies it). Object-safety: the daemon
uses
Box<dyn Deployer>(seedeploy.rs:424,deploy.rs:651-652,lib.rs:253), which nativeasync fnin traits does not currently makeobject-safe — so
async-traitis the realistic choice here unless weswitch the daemon to a non-dyn dispatch.
espflash 4.xexposesFlasher::write_bin_to_flash(...)as a fully sync API atopserialport::open_native(seeesp32_native/write.rs:88-148andesp32_native/verify.rs:60-110). It cannot itself become async — thecalls inside an
async fn deploy(...)must remain wrapped intokio::task::spawn_blocking. That's fine: this audit only asks "couldthis be async on the runtime?" — for espflash the honest answer is "wrap
the sync call in
spawn_blockingfrom inside the async deploy method,"which still gives tokio-console a parent task to attach the work to.
fbuild_core::subprocess::run_commandis the choke point. It's used byevery non-espflash deployer (avrdude, esptool, lpc21isp, teensy_loader_cli)
and is sync (
running_process::NativeProcess::wait(),fbuild-core/src/subprocess.rs:182). Either give it anasync fn run_command_async(...)sibling routed throughtokio::process::Command, or wrap each call inspawn_blockingat thedeploy callsite. The former is the right end-state and is the same fix
recommended by the
fbuild-coreaudit slice of audit: go fully async — whole-app tokio runtime sharing for tokio-console (meta) #813.Recommended sequencing:
fbuild_core::subprocess::run_command_async(async sibling usingtokio::process::Command, sameToolOutputreturn shape, same timeoutsemantics). This is a no-op for sync callers but unblocks every deployer.
Deployertrait to#[async_trait] pub trait Deployerwithasync fn deploy(...)andasync fn post_deploy_recovery(...)..awaitthe newrun_command_async. Theteensy retry loop (
teensy/flash.rs:90-175) becomestokio::time::sleep(backoff).awaitand.await-driven.spawn_blockingwrappers infbuild-daemon/src/handlers/operations/deploy.rs:418and:772.Flasher::*calls inside aper-region
spawn_blockingso they're cancellable at region boundariesand visible to tokio-console as nested blocking tasks.
Findings
avr.rs:123run_command(&args_ref, None, None, Some(timeout))?— sync subprocess spawn for the full avrdude flash (up totimeout_secs, board JSON default 60s)run_command_async(...).awaitafterDeployer::deploybecomesasync fnlpc.rs:217run_command(&args_ref, ..., Some(Duration::from_secs(self.timeout_secs)))?— synclpc21ispflash (default 60s)run_command_async(...).awaitesp32/deployer.rs:330run_command(&args_ref, ..., Some(Duration::from_secs(30)))?— syncesptool verify-flash(~6-10s)run_command_async(...).awaitesp32/deployer.rs:671run_command(&args_ref, ..., Some(Duration::from_secs(120)))?— syncesptool write-flashfor SelectiveFlash (~5-30s)run_command_async(...).awaitesp32/deployer.rs:746run_command(&args_ref, ..., Some(Duration::from_secs(120)))?— syncesptool write-flashfor FullFlash (~5-60s)run_command_async(...).awaitteensy/flash.rs:122run_command(&args_ref, None, None, Some(attempt_timeout))?inside a retry loop of up toretries + 1attempts — blocks one worker for(retries+1) * flash_timeout_secsworst case (default 6 × 30s = 3 min)run_command_async(...).await; surrounding loop becomes async-awareteensy/flash.rs:167std::thread::sleep(Duration::from_millis(backoff_ms))between retry attempts (default 1500 ms)tokio::time::sleep(...).awaitlib.rs:122-132Deployer::post_deploy_recoverydefault impl: 3-second sync poll loop usingserialport::new(...).open()+std::thread::sleep(Duration::from_millis(100))tokio::time::sleep(...).awaitteensy/port_discovery.rs:97-124wait_for_new_cdc_portpollsserialport::available_ports()withstd::thread::sleep(100ms)until timeout — called post-flash to discover the re-enumerated CDC ACM port (budgetpost_flash_port_discovery_secs, default 5s)tokio::task::spawn_blockingper snapshot +tokio::time::sleep(...).awaitbetween pollsteensy/halfkay_probe.rs:42-55wait_for_disappearancepollsavailable_ports()withstd::thread::sleep(75ms)— 3s blocking after baud-134 trigger to confirm device left CDC classtokio::time::sleep(...).awaitbetween pollsesp32_native/write.rs:88-148espflash::flasher::Flasher::connect(...)+ per-regionflasher.write_bin_to_flash(...)are fully sync; entire native write path runs on a singlespawn_blockingworker todaytokio::task::spawn_blocking, so tokio-console sees per-region nested tasks under the parent deploy spanesp32_native/verify.rs:60-110Flasher::connect+ sync verify, all on one blocking workerspawn_blockingper call site after trait goes asynclib.rs:95-103Deployer::deploytrait method is syncfn#[async_trait] async fn deploy(...) -> Result<DeploymentResult>— cascades to 4 production impls + 2 test impls +crates/fbuild-daemon/src/handlers/operations/deploy.rs:418(dropspawn_blocking)lib.rs:121-135Deployer::post_deploy_recoverytrait method is syncfn; daemon also wraps it inspawn_blocking(fbuild-daemon/.../deploy.rs:772)async fn post_deploy_recovery(...) -> Result<()>with body usingtokio::time::sleepand an async port-availability probe; drop the daemonspawn_blockingreset.rs:72,96,101,127,129std::thread::sleep(Duration::from_millis(50-100))in DTR/RTS toggle sequences for ESP32/AVR/Teensy/generic reset pathstokio::time::sleep(...).awaitafter wrapping theserialportwrite calls inspawn_blocking(serialport has no asyncwrite_data_terminal_ready)reset.rs:64-69,84-108,117-129serialport::new(port, ...).open()+ syncwrite_data_terminal_ready/write_request_to_sendcalls for device resetspawn_blocking(no async alternative inserialport), interleave withtokio::time::sleepfor the sleep gapsteensy/first_byte_probe.rs:44-77serialport::open+ syncReadloop with 100ms internalserial.read()timeout for up tofirst_byte_timeout_secs(default 10s); runs post-flashtokio_serial::SerialStreamfor the open +tokio::io::AsyncReadExt::readwithtokio::time::timeout; or keepspawn_blockingwrap with explicittokio::time::sleepbetween pollsteensy/soft_reboot.rs:40-69serialport::new(port, 134).open()+std::thread::sleep(Duration::from_millis(100))for baud-134 triggertokio_serial::SerialStream::openor wrap open inspawn_blocking+tokio::time::sleep(...).awaitfor the 100 ms holdesp32_native/write.rs:69-76,esp32_native/verify.rs:60-65serialport::new(port, 115_200).flow_control(...).timeout(...).open_native()— syncopen_native()call to obtain the handle espflash will consumeTTYPorthandle); leave but make the surrounding call inside aspawn_blockingfrom within anasync fnso the rest of the deploy can proceedesp32_native/write.rs:109,esp32_native/verify.rs:102std::fs::read(&r.path)?for firmware/bootloader/partitions region bytes — can be 1-3 MB per regiontokio::fs::read(&r.path).await(issued before entering thespawn_blockingthat owns the espflashFlasher)esp32/image.rs:36,56,260std::fs::read(firmware_path)?,std::fs::read(elf_path)?,std::fs::File::open(input_path)?— used in QEMU-specific image patching + flash-image assemblytokio::fs::*once the calling deploy method is async; patch loop itself stays sync (CPU-bound)esp32/image.rs:46-50,243-260std::fs::OpenOptions::new().write(true).open(...)+seek+write_allfor the assembled flash imagetokio::fs::OpenOptions+tokio::io::AsyncWriteExt(small overhead but consistent with the runtime)size_check.rs:65std::fs::metadata(artifact)(one stat call before deploy)tokio::fs::metadata(artifact).awaitfor consistency once the entry point is async; impact is negligibleesp32/qemu.rs:63,66std::fs::create_dir_all(parent)?+std::fs::File::create(output_path)?for QEMU image outputtokio::fs::*for consistencyNot flagged (deliberate sync, out-of-scope):
teensy/usb_type.rs:91,99—fs::read_to_stringon the siblingusb_type.txt(~tens of bytes, one-shot read at deploy entry).lib.rs:201,252—std::sync::Mutex<Option<String>>used only inside#[cfg(test)]deployer fixtures.teensy/soft_reboot.rs:87—static TEST_ENV_LOCK: std::sync::Mutex<()>test-only env serialization mutex.tests.rsstd::fs::read/std::fs::writecalls (test fixtures).containment::*interactions infbuild-core(covered by thefbuild-coreaudit slice of audit: go fully async — whole-app tokio runtime sharing for tokio-console (meta) #813).What was searched
Patterns run via Grep against
crates/fbuild-deploy/src/:Command::new|std::process|tokio::process— no direct hits in the crate; subprocess work is centralized throughfbuild_core::subprocess::run_command(sync) — that's the choke point. Notokio::processuse at all.std::thread|tokio::spawn|JoinHandle|block_on|spawn_blocking— 10 hits, allstd::thread::sleep(inreset.rs,teensy/*,lib.rs); zeroblock_on; zerospawn_blocking(the daemon does it externally).serialport::|tokio_serial::— 11 hits, allserialport::*(sync); zerotokio_serial::use.espflash::|use espflash— confirmed espflash 4.x sync API is used directly (Flasher::connect,flasher.write_bin_to_flash, noblock_onshim, no async path available upstream).std::fs::|tokio::fs::— 14 production-code hits inesp32/image.rs,esp32_native/write.rs,esp32_native/verify.rs,size_check.rs,esp32/qemu.rs,teensy/usb_type.rs; zerotokio::fs::use.Mutex|RwLock|mpsc— only test fixtures + a test-env lock; nothing held across.await.reqwest::— no hits (OTA endpoints don't exist in this crate today).post_deploy_recovery— one trait default inlib.rs:121-135(sync poll loop); two daemon call sites both wrapped inspawn_blocking.Per-deployer modules audited:
avr.rs— single syncrun_commandcall site (CRITICAL).lpc.rs— single syncrun_commandcall site (CRITICAL).esp32/deployer.rs— three syncrun_commandcall sites: verify, write, selective write (3 × CRITICAL).esp32/image.rs,esp32/qemu.rs— syncstd::fs::*for image prep (MEDIUM/LOW).esp32_native/{write,verify}.rs— sync espflash 4.x API (HIGH but bounded by upstream); syncserialport::open_native; syncstd::fs::readof region binaries.teensy/mod.rs+ sub-modules — syncrun_commandretry loop (CRITICAL), sync USB re-enumeration polls (2 × HIGH), sync baud-134 + first-byte probe (2 × MEDIUM).reset.rs— syncserialport::open+std::thread::sleepDTR/RTS sequences (MEDIUM).lib.rs— syncDeployertrait + sync defaultpost_deploy_recoverypoll (2 × MEDIUM, the cascading items).Daemon dispatch boundary corroborated:
crates/fbuild-daemon/src/handlers/operations/deploy.rs:418— entire deploy wrapped intokio::task::spawn_blocking.crates/fbuild-daemon/src/handlers/operations/deploy.rs:772—post_deploy_recoverywrapped in a secondtokio::task::spawn_blocking.Both wrappers vanish once the trait goes async.
Platform coverage note
The audit instructions list 10 candidate platforms;
fbuild-deploycurrentlyimplements four (AVR, ESP32, LPC, Teensy). RP2040 / picotool, STM32 / dfu-util,
nrf52 / pyocd, sam / bossac, silabs / Simplicity Commander, ch32v / wch-isp
have no Rust deployer in this crate today — the only RP2040 / pyocd / dfu-util
references in the workspace are the
pyocd/dfu-util/picotoolhook bannamed in CLAUDE.md (#694's scope) and the LPC composite-USB
recovery note in
lpc.rs:1-30. New deployer impls under #694 should be filedas async from day one rather than retrofitted.
Out-of-scope notes
ESP32 native is "an
async fn deploy(...)whose body usesspawn_blockingper region for the espflash
Flasher::write_bin_to_flashcall." That'sstill a strict improvement over today (one
spawn_blockingfor the entiredeploy in the daemon) because each region becomes a separately cancellable,
separately spannable tokio task.
running_process::NativeProcess::wait()(the underlying engine infbuild_core::subprocess::run_command) is sync. The right fix is to add asibling async runner using
tokio::process::Command::output().awaitandport the deploy callers to it — this is also the recommendation for the
fbuild-coreaudit slice.size_check.rs) is a singlemetadatacall —converting it is fine but the perf delta is below noise. Flagged LOW only.
crates/fbuild-deploy/src/method_validation.rs(pure CPU, no I/O) or
crates/fbuild-deploy/src/esp32/parse.rs(purebyte parsing).