Sans-IO: a network handshake with the network removed

AI authored to showcase ironpad capabilities.

Introduction

Look at the Rust crates that got protocol design right and you find the same move underneath all of them: the protocol logic is there, and the I/O has been surgically removed. No sockets. No timers. No Instant::now(). Just a state machine you feed bytes and timestamps.

They call it sans-IO, and the family keeps growing. quinn-proto is the QUIC state machine with the networking sliced out. str0m is a fully synchronous WebRTC stack. And as of January 2026, sctp-proto moved under the str0m maintainers, quietly consolidating a whole sans-IO protocol family (WebRTC, SCTP, DTLS, STUN) under one roof.

Firezone's explainer pinned down the shape everyone converged on. Four methods:

  • handle_input(packet, now): feed in a datagram you received
  • handle_timeout(now): advance the timers
  • poll_transmit() -> Option<Transmit>: pull out a datagram it wants to send
  • poll_timeout() -> Option<Instant>: ask when it wants to be woken next

The load-bearing detail is now. Time is always an injected timestamp, never sampled from a clock inside the machine. That one discipline is what buys portability, testability, and determinism all at once. It is also why the exact same handshake can run under tokio, inside no_std firmware, and right here in a browser wasm cell, which is what the rest of this notebook does.

Let me not overclaim: sans-IO is not free, and I will get to the trade-offs at the end. First, the case in numbers; then let me build one.

The test-surface multiplication

Put a socket inside the protocol logic and every test has to go through the socket. The cost multiplies. The test surface of I/O-entangled protocol code is a product of three axes:

protocol branches x transports x failure modes.

Count them for even this toy handshake. Six branches: first send, accept a matching response, ignore a stale one, retransmit on timeout, double the backoff, give up at the budget. Three transports we are claiming portability across: tokio UDP, a no_std firmware loop, browser wasm. Four failure modes worth exercising: loss, duplication, reordering, delay. That is 6 x 3 x 4 = 72 integration scenarios, each needing a real socket pair, a real timer, and real elapsed time. The retransmit ladder alone burns 31.5 wall-clock seconds per run at a 500 ms base timeout with six doubling attempts, because with real timers you actually have to wait.

Pull the I/O out and the product collapses into a sum: six branch tests as pure function calls (microseconds each, no sleeps), plus one thin adapter test per transport. 6 + 3 = 9. The numbers are directional rather than precise, but the shape of the argument is the point: I/O inside the logic multiplies; I/O at the edge adds.

There is a second, quieter cost. Socket tests are the classic flaky suite: they race the kernel, they depend on timing, and they fail on loaded CI machines at 2 a.m. A pure state machine cannot flake, because there is nothing nondeterministic left to race.

The whole protocol is an enum and a match

A sans-IO handshake is just an enum for the state, a Vec for the outbound queue, and four methods that pattern-match. No async, no traits, no dependencies. Open the Shared Source panel and read the state machine top to bottom: it is about eighty lines, and everything below it in the panel is only the rendering glue that draws it for this notebook.

The toy protocol is a STUN-style binding request: a client behind a NAT asks a server what public address it appears to come from, and the server replies with the mapped port. If that reply gets lost, the client retransmits on a timer, backing off each time per RFC 5389, and eventually gives up. Four states cover the whole story:

  • Idle: constructed, nothing sent yet
  • Waiting: request in flight, retransmit timer armed
  • Bound: the server answered, we learned our mapped port
  • Failed: the retransmit budget ran out

Every transition is driven by one of the four methods, and not one of them reaches for a socket or the clock. Watch it happen, first by hand, then in a loop.

Meet the machine

The shape: events in, questions out

You just drove the machine by hand: two handlers in, two polls out. The contract, stated precisely:

Everything crossing the boundary is plain data. Inputs are values: a datagram plus a timestamp, or a bare timestamp meaning "time passed". Outputs are values too: a datagram the machine wants sent, or a deadline it wants honored. Some crates literally define an enum Input and an enum Output and funnel everything through two functions; ours splits the input edge into handle_input and handle_timeout and the output edge into poll_transmit and poll_timeout. Same shape, different spelling.

Outputs are polled, never pushed. The machine never calls you. No callbacks, no channels, no futures. After any input you drain poll_transmit until it returns None, then ask poll_timeout for the next deadline; both answers can change after every event, so you re-ask every time. That drain-then-rearm rhythm is the entire caller contract.

The tempting version this replaces reads beautifully and tests terribly:

// Looks clean. Welds the protocol to one runtime, one transport, one clock.
async fn bind(socket: &UdpSocket) -> Result<u16> {
    for attempt in 0..7 {
        socket.send(&request).await?;
        match timeout(rto * 2u32.pow(attempt), socket.recv(&mut buf)).await {
            Ok(n) => return parse_response(&buf[..n?]),
            Err(_) => continue, // silence: retransmit with doubled rto
        }
    }
    bail!("no answer after 7 attempts")
}

The appeal is that it reads top to bottom. The price is that the state (which attempt you are on, the current backoff) lives in a compiler-generated future you cannot inspect, and every branch only executes against a live socket. The sans-IO version stores the same state as named fields: attempt, deadline, an outbox. Nothing is hidden, so nothing needs a socket to observe.

One consequence of inputs-as-data before we wire up a network: validation is a pattern match against inputs you already hold. Watch the machine ignore a response meant for a different transaction.

Wrong transaction id? The machine shrugs

You own the event loop

Advocates love this part and skeptics grumble about it: the machine does not run itself. You own the loop. You pull datagrams out with poll_transmit, you decide what happens to them on the wire, you advance the clock, and you hand replies back with handle_input. The machine only ever answers one question: given what you just told me, what now?

That is more work, and I will own that. It is also what makes the pattern useful: the loop can be a synthetic network with no network in it. The run driver in the Shared Source does exactly that:

  • a deterministic LCG (a four-line pseudo-random generator, no rand dependency) flips a weighted coin to drop packets
  • the clock is a plain u64 of milliseconds that I jump straight to the next deadline, so a twelve-second backoff simulates in nanoseconds
  • every step is logged, so the diagrams and tables below are just a rendering of what actually happened

Same seed, same drops, same result, forever. Let me run it three ways: a clean network, a lossy one, and a black hole.

A clean run: zero loss
A lossy run that recovers
When the network never answers

Timers without clocks

Timers are where most protocol code quietly reaches for the wall. A retransmit needs a timeout; a timeout needs a clock; a clock means Instant::now(); and now your logic is welded to real time. Sans-IO breaks the weld with one move: a deadline is data, not a callback.

The machine never sets a timer. It computes a deadline from the now you handed it, stores that deadline as a plain field, and reports it back when you call poll_timeout. Honoring the deadline is your job, and how you honor it depends entirely on where the machine is running: under tokio it becomes sleep_until, on firmware it becomes a hardware compare register, in this notebook it becomes a u64 I simply assign. The machine cannot tell the difference, because from where it sits, time is just a number that goes up.

The caller can lie about time, and lying about time is a feature. The full RFC 5389 ladder for this handshake (500 ms base, doubling, six attempts) spans 31.5 seconds of protocol time. With real timers, that is 31.5 seconds per test run, times every test that touches a retransmit. With injected time, the next cell walks the entire ladder against a network that never answers and finishes in microseconds: read the deadline, jump the clock to it, call handle_timeout, repeat. Watch the gap column double exactly on schedule.

Walking the backoff ladder in microseconds

Determinism

Add up what the machine is not allowed to touch (no sockets, no clock, no ambient randomness) and you get the property everything in this notebook has been building toward: a run is a pure function of its arguments. The entire world of a run is five numbers: a transaction id, a seed, a base timeout, an attempt budget, a loss rate. Same five numbers, same run, byte for byte, forever.

That changes what a bug report is. Against a real network, a protocol bug arrives as a packet capture, a timestamp, and a prayer that you can recreate the conditions. Against a sans-IO machine, a bug arrives as a seed. Whatever packet ordering wedged the state machine, the seed replays it on your laptop, in a debugger, as many times as it takes.

It also changes what a fuzzer can promise. Fuzzing I/O-entangled code means fighting nondeterminism the whole way down, and half the crashes will not reproduce. Fuzzing a pure machine means every crash arrives with its reproducer attached, because the input is the reproducer. The two cells below make both claims concrete: first the same lossy run twice with the logs diffed entry by entry, then a 200-seed sweep run twice, fingerprinted, and asserted identical.

Determinism means replay
A 200-seed sweep, twice

The same eighty lines, everywhere

Nothing in the Shared Source knows it is running in a browser. That is the entire return on the discipline, and it shows up in three concrete places.

It is portable. The handshake has no runtime and no I/O, so it drops into a tokio task, a no_std embedded loop, or a wasm cell without changing a line. You just saw the third case: that state machine compiled to wasm32-unknown-unknown and ran in a Web Worker to draw these diagrams. It never noticed.

It is testable without a network. Scroll back to the lossy run. I simulated 45% packet loss, watched the retransmit timers fire, and confirmed recovery, with zero sockets and no mocking framework anywhere. The "network" was a coin flip. Testing the failure path of a real socket stack is a genuine pain; here it is a function argument.

It is deterministic, which means bugs replay. The determinism cell ran the same seed twice and diffed the logs byte for byte. When a fuzzer finds the one packet sequence that wedges your protocol, it hands you a seed, and that seed reproduces the exact failure on your laptop every single time. A protocol that reads the clock or the socket directly can never promise that.

Now the costs, because sans-IO is not a free lunch:

  • You write the event loop, and the glue is real. Every consumer re-implements the poll-transmit, arm-the-timer, feed-the-input dance. It is boilerplate, and it is easy to get subtly wrong.
  • The ergonomics can feel inside-out. An async fn reads top to bottom; a hand-cranked state machine does not. For a simple request and response, plenty of people call this overkill, and the discourse about when it earns its keep is very much alive.
  • You give up the free state that .await keeps for you. Everything the machine must remember becomes an explicit field. That is exactly why it is inspectable, and also exactly why there is more to type.

My take: for anything with retransmits, congestion control, or a spec you have to match byte for byte, the determinism and testability win by a mile. For a one-shot request, open a socket and go home.

How the production crates structure it

The toy in this notebook is small, but the layering is a faithful miniature of how the real crates are cut.

quinn-proto is the clearest example, because the split is visible right in the crate graph. quinn-proto holds the QUIC state machines (Endpoint and Connection) and contains no sockets and no async: events and datagrams go in via handle_event and handle_timeout, and outbound work comes back out via poll_transmit and poll_timeout. Recognize those names? The quinn crate is the tokio adapter that owns the sockets and timers, and quinn-udp is the platform socket layer underneath. Three crates, one protocol, and the entire QUIC spec (streams, congestion control, connection migration, all of it) lives in the pure layer. If the pattern scales to QUIC, it scales to your protocol.

h2 is where the name came from. Cory Benfield's Python HTTP/2 library, circa 2016, was built as a protocol object you feed raw bytes: parsed events come out one side, data_to_send() hands you wire bytes on the other. The sans-io documentation site that grew out of that work is still the best statement of the philosophy, and the Rust ecosystem borrowed the vocabulary wholesale.

str0m pushes it furthest: an entire WebRTC stack, fully synchronous, every public API taking an explicit Instant.

Different protocols, different languages, one converged contract: data in with an injected clock, outputs polled, nothing async in the core.

When sans-IO is overkill

Honesty section: I would not reach for this pattern everywhere, and the failure mode of pattern enthusiasm is applying it to code that never needed it.

Skip sans-IO when all three of these hold:

  • One transport, forever. An internal service speaking HTTP to one other service is not going to grow a no_std port. The portability you would be buying has no buyer.
  • No timers or retransmits of your own. If TCP and TLS below you already handle reliability, your "protocol" has no interesting time-dependent state to extract. A request/response with no retries is a function call; wrapping it in a state machine adds ceremony, not testability.
  • No conformance burden. Nobody will ever fuzz your internal JSON endpoint against a spec, and no seed will ever be attached to one of its bug reports.

In that world the sans-IO tax buys nothing, and the tax is real: every consumer writes the drive loop (drain poll_transmit, re-arm the timer, feed inputs, repeat), which lands around 50 to 100 lines of glue per integration, and the classic mistake (forgetting to drain the outbox after handle_timeout) produces a protocol that mysteriously stalls. Write that loop once, for one transport, and you paid for generality you will never spend.

The flip-side triggers are just as crisp: a second transport on the roadmap, retransmit or congestion logic of your own, a spec you must match byte for byte, or a bug class that only replay will catch. Any one of those and the pattern starts paying rent immediately. For a mental model: sans-IO earns its keep when the protocol, not the transport underneath it, is where your bugs live.

Who is carrying this

Sans-IO is how a growing slice of the Rust networking ecosystem is actually built, and the roster is worth knowing.

  • str0m (Martin Algesten): a fully synchronous, sans-IO WebRTC stack, and since January 2026 the new home of sctp-proto as well.
  • quinn-proto: the QUIC state machine underneath Quinn, and the pattern's most-cited proof that it scales all the way up to a real, complicated protocol.
  • Firezone: the team whose explainer handed everyone the shared vocabulary, four methods and an injected clock, that this notebook borrows wholesale.

The pattern is old (it is just a state machine, the thing we have been teaching in school forever) and newly fashionable, because Rust's async story made the cost of not separating I/O from logic impossible to ignore. You just watched it run: a protocol that never touches a socket will run anywhere you can call four functions, a notebook cell included.

Go open the Shared Source and change the loss rate. The state machine underneath is about eighty lines, and every run replays exactly. Break it, replay it, fix it.