mcpField
A
async-rust

async-rust

github.com/rewrite-rsDevelopment1044 installsUpdated 11w ago
Repository

About async-rust

A Rust skill for developers working with asynchronous code. It focuses on concurrency correctness, executor blocking, lock usage across awaits, and cancellation safety, with guidance on runtime and shared state choices.

Key features

  1. 01Concurrency and cancellation correctness
  2. 02Avoiding executor-thread blocking
  3. 03Lock handling across await points
  4. 04Runtime and shared-state guidance
  5. 05Async lint and test verification

Use cases

  1. 01Reviewing Rust async code for deadlocks
  2. 02Handling cancellation in multi-step operations
  3. 03Choosing channels for task communication
  4. 04Checking async changes with Clippy and tests

Install

Copy the command and run it in a terminal.

$npx skills add https://github.com/rewrite-rs/skills --skill async-rust

After installing, type/async-rustin Claude Code to confirm it loaded.

Bundled filesagents/openai.yamlCANCELLATION.md

Details

From the project README

Async Rust

Async Rust fails in three places: an executor thread that gets blocked, a guard held across an await point, and a future dropped mid-operation. This skill governs correctness under concurrency and cancellation — it does not decide whether the code should be async in the first place.

Async is not free concurrency

An async fn yields a future that does nothing until polled. Concurrency comes from the runtime and from combining futures — join!, select!, spawn — not from the async keyword:

// Serial: the second fetch starts only after the first completes.
let a = fetch_user(id).await;
let b = fetch_orders(id).await;

// Concurrent: both futures are polled at the same time.
let (a, b) = tokio::join!(fetch_user(id), fetch_orders(id));

Every .await in a function adds to the size of the generated future, and a large future is copied on every move — into Box::pin, into a JoinSet. Many awaits and large locals mean kilobytes; box the inner future or split the function once a profile says the moves matter.

Runtime choice, once, at the top

Pick a runtime, in the overwhelming majority of cases tokio, deliberately. The library rules: stay runtime-agnostic if you can, feature-gate the integration if you cannot, and never start a runtime inside a library function (#[tokio::main] belongs in a binary or test; a Runtime::block_on there hands the consumer a second, conflicting executor).

Blocking work in an async context

A CPU-bound loop, a synchronous file or network call, or a compute stretch without an .await inside an async fn stalls the executor thread and starves every other task on it. The fix: tokio::task::spawn_blocking for blocking I/O and short CPU work, a dedicated thread pool (rayon) for sustained CPU work, and never a std::thread::sleep inside a task. For a compute loop, yielding periodically is the weaker fix — it only helps if the chunks are genuinely small.

Send, Sync, and the spawn bound

tokio::spawn requires Send + 'static: everything held across an .await must be Send — where Rc and RefCell fail to compile, and where a std::sync::MutexGuard held across an .await becomes a deadlock:

// Deadlock waiting to happen: the std guard is held across the await.
let mut guard = state.lock().unwrap();
guard.value = fetch().await;

// Either drop the guard before awaiting…
let value = fetch().await;
state.lock().unwrap().value = value;

// …or use the async-aware mutex when the lock genuinely must span the await.
let mut guard = state.lock().await; // tokio::sync::Mutex
guard.value = fetch().await;

Prefer std::sync::Mutex unless the lock must span an .await, then tokio::sync::Mutex — clippy::await_holding_lock catches the common case.

Cancellation safety

Any future can be dropped at any .await point — that is what select! and timeouts do. A future that has consumed input but not yet committed it loses data when dropped mid-flight. The rule: do the irreversible step in one non-cancellable piece, or make the operation resumable. The worked cases live in CANCELLATION.md.

Shared state across tasks

Arc<Mutex<T>> for shared mutation, channels for handing work between tasks, and message passing when the state has one logical owner — a task that owns its state and is told what to do beats tasks that reach into shared memory.

ChannelUse for
mpscMany producers to one consumer
oneshotA single reply
watchThe latest value, where missing intermediate values is correct
broadcastEvery receiver seeing every message, with a bounded backlog

The choice is semantic, not performance — picking broadcast where watch was meant produces a lagging receiver error nobody expected.

Dynamic task groups and their abort semantics are in CANCELLATION.md.

Async in a public API

Prefer async fn over -> impl Future unless the extra control is needed — the desugared form additionally promises Send-ness (or its absence) in ways the caller then depends on. In traits, async fn still lacks Send bound sugar, and #[async_trait] remains the pragmatic choice for object-safe async traits.

Deferrals

Error types for timeouts and cancellation are /rust-errors; shared-ownership, /ownership-not-clone; public-surface commitments, /rust-api-design; the Pin invariants of a manual Future impl, /unsafe-rust.

Verification

cargo clippy --all-targets   # add -- -D warnings only if the repo has no lint config
cargo test

The async-relevant lints (await_holding_lock, await_holding_refcell_ref, async_yields_async) come out of its output, not switched on. Async bugs are timing-dependent — a passing cargo test is weak evidence: where the repo uses #[tokio::test], cover the changed path with #[tokio::test(flavor = "multi_thread", worker_threads = 2)].

Comments

Markdown supported