Skip to content

Commit a8c1623

Browse files
authored
Switch ServiceBrowser query scheduling to use call_later instead of a loop (#849)
- Simplifies scheduling as there is no more need to sleep in a loop as we now schedule future callbacks with call_later - Simplifies cancelation as there is no more coroutine to cancel, only a timer handle We no longer have to handle the canceled error and cleaning up the awaitable - Solves the infrequent test failures in test_backoff and test_integration
1 parent 9f71e5b commit a8c1623

4 files changed

Lines changed: 46 additions & 95 deletions

File tree

tests/services/test_browser.py

Lines changed: 3 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -496,7 +496,7 @@ def on_service_state_change(zeroconf, service_type, state_change, name):
496496
else:
497497
assert not got_query.is_set()
498498
time_offset += initial_query_interval
499-
zeroconf_browser.loop.call_soon_threadsafe(browser.query_scheduler.set_schedule_changed)
499+
zeroconf_browser.loop.call_soon_threadsafe(browser.schedule_changed)
500500

501501
finally:
502502
browser.cancel()
@@ -984,8 +984,8 @@ async def test_query_scheduler():
984984

985985
# Test query interval is increasing
986986
assert query_scheduler.millis_to_wait(now - 1) == 1
987-
assert query_scheduler.millis_to_wait(now) is None
988-
assert query_scheduler.millis_to_wait(now + 1) is None
987+
assert query_scheduler.millis_to_wait(now) is 0
988+
assert query_scheduler.millis_to_wait(now + 1) is 0
989989

990990
assert set(query_scheduler.process_ready_types(now)) == types_
991991
assert set(query_scheduler.process_ready_types(now)) == set()
@@ -1018,41 +1018,3 @@ async def test_query_scheduler():
10181018
assert set(query_scheduler.process_ready_types(now + delay * 20)) == set()
10191019

10201020
assert set(query_scheduler.process_ready_types(now + delay * 31)) == set(["_http._tcp.local."])
1021-
1022-
1023-
@pytest.mark.asyncio
1024-
async def test_query_scheduler_triggers_async_wait_ready_on_reschedule():
1025-
"""Test that a reschedule wakes up the async_wait_ready."""
1026-
delay = const._BROWSER_TIME
1027-
types_ = set(["_hap._tcp.local.", "_http._tcp.local."])
1028-
query_scheduler = _services_browser.QueryScheduler(types_, delay, (0, 0))
1029-
1030-
now = current_time_millis()
1031-
query_scheduler.start(now)
1032-
assert set(query_scheduler.process_ready_types(now)) == types_
1033-
assert query_scheduler.millis_to_wait(now) == delay
1034-
1035-
task = asyncio.ensure_future(query_scheduler.async_wait_ready(now))
1036-
await asyncio.sleep(0) # Start the task
1037-
await asyncio.sleep(0) # Make sure its waiting
1038-
assert not task.done()
1039-
assert query_scheduler.millis_to_wait(now + 1) == delay - 1
1040-
query_scheduler.reschedule_type("_hap._tcp.local.", now + 1)
1041-
assert query_scheduler.millis_to_wait(now + 1) is None
1042-
await asyncio.wait_for(task, timeout=0.1)
1043-
assert task.done()
1044-
1045-
task2 = asyncio.ensure_future(query_scheduler.async_wait_ready(now + 10000))
1046-
assert set(query_scheduler.process_ready_types(now + 1)) == set(["_hap._tcp.local."])
1047-
assert not task2.done()
1048-
assert query_scheduler.millis_to_wait(now + 2) == delay - 2
1049-
query_scheduler.reschedule_type("_hap._tcp.local.", now + 2)
1050-
assert query_scheduler.millis_to_wait(now + 2) is None
1051-
await asyncio.wait_for(task2, timeout=0.1)
1052-
assert task2.done()
1053-
assert set(query_scheduler.process_ready_types(now + 10000)) == types_
1054-
assert query_scheduler.millis_to_wait(now + 10000) == delay * 2
1055-
1056-
task3 = asyncio.ensure_future(query_scheduler.async_wait_ready(now + 10000))
1057-
with pytest.raises(asyncio.TimeoutError):
1058-
await asyncio.wait_for(task3, timeout=0.1)

tests/test_aio.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -688,7 +688,7 @@ def on_service_state_change(zeroconf, service_type, state_change, name):
688688

689689
time_offset = 0.0
690690

691-
def current_time_millis():
691+
def _new_current_time_millis():
692692
"""Current system time in milliseconds"""
693693
return (time.time() * 1000) + (time_offset * 1000)
694694

@@ -705,7 +705,6 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()):
705705
unexpected_ttl.set()
706706

707707
got_query.set()
708-
got_query.clear()
709708

710709
old_send(out, addr=addr, port=port, v6_flow_scope=v6_flow_scope)
711710

@@ -734,7 +733,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()):
734733
), patch.object(
735734
zeroconf_browser, "async_send", send
736735
), patch(
737-
"zeroconf._services.browser.current_time_millis", current_time_millis
736+
"zeroconf._services.browser.current_time_millis", _new_current_time_millis
738737
), patch.object(
739738
_services_browser, "_BROWSER_BACKOFF_LIMIT", int(expected_ttl / 4)
740739
):
@@ -762,9 +761,11 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()):
762761
while nbr_answers < test_iterations:
763762
# Increase simulated time shift by 1/4 of the TTL in seconds
764763
time_offset += expected_ttl / 4
765-
browser.query_scheduler.set_schedule_changed()
764+
now = _new_current_time_millis()
765+
browser.reschedule_type(type_, now)
766766
sleep_count += 1
767767
await asyncio.wait_for(got_query.wait(), 1)
768+
got_query.clear()
768769
# Prevent the test running indefinitely in an error condition
769770
assert sleep_count < test_iterations * 4
770771
assert not unexpected_ttl.is_set()

zeroconf/_services/browser.py

Lines changed: 38 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@
2121
"""
2222

2323
import asyncio
24-
import contextlib
2524
import queue
2625
import random
2726
import threading
@@ -39,7 +38,7 @@
3938
SignalRegistrationInterface,
4039
)
4140
from .._updates import RecordUpdate, RecordUpdateListener
42-
from .._utils.aio import get_best_available_queue, wait_event_or_timeout
41+
from .._utils.aio import get_best_available_queue
4342
from .._utils.name import service_type_name
4443
from .._utils.time import current_time_millis, millis_to_seconds
4544
from ..const import (
@@ -221,25 +220,17 @@ def _generate_first_next_time(self, now: float) -> None:
221220
next_time = now + delay
222221
self._next_time = {check_type_: next_time for check_type_ in self._types}
223222

224-
def millis_to_wait(self, now: float) -> Optional[float]:
223+
def millis_to_wait(self, now: float) -> float:
225224
"""Returns the number of milliseconds to wait for the next event."""
226225
# Wait for the type has the smallest next time
227226
next_time = min(self._next_time.values())
228-
return None if next_time <= now else next_time - now
227+
return 0 if next_time <= now else next_time - now
229228

230229
def reschedule_type(self, type_: str, next_time: float) -> None:
231230
"""Reschedule the query for a type to happen sooner."""
232231
if next_time >= self._next_time[type_]:
233232
return
234-
235233
self._next_time[type_] = next_time
236-
self.set_schedule_changed()
237-
238-
def set_schedule_changed(self) -> None:
239-
"""Set the event to unblock async_wait_ready to make sure the adjusted next time is seen."""
240-
assert self._schedule_changed_event is not None
241-
self._schedule_changed_event.set()
242-
self._schedule_changed_event.clear()
243234

244235
def process_ready_types(self, now: float) -> List[str]:
245236
"""Generate a list of ready types that is due and schedule the next time."""
@@ -258,13 +249,6 @@ def process_ready_types(self, now: float) -> List[str]:
258249

259250
return ready_types
260251

261-
async def async_wait_ready(self, now: float) -> None:
262-
"""Wait for at least one query to be ready."""
263-
timeout = self.millis_to_wait(now)
264-
if timeout:
265-
assert self._schedule_changed_event is not None
266-
await wait_event_or_timeout(self._schedule_changed_event, timeout=millis_to_seconds(timeout))
267-
268252

269253
class _ServiceBrowserBase(RecordUpdateListener):
270254
"""Base class for ServiceBrowser."""
@@ -302,7 +286,6 @@ def __init__(
302286
for check_type_ in self.types:
303287
# Will generate BadTypeInNameException on a bad name
304288
service_type_name(check_type_, strict=False)
305-
self._browser_task: Optional[asyncio.Task] = None
306289
self.zc = zc
307290
self.addr = addr
308291
self.port = port
@@ -313,6 +296,8 @@ def __init__(
313296
self.query_scheduler = QueryScheduler(self.types, delay, _FIRST_QUERY_DELAY_RANDOM_INTERVAL)
314297
self.queue: Optional[queue.Queue] = None
315298
self.done = False
299+
self._first_request: bool = True
300+
self._next_send_timer: Optional[asyncio.TimerHandle] = None
316301

317302
if hasattr(handlers, 'add_service'):
318303
listener = cast('ServiceListener', handlers)
@@ -335,7 +320,7 @@ def _async_start(self) -> None:
335320
self.query_scheduler.start(current_time_millis())
336321
self.zc.async_add_listener(self, [DNSQuestion(type_, _TYPE_PTR, _CLASS_IN) for type_ in self.types])
337322
# Only start queries after the listener is installed
338-
self._browser_task = cast(asyncio.Task, asyncio.ensure_future(self.async_browser_task()))
323+
asyncio.ensure_future(self._async_start_query_sender())
339324

340325
@property
341326
def service_state_changed(self) -> SignalRegistrationInterface:
@@ -378,9 +363,7 @@ def _async_process_record_update(
378363
elif expired:
379364
self._enqueue_callback(ServiceStateChange.Removed, record.name, record.alias)
380365
else:
381-
self.query_scheduler.reschedule_type(
382-
record.name, record.get_expiration_time(_EXPIRE_REFRESH_TIME_PERCENT)
383-
)
366+
self.reschedule_type(record.name, record.get_expiration_time(_EXPIRE_REFRESH_TIME_PERCENT))
384367
return
385368

386369
# If its expired or already exists in the cache it cannot be updated.
@@ -448,6 +431,7 @@ def _fire_service_state_changed_event(self, event: Tuple[Tuple[str, str], Servic
448431
def _async_cancel(self) -> None:
449432
"""Cancel the browser."""
450433
self.done = True
434+
self._cancel_send_timer()
451435
self.zc.async_remove_listener(self)
452436

453437
def _generate_ready_queries(self, first_request: bool) -> List[DNSOutgoing]:
@@ -464,28 +448,40 @@ def _generate_ready_queries(self, first_request: bool) -> List[DNSOutgoing]:
464448
question_type = DNSQuestionType.QU if not self.question_type and first_request else self.question_type
465449
return generate_service_query(self.zc, now, ready_types, self.multicast, question_type)
466450

467-
async def async_browser_task(self) -> None:
468-
"""Run the browser task."""
451+
async def _async_start_query_sender(self) -> None:
452+
"""Start scheduling queries."""
469453
await self.zc.async_wait_for_start()
470-
first_request = True
471-
while True:
472-
await self.query_scheduler.async_wait_ready(current_time_millis())
473-
outs = self._generate_ready_queries(first_request)
474-
if not outs:
475-
continue
454+
self._async_send_ready_queries_schedule_next()
455+
456+
def _cancel_send_timer(self) -> None:
457+
"""Cancel the next send."""
458+
if self._next_send_timer:
459+
self._next_send_timer.cancel()
476460

477-
first_request = False
461+
def reschedule_type(self, type_: str, next_time: float) -> None:
462+
"""Reschedule a type to be refreshed in the future."""
463+
self.query_scheduler.reschedule_type(type_, next_time)
464+
self.schedule_changed()
465+
466+
def schedule_changed(self) -> None:
467+
"""Called when the schedule has changed."""
468+
self._cancel_send_timer()
469+
self._async_send_ready_queries_schedule_next()
470+
471+
def _async_send_ready_queries_schedule_next(self) -> None:
472+
"""Send any ready queries and scheule the next time."""
473+
if self.done or self.zc.done:
474+
return
475+
476+
outs = self._generate_ready_queries(self._first_request)
477+
if outs:
478+
self._first_request = False
478479
for out in outs:
479480
self.zc.async_send(out, addr=self.addr, port=self.port)
480481

481-
async def _async_cancel_browser(self) -> None:
482-
"""Cancel the browser."""
483-
assert self._browser_task is not None
484-
self._browser_task.cancel()
485-
browser_task = self._browser_task
486-
self._browser_task = None
487-
with contextlib.suppress(asyncio.CancelledError):
488-
await browser_task
482+
assert self.zc.loop is not None
483+
delay = millis_to_seconds(self.query_scheduler.millis_to_wait(current_time_millis()))
484+
self._next_send_timer = self.zc.loop.call_later(delay, self._async_send_ready_queries_schedule_next)
489485

490486

491487
class ServiceBrowser(_ServiceBrowserBase, threading.Thread):
@@ -523,18 +519,12 @@ def __init__(
523519
getattr(self, 'native_id', self.ident),
524520
)
525521

526-
def _async_cancel_soon(self) -> None:
527-
"""Cancel the browser from the event loop."""
528-
self._async_cancel()
529-
if self._browser_task:
530-
asyncio.ensure_future(self._async_cancel_browser())
531-
532522
def cancel(self) -> None:
533523
"""Cancel the browser."""
534524
assert self.zc.loop is not None
535525
assert self.queue is not None
536526
self.queue.put(None)
537-
self.zc.loop.call_soon_threadsafe(self._async_cancel_soon)
527+
self.zc.loop.call_soon_threadsafe(self._async_cancel)
538528
self.join()
539529

540530
def run(self) -> None:

zeroconf/aio.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,6 @@ def __init__(
9191
async def async_cancel(self) -> None:
9292
"""Cancel the browser."""
9393
self._async_cancel()
94-
with contextlib.suppress(asyncio.CancelledError):
95-
await self._async_cancel_browser()
9694

9795

9896
class AsyncZeroconfServiceTypes(ZeroconfServiceTypes):

0 commit comments

Comments
 (0)