effect-routines: four ways to write one interleaved-effect program #
2026-09-10
Companion documents in this directory:
| file | contents |
|---|---|
strawman.md |
the weak case for effect routines over sans-io, and a sans-io advocate taking it apart |
steelman.md |
the strong case, conceding what sans-io genuinely has |
prior-art.md |
who else drives coroutines by hand, and how close they come |
evidence.md |
the sourced citations the arguments rest on: measurements, Kotlin, Eizinger, JEP 444, what has not shipped |
ffi.md |
what driving both machines from a multithreaded Java host taught us; where the designs converge |
coeffects.md |
the coeffect reading expanded: six advantages of host-owned vocabularies, their costs, and when to use which |
deadlock.md |
why waiting actors face deadlock, TOCTOU, or redo regardless of mechanism; the six mitigations and their costs; channel levels |
hosts-review.md |
the four hosts/ approaches measured and judged: lines, imports, edits per new wait, native path, attenuation; what to ship and what to fix |
hosts-prior-art.md |
what hosts/ borrowed — tagless final, sans-io tokens, zlib/SQLite/quiche-shaped C ABIs, UniFFI/Diplomat — and the four things not found elsewhere |
Question #
Can we get the scheduler control and testability of sans-io in Rust without hand-writing the state machines, while keeping the core logic free of any async runtime and IO, no_std, buildable for wasm32, and drivable from tokio, a single-threaded Wasm loop, or a foreign runtime (Java, Python, Go) over FFI?
The candidates were:
| name | mechanism |
|---|---|
tokio-direct |
plain tokio: direct style, runtime everywhere |
sansio |
hand-written state machine, step(input) -> effects |
coro |
stackful coroutine (corosensei) |
effect-routine |
async fn as a runtime-free coroutine, polled by the host |
Each implements the same program against the same host, so the differences are attributable to the mechanism.
What to call it #
Effect routines. An effect routine is an isolated coroutine, written in direct style, whose every wait is a typed effect answered by whoever drives it. The name is literal on all three counts: -routine places it in the subroutine/coroutine/goroutine family (a unit of code you have many of, written as straight-line code), and effect says what crosses its boundary.
The word does double duty as pattern and unit — "effect routines" is the design, "an effect routine" is one of them — which none of the two-word candidates managed.
Four axes #
| axis | what it answers | status |
|---|---|---|
| mechanism | how does one of them suspend? | built: stackless coroutine, async fn polled by hand, no waker |
| effects | how does it wait on the world? | built: perform/request, typed Reply<T>, host loop as handler |
| unit | what is the unit of state and parallelism? | unit built: isolated, Send, exclusive (try_lock → Busy), identity. Messaging and supervision not built |
| boundary | where does the scheduler live? | built: outside the runtime — host owns scheduling, time, and IO |
The unit axis is where the multithreading comes from, and it is why "effect handlers" alone under-describes this: in OCaml 5 or Effekt, handlers give you concurrency within one computation, and parallelism is a separate mechanism. Here parallelism is many routines placed on threads by the host, which is the actor model's story. The Send bound on spawn is the whole of the type-level requirement.
Relationship to the neighbours #
Algebraic effects. The semantics map one-to-one, and the vocabulary is worth borrowing:
| here | effects literature (Eff, Koka, OCaml 5) |
|---|---|
Effect enum |
the effect signature |
ctx.perform(..) |
perform |
Reply<T> |
the one-shot continuation (OCaml continue, Kotlin Continuation) |
the host's resume loop |
the effect handler |
Status::Stalled |
an unhandled effect |
Precisely: one-shot algebraic effects with the handler outside the runtime. Read from the other side, Ctx<E> is the coeffect — what the routine requires of its context — and compare/coeffects makes that reading fine-grained (§"Effects and coeffects").
Actors. An effect routine is an actor whose waits are typed effects instead of selective receive, and whose scheduler is outside the process rather than in a VM. Orleans grains are the closest deployed relative (turn-based, runtime-scheduled, direct-style async bodies); see prior-art.md §5. An actor system in the full sense is what you get when an effect's handler is another routine rather than the host, with the host as router — that layer is not built.
Cellular automata. A useful image for the external-stepping property, and the origin of this crate's first name: cells with local state, advanced only when something outside steps them, many in parallel. Ours are asynchronous and heterogeneous and communicate by explicit effects rather than an implicit neighbourhood, so it is a metaphor rather than a model — but "cellular automata, except you write the effects and the compiler writes the automaton" is a fair one-line pitch.
It is not green threads (no stacks), not CPS at the boundary (the host never receives a callback into Rust — the property that made the Java side of ffi/ trivial), and not a runtime.
Names considered and rejected #
automata (the generic word for the machines, and a sans-io state machine is literally an automaton — it pointed at the competitor); outboard (good on the boundary axis, silent on the unit, which is where the parallelism lives); outsourced effects (negative connotation — abdication, not delegation); waitless (accurate about threads, false about program semantics, collides with "wait-free", a formal progress guarantee we do not meet); upwait (Algesten's ".await releasing up instead of down" — precise, but taken and typo-adjacent); effing (right family, but a minced oath, and effing-mad already owns the pun for the nightly-Coroutine version of this idea); performers (good unit noun, but needs the effects dialect to parse and says nothing about direct style); grain (Orleans owns it, and it promises virtual activation we do not have); cellular effects (all three axes, but needs a Wolfram disclaimer and gives no unit noun).
The runner crate is effectory — -ory names a place, so the effectory is where effect routines run, complementing the pattern name rather than competing with it as effectron would have. The repository is effect-routines.
How light it is #
The runner needs almost nothing, and that is a large part of the argument. Everything below is checked by the build, not asserted:
| what the runner does not need | detail |
|---|---|
std |
#![no_std] + alloc; the std feature only swaps the mutex and adds catch_unwind |
| an async runtime, executor, or reactor | none; the host calls resume() |
a Waker implementation |
polls with core::task::Waker::noop() |
| macros of any kind | zero proc-macros, zero macro_rules!; automata are plain async fns with no attributes |
unsafe |
#![forbid(unsafe_code)] workspace-wide; the runner has zero unsafe blocks |
| dependencies | one: spin (for no_std); none of its own under std. No futures, no pin-project |
Pin gymnastics |
one Box::pin at spawn; Perform is Unpin by construction |
| trait bounds on user code | automaton authors write async fn run(ctx: Ctx<E>); Send is checked once, at spawn |
| a wire format | Effect is whatever enum the application defines |
| nightly | stable Rust ≥ 1.85 (Waker::noop) |
Size: ~350 lines of reusable mechanism in runner/src/lib.rs plus a 26-line mutex shim, before tests and docs. Builds for x86_64, wasm32-unknown-unknown, and (with the documented SPSC-slot swap) bare-metal targets without CAS atomics.
For contrast: tokio-direct brings tokio and its proc-macros; coro brings corosensei's per-architecture assembly and mmaped stacks; genawaiter and Embassy lean on proc-macros (gen!, #[embassy_executor::task]), and Embassy historically required nightly for type_alias_impl_trait; the user's own future_form needs a proc-macro to generate Send/!Send impl pairs. The effect-routine runner is the smallest thing in this comparison that still gives direct-style code.
The program #
loop {
write("Who are you?")
name = read_line() -- wait (query)
if name == "quit" { write("Bye."); return }
greeting = lookup(name) -- wait (query)
sleep(50ms) -- wait
write("{greeting}, {name}!")
n = count() -- wait (query)
write("(greeted {n} so far)")
}
Four waits per iteration, each a different kind of effect, with fire-and-forget commands (write) interleaved between them. Two participants, alice and bob, run concurrently with scripted input. This is small, but it has the property that hurts sans-io: sequential logic with locals that live across several waits (name survives three of them).
Results #
Effort #
Non-blank, non-comment lines. Program = the no_std library (or the inseparable single file for tokio). Host = Host, greeting_for, drive, main; near-identical by construction. Shared runtime = reusable machinery the program leans on, written once per project rather than per program.
| approach | program | host | core function only | shared runtime |
|---|---|---|---|---|
tokio-direct |
104 | – | 15 | tokio |
sansio |
112 | 67 | 67 | none |
coro |
45 | 57 | 15 | corosensei |
effect-routine |
30 | 59 | 15 | 352 (effectory) |
coeffects |
59 | 119 | 15 | 352 (effectory) |
The "core function" column is greeter, or for sansio the impl Greeter plus the four impl Answer transitions. Three of the four are the same fifteen lines of direct-style code. Sansio is four times that. About half of its extra is the defunctionalization itself — a six-variant state enum, locals threaded from variant to variant, the loop body smeared across the first and last transitions — and the other half is the price of typed replies in this encoding (see below): four reply newtypes, a Token<R> witness, an Answer trait, and one impl block per reply kind, each with its own state match and unreachable arm. Before typed replies were added, sansio was 63 / 29 with an Input enum and a single catch-all arm.
The last column is the one to read carefully, because the program column alone flatters effect-routine. Sansio pays for its typed-reply machinery inside its 112 — roughly 19 lines of Token, its Debug impl, the reply newtypes, and the Answer trait — whereas the equivalent for effect-routine lives in effectory and appears nowhere in the program column. For a single program the honest totals are 112 for sansio against 382 for an effect routine. The 352 is paid once and the 30 per routine, so the crossover is at about one routine and everything after that favours the runner; but a reader comparing one program should see both numbers. coro sits in between, carrying its own 16-line Reply and perform helper in-crate and depending on corosensei for the stack switching.
The "program" column shows what each mechanism makes you carry besides the logic. coeffects is effect-routine with the vocabulary turned inside out — request structs and requirement bounds in the program, the enum in the host — and its host column is large because the host now owns the enum and its From impls (55 lines); see §"Effects and coeffects". tokio-direct cannot be split: the actors, channels and IO are the program. The fan-out variant lives in compare/effect-routine/src/impatient.rs (19 lines) and is excluded from the table, since only one of the five approaches can express it at all.
Typed replies in both #
Both sansio and effect-routine now make a wrong-typed answer a compile error, by different means:
effect-routine |
sansio |
|
|---|---|---|
| the effect carries | Reply<T>: an Arc<Mutex<Option<T>>> slot |
Token<R>: a zero-sized, unforgeable, single-use witness |
| the host answers by | reply.send(value) |
greeter.answer(token, value) |
| where the transition lives | the code after .await |
an impl Answer for R block |
| cost of adding a reply kind | one enum variant | one newtype + one impl block with its own state match |
| state stays plain data | n/a (state is the future) | yes — the token lives with the host, not in the enum |
The sans-io version is the "ghost of a departed proof" pattern: the machine mints a Token<R> only when it enters the state that awaits R, answer consumes it, so the wrong-state arm in each impl is unreachable through the public API (documented as such, since the compiler cannot see it). It keeps everything sans-io stands for — no IO, no cells in the state, deterministic, no_std, zero dependencies, builds for thumbv6m — and it is not the common sans-io idiom (quinn-proto and str0m use input enums). The nearest precedent is rustls's unbuffered API, whose typed state objects the host must consume (from memory).
The asymmetry in cost is the interesting part. In an effect routine the reply type and the continuation are the same thing — let name = ctx.perform(Effect::ReadLine).await — so one generic Reply<T> covers every kind of wait. In sans-io the continuation is a separate piece of code that must be attached to the reply type by hand, so every kind of wait costs an impl. Typed replies are nearly free in one encoding and ~50 lines in the other, for the same guarantee.
Portability #
| crate | x86_64 | wasm32-unknown-unknown | thumbv6m-none-eabi | reason |
|---|---|---|---|---|
sansio |
yes | yes | yes | core + alloc only |
coro |
yes | no | yes | corosensei has no wasm32 backend |
effect-routine |
yes | yes | no | Would be yes in production but Brooke doesn't want unsafe in this experimental repo |
effectory |
yes | yes | no | same |
tokio |
yes | n/a | n/a | std by design |
Verified with cargo build --lib --no-default-features --target <t>. The thumbv6m gap for the runner is a dependency choice, not a design limit; the fix is documented in runner/src/sync.rs.
Scheduler control #
Only effect-routine (and sansio) let the host own time. extras/schedulers runs the unchanged effect-routine program under three drivers:
| driver | threads | clock | result |
|---|---|---|---|
virtual |
1 | virtual | 150 ms of sleeps simulated in ~100 µs, deterministic order |
round-robin |
1 | real | ~250 ms wall; interleaving without a runtime |
crash |
1 | real | one automaton panics; it alone is killed; others finish |
coro could do the same in principle, but its !Send coroutines pin the "which thread" decision. tokio-direct needs tokio::time::pause and turmoil-style wrapping to approximate it.
Three hosts, one program #
extras/tokio-driver adds tokio as a third host for the unchanged effect-routine and sansio programs. The driver performs effects with real async IO — tokio::time::sleep, tokio stdout, mpsc input, and the mpsc+oneshot lookup and counter actors lifted verbatim from tokio-direct — and tokio::spawn on the multi-thread runtime moves each machine between worker threads on every step. A #[tokio::test(start_paused = true)] runs the same driver under tokio's virtual clock and passes in 0 ms wall.
| host | scheduler | time | program changes |
|---|---|---|---|
extras/schedulers virtual |
hand-written, 1 thread | virtual heap | none |
ffi/java |
Java pool + timer thread | Java timers | none |
extras/tokio-driver |
tokio multi_thread | tokio::time, real or paused |
none |
Driver cost: drive_automaton is 20 lines and drive_sansio 22, over 35 lines of shared plumbing. Against tokio-direct (104 lines, program and host inseparable), the effect-routine split is 29 lines of portable program plus ~55 of tokio host. More lines in total; the 29 are the ones that run on Wasm, under Java, and in virtual time. Each host's driver loop is the same eight lines in a different dialect — which is the practical content of "host-owned scheduling".
FFI: driven from Java, multithreaded #
ffi/ proves the host-driven claim with a foreign host. ffi/rust is a cdylib exposing ten extern "C" functions over both the effect-routine greeter (via effectory) and the sans-io greeter; ffi/java is a Panama (java.lang.foreign, JDK 21 preview) client with no JNI glue and no upcalls — Rust never calls Java. Handles are generational u64s, not pointers, so a stale handle is BAD_HANDLE rather than a fault. Effects cross as a ~40-line little-endian encoding; the one typed reply slot a machine has pending is fulfilled by effect_routine_reply_string / _u32 / _unit on the machine handle.
The Java Scheduler submits every step of every machine as a separate task on a shared four-thread pool and turns Sleep into a ScheduledExecutorService timer. Consecutive resumes of the same automaton therefore land on different OS threads — the migration Send was checked for at spawn — and four conversations (alice and bob, as effect routines and as sans-io) interleave:
[alice/effect-routine @ pool-1] Who are you?
[alice/effect-routine @ pool-2] Hello, World!
[alice/effect-routine @ pool-4] (greeted 1 so far)
[alice/effect-routine @ pool-1] Bonjour, Monde!
-- 4 conversations on 4 pool threads + 1 timer thread: 184.0 ms wall
The two Java drivers are the same shape and, counting the sans-io input encoders, about the same length (effect routine 66 lines, sans-io 51 + 25). On the Rust side the two halves mirror each other: each machine kind has one table entry holding the machine and its one pending typed thing — a PendingReply slot for the effect routine, a PendingToken witness for sans-io — and the encoding is identical. The effect routine is 6 exports and 3 native calls per awaited step (fulfil, then resume with no argument); sans-io is 3 exports and 2 calls (resume with the answer as input). That API-shape difference is the whole of what the host sees; the 15-vs-67-line gap that motivates effect routines lives in the Rust program and is invisible over FFI. Details, including that both kinds of compile-time reply typing degrade to the same runtime check at the ABI, are in ffi.md.
Run with JAVA_HOME=<jdk21> ffi/run.sh [effect-routine|sansio|both].
Analysis #
Why sans-io hurts #
The sans-io cost is not lines per se; it is that the shape of the program disappears. Sequential code becomes a table of transitions, and every edit that adds a wait touches the enum, the arm before it, the arm after it, and every variant that must now carry a new local. The 29-line impl Greeter is the readable version; at ten waits and five live locals it would not be.
What sans-io buys for that price: the state is a plain value. It can be serialized, inspected, snapshot, and diffed. Greeter::Pausing { greeting, name } says exactly what the program is waiting for and what it knows. None of the other three can say that.
Where sans-io fits: the wire, not the internals #
The successful sans-io crates share a shape. quinn-proto is the QUIC state machine: packet in, packet out, timers exposed as "next timeout". str0m is the same for WebRTC; quiche for QUIC; rustls has an unbuffered API of the same kind. In each, the control state is the domain state — an RFC draws it as a state diagram, and the states have names peers agree on (Handshake, Established, Draining). There are few sequential steps, many concurrent sub-machines, and the natural unit of work is one datagram. Hand-written enums are the right encoding for that, and the transitions are the specification.
The transitions belong in the library, not the driver — that is what makes it sans-io rather than an ordinary IO program that happens to track state. compare/sansio follows the rule: start, answer, and the four impl Answer transitions live in lib.rs, and the same unmodified sansio::Greeter is driven by three hosts in this repo — the threaded main.rs, the async extras/tokio-driver, and Java through ffi/. Hoisting the transitions into a driver would delete two of those three.
Notice what sits on top of them. quinn wraps quinn-proto in tokio tasks and async fns, because the application's view of QUIC — open a stream, write a request, await the response, close — is sequential, and nobody wants to hand-write that as an enum. str0m is driven by an event loop the user writes. The sans-io core handles the wire; the layer above it handles the choreography; and the boundary between them is exactly where the effect vocabulary lives.
The Firezone thread on Hacker News (story 40872020) is a live demonstration of what happens when sans-io is pushed up into the internals. The author's Node composes N TURN clients and M ICE-plus-WireGuard connections over one UDP socket with &mut everywhere — a strong case for sans-io at the packet layer. But the critique that landed (joshka, comment 40972351) was about the code above that layer: "Your approach replaces a linear method with a driver that calls into a struct that explodes out each line of the method… the node code feels a bit spaghetti to me as an outsider, because of the sans-io abstractions, not in spite of it." The author's own concession (40983122): "If the protocols would have more steps (i.e. more .await points in an async style), then it might get cumbersome and we'd need to think about an alternative."
That is the division of labour this workspace argues for. Sans-io for the wire protocol, where states are nouns and the diagram is the spec. Effect routines for the internals — request/response choreography, orchestration, application conversations — where the "state" is just how far through a recipe you are, and the recipe is best written as a recipe. The greeter is the second kind, and so are most of the automata a distributed application would spawn. A production system would likely have both: a sans-io transport core emitting events, and effect routines consuming them in direct style.
Effects and coeffects #
The effect-routine design is an algebraic effect system with the handler outside the runtime (§"What to call it"). Effects describe what a computation does to its context; coeffects (Petricek, Orchard, Mycroft) describe what it requires from its context — resources, capabilities, implicit parameters, bounded reuse. Gaboardi et al. (ICFP 2016) treat the two as dual gradings of one computation, and Effekt ("Effects as Capabilities", Brachthäuser et al. 2020) shows effect handlers lowering to capability passing. In our terms both are already present, one fine-grained and one coarse: the emitted Effect values are the effect; Ctx<E> is the coeffect, and today it demands the whole vocabulary E of every automaton.
compare/coeffects makes the coeffect fine-grained. The program defines request structs (ReadLine, Sleep, …) that name their reply type, and states its requirements as bounds on its context type; the host defines the enum and implements From for what it provides. They meet at spawn, where the compiler checks provision against requirement:
pub async fn greeter<E: GreeterEnv>(ctx: Ctx<E>) // GreeterEnv = From<Pending<ReadLine>> + From<Pending<Sleep>> + … + From<Write>
pub async fn ticker<E: TickerEnv>(ctx: Ctx<E>) // TickerEnv = From<Pending<Sleep>> + From<Write>
AutomatonCell::<Full>::spawn(greeter); // ok: Full provides all five
AutomatonCell::<Quiet>::spawn(ticker); // ok: Quiet provides a clock and an output
AutomatonCell::<Quiet>::spawn(greeter); // error[E0277]: Quiet: From<Pending<ReadLine>> not satisfied (compile_fail doctest)
What is different from effect-routine, and what is not:
effect-routine |
coeffects |
|
|---|---|---|
| runner, poll loop, slots, host driver, FFI bytes | same | same (request and Pending<R> are additive, 27 lines) |
| who owns the effect enum | the program | the host |
| an automaton's demand on its host | the whole enum, implicitly | its bound list, in the signature |
| pairing check | host's exhaustive match |
trait bounds at spawn |
| attenuation | runtime filter | a narrower vocabulary type; ticker as AutomatonCell<Quiet> cannot ask for input, statically |
| typed replies | Effect::ReadLine(Reply<String>), closure perform |
Request::Reply associated type, ctx.request(ReadLine).await: String |
| grading | one Ctx |
split capabilities and pass by value for "at most once" (not exercised) |
greeter body |
15 lines | 15 lines, identical shape |
| program crate | 29 | 59 (request structs and impls 22, requirement aliases 10) |
| host crate | 62 | 119 (Full and Quiet enums with From impls, 55) |
The binary does the same thing. What moved is the sum type: from a closed enum the program dictates to a set of From impls the host chooses. That is the whole difference between an effect and a coeffect reading of this design, and it is why the two readings are dual rather than distinct systems. It costs lines (one From per host-request pair, a bound row per automaton) and buys three things: per-automaton capability footprints visible in signatures — the ocap "what can it do" question answered by the type checker; composition of automata with different footprints on one host without a god-enum; and attenuation by type. The nearest deployed relative is Scala 3's capture checking; the nearest ABI-level relative is a WIT world, whose imports are exactly a coeffect signature checked at instantiation.
For one automaton and one host, none of it is observable and effect-routine is the right choice. The value appears with several automata of different footprint, or with hosts that legitimately lack capabilities — a Wasm guest without a clock, a test host without IO — which is the situation this workspace's goals describe. The advantages are expanded in coeffects.md.
Why async is the only stackless coroutine that reaches Wasm #
Rust has one stable stackless coroutine — async fn — specialised to poll() -> Ready | Pending. The general form, core::ops::Coroutine (resume(input) -> Yielded | Complete), has been nightly since 2017 and its stabilisation path (gen blocks) does not include resume arguments.
Stackful coroutines (corosensei) give the nicest ergonomics: arbitrary code, recursion, no pinning, borrows across yields. They need a stack to switch to, and Wasm has no accessible stack pointer. Asyncify can fake it at roughly 2x code size and speed; WasmFX is phase 3. If Wasm is required, stackful is out, and the Rust ecosystem's !Send-by-necessity Coroutine type is a second strike for hosts that move work between threads.
That leaves async fn as a state-machine generator, with the executor, Waker, and IO removed. core::future is no_std; Waker::noop() is stable; Pin is needed exactly once. The result is a coroutine that runs wherever Rust does.
flowchart LR
A[direct-style program] -->|rustc lowers| B[state machine]
B -->|Box::pin once| C[AutomatonCell]
H[host loop] -->|resume| C
C -->|effects| H
H -->|reply.send| R[Reply<T>]
R -.->|read on next poll| C
What the runner is #
The reusable crate is effectory — -ory names a place, and it is where effect routines run.
AutomatonCell<E> Mutex<Inner { future: Option<Pin<Box<dyn Future + Send>>>, slot }>
resume() try_lock → poll with noop waker → drain effects → Status
resume_catching() std only: catch_unwind → Panicked
Ctx<E> the routine's only handle on the world
tell(Effect::W, arg) fire-and-forget; no suspension
ask(Effect::R) no argument, awaits a reply
call(Effect::L, arg) argument and reply
emit(E) / perform(f) the primitives the three are built on
request(R) / announce(M) the coeffect path: E: From<Pending<R>> / From<M>
Tell<A> / Ask<R> / Call<A,R> the three shapes, carried inside the effect enum
Reply<T> Arc<Mutex<Option<T>>>; the slot a host answers into
Perform<'_, E, T> lazy future; .await, or .start() to fire now
Oneshot<T> handle from .start(); .await or .try_recv()
Status Awaiting | Complete | Stalled
ResumeError Busy | Finished | ReplyMissing | Panicked
352 lines of mechanism, 466 including tests and docs; zero unsafe, one dependency (spin, unused when std is on). It was 222 before the Tell/Ask/Call vocabulary (+55) and fan-out (+75) were added — both bought expressiveness rather than saving program lines, and the trade is deliberate.
The contract is small:
- Any number of requests may be outstanding. The host must answer at least one per resume or it gets
ReplyMissingrather than a hang; it need not answer all. - Abandoning a request — dropping a
Oneshot, or aPerformthat lost aselect!— stops the routine waiting on it. - Await anything other than a request →
Stalled. Resume from two threads at once →Busy. - A panic mid-poll drops the future (drop guard); the cell is
Finishedthereafter.
Fan-out: the thing a state machine cannot copy #
Perform is lazy, like every Rust future — ctx.call(…) without .await emits nothing. start() emits immediately and hands back a Oneshot, so several requests can be in flight at once:
let nap = ctx.call(Effect::Sleep, PAUSE).start(); // emitted into this batch
let greeting = ctx.call(Effect::Lookup, name).await; // host receives both together
nap.await; // collect
The host gets [Sleep, Lookup] in one batch and may perform them concurrently, so the routine waits max(sleep, lookup) instead of the sum. compare/effect-routine/src/impatient.rs is the whole diff: three lines, in its own module so the baseline line counts stay comparable. extras/tokio-driver runs the timers concurrently with the rest of each batch to show the effect.
Sans-io cannot follow. Waiting on two things at once costs a hand-written machine a state per subset of outstanding waits — AwaitingBoth, AwaitingSleepOnly, AwaitingLookupOnly — and the cross-product grows with every concurrent wait added. This is Boats' "heterogeneous select is the point of async Rust" as a concrete, measured difference rather than a claim: one line for an effect routine, a state explosion for the enum.
Not yet across FFI. ffi/rust still keeps one pending reply per machine, so fan-out is Rust-side only until the wire encoding grows a request id (see ffi.md §3, which predicted exactly this trigger).
Typed replies #
The first version had one Input enum for all replies. Every wait needed let Input::Line(x) = … else { violation() }, coro needed an Input::Ack for fire-and-forget effects, and a host could reply with the wrong variant.
The fix is the one tokio-direct already uses: put the reply channel in the request. Effect::ReadLine(Reply<String>) means the host cannot answer with anything but a String, the automaton never matches on a reply, and ctx.perform(Effect::ReadLine).await type-checks because a tuple-variant constructor is already FnOnce(Reply<T>) -> E. This removed the Input enums and violation() from both coro and effect-routine, and is the pi-calculus "channel handle inside a message" idea made concrete. Sansio is unaffected: its mismatch risk is state × input, intrinsic to explicit state machines.
Auto traits: state them once #
The FFI pain with async Rust is usually not async itself but auto traits leaking through APIs: every K::Future<'_, T> in a trait signature is a place where Send, Sync, UnwindSafe must be decided, and the matrix doubles with each one. Here the future type is named in exactly one place — the runner's slab entry — so:
Sendis a bound onspawn, inferred from each automaton's body and checked at the call site. Automaton authors never write it.Syncis provided by the cell's mutex; the future never needs it.UnwindSafeis handled once byAssertUnwindSafeinresume_catching, sound because the drop guard has already destroyed the state a panic could have corrupted.
Send is genuinely required for the FFI case: a Java pool may resume the same automaton on different threads over time. That is migration, which is what Send certifies; exclusivity is the mutex's job.
Why the mutexes exist #
Nothing in the runner is contended. The three locks are there because a shared slot needs a Sync interior-mutability primitive (for the future to be Send) and a happens-before edge between the host's send and the next poll. An uncontended try_lock is one CAS — the same cost as a hand-rolled flag.
The production replacement is a single-producer/single-consumer slot: AtomicBool with Release/Acquire plus UnsafeCell<Option<T>>. About twenty lines of unsafe, needs only atomic load/store (so it builds on thumbv6m), and drops the spin dependency. This workspace forbids unsafe_code, so the mutex stands in — std::sync::Mutex under the std feature, spin::Mutex otherwise, normalised in runner/src/sync.rs.
The remaining structural cost: waiting means returning #
Without stackful coroutines or threads, the only way to wait is to return to the runner. async hides that syntactically but not semantically: an automaton that is "in" an .await is not running and cannot service its inbox. Two servers that query each other while waiting deadlock — the gen_server:call cycle — and no type checks it.
The mitigation is structural: servers never wait; conversations do. Long- lived automata handle every input to completion; anything that needs to wait on another automaton is spawned as a short-lived child that carries the requester's Reply cap, runs the sequential protocol, and terminates. A call-graph cycle then no longer induces a wait-for cycle. This requires cheap child automata and dispatcher-owned parent/child lifetimes, neither of which the runner has yet (see TODO). It is not free: removing hold-and-wait also removes the atomicity of the sub-call, and the same deadlock exists for sans-io actors; deadlock.md works through the trade and the alternatives, including a static order on channels that keeps both.
Verdict #
| criterion | tokio | sansio | coro | routine |
|---|---|---|---|---|
| direct style | yes | no | yes | yes |
| no runtime in core | no | yes | yes | yes |
no_std program |
no | yes | yes | yes |
| wasm32 | no* | yes | no | yes |
| host owns scheduling | no | yes | partly | yes |
Send / thread migration |
yes | yes | no | yes |
| serializable wait state | no | yes | no | no |
| FFI without waker bridging | no | yes | yes | yes |
* with a shim.
For the stated goals, effect-routine is the only column without a hard failure. Its one concession — opaque wait state — was rated "nice to have at best". If that ever becomes a requirement, the recommended hybrid is to keep durable domain state in an explicit struct and let async handle only the sequential plumbing, which recovers most of sansio's introspection without its enum.
coro is the better developer experience on native and is worth keeping as a comparison point; revisit if WasmFX ships. sansio remains right for the wire-protocol layer, where states are nouns and must be inspectable (quinn-proto, str0m); the recommended architecture layers effect routines on top of such a core rather than replacing it. tokio-direct is what most code should be when none of the constraints apply.
Prior art #
The mechanism (async as a hand-polled coroutine) appears in genawaiter and cassette; the host-drives-guest-coroutines shape is what the WASI 0.3 component-model async ABI does across the Wasm boundary; the scheduler- control payoff is what shuttle, turmoil, madsim, Fuchsia's TestExecutor, and GPUI's test dispatcher exist for. Effects-as-data with a host interpreter is redux-saga in JavaScript, and a freer monad in Haskell — Effect is the functor, perform the suspend, async the do-notation, the driver the interpreter. No mainstream Rust crate combines typed reply slots, no waker, no_std, and a multi-language driver contract; wit-bindgen is the nearest, with its vocabulary fixed to WIT.
Reproducing #
# run the four comparisons and the scheduler demos
cargo run --bin tokio-direct
cargo run --bin sansio
cargo run --bin coro
cargo run --bin effect-routine
cargo run --bin schedulers -- virtual # or round-robin | crash
# runner tests on both mutex paths
cargo test -p effectory
cargo test -p effectory --features std
# prove the programs are no_std
cargo build --lib -p sansio -p effect-routine -p effectory --no-default-features --target wasm32-unknown-unknown
cargo build --lib -p sansio -p coro --no-default-features --target thumbv6m-none-eabi
Toolchain: Rust 1.91 (Waker::noop needs ≥ 1.85). flake.nix provides it.