Skip to content

Commit d03ea36

Browse files
authored
test: speed up slow loopback tests (closes #1700) (#1703)
1 parent 653c385 commit d03ea36

8 files changed

Lines changed: 97 additions & 37 deletions

File tree

tests/__init__.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,23 @@ def _clear_cache(zc: Zeroconf) -> None:
9191
zc.question_history.clear()
9292

9393

94+
def _backdate_cache(zc: Zeroconf, ms: int = 1100) -> None:
95+
"""Backdate every cached record's `created` time by `ms` milliseconds.
96+
97+
rfc6762#section-10.2 keys off "received more than one second ago", so
98+
backdating is equivalent to sleeping `ms` in real time without the
99+
wall-clock wait.
100+
101+
Iterate `store.values()`, not the dict directly — when a record is
102+
re-added with an equal hash, the key stays the original object while
103+
the value is replaced with the latest; mutating the key would update
104+
stale objects no one reads.
105+
"""
106+
for store in zc.cache.cache.values():
107+
for record in store.values():
108+
record.created -= ms
109+
110+
94111
def time_changed_millis(millis: float | None = None) -> None:
95112
"""Call all scheduled events for a time."""
96113
loop = asyncio.get_running_loop()

tests/services/test_browser.py

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -556,7 +556,7 @@ def on_service_state_change(zeroconf, service_type, state_change, name):
556556

557557

558558
@pytest.mark.asyncio
559-
async def test_asking_default_is_asking_qm_questions_after_the_first_qu():
559+
async def test_asking_default_is_asking_qm_questions_after_the_first_qu(quick_timing: None) -> None:
560560
"""Verify the service browser's first questions are QU and refresh queries are QM."""
561561
service_added = asyncio.Event()
562562
service_removed = asyncio.Event()
@@ -658,7 +658,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()):
658658

659659

660660
@pytest.mark.asyncio
661-
async def test_ttl_refresh_cancelled_rescue_query():
661+
async def test_ttl_refresh_cancelled_rescue_query(quick_timing: None) -> None:
662662
"""Verify seeing a name again cancels the rescue query."""
663663
service_added = asyncio.Event()
664664
service_removed = asyncio.Event()
@@ -846,7 +846,7 @@ def on_service_state_change(zeroconf, service_type, state_change, name):
846846
await aiozc.async_close()
847847

848848

849-
def test_legacy_record_update_listener():
849+
def test_legacy_record_update_listener(quick_timing: None) -> None:
850850
"""Test a RecordUpdateListener that does not implement update_records."""
851851

852852
# instantiate a zeroconf instance
@@ -1499,10 +1499,15 @@ def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-de
14991499
# Force the ttl to be 1 second
15001500
now = current_time_millis()
15011501
for cache_record in list(zc.cache.cache.values()):
1502-
for record in cache_record:
1502+
for record in cache_record.values():
15031503
zc.cache._async_set_created_ttl(record, now, 1)
15041504

1505-
time.sleep(0.3)
1505+
# Wait for the add callback to fire from the original inject_response.
1506+
for _ in range(30):
1507+
time.sleep(0.01)
1508+
if len(callbacks) == 1:
1509+
break
1510+
15061511
info.port = 400
15071512
info._dns_service_cache = None # we are mutating the record so clear the cache
15081513

@@ -1511,8 +1516,8 @@ def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-de
15111516
mock_incoming_msg([info.dns_service()]),
15121517
)
15131518

1514-
for _ in range(10):
1515-
time.sleep(0.05)
1519+
for _ in range(30):
1520+
time.sleep(0.01)
15161521
if len(callbacks) == 2:
15171522
break
15181523

@@ -1521,8 +1526,19 @@ def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-de
15211526
("update", type_, registration_name),
15221527
]
15231528

1524-
for _ in range(25):
1525-
time.sleep(0.05)
1529+
# Re-add every cached record with `created` in the past so the
1530+
# next reaper tick (0.01s) expires them and fires the remove
1531+
# callback, instead of waiting the full TTL in real time.
1532+
# Going through `_async_set_created_ttl` updates the expiration
1533+
# heap; mutating `record.created` directly would leave the heap
1534+
# entry pointing at the original `when` so the reaper never wakes.
1535+
past = current_time_millis() - 2000
1536+
for cache_record in list(zc.cache.cache.values()):
1537+
for record in list(cache_record.values()):
1538+
zc.cache._async_set_created_ttl(record, past, 1)
1539+
1540+
for _ in range(30):
1541+
time.sleep(0.01)
15261542
if len(callbacks) == 3:
15271543
break
15281544

@@ -1567,7 +1583,7 @@ def test_scheduled_ptr_query_dunder_methods():
15671583

15681584

15691585
@pytest.mark.asyncio
1570-
async def test_close_zeroconf_without_browser_before_start_up_queries():
1586+
async def test_close_zeroconf_without_browser_before_start_up_queries(quick_timing: None) -> None:
15711587
"""Test that we stop sending startup queries if zeroconf is closed out from under the browser."""
15721588
service_added = asyncio.Event()
15731589
type_ = "_http._tcp.local."
@@ -1634,7 +1650,7 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()):
16341650

16351651

16361652
@pytest.mark.asyncio
1637-
async def test_close_zeroconf_without_browser_after_start_up_queries():
1653+
async def test_close_zeroconf_without_browser_after_start_up_queries(quick_timing: None) -> None:
16381654
"""Test that we stop sending rescue queries if zeroconf is closed out from under the browser."""
16391655
service_added = asyncio.Event()
16401656

tests/test_asyncio.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -708,16 +708,21 @@ async def test_service_info_async_request(quick_timing: None) -> None:
708708
aiosinfo = AsyncServiceInfo(type_, registration_name)
709709
_clear_cache(aiozc.zeroconf)
710710
# Generating the race condition is almost impossible
711-
# without patching since its a TOCTOU race
711+
# without patching since its a TOCTOU race. 1500ms covers
712+
# the initial _LISTENER_TIME + random delay (200-320ms) and
713+
# leaves plenty of margin for the loopback response to land
714+
# before the loop times out.
712715
with patch("zeroconf.asyncio.AsyncServiceInfo._is_complete", False):
713-
await aiosinfo.async_request(aiozc.zeroconf, 3000)
716+
await aiosinfo.async_request(aiozc.zeroconf, 1500)
714717
assert aiosinfo is not None
715718
assert aiosinfo.addresses == [socket.inet_aton("10.0.1.3")]
716719

717720
task = await aiozc.async_unregister_service(new_info)
718721
await task
719722

720-
aiosinfo = await aiozc.async_get_service_info(type_, registration_name)
723+
# Cap timeout: the service is gone, so this is expected to return None;
724+
# waiting the default 3000ms is pure overhead.
725+
aiosinfo = await aiozc.async_get_service_info(type_, registration_name, timeout=200)
721726
assert aiosinfo is None
722727

723728
await aiozc.async_close()
@@ -784,7 +789,7 @@ def update_service(self, aiozc: Zeroconf, type: str, name: str) -> None:
784789

785790

786791
@pytest.mark.asyncio
787-
async def test_async_context_manager() -> None:
792+
async def test_async_context_manager(quick_timing: None) -> None:
788793
"""Test using an async context manager."""
789794
type_ = "_test10-sr-type._tcp.local."
790795
name = "xxxyyy"
@@ -984,7 +989,7 @@ def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-de
984989

985990

986991
@pytest.mark.asyncio
987-
async def test_integration():
992+
async def test_integration(quick_timing: None) -> None:
988993
service_added = asyncio.Event()
989994
service_removed = asyncio.Event()
990995
unexpected_ttl = asyncio.Event()
@@ -1176,9 +1181,11 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT):
11761181
# patch the zeroconf send
11771182
with patch.object(zeroconf_info, "async_send", send):
11781183
aiosinfo = AsyncServiceInfo(type_, registration_name)
1179-
# Patch _is_complete so we send multiple times
1184+
# Patch _is_complete so we send multiple times. 500ms covers
1185+
# the QU query at 0ms plus the QM query at ~_LISTENER_TIME +
1186+
# max random delay (~320ms).
11801187
with patch("zeroconf.asyncio.AsyncServiceInfo._is_complete", False):
1181-
await aiosinfo.async_request(aiozc.zeroconf, 1200)
1188+
await aiosinfo.async_request(aiozc.zeroconf, 500)
11821189
try:
11831190
assert first_outgoing.questions[0].unicast is True # type: ignore[union-attr]
11841191
assert second_outgoing.questions[0].unicast is False # type: ignore[attr-defined]

tests/test_core.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
from zeroconf._protocol.incoming import DNSIncoming
2525
from zeroconf.asyncio import AsyncZeroconf
2626

27-
from . import _clear_cache, _inject_response, _wait_for_start, has_working_ipv6
27+
from . import _backdate_cache, _clear_cache, _inject_response, _wait_for_start, has_working_ipv6
2828

2929
log = logging.getLogger("zeroconf")
3030
original_logging_level = logging.NOTSET
@@ -301,7 +301,7 @@ def mock_split_incoming_msg(
301301
# all old records with that name, rrtype, and rrclass that were received
302302
# more than one second ago are declared invalid,
303303
# and marked to expire from the cache in one second.
304-
time.sleep(1.1)
304+
_backdate_cache(zeroconf)
305305

306306
# service updated. currently only text record can be updated
307307
service_text = b"path=/~humingchun/"
@@ -310,12 +310,12 @@ def mock_split_incoming_msg(
310310
assert dns_text is not None
311311
assert cast(r.DNSText, dns_text).text == service_text # service_text is b'path=/~humingchun/'
312312

313-
time.sleep(1.1)
313+
_backdate_cache(zeroconf)
314314

315315
# The split message only has a SRV and A record.
316316
# This should not evict TXT records from the cache
317317
_inject_response(zeroconf, mock_split_incoming_msg(r.ServiceStateChange.Updated))
318-
time.sleep(1.1)
318+
_backdate_cache(zeroconf)
319319
dns_text = zeroconf.cache.get_by_details(service_name, const._TYPE_TXT, const._CLASS_IN)
320320
assert dns_text is not None
321321
assert cast(r.DNSText, dns_text).text == service_text # service_text is b'path=/~humingchun/'
@@ -426,7 +426,7 @@ def test_goodbye_all_services():
426426
zc.close()
427427

428428

429-
def test_register_service_with_custom_ttl():
429+
def test_register_service_with_custom_ttl(quick_timing: None) -> None:
430430
"""Test a registering a service with a custom ttl."""
431431

432432
# instantiate a zeroconf instance
@@ -453,7 +453,7 @@ def test_register_service_with_custom_ttl():
453453
zc.close()
454454

455455

456-
def test_logging_packets(caplog):
456+
def test_logging_packets(caplog: pytest.LogCaptureFixture, quick_timing: None) -> None:
457457
"""Test packets are only logged with debug logging."""
458458

459459
# instantiate a zeroconf instance

tests/test_engine.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,17 @@ async def test_reaper():
3939
original_entries = list(itertools.chain(*(cache.entries_with_name(name) for name in cache.names())))
4040
record_with_10s_ttl = r.DNSAddress("a", const._TYPE_SOA, const._CLASS_IN, 10, b"a")
4141
record_with_1s_ttl = r.DNSAddress("a", const._TYPE_SOA, const._CLASS_IN, 1, b"b")
42+
# Backdate the short-lived record so it expires at the next
43+
# reaper tick instead of waiting the full TTL in real time.
44+
record_with_1s_ttl.created -= 2000
4245
zeroconf.cache.async_add_records([record_with_10s_ttl, record_with_1s_ttl])
4346
question = r.DNSQuestion("_hap._tcp._local.", const._TYPE_PTR, const._CLASS_IN)
4447
now = r.current_time_millis()
48+
# Add the question at `past` so the reaper's next tick will see
49+
# `current_time - past > _DUPLICATE_QUESTION_INTERVAL` and prune it,
50+
# while the initial `suppresses(now, ...)` check still sees the
51+
# question as recent (since `now - past == 999`, not strictly `> 999`).
52+
past = now - 999
4553
other_known_answers: set[r.DNSRecord] = {
4654
r.DNSPointer(
4755
"_hap._tcp.local.",
@@ -51,10 +59,10 @@ async def test_reaper():
5159
"known-to-other._hap._tcp.local.",
5260
)
5361
}
54-
zeroconf.question_history.add_question_at_time(question, now, other_known_answers)
62+
zeroconf.question_history.add_question_at_time(question, past, other_known_answers)
5563
assert zeroconf.question_history.suppresses(question, now, other_known_answers)
5664
entries_with_cache = list(itertools.chain(*(cache.entries_with_name(name) for name in cache.names())))
57-
await asyncio.sleep(1.2)
65+
await asyncio.sleep(0.1)
5866
entries = list(itertools.chain(*(cache.entries_with_name(name) for name in cache.names())))
5967
assert zeroconf.cache.get(record_with_1s_ttl) is None
6068
await aiozc.async_close()
@@ -77,6 +85,10 @@ async def test_reaper_aborts_when_done():
7785
assert zeroconf.cache.get(record_with_10s_ttl) is not None
7886
assert zeroconf.cache.get(record_with_1s_ttl) is not None
7987
await aiozc.async_close()
80-
await asyncio.sleep(1.2)
88+
# Backdate to immediate expiry so we don't have to wait the full
89+
# TTL; the assertion is that the reaper has stopped, so a
90+
# short sleep is enough to give it a chance to (incorrectly) run.
91+
record_with_1s_ttl.created -= 2000
92+
await asyncio.sleep(0.1)
8193
assert zeroconf.cache.get(record_with_10s_ttl) is not None
8294
assert zeroconf.cache.get(record_with_1s_ttl) is not None

tests/test_handlers.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -215,15 +215,18 @@ def test_register_and_lookup_type_by_uppercase_name(self):
215215
out = r.DNSOutgoing(const._FLAGS_QR_QUERY)
216216
out.add_question(r.DNSQuestion(type_.upper(), const._TYPE_PTR, const._CLASS_IN))
217217
zc.send(out)
218-
time.sleep(1)
219218
info = ServiceInfo(type_, registration_name)
220-
info.load_from_cache(zc)
219+
for _ in range(50):
220+
time.sleep(0.02)
221+
info.load_from_cache(zc)
222+
if info.addresses:
223+
break
221224
assert info.addresses == [socket.inet_pton(socket.AF_INET, "1.2.3.4")]
222225
assert info.properties == {b"version": b"1.0"}
223226
zc.close()
224227

225228

226-
def test_ptr_optimization():
229+
def test_ptr_optimization(quick_timing: None) -> None:
227230
# instantiate a zeroconf instance
228231
zc = Zeroconf(interfaces=["127.0.0.1"])
229232

@@ -597,7 +600,7 @@ async def test_probe_answered_immediately_with_uppercase_name():
597600
zc.close()
598601

599602

600-
def test_qu_response():
603+
def test_qu_response(quick_timing: None) -> None:
601604
"""Handle multicast incoming with the QU bit set."""
602605
# instantiate a zeroconf instance
603606
zc = Zeroconf(interfaces=["127.0.0.1"])
@@ -1351,8 +1354,12 @@ async def test_cache_flush_bit():
13511354
else:
13521355
assert entry.ttl == 1
13531356

1354-
# Wait for the ttl 1 records to expire
1355-
await asyncio.sleep(1.1)
1357+
# Backdate the ttl=1 records so they are already expired when
1358+
# load_from_cache runs — equivalent to sleeping 1.1s without the wait.
1359+
for store in zc.cache.cache.values():
1360+
for cached in store.values():
1361+
if cached.ttl == 1:
1362+
cached.created -= 1100
13561363

13571364
loaded_info = r.ServiceInfo(type_, registration_name)
13581365
loaded_info.load_from_cache(zc)

tests/test_services.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
import logging
66
import os
77
import socket
8-
import time
98
import unittest
109
from threading import Event
1110
from typing import Any
@@ -113,8 +112,10 @@ def update_service(self, zeroconf, type, name):
113112
service_added.wait(1)
114113
assert service_added.is_set()
115114

116-
# short pause to allow multicast timers to expire
117-
time.sleep(0.5)
115+
# Drain pending multicast announces from the registrar instead
116+
# of sleeping for them — same pattern as PR #1701.
117+
zeroconf_registrar.out_queue.queue.clear()
118+
zeroconf_registrar.out_delay_queue.queue.clear()
118119

119120
zeroconf_browser.add_service_listener(type_, DuplicateListener())
120121
duplicate_service_added.wait(

tests/test_updates.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ def teardown_module():
2929
log.setLevel(original_logging_level)
3030

3131

32-
def test_legacy_record_update_listener():
32+
def test_legacy_record_update_listener(quick_timing: None) -> None:
3333
"""Test a RecordUpdateListener that does not implement update_records."""
3434

3535
# instantiate a zeroconf instance

0 commit comments

Comments
 (0)