Files
pyMC_Repeater/tests/test_fabric_fanout.py
agessaman 42e7939447 test(duty-cycle): run the suite against an older openhop_core too
The tests that cover per-channel metering ask a fabric which radio will carry a
packet, which an openhop_core from before the selector change cannot answer. Run
against one, fourteen of them failed -- on a repeater whose whole point is that
the core change is optional.

Tests that genuinely need the newer core (they drive build_radio_stack through a
real FabricRadio) are skipped when it is absent. The engine tests instead use a
fabric double that implements core's resolve rule and nothing else: explicit
radio_id wins, then the selector, then default_radio. That keeps the engine
measured against the contract rather than against a convenience, and the real
RFFabric is still what runs wherever the installed core supports it.

One fan-out test asserted the engine names the egress radio. What must not
change is which radio transmits; whether the engine names it or leaves the
choice to the fabric depends on the core underneath, so it accepts either.

Against openhop_core c95a684: 2154 pass, 6 skipped. Against dev with the
selector change: 2157 pass, 3 skipped.
2026-09-13 13:38:59 -07:00

1000 lines
37 KiB
Python

"""Dual-radio Fabric fan-out: repeat on both sides of the bridge, and send
locally originated packets on every radio.
These drive the real openhop_core Dispatcher and FabricRadio over two fake
physical radios, so radio_id routing, the TX locks and the ACK waiters are the
production ones; only the hardware is faked.
"""
import asyncio
import time
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from openhop_core.node.dispatcher import Dispatcher
from openhop_core.protocol import Packet
from openhop_core.protocol.constants import (
PAYLOAD_TYPE_ACK,
PAYLOAD_TYPE_ADVERT,
PAYLOAD_TYPE_GRP_TXT,
PAYLOAD_TYPE_MULTIPART,
PAYLOAD_TYPE_RESPONSE,
PAYLOAD_TYPE_TXT_MSG,
PH_TYPE_SHIFT,
ROUTE_TYPE_DIRECT,
ROUTE_TYPE_FLOOD,
ROUTE_TYPE_TRANSPORT_DIRECT,
ROUTE_TYPE_TRANSPORT_FLOOD,
)
from openhop_core.protocol.packet_filter import PacketFilter
from openhop_core.protocol.transport_keys import get_auto_key_for
from openhop_core.rf_fabric import FabricRadio
from repeater.config import _apply_fabric_tx_mode
from repeater.engine import FanoutTxResult, RadioTxResult, RepeaterHandler
from repeater.packet_router import PacketRouter
from repeater.policy_engine import PolicyEngine
LOCAL_HASH = 0xAB
# ---------------------------------------------------------------------------
# Rig: two fake radios behind a real FabricRadio + Dispatcher + RepeaterHandler
# ---------------------------------------------------------------------------
class _Air:
"""Shared on-air log: every transmitted frame in order, plus overlap detection."""
def __init__(self):
self.frames = []
self.overlapped = False
self._keyed = 0
@property
def order(self):
return [name for name, _ in self.frames]
class _FakeRadio:
def __init__(self, name, air):
self.name = name
self.air = air
self.rx_callback = None
self.fail = False # send() returns no confirmation
self.airtime_s = 0.0
self.is_connected = True
self.is_degraded = False
self.on_send = None
self.sent_at = []
def set_rx_callback(self, cb):
self.rx_callback = cb
async def send(self, data: bytes):
if self.air._keyed:
self.air.overlapped = True
self.air._keyed += 1
try:
if self.airtime_s:
await asyncio.sleep(self.airtime_s)
if self.fail:
return None
self.sent_at.append(time.monotonic())
self.air.frames.append((self.name, bytes(data)))
if self.on_send is not None:
await self.on_send(self.name, data)
return {"ok": True}
finally:
self.air._keyed -= 1
def _config(fabric, repeater):
return {
"repeater": {
"mode": "forward",
"cache_ttl": 3600,
"send_advert_interval_hours": 0,
**repeater,
},
"mesh": {"unscoped_flood_allow": True, "loop_detect": "off"},
"delays": {
"tx_delay_factor": 1.0,
"direct_tx_delay_factor": 0.5,
"local_tx_link_wait_seconds": 0.2,
},
"duty_cycle": {"max_airtime_per_minute": 3600, "enforcement_enabled": True},
"radio": {
"spreading_factor": 7,
"bandwidth": 62500,
"coding_rate": 5,
"preamble_length": 17,
},
"fabric": {"default_radio": "local", "tx_mode": "bridge", **fabric},
}
class _Rig:
def __init__(self, fabric=None, **repeater):
cfg = _config(fabric or {}, repeater)
self.air = _Air()
self.local = _FakeRadio("local", self.air)
self.link = _FakeRadio("link", self.air)
self.radio = FabricRadio(
radios=[(self.local, "local"), (self.link, "link")],
default_radio_id=cfg["fabric"]["default_radio"],
)
# build_radio_stack binds the tx_mode selector; a rig that skips it runs
# every test on a fabric that has no TX policy at all, which is how an
# on-air change in where locally originated packets leave by went
# unnoticed through two reviews.
_apply_fabric_tx_mode(self.radio.fabric, cfg["fabric"].get("tx_mode", "default"))
self.dispatcher = Dispatcher(radio=self.radio, packet_filter=PacketFilter())
# Every Packet object handed to the dispatcher, with its target radio.
self.sent_packets = []
real_send = self.dispatcher.send_packet
async def spy(packet, wait_for_ack=True, expected_crc=None, radio_id=None):
self.sent_packets.append((radio_id, packet))
return await real_send(
packet, wait_for_ack=wait_for_ack, expected_crc=expected_crc, radio_id=radio_id
)
self.dispatcher.send_packet = spy
with (
patch("repeater.engine.StorageCollector"),
patch("repeater.engine.RepeaterHandler._start_background_tasks"),
):
self.handler = RepeaterHandler(
cfg, self.dispatcher, LOCAL_HASH, local_hash_bytes=bytes([LOCAL_HASH])
)
# Deterministic, fast TX timing; delay randomisation is covered elsewhere.
self.handler._calculate_tx_delay = lambda packet, snr=0.0: 0.0
@property
def sent_radio_ids(self):
return [radio_id for radio_id, _ in self.sent_packets]
def records(self):
return [c.args[0] for c in self.handler.storage.record_packet.call_args_list]
def _packet(route, payload_type, payload, path, rx=None):
pkt = Packet()
pkt.header = route | (payload_type << PH_TYPE_SHIFT)
pkt.payload = bytearray(payload)
pkt.payload_len = len(payload)
pkt.path = bytearray(path)
pkt.path_len = len(path)
if rx is not None:
pkt._rx_radio_id = rx
return pkt
def _flood(payload=b"\x10\x20\x30\x40", path=b"", payload_type=PAYLOAD_TYPE_TXT_MSG, rx=None):
return _packet(ROUTE_TYPE_FLOOD, payload_type, payload, path, rx)
def _direct(
payload=b"\x10\x20\x30\x40",
path=bytes([LOCAL_HASH, 0xCC, 0xDD]),
payload_type=PAYLOAD_TYPE_TXT_MSG,
rx=None,
route=ROUTE_TYPE_DIRECT,
):
return _packet(route, payload_type, payload, path, rx)
def _rx(radio_id):
return {"rx_radio_id": radio_id, "snr": 5.0, "rssi": -80}
# ---------------------------------------------------------------------------
# RF relay fan-out
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
@pytest.mark.parametrize(
"ingress, expected",
[("local", ["link", "local"]), ("link", ["local", "link"])],
)
async def test_rf_relay_repeats_on_both_radios_bridge_first(ingress, expected):
rig = _Rig({"repeat_on_ingress": True})
pkt = _flood(path=bytes([0x11, 0x22]), rx=ingress)
assert await rig.handler(pkt, _rx(ingress)) is True
assert rig.air.order == expected
(_, first), (_, second) = rig.air.frames
assert first == second # one logical packet, identical on both radios
# The path append ran once: our hash appears exactly once on each copy.
assert [bytes(p.path) for _, p in rig.sent_packets] == [bytes([0x11, 0x22, LOCAL_HASH])] * 2
# Each physical send has its own Packet, so its TX metadata is its own.
(rid_a, pkt_a), (rid_b, pkt_b) = rig.sent_packets
assert pkt_a is not pkt_b
assert pkt_a._tx_metadata["radio_id"] == rid_a
assert pkt_b._tx_metadata["radio_id"] == rid_b
record = rig.records()[-1]
assert record["rx_radio_id"] == ingress
assert record["tx_radio_id"] == expected[0]
assert record["tx_radio_ids"] == expected
assert record["transmitted"] is True
assert record["drop_reason"] is None
# One logical RX and forward; two physical flood transmissions.
assert rig.handler.rx_count == 1
assert rig.handler.forwarded_count == 1
assert rig.handler.sent_flood_count == 2
assert rig.handler.dropped_count == 0
@pytest.mark.asyncio
@pytest.mark.parametrize("ingress, other", [("local", "link"), ("link", "local")])
async def test_bridge_without_repeat_on_ingress_is_unchanged(ingress, other):
rig = _Rig()
assert await rig.handler(_flood(rx=ingress), _rx(ingress)) is True
assert rig.air.order == [other]
assert rig.records()[-1]["tx_radio_ids"] == [other]
assert rig.handler.sent_flood_count == 1
@pytest.mark.asyncio
async def test_flood_is_validated_and_marked_seen_once_for_both_radios():
rig = _Rig({"repeat_on_ingress": True})
with (
patch.object(rig.handler, "flood_forward", wraps=rig.handler.flood_forward) as fwd,
patch.object(rig.handler, "mark_seen", wraps=rig.handler.mark_seen) as seen,
):
assert await rig.handler(_flood(rx="local"), _rx("local")) is True
assert fwd.call_count == 1
assert seen.call_count == 1
assert len(rig.air.frames) == 2
@pytest.mark.asyncio
async def test_returned_copies_are_deduplicated_without_new_fanout():
rig = _Rig({"repeat_on_ingress": True})
payload = b"\x77\x66\x55"
assert await rig.handler(_flood(payload=payload, path=b"\x11", rx="local"), _rx("local"))
assert len(rig.air.frames) == 2
# Neighbours on each side repeat our copy back to us.
for ingress in ("link", "local"):
echo = _flood(payload=payload, path=bytes([0x11, LOCAL_HASH, 0x33]), rx=ingress)
assert await rig.handler(echo, _rx(ingress)) is False
assert rig.records()[-1]["drop_reason"] == "Duplicate"
assert len(rig.air.frames) == 2 # no ping-pong between the domains
assert rig.handler.forwarded_count == 1
@pytest.mark.asyncio
async def test_own_transmission_heard_on_the_other_radio_is_not_a_neighbour():
"""Same frequency: each radio hears the other send, bytes exactly as sent."""
rig = _Rig({"repeat_on_ingress": True})
payload = b"\x42\x43\x44"
assert await rig.handler(_flood(payload=payload, path=b"\x11", rx="local"), _rx("local"))
for ingress in ("link", "local"):
echo = _flood(payload=payload, path=bytes([0x11, LOCAL_HASH]), rx=ingress)
assert await rig.handler(echo, _rx(ingress)) is False
# A neighbour repeating our copy appends its own hash and is still a neighbour.
relayed = _flood(payload=payload, path=bytes([0x11, LOCAL_HASH, 0x33]), rx="link")
assert await rig.handler(relayed, _rx("link")) is False
links = rig.handler.neighbour_link_tracker.links
assert set(links) == {"1:11", "1:33"}
assert set(links["1:11"].radios) == {"local"}
assert set(links["1:33"].radios) == {"link"}
# The echoes are still stored as the receptions they were.
assert [r["drop_reason"] for r in rig.records()[1:]] == ["Duplicate"] * 3
@pytest.mark.asyncio
async def test_a_copy_heard_on_the_radio_that_sent_it_is_not_our_echo():
"""A radio cannot hear its own transmission, so that copy came from a neighbour."""
rig = _Rig() # bridge only: heard on local, sent on link
payload = b"\x51\x52\x53"
assert await rig.handler(_flood(payload=payload, path=b"\x11", rx="local"), _rx("local"))
assert rig.air.order == ["link"]
collided = _flood(payload=payload, path=bytes([0x11, LOCAL_HASH]), rx="link")
assert await rig.handler(collided, _rx("link")) is False
assert f"1:{LOCAL_HASH:02X}" in rig.handler.neighbour_link_tracker.links
echo = _flood(payload=payload, path=bytes([0x11, LOCAL_HASH]), rx="local")
rig.handler.neighbour_link_tracker.links.clear()
assert await rig.handler(echo, _rx("local")) is False
assert f"1:{LOCAL_HASH:02X}" not in rig.handler.neighbour_link_tracker.links
@pytest.mark.asyncio
async def test_a_matching_copy_after_the_echo_window_is_a_neighbour():
rig = _Rig({"repeat_on_ingress": True})
rig.handler.OWN_ECHO_MARGIN_SECONDS = -1.0 # every finished send is already outside it
payload = b"\x61\x62\x63"
assert await rig.handler(_flood(payload=payload, path=b"\x11", rx="local"), _rx("local"))
late = _flood(payload=payload, path=bytes([0x11, LOCAL_HASH]), rx="link")
assert await rig.handler(late, _rx("link")) is False
assert f"1:{LOCAL_HASH:02X}" in rig.handler.neighbour_link_tracker.links
assert not rig.handler._recent_own_tx # expired sends are dropped
@pytest.mark.asyncio
async def test_a_send_refused_by_duty_cycle_leaves_no_echo_to_match():
rig = _Rig({"repeat_on_ingress": True})
rig.handler.airtime_mgr.can_transmit = MagicMock(return_value=(False, 5.0))
payload = b"\x71\x72\x73"
assert (
await rig.handler(_flood(payload=payload, path=b"\x11", rx="local"), _rx("local")) is False
)
assert rig.air.frames == []
collided = _flood(payload=payload, path=bytes([0x11, LOCAL_HASH]), rx="link")
assert await rig.handler(collided, _rx("link")) is False
assert not rig.handler._recent_own_tx
assert f"1:{LOCAL_HASH:02X}" in rig.handler.neighbour_link_tracker.links
@pytest.mark.asyncio
async def test_a_failed_send_is_forgotten():
rig = _Rig()
rig.link.fail = True
assert await rig.handler(_flood(path=b"\x11", rx="local"), _rx("local")) is False
assert not rig.handler._recent_own_tx
@pytest.mark.asyncio
async def test_a_single_radio_node_keeps_no_echo_records():
rig = _Rig()
with patch.object(rig.handler, "_fabric_endpoints", return_value=(None, ["local"])):
assert await rig.handler(_flood(path=b"\x11", rx="local"), _rx("local"))
assert not rig.handler._recent_own_tx
@pytest.mark.asyncio
async def test_egress_follows_captured_ingress_not_the_latest_rx():
rig = _Rig({"repeat_on_ingress": True})
rig.handler._calculate_tx_delay = lambda packet, snr=0.0: 0.05
first = _flood(payload=b"\xa1\xa2\xa3", rx="local")
second = _flood(payload=b"\xb1\xb2\xb3", rx="link")
task_first = asyncio.create_task(rig.handler(first, _rx("local")))
await asyncio.sleep(0.01)
# A later reception on the other radio moves the fabric's last-RX marker
# while the first packet is still waiting out its TX delay.
rig.radio.fabric._last_rx_radio_id = "link"
task_second = asyncio.create_task(rig.handler(second, _rx("link")))
await asyncio.gather(task_first, task_second)
by_payload = {}
for radio_id, pkt in rig.sent_packets:
by_payload.setdefault(bytes(pkt.payload), []).append(radio_id)
assert by_payload[b"\xa1\xa2\xa3"] == ["link", "local"]
assert by_payload[b"\xb1\xb2\xb3"] == ["local", "link"]
@pytest.mark.asyncio
@pytest.mark.parametrize("route", [ROUTE_TYPE_DIRECT, ROUTE_TYPE_TRANSPORT_DIRECT])
async def test_direct_packet_removes_our_hop_once_for_both_radios(route):
rig = _Rig({"repeat_on_ingress": True})
pkt = _direct(path=bytes([LOCAL_HASH, 0xCC, 0xDD]), rx="link", route=route)
if route == ROUTE_TYPE_TRANSPORT_DIRECT:
pkt.transport_codes = [0x1234, 0x5678]
assert await rig.handler(pkt, _rx("link")) is True
assert rig.sent_radio_ids == ["local", "link"]
assert [bytes(p.path) for _, p in rig.sent_packets] == [b"\xcc\xdd"] * 2
assert rig.air.frames[0][1] == rig.air.frames[1][1]
assert rig.handler.sent_direct_count == 2
assert rig.handler.forwarded_count == 1
@pytest.mark.asyncio
async def test_multi_ack_redundancy_copies_share_the_egress_set():
rig = _Rig({"repeat_on_ingress": True}, multi_acks=1)
ack = _direct(
payload=b"\x01\x02\x03\x04",
path=bytes([LOCAL_HASH, 0xCC]),
payload_type=PAYLOAD_TYPE_ACK,
rx="local",
)
assert await rig.handler(ack, _rx("local")) is True
for ptype in (PAYLOAD_TYPE_MULTIPART, PAYLOAD_TYPE_ACK):
radios = [rid for rid, p in rig.sent_packets if p.get_payload_type() == ptype]
assert radios == ["link", "local"], f"payload type {ptype}"
assert len(rig.air.frames) == 4
# The plain ACK and its redundancy copy each count as one logical forward.
assert rig.handler.forwarded_count == 2
@pytest.mark.asyncio
async def test_policy_runs_once_per_rx_before_fanout():
rig = _Rig({"repeat_on_ingress": True})
rig.handler.policy_engine = PolicyEngine(
{
"enabled": True,
"default_action": "allow",
"rules": [
{
"id": "drop-link",
"if": {"all": [{"field": "rx_radio_id", "op": "eq", "value": "link"}]},
"then": "drop",
}
],
}
)
policy = rig.handler.policy_engine
with patch.object(policy, "evaluate", wraps=policy.evaluate) as evaluate:
assert await rig.handler(_flood(payload=b"\x01\x02", rx="link"), _rx("link")) is False
assert rig.air.frames == []
assert await rig.handler(_flood(payload=b"\x03\x04", rx="local"), _rx("local")) is True
assert rig.air.order == ["link", "local"]
assert evaluate.call_count == 2 # once per logical RX, never per egress
@pytest.mark.asyncio
@pytest.mark.parametrize(
"link_ok, local_ok, expected",
[
(True, True, ["link", "local"]),
(True, False, ["link"]),
(False, True, ["local"]),
(False, False, []),
],
ids=["both", "link-only", "local-only", "neither"],
)
async def test_rf_relay_partial_failure(link_ok, local_ok, expected):
rig = _Rig({"repeat_on_ingress": True})
rig.link.fail = not link_ok
rig.local.fail = not local_ok
sent = await rig.handler(_flood(rx="local"), _rx("local"))
assert sent is bool(expected)
assert rig.sent_radio_ids == ["link", "local"] # both attempted, bridge first
assert rig.air.order == expected
record = rig.records()[-1]
assert record["transmitted"] is bool(expected)
assert record["tx_radio_ids"] == (expected or None)
assert record["tx_radio_id"] == (expected[0] if expected else None)
assert rig.handler.sent_flood_count == len(expected)
if expected:
assert record["drop_reason"] is None
assert (rig.handler.forwarded_count, rig.handler.dropped_count) == (1, 0)
else:
assert record["drop_reason"] == "TX failed"
assert (rig.handler.forwarded_count, rig.handler.dropped_count) == (0, 1)
@pytest.mark.asyncio
async def test_send_error_on_one_radio_does_not_abort_the_other():
rig = _Rig({"repeat_on_ingress": True})
send = rig.dispatcher.send_packet
async def flaky(packet, wait_for_ack=True, expected_crc=None, radio_id=None):
if radio_id == "link":
raise RuntimeError("modem_tcp connection reset")
return await send(packet, wait_for_ack, expected_crc, radio_id)
rig.dispatcher.send_packet = flaky
assert await rig.handler(_flood(rx="local"), _rx("local")) is True
assert rig.air.order == ["local"]
assert rig.records()[-1]["tx_radio_ids"] == ["local"]
@pytest.mark.asyncio
async def test_duty_cycle_admits_the_bridge_egress_and_refuses_the_repeat():
rig = _Rig({"repeat_on_ingress": True})
# Advisory check in __call__, then the authoritative in-lock gate per egress.
admissions = iter([(True, 0.0), (True, 0.0), (False, 4.0)])
rig.handler.airtime_mgr.can_transmit = MagicMock(side_effect=lambda ms: next(admissions))
assert await rig.handler(_flood(rx="local"), _rx("local")) is True
assert rig.air.order == ["link"]
record = rig.records()[-1]
assert record["transmitted"] is True
assert record["tx_radio_ids"] == ["link"]
assert record["drop_reason"] is None
assert rig.handler.forwarded_count == 1
@pytest.mark.asyncio
async def test_fanout_transmissions_never_overlap():
rig = _Rig({"repeat_on_ingress": True})
rig.local.airtime_s = rig.link.airtime_s = 0.03
await asyncio.gather(
rig.handler(_flood(payload=b"\x01\x01", rx="local"), _rx("local")),
rig.handler(_flood(payload=b"\x02\x02", rx="link"), _rx("link")),
)
assert len(rig.air.frames) == 4
assert rig.air.overlapped is False
@pytest.mark.asyncio
async def test_monitor_mode_blocks_relay_fanout_but_not_local_fanout():
rig = _Rig({"repeat_on_ingress": True, "origin_tx": "all"}, mode="monitor")
assert await rig.handler(_flood(rx="local"), _rx("local")) is False
assert rig.air.frames == []
assert rig.records()[-1]["drop_reason"] == "Repeat disabled"
assert await rig.handler(_flood(payload=b"\x55\x66"), {}, local_transmission=True) is True
assert rig.air.order == ["local", "link"]
# ---------------------------------------------------------------------------
# Locally originated fan-out (engine level)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_local_origin_default_mode_keeps_single_fabric_send():
rig = _Rig({"repeat_on_ingress": True}) # origin_tx defaults to "default"
assert await rig.handler(_flood(), {}, local_transmission=True) is True
assert rig.air.order == ["local"]
# Same radio on the air either way. With a core that passes the ingress radio
# to the TX policy the engine names it, so the duty cycle can be charged to
# the right channel; without one it leaves the choice to the fabric, as it
# always did. What must not change is which radio transmits.
assert rig.sent_radio_ids in (["local"], [None])
assert rig.records()[-1]["tx_radio_ids"] == ["local"]
@pytest.mark.asyncio
@pytest.mark.parametrize(
"default_radio, expected", [("local", ["local", "link"]), ("link", ["link", "local"])]
)
async def test_local_origin_all_mode_sends_on_every_radio_default_first(default_radio, expected):
rig = _Rig({"origin_tx": "all", "default_radio": default_radio})
pkt = _direct(path=bytes([0x42, 0x43]))
pkt._injected_origin_hash = "0x1a"
assert await rig.handler(pkt, {}, local_transmission=True) is True
assert rig.air.order == expected
assert rig.air.frames[0][1] == rig.air.frames[1][1]
# No forwarding transformation for local origins: the path is untouched.
assert [bytes(p.path) for _, p in rig.sent_packets] == [b"\x42\x43"] * 2
# The primary egress transmits the caller's own packet (the router echoes and
# re-enqueues it); the other egress gets a copy that keeps the origin tag.
assert rig.sent_packets[0][1] is pkt
assert rig.sent_packets[1][1] is not pkt
assert all(p._injected_origin_hash == "0x1a" for _, p in rig.sent_packets)
record = rig.records()[-1]
assert record["rx_radio_id"] is None
assert record["tx_radio_id"] == expected[0]
assert record["tx_radio_ids"] == expected
assert rig.handler.rx_count == 0
assert rig.handler.forwarded_count == 1
assert rig.handler.sent_direct_count == 2
@pytest.mark.asyncio
@pytest.mark.parametrize(
"fabric, expected",
[
({"local_tx_mode": "all"}, ["local", "link"]), # the option's former name
({"origin_tx": "default", "local_tx_mode": "all"}, ["local"]), # new name wins
],
)
async def test_origin_tx_old_name_still_selects_the_mode(fabric, expected):
rig = _Rig(fabric)
assert await rig.handler(_flood(), {}, local_transmission=True) is True
assert rig.air.order == expected
@pytest.mark.asyncio
async def test_local_resend_of_a_received_packet_resolves_as_a_relay():
"""TRACE forwarding re-injects the received packet, still tagged with its ingress."""
rig = _Rig({"repeat_on_ingress": True})
pkt = _flood(rx="link")
assert await rig.handler(pkt, {"rx_radio_id": "link"}, local_transmission=True) is True
assert rig.air.order == ["local", "link"]
@pytest.mark.asyncio
async def test_local_fanout_evaluates_link_health_per_radio():
rig = _Rig({"origin_tx": "all"})
rig.handler.config["delays"]["local_tx_link_wait_seconds"] = 0.6
rig.link.is_connected = False
rig.link.is_degraded = True
started = time.monotonic()
assert await rig.handler(_flood(), {}, local_transmission=True) is True
elapsed = time.monotonic() - started
assert rig.air.order == ["local"]
assert rig.records()[-1]["tx_radio_ids"] == ["local"]
assert rig.local.sent_at[0] - started < 0.3 # never held up by the down link
assert elapsed >= 0.6 # the down link got its bounded wait, then gave up
@pytest.mark.asyncio
async def test_down_primary_does_not_hold_up_the_healthy_radio():
"""Primary-first ordering applies when both radios are ready, never as a wait."""
rig = _Rig({"origin_tx": "all"}) # "local" is the primary egress
rig.handler.config["delays"]["local_tx_link_wait_seconds"] = 0.6
rig.local.is_connected = False
rig.local.is_degraded = True
started = time.monotonic()
assert await rig.handler(_flood(), {}, local_transmission=True) is True
assert rig.air.order == ["link"]
assert rig.link.sent_at[0] - started < 0.3
record = rig.records()[-1]
assert record["tx_radio_id"] == "link" # the primary *successful* egress
assert record["tx_radio_ids"] == ["link"]
@pytest.mark.asyncio
async def test_every_egress_carries_the_same_scope_and_hash_mode():
"""TX-time normalisation is decided once, so a live change mid-fan-out cannot split it."""
rig = _Rig({"origin_tx": "all"})
rig.dispatcher.default_flood_transport_key = get_auto_key_for("#fanout")
rig.dispatcher.set_default_path_hash_mode(1)
async def reconfigure(name, _data):
if name == "local": # the node's region / hash mode change between the sends
rig.dispatcher.default_flood_transport_key = get_auto_key_for("#elsewhere")
rig.dispatcher.set_default_path_hash_mode(2)
rig.local.on_send = reconfigure
pkt = _flood(payload=b"\x5a\x01\x02\x03", payload_type=PAYLOAD_TYPE_GRP_TXT)
assert await rig.handler(pkt, {}, local_transmission=True) is True
(_, first), (_, second) = rig.air.frames
assert first == second
assert pkt.get_route_type() == ROUTE_TYPE_TRANSPORT_FLOOD
assert pkt.get_path_hash_size() == 2
assert pkt.write_to() == first # what the router echoes is what went on air
@pytest.mark.asyncio
async def test_scoped_fanout_meters_the_normalised_length():
"""Scoping adds transport codes; every duty-cycle gate must see those bytes."""
rig = _Rig({"origin_tx": "all"})
rig.dispatcher.default_flood_transport_key = get_auto_key_for("#fanout")
mgr = rig.handler.airtime_mgr
pkt = _flood(payload=b"\x5a\x01\x02\x03", payload_type=PAYLOAD_TYPE_GRP_TXT)
unscoped_len = pkt.get_raw_length()
with patch.object(mgr, "can_transmit", wraps=mgr.can_transmit) as gate:
assert await rig.handler(pkt, {}, local_transmission=True) is True
on_air_len = len(rig.air.frames[0][1])
assert on_air_len == unscoped_len + 4 # the two 16-bit transport codes
on_air_ms = mgr.calculate_airtime(on_air_len)
assert on_air_ms != mgr.calculate_airtime(unscoped_len) # the test can tell them apart
# The advisory gate in __call__ and the in-lock gate of each egress.
assert [c.args[0] for c in gate.call_args_list] == [on_air_ms] * 3
@pytest.mark.asyncio
async def test_cancelling_a_fanout_stops_pending_egresses_and_frees_the_radio():
rig = _Rig({"origin_tx": "all"})
rig.local.airtime_s = 0.2
task = await rig.handler.schedule_retransmit_fanout(
_flood(), 0.0, 0.0, ("local", "link"), local_transmission=True
)
await _until(lambda: rig.air._keyed == 1) # the primary is mid-transmission
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
await asyncio.sleep(0.05)
assert rig.air.frames == [] # neither egress completed after the cancel
# The TX lock was released: the next send goes straight out on both radios.
rig.local.airtime_s = 0.0
assert await rig.handler(_flood(payload=b"\x09\x09"), {}, local_transmission=True) is True
assert rig.air.order == ["local", "link"]
# ---------------------------------------------------------------------------
# Companion / router injection and ACKs
# ---------------------------------------------------------------------------
def _router_for(rig):
daemon = MagicMock()
daemon.repeater_handler = rig.handler
daemon.dispatcher = rig.dispatcher
daemon.config = rig.handler.config
daemon.local_hash = LOCAL_HASH
daemon.companion_bridges = {}
daemon.companion_frame_servers = []
daemon._on_raw_rx_for_companions = AsyncMock()
for helper in (
"trace_helper",
"discovery_helper",
"advert_helper",
"login_helper",
"text_helper",
"path_helper",
"protocol_request_helper",
"neighbor_scope_helper",
):
setattr(daemon, helper, None)
return PacketRouter(daemon)
def _bridge():
bridge = MagicMock()
bridge.process_received_packet = AsyncMock()
return bridge
def _txt():
return _direct(payload=b"\x42\x1a\x01\x02\x03\x04", path=bytes([0x42]))
def _ack(crc, rx):
return _direct(
payload=crc.to_bytes(4, "little"), path=b"", payload_type=PAYLOAD_TYPE_ACK, rx=rx
)
async def _until(predicate, timeout=3.0):
deadline = time.monotonic() + timeout
while not predicate():
if time.monotonic() > deadline:
raise AssertionError("condition not reached")
await asyncio.sleep(0.005)
@pytest.mark.asyncio
async def test_companion_injection_fans_out_as_one_logical_packet():
rig = _Rig({"origin_tx": "all"})
router = _router_for(rig)
pkt = _flood(payload=b"\x5a\x01\x02\x03", payload_type=PAYLOAD_TYPE_GRP_TXT)
assert await router.inject_packet(pkt, origin_hash="0x1a") is True
assert rig.air.order == ["local", "link"]
assert all(p._injected_origin_hash == "0x1a" for _, p in rig.sent_packets)
assert rig.handler.storage.record_packet.call_count == 1
assert len(rig.handler.recent_packets) == 1
assert rig.handler.rx_count == 0
# Only the logical packet re-enters the router for companion delivery.
assert router.queue.qsize() == 1
assert router.queue.get_nowait() is pkt
router.daemon._on_raw_rx_for_companions.assert_awaited_once()
assert router.daemon._on_raw_rx_for_companions.await_args.kwargs["exclude_hash"] == "0x1a"
@pytest.mark.asyncio
async def test_originating_companion_never_receives_its_fanned_out_packet():
rig = _Rig({"origin_tx": "all"})
router = _router_for(rig)
origin, other = _bridge(), _bridge()
router.daemon.companion_bridges = {0x1A: origin, 0x2B: other}
pkt = _flood(payload=b"\x5a\x01\x02\x03", payload_type=PAYLOAD_TYPE_GRP_TXT)
assert await router.inject_packet(pkt, origin_hash="0x1a") is True
await router._route_packet(router.queue.get_nowait())
origin.process_received_packet.assert_not_awaited()
other.process_received_packet.assert_awaited_once()
assert len(rig.air.frames) == 2 # the re-routed packet is not transmitted again
@pytest.mark.asyncio
@pytest.mark.parametrize(
"payload_type, route, path",
[
(PAYLOAD_TYPE_ADVERT, ROUTE_TYPE_FLOOD, b""), # repeater advert
(PAYLOAD_TYPE_RESPONSE, ROUTE_TYPE_DIRECT, bytes([0x42, 0x43])), # server reply
],
ids=["advert", "response"],
)
async def test_non_companion_local_origins_fan_out(payload_type, route, path):
"""Adverts and helper replies reach the air the way main.py and helpers send them."""
rig = _Rig({"origin_tx": "all"})
router = _router_for(rig)
pkt = _packet(route, payload_type, bytes(range(1, 33)), path)
assert await router.inject_packet(pkt, wait_for_ack=False) is True
assert rig.air.order == ["local", "link"]
assert [bytes(p.path) for _, p in rig.sent_packets] == [path] * 2
@pytest.mark.asyncio
@pytest.mark.parametrize("ack_radio", ["local", "link"])
async def test_directed_message_waits_for_one_ack_from_either_radio(ack_radio):
rig = _Rig({"origin_tx": "all"})
router = _router_for(rig)
crc = 0x1234ABCD
registered = []
expect_ack = rig.dispatcher.expect_ack
def recording_expect_ack(ack_crc):
event = expect_ack(ack_crc)
registered.append(len(rig.dispatcher._waiting_acks.get(ack_crc, [])))
return event
rig.dispatcher.expect_ack = recording_expect_ack
task = asyncio.create_task(
router.inject_packet(_txt(), wait_for_ack=True, expected_crc=crc, ack_timeout_s=2.0)
)
await _until(lambda: len(rig.air.frames) == 2)
await router._route_packet(_ack(crc, ack_radio))
assert await asyncio.wait_for(task, 1.0) is True
# One logical waiter for the CRC at any moment, never one per radio.
assert registered and max(registered) == 1
assert crc not in rig.dispatcher._waiting_acks
@pytest.mark.asyncio
async def test_ack_via_link_completes_send_when_local_tx_failed():
rig = _Rig({"origin_tx": "all"})
rig.local.fail = True
router = _router_for(rig)
crc = 0x0BADF00D
task = asyncio.create_task(
router.inject_packet(_txt(), wait_for_ack=True, expected_crc=crc, ack_timeout_s=2.0)
)
await _until(lambda: rig.air.order == ["link"]) # reply follows the link TX
await router._route_packet(_ack(crc, "link"))
assert await asyncio.wait_for(task, 3.0) is True
assert rig.air.order == ["link"]
assert crc not in rig.dispatcher._waiting_acks
@pytest.mark.asyncio
async def test_no_ack_wait_when_every_radio_failed():
rig = _Rig({"origin_tx": "all"})
rig.local.fail = rig.link.fail = True
router = _router_for(rig)
with patch.object(rig.dispatcher, "wait_for_ack", wraps=rig.dispatcher.wait_for_ack) as wait:
started = time.monotonic()
ok = await router.inject_packet(
_txt(), wait_for_ack=True, expected_crc=0xFEEDBEEF, ack_timeout_s=5.0
)
elapsed = time.monotonic() - started
assert ok is False
wait.assert_not_called()
assert elapsed < 2.5 # only the local-TX retry backoff, never the ACK timeout
assert rig.air.frames == []
assert 0xFEEDBEEF not in rig.dispatcher._waiting_acks # reservation released
@pytest.mark.asyncio
async def test_ack_timeout_is_one_timeout_not_one_per_radio():
rig = _Rig({"origin_tx": "all"})
router = _router_for(rig)
with patch.object(rig.dispatcher, "wait_for_ack", wraps=rig.dispatcher.wait_for_ack) as wait:
started = time.monotonic()
ok = await router.inject_packet(
_txt(), wait_for_ack=True, expected_crc=0x5151A0A0, ack_timeout_s=0.5
)
elapsed = time.monotonic() - started
assert ok is False
assert len(rig.air.frames) == 2
wait.assert_called_once_with(0x5151A0A0, timeout=0.5)
assert 0.5 <= elapsed < 0.95 # two sequential timeouts would take 1.0 s
assert 0x5151A0A0 not in rig.dispatcher._waiting_acks
@pytest.mark.asyncio
async def test_ack_heard_during_the_second_radio_send_is_not_lost():
"""The reply can land before the single ACK wait starts; the dispatcher cache holds it."""
rig = _Rig({"origin_tx": "all"})
router = _router_for(rig)
crc = 0x600DCAFE
async def ack_lands(name, _data):
if name == "link":
await router._route_packet(_ack(crc, "local"))
rig.link.on_send = ack_lands
ok = await asyncio.wait_for(
router.inject_packet(_txt(), wait_for_ack=True, expected_crc=crc, ack_timeout_s=1.0),
2.0,
)
assert ok is True
@pytest.mark.asyncio
async def test_early_ack_outlives_the_dispatcher_ack_cache_prune():
"""An ACK heard while the other radio waits out a link outage is not lost.
The link wait (5 s default, up to 30 s) can outlast the dispatcher's 5 s
cache of unclaimed ACKs, so the send's waiter must exist before the TX.
"""
rig = _Rig({"origin_tx": "all"})
rig.handler.config["delays"]["local_tx_link_wait_seconds"] = 0.4
rig.link.is_connected = False
rig.link.is_degraded = True
router = _router_for(rig)
crc = 0xACEDF00D
async def reply_then_prune(_name, _data):
await router._route_packet(_ack(crc, "local"))
# Stand-in for Dispatcher.run_forever expiring the cached ACK.
rig.dispatcher._recent_acks.clear()
rig.local.on_send = reply_then_prune
ok = await asyncio.wait_for(
router.inject_packet(_txt(), wait_for_ack=True, expected_crc=crc, ack_timeout_s=0.5),
3.0,
)
assert ok is True
assert rig.air.order == ["local"]
assert crc not in rig.dispatcher._waiting_acks
# ---------------------------------------------------------------------------
# Result aggregation
# ---------------------------------------------------------------------------
def test_fanout_result_primary_is_first_successful_egress():
result = FanoutTxResult(
[RadioTxResult("link", False, error=RuntimeError("x")), RadioTxResult("local", True)]
)
assert result.any_success is True
assert result.all_success is False
assert result.primary.radio_id == "local"
assert result.successful_radio_ids == ["local"]
assert result.failed_radio_ids == ["link"]
failed = FanoutTxResult([RadioTxResult("link", False), RadioTxResult("local", False)])
assert failed.any_success is False
assert failed.primary.radio_id == "link"
assert FanoutTxResult().all_success is False
@pytest.mark.asyncio
async def test_multi_ack_redundancy_copies_are_recorded_per_radio():
"""A redundancy burst is real airtime on both radios, so it belongs in the rows.
Only the primary result used to be recorded, which undercounted exactly the
traffic multi-ack adds to a channel that is already busy enough to need it.
"""
rig = _Rig({"repeat_on_ingress": True}, multi_acks=1)
pkt = _direct(payload=b"\x4d\xab\xaf\x95", payload_type=PAYLOAD_TYPE_ACK, rx="local")
assert await rig.handler(pkt, _rx("local")) is True
calls = rig.handler.storage.record_packet.call_args_list
egress = calls[-1].kwargs.get("tx_egress")
# The plain ACK and its MULTIPART-wrapped copy, each on both radios.
assert len(rig.air.frames) == 4
assert len(egress) == 4
assert sorted(row["radio_id"] for row in egress) == sorted(
radio_id for radio_id, _ in rig.sent_packets
)
assert all(row["success"] for row in egress)