Skip to content

Commit cbe3afa

Browse files
committed
test: scale aggregation timings 10x to speed up timing-dependent tests
`test_response_aggregation_timings` and `test_response_aggregation_timings_multiple` exercise the `MulticastOutgoingQueue` aggregation window, network protection (~1s), and protected aggregation. The behaviour under test is the ratio of these timings — not their absolute wall-clock values — so scaling the constants and the corresponding `asyncio.sleep`s down together preserves the test contract while dropping each test from ~3s to ~0.5s. Add `quick_aggregation_timing` to `tests/conftest.py` patching `zeroconf._core._AGGREGATION_DELAY` (500ms → 50ms), `_PROTECTED_AGGREGATION_DELAY` (200ms → 20ms), and `_ONE_SECOND` (1000ms → 100ms) before `MulticastOutgoingQueue` is constructed. Each test additionally pins its per-queue jitter (`_multicast_delay_random_min` / `_max`, exposed via `cdef public` in `multicast_outgoing_queue.pxd`) to 1-5ms so the scaled-down window is not dominated by the unscaled 20-120ms random delay. Net: ~5s saved per test run; sister tests still cover the same timing semantics. Closes the top two slowest entries on issue #1707. The remaining slow tests in #1707 (`test_get_info_suppressed_by_question_history`, `test_we_try_four_times_with_random_delay`) are dominated by `_DUPLICATE_QUESTION_INTERVAL = 999ms` which is `cdef`'d in `_services/info.pxd` and therefore unpatchable from Python under the Cython build; speeding those up is a separate refactor.
1 parent cb0af4a commit cbe3afa

2 files changed

Lines changed: 81 additions & 28 deletions

File tree

tests/conftest.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,33 @@ def quick_timing() -> Generator[None]:
9898
yield
9999

100100

101+
@pytest.fixture
102+
def quick_aggregation_timing() -> Generator[None]:
103+
"""Scale multicast aggregation / network-protection delays 10x for tests.
104+
105+
The aggregation tests in `tests/test_handlers.py` verify timing-
106+
dependent behaviour of `MulticastOutgoingQueue`: aggregation window,
107+
network protection (~1s), and protected aggregation. The behaviour
108+
under test is a ratio of these constants — the exact wall-clock
109+
values are not the contract — so scaling them down and the test
110+
sleeps in lock-step preserves what is tested while dropping each
111+
test from ~3s to ~0.3s.
112+
113+
The patches must be in place before `AsyncZeroconf(...)` is
114+
constructed because `MulticastOutgoingQueue` reads the constants at
115+
init time and stashes them on the instance. The per-queue
116+
`_multicast_delay_random_min` / `_max` jitter (1-5ms here) can
117+
still be set on the queue instance after construction by the test
118+
itself — those slots are `cdef public` in the .pxd.
119+
"""
120+
with (
121+
patch.object(_core, "_AGGREGATION_DELAY", 50),
122+
patch.object(_core, "_PROTECTED_AGGREGATION_DELAY", 20),
123+
patch.object(_core, "_ONE_SECOND", 100),
124+
):
125+
yield
126+
127+
101128
@pytest.fixture
102129
def quick_request_timing() -> Generator[None]:
103130
"""Shorten the initial-query delay used by AsyncServiceInfo.async_request.

tests/test_handlers.py

Lines changed: 54 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1601,14 +1601,23 @@ async def test_duplicate_goodbye_answers_in_packet():
16011601

16021602

16031603
@pytest.mark.asyncio
1604-
async def test_response_aggregation_timings(run_isolated):
1605-
"""Verify multicast responses are aggregated."""
1604+
@pytest.mark.usefixtures("quick_aggregation_timing")
1605+
async def test_response_aggregation_timings(run_isolated: None) -> None:
1606+
"""Verify multicast responses are aggregated.
1607+
1608+
Aggregation / network-protection constants are scaled 10x by
1609+
``quick_aggregation_timing``; the asserted ratios are unchanged
1610+
but each phase finishes in ~1/10 the wall time.
1611+
"""
16061612
type_ = "_mservice._tcp.local."
16071613
type_2 = "_mservice2._tcp.local."
16081614
type_3 = "_mservice3._tcp.local."
16091615

16101616
aiozc = AsyncZeroconf(interfaces=["127.0.0.1"])
16111617
await aiozc.zeroconf.async_wait_for_start()
1618+
for queue in (aiozc.zeroconf.out_queue, aiozc.zeroconf.out_delay_queue):
1619+
queue._multicast_delay_random_min = 1
1620+
queue._multicast_delay_random_max = 5
16121621

16131622
name = "xxxyyy"
16141623
registration_name = f"{name}.{type_}"
@@ -1673,9 +1682,10 @@ async def test_response_aggregation_timings(run_isolated):
16731682
protocol.datagram_received(query.packets()[0], ("127.0.0.1", const._MDNS_PORT))
16741683
protocol.datagram_received(query2.packets()[0], ("127.0.0.1", const._MDNS_PORT))
16751684
protocol.datagram_received(query.packets()[0], ("127.0.0.1", const._MDNS_PORT))
1676-
await asyncio.sleep(0.7)
1685+
await asyncio.sleep(0.07)
16771686

1678-
# Should aggregate into a single answer with up to a 500ms + 120ms delay
1687+
# Should aggregate into a single answer with up to a 50ms + 5ms delay
1688+
# (scaled from 500ms + 120ms by `quick_aggregation_timing`).
16791689
calls = send_mock.mock_calls
16801690
assert len(calls) == 1
16811691
outgoing = send_mock.call_args[0][0]
@@ -1686,10 +1696,10 @@ async def test_response_aggregation_timings(run_isolated):
16861696
send_mock.reset_mock()
16871697

16881698
protocol.datagram_received(query3.packets()[0], ("127.0.0.1", const._MDNS_PORT))
1689-
await asyncio.sleep(0.3)
1699+
await asyncio.sleep(0.03)
16901700

1691-
# Should send within 120ms since there are no other
1692-
# answers to aggregate with
1701+
# Should send within 12ms (scaled max random delay) since there are
1702+
# no other answers to aggregate with.
16931703
calls = send_mock.mock_calls
16941704
assert len(calls) == 1
16951705
outgoing = send_mock.call_args[0][0]
@@ -1698,21 +1708,21 @@ async def test_response_aggregation_timings(run_isolated):
16981708
assert info3.dns_pointer() in incoming.answers()
16991709
send_mock.reset_mock()
17001710

1701-
# Because the response was sent in the last second we need to make
1702-
# sure the next answer is delayed at least a second
1711+
# Because the response was sent in the last 100ms (scaled 1s) we
1712+
# need to make sure the next answer is delayed at least that long.
17031713
aiozc.zeroconf.engine.protocols[0].datagram_received(
17041714
query4.packets()[0], ("127.0.0.1", const._MDNS_PORT)
17051715
)
1706-
await asyncio.sleep(0.5)
1716+
await asyncio.sleep(0.05)
17071717

1708-
# After 0.5 seconds it should not have been sent
1718+
# After 50ms it should not have been sent.
17091719
# Protect the network against excessive packet flooding
17101720
# https://datatracker.ietf.org/doc/html/rfc6762#section-14
17111721
calls = send_mock.mock_calls
17121722
assert len(calls) == 0
17131723
send_mock.reset_mock()
17141724

1715-
await asyncio.sleep(1.2)
1725+
await asyncio.sleep(0.12)
17161726
calls = send_mock.mock_calls
17171727
assert len(calls) == 1
17181728
outgoing = send_mock.call_args[0][0]
@@ -1723,14 +1733,30 @@ async def test_response_aggregation_timings(run_isolated):
17231733

17241734

17251735
@pytest.mark.asyncio
1726-
async def test_response_aggregation_timings_multiple(run_isolated, disable_duplicate_packet_suppression):
1727-
"""Verify multicast responses that are aggregated do not take longer than 620ms to send.
1728-
1729-
620ms is the maximum random delay of 120ms and 500ms additional for aggregation."""
1736+
@pytest.mark.usefixtures("quick_aggregation_timing")
1737+
async def test_response_aggregation_timings_multiple(
1738+
run_isolated: None, disable_duplicate_packet_suppression: None
1739+
) -> None:
1740+
"""Verify multicast responses that are aggregated do not take longer than 62ms to send.
1741+
1742+
Aggregation / network-protection constants are scaled 10x by
1743+
``quick_aggregation_timing`` (500ms→50ms, 200ms→20ms, 1000ms→100ms)
1744+
and the per-queue jitter is set to 1-5ms below. The asserted
1745+
ratios are the same as the production behaviour the test pins —
1746+
aggregation window, network protection, protected aggregation —
1747+
only the absolute durations are scaled.
1748+
"""
17301749
type_2 = "_mservice2._tcp.local."
17311750

17321751
aiozc = AsyncZeroconf(interfaces=["127.0.0.1"])
17331752
await aiozc.zeroconf.async_wait_for_start()
1753+
# Scale the queues' random jitter to match the 10x scaled
1754+
# additional / aggregation delays; without this, the 20-120ms
1755+
# jitter would dominate the scaled window and make timing assertions
1756+
# unreliable.
1757+
for queue in (aiozc.zeroconf.out_queue, aiozc.zeroconf.out_delay_queue):
1758+
queue._multicast_delay_random_min = 1
1759+
queue._multicast_delay_random_max = 5
17341760

17351761
name = "xxxyyy"
17361762
registration_name2 = f"{name}.{type_2}"
@@ -1760,7 +1786,7 @@ async def test_response_aggregation_timings_multiple(run_isolated, disable_dupli
17601786
protocol.datagram_received(query2.packets()[0], ("127.0.0.1", const._MDNS_PORT))
17611787
protocol.last_time = 0 # manually reset to avoid duplicate packet suppression
17621788
protocol._recent_packets.clear()
1763-
await asyncio.sleep(0.2)
1789+
await asyncio.sleep(0.02)
17641790
calls = send_mock.mock_calls
17651791
assert len(calls) == 1
17661792
outgoing = send_mock.call_args[0][0]
@@ -1772,7 +1798,7 @@ async def test_response_aggregation_timings_multiple(run_isolated, disable_dupli
17721798
protocol.datagram_received(query2.packets()[0], ("127.0.0.1", const._MDNS_PORT))
17731799
protocol.last_time = 0 # manually reset to avoid duplicate packet suppression
17741800
protocol._recent_packets.clear()
1775-
await asyncio.sleep(1.2)
1801+
await asyncio.sleep(0.12)
17761802
calls = send_mock.mock_calls
17771803
assert len(calls) == 1
17781804
outgoing = send_mock.call_args[0][0]
@@ -1787,19 +1813,19 @@ async def test_response_aggregation_timings_multiple(run_isolated, disable_dupli
17871813
protocol.datagram_received(query2.packets()[0], ("127.0.0.1", const._MDNS_PORT))
17881814
protocol.last_time = 0 # manually reset to avoid duplicate packet suppression
17891815
protocol._recent_packets.clear()
1790-
# The minimum protected send_after is 1000ms + 20ms random; sleep
1791-
# well under that so coarse timers on slow runners cannot push the
1792-
# send into this window and flake the assertion.
1793-
await asyncio.sleep(0.5)
1816+
# Scaled: minimum protected send_after is 100ms + 1-5ms random;
1817+
# sleep well under that so coarse timers on slow runners cannot
1818+
# push the send into this window and flake the assertion.
1819+
await asyncio.sleep(0.05)
17941820
calls = send_mock.mock_calls
17951821
assert len(calls) == 0
17961822

1797-
# 1000ms (1s network protection delays)
1798-
# - 500ms (already slept)
1799-
# + 120ms (maximum random delay)
1800-
# + 200ms (maximum protected aggregation delay)
1801-
# + 20ms (execution time)
1802-
await asyncio.sleep(millis_to_seconds(1000 - 500 + 120 + 200 + 20))
1823+
# 100ms (scaled 1s network protection)
1824+
# - 50ms (already slept)
1825+
# + 5ms (scaled maximum random delay)
1826+
# + 20ms (scaled protected aggregation delay)
1827+
# + 5ms (execution slack)
1828+
await asyncio.sleep(millis_to_seconds(100 - 50 + 5 + 20 + 5))
18031829
calls = send_mock.mock_calls
18041830
assert len(calls) == 1
18051831
outgoing = send_mock.call_args[0][0]

0 commit comments

Comments
 (0)