-module(nquic_protocol_ack).
-moduledoc """
ACK generation and received-packet-number bookkeeping (RFC 9000 §13.2,
§19.3).
Pure functions over `#conn_state{}` covering received-PN range algebra
(insert / merge / prune), per-space `received_ranges` and
`largest_received` tracking, ECN counter accounting, ACK-frame
construction, and the ack-eliciting decision that governs whether an
ACK is queued. Queued ACKs drain into the per-encryption-level frame
queues owned by `nquic_protocol_send`, which this module calls
module-qualified.
""".
-include("nquic_conn.hrl").
-include("nquic_frame.hrl").
-export([
apply_received_ranges_prune/3,
build_ack_for_space/2,
force_queue_ack/2,
insert_pn_range/2,
is_ack_eliciting/1,
maybe_queue_ack/3,
merge_ranges/1,
prune_received_ranges/2,
ranges_to_ack_ranges/2,
track_ecn_mark/3,
track_received_pn/3,
track_received_pn_and_ecn/4
]).
-doc """
Apply RFC 9000 §13.2.4 pruning to `received_ranges` for `Space`.
`AckedFrames` is the flattened frame list returned by
`nquic_loss:on_ack_received/6` for an incoming ACK in `Space`. Any
`#ack{}` frames in that list are ACKs we previously sent that the peer
has now acknowledged; their `largest_acknowledged` value is therefore
known-known on both sides, so the corresponding tracked PNs can be
dropped from our `received_ranges`. Without this prune `received_ranges`
grows monotonically across a connection's lifetime and inflates both
ACK build cost and the work the peer does to process those ACKs.
""".
-spec apply_received_ranges_prune(
nquic_packet:space(), [nquic_frame:t()], nquic_protocol:state()
) -> nquic_protocol:state().
apply_received_ranges_prune(Space, AckedFrames, State) ->
case max_largest_acked(AckedFrames, undefined) of
undefined ->
State;
Threshold ->
#conn_state{pn_spaces = PnSpaces} = State,
SpaceMap = maps:get(Space, PnSpaces, #{}),
case maps:get(received_ranges, SpaceMap, []) of
[] ->
State;
Ranges ->
NewRanges = prune_received_ranges(Ranges, Threshold),
NewSpaceMap = SpaceMap#{received_ranges => NewRanges},
State#conn_state{pn_spaces = PnSpaces#{Space => NewSpaceMap}}
end
end.
-spec build_ack_for_space(nquic_packet:space(), nquic_protocol:state()) -> {ok, #ack{}} | none.
build_ack_for_space(Space, #conn_state{pn_spaces = PnSpaces}) ->
SpaceMap = maps:get(Space, PnSpaces, #{}),
Ranges = maps:get(received_ranges, SpaceMap, []),
case Ranges of
[{Largest, Low} | RestRanges] ->
FirstAckRange = Largest - Low,
AckRanges = ranges_to_ack_ranges(Low, RestRanges),
ECN = ecn_counts_from_space(SpaceMap),
{ok, #ack{
largest_acknowledged = Largest,
delay = 0,
first_ack_range = FirstAckRange,
ack_ranges = AckRanges,
ecn_counts = ECN
}};
[] ->
none
end.
-spec bump_app_ack(non_neg_integer(), nquic_protocol:state()) -> nquic_protocol:state().
bump_app_ack(Count, State) when Count >= 2 ->
force_queue_ack(application, State#conn_state{pending_ack_count = 0});
bump_app_ack(Count, State) ->
State#conn_state{pending_ack_count = Count}.
-spec ecn_counts_from_space(map()) ->
{non_neg_integer(), non_neg_integer(), non_neg_integer()} | undefined.
ecn_counts_from_space(SpaceMap) ->
ECT0 = maps:get(ecn_ect0, SpaceMap, 0),
ECT1 = maps:get(ecn_ect1, SpaceMap, 0),
CE = maps:get(ecn_ce, SpaceMap, 0),
case ECT0 + ECT1 + CE of
0 -> undefined;
_ -> {ECT0, ECT1, CE}
end.
-spec ecn_mark_key(nquic_socket:ecn_mark()) -> ecn_ect0 | ecn_ect1 | ecn_ce.
ecn_mark_key(ect0) -> ecn_ect0;
ecn_mark_key(ect1) -> ecn_ect1;
ecn_mark_key(ce) -> ecn_ce.
-spec force_queue_ack(application, nquic_protocol:state()) -> nquic_protocol:state().
force_queue_ack(application, State) ->
queue_app_ack(build_ack_for_space(application, State), State).
-spec insert_pn_range(nquic_packet_number:t(), [{nquic_packet_number:t(), nquic_packet_number:t()}]) ->
[{nquic_packet_number:t(), nquic_packet_number:t()}].
insert_pn_range(PN, []) ->
[{PN, PN}];
insert_pn_range(PN, [{High, Low} | Rest]) ->
if
PN >= Low, PN =< High ->
[{High, Low} | Rest];
PN =:= High + 1 ->
merge_ranges([{PN, Low} | Rest]);
PN =:= Low - 1 ->
merge_ranges([{High, PN} | Rest]);
PN > High + 1 ->
[{PN, PN}, {High, Low} | Rest];
true ->
[{High, Low} | insert_pn_range(PN, Rest)]
end.
-spec is_ack_eliciting([nquic_frame:t()]) -> boolean().
is_ack_eliciting([]) -> false;
is_ack_eliciting([#ack{} | Rest]) -> is_ack_eliciting(Rest);
is_ack_eliciting([#padding{} | Rest]) -> is_ack_eliciting(Rest);
is_ack_eliciting([#connection_close{} | Rest]) -> is_ack_eliciting(Rest);
is_ack_eliciting([_ | _]) -> true.
-spec max_largest_acked([nquic_frame:t()], nquic_packet_number:t() | undefined) ->
nquic_packet_number:t() | undefined.
max_largest_acked([], Acc) ->
Acc;
max_largest_acked([#ack{largest_acknowledged = L} | Rest], undefined) ->
max_largest_acked(Rest, L);
max_largest_acked([#ack{largest_acknowledged = L} | Rest], Acc) when L > Acc ->
max_largest_acked(Rest, L);
max_largest_acked([_ | Rest], Acc) ->
max_largest_acked(Rest, Acc).
-spec maybe_queue_ack(nquic_packet:header(), [nquic_frame:t()], nquic_protocol:state()) ->
nquic_protocol:state().
maybe_queue_ack(Header, Frames, State) ->
queue_ack_if_eliciting(is_ack_eliciting(Frames), Header, State).
-spec merge_ranges([{nquic_packet_number:t(), nquic_packet_number:t()}]) ->
[{nquic_packet_number:t(), nquic_packet_number:t()}].
merge_ranges([{H1, L1}, {H2, L2} | Rest]) when L1 =< H2 + 1 ->
merge_ranges([{max(H1, H2), min(L1, L2)} | Rest]);
merge_ranges(Ranges) ->
Ranges.
-doc """
Drop ranges from `received_ranges` (descending list of `{High, Low}`
pairs) whose entire span is at or below `Threshold`. The range that
straddles `Threshold` is truncated; ranges entirely above `Threshold`
are unchanged. Used by `apply_received_ranges_prune/3` to apply RFC
9000 §13.2.4.
""".
-spec prune_received_ranges(
[{nquic_packet_number:t(), nquic_packet_number:t()}], nquic_packet_number:t()
) -> [{nquic_packet_number:t(), nquic_packet_number:t()}].
prune_received_ranges([], _Threshold) ->
[];
prune_received_ranges([{_H, L} = R | Rest], Threshold) when L > Threshold ->
[R | prune_received_ranges(Rest, Threshold)];
prune_received_ranges([{H, _L} | _Rest], Threshold) when H =< Threshold ->
[];
prune_received_ranges([{H, _L} | _Rest], Threshold) ->
[{H, Threshold + 1}].
-spec queue_ack_for_space(nquic_packet:space(), nquic_protocol:state()) -> nquic_protocol:state().
queue_ack_for_space(initial, State) ->
queue_initial_ack(build_ack_for_space(initial, State), State);
queue_ack_for_space(handshake, State) ->
queue_handshake_ack(build_ack_for_space(handshake, State), State);
queue_ack_for_space(application, #conn_state{pending_ack_count = Count} = State) ->
bump_app_ack(Count + 1, State).
-spec queue_ack_if_eliciting(boolean(), nquic_packet:header(), nquic_protocol:state()) ->
nquic_protocol:state().
queue_ack_if_eliciting(false, _Header, State) ->
State;
queue_ack_if_eliciting(true, Header, State) ->
queue_ack_for_space(nquic_protocol_send:packet_space_from_header(Header), State).
-spec queue_app_ack({ok, #ack{}} | none, nquic_protocol:state()) -> nquic_protocol:state().
queue_app_ack({ok, AckFrame}, State) ->
{ok, State1} = nquic_protocol_send_queues:queue_app_frame(AckFrame, State),
State1;
queue_app_ack(none, State) ->
State.
-spec queue_handshake_ack({ok, #ack{}} | none, nquic_protocol:state()) -> nquic_protocol:state().
queue_handshake_ack({ok, AckFrame}, State) ->
{ok, State1} = nquic_protocol_send_queues:queue_handshake_frame(AckFrame, State),
State1;
queue_handshake_ack(none, State) ->
State.
-spec queue_initial_ack({ok, #ack{}} | none, nquic_protocol:state()) -> nquic_protocol:state().
queue_initial_ack({ok, AckFrame}, State) ->
{ok, State1} = nquic_protocol_send_queues:queue_initial_frame(AckFrame, State),
State1;
queue_initial_ack(none, State) ->
State.
-spec ranges_to_ack_ranges(nquic_packet_number:t(), [
{nquic_packet_number:t(), nquic_packet_number:t()}
]) ->
[#ack_range{}].
ranges_to_ack_ranges(_PrevLow, []) ->
[];
ranges_to_ack_ranges(PrevLow, [{High, Low} | Rest]) ->
Gap = PrevLow - High - 2,
Length = High - Low,
[#ack_range{gap = Gap, length = Length} | ranges_to_ack_ranges(Low, Rest)].
-doc "Increment the per-space ECN counter for the given mark.".
-spec track_ecn_mark(nquic_packet:space(), nquic_socket:ecn_mark(), nquic_protocol:state()) ->
nquic_protocol:state().
track_ecn_mark(_Space, not_ect, State) ->
State;
track_ecn_mark(Space, Mark, #conn_state{pn_spaces = PnSpaces} = State) ->
SpaceMap = maps:get(Space, PnSpaces, #{next_pn => 0}),
Key = ecn_mark_key(Mark),
Old = maps:get(Key, SpaceMap, 0),
NewSpaceMap = SpaceMap#{Key => Old + 1},
State#conn_state{pn_spaces = PnSpaces#{Space => NewSpaceMap}}.
-spec track_received_pn(nquic_packet:space(), nquic_packet_number:t(), nquic_protocol:state()) ->
nquic_protocol:state().
track_received_pn(application, PN, State) ->
PnSpaces = State#conn_state.pn_spaces,
SpaceMap = maps:get(application, PnSpaces, #{next_pn => 0}),
Ranges = maps:get(received_ranges, SpaceMap, []),
NewRanges = insert_pn_range(PN, Ranges),
NewSpaceMap = SpaceMap#{received_ranges => NewRanges},
State#conn_state{
app_largest_received = max(PN, State#conn_state.app_largest_received),
pn_spaces = PnSpaces#{application => NewSpaceMap}
};
track_received_pn(Space, PN, State) ->
#conn_state{pn_spaces = PnSpaces} = State,
SpaceMap = maps:get(Space, PnSpaces, #{next_pn => 0}),
LargestReceived = maps:get(largest_received, SpaceMap, -1),
Ranges = maps:get(received_ranges, SpaceMap, []),
NewRanges = insert_pn_range(PN, Ranges),
NewSpaceMap = SpaceMap#{
largest_received => max(PN, LargestReceived),
received_ranges => NewRanges
},
State#conn_state{pn_spaces = PnSpaces#{Space => NewSpaceMap}}.
-doc """
Fused PN tracking + ECN counter update in a single `pn_spaces` rewrite.
Replaces `track_received_pn/3` followed by `track_ecn_mark/3` on the
recv hot path, halving the `#conn_state{}` record copies and the
outer `pn_spaces` map updates. Behaviourally equivalent to calling
both helpers in sequence.
""".
-spec track_received_pn_and_ecn(
nquic_packet:space(), nquic_packet_number:t(), nquic_socket:ecn_mark(), nquic_protocol:state()
) -> nquic_protocol:state().
track_received_pn_and_ecn(application, PN, ECN, #conn_state{pn_spaces = PnSpaces} = State) ->
SpaceMap = maps:get(application, PnSpaces, #{next_pn => 0}),
Ranges = maps:get(received_ranges, SpaceMap, []),
NewRanges = insert_pn_range(PN, Ranges),
SpaceMap1 = SpaceMap#{received_ranges => NewRanges},
NewSpaceMap =
case ECN of
not_ect ->
SpaceMap1;
_ ->
Key = ecn_mark_key(ECN),
Old = maps:get(Key, SpaceMap1, 0),
SpaceMap1#{Key => Old + 1}
end,
State#conn_state{
app_largest_received = max(PN, State#conn_state.app_largest_received),
pn_spaces = PnSpaces#{application => NewSpaceMap}
};
track_received_pn_and_ecn(Space, PN, ECN, #conn_state{pn_spaces = PnSpaces} = State) ->
SpaceMap = maps:get(Space, PnSpaces, #{next_pn => 0}),
LargestReceived = maps:get(largest_received, SpaceMap, -1),
Ranges = maps:get(received_ranges, SpaceMap, []),
NewRanges = insert_pn_range(PN, Ranges),
SpaceMap1 = SpaceMap#{
largest_received => max(PN, LargestReceived),
received_ranges => NewRanges
},
NewSpaceMap =
case ECN of
not_ect ->
SpaceMap1;
_ ->
Key = ecn_mark_key(ECN),
Old = maps:get(Key, SpaceMap1, 0),
SpaceMap1#{Key => Old + 1}
end,
State#conn_state{pn_spaces = PnSpaces#{Space => NewSpaceMap}}.