Changelog
View SourceAll notable changes to h2 are documented here. This project follows Semantic Versioning.
[Unreleased]
[0.6.0] - 2026-05-20
Security and concurrency hardening pass driven by a multi-agent audit. Several behaviour changes are flagged below; the new error returns require callers to widen their pattern matches.
Security
- CONTINUATION flood (Critical).
handle_continuationcaps the raw bytes of an in-flight HEADERS+CONTINUATION block at?MAX_HEADER_BLOCK_BYTES(256 KB). Past the cap the connection emitsGOAWAY(ENHANCE_YOUR_CALM). Pre-fix a peer could OOM the node beforemax_header_list_size(which acts on decoded headers) could fire, same class as CVE-2024-27316. - Owner liveness (Critical).
controlling_process/2now monitors the new owner and demonitors the previous one. Before the fix the new owner's death produced no signal and the connection orphaned the socket. send_frame/2error propagation (High).send_request/send_request_headers/send_response/send_data/send_trailersused to replyokto the caller even afterTransport:sendreturned{error, closed}. They now stop with{shutdown, {send_failed, Reason}}and propagate{error, Reason}to the in-flight caller.- Peer
SETTINGS_HEADER_TABLE_SIZEcapped (High) at 64 KB before applying. RFC 7541 lets the peer advertise any 32-bit value; honoring it fed the encoder dynamic table (O(n) lookup) and turned a chatty peer into a CPU/memory exhaustion vector. - Per-stream send-buffer cap (High).
Stream#stream.send_bufferis capped at?MAX_SEND_BUFFER_BYTES(1 MB). A peer that stalls its receive window now gets{error, send_buffer_full}instead of growing the connection process unbounded. - Acceptor mailbox drain (High). ssl and tcp acceptor loops drain queued
{'EXIT', _, _}after every accept. Pre-fix every closed connection left an EXIT message in the trapping acceptor; on a busy server the mailbox grew without bound. - TLS server hardening (High).
start_serverhonors top-levelverify(defaultverify_none), rejectsverify_peerwithoutcacerts({error, verify_peer_requires_cacerts}), accepts anssl_optsoverride list, and defaultshonor_cipher_ordertotrue. - Demo escript path traversal.
h2_server:safe_path/2usesfilelib:safe_relative_path/2; URL-encoded%2e%2eand normalised escapes (/a/../../etc/passwd) are now rejected.
Breaking
h2:set_stream_handler/3,4default flipped fromdrain_buffer => truetodrain_buffer => false. The connection now replays previously-buffered DATA frames to the handler pid itself; the call returnsok. Callers that explicitly matched{ok, Buf}on the default and forwarded by hand can drop that code. Pass#{drain_buffer => true}to keep the old shape.h2:send_data/3,4may return{error, send_buffer_full}when the peer has stopped consuming. Widen anyok = h2:send_data(...)match.h2:cancel_stream/2,3is marked-deprecatedin favour ofh2:cancel/2,3. Same behaviour; compiler emits the deprecation warning.- Default
SETTINGS_MAX_CONCURRENT_STREAMSis now100(wasunlimited, per RFC 9113 §5.1.2 floor). Peers attempting more than 100 concurrent streams now getRST_STREAM(REFUSED_STREAM). - TLS server
start_server:verify_peerwithoutcacertsnow fails fast with{error, verify_peer_requires_cacerts}instead of silently accepting unauthenticated peers.
Added
?MAX_HEADER_BLOCK_BYTES(256 KB),?MAX_PEER_HEADER_TABLE_SIZE(64 KB),?MAX_SEND_BUFFER_BYTES(1 MB),?DEFAULT_TIMEOUT_MS(30 s) macros inh2.hrl.h2_settings:setting_id/1andh2_settings:encode_value/1exported;h2_connectionreuses them so the literal-hex setting-id table cannot drift again.- CT regressions in
h2_compliance_SUITE:continuation_flood_triggers_enhance_your_calm_test: > 256 KB of CONTINUATION traffic yields GOAWAY.controlling_process_monitors_new_owner_test: killing the new owner terminates the connection.send_returns_error_on_closed_socket_test: closed socket +send_datareturns{error, _}.send_buffer_full_when_peer_stalls_window_test: 2 MB push past stalled window yieldssend_buffer_full.set_stream_handler_default_replays_buffer_test: the new auto-replay default is exercised.large_body_yields_to_inbound_frames_test: PING ACK round-trips during a 512 KB upload.
Changed
handle_send_datano longer recurses inside the gen_statem callback for multi-frame bodies. It buffers the payload (subject to the cap) andflush_stream_one_chunk/2emits one DATA frame per gen_statem step via self-cast. Inbound frames (PING, WINDOW_UPDATE, RST_STREAM) are now interleaved with outbound chunks instead of queueing for the duration.- HPACK decode failures and handler crashes log via
logger:error/2(was the deprecatederror_logger:error_msg/2). The HPACK reason term is dropped from the log line; it echoed attacker-supplied header bytes. - An unsolicited
SETTINGS_ACKpreserves the current state name instead of forcingconnected(still lenient-ignore, not the RFC 9113 §6.5.3 PROTOCOL_ERROR; just no longer short-circuits the preface state machine). peel_reason/1is recursive; doubly-wrapped{shutdown, {shutdown, _}}reasons collapse to the inner value.- Default ssl/tcp transport tag, ALPN, and timeouts all flow from
?DEFAULT_TIMEOUT_MS(30 s).
Fixed
set_active/2on a closed socket no longer crashes the genstatem withbadmatch; the connection stops cleanly with `{shutdown, {socket_error, }}`.cancel_timeruses synchronous cancel + flushes any already-delivered{timeout, Ref, _}so stale messages cannot match a future reused timer.h2_listener:loop/4andh2:server_connection_loop/2logger:debugunknown messages instead of silently dropping.
[0.5.0] - 2026-04-19
Added
h2_settings: WebTransport over HTTP/2 settings (draft-ietf-webtrans-http2-14 §11.2). IDs0x2b61-0x2b66encode/decode aswt_initial_max_data,wt_initial_max_stream_data_uni,wt_initial_max_stream_data_bidi_local,wt_initial_max_stream_data_bidi_remote,wt_initial_max_streams_uni,wt_initial_max_streams_bidi. No defaults: absence means "not advertised".
Changed
h2_settings:decode/1: unknown setting IDs are preserved under their raw 16-bit integer key in the returned map instead of being dropped. RFC 7540 §6.5.2 "MUST ignore" means "do not act on", not "discard"; keeping them lets higher layers (e.g. WebTransport) inspect extension settings without a patch here.h2_settings:encode/1also accepts integer keys for symmetric round-trip.
[0.4.0] - 2026-04-15
Listener robustness + TLS regression guard. The server listener no longer dies when the process that called h2:start_server/2 exits, which broke test helpers and init callbacks spinning up short-lived listeners.
Added
h2_app/h2_sup/h2_listener:h2is now a proper OTP application with a top-levelsimple_one_for_onesupervisor for per-server listeners. Server listeners live under the application supervision tree instead of being linked to the caller ofh2:start_server/2.- CT regression
tls_transport_tag_detected_testinh2_compliance_SUITE: assertsh2_connectionclassifies the TLS socket asssl(notgen_tcp) after connect, so any future drift in OTP'ssslsockettuple shape is caught early.
Changed
- Breaking:
h2:start_server/2now requires theh2application to be started (application:ensure_started(h2)). Previously it worked from any process; now it registers a child underh2_sup. h2:stop_server/1sends a stop message to the listener process and lets it shut down the acceptor pool and close the listen socket under OTP supervision.
Fixed
wait_connected/1,2callers and the{h2, Conn, connected}owner event are now fired inline fromhandle_framewhen the first SETTINGS ack transitions the connection toconnected. Previously, if the same socket read buffer also contained a frame that caused a connection error,gen_statemwould enterclosingbefore theconnectedstate-enter callback ran and waiters would only see the teardown reply.closingstate-enter now replies to any still-queuedwait_connected/1,2callers with{error, ErrorCode}instead of leaving them to time out.closingstate-enter now half-closes the write side (shutdown(write)) and keeps reading to drain the recv buffer before the final close. A fullclose()with unread peer data was causing Linux to emit RST instead of FIN, which masked our GOAWAY on the h2spec oversized-frame case (4.2 / 2: Sends a large size DATA frame that exceeds SETTINGS_MAX_FRAME_SIZE).
[0.3.0] - 2026-04-15
Third review pass + h2spec interop; behaviour-visible spec fixes across the whole state machine. No breaking API change, but callers that matched on specific error atoms may see different values on edge cases (ALPN, ENABLE_PUSH, IWS).
Added
- External interop suite
test/h2_interop_SUITE.erldrives the server from h2spec. Six groups: TLS generic/HPACK, h2c plaintext generic/HPACK, small-window (forced flow-control fragmentation),--strictmode. 146/146 generic+HPACK cases pass. Skips cleanly whenh2specis not on$PATH. .github/workflows/interop.yml: CI runs h2spec v2.6.0 on every push/PR; logs uploaded on failure..github/workflows/ci.yml: now runsh2_compliance_SUITEin addition to eunit; CT logs uploaded on failure.- PING/RST_STREAM flood mitigation (RFC 9113 §10.5): per-second counters per connection, GOAWAY(ENHANCE_YOUR_CALM) on overflow.
- Extended CONNECT (RFC 8441): server opt-in via
h2:start_server(Port, #{enable_connect_protocol => true, ...})advertisesSETTINGS_ENABLE_CONNECT_PROTOCOL=1. Client usesh2:request(Conn, Headers, #{protocol => <<"websocket">>}). New errors:{error, extended_connect_disabled},{error, extended_connect_method}. Inbound: server rejects:protocolwith streamPROTOCOL_ERRORif it has not opted in. h2:start_serverhonorstransport => tcp(cleartext h2c prior-knowledge listener with gen_tcp acceptor pool).h2:connect/3top-levelverifyandcacertsoptions merged into SSL options.- Owner event
{h2, Conn, {informational, StreamId, Status, Headers}}for 1xx interim responses (excluding 101). - Send-side header validation runs before HPACK encode on
send_request,send_request_headers,send_response, andsend_trailers. - README: "Using with Ranch" and "Coexisting with HTTP/1.1" sections with code sketches.
Changed
SETTINGS_ENABLE_PUSHdefault is now 0 (was 1). Server advertises 0; inbound PUSH_PROMISE on either side is a connection PROTOCOL_ERROR (RFC 9113 §6.5.2 / §8.4).- TLS
connect/2,3requires ALPNh2. Previously fell through silently onprotocol_not_negotiated; now returns{error, alpn_not_negotiated}(§3.3). - Connection-level receive window fixed at 65535 regardless of
SETTINGS_INITIAL_WINDOW_SIZE(§6.9.2). IWS now only adjusts stream windows. - Request builder no longer injects
:authority = ""when the caller omitshost. Non-CONNECT requests without host now send no:authority; CONNECT without host returns{error, missing_authority}(§8.3.1). - Trailers from the peer transition the stream to
half_closed_remote(wasclosed), so a handler mid-response isn't surprised byinvalid_stream_statewhen the peer pipelines body+trailers (§5.1). closingstate proactively closes the TCP/TLS socket on entry (§5.4) instead of waiting up to 5 s for the peer.- Closed-stream error classification: closed-reason retained in a compact id → reason side map bounded at 10 000 entries. Late DATA/HEADERS on a recently-closed stream is scoped exactly (connection vs stream) regardless of whether the full record has been evicted from the 100-entry window.
- Owner event
{h2, Conn, {goaway, LastStreamId}}is now{goaway, LastStreamId, ErrorCode}. - Per-stream events (
data,trailers,stream_reset) routed to the registered stream handler when set; connection owner receives them only as fallback. - HEADERS whose encoded block exceeds peer
SETTINGS_MAX_FRAME_SIZEare split into HEADERS + CONTINUATION chain (§4.2). - Body-less responses (HEAD / 204 / 304) emit a trailing
{data, Sid, <<>>, true}event so callers waiting for end-of-stream don't hang. - HPACK: Huffman encode/decode tables precomputed once via
persistent_term+-on_load; dynamic table caches length and uses a singlelists:reverseon eviction.
Fixed
- HEADERS/DATA on a stream closed via END_STREAM: connection STREAM_CLOSED (was stream-scoped RST) (§5.1).
- HEADERS/DATA on a stream closed via RST_STREAM: stream-scoped STREAM_CLOSED (was connection-scoped).
- CONTINUATION without an outstanding pending HEADERS: connection PROTOCOL_ERROR (§6.10).
- PRIORITY self-dependency on both the PRIORITY frame and inline HEADERS priority: stream PROTOCOL_ERROR (§5.3.1).
- PRIORITY frame with length != 5: stream FRAME_SIZE_ERROR (was connection) (§6.3).
- WINDOW_UPDATE on an idle stream: connection PROTOCOL_ERROR (was silently ignored) (§5.1).
- Unknown frame types: ignored per §4.1 (previously function_clause-crashed the connection).
- Inbound
MAX_CONCURRENT_STREAMSenforced: peer HEADERS over our advertised limit now getRST_STREAM(REFUSED_STREAM)(§5.1.2). SETTINGS_ENABLE_PUSH=1received as client: connection PROTOCOL_ERROR (§6.5.2).SETTINGS_INITIAL_WINDOW_SIZEabove 2³¹−1: connection FLOW_CONTROL_ERROR (was PROTOCOL_ERROR) (§6.9.2).- Body-less responses (HEAD/204/304): accept
content-length > 0withEND_STREAM(RFC 9110 §9.3.2); reject whenEND_STREAMis absent on the header block. in_closed_stream_rangewas asymmetric in client mode (only matched peer-initiated ids); now mirrors server mode.- HPACK decoder: truncated literal returns
{error, incomplete_string}instead offunction_clause. - 1xx interim responses with
END_STREAMorContent-Length→ streamPROTOCOL_ERROR(§8.1, RFC 9110 §15.2). - CONNECT tunnel flag no longer pre-set on the request; the stream becomes a tunnel only when the 2xx response is sent/received. Non-2xx CONNECT responses permit trailers and enforce body rules (RFC 7540 §8.3).
:authoritycontaining userinfo (user@host) rejected withPROTOCOL_ERRORon both inbound and outbound paths (§8.3.1);check_authority_hostruns for CONNECT requests too.- Extended CONNECT
:protocolvalue validated as an RFC 7230 token (RFC 8441 §4). :schemepseudo-header follows the actual transport (httpon TCP,httpson TLS).:method = CONNECToutbound: trailers rejected with{error, tunnel_no_trailers}.WINDOW_UPDATEwith increment 0 on a non-zero stream → stream RST_STREAM(PROTOCOL_ERROR) (§6.9.1).:statusparsing: malformed values trigger streamPROTOCOL_ERRORinstead of crashing the gen_statem.:status = 101rejected on both send and receive (§8.6).- Padding counted against receive flow control (§6.1). Connection-level receive window consumed on DATA for closed or unknown streams (§5.1).
Content-Lengthenforcement (§8.1.1): duplicate/mismatched/non-numeric/negative values →PROTOCOL_ERROR; DATA overshoot or END_STREAM mismatch → stream RST.- Server-side request trailers: trailing HEADERS without END_STREAM →
PROTOCOL_ERROR. - Field name validation tightened to RFC 7230
tchar(rejects SP, HTAB, colon in regular headers, other controls, DEL, non-ASCII). Field values: leading/trailing SP/HTAB rejected in addition to NUL/CR/LF. SETTINGS_MAX_HEADER_LIST_SIZEenforced in both directions: outbound exceed →{error, header_list_too_large}; inbound exceed → streamPROTOCOL_ERROR.
Docs
docs/features.md: PRIORITY metadata is parsed and self-dep rejected, but no scheduler is implemented (RFC 9218 supersedes RFC 7540 priorities).
[0.2.0] - 2026-04-13
First usable release with a full connection-layer client and server. Previous 0.1.0 shipped only frame/HPACK primitives.
Added
- Public API module
h2with client and server entry points aligned withquic_h3:h2:connect/2,3,h2:wait_connected/1,2h2:request/2,3,4,5,h2:send_data/3,4,h2:send_trailers/3h2:start_server/2,3,h2:stop_server/1,h2:server_port/1h2:send_response/4,h2:cancel/2,3,h2:cancel_stream/2,3h2:set_stream_handler/3,4,h2:unset_stream_handler/2h2:goaway/1,2,h2:close/1,h2:controlling_process/2h2:get_settings/1,h2:get_peer_settings/1
h2_connectiongen_statem implementing the full stream state machine (RFC 7540 §5.1) with flow control, SETTINGS negotiation, two-phase GOAWAY, and CONTINUATION handling.- CONNECT tunnel mode (RFC 7540 §8.3): bidirectional byte tunnels over a stream, half-close semantics, trailers rejection,
Content-Length/Transfer-Encodingrejection on 2xx. h2_serverwith TLS acceptor pool and ALPNh2negotiation.- Owner-process event messages:
{h2, Conn, {response, ...}},{data, ...},{trailers, ...},{stream_reset, ...},{goaway, ...},closed,connected. - Compliance test suite (Common Test) with 32 cases covering protocol conformance, API parity, and tunnel mode.
Changed
h2_hpack: persistent_term-backed precomputed Huffman encode tuple and sorted decode table; static table lookups viaelement/2(O(1)); dynamic table caches length and evicts with a singlelists:reverse.h2_connection: cachedpeer_max_frame_size,peer_initial_window_size,peer_max_concurrent_streamson the state record; CONTINUATION header buffer usesiodata()(flattened once on END_HEADERS) instead of per-frame binary concatenation.- Frame decoder (
h2_frame:decode/2) takesMaxFrameSizeand enforces per-type stream-id 0 rules (DATA/HEADERS/PRIORITY/RST_STREAM/PUSH_PROMISE/CONTINUATION reject 0; SETTINGS/PING/GOAWAY require 0).
Fixed
- RFC 7540/7541 compliance gaps:
- Padding bound in DATA/HEADERS frames now accepts
PadLength == byte_size(Rest). WINDOW_UPDATEincrement 0: stream-level RST_STREAM / connection-level GOAWAY withPROTOCOL_ERROR.SETTINGS_INITIAL_WINDOW_SIZEchange that overflows any open stream → connectionFLOW_CONTROL_ERROR.- Pseudo-header validation: order, lowercase header names, connection-specific headers rejected,
:path/:authoritychecks. - CONTINUATION interleaving on a different stream →
PROTOCOL_ERROR. - RST_STREAM on an idle stream →
PROTOCOL_ERROR. - HEADERS on a half-closed-remote or closed stream →
STREAM_CLOSED. - HPACK: dynamic-table size update larger than peer-advertised max →
COMPRESSION_ERROR; size update after a non-size-update representation →COMPRESSION_ERROR. - Huffman decoder: EOS symbol in the middle of a string rejected; padding must be < 8 bits of all-ones.
- Padding bound in DATA/HEADERS frames now accepts
- Body duplication when
send_datasplit buffers across frames. - Connection owner notified with
closedon termination. - CT suite flakiness: acceptor socket defaulted to active mode, losing data between
ssl:handshakereturn andsetopts({active, false}).
[0.1.0]
Initial release. Low-level HTTP/2 primitives (frames, HPACK, settings, capsule, varint).