Skip to main content

src/nquic_conn_timers.erl

-module(nquic_conn_timers).
-moduledoc """
gen_statem timer adapters for the QUIC connection state machine.

Translates `nquic_protocol`'s abstract timer actions into gen_statem
timeout actions, computes the idle / PTO timer values for the
current state, and runs the PTO probe handler for the
initial / handshake states (the established state delegates PTO to
`nquic_protocol:handle_timeout/2`).
""".

-include("nquic_conn.hrl").
-include("nquic_frame.hrl").
-include("nquic_transport.hrl").
-export([
    ensure_handshake_timers/1,
    handle_pto/2,
    idle_timeout_to_param/1,
    set_idle_timer/1,
    set_pto_timer/1,
    timer_actions_to_statem/1
]).

%%%-----------------------------------------------------------------------------
%% API
%%%-----------------------------------------------------------------------------
-doc """
Ensure idle + PTO timers are armed on a handler result.

Used after the client's first Initial send so the loss-detection
timer is armed even if no packet arrived synchronously (the send-only
path skips `process_datagram`, which is where these timers are
normally set). Subsequent PTO or `process_datagram` calls replace
these actions idempotently.
""".
-spec ensure_handshake_timers(gen_statem:event_handler_result(dynamic())) ->
    gen_statem:event_handler_result(dynamic()).
ensure_handshake_timers({keep_state, Data, Actions}) ->
    {keep_state, Data, Actions ++ set_pto_timer(Data) ++ set_idle_timer(Data)};
ensure_handshake_timers({keep_state, Data}) ->
    {keep_state, Data, set_pto_timer(Data) ++ set_idle_timer(Data)};
ensure_handshake_timers({next_state, NextState, Data, Actions}) ->
    {next_state, NextState, Data, Actions ++ set_pto_timer(Data) ++ set_idle_timer(Data)};
ensure_handshake_timers({stop, _, _} = Stop) ->
    Stop.

-doc """
Handle a PTO firing in the initial / handshake states.
Queues a PING probe at the relevant encryption level, flushes, and
restarts the PTO timer. The established state delegates PTO to
`nquic_protocol:handle_timeout/2` instead.
""".
-spec handle_pto(initial | handshake, #conn_state{}) ->
    gen_statem:event_handler_result(term()).
handle_pto(StateName, Data) ->
    LossState0 = Data#conn_state.loss_state,
    LossState1 = nquic_loss:on_pto(LossState0),
    Data1 = Data#conn_state{loss_state = LossState1},
    PingFrame = #ping{},
    Data2 =
        case StateName of
            initial ->
                Keys = (Data1#conn_state.crypto)#conn_crypto.keys,
                case maps:is_key(initial, Keys) of
                    true ->
                        {ok, D} = nquic_protocol_send_queues:queue_initial_frame(PingFrame, Data1),
                        D;
                    false ->
                        Data1
                end;
            handshake ->
                {ok, D} = nquic_protocol_send_queues:queue_handshake_frame(PingFrame, Data1),
                D
        end,
    {Data3, _Timers} = nquic_conn_statem:flush_and_send(Data2),
    Actions = set_pto_timer(Data3),
    {keep_state, Data3, Actions}.

-doc """
Convert a user-facing `idle_timeout` option to the transport-parameter
value: `infinity` becomes `0` (RFC 9000 §18.2: "no limit advertised").
""".
-spec idle_timeout_to_param(timeout() | non_neg_integer()) -> non_neg_integer().
idle_timeout_to_param(infinity) -> 0;
idle_timeout_to_param(Ms) when is_integer(Ms), Ms >= 0 -> Ms.

-doc """
Compute the gen_statem `idle_timeout` action for the current state.
RFC 9000 §10.1: effective timeout is `min(local, remote)`; `0` means
disabled, in which case no timer is armed (returned as an empty list).
""".
-spec set_idle_timer(#conn_state{}) -> [gen_statem:action()].
set_idle_timer(#conn_state{local_params = LocalParams, remote_params = RemoteParams}) ->
    Local = LocalParams#transport_params.max_idle_timeout,
    Remote =
        case RemoteParams of
            undefined -> 0;
            #transport_params{max_idle_timeout = R} -> R
        end,
    case nquic_protocol:get_idle_timeout(Local, Remote) of
        infinity -> [];
        Timeout -> [{{timeout, idle_timeout}, Timeout, idle_fire}]
    end.

-doc """
Compute the gen_statem `pto_timeout` action for the current state.
When no ack-eliciting packet is in flight, returns a PTO cancellation.
""".
-spec set_pto_timer(#conn_state{}) -> [gen_statem:action()].
set_pto_timer(#conn_state{loss_state = LossState, remote_params = RemoteParams}) ->
    case nquic_loss:has_ack_eliciting_in_flight(LossState) of
        true ->
            MaxAckDelayUs = remote_max_ack_delay_us(RemoteParams),
            PtoUs = nquic_loss:get_pto_timeout(LossState, MaxAckDelayUs),
            PtoMs = max(1, (PtoUs + 999) div 1000),
            [{{timeout, pto_timeout}, PtoMs, pto_fire}];
        false ->
            [{{timeout, pto_timeout}, infinity, undefined}]
    end.

-doc """
Translate `nquic_protocol`'s timer actions into gen_statem timeout actions.
Tail-recursive with accumulator; order doesn't matter for gen_statem
actions. `nquic_protocol` only emits `cancel_timer` for the PTO timer
today; other timers replace themselves implicitly when re-armed. If
the protocol starts cancelling more types, Dialyzer flags the missing
clauses.
""".
-spec timer_actions_to_statem([nquic_protocol:timeout_action()]) -> [gen_statem:action()].
timer_actions_to_statem(Actions) ->
    timer_actions_to_statem(Actions, []).

%%%-----------------------------------------------------------------------------
%% INTERNAL
%%%-----------------------------------------------------------------------------
-spec remote_max_ack_delay_us(#transport_params{} | undefined) -> non_neg_integer().
remote_max_ack_delay_us(undefined) ->
    0;
remote_max_ack_delay_us(#transport_params{max_ack_delay = MAD}) ->
    MAD * 1000.

-spec timer_actions_to_statem([nquic_protocol:timeout_action()], [gen_statem:action()]) ->
    [gen_statem:action()].
timer_actions_to_statem([], Acc) ->
    Acc;
timer_actions_to_statem([{set_timer, idle, Ms} | Rest], Acc) ->
    timer_actions_to_statem(Rest, [{{timeout, idle_timeout}, Ms, idle_fire} | Acc]);
timer_actions_to_statem([{set_timer, pto, Ms} | Rest], Acc) ->
    timer_actions_to_statem(Rest, [{{timeout, pto_timeout}, Ms, pto_fire} | Acc]);
timer_actions_to_statem([{set_timer, path_validation, Ms} | Rest], Acc) ->
    timer_actions_to_statem(Rest, [{{timeout, path_validation}, Ms, path_validation_fire} | Acc]);
timer_actions_to_statem([{set_timer, ack_delay, Ms} | Rest], Acc) ->
    timer_actions_to_statem(Rest, [{{timeout, ack_delay}, Ms, ack_delay_fire} | Acc]);
timer_actions_to_statem([{cancel_timer, pto} | Rest], Acc) ->
    timer_actions_to_statem(Rest, [{{timeout, pto_timeout}, infinity, undefined} | Acc]).