nquic_lib (nquic v1.0.0)

View Source

Library-mode API for nquic.

Library mode exposes the QUIC protocol as pure functions over an opaque nquic:ctx() value. The owning process drives the socket, receives packets, and flushes outgoing data. Compared to gen_statem mode this avoids every gen_statem round trip on the hot path.

Production use requires the caller to understand the protocol carefully; the compiler will not stop you from calling the library functions out of order.

Typical usage

{ok, Ctx}  = nquic:accept(Listener),
{ok, Ctx1} = nquic_lib:takeover(Ctx),
{ok, Ctx2} = nquic_lib:upgrade_to_connected(Ctx1),
loop(Ctx2).

loop(Ctx) ->
    case nquic_lib:recv_and_process(Ctx, 5000) of
        {ok, Events, Ctx1} -> handle(Events), loop(Ctx1);
        {error, _Reason, _} -> ok
    end.

Listener drain

A server connection's owner process is sent {quic_drain, Listener} when its listener is torn down via nquic:stop_listener/1,2 with mode => cascade (the default). The owner SHOULD handle this message by closing the connection (shutdown/1 for a graceful CONNECTION_CLOSE) and terminating. An owner that ignores it is not leaked indefinitely (the connection still closes on its idle timeout), but a prompt cascade depends on owners honouring the signal. Client connections own their socket, have no listener, and never receive it.

Summary

Functions

Buffer protocol events into the context.

Close the connection gracefully (transport scope, error code 0, empty reason).

Close the connection with the given options. scope => transport (default) emits CONNECTION_CLOSE type 0x1c; application emits type 0x1d (RFC 9000 §19.19).

Close a stream.

Flush pending frames into packets and send them. Encrypts queued frames, sends the resulting packets via the socket, and schedules any timer updates.

Flush pending frames without scheduling timers. Use this when the caller will immediately call a recv function that handles timers; avoids redundant cancel_timer / send_after syscalls.

Process an incoming packet. Decrypts, decodes, and handles every frame in the packet. Returns the protocol events and the context with timers scheduled.

Like handle_packet/3 but carries the inbound ECN codepoint.

Process a GRO-coalesced datagram delivered as a single {packet_batch, Source, Buf, GsoSize, ECN} message. Splits the buffer per GsoSize bytes and runs handle_packet/4 on each segment, consolidating events. Schedules timers once at the end.

Like handle_packet_batch/5 but without scheduling timers. For callers that drain many datagrams per wakeup and call schedule_timers/1 once at the end before flushing (the batch sibling of handle_packet_notimers/3). Per-segment decode errors are dropped rather than fatal, mirroring drain_packet_batch/6: an undecryptable packet in a GRO-coalesced buffer must not tear down the connection (RFC 9000 §12.2).

Process an incoming packet without scheduling timers. Use for batch processing: call this once per packet, then call schedule_timers/1 once at the end before flushing.

Handle a {quic_timeout, Type} expiration during the server handshake. Phase is the encryption level the owner is currently driving (initial until it observes {state_transition, handshake}, then handshake). On a PTO this sends the probe at that level; the established timeout/2 (which probes 1-RTT) is wrong before the connection is established. Switch to timeout/2 once connected is observed.

Initiate a client-side key update (RFC 9001 Section 6). Rotates the 1-RTT traffic secrets and flips the key phase so the next sent 1-RTT packet carries the new keys. No frame is emitted and no packet is sent here; the owner's next flush/1 (or any send) carries the rotation, and the peer's reply confirms it. Returns {error, key_update_pending} if a previously initiated update has not yet been confirmed by the peer.

Point-in-time stream writability probe on a library-mode context. Pure projection on the conn_state held inside the nquic:ctx(): returns true when the stream exists, its send side is not terminal, and one byte fits under current connection-flow, stream-flow, and congestion limits. false otherwise (including unknown streams). A false does not guarantee the next send succeeds, and a true can be stale before the caller acts; it is a between-recv-turns probe, not a poll loop.

Open a new stream.

Project path-level statistics from a library-mode context. Pure projection on the conn_state held inside the nquic:ctx(). No message hops, no syscalls. See nquic_conn:path_stats/1 for the field list.

Read available data from a stream buffer. Does not block. Returns {ok, Data, IsFin, Ctx} where IsFin marks end of stream.

Receive and process the next packet or timeout. Waits for either a {packet, Source, Bin} message or a {quic_timeout, Type} timer expiration. Equivalent to recv_and_process(Ctx, infinity).

Receive and process the next packet or timeout, with timeout. Returns {ok, [], Ctx} if no message arrives within Timeout ms.

Receive and process ALL available packets, then schedule timers and flush once. For ctxs upgraded with upgrade_to_connected/1, drains both the mailbox and the kernel queue (via non-blocking socket:recvfrom/3). For dispatched-mode ctxs (sharing the listener socket), drains only the mailbox; polling the shared socket would steal packets routed to sibling connections. Blocks up to Timeout ms for the first packet if none are available. The batch equivalent of recv_direct/1 / recv_and_process/1. Amortises timer scheduling and flushing across many packets.

Like recv_batch/1 with a timeout on the first packet.

Receive a buffered DATAGRAM. Returns the oldest datagram from the buffer, or {error, empty} if none are available. Use buffer_events/2 after handle_packet/3 to move received datagrams into the buffer.

Receive and process the next packet directly from the socket. For connections that called upgrade_to_connected/1, reads packets directly via socket:recvfrom/3 instead of waiting for {packet,...} messages. Also services timer expirations.

Process every buffered {packet, Source, Bin} message in the current process mailbox. Call after takeover/1 and/or upgrade_to_connected/1 to handle packets that arrived during the ownership transition window. Returns accumulated events, or {error, Reason, Ctx} on transport error.

Reset a stream with an error code.

Compute and schedule timer actions for the current protocol state. Call this after a batch of handle_packet_notimers/3 calls and before flush/1. Computes loss detection, idle, and ACK delay timers and schedules them as erlang:send_after messages.

Send data on a stream. Queues the data, flushes pending frames into packets, and writes them to the socket.

Send an unreliable DATAGRAM frame.

Send data with FIN on a stream.

Queue data + FIN on a stream without flushing. Use to batch multiple stream sends into a single subsequent flush/1 call.

Seed a server-side nquic:ctx/0 for an owner that drives the handshake itself, from the first Initial packet's options. Opts is the option map the receiver builds for a new connection (role, socket, peer, dcid/odcid, version, dispatchtable, listener, certs, alpn, static_key, transport params, ...). Builds the same #conn_state{} as nquic_conn_statem:init/1 (via nquic_conn_init:new_conn_state/1), registers SCID -> self() in the dispatch table so the connection's own CIDs route to this owner, and returns a context in the initial phase. The owner then drives initial -> handshake -> established by calling handle_packet/3,4 + flush/1 on inbound `{packet, }messages andhandshaketimeout/3on{quic_timeout, }, untilhandle_packetemitsconnected`. There is no export, accept queue, or takeover: the owner is the registrant from the first packet, so the connection's CID never resolves to a non-owner.

Shut down a library-mode connection. Sends CONNECTION_CLOSE with error code 0, flushes pending packets, cancels all timers, and explicitly closes the connected UDP socket (if any). Idempotent. Always returns ok. The caller is expected to clean up dispatch table entries and exit the process itself after this returns.

Like shutdown/1 but sends an application error code.

Take ownership of a library-mode context in this process. Re-registers all local connection IDs in the dispatch table to point to self(). Call this when a process receives a nquic:ctx() from another process (e.g. an accept loop handing off to a per-connection handler). After takeover/1, call recv_pending/1 to process any packets that were buffered in the previous owner's mailbox during the transition.

Handle a QUIC timer expiration. Called when a {quic_timeout, Type} message is received by the owner. Processes the timeout (loss detection, idle, path validation), flushes any resulting packets, and returns protocol events.

Upgrade the context to use a connected UDP socket. Opens a new UDP socket on the same port as the listener, then connect(2)s it to the peer. The kernel routes packets from this peer directly to the new socket, bypassing the receiver dispatch entirely. After this call, use recv_direct/1,2 instead of recv_and_process/1,2.

Functions

buffer_events(Events, Ctx)

-spec buffer_events([nquic_protocol:event()], nquic:ctx()) -> {[nquic_protocol:event()], nquic:ctx()}.

Buffer protocol events into the context.

Moves {datagram_received, Data} events into the datagram buffer (bounded, drops oldest on overflow). Returns remaining non-datagram events and the updated context.

close(Ctx)

-spec close(nquic:ctx()) -> {ok, nquic:ctx()}.

Close the connection gracefully (transport scope, error code 0, empty reason).

close(Ctx, Opts)

-spec close(nquic:ctx(), nquic:close_opts()) -> {ok, nquic:ctx()}.

Close the connection with the given options. scope => transport (default) emits CONNECTION_CLOSE type 0x1c; application emits type 0x1d (RFC 9000 §19.19).

close_stream(Ctx, StreamId)

-spec close_stream(nquic:ctx(), nquic:stream_id()) ->
                      {ok, nquic:ctx()} | {error, nquic_error:any_reason()}.

Close a stream.

flush(Ctx)

-spec flush(nquic:ctx()) -> {ok, nquic:ctx()}.

Flush pending frames into packets and send them. Encrypts queued frames, sends the resulting packets via the socket, and schedules any timer updates.

flush_notimers(Ctx)

-spec flush_notimers(nquic:ctx()) -> {ok, nquic:ctx()}.

Flush pending frames without scheduling timers. Use this when the caller will immediately call a recv function that handles timers; avoids redundant cancel_timer / send_after syscalls.

handle_packet(Ctx, Source, Bin)

-spec handle_packet(nquic:ctx(), nquic_socket:sockaddr(), binary()) ->
                       {ok, [nquic_protocol:event()], nquic:ctx()} | {error, term(), nquic:ctx()}.

Process an incoming packet. Decrypts, decodes, and handles every frame in the packet. Returns the protocol events and the context with timers scheduled.

handle_packet(Ctx, Source, Bin, ECN)

-spec handle_packet(nquic:ctx(), nquic_socket:sockaddr(), binary(), nquic_socket:ecn_mark()) ->
                       {ok, [nquic_protocol:event()], nquic:ctx()} | {error, term(), nquic:ctx()}.

Like handle_packet/3 but carries the inbound ECN codepoint.

handle_packet_batch(Ctx, Source, Buf, GsoSize, ECN)

-spec handle_packet_batch(nquic:ctx(),
                          nquic_socket:sockaddr(),
                          binary(),
                          pos_integer(),
                          nquic_socket:ecn_mark()) ->
                             {ok, [nquic_protocol:event()], nquic:ctx()}.

Process a GRO-coalesced datagram delivered as a single {packet_batch, Source, Buf, GsoSize, ECN} message. Splits the buffer per GsoSize bytes and runs handle_packet/4 on each segment, consolidating events. Schedules timers once at the end.

handle_packet_batch_notimers(Ctx, Source, Buf, GsoSize, ECN)

-spec handle_packet_batch_notimers(nquic:ctx(),
                                   nquic_socket:sockaddr(),
                                   binary(),
                                   pos_integer(),
                                   nquic_socket:ecn_mark()) ->
                                      {ok, [nquic_protocol:event()], nquic:ctx()}.

Like handle_packet_batch/5 but without scheduling timers. For callers that drain many datagrams per wakeup and call schedule_timers/1 once at the end before flushing (the batch sibling of handle_packet_notimers/3). Per-segment decode errors are dropped rather than fatal, mirroring drain_packet_batch/6: an undecryptable packet in a GRO-coalesced buffer must not tear down the connection (RFC 9000 §12.2).

handle_packet_notimers(Ctx, Source, Bin)

-spec handle_packet_notimers(nquic:ctx(), nquic_socket:sockaddr(), binary()) ->
                                {ok, [nquic_protocol:event()], nquic:ctx()} |
                                {error, term(), nquic:ctx()}.

Process an incoming packet without scheduling timers. Use for batch processing: call this once per packet, then call schedule_timers/1 once at the end before flushing.

handle_packet_notimers(Ctx, Source, Bin, ECN)

-spec handle_packet_notimers(nquic:ctx(), nquic_socket:sockaddr(), binary(), nquic_socket:ecn_mark()) ->
                                {ok, [nquic_protocol:event()], nquic:ctx()} |
                                {error, term(), nquic:ctx()}.

Like handle_packet_notimers/3 but carries an ECN mark.

handshake_timeout(Ctx, Phase, Type)

-spec handshake_timeout(nquic:ctx(), initial | handshake, nquic_protocol:timer_type()) ->
                           {ok, [nquic_protocol:event()], nquic:ctx()} | {error, term(), nquic:ctx()}.

Handle a {quic_timeout, Type} expiration during the server handshake. Phase is the encryption level the owner is currently driving (initial until it observes {state_transition, handshake}, then handshake). On a PTO this sends the probe at that level; the established timeout/2 (which probes 1-RTT) is wrong before the connection is established. Switch to timeout/2 once connected is observed.

initiate_key_update(Ctx)

-spec initiate_key_update(nquic:ctx()) -> {ok, nquic:ctx()} | {error, key_update_pending}.

Initiate a client-side key update (RFC 9001 Section 6). Rotates the 1-RTT traffic secrets and flips the key phase so the next sent 1-RTT packet carries the new keys. No frame is emitted and no packet is sent here; the owner's next flush/1 (or any send) carries the rotation, and the peer's reply confirms it. Returns {error, key_update_pending} if a previously initiated update has not yet been confirmed by the peer.

is_writable(Ctx, StreamId)

-spec is_writable(nquic:ctx(), nquic:stream_id()) -> boolean().

Point-in-time stream writability probe on a library-mode context. Pure projection on the conn_state held inside the nquic:ctx(): returns true when the stream exists, its send side is not terminal, and one byte fits under current connection-flow, stream-flow, and congestion limits. false otherwise (including unknown streams). A false does not guarantee the next send succeeds, and a true can be stale before the caller acts; it is a between-recv-turns probe, not a poll loop.

open_stream(Ctx, Opts)

-spec open_stream(nquic:ctx(), nquic:stream_opts()) ->
                     {ok, nquic:stream_id(), nquic:ctx()} | {error, term()}.

Open a new stream.

path_stats(Ctx)

-spec path_stats(nquic:ctx()) -> nquic_loss:path_stats().

Project path-level statistics from a library-mode context. Pure projection on the conn_state held inside the nquic:ctx(). No message hops, no syscalls. See nquic_conn:path_stats/1 for the field list.

recv(Ctx, StreamId)

-spec recv(nquic:ctx(), nquic:stream_id()) -> {ok, binary(), boolean(), nquic:ctx()} | {error, term()}.

Read available data from a stream buffer. Does not block. Returns {ok, Data, IsFin, Ctx} where IsFin marks end of stream.

recv_and_process(Ctx)

-spec recv_and_process(nquic:ctx()) ->
                          {ok, [nquic_protocol:event()], nquic:ctx()} | {error, term(), nquic:ctx()}.

Receive and process the next packet or timeout. Waits for either a {packet, Source, Bin} message or a {quic_timeout, Type} timer expiration. Equivalent to recv_and_process(Ctx, infinity).

recv_and_process(Ctx, Timeout)

-spec recv_and_process(nquic:ctx(), timeout()) ->
                          {ok, [nquic_protocol:event()], nquic:ctx()} | {error, term(), nquic:ctx()}.

Receive and process the next packet or timeout, with timeout. Returns {ok, [], Ctx} if no message arrives within Timeout ms.

recv_batch(Ctx)

-spec recv_batch(nquic:ctx()) ->
                    {ok, [nquic_protocol:event()], nquic:ctx()} | {error, term(), nquic:ctx()}.

Receive and process ALL available packets, then schedule timers and flush once. For ctxs upgraded with upgrade_to_connected/1, drains both the mailbox and the kernel queue (via non-blocking socket:recvfrom/3). For dispatched-mode ctxs (sharing the listener socket), drains only the mailbox; polling the shared socket would steal packets routed to sibling connections. Blocks up to Timeout ms for the first packet if none are available. The batch equivalent of recv_direct/1 / recv_and_process/1. Amortises timer scheduling and flushing across many packets.

recv_batch(Ctx, Timeout)

-spec recv_batch(nquic:ctx(), timeout()) ->
                    {ok, [nquic_protocol:event()], nquic:ctx()} | {error, term(), nquic:ctx()}.

Like recv_batch/1 with a timeout on the first packet.

recv_datagram(Ctx)

-spec recv_datagram(nquic:ctx()) -> {ok, binary(), nquic:ctx()} | {error, empty}.

Receive a buffered DATAGRAM. Returns the oldest datagram from the buffer, or {error, empty} if none are available. Use buffer_events/2 after handle_packet/3 to move received datagrams into the buffer.

recv_direct(Ctx)

-spec recv_direct(nquic:ctx()) ->
                     {ok, [nquic_protocol:event()], nquic:ctx()} | {error, term(), nquic:ctx()}.

Receive and process the next packet directly from the socket. For connections that called upgrade_to_connected/1, reads packets directly via socket:recvfrom/3 instead of waiting for {packet,...} messages. Also services timer expirations.

recv_direct(Ctx, Timeout)

-spec recv_direct(nquic:ctx(), timeout()) ->
                     {ok, [nquic_protocol:event()], nquic:ctx()} | {error, term(), nquic:ctx()}.

Like recv_direct/1 with a timeout.

recv_pending(Ctx)

-spec recv_pending(nquic:ctx()) ->
                      {ok, [nquic_protocol:event()], nquic:ctx()} | {error, term(), nquic:ctx()}.

Process every buffered {packet, Source, Bin} message in the current process mailbox. Call after takeover/1 and/or upgrade_to_connected/1 to handle packets that arrived during the ownership transition window. Returns accumulated events, or {error, Reason, Ctx} on transport error.

reset_stream(Ctx, StreamId, ErrorCode)

-spec reset_stream(nquic:ctx(), nquic:stream_id(), non_neg_integer()) ->
                      {ok, nquic:ctx()} | {error, term()}.

Reset a stream with an error code.

schedule_timers(Ctx)

-spec schedule_timers(nquic:ctx()) -> nquic:ctx().

Compute and schedule timer actions for the current protocol state. Call this after a batch of handle_packet_notimers/3 calls and before flush/1. Computes loss detection, idle, and ACK delay timers and schedules them as erlang:send_after messages.

send(Ctx, StreamId, Data)

-spec send(nquic:ctx(), nquic:stream_id(), iodata()) ->
              {ok, nquic:ctx()} | {error, term(), nquic:ctx()} | {error, term()}.

Send data on a stream. Queues the data, flushes pending frames into packets, and writes them to the socket.

send_datagram(Ctx, Data)

-spec send_datagram(nquic:ctx(), binary()) -> {ok, nquic:ctx()} | {error, nquic_error:any_reason()}.

Send an unreliable DATAGRAM frame.

send_fin(Ctx, StreamId, Data)

-spec send_fin(nquic:ctx(), nquic:stream_id(), iodata()) ->
                  {ok, nquic:ctx()} | {error, term(), nquic:ctx()} | {error, term()}.

Send data with FIN on a stream.

send_fin_noflush(Ctx, StreamId, Data)

-spec send_fin_noflush(nquic:ctx(), nquic:stream_id(), iodata()) ->
                          {ok, nquic:ctx()} | {error, term(), nquic:ctx()} | {error, term()}.

Queue data + FIN on a stream without flushing. Use to batch multiple stream sends into a single subsequent flush/1 call.

server_accept_init(Opts)

-spec server_accept_init(map()) -> {ok, nquic:ctx()}.

Seed a server-side nquic:ctx/0 for an owner that drives the handshake itself, from the first Initial packet's options. Opts is the option map the receiver builds for a new connection (role, socket, peer, dcid/odcid, version, dispatchtable, listener, certs, alpn, static_key, transport params, ...). Builds the same #conn_state{} as nquic_conn_statem:init/1 (via nquic_conn_init:new_conn_state/1), registers SCID -> self() in the dispatch table so the connection's own CIDs route to this owner, and returns a context in the initial phase. The owner then drives initial -> handshake -> established by calling handle_packet/3,4 + flush/1 on inbound `{packet, }messages andhandshaketimeout/3on{quic_timeout, }, untilhandle_packetemitsconnected`. There is no export, accept queue, or takeover: the owner is the registrant from the first packet, so the connection's CID never resolves to a non-owner.

shutdown(Ctx)

-spec shutdown(nquic:ctx()) -> ok.

Shut down a library-mode connection. Sends CONNECTION_CLOSE with error code 0, flushes pending packets, cancels all timers, and explicitly closes the connected UDP socket (if any). Idempotent. Always returns ok. The caller is expected to clean up dispatch table entries and exit the process itself after this returns.

shutdown(Ctx, ErrorCode, Reason)

-spec shutdown(nquic:ctx(), non_neg_integer(), binary()) -> ok.

Like shutdown/1 but sends an application error code.

takeover(Ctx)

-spec takeover(nquic:ctx()) -> {ok, nquic:ctx()}.

Take ownership of a library-mode context in this process. Re-registers all local connection IDs in the dispatch table to point to self(). Call this when a process receives a nquic:ctx() from another process (e.g. an accept loop handing off to a per-connection handler). After takeover/1, call recv_pending/1 to process any packets that were buffered in the previous owner's mailbox during the transition.

timeout(Ctx, Type)

-spec timeout(nquic:ctx(), nquic_protocol:timer_type()) ->
                 {ok, [nquic_protocol:event()], nquic:ctx()} | {error, term(), nquic:ctx()}.

Handle a QUIC timer expiration. Called when a {quic_timeout, Type} message is received by the owner. Processes the timeout (loss detection, idle, path validation), flushes any resulting packets, and returns protocol events.

upgrade_to_connected(Ctx)

-spec upgrade_to_connected(nquic:ctx()) -> {ok, nquic:ctx()} | {error, term()}.

Upgrade the context to use a connected UDP socket. Opens a new UDP socket on the same port as the listener, then connect(2)s it to the peer. The kernel routes packets from this peer directly to the new socket, bypassing the receiver dispatch entirely. After this call, use recv_direct/1,2 instead of recv_and_process/1,2.

Warning: handshake race under burst load

The connected socket must bind to the listener's port with SO_REUSEPORT (Linux requires all sockets sharing a port to have it if any does). Each upgraded connection adds another member to the reuseport group. For a new client's Initial (no existing 4-tuple match), Linux hashes across the group and may land on a connected socket whose peer does not match; compute_score() returns -1 and the kernel drops the packet rather than retrying another slot. The new client then stalls until wait_established times out.

Under sequential-but-bursty connects the observed loss rate is ~1% per new handshake once many connections are held open. The proper fix is an SO_ATTACH_REUSEPORT_CBPF filter pinning fallback hashes to the listener slot (exact 4-tuple matches bypass it and keep the fast path). OTP 28's socket module does not yet expose this option and setopt_native cannot marshal the required struct sock_fprog pointer, so the fix is pending.

Callers that accept many concurrent connections should avoid this helper until the library-level fix lands, or tolerate the rare timeout at the accept layer.