Changelog
View SourceAll notable changes to h2 are documented here. This project follows Semantic Versioning.
[Unreleased]
[0.10.1] - 2026-06-13
Documentation
- Documented gRPC bidirectional streaming in the README and the
h2module docs: per-stream handlers, half-close, receive backpressure (consume/3), blocking send (send_data/5), and cancel / goaway / closed delivery to the handler. - Fixed ex_doc autolink warnings for private functions referenced in the changelog so the docs build clean.
- Synced the README install snippet to the current version.
[0.10.0] - 2026-06-13
Added
- gRPC bidirectional streaming support. A dedicated per-call process can own a
single stream's events without owning the connection, while many calls
multiplex one connection.
- Per-stream event routing now covers every event type.
set_stream_handler/3routes{response,...},{data,...},{trailers,...},{informational,...}and{stream_reset,...}to the handler, and replays in arrival order any events buffered before registration (previously only DATA was buffered, and response/trailers went only to the owner).h2:request(Conn, Headers, #{handler => Pid})' sets the handler at stream creation to avoid the registration race. - Receive-side backpressure:#{flow_control => manual}' stops auto-replenishing the stream receive window on dispatch;h2:consume(Conn, StreamId, N)' sends the WINDOW_UPDATE after the consumer has processed N bytes, bounding a slow consumer to one window instead of an unbounded mailbox. - Send-side backpressure:h2:send_data/5' with#{block => Timeout}' blocks until the peer's window accepts the data (ok') or the deadline passes ({error, timeout}'). The default non-blocking path still returns{error, send_buffer_full}' once the per-stream buffer cap is reached. - Teardown is delivered per stream: a stream handler now also receives
{goaway, Last, Code}' and{closed, Reason}', so a bidi call process learns its connection is going away.cancel/2,3' RST_STREAM continues to reach the peer handler as{stream_reset, StreamId, Code}'. - All additions are opt-in; default streams and the existing client/server and WebSocket-over-h2 (extended CONNECT) APIs are unchanged.
- Interop coverage in both directions: a real gRPC client (grpcurl) against an h2-hosted echo service, and our h2 client against a real grpc-python echo server.
- Per-stream event routing now covers every event type.
Fixed
send_trailers/3no longer lets the trailers (END_STREAM) overtake DATA still buffered behind a closed flow-control window. The trailers are now queued and emitted once the send buffer drains, so asend_datathensend_trailerssequence under backpressure (e.g. a gRPC server response) cannot drop the tail of the body. Surfaced by the small-window bidi stress test.
[0.9.0] - 2026-06-06
Changed
- A response's frames are written to the socket in one
Transport:sendinstead of one send per frame.flush_stream_one_chunk/2now stages every flow-control-ready DATA frame (chunked topeer_max_frame_size, bounded by a 1 MiB coalescing cap) into a single write, and therespond/5fast path writes[HEADERS | DATA...]in one go. A 100 KiB TLS response drops from 8 socket writes to 1, cutting the per-frame gen_statem round-trips and TLS-record AEAD encryptions; large-body throughput roughly doubles (~37k -> ~65k req/s on h2load-c64 -m32over TLS). Flow control, framing and the public API are unchanged; multi-megabyte bodies still yield between cap-sized batches. - Active stream counts are maintained incrementally instead of folding the whole
stream map on every new stream.
count_peer_active_streams/2(checked per inbound HEADERS) andcount_active_streams/1(checked per outbound request) are now O(1) reads of counters kept in sync byput_stream/3, removing an O(n^2) cost under stream churn. About 13% h2load throughput gain at c=100 m=100 over h2c. No behaviour change.
[0.8.0] - 2026-06-03
Added
h2:respond/5sends a complete response (status, headers and body) in one call and one coalesced socket write (HEADERS plus DATA), instead of the two round-trips ofsend_response/4followed bysend_data/4. It falls back to the granular path when the response cannot be coalesced (oversized headers or body, CONNECT tunnels). The existing send functions are unchanged.backlogserver option (default 1024) sizes the listen queue.
Fixed
- Connection collapse under concurrent load. The server dropped responses for
requests pipelined before the client's SETTINGS-ACK (legal per RFC 9113): a
handler's
send_response/send_datawas rejected while the connection was still in thesettingsstate, so fast clients (h2load, browsers) lost whole connections under load. The server now serves while in thesettingsstate. - Client stream leak. Response HEADERS without END_STREAM reset a
half_closed_localstream back toopen, so the final DATA reached onlyhalf_closed_remoteand completed streams never closed, eventually exhaustingSETTINGS_MAX_CONCURRENT_STREAMS.
Changed
- HPACK encoder static-table lookup is an O(1) precomputed map, and the dynamic
table is a map keyed by insertion sequence for O(1) indexed lookup, insert and
eviction (was
lists:nth/2). - HPACK Huffman decoding is a table-driven 8-bit state machine (one tuple lookup per input byte); cold header decode is about 9x faster.
- DATA frames are sent as iodata without copying the body.
[0.7.0] - 2026-06-02
Added
- Listeners can bind a specific address or family.
start_server/2,3acceptip => inet:ip_address()(an 8-tuple selects IPv6) andinet6 => boolean()(bind the IPv6 wildcard::) for both thesslandtcptransports.
[0.6.1] - 2026-05-28
Changed
- OTP 29 compatibility: replaced every deprecated old-style
catch Exprinsrc/withtry ... catch ... end. Fire-and-forget cleanup calls now go through a privateignore_errors/1helper; the--port/--timeout/URL-port argument parsers usetry. No behaviour change. The build is clean underwarnings_as_errorson OTP 29. - CI now runs on OTP 29.0.
[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. The
h2_serverescript'ssafe_pathhelper usesfilelib: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.- The
h2_listeneraccept loop and theh2server connection looplogger: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).