From 0e6b9663526b3b4f74d79dfab6493958fdd7400c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 10 Oct 2026 15:36:50 -1000 Subject: [PATCH] Back the UDP resend timer off while acks come back untimed On a slow, congested link every message was resent before its ack came back, so no round trip could be timed and the timer stayed at 0.5 s: 16 datagrams per message. Eight progress acks in a row with no timed round trip now double the timer, up to its 2 s cap, until a sample resets it. Random loss still yields samples, so it never triggers there. --- esphome/espota2.py | 24 +++++++++++++++++++++--- tests/unit_tests/test_espota2.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/esphome/espota2.py b/esphome/espota2.py index 2f6b117ce86..84e54f42f37 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -142,6 +142,9 @@ UDP_RESEND_MIN = 0.2 UDP_RESEND_MAX = 2.0 # Acks still asking for the head after later messages arrived; resend it once UDP_DUP_ACKS = 2 +# Progress acks in a row with no round trip to time: the link is slower than +# the resend timer, so back it off (random loss still yields samples) +UDP_UNTIMED_ACKS = 8 # Refusals in a row with no ACK between: the device's UDP port is closed UDP_REFUSALS = 3 UDP_FALLBACK = b"\x00" @@ -616,6 +619,10 @@ class UdpChannel: self._committed = False self._srtt: float | None = None self._rttvar = 0.0 + # Doubles while acks come back untimed, so a link slower than the + # timer still yields messages sent once to time (Karn, RFC 6298) + self._backoff = 1 + self._untimed = 0 self._dup_acks = 0 self._free = UDP_WINDOW # the device's empty slots, as of its last ACK self._last_ping = 0.0 @@ -683,8 +690,10 @@ class UdpChannel: def _rto(self) -> float: if self._srtt is None: - return UDP_RESEND_INITIAL - return min(max(self._srtt + 4 * self._rttvar, UDP_RESEND_MIN), UDP_RESEND_MAX) + rto = UDP_RESEND_INITIAL + else: + rto = max(self._srtt + 4 * self._rttvar, UDP_RESEND_MIN) + return min(rto * self._backoff, UDP_RESEND_MAX) def _send(self, msg_type: int, seq: int, payload: bytes) -> None: flags = UDP_FLAG_COMMITTED if self._committed else 0 @@ -754,6 +763,8 @@ class UdpChannel: def _sample_rtt(self, sample: float) -> None: # RFC 6298 smoothing + self._backoff = 1 + self._untimed = 0 if self._srtt is None: self._srtt, self._rttvar = sample, sample / 2 else: @@ -777,13 +788,20 @@ class UdpChannel: head = self._queue[0].seq index = (prompted - head) & 0xFFFF # This datagram's own round trip; only from messages sent once (Karn) - if index < len(self._queue) and self._queue[index].sends == 1: + timed = index < len(self._queue) and self._queue[index].sends == 1 + if timed: self._sample_rtt(now - self._queue[index].last_send) done = (received - head) & 0xFFFF if 0 < done <= len(self._queue): del self._queue[:done] self._last_progress = now self._dup_acks = 0 + if not timed: + self._untimed += 1 + if self._untimed >= UDP_UNTIMED_ACKS: + self._untimed = 0 + if self._rto() < UDP_RESEND_MAX: + self._backoff *= 2 elif done == 0 and self._queue[0].sends: # A later message arrived while the head is missing; resend it once self._dup_acks += 1 diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index 0e85010ef0f..28caadf254d 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -1958,3 +1958,31 @@ def test_start_udp_socket_error_falls_back(mock_socket: Mock) -> None: call(espota2.UDP_REQUEST), call(espota2.UDP_FALLBACK), ] + + +def test_udp_channel_backs_off_when_acks_come_back_untimed() -> None: + """Progress acks that only answer resent messages mean the link is slower + than the timer: it backs off, up to its cap, until a sample resets it.""" + token = b"\x01\x01\x01\x01" + channel = espota2.UdpChannel(Mock(), token) + channel._queue = [espota2._UdpMessage(seq, b"x") for seq in range(40)] + rtos = [] + for received in range(1, 33): + for message in channel._queue[:1]: + message.sends = 2 # every message was resent before its ack + rtos.append(channel._rto()) + ack = ( + bytes([espota2.UDP_MSG_ACK]) + + token + + received.to_bytes(2, "big") + + (received - 1).to_bytes(2, "big") + + bytes(2) + + b"\x40" + ) + channel._handle_ack(ack) + n = espota2.UDP_UNTIMED_ACKS + assert rtos[0] == espota2.UDP_RESEND_INITIAL + assert rtos[n] == espota2.UDP_RESEND_INITIAL * 2 + assert rtos[-1] == espota2.UDP_RESEND_MAX + channel._sample_rtt(0.1) + assert channel._rto() == pytest.approx(0.3) # srtt 0.1 + 4 * rttvar 0.05