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.
This commit is contained in:
agessaman
2026-07-19 07:12:24 -07:00
parent bf72735233
commit 0a65e0566b
2 changed files with 86 additions and 18 deletions
+57
View File
@@ -1326,6 +1326,63 @@ class TestInjectedTxRawEcho(unittest.IsolatedAsyncioTestCase):
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."""