| HIGH |
fbuild-python |
serial_monitor.rs:461 (reset_device) |
reqwest::blocking::Client::new().post(&url)...send() inside a #[pymethod] that already owns self.runtime: Option<Runtime> |
Use rt.block_on(reqwest::Client::new().post(&url)...send().await); eliminates a second runtime spinup per reset and reuses the existing connection pool. |
| HIGH |
fbuild-python |
serial_monitor.rs:493, serial_monitor.rs:516 (reset_device) |
std::thread::sleep(...) for USB re-enumeration and post-reset settle |
These run after py.allow_threads is implicitly NOT held; they're fine for sync SerialMonitor but the equivalent path in AsyncSerialMonitor should be tokio::time::sleep(...).await. Verify AsyncSerialMonitor::reset_device already does. (It does — async_serial_monitor.rs:287 uses reqwest::Client::new(); this finding is to align the sync path's choice of HTTP client.) |
| HIGH |
fbuild-python |
daemon.rs:142 (verify_broker_daemon_cache_identity_blocking) |
reqwest::blocking::Client::builder()...send()...json() |
Already has an _async counterpart at daemon.rs:158. Migrate sync Daemon::ensure_running (line 283) to one-shot block_on(_async) and delete the blocking helper. |
| HIGH |
fbuild-python |
daemon.rs:181-220 (ensure_running_via_broker_blocking) |
Sync clone of ensure_running_via_broker_async (lines 222-267); uses reqwest::blocking::get and std::thread::sleep in the health-poll loop |
Delete the sync clone; have sync Daemon::ensure_running create a one-shot current_thread runtime and block_on(ensure_running_via_broker_async(...)). ~80 LoC of duplication goes away. |
| HIGH |
fbuild-python |
daemon.rs:191-201 (broker health poll) |
for _ in 0..100 { std::thread::sleep(100ms); reqwest::blocking::get(url) } |
Replaced by the change above — the async version already polls with tokio::time::sleep + reqwest::Client. |
| HIGH |
fbuild-python |
daemon.rs:291,327 (Daemon::ensure_running direct path) |
reqwest::blocking::get(&url) for /health checks + std::thread::sleep(100ms) polling |
Same as above — fold into a single block_on(async path). |
| HIGH |
fbuild-python |
daemon.rs:339-345 (Daemon::stop) |
reqwest::blocking::Client::new().post(&url).headers(...).send() |
AsyncDaemon::stop (line 505) already exists as async. Sync version should block_on(async_version) instead of cloning the body. |
| HIGH |
fbuild-python |
daemon.rs:350 (Daemon::status) |
reqwest::blocking::get(&url) + resp.text() blocking |
Fold into one-shot block_on(AsyncDaemon::status_impl(...)). |
| HIGH |
fbuild-python |
outcome.rs:121-164 (send_op) |
Whole function is reqwest::blocking::Client POST + JSON. Used by DaemonConnection::{build, deploy, monitor} |
send_op_async already exists in the same file (line 172). Sync send_op could literally be tokio::runtime::Builder::new_current_thread()...block_on(send_op_async(...)) — 35 lines deleted. |
| MEDIUM |
fbuild-python |
serial_monitor.rs:6,33-35 (SerialMonitor struct holds Mutex<WsSink> + Mutex<WsSource> from std::sync) |
std::sync::Mutex cannot be held across .await, so each WS op must lock() outside the future and pass into block_on. Works today, but forces every WS op to be sync. |
(Already done in AsyncSerialMonitor — it uses Arc<tokio::sync::Mutex<Option<_>>> deliberately, per the module docstring at line 22-42.) This finding is informational; sync SerialMonitor cannot adopt tokio::sync::Mutex without becoming async. |
| MEDIUM |
fbuild-cli |
cli/port_scan.rs:117-160 (fetch_overlay_to) |
reqwest::blocking::Client for USB VID overlay fetch, wrapped in std::thread::spawn(...).join() to escape the outer tokio runtime — comment explicitly says "reqwest::blocking spins its own internal runtime and rejects being called from inside an outer tokio runtime" |
Migrate to reqwest::Client::builder().timeout(15s).build() then .get(url).send().await — the surrounding port_scan is invoked from the async dispatcher so no thread hop is needed. Removes the workaround thread and lets cancellation propagate. |
| MEDIUM |
fbuild-cli |
cli/show.rs:38-58 (show_daemon_logs follow loop) |
std::thread::sleep(100ms) polling loop + std::fs::File::open + seek + read_to_string |
Use tokio::time::interval(Duration::from_millis(100)) and tokio::fs::*. Lets Ctrl+C / signal handling work cleanly under the tokio runtime. Low-throughput so the gain is small but the consistency win is real. |
| MEDIUM |
fbuild-cli |
daemon_client.rs:876-887 (spawn_daemon_process) |
std::fs::create_dir_all + std::fs::OpenOptions::new().create(true).append(true).open(...) for the daemon log file inside an async fn |
tokio::fs::create_dir_all(...).await + tokio::fs::OpenOptions::new().append(true)...open().await. Doesn't matter for a one-shot 1-byte create, but keeps the function fully non-blocking. |
| MEDIUM |
fbuild-cli |
cli/clang_tools.rs:274,299,369 (IWYU cache read/write) |
std::fs::read_to_string, std::fs::write, std::fs::read inside tokio::spawn tasks |
These tasks already run tokio::process::Command::output().await for IWYU itself, so the fs ops are tiny compared to the subprocess. Migrating to tokio::fs::* is purely a consistency win, not a perf one. |
| MEDIUM |
fbuild-cli |
daemon_client.rs:166-179 (LAST_DAEMON_ACQUISITION) |
OnceLock<Mutex<Option<DaemonAcquisition>>> using std::sync::Mutex |
The acquisition struct is read/written from async fns but the lock is never held across an .await, so std::sync::Mutex is fine. Leaving as-is is correct. (Listed for completeness — this is NOT a finding.) |
| LOW |
fbuild-cli |
main.rs:6-27 |
Trampoline-thread + tokio::runtime::Builder::new_multi_thread().build()...block_on(cli::async_main()) |
This is the runtime entry point; not a finding. Listed only so #813 readers don't flag it. |
| LOW |
fbuild-cli |
cli/lnk.rs:112,168,197 |
std::fs::read, std::fs::File::create + write_all for .lnk archive read/write inside async fns |
Consistency-only; the surrounding download_file().await already does the heavy lifting. |
| LOW |
fbuild-cli |
cli/clangd_config.rs:63-87,148,224 |
std::fs::write, std::fs::read_to_string for .clangd / .vscode/settings.json generation |
One-shot config file emission; the function is itself sync (fn run_clangd_config(...)), no async runtime to migrate to. Out of scope unless the surrounding command itself is made async. |
| LOW |
fbuild-cli |
cli/purge.rs:104-174, cli/symbols_cmd.rs:107-180, cli/graph_cmd.rs:85, cli/build.rs:160 |
Various std::fs::* calls in sync command bodies |
These commands are inherently sync (file system enumeration, ELF parsing, etc.); migrating to tokio::fs::* would force them to be async without measurable benefit. Skip. |
| LOW |
fbuild-cli |
mcp/server.rs:24-33 |
std::io::stdin().lock(); for line in reader.lines() { ... } blocking stdin read loop inside an async fn run_mcp_server() -> i32 |
MCP protocol is request/response over stdin/stdout; one outstanding request at a time. Migrating to tokio::io::stdin() adds complexity for zero behavioral gain. Skip unless we want to add concurrent notification streaming. |
Sub-issue of #813. Audit of
crates/fbuild-cli/andcrates/fbuild-python/for synchronous code paths that could be migrated to async so they share the tokio runtime (CLI's per-invocation runtime, or pyo3-async-runtimes' tokio for Python).Intro
These two crates have very different audiences but share the same goal: lean on tokio so they can do more than one I/O thing at a time.
fbuild-cli— transient CLI process; spins aBuilder::new_multi_thread()runtime inmain.rsandblock_on(cli::async_main()). Most of the CLI is already async (daemon_client.rs'sDaemonClientis fullyreqwest::Client+await, daemon spawn usestokio::process::Command, daemon polling usestokio::time::sleep). A few diagnostic and edge-case paths still use blocking primitives even though they execute inside the tokio context. The biggest motivator to fix them is concurrent progress reporting (e.g. spinner while a build streams) — today the CLI is already async-clean enough that a sync HTTP call in a hot path is the exception, not the rule.fbuild-python— PyO3 extension shipped to FastLED. The crate is structured around the "additive async surface" pattern (issue feat(python): native async API via pyo3-async-runtimes (AsyncSerialMonitor, AsyncDaemon) #65): sync types (SerialMonitor,Daemon,DaemonConnection) stay untouched for backwards compat and the native async counterparts (AsyncSerialMonitor,AsyncDaemon,AsyncDaemonConnection) wrap the same logic inpyo3_async_runtimes::tokio::future_into_py. The sync surface is intentional — but a few HTTP calls inside the sync surface (notablySerialMonitor.reset_device) still usereqwest::blocking::*even though the type owns atokio::runtime::Runtimealready and could reuse it.PyO3-async-runtimes adoption status
I counted 16
pyo3_async_runtimes::tokio::future_into_pycallsites across the crate:async_serial_monitor.rs— 7 (__aenter__,__aexit__,read_lines,write,write_json_rpc, plus 2 more). Full async surface.async_daemon_connection.rs— 8 (__aenter__,__aexit__,build,deploy,monitor,build_result,deploy_result,monitor_result). Full async surface.daemon.rs— 3 onAsyncDaemon(status,ensure_running,stop). Full async surface.Sync surface (
SerialMonitor,DaemonConnection,Daemon) is entirely synchronous and uses eitherrt.block_on(...)(when the type owns atokio::runtime::Runtime) orreqwest::blocking::*directly.Migration order recommendation: the async surface is already complete and FastLED should consume it. The remaining work is purely internal hygiene on the sync surface — finding/replacing the few
reqwest::blocking::*calls that could reuse the owned runtime viart.block_on(reqwest::Client::...). That gives a single HTTP connection pool perSerialMonitorinstance instead of a fresh blocking client per call (zero behavioral change visible to Python, but avoids re-running TLS / connection setup on everyreset_deviceetc.).For
Daemon(the sync sibling ofAsyncDaemon), the duplication is more substantial — there are two parallel_blocking/_asynchelpers (verify_broker_daemon_cache_identity_blockingvs_async,ensure_running_via_broker_blockingvs_async). If we're willing to make the syncDaemonmethods spin a one-shottokio::runtime::Builder::new_current_thread()andblock_on(...)the async version, we delete ~80 lines of duplicated broker-adoption logic. Out of scope for this issue, but worth noting in #813.Findings table
serial_monitor.rs:461(reset_device)reqwest::blocking::Client::new().post(&url)...send()inside a#[pymethod]that already ownsself.runtime: Option<Runtime>rt.block_on(reqwest::Client::new().post(&url)...send().await); eliminates a second runtime spinup per reset and reuses the existing connection pool.serial_monitor.rs:493,serial_monitor.rs:516(reset_device)std::thread::sleep(...)for USB re-enumeration and post-reset settlepy.allow_threadsis implicitly NOT held; they're fine for syncSerialMonitorbut the equivalent path inAsyncSerialMonitorshould betokio::time::sleep(...).await. VerifyAsyncSerialMonitor::reset_devicealready does. (It does — async_serial_monitor.rs:287 usesreqwest::Client::new(); this finding is to align the sync path's choice of HTTP client.)daemon.rs:142(verify_broker_daemon_cache_identity_blocking)reqwest::blocking::Client::builder()...send()...json()_asynccounterpart atdaemon.rs:158. Migrate syncDaemon::ensure_running(line 283) to one-shotblock_on(_async)and delete the blocking helper.daemon.rs:181-220(ensure_running_via_broker_blocking)ensure_running_via_broker_async(lines 222-267); usesreqwest::blocking::getandstd::thread::sleepin the health-poll loopDaemon::ensure_runningcreate a one-shotcurrent_threadruntime andblock_on(ensure_running_via_broker_async(...)). ~80 LoC of duplication goes away.daemon.rs:191-201(broker health poll)for _ in 0..100 { std::thread::sleep(100ms); reqwest::blocking::get(url) }tokio::time::sleep+reqwest::Client.daemon.rs:291,327(Daemon::ensure_runningdirect path)reqwest::blocking::get(&url)for/healthchecks +std::thread::sleep(100ms)pollingblock_on(async path).daemon.rs:339-345(Daemon::stop)reqwest::blocking::Client::new().post(&url).headers(...).send()AsyncDaemon::stop(line 505) already exists as async. Sync version shouldblock_on(async_version)instead of cloning the body.daemon.rs:350(Daemon::status)reqwest::blocking::get(&url)+resp.text()blockingblock_on(AsyncDaemon::status_impl(...)).outcome.rs:121-164(send_op)reqwest::blocking::ClientPOST + JSON. Used byDaemonConnection::{build, deploy, monitor}send_op_asyncalready exists in the same file (line 172). Syncsend_opcould literally betokio::runtime::Builder::new_current_thread()...block_on(send_op_async(...))— 35 lines deleted.serial_monitor.rs:6,33-35(SerialMonitorstruct holdsMutex<WsSink>+Mutex<WsSource>fromstd::sync)std::sync::Mutexcannot be held across.await, so each WS op mustlock()outside the future and pass intoblock_on. Works today, but forces every WS op to be sync.AsyncSerialMonitor— it usesArc<tokio::sync::Mutex<Option<_>>>deliberately, per the module docstring at line 22-42.) This finding is informational; syncSerialMonitorcannot adopttokio::sync::Mutexwithout becoming async.cli/port_scan.rs:117-160(fetch_overlay_to)reqwest::blocking::Clientfor USB VID overlay fetch, wrapped instd::thread::spawn(...).join()to escape the outer tokio runtime — comment explicitly says "reqwest::blocking spins its own internal runtime and rejects being called from inside an outer tokio runtime"reqwest::Client::builder().timeout(15s).build()then.get(url).send().await— the surroundingport_scanis invoked from the async dispatcher so no thread hop is needed. Removes the workaround thread and lets cancellation propagate.cli/show.rs:38-58(show_daemon_logsfollow loop)std::thread::sleep(100ms)polling loop +std::fs::File::open+seek+read_to_stringtokio::time::interval(Duration::from_millis(100))andtokio::fs::*. Lets Ctrl+C / signal handling work cleanly under the tokio runtime. Low-throughput so the gain is small but the consistency win is real.daemon_client.rs:876-887(spawn_daemon_process)std::fs::create_dir_all+std::fs::OpenOptions::new().create(true).append(true).open(...)for the daemon log file inside anasync fntokio::fs::create_dir_all(...).await+tokio::fs::OpenOptions::new().append(true)...open().await. Doesn't matter for a one-shot 1-byte create, but keeps the function fully non-blocking.cli/clang_tools.rs:274,299,369(IWYU cache read/write)std::fs::read_to_string,std::fs::write,std::fs::readinsidetokio::spawntaskstokio::process::Command::output().awaitfor IWYU itself, so the fs ops are tiny compared to the subprocess. Migrating totokio::fs::*is purely a consistency win, not a perf one.daemon_client.rs:166-179(LAST_DAEMON_ACQUISITION)OnceLock<Mutex<Option<DaemonAcquisition>>>usingstd::sync::Mutexasync fns but the lock is never held across an.await, sostd::sync::Mutexis fine. Leaving as-is is correct. (Listed for completeness — this is NOT a finding.)main.rs:6-27tokio::runtime::Builder::new_multi_thread().build()...block_on(cli::async_main())cli/lnk.rs:112,168,197std::fs::read,std::fs::File::create+write_allfor .lnk archive read/write insideasync fnsdownload_file().awaitalready does the heavy lifting.cli/clangd_config.rs:63-87,148,224std::fs::write,std::fs::read_to_stringfor.clangd/.vscode/settings.jsongenerationfn run_clangd_config(...)), no async runtime to migrate to. Out of scope unless the surrounding command itself is made async.cli/purge.rs:104-174,cli/symbols_cmd.rs:107-180,cli/graph_cmd.rs:85,cli/build.rs:160std::fs::*calls in sync command bodiestokio::fs::*would force them to be async without measurable benefit. Skip.mcp/server.rs:24-33std::io::stdin().lock(); for line in reader.lines() { ... }blocking stdin read loop inside anasync fn run_mcp_server() -> i32tokio::io::stdin()adds complexity for zero behavioral gain. Skip unless we want to add concurrent notification streaming.What was searched
Grep patterns (across both
crates/fbuild-cli/src/andcrates/fbuild-python/src/):reqwest::— classifiedreqwest::Client(async, good) vsreqwest::blocking::*(sync, candidate)block_on— distinguished the explicit sync-Python bridge calls (intentional, in PyO3 entry points) fromblock_oninside HTTP/WS plumbing (candidate for sharing the host runtime)pyo3_async_runtimes— counted the existing async surface (16 callsites, all inAsync*types)#[pyfunction]/#[pymethods]/#[staticmethod]— enumerated PyO3 entry points, sorted by sync vs async return typestd::thread::sleep/std::thread::spawn— flagged blocking sleeps insideasynccontextsstd::process::Commandvstokio::process::Command— CLI uses tokio variant everywhere except the diagnostic clang tools (which use tokio); fbuild-python usesstd::process::Commandfor daemon spawn, which is correct since the child is detachedtokio_tungstenite::/tungstenite::— only infbuild-python(syncSerialMonitorusesblock_on(read.next());AsyncSerialMonitoruses native.await)std::sync::Mutex/RwLock/mpsc— all uses are either short-lived non-.awaitcritical sections or test infrastructuretokio::runtime::Runtime::new()— syncSerialMonitorconstructs a per-instance multi-thread runtime; tests construct one-shot runtimes. PyO3 itself uses the sharedpyo3-async-runtimesruntime for async surface, as expectedClient::new/Client::builder/Client::default— daemon_client.rs's singlereqwest::ClientperDaemonClientinstance is correct (one connection pool per CLI invocation)spawn_blocking/block_in_place— no occurrences in fbuild-cli or fbuild-python (correctly — fbuild-cli has no blocking work; fbuild-python sync surface usesRuntime::block_ondirectly)WebSocket readsfrom/ws/serial-monitor,/ws/logs,/ws/status— onlyfbuild-pythonreads/ws/serial-monitor(SerialMonitorsync viablock_on(read.next()),AsyncSerialMonitorasync via.await)Modules / files audited:
fbuild-cli (12 files):
main.rs,daemon_client.rs(+daemon_client/tests.rs,daemon_client/types.rs),lib_select.rs,mcp/server.rs,mcp/jsonrpc.rs,cli/dispatch.rs,cli/daemon_cmd.rs,cli/port_scan.rs,cli/show.rs,cli/clang_tools.rs,cli/clangd_config.rs,cli/lnk.rs,cli/serial_probe.rs,cli/symbols_cmd.rs,cli/purge.rs,cli/graph_cmd.rs,cli/build.rs,cli/compile_many.rs,cli/bringup.rs.fbuild-python (9 files):
lib.rs,daemon.rs,daemon_connection.rs,async_daemon_connection.rs,serial_monitor.rs,async_serial_monitor.rs,outcome.rs,json_rpc.rs,messages.rs.Out-of-scope notes
block_onat the PyO3 boundary in sync types is intentional, per feat(python): native async API via pyo3-async-runtimes (AsyncSerialMonitor, AsyncDaemon) #65's additive-async pattern. The crate explicitly preserves syncSerialMonitor,DaemonConnection,Daemonfor backwards-compatible Python consumers. We are not proposing to delete those types — only to thin out the sync internal helpers that duplicate logic already present in theirAsync*siblings.std::process::Command::spawnfor daemon spawn in bothfbuild-cli/src/daemon_client.rs::spawn_daemon_process(uses tokio) andfbuild-python/src/daemon.rs::Daemon::ensure_running(uses std) is intentional. The daemon is detached and outlives its parent (#32); tokio vs stdCommanddoesn't matter for aStdio::null()+spawn()+ no.awaitpath.The
LAST_DAEMON_ACQUISITIONOnceLock<Mutex<Option<_>>>indaemon_client.rs:166is correctlystd::sync::Mutex— the lock is never held across.await, only for set/get of a small struct.CLI WebSocket consumption does not exist. The CLI does not connect to
/ws/serial-monitor,/ws/logs, or/ws/statusdirectly — the only WebSocket consumer in this audit's scope isfbuild-python'sSerialMonitor/AsyncSerialMonitor, which talk to/ws/serial-monitor. The streaming build output uses NDJSON over HTTP (daemon_client.rs:304), not a WebSocket.Test-only
tokio::runtime::Runtime::new()constructions inlib.rs:312,330,350are integration-test helpers and not part of the runtime story.fbuild-cli/src/cli/show.rs::show_daemon_logsis invoked as a syncfnfrom the dispatcher (it has noasync fnwrapping). Migrating it totokio::fsrequires also making the caller async, which is straightforward but touchesdispatch.rs. Listed as MEDIUM rather than HIGH because the throughput is human-paced log tailing.