Rust Tokio Async skill for Codex
Tasks that start, stop and cancel on purpose. Codex writes Tokio code where every spawned task has an owner, every select! branch is checked for cancellation safety, and blocking work moves to spawn_blocking. It wires graceful shutdown with CancellationToken and TaskTracker under a deadline, adds timeouts on network waits, and tests time-dependent logic with paused time instead of real sleeps.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- rust-tokio-async Installs to ~/.agents/skills/rust-tokio-async/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install rust-tokio-async The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project rust-tokio-async Installs to .agents/skills/rust-tokio-async/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/rust-tokio-async.zipunzip -o rust-tokio-async.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) rust-tokio-async PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $rust-tokio-async, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Writes and debugs async Rust on the Tokio runtime - spawning and joining tasks with JoinSet, tokio::select! and cancellation safety, CancellationToken graceful shutdown, timeouts, channels (mpsc, oneshot, broadcast, watch), spawn_blocking for CPU or blocking I/O, and fixing "future is not Send" errors from guards held across await. Use when building a Tokio service or worker, when a task hangs, leaks or never shuts down, when select! loses messages, or when rustc says a future cannot be sent between threads.
Use it when
- You are building or reviewing a Tokio service, worker or async CLI
- Tasks hang, leak, or keep running after shutdown starts
- A tokio::select! loop loses data or starves a branch
- rustc reports that a future is not Send
Use something else when
- The error is a plain borrow or lifetime conflict with no async involved: use rust-ownership
- You are building an HTTP API with axum routing and extractors: use rust-axum
How to check the result
Paused-time tests cover retries and timeouts, a shutdown test exits within the deadline, and clippy await_holding_lock reports nothing.
The mistake it prevents
Awaiting a non-cancel-safe future such as read_exact directly inside a select! loop, which silently discards partially read data whenever another branch wins.
Done well means: every spawned task has an owner that joins or cancels it, shutdown
completes within a bounded time, no blocking call runs on a runtime worker, every
select! branch is cancel-safe or deliberately pinned, and timeouts exist on every
network wait.
Before changing code
- Read
Cargo.toml: Tokio version and enabled features (full, or a list such asrt-multi-thread,macros,sync,time,signal). Missing features cause “cannot find macroselect” or “no functionsleep” errors. - Check whether
tokio-util(CancellationToken, TaskTracker, codecs) is already a dep. - Find the runtime entry:
#[tokio::main], a manualBuilder, or a framework (axum, tonic) that owns it. Never create a second runtime inside async code.
Runtime setup
#[tokio::main] // multi-thread, workers = CPU cores
async fn main() -> anyhow::Result<()> { run().await }
#[tokio::main(flavor = "current_thread")] // CLIs, small tools, lower overhead
async fn main() -> anyhow::Result<()> { run().await }
Use tokio::runtime::Builder::new_multi_thread().worker_threads(n).enable_all().build()?
only when you need explicit sizing or to embed the runtime in a sync program.
Spawning and owning tasks
| Need | Use |
|---|---|
| Fixed set of concurrent futures in one function, no spawn | tokio::join! / try_join! |
| Dynamic set of tasks, collect results, abort the rest on drop | tokio::task::JoinSet |
| Fire-and-track background tasks, wait for all at shutdown | tokio_util::task::TaskTracker |
| Bounded concurrency over many items | Semaphore::acquire_owned + JoinSet, or futures::stream::iter(..).buffer_unordered(n) |
| CPU-heavy or blocking sync call (std::fs, bcrypt, compression, sync DB driver) | tokio::task::spawn_blocking |
| Long CPU-bound parallel work | rayon with a oneshot back to async |
let mut set = tokio::task::JoinSet::new();
for url in urls {
let client = client.clone();
set.spawn(async move { fetch(&client, &url).await });
}
while let Some(res) = set.join_next().await {
match res {
Ok(Ok(body)) => handle(body),
Ok(Err(e)) => tracing::warn!(error = ?e, "fetch failed"),
Err(join_err) if join_err.is_panic() => std::panic::resume_unwind(join_err.into_panic()),
Err(_) => {} // cancelled
}
}
A bare tokio::spawn whose JoinHandle is dropped detaches the task: its panic is
swallowed and nobody waits for it at shutdown. Keep the handle or use JoinSet/TaskTracker.
Cancellation and select!
Dropping a future cancels it at its current .await. select! drops every losing branch
each time it runs, so a branch future that has consumed data internally loses it.
| Operation | Cancel-safe in select! loop? |
|---|---|
mpsc::Receiver::recv, broadcast::Receiver::recv, watch::Receiver::changed | Yes |
TcpListener::accept, CancellationToken::cancelled, sleep, Interval::tick | Yes |
AsyncReadExt::read into a buffer you own | Yes (no data lost) |
read_exact, read_to_end, read_line, write_all | No |
Mutex::lock, Semaphore::acquire | Yes, but you lose your place in the queue |
Your own async fn that awaits more than once with state | Assume no |
For a byte stream, wrap the reader in a tokio_util::codec::FramedRead so each branch
pulls whole frames from a cancel-safe Stream. For a one-off non-cancel-safe future
(an upload, a multi-step handshake), create it once outside the loop and poll it by
mutable reference so losing a race does not restart it:
use futures::StreamExt;
use tokio_util::codec::{FramedRead, LinesCodec};
let mut lines = FramedRead::new(reader, LinesCodec::new_with_max_length(64 * 1024));
let upload = upload_snapshot(&client); // started once, not per iteration
tokio::pin!(upload);
let mut upload_done = false;
loop {
tokio::select! {
biased; // poll shutdown first
_ = token.cancelled() => break,
res = &mut upload, if !upload_done => { upload_done = true; res?; }
line = lines.next() => match line {
Some(line) => process(line?).await,
None => break, // peer closed
},
Some(cmd) = commands.recv() => apply(cmd),
}
}
Without biased;, branches are polled in random order. Use biased; when priority
matters (shutdown first) and accept that a busy early branch can starve later ones.
Graceful shutdown
use tokio_util::{sync::CancellationToken, task::TaskTracker};
let token = CancellationToken::new();
let tracker = TaskTracker::new();
for i in 0..workers {
let token = token.child_token();
tracker.spawn(async move { worker(i, token).await });
}
tokio::signal::ctrl_c().await?; // add SIGTERM via tokio::signal::unix on Linux
token.cancel();
tracker.close();
if tokio::time::timeout(Duration::from_secs(20), tracker.wait()).await.is_err() {
tracing::warn!("workers did not stop within 20s");
}
Each worker checks token.cancelled() in its select! and finishes the current unit of
work before returning. Kubernetes sends SIGTERM and waits terminationGracePeriodSeconds
(30 s by default); keep the shutdown timeout below it.
Timeouts and time
- Wrap every external call:
tokio::time::timeout(Duration::from_secs(5), call()).awaitreturnsErr(Elapsed)on timeout. Client-level timeouts (reqwest, tonic) are better when available. tokio::time::intervalfires immediately on the first tick and bursts to catch up after a stall; setinterval.set_missed_tick_behavior(MissedTickBehavior::Delay)for pollers.- Never
std::thread::sleepin async code; usetokio::time::sleep.
Shared state and channels
| Pattern | Choose |
|---|---|
Short critical section, no .await while locked | std::sync::Mutex (or parking_lot) |
Must hold the lock across .await | tokio::sync::Mutex (slower; rethink first) |
| Many readers of config that changes rarely | tokio::sync::watch or arc-swap |
| Work queue with backpressure | bounded mpsc::channel(n) |
| Request/response to an actor task | mpsc of commands each carrying a oneshot::Sender |
| Fan-out events to all subscribers | broadcast (handle RecvError::Lagged) |
Prefer owning state in one task (actor pattern) over a widely shared Arc<Mutex<_>>.
Unbounded channels hide overload until memory runs out; use bounded ones by default.
“future is not Send” errors
tokio::spawn requires Send + 'static. The usual causes:
// Bad: MutexGuard (not Send) lives across .await
let mut g = state.lock().unwrap();
g.count += 1;
notify().await; // error: future is not Send
// Good: scope the guard so it drops before the await
{ let mut g = state.lock().unwrap(); g.count += 1; }
notify().await;
Other sources: Rc, RefCell borrows, &mut to non-Sync data, or a Box<dyn Error>
without + Send + Sync held across an await. The compiler note “has type X which is not
Send … await occurs here” names the value; drop or replace it before that line.
Async fn in traits (stable since 1.75) does not promise Send futures; for traits used
with tokio::spawn, declare fn call(&self) -> impl Future<Output = R> + Send or use the
trait-variant crate. Async closures (async || {}) are stable since 1.85.
Gotchas
- Blocking inside async (sync file I/O,
reqwest::blocking, heavy JSON, password hashing) stalls every task on that worker. Rule of thumb: more than ~100 microseconds without an.awaitbelongs inspawn_blocking. spawn_blockingtasks cannot be cancelled; shutdown waits for them.block_oninside a runtime panics (“Cannot start a runtime from within a runtime”).JoinHandle::abortcancels at the next.await; a task in a tight CPU loop never sees it.- Holding a
tokio::sync::Mutexwhile calling code that locks the same mutex deadlocks. #[tokio::test]is single-threaded by default; race bugs may need#[tokio::test(flavor = "multi_thread", worker_threads = 2)]to show up.
Verification
#[tokio::test(start_paused = true)] // needs tokio feature "test-util"
async fn retries_after_backoff() {
let handle = tokio::spawn(retry_loop());
tokio::time::advance(Duration::from_secs(30)).await; // no real waiting
assert!(handle.await.unwrap().is_ok());
}
- Test shutdown: start the service, cancel the token, assert it exits within the timeout.
- Hangs: run with
tokio-console(console-subscribercrate, build withRUSTFLAGS="--cfg tokio_unstable") to see tasks stuck on an await or busy-polling. cargo clippyflagsawait_holding_lockandawait_holding_refcell_ref; keep them denied.
Report template
**Change:** worker pool moved from detached tokio::spawn to TaskTracker + CancellationToken
**Cancel safety:** read_frame pinned outside select!; recv branches unchanged
**Blocking calls moved:** argon2 hash -> spawn_blocking
**Shutdown:** SIGTERM to exit 1.8s in local test (limit 20s)
**Checks:** cargo test (incl. paused-time retry test), clippy await_holding_lock clean What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Rust Tokio Async"
short_description: "Tasks that start, stop and cancel on purpose"
default_prompt: "$rust-tokio-async Our Tokio worker never exits on SIGTERM, add proper graceful shutdown" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.