sans-io, sans effort
sans-effort lets you write a coroutine in direct style as an ordinary async fn, where every wait is a typed effect answered by whoever drives it. No waker, no executor, no Pin in the routine; one Box::pin at the boundary. It is a sans-io state machine that the compiler writes for you — and because the routine asks for traits rather than effects, the very same code is also a plain async fn that tokio runs at native speed with no driver at all.
This is the library. The research that motivates it, with the alternatives built out and measured, lives in effect-routines-exploration:
| If you want | Read |
|---|---|
| To write one today | How to Write Effect Routines (PDF) |
| The argument, and the counter-argument | analysis/README.md |
| The same program in six styles, side by side | compare/ |
| What it costs per step, per host | hosts/bench/ |
┌──────────────────────────────────────────────────────────────────────┐
│ routine Greeter<C: Sleep + Lookup + ReadLine + WriteLine>: Run │ no_std
│ owns the logic; asks for traits; knows nothing of │
│ effects, handles, drivers, or hosts │
├──────────────────────────────────────────────────────────────────────┤
│ context impl Sleep for TokioCtx │ impl Sleep for Ctx<E> │
│ each call is a real future │ each call records an │
│ │ effect and suspends │
├─────────────────────────────────────────┼────────────────────────────┤
│ host tokio polls the task │ a Driver polls; a host │
│ directly — no driver │ performs and replies by id │
│ │ Python · JS · a test · … │
└─────────────────────────────────────────┴────────────────────────────┘
The routine is the foundation and depends on nothing above it. The left column is why you write it this way: it is also an ordinary library function. The right column is what this crate provides.
pub trait Sleep { async fn sleep(&self, d: Duration); }
pub trait Lookup { async fn lookup(&self, name: String) -> String; }
pub trait ReadLine { async fn read_line(&self) -> String; }
pub trait WriteLine { fn write_line(&self, line: String); }
impl<C: Sleep + Lookup + ReadLine + WriteLine> Run for Greeter<C> {
async fn step(&mut self) -> ControlFlow<()> {
self.ctx.write_line("Who are you?".into());
let name = self.ctx.read_line().await;
let greeting = self.ctx.lookup(name.clone()).await;
self.ctx.sleep(PAUSE).await;
self.ctx.write_line(format!("{greeting}, {name}!"));
ControlFlow::Continue(())
}
}Natively, on tokio — no driver, no effects, tokio polls the task:
impl Sleep for TokioCtx {
async fn sleep(&self, d: Duration) { tokio::time::sleep(d).await }
}
// …
tokio::spawn(Greeter::new(TokioCtx::new(stdin)).run());Behind a host — each call records a request and suspends until the host replies by id. The context is generic over the host's vocabulary E, so a host can offer a routine less than everything, and the type system enforces it:
impl<E: From<Asked<Sleep>>> traits::Sleep for Ctx<E> {
async fn sleep(&self, d: Duration) { self.outbox.request(Sleep(d)).await }
}
// …
Driver::<Full>::new(|outbox| Greeter::new(Ctx::new(outbox)).run()); // ok
Driver::<Quiet>::new(|outbox| Greeter::new(Ctx::new(outbox)).run()); // E0277: Ctx<Quiet>: Lookup
Driver::<Quiet>::new(|outbox| Ticker::new(Ctx::new(outbox), 3).run()); // ok: Ticker needs only Sleep + WriteLinedemo/ has all of this in full — the greeter, the ticker, both contexts, native hosts on tokio and on Node (wasm-bindgen), and a C-ABI skin driven from Python (ctypes) and Java (Panama) — and nix develop then demo runs all four and checks the transcripts are byte-identical.
host routine
│ │
│ start() │
│───────────────────────────────────────▶│ runs until it needs input:
│ │ tells WriteLine, asks ReadLine·1
│ [WriteLine, ReadLine·1] AWAITING │
│◀───────────────────────────────────────│
│ │
│ reply(1, "bob") │
│───────────────────────────────────────▶│ resumes; asks Lookup·2
│ [Lookup·2] AWAITING │
│◀───────────────────────────────────────│
│ │
│ reply(2, "Hello") │
│───────────────────────────────────────▶│ resumes; tells WriteLine, returns
│ [WriteLine] COMPLETE │
│◀───────────────────────────────────────│
│ ┴
- Pull-only. The routine asks for everything it needs, but by returning an effect from
start/reply, never by calling the host. No callbacks, no upcalls, so no foreign value ever enters a Rust frame — which is why the routine isSendfor free. - Typed replies. Every awaiting effect carries a
ReplyHandle<T>: the typed, single-use capability to answer it. A Rust host replies through the handle, infallibly; a foreign host replies by id, and the host layer checks the kind. - Many waits outstanding. Requests carry ids, so a routine may
jointwo waits and a host may reply in any order. no_stdcore. The mechanism, the routine, and its boundary crate all build forwasm32; the mechanism forthumbv6mwithcritical-section.
| Crate | Purpose | Target |
|---|---|---|
sans-effort |
The mechanism: Run, Outbox, ReplyHandle, Request, Driver, join, the reply menu, boundary, testing |
no_std + alloc |
sans-effort-host |
The host side for foreign hosts: a typed Machine, a byte layer, a handle table, panic isolation. No unsafe |
std |
ABI.md |
The contract a foreign host assumes | — |
demo/ |
The greeter and ticker; native contexts for tokio and for JS (wasm-bindgen, no driver); a reifying context with Full/Quiet vocabularies; a C-ABI skin driven from Python and Java |
— |
An in-process scheduler that routes between many routines; child routines; deadlock levels; language-side host SDKs beyond the demo; a derive for the View/Encode restatement (next); pyo3/rustler skins. Each is a natural next layer; none is needed to use what is here.
nix develop # dev shell with a command menu
menu # list project commands: ci:full, demo, test:no_std, …Without Nix, rust-toolchain.toml pins the toolchain for rustup; the demo's foreign hosts need Python 3, Node, and wasm-bindgen-cli at the version pinned in Cargo.toml.
Licensed under either of
- Apache License, Version 2.0 (LICENSE-APACHE)
- MIT license (LICENSE-MIT)
at your option.