nquic_protocol (nquic v1.0.0)
View SourcePure functional QUIC protocol state machine.
Provides the core QUIC protocol logic without any process or I/O dependencies. Takes data in, returns events and packets out. Can be driven by any process (gen_statem, gen_server, or direct loop).
The caller is responsible for:
- Sending UDP packets returned by
flush/1 - Scheduling timers from timeout actions
- Delivering events to the application
- Registering/unregistering connection IDs in dispatch tables
Usage
{ok, Events, State1} = nquic_protocol:handle_packet(Bin, Source, State),
{ok, Packets, State2} = nquic_protocol:flush(State1),
[nquic_socket:send(Socket, Peer, Pkt) || Pkt <- Packets].
Summary
Functions
Tear down the listener-side dispatch routing for this connection.
Pure state operation paired with the side-effecting CID
dispatch_unregister/2 calls in
nquic_conn_migration:finalize_server_migration/1. Library-mode callers invoke this after
seeing local_migration_validated so subsequent CID frame handling
(RETIRE_CONNECTION_ID, key rotation issuance) stops consulting a
dispatch table that is no longer authoritative; the kernel already
routes new packets by 4-tuple to the per-connection FD.
Returns the dispatch table reference that was previously set
(undefined if none), letting the caller run the matching
dispatch_unregister loop without re-reading state.
Queue a CONNECTION_CLOSE frame with a transport error code.
Queue a CONNECTION_CLOSE frame with an application error code.
Close the send side of a stream by latching FIN.
The actual FIN-bearing STREAM frame is emitted by the next drain at flush
time (see nquic_protocol_streams_send:drain_pending_sends/1). Send-side
cleanup happens later, once the FIN frame has been queued and the stream
reaches the data_sent state.
Encrypt all queued frames into QUIC packets and return them.
Returns {ok, [iodata()], State, [timeout_action()]} with packets
and timer actions when there are frames to send, or {ok, State}
if there is nothing to flush.
The packets are produced in encryption-level order: Initial first,
then Handshake, then 1-RTT (application). Each is a separate datagram;
there is no in-datagram coalescing today, so the wrapper sends each
as an independent UDP datagram.
Handle a timer expiration during the server handshake (initial /
handshake packet number spaces, before the connection is
established).
The established handle_timeout/2 queues a 1-RTT PING on PTO, which is
useless before app keys exist and never retransmits the handshake
flight. This variant sends the PTO probe at the encryption level the
owner is currently driving (Phase), mirroring
nquic_conn_timers:handle_pto/2. The owner tracks Phase from the
{state_transition, handshake} event and switches to the established
timeout/2 once it observes connected.
Process an incoming UDP packet.
Source is the sockaddr map from the socket recv (e.g.
#{family => inet, addr => {127,0,0,1}, port => 4433}).
Returns events for the caller to handle, may queue internal frames
(ACKs, window updates) that flush/1 will encrypt, and returns
timeout actions for the caller to schedule.
Process an incoming UDP packet with an ECN codepoint from IP header.
Process an incoming UDP packet without computing timer actions.
Same as handle_packet/3 but skips
nquic_protocol_timer:compute_timer_actions/1 at the end. Use this
for batched recv loops where timer computation is deferred to the
final packet. The caller must call
nquic_protocol_timer:compute_timer_actions/1 after the batch
completes.
Process an incoming packet without timer actions, with ECN codepoint.
Handle a timer expiry. Returns events, updated state, and new timer actions. The caller should deliver events and schedule returned timers.
Get connection information as a map.
Get all local connection IDs.
Get the original destination connection ID (server connections).
Open a new stream. Returns {ok, StreamId, State} on success.
Project path-level statistics from the connection state.
Combines RTT estimator state (smoothed_rtt, rttvar, min_rtt,
latest_rtt), congestion window state (cwnd, bytes_in_flight,
ssthresh), lifetime packet counters, and ECN state into a flat map
suitable for routing decisions and operational dashboards.
Get the current peer address.
Get the peer's TLS certificate in DER form, or undefined if none.
Return IDs of peer-initiated streams that have buffered application data.
Read and consume buffered data from a stream. Returns the accumulated data and whether FIN has been received. Clears the stream's application buffer so subsequent calls return new data only.
Reset a stream with an application error code.
Clear the cached timer values so
nquic_protocol_timer:compute_timer_actions/1 re-emits set_timer
actions for every live timer.
Use this at an ownership boundary (for example on library-mode
takeover/1), where the previous owner's timers were not transferred
and the new owner must schedule them afresh.
Send an unreliable DATAGRAM frame (RFC 9221). Checks that datagrams were negotiated, that the data fits within the peer's max_datagram_frame_size, and that the congestion window allows it. Datagrams are not retransmitted on loss and are not flow-controlled.
Queue a STREAM frame for sending. Call flush/1 to encrypt and retrieve packets.
On flow-control or congestion-control block, returns {error, Reason, State}
with the stream recorded in the state's blocked_streams set. Callers that
need the updated state (to observe a subsequent {stream_writable, _}
event) should propagate it; callers that don't can discard the third
element.
Queue at most send_buffer_high_water - byte_size(pending_send_data)
bytes of DataBin onto the stream's outbound buffer, capped further by
the peer's connection / stream flow-control windows. Returns the number
of bytes accepted; the caller is responsible for parking the unaccepted
tail (e.g. as a send_waiter) so it can be queued later when the drain
or a peer flow-control update frees room.
fin is only latched when the entire input fits; a partial accept
leaves FIN un-latched so the eventual final byte carries the FIN.
On stream / connection flow-control rejection (peer's window is full),
returns {error, Reason, State} with the stream recorded in
blocked_streams so a later stream_writable event can fire.
True when the connection's socket has been connect(2)-bound to its
peer 4-tuple. Set after server_per_conn_fd migration (RFC 9000 §9):
callers exporting such a connection into library mode must drive the
send path through socket:send/2 instead of sendto.
Types
-type event() :: {stream_data, nquic:stream_id()} | {stream_opened, nquic:stream_id()} | {stream_reset, nquic:stream_id(), non_neg_integer()} | {stop_sending, nquic:stream_id(), non_neg_integer()} | connection_closed | {datagram_received, binary()} | {new_session_ticket, binary()} | {new_token_received, binary()} | {stream_writable, nquic:stream_id()} | {state_transition, handshake | established} | connected | listener_established | {migrate_to_preferred, nquic_transport:preferred_address()} | local_migration_validated.
-type state() :: #conn_state{role :: client | server, scid :: nquic:connection_id(), dcid :: nquic:connection_id(), odcid :: nquic:connection_id() | undefined, retry_scid :: nquic:connection_id() | undefined, retry_token :: binary(), version :: non_neg_integer(), version_preference :: [non_neg_integer()], socket :: nquic_socket:t() | undefined, peer :: nquic_socket:sockaddr() | undefined, select_info :: nquic_socket:select_info() | undefined, pn_spaces :: #{nquic_packet:space() => map()}, app_next_pn :: non_neg_integer(), app_largest_received :: integer(), loss_state :: nquic_loss:loss_state() | undefined, dispatch_table :: nquic_dispatch:t() | undefined, listener :: pid() | undefined, connect_waiters :: [gen_server:from()], local_params :: #transport_params{original_destination_connection_id :: nquic:connection_id() | undefined, max_idle_timeout :: non_neg_integer(), stateless_reset_token :: binary() | undefined, max_udp_payload_size :: pos_integer(), initial_max_data :: non_neg_integer(), initial_max_stream_data_bidi_local :: non_neg_integer(), initial_max_stream_data_bidi_remote :: non_neg_integer(), initial_max_stream_data_uni :: non_neg_integer(), initial_max_streams_bidi :: non_neg_integer(), initial_max_streams_uni :: non_neg_integer(), ack_delay_exponent :: 0..20, max_ack_delay :: non_neg_integer(), disable_active_migration :: boolean(), preferred_address :: nquic_transport:preferred_address() | undefined, active_connection_id_limit :: non_neg_integer(), initial_source_connection_id :: nquic:connection_id() | undefined, retry_source_connection_id :: nquic:connection_id() | undefined, version_information :: nquic_transport:version_information() | undefined, max_datagram_frame_size :: non_neg_integer() | undefined}, remote_params :: #transport_params{original_destination_connection_id :: nquic:connection_id() | undefined, max_idle_timeout :: non_neg_integer(), stateless_reset_token :: binary() | undefined, max_udp_payload_size :: pos_integer(), initial_max_data :: non_neg_integer(), initial_max_stream_data_bidi_local :: non_neg_integer(), initial_max_stream_data_bidi_remote :: non_neg_integer(), initial_max_stream_data_uni :: non_neg_integer(), initial_max_streams_bidi :: non_neg_integer(), initial_max_streams_uni :: non_neg_integer(), ack_delay_exponent :: 0..20, max_ack_delay :: non_neg_integer(), disable_active_migration :: boolean(), preferred_address :: nquic_transport:preferred_address() | undefined, active_connection_id_limit :: non_neg_integer(), initial_source_connection_id :: nquic:connection_id() | undefined, retry_source_connection_id :: nquic:connection_id() | undefined, version_information :: nquic_transport:version_information() | undefined, max_datagram_frame_size :: non_neg_integer() | undefined} | undefined, server_packet_processed :: boolean(), owner :: pid() | undefined, owner_mon :: reference() | undefined, deferred_flush_pending :: boolean(), pending_ack_count :: non_neg_integer(), last_idle_ms :: non_neg_integer() | infinity | undefined, last_pto_ms :: non_neg_integer() | cancel | undefined, recv_ecn :: nquic_socket:ecn_mark(), pmtud :: nquic_pmtud:pmtud_state() | undefined, gso_size :: undefined | pos_integer(), max_payload_size :: pos_integer(), server_per_conn_fd :: boolean(), proactive_cids :: boolean(), socket_connected :: boolean(), self_migration_pending :: boolean(), metrics_counters :: nquic_metrics:conn_counters() | undefined, spin_enabled :: boolean(), peer_spin :: 0..1, new_token_enabled :: boolean(), new_token_lifetime :: pos_integer(), qlog :: undefined | nquic_qlog:qlog_state(), close_kind :: undefined | local | peer | idle_timeout | protocol_error, crypto :: #conn_crypto{tls_state :: term(), keys :: #{nquic_packet:space() | rtt0 => map()}, app_send_keys :: map() | undefined, app_recv_keys :: map() | undefined, crypto_buffer :: #{nquic_packet:space() => {non_neg_integer(), binary(), list()}}, cipher :: aes_128_gcm | aes_256_gcm | chacha20_poly1305, cipher_suites :: [aes_128_gcm | aes_256_gcm | chacha20_poly1305] | undefined, key_phase :: boolean(), key_update_pending :: boolean(), client_app_secret :: binary() | undefined, server_app_secret :: binary() | undefined, old_read_keys :: #{key := binary(), iv := binary()} | undefined, zero_rtt_accepted :: boolean(), replay_protection :: module() | undefined, session_ticket :: map() | undefined, resumption_secret :: binary() | undefined, session_cache :: atom() | false | {module, module()} | undefined, token_cache :: atom() | false | {module, module()}, alpn :: [binary()] | undefined, hostname :: string() | binary() | undefined, cert :: binary() | undefined, cert_chain :: [binary()], key :: any() | undefined, verify :: verify_none | verify_peer, cacerts :: [binary()], peer_cert :: binary() | undefined, static_key :: binary() | undefined}, streams_state :: #conn_streams{streams :: #{nquic:stream_id() => #stream_state{stream_id :: nquic:stream_id(), type :: bidi | uni, send_state :: ready | send | data_sent | data_recvd | reset_sent | reset_recvd, send_offset :: non_neg_integer(), send_max_data :: non_neg_integer(), last_stream_data_blocked :: non_neg_integer(), pending_send_data :: [binary()], pending_send_size :: non_neg_integer(), pending_send_fin :: boolean(), recv_state :: recv | size_known | data_recvd | reset_recvd | data_read | reset_read, recv_offset :: non_neg_integer(), recv_max_offset :: non_neg_integer(), recv_window :: non_neg_integer(), recv_buffer :: gb_trees:tree(non_neg_integer(), {binary(), boolean()}), app_buffer :: iodata(), app_buffer_size :: non_neg_integer()}}, next_bidi_stream :: nquic:stream_id() | undefined, next_uni_stream :: nquic:stream_id() | undefined, peer_max_streams_bidi :: non_neg_integer(), peer_max_streams_uni :: non_neg_integer(), local_max_streams_bidi :: non_neg_integer(), local_max_streams_uni :: non_neg_integer(), last_sent_max_streams_bidi :: non_neg_integer(), last_sent_max_streams_uni :: non_neg_integer(), max_peer_bidi_stream_id :: non_neg_integer() | undefined, max_peer_uni_stream_id :: non_neg_integer() | undefined, opened_peer_bidi_count :: non_neg_integer(), opened_peer_uni_count :: non_neg_integer(), closed_peer_bidi_wm :: integer(), closed_peer_uni_wm :: integer(), closed_peer_streams :: #{nquic:stream_id() => true}, recv_waiters :: #{nquic:stream_id() => gen_statem:from()}, accept_stream_waiters :: queue:queue(gen_statem:from()), pending_streams :: queue:queue(nquic:stream_id()), blocked_streams :: #{nquic:stream_id() => true}, pending_send_streams :: #{nquic:stream_id() => true}, send_buffer_high_water :: pos_integer(), send_timeout :: timeout(), send_waiters :: queue:queue(nquic_conn_send_waiters:t())}, flow :: #conn_flow{local_max_data :: non_neg_integer(), remote_max_data :: non_neg_integer(), data_sent :: non_neg_integer(), data_received :: non_neg_integer(), last_data_blocked :: non_neg_integer(), pending_initial_frames :: [nquic_frame:t()], pending_handshake_frames :: [nquic_frame:t()], pending_app_frames :: [nquic_frame:t()], pending_app_pre_encoded :: [{non_neg_integer(), iodata(), nquic_frame:t()}], queued_app_send_bytes :: non_neg_integer()}, path :: #conn_path_mgmt{path_state :: nquic_path:state() | undefined, peer_cids :: #{non_neg_integer() => #{cid := nquic:connection_id(), token := binary()}}, local_cids :: #{non_neg_integer() => nquic:connection_id()}, local_cid_seq :: non_neg_integer(), peer_retire_prior_to :: non_neg_integer(), anti_amp_bytes_received :: non_neg_integer(), anti_amp_bytes_sent :: non_neg_integer(), address_validated :: boolean()}}.
-type timeout_action() :: {set_timer, timer_type(), non_neg_integer()} | {cancel_timer, timer_type()}.
-type timer_type() :: idle | pto | path_validation | draining | ack_delay | pmtud | pace.
Functions
-spec clear_dispatch_table(state()) -> {nquic_dispatch:t() | undefined, state()}.
Tear down the listener-side dispatch routing for this connection.
Pure state operation paired with the side-effecting CID
dispatch_unregister/2 calls in
nquic_conn_migration:finalize_server_migration/1. Library-mode callers invoke this after
seeing local_migration_validated so subsequent CID frame handling
(RETIRE_CONNECTION_ID, key rotation issuance) stops consulting a
dispatch table that is no longer authoritative; the kernel already
routes new packets by 4-tuple to the per-connection FD.
Returns the dispatch table reference that was previously set
(undefined if none), letting the caller run the matching
dispatch_unregister loop without re-reading state.
-spec close(non_neg_integer(), binary(), state()) -> {ok, state()}.
Queue a CONNECTION_CLOSE frame with a transport error code.
-spec close_app(non_neg_integer(), binary(), state()) -> {ok, state()}.
Queue a CONNECTION_CLOSE frame with an application error code.
-spec close_stream(nquic:stream_id(), state()) -> {ok, state()} | {error, term()}.
Close the send side of a stream by latching FIN.
The actual FIN-bearing STREAM frame is emitted by the next drain at flush
time (see nquic_protocol_streams_send:drain_pending_sends/1). Send-side
cleanup happens later, once the FIN frame has been queued and the stream
reaches the data_sent state.
-spec error_code(nquic_error:any_reason()) -> non_neg_integer().
-spec error_to_reason_phrase(nquic_error:any_reason()) -> binary().
-spec flush(state()) -> {ok, [iodata()], state(), [timeout_action()]} | {ok, state()}.
Encrypt all queued frames into QUIC packets and return them.
Returns {ok, [iodata()], State, [timeout_action()]} with packets
and timer actions when there are frames to send, or {ok, State}
if there is nothing to flush.
The packets are produced in encryption-level order: Initial first,
then Handshake, then 1-RTT (application). Each is a separate datagram;
there is no in-datagram coalescing today, so the wrapper sends each
as an independent UDP datagram.
-spec get_draining_timeout(state()) -> non_neg_integer().
-spec get_idle_timeout(non_neg_integer(), non_neg_integer()) -> pos_integer() | infinity.
-spec handle_handshake_timeout(initial | handshake, timer_type(), state()) -> {ok, [event()], state(), [timeout_action()]} | {error, term(), state()}.
Handle a timer expiration during the server handshake (initial /
handshake packet number spaces, before the connection is
established).
The established handle_timeout/2 queues a 1-RTT PING on PTO, which is
useless before app keys exist and never retransmits the handshake
flight. This variant sends the PTO probe at the encryption level the
owner is currently driving (Phase), mirroring
nquic_conn_timers:handle_pto/2. The owner tracks Phase from the
{state_transition, handshake} event and switches to the established
timeout/2 once it observes connected.
-spec handle_packet(binary(), nquic_socket:sockaddr(), state()) -> {ok, [event()], state(), [timeout_action()]} | {error, term(), state()}.
Process an incoming UDP packet.
Source is the sockaddr map from the socket recv (e.g.
#{family => inet, addr => {127,0,0,1}, port => 4433}).
Returns events for the caller to handle, may queue internal frames
(ACKs, window updates) that flush/1 will encrypt, and returns
timeout actions for the caller to schedule.
-spec handle_packet(binary(), nquic_socket:sockaddr(), state(), nquic_socket:ecn_mark()) -> {ok, [event()], state(), [timeout_action()]} | {error, term(), state()}.
Process an incoming UDP packet with an ECN codepoint from IP header.
-spec handle_packet_notimers(binary(), nquic_socket:sockaddr(), state()) -> {ok, [event()], state()} | {error, term(), state()}.
Process an incoming UDP packet without computing timer actions.
Same as handle_packet/3 but skips
nquic_protocol_timer:compute_timer_actions/1 at the end. Use this
for batched recv loops where timer computation is deferred to the
final packet. The caller must call
nquic_protocol_timer:compute_timer_actions/1 after the batch
completes.
-spec handle_packet_notimers(binary(), nquic_socket:sockaddr(), state(), nquic_socket:ecn_mark()) -> {ok, [event()], state()} | {error, term(), state()}.
Process an incoming packet without timer actions, with ECN codepoint.
-spec handle_timeout(timer_type(), state()) -> {ok, [event()], state(), [timeout_action()]} | {error, term(), state()}.
Handle a timer expiry. Returns events, updated state, and new timer actions. The caller should deliver events and schedule returned timers.
Get connection information as a map.
-spec local_cids(state()) -> [nquic:connection_id()].
Get all local connection IDs.
-spec odcid(state()) -> nquic:connection_id() | undefined.
Get the original destination connection ID (server connections).
-spec open_stream(#{type => bidi | uni}, state()) -> {ok, nquic:stream_id(), state()} | {error, term()}.
Open a new stream. Returns {ok, StreamId, State} on success.
-spec path_stats(state()) -> nquic_loss:path_stats().
Project path-level statistics from the connection state.
Combines RTT estimator state (smoothed_rtt, rttvar, min_rtt,
latest_rtt), congestion window state (cwnd, bytes_in_flight,
ssthresh), lifetime packet counters, and ECN state into a flat map
suitable for routing decisions and operational dashboards.
-spec peer(state()) -> nquic_socket:sockaddr() | undefined.
Get the current peer address.
Get the peer's TLS certificate in DER form, or undefined if none.
-spec pending_stream_ids(state()) -> [nquic:stream_id()].
Return IDs of peer-initiated streams that have buffered application data.
-spec read_stream(nquic:stream_id(), state()) -> {ok, binary(), boolean(), state()} | {error, term()}.
Read and consume buffered data from a stream. Returns the accumulated data and whether FIN has been received. Clears the stream's application buffer so subsequent calls return new data only.
-spec reset_stream(nquic:stream_id(), non_neg_integer(), state()) -> {ok, state()} | {error, term()}.
Reset a stream with an application error code.
Clear the cached timer values so
nquic_protocol_timer:compute_timer_actions/1 re-emits set_timer
actions for every live timer.
Use this at an ownership boundary (for example on library-mode
takeover/1), where the previous owner's timers were not transferred
and the new owner must schedule them afresh.
-spec scale_ack_delay(non_neg_integer(), #transport_params{original_destination_connection_id :: nquic:connection_id() | undefined, max_idle_timeout :: non_neg_integer(), stateless_reset_token :: binary() | undefined, max_udp_payload_size :: pos_integer(), initial_max_data :: non_neg_integer(), initial_max_stream_data_bidi_local :: non_neg_integer(), initial_max_stream_data_bidi_remote :: non_neg_integer(), initial_max_stream_data_uni :: non_neg_integer(), initial_max_streams_bidi :: non_neg_integer(), initial_max_streams_uni :: non_neg_integer(), ack_delay_exponent :: 0..20, max_ack_delay :: non_neg_integer(), disable_active_migration :: boolean(), preferred_address :: nquic_transport:preferred_address() | undefined, active_connection_id_limit :: non_neg_integer(), initial_source_connection_id :: nquic:connection_id() | undefined, retry_source_connection_id :: nquic:connection_id() | undefined, version_information :: nquic_transport:version_information() | undefined, max_datagram_frame_size :: non_neg_integer() | undefined} | undefined) -> non_neg_integer().
-spec send_datagram(binary(), state()) -> {ok, state()} | {error, nquic_error:any_reason()}.
Send an unreliable DATAGRAM frame (RFC 9221). Checks that datagrams were negotiated, that the data fits within the peer's max_datagram_frame_size, and that the congestion window allows it. Datagrams are not retransmitted on loss and are not flow-controlled.
-spec send_stream(nquic:stream_id(), iodata(), fin | nofin, state()) -> {ok, state()} | {error, term()} | {error, term(), state()}.
Queue a STREAM frame for sending. Call flush/1 to encrypt and retrieve packets.
On flow-control or congestion-control block, returns {error, Reason, State}
with the stream recorded in the state's blocked_streams set. Callers that
need the updated state (to observe a subsequent {stream_writable, _}
event) should propagate it; callers that don't can discard the third
element.
-spec send_stream_capped(nquic:stream_id(), iodata(), fin | nofin, state()) -> {ok, non_neg_integer(), state()} | {error, term()} | {error, term(), state()}.
Queue at most send_buffer_high_water - byte_size(pending_send_data)
bytes of DataBin onto the stream's outbound buffer, capped further by
the peer's connection / stream flow-control windows. Returns the number
of bytes accepted; the caller is responsible for parking the unaccepted
tail (e.g. as a send_waiter) so it can be queued later when the drain
or a peer flow-control update frees room.
fin is only latched when the entire input fits; a partial accept
leaves FIN un-latched so the eventual final byte carries the FIN.
On stream / connection flow-control rejection (peer's window is full),
returns {error, Reason, State} with the stream recorded in
blocked_streams so a later stream_writable event can fire.
True when the connection's socket has been connect(2)-bound to its
peer 4-tuple. Set after server_per_conn_fd migration (RFC 9000 §9):
callers exporting such a connection into library mode must drive the
send path through socket:send/2 instead of sendto.