npro: plan for the sansIO portStatus: agreed, 2026-10-03. Phases 0 to 1d 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 (done, 2026-10-05)
- 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.
The server half (done, 2026-10-05): npro_h1::server::Server, one
connection's transactions. rx takes at most one thing per call, a
request's head, a piece of its body borrowed from the input, or the body's
end, and holds what it did not take, so pipelined requests wait and body
bytes that came with a head are never lost. respond composes C's status
line and headers, tx writes what the connection owes and then pulls the
payload from a TxSource, and complete is C's
lws_http_transaction_completed(). C's checks between head and app run
in C's order (400 for both framings or two Hosts, 417, 501, 400 for a bad
or second Content-Length, 413), a refusal is C's status page and a
shutdown, an answer short of its length or without one shuts down, and
keep-alive follows the version and Connection. A request with neither
Content-Length nor Transfer-Encoding has no body, as C now has it
(h1-post-no-length). tests/h1_server_replay.rs replays 19 of C's
server transcripts byte for byte with C's sansio-uri app: the h1-uri-*
and h1-reqline-* ones, h1-header-past-limit, h1-post-no-length,
h1-short-answer and h1-body-done.
The client half (done, 2026-10-05): npro_h1::client::Client, one
connection's transaction. tx writes the request head as C's
lws_generate_client_handshake() composes it, in C's order (request line,
Pragma and Cache-Control, Host, Origin, connection: close).
rx takes the response a thing at a time, as the server does, so the body
is pulled at the application's pace: a 1xx other than 101 is rewound out
of the table, up to 8; a HEAD's answer, a 204 or a 304 has no body; a
lone chunked wins over a Content-Length; a list of codings, or a second
or malformed Content-Length, fails the connection; with neither, the body
runs to the close, rx_closed. The status is C's atoi() of the status
line. tests/h1_client_replay.rs replays the seven h1 client transcripts
that need nothing more (not h1-client-digest-retry): the request byte
for byte, the body the app is given, and the release exactly where C
released, or else a complete transaction.
Exit check, met: h1-client-get and the first exchange of
h1-ws-server replay byte for byte, with the server and client
transcripts above.
Not yet, for later phases or when something needs them: the connection
machines of phase 1b drive neither side; no 100 Continue is sent; a
server's fallback role is a shutdown; a client sends no request body,
follows no redirect, does no digest auth, and has no kept-warm IDLING
state with its deadline; and neither side has a fuzz target of its own.
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.
The server half (done, 2026-10-05): the new crate npro-ws.
handshake::server is C's lws_process_ws_upgrade() over the h1
server's request: a GET, upgrade among the Connection tokens, a key
under 128 bytes and a Host, version 13 (none is a 400, another a 426
saying sec-websocket-version: 13), and the first subprotocol of the
request's list the server has, or its default for none; a refusal is
answered by the h1 server's new refuse_upgrade, with C's status page.
response_101 is C's 101, header for header. conn::Ws is the
connection after it, sans-IO as the h1 sides are: rx takes a thing at a
time and unmasks a payload where it lies, handing the application each
piece of a message as it arrives, with whether it starts and ends the
message, rather than C's whole frame up to its rx buffer; control frames
are gathered, a ping answered with C's one pending pong, the peer's close
answered with its own payload, its code made 1002 as C's
answer_peer_close makes it. What C refuses is refused with C's close
code and reason, and nothing is read after either close. tx writes in
C's order and pulls the application's payload, as the h1 server does;
close_when_flushed is C's close with no close frame once the last
message has gone. crates/npro-test/tests/ws_server_replay.rs replays,
byte for byte, four bytes at a time, with C's echo app: all of
h1-ws-server; the refused upgrades ws-server-version-8, -no-version,
-conn-no-upgrade, -no-subprotocol and -not-get; and
ws-server-ping-close, -close-partial, -close-when-flushed and
-huge-frame. The fuzz target ws-server is seeded from C's
fuzz/fuzz-ws/seeds.
The client half (done, 2026-10-05): handshake::ClientKey is C's
lws_generate_client_ws_handshake() and lws_client_ws_upgrade(): a
key of 16 bytes drawn in one draw, the request's upgrade lines in C's
order, which the h1 client's request carries in place of `connection:
close (npro_h1::client::Connection::Upgrade`, after which the final
response is handed over unframed), and C's checks of the response in C's
order: a 101, an accept, Upgrade: websocket, upgrade among the
Connection tokens, a subprotocol, if named, that was offered, no
extension, and the key's accept. conn::Ws is now one parser for both
ends, a client's Ws::client masking each frame with a mask drawn when
the frame is begun, and checking each end's rules in its C parser's order
("srv mask", "bad fin", the client taking 1012 to 1015 from a server).
After a refusal, as C, the rest of the read is dropped, and once our close
has gone, whatever comes ends the connection.
crates/npro-test/tests/ws_client_replay.rs replays, byte for byte, with
C's seeded random and C's callback_client, ws-client,
ws-client-interim, ws-client-ping-close, ws-client-huge-frame,
ws-client-rsv1-no-ext and ws-client-rsv2: the request, every masked
frame, the app's messages and where C closed. The fuzz target ws-client
is seeded from those transcripts, C having no client corpus.
Porting it found one difference kept from C: C's client takes any
Connection token that starts upgrade (it compares only the token's
length of it), so Connection: up passes; npro takes only upgrade.
Exit check, met: ws-client (seeded) and h1-ws-server replay byte
for byte, as do the new ws transcripts but the pmd ones (phase 1f) and
ws-client-digest-retry (digest auth); the fuzz targets are seeded from
fuzz/fuzz-ws/seeds and the client transcripts.
Not yet: nothing here has time, so there are no close deadlines and no
keepalive pings; the application cannot yet begin a close of its own with
a code, only close once flushed; the frame maximum is C's fixed 256 MiB,
not configured, and there is no message maximum; the h1 server does not
yet answer an unknown Upgrade with C's 403, or an upgrade with a body
with its 400; a message's pieces are the application's to gather; and an
application's message goes as one final frame.
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).