npro: plan for the sansIO portStatus: agreed, 2026-10-03. Phases 0, 1a, 1b and 1c are done. npro is the Rust port of libwebsockets' sansIO half (https://npro.rs). This plan puts into practice the C tree's porting guide
( The work is split into phases. Each phase is a series of commits, and each commit builds, passes its tests and has one purpose. A phase ends when its exit check passes, not when its code has been written. 0. Decisions needed before any codeThese are Andy's calls. Each one has a recommendation and the reason for it.
C's
Two items from the earlier list remain for the port to settle itself.
Each gets a test in the port:
- Fragment-order violations on tx are still only
1. What the C tree gives us to check againstWhat exists today on
They still include no request or response body framing, no ws close
started by the application, no ping, and no timers.
- Fuzz seeds: The port cannot be checked beyond what those cover. So phase 1 includes
adding transcripts in C first, each a case in
That is C work, committed to the C tree, and it lands before the Rust equivalent is claimed done. 1a. C work before the C tree is a clean basisFirst taken stock of on Resolved since the first lookEach item below is fixed on
Resolved at |
| C | Rust |
|---|---|
rx(bytes) -> consumed, empty = peer closed | conn.rx(now, &mut [u8]) -> Result<Rx<'_>, Error>, with rx.consumed and rx.event: Option<Event<'_>>. It takes &mut so that ws unmasks in place, and it returns at the first event, borrowing its payload from the input. The caller loops while progress is made ("a parse that consumed less is a held byte"). conn.rx_closed(now) is the peer closing |
tx(buf, max) -> n, more | conn.tx(now, &mut [u8], &mut impl TxSource) -> Tx, with written and more. The connection's own frames (status lines, 101, CLOSE, PONG) come from its state. The application's payload is pulled from TxSource into the buffer behind the frame header the connection has reserved. That is C's LWS_PRE framing in place turned into a pull, with no copy and no parking. This is "Open before it is done" item 4 |
deadline() | conn.deadline_passed(now). conn.next_deadline() -> Option<Instant> is a query, not a request |
want_write, want_read(on/off), close(phase) | conn.poll_request() -> Option<Request>: WantWrite, WantRead(bool), Close(Phase). Only the phases that exist for stage 1: quiesce, shutdown, stage, release |
transport(up/failed/gone) | conn.transport_up(now, TransportInfo), carrying alpn and tls-ness, which IO sets before up; conn.transport_failed(now) |
| callbacks (125 reasons) | Event variants returned from rx / poll_event(), and the application's calls made between them. Stage 1 needs about 15: request headers, body chunk, body done, response headers, response body, ws established, ws message fragment, pong, peer close, closed, writeable |
lws_service_set_now() | now: Instant is an argument of every entry point. Instant is a newtype of µs u64 and WallTime one of seconds. Monotonicity is enforced (debug assert plus saturating) |
lws_get_random() | a Random trait, handed in where drawn (&mut dyn or generic: to be settled in phase 1a) |
Buffers without alloc:
- The header table. C's
ahis a pool ofmax_http_header_data-byte tables with a fragment index. The RustHeaderTable<S: AsMut<[u8]>>has its capacity fixed byS:[u8; 4096]on a device, orBox<[u8]>withalloc. The fragment index is a fixed array of(offset, len)newtypes per known token. Header presence is explicit, as aPresentvariant, not a length. Unknown headers are records in the same storage. - Rx. The ws rx buffer does not exist as such: payload is delivered
from the input slice, in fragments, with
rx_buffer_sizeas the fragment cap. - Tx. The tx side owns nothing but its position.
The connection is four machines, each its own enum, with the role,
the side and the socket's usability: npro_core::state::Machines. As
built in phase 1b, which departs from the first form of this plan (per-role
enums carrying each state's data) for reasons given there:
Transport,Carrier,Live(the transaction) andClose, each one enum shared by every role, as in C; all of C's transport phases are declared. The fields are private, and only the event table writes them.Role(None,H1,Ws,RawSktso far) andSide; a role or side change is a transition like any other.- Events: C's
LWS_WSIEV_*are theEventenum. The transition function is onematchon the event, exhaustive, so an event cannot be added without its rows; within it, the rows are C's, in C's order, with_for C's"*"andANY. An event with no row is refused, as C refuses it (andLWS_WITH_STATE_CHECKaborts). TheANYrows are C's as they are:RESTART,CONN_FAILED,RETARGET, the close events. - Trace: every change is an
Edge, whoseDisplayis the line C'sLWS_WITH_STATE_TRACEwrites, less the connection's tag:LRS h1/S:HEADERS -> h1/S:H1_UPGRADE set_state ev=REQ_HDRS_COMPLETE. It needs no feature: writing lines is the IO side's business. That makes C's edge set diffable against the port's: README.sans-io-split.md rule 3, "the trace is the oracle". - Close invariants are refusals, as C's check makes them aborts: no
polite close phase entered with the socket unusable, no shutdown on a
raw socket, the close never going backwards, no live state while a
client restarts. The rows themselves keep
RETURNED_CLOSEto ws andSHUTDOWNto servers.
3. Phases
Phase 0: scaffold and gates (done, 2026-10-03)
Three commits:
- The workspace (crates/*, with the npro facade), its
[workspace.lints], clippy.toml, deny.toml, the README, and
scripts/ci.sh. CI runs fmt, clippy (with all features and with the
defaults), test, doc, the MSRV 1.85 check, the no_std build, cargo deny
and cargo audit.
- npro-test: a strict reader for lws-transcript/1, and copies of
the C transcripts with C-COMMIT and the C README beside them.
scripts/sync-c-oracle.sh refreshes them from a C checkout. C's fuzz
seeds join fuzz/seeds/ with the targets that use them.
Exit check, met: every gate passes at 1.85 and at stable, and the reader takes all 48 transcripts.
Phase 1a: substrate in the core crate (done, 2026-10-03)
- Random: the
Randomtrait, one draw perfill(), failing asUnavailable.SeededRandom, behind the additivereplayfeature, is C's stream; it is pinned by theapi-test-random-prngvector and by the ws-client transcript's key and first mask. sha1andbase64(encode only), with their RFC vectors and RFC 6455's accept value.- The incremental UTF-8 validator. It is checked exhaustively
against
core::str::from_utf8, and once, outside the tree, against C's ownlws_check_utf8(): 134M cases, none differing. time::Instant, in microseconds, withcore::time::Durationfor intervals. Wall time and deadlines come with their first users.- Ids and size newtypes move to the crates that define them: a
StreamIdbelongs to h2.
Property tests need no dependency. The generators are written over
SeededRandom, so a failing case is reproduced from its seed. Every
parser gets the one-shot vs fragmented oracle.
Exit check, met: the vectors pass, and clippy passes with
indexing_slicing, arithmetic_side_effects and as_conversions
denied.
Fuzzing (done, 2026-10-03)
Ahead of the parsers, so that each is fuzzed from its first commit:
crates/npro-fuzz holds a harness per target with an oracle, smoke-tested
in cargo test; fuzz/ holds the libFuzzer targets in a workspace of its
own; scripts/fuzz.sh runs them by hand and under sai, in CI and idle
time, with the corpora in a sai pool. The first targets are the
substrate's: utf8, sha1, base64, and the transcript reader.
fuzzing.md has the details.
Phase 1b: the connection machines (done, 2026-10-04)
npro_core::state: the four machines, the role, side and socket, theEventenum and C's event table rows for the stage-1 roles: h1 client and server, ws, and raw sockets. The setters are C's (lws_wsi_set_state_ev(),lws_wsi_role_transition_ev()): a live state ends the transport phase, a handshake-named state is the carrier's until it is established, a restart leaves the old socket and close behind.- The oracles, in
crates/npro-test/states/(scripts/sync-c-states.shrefreshes them): C's table rows with their line inwsi-state.c, every distinct edge C's ctest suite takes and every table row it fires (C'sLRSROWtrace), over the three builds C measures coverage with, all withLWS_WITH_STATE_TRACEandLWS_WITH_STATE_CHECK, and C's README, which lists the rows no test fires and why. - The tests,
crates/npro-test/tests/states.rs: - a second model of C's machines, in C's terms, reading its rows from the copy of C's table. Every state the port reaches from a birth (3,022 of them, walked exhaustively, which subsumes the property tests this plan asked for) is driven with every event, with and without each role a site can give, through both: 604,400 cases, agreeing on refusal, machines after, setter and trace line; - every one of C's 244 stage-1 edges is one the port takes; - the structural invariants hold in every reachable state.
Each was checked by planting bugs: a wrong row, a missing row, a setter keeping the transport or the dead socket, the close going backwards, no unusable-socket rule, tracing every edge. Every one fails the tests.
Exit check, met. The port takes every edge C's suite takes, and none C's table does not. The plan's other direction, every edge the port can take seen in C's trace, was the wrong measure: rows that fire from any state multiply into thousands of edges whose code is one path. It is replaced by row coverage. C's trace now names each table row as it fires, C's suite was given tests for the rows it never fired (and fixes where those found bugs), unreachable rows were dropped, and C's README lists every row still unfired with why: 21 over its three builds. npro's states test requires each row npro's machines can fire to be one C's suite fires or one of those. That check needs the oracle measured where C measures it: a host without IPv6 never connects to a second address, and h3 needs gnutls.
The machines' shape, agreed (2026-10-04): one enum per machine,
shared by the roles, as C's are, rather than per-role enums carrying each
state's data. The protocols share the machines' abstractions: the close
machine is one ordered sequence the ws phases sit inside, which a role
change carries (h1 to ws), and the carrier's names are reused per
transaction. Keeping C's shape also lets the port be held to C's table
row by row. Typed per-role views for the protocol crates (an H1Server
that can only be in its states) can sit on top when phase 1d has callers
for them.
Phase 1c: h1 parsing (done, 2026-10-04)
The crate npro-h1, no_std with no dependencies, is C's
lib/sansio/http/parsers.c:
token: C'senum lws_token_indexes, its 97 tokens with C's indices and spellings, with every header option on as C's default build has it. Name matching islookup()over the spellings: no spelling is the start of another, so a name is matched exactly when C's lextable reaches its terminal, and the trie itself is not ported.table: C's ah,HeaderTable<S>over caller-owned storage, at most 32768 bytes. Its layout is C's byte for byte in what it uses up (each value's NUL, each unknown header's eight byte record, the?'s unused byte, C's 97 fragment slots), so a head fills it at the same byte as C's. Presence is aSlot::Present, not a nonzero index. A client's own request goes in withcreate(), and an interim response is dropped withsnapshot()andrewind().head: C'slws_parse()andlws_parse_urldecode(), byte for byte restartable. C'sparser_state,ues,ups,post_literal_equal,lextable_posandunk_posare enums. A refusal is aCause, one per way out of C's parser, and what a server answers for it (LPR_REFUSED's 400, 403, 414, 431, 501 or 505, or none forLPR_FAIL). Limits are the table's size and per-tokenConfig::with_limit(), C'stoken_limits.chunked: C'slws_http_dechunk_framing(), handing back the payload where it lies in the input;fields: C's Content-Length and lone-chunkedTransfer-Encoding readers.
Porting it found three bugs in C, fixed there first (lws 3c8459075 and the two before it), so npro follows C as it now is: C marked "no name begun" by the name's record being at offset 0, which a server's first name is, so it began that name twice, losing nine bytes of every request's ah, and took a LF as a request's second byte as a bare LF; C's strict server took a header line starting with a bare CR into an unknown header's name; and a repeated header's value kept its leading spaces, where RFC 9110's OWS is not part of a value.
The tests, in crates/npro-test:
- The differential test against C,
tests/h1_c.rs. The plan had it as a C harness in an optional CI job; it is instead an oracle vendored like the state machines':scripts/sync-c-h1.shbuilds C static, compilesh1/c-heads.cagainst its private headers to calllws_parse()and the dechunker directly, and records what C makes of a corpus (C's fuzz-h1 seeds and heads written to reach each branch, as a server and as a client, and chunked bodies), with twelve variations of each, in C's default configuration and a tight one: 7,007 cases. npro must reach the same verdict and leave the same table, down to the bytes used. - The one-shot vs fragmented oracle over the same heads, each split three ways, and over every transcript's first request split at each byte.
- The transcripts: the
h1-uri-*,h1-reqline-*andh1-header-past-limitrequests come to the path, urlargs or refusal C'ssansio-uriapp answered with, and the h1 client cases' heads and framing headers read as C read them (tests/h1_heads.rs). - Fuzz targets
h1-request,h1-responseandchunked, seeded from C'sfuzz/fuzz-h1/seedsand the corpus (fuzzing.md).
Exit check, met. Every case of the vendored oracle agrees with C,
and planted bugs in the
parser and dechunker (a limit off by one, + left alone in the query, the
continuation's SP dropped) each fail it.
Phase 1d: the h1 transaction, server and client
- Server (
lws_http_actionrules, in C's order): CL with TE gives 400; more than one Host gives 400; Expect gives 417 or a 100; TE other than a lonechunkedgives 501; CL is parsed strictly with overflow checked, and over the maximum gives 413; plus Connection keep-alive rules by version, the body states,DISCARD_BODY, the pipelining limit of 64, and the TXN_COMPLETED / drained handling. - A request with neither CL nor TE has no body, per RFC 9112 6.3 rule 7. C's server still reads a POST, PUT or PATCH body to the close, while its proxy path already treats it as zero-length. See section 1a, open item 3: C decides, and a transcript pins it, before the port claims it.
- Client:
- request composition in C's exact header order (the transcript pins
it);
- 1xx swallowed up to 8, with the header table rewound;
- CL and chunked bodies, and read-to-EOF when there is neither;
- the body pulled at the application's pace, never buffered;
- the kept-warm
IDLINGstate with its deadline. - Body bytes that arrived with the headers are kept. With the borrow-from-input rx this is structural: the rx simply does not consume them until the body event.
Exit check: h1-client-get and the first exchange of h1-ws-server
replay byte for byte, plus the new C transcripts of section 1 that cover
h1.
Phase 1e: ws
- The handshake.
- Server upgrade:
- the Connection token must include
upgrade; - Sec-WebSocket-Key must be present and under 128 bytes; - the subprotocol is the first known one, or the default; - the 101 is written in C's order, withUpgrade: WebSocketcapitalised as the transcript has it. - Client: 16 random bytes for the key, then the response is checked: status 101, Accept, Upgrade and Connection, and the offered protocol. - The frame parser. - Server and client are one parser parameterised by side, not two as in C. - It checks opcodes, RSV, continuation and FIN ordering, masking by side, control-frame length and fragmentation, and the 64-bit length top bit. - The configured frame and message maxima apply on both sides. - UTF-8 is validated incrementally, with close codes 1002 and 1007 as C uses them.
- Tx framing as a pull, the mask drawn per frame.
- The close machine:
-
WaitingToSendClose, thenAwaitingCloseAck; -ReturnedClose, where the peer's payload is echoed; -FlushingBeforeClose; - the received-code rewrite to 1002 per side; - 5 s CLOSE_SEND and CLOSE_ACK deadlines, 3 s on the client's reply; - the one-pending-pong rule, and the POLLOUT priority order: CLOSE, then PONG or the echoed CLOSE, then keepalive PING, then the application. - Keepalive: 40 / 50 s by default, with the ping payload being the
8-byte
now.
Exit check: ws-client (seeded) and h1-ws-server replay byte for
byte, as do the new ws transcripts. The fuzz target is seeded from
fuzz/fuzz-ws/seeds.
Phase 1f: permessage-deflate (feature pmd)
- Negotiation, the parameters with their C ranges, the zip-bomb cap of 256 MiB per message (configurable), and the drain budget.
- Parameters are chosen per direction by role, as C now does (
c85cd0c). - The fuzz target is seeded from
fuzz/fuzz-ws-pmd/seeds, which includesbomb-2mb-zeros.
Phase 1g: minimal npro-io
- std TCP, plain poll-style loop, no tls yet. Enough to run:
- autobahn's fuzzingclient against the ws server. This is a
conformance gate. It needs a Python tool, so Andy must say where it
may run;
- a differential run of the port's client against C's
minimal-http-serverand ws echo, and the reverse. - tls: rustls is the obvious candidate. It is a large dependency tree, so it is a separate decision, not taken here.
Later stages
Later stages follow the port guide's order and wait for their C transcripts: h2 (stage 2), quic and h3 (stage 3), and mqtt (stage 4). The guide's "Do not copy" list stays in force: a quic frame in flight is not pending output; mux parked rx; the kept-warm joiner's status.
4. Checks at a glance
| check | from | where it runs |
|---|---|---|
| transcripts byte for byte | C api-test-sansio | cargo test, every commit |
| state edge set vs C trace | C LWS_WITH_STATE_TRACE | cargo test, vendored edge file |
| one-shot vs fragmented parse | agent-context "Parsers" | proptest, every parser |
| fuzz, with an oracle per target | C's fuzz/fuzz-*/seeds, copied into fuzz/seeds/ | smoke tests in cargo test everywhere; libFuzzer in sai CI and idle time (fuzzing.md) |
| differential parse vs C | C's lws_parse() and dechunker over a corpus, scripts/sync-c-h1.sh | cargo test, vendored c-heads.txt |
| autobahn / h2spec / h3spec | conformance suites | per phase, once npro-io exists |
| lints, docs, deny, audit, MSRV | AGENTS.md | CI, every commit |
5. Not settled by this plan
- The core crate's name (decision 0.1).
- The mapping from each of C's callback reasons to an
Event. This plan lists the stage-1 set only. - Whether
Randomis&mut dynor a generic parameter. Generic avoids the vtable, but spreads a type parameter through every connection. - tls in
npro-io. - Where autobahn runs in CI, and whether the C oracles are measured there (
sync-c-states.shandsync-c-h1.share run by hand).