%%% -*- erlang -*- %%% %%% QUIC BBRv3 Congestion Control %%% IETF Draft: draft-ietf-ccwg-bbr %%% RFC 9406 - HyStart++: Modified Slow Start for TCP %%% %%% Copyright (c) 2024-2026 Benoit Chesneau %%% Apache License 2.0 %%% %%% @doc QUIC BBRv3 congestion control implementation. %%% %%% BBRv3 (Bottleneck Bandwidth and Round-trip propagation time) is a %%% model-based congestion control algorithm that aims to maximize throughput %%% while minimizing latency and packet loss. %%% %%% HyStart++ (RFC 9406) provides additional RTT-based startup exit detection %%% alongside BBR's bandwidth plateau detection for earlier, safer exits. %%% %%% == States == %%% %%% 1. Startup: Exponential bandwidth probing (2.77x pacing gain) with HyStart++ RTT monitoring %%% 2. Drain: Reduce queue built during startup (0.35x pacing gain) %%% 3. ProbeBW: Steady-state with cycling phases (DOWN/CRUISE/REFILL/UP) %%% 4. ProbeRTT: Periodic RTT measurement with reduced cwnd %%% %%% == Key Concepts == %%% %%% - BDP (Bandwidth-Delay Product): max_bw * min_rtt %%% - Pacing Rate: pacing_gain * max_bw * 0.99 %%% - CWND: cwnd_gain * BDP (minimum 4 packets) %%% - Delivery Rate: Measured bandwidth from ACK feedback -module(quic_cc_bbr). -behaviour(quic_cc). -include_lib("kernel/include/logger.hrl"). -define(QUIC_LOG_META, #{domain => [erlang_quic, congestion_control]}). -export([ %% Behavior callbacks new/1, on_packet_sent/2, on_packets_acked/2, on_packets_acked/3, on_packets_lost/2, on_congestion_event/2, on_ecn_ce/2, on_persistent_congestion/1, detect_persistent_congestion/3, update_pacing_rate/2, update_mtu/2, cwnd/1, ssthresh/1, bytes_in_flight/1, can_send/2, can_send_control/2, available_cwnd/1, in_slow_start/1, in_recovery/1, pacing_allows/2, get_pacing_tokens/2, pacing_delay/2, send_check/3, max_datagram_size/1, min_recovery_duration/1, ecn_ce_counter/1 ]). %%==================================================================== %% BBRv3 Constants %%==================================================================== %% Pacing gains (matched to Google quiche BBR2) -define(STARTUP_PACING_GAIN, 2.885). -define(DRAIN_PACING_GAIN, 0.347). -define(PROBE_BW_DOWN_PACING_GAIN, 0.91). %% Initial RTT estimate when no samples exist (milliseconds) -define(INITIAL_RTT, 100). -define(PROBE_BW_CRUISE_PACING_GAIN, 1.0). -define(PROBE_BW_REFILL_PACING_GAIN, 1.0). -define(PROBE_BW_UP_PACING_GAIN, 1.25). %% CWND gains -define(DEFAULT_CWND_GAIN, 2.0). -define(PROBE_RTT_CWND_GAIN, 0.5). %% Loss handling -define(LOSS_THRESH, 0.02). -define(BETA, 0.7). %% Minimum cwnd in packets -define(MIN_PIPE_CWND, 4). %% Timing constants (microseconds internally; loopback RTTs lose %% signal at ms granularity so every timestamp field below is µs). -define(PROBE_RTT_DURATION, 200 * 1000). -define(PROBE_RTT_INTERVAL, 5000 * 1000). -define(MIN_RTT_FILTER_LEN, 10000 * 1000). -define(MAX_BW_FILTER_LEN, 2). %% Minimum ACK interval before falling back to initial_rtt (µs). %% Below this, delivery-rate samples are unreliable (e.g. coalesced %% ACKs on loopback) and would artificially depress max_bw. -define(MIN_ACK_INTERVAL_US, 200). %% Pacing margin (1% headroom) -define(PACING_MARGIN, 0.99). %% Startup exit threshold (25% growth) -define(STARTUP_GROWTH_TARGET, 1.25). -define(STARTUP_FULL_BW_ROUNDS, 3). %% Default values -define(MAX_DATAGRAM_SIZE, 1200). -define(PERSISTENT_CONGESTION_THRESHOLD, 3). %% HyStart++ constants (RFC 9406) %% Minimum RTT samples before making slow start exit decision -define(HYSTART_MIN_SAMPLES, 8). %% Dynamic RTT threshold bounds (RFC 9406) %% Minimum RTT threshold in milliseconds -define(HYSTART_MIN_RTT_THRESH, 4). %% Maximum RTT threshold in milliseconds -define(HYSTART_MAX_RTT_THRESH, 16). %% Divisor for baseline RTT to calculate dynamic threshold -define(HYSTART_MIN_RTT_DIVISOR, 8). %%==================================================================== %% BBRv3 State Record %%==================================================================== -record(bbr_state, { %% State Machine state = startup :: startup | drain | probe_bw | probe_rtt, probe_bw_phase = down :: down | cruise | refill | up, cycle_count = 0 :: non_neg_integer(), %% Bandwidth Model (windowed max filter) max_bw = 0 :: non_neg_integer(), max_bw_filter = [] :: list(), %% RTT Model (windowed min filter) initial_rtt = ?INITIAL_RTT :: pos_integer(), min_rtt = infinity :: non_neg_integer() | infinity, min_rtt_stamp = 0 :: non_neg_integer(), %% Delivery Rate Sampling delivered = 0 :: non_neg_integer(), delivered_time = 0 :: non_neg_integer(), first_sent_time = 0 :: non_neg_integer(), %% Gains pacing_gain = ?STARTUP_PACING_GAIN :: float(), cwnd_gain = ?DEFAULT_CWND_GAIN :: float(), %% CWND and Pacing cwnd = 0 :: non_neg_integer(), prior_cwnd = 0 :: non_neg_integer(), pacing_rate = 0 :: non_neg_integer(), %% Bytes Tracking bytes_in_flight = 0 :: non_neg_integer(), %% Round Tracking round_count = 0 :: non_neg_integer(), round_start = 0 :: non_neg_integer(), next_round_delivered = 0 :: non_neg_integer(), %% Startup Exit Detection startup_full_bw = 0 :: non_neg_integer(), startup_full_bw_count = 0 :: non_neg_integer(), %% Loss Handling (BBRv3) loss_in_round = 0 :: non_neg_integer(), bytes_in_round = 0 :: non_neg_integer(), inflight_hi = infinity :: non_neg_integer() | infinity, %% ProbeRTT probe_rtt_done_stamp = 0 :: non_neg_integer(), probe_rtt_min_stamp = 0 :: non_neg_integer(), %% Recovery (for interface compatibility) in_recovery = false :: boolean(), recovery_start_time = 0 :: non_neg_integer(), %% ECN ecn_ce_counter = 0 :: non_neg_integer(), %% Configuration max_datagram_size = ?MAX_DATAGRAM_SIZE :: pos_integer(), minimum_window = 2400 :: non_neg_integer(), min_recovery_duration = 100 :: non_neg_integer(), %% Control message allowance control_allowance = 1200 :: non_neg_integer(), %% Pacing Tokens pacing_tokens = 0 :: non_neg_integer(), pacing_max_burst = 14400 :: non_neg_integer(), last_pacing_update = 0 :: non_neg_integer(), %% HyStart++ state (RFC 9406) %% Whether HyStart++ is enabled hystart_enabled = true :: boolean(), %% RTT sample count in current round hystart_rtt_sample_count = 0 :: non_neg_integer(), %% Last round's minimum RTT (milliseconds) hystart_last_rtt = 0 :: non_neg_integer(), %% Current round's minimum RTT (milliseconds) hystart_curr_rtt = infinity :: non_neg_integer() | infinity }). -opaque cc_state() :: #bbr_state{}. -export_type([cc_state/0]). %%==================================================================== %% Behavior Callbacks - State Management %%==================================================================== %% @doc Create a new BBRv3 congestion control state. -spec new(quic_cc:cc_opts()) -> cc_state(). new(Opts) -> MaxDatagramSize = maps:get(max_datagram_size, Opts, ?MAX_DATAGRAM_SIZE), MinimumWindow = maps:get(minimum_window, Opts, 2 * MaxDatagramSize), %% Public option is milliseconds; store as µs internally. MinRecoveryDurationMs = maps:get(min_recovery_duration, Opts, 100), MinRecoveryDuration = MinRecoveryDurationMs * 1000, InitialRtt = maps:get(initial_rtt, Opts, ?INITIAL_RTT), HystartEnabled = maps:get(hystart_enabled, Opts, true), PacingMaxBurst = 12 * MaxDatagramSize, Now = erlang:monotonic_time(microsecond), %% Initial cwnd: 10 packets or BDP estimate InitialCwnd = max( maps:get(initial_window, Opts, 10 * MaxDatagramSize), ?MIN_PIPE_CWND * MaxDatagramSize ), %% Initial bandwidth estimate: cwnd / rtt in bytes/sec. %% InitialRtt is ms, so multiply by 1000 to get bytes/sec. InitialMaxBw = (InitialCwnd * 1000) div InitialRtt, %% Initial pacing rate (bytes/sec): startup_gain * max_bw. InitialPacingRate = trunc(?STARTUP_PACING_GAIN * InitialMaxBw), ?LOG_DEBUG( #{ what => cc_state_initialized, algorithm => bbr, initial_cwnd => InitialCwnd, minimum_window => MinimumWindow, max_datagram_size => MaxDatagramSize, initial_rtt => InitialRtt, initial_pacing_rate => InitialPacingRate, initial_max_bw => InitialMaxBw, hystart_enabled => HystartEnabled }, ?QUIC_LOG_META ), #bbr_state{ state = startup, pacing_gain = ?STARTUP_PACING_GAIN, cwnd_gain = ?DEFAULT_CWND_GAIN, cwnd = InitialCwnd, initial_rtt = InitialRtt, max_bw = InitialMaxBw, pacing_rate = InitialPacingRate, minimum_window = max(MinimumWindow, 2 * MaxDatagramSize), max_datagram_size = MaxDatagramSize, min_recovery_duration = MinRecoveryDuration, pacing_max_burst = PacingMaxBurst, pacing_tokens = PacingMaxBurst, last_pacing_update = Now, min_rtt_stamp = Now, probe_rtt_min_stamp = Now, delivered_time = Now, round_start = Now, %% HyStart++ initialization hystart_enabled = HystartEnabled }. %%==================================================================== %% Behavior Callbacks - Congestion Control Events %%==================================================================== %% @doc Record that a packet was sent. -spec on_packet_sent(cc_state(), non_neg_integer()) -> cc_state(). on_packet_sent( #bbr_state{ bytes_in_flight = InFlight, first_sent_time = 0 } = State, Size ) -> Now = erlang:monotonic_time(microsecond), State#bbr_state{ bytes_in_flight = InFlight + Size, first_sent_time = Now }; on_packet_sent( #bbr_state{ bytes_in_flight = InFlight, bytes_in_round = BytesInRound } = State, Size ) -> State#bbr_state{ bytes_in_flight = InFlight + Size, bytes_in_round = BytesInRound + Size }. %% @doc Process acknowledged packets (2-arg version). -spec on_packets_acked(cc_state(), non_neg_integer()) -> cc_state(). on_packets_acked(State, AckedBytes) -> %% 2-arg path has no sent-time info; use current time so the RTT %% sample on this ACK would be 0 and gets ignored downstream. Now = erlang:monotonic_time(microsecond), do_on_packets_acked(State, AckedBytes, Now, Now). %% @doc Process acknowledged packets with timing info. %% AckTime is the sent-time of the largest acked packet, in ms %% (supplied by quic_connection). We convert to µs on entry and work %% in microsecond precision throughout — loopback RTTs round to 0 ms %% otherwise, which collapses BDP. -spec on_packets_acked(cc_state(), non_neg_integer(), non_neg_integer()) -> cc_state(). on_packets_acked(State, AckedBytes, AckTimeMs) -> Now = erlang:monotonic_time(microsecond), AckTimeUs = AckTimeMs * 1000, do_on_packets_acked(State, AckedBytes, AckTimeUs, Now). do_on_packets_acked( #bbr_state{ bytes_in_flight = InFlight, delivered = Delivered, delivered_time = DeliveredTime } = State, AckedBytes, AckTime, Now ) -> NewInFlight = max(0, InFlight - AckedBytes), NewDelivered = Delivered + AckedBytes, %% Calculate delivery rate State1 = State#bbr_state{ bytes_in_flight = NewInFlight, delivered = NewDelivered }, %% Update delivery rate and bandwidth State2 = update_delivery_rate(State1, AckedBytes, DeliveredTime, Now), %% Update RTT if we have timing info. RttSample = Now - AckTime, µs. RttSample = Now - AckTime, State3 = case RttSample > 0 of true -> update_min_rtt(State2, RttSample, Now); false -> State2 end, %% Check round completion State4 = check_round_completion(State3), %% Run the state machine State5 = run_state_machine(State4, Now), %% Update cwnd and pacing based on new state State6 = update_cwnd(State5), update_bbr_pacing_rate(State6). %% @doc Process lost packets. -spec on_packets_lost(cc_state(), non_neg_integer()) -> cc_state(). on_packets_lost( #bbr_state{ bytes_in_flight = InFlight, loss_in_round = LossInRound } = State, LostBytes ) -> NewInFlight = max(0, InFlight - LostBytes), State#bbr_state{ bytes_in_flight = NewInFlight, loss_in_round = LossInRound + LostBytes }. %% @doc Handle a congestion event (packet loss detected). -spec on_congestion_event(cc_state(), non_neg_integer()) -> cc_state(). %% SentTime is ms (from quic_connection); compare in µs internally. on_congestion_event( #bbr_state{ in_recovery = true, recovery_start_time = RecoveryStart } = State, SentTimeMs ) when (SentTimeMs * 1000) =< RecoveryStart -> %% Already in recovery for packets sent before recovery started State; on_congestion_event( #bbr_state{ in_recovery = true, recovery_start_time = RecoveryStart, min_recovery_duration = MinDuration } = State, _SentTimeMs ) -> Now = erlang:monotonic_time(microsecond), case Now - RecoveryStart < MinDuration of true -> %% Still within protected recovery period State; false -> do_bbr_congestion_event(State) end; on_congestion_event(State, _SentTimeMs) -> do_bbr_congestion_event(State). %% @private BBRv3 congestion response do_bbr_congestion_event( #bbr_state{ bytes_in_round = BytesInRound, loss_in_round = LossInRound, max_bw = MaxBw, bytes_in_flight = InFlight, inflight_hi = InflightHi } = State ) -> Now = erlang:monotonic_time(microsecond), %% Check if loss rate exceeds threshold (2%) LossRate = case BytesInRound > 0 of true -> LossInRound / BytesInRound; false -> 0.0 end, State1 = case LossRate > ?LOSS_THRESH of true -> %% BBRv3: Reduce max_bw by Beta (0.7) on excessive loss NewMaxBw = trunc(MaxBw * ?BETA), %% Set inflight_hi to current in-flight NewInflightHi = case InflightHi of infinity -> InFlight; _ -> min(InflightHi, InFlight) end, ?LOG_DEBUG( #{ what => bbr_loss_response, loss_rate => LossRate, old_max_bw => MaxBw, new_max_bw => NewMaxBw, inflight_hi => NewInflightHi }, ?QUIC_LOG_META ), State#bbr_state{ max_bw = NewMaxBw, inflight_hi = NewInflightHi }; false -> State end, %% Enter recovery State1#bbr_state{ in_recovery = true, recovery_start_time = Now, loss_in_round = 0, bytes_in_round = 0 }. %% @doc Handle ECN-CE signal. -spec on_ecn_ce(cc_state(), non_neg_integer()) -> cc_state(). on_ecn_ce(#bbr_state{ecn_ce_counter = OldCount} = State, NewCECount) when NewCECount =< OldCount -> State; on_ecn_ce(#bbr_state{max_bw = MaxBw} = State, NewCECount) -> %% BBRv3: Treat ECN-CE similar to loss Now = erlang:monotonic_time(microsecond), NewMaxBw = trunc(MaxBw * ?BETA), ?LOG_DEBUG( #{ what => bbr_ecn_ce_response, old_max_bw => MaxBw, new_max_bw => NewMaxBw, ce_count => NewCECount }, ?QUIC_LOG_META ), State#bbr_state{ max_bw = NewMaxBw, ecn_ce_counter = NewCECount, in_recovery = true, recovery_start_time = Now }. %% @doc Handle persistent congestion. -spec on_persistent_congestion(cc_state()) -> cc_state(). on_persistent_congestion(#bbr_state{minimum_window = MinimumWindow} = State) -> %% Reset to Startup with minimum cwnd ?LOG_DEBUG( #{ what => bbr_persistent_congestion, new_cwnd => MinimumWindow }, ?QUIC_LOG_META ), State#bbr_state{ state = startup, pacing_gain = ?STARTUP_PACING_GAIN, cwnd_gain = ?DEFAULT_CWND_GAIN, cwnd = MinimumWindow, max_bw = 0, max_bw_filter = [], min_rtt = infinity, in_recovery = false, recovery_start_time = 0, startup_full_bw = 0, startup_full_bw_count = 0, inflight_hi = infinity, %% Reset HyStart++ state hystart_rtt_sample_count = 0, hystart_last_rtt = 0, hystart_curr_rtt = infinity }. %% @doc Detect persistent congestion from lost packets. -spec detect_persistent_congestion( [{non_neg_integer(), non_neg_integer()}], non_neg_integer(), cc_state() ) -> boolean(). detect_persistent_congestion([], _PTO, _State) -> false; detect_persistent_congestion([_], _PTO, _State) -> false; detect_persistent_congestion(LostPackets, PTO, _State) -> Times = [T || {_PN, T} <- LostPackets], MinTime = lists:min(Times), MaxTime = lists:max(Times), CongestionPeriod = PTO * ?PERSISTENT_CONGESTION_THRESHOLD, (MaxTime - MinTime) >= CongestionPeriod. %%==================================================================== %% Behavior Callbacks - Pacing %%==================================================================== %% @doc Update pacing rate based on smoothed RTT. %% Note: BBR primarily uses its own pacing_rate calculation, %% but this callback allows external RTT info integration. -spec update_pacing_rate(cc_state(), non_neg_integer()) -> cc_state(). update_pacing_rate(State, SmoothedRTT) when SmoothedRTT > 0 -> %% SmoothedRTT is ms from quic_loss; convert to µs internally. Now = erlang:monotonic_time(microsecond), RttUs = SmoothedRTT * 1000, State1 = update_min_rtt(State, RttUs, Now), %% HyStart++ threshold math is expressed in ms in RFC 9406 — %% keep the hystart tracker ms-based. update_hystart_rtt(State1, SmoothedRTT); update_pacing_rate(State, _SmoothedRTT) -> State. %% @doc Check if pacing allows sending Size bytes. -spec pacing_allows(cc_state(), non_neg_integer()) -> boolean(). pacing_allows(#bbr_state{pacing_rate = 0}, _Size) -> true; pacing_allows( #bbr_state{ pacing_tokens = Tokens, pacing_max_burst = MaxBurst, pacing_rate = Rate, last_pacing_update = LastUpdate }, Size ) -> Now = erlang:monotonic_time(microsecond), RefreshedTokens = refill_tokens_at(Tokens, MaxBurst, Rate, LastUpdate, Now), RefreshedTokens >= Size. %% @doc Get tokens for sending. -spec get_pacing_tokens(cc_state(), non_neg_integer()) -> {non_neg_integer(), cc_state()}. get_pacing_tokens(#bbr_state{pacing_rate = 0} = State, Size) -> {Size, State}; get_pacing_tokens( #bbr_state{ pacing_tokens = Tokens, pacing_max_burst = MaxBurst, pacing_rate = Rate, last_pacing_update = LastUpdate } = State, Size ) -> Now = erlang:monotonic_time(microsecond), NewTokens = refill_tokens_at(Tokens, MaxBurst, Rate, LastUpdate, Now), Allowed = min(Size, NewTokens), RemainingTokens = max(0, NewTokens - Allowed), NewState = State#bbr_state{ pacing_tokens = RemainingTokens, last_pacing_update = Now }, {Allowed, NewState}. %% @doc Calculate pacing delay. -spec pacing_delay(cc_state(), non_neg_integer()) -> non_neg_integer(). pacing_delay(#bbr_state{pacing_rate = 0}, _Size) -> 0; pacing_delay( #bbr_state{ pacing_tokens = Tokens, pacing_max_burst = MaxBurst, pacing_rate = Rate, last_pacing_update = LastUpdate }, Size ) -> Now = erlang:monotonic_time(microsecond), CurrentTokens = refill_tokens_at(Tokens, MaxBurst, Rate, LastUpdate, Now), case CurrentTokens >= Size of true -> 0; false -> %% Rate is bytes/sec; return delay in ms (timer granularity). %% Delay = deficit_bytes * 1000 / rate_bps (rounded up, min 1). Deficit = Size - CurrentTokens, SafeRate = max(Rate, 1), max(1, (Deficit * 1000 + SafeRate - 1) div SafeRate) end. %% @doc Fused cwnd + pacing check for the hot send path. -spec send_check(cc_state(), non_neg_integer(), non_neg_integer()) -> {ok, cc_state()} | {blocked_cwnd, non_neg_integer()} | {blocked_pacing, non_neg_integer()}. send_check( #bbr_state{ cwnd = Cwnd, bytes_in_flight = InFlight, control_allowance = Allowance, pacing_rate = Rate } = State, Size, Urgency ) -> CwndLimit = case Urgency of 0 -> Cwnd + Allowance; _ -> Cwnd end, case InFlight + Size =< CwndLimit of false -> {blocked_cwnd, max(0, Cwnd - InFlight)}; true when Rate =:= 0 -> {ok, State}; true -> #bbr_state{ pacing_tokens = Tokens, pacing_max_burst = MaxBurst, last_pacing_update = LastUpdate } = State, Now = erlang:monotonic_time(microsecond), Refreshed = refill_tokens_at(Tokens, MaxBurst, Rate, LastUpdate, Now), case Refreshed >= Size of true -> {ok, State#bbr_state{ pacing_tokens = Refreshed - Size, last_pacing_update = Now }}; false -> %% BBR pacing rate is bytes/sec; return delay in ms. Deficit = Size - Refreshed, SafeRate = max(Rate, 1), DelayMs = max(1, (Deficit * 1000 + SafeRate - 1) div SafeRate), {blocked_pacing, DelayMs} end end. %%==================================================================== %% Behavior Callbacks - MTU Update %%==================================================================== %% @doc Update congestion control state when MTU changes. -spec update_mtu(cc_state(), pos_integer()) -> cc_state(). update_mtu(#bbr_state{max_datagram_size = OldMDS} = State, NewMTU) when NewMTU =:= OldMDS -> State; update_mtu(#bbr_state{max_datagram_size = OldMDS, minimum_window = OldMinWin} = State, NewMTU) -> NewMinimumWindow = max(2 * NewMTU, (OldMinWin * NewMTU) div OldMDS), NewPacingMaxBurst = 12 * NewMTU, ?LOG_DEBUG( #{ what => bbr_mtu_updated, old_mtu => OldMDS, new_mtu => NewMTU, new_minimum_window => NewMinimumWindow }, ?QUIC_LOG_META ), State#bbr_state{ max_datagram_size = NewMTU, minimum_window = NewMinimumWindow, pacing_max_burst = NewPacingMaxBurst }. %%==================================================================== %% Behavior Callbacks - Queries %%==================================================================== %% @doc Get the current congestion window. -spec cwnd(cc_state()) -> non_neg_integer(). cwnd(#bbr_state{cwnd = Cwnd}) -> Cwnd. %% @doc Get the slow start threshold. %% BBR doesn't use ssthresh; returns infinity. -spec ssthresh(cc_state()) -> non_neg_integer() | infinity. ssthresh(_State) -> infinity. %% @doc Get bytes currently in flight. -spec bytes_in_flight(cc_state()) -> non_neg_integer(). bytes_in_flight(#bbr_state{bytes_in_flight = B}) -> B. %% @doc Check if we can send more bytes. -spec can_send(cc_state(), non_neg_integer()) -> boolean(). can_send(#bbr_state{cwnd = Cwnd, bytes_in_flight = InFlight}, Size) -> InFlight + Size =< Cwnd. %% @doc Check if a control message can be sent. -spec can_send_control(cc_state(), non_neg_integer()) -> boolean(). can_send_control( #bbr_state{cwnd = Cwnd, bytes_in_flight = InFlight, control_allowance = Allowance}, Size ) -> InFlight + Size =< Cwnd + Allowance. %% @doc Get the available congestion window. -spec available_cwnd(cc_state()) -> non_neg_integer(). available_cwnd(#bbr_state{cwnd = Cwnd, bytes_in_flight = InFlight}) -> max(0, Cwnd - InFlight). %% @doc Check if in slow start phase (Startup state for BBR). -spec in_slow_start(cc_state()) -> boolean(). in_slow_start(#bbr_state{state = startup}) -> true; in_slow_start(_State) -> false. %% @doc Check if in recovery phase. -spec in_recovery(cc_state()) -> boolean(). in_recovery(#bbr_state{in_recovery = R}) -> R. %% @doc Get the current max datagram size. -spec max_datagram_size(cc_state()) -> pos_integer(). max_datagram_size(#bbr_state{max_datagram_size = MDS}) -> MDS. %% @doc Get minimum recovery duration setting (milliseconds, matching %% the public option contract; stored internally as microseconds). -spec min_recovery_duration(cc_state()) -> non_neg_integer(). min_recovery_duration(#bbr_state{min_recovery_duration = D}) -> D div 1000. %% @doc Get the current ECN-CE counter. -spec ecn_ce_counter(cc_state()) -> non_neg_integer(). ecn_ce_counter(#bbr_state{ecn_ce_counter = C}) -> C. %%==================================================================== %% Internal Functions - Delivery Rate %%==================================================================== %% @private Update delivery rate from ACK feedback. %% Works in microseconds: loopback ACK intervals are ~100-500 µs and %% the old ms-granularity code rounded them to 0–1 ms, wedging %% Interval to InitialRtt (100 ms) on every sample. update_delivery_rate( #bbr_state{ delivered_time = OldDeliveredTime, initial_rtt = InitialRtt, max_bw = MaxBw, max_bw_filter = Filter, round_count = RoundCount } = State, AckedBytes, _OldDeliveredTime, Now ) -> AckElapsed = Now - OldDeliveredTime, %% Below 200 µs the sample is unreliable (coalesced ACKs, burst %% arrivals); fall back to initial_rtt scaled to µs. Interval = case AckElapsed of A when A =< ?MIN_ACK_INTERVAL_US -> InitialRtt * 1000; A -> A end, %% delivery_rate = acked_bytes * 1_000_000 / interval_us → bytes/sec DeliveryRate = (AckedBytes * 1_000_000) div Interval, %% Update max_bw filter (windowed max over last 2 cycles) {NewMaxBw, NewFilter} = update_bw_filter(DeliveryRate, RoundCount, MaxBw, Filter), State#bbr_state{ max_bw = NewMaxBw, max_bw_filter = NewFilter, delivered_time = Now }. %% @private Windowed max filter for bandwidth update_bw_filter(NewBw, RoundCount, _MaxBw, Filter) -> %% Add new sample NewFilter0 = [{RoundCount, NewBw} | Filter], %% Remove old samples (keep last MAX_BW_FILTER_LEN cycles) MinRound = max(0, RoundCount - ?MAX_BW_FILTER_LEN), NewFilter = [{R, B} || {R, B} <- NewFilter0, R >= MinRound], %% Find max in filter MaxBw = case NewFilter of [] -> NewBw; _ -> lists:max([B || {_, B} <- NewFilter]) end, {MaxBw, NewFilter}. %%==================================================================== %% Internal Functions - RTT %%==================================================================== %% @private Update min_rtt from RTT sample update_min_rtt( #bbr_state{ min_rtt = MinRtt, min_rtt_stamp = MinRttStamp } = State, RttSample, Now ) -> %% Check if min_rtt filter has expired FilterExpired = (Now - MinRttStamp) > ?MIN_RTT_FILTER_LEN, NewMinRtt = case FilterExpired orelse RttSample < MinRtt of true -> RttSample; false -> MinRtt end, NewMinRttStamp = case NewMinRtt =:= RttSample of true -> Now; false -> MinRttStamp end, State#bbr_state{ min_rtt = NewMinRtt, min_rtt_stamp = NewMinRttStamp }. %%==================================================================== %% Internal Functions - Round Tracking %%==================================================================== %% @private Check if a round has completed check_round_completion( #bbr_state{ delivered = Delivered, next_round_delivered = NextRoundDelivered, round_count = RoundCount, in_recovery = WasInRecovery, hystart_curr_rtt = CurrRTT } = State ) -> case Delivered >= NextRoundDelivered of true -> %% Round completed - reset per-round state including recovery %% BBRv3: Recovery state is reset per round, allowing fresh %% loss detection in the next round. %% Also reset inflight_hi when exiting recovery to allow %% cwnd to grow again (per BBRv3 spec). NewInflightHi = case WasInRecovery of true -> infinity; false -> State#bbr_state.inflight_hi end, %% HyStart++: Save current round's min RTT as last RTT for next round comparison NewLastRTT = case CurrRTT of infinity -> 0; _ -> CurrRTT end, State#bbr_state{ round_count = RoundCount + 1, next_round_delivered = Delivered, loss_in_round = 0, bytes_in_round = 0, in_recovery = false, inflight_hi = NewInflightHi, %% HyStart++ round tracking hystart_last_rtt = NewLastRTT, hystart_curr_rtt = infinity, hystart_rtt_sample_count = 0 }; false -> State end. %%==================================================================== %% Internal Functions - State Machine %%==================================================================== %% @private Run the BBR state machine run_state_machine(#bbr_state{state = startup} = State, Now) -> run_startup(State, Now); run_state_machine(#bbr_state{state = drain} = State, Now) -> run_drain(State, Now); run_state_machine(#bbr_state{state = probe_bw} = State, Now) -> run_probe_bw(State, Now); run_state_machine(#bbr_state{state = probe_rtt} = State, Now) -> run_probe_rtt(State, Now). %% @private Startup state logic %% BBRv3: Exit STARTUP when bandwidth plateaus (no 25% growth for 3 rounds) %% or when HyStart++ detects RTT increase (RFC 9406). %% Loss is handled by reducing max_bw in on_congestion_event, NOT by exiting STARTUP. %% This prevents premature exit to DRAIN where pacing_gain drops to 0.35x. run_startup( #bbr_state{ max_bw = MaxBw, startup_full_bw = FullBw, startup_full_bw_count = FullBwCount } = State, _Now ) -> %% Check for Startup exit condition: %% 1. Bandwidth hasn't grown by 25% for 3 consecutive rounds %% 2. HyStart++ RTT-based exit (RFC 9406) %% Note: Loss does NOT cause STARTUP exit per BBRv3 spec. %% Loss is handled separately by reducing max_bw in on_congestion_event. {NewFullBw, NewFullBwCount, ExitForBw} = case MaxBw >= trunc(FullBw * ?STARTUP_GROWTH_TARGET) of true -> %% Bandwidth grew by 25%+, reset counter {MaxBw, 0, false}; false when FullBwCount + 1 >= ?STARTUP_FULL_BW_ROUNDS -> %% No growth for 3 rounds, exit {FullBw, FullBwCount + 1, true}; false -> {FullBw, FullBwCount + 1, false} end, State1 = State#bbr_state{ startup_full_bw = NewFullBw, startup_full_bw_count = NewFullBwCount }, %% Check HyStart++ RTT-based exit ExitForHystart = hystart_should_exit(State1), case ExitForBw orelse ExitForHystart of true -> Reason = case ExitForBw of true -> bandwidth_plateau; false -> hystart_rtt_increase end, %% Exit Startup, enter Drain ?LOG_DEBUG( #{ what => bbr_exit_startup, reason => Reason, max_bw => MaxBw }, ?QUIC_LOG_META ), exit_startup(State1); false -> State1 end. %% @private Exit startup and enter drain, resetting HyStart++ state exit_startup(State) -> State#bbr_state{ state = drain, pacing_gain = ?DRAIN_PACING_GAIN, cwnd_gain = ?DEFAULT_CWND_GAIN, %% Reset HyStart++ state hystart_rtt_sample_count = 0, hystart_last_rtt = 0, hystart_curr_rtt = infinity }. %% @private Drain state logic run_drain( #bbr_state{ bytes_in_flight = InFlight, max_bw = MaxBw, min_rtt = MinRtt } = State, Now ) -> %% Exit Drain when inflight <= BDP BDP = calculate_bdp(MaxBw, MinRtt, State), case InFlight =< BDP of true -> %% Enter ProbeBW ?LOG_DEBUG( #{ what => bbr_exit_drain, bytes_in_flight => InFlight, bdp => BDP }, ?QUIC_LOG_META ), enter_probe_bw(State, Now); false -> State end. %% @private Enter ProbeBW state enter_probe_bw(State, _Now) -> State#bbr_state{ state = probe_bw, probe_bw_phase = cruise, pacing_gain = ?PROBE_BW_CRUISE_PACING_GAIN, cwnd_gain = ?DEFAULT_CWND_GAIN, cycle_count = 0 }. %% @private ProbeBW state logic run_probe_bw( #bbr_state{ probe_bw_phase = Phase, min_rtt_stamp = MinRttStamp } = State, Now ) -> %% Check if we need to enter ProbeRTT NeedProbeRtt = (Now - MinRttStamp) > ?PROBE_RTT_INTERVAL, case NeedProbeRtt of true -> enter_probe_rtt(State, Now); false -> advance_probe_bw_phase(State, Phase, Now) end. %% @private Advance ProbeBW phase cycle advance_probe_bw_phase( #bbr_state{ bytes_in_flight = InFlight, max_bw = MaxBw, min_rtt = MinRtt, cycle_count = CycleCount } = State, Phase, _Now ) -> BDP = calculate_bdp(MaxBw, MinRtt, State), %% Cycle through phases: DOWN -> CRUISE -> REFILL -> UP -> DOWN case Phase of down when InFlight =< BDP -> %% Exit DOWN when inflight drains to BDP State#bbr_state{ probe_bw_phase = cruise, pacing_gain = ?PROBE_BW_CRUISE_PACING_GAIN }; cruise -> %% Stay in CRUISE for steady state %% Transition to REFILL to prepare for UP State#bbr_state{ probe_bw_phase = refill, pacing_gain = ?PROBE_BW_REFILL_PACING_GAIN }; refill when InFlight >= BDP -> %% Exit REFILL when pipe is refilled State#bbr_state{ probe_bw_phase = up, pacing_gain = ?PROBE_BW_UP_PACING_GAIN }; up -> %% After UP phase, go to DOWN State#bbr_state{ probe_bw_phase = down, pacing_gain = ?PROBE_BW_DOWN_PACING_GAIN, cycle_count = CycleCount + 1 }; _ -> %% Stay in current phase State end. %% @private Enter ProbeRTT state enter_probe_rtt(#bbr_state{cwnd = Cwnd} = State, Now) -> ?LOG_DEBUG( #{ what => bbr_enter_probe_rtt, prior_cwnd => Cwnd }, ?QUIC_LOG_META ), State#bbr_state{ state = probe_rtt, prior_cwnd = Cwnd, cwnd_gain = ?PROBE_RTT_CWND_GAIN, probe_rtt_done_stamp = Now + ?PROBE_RTT_DURATION, probe_rtt_min_stamp = Now }. %% @private ProbeRTT state logic run_probe_rtt( #bbr_state{ probe_rtt_done_stamp = DoneStamp, bytes_in_flight = InFlight, minimum_window = MinWindow } = State, Now ) -> %% Stay in ProbeRTT until: %% 1. Duration has passed (200ms) %% 2. Inflight is drained to minimum DurationDone = Now >= DoneStamp, InFlightDrained = InFlight =< MinWindow, case DurationDone andalso InFlightDrained of true -> exit_probe_rtt(State, Now); false -> State end. %% @private Exit ProbeRTT state exit_probe_rtt(#bbr_state{prior_cwnd = PriorCwnd} = State, Now) -> ?LOG_DEBUG( #{ what => bbr_exit_probe_rtt, restored_cwnd => PriorCwnd }, ?QUIC_LOG_META ), %% Restore cwnd and return to ProbeBW State#bbr_state{ state = probe_bw, probe_bw_phase = cruise, cwnd = PriorCwnd, cwnd_gain = ?DEFAULT_CWND_GAIN, pacing_gain = ?PROBE_BW_CRUISE_PACING_GAIN, min_rtt_stamp = Now }. %%==================================================================== %% HyStart++ Implementation (RFC 9406) %%==================================================================== %% @private Check if HyStart++ RTT-based exit should occur hystart_should_exit(#bbr_state{ hystart_enabled = true, hystart_rtt_sample_count = Samples, hystart_curr_rtt = CurrRTT, hystart_last_rtt = LastRTT }) when Samples >= ?HYSTART_MIN_SAMPLES, is_integer(CurrRTT), CurrRTT > 0, LastRTT > 0 -> %% Calculate dynamic RTT threshold (RFC 9406) RTTThresh = calculate_rtt_threshold(LastRTT), (CurrRTT - LastRTT) > RTTThresh; hystart_should_exit(_) -> false. %% @private Calculate dynamic RTT threshold (RFC 9406) %% RttThresh = clamp(MIN, lastRTT/DIVISOR, MAX) calculate_rtt_threshold(LastRTT) when LastRTT > 0 -> Threshold = LastRTT div ?HYSTART_MIN_RTT_DIVISOR, max(?HYSTART_MIN_RTT_THRESH, min(Threshold, ?HYSTART_MAX_RTT_THRESH)); calculate_rtt_threshold(_) -> ?HYSTART_MIN_RTT_THRESH. %% @private Update HyStart++ RTT samples %% Called from update_pacing_rate when in startup update_hystart_rtt( #bbr_state{ hystart_enabled = true, hystart_curr_rtt = CurrRTT, hystart_rtt_sample_count = SampleCount, state = startup } = State, RTT ) -> %% In startup, track RTT samples NewCurrRTT = case CurrRTT of infinity -> RTT; _ -> min(CurrRTT, RTT) end, State#bbr_state{ hystart_curr_rtt = NewCurrRTT, hystart_rtt_sample_count = SampleCount + 1 }; update_hystart_rtt(State, _RTT) -> State. %%==================================================================== %% Internal Functions - CWND and Pacing %%==================================================================== %% @private Calculate BDP. max_bw is bytes/sec, min_rtt is µs. calculate_bdp(_MaxBw, infinity, #bbr_state{max_datagram_size = MDS}) -> %% No RTT sample yet, use minimum ?MIN_PIPE_CWND * MDS; calculate_bdp(MaxBw, MinRttUs, #bbr_state{max_datagram_size = MDS}) -> BDP = (MaxBw * MinRttUs) div 1_000_000, max(BDP, ?MIN_PIPE_CWND * MDS). %% @private Update cwnd based on current state update_cwnd( #bbr_state{ state = BbrState, max_bw = MaxBw, min_rtt = MinRtt, cwnd_gain = CwndGain, inflight_hi = InflightHi, minimum_window = MinWindow, max_datagram_size = MDS } = State ) -> %% cwnd = cwnd_gain * BDP BDP = calculate_bdp(MaxBw, MinRtt, State), TargetCwnd = trunc(CwndGain * BDP), %% Apply inflight_hi cap only in ProbeBW state (not in STARTUP/DRAIN) %% BBRv3: During STARTUP we want to probe the full BDP without caps CappedCwnd = case {BbrState, InflightHi} of {startup, _} -> TargetCwnd; {drain, _} -> TargetCwnd; {_, infinity} -> TargetCwnd; {_, _} -> min(TargetCwnd, InflightHi) end, %% Ensure minimum MinCwnd = max(MinWindow, ?MIN_PIPE_CWND * MDS), NewCwnd = max(CappedCwnd, MinCwnd), State#bbr_state{cwnd = NewCwnd}. %% @private Update BBR's pacing rate (bytes/sec, matches max_bw unit). update_bbr_pacing_rate( #bbr_state{ max_bw = MaxBw, pacing_gain = PacingGain } = State ) -> PacingRate = trunc(PacingGain * MaxBw * ?PACING_MARGIN), State#bbr_state{pacing_rate = max(1, PacingRate)}. %%==================================================================== %% Internal Functions - Pacing Token Bucket %%==================================================================== %% @private Refill pacing tokens based on elapsed time. %% Elapsed is µs, Rate is bytes/sec; Added = Elapsed * Rate / 1e6. refill_tokens_at(Tokens, MaxBurst, Rate, LastUpdate, Now) -> Elapsed = max(0, Now - LastUpdate), Added = (Elapsed * Rate) div 1_000_000, min(MaxBurst, Tokens + Added).