Changelog
View SourceAll notable changes to this project will be documented in this file.
[3.0.0] - 2026-11-15
Upgrading from 2.x:
- A peer's certificate is now checked for what it may be used for: a
server's for TLS server authentication and a client's for TLS client
authentication, when it has an Extended Key Usage, and for signing,
when it has a Key Usage. A certificate issued for something else is
refused. A certificate used to both accept and open connections, as
an Erlang distribution node uses its own, needs both purposes or no
Extended Key Usage. A certificate you list yourself in
cacertsis taken as configured. quic:datagram_max_size/1returns the largest payload you can send now, not the limit the peer advertised. Code that used it as the peer's limit gets a smaller number, which grows as path MTU discovery confirms larger packets.- A congestion control module no longer implements persistent
congestion detection, and
quic_cc:can_send_control/2and the urgency argument ofquic_cc:send_checkandquic_cc:send_check_runare gone. Only custom congestion control modules are affected. - A server configured with the
stream_cipherorblock_cipherQUIC-LB algorithm no longer starts. Useencryptedwith the same key. - A server with an invalid
lb_configno longer starts and returns{error, {invalid_lb_config, Reason}}.
Security
- A peer's certificate was accepted whatever it was issued for. A
certificate from a trusted CA meant for another purpose, such as code
signing, could authenticate a QUIC server or client, and so could one
whose Key Usage does not allow signatures. A server's certificate now
has to allow TLS server authentication and a client's TLS client
authentication when it carries an Extended Key Usage (RFC 5280
Section 4.2.1.12), and its Key Usage, when present, has to allow
signatures (RFC 9846 Section 4.5.1.2). A certificate you list yourself
in
cacertsis still taken as configured. A certificate used both to accept and to open connections, as an Erlang distribution node uses its own, needs both purposes or no Extended Key Usage.
Added
alpn_handlercan take a third argument, the connection's current owner: the oneconnection_handlerreturned, or the listener without one. An owner started byconnection_handlercan now be told the negotiated ALPN without keeping a map from connection to owner.- QUIC-LB now follows draft-ietf-quic-load-balancers-22. The new
encryptedalgorithm produces the encrypted connection IDs the draft defines (Section 5.4), which a load balancer that follows it decodes with the same key. - The QUIC ACK frequency extension (draft-ietf-quic-ack-frequency-14).
Connections advertise
min_ack_delayand follow a peer's request to acknowledge less or more often, or at once, which lets a peer cut the ACK traffic it receives on bulk transfers. With BBR, a connection to a peer that supports it asks for fewer ACKs while the bandwidth is steady and for more while probing, and for an immediate ACK at the end of each flight in ProbeRTT (draft-ietf-ccwg-bbr-07 Section 4.1.3.3). To a peer that supports it, PTO probes, PMTU probes and the first packet after a client moves to a new path ask for an immediate ACK, so loss recovery, path MTU discovery and migration are not slowed by the peer's ACK delay. Until a peer sends a request, acknowledgments go out as before.
Changed
- Breaking: the
stream_cipherandblock_cipherQUIC-LB algorithms are refused. Their connection IDs followed no QUIC-LB draft, so a load balancer could not read the server ID from them. A server configured with either no longer starts; useencryptedand give the load balancer the same key. - A server whose
lb_configis invalid no longer starts and returns{error, {invalid_lb_config, Reason}}. It used to log a warning and issue random connection IDs the load balancer could not route. - BBR now follows draft-ietf-ccwg-bbr-07 (BBRv3). It measures bandwidth per packet, ends Startup on heavy loss as well as on a bandwidth plateau, lowers its rate and data in flight by at most 30% per round trip with loss, and probes for more bandwidth every 2 to 3 seconds, sooner on paths with a small bandwidth-delay product. Startup no longer ends on an RTT increase (HyStart++), which the draft does not use. On an ECN congestion mark BBR enters recovery, with its window bounded by its model; the draft defines no further response to ECN.
- With BBR, persistent congestion drops the congestion window to the minimum and keeps the bandwidth and RTT estimates, instead of starting over from Startup. The window from before comes back when the recovery ends.
- BBR sends bursts of at most 1 ms at its pacing rate, between 2 packets and 64 KB (draft-ietf-ccwg-bbr-07 Section 5.6.3), instead of 2 ms with a floor of 12 packets.
- Stream urgency 0 no longer lets a stream send up to 1200 bytes past
the congestion window. This covers a peer's HTTP/3
priorityof u=0 (RFC 9218) and Erlang distribution ticks, which now wait for the window like other data; urgency still decides which stream goes first. Distribution liveness counts every packet received, so a full window does not cause anet_tick_timeout. quic:send_ping/1queues its PING while the congestion window is full instead of sending it past the window. A keep-alive PING is skipped then: the data in flight already keeps the connection alive.- Breaking:
quic_cc:can_send_control/2is removed, andquic_cc:send_checkandquic_cc:send_check_runno longer take an urgency argument. - Breaking:
quic_cc:detect_persistent_congestion/3and the matching congestion control callback are removed. Persistent congestion is now decided by the loss tracker, so a congestion control module no longer implements it. - Breaking:
quic:datagram_max_size/1now returns the largest payloadquic:send_datagram/2accepts right now, not the limit the peer advertised. That is the peer's limit less the frame overhead, or what fits in one packet at the confirmed path MTU, whichever is smaller, so it grows as path MTU discovery confirms larger packets. It is still 0 when the peer does not support datagrams, and a connection that is gone now returns{error, closed}. The peer's own value is inquic:get_peer_transport_params/1.quic_h3:max_datagram_size/2follows it. quic:get_peer_transport_params/1andquic:datagram_max_size/1also answer before the handshake completes, from what is known of the peer so far: for a client sending 0-RTT, the parameters remembered with the session ticket. HTTP/3 uses them to advertise datagrams in 0-RTT.
Fixed
- An HTTP/3 connection kept an entry and a monitor for every finished request whose handler stayed registered, so a long-lived handler grew the connection's memory with each request. A finished request now lets go of its handler.
- A client that used zero-length connection IDs and moved to the server's preferred address got no answer there: the server looked for a new connection ID for that address, and such a client has none to give. It is now answered under its empty connection ID, which is used on every path (RFC 9000 Sections 5.1.1 and 8.2.2).
- Datagrams were checked against the largest packet size path MTU
discovery may try, not the size it has confirmed. A datagram between
the two was accepted and sent, and lost on a path that does not carry
it; those losses could also make the connection fall back to the
smallest packet size. A peer that limits UDP payloads to 1200 bytes
could also be sent datagrams larger than it accepts (RFC 9000 Section
18.2). A datagram now has to fit the confirmed path MTU, as RFC 8899
Section 3 requires, and is refused with
datagram_too_large_for_pathuntil a larger one is confirmed. - The peer's
max_datagram_frame_sizewas applied to the datagram payload alone, so a datagram up to a few bytes over the peer's limit was sent. The limit covers the whole frame (RFC 9221 Section 3), and datagrams are now checked that way. - A received datagram up to a few bytes larger than our
max_datagram_frame_sizewas accepted. The connection now closes with PROTOCOL_VIOLATION, as RFC 9221 Section 3 requires. - A datagram of more than 16383 bytes, on a path whose MTU allows it, could be sent in a packet 2 bytes larger than the path MTU. A packet that ended a flight could also be made 1 byte too large by the immediate acknowledgment request added to it.
- A stream reset could give a smaller final size than the stream's end had already given, when that end went out ahead of data still waiting to be sent. The peer then closed the whole connection, as RFC 9000 Section 4.5 requires. This happened when a server stopped reading an upload early. A reset now keeps the final size already sent.
- When the network reordered packets, a packet could be acknowledged after it had been declared lost, and what it carried was still sent again: stream data, control frames and handshake data waiting to be resent, or already resent and lost again. A stream whose last data was in such a packet also waited for the copy to be acknowledged before it finished. A late acknowledgment now stops those copies, and the stream no longer waits for them (RFC 9000 Section 13.3).
- Packets that carry padding but nothing that needs an acknowledgment, such as a client's Initial packets that only acknowledge, were not counted as data in flight. They now count until they are acknowledged, declared lost or discarded (RFC 9002 Section 2), and like other data in flight they wait for room in the congestion window (Section 7).
- The loss tracker and the congestion controller could disagree about the data in flight: discarding the Initial or Handshake keys removed bytes that had never been counted, which could take the count of other packets with it, and a lost packet that only acknowledged was taken off the congestion controller's count. Both now count the same packets (RFC 9002 Section 2 and Appendix B.9).
- A lost packet that only carried acknowledgments cut the congestion window when the loss timer declared it lost. Only packets in flight now cause a congestion response (RFC 9002 Appendix B.8).
- Switching congestion control algorithm on a live connection started the new controller with nothing in flight, so it could send past its window until the earlier packets were acknowledged. It now starts with the data already in flight.
- Persistent congestion, which drops the congestion window to its minimum, was declared from any two lost packets far enough apart: even when a packet sent between them had been acknowledged, when one of them only carried acknowledgments, or when they were exactly the threshold apart. It now follows RFC 9002 Section 7.6.2: two lost packets that needed an acknowledgment, sent after the first RTT sample and more than the persistent congestion duration apart, with no packet sent between them acknowledged in any packet number space. The two packets no longer have to be declared lost at the same time, and once persistent congestion is declared the minimum RTT restarts from the newest sample, so a path whose RTT rose is not declared congested again and again (Section 5.2).
- A client that had sent 0-RTT data could not complete a connection when the server answered with a Retry: processing the Retry failed each time and the connection timed out. The 0-RTT data now goes out again as 0-RTT after the new Initial, to the connection ID the Retry gave (RFC 9000 Section 17.2.5.3), without data the stream no longer needs (Section 13.3).
- Control frames were sent even when the congestion window was full, and still counted as in flight: MAX_DATA, MAX_STREAM_DATA, MAX_STREAMS, the BLOCKED frames, NEW_CONNECTION_ID, RETIRE_CONNECTION_ID, RESET_STREAM, STOP_SENDING, HANDSHAKE_DONE, NEW_TOKEN, session tickets, ACK_FREQUENCY and PING. So were 0-RTT data, small stream data sent along with an ACK, frames added to a packet already being filled, and one packet too many of a large write. Every packet that counts as in flight now waits for the congestion window and the pacer (RFC 9002 Section 7); packets that carry only ACKs or a CONNECTION_CLOSE still go out at once. Held control frames go out in order, before stream data, as soon as the window opens. A connection holding more than 16384 of them closes with INTERNAL_ERROR.
- A PTO probe did not carry the control frames waiting for the congestion window, so a MAX_DATA the peer needed could wait for an acknowledgement that a lost path would not deliver. The probe now carries them, and a Handshake or Initial probe sends the handshake data the window held back (RFC 9002 Sections 6.2.4 and 7.5). A probe no longer resends a flow-control limit that a newer one replaced (RFC 9000 Section 13.3).
- When a loss cut the congestion window below the data in flight, the retransmission waited for the window to drain. One packet, normally that retransmission, now goes out at once (RFC 9002 Section 7.3.2).
- A DATAGRAM the pacer refused was sent anyway. It is now dropped and
reported as
congestion_limited, like one the congestion window refuses. - A QUIC-LB server ID and nonce longer than 19 bytes together were accepted. The connection IDs were longer than QUIC allows and their first byte named the wrong configuration. They are now refused (draft-ietf-quic-load-balancers-22 Section 5.3).
- A QUIC-LB configuration rotation, nonce length or connection ID length that was not an integer was accepted and made connection ID generation fail later. It is now refused when the configuration is created.
quic_lb:decode_server_id/2returned a server ID for a connection ID whose first three bits name another configuration. It now returns{error, config_rotation_mismatch}(Section 3.1).- BBR counted a round trip on every ACK, so it left Startup after a few ACKs, well before the path was full, and forgot bandwidth samples within one round trip. It now counts one round per round trip (draft-ietf-ccwg-bbr-07 Section 5.5.1).
- An application sending less than BBR allowed lowered BBR's bandwidth estimate. Such samples now count only when they raise it (draft-ietf-ccwg-bbr-07 Section 5.5.4).
- BBR's ProbeRTT started only from ProbeBW and always returned there. It now starts from any state once the minimum RTT is 10 seconds old and returns to Startup if the path was never filled (draft-ietf-ccwg-bbr-07 Section 5.3.4).
- An ACK that both acknowledged packets and revealed a loss grew the congestion window with those packets before cutting it, for every congestion controller. The loss now counts first, so packets sent before the recovery it starts no longer grow the window (RFC 9002 Appendix A.7).
- ECN-CE counts were compared with one count shared by all packet number spaces, so an increase in one space after a higher count in another was missed. Each space now keeps its own count, and an increase is a congestion event for the largest acknowledged packet; a recovery already under way absorbs it (RFC 9002 Section 7.1 and Appendix B.7). This changes NewReno and CUBIC when the peer reports ECN. A window cut by a CE mark is no longer undone when losses from the same episode later prove spurious.
- A lost PING was sent again, although it carries nothing to repair (RFC 9000 Section 13.3). It no longer is; the probe timeout still sends a PING when nothing else is outstanding, so keep-alive and liveness checks work as before.
- A lost DATA_BLOCKED, STREAM_DATA_BLOCKED or STREAMS_BLOCKED was sent again with its old limit even after the peer had raised that limit, or the stream had been reset. It is now sent again only while the connection or stream is still blocked at that limit (RFC 9000 Section 13.3).
- A lost STOP_SENDING was sent again after the stream had received all its data or the peer had reset it. It is now sent again only while the receive side of the stream is still open (RFC 9000 Section 13.3).
- A lost RESET_STREAM_AT was sent again even after a later reset of the same stream had lowered its reliable size, so the peer could see the reliable size go back up, which the draft forbids. Only the reset carrying the current reliable size is sent again now (draft-ietf-quic-reliable-stream-reset-11 Section 5.2).
- A lost NEW_CONNECTION_ID was sent again even after the peer had retired that connection ID, offering it a connection ID it had already given up. A connection ID the peer has retired is no longer sent again (RFC 9000 Section 13.3).
- A PATH_RESPONSE sent as an ordinary frame was sent again when its packet was lost, and a lost PATH_CHALLENGE would have been repeated with the same data. A PATH_RESPONSE is now sent once and a PATH_CHALLENGE is never repeated; the peer's next challenge gets its own response (RFC 9000 Sections 8.2.2 and 13.3).
- A path MTU probe was not counted as data in flight, so it went out whatever the congestion window, and one declared lost by the loss timer cut the window. Probes now count as in flight and wait for the congestion window like other packets, and losing one never lowers the sending rate (RFC 9000 Section 14.4, RFC 9002 Section 2).
- An HTTP/3 request stream received receive credit with its first
packet, before its handler could register. A handler that then chose
flow_control => manualwas not holding the client to one window: the client could send several windows ahead of what the handler had consumed, up tomax_receive_window. A stream the peer opens now keeps its initial window until its handler registers, so a manual handler gets exactly one window past what it consumed, and a handler in the default mode gets credit as before once it registers (RFC 9000 Section 4.1). When a handler answers in full and ends without registering, as the default 404 handler does, the client is asked to stop sending the rest of the body withH3_NO_ERRORand what was buffered for it is dropped (RFC 9114 Section 4.1). Such a body used to be buffered up tomax_buffered_bodyand its stream reset.
[2.7.0] - 2026-11-13
Security
- The client accepted a server certificate whose names RFC 9525 does not
allow to match the host: a wildcard that was only part of a label (such
as
f*o.example.com), sat inside anxn--label, or was a lone*; and, on OTP 27 and 28, a certificate with no subjectAltName whose subject common name matched the host. These certificates are now rejected as a hostname mismatch. Only the DNS and IP subjectAltName entries are matched, and a wildcard counts only as the whole leftmost label, standing for one label (RFC 9525 Sections 2, 6.3 and 6.4). An internationalized host name is converted to A-labels, with UTS 46 mapping, before it is matched; a name that cannot be converted matches no certificate. A server name that is not a valid host name, such as one with brackets, a port or an empty label, matches no certificate and is not sent as SNI. - A client that connected to an IP address, with verification on and no
server_name, did not check the server certificate's name: any certificate that chained to the trust anchors was accepted. The address is now checked against the certificate's IP subjectAltName entries (RFC 9525 Section 6.4), and it is no longer sent as SNI (RFC 6066 Section 3), including when given asserver_name. An IPv6 address in brackets, such as[::1], or with a zone ID counts as the address, whether given as the host, asserver_nameor throughquic_h3:connect. A host in brackets that is not an IP address, such as[localhost], is refused with{error, einval}instead of being dialled without its brackets. A hostname, as the host or asserver_name, works as before. - Crash reports, supervisor reports and
sys:get_status/1printed private keys, PSKs, TLS secrets, packet protection keys, session ticket secrets, QUIC-LB keys and stateless reset secrets and tokens. These are no longer shown for connections, listeners, the server registry, HTTP/3 connections, distribution controllers and the distribution ticket store. The server, listener pool, top-level and distribution supervisors no longer keep these options in their state or child specs. The stack trace of a crash could still show them in the arguments of the failing call; erlang_quic now adds a logger filter,quic_redact, that removes these arguments from crash, terminate and supervisor reports. - With the optional crypto NIF built, a protected 1-RTT packet with an empty payload made the receive path read frames from memory past the decrypted packet. Only the decrypted bytes are read now, so such a packet carries no frames and is rejected.
- QUIC distribution with
verify_peerand no CA still started its early-boot listener, which then checked node certificates against the system trust store. That listener now refuses to start, as the supervised one already did. Acacertwas also ignored when the certificate came from files, and acacert_filewhen the certificate was given inline; the CA now applies however the certificate is given. - QUIC distribution read some malformed security settings as a default:
an unrecognised
verifyvalue asverify_none, a malformedauth_callbackorpsk_callbackas no callback, and a setting repeated on the command line as unset. A malformed setting, a repeated or unknown-quic_distsetting, or a certificate without its key now stops distribution from starting, and connection setup, with{bad_config, Reason}. One-quic_distflag can now carry several settings.
Added
- An auth callback can return
{ok, {authenticated_node, Node}}; quic_dist then refuses a peer whose dist node name differs from the authenticated one. Before, nothing tied the node a callback had authenticated to the name the peer then used in the dist handshake. Other return values work as before. - Listener option
alpn_handler, called with the connection and the negotiated ALPN during the handshake, returns the connection's owner. One listener can then hand each protocol to its own process, before any stream data arrives. A handler that is not a fun of arity 2 is refused when the server starts. quic_h3:accept/2starts the server side of HTTP/3 on a connection the application already holds, such as one analpn_handlerpicked forh3. It takes the per-connection options ofquic_h3:start_server/3.
Changed
- erlang_quic now depends on idna, used to compare internationalized host names with certificates.
- The default
pool_sizenow runs one listener, as documented. It ran two on Linux. Setpool_size => 2for the old behaviour. - A
connection_handlerthat returns{error, Reason}now declines the connection: it is stopped and the client is refused with CONNECTION_REFUSED. - Calls on a connection whose process has stopped return
{error, closed}instead of exiting withnoproc. send_data,send_data_async,reset_stream,reset_stream_at,stop_sendingandset_ownerreturn{error, badarg}for bad arguments, andset_stream_deadlinereturns{error, invalid_action}or{error, invalid_error_code}, instead of crashing the connection.- A handshake that fails with a TLS alert now tells the owner
{closed, {tls_alert, Reason}}. - With
idle_timeout => 0, a handshake that hears nothing from the peer now ends afterdisconnect_timeoutinstead of never. - The owner of a connection whose handshake ends at its deadline gets
{closed, handshake_timeout}. A handshake that heard nothing from the peer for the whole idle timeout still ends with{closed, idle_timeout}. - A FIN on the peer's control or QPACK streams went unnoticed; only a reset closed the connection. Both now close it with H3_CLOSED_CRITICAL_STREAM (RFC 9114 Section 6.2.1, RFC 9204 Section 4.2).
- More streams waiting for the QPACK encoder stream than SETTINGS_QPACK_BLOCKED_STREAMS allows now close the connection with QPACK_DECOMPRESSION_FAILED, for trailers as well as HEADERS. Before, blocked HEADERS past the limit only reset their stream, and blocked trailers were not counted at all (RFC 9204 Section 2.1.2).
- DATA before HEADERS on a request stream, HEADERS or DATA after the trailers, and HEADERS or PUSH_PROMISE on an established CONNECT tunnel now close the connection with H3_FRAME_UNEXPECTED instead of resetting the stream (RFC 9114 Sections 4.1 and 4.4).
- A server receiving CANCEL_PUSH for a push ID it never promised now closes the connection with H3_ID_ERROR (RFC 9114 Section 7.2.3).
- PRIORITY_UPDATE on a request or push stream, or received by a client, was ignored or applied. It now closes the connection with H3_FRAME_UNEXPECTED. On a server, one naming a stream that is not a request stream, or a push not yet promised, closes it with H3_ID_ERROR (RFC 9218 Section 7.2).
quic_h3:send_data/4,quic_h3:send_trailers/3andquic_h3:send_push_data/4returnokand send nothing after a HEAD, 204 or 304 response, which is already complete (RFC 9110 Section 6.4.1). Before a final response, with nothing or only a 1xx sent, they return{error, no_final_response}(RFC 9114 Section 4.1).quic_h3:respond/5with a 1xx status returns{error, informational_status}, and status 101 given tosend_response/4,respond/5orsend_push_response/4returns{error, status_101_forbidden}(RFC 9114 Section 4.5).- TLS now follows RFC 9846, which obsoletes RFC 8446.
- Reliable stream reset now follows draft-ietf-quic-reliable-stream-reset-11; both transport parameter ids are sent and accepted.
Fixed
- A client connecting to an internationalized host name sent it in Server Name Indication as UTF-8, which servers do not match. It is now sent as A-labels, and a name that is not a valid IDN is not sent (RFC 6066 Section 3). A trailing dot is no longer sent, and a name with one now matches a certificate for the same name without it.
- A peer that sent a packet within each idle period kept a handshake open
for ever, holding a connection and a
max_connectionsslot. A handshake that has not completed within the idle timeout from its start, ordisconnect_timeoutwhen no idle timeout is set, now ends (RFC 9000 Section 10.1). - A CONNECTION_CLOSE sent before the handshake was confirmed went out at one encryption level only, in 1-RTT packets once there were 1-RTT keys, which the peer may not be able to read yet, so the peer waited for its own timeout. This held for a local close, a transport error, a TLS alert and the close sent when a connection stops unexpectedly. The close now also goes out in a Handshake packet, and from a server in an Initial packet too. Below 1-RTT an application close is sent as APPLICATION_ERROR without its reason (RFC 9000 Section 10.2.3).
- Datagrams waiting for an unvalidated address to be validated, held back by the anti-amplification limit, were kept without a bound, so a peer that kept sending while its new address was checked could make the connection hold an unbounded amount of memory. At most 64 are held now; what a dropped one carried is sent again by loss recovery (RFC 9000 Section 8.1).
- A server that refused a connection, at
max_connectionsor when theconnection_handlerdeclined it, sent nothing, so the client waited for its handshake timeout. It now answers at once with CONNECTION_REFUSED, or INTERNAL_ERROR when the handler fails, at a bounded rate (RFC 9000 Section 5.2.2). - On paths with a round-trip time under 8 ms, such as a LAN or loopback, packets were declared lost as soon as one round trip had passed since they were sent, instead of 9/8 of it, so packets that were only slightly late were retransmitted and cut the congestion window. The time threshold is now rounded up, never below 9/8 of the round-trip time (RFC 9002 Section 6.1.2).
- A write to a stream whose sending side had been reset, by the application, a stream deadline or the automatic answer to the peer's STOP_SENDING, was still sent, at or past the final size the reset had announced, and peers closed the connection with FINAL_SIZE_ERROR. Such a write now fails with stream_closed, as one after the FIN does (RFC 9000 Section 3.3).
- Resetting a stream a second time sent another RESET_STREAM with a new error code and a larger final size, and the peer closed the connection with FINAL_SIZE_ERROR. Resetting a stream whose sending side is already done now does nothing (RFC 9000 Section 4.5).
- A RESET_STREAM the peer sent again counted the stream's unreceived bytes against connection flow control a second time and told the owner about the reset again. A repeated RESET_STREAM is now ignored (RFC 9000 Sections 4.5 and 13.3).
- A RESET_STREAM_AT from the peer did not count the stream's final size against connection flow control, so connection credit fell behind what the peer had used and the peer could stall waiting for more (RFC 9000 Section 4.5, draft-ietf-quic-reliable-stream-reset-11).
- After heavy packet loss left more than 64 ACK ranges, a packet number below the ranges kept was accepted again, so a duplicated packet was processed twice. Packet numbers below the lowest range kept are now discarded as duplicates (RFC 9000 Sections 12.3 and 13.2.3).
- An ACK frame whose ranges reached below packet number 0 was processed instead of closing the connection with FRAME_ENCODING_ERROR (RFC 9000 Section 19.3.1).
- A version 1 or 2 long-header packet with a connection ID longer than 20 bytes is now dropped (RFC 9000 Section 17.2). Before, a listener started a connection for an Initial with such a Destination Connection ID, and either side took such a Source Connection ID as the peer's. Version Negotiation still accepts the longer IDs RFC 8999 allows.
- A client now discards a Retry whose Source Connection ID is the Destination Connection ID of its own Initial (RFC 9000 Section 17.2.5.1). Before, it took the Retry and restarted the handshake.
- A disable_active_migration transport parameter with a value, or a preferred_address with a connection ID longer than 20 bytes, now closes the connection with TRANSPORT_PARAMETER_ERROR (RFC 9000 Sections 7.4 and 18.2). Before, both were accepted, and the malformed disable_active_migration was read as allowing migration.
- CRYPTO data at an encryption level the handshake had left, extending past what had been received there, was handed to TLS: a fragment was buffered for good, a whole message closed the connection with unexpected_message. It is now a PROTOCOL_VIOLATION; retransmissions of data already received are still ignored (RFC 9001 Section 4.1.3).
- When the handshake moved on to a higher encryption level, CRYPTO data left over at the previous one was ignored, or closed the connection with unexpected_message if it held a whole message. It is now a PROTOCOL_VIOLATION (RFC 9001 Section 4.1.3).
- A Finished message that failed verification, or was shorter than 32 bytes, was ignored, leaving the handshake to time out. It now closes the connection with decrypt_error, or decode_error when its length is not that of the handshake hash (RFC 9846 Section 4.5.3).
- A client took a ServerHello or HelloRetryRequest naming a cipher suite it does not know for TLS_AES_128_GCM_SHA256 and carried on with the wrong keys. It now aborts with illegal_parameter (RFC 9846 Section 4.2.3).
- A packet that opened with the previous 1-RTT keys was accepted even when a lower-numbered packet had already used the new ones. It now closes the connection with KEY_UPDATE_ERROR (RFC 9001 Section 6.4).
- Packets that failed authentication were not counted, so the AEAD integrity limit was never enforced. The connection now closes with AEAD_LIMIT_REACHED at the limit (RFC 9001 Section 6.6).
- A connection whose handshake lost a few packets could start with its congestion window collapsed to two packets: losses of the first flight and its probes, all sent before any RTT sample, were taken for persistent congestion. Only packets sent after the first RTT sample now count towards it (RFC 9002 Section 7.6.2).
- A datagram the kernel refused as too large for the interface took the rest of its send batch with it, and the packets queued behind it came back as losses. Only the oversized datagram is lost now (RFC 9000 Section 14.4).
- On Linux, datagrams now always leave with Don't Fragment set (RFC 9000 Section 14). Before, Linux fragmented datagrams larger than its cached path MTU, so a path MTU probe could succeed in fragments and raise the datagram size past what the path carries unfragmented.
- A 1-RTT packet the kernel refused to send outside a batch was never sent again, so the stream data it carried was lost and the stream stalled. It is now counted as sent, and loss detection sends its data again (RFC 9002 Section 6).
- Every datagram the listener could not parse logged a warning. It is now logged at debug level.
- The warning logged for a packet that fails to decrypt included the peer's stateless reset tokens. It now lists only the peer's connection IDs and their sequence numbers.
- After closing a connection itself, erlang_quic ignored the peer's packets, so when its CONNECTION_CLOSE was lost the peer kept retransmitting until its own idle timeout. Packets from the peer's address are now answered with the close again, at an exponentially decreasing rate, on the socket the connection sends from, including a server's preferred address. Nothing is sent again once the peer has closed (RFC 9000 Sections 10.2.1 and 10.2.2).
- An idle timeout after the handshake sent a CONNECTION_CLOSE to a peer that had most likely discarded the connection already. The connection now closes silently, as it already did during the handshake (RFC 9000 Section 10.1).
- A server answered a PATH_CHALLENGE from an address the client was not using with a full-size datagram, whatever the size of the probe. Until that address is validated, the server now sends it at most three times what it received from it (RFC 9000 Section 8). An answer too small for a full-size datagram carries a PATH_CHALLENGE that validates the address only, and the path is then challenged again at full size (Section 8.2.1).
- A client that moved twice before the handshake was confirmed was followed to its second address without that address being validated or the data sent to it being limited. The second address is now validated like the first (RFC 9000 Sections 8 and 9.3).
- A server kept state for every address that probed it, with no limit. It now keeps the four most recently probed paths.
- Out-of-order stream data was bounded in bytes only, not by the memory each fragment takes, so many small fragments held far more memory than the 32 MiB receive buffer cap counted, and a small fragment kept its whole packet in memory. Each buffered fragment now counts against the cap (RFC 9000 Section 21.7).
- A stream that had used up its own flow-control window held back data queued for other streams. Only a connection-level limit stops the send queue now, and data waiting for a blocked stream no longer slows down sending on the others (RFC 9000 Section 4.1).
quic:connect/4returns{error, Reason}when the client socket cannot be opened, instead of killing a caller that does not trap exits.- API calls on a connection whose process has already stopped exited
with
noprocalthough their specs promise an error. They return{error, closed}. - Bad arguments to
send_data,send_data_async,reset_stream,reset_stream_at,stop_sending,set_stream_deadlineandset_ownercrashed the connection and its owner. They are refused with an error. - The owner of a connection whose handshake failed with a TLS alert, for example a server rejecting the client's ALPN, was not told it closed.
keep_alive_interval => autowas half the local idle timeout, and at least 5 seconds, so a peer with a shorter timeout, or one of 5 seconds or less, idled the connection out. It is now half the idle timeout in force, the smaller of the two advertised, and at least one second (RFC 9000 Section 10.1).- With
idle_timeout => 0, nothing ended a handshake that heard nothing from the peer: a server kept a connection for ever after one unreadable Initial, and a client connecting to an address that never answered was never told. Such a handshake is now abandoned afterdisconnect_timeout, and the owner gets{closed, disconnect_timeout}. With an idle timeout, the idle timeout still bounds the handshake. - A stream deadline that fired just as it was cancelled or set again still reset the stream. It is now ignored.
quic:handle_timeout/2on a connection past its idle timeout did not close it. It now does, as the idle timer does.- A
connection_handlerthat raised, returned something other than{ok, Pid}or{error, Reason}, or named an owner for a connection that had already stopped, crashed the listener and every connection it served. Only the connection the handler was called for is now closed, and the failure is logged. - A
connection_handlerreturning{error, Reason}did not decline the connection: the handshake completed with the listener as owner and nothing served it. The connection is now stopped before it sees a packet, and the handler examples in the guides return{ok, Pid}as the listener requires. - In a server pool, a listener that stopped or crashed left its
connections registered. Packets for them went to a dead process
instead of drawing a stateless reset (RFC 9000 Section 10.3), and they
counted against
max_connectionsuntil the server restarted. They are now removed. pool_sizestarted one listener more than asked, with SO_REUSEPORT always set, so another process could bind a single-listener server's port, and on port 0 the listeners bound different ports.pool_sizeN now starts N listeners, with SO_REUSEPORT only when N is above 1, andquic:get_server_connections/1returns each connection once instead of once per listener.- NewReno and CUBIC moved the start of the recovery period to the moment they left recovery, so the loss of a packet sent during recovery was ignored and, under continued overload, the next window reduction came about one RTT late. The recovery period now keeps the time the reduction was made (RFC 9002 Section 7.3.2 and Appendix B.6). The 100 ms minimum recovery period still keeps a burst loss from cutting the window twice.
- A server whose
ciphersshared no suite with the client went ahead with TLS_AES_128_GCM_SHA256 anyway. It now aborts the handshake with handshake_failure (RFC 9846 Section 4.2.1). - A client did not check the cipher suite of a HelloRetryRequest against the ones it offered, nor that the ServerHello after it named the same suite. It now aborts the handshake with illegal_parameter in either case (RFC 9846 Section 4.2.4).
- A server that selected TLS_AES_256_GCM_SHA384 still took the client's external PSK, which is SHA-256, so a client checking the suite against the PSK could not complete the handshake. A known external PSK now makes the server pick a SHA-256 suite both sides allow; when none exists the PSK is skipped and the certificate path is used (RFC 9846 Section 4.3.11). Resumption tickets keep the server's order.
- A client accepted a server that took its PSK, external or a resumption ticket, with a cipher suite whose hash is not the PSK's. It now aborts with illegal_parameter (RFC 9846 Section 4.3.11).
- After
cancel_stream, the rest of the request body was read as a new request, so a HEADERS frame inside a body could be delivered as a second request. A cancelled stream is now ignored until the peer ends it, and the peer is asked to stop sending (RFC 9114 Section 4.1.1, RFC 9000 Section 3.5). - After
cancel_streamon a request whose last bytes had reached the QUIC connection but not yet the HTTP/3 one, those bytes were read as the start of a new stream: a HEADERS frame among them became a second request, anything else could close the connection. They are dropped until the stream's end has been seen (RFC 9114 Section 4.1.1). - A request stream reset for an error of its own, a malformed message
for example, kept being read: only RESET_STREAM was sent, so the peer
went on sending a body nobody wanted. STOP_SENDING is sent as well, as
on
cancel_stream, and the stream is forgotten once the peer has ended it; one that failed at its FIN used to stay for the life of the connection (RFC 9114 Section 8, RFC 9000 Section 3.5). - When several requests waiting for QPACK encoder stream data were unblocked together and one was malformed, the others lost their state or were never processed. Only the malformed one is reset now (RFC 9204 Section 2.1.2).
- A stream reset while its HEADERS were waiting for the QPACK encoder stream stayed blocked, and a later insertion delivered a request on the reset stream. It is no longer blocked. Stream Cancellation now goes to the peer for every request or push stream given up before it was read to the end, by either side, not only for blocked ones, so the peer's encoder can evict what that stream referred to (RFC 9204 Section 2.2.2.2).
- A server remembered every push ID a client cancelled, promised or not, so that set could grow without limit. A CANCEL_PUSH for a promised push with no open stream is now ignored (RFC 9114 Section 7.2.3).
- A server that answered requests without registering a stream handler kept each request's unread body buffered after the exchange finished, and eventually reset every new request with H3_EXCESSIVE_LOAD. The buffer is released when the stream finishes.
- The QPACK encoder kept an entry for every field section it sent, including those a decoder never acknowledges, so memory grew with every stream. Only sections that use the dynamic table are tracked, each until the peer acknowledges it or cancels its stream, however the stream ended, so the entries it uses are not evicted while the peer may still need them. Once 100 streams wait for acknowledgments, new streams are encoded without the dynamic table, which bounds what a peer that never acknowledges can make the encoder keep (RFC 9204 Sections 2.1.1 and 4.4.1).
- The QPACK encoder could evict a dynamic table entry that a field section still waiting for acknowledgment, or the section being encoded, referred to, or one the peer had not yet acknowledged, and inserted entries past the table capacity when nothing could be evicted. The peer's decoder then failed on those sections. Such fields are now sent as literals instead (RFC 9204 Section 2.1.1).
- A Section Acknowledgment was sent for every field section received, including those that do not use the dynamic table. A peer that checks closes the connection with QPACK_DECODER_STREAM_ERROR. Only sections with a non-zero Required Insert Count are acknowledged now (RFC 9204 Section 4.4.1).
- The QPACK encoder encoded the Required Insert Count using our own advertised table capacity instead of the peer's. When the two differed, field sections stopped decoding after a few dynamic table insertions (RFC 9204 Section 4.5.1.1).
- An over-long integer on the QPACK decoder stream, or an insertion naming a static table entry that does not exist on the encoder stream, crashed the HTTP/3 connection process. They are now QPACK_DECODER_STREAM_ERROR and QPACK_ENCODER_STREAM_ERROR (RFC 9204 Sections 3.1 and 6).
- Huffman-encoded strings with more than 7 bits of padding, or padding that is not all ones, were accepted. Both are decoding errors now (RFC 7541 Section 5.2, RFC 9204 Section 4.1.2).
- DATA that followed HEADERS waiting for the QPACK encoder stream reset
the stream with H3_FRAME_UNEXPECTED, so a request with content failed
whenever its field section referred to a dynamic table entry that had
not arrived yet. What follows blocked HEADERS on a stream is now kept
and read in order once they decode. It counts against
max_buffered_bodytogether with request bodies waiting for a handler (RFC 9204 Section 2.1.2). - State kept for a peer's unidirectional stream (unknown stream types,
an incomplete stream type, streams claimed by a
stream_type_handler) was only released on a reset, so each stream ended with FIN left an entry behind. It is released at the FIN (RFC 9114 Section 6.2.3). - A request or response with less content than its Content-Length was accepted when the stream ended with a bare FIN, with trailers, or with the HEADERS frame itself. All are now reset with H3_MESSAGE_ERROR (RFC 9114 Section 4.1.2).
- A client held the tunnel of a successful CONNECT to the response's Content-Length, and reset it once more bytes came. A 2xx response to CONNECT has its Content-Length ignored (RFC 9110 Section 9.3.6).
content-length: +10and:status: +200were accepted. Both must be digits only (RFC 9110 Sections 8.6 and 15); anything else is a malformed message.- Trailers were only accepted when the stream's FIN arrived together with their HEADERS frame; with the FIN in a later packet the stream was reset with H3_FRAME_UNEXPECTED. HEADERS after the content are the trailer section either way, and the message ends at the FIN (RFC 9114 Section 4.1).
- PUSH_PROMISE after a response's trailers reset the stream. It is not part of the response and is now accepted (RFC 9114 Section 4.1).
- A server ignored a request stream that ended with no HEADERS at all, empty or holding only frames of unknown types, and kept an entry for it. The stream is now reset with H3_REQUEST_INCOMPLETE and forgotten (RFC 9114 Section 4.1).
- After a push was cancelled, bytes still arriving on its stream were read as the start of a new unidirectional stream and could close the connection. The stream is now ignored, and a push the client cancelled is stopped with H3_REQUEST_CANCELLED (RFC 9114 Section 7.2.3).
- A pushed response that arrived whole in one packet, from the push ID
to the FIN, never completed: the client got its headers and data but
no
push_complete, and kept the push for the life of the connection. The FIN is now seen wherever it arrives (RFC 9114 Section 4.6). - Push trailers whose FIN came in a later packet closed the connection, and a push whose FIN came after its last DATA frame never completed. Both now complete at the FIN, and anything but the FIN after push trailers is H3_FRAME_UNEXPECTED (RFC 9114 Section 4.1).
- A client that received GOAWAY gave up every response still waiting for the QPACK encoder stream, including those to requests the server had said it would answer, and a server did the same on a client's GOAWAY, which only concerns pushes. Only requests at or above the server's GOAWAY ID are given up now (RFC 9114 Section 5.2).
- HTTP datagrams for a request stream this side had reset were still
delivered, and could still be sent. They are now dropped, and
quic_h3:send_datagram/3returns{error, unknown_stream}(RFC 9297 Section 2.1). send_trailers/3on a stream that had finished or been reset crashed the HTTP/3 connection process. It returns{error, unknown_stream}.- A HEAD, 204 or 304 response sent with
quic_h3:send_response/4alone never reached the client, which waited for it, and its stream stayed open on the server: after 100 of them the client could open no new request. These responses now end the stream as they are sent (RFC 9110 Section 6.4.1, RFC 9114 Section 4.1). quic_h3:respond/5followed a HEAD, 204 or 304 response with an empty DATA frame. It now sends the response alone.- A body or trailers sent after a HEAD, 204 or 304 response went out, and the client delivered them. They are now dropped (RFC 9110 Section 6.4.1).
- A Content-Length the application set on a 1xx or 204 response was sent, which RFC 9110 Section 8.6 forbids. It is now left out; 304 and HEAD responses keep theirs.
- A server could send a 101 response, which HTTP/3 does not have (RFC 9114 Section 4.5). It is now refused.
- A body sent before the final response went out ahead of it, and the client closed the connection with H3_FRAME_UNEXPECTED. It is now refused (RFC 9114 Section 4.1).
- Between two erlang_quic peers the QPACK dynamic table did no good: the decoder never told the encoder which entries it had received, so the encoder inserted the same fields again for every response. The decoder now sends Insert Count Increment, and later field sections refer to the entries already in the table (RFC 9204 Section 4.4.3).
- The client accepted responses HTTP/3 does not allow, and misread one: DATA with content on a response to HEAD, or on a 204 or 304, was delivered; a 101 was taken as an interim response; and the Content-Length of a 1xx was applied to the final response, which then failed. The first two now reset the stream with H3_MESSAGE_ERROR, and a 1xx's Content-Length is ignored (RFC 9110 Sections 6.4.1, 9.3.2 and 15.2, RFC 9114 Sections 4.1.2 and 4.5). A pushed response follows the same rules, and a pushed HEAD response with a Content-Length no longer fails for having no body.
- Resuming a session or using an external PSK failed after a HelloRetryRequest: the server checked the PSK binder without the first ClientHello and the HelloRetryRequest in front, and the client refused to retry with its PSK. Both sides now bind the PSK to the whole handshake so far, and the retried ClientHello carries no early data (RFC 9846 Sections 4.3.10 and 4.3.11.2).
- The client did not check that a ServerHello or HelloRetryRequest selects TLS 1.3, and the server did not check that a ClientHello offers it. Such a handshake now ends with protocol_version, or with illegal_parameter when another version is selected (RFC 9846 Section 4.3.1, RFC 9001 Section 4.2).
- A NewSessionTicket cut short crashed the client's handling of it, and one with bytes left over was accepted. A NewSessionTicket that does not decode now ends the connection with decode_error (RFC 9846 Section 4.7.1).
- A HelloRetryRequest asking for the key share the client had already sent, or asking for nothing, got the same ClientHello again. The client now ends the handshake with illegal_parameter, and sends back the cookie of a HelloRetryRequest that carries one (RFC 9846 Sections 4.2.4 and 4.3.2).
- The server resumed a session ticket for a client that had not offered psk_dhe_ke, the only mode those tickets work in, and accepted a PSK offered without psk_key_exchange_modes. It now does a full handshake in the first case and ends the handshake with missing_extension in the second. The client now ends the handshake with illegal_parameter when the server picks a key exchange mode or PSK it did not offer (RFC 9846 Sections 4.3.9 and 4.3.11).
- A ClientHello with a legacy_version other than 0x0303 was dropped without an answer. The server now ends the handshake with protocol_version, and with illegal_parameter for a ClientHello offering compression (RFC 9846 Section 4.2.2).
- A ServerHello or HelloRetryRequest with a legacy_version other than 0x0303 was ignored, and the client waited for its timeout. The client now ends the handshake with protocol_version, and with illegal_parameter when the session ID echo or the compression method is not what it sent (RFC 9846 Sections 4.2.3 and 4.2.4).
- Handshake messages with bytes left over inside an extension or after the message, odd-length lists or empty names were accepted, or dropped without an answer. Both sides now end the handshake with decode_error, with illegal_parameter for a duplicate extension or a pre_shared_key that is not the last extension, and with missing_extension for a CertificateRequest without signature_algorithms (RFC 9846 Sections 4.3, 4.3.11 and 4.4.2).
- The server sent session tickets to clients that had not offered psk_dhe_ke and so could not use them. It now sends one only to a client that did (RFC 9846 Section 4.7.1).
- The client kept using a session ticket for as long as its lifetime said, even past 7 days. It now stops after 7 days at most (RFC 9846 Section 4.7.1).
- The reset_stream_at transport parameter was sent and read only under its draft -07 id, so peers on later drafts, which use 0x1d, could not use RESET_STREAM_AT with erlang_quic. Both ids are now sent and either is accepted, also for 0-RTT (draft-ietf-quic-reliable-stream-reset-11 Section 3).
- A RESET_STREAM_AT could carry a final size that counted data still
waiting for flow control credit, past the limits the peer had granted,
which the peer must treat as a flow control error. The final size now
covers what was sent and the bytes kept reliable, and
quic:reset_stream_at/4returns{error, flow_control_blocked}when even that is over the peer's limits (draft-ietf-quic-reliable-stream-reset-11 Section 4). - A peer that changed a stream's final size in a RESET_STREAM_AT got STREAM_STATE_ERROR instead of FINAL_SIZE_ERROR, and stream data past the final size after a RESET_STREAM_AT, or a FIN that moved it, was dropped without an error. Both now close the connection with FINAL_SIZE_ERROR (draft-ietf-quic-reliable-stream-reset-11 Section 5.2).
- After quic answered the peer's STOP_SENDING with RESET_STREAM, a call
to
quic:reset_stream_at/4on the stream still sent a RESET_STREAM_AT with a reliable size above 0. It is now refused, so the answer keeps a reliable size of 0 (draft-ietf-quic-reliable-stream-reset-11 Section 5.4). A stream given a reliable size withquic:set_stream_reliable_size/3is still answered with RESET_STREAM_AT, as WebTransport needs, and the bytes it keeps are now sent even when they were still queued; they were dropped, and the peer waited for them. - Closing a stream after
quic:reset_stream_at/4while its reliable bytes were still on their way, or answering the peer's STOP_SENDING then, sent a RESET_STREAM with another error code and final size, which the peer must treat as a connection error. It now repeats those of the RESET_STREAM_AT (draft-ietf-quic-reliable-stream-reset-11 Section 5.2). - A peer's RESET_STREAM and RESET_STREAM_AT on one stream with different error codes were accepted, and the owner was told of the reset again for each such frame, retransmissions included. A changed error code now closes the connection with STREAM_STATE_ERROR, the owner hears of the reset once, and no stream data is delivered after the peer's RESET_STREAM (draft-ietf-quic-reliable-stream-reset-11 Section 5.2).
[2.6.1] - 2026-11-08
Fixed
- With
notify_streams_available, quic_h3 told its owner only about refused requests. A stream the owner opens itself withquic_h3:open_uni_stream/2oropen_bidi_stream/2and refused withstream_limitnever got{streams_available, uni | bidi}, so a WebTransport session could not know when to retry, and nothing was sent after GOAWAY although such a session keeps opening streams while it drains. Each refused open is now told for its own kind, those two also after GOAWAY.quic_h3:start_server/3also lists the option in its type, so a server passing it no longer fails dialyzer.
[2.6.0] - 2026-11-08
Added
notify_streams_availableoption. When a stream cannot be opened because the peer allows no more ({error, stream_limit}), the owner is now told once one can be opened again, as{quic, Conn, {streams_available, bidi | uni}}, instead of polling.quic_h3takes the option too and sends{quic_h3, Conn, {streams_available, bidi}}for refused requests. Off by default.
[2.5.0] - 2026-11-08
Added
stream_reset_final_sizeoption forquic_h3. When set, a peer's reset of a stream claimed by astream_type_handleris reported with the stream's final size, as{stream_type_reset, Dir, StreamId, ErrorCode, FinalSize}. A WebTransport session needs it to count the stream's bytes against WT_MAX_DATA: without it, every reset with data in flight made the receiver count less than the sender, until the sender stalled. The same option on a QUIC connection makes the owner'sstream_resetnotice carry the final size. Off by default, so existing owners see the same events.quic:set_stream_reliable_size/3. A stream given a reliable size is reset with RESET_STREAM_AT, keeping that many bytes, when quic resets it on its own: answering the peer's STOP_SENDING, or on a send deadline. A WebTransport stream answered with a plain RESET_STREAM could lose its header, and with it the session it belonged to; WebTransport draft-17 requires RESET_STREAM_AT there. Streams without the setting, and peers withoutreset_stream_at, are reset as before.quic_h3:open_uni_stream/2opens a unidirectional stream of an extension type, writes the type and claims it, asopen_bidi_stream/2does for bidirectional streams. A WebTransport endpoint opened its uni streams withquic:open_unidirectional_stream/1, so quic_h3 did not know them and dropped the peer's STOP_SENDING on them: the endpoint kept writing to a stream the peer had stopped, and a draining session never saw it finish. The owner now receives{stream_type_stop_sending, uni, StreamId, ErrorCode}.
[2.4.1] - 2026-11-08
Security
- A listener started with
require_client_cert => truenever asked clients for a certificate unlessverify => truewas set as well, so it accepted every client while appearing to enforce mutual TLS, andquic:peercert/1found nothing.require_client_certnow asks for a certificate and refuses a client without one, whateververifysays.verify => verify_peeron a listener now asks for one too, asverify => truedoes. A listener that asks for client certificates now needscacertsorcacertfileand fails to start without them, instead of checking client certificates against the system trust store, which any publicly issued certificate would pass. For QUIC distribution this meansverify_peerneedscacert_file. (GHSA-wc8g-r527-q23h, CWE-287).
Fixed
- A resumed connection to a listener that asks for client certificates failed with an unexpected-message alert: the server waited for a client certificate it had not asked for.
[2.4.0] - 2026-11-07
Changed
- erlang_quic now requires Erlang/OTP 27 or later, following OTP's own support for its last three releases. OTP 26 is no longer tested or supported.
Fixed
- A client that migrated several times in a row could be left unable to
migrate again, with
quic:migrate/1returning{error, no_available_connection_id}for good. When the client moved, the server sent the replacement connection id answering the client's retirement to the address the client had just left; if that network was gone the id never arrived. Replies to the packet that moves a client now go to its new address. - With a
stream_type_handlerset, as every WebTransport client and server has, a request stream was reset with H3_FRAME_UNEXPECTED as soon as its second piece of data arrived: a WebTransport session ended at the first capsule after the 2xx, and a response body in several packets never arrived. Each new piece was taken for the start of a new stream and the stream's state was thrown away. Only a new stream the peer opens is now offered to the handler.
[2.3.0] - 2026-11-07
Security
- An HTTP/3 peer could make the other side spend minutes of CPU on one request. Decoding a header block took time that grew with the square of its size, so a 1 MiB block of small header fields kept a scheduler busy for over ten minutes, and the header size limit was only checked once everything was decoded. Header blocks now decode in linear time, the limit we advertise stops decoding as soon as it is crossed, and it now applies to trailers too: trailers over it reset only their own request. (GHSA-224p-pchv-5r3r, CWE-407).
Added
- HTTP/3 server option
defer_requests_until_settings. When set, a request or WebTransport stream that arrives before the client's SETTINGS waits for them, so the application always sees the peer's settings. WebTransport draft-17 requires this, as the client's settings select the draft version. quic_h3:close/2closes the connection with an HTTP/3 error code, such as H3_SETTINGS_ERROR or H3_ID_ERROR, which the peer receives.- erlang_quic runs in the QUIC Interop Runner, which tests it against
the other QUIC implementations:
make interop, see interop/README.md. Each release publishes the image the runner needs, so the project can be listed on interop.seemann.io.
Changed
wt_enabledin HTTP/3 settings now means the WebTransport draft-17 codepoint. The draft-15 codepoint is still available aswt_enabled_draft15, so a server can advertise both.quic_h3_frame:setting_to_id/1is exported to turn a setting name into its id.
Fixed
- A WebTransport stream signal (0x41) found anywhere other than the start of a bidirectional stream opened by the peer was skipped. It now closes the connection with H3_FRAME_UNEXPECTED, as WebTransport draft-17 requires.
- An HTTP/3 connection closed by its peer with an error code told its
owner
{closed, normal}, so the code was lost. The owner now receives{closed, {peer_closed, application, ErrorCode, Phrase}}; a peer that closes with no error still reads asnormal. - A server no longer crashes when a client that has just moved to a new address sends a small path check there. The answer did not fit in what the server may send to an address it has not yet confirmed, and the connection went down instead of waiting for more data from the client.
- HTTP/3 header fields a peer sent by reference to a static table entry
with no value, such as an empty
cookie, came out with no value at all and brought the connection down. They now decode as empty fields. - The QPACK static table had entry 2 as
:ageinstead ofage, so anage: 0header sent by reference was taken for an unknown pseudo-header and the message refused.
[2.2.1] - 2026-11-07
Changed
- Retry tokens are accepted for at most 10 seconds, so a captured token can no longer be replayed later.
Fixed
- A large download could be closed with a flow-control error, seen as a transfer that stopped part way. The sender counted its connection credit in bytes sent while the receiver counts the highest offset on each stream, as the RFC says, so a write that went out ahead of data still queued made the two disagree. The sender now counts the same way.
quic:migrate/1on a server connection closed the listener's socket, breaking every connection on it. It now returns{error, not_supported_on_server}.- A resumed connection could send more 0-RTT data than the server's current limits allowed, and a server with lower limits closed the connection. A ticket now keeps the server's limits and 0-RTT data stays within them; what does not fit waits for the handshake to finish. A ticket stored by an older version is used without 0-RTT.
- A server whose client moves to a new address sends it at most three times what it received from there until the address is validated, goes back to the old address when validation fails, and follows only the newest packet, so a late or spoofed packet no longer moves it. An answer to any of its earlier checks of the address counts, a slow new path gets time to answer, and the path must carry full-size packets before it is validated. The same holds for a client that moves during the handshake.
- After a client moves, the server uses a new connection ID towards the new address unless the client kept its own, from the first packet it sends there.
- Packets sent before a migration no longer feed the new path's RTT estimate or congestion window, and PMTU is searched again on the new path. A client's own migration resets its congestion state once the new path is validated.
- A connection that moves to a new path keeps the congestion control it was
configured with. Each path change started over with NewReno and the default
windows, whatever
cc_algorithm,initial_windowandminimum_windowsaid, andquic:set_congestion_control/2also dropped those windows.
[2.2.0] - 2026-11-07
Security
- QUIC distribution now checks certificates when configured with
{verify, verify_peer}. Theverifyandcacert_fileoptions were read and never used: a node did not check the certificate of the node it dialed, and the listener asked for none. Anyone on the network path could stand between two nodes, relay the cookie exchange and then read and inject distribution traffic, including remote calls. Withverify_peer, both nodes now present a certificate and check the other's againstcacert_fileand the host in its node name. The-quic_dist_verifycommand line flag is read as well. (GHSA-h2qx-fxx7-7r6j, CWE-295). Reported by Johan Bevemyr.
Fixed
A client given
versionsnow offers them, so the server can move the connection to QUIC v2. Only the server announced its versions, so a client that started in v1 always stayed in v1.A connection whose owner exits on
closednow finishes its draining period. It stopped at once, and the peer's repeated close got a stateless reset back.A server no longer reads 1-RTT packets before the handshake completes. The client could take the handshake as done too early and the connection hung. Such packets are kept and read once the handshake is done, so a request sent with the client's last handshake message is not lost.
A client drops its Initial keys as soon as it sends a Handshake packet of any kind. It went on probing with Initial packets, and a server could appear to send more than its amplification limit allows.
A server follows a client to a new address when the client uses zero-length connection IDs or keeps its connection ID. It stayed on the old address and the transfer stopped. After such a move the congestion state starts again, since the path is a different one.
A server follows a client that moves before the handshake completes, and checks the new address before using it. The handshake used to hang when the first address was gone.
A client writes its handshake secrets to SSLKEYLOGFILE too, so Wireshark and the QUIC Interop Runner can decrypt its handshake.
A connection follows a key update the peer starts right after one of its own. Every packet after it failed to decrypt and the connection stalled.
Lossy handshakes recover faster and no longer stall:
- a server no longer queues a probe its amplification limit refuses, which could end in "pending handshake queue overflow";
- a server no longer pads an Initial that only acknowledges, and puts its handshake packets in fewer datagrams while the client's address is not yet validated, so it can repeat a lost flight more often;
- a server acknowledges the client's Initial in the same packet as its reply, so the client gets an RTT sample early;
- a client keeps backing off its probes until the server has validated its address, and probes with a Handshake packet once it can;
- a probe covers both handshake packet spaces and carries the current acknowledgement.
A late NEW_CONNECTION_ID whose sequence number was already retired is retired at once instead of closing the connection with CONNECTION_ID_LIMIT_ERROR.
A server acknowledges 0-RTT packets. A client sending more early data than its window allows could not finish the handshake.
A STREAM FIN that was already delivered is not delivered again when the peer repeats it. A server could take it as a new request.
A listener's
preferred_ipv4/preferred_ipv6addresses now work. The server advertised them but nothing answered there, so a client that moved lost the connection. The server now receives on them, and moves there once it has checked the client can be reached that way. Thesocketbackend refuses preferred addresses for now instead of advertising ones it cannot serve.A client moves to the server's preferred address once the handshake is confirmed, in the address family it already uses, and repeats an unanswered check. It used to send one check before the handshake was confirmed and give up.
A client that moved with
quic:migrate/1keeps its connection through a long outage. With the repeated checks it gave up on the path it was using and could no longer send.A server refuses a client that offers none of its application protocols (ALPN), as RFC 9001 requires. It completed the handshake with no protocol agreed. With the default
alpn => [<<"h3">>]a listener now turns away clients that do not offer h3.A connection made with the
happy_eyeballsoption no longer crashes when the winning connection closes before it is handed to the caller. The race goes on with the other attempts.Many protocol rules from RFC 9000, 9001 and 9002 that were not checked now are. A peer that breaks them gets the error the RFCs name instead of being accepted, and well-behaved peers see no difference. Among them: invalid or missing transport parameters, frames a packet type may not carry, frames on a stream the peer may not use, reserved header bits, truncated frames, unknown frame types, TLS messages at the wrong level or out of order, a TLS signature scheme that does not fit the key, an ACK for a packet never sent, stream counts above the maximum, and final sizes that contradict the data received or exceed a flow control limit.
Packets the RFCs say to drop are dropped: duplicates, Initials in datagrams under 1200 bytes, a late Retry, a Version Negotiation that does not echo our connection ID, server packets with a changed source connection ID or an Initial token, and long-header packets with the fixed bit clear. The rest of a datagram is still read after a packet that cannot be decrypted.
Flow control matches the peer's view. A reset stream counts its final size once, data arriving after the reset is dropped, a stream we reset sends the size we really sent, and data on several out-of-order frames is counted once per stream. An absent flow control or stream limit parameter means 0, as the RFC says.
Recovery is closer to RFC 9002: ACK ranges wider than 65536 packets are accepted (a long lossless transfer used to stall after about 90 MB), an ACK that acknowledges nothing new no longer resets the probe backoff, an RTT sample is taken when the largest acknowledged packet only carried ACKs, the ACK delay is reported, rejected 0-RTT packets leave the recovery state, retransmissions fit the current path MTU, and stale flow control limits and data of a reset stream are not resent.
Connections use the smaller of the two idle timeouts, at least three probe timeouts, and close silently when it expires during the handshake. An application close during the handshake now reaches the peer.
Connection IDs: those issued at the peer's request are capped, retired ones are dropped, QUIC-LB configurations apply to every issued ID, reset tokens are checked only for IDs in use, and short stateless resets are recognised. Stateless resets and Version Negotiation are sent the way the RFC asks.
A server pads Initial packets with PADDING frames inside the packet. Path validation packets reach 1200 bytes whatever the packet number length. The spin bit follows the RFC roles. A peer stream is opened the same way whichever frame opens it, and is reclaimed when the peer resets it first.
CUBIC and BBR pace in bursts sized to the sending rate, as NewReno does.
Changed
quic_connection:key_update/1andquic:migrate/1return{error, handshake_not_confirmed}until the handshake is confirmed, as RFC 9000 and 9001 require.- A local close with no application error code is sent as a transport CONNECTION_CLOSE with APPLICATION_ERROR. Peers used to read it as application error 12.
- A
reset_secretshorter than 32 bytes is refused when a listener starts instead of failing every handshake. - The client drops server Initial packets that carry a token, which the RFC forbids. xquic sends them after a Retry or on resumption, so those connections fail until xquic is fixed.
- Sends queued before the connection is up share packets, for 0-RTT and for writes held until the handshake completes, instead of one packet each. A burst of small requests no longer fills a router queue.
- A connection returns stream credit to the peer in steps of half the initial limit, rather than one MAX_STREAMS frame per closed stream.
- The interop endpoints serve over IPv6 as well, send a batch of requests or responses together, and warm up before the first test.
[2.1.4] - 2026-11-06
Fixed
- Downloads on paths that reorder packets are several times faster. Packets that arrived out of order were taken as lost, so they were sent again and the sending rate was cut each time. Late packets are now recognised: the connection waits longer before it calls a packet lost, and gives back the rate it cut when the loss turns out not to be real. On a simulated mobile path (80 ms round trip, 10 ms jitter, 0.5% loss) a download went from 68 to 430 KB/s with NewReno, and from 52 to over 300 KB/s with CUBIC.
- Connecting over such a path takes about one round trip again. A server packet that arrived before the one it depends on was dropped and had to be sent again; the client now keeps it until it can be read.
- A connection whose network comes back late in a long outage is no
longer closed by the disconnect timeout just before it would have
recovered. When the timeout is reached, the connection now sends one
more probe and waits for its answer before giving up. This also covers
a
quic:migrate/1made while there is no network at all. quic:sockname/1now returns the address the connection sends from now. It kept the one from connect time afterquic:migrate/1, and with thesocketbackend it reported port 0.
[2.1.3] - 2026-10-26
Changed
- Connections produce much less garbage and use less CPU per packet. A small request/response exchange creates about 70% less garbage per round trip, decoding the common frames is about twice as fast, and bulk transfers run faster as well.
- A pending acknowledgement now travels on the next data packet instead
of in a packet of its own, so a request/response exchange sends two
packets per round trip instead of three.
ack_sentinget_stats/1counts these acknowledgements too. - Sending an acknowledgement now cancels its delay timer, so the connection no longer wakes up later for a timer with nothing left to do.
[2.1.2] - 2026-10-25
Security
- A session ticket now resumes only on the listener that issued it, for the server name and ALPN it was issued under. Tickets were kept in one table for the whole VM and accepted by any listener, so a client given a ticket by one listener could log in to any other listener in the same VM, including one that only admits clients holding its pre-shared key. (GHSA-cv3h-phw9-vfjm, CWE-287). Found by GPT Cyber.
- A listener no longer stalls when many connections close at once. Each closing connection made the listener scan its whole routing table, so a burst of short-lived connections, which anyone can open, kept it from accepting or routing other clients for tens of seconds. (GHSA-fqq7-rg4w-x69m, CWE-407). Reported by wallace0409.
- A peer can no longer fill a slow connection owner's mailbox until the
VM runs out of memory. Stream data earned the peer new credit as soon
as it reached the owner's mailbox, read or not. Credit now waits while
the owner's mailbox holds more than
max_owner_queuemessages (1000 by default). (GHSA-6fr7-vvrm-wf2w, CWE-770). Reported by wallace0409. - A peer can no longer fill a slow owner's mailbox with DATAGRAMs.
Every DATAGRAM received was sent to the owner whatever its backlog;
the
datagram_recv_queue_lencap did not limit the mailbox. DATAGRAMs that arrive while the owner's mailbox holds more thanmax_owner_queuemessages are now dropped and counted indatagram_stats/1. (GHSA-gq4m-5qh3-q47h, CWE-770). Reported by wallace0409.
Added
quic:set_stream_flow_control/3andquic:consume/3: a stream inmanualmode earns the peer credit only for the bytes the owner reports as processed, so the peer sends at most one stream window ahead of the reader.
Fixed
- An HTTP/3 server handler reading a request body now always learns where the body ends. A request without a body (curl sends every GET this way) or one ending with trailers never signalled its end to the handler, so a handler waiting for the body hung.
- A server that answers a request before it is fully received, then stops reading it (RFC 9114 Section 4.1.1), no longer resets its own response when the client resets the request. On a lossy link the reset could throw away the end of the response.
[2.1.1] - 2026-10-04
Fixed
- HTTP/3 streams already open keep working after a GOAWAY, sent or
received (RFC 9114 Section 5.2).
quic_h3:cancel/2,3sent nothing, the peer's RESET_STREAM and STOP_SENDING were never reported, and calls such assend_trailers/3,send_response/4orget_quic_conn/1waited out their timeout. Only new requests are refused. A call the connection does not know is answered with{error, unknown_call}in every live state instead of being dropped. - GOAWAY from both endpoints. A server that had sent GOAWAY closed with
H3_ID_ERROR when the client answered with its own, or rejected every
new stream. The identifier the peer sent is now kept apart from the one
we sent, either side can answer the other's GOAWAY with
quic_h3:goaway/1, and a server refuses new pushes once the client's GOAWAY arrives.
[2.1.0] - 2026-09-28
Added
- Receive backpressure for HTTP/3.
quic_h3:set_stream_handler/4andquic_h3:request/3takeflow_control => manual, andquic_h3:consume/3returns credit for body the reader has processed. The peer can then send at most one stream window past what was consumed, so a handler that stops reading stops the peer instead of filling its mailbox. HTTP/3 framing is credited as it is parsed.auto, the default, is unchanged. - A write refused with
send_queue_fullis followed by{quic_h3, Conn, {send_ready, StreamId}}to the refused caller once the send queue drains below half its ceiling, so writers need not poll.
Fixed
- A small queued stream frame could ride along with an ACK past the peer's flow-control limit, closing the connection with FLOW_CONTROL_ERROR. It now waits for both windows, and its bytes count against the connection limit.
quic_h3:set_stream_handler/3,4andunset_stream_handler/2no longer leave the caller waiting forever while the connection is still connecting or after a GOAWAY: every state answers them.
[2.0.3] - 2026-09-27
Fixed
- A transfer through a small receive window no longer stops after the
first window. Each bidirectional stream tracked the other limit than the
one it advertised (RFC 9000 Section 18.2), so with a small
max_receive_windowthe receiver never sent MAX_STREAM_DATA and the sender's writes sat queued. A receiver now keeps granting credit as it receives, whatever the window. - The hex package ships the README image and leaves out a local
c_src/builddirectory.
Changed
max_receive_windowbelow 16384 bytes is refused byquic:connect/4andquic:start_server/3with{error, {invalid_max_receive_window, W}}; such a window cannot keep the peer moving.- A write refused with
send_queue_fullis logged at debug rather than as a warning, since callers retry it until the queue drains.quic:get_stats/1reports the count assend_queue_full_refusals.
[2.0.2] - 2026-09-27
Fixed
{error, send_queue_full}fromquic:send_data/4,5andquic_h3:send_data/3,4now means nothing was written. A write larger than one packet could already have sent part of itself before being refused, and the connection then forgot those packets, so the retry reused their packet numbers and loss recovery never covered them. The ceiling is now checked once, before anything is sent, and it is per connection.quic:send_data_async/4is never refused, and a queued remainder is no longer dropped when the queue is over the ceiling.- A refused HTTP/3 HEADERS, trailers or PUSH_PROMISE write keeps the QPACK encoder state once its encoder instructions were sent, so the peer's decoder stays in step.
[2.0.1] - 2026-09-25
Fixed
quiccompiles on Windows again. The NIF build and clean hooks ran throughsh, which Windows lacks, so every compile failed there, including projects that only reachquicthrough hackney. The hooks now runc_src/build_nif.shonly on Unix platforms, and Windows runs on OTP crypto (#358).
[2.0.0] - 2026-09-24
Upgrading from 1.x: the HTTP/3 connection close is now always
{quic_h3, Conn, {closed, Reason}}. Two of the three close paths used to send
the bare atom closed, so code matching that atom stops matching and, where the
match sits in a handle_info/2 with a catch-all or in a selective receive, the
close is dropped silently and the caller waits out its own timeout instead.
Match the reason-carrying shape. Code that already matches a peer
{stream_reset, StreamId, ErrorCode} needs no change and will simply start
seeing the streams this library resets as well.
Changed
- An HTTP/3 connection reports every close as
{quic_h3, Conn, {closed, Reason}}. Two of the three close paths sent a bareclosed, whichquic_h3:wait_connected/2does not match, so a connection that died while connecting reported a timeout it never waited out. The reason isnormalfor a local close or a drained GOAWAY,owner_downwhen the owning process exits,{h3_error, Code, Phrase}for a protocol error, and otherwise whatever the QUIC connection reported. - An HTTP/3 stream that this library resets is reported to its stream handler,
or to the connection owner if none is registered, as
{stream_reset, StreamId, ErrorCode}, the same event a peer reset produces. A body refused pastmax_buffered_body, a frame error on a stream, andquic_h3:cancel/2,3told the peer and left the local caller waiting out its own timeout. The stream's handler registration is dropped with it, so a cancelled stream no longer leaves a monitor behind. - An HTTP/3 request that finished in both directions is dropped from the
connection's stream map. One consequence to be aware of:
quic_h3:send_datagram/3for such a stream now returns{error, unknown_stream}, where the leaked entry used to let it through. A datagram session holds its request stream open (end_stream => false), as extended CONNECT and MASQUE do, so it is unaffected. Nothing removed it on a clean FIN, so a connection grew by the retained header list of every request it had served, and, because the GOAWAY drain waits for that map to empty, a connection that had served anything never closed gracefully. The half-close state is now combined rather than overwritten, so a stream that finished both directions reachesclosed.
Added
quic:connect/4,quic:start_server/3andquic:server_spec/3acceptcacertfile, a PEM file holding the trust anchors, read once where the connection or server starts. The key was accepted nowhere and silently ignored, so verification fell back to the OS trust store without saying so. A file that cannot be read, or that holds no certificate, fails the call.cacertsstill wins when both are given.
Fixed
- A response that announces a Content-Length and carries no body is read as the protocol defines it rather than as a short body. A HEAD response states the size a GET would have returned (RFC 9110 Section 8.6), and 204 and 304 never carry content (Section 6.4.1); all three were reset with H3_MESSAGE_ERROR. A body that really is shorter than its Content-Length is still refused.
quic_h3:send_response/4and the push equivalent take the status as an argument, so a:statusin the header list as well is dropped rather than sent beside it. Two of one pseudo-header is a malformed response (RFC 9114 Section 4.3.1).- An HTTP/3 peer resetting a request stream, or sending STOP_SENDING
on it, is reported as
{stream_reset, StreamId, ErrorCode}or{stop_sending, StreamId, ErrorCode}, to the stream handler if one is set and to the connection owner otherwise. Both were dropped, so a client waiting on a response the server had reset waited out its own timeout. - A STOP_SENDING is answered with a RESET_STREAM carrying the peer's code (RFC 9000 Section 3.5).
quic:reset_stream/3,quic:reset_stream_at/4,quic:stop_sending/3andquic:send_ping/1send their frame before returning, instead of leaving it for the next packet the connection happened to send.- qlog events reach the file while the connection is open, within the writer's 100 ms flush interval. The file driver's write buffer held them back until 64 KB had built up or the connection closed.
- qlog logs a run of padding as one
paddingframe with itslength, instead of one entry per byte. - HTTP/3 DATA frames of any size are accepted, and their payload reaches
the owner or stream handler as it arrives. A DATA frame over 1 MiB,
which
quic_h3:send_data/4and the Go http3 server produce for a large body written in one call, closed the connection with H3_FRAME_ERROR. - An HTTP/3 client receives a response without Content-Length past 16 MiB; the cap now applies only to the request body a server buffers.
- A request or push stream ending inside a frame closes the connection with H3_FRAME_ERROR instead of crashing the HTTP/3 connection process.
- A server issues a new session ticket on a handshake resumed from a ticket. Tickets are single-use, so a client could resume only once and then fell back to a full handshake without 0-RTT.
- A client offers 0-RTT only on a ticket that allows early data (RFC 8446 Section 4.2.10), and derives early keys only then.
- A resuming client whose HTTP/3 layer opens its control and QPACK streams after the first server packets arrived no longer fails the connect. The QUIC connection refused to open unidirectional streams in the handshaking state, which one scheduler made common.
- A stopping server closes its connections before its socket, so each sends CONNECTION_CLOSE and its clients are told at once. They heard nothing until their own idle timeout.
- A QPACK encoder-stream instruction that announces more than any insertable entry could need is refused before its bytes are buffered, and an entry larger than the capacity in force is an encoder stream error rather than being dropped while our insert count drifts from the peer's (RFC 9204 Section 3.2.2). A peer could pin memory for the life of the connection by announcing a huge name and stopping.
- The QPACK dynamic table starts at zero capacity and opens only on the peer's Set Dynamic Table Capacity, within what we advertised, while the Required Insert Count of a field section is decoded against the advertised capacity as RFC 9204 Section 4.5.1.1 requires.
- A malformed Huffman literal or an over-long integer on the encoder stream closes the connection with QPACK_ENCODER_STREAM_ERROR instead of crashing the HTTP/3 connection process.
- An HTTP/3 server bounds what it holds for request bodies whose handler
has not registered yet:
max_buffered_body, 16 MiB by default, counted across the whole connection. A body with a Content-Length was not bounded at all, and the guard that did fire counted bytes received rather than held, so it also reset large uploads a handler was reading. Buffered bytes are now released when a stream is reset or cancelled, and empty or fin-only pieces are charged so they cannot accumulate for free. {error, send_queue_full}is named in thequic:send_data/4,5andquic_h3:send_data/3,4specs and documented as the backpressure signal it is: nothing was written, and the same piece should be sent again once the connection drains. It appeared in no spec and no page.- A QUIC distribution connection is closed when the
dist_utilprocess handshaking over it dies, which is what stock distribution gets from socket ownership. net_kernel resolves a simultaneous connect by killing the losing handshake process, and the peer, seeing no drop, blocked inrecv_statusuntil its setup timer fired seconds later while net_kernel held a pending entry for that node, so the nextconnect_node/1on either side returned false. - A distribution handshake read waiting on a connection that closes gets
{error, closed}, whichdist_utilreports as a failed handshake, instead of the caller dying out of the gen_statem call. SO_REUSEADDRis set only on a listener bound to a fixed port. On a client socket, or a listener on port 0, it let the kernel autobind two sockets to one port and deliver every datagram for it to one of them, leaving the other connection unable to hear the server. A gen_udp caller that wants it can still pass it inextra_socket_opts.
[1.10.0] - 2026-09-21
Added
active_connection_id_limitsets how many connection IDs the peer may hold at once. (#317)quic:get_stats/1reportszero_rtt_streams, the number of streams whose 0-RTT data the server kept. (#334)
Changed
- Loss detection and recovery keep per-space state behind one timer, the shape RFC 9002 Appendix A describes. Initial and Handshake packets are tracked and acknowledged in their own spaces instead of being dropped by a guard, the application-data probe is armed only once the handshake is confirmed, and the three ad-hoc handshake retransmission timers are replaced by the probe for whichever space lost something. Handshake packets are admitted against the congestion window and the anti-amplification budget before a packet number, the send counter or the pacing allowance is spent; either refusal holds the frames in one per-space queue and a later attempt is a first attempt. Keys are discarded with the spaces they protect, on the role-specific triggers RFC 9001 §4.9.1 gives, and a Retry resets recovery and congestion state while keeping the data to resend. (#333, #334)
quic_connectionsheds the pieces that stand on their own. New internal modules:quic_pqueuefor the send queue's priority buckets,quic_reassemblyandquic_intervalfor stream reassembly,quic_tls_negotiationfor TLS negotiation and the node-wide ticket store,quic_cidfor the connection ID pools, andquic_rttfor the RTT estimate, which belongs to the path rather than to any packet number space. The connection's ACK frame path, its receive-side packet number bookkeeping and its range accumulation move intoquic_ack, which now separates its two topics, and both halves of a range encode through one guarded gap encoder. Peer transport parameters are validated in one place, and the module is laid out by section with the handshake-flight timers out of the PMTU code. No public API changes. (#319, #320, #321, #322, #323, #327, #329, #330, #332, #334)- Elvis rules live in
elvis.configalone, the build fails if a second lint configuration appears, andrebar3_lintis pinned to the version CI resolves. (#328)
Fixed
- A connection ID is bound to the path that first used it and retired
when a new path replaces it (RFC 9000 §9.5), and
RETIRE_CONNECTION_IDis sent for the IDs aretire_prior_tocovers. The peer's handshake connection ID counts againstactive_connection_id_limit. (#317, #324, #333) - The delayed-ACK timer runs on our own
max_ack_delayrather than the peer's, and the persistent-congestion window is built from the PTO without its exponential backoff (RFC 9002 §7.6.1). (#333) - The peer's ACK timing parameters reach loss detection. (#324)
[1.9.1] - 2026-09-15
Changed
- Interop and compliance tests exercise the external peers for real.
Peers are probed with a Version Negotiation request instead of a UDP
send, network tests fail on connect, handshake, echo and close errors,
the quic-go container runs its HTTP/3 server instead of an echo demo
that exits, and CI runs the compliance, reassembly and 0-RTT suites
against the peers. A Docs job builds
rebar3 ex_doc. (#310) quic_connection,quic_listenerandquic_socketcompile withexport_allunder TEST instead of carrying test-only export lists. The#state{}record moves tosrc/quic_connection_state.hrland the state builders totest/quic_connection_test_support.erl. The default build is unchanged. (#307, #311)
Fixed
- The owner-exit close tests no longer race the connection's exit on slow runners. (#312)
[1.9.0] - 2026-09-14
Added
delivery_coalescing(defaultfalse) merges consecutive same-streamstream_datadeliveries of one receive pass into a single owner message, flushed at the end of the pass or as soon as another stream delivers, so arrival order across streams is kept and astream_resetnever overtakes data delivered before it. Owners otherwise wake once per packet on bulk flows. QUIC gives no message-boundary guarantee, but owners that decode each delivery as one complete application message break when deliveries merge, so it is opt-in.- Bulk stream sends go out as runs: once the first full-size chunk is
approved, as many further chunks as congestion control, pacing and
the burst budget allow are approved up front (consuming pacing
tokens exactly as one-at-a-time sends would), sealed and handed to
the socket in one loop, and loss, congestion, packet-number and
counter bookkeeping is updated once for the run:
quic_cc:send_check_run/4approves the run against cwnd and pacing in one step (never more permissive than the sequential checks),quic_cc:on_packets_sent/2andquic_loss:on_packets_sent_run/4fold it into one record update each (native for NewReno, a fold for other algorithms). The per-packet connection-state rebuild was the largest own-time item on the bulk-send profile; a 64-packet run replaces 64 copies with one. The socket-backend receive queue bound is the connection's flow-control window in full-size packets (never below 512 messages): a run sender inside the window it was given is never tail-dropped, and the bound only catches a peer outside it. A fixed 512-message bound sat below a 16 MB window and turned flow-control-permitted data into loss on loopback. - The listener drains the datagram messages already queued in its mailbox into one receive sweep (up to 256 packets), groups them per source address and dispatches each group as one batch. Distinct flows never GRO-coalesce, so at a server with many sparse connections every datagram used to be its own listener message and its own connection wakeup; connections now wake once per sweep and their batched receive path engages even for sparse traffic. ACK processing re-arms the PTO once at the end of the receive pass instead of once per ACK frame. processing re-arms the PTO once at the end of the receive pass instead of once per ACK frame.
- Server connections on the socket backend hand their finished send
batches to one shared sender process per listener, which performs
the
sendmsgcalls serially. Concurrentsendmsgon one socket handle from many connection processes burns about 20x the CPU in NIF-level contention (98.6 vs 4.7 us per send with 50 concurrent senders); in a 50-connection fan-out model server CPU went from 24.3 to 11.0 s for the same window. Connections keep their own batch buffers, GSO grouping and counters; a connection whose sender has died sends directly from then on, and the listener starts a new one for new connections.
Fixed
- PMTU black-hole detection counts loss events, not lost packets, and a large packet acknowledged in between clears the count. It counted every large packet declared lost and, once the search had completed, nothing ever reset it, so a single burst loss of six or more full-size packets on a healthy path (a qdisc dropping one GSO super-packet, a small receiver socket buffer) dropped the path MTU to 1200 for the rest of the connection: about 15 % more packets for every byte after it. On a 1 GbE rig two such drops per 50 MB were enough. Now one ACK's worth of losses is one strike if any of them was large, and an ACK carrying a large packet proves the path still passes them.
- A stream receiver with a hole at the head of its reassembly buffer no longer slows down with every packet buffered behind it. The connection-level receive-buffer byte count was recomputed from the buffer on every out-of-order stream frame, twice, by walking every chunk in the tree, so with one lost packet and a congestion window's worth of data queued behind it each further packet cost a walk of thousands of chunks, the receiver's mailbox grew into the thousands, ACKs went out seconds late and the sender sat on its window: a 50 MB download over 1 GbE with a single early loss ran at 13 MB/s where 10 MB, or a peer that never lost a packet, ran at 90 to 108. The stream now keeps a running byte count and every tree change reports its delta.
- The NIF's AEAD context cache is bounded and no longer raises. Each
key update derives fresh keys, and the per-process cache kept a live
EVP context for every one of them, so a long-lived bulk connection
accumulated them until it exited; only the current and previous key
phase are ever live (RFC 9001 section 6), so the oldest are now
evicted. A context the NIF cannot build also falls back to the OTP
crypto path instead of failing a
{ok, Ctx}match, which is what the optional NIF is supposed to guarantee. - A client Initial flight spanning more than one datagram reaches the
connection in full. The listener's receive sweep groups the packets
of one flight together, and the group that starts a new connection
handed only its first packet over, dropping the rest; the handshake
then stalled until the client retransmitted. A chunked
x25519mlkem768ClientHello makes a multi-datagram flight the normal case, and the sweep put this on the defaultgen_udppath. - Pacing no longer freezes on sub-millisecond links. RTT samples are
whole milliseconds, so such a link reports a smoothed RTT of 0 and
update_pacing_ratetreated that as "no RTT yet" and skipped the update: the rate stayed at its handshake-time value while cwnd kept growing, clocking the connection at that stale rate forever. The RTT is floored at 1 ms for the rate computation instead. - Burst continuations are sent to the connection directly rather than
through
erlang:send_after(0, ...). The timer wheel's ~1 ms service tick turned every 64-packet burst continuation into a 64 packets per millisecond clock, capping bulk streams at roughly 85 MB/s regardless of the paced rate. Together with the previous fix, a verified single-stream loopback bulk transfer went from 83 to 194 MiB/s. - The frames a connection sends on entering
connected(data queued before the handshake finished, the server's NEW_TOKEN, the first PMTU probe) leave with that transition. They went through the send batch and the state-enter handler returned without flushing it, so they waited for the next event on the connection: on a quiet connection the peer's delayed ACK or a PTO.get_statsnow reportssend_batch_pending, the packets built but not yet handed to the socket, which is what caught this.
Changed
- The optional crypto NIF is no longer built by default. Set
QUIC_BUILD_NIF=1while compilingquicto build it; a requested build that fails now fails the compile instead of being skipped. Without the variable nothing is compiled and the library runs on OTP crypto. - ChaCha20-Poly1305 connections use the fused crypto NIF paths
(
protect_run,open_packet,open_run) like the AES suites. The header-protection mask is five ChaCha20 keystream bytes with the 16-byte sample as the cipher IV (RFC 9001 5.4.4), so the NIF keeps a keyed ChaCha20 context per HP key and reloads only the IV per packet, the same shape as the AES-ECB context. Where the CPU has no AES instructions ChaCha is the faster cipher by a wide margin and was the one suite left on the per-packet path. - The interop runner declares the passive robustness cases (longrtt,
blackhole, amplificationlimit, handshakeloss, transferloss,
handshakecorruption, transfercorruption, rebind-port, rebind-addr),
runs the multiconnect case as one connection per file so the runner's
handshake count matches, disables
disconnect_timeoutin both endpoints so the blackhole case can outlast its outage, waits 60 s per download, and the server loads the full certificate chain from cert.pem. - Count-based ACK decimation defers its flush while a receive pass is
active, so one ACK covers the whole drained train instead of one per
ack_packet_tolerancepackets. A 64-packet pass previously emitted about 32 ACKs, each costing a packet build, an AEAD seal and a send on the receiver and a decrypt plus ACK-frame pass on the sender; it also released the sender's window in 2-3 packet quanta, which kept GSO batches near size 1. The max_ack_delay timer still bounds ACK latency for below-tolerance remainders and the reordering immediate-ACK path is unchanged. - The out-of-order reassembly buffers (stream data and CRYPTO) are ordered trees instead of maps. A miss at the delivery point, which happens once per received packet while a hole is outstanding, is now answered with one smallest-key lookup, and the trim walk for repacketized overlaps visits only chunks below the delivery point. The map version rebuilt the whole buffer on every miss; with a multi-megabyte hole under loss recovery that walk dominated receiver CPU.
- The per-packet receive bookkeeping on the 1-RTT path (PN space, spin bit, activity stamp) is one state update instead of three; the three separate helpers each rebuilt the full connection state and together cost about a tenth of receive CPU on bulk flows.
- Receive-side fast paths for the dominant bulk shape: an in-order
stream frame on an existing stream with an empty reassembly buffer
and both flow-control windows comfortably open is one stream-record
update and one map put, falling back to the general path for every
other shape; a sequential packet number extends the head ACK range
without the range-cap scan; a packet led by a stream frame skips the
datagram-only delayed-ACK check. The per-frame
max_stream_data_checkdebug log is gone, it cost a logger allow-check per frame. - The connected-state receive pass drains the datagram messages already queued in the connection's mailbox (up to 64) before flushing ACKs, socket batches and timers, so those flushes amortize over a train instead of running once per datagram. On the socket backend the listener and the client receiver now emulate a bounded kernel receive buffer: trains are forwarded in chunks of at most 8 packets and tail-dropped once the connection's mailbox exceeds its bound (see the send-side entry), keeping the head packet so the peer still gets a timely ACK. An unbounded mailbox turned receiver overload into queueing delay instead of loss, which inflated the peer's RTT samples and destabilized its loss detector.
- With the crypto NIF loaded, a bulk chunk run is sealed in one call:
protect_runbuilds each short header, derives the nonce, AEAD-seals the payload and applies header protection for the whole run in C, instead of two crypto NIF calls plus header, nonce and mask glue per packet. AES only (the header-protection context is ECB); ChaCha and NIF-less builds seal per packet as before. Send-side crypto drops from about 9% of connection CPU to about 2% on bulk flows. The output is checked byte for byte against the per-packet path. - With the crypto NIF loaded, a received 1-RTT packet is opened in one
call:
open_packetdoes header unprotection, packet-number reconstruction (RFC 9000 Appendix A) and AEAD open together, replacing two crypto NIF calls plus mask, packet-number and AAD glue per packet. It runs only while no key update is in flight and checks the key-phase bit before decrypting, handing a phase change to the two-stage path so RFC 9001 §6 bookkeeping stays where it was. ChaCha and NIF-less builds are unchanged. - A train of short-header datagrams (a GRO train at the server, the
drained mailbox at the client) is opened in one NIF call:
open_runcarries the largest received packet number forward across the run in C, matching sequentialopen_packetcalls exactly, and stops at the first packet that needs the generic path (key-phase transition, authentication failure, undersized datagram), which the caller then handles per packet. The client drain now collects queued same-source datagrams into one batch before processing. - After the batched decrypt,
open_runalso parses each packet's frames in C and returns a frame list term-identical to whatquic_frame:decode/1produces (PING, ACK with ECN, CRYPTO, STREAM, MAX_DATA, MAX_STREAM_DATA, MAX_STREAMS, HANDSHAKE_DONE, with stream and crypto payloads as zero-copy sub-binaries). A packet with any other frame type comes back raw and goes through the Erlang decoder, which keeps every error semantic (unknown type, truncation, empty packet). The parser is differential-tested against the decoder and fuzzed with random and mutated frame sequences. In a 50-connection server model this cut steady-state reductions by 8%; on a verified bulk transfer, 190 to 222 MiB/s. - A batch-opened run whose packets continue the receive sequence is folded with one receive-bookkeeping update (largest, ACK range, spin bit, activity) and one ACK-decimation increment instead of one of each per packet; and when such a run carries one stream frame per packet, all for the same stream, offsets contiguous, no FIN, the stream bookkeeping runs once for the train: one flow-control check with the train's totals, one stream-record update, one delivery. Every deviation (mixed frames, gaps, duplicates, FIN, tight windows, qlog enabled) takes the per-packet path unchanged and the delivery contract holds in both modes. Verified bulk with delivery coalescing on: 339 to 405 MiB/s (+19%).
- AEAD sealing and opening reuse a cipher context instead of re-running
the key schedule on every packet.
crypto:crypto_one_time_aead/7sets the key up on each call and QUIC seals each packet as its own AEAD unit, so that cost landed on every packet in both directions. Where the running OTP hascrypto_one_time_aead_init/4(OTP 28+) the handle is cached per key and direction, bounded the way the header-protection contexts already are; older releases keep the one-shot path. Measured on a 1200-byte packet: AES-128-GCM seal 0.540 to 0.331 us and open 0.468 to 0.331, ChaCha20-Poly1305 seal 1.132 to 0.934 and open 1.067 to 0.934. A payload too short to hold an authentication tag is now rejected as an authentication failure rather than raising. - Accepted connections now monitor their owner and stop with
{shutdown, owner_down}(CONNECTION_CLOSE to the peer) when it exits, as supervised client connections already did. Before, a dead handler left the connection up until its idle timeout, delivering into a dead mailbox.monitor_owner => falsein the listener options restores the old behaviour.
[1.8.2] - 2026-09-05
Added
versionslists the QUIC versions a connection will also accept, for RFC 9368 compatible version negotiation. Contributed by jbevemyr (#243).hibernate_after(default 5000 ms) hibernates an idle connection process, running a fullsweep so the handshake's garbage stops being pinned to a heap that never collects on its own.infinityopts out. Contributed by jbevemyr (#207).- TLS secrets are written to the file named by
SSLKEYLOGFILEwhen it is set, in the format Wireshark reads. Contributed by jbevemyr (#231). max_burst_packetsbounds how many packets leave per send drain, so a large queued write cannot monopolise the scheduler. Contributed by jbevemyr (#214).ack_packet_tolerancemakes the 1-RTT ACK decimation threshold configurable; it defaults to the RFC 9000 section 13.2.1 value of 2. Contributed by jbevemyr (#213).
Fixed
- A stream written in more than one
send_data/4call no longer goes out interleaved. When the per-drain burst budget was spent, the unsent remainder was requeued at the back of its priority bucket instead of the front, so the drain round-robined between the queued entries and the stream stayed out of order for the rest of the transfer, leaving the receiver holding most of it for reassembly (1.2 MB buffered on a 2 MB transfer in 16 writes, against 0 once ordered). Throughput on loopback is unchanged; the cost is the reassembly buffer, and any real path where reordering matters. - An authenticated distribution connection no longer drops the peer's
first handshake message about half the time. The
auth_callbackpath put a short-lived gatekeeper process in front of the dist controller and replayed its mailbox after the handoff, but the controller took ownership from agen_statemstate-enter callback, which runs afterstart_linkhas already returned; everything the connection emitted in between arrived at a process that had stopped reading. The controller now owns the connection from the first packet on the server and from beforestart_linkreturns on the client, so there is no handoff to race. Auth callbacks must not open streams, which never worked. - A TLS alert raised during the handshake now reaches the peer. The
CONNECTION_CLOSE was batched into the state
send_tls_alert/2returns, which the six immediate-exit sites discarded before callingexit/1;terminate/3could not recover it, since it runs with the pre-alert state and its fallback close is skipped entirely while app keys do not exist. The peer saw silence and waited out its idle timeout instead of learning why the handshake failed. Reported by obi458 (#227). - Two nodes dialling each other at the same time no longer deadlock over
QUIC distribution.
net_kernelresolves a simultaneous connect by killing the losing setup process and then blocking, with no timeout, until that process dies; the setup process trapped exits, so the signal became an unread message, nothing died, and the node's distribution machinery stayed wedged until both dials timed out. It now acts on that exit while it still owns the connection, and stops trapping once the controller has taken ownership, which is what OTP's own setup processes do. - An HTTP/3 message body ends at stream end rather than at a frame boundary, so a response whose last DATA frame is followed by the FIN in a separate packet is not truncated. Contributed by jbevemyr (#244).
- A server answers a long-header packet carrying a version it does not support with a Version Negotiation packet (RFC 9000 section 6.1) instead of silence. Probing with a reserved version is how readiness checks and the interop runner detect a live server. A packet that is itself a Version Negotiation is never answered. Contributed by jbevemyr (#229).
- QUIC v2 (RFC 9369) uses the correct wire format: version-specific packet-type bits, salts and key labels, and compatible version negotiation settles on the first ClientHello. Contributed by jbevemyr (#243).
- A client switches away from a connection ID the peer has retired (RFC 9000 section 5.1.2) instead of continuing to use it. Contributed by jbevemyr (#218).
- The keep-alive PING no longer spins on a connection whose peer has gone quiet. Contributed by jbevemyr (#221).
- An unresponsive peer is given up on after a disconnect timeout, checked on its own timer rather than on the PTO. Contributed by jbevemyr (#224).
- A peer address change is followed immediately and validated in the background, so a NAT rebind does not stall the connection while validation runs. Contributed by jbevemyr (#255).
- The server sends a Retry when configured to, and its cipher preference order is configurable rather than fixed. Contributed by jbevemyr (#232).
- The client offers the cipher suites it was configured with instead of
always the built-in list, and honours the
versionoption. Contributed by jbevemyr (#232, #235). - Short headers are unprotected with the negotiated cipher, so a connection that negotiated ChaCha20-Poly1305 no longer fails to decrypt. Contributed by jbevemyr (#234).
- A session ticket selects the PSK it belongs to rather than only feeding 0-RTT, so resumption works on its own. Contributed by jbevemyr (#238).
- The session-ticket table is owned by the server registry rather than whichever connection created it: it used to vanish with that connection, and a client resuming afterwards fell back to a full handshake. Contributed by jbevemyr (#239).
- Only the client sends 0-RTT, and 0-RTT data lost in flight is resent. Oversized 0-RTT writes are split, and the idle-state send path is gated too. Contributed by jbevemyr (#240).
- The connection-level MAX_DATA update triggers on remaining headroom rather than cumulative bytes received. The old comparison became permanently true once total received passed one window, putting an ack-eliciting MAX_DATA on every packet. Contributed by jbevemyr (#209).
- Retained ACK ranges are capped at 64 per packet-number space (RFC 9000 section 13.2.4). Under burst loss the list fragmented into hundreds of ranges, and every outgoing ACK encoded all of them. Contributed by jbevemyr (#211).
- Overlapping and duplicate chunks are handled when reassembling stream and CRYPTO data, keeping the longer chunk at a given offset instead of trusting whichever arrived first. Contributed by jbevemyr (#223).
- A flow-control-blocked write no longer strands data behind a blocked queue head: the sendable prefix goes out, the remainder requeues in order, and queued data is normalised to a binary. Contributed by jbevemyr (#233).
- Streams are reclaimed when their FIN is acknowledged rather than when it is sent, so a lost FIN cannot retire the stream early. Contributed by jbevemyr (#236).
- MAX_STREAM_DATA is no longer sent for a stream whose final size the peer has already declared. Contributed by jbevemyr (#241).
- The client's retained Finished flight carries its own retransmission timer. Once the state machine leaves the handshake nothing is guaranteed to be in flight to arm a PTO, so a Finished lost more than once was never resent and the handshake stalled until the idle timeout.
- The GSO segment size is derived from the batch instead of a
configured constant. 1-RTT packets follow the current max datagram
size, so the uniformity check against the fixed 1200 never matched and
the GSO path never ran; a batch is now split into runs of equal-sized
packets and each run segmented on its own size. Each write is capped
at 64 segments and 64 KB, and GSO is requested per message rather than
as a socket-level
UDP_SEGMENT, which segmented every datagram including handshake packets. Reported by jbevemyr (#196). - The socket-backend client binds the source address from
extra_socket_optsinstead of leaving it to the kernel's route lookup, which picks the wrong address on a multi-address host. Contributed by jbevemyr (#261). - A GRO train reaches the client connection as one message rather than one per packet, so the whole train is processed in a single receive pass. Contributed by jbevemyr (#248).
- PTO probe packets are exempt from the congestion window and loss retransmissions are bound to it, per RFC 9002 section 7. A probe is the only thing that can restart a stalled connection, so blocking it on a window the peer's silence keeps closed deadlocks the transfer; ordinary loss retransmissions, which were previously sent regardless, now respect the window like any other send. Contributed by jbevemyr (#249).
- ACKs arriving at the Initial or Handshake encryption level no longer reach the 1-RTT loss tracker. Packet numbers restart per space (RFC 9000 §12.3), so a Handshake-space ACK of packet numbers 0..N was retiring the first N 1-RTT packets from the sent queue without the peer having received them: nothing retransmitted them and the peer kept a permanent hole in the stream. A path that drops a full window and then returns (WiFi-to-cellular handover, VPN reconnect, NAT rebind) left the transfer stalled for good. Contributed by jbevemyr (#250).
- The sent-packet tracker now survives an active path migration. It was
replaced wholesale, which orphaned every packet already in flight: no
ACK matched them, loss detection never ran, and
bytes_in_flightread zero so no PTO fired either. Their data was never retransmitted and the peer kept a permanent hole in the stream. The path-derived estimates (RTT, PTO count) still reset, since those belong to the old path. Contributed by jbevemyr (#251). - The PTO backoff is capped at 5 seconds. RFC 9002 section 6.2.1 doubles the PTO on each consecutive expiration and the doubling had no ceiling, so on a 50 ms path it reached roughly 90 seconds after nine expirations and about twelve minutes after twelve. A probe scheduled that far out never happens: the idle timer and any request deadline above it have long since fired. Contributed by jbevemyr (#254).
- The loss time threshold is
max(smoothed_rtt, latest_rtt)(RFC 9002 section 6.1.2) rather than the smoothed estimate alone. When an RTT spike outruns the EWMA the two diverge, and the smaller threshold declares in-flight packets lost while their ACKs are merely late. Each spurious loss both retransmits data the peer already has and collapses the congestion window, so a single latency excursion (receiver queueing, bufferbloat) turned into a throughput collapse. Contributed by jbevemyr (#210). - PMTU probes are tracked as non-ack-eliciting, so a probe lost past the path MTU no longer inflates
bytes_in_flight, arms the PTO machinery, or feeds a congestion event (RFC 8899 §3, RFC 9000 §14.4). Combined with an in-flight-keyed liveness check, the periodic raise probe previously killed every long-lived connection on an MTU-limited path once per 600-second raise interval, both ends at once. The raise interval is configurable aspmtu_raise_interval. (#264) - The UDP_GRO control message is read as the int the kernel sends. Matching exactly two bytes meant the lookup never succeeded, so a GRO-coalesced buffer was passed up unsplit as one oversized datagram and dropped by the QUIC layer, which cannot re-split short-header packets. GRO was therefore silently losing every coalesced train. Contributed by jbevemyr (#204).
- The GRO receive path sizes its read buffer for a maximally coalesced
train (64 KiB). Passing 0 used the OTP default 8 KiB buffer and
recvmsg' silently truncated anything larger, discarding every segment past the first few. The client receiver also went through a plainrecvfrom', which never split trains at all. Contributed by jbevemyr (#215). - A client whose Finished is lost now recovers. The
Certificate(+CertificateVerify)+Finished flight goes out at the
Handshake level, and once the client state machine left
handshaking' nothing retransmitted it: handshake-space packets are not in the 1-RTT loss tracker and the handshake retransmit timer only runs in that state. The client considered itself connected and sent 1-RTT data the server could not act on before handshake completion, while the server replayed its own flight against ACK-only answers, until the connection died on the idle timer. The flight is now retained until HANDSHAKE_DONE and resent from the PTO and on a duplicate handshake-level CRYPTO at offset 0. A write that exceeds the peer's flow-control limit also sends the part that fits rather than nothing, which is what lets the recovered connection drain its queue. Contributed by jbevemyr (#252, #230, #225). - Anti-amplification accounting (RFC 9000 §8.1) now also runs on the batched listener delivery path. A server whose ClientHello arrived in a GRO batch kept its amp budget at zero, deferred the handshake flight, and the handshake wedged until the connect timeout. (#263) - A server handshake flight lost on the wire is retransmitted on the client-Initial backoff schedule until the client's Finished arrives. Initial/Handshake packets are not loss-tracked, so a lost flight previously wedged the handshake permanently: the client's Initial retransmits only elicited ACKs once the server TLS state had advanced. (#263) ### Changed - Header protection reuses a cipher context instead of re-running the key schedule on every packet. Measured on an 8 MB transfer, thecrypto:*share of connection-process time drops from 10.95% to 8.30%; header protection itself goes from 0.77us to 0.28us per mask. Both directions' keys are cached, since they alternate packet by packet. - The interop runner speaks real HTTP/3 in the http3 case, serves more than one request per connection, issues requests concurrently within the peer's stream credit, and its resumption and 0-RTT cases test what they claim. Contributed by jbevemyr (#245). - A mixed-size send batch on a GSO socket is split into runs of equal-sized packets and each run sent with one UDP_SEGMENT call, instead of falling back to onesendmsg' per packet. A single odd-sized packet between data packets (an ACK, a flow-control update) previously degraded the whole batch. Contributed by jbevemyr (#217). - Small sends are coalesced into shared packets. Contributed by jbevemyr (#203).
- The pacing burst allowance scales with the pacing rate rather than sitting at a fixed 12 packets. Pacing wakeups have roughly millisecond resolution, so the fixed bucket capped throughput at 12 packets per wakeup whenever the sender outran the ACK clock, regardless of the configured rate. Contributed by jbevemyr (#219).
- A server with no explicit
groupsoption now offers every classical group the crypto layer supports (x25519, secp256r1, secp384r1) instead of x25519 alone. A preference list holding only x25519 sent a HelloRetryRequest to any client whose key_share led with another curve, costing a full extra round trip on every such connection and exercising the HRR path in flows that did not need it. picoquic shares P-256 first, so this was every connection from it. An explicitgroupsoption still restricts the set exactly as before. Contributed by jbevemyr (#246). - The CONNECTION_CLOSE sent from terminate/3 (owner death, exit signals) reaches the wire: it was batched into an updated socket state that terminate discarded, flushing the stale one instead, so the peer never learned of the close and held a phantom connection until its idle timeout. Peers now see an owner-death close within milliseconds. (#265)
[1.8.1] - 2026-08-15
Added
- Post-quantum hybrid key exchange
x25519mlkem768(draft-ietf-tls-ecdhe-mlkem, ML-KEM-768 + X25519), opt-in viagroups. Negotiable only when the crypto library provides the ML-KEM APIs (OTP 28.1+); agroupsoption naming an unsupported group is rejected up front fromconnect/4andstart_server/3as{error, {unsupported_group, _}}instead of crashing during the handshake. (#195, #198) docs/INTERNAL_NETWORKS.mdcovers running without a CA on a trusted subnet: why TLS cannot be disabled, and theverify_noneand PSK-only setups for both QUIC and Erlang distribution. (#197)
Fixed
- Initial-level CRYPTO is now chunked across Initial packets, each within the 1200-byte pre-PMTU limit, and the whole flight is replayed on retransmit. A hybrid
x25519mlkem768ClientHello (~1360 bytes) or ServerHello Initial (~1225 bytes) no longer leaves as a single oversized datagram that is dropped on paths with an MTU below ~1470 (IPv6-over-PPPoE, WireGuard, mobile). (#195, #198) - A peer key share that does not fit the negotiated group is answered with an
illegal_parameteralert instead of raising insidecryptoand taking the connection process down. The ServerHello key_share group is kept and checked against the negotiated one, each group accepts exactly one share length, and an all-zero ECDH shared secret is rejected (RFC 8446 §7.4.2). (#198) start_server/3returns{error, no_auth_method}when a listener has neithercert/keynor PSK configuration, as documented. The check ran after the listener had started, so the call returned{ok, Pid}and the pool then collapsed. (#197)connect/4andstart_server/3reject agroupsoption that is not a non-empty list rather than failing later inside the connection. (#198)
Changed
- The
verifydefault is documented correctly asverify_peer: clients validate the server certificate unless told otherwise.docs/CLIENT_GUIDE.mdanddocs/DEVELOPER_GUIDE.mdboth listed the default as no verification. (#197)
[1.8.0] - 2026-08-05
Fixed
- The client hostname check applies the RFC 6125 HTTPS rules, so a wildcard SAN such as
*.example.commatcheshost.example.com. Servers behind a wildcard-only certificate,www.google.comamong them, were rejected as{hostname_mismatch, _}. (#188) - A Happy Eyeballs winner hands its owner the events it delivered before reporting
connected. A server that sends its HTTP/3 SETTINGS in the same flight as the handshake had them dropped, soquic_h3:connect/3timed out for a multi-address host. (#188) - A client that receives a Retry keeps counting Initial packet numbers up (RFC 9000 §17.2.5.3) instead of restarting at 0, so the retried Initial is not a replay of the packet the Retry answered. The pre-Retry Initials leave loss detection instead of staying charged as bytes in flight.
- A client acts on a Version Negotiation packet (RFC 9000 §6.2) instead of misparsing and dropping it: no shared version closes the connection as
{version_negotiation, Versions}, and a packet that arrives late, carries a foreign connection ID, or offers back our own version is discarded. - The advertised
max_udp_payload_sizeis what we are willing to receive rather than the PMTU probing ceiling: themax_udp_payload_sizeoption when set, otherwise 1472 over IPv4 and 1452 over IPv6. Both roles used to advertise 1500, which does not fit a 1500-byte path. (#184)
Changed
- A handshake failure reaches the owner as
{quic, Conn, {error, Reason}}, tagged with the connection handle like every other owner event; the connection reference used to be the tag and nothing matched it.quic_h3:connect/3passes the reason through, so a rejected certificate returns{error, {certificate_invalid, _}}rather than{error, connect_timeout}, and an exhausted Happy Eyeballs race returns the last attempt's reason rather thanall_attempts_failed. Owners matching{quic, ConnRef, {error, _}}must match the handle instead.
[1.7.1] - 2026-07-17
Fixed
- An HTTP/3 connection process now stops with
normalwhen the underlying QUIC connection closes cleanly (graceful drain, idle timeout, shutdown), instead of the abnormal reasonquic_closed. Clean closes no longer emit ERROR and CRASH reports or kill non-trapping linked owners; an abnormal QUIC exit still stops the H3 process, now as{quic_closed, Reason}. The owner also receives the{quic_h3, Conn, closed}notification on this path, which was previously skipped. (#186)
[1.7.0] - 2026-07-06
Added
- Stateless reset on restart (RFC 9000 §10.3). A server advertises a
stateless_reset_tokentransport parameter bound to its initial connection ID and, after losing connection state (for example a restart), replies to an unroutable 1-RTT packet with a stateless reset derived from the same secret. A client stores the advertised token and recognises the reset, tearing the dead connection down promptly instead of waiting for its idle timer. Contributed by sstrollo (#177). require_client_certserver option for mutual TLS. Withverify => truethe server requests a client certificate and validates any presented chain againstcacerts;require_client_cert => trueadditionally rejects a client that sends no certificate (certificate_required), making mutual TLS mandatory. Contributed by sstrollo (#178).
Security
- The server now validates the client certificate chain in mutual TLS. With
verify => truea presented client certificate is checked against the configured trust anchors (cacerts, OS store by default) in addition to the CertificateVerify signature, so a self-signed or otherwise untrusted certificate is rejected instead of accepted. An empty client certificate is still accepted by default (optional mTLS, RFC 8446 §4.4.2.4); setrequire_client_cert => trueto require one. Contributed by sstrollo (#178).
Fixed
- HTTP/3 client connections close cleanly on an invalid peer SETTINGS frame instead of crashing the connection process. A SETTINGS violation (for example
SETTINGS_H3_DATAGRAMadvertised without the QUICmax_datagram_frame_sizetransport parameter) is reported to the owner as{error, 265, _}(H3_SETTINGS_ERROR) and the connection closes, rather than the state machine terminating withbad_return_from_state_functionand taking the owner's request down. (#172) - The connection idle timer is driven by received activity (RFC 9000 §10.1): it restarts on every received packet and on the first ack-eliciting packet sent since the last receive, but not on subsequent sends. A connection sending keep-alive PINGs or PTO retransmits into a black hole now idle-closes instead of holding its own timer open forever, while a reactivated idle connection is not closed before its first ack returns. Contributed by sstrollo (#174).
- Connection-level MAX_DATA slides forward with received bytes instead of capping the absolute limit at the receive window, so a sustained transfer no longer stalls permanently once the connection has received
fc_max_receive_window(8 MiB) bytes in total. Contributed by sstrollo (#176), reported independently by maslowalex (#173).
Changed
- Per-packet routing traces in the listener are logged at debug instead of info, so a busy or post-restart listener no longer floods the log. Contributed by sstrollo (#175).
[1.6.5] - 2026-06-12
Added
quic_h3:respond/5sends an HTTP/3 response status, headers and full body with end-stream in a single connection call, coalescing what previously tooksend_response/4plussend_data/4. HEAD, 204 and 304 responses send no body.quic:start_server/3accepts asni_callbackthat selects the server certificate and key per connection from the ClientHello SNI (RFC 6066 §3), so an HTTP/3 listener can present different certificates per hostname. The callback is invoked with the parsedserver_nameand returns{ok, #{cert => Cert, key => Key, cert_chain => Chain}}or{error, _}; an error, malformed result or raised exception fails the handshake with ahandshake_failurealert. The staticcert/keyremain the default when no callback is set.
[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.bodyfield is retained for compatibility but is no longer populated.
[1.6.3] - 2026-06-03
Added
quic:safe_close/1,2,3closes 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_errorpassed the error reason straight toquic:close/3, but several call sites supply a non-binary reason, which failed the function's binary guard and was swallowed by a surroundingcatch, so the connection was left open. The reason is now coerced to a binary phrase.
Changed
- Replaced the deprecated bare
catchexpressions in the library and test suites withtry ... 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/3untilconnect_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_ownerre-delivers{connected}but not that already-arrived stream data; the race coordinator andquic_h3:connect/3now 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_dataextension in EncryptedExtensions when it accepts 0-RTT, so a resuming client reportsearly_data_accepted/1 =:= trueinstead 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/1reports whether 0-RTT keys are available, andquic:early_data_accepted/1/quic_h3:early_data_accepted/1report whether the server accepted the early data. (#148) quic_listener:get_sockname/1andquic:get_server_sockname/1return the listener's bound{IP, Port}, resolved live from the socket so it is correct when the listener was opened withinet6,{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_ticktimeinstead of the QUIC idle timeout, so a healthy connection is no longer declared down bynet_kernelunder load. (#157)
[1.5.0] - 2026-05-30
Added
- IPv6 client connections:
quic:connect/4accepts a hostname, an IP-literal string (IPv4 or IPv6, optionally bracketed), or aninet:ip_address()tuple. Dual-stack hostnames use RFC 8305 Happy Eyeballs (IPv6-first racing) withhappy_eyeballs,family,connection_attempt_delayandconnect_timeoutoptions. A hostname that fails to resolve returns{error, Reason}instead of dialing a default address. (#150) - Listeners can bind to IPv6: pass
inet6or an IPv6{ip, Addr}inextra_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.comover Google Trust Services on Mozilla NSS,certifiand FreeBSDca_root_nss) withunknown_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 (
cacertsoption, OS store by default), and checks the hostname. Previouslyverifywas 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.verifynow defaults to on for clients; setverify => falseto 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
200until the first DATA frame, but a tunnel server sends no DATA until the client does and the client waits for the200, so the tunnel deadlocked (WebTransport and WebSocket-over-H3). CONNECT responses now flush the200immediately; 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_activitytimestamp, 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_pskand serverpsks/psk_callbackoptions, bothpsk_dhe_keandpsk_kemodes, constant-time server-side binder verification, cert and PSK coexistence on one listener, and client downgrade protection.quic_distcan authenticate node-to-node with a shared PSK and no certificates. Seedocs/PSK.md. (#133) - TLS 1.3 HelloRetryRequest with multi-group key exchange. The
groupsoption advertisesx25519,secp256r1andsecp384r1; 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_algsoption, adding ECDSA secp384r1-SHA384, RSA-PSS-RSAE SHA384/512 and Ed25519 sign/verify. Theconnectedevent now reportsnegotiated_groupandnegotiated_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.mdwith 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
@doctag preceding a-callback. (#129)
[1.3.3] - 2026-05-03
Added
quic:get_path_stats/1returns 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_distauth_callbackoption runs a custom{Mod,Fun}(orfun/3) on both sides between the QUIC handshake and the distutil handshake. `{error, }closes the connection without ever starting the dist controller. Newquic_dist_auth` behaviour. (#126)quic_distregister_with_epmdoption (defaultfalse) registers the listening port via the configuredepmd_moduleso external tooling (e.g.epmd -names) can resolve the node. (#126)
[1.3.2] - 2026-05-03
Added
priv/bin/quic_call.sh, anerl_call-style one-shot RPC helper forquic_distclusters. Boots a hidden probe node with-proto_dist quic, runsrpc:call/5against the target, asks the target to disconnect so the hidden-node entry is reaped immediately, and halts. Reuses the cluster'ssys.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 => adapterlets callers plug their own datagram transport (for example a MASQUE CONNECT-UDP session) under a QUIC client. The adapter map carriessend_fun(and optionalclose_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/6emits 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
:statuspseudo-header (RFC 9114 §4.3.1). (#112) quic_qpack:set_dynamic_capacity/2clamps tomax_allowed_capacityper RFC 9204 §4.3. (#112)quic:reset_stream/3keeps the stream entry alive so subsequentquic:stop_sending/3emits STOP_SENDING instead of returning{error, unknown_stream}. (#113, #115)quic:close/2with 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/6so the CLOSE frame reaches the peer when app keys are absent. (#111)
Removed
quic_h3_h3spec_SUITEanddocker/h3spec/. The corpus is ported intoquic_h3_compliance_testsas 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 => socketfor client connections. Routes the client throughquic_socket:open_for_send/2so it picks up the OTP socket NIF on Linux with GSO available per-message via cmsg, instead of thegen_udpport 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_socketstart_client_receiver/stop_client_receiver: dedicated receiver process for the socket-backend client path (the OTP socket NIF has no{active, N}mode). (#88)quic_socket:set_socket/2swaps the underlying socket handle inside a#socket_state{}while preserving batching configuration. Used by the migration rebind path. (#93)- Instrumentation counters
ack_sentandretransmitsonquic_connection:get_stats/1and the throughput bench output (Phase 0a). (#77, #78)
Fixed
quic:migrate/1on the default gen_udp client no longer drops post-migrate traffic. Rebinding previously left#state.socket_statepointing 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 bothnet_kernel:connect_node/1calls indefinitely. The old accept path ran the dist worker through a nine-hop handoff (register_pending / controller rendezvous in acceptor_loop) before reachingdist_util:mark_pending, so net_kernel's tie-breaker arbitration never ran in time. Collapsed to the TCP-dist shape:accept_connection/5runsset_supervisor+start_timer+handshake_other_startedinline. Docker 5-node regression now passes 5/5. (#106)quic_dist: batch-yield path ininput_handler_loopcould 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/2doc: 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, soglobaldoes 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_loopexits 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 thesocket+{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_batchingisfalseso the direct-send path uses the samequic_socketshape 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/1on the bulk-upload hot path. (#82) - Thread the updated
socket_stateback fromdo_socket_sendvia the return value, dropping the process-dictionary roundtrip. (#83) - Replace the
crypto:exor/2NIF call with inline Erlang XOR for the 1-4 byte header-protection mask. (#84) - Inline the
?QLOG_ENABLEDcheck at packet/frame event call sites so the event-map is never built when qlog is off. (#86) - Coalesce the
monotonic_timesamples 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_SEGMENTsetsockopt and rely on per-message cmsg viaflush_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_socketbatch buffer that reuses the listener's UDP socket. Gated by the newserver_send_batchingoption onstart_server/3(defaulttrue); set tofalseto fall back to the previous directgen_udp:send/4path. (#66) quic_socket:info/1— map withbackend,gso_supported,gso_size,gro_enabled,batching_enabled,max_batch_packets, and the newbatch_flushes/packets_coalescedcounters.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/1now returnsbatch_flushesandpackets_coalescedso 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 assertpackets_coalesced > 1when 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.erlandquic_throughput_bench:run_download_sink/0,1drive server-to-client bulk transfers and report MB/s alongsidebatch_flushes/packets_coalescedso the batching effect is visible next to throughput.
Changed
- Stream send path is iovec-native.
quic_frame:encode_iodata/1returns[Header, Data]and threads iodata through header protection andquic_aeadwithout copyingDatainto a fresh binary. AEAD specs relaxed to accept iodata. - 1-RTT ACKs delayed to every 2nd packet or
max_ack_delayper 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_lossswitched to a singlequeue: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/1passes the batch as an iov list directly tosocket:sendmsg/2with 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/3samplesmonotonic_timeonce per packet and reuses it for loss tracking andlast_activity. (#71)- Per-packet overhead on the bulk-send path reduced: single
#state{}update, PTO timer reschedule skipped when within tolerance,process_send_queueand pacing timeout short-circuit on empty queue, stream data normalised to binary once at the fragmentation boundary. state_to_map/1replaces the coarsesend_batchingboolean with three explicit fields:send_backend(direct|gen_udp|socket),send_batching_enabled,send_gso_supported.
Fixed
- Server connection crashed with
function_clausewhen the listener was onsocket_backend => socketbecauseinet:sockname/1rejects{'$socket', Ref}handles. Branch on socket shape:socket:sockname/1for OTP socket handles,inet:sockname/1forgen_udpports. - UDP_SEGMENT
setsockoptnow usessizeof(int)(32-bit native) instead of u16, which Linux rejected withEINVAL; 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_sizesingle-packet payload drops silently on ubuntu-24.04.batch_count == 1has no segmentation work; fall through toflush_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 atawaiting_encrypted_extensions.flush/1checks uniformity and falls through toflush_individualwhen it fails. (#75) - Listener self-send:
send_packet/6was callingquic_socket:send/4and dropping the returned state, so version-negotiation / retry / stateless-reset packets were buffered then lost on the socket backend withbatching_enabled=true. Switched tosend_immediate/4. send_queue_bytesaccounting leaked on ACK-coalesce dequeues and could eventually trip?MAX_SEND_QUEUE_BYTESon long-lived connections. Addedsend_queue_countas 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/2expects a DCID binary, not an info map; returns{ok, HandlerPid}so the listener transfers ownership; peer address fetched viaquic:peername/1. (#65)examples/qlog_example.erl: added aconnection_handlerso 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_handleron fresh peer-initiated bidi streams so extensions can claim them before default request handling (#62) - docs:
rebar3 ex_docnow 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):
priorityrequest 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_handleroption onstart_server/3claims 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 receivesstream_type_open,stream_type_closed,stream_type_reset,stream_type_stop_sendingevents - Client-initiated extension streams:
quic_h3:open_bidi_stream/1,2pre-claims a bidi stream with a signal-type varint (e.g. WebTransport's0x41) so inbound bytes route through the claimed-bidi path - Per-connection owner override via
connection_handlercallback onstart_server/3for hosting many sessions per listener - Per-stream handler registration:
set_stream_handler/3,4,unset_stream_handler/2to redirect body data to a worker pid - Query API:
get_settings/1,get_peer_settings/1,get_quic_conn/1 - Documentation:
docs/HTTP3.mdreference + 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_servercaller pid; durable owners for datagram / stream-type events should be supplied via the per-connectionconnection_handlercallback - SETTINGS directionality validation tightened to RFC 9114
Fixed
- Server connections wedged with
connect_timeoutwhen the process that calledstart_server/3exited before a client arrived and eitherh3_datagram_enabledorstream_type_handlerwas 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 = trueexactly once - Strict PRIORITY_UPDATE frame parsing per RFC 9218
- DoS hardening on header / capsule / frame parsing
- Header / trailer /
:path/:statussymmetry 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
syncoption onconnect/3resolves 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_ATtransport parameter and frame plumbingquic:set_congestion_control/2runtime CC switch APIquic:get_peer_transport_params/1introspection API
Changed
- BBR internal clock switched to microseconds; loopback transfers no longer pin to the InitialRtt fallback
Fixed
- Stream-level
MAX_STREAM_DATAwindow stopped sliding oncerecv_max_datareachedfc_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 pastrecv_offsetlike 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_STREAMSas 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_*_SUITEandquic_h3_e2e_SUITErun 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_migrationtransport parameter
- Application error code support for CONNECTION_CLOSE frames
- Client certificate support (
verifyserver 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_sizetransport 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/1API for connection packet counts (used for liveness detection)quic:send_ping/1API 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_timeouterrors under heavy load by using QUIC-level activity as liveness proof- Stream flow control
recv_max_datausing 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
catchexpressions replaced withtry...catch...end - Undefined
dynamic()type replaced withterm()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/1withbadarg
[0.10.0] - 2026-02-21
Added
- RFC 9312 QUIC-LB Connection ID encoding support for load balancer routing
- New
quic_lbmodule 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 configurationlb_configoption inquic_listenerto enable LB-aware CID generation- Variable DCID length support in short header packet parsing
- LB-aware CID generation in
quic_connectionfor NEW_CONNECTION_ID frames - E2E test suite
quic_lb_e2e_SUITEwith 21 integration tests quic:server_spec/3to get a child spec for embedding QUIC servers in custom supervision trees- Stream reassembly test suite
quic_stream_reassembly_SUITEfor ordered delivery verification
Changed
quic:set_owner/2is now asynchronous (cast instead of call)
Fixed
quic:get_server_port/1now returns the actual OS-assigned port when server was started with port 0 (ephemeral port), instead of returning 0quic:get_server_connections/1now correctly returns connection PIDs; was returning empty list due toget_listeners/1returning supervisor pids instead of actual listener processes- Removed redundant
link/1call in listener (connection already linked viagen_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 * PTOtimeout 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/3to start named server with connection poolingquic:stop_server/1to stop named serverquic:get_server_info/1to get server information (pid, port, opts, started_at)quic:get_server_port/1to get server listening portquic:get_server_connections/1to get server connection PIDsquic:which_servers/0to 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_sizeoption 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.srcnow 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/4andquic:get_stream_priority/2API- 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_ipv4andpreferred_ipv6listener options for server configuration#preferred_address{}record for IPv4/IPv6 addresses, CID, and reset tokenquic_tls:encode_preferred_address/1andquic_tls:decode_preferred_address/1- Idle timeout enforcement (RFC 9000 Section 10.1): when
idle_timeoutoption 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/1API 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_receivedand 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/2for unlinked listener processesset_ownercall 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/2to transfer connection ownership (like gen_tcp:controlling_process/2)quic:peercert/1to retrieve peer certificate (DER-encoded)quic:send_datagram/2to send QUIC datagrams- Connection handler callback in
quic_listenerfor 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/todocs/to prevent ex_doc from overwriting documentation - Consolidated
hash_len/1andcipher_to_hash/1functions inquic_cryptomodule - Refactored key derivation in
quic_keysusingcipher_params/1helper - Improved socket cleanup on initialization failure in
quic_connection
Removed
- Removed
send_headers/4API (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_frameto prevent memory exhaustion - Added ACK range limits in
quic_ackto prevent DoS attacks - Fixed weak random: use
crypto:strong_rand_bytes/1for ticket age_add - Fixed dialyzer warning in
quic_tlsby adding error handling todecode_transport_params/1
[0.3.0] - 2025-02-16
Added
- Server mode with
quic_listenermodule - 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)