Skip to content

audit: sync code that could be async in fbuild-deploy (sub-issue of #813) #819

Description

@zackees

Intro

This is the fbuild-deploy slice of the whole-app async migration meta tracked
in #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 is
synchronous and runs on a spawn_blocking worker thread today, not on the
tokio runtime. The two fixes compose; the ideal end state is "async with
deadline".

The end state for fbuild-deploy is: Deployer::deploy(...) returns an
async fn future, every flash tool is invoked through
tokio::process::Command::output().await, every poll loop is
tokio::time::sleep, and the daemon's
crates/fbuild-daemon/src/handlers/operations/deploy.rs drops the
tokio::task::spawn_blocking wrappers it currently uses to bridge to the
sync trait (deploy.rs:418 for deploy(...), deploy.rs:772 for
post_deploy_recovery). Those spawn_blocking calls are the smoking gun
that the entire crate is sync.

The Deployer trait question

crates/fbuild-deploy/src/lib.rs:95-136 defines a sync pub trait Deployer
with fn deploy(...) -> Result<DeploymentResult> plus
fn post_deploy_recovery(&self, port: &str) -> Result<()> whose default impl
is a 3-second serialport::new(...).open() poll with
std::thread::sleep(100ms) between probes.

Should this become async fn? Yes — that's the highest-leverage single
change in this crate.

Pros:

  • Runtime unity: today deploy.rs:418-748 wraps the entire deployer.deploy(...)
    call in tokio::task::spawn_blocking, which means the work is invisible to
    tokio-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::task whose name shows up in the console.
  • The daemon already pays for one runtime; reusing it removes the
    spawn_blocking budget pressure (default tokio blocking pool is 512
    threads, but a stuck flash tool holds one for ~60 s, and 4-8 simultaneous
    flashes on a CI matrix would each block one).
  • Cancellation: today a daemon shutdown can't abort a stuck teensy_loader_cli
    retry loop (teensy/flash.rs:90-175) — spawn_blocking is uncancellable.
    Switching to tokio::process::Command::output().await makes the future
    cancellable, so the daemon's tokio::sync::watch shutdown signal collapses
    in-flight deploys cleanly.
  • post_deploy_recovery's 3s sync serialport::open retry +
    std::thread::sleep poll currently runs on a blocking thread; an async
    version with tokio::time::sleep would yield to other handlers between
    probes.
  • Per-region write_bin_to_flash calls inside the native espflash path
    (esp32_native/write.rs:108-148) could each be a spawn_blocking task with
    a parent span — observable in tokio-console as nested work units.

Cons:

  • Cascade: four sync deployer impls today
    (Esp32Deployer/esp32/deployer.rs:709, AvrDeployer/avr.rs:86,
    LpcDeployer/lpc.rs:165, TeensyDeployer/teensy/mod.rs:148), plus the
    ObservableDeployer and DefaultRecoveryDeployer test types in
    lib.rs:199-247, all need to become async fn. Total: 4 production impls
    • 2 test impls.
  • async_trait vs native: the crate already depends on async-trait via
    Cargo.toml:28. Either route is viable; native async fn in traits is
    stable as of 1.75 (MSRV 1.94.1 satisfies it). Object-safety: the daemon
    uses Box<dyn Deployer> (see deploy.rs:424, deploy.rs:651-652,
    lib.rs:253), which native async fn in traits does not currently make
    object-safe — so async-trait is the realistic choice here unless we
    switch the daemon to a non-dyn dispatch.
  • espflash crate is sync.espflash 4.x exposes
    Flasher::write_bin_to_flash(...) as a fully sync API atop
    serialport::open_native (see esp32_native/write.rs:88-148 and
    esp32_native/verify.rs:60-110). It cannot itself become async — the
    calls inside an async fn deploy(...) must remain wrapped in
    tokio::task::spawn_blocking. That's fine: this audit only asks "could
    this be async on the runtime?" — for espflash the honest answer is "wrap
    the sync call in spawn_blocking from inside the async deploy method,"
    which still gives tokio-console a parent task to attach the work to.
  • fbuild_core::subprocess::run_command is the choke point. It's used by
    every 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 an
    async fn run_command_async(...) sibling routed through
    tokio::process::Command, or wrap each call in spawn_blocking at the
    deploy callsite. The former is the right end-state and is the same fix
    recommended by the fbuild-core audit slice of audit: go fully async — whole-app tokio runtime sharing for tokio-console (meta) #813.

Recommended sequencing:

  1. Land fbuild_core::subprocess::run_command_async (async sibling using
    tokio::process::Command, same ToolOutput return shape, same timeout
    semantics). This is a no-op for sync callers but unblocks every deployer.
  2. Switch the Deployer trait to #[async_trait] pub trait Deployer with
    async fn deploy(...) and async fn post_deploy_recovery(...).
  3. Migrate each deployer impl to .await the new run_command_async. The
    teensy retry loop (teensy/flash.rs:90-175) becomes
    tokio::time::sleep(backoff).await and .await-driven.
  4. Drop the two spawn_blocking wrappers in
    fbuild-daemon/src/handlers/operations/deploy.rs:418 and :772.
  5. For the native espflash path, keep the inner Flasher::* calls inside a
    per-region spawn_blocking so they're cancellable at region boundaries
    and visible to tokio-console as nested blocking tasks.

Findings

SeverityFile:lineCurrent patternProposed async replacementTool
CRITICALavr.rs:123run_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 fnavrdude
CRITICALlpc.rs:217run_command(&args_ref, ..., Some(Duration::from_secs(self.timeout_secs)))? — sync lpc21isp flash (default 60s)run_command_async(...).awaitlpc21isp
CRITICALesp32/deployer.rs:330run_command(&args_ref, ..., Some(Duration::from_secs(30)))? — sync esptool verify-flash (~6-10s)run_command_async(...).awaitesptool
CRITICALesp32/deployer.rs:671run_command(&args_ref, ..., Some(Duration::from_secs(120)))? — sync esptool write-flash for SelectiveFlash (~5-30s)run_command_async(...).awaitesptool
CRITICALesp32/deployer.rs:746run_command(&args_ref, ..., Some(Duration::from_secs(120)))? — sync esptool write-flash for FullFlash (~5-60s)run_command_async(...).awaitesptool
CRITICALteensy/flash.rs:122run_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-awareteensy_loader_cli
HIGHteensy/flash.rs:167std::thread::sleep(Duration::from_millis(backoff_ms)) between retry attempts (default 1500 ms)tokio::time::sleep(...).await(loop control)
HIGHlib.rs:122-132Deployer::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)
HIGHteensy/port_discovery.rs:97-124wait_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)
HIGHteensy/halfkay_probe.rs:42-55wait_for_disappearance polls available_ports() with std::thread::sleep(75ms) — 3s blocking after baud-134 trigger to confirm device left CDC classSame: blocking enumerate + tokio::time::sleep(...).await between polls(HID enum)
HIGHesp32_native/write.rs:88-148espflash::flasher::Flasher::connect(...) + per-region flasher.write_bin_to_flash(...) are fully sync; entire native write path runs on a single spawn_blocking worker todayKeep 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 spanespflash (native)
HIGHesp32_native/verify.rs:60-110Same as write: sync Flasher::connect + sync verify, all on one blocking workerWrap in spawn_blocking per call site after trait goes asyncespflash (native)
MEDIUMlib.rs:95-103Deployer::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)
MEDIUMlib.rs:121-135Deployer::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)
MEDIUMreset.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(...).await after wrapping the serialport write calls in spawn_blocking (serialport has no async write_data_terminal_ready)(DTR/RTS)
MEDIUMreset.rs:64-69,84-108,117-129serialport::new(port, ...).open() + sync write_data_terminal_ready / write_request_to_send calls for device resetWrap port open + control-line writes in spawn_blocking (no async alternative in serialport), interleave with tokio::time::sleep for the sleep gaps(DTR/RTS)
MEDIUMteensy/first_byte_probe.rs:44-77serialport::open + sync Read loop with 100ms internal serial.read() timeout for up to first_byte_timeout_secs (default 10s); runs post-flashUse 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)
MEDIUMteensy/soft_reboot.rs:40-69serialport::new(port, 134).open() + std::thread::sleep(Duration::from_millis(100)) for baud-134 triggertokio_serial::SerialStream::open or wrap open in spawn_blocking + tokio::time::sleep(...).await for the 100 ms hold(baud-134)
MEDIUMesp32_native/write.rs:69-76, esp32_native/verify.rs:60-65serialport::new(port, 115_200).flow_control(...).timeout(...).open_native() — sync open_native() call to obtain the handle espflash will consumeCannot 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 proceedespflash (native)
MEDIUMesp32_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 the spawn_blocking that owns the espflash Flasher)(file I/O)
MEDIUMesp32/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)(QEMU image prep)
MEDIUMesp32/image.rs:46-50,243-260std::fs::OpenOptions::new().write(true).open(...) + seek + write_all for the assembled flash imagetokio::fs::OpenOptions + tokio::io::AsyncWriteExt (small overhead but consistent with the runtime)(QEMU image prep)
LOWsize_check.rs:65std::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)
LOWesp32/qemu.rs:63,66std::fs::create_dir_all(parent)? + std::fs::File::create(output_path)? for QEMU image outputtokio::fs::* for consistency(QEMU image output)

Not flagged (deliberate sync, out-of-scope):

  • teensy/usb_type.rs:91,99fs::read_to_string on the sibling usb_type.txt (~tens of bytes, one-shot read at deploy entry).
  • lib.rs:201,252std::sync::Mutex<Option<String>> used only inside #[cfg(test)] deployer fixtures.
  • teensy/soft_reboot.rs:87static TEST_ENV_LOCK: std::sync::Mutex<()> test-only env serialization mutex.
  • All tests.rsstd::fs::read/std::fs::write calls (test fixtures).
  • containment::* interactions in fbuild-core (covered by the fbuild-core audit 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 through fbuild_core::subprocess::run_command (sync) — that's the choke point. No tokio::process use at all.
  • std::thread|tokio::spawn|JoinHandle|block_on|spawn_blocking — 10 hits, all std::thread::sleep (in reset.rs, teensy/*, lib.rs); zero block_on; zero spawn_blocking (the daemon does it externally).
  • serialport::|tokio_serial:: — 11 hits, all serialport::* (sync); zero tokio_serial:: use.
  • espflash::|use espflash — confirmed espflash 4.x sync API is used directly (Flasher::connect, flasher.write_bin_to_flash, no block_on shim, no async path available upstream).
  • std::fs::|tokio::fs:: — 14 production-code hits in esp32/image.rs, esp32_native/write.rs, esp32_native/verify.rs, size_check.rs, esp32/qemu.rs, teensy/usb_type.rs; zero tokio::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 in lib.rs:121-135 (sync poll loop); two daemon call sites both wrapped in spawn_blocking.

Per-deployer modules audited:

  • avr.rs — single sync run_command call site (CRITICAL).
  • lpc.rs — single sync run_command call site (CRITICAL).
  • esp32/deployer.rs — three sync run_command call sites: verify, write, selective write (3 × CRITICAL).
  • esp32/image.rs, esp32/qemu.rs — sync std::fs::* for image prep (MEDIUM/LOW).
  • esp32_native/{write,verify}.rs — sync espflash 4.x API (HIGH but bounded by upstream); sync serialport::open_native; sync std::fs::read of region binaries.
  • teensy/mod.rs + sub-modules — sync run_command retry loop (CRITICAL), sync USB re-enumeration polls (2 × HIGH), sync baud-134 + first-byte probe (2 × MEDIUM).
  • reset.rs — sync serialport::open + std::thread::sleep DTR/RTS sequences (MEDIUM).
  • lib.rs — sync Deployer trait + sync default post_deploy_recovery poll (2 × MEDIUM, the cascading items).

Daemon dispatch boundary corroborated:

  • crates/fbuild-daemon/src/handlers/operations/deploy.rs:418 — entire deploy wrapped in tokio::task::spawn_blocking.
  • crates/fbuild-daemon/src/handlers/operations/deploy.rs:772post_deploy_recovery wrapped in a second tokio::task::spawn_blocking.

Both wrappers vanish once the trait goes async.

Platform coverage note

The audit instructions list 10 candidate platforms; fbuild-deploy currently
implements 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 / picotool hook ban
named 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 filed
as async from day one rather than retrofitted.

Out-of-scope notes

  • The espflash crate is sync upstream (4.x). The honest async story for
    ESP32 native is "an async fn deploy(...) whose body uses spawn_blocking
    per region for the espflash Flasher::write_bin_to_flash call." That's
    still a strict improvement over today (one spawn_blocking for the entire
    deploy in the daemon) because each region becomes a separately cancellable,
    separately spannable tokio task.
  • running_process::NativeProcess::wait() (the underlying engine in
    fbuild_core::subprocess::run_command) is sync. The right fix is to add a
    sibling async runner using tokio::process::Command::output().await and
    port the deploy callers to it — this is also the recommendation for the
    fbuild-core audit slice.
  • Pre-deploy size check (size_check.rs) is a single metadata call —
    converting it is fine but the perf delta is below noise. Flagged LOW only.
  • This audit does not touch crates/fbuild-deploy/src/method_validation.rs
    (pure CPU, no I/O) or crates/fbuild-deploy/src/esp32/parse.rs (pure
    byte parsing).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Status
    Triage

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions