nquic_protocol_send (nquic v1.2.0)

View Source

Outbound side of the QUIC protocol state.

Pure functions over #conn_state{} covering encrypted packet construction (Initial / Handshake / 1-RTT / 0-RTT), MTU-aware splitting and batched send, the per-flush send context, anti-amplification gating (RFC 9000 §8.1), congestion-control admission (RFC 9002), header protection sample extraction (RFC 9001 §5.4), Initial-key derivation, and the loss-detection retransmission glue.

ACK generation lives in nquic_protocol_ack; the per-encryption-level pending-frame queues and their flush drains live in nquic_protocol_send_queues. Both call down into this module's packet builders module-qualified; the dependency is one-way.

Summary

Functions

Build an encrypted Handshake-space packet from a list of frames. Same return shape as build_initial_packet/2. Returns {error, no_handshake_keys, nquic_protocol:state()} before Handshake keys are installed.

Build one or more Handshake-space packets from Frames, fragmenting the CRYPTO flight so that no datagram exceeds the path/peer UDP payload limit (min(max_payload_size, peer max_udp_payload_size), RFC 9000 §14.1 and §18.2). Each batch becomes its own Handshake packet/datagram; oversized CRYPTO frames are split at their offset boundary (RFC 9000 §19.6) and reassemble on the peer. Without this split a large server flight (cert chain) is sent as one >1200-byte datagram, which Chromium/quiche drops on receipt (ERR_MSG_TOO_BIG) since it exceeds the peer's advertised max_udp_payload_size, stalling the handshake.

Build an encrypted Initial-space packet from a list of frames. Returns {ok, iodata(), nquic_protocol:state()} with the masked packet and the state updated for loss detection, anti-amplification, and packet-number space. The wire datagram is padded to RFC 9000 §14.1's 1200-byte minimum. Returns {ok, <<>>, nquic_protocol:state()} if the connection is anti-amplification limited (server has not yet validated the client). The caller should not send anything in that case. Returns {error, no_initial_keys, nquic_protocol:state()} if Initial keys have not been derived yet (caller bug). Used by flush/1 to drain pending_initial_frames and by callers that need to send a single Initial packet directly (e.g. client first flight once the handshake API exposes it).

Build an encrypted Initial-space packet with a Retry token. Same shape as build_initial_packet/2. The token is included in the long-header packet (see RFC 9000 §17.2.5, Retry); pass <<>> for no-token Initials.

Service the loss detection timer before the handshake completes (RFC 9002 Appendix A.10). A pending loss time runs handle_loss_timeout/2. Otherwise the timer is a PTO, and queue_handshake_probes/2 queues the probes.

Service the loss time of Space (RFC 9002 Appendix A.10). The frames of the packets that the time threshold declares lost are queued again by handle_lost_frames/3. When the congestion window decreases, one packet can exceed it (RFC 9002 §7.3.2).

Largest UDP payload this endpoint sends on the path: min(max_payload_size, peer max_udp_payload_size), never below 1200 bytes. This is the maximum datagram size of the congestion controller (RFC 9002 §7.2). The peer max_udp_payload_size is a receive limit, not a path MTU (RFC 9000 §18.2), so it only bounds the value.

Outgoing latency spin bit for 1-RTT packets (RFC 9000 §17.4). Returns 0 when the spin bit is disabled or no peer packet has been seen yet. When enabled, the server inverts the peer's last spin sample and the client mirrors it.

Upper bound of the bytes that a 1-RTT packet adds to its payload: the short header with a 4-byte packet number and the AEAD tag.

Get the max_ack_delay of the peer in microseconds. Before the transport parameters of the peer arrive, the value is the 25 ms default of RFC 9000 §18.2.

Queue the content of a 1-RTT PTO probe (RFC 9002 §6.2.4). Queued STREAM or DATAGRAM data is new data, and the probe carries it. Otherwise the frames of the oldest ack-eliciting packet in flight that still carries retransmittable data are queued again, as handle_lost_frames/3 does. When no packet in flight carries such data, the probe is a PING.

Count a handshake PTO and queue its probes (RFC 9002 §6.2.4).

Types

pre_encoded()

-type pre_encoded() :: {non_neg_integer(), iodata(), nquic_frame:t()}.

Functions

build_app_packet(Frames, State)

-spec build_app_packet([nquic_frame:t()], nquic_protocol:state()) ->
                          {ok, iodata(), nquic_protocol:state()} |
                          {error, term(), nquic_protocol:state()}.

build_app_packet_pre(PreEncoded, Time, State)

-spec build_app_packet_pre([pre_encoded()], integer(), nquic_protocol:state()) ->
                              {ok, iodata(), nquic_protocol:state()} |
                              {error, term(), nquic_protocol:state()}.

build_app_packet_pre_ctx(PreEncoded, Ctx, State)

-spec build_app_packet_pre_ctx([pre_encoded()],
                               #app_send_ctx{cipher :: aes_128_gcm | aes_256_gcm | chacha20_poly1305,
                                             role_keys :: map(),
                                             key :: binary(),
                                             iv :: binary(),
                                             hp_source :: {ctx, crypto:crypto_state()} | {key, binary()},
                                             dcid :: nquic:connection_id(),
                                             dcid_prefix_size :: pos_integer(),
                                             gso_size :: undefined | pos_integer(),
                                             pad_bin :: undefined | binary(),
                                             key_phase :: boolean(),
                                             largest_acked :: non_neg_integer(),
                                             time :: integer(),
                                             track_anti_amp :: boolean()},
                               nquic_protocol:state()) ->
                                  {ok, iodata(), nquic_protocol:state()} |
                                  {error, term(), nquic_protocol:state()}.

build_handshake_packet(Frames, State)

-spec build_handshake_packet([nquic_frame:t()], nquic_protocol:state()) ->
                                {ok, iodata(), nquic_protocol:state()} |
                                {error, term(), nquic_protocol:state()}.

Build an encrypted Handshake-space packet from a list of frames. Same return shape as build_initial_packet/2. Returns {error, no_handshake_keys, nquic_protocol:state()} before Handshake keys are installed.

build_handshake_packets(Frames, State)

-spec build_handshake_packets([nquic_frame:t()], nquic_protocol:state()) ->
                                 {ok, [iodata()], nquic_protocol:state()}.

Build one or more Handshake-space packets from Frames, fragmenting the CRYPTO flight so that no datagram exceeds the path/peer UDP payload limit (min(max_payload_size, peer max_udp_payload_size), RFC 9000 §14.1 and §18.2). Each batch becomes its own Handshake packet/datagram; oversized CRYPTO frames are split at their offset boundary (RFC 9000 §19.6) and reassemble on the peer. Without this split a large server flight (cert chain) is sent as one >1200-byte datagram, which Chromium/quiche drops on receipt (ERR_MSG_TOO_BIG) since it exceeds the peer's advertised max_udp_payload_size, stalling the handshake.

Stops early and re-queues the unsent remainder if anti-amplification blocks a packet, so the rest is resent on the next flush / PTO.

build_initial_packet(Frames, State)

-spec build_initial_packet([nquic_frame:t()], nquic_protocol:state()) ->
                              {ok, iodata(), nquic_protocol:state()} |
                              {error, term(), nquic_protocol:state()}.

Build an encrypted Initial-space packet from a list of frames. Returns {ok, iodata(), nquic_protocol:state()} with the masked packet and the state updated for loss detection, anti-amplification, and packet-number space. The wire datagram is padded to RFC 9000 §14.1's 1200-byte minimum. Returns {ok, <<>>, nquic_protocol:state()} if the connection is anti-amplification limited (server has not yet validated the client). The caller should not send anything in that case. Returns {error, no_initial_keys, nquic_protocol:state()} if Initial keys have not been derived yet (caller bug). Used by flush/1 to drain pending_initial_frames and by callers that need to send a single Initial packet directly (e.g. client first flight once the handshake API exposes it).

build_initial_packet(Frames, State, RetryToken)

-spec build_initial_packet([nquic_frame:t()], nquic_protocol:state(), binary()) ->
                              {ok, iodata(), nquic_protocol:state()} |
                              {error, term(), nquic_protocol:state()}.

Build an encrypted Initial-space packet with a Retry token. Same shape as build_initial_packet/2. The token is included in the long-header packet (see RFC 9000 §17.2.5, Retry); pass <<>> for no-token Initials.

build_packets_mtu_pre/5

-spec build_packets_mtu_pre([pre_encoded()],
                            pos_integer(),
                            integer(),
                            nquic_protocol:state(),
                            [iodata()]) ->
                               {[iodata()], nquic_protocol:state()}.

build_packets_mtu_pre_ctx/5

-spec build_packets_mtu_pre_ctx([pre_encoded()],
                                pos_integer(),
                                #app_send_ctx{cipher :: aes_128_gcm | aes_256_gcm | chacha20_poly1305,
                                              role_keys :: map(),
                                              key :: binary(),
                                              iv :: binary(),
                                              hp_source ::
                                                  {ctx, crypto:crypto_state()} | {key, binary()},
                                              dcid :: nquic:connection_id(),
                                              dcid_prefix_size :: pos_integer(),
                                              gso_size :: undefined | pos_integer(),
                                              pad_bin :: undefined | binary(),
                                              key_phase :: boolean(),
                                              largest_acked :: non_neg_integer(),
                                              time :: integer(),
                                              track_anti_amp :: boolean()},
                                nquic_protocol:state(),
                                [iodata()]) ->
                                   {[iodata()], nquic_protocol:state()}.

build_zero_rtt_packet_pre(PreEncoded, Time, State)

-spec build_zero_rtt_packet_pre([pre_encoded()], integer(), nquic_protocol:state()) ->
                                   {ok, iodata(), nquic_protocol:state()} |
                                   {error, term(), nquic_protocol:state()}.

check_anti_amplification/2

-spec check_anti_amplification(nquic_protocol:state(), non_neg_integer()) ->
                                  ok_no_track | ok_track | amplification_limited.

check_congestion_control/2

-spec check_congestion_control(nquic_protocol:state(), non_neg_integer()) ->
                                  ok | {blocked, non_neg_integer()}.

ensure_initial_keys/2

-spec ensure_initial_keys(nquic_packet:header(), nquic_protocol:state()) ->
                             {ok, nquic_protocol:state()} | {error, nquic_error:any_reason()}.

ensure_sample_size(Payload, PnLen)

-spec ensure_sample_size(iodata(), 1..4) -> iodata().

get_packet_len/2

-spec get_packet_len(nquic_packet:header(), non_neg_integer()) -> {ok, non_neg_integer()}.

handle_handshake_timer/2

-spec handle_handshake_timer(initial | handshake, nquic_protocol:state()) -> nquic_protocol:state().

Service the loss detection timer before the handshake completes (RFC 9002 Appendix A.10). A pending loss time runs handle_loss_timeout/2. Otherwise the timer is a PTO, and queue_handshake_probes/2 queues the probes.

handle_loss_timeout/2

-spec handle_loss_timeout(nquic_packet:space(), nquic_protocol:state()) -> nquic_protocol:state().

Service the loss time of Space (RFC 9002 Appendix A.10). The frames of the packets that the time threshold declares lost are queued again by handle_lost_frames/3. When the congestion window decreases, one packet can exceed it (RFC 9002 §7.3.2).

handle_lost_frames/3

make_app_send_ctx(State, Time)

-spec make_app_send_ctx(nquic_protocol:state(), integer()) ->
                           #app_send_ctx{cipher :: aes_128_gcm | aes_256_gcm | chacha20_poly1305,
                                         role_keys :: map(),
                                         key :: binary(),
                                         iv :: binary(),
                                         hp_source :: {ctx, crypto:crypto_state()} | {key, binary()},
                                         dcid :: nquic:connection_id(),
                                         dcid_prefix_size :: pos_integer(),
                                         gso_size :: undefined | pos_integer(),
                                         pad_bin :: undefined | binary(),
                                         key_phase :: boolean(),
                                         largest_acked :: non_neg_integer(),
                                         time :: integer(),
                                         track_anti_amp :: boolean()}.

max_datagram_size/1

-spec max_datagram_size(nquic_protocol:state()) -> pos_integer().

Largest UDP payload this endpoint sends on the path: min(max_payload_size, peer max_udp_payload_size), never below 1200 bytes. This is the maximum datagram size of the congestion controller (RFC 9002 §7.2). The peer max_udp_payload_size is a receive limit, not a path MTU (RFC 9000 §18.2), so it only bounds the value.

maybe_update_dcid/2

-spec maybe_update_dcid(nquic_packet:header(), nquic_protocol:state()) -> nquic_protocol:state().

outgoing_spin/1

-spec outgoing_spin(nquic_protocol:state()) -> 0..1.

Outgoing latency spin bit for 1-RTT packets (RFC 9000 §17.4). Returns 0 when the spin bit is disabled or no peer packet has been seen yet. When enabled, the server inverts the peer's last spin sample and the client mirrors it.

packet_number_from_header/1

-spec packet_number_from_header(nquic_packet:header()) -> nquic_packet_number:t() | undefined.

packet_overhead/1

-spec packet_overhead(nquic_protocol:state()) -> pos_integer().

Upper bound of the bytes that a 1-RTT packet adds to its payload: the short header with a 4-byte packet number and the AEAD tag.

packet_payload_budget/1

-spec packet_payload_budget(nquic_protocol:state()) -> pos_integer().

packet_space_from_header/1

-spec packet_space_from_header(nquic_packet:header()) -> nquic_packet:space().

peer_max_ack_delay_us/1

-spec peer_max_ack_delay_us(#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().

Get the max_ack_delay of the peer in microseconds. Before the transport parameters of the peer arrive, the value is the 25 ms default of RFC 9000 §18.2.

queue_app_probe/1

-spec queue_app_probe(nquic_protocol:state()) -> nquic_protocol:state().

Queue the content of a 1-RTT PTO probe (RFC 9002 §6.2.4). Queued STREAM or DATAGRAM data is new data, and the probe carries it. Otherwise the frames of the oldest ack-eliciting packet in flight that still carries retransmittable data are queued again, as handle_lost_frames/3 does. When no packet in flight carries such data, the probe is a PING.

queue_handshake_probes/2

-spec queue_handshake_probes(initial | handshake, nquic_protocol:state()) -> nquic_protocol:state().

Count a handshake PTO and queue its probes (RFC 9002 §6.2.4).

Each of the Initial and Handshake spaces with ack-eliciting packets in flight gets one probe. The probe carries the CRYPTO data of those packets, merged by offset, or a PING when they carry no CRYPTO data. When neither space has packets in flight, the PTO is the client anti-deadlock PTO of RFC 9002 §6.2.2.1. One PING then goes out at the Handshake level if Handshake keys exist, else at the Phase level.

take_frames_for_mtu_pre(Frames, Budget)

-spec take_frames_for_mtu_pre([pre_encoded()], pos_integer()) -> {[pre_encoded()], [pre_encoded()]}.