fix(router): keep offering local candidates when a companion bridge raises

_consume_via_local_candidates awaited the targeted companion bridge with
no exception guard, so a raising bridge aborted the whole candidate loop:
the hash-colliding room-server or repeater identity registered at the
same one-byte dest hash was never offered the packet, and an unclaimed
packet never fell through to the forwarding engine. Wrap the bridge call
with the same log-and-continue handling the fan-out path already uses.
This commit is contained in:
agessaman
2026-07-19 07:12:10 -07:00
parent 1bbd2156a1
commit bf72735233
2 changed files with 55 additions and 3 deletions
+9 -3
View File
@@ -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).
+46
View File
@@ -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()