Skip to main content

src/nquic_protocol_send.erl

-module(nquic_protocol_send).
-moduledoc """
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.
""".

-include("nquic_conn.hrl").
-include("nquic_frame.hrl").
-include("nquic_packet.hrl").
-export([
    build_app_packet/2,
    build_handshake_packet/2,
    build_handshake_packets/2,
    build_initial_packet/2,
    build_initial_packet/3
]).

-export([
    build_app_packet_pre/3,
    build_app_packet_pre_ctx/3,
    build_packets_mtu_pre/5,
    build_packets_mtu_pre_ctx/5,
    build_zero_rtt_packet_pre/3,
    make_app_send_ctx/2,
    packet_payload_budget/1
]).

-export([
    check_anti_amplification/2,
    check_congestion_control/2
]).

-export([
    ensure_initial_keys/2,
    get_packet_len/2,
    maybe_update_dcid/2,
    outgoing_spin/1,
    packet_number_from_header/1,
    packet_space_from_header/1
]).

-export([
    ensure_sample_size/2,
    hp_sample/3
]).

-export([handle_lost_frames/3]).

-export([
    take_frames_for_mtu_pre/2
]).

-export_type([pre_encoded/0]).

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

-record(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()
}).

%%%-----------------------------------------------------------------------------
%% PACKET BUILDERS (RFC 9000 17, RFC 9001 5)
%%%-----------------------------------------------------------------------------
-spec build_app_packet([nquic_frame:t()], nquic_protocol:state()) ->
    {ok, iodata(), nquic_protocol:state()} | {error, term(), nquic_protocol:state()}.
build_app_packet(Frames, State) ->
    #conn_state{
        crypto = #conn_crypto{cipher = Cipher, app_send_keys = SendKeys},
        app_next_pn = PN,
        dcid = DCID,
        peer = Peer
    } = State,
    case SendKeys of
        undefined ->
            {error, no_app_keys, State};
        RoleKeys ->
            #{key := Key, iv := IV, hp := _} = RoleKeys,
            Payload0 = [nquic_frame:encode(F) || F <- Frames],
            LargestAcked = nquic_loss:get_largest_acked(State#conn_state.loss_state, application),
            {PnLen, TruncPN} = nquic_packet_number:encode(PN, LargestAcked),
            Payload = ensure_sample_size(Payload0, PnLen),
            Header = #short_header{
                dcid = DCID,
                packet_number = TruncPN,
                key_phase = (State#conn_state.crypto)#conn_crypto.key_phase,
                spin = outgoing_spin(State),
                pn_len = PnLen
            },
            HeaderBin = nquic_packet:encode_header(Header),
            PnOffset = byte_size(HeaderBin) - PnLen,
            {Ciphertext, Tag} = nquic_crypto:encrypt(Cipher, Key, IV, PN, HeaderBin, Payload),
            SampleOff = 4 - PnLen,
            Sample = hp_sample(Ciphertext, Tag, SampleOff),
            Mask = nquic_hp:generate_mask_from_keys(RoleKeys, Cipher, Sample),
            {MaskedHeader, _} = nquic_hp:mask_header(Mask, HeaderBin, PnOffset, false),
            MaskedPacket = [MaskedHeader, Ciphertext, Tag],
            PacketSize = byte_size(HeaderBin) + byte_size(Ciphertext) + byte_size(Tag),
            case check_anti_amplification(State, PacketSize) of
                amplification_limited ->
                    {ok, <<>>, State};
                AntiAmp ->
                    Time = erlang:monotonic_time(microsecond),
                    LossState = nquic_loss:on_packet_sent(
                        State#conn_state.loss_state, application, PN, Frames, Time, PacketSize
                    ),
                    State1 = apply_post_send_app(
                        AntiAmp, State, PN + 1, LossState, PacketSize
                    ),
                    _ = Peer,
                    {ok, MaskedPacket, State1}
            end
    end.

-doc """
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.
""".
-spec build_handshake_packet([nquic_frame:t()], nquic_protocol:state()) ->
    {ok, iodata(), nquic_protocol:state()} | {error, term(), nquic_protocol:state()}.
build_handshake_packet(Frames, State) ->
    #conn_state{
        crypto = #conn_crypto{keys = Keys, cipher = Cipher},
        pn_spaces = PnSpaces,
        dcid = DCID,
        scid = SCID,
        role = Role,
        version = Ver
    } = State,
    case maps:get(handshake, Keys, undefined) of
        undefined ->
            {error, no_handshake_keys, State};
        HsKeys ->
            RoleKeys = nquic_keys:local_keys(Role, HsKeys),
            #{key := Key, iv := IV, hp := _} = RoleKeys,
            Payload0 = [nquic_frame:encode(F) || F <- Frames],
            Payload0Size = iolist_size(Payload0),
            HsSpaceMap = maps:get(handshake, PnSpaces, #{next_pn => 0}),
            PN = maps:get(next_pn, HsSpaceMap, 0),
            LargestAcked = nquic_loss:get_largest_acked(
                State#conn_state.loss_state, handshake
            ),
            {PnLen, TruncPN} = nquic_packet_number:encode(PN, LargestAcked),
            {Payload, PayloadSize} = ensure_sample_size_sized(
                Payload0, Payload0Size, PnLen
            ),
            Header = #long_header{
                type = handshake,
                version = Ver,
                dcid = DCID,
                scid = SCID,
                payload_len = PayloadSize + ?AEAD_TAG_SIZE,
                packet_number = TruncPN,
                pn_len = PnLen
            },
            HeaderBin = nquic_packet:encode_header(Header),
            HeaderSize = byte_size(HeaderBin),
            PnOffset = HeaderSize - PnLen,
            {Ciphertext, Tag} = nquic_crypto:encrypt(
                Cipher, Key, IV, PN, HeaderBin, Payload
            ),
            SampleOff = 4 - PnLen,
            Sample = hp_sample(Ciphertext, Tag, SampleOff),
            Mask = nquic_hp:generate_mask_from_keys(RoleKeys, Cipher, Sample),
            {MaskedHeader, _} = nquic_hp:mask_header(Mask, HeaderBin, PnOffset, true),
            MaskedPacket = [MaskedHeader, Ciphertext, Tag],
            PacketSize = HeaderSize + PayloadSize + ?AEAD_TAG_SIZE,
            case check_anti_amplification(State, PacketSize) of
                amplification_limited ->
                    {ok, <<>>, State};
                AntiAmp ->
                    Time = erlang:monotonic_time(microsecond),
                    LossState = nquic_loss:on_packet_sent(
                        State#conn_state.loss_state,
                        handshake,
                        PN,
                        Frames,
                        Time,
                        PacketSize
                    ),
                    NewPnSpaces = PnSpaces#{
                        handshake => HsSpaceMap#{next_pn => PN + 1}
                    },
                    State1 = apply_post_send(
                        AntiAmp, State, NewPnSpaces, LossState, PacketSize
                    ),
                    State2 = maybe_discard_initial_on_client_handshake_sent(Role, State1),
                    {ok, MaskedPacket, State2}
            end
    end.

-doc """
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.
""".
-spec build_handshake_packets([nquic_frame:t()], nquic_protocol:state()) ->
    {ok, [iodata()], nquic_protocol:state()}.
build_handshake_packets(Frames, State) ->
    Budget = handshake_payload_budget(State),
    Batches = batch_frames_by_budget(presplit_crypto_frames(Frames, Budget), Budget),
    build_handshake_batches(Batches, State, []).

-spec build_handshake_batches([[nquic_frame:t()]], nquic_protocol:state(), [iodata()]) ->
    {ok, [iodata()], nquic_protocol:state()}.
build_handshake_batches([], State, Acc) ->
    {ok, lists:reverse(Acc), State};
build_handshake_batches([Batch | Rest], State, Acc) ->
    case build_handshake_packet(Batch, State) of
        {ok, <<>>, State1} ->
            {ok, lists:reverse(Acc), requeue_handshake_frames([Batch | Rest], State1)};
        {ok, Packet, State1} ->
            build_handshake_batches(Rest, State1, [Packet | Acc]);
        {error, _, State1} ->
            {ok, lists:reverse(Acc), State1}
    end.

-spec batch_frames_by_budget([nquic_frame:t()], pos_integer()) -> [[nquic_frame:t()]].
batch_frames_by_budget(Frames, Budget) ->
    batch_frames(Frames, Budget, 0, [], []).

-spec batch_frames(
    [nquic_frame:t()], pos_integer(), non_neg_integer(), [nquic_frame:t()], [[nquic_frame:t()]]
) -> [[nquic_frame:t()]].
batch_frames([], _Budget, _Used, [], Batches) ->
    lists:reverse(Batches);
batch_frames([], _Budget, _Used, Cur, Batches) ->
    lists:reverse([lists:reverse(Cur) | Batches]);
batch_frames([F | Rest], Budget, Used, Cur, Batches) ->
    Size = iolist_size(nquic_frame:encode(F)),
    case Cur of
        [] ->
            batch_frames(Rest, Budget, Size, [F], Batches);
        _ when Used + Size =< Budget ->
            batch_frames(Rest, Budget, Used + Size, [F | Cur], Batches);
        _ ->
            batch_frames(Rest, Budget, Size, [F], [lists:reverse(Cur) | Batches])
    end.

-spec handshake_payload_budget(nquic_protocol:state()) -> pos_integer().
handshake_payload_budget(#conn_state{
    dcid = DCID, scid = SCID, remote_params = RP
}) ->
    PeerMax =
        case RP of
            #transport_params{max_udp_payload_size = M} when is_integer(M), M >= 1200 -> M;
            _ -> 1200
        end,
    Overhead = 1 + 4 + 1 + byte_size(DCID) + 1 + byte_size(SCID) + 2 + 4 + ?AEAD_TAG_SIZE,
    PeerMax - Overhead.

-spec presplit_crypto_frames([nquic_frame:t()], pos_integer()) -> [nquic_frame:t()].
presplit_crypto_frames(Frames, Budget) ->
    MaxData = max(1, Budget - 8),
    lists:flatmap(fun(F) -> split_crypto_frame(F, MaxData) end, Frames).

-spec requeue_handshake_frames([[nquic_frame:t()]], nquic_protocol:state()) ->
    nquic_protocol:state().
requeue_handshake_frames(Batches, #conn_state{flow = Flow} = State) ->
    Remaining = lists:append(Batches),
    Pending = Flow#conn_flow.pending_handshake_frames,
    State#conn_state{
        flow = Flow#conn_flow{pending_handshake_frames = Pending ++ lists:reverse(Remaining)}
    }.

-spec split_crypto_frame(nquic_frame:t(), pos_integer()) -> [nquic_frame:t()].
split_crypto_frame(#crypto{offset = Off, data = Data}, MaxData) when byte_size(Data) > MaxData ->
    split_crypto_data(Off, Data, MaxData, []);
split_crypto_frame(Frame, _MaxData) ->
    [Frame].

-spec split_crypto_data(non_neg_integer(), binary(), pos_integer(), [#crypto{}]) -> [#crypto{}].
split_crypto_data(Off, Data, MaxData, Acc) when byte_size(Data) > MaxData ->
    <<Chunk:MaxData/binary, Rest/binary>> = Data,
    split_crypto_data(Off + MaxData, Rest, MaxData, [#crypto{offset = Off, data = Chunk} | Acc]);
split_crypto_data(Off, Data, _MaxData, Acc) ->
    lists:reverse([#crypto{offset = Off, data = Data} | Acc]).

-doc """
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).
""".
-spec build_initial_packet([nquic_frame:t()], nquic_protocol:state()) ->
    {ok, iodata(), nquic_protocol:state()} | {error, term(), nquic_protocol:state()}.
build_initial_packet(Frames, State) ->
    build_initial_packet(Frames, State, State#conn_state.retry_token).

-doc """
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.
""".
-spec build_initial_packet([nquic_frame:t()], nquic_protocol:state(), binary()) ->
    {ok, iodata(), nquic_protocol:state()} | {error, term(), nquic_protocol:state()}.
build_initial_packet(Frames, State, RetryToken) ->
    #conn_state{
        crypto = #conn_crypto{keys = Keys},
        pn_spaces = PnSpaces,
        dcid = DCID,
        scid = SCID,
        role = Role,
        version = Ver
    } = State,
    case maps:get(initial, Keys, undefined) of
        undefined ->
            {error, no_initial_keys, State};
        InitKeys ->
            RoleKeys = nquic_keys:local_keys(Role, InitKeys),
            #{key := Key, iv := IV, hp := _} = RoleKeys,
            Payload0 = [nquic_frame:encode(F) || F <- Frames],
            Payload0Size = iolist_size(Payload0),
            InitSpaceMap = maps:get(initial, PnSpaces, #{next_pn => 0}),
            PN = maps:get(next_pn, InitSpaceMap, 0),
            LargestAcked = nquic_loss:get_largest_acked(State#conn_state.loss_state, initial),
            {PnLen, TruncPN} = nquic_packet_number:encode(PN, LargestAcked),
            TrialHeader = #long_header{
                type = initial,
                version = Ver,
                dcid = DCID,
                scid = SCID,
                token = RetryToken,
                payload_len = Payload0Size + ?AEAD_TAG_SIZE,
                packet_number = TruncPN,
                pn_len = PnLen
            },
            TrialHeaderBin = nquic_packet:encode_header(TrialHeader),
            TrialHeaderSize = byte_size(TrialHeaderBin),
            MinPayloadSize = max(0, 1200 - TrialHeaderSize - ?AEAD_TAG_SIZE),
            PadLen = max(0, MinPayloadSize - Payload0Size),
            Padding = <<0:(PadLen * 8)>>,
            PaddedPayloadSize = Payload0Size + PadLen,
            {Payload, PayloadSize} = ensure_sample_size_sized(
                [Payload0, Padding], PaddedPayloadSize, PnLen
            ),
            Header = TrialHeader#long_header{
                payload_len = PayloadSize + ?AEAD_TAG_SIZE
            },
            HeaderBin = nquic_packet:encode_header(Header),
            HeaderSize = byte_size(HeaderBin),
            PnOffset = HeaderSize - PnLen,
            {Ciphertext, Tag} = nquic_crypto:encrypt(
                aes_128_gcm, Key, IV, PN, HeaderBin, Payload
            ),
            SampleOff = 4 - PnLen,
            Sample = hp_sample(Ciphertext, Tag, SampleOff),
            Mask = nquic_hp:generate_mask_from_keys(RoleKeys, aes_128_gcm, Sample),
            {MaskedHeader, _} = nquic_hp:mask_header(Mask, HeaderBin, PnOffset, true),
            MaskedPacket = [MaskedHeader, Ciphertext, Tag],
            PacketSize = HeaderSize + PayloadSize + ?AEAD_TAG_SIZE,
            case check_anti_amplification(State, PacketSize) of
                amplification_limited ->
                    {ok, <<>>, State};
                AntiAmp ->
                    Time = erlang:monotonic_time(microsecond),
                    LossState = nquic_loss:on_packet_sent(
                        State#conn_state.loss_state, initial, PN, Frames, Time, PacketSize
                    ),
                    NewPnSpaces = PnSpaces#{
                        initial => InitSpaceMap#{next_pn => PN + 1}
                    },
                    State1 = apply_post_send(
                        AntiAmp, State, NewPnSpaces, LossState, PacketSize
                    ),
                    {ok, MaskedPacket, State1}
            end
    end.

-spec maybe_discard_initial_on_client_handshake_sent(
    client | server, nquic_protocol:state()
) -> nquic_protocol:state().
maybe_discard_initial_on_client_handshake_sent(client, State) ->
    case maps:is_key(initial, (State#conn_state.crypto)#conn_crypto.keys) of
        true -> nquic_protocol_handshake:discard_initial_keys(State);
        false -> State
    end;
maybe_discard_initial_on_client_handshake_sent(server, State) ->
    State.

%%%-----------------------------------------------------------------------------
%% MTU-AWARE PACKET BUILDING (1-RTT BATCHED SEND)
%%%-----------------------------------------------------------------------------
-spec anti_amp_must_track(client | server, #conn_path_mgmt{} | undefined) -> boolean().
anti_amp_must_track(client, _Path) -> false;
anti_amp_must_track(server, #conn_path_mgmt{address_validated = true}) -> false;
anti_amp_must_track(server, _) -> true.

-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(PreEncoded, Time, State) ->
    #conn_state{
        crypto = #conn_crypto{
            cipher = Cipher, key_phase = KeyPhase, app_send_keys = SendKeys
        },
        app_next_pn = PN,
        dcid = DCID,
        gso_size = GsoSize
    } = State,
    case SendKeys of
        undefined ->
            {error, no_app_keys, State};
        RoleKeys ->
            #{key := Key, iv := IV, hp := _} = RoleKeys,
            {Payload0, OrigFrames, Payload0Size} = unzip_pre_encoded(PreEncoded),
            LargestAcked = nquic_loss:get_largest_acked(State#conn_state.loss_state, application),
            {PnLen, TruncPN} = nquic_packet_number:encode(PN, LargestAcked),
            HeaderSize = 1 + byte_size(DCID) + PnLen,
            {Payload1, Payload1Size} = maybe_pad_to_gso(
                Payload0, Payload0Size, OrigFrames, HeaderSize, GsoSize
            ),
            {Payload, PayloadSize} = ensure_sample_size_sized(Payload1, Payload1Size, PnLen),
            Header = #short_header{
                dcid = DCID,
                packet_number = TruncPN,
                key_phase = KeyPhase,
                spin = outgoing_spin(State),
                pn_len = PnLen
            },
            HeaderBin = nquic_packet:encode_header(Header),
            PnOffset = HeaderSize - PnLen,
            {Ciphertext, Tag} = nquic_crypto:encrypt(Cipher, Key, IV, PN, HeaderBin, Payload),
            SampleOff = 4 - PnLen,
            Sample = hp_sample(Ciphertext, Tag, SampleOff),
            Mask = nquic_hp:generate_mask_from_keys(RoleKeys, Cipher, Sample),
            {MaskedHeader, _} = nquic_hp:mask_header(Mask, HeaderBin, PnOffset, false),
            MaskedPacket = [MaskedHeader, Ciphertext, Tag],
            PacketSize = HeaderSize + PayloadSize + ?AEAD_TAG_SIZE,
            case check_anti_amplification(State, PacketSize) of
                amplification_limited ->
                    {ok, <<>>, State};
                AntiAmp ->
                    LossState = nquic_loss:on_packet_sent(
                        State#conn_state.loss_state,
                        application,
                        PN,
                        OrigFrames,
                        Time,
                        PacketSize
                    ),
                    State1 = apply_post_send_app(
                        AntiAmp, State, PN + 1, LossState, PacketSize
                    ),
                    {ok, MaskedPacket, State1}
            end
    end.

-spec build_app_packet_pre_ctx(
    [pre_encoded()], #app_send_ctx{}, nquic_protocol:state()
) ->
    {ok, iodata(), nquic_protocol:state()} | {error, term(), nquic_protocol:state()}.
build_app_packet_pre_ctx(PreEncoded, Ctx, State) ->
    #app_send_ctx{
        cipher = Cipher,
        key = Key,
        iv = IV,
        hp_source = HpSource,
        dcid = DCID,
        dcid_prefix_size = DCIDPrefixSize,
        gso_size = GsoSize,
        pad_bin = PadBin,
        key_phase = KeyPhase,
        largest_acked = LargestAcked,
        time = Time,
        track_anti_amp = Track
    } = Ctx,
    {Payload0, OrigFrames, Payload0Size} = unzip_pre_encoded(PreEncoded),
    PN = State#conn_state.app_next_pn,
    {PnLen, TruncPN} = nquic_packet_number:encode(PN, LargestAcked),
    HeaderSize = DCIDPrefixSize + PnLen,
    {Payload1, Payload1Size} = maybe_pad_to_gso_ctx(
        Payload0, Payload0Size, OrigFrames, HeaderSize, GsoSize, PadBin
    ),
    {Payload, PayloadSize} = ensure_sample_size_sized(Payload1, Payload1Size, PnLen),
    Header = #short_header{
        dcid = DCID,
        packet_number = TruncPN,
        key_phase = KeyPhase,
        spin = outgoing_spin(State),
        pn_len = PnLen
    },
    HeaderBin = nquic_packet:encode_header(Header),
    PnOffset = HeaderSize - PnLen,
    {Ciphertext, Tag} = nquic_crypto:encrypt(Cipher, Key, IV, PN, HeaderBin, Payload),
    SampleOff = 4 - PnLen,
    Sample = hp_sample(Ciphertext, Tag, SampleOff),
    Mask = generate_mask_for_source(HpSource, Cipher, Sample),
    {MaskedHeader, _} = nquic_hp:mask_header(Mask, HeaderBin, PnOffset, false),
    MaskedPacket = [MaskedHeader, Ciphertext, Tag],
    PacketSize = HeaderSize + PayloadSize + ?AEAD_TAG_SIZE,
    case Track of
        false ->
            LossState = nquic_loss:on_packet_sent(
                State#conn_state.loss_state,
                application,
                PN,
                OrigFrames,
                Time,
                PacketSize
            ),
            {ok, MaskedPacket, State#conn_state{
                app_next_pn = PN + 1, loss_state = LossState
            }};
        true ->
            case check_anti_amplification(State, PacketSize) of
                amplification_limited ->
                    {ok, <<>>, State};
                AntiAmp ->
                    LossState = nquic_loss:on_packet_sent(
                        State#conn_state.loss_state,
                        application,
                        PN,
                        OrigFrames,
                        Time,
                        PacketSize
                    ),
                    State1 = apply_post_send_app(
                        AntiAmp, State, PN + 1, LossState, PacketSize
                    ),
                    {ok, MaskedPacket, State1}
            end
    end.

-spec build_packets_mtu_pre(
    [pre_encoded()], pos_integer(), integer(), nquic_protocol:state(), [iodata()]
) ->
    {[iodata()], nquic_protocol:state()}.
build_packets_mtu_pre([], _Budget, _Time, State, Acc) ->
    {lists:reverse(Acc), State};
build_packets_mtu_pre(Frames, Budget, Time, State, Acc) ->
    {Batch, Remaining} = take_frames_for_mtu_pre(Frames, Budget),
    case nquic_protocol_send_queues:build_app_or_zero_rtt(Batch, Time, State) of
        {ok, Packet, State1} ->
            build_packets_mtu_pre(Remaining, Budget, Time, State1, [Packet | Acc]);
        {error, _, State1} ->
            build_packets_mtu_pre(Remaining, Budget, Time, State1, Acc)
    end.

-spec build_packets_mtu_pre_ctx(
    [pre_encoded()], pos_integer(), #app_send_ctx{}, nquic_protocol:state(), [iodata()]
) -> {[iodata()], nquic_protocol:state()}.
build_packets_mtu_pre_ctx([], _Budget, _Ctx, State, Acc) ->
    {lists:reverse(Acc), State};
build_packets_mtu_pre_ctx(Frames, Budget, Ctx, State, Acc) ->
    {Batch, Remaining} = take_frames_for_mtu_pre(Frames, Budget),
    {ok, Packet, State1} = build_app_packet_pre_ctx(Batch, Ctx, State),
    build_packets_mtu_pre_ctx(Remaining, Budget, Ctx, State1, [Packet | Acc]).

-spec build_zero_rtt_packet_pre([pre_encoded()], integer(), nquic_protocol:state()) ->
    {ok, iodata(), nquic_protocol:state()} | {error, term(), nquic_protocol:state()}.
build_zero_rtt_packet_pre(PreEncoded, Time, State) ->
    #conn_state{
        crypto = #conn_crypto{keys = Keys, cipher = Cipher},
        app_next_pn = PN,
        dcid = DCID,
        scid = SCID,
        version = Ver,
        role = client
    } = State,
    case maps:get(rtt0, Keys, undefined) of
        undefined ->
            {error, no_zero_rtt_keys, State};
        #{client := RoleKeys} ->
            #{key := Key, iv := IV, hp := _} = RoleKeys,
            {Payload0, OrigFrames, Payload0Size} = unzip_pre_encoded(PreEncoded),
            LargestAcked = nquic_loss:get_largest_acked(
                State#conn_state.loss_state, application
            ),
            {PnLen, TruncPN} = nquic_packet_number:encode(PN, LargestAcked),
            {Payload, PayloadSize} = ensure_sample_size_sized(
                Payload0, Payload0Size, PnLen
            ),
            Header = #long_header{
                type = rtt0,
                version = Ver,
                dcid = DCID,
                scid = SCID,
                payload_len = PayloadSize + ?AEAD_TAG_SIZE,
                packet_number = TruncPN,
                pn_len = PnLen
            },
            HeaderBin = nquic_packet:encode_header(Header),
            HeaderSize = byte_size(HeaderBin),
            PnOffset = HeaderSize - PnLen,
            {Ciphertext, Tag} = nquic_crypto:encrypt(Cipher, Key, IV, PN, HeaderBin, Payload),
            SampleOff = 4 - PnLen,
            Sample = hp_sample(Ciphertext, Tag, SampleOff),
            Mask = nquic_hp:generate_mask_from_keys(RoleKeys, Cipher, Sample),
            {MaskedHeader, _} = nquic_hp:mask_header(Mask, HeaderBin, PnOffset, true),
            MaskedPacket = [MaskedHeader, Ciphertext, Tag],
            PacketSize = HeaderSize + PayloadSize + ?AEAD_TAG_SIZE,
            case check_anti_amplification(State, PacketSize) of
                amplification_limited ->
                    {ok, <<>>, State};
                AntiAmp ->
                    LossState = nquic_loss:on_packet_sent(
                        State#conn_state.loss_state,
                        application,
                        PN,
                        OrigFrames,
                        Time,
                        PacketSize
                    ),
                    State1 = apply_post_send_app(
                        AntiAmp, State, PN + 1, LossState, PacketSize
                    ),
                    {ok, MaskedPacket, State1}
            end
    end.

-spec generate_mask_for_source(
    {ctx, crypto:crypto_state()} | {key, binary()},
    aes_128_gcm | aes_256_gcm | chacha20_poly1305,
    binary()
) -> binary().
generate_mask_for_source({ctx, HpCtx}, _Cipher, Sample) ->
    nquic_hp:generate_mask_ctx(HpCtx, Sample);
generate_mask_for_source({key, HP}, Cipher, Sample) ->
    nquic_hp:generate_mask(Cipher, HP, Sample).

-spec make_app_send_ctx(nquic_protocol:state(), integer()) -> #app_send_ctx{}.
make_app_send_ctx(State, Time) ->
    #conn_state{
        crypto = #conn_crypto{
            cipher = Cipher,
            key_phase = KeyPhase,
            app_send_keys = RoleKeys
        },
        dcid = DCID,
        gso_size = GsoSize,
        loss_state = LossState,
        role = Role,
        path = Path
    } = State,
    #{key := Key, iv := IV, hp := HP} = RoleKeys,
    HpSource =
        case RoleKeys of
            #{hp_ctx := HpCtx} -> {ctx, HpCtx};
            _ -> {key, HP}
        end,
    LargestAcked = nquic_loss:get_largest_acked(LossState, application),
    Track = anti_amp_must_track(Role, Path),
    PadBin =
        case GsoSize of
            undefined -> undefined;
            _ -> pad_bin()
        end,
    #app_send_ctx{
        cipher = Cipher,
        role_keys = RoleKeys,
        key = Key,
        iv = IV,
        hp_source = HpSource,
        dcid = DCID,
        dcid_prefix_size = 1 + byte_size(DCID),
        gso_size = GsoSize,
        pad_bin = PadBin,
        key_phase = KeyPhase,
        largest_acked = LargestAcked,
        time = Time,
        track_anti_amp = Track
    }.

-spec packet_payload_budget(nquic_protocol:state()) -> pos_integer().
packet_payload_budget(#conn_state{dcid = DCID, max_payload_size = MaxPayload}) ->
    Overhead = 1 + byte_size(DCID) + 4 + 16,
    MaxPayload - Overhead.

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

-spec take_frames_for_mtu_pre(
    [pre_encoded()], pos_integer(), non_neg_integer(), [pre_encoded()]
) -> {[pre_encoded()], [pre_encoded()]}.
take_frames_for_mtu_pre([], _Budget, _Used, Acc) ->
    {lists:reverse(Acc), []};
take_frames_for_mtu_pre([{Size, _, _} = Entry | Rest], Budget, Used, Acc) ->
    case Acc of
        [] ->
            take_frames_for_mtu_pre(Rest, Budget, Size, [Entry]);
        _ when Used + Size =< Budget ->
            take_frames_for_mtu_pre(Rest, Budget, Used + Size, [Entry | Acc]);
        _ ->
            {lists:reverse(Acc), [Entry | Rest]}
    end.

-spec unzip_pre_encoded([pre_encoded(), ...]) ->
    {[iodata()], [nquic_frame:t()], non_neg_integer()}.
unzip_pre_encoded([{Sz, Enc, Frame}]) ->
    {[Enc], [Frame], Sz};
unzip_pre_encoded(PreEncoded) ->
    unzip_pre_encoded(PreEncoded, [], [], 0).

-spec unzip_pre_encoded([pre_encoded()], [iodata()], [nquic_frame:t()], non_neg_integer()) ->
    {[iodata()], [nquic_frame:t()], non_neg_integer()}.
unzip_pre_encoded([], PayloadAcc, FrameAcc, Size) ->
    {lists:reverse(PayloadAcc), lists:reverse(FrameAcc), Size};
unzip_pre_encoded([{Sz, Enc, Frame} | Rest], PayloadAcc, FrameAcc, Size) ->
    unzip_pre_encoded(Rest, [Enc | PayloadAcc], [Frame | FrameAcc], Size + Sz).

%%%-----------------------------------------------------------------------------
%% SEND-SIDE GATES (ANTI-AMPLIFICATION CONGESTION CONTROL)
%%%-----------------------------------------------------------------------------
-spec apply_post_send(
    ok_track | ok_no_track,
    nquic_protocol:state(),
    map(),
    nquic_loss:loss_state(),
    non_neg_integer()
) -> nquic_protocol:state().
apply_post_send(ok_no_track, State, NewPnSpaces, LossState, _PacketSize) ->
    State#conn_state{pn_spaces = NewPnSpaces, loss_state = LossState};
apply_post_send(ok_track, State, NewPnSpaces, LossState, PacketSize) ->
    Path0 = State#conn_state.path,
    NewPath = Path0#conn_path_mgmt{
        anti_amp_bytes_sent = Path0#conn_path_mgmt.anti_amp_bytes_sent + PacketSize
    },
    State#conn_state{
        pn_spaces = NewPnSpaces,
        loss_state = LossState,
        path = NewPath
    }.

-spec apply_post_send_app(
    ok_track | ok_no_track,
    nquic_protocol:state(),
    non_neg_integer(),
    nquic_loss:loss_state(),
    non_neg_integer()
) -> nquic_protocol:state().
apply_post_send_app(ok_no_track, State, NewAppNextPn, LossState, _PacketSize) ->
    State#conn_state{app_next_pn = NewAppNextPn, loss_state = LossState};
apply_post_send_app(ok_track, State, NewAppNextPn, LossState, PacketSize) ->
    Path0 = State#conn_state.path,
    NewPath = Path0#conn_path_mgmt{
        anti_amp_bytes_sent = Path0#conn_path_mgmt.anti_amp_bytes_sent + PacketSize
    },
    State#conn_state{
        app_next_pn = NewAppNextPn,
        loss_state = LossState,
        path = NewPath
    }.

-spec check_anti_amplification(nquic_protocol:state(), non_neg_integer()) ->
    ok_no_track | ok_track | amplification_limited.
check_anti_amplification(#conn_state{path = #conn_path_mgmt{address_validated = true}}, _Size) ->
    ok_no_track;
check_anti_amplification(#conn_state{role = client}, _Size) ->
    ok_no_track;
check_anti_amplification(
    #conn_state{
        path = #conn_path_mgmt{
            anti_amp_bytes_sent = Sent,
            anti_amp_bytes_received = Received
        }
    },
    Size
) ->
    case Sent + Size =< 3 * Received of
        true -> ok_track;
        false -> amplification_limited
    end.

-spec check_congestion_control(nquic_protocol:state(), non_neg_integer()) ->
    ok | {blocked, non_neg_integer()}.
check_congestion_control(#conn_state{loss_state = LossState}, Len) ->
    Cwnd = nquic_loss:get_cwnd(LossState),
    InFlight = nquic_loss:get_bytes_in_flight(LossState),
    case InFlight + Len =< Cwnd of
        true -> ok;
        false -> {blocked, Cwnd}
    end.

%%%-----------------------------------------------------------------------------
%% HEADER / PACKET HELPERS
%%%-----------------------------------------------------------------------------
-spec ensure_initial_keys(nquic_packet:header(), nquic_protocol:state()) ->
    {ok, nquic_protocol:state()} | {error, nquic_error:any_reason()}.
ensure_initial_keys(#long_header{type = initial, dcid = DCID, version = HeaderVer}, State) ->
    Crypto0 = State#conn_state.crypto,
    case maps:get(initial, Crypto0#conn_crypto.keys, undefined) of
        undefined ->
            case State#conn_state.role of
                server ->
                    {CSecret, SSecret} = nquic_keys:initial_secrets(DCID, HeaderVer),
                    {CKey, CIV, CHP} = nquic_keys:derive_packet_protection(
                        CSecret, aes_128_gcm, HeaderVer
                    ),
                    {SKey, SIV, SHP} = nquic_keys:derive_packet_protection(
                        SSecret, aes_128_gcm, HeaderVer
                    ),
                    Keys = #{
                        initial => #{
                            client => nquic_keys:make_role_keys(aes_128_gcm, CKey, CIV, CHP),
                            server => nquic_keys:make_role_keys(aes_128_gcm, SKey, SIV, SHP)
                        }
                    },
                    {ok, State#conn_state{crypto = Crypto0#conn_crypto{keys = Keys}}};
                client ->
                    {error, no_initial_keys}
            end;
        _ ->
            {ok, State}
    end;
ensure_initial_keys(_, State) ->
    {ok, State}.

-spec get_packet_len(nquic_packet:header(), non_neg_integer()) -> {ok, non_neg_integer()}.
get_packet_len(#long_header{payload_len = Len}, _) -> {ok, Len};
get_packet_len(#short_header{}, RestLen) -> {ok, RestLen}.

-spec maybe_update_dcid(nquic_packet:header(), nquic_protocol:state()) -> nquic_protocol:state().
maybe_update_dcid(
    #long_header{type = initial, scid = PeerSCID}, #conn_state{role = server, dcid = <<>>} = State
) ->
    State#conn_state{dcid = PeerSCID};
maybe_update_dcid(
    #long_header{scid = PeerSCID},
    #conn_state{role = client, server_packet_processed = false} = State
) when PeerSCID =/= <<>> ->
    State#conn_state{dcid = PeerSCID};
maybe_update_dcid(_, State) ->
    State.

-doc """
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.
""".
-spec outgoing_spin(nquic_protocol:state()) -> 0..1.
outgoing_spin(#conn_state{spin_enabled = false}) -> 0;
outgoing_spin(#conn_state{role = client, peer_spin = PS}) -> PS;
outgoing_spin(#conn_state{role = server, peer_spin = PS}) -> 1 - PS.

-spec packet_number_from_header(nquic_packet:header()) -> nquic_packet_number:t() | undefined.
packet_number_from_header(#long_header{packet_number = PN}) -> PN;
packet_number_from_header(#short_header{packet_number = PN}) -> PN.

-spec packet_space_from_header(nquic_packet:header()) -> nquic_packet:space().
packet_space_from_header(#long_header{type = initial}) -> initial;
packet_space_from_header(#long_header{type = handshake}) -> handshake;
packet_space_from_header(#long_header{type = rtt0}) -> application;
packet_space_from_header(#short_header{}) -> application.

%%%-----------------------------------------------------------------------------
%% HEADER PROTECTION SAMPLE / PAYLOAD PADDING
%%%-----------------------------------------------------------------------------
-spec ensure_sample_size(iodata(), 1..4) -> iodata().
ensure_sample_size(Payload, PnLen) ->
    {Out, _} = ensure_sample_size_sized(Payload, iolist_size(Payload), PnLen),
    Out.

-spec ensure_sample_size_sized(iodata(), non_neg_integer(), 1..4) ->
    {iodata(), non_neg_integer()}.
ensure_sample_size_sized(Payload, PayloadSize, PnLen) ->
    MinPlaintext = max(0, 4 - PnLen),
    case PayloadSize < MinPlaintext of
        true ->
            PadLen = MinPlaintext - PayloadSize,
            {[Payload, <<0:(PadLen * 8)>>], PayloadSize + PadLen};
        false ->
            {Payload, PayloadSize}
    end.

-spec maybe_pad_to_gso(
    [iodata()],
    non_neg_integer(),
    [nquic_frame:t()],
    non_neg_integer(),
    undefined | pos_integer()
) -> {[iodata()], non_neg_integer()}.
maybe_pad_to_gso(Payload, PayloadSize, _Frames, _HeaderSize, undefined) ->
    {Payload, PayloadSize};
maybe_pad_to_gso(Payload, PayloadSize, Frames, HeaderSize, GsoSize) ->
    case has_stream_frame(Frames) of
        false ->
            {Payload, PayloadSize};
        true ->
            CurrentSize = HeaderSize + PayloadSize + ?AEAD_TAG_SIZE,
            case GsoSize - CurrentSize of
                Pad when Pad > 0 ->
                    {[Payload, binary:part(pad_bin(), 0, Pad)], PayloadSize + Pad};
                _ ->
                    {Payload, PayloadSize}
            end
    end.

-spec maybe_pad_to_gso_ctx(
    [iodata()],
    non_neg_integer(),
    [nquic_frame:t()],
    non_neg_integer(),
    undefined | pos_integer(),
    undefined | binary()
) -> {[iodata()], non_neg_integer()}.
maybe_pad_to_gso_ctx(Payload, PayloadSize, _Frames, _HeaderSize, _GsoSize, undefined) ->
    {Payload, PayloadSize};
maybe_pad_to_gso_ctx(Payload, PayloadSize, Frames, HeaderSize, GsoSize, PadBin) ->
    case has_stream_frame(Frames) of
        false ->
            {Payload, PayloadSize};
        true ->
            CurrentSize = HeaderSize + PayloadSize + ?AEAD_TAG_SIZE,
            case GsoSize - CurrentSize of
                Pad when Pad > 0 ->
                    {[Payload, binary:part(PadBin, 0, Pad)], PayloadSize + Pad};
                _ ->
                    {Payload, PayloadSize}
            end
    end.

-define(PAD_BIN_KEY, '$nquic_pad_bin').
-define(PAD_BIN_SIZE, 65000).
-spec has_stream_frame([nquic_frame:t()]) -> boolean().
has_stream_frame([]) -> false;
has_stream_frame([#stream{} | _]) -> true;
has_stream_frame([_ | Rest]) -> has_stream_frame(Rest).

-spec hp_sample(binary(), binary(), non_neg_integer()) -> binary().
hp_sample(Ciphertext, _Tag, SampleOff) when byte_size(Ciphertext) >= SampleOff + 16 ->
    <<_:SampleOff/binary, Sample:16/binary, _/binary>> = Ciphertext,
    Sample;
hp_sample(Ciphertext, Tag, SampleOff) ->
    <<_:SampleOff/binary, Sample:16/binary, _/binary>> = <<Ciphertext/binary, Tag/binary>>,
    Sample.

-spec pad_bin() -> binary().
pad_bin() ->
    case erlang:get(?PAD_BIN_KEY) of
        undefined ->
            B = binary:copy(<<0>>, ?PAD_BIN_SIZE),
            _ = erlang:put(?PAD_BIN_KEY, B),
            B;
        Bin ->
            Bin
    end.

%%%-----------------------------------------------------------------------------
%% LOSS DETECTION / RETRANSMISSION (RFC 9002 6 2)
%%%-----------------------------------------------------------------------------
-spec handle_lost_frames([nquic_frame:t()], nquic_packet:space(), nquic_protocol:state()) ->
    nquic_protocol:state().
handle_lost_frames([], _Space, State) ->
    State;
handle_lost_frames([Frame | Rest], Space, State) ->
    State1 = retransmit_frame(Frame, Space, State),
    handle_lost_frames(Rest, Space, State1).

-spec retransmit_data_blocked(non_neg_integer(), nquic_protocol:state()) -> nquic_protocol:state().
retransmit_data_blocked(Limit, State) ->
    Flow = State#conn_state.flow,
    case
        Flow#conn_flow.remote_max_data =:= Limit andalso
            Flow#conn_flow.data_sent >= Limit
    of
        true ->
            {ok, State1} = nquic_protocol_send_queues:queue_app_frame(
                #data_blocked{limit = Limit}, State
            ),
            State1;
        false ->
            State
    end.

-spec retransmit_frame(nquic_frame:t(), nquic_packet:space(), nquic_protocol:state()) ->
    nquic_protocol:state().
retransmit_frame(#stream{} = Frame, _Space, State) ->
    {ok, State1} = nquic_protocol_send_queues:queue_app_frame(Frame, State),
    State1;
retransmit_frame(#crypto{} = Frame, application, State) ->
    {ok, State1} = nquic_protocol_send_queues:queue_app_frame(Frame, State),
    State1;
retransmit_frame(#crypto{} = Frame, initial, State) ->
    {ok, State1} = nquic_protocol_send_queues:queue_initial_frame(Frame, State),
    State1;
retransmit_frame(#crypto{} = Frame, handshake, State) ->
    {ok, State1} = nquic_protocol_send_queues:queue_handshake_frame(Frame, State),
    State1;
retransmit_frame(#max_data{max_data = Lost}, _Space, State) ->
    retransmit_max_data(Lost, State);
retransmit_frame(#max_stream_data{stream_id = StreamID, max_stream_data = Lost}, _Space, State) ->
    retransmit_max_stream_data(StreamID, Lost, State);
retransmit_frame(#data_blocked{limit = Limit}, _Space, State) ->
    retransmit_data_blocked(Limit, State);
retransmit_frame(#stream_data_blocked{stream_id = StreamID, limit = Limit}, _Space, State) ->
    retransmit_stream_data_blocked(StreamID, Limit, State);
retransmit_frame(#max_streams{is_uni = IsUni, max_streams = Lost}, _Space, State) ->
    retransmit_max_streams(IsUni, Lost, State);
retransmit_frame(#streams_blocked{is_uni = IsUni, limit = Limit}, _Space, State) ->
    retransmit_streams_blocked(IsUni, Limit, State);
retransmit_frame(_, _Space, State) ->
    State.

-spec retransmit_max_data(non_neg_integer(), nquic_protocol:state()) -> nquic_protocol:state().
retransmit_max_data(Lost, State) ->
    Current = (State#conn_state.flow)#conn_flow.local_max_data,
    case Current >= Lost of
        true ->
            {ok, State1} = nquic_protocol_send_queues:queue_app_frame(
                #max_data{max_data = Current}, State
            ),
            State1;
        false ->
            State
    end.

-spec retransmit_max_stream_data(nquic:stream_id(), non_neg_integer(), nquic_protocol:state()) ->
    nquic_protocol:state().
retransmit_max_stream_data(StreamID, Lost, State) ->
    Streams = (State#conn_state.streams_state)#conn_streams.streams,
    case maps:find(StreamID, Streams) of
        {ok, #stream_state{recv_window = Current}} when Current >= Lost ->
            {ok, State1} = nquic_protocol_send_queues:queue_app_frame(
                #max_stream_data{stream_id = StreamID, max_stream_data = Current}, State
            ),
            State1;
        _ ->
            State
    end.

-spec retransmit_max_streams(boolean(), non_neg_integer(), nquic_protocol:state()) ->
    nquic_protocol:state().
retransmit_max_streams(false, Lost, State) ->
    SS = State#conn_state.streams_state,
    Current = SS#conn_streams.local_max_streams_bidi,
    case Current >= Lost of
        true ->
            Frame = #max_streams{max_streams = Current, is_uni = false},
            {ok, State1} = nquic_protocol_send_queues:queue_app_frame(Frame, State),
            SS1 = (State1#conn_state.streams_state)#conn_streams{
                last_sent_max_streams_bidi = Current
            },
            State1#conn_state{streams_state = SS1};
        false ->
            State
    end;
retransmit_max_streams(true, Lost, State) ->
    SS = State#conn_state.streams_state,
    Current = SS#conn_streams.local_max_streams_uni,
    case Current >= Lost of
        true ->
            Frame = #max_streams{max_streams = Current, is_uni = true},
            {ok, State1} = nquic_protocol_send_queues:queue_app_frame(Frame, State),
            SS1 = (State1#conn_state.streams_state)#conn_streams{
                last_sent_max_streams_uni = Current
            },
            State1#conn_state{streams_state = SS1};
        false ->
            State
    end.

-spec retransmit_stream_data_blocked(nquic:stream_id(), non_neg_integer(), nquic_protocol:state()) ->
    nquic_protocol:state().
retransmit_stream_data_blocked(StreamID, Limit, State) ->
    Streams = (State#conn_state.streams_state)#conn_streams.streams,
    case maps:find(StreamID, Streams) of
        {ok, #stream_state{send_max_data = Max, send_offset = Off}} when
            Max =:= Limit, Off >= Limit
        ->
            {ok, State1} = nquic_protocol_send_queues:queue_app_frame(
                #stream_data_blocked{stream_id = StreamID, limit = Limit}, State
            ),
            State1;
        _ ->
            State
    end.

-spec retransmit_streams_blocked(boolean(), non_neg_integer(), nquic_protocol:state()) ->
    nquic_protocol:state().
retransmit_streams_blocked(false, Limit, State) ->
    SS = State#conn_state.streams_state,
    PeerMax = SS#conn_streams.peer_max_streams_bidi,
    NextStream = SS#conn_streams.next_bidi_stream,
    BlockedAtPeerMax = NextStream =/= undefined andalso NextStream div 4 >= PeerMax,
    case PeerMax =:= Limit andalso BlockedAtPeerMax of
        true ->
            Frame = #streams_blocked{limit = Limit, is_uni = false},
            {ok, State1} = nquic_protocol_send_queues:queue_app_frame(Frame, State),
            State1;
        false ->
            State
    end;
retransmit_streams_blocked(true, Limit, State) ->
    SS = State#conn_state.streams_state,
    PeerMax = SS#conn_streams.peer_max_streams_uni,
    NextStream = SS#conn_streams.next_uni_stream,
    BlockedAtPeerMax = NextStream =/= undefined andalso NextStream div 4 >= PeerMax,
    case PeerMax =:= Limit andalso BlockedAtPeerMax of
        true ->
            Frame = #streams_blocked{limit = Limit, is_uni = true},
            {ok, State1} = nquic_protocol_send_queues:queue_app_frame(Frame, State),
            State1;
        false ->
            State
    end.