Async / tokio audit
Async Rust has a small set of footguns that produce subtle, hard-to-debug failures: stalls, deadlocks, dropped state on cancellation, runtime panics. Walk the code against this checklist.
Workflow
- Identify the runtime: tokio, async-std, smol? Mixing two in one binary is Major.
- Grep for the high-signal anti-patterns first (see below).
- Read each
async fn and tokio::spawn / task::spawn_blocking / select! / join! block.
- Report findings with
file:line and severity.
High-signal greps
Run these and triage results:
# Blocking calls inside async contexts
rg -n 'std::thread::sleep|std::fs::|std::io::stdin|reqwest::blocking|rusqlite::' --type rust
# Sync locks that may be held across .await
rg -nU 'std::sync::Mutex|parking_lot::Mutex' --type rust -A 5
# Spawned tasks (need to check for Send bounds, panics, error handling)
rg -n 'tokio::spawn|task::spawn' --type rust
# select! without biased — fairness / cancellation traps live here
rg -n 'select!' --type rust -A 2
# Manual Future / Poll impls — usually unnecessary, often buggy
rg -n 'impl.*Future for|fn poll\(' --type rust
Checklist
Runtime hygiene
- One runtime per binary. Mixing tokio + async-std → Major. Even within tokio, don't call
Runtime::block_on from inside another tokio task.
#[tokio::main] flavor. Default is multi-threaded; if the workload is single-task glue, flavor = "current_thread" is lighter. Note as Minor if obviously wrong.
tokio::main worker_threads tuning is almost always premature; flag unjustified non-default values.
Blocking inside async — Major
Anything that parks the OS thread inside an async task starves the runtime:
std::thread::sleep → tokio::time::sleep
std::fs::* → tokio::fs::*
- CPU-bound work (>~10µs) → wrap in
tokio::task::spawn_blocking
- Synchronous DB / HTTP clients (
reqwest::blocking, rusqlite without tokio-rusqlite) → use the async variant or spawn_blocking
- Calling
.block_on() from within an async context → panic on multi-thread runtime, deadlock on current-thread
Locks across .await — Major (subtle)
Concurrency primitives — pick the right one
- Independent fire-and-forget tasks:
tokio::spawn — but ensure the JoinHandle is awaited or stored, otherwise panics in the task are silent.
- Fixed set of awaits, all must succeed:
tokio::try_join!(a, b, c).
- Fixed set, run concurrently regardless:
tokio::join!(a, b, c).
- Dynamic set of futures:
FuturesUnordered (no spawn) or JoinSet (spawns each).
- Awaiting sequentially when they could run concurrently is a Minor perf bug —
a.await; b.await; vs join!(a, b).
Cancellation safety — Major when violated
tokio::select! drops all but the winning branch. Any state partially built up inside a losing branch is lost. Audit each select!:
- Are the futures cancel-safe? (Reads from a channel: yes. Sending into a channel: maybe not. Anything that mutates external state mid-future: no.)
- If a branch is not cancel-safe, restructure: pull it out of
select! and into its own task, or use tokio::pin! and select! { biased; ... } to control polling order.
- Reference: https://docs.rs/tokio/latest/tokio/macro.select.html#cancellation-safety
Spawned task hygiene
tokio::spawn(async move { ... }) with no error path: panics print to stderr but disappear. Wrap the body in a result, log on drop, or use a supervisor pattern.
JoinHandles dropped without .await → task runs detached. Fine if intentional; flag as Minor if it looks accidental.
- Spawning from a
!Send context will fail to compile; spawning a !Send future on the multi-thread runtime ditto. Use tokio::task::spawn_local + LocalSet if the future is !Send on purpose.
Channels
tokio::sync::mpsc for backpressure (bounded). unbounded_channel only when you can prove the producer can't outrun the consumer — otherwise memory leak.
tokio::sync::broadcast for fan-out with lag tolerance; watch for "latest value only".
oneshot for single-reply request/response.
Retry / backoff loops
- Pure
loop { ... tokio::time::sleep(...).await; } is fine, but:
- Use
tokio::time::sleep_until if you need wall-clock anchoring.
- Add a max-attempts cap; an infinite loop in a sidecar with no exit path is a hang.
- Use
tokio::select! { _ = shutdown.recv() => break, _ = sleep(...) => {} } so the loop respects shutdown signals.
- Arithmetic in backoff: use
.saturating_mul() / .saturating_add(), not plain * / +. A comment claiming "saturating" near plain arithmetic is a Major correctness smell.
Tracing in async
#[tracing::instrument] on async fns is great; it ensures spans follow tasks.
- Bare
info!/debug! inside tokio::spawn will inherit the spawning span, not a fresh one — usually desired, but flag if span context looks wrong.
Tests
#[tokio::test] is fine; #[tokio::test(flavor = "multi_thread")] if the test needs real concurrency.
- Sleep-based tests are flaky; prefer
tokio::time::pause() + advance() for time-dependent logic.
- Tests that touch global state (env vars, current dir, signal handlers) cannot run in parallel with
cargo test — use serial_test or single-thread.
Report format
## Async audit — <scope>
### Major
- [`path.rs:L`] <issue> → <fix>
### Minor
- [`path.rs:L`] <issue> → <fix>
### Cancellation review
<one paragraph per `select!` block flagging cancel-safety>
### Lock audit
<table of every sync Mutex/RefCell and whether it crosses .await>
### Recommended next step
<single highest-leverage fix>
Source: outsideorbit/vaulpner — distributed by TomeVault.
1---2name: rust-async-audit3description: Audit async Rust / tokio code for blocking calls, lock-across-await, cancellation safety, runtime mixing, and concurrency anti-patterns. Use when reviewing tokio code, troubleshooting async performance issues, or auditing a tokio-based binary/service. Do NOT use for general Rust review (use rust-review) or error refactors (use rust-error-design). Use when this capability is needed.4---56# Async / tokio audit78Async Rust has a small set of footguns that produce subtle, hard-to-debug failures: stalls, deadlocks, dropped state on cancellation, runtime panics. Walk the code against this checklist.910## Workflow11121. Identify the runtime: tokio, async-std, smol? Mixing two in one binary is Major.132. Grep for the high-signal anti-patterns first (see below).143. Read each `async fn` and `tokio::spawn` / `task::spawn_blocking` / `select!` / `join!` block.154. Report findings with `file:line` and severity.1617## High-signal greps1819Run these and triage results:2021```bash22# Blocking calls inside async contexts23rg -n 'std::thread::sleep|std::fs::|std::io::stdin|reqwest::blocking|rusqlite::' --type rust2425# Sync locks that may be held across .await26rg -nU 'std::sync::Mutex|parking_lot::Mutex' --type rust -A 52728# Spawned tasks (need to check for Send bounds, panics, error handling)29rg -n 'tokio::spawn|task::spawn' --type rust3031# select! without biased — fairness / cancellation traps live here32rg -n 'select!' --type rust -A 23334# Manual Future / Poll impls — usually unnecessary, often buggy35rg -n 'impl.*Future for|fn poll\(' --type rust36```3738## Checklist3940### Runtime hygiene41- **One runtime per binary.** Mixing tokio + async-std → Major. Even within tokio, don't call `Runtime::block_on` from inside another tokio task.42- **`#[tokio::main]` flavor.** Default is multi-threaded; if the workload is single-task glue, `flavor = "current_thread"` is lighter. Note as Minor if obviously wrong.43- **`tokio::main` worker_threads tuning** is almost always premature; flag unjustified non-default values.4445### Blocking inside async — Major46Anything that parks the OS thread inside an async task starves the runtime:47- `std::thread::sleep` → `tokio::time::sleep`48- `std::fs::*` → `tokio::fs::*`49- CPU-bound work (>~10µs) → wrap in `tokio::task::spawn_blocking`50- Synchronous DB / HTTP clients (`reqwest::blocking`, `rusqlite` without `tokio-rusqlite`) → use the async variant or `spawn_blocking`51- Calling `.block_on()` from within an async context → panic on multi-thread runtime, deadlock on current-thread5253### Locks across `.await` — Major (subtle)54- `std::sync::Mutex` / `parking_lot::Mutex` held across `.await` → deadlock risk + `!Send` future. Either drop the guard before `.await` or use `tokio::sync::Mutex`.55- `RefCell` borrow across `.await` → panics at runtime if re-entered.56- Pattern to look for:57 ```rust58 let mut guard = state.lock().unwrap();59 let v = some_async_call().await; // <-- guard still held60 guard.update(v);61 ```62 Fix: drop the guard first, or use `tokio::sync::Mutex` and `await` the lock.6364### Concurrency primitives — pick the right one65- **Independent fire-and-forget tasks:** `tokio::spawn` — but ensure the `JoinHandle` is awaited or stored, otherwise panics in the task are silent.66- **Fixed set of awaits, all must succeed:** `tokio::try_join!(a, b, c)`.67- **Fixed set, run concurrently regardless:** `tokio::join!(a, b, c)`.68- **Dynamic set of futures:** `FuturesUnordered` (no spawn) or `JoinSet` (spawns each).69- **Awaiting sequentially when they could run concurrently** is a Minor perf bug — `a.await; b.await;` vs `join!(a, b)`.7071### Cancellation safety — Major when violated72`tokio::select!` drops all but the winning branch. Any state partially built up inside a losing branch is lost. Audit each `select!`:73- Are the futures cancel-safe? (Reads from a channel: yes. Sending into a channel: maybe not. Anything that mutates external state mid-future: no.)74- If a branch is not cancel-safe, restructure: pull it out of `select!` and into its own task, or use `tokio::pin!` and `select! { biased; ... }` to control polling order.75- Reference: <https://docs.rs/tokio/latest/tokio/macro.select.html#cancellation-safety>7677### Spawned task hygiene78- `tokio::spawn(async move { ... })` with no error path: panics print to stderr but disappear. Wrap the body in a result, log on drop, or use a supervisor pattern.79- `JoinHandle`s dropped without `.await` → task runs detached. Fine if intentional; flag as Minor if it looks accidental.80- Spawning from a `!Send` context will fail to compile; spawning a `!Send` future on the multi-thread runtime ditto. Use `tokio::task::spawn_local` + `LocalSet` if the future is `!Send` on purpose.8182### Channels83- `tokio::sync::mpsc` for backpressure (bounded). `unbounded_channel` only when you can prove the producer can't outrun the consumer — otherwise memory leak.84- `tokio::sync::broadcast` for fan-out with lag tolerance; `watch` for "latest value only".85- `oneshot` for single-reply request/response.8687### Retry / backoff loops88- Pure `loop { ... tokio::time::sleep(...).await; }` is fine, but:89 - Use `tokio::time::sleep_until` if you need wall-clock anchoring.90 - Add a max-attempts cap; an infinite loop in a sidecar with no exit path is a hang.91 - Use `tokio::select! { _ = shutdown.recv() => break, _ = sleep(...) => {} }` so the loop respects shutdown signals.92- Arithmetic in backoff: use `.saturating_mul()` / `.saturating_add()`, not plain `*` / `+`. A comment claiming "saturating" near plain arithmetic is a Major correctness smell.9394### Tracing in async95- `#[tracing::instrument]` on async fns is great; it ensures spans follow tasks.96- Bare `info!`/`debug!` inside `tokio::spawn` will inherit the *spawning* span, not a fresh one — usually desired, but flag if span context looks wrong.9798### Tests99- `#[tokio::test]` is fine; `#[tokio::test(flavor = "multi_thread")]` if the test needs real concurrency.100- Sleep-based tests are flaky; prefer `tokio::time::pause()` + `advance()` for time-dependent logic.101- Tests that touch global state (env vars, current dir, signal handlers) cannot run in parallel with `cargo test` — use `serial_test` or single-thread.102103## Report format104105```106## Async audit — <scope>107108### Major109- [`path.rs:L`] <issue> → <fix>110111### Minor112- [`path.rs:L`] <issue> → <fix>113114### Cancellation review115<one paragraph per `select!` block flagging cancel-safety>116117### Lock audit118<table of every sync Mutex/RefCell and whether it crosses .await>119120### Recommended next step121<single highest-leverage fix>122```123124---125> Source: [outsideorbit/vaulpner](https://github.com/outsideorbit/vaulpner) — distributed by [TomeVault](https://tomevault.io).126<!-- tomevault:4.0:skill_md:2026-05-23 -->