Skip to content

Commit a096238

Browse files
authored
fix: bound TC-deferral assembly window to first-arrival + max delay (#1732)
1 parent 37edde2 commit a096238

3 files changed

Lines changed: 120 additions & 34 deletions

File tree

src/zeroconf/_listener.pxd

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ cdef class AsyncListener:
2929
cdef public object sock_description
3030
cdef public cython.dict _deferred
3131
cdef public cython.dict _timers
32+
cdef public cython.dict _deferred_deadlines
3233

3334
@cython.locals(now=double, debug=cython.bint)
3435
cpdef datagram_received(self, cython.bytes bytes, cython.tuple addrs)
@@ -38,7 +39,10 @@ cdef class AsyncListener:
3839

3940
cdef _cancel_any_timers_for_addr(self, object addr)
4041

41-
@cython.locals(incoming=DNSIncoming, deferred=list)
42+
@cython.locals(deadline=object, fire_at=double)
43+
cdef double _compute_deferred_fire_at(self, object addr, double now, double delay)
44+
45+
@cython.locals(incoming=DNSIncoming, deferred=list, now=double, delay=double, fire_at=double)
4246
cpdef handle_query_or_defer(
4347
self,
4448
DNSIncoming msg,

src/zeroconf/_listener.py

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ class AsyncListener:
5858

5959
__slots__ = (
6060
"_deferred",
61+
"_deferred_deadlines",
6162
"_query_handler",
6263
"_record_manager",
6364
"_registry",
@@ -82,6 +83,7 @@ def __init__(self, zc: Zeroconf) -> None:
8283
self.sock_description: str | None = None
8384
self._deferred: dict[str, list[DNSIncoming]] = {}
8485
self._timers: dict[str, asyncio.TimerHandle] = {}
86+
self._deferred_deadlines: dict[str, float] = {}
8587
super().__init__()
8688

8789
def datagram_received(self, data: _bytes, addrs: tuple[str, int] | tuple[str, int, int, int]) -> None:
@@ -203,12 +205,19 @@ def handle_query_or_defer(
203205
if incoming.data == msg.data:
204206
return
205207
deferred.append(msg)
206-
delay = millis_to_seconds(random.randint(*_TC_DELAY_RANDOM_INTERVAL)) # noqa: S311
207208
loop = self.zc.loop
208209
assert loop is not None
210+
now = loop.time()
211+
delay = millis_to_seconds(random.randint(*_TC_DELAY_RANDOM_INTERVAL)) # noqa: S311
212+
fire_at = self._compute_deferred_fire_at(addr, now, delay)
213+
if fire_at < 0.0:
214+
# Sentinel: a new reset would push the flush past the
215+
# per-addr reassembly deadline, so leave the existing
216+
# TimerHandle in place rather than re-arming it.
217+
return
209218
self._cancel_any_timers_for_addr(addr)
210219
self._timers[addr] = loop.call_at(
211-
loop.time() + delay,
220+
fire_at,
212221
self._respond_query,
213222
None,
214223
addr,
@@ -217,6 +226,27 @@ def handle_query_or_defer(
217226
v6_flow_scope,
218227
)
219228

229+
def _compute_deferred_fire_at(self, addr: _str, now: _float, delay: _float) -> _float:
230+
"""Return the bounded call_at time for a TC-deferred flush, or -1.0 to keep the existing timer."""
231+
# RFC 6762 §18.5 frames the random delay as a fixed reassembly budget
232+
# starting at first arrival, not a sliding heartbeat.
233+
deadline = self._deferred_deadlines.get(addr)
234+
if deadline is None:
235+
deadline = now + millis_to_seconds(_TC_DELAY_RANDOM_INTERVAL[1])
236+
self._deferred_deadlines[addr] = deadline
237+
fire_at = now + delay
238+
if fire_at >= deadline:
239+
if addr in self._timers:
240+
# Existing timer already fires at or before the deadline;
241+
# signal the caller to leave it alone rather than reset it.
242+
return -1.0
243+
# First packet for this addr already proposes a fire-time at
244+
# or past the deadline — clamp to the deadline so the flush
245+
# still happens within the reassembly budget.
246+
return deadline
247+
# Within budget: schedule at the proposed fire-time.
248+
return fire_at
249+
220250
def _cancel_any_timers_for_addr(self, addr: _str) -> None:
221251
"""Cancel any future truncated packet timers for the address."""
222252
if addr in self._timers:
@@ -232,6 +262,7 @@ def _respond_query(
232262
) -> None:
233263
"""Respond to a query and reassemble any truncated deferred packets."""
234264
self._cancel_any_timers_for_addr(addr)
265+
self._deferred_deadlines.pop(addr, None)
235266
packets = self._deferred.pop(addr, [])
236267
if msg:
237268
packets.append(msg)

tests/test_core.py

Lines changed: 82 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020

2121
import zeroconf as r
2222
from zeroconf import NotRunningException, Zeroconf, const, current_time_millis
23-
from zeroconf._listener import AsyncListener, _WrappedTransport
23+
from zeroconf._listener import _TC_DELAY_RANDOM_INTERVAL, AsyncListener, _WrappedTransport
2424
from zeroconf._protocol.incoming import DNSIncoming
2525
from zeroconf.asyncio import AsyncZeroconf
2626

@@ -699,36 +699,41 @@ def test_tc_bit_defers_last_response_missing():
699699
assert len(packets) == 4
700700
expected_deferred = []
701701

702-
next_packet = r.DNSIncoming(packets.pop(0))
703-
expected_deferred.append(next_packet)
704-
threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT, Mock(), ())
705-
assert protocol._deferred[source_ip] == expected_deferred
706-
timer1 = protocol._timers[source_ip]
707-
708-
next_packet = r.DNSIncoming(packets.pop(0))
709-
expected_deferred.append(next_packet)
710-
threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT, Mock(), ())
711-
assert protocol._deferred[source_ip] == expected_deferred
712-
timer2 = protocol._timers[source_ip]
713-
assert timer1.cancelled()
714-
assert timer2 != timer1
715-
716-
# Send the same packet again to similar multi interfaces
717-
threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT, Mock(), ())
718-
assert protocol._deferred[source_ip] == expected_deferred
719-
assert source_ip in protocol._timers
720-
timer3 = protocol._timers[source_ip]
721-
assert not timer3.cancelled()
722-
assert timer3 == timer2
723-
724-
next_packet = r.DNSIncoming(packets.pop(0))
725-
expected_deferred.append(next_packet)
726-
threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT, Mock(), ())
727-
assert protocol._deferred[source_ip] == expected_deferred
728-
assert source_ip in protocol._timers
729-
timer4 = protocol._timers[source_ip]
730-
assert timer3.cancelled()
731-
assert timer4 != timer3
702+
# Pin per-packet delay to the minimum so each successive fire_at lands
703+
# before the deadline established by the first packet — keeps the
704+
# timer-replacement assertions deterministic under bounded TC-deferral.
705+
min_delay_ms = _TC_DELAY_RANDOM_INTERVAL[0]
706+
with patch("zeroconf._listener.random.randint", return_value=min_delay_ms):
707+
next_packet = r.DNSIncoming(packets.pop(0))
708+
expected_deferred.append(next_packet)
709+
threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT, Mock(), ())
710+
assert protocol._deferred[source_ip] == expected_deferred
711+
timer1 = protocol._timers[source_ip]
712+
713+
next_packet = r.DNSIncoming(packets.pop(0))
714+
expected_deferred.append(next_packet)
715+
threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT, Mock(), ())
716+
assert protocol._deferred[source_ip] == expected_deferred
717+
timer2 = protocol._timers[source_ip]
718+
assert timer1.cancelled()
719+
assert timer2 != timer1
720+
721+
# Send the same packet again to similar multi interfaces
722+
threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT, Mock(), ())
723+
assert protocol._deferred[source_ip] == expected_deferred
724+
assert source_ip in protocol._timers
725+
timer3 = protocol._timers[source_ip]
726+
assert not timer3.cancelled()
727+
assert timer3 == timer2
728+
729+
next_packet = r.DNSIncoming(packets.pop(0))
730+
expected_deferred.append(next_packet)
731+
threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT, Mock(), ())
732+
assert protocol._deferred[source_ip] == expected_deferred
733+
assert source_ip in protocol._timers
734+
timer4 = protocol._timers[source_ip]
735+
assert timer3.cancelled()
736+
assert timer4 != timer3
732737

733738
for _ in range(8):
734739
time.sleep(0.1)
@@ -743,6 +748,52 @@ def test_tc_bit_defers_last_response_missing():
743748
zc.close()
744749

745750

751+
def test_tc_bit_defer_window_is_bounded():
752+
"""TC-deferral assembly window must not slide past first_arrival + max delay."""
753+
zc = Zeroconf(interfaces=["127.0.0.1"])
754+
_wait_for_start(zc)
755+
type_ = "_boundeddefer._tcp.local."
756+
registration_name = f"knownname.{type_}"
757+
758+
info = r.ServiceInfo(
759+
type_,
760+
registration_name,
761+
80,
762+
0,
763+
0,
764+
{"path": "/~paulsm/"},
765+
"ash-2.local.",
766+
addresses=[socket.inet_aton("10.0.1.2")],
767+
)
768+
zc.registry.async_add(info)
769+
770+
protocol = zc.engine.protocols[0]
771+
now_ms = r.current_time_millis()
772+
_clear_cache(zc)
773+
source_ip = "203.0.113.99"
774+
775+
generated = r.DNSOutgoing(const._FLAGS_QR_QUERY)
776+
generated.add_question(r.DNSQuestion(type_, const._TYPE_PTR, const._CLASS_IN))
777+
for _ in range(300):
778+
generated.add_answer_at_time(info.dns_pointer(), now_ms)
779+
packets = generated.packets()
780+
assert len(packets) >= 3
781+
782+
# Pin the per-packet delay at its maximum so any subsequent reset would
783+
# land past the deadline established by the first packet.
784+
max_delay_ms = _TC_DELAY_RANDOM_INTERVAL[1]
785+
with patch("zeroconf._listener.random.randint", return_value=max_delay_ms):
786+
threadsafe_query(zc, protocol, r.DNSIncoming(packets[0]), source_ip, const._MDNS_PORT, Mock(), ())
787+
first_when = protocol._timers[source_ip].when()
788+
789+
for raw in packets[1:-1]:
790+
threadsafe_query(zc, protocol, r.DNSIncoming(raw), source_ip, const._MDNS_PORT, Mock(), ())
791+
assert protocol._timers[source_ip].when() <= first_when
792+
793+
zc.registry.async_remove(info)
794+
zc.close()
795+
796+
746797
@pytest.mark.asyncio
747798
async def test_open_close_twice_from_async() -> None:
748799
"""Test we can close twice from a coroutine when using Zeroconf.

0 commit comments

Comments
 (0)