refactor: split bot.py into runtime/dispatch/worker modules; add retry scheduler, route TTL, ack tracking, follow-up screener, structured event log, and test suite
This commit is contained in:
24 files changed
+3531
-434
No files matched your search
Whitespace-only changes.
@@ -0,0 +1,11 @@
|
||||
"""Shared pytest fixtures and path setup."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure the project root is on sys.path so `import orac...` works from anywhere.
|
||||
_ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_ROOT))
|
||||
@@ -0,0 +1,42 @@
|
||||
"""ACK-hash stability across retry rebuilds.
|
||||
|
||||
The retry scheduler must be able to recompute the expected ACK CRC using the
|
||||
same plaintext (ts + attempt byte + text) that the receiver will compute against.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from orac.crypto import build_peer_plaintext
|
||||
from orac.meshcore import compute_ack_hash
|
||||
|
||||
|
||||
def test_ack_hash_stable_for_same_inputs() -> None:
|
||||
ts = 1700000000
|
||||
pt1 = build_peer_plaintext("hello", ts=ts, attempt=0)
|
||||
pt2 = build_peer_plaintext("hello", ts=ts, attempt=0)
|
||||
sender = b"\x11" * 32
|
||||
assert compute_ack_hash(pt1, sender) == compute_ack_hash(pt2, sender)
|
||||
|
||||
|
||||
def test_ack_hash_changes_with_attempt_byte() -> None:
|
||||
ts = 1700000000
|
||||
pt0 = build_peer_plaintext("hello", ts=ts, attempt=0)
|
||||
pt1 = build_peer_plaintext("hello", ts=ts, attempt=1)
|
||||
sender = b"\x22" * 32
|
||||
assert compute_ack_hash(pt0, sender) != compute_ack_hash(pt1, sender)
|
||||
|
||||
|
||||
def test_ack_hash_changes_with_text() -> None:
|
||||
ts = 1700000000
|
||||
sender = b"\x33" * 32
|
||||
a = compute_ack_hash(build_peer_plaintext("hello", ts=ts, attempt=0), sender)
|
||||
b = compute_ack_hash(build_peer_plaintext("world", ts=ts, attempt=0), sender)
|
||||
assert a != b
|
||||
|
||||
|
||||
def test_ack_hash_changes_with_sender() -> None:
|
||||
ts = 1700000000
|
||||
pt = build_peer_plaintext("hello", ts=ts, attempt=0)
|
||||
a = compute_ack_hash(pt, b"\x44" * 32)
|
||||
b = compute_ack_hash(pt, b"\x55" * 32)
|
||||
assert a != b
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Tests for orac.followup: recent-activity tracker + screener rate limit."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from orac import followup
|
||||
|
||||
|
||||
def test_untouched_channel_is_not_recent() -> None:
|
||||
followup.reset()
|
||||
assert followup.was_recent("unused", window_s=60.0) is False
|
||||
assert followup.last_interaction_age("unused") is None
|
||||
|
||||
|
||||
def test_touch_then_was_recent_within_window() -> None:
|
||||
followup.reset()
|
||||
followup.touch("Public")
|
||||
assert followup.was_recent("Public", window_s=60.0) is True
|
||||
age = followup.last_interaction_age("Public")
|
||||
assert age is not None and age < 1.0
|
||||
|
||||
|
||||
def test_was_recent_expires_after_window() -> None:
|
||||
followup.reset()
|
||||
followup.touch("Public")
|
||||
# Immediately check with a zero-width window — should not count as recent.
|
||||
time.sleep(0.01)
|
||||
assert followup.was_recent("Public", window_s=0.001) is False
|
||||
|
||||
|
||||
def test_screener_ok_rate_limits_per_channel() -> None:
|
||||
followup.reset()
|
||||
# First call allowed
|
||||
assert followup.screener_ok("Public", min_interval_s=0.5) is True
|
||||
# Immediate second call blocked
|
||||
assert followup.screener_ok("Public", min_interval_s=0.5) is False
|
||||
# Different channel not blocked
|
||||
assert followup.screener_ok("#general", min_interval_s=0.5) is True
|
||||
|
||||
|
||||
def test_screener_ok_allows_after_interval() -> None:
|
||||
followup.reset()
|
||||
assert followup.screener_ok("Public", min_interval_s=0.05) is True
|
||||
time.sleep(0.1)
|
||||
assert followup.screener_ok("Public", min_interval_s=0.05) is True
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Protocol fidelity: MULTIPART ACK packet layout + RX unwrap.
|
||||
|
||||
Mirrors upstream MeshCore:
|
||||
- `createMultiAck(crc, remaining)` (src/Mesh.cpp:570-583)
|
||||
- `createAck(crc)` (src/Mesh.cpp:556-568)
|
||||
- Receiver unwrap (src/Mesh.cpp:270-282)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from orac import meshcore
|
||||
from orac.meshcore import build_ack_packet, build_multiack_packet, parse_header_and_path
|
||||
|
||||
|
||||
def _clear_routes() -> None:
|
||||
with meshcore._route_lock:
|
||||
meshcore._route_table.clear()
|
||||
|
||||
|
||||
def test_multiack_flood_layout() -> None:
|
||||
"""With no learned route, MULTIPART uses flood route_type = 1."""
|
||||
_clear_routes()
|
||||
crc = bytes([0xDE, 0xAD, 0xBE, 0xEF])
|
||||
packet = build_multiack_packet(crc, remaining=1, dest_hash=0x42)
|
||||
# Expect: header(1) + path_len(1) + payload(5)
|
||||
# header: PAYLOAD_TYPE_MULTIPART(0x0A) << 2 | ROUTE_TYPE_FLOOD(0x01)
|
||||
# = (0x0A << 2) | 0x01 = 0x29
|
||||
assert packet[0] == 0x29, f"header byte wrong: {packet[0]:#04x}"
|
||||
# path_len byte: 0x40 = hash_size_code=1 (2-byte), 0 hops
|
||||
assert packet[1] == 0x40
|
||||
# Payload: (remaining << 4) | 0x03 + crc
|
||||
assert packet[2] == (1 << 4) | 0x03
|
||||
assert packet[3:7] == crc
|
||||
|
||||
|
||||
def test_multiack_direct_layout_with_route() -> None:
|
||||
"""With a learned direct route, header uses ROUTE_TYPE_DIRECT = 2."""
|
||||
_clear_routes()
|
||||
# Seed a 2-byte single-hop route
|
||||
meshcore.learn_route(0x77, [b"\xaa\xbb"], hash_size=2, snr=10.0)
|
||||
crc = bytes([0x01, 0x02, 0x03, 0x04])
|
||||
packet = build_multiack_packet(crc, remaining=2, dest_hash=0x77)
|
||||
# header = (0x0A << 2) | 0x02 (ROUTE_TYPE_DIRECT) = 0x2A
|
||||
assert packet[0] == 0x2A, f"direct-route header wrong: {packet[0]:#04x}"
|
||||
# path_len byte: hash_size_code=1 (2-byte), 1 hop -> 0x41
|
||||
assert packet[1] == 0x41
|
||||
# Path data
|
||||
assert packet[2:4] == b"\xaa\xbb"
|
||||
# Payload: (remaining << 4) | 0x03 + crc
|
||||
assert packet[4] == (2 << 4) | 0x03
|
||||
assert packet[5:9] == crc
|
||||
|
||||
|
||||
def test_multiack_remaining_nibble_bounds() -> None:
|
||||
import pytest
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
build_multiack_packet(b"\x00\x00\x00\x00", remaining=-1, dest_hash=0x11)
|
||||
with pytest.raises(ValueError):
|
||||
build_multiack_packet(b"\x00\x00\x00\x00", remaining=16, dest_hash=0x11)
|
||||
# 0 and 15 are valid
|
||||
build_multiack_packet(b"\x00\x00\x00\x00", remaining=0, dest_hash=0x11)
|
||||
build_multiack_packet(b"\x00\x00\x00\x00", remaining=15, dest_hash=0x11)
|
||||
|
||||
|
||||
def test_multiack_crc_length_enforced() -> None:
|
||||
import pytest
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
build_multiack_packet(b"\x00\x00\x00", remaining=1, dest_hash=0x11)
|
||||
with pytest.raises(ValueError):
|
||||
build_multiack_packet(b"\x00\x00\x00\x00\x00", remaining=1, dest_hash=0x11)
|
||||
|
||||
|
||||
def test_plain_ack_layout_unchanged() -> None:
|
||||
"""Regression: plain PAYLOAD_TYPE_ACK (0x03) still works as before."""
|
||||
_clear_routes()
|
||||
crc = bytes([0xAA, 0xBB, 0xCC, 0xDD])
|
||||
packet = build_ack_packet(crc, dest_hash=0x99)
|
||||
# header = (0x03 << 2) | 0x01 = 0x0D (flood)
|
||||
assert packet[0] == 0x0D
|
||||
assert packet[1] == 0x40
|
||||
# Payload is just the 4-byte CRC, no remaining-nibble byte.
|
||||
assert packet[2:6] == crc
|
||||
assert len(packet) == 6
|
||||
|
||||
|
||||
def test_round_trip_parse_extracts_multiack_payload() -> None:
|
||||
"""Firmware-style RX: parse_header_and_path strips header+path and hands us
|
||||
the 5-byte MULTIPART payload; our handler expects [nibble|type][crc:4]."""
|
||||
_clear_routes()
|
||||
crc = bytes([0xC0, 0xDE, 0xCA, 0xFE])
|
||||
packet = build_multiack_packet(crc, remaining=1, dest_hash=0x33)
|
||||
parsed = parse_header_and_path(packet)
|
||||
assert parsed is not None
|
||||
payload_type, route_type, _ver, _pos, payload, _path_hops, _hash_size = parsed
|
||||
assert payload_type == 0x0A # PAYLOAD_TYPE_MULTIPART
|
||||
assert route_type == 0x01 # flood (no route learned)
|
||||
assert len(payload) == 5
|
||||
assert payload[0] & 0x0F == 0x03 # inner type == PAYLOAD_TYPE_ACK
|
||||
assert payload[0] >> 4 == 1 # remaining = 1
|
||||
assert payload[1:5] == crc
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Tests for orac.reply_state.PendingAckTable and ReplyCache."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from orac.reply_state import PendingAckTable, PendingReply, ReplyCache
|
||||
|
||||
|
||||
def _entry(ack: bytes, peer_pk: bytes, attempt: int = 1) -> PendingReply:
|
||||
now = time.monotonic()
|
||||
return PendingReply(
|
||||
expected_ack=ack,
|
||||
peer_pk=peer_pk,
|
||||
peer_name=f"peer{peer_pk[0]:02x}",
|
||||
text="hello",
|
||||
first_ts=1234567890,
|
||||
first_sent_at=now,
|
||||
attempt=attempt,
|
||||
max_attempts=5,
|
||||
retry_at=now + 6.0,
|
||||
)
|
||||
|
||||
|
||||
def test_register_and_consume_roundtrip() -> None:
|
||||
table = PendingAckTable(cap=4)
|
||||
ack = b"\x01\x02\x03\x04"
|
||||
table.register(_entry(ack, b"\xaa" * 32))
|
||||
assert table.depth() == 1
|
||||
entry = table.consume(ack)
|
||||
assert entry is not None
|
||||
assert entry.expected_ack == ack
|
||||
assert table.depth() == 0
|
||||
# Consuming again returns None.
|
||||
assert table.consume(ack) is None
|
||||
|
||||
|
||||
def test_expired_returns_entries_past_retry_at() -> None:
|
||||
table = PendingAckTable()
|
||||
ack = b"\x00\x00\x00\x01"
|
||||
e = _entry(ack, b"\x11" * 32)
|
||||
table.register(e)
|
||||
# retry_at was set to now + 6.0 — force expiry by passing a far-future "now".
|
||||
expired = table.expired(time.monotonic() + 10.0)
|
||||
assert len(expired) == 1
|
||||
assert expired[0].expected_ack == ack
|
||||
# expired() does not remove.
|
||||
assert table.depth() == 1
|
||||
|
||||
|
||||
def test_replace_key_rekeys_entry() -> None:
|
||||
table = PendingAckTable()
|
||||
old = b"\x00\x00\x00\x01"
|
||||
new = b"\x00\x00\x00\x02"
|
||||
table.register(_entry(old, b"\x22" * 32))
|
||||
table.replace_key(old, new)
|
||||
assert table.consume(old) is None
|
||||
got = table.consume(new)
|
||||
assert got is not None and got.expected_ack == new
|
||||
|
||||
|
||||
def test_has_pending_for_peer() -> None:
|
||||
table = PendingAckTable()
|
||||
pk_a = b"\x10" + b"\x00" * 31
|
||||
pk_b = b"\x20" + b"\x00" * 31
|
||||
table.register(_entry(b"\x00\x00\x00\x01", pk_a))
|
||||
assert table.has_pending_for_peer(pk_a) is True
|
||||
assert table.has_pending_for_peer(pk_b) is False
|
||||
|
||||
|
||||
def test_cap_evicts_oldest_on_overflow() -> None:
|
||||
table = PendingAckTable(cap=2)
|
||||
table.register(_entry(b"\x01\x00\x00\x00", b"\xaa" * 32))
|
||||
table.register(_entry(b"\x02\x00\x00\x00", b"\xbb" * 32))
|
||||
table.register(_entry(b"\x03\x00\x00\x00", b"\xcc" * 32))
|
||||
# Oldest (first registered) should be gone.
|
||||
assert table.consume(b"\x01\x00\x00\x00") is None
|
||||
assert table.consume(b"\x02\x00\x00\x00") is not None
|
||||
assert table.consume(b"\x03\x00\x00\x00") is not None
|
||||
|
||||
|
||||
def test_reply_cache_roundtrip() -> None:
|
||||
cache = ReplyCache(cap=4, ttl=100.0)
|
||||
pk = b"\x55" * 32
|
||||
cache.put(
|
||||
peer_pk=pk,
|
||||
peer_name="alice",
|
||||
dm_text="hi",
|
||||
reply_packet=b"\xff\xee",
|
||||
reply_text="hello back",
|
||||
expected_ack=b"\x01\x02\x03\x04",
|
||||
)
|
||||
got = cache.get(pk.hex(), "hi")
|
||||
assert got is not None and got.reply_text == "hello back"
|
||||
# Different DM text misses.
|
||||
assert cache.get(pk.hex(), "different") is None
|
||||
|
||||
|
||||
def test_reply_cache_ttl_expires() -> None:
|
||||
cache = ReplyCache(cap=4, ttl=0.001)
|
||||
pk = b"\x66" * 32
|
||||
cache.put(
|
||||
peer_pk=pk,
|
||||
peer_name="bob",
|
||||
dm_text="q",
|
||||
reply_packet=b"\x00",
|
||||
reply_text="a",
|
||||
expected_ack=b"\x00\x00\x00\x00",
|
||||
)
|
||||
time.sleep(0.01)
|
||||
assert cache.get(pk.hex(), "q") is None
|
||||
|
||||
|
||||
def test_reply_cache_ts_lets_caller_gate_on_age() -> None:
|
||||
"""Caller can decide whether a hit is within the loss-recovery window."""
|
||||
cache = ReplyCache(cap=4, ttl=100.0)
|
||||
pk = b"\x77" * 32
|
||||
cache.put(
|
||||
peer_pk=pk,
|
||||
peer_name="carol",
|
||||
dm_text="status?",
|
||||
reply_packet=b"\x00",
|
||||
reply_text="ok",
|
||||
expected_ack=b"\x00\x00\x00\x01",
|
||||
)
|
||||
got = cache.get(pk.hex(), "status?")
|
||||
assert got is not None
|
||||
# Caller interprets .ts; entry is fresh right now.
|
||||
assert (time.monotonic() - got.ts) < 1.0
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Tests for orac.runtime.RetryScheduler: attempt progression, path reset, exhaustion."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import time
|
||||
|
||||
from orac.constants import (
|
||||
PENDING_ACK_TTL_S,
|
||||
REPLY_FLOOD_FIRST_ATTEMPT,
|
||||
REPLY_MAX_ATTEMPTS,
|
||||
RETRY_SCHEDULE_S,
|
||||
)
|
||||
from orac.reply_state import PendingAckTable, PendingReply, ReplyCache # noqa: F401
|
||||
from orac.runtime import Metrics, RetryScheduler, TxQueue
|
||||
|
||||
|
||||
def _entry(
|
||||
ack: bytes,
|
||||
attempt: int = 1,
|
||||
retry_at_offset: float = 0.0,
|
||||
max_attempts: int = 5,
|
||||
) -> PendingReply:
|
||||
now = time.monotonic()
|
||||
return PendingReply(
|
||||
expected_ack=ack,
|
||||
peer_pk=b"\xaa" * 32,
|
||||
peer_name="peer_aa",
|
||||
text="hi",
|
||||
first_ts=1234567890,
|
||||
first_sent_at=now,
|
||||
attempt=attempt,
|
||||
max_attempts=max_attempts,
|
||||
retry_at=now + retry_at_offset,
|
||||
)
|
||||
|
||||
|
||||
def _noop_rebuild(_entry: PendingReply, attempt: int, force_flood: bool) -> tuple[bytes, bytes]:
|
||||
"""Rebuild stub: new ACK differs from old so replace_key is exercised."""
|
||||
new_ack = bytes([attempt & 0xFF, 0xAA, 0xBB, 0xCC])
|
||||
return (b"REBUILT-%d-%s" % (attempt, b"F" if force_flood else b"D"), new_ack)
|
||||
|
||||
|
||||
def test_direct_retry_arms_next_attempt() -> None:
|
||||
pending = PendingAckTable()
|
||||
tx = TxQueue()
|
||||
m = Metrics()
|
||||
forgot: list[int] = []
|
||||
|
||||
sched = RetryScheduler(
|
||||
pending=pending,
|
||||
tx_queue=tx,
|
||||
metrics=m,
|
||||
rebuild_reply=_noop_rebuild,
|
||||
forget_route_fn=lambda h: (forgot.append(h), True)[1],
|
||||
)
|
||||
|
||||
# First attempt (entry.attempt=1) with retry_at already passed.
|
||||
e = _entry(b"\x00\x00\x00\x01", attempt=1, retry_at_offset=-0.1)
|
||||
pending.register(e)
|
||||
sched.tick(time.monotonic())
|
||||
|
||||
# Next attempt (2) is a direct retry — route should NOT have been forgotten.
|
||||
assert forgot == []
|
||||
# The entry's expected_ack has been re-keyed.
|
||||
assert pending.consume(b"\x00\x00\x00\x01") is None
|
||||
# New key from _noop_rebuild is (attempt=2, ...).
|
||||
item = tx.pop_ready(time.monotonic())
|
||||
assert item is not None
|
||||
assert item.attempt == 2
|
||||
assert item.expected_ack == bytes([0x02, 0xAA, 0xBB, 0xCC])
|
||||
|
||||
|
||||
def test_flood_retry_forgets_route() -> None:
|
||||
pending = PendingAckTable()
|
||||
tx = TxQueue()
|
||||
m = Metrics()
|
||||
forgot: list[int] = []
|
||||
sched = RetryScheduler(
|
||||
pending=pending,
|
||||
tx_queue=tx,
|
||||
metrics=m,
|
||||
rebuild_reply=_noop_rebuild,
|
||||
forget_route_fn=lambda h: (forgot.append(h), True)[1],
|
||||
)
|
||||
|
||||
# Entry attempt = REPLY_FLOOD_FIRST_ATTEMPT - 1; next tick hits flood attempt.
|
||||
e = _entry(
|
||||
b"\x00\x00\x00\x02",
|
||||
attempt=REPLY_FLOOD_FIRST_ATTEMPT - 1,
|
||||
retry_at_offset=-0.1,
|
||||
)
|
||||
pending.register(e)
|
||||
sched.tick(time.monotonic())
|
||||
|
||||
# route should have been forgotten for the peer's 1-byte hash.
|
||||
assert forgot == [e.peer_pk[0]]
|
||||
item = tx.pop_ready(time.monotonic())
|
||||
assert item is not None
|
||||
assert item.attempt == REPLY_FLOOD_FIRST_ATTEMPT
|
||||
# The rebuilt packet's label tag for flood is 'F' in our stub.
|
||||
assert item.packet.endswith(b"F")
|
||||
|
||||
|
||||
def test_exhausted_drops_and_counts() -> None:
|
||||
pending = PendingAckTable()
|
||||
tx = TxQueue()
|
||||
m = Metrics()
|
||||
sched = RetryScheduler(
|
||||
pending=pending,
|
||||
tx_queue=tx,
|
||||
metrics=m,
|
||||
rebuild_reply=_noop_rebuild,
|
||||
forget_route_fn=lambda _h: False,
|
||||
)
|
||||
|
||||
e = _entry(b"\x00\x00\x00\x03", attempt=5, retry_at_offset=-0.1, max_attempts=5)
|
||||
pending.register(e)
|
||||
sched.tick(time.monotonic())
|
||||
assert pending.depth() == 0
|
||||
counters, _, _ = m.snapshot()
|
||||
assert counters.get("reply_exhausted", 0) == 1
|
||||
|
||||
|
||||
def test_retry_schedule_is_airtime_headroom() -> None:
|
||||
# Guardrail: encourage authors to keep realistic spacing.
|
||||
assert RETRY_SCHEDULE_S[0] >= 3.0, "attempt-2 must leave headroom for a 2-hop RTT"
|
||||
for prev, cur in itertools.pairwise(RETRY_SCHEDULE_S):
|
||||
assert cur >= prev, "retry schedule should be monotonically non-decreasing"
|
||||
|
||||
|
||||
def test_schedule_covers_all_retries_for_max_attempts() -> None:
|
||||
"""Each retry after the initial send needs an explicit delay entry."""
|
||||
# REPLY_MAX_ATTEMPTS counts the initial send, so retries = MAX - 1.
|
||||
assert len(RETRY_SCHEDULE_S) >= REPLY_MAX_ATTEMPTS - 1, (
|
||||
"RETRY_SCHEDULE_S too short for REPLY_MAX_ATTEMPTS — the final retry clamps "
|
||||
"back to the last entry rather than using a distinct delay"
|
||||
)
|
||||
|
||||
|
||||
def test_pending_ack_ttl_exceeds_final_retry_plus_headroom() -> None:
|
||||
"""TTL sweep must not kill pending entries before the last retry's ACK window."""
|
||||
total_retry_delay = sum(RETRY_SCHEDULE_S[: REPLY_MAX_ATTEMPTS - 1])
|
||||
# At least 20 s past the final retry's send time for its ACK to arrive.
|
||||
min_required = total_retry_delay + 20
|
||||
assert min_required <= PENDING_ACK_TTL_S, (
|
||||
f"PENDING_ACK_TTL_S={PENDING_ACK_TTL_S} cuts the last retry short; "
|
||||
f"needs >= {min_required}s (sum of retry delays + 20s ACK window)"
|
||||
)
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Tests for orac.meshcore route table TTL and SNR upgrade rule."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
import orac.meshcore as mc
|
||||
|
||||
|
||||
def _clear_route_table() -> None:
|
||||
with mc._route_lock:
|
||||
mc._route_table.clear()
|
||||
|
||||
|
||||
def test_learn_and_get_roundtrip() -> None:
|
||||
_clear_route_table()
|
||||
mc.learn_route(0xAB, [b"\x11\x22"], hash_size=2, snr=10.0)
|
||||
got = mc.get_route(0xAB)
|
||||
assert got is not None
|
||||
hops, hs = got
|
||||
# path stored reversed
|
||||
assert hops == [b"\x11\x22"]
|
||||
assert hs == 2
|
||||
|
||||
|
||||
def test_ttl_expiry_and_lazy_eviction() -> None:
|
||||
_clear_route_table()
|
||||
mc.learn_route(0xCD, [b"\x01\x02"], hash_size=2, snr=5.0)
|
||||
|
||||
# Fast-forward monotonic time by patching time.monotonic to return now+TTL+1
|
||||
fake_future = time.monotonic() + mc.ROUTE_TTL_S + 1.0
|
||||
with patch("orac.meshcore.time.monotonic", return_value=fake_future):
|
||||
assert mc.get_route(0xCD) is None
|
||||
|
||||
# Entry should have been lazy-evicted.
|
||||
assert mc.get_route(0xCD) is None
|
||||
|
||||
|
||||
def test_forget_route_removes_entry() -> None:
|
||||
_clear_route_table()
|
||||
mc.learn_route(0x55, [b"\xaa\xbb"], hash_size=2)
|
||||
assert mc.forget_route(0x55) is True
|
||||
assert mc.get_route(0x55) is None
|
||||
# Double-forget is a no-op.
|
||||
assert mc.forget_route(0x55) is False
|
||||
|
||||
|
||||
def test_snr_upgrade_rule_rejects_bad_path() -> None:
|
||||
_clear_route_table()
|
||||
mc.learn_route(0x77, [b"\x01\x02", b"\x03\x04"], hash_size=2, snr=10.0)
|
||||
# New path arrives with much worse SNR → existing path kept.
|
||||
mc.learn_route(0x77, [b"\xff\xff"], hash_size=2, snr=-5.0)
|
||||
got = mc.get_route(0x77)
|
||||
assert got is not None
|
||||
hops, _ = got
|
||||
assert hops == [b"\x03\x04", b"\x01\x02"] # original (reversed) hops
|
||||
|
||||
|
||||
def test_snr_upgrade_rule_accepts_comparable_path() -> None:
|
||||
_clear_route_table()
|
||||
mc.learn_route(0x88, [b"\x01\x02", b"\x03\x04"], hash_size=2, snr=5.0)
|
||||
# New path with slightly worse SNR (within 3 dB tolerance) → upgrade (refreshes learned_at).
|
||||
mc.learn_route(0x88, [b"\xaa\xbb"], hash_size=2, snr=3.0)
|
||||
got = mc.get_route(0x88)
|
||||
assert got is not None
|
||||
hops, _ = got
|
||||
assert hops == [b"\xaa\xbb"]
|
||||
|
||||
|
||||
def test_hash_size_downgrade_rejected() -> None:
|
||||
_clear_route_table()
|
||||
mc.learn_route(0x99, [b"\x01\x02"], hash_size=2, snr=10.0)
|
||||
# Smaller hash size should NOT replace a larger one.
|
||||
mc.learn_route(0x99, [b"\xff"], hash_size=1, snr=10.0)
|
||||
got = mc.get_route(0x99)
|
||||
assert got is not None
|
||||
_, hs = got
|
||||
assert hs == 2
|
||||
|
||||
|
||||
def test_one_byte_hash_paths_are_never_learned() -> None:
|
||||
"""Policy: we refuse to learn routes with 1-byte hashes (MIN_HASH_SIZE=2)."""
|
||||
_clear_route_table()
|
||||
mc.learn_route(0xAA, [b"\xff"], hash_size=1, snr=10.0)
|
||||
assert mc.get_route(0xAA) is None
|
||||
|
||||
|
||||
def test_build_path_return_rejects_one_byte_hashes() -> None:
|
||||
"""Policy: refuse to echo 1-byte PATH returns."""
|
||||
import pytest
|
||||
|
||||
peer_pk = b"\x55" * 32
|
||||
with pytest.raises(ValueError):
|
||||
mc.build_path_return_packet(peer_pk, [b"\xff"], hash_size=1)
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Tests for orac.runtime.TxQueue: priority ordering, not_before gating, eviction."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from orac.runtime import TxItem, TxPriority, TxQueue
|
||||
|
||||
|
||||
def _item(
|
||||
priority: int,
|
||||
not_before: float,
|
||||
*,
|
||||
label: str = "x",
|
||||
expected_ack: bytes | None = None,
|
||||
) -> TxItem:
|
||||
return TxItem(
|
||||
priority=priority,
|
||||
not_before=not_before,
|
||||
packet=b"\x00\x01\x02",
|
||||
label=label,
|
||||
expected_ack=expected_ack,
|
||||
)
|
||||
|
||||
|
||||
def test_pop_ready_none_when_nothing_due() -> None:
|
||||
q = TxQueue(cap=8)
|
||||
now = time.monotonic()
|
||||
q.push(_item(TxPriority.ACK, now + 10.0))
|
||||
assert q.pop_ready(now) is None
|
||||
|
||||
|
||||
def test_pop_ready_highest_priority_wins_when_all_ready() -> None:
|
||||
q = TxQueue(cap=8)
|
||||
now = time.monotonic()
|
||||
q.push(_item(TxPriority.ADVERT, now - 1.0, label="advert"))
|
||||
q.push(_item(TxPriority.REPLY_FLOOD, now - 1.0, label="reply_flood"))
|
||||
q.push(_item(TxPriority.ACK, now - 1.0, label="ack"))
|
||||
q.push(_item(TxPriority.PATH, now - 1.0, label="path"))
|
||||
order: list[str] = []
|
||||
for _ in range(4):
|
||||
item = q.pop_ready(now)
|
||||
assert item is not None
|
||||
order.append(item.label)
|
||||
assert order == ["ack", "path", "reply_flood", "advert"]
|
||||
|
||||
|
||||
def test_not_before_gates_lower_priority_first() -> None:
|
||||
q = TxQueue(cap=8)
|
||||
now = time.monotonic()
|
||||
q.push(_item(TxPriority.ACK, now + 5.0, label="future_ack"))
|
||||
q.push(_item(TxPriority.ADVERT, now - 1.0, label="advert"))
|
||||
item = q.pop_ready(now)
|
||||
assert item is not None
|
||||
assert item.label == "advert"
|
||||
# Future ACK still gated
|
||||
assert q.pop_ready(now) is None
|
||||
|
||||
|
||||
def test_eviction_drops_lowest_priority_oldest_when_cap() -> None:
|
||||
q = TxQueue(cap=3)
|
||||
now = time.monotonic()
|
||||
q.push(_item(TxPriority.ACK, now, label="a"))
|
||||
q.push(_item(TxPriority.PATH, now, label="b"))
|
||||
q.push(_item(TxPriority.ADVERT, now, label="c"))
|
||||
# Cap hit; new ACK should evict ADVERT.
|
||||
q.push(_item(TxPriority.ACK, now, label="d"))
|
||||
labels: set[str] = set()
|
||||
for _ in range(3):
|
||||
item = q.pop_ready(now)
|
||||
assert item is not None
|
||||
labels.add(item.label)
|
||||
assert labels == {"a", "b", "d"}
|
||||
|
||||
|
||||
def test_cancel_by_ack_removes_matching_items() -> None:
|
||||
q = TxQueue(cap=8)
|
||||
now = time.monotonic()
|
||||
q.push(_item(TxPriority.REPLY_DIRECT, now, label="x", expected_ack=b"\xde\xad\xbe\xef"))
|
||||
q.push(_item(TxPriority.REPLY_DIRECT, now, label="y", expected_ack=b"\x01\x02\x03\x04"))
|
||||
q.push(_item(TxPriority.ACK, now, label="ack_bare"))
|
||||
removed = q.cancel_by_ack(b"\xde\xad\xbe\xef")
|
||||
assert removed == 1
|
||||
# The other two items survive.
|
||||
remaining: set[str] = set()
|
||||
for _ in range(2):
|
||||
item = q.pop_ready(now)
|
||||
assert item is not None
|
||||
remaining.add(item.label)
|
||||
assert remaining == {"y", "ack_bare"}
|
||||
Reference in new issue
Block a user