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.
This commit is contained in:
J. Nick Koston
2026-10-10 15:36:50 -10:00
parent d0fc2c182e
commit 0e6b966352
2 changed files with 49 additions and 3 deletions
+21 -3
View File
@@ -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
+28
View File
@@ -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