# 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`](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`](analysis/hosts-review.md) | | the one finding to take away if you take one | *Ship one driver*, in [`analysis/hosts-review.md`](analysis/hosts-review.md#what-i-would-ship) | | what it costs per step, per host | [`hosts/bench/README.md`](hosts/bench/README.md) | | whether it survives a larger program | [`scale/README.md`](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 `/abi/`) is copied, not shared, so a directory can be handed to someone entire. [`hosts/check-sync.sh`](hosts/check-sync.sh) polices the copies against drift. Beside the four sits [`hosts/bench/`](hosts/bench/README.md): 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`](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). ```sh 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////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.