Files
pyMC_Repeater/tests/test_packet_router.py
T
agessaman 0a65e0566b fix(router): mark companion delivery only after a bridge receives the packet
The PATH and protocol-response paths recorded the companion dedupe
unconditionally after the bridge fan-out, so a delivery where every
bridge raised was still suppressed for the full dedupe TTL — the client
lost the packet even though later mesh copies arrived. Have the fan-out
report delivery (at least one bridge completed without raising) next to
authentication, and mark the dedupe only on delivery, which is the
behaviour the marking helper's contract already documented. One healthy
bridge still counts as delivered, so duplicate suppression of repeated
mesh copies is unchanged.
2026-07-19 07:12:24 -07:00

1427 lines
60 KiB
Python

"""
Tests for PacketRouter in-flight cap and shutdown behaviour.
Addresses the three concerns raised in PR 191 review:
1. Cap enforcement: packets beyond _max_in_flight are dropped, not queued.
2. Drop counter: _cap_drop_count increments on each cap-drop so operators
have visibility into how often the safety valve fires.
3. Shutdown drain: stop() waits for in-flight tasks to finish (up to 5 s),
then cancels any that remain — tasks are never silently abandoned.
Run with:
python -m pytest tests/test_packet_router.py -v
or:
python -m unittest tests.test_packet_router -v
"""
import asyncio
import hashlib
import time
import unittest
from unittest.mock import AsyncMock, MagicMock, patch
from openhop_core.companion import CompanionBridge
from openhop_core.node.handlers.ack import AckHandler
from openhop_core.node.handlers.advert import AdvertHandler
from openhop_core.node.handlers.control import ControlHandler
from openhop_core.node.handlers.group_text import GroupTextHandler
from openhop_core.node.handlers.login_response import LoginResponseHandler
from openhop_core.node.handlers.login_server import LoginServerHandler
from openhop_core.node.handlers.multipart import MultipartAckHandler
from openhop_core.node.handlers.path import PathHandler
from openhop_core.node.handlers.protocol_request import ProtocolRequestHandler
from openhop_core.node.handlers.protocol_response import ProtocolResponseHandler
from openhop_core.node.handlers.result import HandlerResult
from openhop_core.node.handlers.text import TextMessageHandler
from openhop_core.node.handlers.trace import TraceHandler
from openhop_core.protocol.constants import (
PAYLOAD_TYPE_GRP_DATA,
ROUTE_TYPE_DIRECT,
ROUTE_TYPE_FLOOD,
ROUTE_TYPE_TRANSPORT_DIRECT,
)
from openhop_core.protocol import LocalIdentity, Packet, PacketBuilder
from repeater.packet_router import (
PacketRouter,
_companion_dedup_key,
_is_direct_final_hop,
)
from repeater.policy_engine import PolicyEngine
# ---------------------------------------------------------------------------
# Minimal daemon stub
# ---------------------------------------------------------------------------
def _make_daemon():
"""Minimal daemon that satisfies PacketRouter without touching hardware."""
daemon = MagicMock()
daemon.repeater_handler = AsyncMock(return_value=True)
daemon.repeater_handler.record_packet_only = MagicMock()
daemon.trace_helper = None
daemon.discovery_helper = None
daemon.advert_helper = None
daemon.companion_bridges = {}
daemon.login_helper = None
daemon.text_helper = None
daemon.path_helper = None
daemon.protocol_request_helper = None
daemon.dispatcher = MagicMock()
daemon.dispatcher._register_ack_received = AsyncMock()
daemon.dispatcher.wait_for_ack = AsyncMock(return_value=True)
return daemon
def _make_packet(payload_type: int = 0xFF):
"""Minimal packet stub."""
pkt = MagicMock()
pkt.get_payload_type.return_value = payload_type
pkt.payload = b"\xff"
pkt.header = 0x00
pkt.rssi = -80
pkt.snr = 5.0
pkt.timestamp = time.time()
pkt._injected_for_tx = False
pkt.path = bytearray()
pkt.calculate_packet_hash.return_value = b"\x01" * 32
pkt.mark_do_not_retransmit = MagicMock()
return pkt
def _make_bridge():
bridge = MagicMock()
bridge.process_received_packet = AsyncMock()
return bridge
class _SlottedPacket:
__slots__ = (
"payload",
"header",
"rssi",
"snr",
"timestamp",
"_injected_for_tx",
"path",
"calculate_packet_hash",
"mark_do_not_retransmit",
"_payload_type",
)
def __init__(self, payload_type: int = 1):
self.payload = b"\x00"
self.header = 0x00
self.rssi = -80
self.snr = 5.0
self.timestamp = time.time()
self._injected_for_tx = False
self.path = bytearray()
self.calculate_packet_hash = MagicMock(return_value=b"\x01" * 32)
self.mark_do_not_retransmit = MagicMock()
self._payload_type = payload_type
def get_payload_type(self):
return self._payload_type
def get_path_hash_size(self):
return 0
def get_path_hash_count(self):
return 0
def get_path_hashes_hex(self):
return []
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
class TestInFlightCap(unittest.IsolatedAsyncioTestCase):
# ── 1. Cap enforcement ──────────────────────────────────────────────────
async def test_cap_drops_packets_when_full(self):
"""
When _in_flight reaches _max_in_flight, new packets from the queue
must be dropped (not passed to _route_packet).
"""
router = PacketRouter(_make_daemon())
router._max_in_flight = 3
# Manually occupy all slots with long-sleeping tasks
barrier = asyncio.Event()
async def slow_route(pkt):
await barrier.wait() # blocks until we release
routed = []
async def counting_route(pkt):
routed.append(pkt)
await barrier.wait()
router._route_packet = counting_route
await router.start()
# Fill the cap
for _ in range(3):
await router.enqueue(_make_packet())
await asyncio.sleep(0.05) # let queue drain into tasks
self.assertEqual(router._in_flight, 3)
# These should be dropped
for _ in range(5):
await router.enqueue(_make_packet())
await asyncio.sleep(0.05)
self.assertEqual(router._in_flight, 3, "In-flight count exceeded cap")
self.assertEqual(router._cap_drop_count, 5, "Expected 5 cap-drops, got different count")
barrier.set() # release blocked tasks
await router.stop()
# ── 2. Drop counter ─────────────────────────────────────────────────────
async def test_cap_drop_count_increments(self):
"""_cap_drop_count must increment by exactly 1 for each dropped packet."""
router = PacketRouter(_make_daemon())
router._max_in_flight = 1
barrier = asyncio.Event()
async def blocking_route(pkt):
await barrier.wait()
router._route_packet = blocking_route
await router.start()
# Fill the single slot
await router.enqueue(_make_packet())
await asyncio.sleep(0.05)
self.assertEqual(router._in_flight, 1)
# Drop three packets
for _ in range(3):
await router.enqueue(_make_packet())
await asyncio.sleep(0.05)
self.assertEqual(router._cap_drop_count, 3)
barrier.set()
await router.stop()
async def test_cap_drop_count_zero_when_cap_not_reached(self):
"""_cap_drop_count must stay 0 when the cap is never reached."""
router = PacketRouter(_make_daemon())
router._max_in_flight = 30
completed = []
async def fast_route(pkt):
completed.append(pkt)
router._route_packet = fast_route
await router.start()
for _ in range(10):
await router.enqueue(_make_packet())
await asyncio.sleep(0.1)
self.assertEqual(router._cap_drop_count, 0)
await router.stop()
async def test_injected_trace_packet_skips_inbound_trace_processing(self):
"""Locally injected TRACE packets must not be re-parsed as inbound trace responses."""
daemon = _make_daemon()
daemon.trace_helper = MagicMock()
daemon.trace_helper.process_trace_packet = AsyncMock()
router = PacketRouter(daemon)
pkt = _make_packet(payload_type=TraceHandler.payload_type())
await router.start()
try:
injected = await router.inject_packet(pkt)
self.assertTrue(injected)
await asyncio.sleep(0.05)
daemon.repeater_handler.assert_awaited_once()
daemon.trace_helper.process_trace_packet.assert_not_awaited()
finally:
await router.stop()
async def test_policy_companion_precheck_handles_slotted_packet(self):
"""Policy companion precheck must not attach attributes to slotted Packet objects."""
daemon = _make_daemon()
daemon.companion_bridges = {"bridge": _make_bridge()}
daemon.repeater_handler.policy_engine = PolicyEngine({"enabled": True, "rules": []})
router = PacketRouter(daemon)
pkt = _SlottedPacket(payload_type=1)
metadata = {"rssi": pkt.rssi, "snr": pkt.snr}
bridges = router._companion_bridges_for_packet(pkt, metadata)
self.assertEqual(bridges, daemon.companion_bridges)
self.assertIn("_policy_precheck_decision", metadata)
async def test_route_grp_txt_reuses_policy_precheck_metadata(self):
"""GRP_TXT should not force a second policy evaluation when the router already pre-checked it."""
daemon = _make_daemon()
daemon.repeater_handler = AsyncMock(return_value=True)
daemon.repeater_handler.storage = MagicMock()
daemon.repeater_handler.record_packet_only = MagicMock()
daemon.repeater_handler.policy_engine = PolicyEngine({"enabled": True, "rules": []})
evaluate_spy = patch.object(
daemon.repeater_handler.policy_engine,
"evaluate",
wraps=daemon.repeater_handler.policy_engine.evaluate,
)
bridge = _make_bridge()
daemon.companion_bridges = {0x01: bridge}
router = PacketRouter(daemon)
pkt = _make_packet(GroupTextHandler.payload_type())
with evaluate_spy as mock_evaluate:
await router._route_packet(pkt)
self.assertEqual(mock_evaluate.call_count, 1)
bridge.process_received_packet.assert_awaited_once()
daemon.repeater_handler.assert_awaited_once()
async def test_route_grp_data_fans_out_to_companions_and_forwards(self):
"""GRP_DATA reaches each companion for channel matching, then reaches the engine."""
daemon = _make_daemon()
first_bridge = _make_bridge()
second_bridge = _make_bridge()
daemon.companion_bridges = {0x01: first_bridge, 0x02: second_bridge}
router = PacketRouter(daemon)
pkt = _make_packet(PAYLOAD_TYPE_GRP_DATA)
# Firmware wire vector: GRP_DATA (0x06), version 0, FLOOD (0x01).
pkt.header = (PAYLOAD_TYPE_GRP_DATA << 2) | ROUTE_TYPE_FLOOD
self.assertEqual(pkt.header, 0x19)
await router._route_packet(pkt)
first_bridge.process_received_packet.assert_awaited_once_with(pkt)
second_bridge.process_received_packet.assert_awaited_once_with(pkt)
daemon.repeater_handler.assert_awaited_once()
async def test_route_grp_data_decrypts_in_a_companion_and_forwards(self):
"""A firmware-format GRP_DATA packet is both delivered locally and forwarded."""
channel_secret = b"\x11" * 32
async def inject_packet(packet, wait_for_ack=False):
return True
bridge = CompanionBridge(LocalIdentity(), inject_packet, node_name="Test")
assert bridge.set_channel(0, "Public", channel_secret)
packet = PacketBuilder.create_group_data_packet(
PAYLOAD_TYPE_GRP_DATA,
channel_hash=hashlib.sha256(channel_secret).digest()[0],
channel_secret=channel_secret,
plaintext=b"\x34\x12\x02\xaa\xbb",
secret=channel_secret,
)
self.assertEqual(packet.header, 0x19)
daemon = _make_daemon()
daemon.companion_bridges = {0x01: bridge}
router = PacketRouter(daemon)
await router._route_packet(packet)
queued = bridge.sync_next_message()
self.assertIsNotNone(queued)
self.assertTrue(queued.is_channel)
self.assertEqual(queued.channel_idx, 0)
self.assertEqual(queued.channel_data_type, 0x1234)
self.assertEqual(queued.channel_data_payload, b"\xaa\xbb")
daemon.repeater_handler.assert_awaited_once()
async def test_route_direct_grp_data_intermediate_skips_companion_delivery(self):
"""A direct GRP_DATA packet with a remaining hop is only offered to the engine."""
daemon = _make_daemon()
bridge = _make_bridge()
daemon.companion_bridges = {0x01: bridge}
router = PacketRouter(daemon)
packet = _make_packet(PAYLOAD_TYPE_GRP_DATA)
packet.header = (PAYLOAD_TYPE_GRP_DATA << 2) | ROUTE_TYPE_DIRECT
packet.path = bytearray([0x42])
await router._route_packet(packet)
bridge.process_received_packet.assert_not_awaited()
daemon.repeater_handler.assert_awaited_once()
async def test_non_injected_handler_false_is_logged(self):
"""Inbound packets should log when repeater_handler reports TX failure."""
daemon = _make_daemon()
daemon.repeater_handler = AsyncMock(return_value=False)
router = PacketRouter(daemon)
pkt = _make_packet(payload_type=0xFF)
with patch("repeater.packet_router.logger.warning") as mock_warn:
await router._route_packet(pkt)
daemon.repeater_handler.assert_awaited_once()
mock_warn.assert_called()
async def test_expected_drop_reason_is_debug_not_warning(self):
"""Policy drops should log as debug to avoid false-alarm warnings."""
daemon = _make_daemon()
async def _handler(packet, metadata):
metadata["_repeater_drop_reason"] = "Max flood hops limit reached"
return False
daemon.repeater_handler = AsyncMock(side_effect=_handler)
router = PacketRouter(daemon)
pkt = _make_packet(payload_type=0x04)
pkt.header = 0x11 # type=4, route=FLOOD
with (
patch("repeater.packet_router.logger.debug") as mock_debug,
patch("repeater.packet_router.logger.warning") as mock_warn,
):
await router._route_packet(pkt)
daemon.repeater_handler.assert_awaited_once()
mock_debug.assert_called()
mock_warn.assert_not_called()
# ── 3. Shutdown: in-flight tasks drained ────────────────────────────────
async def test_stop_waits_for_in_flight_tasks(self):
"""
stop() must wait for in-flight tasks to complete before returning.
Tasks that finish within the 5-second timeout must complete normally,
not be cancelled.
"""
router = PacketRouter(_make_daemon())
completed = []
started = asyncio.Event()
async def slow_route(pkt):
started.set()
await asyncio.sleep(0.2) # finishes well within 5 s timeout
completed.append(pkt)
router._route_packet = slow_route
await router.start()
pkt = _make_packet()
await router.enqueue(pkt)
# Wait until the task has actually started
await asyncio.wait_for(started.wait(), timeout=1.0)
await router.stop()
# Task should have completed, not been cancelled
self.assertEqual(len(completed), 1, "In-flight task was cancelled instead of drained")
async def test_stop_cancels_tasks_that_exceed_timeout(self):
"""
Tasks that don't finish within the 5-second timeout must be cancelled,
not left running indefinitely.
"""
router = PacketRouter(_make_daemon())
router._max_in_flight = 5
cancelled = []
started = asyncio.Event()
async def hanging_route(pkt):
started.set()
try:
await asyncio.sleep(999) # will not finish within 5 s
except asyncio.CancelledError:
cancelled.append(pkt)
raise
router._route_packet = hanging_route
async def fast_stop():
router.running = False
if router.router_task:
router.router_task.cancel()
try:
await router.router_task
except asyncio.CancelledError:
pass
if router._route_tasks:
snapshot = set(router._route_tasks)
_, still_pending = await asyncio.wait(snapshot, timeout=0.1)
for task in still_pending:
task.cancel()
await asyncio.gather(*still_pending, return_exceptions=True)
router.stop = fast_stop
await router.start()
await router.enqueue(_make_packet())
await asyncio.wait_for(started.wait(), timeout=1.0)
await router.stop()
self.assertEqual(len(cancelled), 1, "Hanging task was not cancelled on shutdown")
# ── 4. Route-tasks set stays in sync with counter ───────────────────────
async def test_route_tasks_set_cleaned_up_on_completion(self):
"""
_route_tasks must be empty after all tasks complete — the done-callback
must discard each task so the set doesn't grow unboundedly.
"""
router = PacketRouter(_make_daemon())
async def fast_route(pkt):
await asyncio.sleep(0) # yield, then done
router._route_packet = fast_route
await router.start()
for _ in range(10):
await router.enqueue(_make_packet())
# Give tasks time to complete
await asyncio.sleep(0.1)
self.assertEqual(
len(router._route_tasks), 0, "_route_tasks not cleaned up after task completion"
)
self.assertEqual(router._in_flight, 0, "_in_flight counter not back to 0 after completion")
await router.stop()
# ── 5. Counter and set always agree ─────────────────────────────────────
async def test_counter_matches_set_size_under_load(self):
"""
_in_flight must always equal len(_route_tasks) while tasks are running.
Checked at steady state when the cap is saturated.
"""
router = PacketRouter(_make_daemon())
router._max_in_flight = 5
barrier = asyncio.Event()
async def blocking_route(pkt):
await barrier.wait()
router._route_packet = blocking_route
await router.start()
for _ in range(5):
await router.enqueue(_make_packet())
await asyncio.sleep(0.05)
self.assertEqual(
router._in_flight,
len(router._route_tasks),
f"Counter ({router._in_flight}) != set size ({len(router._route_tasks)})",
)
barrier.set()
await router.stop()
if __name__ == "__main__":
unittest.main()
class TestPacketRouterRoutingBranches(unittest.IsolatedAsyncioTestCase):
def test_companion_dedup_key_handles_hash_exceptions(self):
pkt = MagicMock()
pkt.calculate_packet_hash.side_effect = RuntimeError("bad packet")
self.assertIsNone(_companion_dedup_key(pkt))
def test_is_direct_final_hop_helper(self):
pkt = _make_packet()
pkt.header = ROUTE_TYPE_DIRECT
pkt.path = bytearray()
self.assertTrue(_is_direct_final_hop(pkt))
pkt.path = bytearray(b"\x01")
self.assertFalse(_is_direct_final_hop(pkt))
async def test_should_deliver_path_to_companions_dedupes(self):
router = PacketRouter(_make_daemon())
pkt = _make_packet(PathHandler.payload_type())
self.assertTrue(router._should_deliver_path_to_companions(pkt))
self.assertFalse(router._should_deliver_path_to_companions(pkt))
key = _companion_dedup_key(pkt)
router._companion_delivered[key] = time.time() - 1.0
# Expired entries are only pruned once the dict grows beyond 200 entries.
for i in range(205):
router._companion_delivered[f"K{i}"] = time.time() + 60.0
self.assertTrue(router._should_deliver_path_to_companions(pkt))
async def test_enqueue_drops_oldest_when_queue_full(self):
router = PacketRouter(_make_daemon())
router.queue = asyncio.Queue(maxsize=1)
p1 = _make_packet()
p2 = _make_packet()
await router.queue.put(p1)
await router.enqueue(p2)
got = await router.queue.get()
self.assertIs(got, p2)
async def test_inject_packet_returns_false_on_engine_error(self):
daemon = _make_daemon()
daemon.repeater_handler = AsyncMock(side_effect=RuntimeError("boom"))
router = PacketRouter(daemon)
ok = await router.inject_packet(_make_packet())
self.assertFalse(ok)
async def test_on_route_done_handles_task_exception(self):
router = PacketRouter(_make_daemon())
async def _fails():
raise RuntimeError("route fail")
task = asyncio.create_task(_fails())
with self.assertRaises(RuntimeError):
await task
router._in_flight = 1
router._route_tasks.add(task)
router._on_route_done(task)
self.assertEqual(router._in_flight, 0)
self.assertEqual(len(router._route_tasks), 0)
async def test_route_trace_inbound_uses_trace_helper_and_skips_engine(self):
daemon = _make_daemon()
daemon.trace_helper = MagicMock()
daemon.trace_helper.process_trace_packet = AsyncMock()
router = PacketRouter(daemon)
pkt = _make_packet(TraceHandler.payload_type())
await router._route_packet(pkt)
daemon.trace_helper.process_trace_packet.assert_awaited_once()
daemon.repeater_handler.assert_not_awaited()
async def test_route_control_calls_discovery_and_delivery_and_engine(self):
daemon = _make_daemon()
daemon.discovery_helper = MagicMock()
daemon.discovery_helper.control_handler = AsyncMock()
daemon.deliver_control_data = AsyncMock()
router = PacketRouter(daemon)
pkt = _make_packet(ControlHandler.payload_type())
pkt.path_len = 0
await router._route_packet(pkt)
daemon.discovery_helper.control_handler.assert_awaited_once()
pkt.mark_do_not_retransmit.assert_called_once()
daemon.deliver_control_data.assert_awaited_once()
daemon.repeater_handler.assert_awaited_once()
async def test_route_control_marks_do_not_retransmit_when_discovery_disabled(self):
"""With discovery disabled (discovery_helper is None), control packets must
still be marked do-not-retransmit so the engine does not relay them. MeshCore
never forwards control packets regardless of whether discovery is enabled."""
daemon = _make_daemon()
daemon.discovery_helper = None
daemon.deliver_control_data = AsyncMock()
router = PacketRouter(daemon)
pkt = _make_packet(ControlHandler.payload_type())
pkt.path_len = 0
await router._route_packet(pkt)
pkt.mark_do_not_retransmit.assert_called_once()
async def test_route_advert_delivers_to_helpers_and_engine(self):
daemon = _make_daemon()
daemon.advert_helper = MagicMock()
daemon.advert_helper.process_advert_packet = AsyncMock()
bridge = _make_bridge()
daemon.companion_bridges = {0x42: bridge}
router = PacketRouter(daemon)
pkt = _make_packet(AdvertHandler.payload_type())
await router._route_packet(pkt)
daemon.advert_helper.process_advert_packet.assert_awaited_once()
bridge.process_received_packet.assert_awaited_once()
daemon.repeater_handler.assert_awaited_once()
async def test_route_advert_policy_drop_blocks_companion_delivery(self):
daemon = _make_daemon()
daemon.advert_helper = MagicMock()
daemon.advert_helper.process_advert_packet = AsyncMock()
daemon.repeater_handler.policy_engine = PolicyEngine(
{
"enabled": True,
"default_action": "drop",
"rules": [],
}
)
bridge = _make_bridge()
daemon.companion_bridges = {0x42: bridge}
router = PacketRouter(daemon)
pkt = _make_packet(AdvertHandler.payload_type())
await router._route_packet(pkt)
daemon.advert_helper.process_advert_packet.assert_awaited_once()
bridge.process_received_packet.assert_not_awaited()
async def test_route_login_server_to_companion_marks_processed(self):
daemon = _make_daemon()
bridge = _make_bridge()
daemon.companion_bridges = {0x7A: bridge}
daemon.repeater_handler = AsyncMock()
daemon.repeater_handler.storage = MagicMock()
daemon.repeater_handler.record_packet_only = MagicMock()
router = PacketRouter(daemon)
pkt = _make_packet(LoginServerHandler.payload_type())
pkt.payload = bytes([0x7A, 0x99])
await router._route_packet(pkt)
bridge.process_received_packet.assert_awaited_once()
daemon.repeater_handler.assert_not_awaited()
async def test_route_login_server_hash_collision_offers_to_both(self):
"""A one-byte dest hash shared by a companion and a room-server identity
must be offered to BOTH handlers; only the one whose key decrypts replies.
Regression: previously the companion shadowed the room server, so the
room-server login never ran and failed with Invalid HMAC."""
daemon = _make_daemon()
bridge = _make_bridge()
daemon.companion_bridges = {0xF5: bridge}
daemon.login_helper = MagicMock()
daemon.login_helper.handlers = {0xF5: MagicMock()}
daemon.login_helper.process_login_packet = AsyncMock(return_value=True)
daemon.repeater_handler = AsyncMock()
daemon.repeater_handler.storage = MagicMock()
daemon.repeater_handler.record_packet_only = MagicMock()
router = PacketRouter(daemon)
pkt = _make_packet(LoginServerHandler.payload_type())
pkt.payload = bytes([0xF5, 0x99])
await router._route_packet(pkt)
bridge.process_received_packet.assert_awaited_once()
daemon.login_helper.process_login_packet.assert_awaited_once()
daemon.repeater_handler.assert_not_awaited()
async def test_route_login_server_companion_only_skips_login_helper(self):
"""No collision: a companion-owned hash with no room server registered
there must not also invoke login_helper."""
daemon = _make_daemon()
bridge = _make_bridge()
daemon.companion_bridges = {0x7A: bridge}
daemon.login_helper = MagicMock()
daemon.login_helper.handlers = {} # no room-server identity at this hash
daemon.login_helper.process_login_packet = AsyncMock(return_value=False)
daemon.repeater_handler = AsyncMock()
daemon.repeater_handler.storage = MagicMock()
daemon.repeater_handler.record_packet_only = MagicMock()
router = PacketRouter(daemon)
pkt = _make_packet(LoginServerHandler.payload_type())
pkt.payload = bytes([0x7A, 0x99])
await router._route_packet(pkt)
bridge.process_received_packet.assert_awaited_once()
daemon.login_helper.process_login_packet.assert_not_awaited()
async def test_route_login_server_room_server_without_companion(self):
"""No local companion claims the hash: login_helper handles the
room-server (or forwards a remote) login as before."""
daemon = _make_daemon()
daemon.companion_bridges = {}
daemon.login_helper = MagicMock()
daemon.login_helper.handlers = {0xF5: MagicMock()}
daemon.login_helper.process_login_packet = AsyncMock(return_value=True)
daemon.repeater_handler = AsyncMock()
daemon.repeater_handler.storage = MagicMock()
daemon.repeater_handler.record_packet_only = MagicMock()
router = PacketRouter(daemon)
pkt = _make_packet(LoginServerHandler.payload_type())
pkt.payload = bytes([0xF5, 0x99])
await router._route_packet(pkt)
daemon.login_helper.process_login_packet.assert_awaited_once()
async def test_route_text_to_helper_marks_processed(self):
daemon = _make_daemon()
daemon.text_helper = MagicMock()
daemon.text_helper.process_text_packet = AsyncMock(return_value=True)
daemon.repeater_handler.storage = MagicMock()
daemon.repeater_handler.record_packet_only = MagicMock()
router = PacketRouter(daemon)
pkt = _make_packet(TextMessageHandler.payload_type())
pkt.payload = bytes([0xEE, 0x01])
await router._route_packet(pkt)
daemon.text_helper.process_text_packet.assert_awaited_once()
daemon.repeater_handler.assert_not_awaited()
async def test_route_text_hash_collision_offers_to_both(self):
"""A dest hash shared by a companion and a room-server text identity must
reach both handlers so a companion never shadows a room-server message."""
daemon = _make_daemon()
bridge = _make_bridge()
daemon.companion_bridges = {0xF5: bridge}
daemon.text_helper = MagicMock()
daemon.text_helper.handlers = {0xF5: MagicMock()}
daemon.text_helper.process_text_packet = AsyncMock(return_value=True)
daemon.repeater_handler = AsyncMock()
daemon.repeater_handler.storage = MagicMock()
daemon.repeater_handler.record_packet_only = MagicMock()
router = PacketRouter(daemon)
pkt = _make_packet(TextMessageHandler.payload_type())
pkt.payload = bytes([0xF5, 0x01])
await router._route_packet(pkt)
bridge.process_received_packet.assert_awaited_once()
daemon.text_helper.process_text_packet.assert_awaited_once()
daemon.repeater_handler.assert_not_awaited()
async def test_route_text_companion_only_skips_text_helper(self):
"""No collision: a companion-owned text hash must not also hit text_helper."""
daemon = _make_daemon()
bridge = _make_bridge()
daemon.companion_bridges = {0xEE: bridge}
daemon.text_helper = MagicMock()
daemon.text_helper.handlers = {} # no room-server text identity here
daemon.text_helper.process_text_packet = AsyncMock(return_value=False)
daemon.repeater_handler = AsyncMock()
daemon.repeater_handler.storage = MagicMock()
daemon.repeater_handler.record_packet_only = MagicMock()
router = PacketRouter(daemon)
pkt = _make_packet(TextMessageHandler.payload_type())
pkt.payload = bytes([0xEE, 0x01])
await router._route_packet(pkt)
bridge.process_received_packet.assert_awaited_once()
daemon.text_helper.process_text_packet.assert_not_awaited()
async def test_route_ack_delivers_to_all_bridges_and_engine(self):
daemon = _make_daemon()
b1 = _make_bridge()
b2 = _make_bridge()
daemon.companion_bridges = {0x01: b1, 0x02: b2}
router = PacketRouter(daemon)
pkt = _make_packet(AckHandler.payload_type())
await router._route_packet(pkt)
b1.process_received_packet.assert_awaited_once()
b2.process_received_packet.assert_awaited_once()
daemon.repeater_handler.assert_awaited_once()
async def test_route_ack_registers_crc_with_dispatcher(self):
"""Discrete ACKs must feed dispatcher ACK matching or wait_for_ack never resolves."""
daemon = _make_daemon()
router = PacketRouter(daemon)
pkt = _make_packet(AckHandler.payload_type())
# 6-byte firmware ACK: 4-byte CRC (LE) + ext-attempt + random byte
pkt.payload = bytes.fromhex("4dabaf95") + b"\x00\x7f"
await router._route_packet(pkt)
daemon.dispatcher._register_ack_received.assert_awaited_once_with(0x95AFAB4D)
async def test_route_ack_short_payload_not_registered(self):
daemon = _make_daemon()
router = PacketRouter(daemon)
pkt = _make_packet(AckHandler.payload_type())
pkt.payload = b"\x01\x02"
await router._route_packet(pkt)
daemon.dispatcher._register_ack_received.assert_not_awaited()
async def test_route_locally_injected_ack_registers_crc(self):
"""ACKs from same-instance companions never cross the radio: the injected
packet re-entering the router is the only chance to resolve local waiters
(e.g. a room server pushing to a virtual companion on this repeater)."""
daemon = _make_daemon()
router = PacketRouter(daemon)
pkt = _make_packet(AckHandler.payload_type())
pkt.payload = b"\xaa\xbb\xcc\xdd"
pkt._injected_for_tx = True
await router._route_packet(pkt)
daemon.dispatcher._register_ack_received.assert_awaited_once_with(0xDDCCBBAA)
daemon.repeater_handler.assert_not_awaited() # already transmitted
async def test_inject_packet_waits_on_expected_ack_crc(self):
"""Callers that know the crypto ACK CRC pass it; packet.get_crc() is a
packet-hash CRC no ACK sender ever produces."""
daemon = _make_daemon()
router = PacketRouter(daemon)
pkt = _make_packet(TextMessageHandler.payload_type())
ok = await router.inject_packet(
pkt, wait_for_ack=True, expected_crc=0x1234ABCD, ack_timeout_s=12.0
)
self.assertTrue(ok)
daemon.dispatcher.wait_for_ack.assert_awaited_once_with(0x1234ABCD, timeout=12.0)
pkt.get_crc.assert_not_called()
async def test_route_path_to_local_identity_runs_path_helper_despite_bridges(self):
"""PATH addressed to a room server/repeater identity must reach the path
helper (out_path update + embedded ACK) even when companion bridges exist;
previously the all-bridges branch swallowed it."""
daemon = _make_daemon()
bridge = _make_bridge()
daemon.companion_bridges = {0x01: bridge}
daemon.path_helper = MagicMock()
daemon.path_helper.acl_dict = {0x48: MagicMock()}
daemon.path_helper.process_path_packet = AsyncMock(return_value=False)
router = PacketRouter(daemon)
pkt = _make_packet(PathHandler.payload_type())
pkt.payload = bytes([0x48, 0x77, 0xAA, 0xBB])
await router._route_packet(pkt)
daemon.path_helper.process_path_packet.assert_awaited_once_with(pkt)
async def test_authenticated_flood_path_skips_engine(self):
daemon = _make_daemon()
daemon.path_helper = MagicMock()
daemon.path_helper.process_path_packet = AsyncMock(return_value=True)
bridge = _make_bridge()
bridge.process_received_packet = AsyncMock(return_value=HandlerResult.consumed())
daemon.companion_bridges = {0x01: bridge}
router = PacketRouter(daemon)
pkt = _make_packet(PathHandler.payload_type())
pkt.payload = bytes([0x01, 0x22])
await router._route_packet(pkt)
bridge.process_received_packet.assert_awaited_once_with(pkt)
daemon.repeater_handler.assert_not_awaited()
async def test_unauthenticated_flood_path_reaches_engine(self):
daemon = _make_daemon()
daemon.path_helper = MagicMock()
daemon.path_helper.process_path_packet = AsyncMock(return_value=False)
bridge = _make_bridge()
bridge.process_received_packet = AsyncMock(return_value=HandlerResult.not_for_us())
daemon.companion_bridges = {0x01: bridge}
router = PacketRouter(daemon)
pkt = _make_packet(PathHandler.payload_type())
pkt.payload = bytes([0x01, 0x22])
await router._route_packet(pkt)
daemon.repeater_handler.assert_awaited_once()
async def test_route_path_dedupes_companion_delivery(self):
daemon = _make_daemon()
bridge = _make_bridge()
daemon.companion_bridges = {0x01: bridge}
router = PacketRouter(daemon)
pkt = _make_packet(PathHandler.payload_type())
pkt.payload = bytes([0x01, 0xAA])
await router._route_packet(pkt)
await router._route_packet(pkt)
bridge.process_received_packet.assert_awaited_once()
self.assertEqual(daemon.repeater_handler.await_count, 2)
async def test_route_login_response_final_hop_skips_engine(self):
daemon = _make_daemon()
b1 = _make_bridge()
daemon.companion_bridges = {0x01: b1}
daemon.local_hash = 0xFF
daemon.repeater_handler.storage = MagicMock()
daemon.repeater_handler.record_packet_only = MagicMock()
router = PacketRouter(daemon)
pkt = _make_packet(LoginResponseHandler.payload_type())
pkt.header = ROUTE_TYPE_DIRECT
pkt.path = bytearray()
pkt.payload = bytes([0xFF, 0x22])
await router._route_packet(pkt)
b1.process_received_packet.assert_awaited_once()
daemon.repeater_handler.assert_not_awaited()
async def test_authenticated_flood_response_skips_engine(self):
daemon = _make_daemon()
bridge = _make_bridge()
bridge.process_received_packet = AsyncMock(return_value=HandlerResult.consumed())
daemon.companion_bridges = {0x01: bridge}
router = PacketRouter(daemon)
pkt = _make_packet(LoginResponseHandler.payload_type())
pkt.payload = bytes([0x01, 0x22])
await router._route_packet(pkt)
bridge.process_received_packet.assert_awaited_once_with(pkt)
daemon.repeater_handler.assert_not_awaited()
async def test_unauthenticated_flood_response_reaches_engine(self):
daemon = _make_daemon()
bridge = _make_bridge()
bridge.process_received_packet = AsyncMock(return_value=HandlerResult.not_for_us())
daemon.companion_bridges = {0x01: bridge}
router = PacketRouter(daemon)
pkt = _make_packet(LoginResponseHandler.payload_type())
pkt.payload = bytes([0x01, 0x22])
await router._route_packet(pkt)
daemon.repeater_handler.assert_awaited_once()
async def test_route_protocol_response_final_hop_skips_engine(self):
daemon = _make_daemon()
b1 = _make_bridge()
daemon.companion_bridges = {0x01: b1}
daemon.repeater_handler.storage = MagicMock()
daemon.repeater_handler.record_packet_only = MagicMock()
router = PacketRouter(daemon)
pkt = _make_packet(ProtocolResponseHandler.payload_type())
pkt.header = ROUTE_TYPE_DIRECT
pkt.path = bytearray()
# PathHandler and ProtocolResponseHandler currently share payload type=8.
# Patch PathHandler type here so ProtocolResponse branch is reachable.
with patch("repeater.packet_router.PathHandler.payload_type", return_value=0x55):
await router._route_packet(pkt)
self.assertGreaterEqual(b1.process_received_packet.await_count, 1)
daemon.repeater_handler.assert_not_awaited()
async def test_route_protocol_request_final_hop_skips_engine(self):
daemon = _make_daemon()
b1 = _make_bridge()
daemon.companion_bridges = {0x01: b1}
daemon.repeater_handler.storage = MagicMock()
daemon.repeater_handler.record_packet_only = MagicMock()
router = PacketRouter(daemon)
pkt = _make_packet(ProtocolRequestHandler.payload_type())
pkt.header = ROUTE_TYPE_DIRECT
pkt.path = bytearray()
pkt.payload = bytes([0xAA, 0xBB])
await router._route_packet(pkt)
b1.process_received_packet.assert_awaited_once()
daemon.repeater_handler.assert_not_awaited()
async def test_route_protocol_request_companion_collision_forwards(self):
"""A REQ whose dest prefix matches a companion but does not decrypt for
it must be forwarded, not swallowed."""
daemon = _make_daemon()
b1 = _make_bridge()
# Collision: the packet is not really for this companion.
b1.process_received_packet = AsyncMock(return_value=HandlerResult.not_for_us())
daemon.companion_bridges = {0x01: b1}
router = PacketRouter(daemon)
pkt = _make_packet(ProtocolRequestHandler.payload_type())
pkt.header = 0x00 # transport-flood: not a direct final hop
pkt.path = bytearray([0xAA])
pkt.payload = bytes([0x01, 0xBB])
await router._route_packet(pkt)
b1.process_received_packet.assert_awaited_once()
# Not consumed -> handed to the forwarding engine.
daemon.repeater_handler.assert_awaited_once()
async def test_route_text_companion_collision_forwards(self):
"""A TXT_MSG whose dest prefix matches a companion but does not decrypt
for it must be forwarded, not swallowed."""
daemon = _make_daemon()
b1 = _make_bridge()
b1.process_received_packet = AsyncMock(return_value=HandlerResult.not_for_us())
daemon.companion_bridges = {0x01: b1}
router = PacketRouter(daemon)
pkt = _make_packet(TextMessageHandler.payload_type())
pkt.header = 0x00
pkt.path = bytearray([0xAA])
pkt.payload = bytes([0x01, 0xBB])
await router._route_packet(pkt)
b1.process_received_packet.assert_awaited_once()
daemon.repeater_handler.assert_awaited_once()
async def test_route_text_tries_all_candidates_room_server_wins(self):
"""A companion and a room-server text identity share a hash. The router
must try both; when the companion fails to decrypt but the room server
authenticates, the packet is consumed (not forwarded)."""
daemon = _make_daemon()
companion = _make_bridge()
companion.process_received_packet = AsyncMock(return_value=HandlerResult.not_for_us())
daemon.companion_bridges = {0x42: companion}
daemon.text_helper = MagicMock()
daemon.text_helper.handlers = {0x42: {"name": "room-a"}}
daemon.text_helper.process_text_packet = AsyncMock(return_value=True)
daemon.repeater_handler.storage = MagicMock()
daemon.repeater_handler.record_packet_only = MagicMock()
router = PacketRouter(daemon)
pkt = _make_packet(TextMessageHandler.payload_type())
pkt.header = 0x00
pkt.path = bytearray([0xAA])
pkt.payload = bytes([0x42, 0xBB])
await router._route_packet(pkt)
# Both local candidates were tried; the room server consumed it.
companion.process_received_packet.assert_awaited_once()
daemon.text_helper.process_text_packet.assert_awaited_once()
daemon.repeater_handler.assert_not_awaited()
async def test_route_text_all_local_candidates_fail_forwards(self):
"""When neither the companion nor the room-server identity at the hash
authenticates, the packet is left for the forwarding engine."""
daemon = _make_daemon()
companion = _make_bridge()
companion.process_received_packet = AsyncMock(return_value=HandlerResult.not_for_us())
daemon.companion_bridges = {0x42: companion}
daemon.text_helper = MagicMock()
daemon.text_helper.handlers = {0x42: {"name": "room-a"}}
daemon.text_helper.process_text_packet = AsyncMock(return_value=False)
router = PacketRouter(daemon)
pkt = _make_packet(TextMessageHandler.payload_type())
pkt.header = 0x00
pkt.path = bytearray([0xAA])
pkt.payload = bytes([0x42, 0xBB])
await router._route_packet(pkt)
companion.process_received_packet.assert_awaited_once()
daemon.text_helper.process_text_packet.assert_awaited_once()
daemon.repeater_handler.assert_awaited_once()
async def test_route_group_text_delivers_and_forwards(self):
daemon = _make_daemon()
b1 = _make_bridge()
daemon.companion_bridges = {0x01: b1}
router = PacketRouter(daemon)
pkt = _make_packet(GroupTextHandler.payload_type())
await router._route_packet(pkt)
b1.process_received_packet.assert_awaited_once()
daemon.repeater_handler.assert_awaited_once()
async def test_direct_intermediate_bypasses_all_local_payload_handlers(self):
"""Direct intermediate hops must reach the engine before local payload handling."""
payload_types = (
AdvertHandler.payload_type(),
LoginServerHandler.payload_type(),
TextMessageHandler.payload_type(),
PathHandler.payload_type(),
LoginResponseHandler.payload_type(),
ProtocolRequestHandler.payload_type(),
GroupTextHandler.payload_type(),
PAYLOAD_TYPE_GRP_DATA,
)
for route_type in (ROUTE_TYPE_DIRECT, ROUTE_TYPE_TRANSPORT_DIRECT):
for payload_type in payload_types:
with self.subTest(route_type=route_type, payload_type=payload_type):
daemon = _make_daemon()
bridge = _make_bridge()
daemon.companion_bridges = {0x01: bridge}
daemon.advert_helper = MagicMock(process_advert_packet=AsyncMock())
daemon.login_helper = MagicMock(
handlers={0x01: object()}, process_login_packet=AsyncMock(return_value=True)
)
daemon.text_helper = MagicMock(
handlers={0x01: object()}, process_text_packet=AsyncMock(return_value=True)
)
daemon.path_helper = MagicMock(process_path_packet=AsyncMock(return_value=True))
daemon.protocol_request_helper = MagicMock(
handlers={0x01: object()},
process_request_packet=AsyncMock(return_value=True),
)
router = PacketRouter(daemon)
packet = _make_packet(payload_type)
packet.header = (payload_type << 2) | route_type
packet.path = bytearray([0x42])
packet.payload = bytes([0x01, 0x02, 0x03, 0x04])
await router._route_packet(packet)
bridge.process_received_packet.assert_not_awaited()
daemon.advert_helper.process_advert_packet.assert_not_awaited()
daemon.login_helper.process_login_packet.assert_not_awaited()
daemon.text_helper.process_text_packet.assert_not_awaited()
daemon.path_helper.process_path_packet.assert_not_awaited()
daemon.protocol_request_helper.process_request_packet.assert_not_awaited()
daemon.repeater_handler.assert_awaited_once_with(packet, unittest.mock.ANY)
async def test_direct_intermediate_firmware_vectors_bypass_local_text_candidates(self):
"""Decoded direct and transport-direct wire vectors skip local text handling."""
# header | [transport codes] | path_len | remaining path | payload
vectors = (
b"\x0a\x01\x42\x01\x02\x03\x04",
b"\x0b\x34\x12\x78\x56\x01\x42\x01\x02\x03\x04",
)
for wire in vectors:
with self.subTest(wire=wire.hex()):
daemon = _make_daemon()
bridge = _make_bridge()
daemon.companion_bridges = {0x01: bridge}
daemon.text_helper = MagicMock(
handlers={0x01: object()}, process_text_packet=AsyncMock(return_value=True)
)
router = PacketRouter(daemon)
packet = Packet()
packet.read_from(wire)
await router._route_packet(packet)
bridge.process_received_packet.assert_not_awaited()
daemon.text_helper.process_text_packet.assert_not_awaited()
daemon.repeater_handler.assert_awaited_once_with(packet, unittest.mock.ANY)
async def test_final_direct_firmware_vectors_deliver_to_local_companion(self):
"""Decoded zero-hop direct vectors reach their local companion rather than forwarding."""
# header | [transport codes] | path_len=0 | dest hash | source hash | payload
vectors = (
b"\x0a\x00\x01\x02\x03\x04",
b"\x0b\x34\x12\x78\x56\x00\x01\x02\x03\x04",
)
for wire in vectors:
with self.subTest(wire=wire.hex()):
daemon = _make_daemon()
bridge = _make_bridge()
bridge.process_received_packet = AsyncMock(return_value=HandlerResult.consumed())
daemon.companion_bridges = {0x01: bridge}
router = PacketRouter(daemon)
packet = Packet()
packet.read_from(wire)
await router._route_packet(packet)
bridge.process_received_packet.assert_awaited_once_with(packet)
daemon.repeater_handler.assert_not_awaited()
async def test_direct_intermediate_ack_notifies_waiter_without_companion_delivery(self):
"""MeshCore's early ACK notification does not make an intermediate hop a recipient."""
daemon = _make_daemon()
bridge = _make_bridge()
daemon.companion_bridges = {0x01: bridge}
router = PacketRouter(daemon)
packet = _make_packet(AckHandler.payload_type())
packet.header = (AckHandler.payload_type() << 2) | ROUTE_TYPE_DIRECT
packet.path = bytearray([0x42])
packet.payload = bytes.fromhex("4dabaf95") + b"\x00\x7f"
await router._route_packet(packet)
daemon.dispatcher._register_ack_received.assert_awaited_once_with(0x95AFAB4D)
bridge.process_received_packet.assert_not_awaited()
daemon.repeater_handler.assert_awaited_once()
async def test_direct_intermediate_control_high_bit_is_released_without_local_delivery(self):
"""A high-bit CONTROL packet with remaining direct hops is not locally processed."""
daemon = _make_daemon()
daemon.discovery_helper = MagicMock(control_handler=AsyncMock())
daemon.deliver_control_data = AsyncMock()
router = PacketRouter(daemon)
packet = _make_packet(ControlHandler.payload_type())
packet.header = (ControlHandler.payload_type() << 2) | ROUTE_TYPE_DIRECT
packet.path = bytearray([0x42])
packet.payload = b"\x90\x00\x01\x00\x00\x00"
await router._route_packet(packet)
daemon.discovery_helper.control_handler.assert_not_awaited()
daemon.deliver_control_data.assert_not_awaited()
packet.mark_do_not_retransmit.assert_not_called()
daemon.repeater_handler.assert_not_awaited()
async def test_direct_intermediate_control_without_high_bit_reaches_engine(self):
"""Only high-bit CONTROL is zero-hop-only; other direct control remains routing traffic."""
daemon = _make_daemon()
daemon.discovery_helper = MagicMock(control_handler=AsyncMock())
daemon.deliver_control_data = AsyncMock()
router = PacketRouter(daemon)
packet = _make_packet(ControlHandler.payload_type())
packet.header = (ControlHandler.payload_type() << 2) | ROUTE_TYPE_DIRECT
packet.path = bytearray([0x42])
packet.payload = b"\x01\x00"
await router._route_packet(packet)
daemon.discovery_helper.control_handler.assert_not_awaited()
daemon.deliver_control_data.assert_not_awaited()
packet.mark_do_not_retransmit.assert_not_called()
daemon.repeater_handler.assert_awaited_once()
async def test_direct_intermediate_multipart_ack_does_not_notify_local_waiter(self):
"""A direct intermediate MULTIPART ACK remains forwarding traffic, not a local ACK."""
daemon = _make_daemon()
router = PacketRouter(daemon)
packet = _make_packet(MultipartAckHandler.payload_type())
packet.header = (MultipartAckHandler.payload_type() << 2) | ROUTE_TYPE_DIRECT
packet.path = bytearray([0x42])
packet.payload = b"\x03" + bytes.fromhex("4dabaf95")
await router._route_packet(packet)
daemon.dispatcher._register_ack_received.assert_not_awaited()
daemon.repeater_handler.assert_awaited_once()
async def test_direct_intermediate_trace_uses_trace_handler(self):
"""TRACE keeps its MeshCore-specific forwarding path ahead of generic direct routing."""
daemon = _make_daemon()
daemon.trace_helper = MagicMock(process_trace_packet=AsyncMock())
router = PacketRouter(daemon)
packet = _make_packet(TraceHandler.payload_type())
packet.header = (TraceHandler.payload_type() << 2) | ROUTE_TYPE_DIRECT
packet.path = bytearray([0x42])
await router._route_packet(packet)
daemon.trace_helper.process_trace_packet.assert_awaited_once_with(packet)
daemon.repeater_handler.assert_not_awaited()
async def test_direct_intermediate_trace_without_helper_is_released(self):
"""TRACE remains MeshCore-owned even if a helper is unavailable during startup."""
daemon = _make_daemon()
daemon.trace_helper = None
router = PacketRouter(daemon)
packet = _make_packet(TraceHandler.payload_type())
packet.header = (TraceHandler.payload_type() << 2) | ROUTE_TYPE_DIRECT
packet.path = bytearray([0x42])
await router._route_packet(packet)
daemon.repeater_handler.assert_not_awaited()
class TestInjectedTxRawEcho(unittest.IsolatedAsyncioTestCase):
"""inject_packet echoes local TX to companion clients as raw RX (0x88)."""
async def test_inject_packet_echoes_raw_tx_to_companions(self):
"""Successful local TX is pushed via _on_raw_rx_for_companions with snr=0/rssi=0."""
daemon = _make_daemon()
daemon._on_raw_rx_for_companions = AsyncMock()
router = PacketRouter(daemon)
pkt = _make_packet()
pkt.write_to.return_value = b"\x10\x20\x30"
ok = await router.inject_packet(pkt)
self.assertTrue(ok)
daemon._on_raw_rx_for_companions.assert_awaited_once_with(
b"\x10\x20\x30", 0, 0.0, exclude_hash=None
)
async def test_inject_packet_excludes_originating_companion(self):
"""A companion's own TX is echoed with its hash excluded (no self-echo)."""
daemon = _make_daemon()
daemon._on_raw_rx_for_companions = AsyncMock()
router = PacketRouter(daemon)
pkt = _make_packet()
pkt.write_to.return_value = b"\xaa\xbb"
ok = await router.inject_packet(pkt, origin_hash="0x1a")
self.assertTrue(ok)
daemon._on_raw_rx_for_companions.assert_awaited_once_with(
b"\xaa\xbb", 0, 0.0, exclude_hash="0x1a"
)
async def test_inject_packet_no_echo_when_tx_fails(self):
"""A failed local transmission must not echo a raw RX frame."""
daemon = _make_daemon()
daemon.repeater_handler = AsyncMock(return_value=False)
daemon._on_raw_rx_for_companions = AsyncMock()
router = PacketRouter(daemon)
ok = await router.inject_packet(_make_packet())
self.assertFalse(ok)
daemon._on_raw_rx_for_companions.assert_not_awaited()
async def test_inject_packet_survives_echo_failure(self):
"""An error while echoing must not fail the injection."""
daemon = _make_daemon()
daemon._on_raw_rx_for_companions = AsyncMock(side_effect=RuntimeError("boom"))
router = PacketRouter(daemon)
ok = await router.inject_packet(_make_packet())
self.assertTrue(ok)
daemon._on_raw_rx_for_companions.assert_awaited_once()
async def test_inject_packet_without_echo_hook(self):
"""Injection succeeds even if the daemon has no raw-RX companion hook."""
daemon = _make_daemon()
daemon._on_raw_rx_for_companions = None
router = PacketRouter(daemon)
ok = await router.inject_packet(_make_packet())
self.assertTrue(ok)
class TestCompanionDeliveryFailureHandling(unittest.IsolatedAsyncioTestCase):
"""Dedupe marking and candidate-loop behaviour when companion bridges raise."""
async def test_path_all_bridges_raising_is_retried_on_next_copy(self):
"""A PATH delivery where every bridge raised must not be marked
delivered; the next mesh copy gets another delivery attempt."""
daemon = _make_daemon()
bridge = _make_bridge()
bridge.process_received_packet = AsyncMock(side_effect=RuntimeError("bridge down"))
daemon.companion_bridges = {0x01: bridge}
router = PacketRouter(daemon)
pkt = _make_packet(PathHandler.payload_type())
pkt.payload = bytes([0x01, 0xAA])
await router._route_packet(pkt)
self.assertEqual(bridge.process_received_packet.await_count, 1)
# Bridge recovers; the second copy must be delivered, not TTL-suppressed.
recovered = AsyncMock(return_value=HandlerResult.not_for_us())
bridge.process_received_packet = recovered
await router._route_packet(pkt)
recovered.assert_awaited_once()
async def test_path_partial_bridge_failure_still_marks_delivered(self):
"""One healthy bridge is a delivery: the duplicate copy stays suppressed."""
daemon = _make_daemon()
raising = _make_bridge()
raising.process_received_packet = AsyncMock(side_effect=RuntimeError("boom"))
healthy = _make_bridge()
healthy.process_received_packet = AsyncMock(return_value=HandlerResult.not_for_us())
daemon.companion_bridges = {0x01: raising, 0x02: healthy}
router = PacketRouter(daemon)
pkt = _make_packet(PathHandler.payload_type())
# Dest not in bridges: anon path-return, delivered to all bridges.
pkt.payload = bytes([0xEE, 0xAA])
await router._route_packet(pkt)
await router._route_packet(pkt)
healthy.process_received_packet.assert_awaited_once()
raising.process_received_packet.assert_awaited_once()
async def test_protocol_response_all_bridges_raising_is_retried_on_next_copy(self):
daemon = _make_daemon()
bridge = _make_bridge()
bridge.process_received_packet = AsyncMock(side_effect=RuntimeError("bridge down"))
daemon.companion_bridges = {0x01: bridge}
router = PacketRouter(daemon)
pkt = _make_packet(ProtocolResponseHandler.payload_type())
pkt.header = ROUTE_TYPE_FLOOD # not a final hop: dedupe decides delivery
with patch("repeater.packet_router.PathHandler.payload_type", return_value=0x55):
await router._route_packet(pkt)
self.assertEqual(bridge.process_received_packet.await_count, 1)
recovered = AsyncMock(return_value=HandlerResult.not_for_us())
bridge.process_received_packet = recovered
await router._route_packet(pkt)
recovered.assert_awaited_once()
async def test_login_candidate_bridge_error_still_offers_local_identity(self):
"""A raising companion bridge must not abort the candidate loop: the
hash-colliding room-server/repeater identity still gets the packet."""
daemon = _make_daemon()
bridge = _make_bridge()
bridge.process_received_packet = AsyncMock(side_effect=RuntimeError("bridge down"))
daemon.companion_bridges = {0xF5: bridge}
daemon.login_helper = MagicMock()
daemon.login_helper.handlers = {0xF5: MagicMock()}
daemon.login_helper.process_login_packet = AsyncMock(return_value=True)
daemon.repeater_handler = AsyncMock()
daemon.repeater_handler.storage = MagicMock()
daemon.repeater_handler.record_packet_only = MagicMock()
router = PacketRouter(daemon)
pkt = _make_packet(LoginServerHandler.payload_type())
pkt.payload = bytes([0xF5, 0x01])
await router._route_packet(pkt)
bridge.process_received_packet.assert_awaited_once()
daemon.login_helper.process_login_packet.assert_awaited_once()
# The colliding identity consumed it, so the engine must not re-forward.
daemon.repeater_handler.assert_not_awaited()
async def test_text_candidate_bridge_error_leaves_packet_for_engine(self):
"""Bridge raises and no local identity claims the text: the packet must
still reach the forwarding engine instead of dying with the exception."""
daemon = _make_daemon()
bridge = _make_bridge()
bridge.process_received_packet = AsyncMock(side_effect=RuntimeError("bridge down"))
daemon.companion_bridges = {0xF5: bridge}
daemon.text_helper = MagicMock()
daemon.text_helper.handlers = {}
daemon.text_helper.process_text_packet = AsyncMock(return_value=False)
router = PacketRouter(daemon)
pkt = _make_packet(TextMessageHandler.payload_type())
pkt.payload = bytes([0xF5, 0x01])
await router._route_packet(pkt)
daemon.repeater_handler.assert_awaited_once()