guide · six steps
From nothing to a packet
An empty project, a frame, a protected header, a second path, a resumed connection, and the clock that holds them together. Every block below is code this module compiles as it stands, except the one marked as a sketch — that one needs a certificate store and a socket loop around it.
Add it
moon add moonbitstack/moonquic, then import what you need. Six packages, each
with one job: frame and packet are codecs, crypto is
protection, recovery is the clock's business, stream is the
application's, and conn is the transport that ties them together.
Nothing here opens a socket or reads a clock. The peer's address is a type parameter — a
string, an enum, whatever your UDP loop keys connections by — and every entry point takes
now in microseconds. That is what makes this library testable against a loss
pattern, and what lets the page you are reading print a real packet: it is the same code,
compiled to JavaScript.
Frames, both ways
A packet's payload is a run of frames. @frame.decode answers the frame and how
many bytes it took, so reading a payload is a loop; encoding is one call per frame.
let frames : Array[@frame.Frame] = [
@frame.Crypto(offset=0UL, data=b"hello"),
@frame.MaxData(1_000_000UL),
@frame.PathStatus(path_id=1UL, seq=1UL, available=true),
]
let mut wire = b""
for frame in frames {
wire = wire + @frame.encode(frame)
}
let mut at = 0
let back : Array[@frame.Frame] = []
while at < wire.length() {
let (frame, used) = @frame.decode(wire[at:]) catch {
_ => abort("a frame did not decode")
}
back.push(frame)
at = at + used
}
assert_eq(back, frames)
The set is the whole of RFC 9000 §19, RFC 9221's DATAGRAM, and the multipath frames. A frame the codec does not know is refused rather than skipped: RFC 9000 §12.4 makes an unknown type a connection error, and a silent skip would hide it from both ends.
Protect a packet
The Initial keys come from the destination connection id alone, so the first packet can be
built before anything has been negotiated. Header first, then seal, which
authenticates the header as associated data and masks it.
let dcid : Bytes = b"\x83\x94\xc8\xf0\x3e\x51\x57\x08"
let scid : Bytes = b"\xaa\xbb\xcc\xdd"
let (rx, tx) = @crypto.initial(dcid[:])
let wire = @conn.seal_long(
@packet.Kind::Initial, 1U, dcid, scid, 0L, 4,
@packet.payload([@frame.Ping]), tx,
)
let opened = @conn.recv_long(wire, rx, largest=-1L) catch {
_ => abort("the packet did not open")
}
guard opened is Some((read, number)) else { abort("the packet did not open") }
assert_eq(number, 0L)
assert_eq(read, [@frame.Ping])
The last sixteen bytes are the tag. A receiver that gets a different number, or a byte
changed anywhere before the tag, gets None — one answer for every way of being
unreadable, because dropping the packet is the only thing to do about any of them.
A second path
Multipath starts with a transport parameter and a connection id: initial_max_path_id
says how many paths each end will maintain, and a path is identified by the id its packets
name, never by the address they came from. Each path then keeps its own packet numbers, its own
round-trip estimate and congestion window, and its own acknowledgement.
let paths = @conn.Paths::new(local_max=2UL) // what this end will maintain
paths.on_max_path_id(1UL) // what the peer said it would
assert_true(paths.openable(1UL))
assert_false(paths.openable(2UL)) // the peer offered one path, not two
let spare : Bytes = b"\xaa\xbb\xcc\xdd"
paths.bind(1UL, spare) // the peer's id for the path
ignore(paths.bind_local(1UL, b"\x01\x02")) // this end's
assert_eq(paths.path_of(b"\x01\x02"), 1UL)
assert_false(paths.validated(1UL))
ignore(paths.start(1UL, b"\x10\x20\x30\x40\x50\x60\x70\x80"))
assert_true(paths.prove(1UL, b"\x10\x20\x30\x40\x50\x60\x70\x80"))
assert_true(paths.validated(1UL))
Two paths may use the same packet number, because the nonce names the path. Path 0 gives exactly the nonce RFC 9001 describes, so a connection that never opens a second path computes what it always did — and the first packet on a new path is number 0 there, whatever path 0 has used.
let (_, keys) = @crypto.initial(b"\x83\x94\xc8\xf0\x3e\x51\x57\x08")
assert_eq(keys.nonce_at(0UL, 54321L), keys.nonce(54321L))
assert_true(keys.nonce_at(1UL, 54321L) != keys.nonce(54321L))
On a server, Session::send_on puts frames on a path and
Session::poll_lane hands back the datagrams that path has ready with the address
they belong on; Endpoint::add_path makes the connection id the path answers to. On
a client the caller owns the sockets, so it says which path a datagram arrived on
(recv_on) and gets the path id back with what to send (poll_paths).
A path is opened on the connection id the peer gives for it, and that id arrives before
anything has been sent on the path — it is what the path is for. A
PATH_NEW_CONNECTION_ID therefore creates the path it names, which is why
paths().peer_cid(1) answers with something to address before
add_path has been called. A caller that puts a stream's frames on a path itself
says the stream exists first: RFC 9000 §19.8 lets the peer answer only on a bidirectional
stream this end opened, and Client::open_stream is that record without any bytes
queued for the library's own send loop.
The shape of it — not compileable on its own
ignore(client.open_stream(4UL)) // a stream this end initiates, nothing on it yet
ignore( // the request, placed on path 1 by the caller
client.send_on(
1UL,
[@frame.Stream(id=4UL, offset=0UL, fin=true, data=b"GET /\r\n")],
now,
),
)
Resume a connection
0-RTT is only safe if the client knows what the last connection left it: RFC 9000 §7.4.1
forbids guessing the flow-control credit. Client::remembered is the ticket
together with the parameter block it was issued under, and passing it back is all the next
connection needs.
The shape of it — not compileable on its own
let kept = client.remembered() // a ticket and the parameters it came with
let next = @conn.Client::new( // the sketch: needs a store and a socket loop
@tls.Client::new(roots, alpn=["h3"], sessions=store, early=true),
"a.example",
@conn.encode(params),
rand=entropy,
dcid=Bytes::make(8, 3),
scid=Bytes::make(8, 4),
now,
resumption=kept,
)
Config's zero_rtt_data and zero_rtt_stream_data overrule
what was remembered rather than being the only source: zero, the default, means the remembered
value governs. A client with a ticket and no remembered block still has nothing to spend, so
its early data waits for the handshake instead of risking a flow-control error the server would
answer with a close.
A server allows early data with @tls.Tickets::new(keys, early=n) and
judges tickets at wall, seconds since the epoch — a different clock from
now, which only drives timers. An endpoint given no wall neither
issues nor takes a ticket, because 0-RTT data can be replayed by whoever recorded it.
A second version
Version 2 is the same protocol under different constants, so the version a packet carries
decides how it is protected and what its first byte means: the salt, the four labels, the
Retry key and the four type codes all change. Config.versions names what an end
speaks, and @crypto.Version::of turns the number a long header carries into
those constants.
let v2 = @crypto.Version::of(@packet.version2).unwrap()
assert_eq(v2.key, b"quicv2 key")
assert_eq(@packet.Kind::Initial.code(@packet.version2), 1)
let dcid : Bytes = b"Èð>QW"
let (rx, tx) = @crypto.initial(dcid[:], version=v2)
let wire = @conn.seal_long(
@packet.Kind::Initial, @packet.version2, dcid, b"ª»ÌÝ", 0L, 4,
@packet.payload([@frame.Ping]), tx,
)
guard @conn.recv_long(wire, rx, largest=-1L) is Some((read, _)) else {
abort("the packet did not open")
}
assert_eq(read, [@frame.Ping])
The same call under version 1 produces different bytes, and neither opens under the other's
keys — which is the whole point of a second version, and why the first flight says which one
it is. A first flight also carries version_information: the version it chose and
the ones beside it, both under the handshake's own authentication.
let param = @conn.version_information(
@packet.version2,
[@packet.version1, @packet.version2][:],
)
assert_eq(param.kind.code(), 0x11UL)
assert_eq(
@conn.read_version_information(param.value[:]),
Some((@packet.version2, [@packet.version1, @packet.version2])),
)
A server that does not speak the version a client chose answers with a Version Negotiation
packet naming the ones it does; the client restarts from a new first flight in a version both
ends speak, keeping its connection id and its packet numbers. When the peer instead answers in
a version the client also offered, the client adopts it without a round trip. Either way the
version the handshake settled on is checked against the peer's own list, and a mismatch ends
the connection with VERSION_NEGOTIATION_ERROR (0x11).
A server does not have to leave the client's choice alone. Put
[@packet.version2, @packet.version1] in Config.versions and it
reads the first flight's list before the handshake starts, selects version 2, re-derives
both directions' Initial keys and answers in version 2. The client learns the negotiated
version from the long headers and adopts it; there is no Version Negotiation packet and no
extra round trip (RFC 9368 §2.3). A client that offers only its Chosen Version keeps the
handshake in that version, and a version_information that does not parse closes
the connection.
Time and loss
Nothing moves without tick. It runs the probe timeout on each handshake level,
the send loop's own, the idle timeout and the closing period, and it is where a lost handshake
flight is put back on the wire. A client also owes a server an occasional padded Initial with a
PING, because a server whose amplification budget is spent cannot answer anything else.
The same timer is what recovers a lost client Finished: the packet nobody can do without. Without it the server never confirms the handshake, and both ends wait out their idle timers holding a connection that is finished but not confirmed.
let policy = @recovery.Policy::new(
datagram=1200L,
ack_delay=25_000L,
granularity=1_000L,
)
let state = @recovery.State::new(policy~, control=@recovery.Cubic::new())
assert_true(state.window() > 0L)
CUBIC follows a cubic function of the time since the last reduction and falls to 0.7 rather
than to half; BBR sizes the window from the bandwidth-delay product and does not treat a loss
as its congestion signal. Both implement Control, so a controller of your own is
a trait implementation and nothing else.