nquic (nquic v1.0.0)
View SourceQUIC transport library for Erlang/OTP, implementing RFC 9000 (transport), RFC 9001 (TLS binding), RFC 9002 (loss detection and congestion control), and RFC 9221 (unreliable datagrams).
This module is the umbrella API: client connect, server listen and
accept, and listener lifecycle and metrics. connect/3 and
accept/1,2 return an opaque ctx/0 connection handle; all
post-handshake traffic (streams, datagrams, close, timers) is driven
through nquic_lib, which threads the updated context through each
call. Closed-form error values are defined in nquic_error.
Connection model
nquic owns exactly one process per connection: the handshake driver.
When the handshake completes the connection (a ctx/0 value) is
handed to the caller of connect/3 or accept/1,2 and the driver
exits. From that point the owning process is the connection: it
drives the protocol core directly through nquic_lib by function
call against the context, with no message hop and no nquic-owned
process. The owner pulls data when ready
(nquic_lib:recv/2, nquic_lib:recv_pending/1); there is no
pushed-message envelope and no ownership-reassignment call. To serve
a connection from a dedicated worker, call accept/1,2 (or
connect/3) from that worker. This is the only connection model;
read the owner-liveness contract below before writing one.
Owner-liveness contract
There is no per-connection nquic process after the handshake, so
nothing services the connection unless the owner's loop does.
ACKs, PTO, loss detection, and the idle timer all fire only when the
owner calls back into nquic_lib. An owner that blocks, or that
reads streams without also draining inbound packets and timer
expiries, will silently stall the connection: the peer times it out
while the owner waits forever.
The contract the owner MUST satisfy, in one loop:
- ingest every inbound packet:
nquic_lib:handle_packet/3for the dispatched server path ({packet, Source, Bin}messages from the listener's receiver) ornquic_lib:recv_direct/1for a connected or client-owned socket; - service every
{quic_timeout, Type}message vianquic_lib:timeout/2(this is what drives ACK/PTO/idle); nquic_lib:flush/1after each of the above so queued frames reach the wire;- observe
{quic_drain, Listener}(the listener cascade-stop signal) and close.owner_loop(Ctx) -> Socket = nquic_lib:ctx_socket(Ctx), receive {'$socket', Socket, select, _Info} -> after_io(nquic_lib:recv_direct(Ctx)); {packet, Source, Bin} -> after_io(nquic_lib:handle_packet(Ctx, Source, Bin)); {quic_timeout, Type} -> after_io(nquic_lib:timeout(Ctx, Type)); {quic_drain, _Listener} -> {ok, _Ctx1} = nquic_lib:close(Ctx) end. after_io({ok, Events, Ctx1}) -> {ok, Ctx2} = nquic_lib:flush(Ctx1), owner_loop(handle_events(Events, Ctx2)); after_io({error, _Reason, _Ctx1}) -> ok.
nquic_lib:recv_direct/1 folds packet ingest and timer servicing
into a single blocking call (it handles {packet, _},
{quic_timeout, _}, and the connected-socket select cycle
internally), so a connected/client owner can collapse steps 1-2 to
recv_direct/1; it still does not observe {quic_drain, _}, which
the owner handles itself.
Quick start: client
{ok, Ctx0} = nquic:connect("example.com", 443,
#{tls => #{alpn => [<<"h3">>]}}),
{ok, Sid, Ctx1} = nquic_lib:open_stream(Ctx0, #{type => bidi}),
{ok, Ctx2} = nquic_lib:send_fin(Ctx1, Sid,
<<"GET / HTTP/1.1\\r\\n\\r\\n">>),
{ok, Body, fin, Ctx3} = nquic_lib:recv(Ctx2, Sid),
{ok, _Ctx4} = nquic_lib:close(Ctx3).connect/3 blocks until the handshake completes (or timeout
elapses) and returns {ok, Ctx}. The context cannot exist before
the handshake, so nowait => true is rejected with
{error, {opts, ctx_requires_wait}}.
Quick start: server
{ok, Listener} = nquic:listen(4433, #{
tls => #{certfile => "server.pem",
keyfile => "server.key",
alpn => [<<"h3">>]}
}),
{ok, Ctx0} = nquic:accept(Listener),
{ok, Ctx1} = nquic_lib:takeover(Ctx0),
{ok, Events, Ctx2} = nquic_lib:recv_pending(Ctx1).The listener owns one or more UDP sockets (set receivers => N to
fan packets across schedulers with SO_REUSEPORT) and a striped
dispatch table that routes datagrams to existing connections without
spawning a process per packet. An accepted context stays on the
listener's dispatch path; the owner calls nquic_lib:takeover/1 to
re-target the connection's CIDs to itself and then drives it through
nquic_lib. Listener-wide observability is exported through
metrics/1.
Listener lifecycle
listen/2 starts a supervision tree with supervisor:start_link/2,
so the listener is linked to the calling process. If that process
dies, the listener tree comes down with it; if the listener tree
crashes abnormally, the link propagates to the owner. This is the
intended contract: tie listener lifetime to an owning process.
Stop a listener explicitly with stop_listener/1,2. It is
synchronous, idempotent, and exits the listener supervisor with
reason normal, so the owner link never turns into a kill. cascade
(the default) stops accepting, signals every owner-held established
connection to close (a {quic_drain, Listener} message the owner
loop observes), then tears down the listener tree and frees the port;
detach stops accepting and frees the port but sends no drain signal,
leaving established connections to run until their own idle timeout. A
blocked accept/1,2 on a stopped listener returns {error, closed}.
Send and backpressure
A stream send is bounded by two windows: the connection's MAX_DATA
and the stream's MAX_STREAM_DATA credit, both granted by the peer.
nquic_lib:send/3 / nquic_lib:send_fin/3 admit as much as the
windows allow and return the updated context; the owner's recv loop
processes inbound MAX_DATA / MAX_STREAM_DATA frames that reopen
the window and resumes parked stream writes.
nquic_lib:is_writable/2 is a point-in-time probe the owner can
check between recv turns: a false does not guarantee the next send
fails, and a true can be stale before the owner acts on it.
Datagrams
RFC 9221 unreliable datagrams are supported when both peers
negotiate max_datagram_frame_size. Datagrams are not retransmitted
and are not flow-controlled. Send via nquic_lib:send_datagram/2;
receive via nquic_lib:recv_datagram/1.
Session caching and 0-RTT
The optional session_cache and token_cache options on
connect/3 opt into TLS 1.3 session resumption and address
validation tokens. The server exposes the same surface via the
replay_protection and new_token listen options. See
nquic_session_cache for the cache contract and
nquic_zero_rtt for early-data handling on the server.
Error handling
Every public function returns {ok, _} | {error, t:error_reason/0}.
error_reason/0 is the closed taxonomy defined in nquic_error,
covering closed, {timeout, Phase}, {transport, _}, {tls, _},
{application, Code, Reason}, {protocol, _}, {flow_control, _},
{opts, _}, {connect, _}, and {listen, _}. Use
nquic_error:category/1, nquic_error:is_retryable/1, and
nquic_error:format/1 to interrogate values without pattern-matching
on the inner shape.
Summary
Types
Options accepted by accept/2.
Client TLS submap (connect/3). All keys optional: a client
presents no server certificate, so certfile/keyfile are absent.
Options for close/2.
Public connection-info map returned by nquic_conn:info/1. The shape
is intentionally a plain map (not a record) so the library can evolve
fields without breaking callers.
Options for connect/3. TLS, transport, and congestion-control
tuning live in the optional tls / transport / cc submaps.
Remaining keys are connection-level policy and session resumption.
Closed error taxonomy returned by every public function. Use
nquic_error:category/1, nquic_error:is_retryable/1, and
nquic_error:format/1 to interrogate values.
Options for listen/2. Endpoint TLS credentials live in the
required tls submap; transport and congestion-control tuning in
the optional transport / cc submaps. Remaining keys are
listener-level policy.
Per-listener metrics snapshot returned by metrics/1. The listener's
atomic counters are monotonic; consumers compute deltas themselves.
Options for stop_listener/2.
Options for open_stream/2 and nquic_lib:open_stream/2. Defaults
to a bidirectional stream if omitted entirely.
Server TLS / credentials submap (listen/2). certfile and
keyfile are required; the rest tune verification and negotiation.
Functions
Accept an incoming QUIC connection with default options
(timeout => infinity).
Accept an incoming QUIC connection.
Blocks until a client connects and the handshake completes, or until
timeout expires, then returns the connection as an opaque
ctx/0. The owning process drives it through nquic_lib,
starting with nquic_lib:takeover/1.
Connect to a QUIC server.
Host and Port identify the endpoint; Opts carries everything
else: timeout (default infinity) and the tls / transport /
cc tuning submaps (e.g.
#{tls => #{alpn => [<<"h3">>], verify => verify_peer}}). See
connect_opts/0.
Blocks until the handshake completes (or timeout elapses) and
returns the connection as an opaque ctx/0, driven through
nquic_lib. The context cannot exist before the handshake, so
nowait => true is rejected with
{error, {opts, ctx_requires_wait}}.
Return the UDP port the listener is bound to.
Useful when the listener was started with port 0 (let the kernel
pick a free port) and the caller needs the actual port to advertise.
Start a QUIC listener bound to Port.
Opts requires a tls submap carrying at least certfile and
keyfile (e.g. #{tls => #{certfile => C, keyfile => K}}).
Transport and congestion-control tuning live in the optional
transport / cc submaps. See listen_opts/0 for the full set.
Snapshot of the listener-wide observability counters. Counters are
monotonic; consumers compute deltas between snapshots themselves. The
udp_rcvbuf_errs field is a delta against the kernel value seen at
listener start on Linux; on other platforms it is always 0.
Stop a listener returned by listen/2.
Cascade shutdown: stops accepting, broadcasts {quic_drain, Listener}
to every owner-held established connection so each closes gracefully,
then closes the listen sockets, releases the port, and tears down the
listener tree (receivers and dispatch). The drain message is delivered
to the connection's owner process; the reference owner loop closes the
connection and exits on it.
Idempotent: stopping an already-stopped listener returns ok. Safe
to call from any process,
including one that did not open the listener; the owner's link is
never turned into an exit. See stop_listener/2 for the detach
variant and the lifecycle contract.
Stop a listener with options.
Types
-type accept_opts() :: #{timeout => timeout()}.
Options accepted by accept/2.
-type cc_opts() :: #{algo => newreno | cubic, slow_start => standard | hystart_plus_plus}.
Congestion-control submap shared by listen/2 and connect/3.
algo selects the controller; slow_start selects the start-up
phase behaviour.
-type cipher_suite() :: aes_128_gcm | aes_256_gcm | chacha20_poly1305.
-type client_tls_opts() :: #{cacertfile => file:filename(), cacerts => [binary()], verify => verify_mode(), cipher_suites => [cipher_suite()], alpn => [binary()]}.
Client TLS submap (connect/3). All keys optional: a client
presents no server certificate, so certfile/keyfile are absent.
-type close_opts() :: #{scope => transport | application, error_code => non_neg_integer(), reason => binary()}.
Options for close/2.
scope(defaulttransport) -transportemits CONNECTION_CLOSE type 0x1c,applicationemits type 0x1d (RFC 9000 §19.19).error_code(default0) - protocol or application error code.reason(default<<>>) - operator-visible reason phrase.
-type conn_info() :: #{state := initial | handshake | established | draining | closed, role := client | server, scid := binary(), dcid := binary(), rtt := #{smoothed_rtt := non_neg_integer(), min_rtt := non_neg_integer(), latest_rtt := non_neg_integer(), rttvar := non_neg_integer()}, cwnd := non_neg_integer(), bytes_in_flight := non_neg_integer(), streams_open := non_neg_integer(), data_sent := non_neg_integer(), data_received := non_neg_integer(), _ => _}.
Public connection-info map returned by nquic_conn:info/1. The shape
is intentionally a plain map (not a record) so the library can evolve
fields without breaking callers.
-type connect_opts() :: #{timeout => timeout(), tls => client_tls_opts(), transport => transport_opts(), cc => cc_opts(), qlog => nquic_qlog:backend_config(), idle_timeout => timeout(), nowait => boolean(), version => non_neg_integer(), session_ticket => map(), session_cache => false | atom() | {module, module()}, token_cache => false | atom() | {module, module()}, client_token => binary(), proactive_cids => boolean()}.
Options for connect/3. TLS, transport, and congestion-control
tuning live in the optional tls / transport / cc submaps.
Remaining keys are connection-level policy and session resumption.
-type connection_id() :: binary().
-type ctx() :: nquic_ctx:t().
-type error_code() :: non_neg_integer().
-type error_reason() :: nquic_error:error_reason().
Closed error taxonomy returned by every public function. Use
nquic_error:category/1, nquic_error:is_retryable/1, and
nquic_error:format/1 to interrogate values.
Outer arms:
closed- connection has been closed locally or by the peer.{timeout, Phase}-Phase :: handshake | idle | recv | send | accept.{transport, Reason}- peer-sent CONNECTIONCLOSE (0x1c) or local transport-layer I/O failure (`{posix, }` payload).{tls, Reason}- TLS alert or local handshake-side validation error.{application, Code, Reason}- peer-sent CONNECTION_CLOSE (0x1d).{protocol, Reason}- wire-format or RFC 9000 violation detected locally (frame encoding, transport parameters, packet integrity, ...).{flow_control, Reason}- local flow or congestion blocking (eagain,congestion_control_blocked,partial_send, ...).{opts, Reason}- options / configuration / API misuse.{connect, Posix}- DNS or client-side connect failure.{listen, Posix}- server-side bind failure.
Example:
case nquic:connect(Host, Port, #{tls => #{alpn => [<<"h3">>]}}) of
{ok, Conn} -> ok;
{error, {timeout, handshake}} -> retry;
{error, {tls, _}} -> bad_tls;
{error, {connect, _}} -> network;
{error, _} -> giveup
end.
-type listen_opts() :: #{tls := tls_opts(), transport => transport_opts(), cc => cc_opts(), qlog => nquic_qlog:backend_config(), receivers => pos_integer(), idle_timeout => timeout(), max_new_conns_per_sec => non_neg_integer(), max_accept_queue => non_neg_integer(), retry => boolean(), retry_token_lifetime => pos_integer(), new_token => boolean(), new_token_lifetime => pos_integer(), spin_bit => boolean(), version => non_neg_integer(), version_preference => [non_neg_integer()], replay_protection => module(), server_per_conn_fd => boolean(), conn_handler => module(), conn_handler_opts => term()}.
Options for listen/2. Endpoint TLS credentials live in the
required tls submap; transport and congestion-control tuning in
the optional transport / cc submaps. Remaining keys are
listener-level policy.
-opaque listener()
-type listener_metrics() :: #{packets_in := non_neg_integer(), packets_dropped_mailbox := non_neg_integer(), packets_dropped_ratelimit := non_neg_integer(), conns_established := non_neg_integer(), conns_closed_normal := non_neg_integer(), conns_closed_idle_timeout := non_neg_integer(), conns_closed_peer := non_neg_integer(), conns_closed_protocol_error := non_neg_integer(), handshakes_inflight := integer(), accept_queue_depth := integer(), udp_rcvbuf_errs := non_neg_integer(), uptime_ms := non_neg_integer()}.
Per-listener metrics snapshot returned by metrics/1. The listener's
atomic counters are monotonic; consumers compute deltas themselves.
-type stop_listener_opts() :: #{mode => cascade | detach, timeout => timeout()}.
Options for stop_listener/2.
mode(defaultcascade) -cascadestops accepting, broadcasts a{quic_drain, Listener}close signal to every owner-held established connection, then tears the whole listener tree down (port released, handshake-phase processes terminated);detachstops accepting and frees the port but sends no drain signal, leaving established connections running until their own idle timeout.timeout(default5000) - milliseconds to wait for a gracefulcascadeshutdown before the supervisor is brutally killed.
-type stream_id() :: non_neg_integer().
-type stream_opts() :: #{type => bidi | uni}.
Options for open_stream/2 and nquic_lib:open_stream/2. Defaults
to a bidirectional stream if omitted entirely.
-type tls_opts() :: #{certfile := file:filename(), keyfile := file:filename(), cacertfile => file:filename(), cacerts => [binary()], verify => verify_mode(), cipher_suites => [cipher_suite()], alpn => [binary()]}.
Server TLS / credentials submap (listen/2). certfile and
keyfile are required; the rest tune verification and negotiation.
-type transport_opts() :: #{gso => boolean() | pos_integer(), gro => boolean(), pacing => boolean(), pacing_factor => number(), pacing_burst => pos_integer(), max_payload_size => pos_integer(), send_buffer => pos_integer(), send_timeout => timeout()}.
Transport tuning submap shared by listen/2 and connect/3:
offload (GSO/GRO), pacing, datagram sizing, socket buffering.
-type verify_mode() :: verify_none | verify_peer.
Functions
-spec accept(listener()) -> {ok, ctx()} | {error, error_reason()}.
Accept an incoming QUIC connection with default options
(timeout => infinity).
-spec accept(listener(), accept_opts()) -> {ok, ctx()} | {error, error_reason()}.
Accept an incoming QUIC connection.
Blocks until a client connects and the handshake completes, or until
timeout expires, then returns the connection as an opaque
ctx/0. The owning process drives it through nquic_lib,
starting with nquic_lib:takeover/1.
-spec connect(inet:hostname() | inet:ip_address(), inet:port_number(), connect_opts()) -> {ok, ctx()} | {error, error_reason()}.
Connect to a QUIC server.
Host and Port identify the endpoint; Opts carries everything
else: timeout (default infinity) and the tls / transport /
cc tuning submaps (e.g.
#{tls => #{alpn => [<<"h3">>], verify => verify_peer}}). See
connect_opts/0.
Blocks until the handshake completes (or timeout elapses) and
returns the connection as an opaque ctx/0, driven through
nquic_lib. The context cannot exist before the handshake, so
nowait => true is rejected with
{error, {opts, ctx_requires_wait}}.
-spec get_port(listener()) -> {ok, inet:port_number()} | {error, error_reason()}.
Return the UDP port the listener is bound to.
Useful when the listener was started with port 0 (let the kernel
pick a free port) and the caller needs the actual port to advertise.
-spec listen(inet:port_number(), listen_opts()) -> {ok, listener()} | {error, error_reason()}.
Start a QUIC listener bound to Port.
Opts requires a tls submap carrying at least certfile and
keyfile (e.g. #{tls => #{certfile => C, keyfile => K}}).
Transport and congestion-control tuning live in the optional
transport / cc submaps. See listen_opts/0 for the full set.
Returns
{ok, Listener}on success{error, Reason}on failure
See Also
-spec metrics(listener()) -> {ok, listener_metrics()} | {error, error_reason()}.
Snapshot of the listener-wide observability counters. Counters are
monotonic; consumers compute deltas between snapshots themselves. The
udp_rcvbuf_errs field is a delta against the kernel value seen at
listener start on Linux; on other platforms it is always 0.
-spec stop_listener(listener()) -> ok.
Stop a listener returned by listen/2.
Cascade shutdown: stops accepting, broadcasts {quic_drain, Listener}
to every owner-held established connection so each closes gracefully,
then closes the listen sockets, releases the port, and tears down the
listener tree (receivers and dispatch). The drain message is delivered
to the connection's owner process; the reference owner loop closes the
connection and exits on it.
Idempotent: stopping an already-stopped listener returns ok. Safe
to call from any process,
including one that did not open the listener; the owner's link is
never turned into an exit. See stop_listener/2 for the detach
variant and the lifecycle contract.
-spec stop_listener(listener(), stop_listener_opts()) -> ok.
Stop a listener with options.
nquic:stop_listener(L, #{mode => detach, timeout => 10000}).mode => cascade (default) stops accepting, drains every owner-held
established connection via a {quic_drain, Listener} signal, then
tears the whole tree down; mode => detach stops accepting and frees
the port but sends no drain signal, leaving established connections
running until their own idle timeout. timeout (default 5000 ms)
bounds a graceful cascade before the supervisor is brutally killed.
See stop_listener_opts/0.