An experimental alternative to Rust sans-io that enables the direct style
Rust 59%
Python 11%
Java 11%
Elixir 5%
Go 5%
Lua 5%
Shell 2%
C <1%
JavaScript <1%
Nix <1%

README.md

effect-routines #

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_std where it can be. Every core, driver, and wire crate builds for wasm32-unknown-unknown without std; host_ffi and the skins are the std half.
  • A library from this should ship the Send driver 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 !Send values, 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_nif entry, 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.