An experimental alternative to Rust sans-io that enables the direct style
README.md

Scale: a larger program, two ways #

compare/ and hosts/ both work on a greeter: four waits, one actor, one conversation. Every claim in analysis/hosts-review.md about how the styles scale — more waits, more actors sharing them, helpers, fan-out, live state across waits — was made from that program and reasoned outward. This directory is the check: one larger program written twice, in the two styles at the ends of the spectrum, with one host each, and the numbers that fall out.

  scale/
  ├── README.md                    this file
  ├── run.sh                       build both, run both hosts, require identical output
  ├── python/world.py              the world and a deterministic simulation clock — shared
  ├── capabilities/
  │   ├── rust/{core,wire,cdylib}  the program against traits; its wire; the C ABI
  │   ├── rust/{driver,host_ffi}   the async mechanism, copied from hosts/capabilities (check-sync polices it)
  │   └── python/{abi,ctypes}      the Python/ctypes host
  └── sansio/
      ├── rust/{core,wire,cdylib}  the same program as hand-written state machines
      ├── rust/{driver,host_ffi}   the sans-io mechanism, copied from hosts/sans-io
      └── python/{abi,ctypes}      its host

The scenario: a library desk #

Three actors share one vocabulary of eleven kinds of wait and three fire-and-forget effects. Replies are not all strings and integers: a patron record, a book record, an optional due day, and a list of loans cross the boundary as structs — encoded as bytes, since the ABI's menu is bytes, str, u32, unit.

wait reply used by
ReadLine String clerk
LookupPatron(id) Patron { name, blocked, loans } clerk, reminder, auditor
LookupBook(isbn) Book { title, available } clerk
Now day (u64) clerk, reminder
Sleep(days) () reminder
StoreGet(key) Option<u64> clerk
StorePut(key, due) () clerk
StoreDelete(key) () clerk
StoreList(prefix) Vec<Loan> clerk, reminder, auditor
Count u32 clerk, reminder
Extend(key, days) bool clerk (added in v2 — see below)
Write, Log, Notify — all

Clerk — the interactive actor. One turn is one command from a script: checkout P ISBN (look up patron and book together, check standing and availability, Now, StorePut, Count, receipt), checkin P ISBN (StoreGet, Now, fine, StoreDelete), status P (a summary), renew P ISBN (v2), quit.

Reminder — the background actor. One turn is one sweep: Sleep(7), Now, StoreList, and for every overdue loan LookupPatron and a Notify; then Count. It carries a tick count across turns and stops after five sweeps.

Auditor — one shot: three patrons' summaries at once, one line.

Two things are shared, and they are where the styles part: fine(due, now) is a pure function, used by the clerk on checkin and the reminder on every overdue loan — both styles share it as a function. summary(patron) is two waits and a format!, used by the clerk's status and three times by the auditor — capabilities shares it as an async fn; sans-io cannot, and has a sub-machine that two parents embed and step.

Fan-out appears three times in the capabilities version (join in checkout, in summary, and across the auditor's three summaries). Sans-io has one witness outstanding by construction, so each is sequential there.

The world #

Both hosts run the same world.py: three patrons (one blocked), three books (one out), a loan store, a counter, and a virtual clock — a patron's visit takes five days, Sleep(d) wakes on day now + d, and machines run as events in day order. So the two hosts' outputs must be byte-identical, and run.sh checks that they are: 53 lines of transcript, one script, both styles.

Measurements #

Stripped lines (no comments, no blanks). v1 is the desk as described; v2 adds the renew command and the Extend wait it needs — one new kind of wait, one new reply type, one new command — to both.

Size #

capabilities sans-io ratio
core — the program, v1 225 633 2.8×
core, v2 250 690 2.8×
wire 289 259 0.9×
cdylib 46 44 1.0×
Python host (codec + driver + native + main) 218 216 1.0×
shared world + simulation 65 65

Of sans-io's 633: 44 state variants, 16 Answer impls, 18 witness_violated arms, and one sub-machine (Summary, 45 lines) standing in for a 5-line async fn. The capabilities core is three structs, seven traits, and two helpers.

The wire and the host are the same size in both — as with the greeter. The program is where the styles differ, and by more as it grows: 2.1× for the greeter's conversation logic, 2.8× here.

Adding a wait #

renew: a StoreGet, then a new wait Extend(key, days) → bool, then a line.

capabilities sans-io
files touched 5 4
lines added +45 +78
of which core 18 46
of which wire 15 17
of which host 12 15
bugs on the way 0 1

The capabilities edit is a trait method, a Ctx arm, an Effect/Seen variant with its encoding, a match arm in the clerk, and the host's codec arm. The sans-io edit is all of that plus two new states, a new Answer impl, and a new arm in an existing Answer impl (Stored now arrives in two states) — and that last one is where the bug was: the arm was missed, the crate compiled, the host ran, and the clerk panicked at witness_violated on the first renew. Nothing in the type system knows that a state machine has a transition missing. The async fn version cannot have that bug, because the continuation is the transition.

Sharing a helper #

summary: capabilities, 5 lines, async fn summary<C: Directory + Store>(ctx: &C, patron: &str) -> String, called from two actors. Sans-io: a 45-line enum with three states and two methods, plus a Status(Summary) variant in the clerk, a current: Summary field in the auditor, and an arm in each parent's Patron and Loans impls to route replies into it — six integration points across two parents, for one helper.

Struct replies #

Both styles pay the menu: a Patron, a Book, an Option<u64>, and a Vec<Loan> each need a byte encoding on the host and a decoder on the Rust side. In capabilities the decoders live in wire::decode (40 lines) and the program never sees a byte. In sans-io they live in the wire too (35 lines) and the machine receives typed replies. Same cost, same place; the menu is indifferent to style.

What it says about the Scaling table #

The review's Scaling section was written from the greeter. This program confirms its rows for the two styles measured:

  • more waits in one program — sans-io is worst, and superlinearly: every wait a variant, every live local a field on every variant between its birth and its use (CheckoutAwaitingCount { patron, isbn, p, b, due }).
  • bigger programs — the ratio grew from 2.1× to 2.8× between the greeter and the desk.
  • helpers — the one thing the greeter could not show. An async fn over two waits is a sub-machine with six integration points.
  • fan-out — free in capabilities (join), absent in sans-io.
  • the wire and the hosts do not care — same size, same shape, in both.

And one thing the table did not predict: the transition bug. Adding a feature to a state machine means adding arms to impls that already exist, and a missing arm is a runtime panic. This experiment produced one on the first try, in a 690-line crate written carefully by someone who knew the risk. That is the strongest argument in this directory.

Reproduce #

nix develop
scale/run.sh              # builds both, runs both hosts, diffs, prints the transcript
hosts/check-sync.sh             # the copied mechanism has not drifted