From cbe3afaee93b7501d3cd64fa6b226771c3155293 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 20 May 2026 11:00:40 -0500 Subject: [PATCH] test: scale aggregation timings 10x to speed up timing-dependent tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- tests/conftest.py | 27 ++++++++++++++ tests/test_handlers.py | 82 +++++++++++++++++++++++++++--------------- 2 files changed, 81 insertions(+), 28 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index c281e2d4..87e7620c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -98,6 +98,33 @@ def quick_timing() -> Generator[None]: yield +@pytest.fixture +def quick_aggregation_timing() -> Generator[None]: + """Scale multicast aggregation / network-protection delays 10x for tests. + + The aggregation tests in `tests/test_handlers.py` verify timing- + dependent behaviour of `MulticastOutgoingQueue`: aggregation window, + network protection (~1s), and protected aggregation. The behaviour + under test is a ratio of these constants — the exact wall-clock + values are not the contract — so scaling them down and the test + sleeps in lock-step preserves what is tested while dropping each + test from ~3s to ~0.3s. + + The patches must be in place before `AsyncZeroconf(...)` is + constructed because `MulticastOutgoingQueue` reads the constants at + init time and stashes them on the instance. The per-queue + `_multicast_delay_random_min` / `_max` jitter (1-5ms here) can + still be set on the queue instance after construction by the test + itself — those slots are `cdef public` in the .pxd. + """ + with ( + patch.object(_core, "_AGGREGATION_DELAY", 50), + patch.object(_core, "_PROTECTED_AGGREGATION_DELAY", 20), + patch.object(_core, "_ONE_SECOND", 100), + ): + yield + + @pytest.fixture def quick_request_timing() -> Generator[None]: """Shorten the initial-query delay used by AsyncServiceInfo.async_request. diff --git a/tests/test_handlers.py b/tests/test_handlers.py index ca5bcabb..69f3c826 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -1601,14 +1601,23 @@ async def test_duplicate_goodbye_answers_in_packet(): @pytest.mark.asyncio -async def test_response_aggregation_timings(run_isolated): - """Verify multicast responses are aggregated.""" +@pytest.mark.usefixtures("quick_aggregation_timing") +async def test_response_aggregation_timings(run_isolated: None) -> None: + """Verify multicast responses are aggregated. + + Aggregation / network-protection constants are scaled 10x by + ``quick_aggregation_timing``; the asserted ratios are unchanged + but each phase finishes in ~1/10 the wall time. + """ type_ = "_mservice._tcp.local." type_2 = "_mservice2._tcp.local." type_3 = "_mservice3._tcp.local." aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) await aiozc.zeroconf.async_wait_for_start() + for queue in (aiozc.zeroconf.out_queue, aiozc.zeroconf.out_delay_queue): + queue._multicast_delay_random_min = 1 + queue._multicast_delay_random_max = 5 name = "xxxyyy" registration_name = f"{name}.{type_}" @@ -1673,9 +1682,10 @@ async def test_response_aggregation_timings(run_isolated): protocol.datagram_received(query.packets()[0], ("127.0.0.1", const._MDNS_PORT)) protocol.datagram_received(query2.packets()[0], ("127.0.0.1", const._MDNS_PORT)) protocol.datagram_received(query.packets()[0], ("127.0.0.1", const._MDNS_PORT)) - await asyncio.sleep(0.7) + await asyncio.sleep(0.07) - # Should aggregate into a single answer with up to a 500ms + 120ms delay + # Should aggregate into a single answer with up to a 50ms + 5ms delay + # (scaled from 500ms + 120ms by `quick_aggregation_timing`). calls = send_mock.mock_calls assert len(calls) == 1 outgoing = send_mock.call_args[0][0] @@ -1686,10 +1696,10 @@ async def test_response_aggregation_timings(run_isolated): send_mock.reset_mock() protocol.datagram_received(query3.packets()[0], ("127.0.0.1", const._MDNS_PORT)) - await asyncio.sleep(0.3) + await asyncio.sleep(0.03) - # Should send within 120ms since there are no other - # answers to aggregate with + # Should send within 12ms (scaled max random delay) since there are + # no other answers to aggregate with. calls = send_mock.mock_calls assert len(calls) == 1 outgoing = send_mock.call_args[0][0] @@ -1698,21 +1708,21 @@ async def test_response_aggregation_timings(run_isolated): assert info3.dns_pointer() in incoming.answers() send_mock.reset_mock() - # Because the response was sent in the last second we need to make - # sure the next answer is delayed at least a second + # Because the response was sent in the last 100ms (scaled 1s) we + # need to make sure the next answer is delayed at least that long. aiozc.zeroconf.engine.protocols[0].datagram_received( query4.packets()[0], ("127.0.0.1", const._MDNS_PORT) ) - await asyncio.sleep(0.5) + await asyncio.sleep(0.05) - # After 0.5 seconds it should not have been sent + # After 50ms it should not have been sent. # Protect the network against excessive packet flooding # https://datatracker.ietf.org/doc/html/rfc6762#section-14 calls = send_mock.mock_calls assert len(calls) == 0 send_mock.reset_mock() - await asyncio.sleep(1.2) + await asyncio.sleep(0.12) calls = send_mock.mock_calls assert len(calls) == 1 outgoing = send_mock.call_args[0][0] @@ -1723,14 +1733,30 @@ async def test_response_aggregation_timings(run_isolated): @pytest.mark.asyncio -async def test_response_aggregation_timings_multiple(run_isolated, disable_duplicate_packet_suppression): - """Verify multicast responses that are aggregated do not take longer than 620ms to send. - - 620ms is the maximum random delay of 120ms and 500ms additional for aggregation.""" +@pytest.mark.usefixtures("quick_aggregation_timing") +async def test_response_aggregation_timings_multiple( + run_isolated: None, disable_duplicate_packet_suppression: None +) -> None: + """Verify multicast responses that are aggregated do not take longer than 62ms to send. + + Aggregation / network-protection constants are scaled 10x by + ``quick_aggregation_timing`` (500ms→50ms, 200ms→20ms, 1000ms→100ms) + and the per-queue jitter is set to 1-5ms below. The asserted + ratios are the same as the production behaviour the test pins — + aggregation window, network protection, protected aggregation — + only the absolute durations are scaled. + """ type_2 = "_mservice2._tcp.local." aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) await aiozc.zeroconf.async_wait_for_start() + # Scale the queues' random jitter to match the 10x scaled + # additional / aggregation delays; without this, the 20-120ms + # jitter would dominate the scaled window and make timing assertions + # unreliable. + for queue in (aiozc.zeroconf.out_queue, aiozc.zeroconf.out_delay_queue): + queue._multicast_delay_random_min = 1 + queue._multicast_delay_random_max = 5 name = "xxxyyy" registration_name2 = f"{name}.{type_2}" @@ -1760,7 +1786,7 @@ async def test_response_aggregation_timings_multiple(run_isolated, disable_dupli protocol.datagram_received(query2.packets()[0], ("127.0.0.1", const._MDNS_PORT)) protocol.last_time = 0 # manually reset to avoid duplicate packet suppression protocol._recent_packets.clear() - await asyncio.sleep(0.2) + await asyncio.sleep(0.02) calls = send_mock.mock_calls assert len(calls) == 1 outgoing = send_mock.call_args[0][0] @@ -1772,7 +1798,7 @@ async def test_response_aggregation_timings_multiple(run_isolated, disable_dupli protocol.datagram_received(query2.packets()[0], ("127.0.0.1", const._MDNS_PORT)) protocol.last_time = 0 # manually reset to avoid duplicate packet suppression protocol._recent_packets.clear() - await asyncio.sleep(1.2) + await asyncio.sleep(0.12) calls = send_mock.mock_calls assert len(calls) == 1 outgoing = send_mock.call_args[0][0] @@ -1787,19 +1813,19 @@ async def test_response_aggregation_timings_multiple(run_isolated, disable_dupli protocol.datagram_received(query2.packets()[0], ("127.0.0.1", const._MDNS_PORT)) protocol.last_time = 0 # manually reset to avoid duplicate packet suppression protocol._recent_packets.clear() - # The minimum protected send_after is 1000ms + 20ms random; sleep - # well under that so coarse timers on slow runners cannot push the - # send into this window and flake the assertion. - await asyncio.sleep(0.5) + # Scaled: minimum protected send_after is 100ms + 1-5ms random; + # sleep well under that so coarse timers on slow runners cannot + # push the send into this window and flake the assertion. + await asyncio.sleep(0.05) calls = send_mock.mock_calls assert len(calls) == 0 - # 1000ms (1s network protection delays) - # - 500ms (already slept) - # + 120ms (maximum random delay) - # + 200ms (maximum protected aggregation delay) - # + 20ms (execution time) - await asyncio.sleep(millis_to_seconds(1000 - 500 + 120 + 200 + 20)) + # 100ms (scaled 1s network protection) + # - 50ms (already slept) + # + 5ms (scaled maximum random delay) + # + 20ms (scaled protected aggregation delay) + # + 5ms (execution slack) + await asyncio.sleep(millis_to_seconds(100 - 50 + 5 + 20 + 5)) calls = send_mock.mock_calls assert len(calls) == 1 outgoing = send_mock.call_args[0][0]