Skip to content

Commit b600547

Browse files
authored
Implement accidental synchronization protection (RFC2762 section 5.2) (#773)
1 parent 5d44a36 commit b600547

2 files changed

Lines changed: 66 additions & 6 deletions

File tree

tests/services/test_browser.py

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
import pytest
1515

1616
import zeroconf as r
17-
from zeroconf import DNSPointer, DNSQuestion, const, current_time_millis
17+
from zeroconf import DNSPointer, DNSQuestion, const, current_time_millis, millis_to_seconds
1818
import zeroconf._services.browser as _services_browser
1919
from zeroconf import Zeroconf
2020
from zeroconf._services import ServiceStateChange
@@ -453,7 +453,11 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT):
453453
# patch the backoff limit to prevent test running forever
454454
with unittest.mock.patch.object(zeroconf_browser, "async_send", send), unittest.mock.patch.object(
455455
_services_browser, "current_time_millis", current_time_millis
456-
), unittest.mock.patch.object(_services_browser, "_BROWSER_BACKOFF_LIMIT", 10):
456+
), unittest.mock.patch.object(
457+
_services_browser, "_BROWSER_BACKOFF_LIMIT", 10
458+
), unittest.mock.patch.object(
459+
_services_browser, "_FIRST_QUERY_DELAY_RANDOM_INTERVAL", (0, 0)
460+
):
457461
# dummy service callback
458462
def on_service_state_change(zeroconf, service_type, state_change, name):
459463
pass
@@ -498,6 +502,44 @@ def on_service_state_change(zeroconf, service_type, state_change, name):
498502
zeroconf_browser.close()
499503

500504

505+
def test_first_query_delay():
506+
"""Verify the first query is delayed.
507+
508+
https://datatracker.ietf.org/doc/html/rfc6762#section-5.2
509+
"""
510+
type_ = "_http._tcp.local."
511+
zeroconf_browser = Zeroconf(interfaces=['127.0.0.1'])
512+
513+
# we are going to patch the zeroconf send to check query transmission
514+
old_send = zeroconf_browser.async_send
515+
516+
first_query_time = None
517+
518+
def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT):
519+
"""Sends an outgoing packet."""
520+
nonlocal first_query_time
521+
if first_query_time is None:
522+
first_query_time = current_time_millis()
523+
old_send(out, addr=addr, port=port)
524+
525+
# patch the zeroconf send
526+
with unittest.mock.patch.object(zeroconf_browser, "async_send", send):
527+
# dummy service callback
528+
def on_service_state_change(zeroconf, service_type, state_change, name):
529+
pass
530+
531+
start_time = current_time_millis()
532+
browser = ServiceBrowser(zeroconf_browser, type_, [on_service_state_change])
533+
time.sleep(millis_to_seconds(_services_browser._FIRST_QUERY_DELAY_RANDOM_INTERVAL[1] + 5))
534+
try:
535+
assert (
536+
current_time_millis() - start_time > _services_browser._FIRST_QUERY_DELAY_RANDOM_INTERVAL[0]
537+
)
538+
finally:
539+
browser.cancel()
540+
zeroconf_browser.close()
541+
542+
501543
def test_integration():
502544
service_added = Event()
503545
service_removed = Event()

zeroconf/_services/browser.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import concurrent.futures
2525
import contextlib
2626
import queue
27+
import random
2728
import threading
2829
import warnings
2930
from collections import OrderedDict
@@ -41,7 +42,7 @@
4142
)
4243
from .._utils.aio import get_best_available_queue, get_running_loop
4344
from .._utils.name import service_type_name
44-
from .._utils.time import current_time_millis
45+
from .._utils.time import current_time_millis, millis_to_seconds
4546
from ..const import (
4647
_BROWSER_BACKOFF_LIMIT,
4748
_BROWSER_TIME,
@@ -56,6 +57,8 @@
5657
_TYPE_PTR,
5758
)
5859

60+
# https://datatracker.ietf.org/doc/html/rfc6762#section-5.2
61+
_FIRST_QUERY_DELAY_RANDOM_INTERVAL = (20, 120) # ms
5962

6063
if TYPE_CHECKING:
6164
# https://github.com/PyCQA/pylint/issues/3525
@@ -190,14 +193,15 @@ def __init__(
190193
self.addr = addr
191194
self.port = port
192195
self.multicast = self.addr in (None, _MDNS_ADDR, _MDNS_ADDR6)
193-
current_time = current_time_millis()
194-
self._next_time = {check_type_: current_time for check_type_ in self.types}
195-
self._delay = {check_type_: delay for check_type_ in self.types}
196+
self._next_time: Dict[str, float] = {}
197+
self._delay: Dict[str, float] = {check_type_: delay for check_type_ in self.types}
196198
self._pending_handlers: OrderedDict[Tuple[str, str], ServiceStateChange] = OrderedDict()
197199
self._service_state_changed = Signal()
198200
self.queue: Optional[queue.Queue] = None
199201
self.done = False
200202

203+
self._generate_first_next_time()
204+
201205
if hasattr(handlers, 'add_service'):
202206
listener = cast('ServiceListener', handlers)
203207
handlers = None
@@ -212,6 +216,20 @@ def __init__(
212216

213217
self.zc.add_listener(self, [DNSQuestion(type_, _TYPE_PTR, _CLASS_IN) for type_ in self.types])
214218

219+
def _generate_first_next_time(self) -> None:
220+
"""Generate the initial next query times.
221+
222+
https://datatracker.ietf.org/doc/html/rfc6762#section-5.2
223+
To avoid accidental synchronization when, for some reason, multiple
224+
clients begin querying at exactly the same moment (e.g., because of
225+
some common external trigger event), a Multicast DNS querier SHOULD
226+
also delay the first query of the series by a randomly chosen amount
227+
in the range 20-120 ms.
228+
"""
229+
delay = millis_to_seconds(random.randint(*_FIRST_QUERY_DELAY_RANDOM_INTERVAL))
230+
next_time = current_time_millis() + delay
231+
self._next_time = {check_type_: next_time for check_type_ in self.types}
232+
215233
@property
216234
def service_state_changed(self) -> SignalRegistrationInterface:
217235
return self._service_state_changed.registration_interface

0 commit comments

Comments
 (0)