fix: resolve room-server push ACKs through the dispatcher

Room server pushes waited on dispatcher.wait_for_ack, but nothing in the
repeater could ever resolve it, so every push timed out and re-pushed on
the backoff schedule (issue #286's duplicate floods — worst for virtual
companions on the same instance, whose ACKs never even cross the air):

- no core AckHandler is registered (all RX lands in the router fallback),
  so received ACK CRCs were never fed to dispatcher ACK matching
- inject_packet waited on packet.get_crc(), a packet-hash CRC that no ACK
  sender produces; the crypto CRC only the room server knows was ignored
- PATH returns (how a flood-received DM is ACKed) were decrypted by
  PathHelper, but it read the encoded path_len wire byte as a raw count —
  an empty 3-byte-hash path (0x80) parsed as a 128-byte truncated
  payload — and the router's PATH branch never ran PathHelper for
  room/repeater destinations when any companion bridge existed

Fixes:
- router ACK branch feeds discrete ACK CRCs (RF and locally injected) to
  dispatcher._register_ack_received
- PATH packets addressed to a local server identity are processed by
  PathHelper regardless of companion bridges; PathHelper decodes the
  encoded path_len via PathUtils, keeps the encoded byte in out_path_len,
  and registers the embedded ACK via ack_received_fn
- inject_packet accepts expected_ack_crc/ack_timeout; the room server
  passes its crypto CRC and a hop-count-based timeout (the encoded byte
  would have produced a ~4-minute direct-push wait)

Verified live on a real mesh: push to a same-instance virtual companion
resolves via the PATH-embedded ACK (encoded path_len 0x80) and push to a
firmware client resolves via the discrete RF ACK, no re-push loops.

Fixes #286
Fixes #341
This commit is contained in:
agessaman
2026-07-07 11:17:54 -07:00
parent 9c6ea0151e
commit b2e45c2038
7 changed files with 260 additions and 23 deletions
@@ -67,6 +67,74 @@ async def test_path_helper_updates_client_out_path_on_valid_decrypt():
assert isinstance(client.last_activity, int)
@pytest.mark.asyncio
async def test_path_helper_registers_embedded_ack():
"""Firmware path returns embed the delivery ACK after the path
(extra_type=PAYLOAD_TYPE_ACK + 4-byte CRC); it must reach ack_received_fn
so local waiters (e.g. room server pushes) resolve."""
client = _FakeClient(pubkey=bytes([0x22]) + b"x" * 31, shared_secret=b"k" * 32)
acl = _FakeACL([client])
ack_fn = AsyncMock()
helper = PathHelper(acl_dict={0x11: acl}, ack_received_fn=ack_fn)
packet = _PathPacket(payload=b"\x11\x22\xaa\xbb\xcc")
# path_len(2) + path + extra_type(PAYLOAD_TYPE_ACK=3) + crc(4, LE)
decrypted = b"\x02\x99\x88" + bytes([0x03]) + bytes.fromhex("4dabaf95")
with patch(
"openhop_core.protocol.crypto.CryptoUtils.mac_then_decrypt",
return_value=decrypted,
):
await helper.process_path_packet(packet)
ack_fn.assert_awaited_once_with(0x95AFAB4D)
assert client.out_path_len == 2 # path update still applied
@pytest.mark.asyncio
async def test_path_helper_handles_encoded_path_len_with_embedded_ack():
"""path_len in a path return is the ENCODED wire byte: with 3-byte hashes an
empty path is 0x80, not 0x00. Reading it as a raw count (128) made the
helper bail as 'truncated' before the embedded ACK was registered, so
room-server pushes to same-instance companions timed out forever.
Bytes below are a decrypted path return captured from a live mesh."""
client = _FakeClient(pubkey=bytes([0x22]) + b"x" * 31, shared_secret=b"k" * 32)
acl = _FakeACL([client])
ack_fn = AsyncMock()
helper = PathHelper(acl_dict={0x11: acl}, ack_received_fn=ack_fn)
packet = _PathPacket(payload=b"\x11\x22\xaa\xbb\xcc")
# path_len 0x80 (3-byte hashes, 0 hops) + extra_type ACK + crc + AES padding
decrypted = bytes.fromhex("80038d48208500000000000000000000")
with patch(
"openhop_core.protocol.crypto.CryptoUtils.mac_then_decrypt",
return_value=decrypted,
):
await helper.process_path_packet(packet)
ack_fn.assert_awaited_once_with(0x8520488D)
assert client.out_path_len == 0x80 # encoded byte preserved
assert bytes(client.out_path) == b""
@pytest.mark.asyncio
async def test_path_helper_ignores_non_ack_extra():
client = _FakeClient(pubkey=bytes([0x22]) + b"x" * 31, shared_secret=b"k" * 32)
acl = _FakeACL([client])
ack_fn = AsyncMock()
helper = PathHelper(acl_dict={0x11: acl}, ack_received_fn=ack_fn)
packet = _PathPacket(payload=b"\x11\x22\xaa\xbb\xcc")
# extra_type 0x08 (PATH) instead of ACK: nothing to register
decrypted = b"\x02\x99\x88" + bytes([0x08]) + b"\x01\x02\x03\x04"
with patch(
"openhop_core.protocol.crypto.CryptoUtils.mac_then_decrypt",
return_value=decrypted,
):
await helper.process_path_packet(packet)
ack_fn.assert_not_awaited()
@pytest.mark.asyncio
async def test_path_helper_returns_false_for_non_matching_or_invalid_inputs():
client = _FakeClient(pubkey=bytes([0x22]) + b"x" * 31, shared_secret=b"k" * 32)