diff --git a/repeater/packet_router.py b/repeater/packet_router.py index d08b9eb..197975d 100644 --- a/repeater/packet_router.py +++ b/repeater/packet_router.py @@ -316,9 +316,15 @@ class PacketRouter: consumed = False if has_companion: - bridge_result = await companion_bridges[dest_hash].process_received_packet(packet) - if bridge_result.authenticated: - consumed = True + # A raising bridge must not abort the candidate loop: the colliding + # room-server / repeater identity below still gets offered the packet. + try: + bridge_result = await companion_bridges[dest_hash].process_received_packet(packet) + except Exception as e: + logger.debug("Companion bridge candidate error: %s", e) + else: + if bridge_result.authenticated: + consumed = True # Offer to the room-server / repeater identity when it shares the hash # (collision) or when no local companion claims it at all (normal # server-owned + remote-forward handling). diff --git a/tests/test_packet_router.py b/tests/test_packet_router.py index 0f0d7b7..4d2456f 100644 --- a/tests/test_packet_router.py +++ b/tests/test_packet_router.py @@ -1321,3 +1321,49 @@ class TestInjectedTxRawEcho(unittest.IsolatedAsyncioTestCase): 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_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()