effect-routines #
sans-io, sans effort
Can a Rust program keep sans-io's scheduler control and testability — a core that is no_std, free of any async runtime and of IO, buildable for wasm32, drivable from tokio, from a single-threaded Wasm loop, or from a foreign runtime over FFI — without hand-writing the state machines?
The answer this repository reaches is yes, and the thing it names is an effect routine: an isolated coroutine, written in direct style as an ordinary async fn, whose every wait is a typed effect answered by whoever drives it. No waker, no executor, no Pin in the program, one Box::pin at the boundary. The same program runs as a plain tokio task, or a Python object, or a BEAM resource, and cannot tell the difference.
This is a research repository, not a library. Everything in it exists to support or to attack that claim, and the argument lives in analysis/.
Where to start #
Read analysis/README.md first: the question, the four candidate mechanisms, the measurements, the verdict. Then, depending on what you want:
| if you want | read |
|---|---|
| the argument, with the counter-argument | analysis/README.md, then strawman.md and steelman.md |
| the four styles built out and judged | analysis/hosts-review.md |
| the one finding to take away if you take one | Ship one driver, in analysis/hosts-review.md |
| what it costs per step, per host | hosts/bench/README.md |
| whether it survives a larger program | scale/README.md |
| the code, smallest first | compare/sansio, then compare/effect-routine, then compare/capabilities |
| the code, at full size | hosts/capabilities (start at rust/core/src/lib.rs) |
Layout #
analysis/ the writing: question, arguments, prior art, measurements, verdicts
compare/ six sketches of one greeter, one crate each — the styles side by side
hosts/ the same greeter built out four ways, each with five languages of host
runner/ `effectory`: the original host-driven coroutine runner
extras/ scheduler demos (virtual time, round-robin, panic isolation) and a tokio driver
ffi/ the first C-ABI probe: three greeters, a Java host
wasm/ two greeters as wasm-bindgen modules, driven from JS
compare/ — the styles, as sketches #
One program (a greeter: prompt, read, look up, pause, count, repeat) written six ways in one crate each, so the differences are attributable to the mechanism and nothing else.
| crate | the greeter as |
|---|---|
tokio-direct |
plain tokio: direct style, runtime everywhere — the control |
sansio |
a hand-written state machine, step(input) -> effects |
coro |
a stackful coroutine (corosensei) |
effect-routine |
an async fn polled by the host, no waker |
capabilities |
generic over capability traits: runs natively or driven |
coeffects |
its requirements in its signature, as trait bounds |
hosts/ — the styles, built out #
Four of those styles taken to full size: a no_std core, a wire, the mechanism, a C ABI, and eight hosts each across five languages — Java (hand-written Panama descriptors, and jextract), Python (ctypes, and PyO3), LuaJIT, Go (cgo), Elixir (a hand-rolled erl_nif entry, and Rustler). Thirty-two host programs in all, plus a native tokio host per style.
| experiment | the program is |
|---|---|
sans-io/ |
a hand-written state machine with typed witnesses — the control |
effects/ |
an effect routine owning a closed Effect enum, which is also its wire |
coeffects/ |
the same, with the vocabulary as request structs and the host's enum chosen at spawn |
capabilities/ |
written against traits; reified by a context only when a host needs it |
Each experiment is self-contained by rule: the mechanism (rust/driver, rust/host_ffi, every <lang>/abi/) is copied, not shared, so a directory can be handed to someone entire. hosts/check-sync.sh polices the copies against drift.
Beside the four sits hosts/bench/: nanoseconds and allocations per step for every Rust layer and every host binding, with charts and raw samples. The short version is that the whole Rust stack costs ~180 ns per step, the cheapest host crossing costs that again, and the dearest costs twenty-six times it.
scale/ — does it hold at size? #
The greeter has four waits and one actor, and every claim about how the styles scale was reasoned outward from it. scale/ checks two of those claims against a library desk: three actors sharing eleven kinds of wait, replies that are structs and lists, a helper used by two actors, fan-out in three places, state carried across turns. Written in capabilities and in sans-io, one Python host each, both producing a byte-identical transcript from one script under one virtual clock.
The program is 225 lines in capabilities against 633 in sans-io; adding one wait costs capabilities +45 lines over 5 files and sans-io +78 over 4, and the sans-io edit produced a runtime panic from an arm missed in an Answer impl that compiled clean. Full numbers in scale/README.md.
Running things #
Everything works from nix develop at the repo root, which provides the Rust toolchain and the five host languages (plus jextract and cbindgen for the generated bindings, and matplotlib for the bench charts).
nix develop
cargo test --workspace # ~100 suites
cargo run -p effects-tokio # a native Rust host
hosts/effects/python/ctypes/run.sh # any of the 32: hosts/<x>/<lang>/<binding>/run.sh
hosts/check-sync.sh # the copied mechanism has not drifted
hosts/bench/run.sh # every layer, every host; writes charts and percentiles
scale/run.sh # the larger program, both styles, identical output
ffi/run.sh # the original Java probe
wasm/run.sh # the wasm-bindgen hosts (needs wasm-bindgen on PATH)
The Java hosts want JAVA_HOME set to a JDK 21 (nix develop sets it); the jextract variants want a JDK ≥ 22 in JEXTRACT_JDK.
Conventions #
- Rust is
no_stdwhere it can be. Everycore,driver, andwirecrate builds forwasm32-unknown-unknownwithoutstd;host_ffiand the skins are thestdhalf. - A library from this should ship the
Senddriver only. Effects are pull-only — the host calls in, Rust never calls out — so no foreign value enters a Rust context and nothing needs to be!Send. Where a runtime has!Sendvalues, confine them to one thread and proxy: what crosses is a channel handle. Both drivers live here because the two threading models are one of the findings; see Ship one driver in the review. - The hosts are deliberately manual. ctypes, hand-typed Panama descriptors, a hand-rolled
erl_nifentry, cgo — because the claim under test is that any host can speak the ABI with a byte buffer and no library. The generated variants (PyO3, Rustler, jextract) sit beside them so the diff is visible. - Duplication between experiments is intentional, and checked rather than removed.
- Licence: MIT or Apache-2.0.