nquic (nquic v1.0.0)

View Source

QUIC 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:

  1. ingest every inbound packet: nquic_lib:handle_packet/3 for the dispatched server path ({packet, Source, Bin} messages from the listener's receiver) or nquic_lib:recv_direct/1 for a connected or client-owned socket;
  2. service every {quic_timeout, Type} message via nquic_lib:timeout/2 (this is what drives ACK/PTO/idle);
  3. nquic_lib:flush/1 after each of the above so queued frames reach the wire;
  4. 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.

Congestion-control submap shared by listen/2 and connect/3. algo selects the controller; slow_start selects the start-up phase behaviour.

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 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.

Transport tuning submap shared by listen/2 and connect/3: offload (GSO/GRO), pacing, datagram sizing, socket buffering.

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

accept_opts()

-type accept_opts() :: #{timeout => timeout()}.

Options accepted by accept/2.

cc_opts()

-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.

cipher_suite()

-type cipher_suite() :: aes_128_gcm | aes_256_gcm | chacha20_poly1305.

client_tls_opts()

-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.

close_opts()

-type close_opts() ::
          #{scope => transport | application, error_code => non_neg_integer(), reason => binary()}.

Options for close/2.

  • scope (default transport) - transport emits CONNECTION_CLOSE type 0x1c, application emits type 0x1d (RFC 9000 §19.19).
  • error_code (default 0) - protocol or application error code.
  • reason (default <<>>) - operator-visible reason phrase.

conn_info()

-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.

connect_opts()

-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.

connection_id()

-type connection_id() :: binary().

ctx()

-type ctx() :: nquic_ctx:t().

error_code()

-type error_code() :: non_neg_integer().

error_reason()

-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.

listen_opts()

-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.

listener()

-opaque listener()

listener_metrics()

-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.

stop_listener_opts()

-type stop_listener_opts() :: #{mode => cascade | detach, timeout => timeout()}.

Options for stop_listener/2.

  • mode (default cascade) - cascade stops 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); detach stops accepting and frees the port but sends no drain signal, leaving established connections running until their own idle timeout.
  • timeout (default 5000) - milliseconds to wait for a graceful cascade shutdown before the supervisor is brutally killed.

stream_id()

-type stream_id() :: non_neg_integer().

stream_opts()

-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.

tls_opts()

-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.

transport_opts()

-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.

verify_mode()

-type verify_mode() :: verify_none | verify_peer.

Functions

accept(Listener)

-spec accept(listener()) -> {ok, ctx()} | {error, error_reason()}.

Accept an incoming QUIC connection with default options (timeout => infinity).

accept(Listener, Opts)

-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.

connect(Host, Port, Opts)

-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}}.

get_port(Listener)

-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.

listen(Port, Opts)

-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

metrics(Listener)

-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.

stop_listener(Listener)

-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.

stop_listener(Listener, Opts)

-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.