feat: implement randomized response jitter in DiscoveryHelper to prevent packet collisions

- Added a default upper bound for randomized pre-send jitter in discovery responses to avoid collisions when multiple repeaters respond simultaneously.
- Introduced a new parameter `response_jitter_ms` in the `DiscoveryHelper` constructor to configure the jitter.
- Updated the `_send_packet_async` method to apply the jitter before sending responses.
- Added tests to verify the correct application of jitter and ensure functionality when jitter is disabled.
This commit is contained in:
agessaman
2026-05-30 18:07:23 -07:00
parent 5fcb6255d5
commit 778adb6917
2 changed files with 66 additions and 1 deletions
+27
View File
@@ -7,11 +7,21 @@ allowing other nodes to discover repeaters on the mesh network.
import asyncio
import logging
import secrets
from pymc_core.node.handlers.control import ControlHandler
logger = logging.getLogger("DiscoveryHelper")
# Default upper bound (ms) for the randomized pre-send jitter applied to node
# discovery responses. A node-discover request is a broadcast that every
# in-range repeater answers at once, so without jitter they all transmit at the
# same engine-scheduled instant and collide. Mirrors the firmware, which spreads
# these replies deliberately (MyMesh.cpp:797, sendZeroHop with
# getRetransmitDelay*4). Safe to be generous: the requester's discovery window is
# 60s (firmware pending_discover_until = futureMillis(60000)).
DEFAULT_DISCOVERY_RESPONSE_JITTER_MS = 2000
class DiscoveryHelper:
"""Helper class for processing discovery requests in the repeater."""
@@ -23,6 +33,7 @@ class DiscoveryHelper:
node_type: int = 2,
log_fn=None,
debug_log_fn=None,
response_jitter_ms: int = DEFAULT_DISCOVERY_RESPONSE_JITTER_MS,
):
"""
Initialize the discovery helper.
@@ -34,10 +45,14 @@ class DiscoveryHelper:
log_fn: Optional logging function for ControlHandler
debug_log_fn: Optional logging for verbose ControlHandler messages (e.g. callback
presence). Pass logger.debug to avoid INFO noise when forwarding to companions.
response_jitter_ms: Upper bound (ms) for the randomized delay added before
transmitting a discovery response, to avoid multiple repeaters colliding
when answering the same broadcast. Set to 0 to disable (e.g. in tests).
"""
self.local_identity = local_identity
self.packet_injector = packet_injector # Function to inject packets into router
self.node_type = node_type
self.response_jitter_ms = max(0, int(response_jitter_ms))
# Create ControlHandler internally as a parsing utility
self.control_handler = ControlHandler(
@@ -147,6 +162,18 @@ class DiscoveryHelper:
tag: The tag for logging purposes
"""
try:
# Randomized pre-send jitter so multiple repeaters answering the same
# zero-hop discovery broadcast don't transmit at the same engine-scheduled
# instant and collide (the engine's DIRECT delay is fixed, not random).
# Mirrors firmware MyMesh.cpp:797. Uses secrets like the engine's TX jitter.
if self.response_jitter_ms > 0:
jitter_s = secrets.randbelow(self.response_jitter_ms + 1) / 1000.0
if jitter_s > 0:
logger.debug(
f"Discovery response jitter {jitter_s * 1000:.0f}ms for tag 0x{tag:08X}"
)
await asyncio.sleep(jitter_s)
success = await self.packet_injector(packet, wait_for_ack=False)
if success:
logger.info(f"Response sent for tag 0x{tag:08X}")
@@ -207,7 +207,10 @@ def test_discovery_request_without_identity_does_not_send():
@pytest.mark.asyncio
async def test_discovery_send_packet_async_success_failure_and_exception():
injector = AsyncMock(side_effect=[True, False, RuntimeError("send fail")])
helper = DiscoveryHelper(local_identity=FakeIdentity(0x42), packet_injector=injector)
# jitter disabled so the test doesn't sleep
helper = DiscoveryHelper(
local_identity=FakeIdentity(0x42), packet_injector=injector, response_jitter_ms=0
)
await helper._send_packet_async(packet=object(), tag=0x11)
await helper._send_packet_async(packet=object(), tag=0x12)
@@ -216,6 +219,41 @@ async def test_discovery_send_packet_async_success_failure_and_exception():
assert injector.await_count == 3
@pytest.mark.asyncio
async def test_discovery_response_applies_bounded_jitter_before_send():
injector = AsyncMock(return_value=True)
helper = DiscoveryHelper(
local_identity=FakeIdentity(0x42), packet_injector=injector, response_jitter_ms=2000
)
slept = []
async def fake_sleep(secs):
slept.append(secs)
with patch("repeater.handler_helpers.discovery.asyncio.sleep", side_effect=fake_sleep):
await helper._send_packet_async(packet=object(), tag=0x55)
# Jitter applied exactly once, bounded to [0, 2.0]s, before the injection.
assert len(slept) == 1
assert 0.0 <= slept[0] <= 2.0
injector.assert_awaited_once()
@pytest.mark.asyncio
async def test_discovery_response_jitter_disabled_does_not_sleep():
injector = AsyncMock(return_value=True)
helper = DiscoveryHelper(
local_identity=FakeIdentity(0x42), packet_injector=injector, response_jitter_ms=0
)
with patch("repeater.handler_helpers.discovery.asyncio.sleep") as sleep_mock:
await helper._send_packet_async(packet=object(), tag=0x56)
sleep_mock.assert_not_called()
injector.assert_awaited_once()
def test_discovery_send_response_without_injector_is_safe():
helper = DiscoveryHelper(local_identity=FakeIdentity(0x42), packet_injector=None)