Fail fast when the device's UDP port refuses, include <algorithm>

This commit is contained in:
J. Nick Koston
2026-10-10 13:17:06 -10:00
parent 37d4bbb72f
commit e5fe8cc351
3 changed files with 46 additions and 11 deletions
@@ -13,6 +13,7 @@
#endif
#endif
#include <algorithm>
#include <cstring>
#include <memory>
+16 -5
View File
@@ -142,6 +142,8 @@ 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
# Refusals in a row with no ACK between: the device's UDP port is closed
UDP_REFUSALS = 3
UDP_FALLBACK = b"\x00"
# The UDP format this client speaks; devices decline values they do not know
UDP_REQUEST = b"\x01"
@@ -621,6 +623,7 @@ class UdpChannel:
self._sends = 0
# Last socket error, e.g. refused once the device's socket is gone
self._last_error: OSError | None = None
self._refusals = 0
def settimeout(self, timeout: float) -> None:
self._timeout = timeout
@@ -640,11 +643,14 @@ class UdpChannel:
def probe(self) -> bool:
"""True once the device answers over UDP within UDP_PROBE_TIMEOUT."""
deadline = time.monotonic() + UDP_PROBE_TIMEOUT
while (now := time.monotonic()) < deadline:
self._send(UDP_MSG_PROBE, 0, b"")
self._receive(min(UDP_PROBE_INTERVAL, deadline - now))
if self._committed:
return True
try:
while (now := time.monotonic()) < deadline:
self._send(UDP_MSG_PROBE, 0, b"")
self._receive(min(UDP_PROBE_INTERVAL, deadline - now))
if self._committed:
return True
except OTANetworkError:
pass # rejected, e.g. by a firewall
return False
def sendall(self, data: bytes) -> None:
@@ -740,6 +746,10 @@ class UdpChannel:
if self._last_error is None or err.errno != self._last_error.errno:
_LOGGER.debug("UDP socket error: %s", err)
self._last_error = err
if isinstance(err, ConnectionRefusedError):
self._refusals += 1
if self._refusals >= UDP_REFUSALS:
raise OTANetworkError(f"device closed its UDP port: {err}")
def _sample_rtt(self, sample: float) -> None:
# RFC 6298 smoothing
@@ -758,6 +768,7 @@ class UdpChannel:
return
now = time.monotonic()
self._committed = True
self._refusals = 0
received = int.from_bytes(data[5:7], "big")
prompted = int.from_bytes(data[7:9], "big")
self._free = data[11] >> 4
+29 -6
View File
@@ -1736,8 +1736,7 @@ def test_tcp_retransmits(platform: str, info: bytes, expected: int | None) -> No
def test_udp_channel_reports_socket_errors() -> None:
"""A dead link still fails after the no-progress timeout, naming the socket
error behind it."""
"""A closed device port fails fast instead of waiting out the timeout."""
gone = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
gone.bind(("127.0.0.1", 0))
addr = gone.getsockname()
@@ -1745,14 +1744,38 @@ def test_udp_channel_reports_socket_errors() -> None:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.connect(addr)
channel = espota2.UdpChannel(sock, b"\x01\x01\x01\x01")
channel.settimeout(0.5)
with pytest.raises(
espota2.OTANetworkError, match="no progress over UDP.*last error"
):
channel.settimeout(60.0)
with pytest.raises(espota2.OTANetworkError, match="closed its UDP port"):
channel.sendall(bytes(espota2.UDP_MAX_PAYLOAD * 5))
# A probe that is refused falls back instead
assert not espota2.UdpChannel(sock, b"\x01\x01\x01\x01").probe()
channel.close()
def test_udp_channel_no_progress_names_last_error() -> None:
"""Without refusals, the no-progress timeout names the last socket error."""
sock = Mock()
sock.send.side_effect = BlockingIOError(35, "full")
channel = espota2.UdpChannel(sock, b"\x01\x01\x01\x01")
channel.settimeout(0.2)
channel.sendall(b"x")
with (
patch("select.select", return_value=([], [], [])),
pytest.raises(espota2.OTANetworkError, match="no progress.*full"),
):
while True:
channel._pump()
def test_udp_channel_ack_resets_refusals() -> None:
"""Refusals only count in a row; an ACK in between resets them."""
channel = espota2.UdpChannel(Mock(), b"\x01\x01\x01\x01")
ack = bytes([espota2.UDP_MSG_ACK]) + b"\x01\x01\x01\x01" + bytes(6) + b"\x40"
for _ in range(espota2.UDP_REFUSALS * 2):
channel._socket_error(ConnectionRefusedError(61, "refused"))
channel._handle_ack(ack)
def _udp_handshake() -> list[bytes]:
"""TCP recv responses up to auth, from a device that offers UDP."""
return [