# Changelog
All notable changes to this project will be documented in this file.
## [Unreleased]
## [1.6.4] - 2026-06-05
### Changed
- HTTP/3 no longer accumulates received DATA payloads in the stream record. Data still reaches owners and handlers through the existing delivery path, while Content-Length and unbounded-body limits are enforced from a byte counter. The `#h3_stream.body` field is retained for compatibility but is no longer populated.
## [1.6.3] - 2026-06-03
### Added
- `quic:safe_close/1,2,3` closes a connection and ignores any error if it is already gone, for teardown paths that must not crash.
### Fixed
- HTTP/3 connection errors now send a CONNECTION_CLOSE. `handle_connection_error` passed the error reason straight to `quic:close/3`, but several call sites supply a non-binary reason, which failed the function's binary guard and was swallowed by a surrounding `catch`, so the connection was left open. The reason is now coerced to a binary phrase.
### Changed
- Replaced the deprecated bare `catch` expressions in the library and test suites with `try ... catch`, clearing the OTP 27+ compiler warnings. CI now runs the unit tests on OTP 26, 27, 28 and 29 (the matrix previously collapsed to one OTP version per OS).
## [1.6.2] - 2026-06-03
### Fixed
- Happy Eyeballs + HTTP/3: a client connecting to a hostname that resolves to more than one address (so the race path runs) could hang in `quic_h3:connect/3` until `connect_timeout`. The QUIC connection completes its handshake while owned by the race coordinator, so the server's HTTP/3 control stream and SETTINGS were delivered to the transient owner and dropped before the H3 connection process existed. `set_owner` re-delivers `{connected}` but not that already-arrived stream data; the race coordinator and `quic_h3:connect/3` now forward the buffered `{quic, Conn, _}` backlog to the new owner at each ownership handoff. Diagnosed and originally fixed by ycastorium (#160, #161).
- Server certificate validation recovers from an expired cross-signed root. When the served chain anchors at an expired cross-signed root (Let's Encrypt ISRG Root X2 cross-signed by the now-expired ISRG Root X1) and the trust store holds a still-valid root with the same public key, the client retries the alternative trust anchors instead of failing with `cert_expired`. A genuinely expired leaf or intermediate still fails.
## [1.6.1] - 2026-06-02
### Fixed
- Server-side 0-RTT acceptance now works end to end. The server echoes the empty `early_data` extension in EncryptedExtensions when it accepts 0-RTT, so a resuming client reports `early_data_accepted/1 =:= true` instead of always seeing the data rejected. Inbound 0-RTT frames are dispatched at the application level, so the early request and its control/QPACK streams are processed rather than dropped, and the resumed request completes normally.
## [1.6.0] - 2026-06-02
### Added
- 0-RTT (early data) support. On resumption a client can send application data in its first flight, and HTTP/3 can issue an early request, before the handshake completes. `quic:has_early_keys/1` reports whether 0-RTT keys are available, and `quic:early_data_accepted/1` / `quic_h3:early_data_accepted/1` report whether the server accepted the early data. (#148)
- `quic_listener:get_sockname/1` and `quic:get_server_sockname/1` return the listener's bound `{IP, Port}`, resolved live from the socket so it is correct when the listener was opened with `inet6`, `{ip, _}` or `{ifaddr, _}`. (#153)
### Fixed
- Streams aborted with RESET_STREAM_AT are reclaimed from the connection's stream map once their reliable obligation is met (local reset: reliable bytes acked; incoming reset: reliable bytes delivered), instead of being retained for the life of the connection. Data beyond the reliable size is trimmed from the send queue and retransmit path, and dropped on receive. (#152)
- A lost-packet retransmission deferred by congestion control is re-queued and resent when the window reopens, instead of being dropped (it had already been removed from the sent queue, so it was never retried).
- STREAM data referencing a locally-initiated stream that was never opened is rejected with STREAM_STATE_ERROR instead of creating the stream. (#152)
- Erlang distribution over QUIC: the keep-alive PING is paced off `net_ticktime` instead of the QUIC idle timeout, so a healthy connection is no longer declared down by `net_kernel` under load. (#157)
## [1.5.0] - 2026-05-30
### Added
- IPv6 client connections: `quic:connect/4` accepts a hostname, an IP-literal string (IPv4 or IPv6, optionally bracketed), or an `inet:ip_address()` tuple. Dual-stack hostnames use RFC 8305 Happy Eyeballs (IPv6-first racing) with `happy_eyeballs`, `family`, `connection_attempt_delay` and `connect_timeout` options. A hostname that fails to resolve returns `{error, Reason}` instead of dialing a default address. (#150)
- Listeners can bind to IPv6: pass `inet6` or an IPv6 `{ip, Addr}` in `extra_socket_opts`; the address family is inferred from those options. (#149)
## [1.4.5] - 2026-05-28
### Fixed
- Server certificate chain validation accepts chains where the server sends an extra or cross-signed cert above the cert that actually anchors. The previous topmost-only anchor lookup rejected valid chains (notably `cloudflare.com` over Google Trust Services on Mozilla NSS, `certifi` and FreeBSD `ca_root_nss`) with `unknown_ca`. The client now walks the served chain for the highest cert whose issuer is in the trust store and validates the sub-path from there.
- Server certificate verification failures reach the QUIC owner as a synchronous `{closed, {certificate_invalid, _}}` event alongside the existing `{error, {certificate_invalid, _}}` notification, so HTTP/3 clients waiting on the close fail fast instead of stalling until their connect timeout fires.
## [1.4.4] - 2026-05-28
### Security
- The QUIC client now authenticates the server. It verifies the CertificateVerify signature, validates the certificate chain against the trust store (`cacerts` option, OS store by default), and checks the hostname. Previously `verify` was a no-op on the client, so any certificate was accepted and a man-in-the-middle on the network path could impersonate any server. `verify` now defaults to on for clients; set `verify => false` to accept any certificate (for example self-signed test servers). HTTP/3 uses the same client and is fixed too. (GHSA-2r8v-p65x-3663, CVE-2026-49457, CWE-295). Reported by benmmurphy.
- Hardening from a full security review: 3x anti-amplification limit with Initial retransmission, CRYPTO-buffer and listener connection caps, MAX_STREAMS and connection-ID limit enforcement, resumption PSK binder verification with single-use 0-RTT, TLS 1.3 handshake state guards, AEAD usage-limit key update, working address-validation Retry with constant-time token compares, and stricter HTTP/3 and QPACK decoding.
## [1.4.3] - 2026-05-25
### Fixed
- QPACK Encoded Field Section Prefix and dynamic table now follow RFC 9204, so the encoder interoperates with strict decoders such as nghttp3. The Base is signalled as S=0 (Base = Required Insert Count), the Required Insert Count is written as an 8-bit prefix integer, and the Insert With Literal Name opcode and dynamic-table field-section encoding are corrected. (#142)
## [1.4.2] - 2026-05-23
### Fixed
- HTTP/3 extended CONNECT (RFC 9220) regressed in 1.4.1: the response-HEADERS coalescing introduced in 1.4.1 buffered a CONNECT tunnel's `200` until the first DATA frame, but a tunnel server sends no DATA until the client does and the client waits for the `200`, so the tunnel deadlocked (WebTransport and WebSocket-over-H3). CONNECT responses now flush the `200` immediately; plain H3 responses still coalesce headers with the first body chunk.
## [1.4.1] - 2026-05-23
### Changed
- Idle and keep-alive timers are now lazy: armed once at connection setup and re-armed only when they fire, from the `last_activity` timestamp, instead of being cancelled and rescheduled on every packet. (#140)
- HTTP/3 responses coalesce the HEADERS frame with the first DATA frame, so a response's headers and first body bytes go out in one 1-RTT packet instead of two. A large body still fragments as before. (#141)
## [1.4.0] - 2026-05-22
### Added
- TLS 1.3 external PSK (RFC 8446 §4.2.11). Client `external_psk` and server `psks` / `psk_callback` options, both `psk_dhe_ke` and `psk_ke` modes, constant-time server-side binder verification, cert and PSK coexistence on one listener, and client downgrade protection. `quic_dist` can authenticate node-to-node with a shared PSK and no certificates. See `docs/PSK.md`. (#133)
- TLS 1.3 HelloRetryRequest with multi-group key exchange. The `groups` option advertises `x25519`, `secp256r1` and `secp384r1`; a server that prefers a group the client did not key-share triggers a HelloRetryRequest and the client retries transparently. (#135)
- Per-handshake signature negotiation via the `signature_algs` option, adding ECDSA secp384r1-SHA384, RSA-PSS-RSAE SHA384/512 and Ed25519 sign/verify. The `connected` event now reports `negotiated_group` and `negotiated_scheme`. (#135)
- Per-pair multi-stream routing for Erlang distribution over QUIC. Dist messages are hashed by `{From, To}` across 16 streams to send concurrently while preserving order within each sender/receiver pair. (#132)
- `SECURITY.md` with a private vulnerability reporting policy.
### Changed
- HTTP/3 header field-character validation inlines the character class into the scanner clause guards, removing a per-byte predicate call from a request hot path. (#136)
### Fixed
- The server now segments its TLS handshake flight so no datagram exceeds `max_udp_payload_size`. The 3-5 KB flight previously went out as one UDP datagram that clients enforcing their advertised limit (Chromium) dropped, stalling the handshake until idle timeout. (#134, #137)
- ex_doc generation no longer breaks on a `@doc` tag preceding a `-callback`. (#129)
## [1.3.3] - 2026-05-03
### Added
- `quic:get_path_stats/1` returns a snapshot of the connection's path metrics (srtt / latest_rtt / min_rtt / rtt_var in microseconds, plus cwnd, bytes_in_flight, in_recovery, congested) for downstream routing layers. Backward-compatible; off the packet-processing path. (#127)
- `quic_dist` `auth_callback` option runs a custom `{Mod,Fun}` (or `fun/3`) on both sides between the QUIC handshake and the dist_util handshake. `{error, _}` closes the connection without ever starting the dist controller. New `quic_dist_auth` behaviour. (#126)
- `quic_dist` `register_with_epmd` option (default `false`) registers the listening port via the configured `epmd_module` so external tooling (e.g. `epmd -names`) can resolve the node. (#126)
## [1.3.2] - 2026-05-03
### Added
- `priv/bin/quic_call.sh`, an `erl_call`-style one-shot RPC helper for `quic_dist` clusters. Boots a hidden probe node with `-proto_dist quic`, runs `rpc:call/5` against the target, asks the target to disconnect so the hidden-node entry is reaped immediately, and halts. Reuses the cluster's `sys.config` (`-C`) for credentials and discovery; cert/key can also be passed via `--cert`/`--key`. (#123, #124)
## [1.3.1] - 2026-04-30
### Added
- `socket_backend => adapter` lets callers plug their own datagram transport (for example a MASQUE CONNECT-UDP session) under a QUIC client. The adapter map carries `send_fun` (and optional `close_fun`, `socket_ref`); batching, GSO and GRO are forced off, and connection migration is rejected on this path. (#121)
## [1.3.0] - 2026-04-25
QUIC and HTTP/3 protocol-conformance hardening: closes the silent-drop
of CONNECTION_CLOSE at handshake-time violations, replaces the
unmaintained h3spec runner with an in-tree RFC 9114 / 9204 compliance
suite, and fixes two externally-reported stream-API bugs.
### Added
- `close_with_error/6` emits CONNECTION_CLOSE at the right encryption level (initial / handshake / app), with fallback to the lower available level. (#111)
- Server-side validation of peer transport parameters per RFC 9000 §18.2: server-only ids (`original_dcid`, `preferred_address`, `retry_scid`, `stateless_reset_token`) and numeric ranges (`max_udp_payload_size` ≥ 1200, `ack_delay_exponent` ≤ 20, `max_ack_delay` < 2^14). (#111)
- Frame-pipeline guards: zero-frame packet → `PROTOCOL_VIOLATION`; unknown frame type → `FRAME_ENCODING_ERROR`. (#111)
- HTTP/3 RFC 9114 + 9204 conformance: 30 in-tree unit tests covering control-stream rules, pseudo-headers, stream-type uniqueness, push-id bounds, CONNECT validation, QPACK static-index and capacity limits, RFC 9218 priority signal, RFC 9297 SETTINGS_H3_DATAGRAM. (#112)
- `docs/h3_compliance.md`: RFC 9114 / 9204 / 9218 / 9297 matrix mapping every MUST and SHOULD to its test. (#112)
### Fixed
- Reject request streams carrying `:status` pseudo-header (RFC 9114 §4.3.1). (#112)
- `quic_qpack:set_dynamic_capacity/2` clamps to `max_allowed_capacity` per RFC 9204 §4.3. (#112)
- `quic:reset_stream/3` keeps the stream entry alive so subsequent `quic:stop_sending/3` emits STOP_SENDING instead of returning `{error, unknown_stream}`. (#113, #115)
- `quic:close/2` with an integer reason propagates that integer as the application error code; previously every input fell through to `?QUIC_APPLICATION_ERROR` (0x0c). (#114, #116)
- NEW_TOKEN received by a server and HANDSHAKE_DONE at the wrong level now route through `close_with_error/6` so the CLOSE frame reaches the peer when app keys are absent. (#111)
### Removed
- `quic_h3_h3spec_SUITE` and `docker/h3spec/`. The corpus is ported into `quic_h3_compliance_tests` as deterministic state-machine tests. (#112)
## [1.2.0] - 2026-04-21
Post-1.1.0 work split across three tracks: a client-side socket-backend
opt-in, a round of hot-path micro-optimisations on the send and
receive paths, and a migration fix for the default gen_udp client.
### Added
- Opt-in `socket_backend => socket` for client connections. Routes
the client through `quic_socket:open_for_send/2` so it picks up the
OTP socket NIF on Linux with GSO available per-message via cmsg,
instead of the `gen_udp` port driver. +18% download throughput on
arm64 Linux docker (10 MB bench); upload is neutral. (#88, #91)
- Client migration (`quic:migrate/1`) now works on the opt-in socket
backend. Rebind closes the old OTP socket, stops its dedicated
receiver process, opens a fresh one, and threads the new handle
through the connection state. (#90)
- `quic_socket:start_client_receiver/2` / `stop_client_receiver/1`:
dedicated receiver process for the socket-backend client path
(the OTP socket NIF has no `{active, N}` mode). (#88)
- `quic_socket:set_socket/2` swaps the underlying socket handle
inside a `#socket_state{}` while preserving batching configuration.
Used by the migration rebind path. (#93)
- Instrumentation counters `ack_sent` and `retransmits` on
`quic_connection:get_stats/1` and the throughput bench output
(Phase 0a). (#77, #78)
### Fixed
- `quic:migrate/1` on the default gen_udp client no longer drops
post-migrate traffic. Rebinding previously left
`#state.socket_state` pointing at the just-closed old socket; every
send went through the dead handle and was silently dropped. Also
flushes any pending batch to the old socket before rebind so
pre-migrate packets reach the server under their original CID.
(#93)
- `quic_dist`: simultaneous-connect deadlock in the accept path.
Two nodes dialling each other within a tight window wedged both
`net_kernel:connect_node/1` calls indefinitely. The old accept
path ran the dist worker through a nine-hop handoff
(register_pending / controller rendezvous in acceptor_loop) before
reaching `dist_util:mark_pending`, so net_kernel's tie-breaker
arbitration never ran in time. Collapsed to the TCP-dist shape:
`accept_connection/5` runs `set_supervisor` + `start_timer` +
`handshake_other_started` inline. Docker 5-node regression now
passes 5/5. (#106)
- `quic_dist`: batch-yield path in `input_handler_loop` could lose
or reorder buffered dist bytes when the mailbox had backlog.
Yield now threads the buffer remnant through the normal return
channel instead of piggybacking on the self-message. (#104)
- `quic_dist_user_stream_SUITE` / `accept_user_streams/2` doc:
refreshed to match the auto-assign / direct
`{quic_dist_stream, _, {data, _, _}}` delivery shape. (#105)
- `docker/dist`: 3+ node cluster mesh formation. Each node now dials
only higher-named peers and boots with `-connect_all false`, so
`global` does not re-introduce cross-dials behind the explicit
test topology. (#95, #106)
- h3: preserve WebTransport and unknown SETTINGS identifiers in the
peer settings map so extension-stream hooks can read them. (#96)
- `quic_socket`: client migrate path opens the new socket before
closing the old one, avoiding a window where the client has no
valid send handle. (#97)
- `quic_socket`: `client_recv_loop` exits cleanly on unexpected
socket errors instead of spinning. (#98)
- `quic_socket`: clear the pending batch buffer on flush error so
stale frames do not get retried on the next flush. (#99)
- `quic:connect/4`: reject the `socket` + `{socket_backend, socket}`
option combination with a clear error instead of silently
overriding one. (#100)
- Client connection: treat receiver-process exit as a fatal error
and close the connection, matching server behaviour. (#101)
- Server: build a per-connection sender even when
`server_send_batching` is `false` so the direct-send path uses the
same `quic_socket` shape as the batched path. (#102, #103)
### Performance
- Fuse per-packet cwnd + pacing check into `quic_cc:send_check/3`
(one BIF call and one record match instead of the previous four).
(#79)
- Hoist per-chunk lookups (`stream_urgency`, `max_stream_data_per_packet`,
pre-computed stream-frame header prefix) out of the chunked send
loop. (#80, #85)
- ACK 1-RTT packets immediately on reorder (RFC 9002 §6.2) while
keeping the decimation window for in-order traffic. (#81)
- Fast-path single-stream-frame in `contains_ack_eliciting_frames/1`
on the bulk-upload hot path. (#82)
- Thread the updated `socket_state` back from `do_socket_send` via
the return value, dropping the process-dictionary roundtrip. (#83)
- Replace the `crypto:exor/2` NIF call with inline Erlang XOR for
the 1-4 byte header-protection mask. (#84)
- Inline the `?QLOG_ENABLED` check at packet/frame event call
sites so the event-map is never built when qlog is off. (#86)
- Coalesce the `monotonic_time` samples on the receive hot path
(one BIF call per received datagram instead of three). (#87)
- Flush the pending stream-data batch before emitting an ACK-only
packet so it does not break GSO uniformity on the opt-in socket
backend. +6.4% upload throughput on arm64 Linux docker. (#92)
- Re-enable GSO on the opt-in socket-backend client: drop the
socket-level `UDP_SEGMENT` setsockopt and rely on per-message cmsg
via `flush_gso/1`. (#91)
## [1.1.0] - 2026-04-18
Server-side throughput work. Per-connection send batching over the
shared listener socket on Linux + socket backend coalesces outgoing
packets into sendmsg super-datagrams via UDP_SEGMENT (GSO); on macOS /
gen_udp it is functionally neutral. Several GSO correctness fixes
after CI surfaced a handshake stall. Extra observability so tests and
operators can see the batching win directly.
### Added
- Per-connection send batching on the server. Each server connection
owns a `quic_socket` batch buffer that reuses the listener's UDP
socket. Gated by the new `server_send_batching` option on
`start_server/3` (default `true`); set to `false` to fall back to
the previous direct `gen_udp:send/4` path. (#66)
- `quic_socket:info/1` — map with `backend`, `gso_supported`,
`gso_size`, `gro_enabled`, `batching_enabled`, `max_batch_packets`,
and the new `batch_flushes` / `packets_coalesced` counters.
- `quic_socket:send_immediate/4` — public wrapper that bypasses the
per-connection batch for one-shot control-plane sends.
- `quic_socket:new_sender/2` — build a per-connection sender that
inherits backend + GSO capability from the listener without owning
the socket.
- `quic_connection:get_stats/1` now returns `batch_flushes` and
`packets_coalesced` so tests and benchmarks can assert batching
behaviour rather than just wiring.
- `quic_server_batching_SUITE` — behaviour-level regression: real
256 KB server-to-client downloads assert `packets_coalesced > 1`
when batching is on, and both counters stay at 0 when disabled.
- `docker/gso-debug/` — Erlang 28 + tcpdump + strace container that
reproduces the GSO handshake stall against a bind-mounted tree.
(#74)
- `bench/run_download_bench.erl` and
`quic_throughput_bench:run_download_sink/0,1` drive server-to-client
bulk transfers and report MB/s alongside `batch_flushes` /
`packets_coalesced` so the batching effect is visible next to
throughput.
### Changed
- Stream send path is iovec-native. `quic_frame:encode_iodata/1`
returns `[Header, Data]` and threads iodata through header
protection and `quic_aead` without copying `Data` into a fresh
binary. AEAD specs relaxed to accept iodata.
- 1-RTT ACKs delayed to every 2nd packet or `max_ack_delay` per
RFC 9002 §6.2. Halves receiver ACK traffic on the server and
sender event-processing on the client. Measured on macOS gen_udp:
10 MB upload 45 → 56 MB/s. (#69)
- `quic_loss` switched to a single `queue:queue(#sent_packet{})` for
outstanding packets. Per-ACK work scales with the ACK window, not
the full outstanding queue. Measured on macOS gen_udp: 10 MB
upload 55 → 59 MB/s, 5 MB download 34 → 50 MB/s. (#72)
- `flush_gso/1` passes the batch as an iov list directly to
`socket:sendmsg/2` with the UDP_SEGMENT cmsg, saving up to
~76 KB of user-space copy per flush on a 64-packet batch. (#70)
- `send_app_packet_internal/3` samples `monotonic_time` once per
packet and reuses it for loss tracking and `last_activity`. (#71)
- Per-packet overhead on the bulk-send path reduced: single
`#state{}` update, PTO timer reschedule skipped when within
tolerance, `process_send_queue` and pacing timeout short-circuit
on empty queue, stream data normalised to binary once at the
fragmentation boundary.
- `state_to_map/1` replaces the coarse `send_batching` boolean with
three explicit fields: `send_backend` (`direct` | `gen_udp` |
`socket`), `send_batching_enabled`, `send_gso_supported`.
### Fixed
- Server connection crashed with `function_clause` when the listener
was on `socket_backend => socket` because `inet:sockname/1` rejects
`{'$socket', Ref}` handles. Branch on socket shape:
`socket:sockname/1` for OTP socket handles, `inet:sockname/1` for
`gen_udp` ports.
- UDP_SEGMENT `setsockopt` now uses `sizeof(int)` (32-bit native)
instead of u16, which Linux rejected with `EINVAL`; GSO capability
detection silently returned false and the GSO CT job was skipping.
The cmsg path already used u16 correctly. (#67)
- GSO skipped for single-packet batches: UDP_SEGMENT with a
sub-`gso_size` single-packet payload drops silently on
ubuntu-24.04. `batch_count == 1` has no segmentation work; fall
through to `flush_individual`. (#73)
- Listener no longer sets UDP_SEGMENT at socket level. A socket-wide
UDP_SEGMENT forces segmentation on every outbound datagram,
including short handshake packets that can't be segmented. GSO is
now applied only via the per-message cmsg in `flush_gso`. (#73)
- GSO bypassed when a batch mixes packet sizes (padded 1200-byte
Initial + ~400-byte Handshake). UDP_SEGMENT requires every segment
except the last to be exactly `gso_size`, otherwise the client
sees undecodable datagrams and stalls at
`awaiting_encrypted_extensions`. `flush/1` checks uniformity and
falls through to `flush_individual` when it fails. (#75)
- Listener self-send: `send_packet/6` was calling `quic_socket:send/4`
and dropping the returned state, so version-negotiation / retry /
stateless-reset packets were buffered then lost on the socket
backend with `batching_enabled=true`. Switched to
`send_immediate/4`.
- `send_queue_bytes` accounting leaked on ACK-coalesce dequeues and
could eventually trip `?MAX_SEND_QUEUE_BYTES` on long-lived
connections. Added `send_queue_count` as an explicit O(1)
emptiness predicate so zero-byte FIN-only sends enqueued under
pacing are no longer stranded.
- `examples/echo_server.erl`: `handle_connection/2` expects a DCID
binary, not an info map; returns `{ok, HandlerPid}` so the listener
transfers ownership; peer address fetched via `quic:peername/1`.
(#65)
- `examples/qlog_example.erl`: added a `connection_handler` so the
server echoes client data; waits for the client connection to
terminate before returning so the qlog writer flushes. (#68)
## [1.0.2] - 2026-04-16
### Fixed
- h3: thread FIN through the peer uni stream-type dispatch so a
STREAM frame carrying type-varint + payload + FIN surfaces as one
`{stream_type_data, uni, _, _, true}` event to claimed-stream
owners (#64)
## [1.0.1] - 2026-04-15
### Fixed
- h3: consult `stream_type_handler` on fresh peer-initiated bidi
streams so extensions can claim them before default request
handling (#62)
- docs: `rebar3 ex_doc` now runs clean (#63)
## [1.0.0] - 2026-04-15
First release with HTTP/3. Brings full client + server HTTP/3
(RFC 9114) with QPACK (RFC 9204), HTTP Datagrams (RFC 9297),
Server Push, Extensible Priorities, Extended CONNECT, and the
extension-stream hooks WebTransport needs. Also a critical
flow-control deadlock fix in the QUIC core, a BBR loopback
throughput fix, and the H3 server owner default change.
### HTTP/3 (`quic_h3`, new module)
#### Added
- HTTP/3 client and server (RFC 9114) with QPACK header compression
(RFC 9204): request/response, body data, trailers, GOAWAY,
cancellation, CLI tools (`bin/quic_h3c`, `bin/quic_h3d`)
- Server Push (RFC 9114 §4.6): `push/3`, `send_push_response/4`,
`send_push_data/4`, `set_max_push_id/2`, `cancel_push/2`
- Extensible Priorities (RFC 9218): `priority` request option,
PRIORITY_UPDATE frames, urgency / incremental hints
- Extended CONNECT (RFC 9220) for WebTransport-style upgrades
- HTTP Datagrams (RFC 9297): `send_datagram/3`,
`h3_datagrams_enabled/1`, `max_datagram_size/2`, capsule framing
- Extension-stream hook: `stream_type_handler` option on
`start_server/3` claims peer-initiated uni and bidi streams whose
first varint matches a caller-supplied filter; claimed bytes are
delivered as `{stream_type_data, ...}` owner messages instead of
being parsed as HTTP/3 requests. Owner also receives
`stream_type_open`, `stream_type_closed`, `stream_type_reset`,
`stream_type_stop_sending` events
- Client-initiated extension streams: `quic_h3:open_bidi_stream/1,2`
pre-claims a bidi stream with a signal-type varint (e.g.
WebTransport's `0x41`) so inbound bytes route through the
claimed-bidi path
- Per-connection owner override via `connection_handler` callback on
`start_server/3` for hosting many sessions per listener
- Per-stream handler registration: `set_stream_handler/3,4`,
`unset_stream_handler/2` to redirect body data to a worker pid
- Query API: `get_settings/1`, `get_peer_settings/1`,
`get_quic_conn/1`
- Documentation: `docs/HTTP3.md` reference + benchmarks section
- E2E test infrastructure: `quic_h3_e2e_SUITE`, `quic_h3_h3spec_SUITE`,
`quic_h3_owner_SUITE`; dedicated CI job
- Performance benchmark: `quic_h3_bench`
#### Changed
- Server connection owner now defaults to the listener gen_server
(long-lived, trap_exit'ed) instead of the `start_server` caller
pid; durable owners for datagram / stream-type events should be
supplied via the per-connection `connection_handler` callback
- SETTINGS directionality validation tightened to RFC 9114
#### Fixed
- Server connections wedged with `connect_timeout` when the process
that called `start_server/3` exited before a client arrived and
either `h3_datagram_enabled` or `stream_type_handler` was set
- Discard unknown unidirectional stream payload (RFC 9114 §6.2
unknown-stream-type rule) instead of erroring the connection
- Emit trailing empty DATA event when response carries FIN so owners
always see `Fin = true` exactly once
- Strict PRIORITY_UPDATE frame parsing per RFC 9218
- DoS hardening on header / capsule / frame parsing
- Header / trailer / `:path` / `:status` symmetry between client and
server validation
- GOAWAY drain enforcement: reject new requests after a GOAWAY is
sent or received
- Server push lifecycle correctness (PUSH_PROMISE pairing, duplicate
detection, MAX_PUSH_ID enforcement)
- Tighten RFC 9114 / 9204 compliance across multiple parsers
- `sync` option on `connect/3` resolves an E2E race where the client
tried to send before SETTINGS exchange completed
- Improved frame error handling and header validation
- aioquic SETTINGS compatibility
- QPACK: encoder eviction guard prevents references to
unacknowledged dynamic-table entries; rejects `Increment = 0`
### QUIC transport
#### Added
- Spin bit (RFC 9000 §17.4)
- Stateless reset support (RFC 9000 §10.3)
- Full NEW_TOKEN issuance and validation loop
- `RESET_STREAM_AT` transport parameter and frame plumbing
- `quic:set_congestion_control/2` runtime CC switch API
- `quic:get_peer_transport_params/1` introspection API
#### Changed
- BBR internal clock switched to microseconds; loopback transfers no
longer pin to the InitialRtt fallback
#### Fixed
- Stream-level `MAX_STREAM_DATA` window stopped sliding once
`recv_max_data` reached `fc_max_receive_window` (8 MB default).
Past the cap, the auto-tune re-sent the same value forever and the
sender stalled at 8 MB lifetime per stream. The window now slides
past `recv_offset` like the connection-level window already does
- BBR loopback throughput regression: ms-precision clock collapsed
delivery-rate intervals to 0/1 ms and clamped BDP to the 4-packet
minimum, holding throughput at ~0.03 Mbps. Microsecond-precision
internal clock restores expected behavior
- Send `MAX_STREAMS` as peer-initiated streams complete
(RFC 9000 §4.6); previously peers could exhaust the stream-id space
### Distribution (`quic_dist`)
#### Added
- User-accessible streams API: `quic_dist:open_stream/1,2`, `send/3`,
`close_stream/1`, `reset_stream/1,2`, `controlling_process/2`,
`list_streams/0,1`, with acceptor pool and stream priorities
- Connection migration logging
- Distributed Erlang benchmarks + multi-node test scripts
- Per-iteration latency stats in throughput benchmark (min/p50/p99/max
+ timeout counts)
#### Changed
- Test runner logs each test's results as it returns rather than at
the end, so a stalled middle test no longer hides the others
### Tests and infrastructure
- `quic_e2e_*_SUITE` and `quic_h3_e2e_SUITE` run against in-process
servers; Docker no longer required for these jobs
## [0.11.0] - 2026-04-09
### Added
- Full QUIC connection migration support (RFC 9000 Section 9)
- Server-side address change detection (NAT rebinding vs active migration)
- Path validation with PATH_CHALLENGE/PATH_RESPONSE
- CID rotation for path unlinkability
- `disable_active_migration` transport parameter
- Application error code support for CONNECTION_CLOSE frames
- Client certificate support (`verify` server option)
- CUBIC congestion control (RFC 9438)
- BBR congestion control
- HyStart++ slow start (RFC 9406) for all CC algorithms
- UDP packet batching with GSO/GRO support
- Configurable UDP buffer sizing (recbuf/sndbuf options)
- QLOG tracing for debug visibility
- Pluggable congestion control behavior
- Stream deadlines for per-stream timeout control
- STOP_SENDING API (`quic:stop_sending/3`)
- `max_udp_payload_size` transport parameter
- Async send API and socket receive optimizations
- Throughput benchmarks (`quic_throughput_bench`, `quic_batch_bench`)
- QUIC-based Erlang distribution (`quic_dist`) for node communication over QUIC
- Distribution modules: `quic_dist`, `quic_dist_controller`, `quic_dist_sup`
- EPMD replacement module (`quic_epmd`) for QUIC-based node discovery
- Discovery backends: `quic_discovery_static` (static config), `quic_discovery_dns` (DNS SRV)
- Session ticket storage (`quic_dist_tickets`) for 0-RTT reconnection
- Stream prioritization for distribution: control stream (urgency 0), data streams (urgency 4-6)
- Backpressure mechanism for distribution congestion control
- Keep-alive PING frames for transport-level liveness (configurable via `keep_alive_interval`)
- `quic:get_stats/1` API for connection packet counts (used for liveness detection)
- `quic:send_ping/1` API for transport-level PING frames
- RTT-based flow control auto-tuning for improved throughput
- Packet pacing (RFC 9002 Section 7.7) to prevent bursts
### Changed
- ConnRef is now connection PID (simpler API)
- Improved ACK processing performance (O(n^2) to O(n) with gb_sets)
- Timer batching for reduced overhead
- Zero-copy packet processing optimizations
- Distribution liveness detection now uses QUIC packet counts instead of application ticks
- Improved congestion control with quic-go-inspired settings (larger initial cwnd)
- Flow control windows auto-tune based on RTT measurements
### Fixed
- Throughput regression in connection migration (wasteful binary allocation)
- CUBIC cwnd collapse issue
- BBR delivery rate interval causing cwnd collapse
- BBR initial pacing rate causing transfer hangs
- Pacing precision loss causing transfer stalls
- Various RFC compliance fixes for QUIC connection migration
- `net_tick_timeout` errors under heavy load by using QUIC-level activity as liveness proof
- Stream flow control `recv_max_data` using wrong limits
- Distribution controller backpressure data loss
- Congestion control protocol compliance issues
- Recovery exit when only non-ack-eliciting packets are ACKed
- Tick timeout issues in distribution controller
- Flow control blocking that caused deadlocks
- Message framing for large message transfers
### Removed
- NAT traversal support from `quic_dist` (use standard QUIC connection migration instead)
## [0.10.2] - 2026-02-21
### Fixed
- Deprecated `catch` expressions replaced with `try...catch...end`
- Undefined `dynamic()` type replaced with `term()` in type specs
- CI workflow consolidated with separate unit-tests, e2e, and interop jobs
## [0.10.1] - 2026-02-21
### Fixed
- ACK range encoding crash for out-of-order packets: when packets arrived out
of order (e.g., 10, 5, 6), ACK ranges were not properly maintained in
descending order or merged, causing negative Gap values that crashed
`quic_varint:encode/1` with `badarg`
## [0.10.0] - 2026-02-21
### Added
- RFC 9312 QUIC-LB Connection ID encoding support for load balancer routing
- New `quic_lb` module with three encoding algorithms:
- Plaintext: server_id visible in CID (no encryption)
- Stream Cipher: AES-128-CTR encryption of server_id
- Block Cipher: 4-round Feistel network for <16 bytes, AES-CTR for 16 bytes,
truncated cipher for >16 bytes
- `#lb_config{}` record for LB configuration (algorithm, server_id, key, nonce_len)
- `#cid_config{}` record for CID generation configuration
- `lb_config` option in `quic_listener` to enable LB-aware CID generation
- Variable DCID length support in short header packet parsing
- LB-aware CID generation in `quic_connection` for NEW_CONNECTION_ID frames
- E2E test suite `quic_lb_e2e_SUITE` with 21 integration tests
- `quic:server_spec/3` to get a child spec for embedding QUIC servers in custom
supervision trees
- Stream reassembly test suite `quic_stream_reassembly_SUITE` for ordered delivery
verification
### Changed
- `quic:set_owner/2` is now asynchronous (cast instead of call)
### Fixed
- `quic:get_server_port/1` now returns the actual OS-assigned port when server
was started with port 0 (ephemeral port), instead of returning 0
- `quic:get_server_connections/1` now correctly returns connection PIDs; was
returning empty list due to `get_listeners/1` returning supervisor pids
instead of actual listener processes
- Removed redundant `link/1` call in listener (connection already linked via
`gen_statem:start_link`)
- Unhandled calls in connection state machine now return `{error, {invalid_state, State}}`
instead of silently timing out
- Server-side connection termination no longer closes shared listener socket:
previously when a server connection terminated, it would close the UDP socket
shared with the listener, breaking all subsequent connections
- Cancel delayed ACK timer in connection terminate to prevent timer messages
to dead processes
- Session ticket table now has TTL (7 days) and size limit (10,000 entries) to
prevent unbounded memory growth
- Listener now properly cleans up ETS tables on terminate (standalone mode only,
pool mode tables are managed by the pool manager)
- Draining state now uses calculated `3 * PTO` timeout per RFC 9000 Section 10.2
instead of hardcoded 3 seconds
- Pre-connection pending data queue now has size limit (1000 entries) to prevent
memory exhaustion from slow handshakes
- Buffer contiguity calculation now has iteration limit to prevent stack overflow
with highly fragmented receive buffers
- Stream data is now properly reassembled before delivery: previously data was
delivered immediately as received, causing corruption when packets arrived out
of order during large file transfers. Data is still streamed incrementally as
contiguous chunks become available
- Server connections no longer modify listener's socket active state: server-side
connections were calling `inet:setopts(Socket, [{active, once}])` on the shared
listener socket, overriding the listener's `{active, N}` configuration and
causing the socket to go passive after receiving packets
## [0.9.0] - 2026-02-20
### Added
- Multi-pool server support with ranch-style named server pools
- `quic:start_server/3` to start named server with connection pooling
- `quic:stop_server/1` to stop named server
- `quic:get_server_info/1` to get server information (pid, port, opts, started_at)
- `quic:get_server_port/1` to get server listening port
- `quic:get_server_connections/1` to get server connection PIDs
- `quic:which_servers/0` to list all running servers
- Application supervision structure (`quic_app`, `quic_sup`, `quic_server_sup`)
- ETS-based server registry (`quic_server_registry`) with process monitoring
- `pool_size` option for listener process pooling with SO_REUSEPORT
- FreeBSD CI testing workflow
- Expanded Linux CI matrix (Ubuntu 22.04/24.04, OTP 26-28)
### Changed
- `quic.app.src` now includes `{mod, {quic_app, []}}` for OTP application behaviour
- Listener supervisor registers with server registry on init for restart recovery
## [0.8.0] - 2026-02-20
### Added
- Stream prioritization (RFC 9218): urgency-based scheduling with 8 priority
levels (0-7) and incremental delivery flag
- `quic:set_stream_priority/4` and `quic:get_stream_priority/2` API
- Bucket-based priority queue for O(1) stream scheduling
- Preferred address handling (RFC 9000 Section 9.6): server can advertise a
preferred address during handshake, client validates via PATH_CHALLENGE and
automatically migrates to validated preferred address
- `preferred_ipv4` and `preferred_ipv6` listener options for server configuration
- `#preferred_address{}` record for IPv4/IPv6 addresses, CID, and reset token
- `quic_tls:encode_preferred_address/1` and `quic_tls:decode_preferred_address/1`
- Idle timeout enforcement (RFC 9000 Section 10.1): when `idle_timeout` option
is set, internal timer automatically closes connection after timeout with no
activity (set to 0 to disable)
- Persistent congestion detection (RFC 9002 Section 7.6): detects prolonged packet
loss spanning > PTO * 3 and resets cwnd to minimum window
- Frame coalescing: ACK frames are coalesced with small pending stream data
(< 500 bytes) for more efficient packet utilization
## [0.7.1] - 2026-02-20
### Fixed
- Packet number reconstruction per RFC 9000 Appendix A: truncated packet numbers
are now properly reconstructed using the largest received PN, fixing decryption
failures for large responses (>255 packets with 1-byte PN encoding)
## [0.7.0] - 2026-02-20
### Added
- Docker interop runner integration (client and server images)
- Session resumption interop test (`resumption`)
- 0-RTT early data interop test (`zerortt`)
- Connection migration interop test (`connectionmigration`)
- `quic:migrate/1` API for triggering active path migration
- All 10 QUIC Interop Runner test cases now pass:
- handshake, transfer, retry, keyupdate, chacha20, multiconnect, v2,
resumption, zerortt, connectionmigration
### Fixed
- Connection-level flow control: now properly tracks `data_received` and sends
MAX_DATA frames when 50% of connection window is consumed (RFC 9000 Section 4.1)
- Large downloads: interop client now writes to disk incrementally (streaming)
instead of accumulating in memory
- Server DCID initialization: server now correctly sets DCID from client's
Initial packet SCID field, fixing short header packet alignment
- Key update HP key preservation: header protection keys are no longer rotated
during key updates per RFC 9001 Section 6.6
- Fixed bit validation: skip padding bytes (0x00) and invalid short headers
(fixed bit not set) in coalesced packets
- Role-based key selection in 1-RTT packet decryption
## [0.6.5] - 2026-02-19
### Added
- `quic_listener:start/2` for unlinked listener processes
- `set_owner` call handling in idle and handshaking states
### Fixed
- IPv4/IPv6 address family matching when opening client sockets
- Race condition: transfer socket ownership before sending packet
- Handle header unprotection errors gracefully in packet decryption
- Removed verbose debug logging from listener
## [0.6.4] - 2026-02-17
### Fixed
- Server now selects correct signature algorithm based on key type (EC vs RSA)
## [0.6.3] - 2026-02-17
### Fixed
- Fixed transport params parsing in ClientHello - properly unwrap {ok, Map} result
## [0.6.2] - 2026-02-17
### Fixed
- Fixed key selection for all packet types based on role (server vs client)
- Server now uses correct keys for both sending and receiving packets
- Fixed Initial, Handshake, and 1-RTT packet encryption/decryption
## [0.6.1] - 2026-02-17
### Fixed
- Server-side packet decryption now uses correct keys (client keys for Initial/Handshake packets received from clients)
## [0.6.0] - 2026-02-17
### Added
- DATAGRAM frame support (RFC 9221) for unreliable data transmission
- `quic:set_owner/2` to transfer connection ownership (like gen_tcp:controlling_process/2)
- `quic:peercert/1` to retrieve peer certificate (DER-encoded)
- `quic:send_datagram/2` to send QUIC datagrams
- Connection handler callback in `quic_listener` for custom connection handling
- ACK delay for datagram-only packets per RFC 9221 Section 5.2
- Proper ACK generation at packet level for all ack-eliciting frames
### Fixed
- Datagrams are not retransmitted on loss (RFC 9221 compliance)
- ACKs now sent for all ack-eliciting frames, not just stream data
## [0.5.1] - 2026-02-17
### Fixed
- Pad payload for header protection sampling to prevent crashes during PTO timeout
## [0.5.0] - 2026-02-17
### Added
- Retry packet handling (RFC 9000 Section 8.1)
- Stateless reset support (RFC 9000 Section 10.3)
- Connection ID limit enforcement (RFC 9000 Section 5.1.1)
- ECN support for congestion control (RFC 9002 Section 7.1)
- RFC 9000/9001 test vectors
- Interoperability test suite with quic-go server
- E2E tests in CI pipeline
### Fixed
- CI compatibility with OTP 28 (use rebar3 nightly)
- quic-go Docker build (pin to v0.48.2)
## [0.4.0] - 2025-02-17
### Changed
- Moved `doc/` to `docs/` to prevent ex_doc from overwriting documentation
- Consolidated `hash_len/1` and `cipher_to_hash/1` functions in `quic_crypto` module
- Refactored key derivation in `quic_keys` using `cipher_params/1` helper
- Improved socket cleanup on initialization failure in `quic_connection`
### Removed
- Removed `send_headers/4` API (HTTP/3 functionality, not core QUIC transport)
### Fixed
- Added bounds checking for header protection sample extraction in `quic_aead`
- Added CID length validation (max 20 bytes per RFC 9000) in `quic_packet`
- Added token length validation in `quic_packet`
- Added frame data length limits in `quic_frame` to prevent memory exhaustion
- Added ACK range limits in `quic_ack` to prevent DoS attacks
- Fixed weak random: use `crypto:strong_rand_bytes/1` for ticket age_add
- Fixed dialyzer warning in `quic_tls` by adding error handling to `decode_transport_params/1`
## [0.3.0] - 2025-02-16
### Added
- Server mode with `quic_listener` module
- 0-RTT early data support (RFC 9001 Section 4.6)
- Connection migration support (RFC 9000 Section 9)
- Key update support (RFC 9001 Section 6)
## [0.2.0] - 2025-02-15
### Added
- Stream multiplexing (bidirectional and unidirectional)
- Flow control (connection and stream level)
- Congestion control (NewReno)
- Loss detection and packet retransmission (RFC 9002)
## [0.1.0] - 2025-02-14
### Added
- Initial release
- TLS 1.3 handshake (RFC 8446)
- Basic QUIC transport (RFC 9000)
- AEAD packet protection (RFC 9001)