%%% -*- erlang -*- %%% %%% QUIC Path MTU Discovery (DPLPMTUD) %%% RFC 8899 - Packetization Layer Path MTU Discovery %%% %%% Copyright (c) 2024-2026 Benoit Chesneau %%% Apache License 2.0 %%% %%% @doc QUIC Path MTU Discovery implementation. %%% %%% Implements Datagram Packetization Layer Path MTU Discovery (DPLPMTUD) %%% to dynamically discover the optimal packet size for a path. %%% %%% == State Machine == %%% %%% The PMTU discovery follows RFC 8899 states: %%% %%% %%% == Binary Search Algorithm == %%% %%% The module uses binary search between 1200 and max_mtu, %%% stopping when the search range is less than 10 bytes. %%% -module(quic_pmtu). -include("quic.hrl"). -include_lib("kernel/include/logger.hrl"). -define(QUIC_LOG_META, #{domain => [erlang_quic, pmtu]}). -export([ %% State management new/0, new/1, %% Connection lifecycle on_connection_established/2, on_path_change/1, on_raise_timer/1, %% Probe lifecycle should_probe/1, create_probe_packet/2, on_probe_sent/2, on_probe_acked/3, on_probe_lost/3, on_probe_timeout/1, %% Black hole detection on_packet_lost/2, on_packet_acked/1, %% Queries current_mtu/1, is_enabled/1, get_state/1, get_generation/1, %% Timer management cancel_timers/1, set_probe_timer/2, set_raise_timer/2 ]). -type pmtu_opts() :: #{ pmtu_enabled => boolean(), pmtu_max_mtu => pos_integer(), pmtu_probe_timeout => pos_integer(), pmtu_raise_interval => pos_integer() }. -export_type([pmtu_opts/0]). %%==================================================================== %% State Management %%==================================================================== %% @doc Create a new PMTU state with default settings. -spec new() -> #pmtu_state{}. new() -> new(#{}). %% @doc Create a new PMTU state with options. %% %% Options: %% - `pmtu_enabled': Enable PMTU discovery (default: true) %% - `pmtu_max_mtu': Maximum MTU to probe (default: 1500) %% %% @param Opts Configuration options %% @returns New PMTU state record -spec new(pmtu_opts()) -> #pmtu_state{}. new(Opts) -> Enabled = maps:get(pmtu_enabled, Opts, true), MaxMTU = maps:get(pmtu_max_mtu, Opts, 1500), BaseMTU = 1200, %% When disabled, set max_mtu = base_mtu to prevent probing %% on_connection_established will check if there's room to probe EffectiveMaxMTU = case Enabled of true -> MaxMTU; false -> BaseMTU end, #pmtu_state{ state = disabled, base_mtu = BaseMTU, current_mtu = BaseMTU, max_mtu = EffectiveMaxMTU, search_min = BaseMTU, lost = [EffectiveMaxMTU, undefined, undefined], last_probe_lost = false, generation = 0 }. %%==================================================================== %% Connection Lifecycle %%==================================================================== %% @doc Initialize PMTU probing after connection is established. %% %% Called when the QUIC handshake completes. Uses the peer's %% `max_udp_payload_size' transport parameter to set the upper bound. -spec on_connection_established(pos_integer() | undefined, #pmtu_state{}) -> #pmtu_state{}. on_connection_established(PeerMax, #pmtu_state{max_mtu = ConfigMax, base_mtu = BaseMTU} = State) -> %% Determine effective max MTU EffectiveMax = case PeerMax of undefined -> ConfigMax; _ -> min(PeerMax, ConfigMax) end, %% Only enable probing if there's room to grow case EffectiveMax > BaseMTU of true -> ?LOG_DEBUG( #{ what => pmtu_enabled, base_mtu => BaseMTU, max_mtu => EffectiveMax, peer_max => PeerMax, config_max => ConfigMax }, ?QUIC_LOG_META ), State#pmtu_state{ state = base, max_mtu = EffectiveMax, lost = [EffectiveMax, undefined, undefined] }; false -> %% No room to probe - stay disabled State end. %% @doc Reset PMTU state on path change (connection migration). %% %% RFC 8899 Section 5.3.3: PMTU should be reset on path change %% since the new path may have different MTU characteristics. %% %% @param PMTUState Current PMTU state %% @returns Reset PMTU state at base MTU -spec on_path_change(#pmtu_state{}) -> #pmtu_state{}. on_path_change(#pmtu_state{state = disabled} = State) -> State; on_path_change(#pmtu_state{base_mtu = BaseMTU, max_mtu = MaxMTU, generation = Gen} = State) -> %% Cancel any pending timers State1 = cancel_timers(State), ?LOG_DEBUG( #{ what => pmtu_path_change, old_mtu => State#pmtu_state.current_mtu, new_mtu => BaseMTU, generation => Gen + 1 }, ?QUIC_LOG_META ), State1#pmtu_state{ state = base, current_mtu = BaseMTU, probe_size = 0, probe_pn = undefined, search_min = BaseMTU, lost = [MaxMTU, undefined, undefined], last_probe_lost = false, generation = Gen + 1, black_hole_count = 0 }. %% @doc Handle raise timer expiration for periodic re-probing. %% %% Unlike on_path_change, this keeps current_mtu and probes higher. %% Used for periodic attempts to increase MTU without dropping throughput. %% %% @param PMTUState Current PMTU state %% @returns Updated PMTU state ready to probe upward -spec on_raise_timer(#pmtu_state{}) -> #pmtu_state{}. on_raise_timer(#pmtu_state{state = disabled} = State) -> State; on_raise_timer(#pmtu_state{state = searching} = State) -> %% Already searching, just clear timer State#pmtu_state{raise_timer = undefined}; on_raise_timer(#pmtu_state{current_mtu = CurrentMTU, max_mtu = MaxMTU} = State) -> case CurrentMTU >= MaxMTU of true -> %% Already at max, nothing to probe State#pmtu_state{raise_timer = undefined}; false -> %% Start probing from current MTU upward ?LOG_DEBUG( #{what => pmtu_raise_timer, current_mtu => CurrentMTU, max_mtu => MaxMTU}, ?QUIC_LOG_META ), State#pmtu_state{ state = searching, search_min = CurrentMTU, lost = [MaxMTU, undefined, undefined], last_probe_lost = false, probe_size = 0, probe_pn = undefined, raise_timer = undefined } end. %%==================================================================== %% Probe Lifecycle %%==================================================================== %% @doc Check if we should send a probe packet. %% %% Returns true if: %% - State is `base' (ready to start probing) %% - State is `searching' and no probe is in flight %% - State is `error' and raise timer expired %% %% @param PMTUState Current PMTU state %% @returns true if a probe should be sent -spec should_probe(#pmtu_state{}) -> boolean(). should_probe(#pmtu_state{state = disabled}) -> false; should_probe(#pmtu_state{state = base}) -> true; should_probe(#pmtu_state{state = searching, probe_pn = undefined}) -> true; should_probe(#pmtu_state{state = error, raise_timer = undefined}) -> %% Ready to re-probe after error recovery true; should_probe(_) -> false. %% @doc Create a probe packet (PING + PADDING frames). %% %% Creates a packet with PING frame and PADDING to reach the target size. %% The probe size is calculated using binary search. %% %% @param PMTUState Current PMTU state %% @param HeaderSize Size of packet headers (to account for in padding) %% @returns {ProbeSize, Frames} where Frames is [ping, {padding, N}] -spec create_probe_packet(#pmtu_state{}, pos_integer()) -> {pos_integer(), [term()]}. create_probe_packet(#pmtu_state{state = State} = PMTUState, HeaderSize) when State =:= base; State =:= searching; State =:= error -> ProbeSize = next_probe_size(PMTUState), %% Calculate padding needed %% Packet = Header + PING (1 byte) + PADDING PingSize = 1, PaddingNeeded = max(0, ProbeSize - HeaderSize - PingSize), Frames = [ping, {padding, PaddingNeeded}], {ProbeSize, Frames}; create_probe_packet(PMTUState, _HeaderSize) -> %% Not in a probing state - return current MTU with empty frames {PMTUState#pmtu_state.current_mtu, []}. %% @doc Record that a probe packet was sent. %% %% @param PacketNumber Packet number of the sent probe %% @param PMTUState Current PMTU state %% @returns {Generation, UpdatedPMTUState} tuple with generation for tracking -spec on_probe_sent(non_neg_integer(), #pmtu_state{}) -> {non_neg_integer(), #pmtu_state{}}. on_probe_sent(PacketNumber, #pmtu_state{state = State, generation = Gen} = PMTUState) when State =:= base; State =:= error -> ProbeSize = next_probe_size(PMTUState), ?LOG_DEBUG( #{ what => pmtu_probe_sent, pn => PacketNumber, probe_size => ProbeSize, state => State, generation => Gen }, ?QUIC_LOG_META ), {Gen, PMTUState#pmtu_state{ state = searching, probe_size = ProbeSize, probe_pn = PacketNumber }}; on_probe_sent( PacketNumber, #pmtu_state{state = searching, probe_size = 0, generation = Gen} = PMTUState ) -> %% Starting a new probe after previous succeeded ProbeSize = next_probe_size(PMTUState), ?LOG_DEBUG( #{ what => pmtu_probe_sent, pn => PacketNumber, probe_size => ProbeSize, state => searching, generation => Gen }, ?QUIC_LOG_META ), {Gen, PMTUState#pmtu_state{ probe_size = ProbeSize, probe_pn = PacketNumber }}; on_probe_sent(PacketNumber, #pmtu_state{state = searching, generation = Gen} = PMTUState) -> %% Retrying a probe that was lost or timed out ProbeSize = PMTUState#pmtu_state.probe_size, ?LOG_DEBUG( #{ what => pmtu_probe_retry, pn => PacketNumber, probe_size => ProbeSize, generation => Gen }, ?QUIC_LOG_META ), {Gen, PMTUState#pmtu_state{probe_pn = PacketNumber}}; on_probe_sent(_PacketNumber, #pmtu_state{generation = Gen} = PMTUState) -> {Gen, PMTUState}. %% @doc Handle probe packet ACK. %% %% Called when the probe packet is acknowledged. %% On success, updates search bounds and MTU using the loss array algorithm. %% %% @param PacketNumber Packet number that was ACKed %% @param Generation Generation when probe was sent (for stale detection) %% @param PMTUState Current PMTU state %% @returns Updated PMTU state -spec on_probe_acked(non_neg_integer(), non_neg_integer(), #pmtu_state{}) -> #pmtu_state{}. on_probe_acked( PacketNumber, Gen, #pmtu_state{ state = searching, probe_pn = PacketNumber, generation = Gen } = PMTUState ) -> #pmtu_state{probe_size = ProbeSize, lost = Lost} = PMTUState, %% Probe succeeded - update search_min and remove smaller losses NewLost = remove_losses_below(ProbeSize, Lost), ?LOG_DEBUG( #{ what => pmtu_probe_acked, pn => PacketNumber, probe_size => ProbeSize, new_mtu => ProbeSize, generation => Gen }, ?QUIC_LOG_META ), State1 = PMTUState#pmtu_state{ search_min = ProbeSize, current_mtu = ProbeSize, lost = NewLost, last_probe_lost = false, probe_size = 0, probe_pn = undefined, black_hole_count = 0 }, case search_done(State1) of true -> ?LOG_INFO( #{what => pmtu_search_complete, final_mtu => ProbeSize}, ?QUIC_LOG_META ), State1#pmtu_state{state = search_complete}; false -> State1 end; on_probe_acked(PacketNumber, Gen, #pmtu_state{generation = CurrentGen} = PMTUState) when Gen =/= CurrentGen -> %% Stale ACK from previous generation - ignore ?LOG_DEBUG( #{ what => pmtu_probe_acked_stale, pn => PacketNumber, probe_gen => Gen, current_gen => CurrentGen }, ?QUIC_LOG_META ), PMTUState; on_probe_acked(_PacketNumber, _Gen, PMTUState) -> %% Not our probe packet PMTUState. %% @doc Handle probe packet loss. %% %% Called when the probe packet is detected as lost. %% Uses loss array algorithm: inserts loss into array and adjusts search. %% %% @param PacketNumber Packet number that was lost %% @param Generation Generation when probe was sent (for stale detection) %% @param PMTUState Current PMTU state %% @returns Updated PMTU state -spec on_probe_lost(non_neg_integer(), non_neg_integer(), #pmtu_state{}) -> #pmtu_state{}. on_probe_lost( PacketNumber, Gen, #pmtu_state{ state = searching, probe_pn = PacketNumber, generation = Gen } = PMTUState ) -> #pmtu_state{probe_size = ProbeSize, lost = Lost, search_min = Min} = PMTUState, ?LOG_DEBUG( #{ what => pmtu_probe_lost, pn => PacketNumber, probe_size => ProbeSize, generation => Gen }, ?QUIC_LOG_META ), %% Insert this size into loss array NewLost = insert_loss(ProbeSize, Lost), State1 = PMTUState#pmtu_state{ lost = NewLost, last_probe_lost = true, probe_pn = undefined, probe_size = 0 }, case search_done(State1) of true -> ?LOG_INFO( #{what => pmtu_search_complete, final_mtu => Min}, ?QUIC_LOG_META ), State1#pmtu_state{state = search_complete, current_mtu = Min}; false -> State1 end; on_probe_lost(PacketNumber, Gen, #pmtu_state{generation = CurrentGen} = PMTUState) when Gen =/= CurrentGen -> %% Stale loss from previous generation - ignore ?LOG_DEBUG( #{ what => pmtu_probe_lost_stale, pn => PacketNumber, probe_gen => Gen, current_gen => CurrentGen }, ?QUIC_LOG_META ), PMTUState; on_probe_lost(_PacketNumber, _Gen, PMTUState) -> PMTUState. %% @doc Handle probe timeout. %% %% Called when the probe timer fires. %% Treats timeout as loss and uses loss array algorithm. %% %% @param PMTUState Current PMTU state %% @returns Updated PMTU state -spec on_probe_timeout(#pmtu_state{}) -> #pmtu_state{}. on_probe_timeout(#pmtu_state{state = searching} = PMTUState) -> #pmtu_state{ probe_size = ProbeSize, lost = Lost, search_min = Min, probe_pn = ProbePn } = PMTUState, ?LOG_DEBUG( #{what => pmtu_probe_timeout, probe_size => ProbeSize}, ?QUIC_LOG_META ), State1 = PMTUState#pmtu_state{probe_timer = undefined}, case ProbePn of undefined -> %% No probe in flight State1; _ -> %% Treat timeout as loss - insert into loss array NewLost = insert_loss(ProbeSize, Lost), State2 = State1#pmtu_state{ lost = NewLost, last_probe_lost = true, probe_pn = undefined, probe_size = 0 }, case search_done(State2) of true -> State2#pmtu_state{state = search_complete, current_mtu = Min}; false -> State2 end end; on_probe_timeout(PMTUState) -> PMTUState#pmtu_state{probe_timer = undefined}. %%==================================================================== %% Black Hole Detection %%==================================================================== %% @doc Track packet loss for black hole detection. %% %% Only counts losses of packets near the current MTU size (within 100 bytes). %% Small packet losses are not indicative of MTU black holes. %% %% @param PacketSize Size of the lost packet %% @param PMTUState Current PMTU state %% @returns Updated PMTU state, possibly in error state -spec on_packet_lost(pos_integer(), #pmtu_state{}) -> #pmtu_state{}. on_packet_lost(_PacketSize, #pmtu_state{state = disabled} = State) -> State; on_packet_lost(PacketSize, #pmtu_state{state = search_complete, current_mtu = CurrentMTU} = State) -> %% Only count losses of large packets (within 100 bytes of current MTU) case PacketSize >= CurrentMTU - 100 of true -> #pmtu_state{ black_hole_count = Count, black_hole_threshold = Threshold, base_mtu = BaseMTU, max_mtu = MaxMTU } = State, NewCount = Count + 1, case NewCount >= Threshold of true -> %% Black hole detected - fall back to base MTU ?LOG_WARNING( #{ what => pmtu_black_hole_detected, losses => NewCount, packet_size => PacketSize, old_mtu => CurrentMTU, new_mtu => BaseMTU }, ?QUIC_LOG_META ), State1 = cancel_timers(State), State1#pmtu_state{ state = error, current_mtu = BaseMTU, search_min = BaseMTU, lost = [MaxMTU, undefined, undefined], last_probe_lost = false, black_hole_count = 0, probe_size = 0, probe_pn = undefined }; false -> State#pmtu_state{black_hole_count = NewCount} end; false -> %% Small packet loss - not indicative of MTU black hole State end; on_packet_lost(_PacketSize, State) -> State. %% @doc Reset black hole counter on successful ACK. -spec on_packet_acked(#pmtu_state{}) -> #pmtu_state{}. on_packet_acked(#pmtu_state{black_hole_count = 0} = State) -> State; on_packet_acked(State) -> State#pmtu_state{black_hole_count = 0}. %%==================================================================== %% Queries %%==================================================================== %% @doc Get the current effective MTU. -spec current_mtu(#pmtu_state{}) -> pos_integer(). current_mtu(#pmtu_state{current_mtu = MTU}) -> MTU. %% @doc Check if PMTU discovery is enabled. -spec is_enabled(#pmtu_state{}) -> boolean(). is_enabled(#pmtu_state{state = disabled}) -> false; is_enabled(_) -> true. %% @doc Get the current state. -spec get_state(#pmtu_state{}) -> disabled | base | searching | search_complete | error. get_state(#pmtu_state{state = State}) -> State. %% @doc Get the current generation. -spec get_generation(#pmtu_state{}) -> non_neg_integer(). get_generation(#pmtu_state{generation = Gen}) -> Gen. %%==================================================================== %% Timer Management %%==================================================================== %% @doc Cancel all pending timers. -spec cancel_timers(#pmtu_state{}) -> #pmtu_state{}. cancel_timers(#pmtu_state{probe_timer = ProbeTimer, raise_timer = RaiseTimer} = State) -> cancel_timer(ProbeTimer), cancel_timer(RaiseTimer), State#pmtu_state{ probe_timer = undefined, raise_timer = undefined }. %% @doc Set the probe timeout timer. -spec set_probe_timer(reference(), #pmtu_state{}) -> #pmtu_state{}. set_probe_timer(TimerRef, State) -> cancel_timer(State#pmtu_state.probe_timer), State#pmtu_state{probe_timer = TimerRef}. %% @doc Set the raise timer for periodic re-probing. -spec set_raise_timer(reference(), #pmtu_state{}) -> #pmtu_state{}. set_raise_timer(TimerRef, State) -> cancel_timer(State#pmtu_state.raise_timer), State#pmtu_state{raise_timer = TimerRef}. %%==================================================================== %% Internal Functions %%==================================================================== %% @private Get effective max from loss array (first defined value). %% The loss array is sorted ascending, so the first defined value is the smallest loss. -spec get_max(#pmtu_state{}) -> pos_integer(). get_max(#pmtu_state{lost = Lost}) -> hd([V || V <- Lost, V =/= undefined]). %% @private Check if search is done (within threshold). -spec search_done(#pmtu_state{}) -> boolean(). search_done(#pmtu_state{search_min = Min} = State) -> Max = get_max(State), Max - Min =< ?PMTU_SEARCH_THRESHOLD. %% @private Calculate the next probe size using the quic-go loss array algorithm. %% If last probe was lost: probe (search_min + lost[0]) / 2 %% Otherwise: probe (search_min + get_max()) / 2 -spec next_probe_size(#pmtu_state{}) -> pos_integer(). next_probe_size(#pmtu_state{state = searching, probe_size = ProbeSize}) when ProbeSize > 0 -> %% In active search - use current probe size ProbeSize; next_probe_size(#pmtu_state{search_min = Min, last_probe_lost = true, lost = [Lost0 | _]}) when Lost0 =/= undefined -> %% After loss, probe between min and smallest loss point (Min + Lost0) div 2; next_probe_size(#pmtu_state{search_min = Min} = State) -> %% Normal case: probe between min and max Max = get_max(State), (Min + Max) div 2. %% @private Insert loss into array (sorted ascending, max 3 elements). %% This implements the quic-go pattern where we track up to 3 loss points. -spec insert_loss(pos_integer(), [pos_integer() | undefined]) -> [pos_integer() | undefined]. insert_loss(Size, Lost) -> Defined = [V || V <- Lost, V =/= undefined], %% Insert and sort Sorted = lists:sort([Size | Defined]), %% Keep only first 3 (smallest values) Trimmed = lists:sublist(Sorted, 3), %% Pad to 3 elements with undefined Trimmed ++ lists:duplicate(3 - length(Trimmed), undefined). %% @private Remove losses smaller than confirmed size. %% When a probe succeeds at size S, all losses < S are no longer relevant. -spec remove_losses_below(pos_integer(), [pos_integer() | undefined]) -> [pos_integer() | undefined]. remove_losses_below(Size, Lost) -> Filtered = [V || V <- Lost, V =/= undefined, V > Size], Filtered ++ lists:duplicate(3 - length(Filtered), undefined). %% @private Cancel a timer if it's set. -spec cancel_timer(reference() | undefined) -> ok. cancel_timer(undefined) -> ok; cancel_timer(TimerRef) -> erlang:cancel_timer(TimerRef), ok.