Files
meshcore-bot/tests/unit/test_command_serializer.py
T
Adam Gessaman 9939e22575 fix(core): release the radio lock while waiting for ACKs (#291)
Radio commands were serialized per method call, so a composite command held the lock for everything it awaited. A DM's send_msg_with_retry kept every other command waiting through all of its ACK timeouts (up to ~36 s with three attempts), stalling channel replies, other DMs, scheduled sends, and automatic message fetching. req_regions_sync did the same for each neighbor scope reply.

Serialize at the frame instead: wrap CommandHandler.send() on the handler instance, which is where every meshcore command writes its frame and waits for the radio's immediate reply. There is still at most one in-flight companion frame, paced as before, and library methods that call self.send are covered without a proxy. Waits for ACKs and remote responses now run outside the lock.

Add MeshCoreBot.radio_session() for short sequences that must not interleave, and use it for region-scoped channel sends so set scope, send, and restore stay together. The per-call lock never guaranteed that: a DM retry loop queued between set_flood_scope and send_chan_msg sent its flood attempts under the channel's scope.
2026-09-19 09:12:07 -07:00

231 lines
7.5 KiB
Python

"""Tests for the host->radio command serializer (_serialize_command_frames).
These validate the core mitigation for the firmware corruption / parser-desync
failure mode: every frame written to the radio is serialized to one in-flight
companion frame at a time and paced by a minimum inter-command interval, so the
firmware's single-threaded serial loop cannot be overrun. The lock covers a
frame and its immediate reply only, so commands that wait for ACKs or remote
responses don't block other senders.
"""
import asyncio
import time
from pathlib import Path
import pytest
from meshcore.commands import CommandHandler
from meshcore.events import Event, EventDispatcher, EventType
from modules.core import MeshCoreBot, _serialize_command_frames
def _make_bot(tmp_path: Path, min_interval_ms: int = 30) -> MeshCoreBot:
config_file = tmp_path / "config.ini"
db_path = tmp_path / "bot.db"
config_file.write_text(
f"""[Connection]
connection_type = serial
serial_port = /dev/ttyUSB0
command_min_interval_ms = {min_interval_ms}
[Bot]
db_path = {db_path.as_posix()}
prefix_bytes = 1
[Channels]
monitor_channels = #general
""",
encoding="utf-8",
)
return MeshCoreBot(config_file=str(config_file))
class FakeCommands:
"""Stand-in for meshcore.commands: every command writes through send()."""
def __init__(self):
self.active = 0
self.max_active = 0
self.frames = []
async def send(self, data, expected_events=None, timeout=None, delay=0.01):
self.frames.append(data)
self.active += 1
self.max_active = max(self.max_active, self.active)
try:
await asyncio.sleep(delay)
return "ok"
finally:
self.active -= 1
async def send_and_wait_for_reply(self, data, wait: float):
"""Like send_msg_with_retry: write one frame, then wait for a remote reply."""
await self.send(data)
await asyncio.sleep(wait)
return "replied"
class FakeMeshcore:
def __init__(self):
self.commands = FakeCommands()
def test_min_interval_parsed_from_config(tmp_path):
bot = _make_bot(tmp_path, min_interval_ms=45)
assert bot._radio_cmd_min_interval == pytest.approx(0.045)
def test_min_interval_negative_clamped_to_zero(tmp_path):
bot = _make_bot(tmp_path, min_interval_ms=-100)
assert bot._radio_cmd_min_interval == 0.0
async def test_serializes_concurrent_frames(tmp_path):
"""Only one frame may be in flight at a time."""
bot = _make_bot(tmp_path, min_interval_ms=0) # isolate mutex from pacing
fake = FakeCommands()
_serialize_command_frames(bot, fake)
await asyncio.gather(*(fake.send(b"\x01") for _ in range(10)))
assert len(fake.frames) == 10
assert fake.max_active == 1 # never more than one in-flight frame
async def test_pacing_enforces_minimum_gap(tmp_path):
bot = _make_bot(tmp_path, min_interval_ms=50)
fake = FakeCommands()
_serialize_command_frames(bot, fake)
start = time.monotonic()
for _ in range(5):
await fake.send(b"\x01", delay=0.0)
elapsed = time.monotonic() - start
# 5 frames => 4 enforced gaps of ~50ms (first frame is not delayed).
assert elapsed >= 0.18
async def test_wrapped_send_returns_value(tmp_path):
bot = _make_bot(tmp_path, min_interval_ms=0)
fake = FakeCommands()
_serialize_command_frames(bot, fake)
assert await fake.send(b"\x01", delay=0.0) == "ok"
async def test_reply_wait_does_not_hold_the_lock(tmp_path):
"""A command waiting for a remote reply must not block other frames."""
bot = _make_bot(tmp_path, min_interval_ms=0)
fake = FakeCommands()
_serialize_command_frames(bot, fake)
waiting = asyncio.create_task(fake.send_and_wait_for_reply(b"\x02", wait=1.0))
await asyncio.sleep(0.05) # first frame written, now waiting for the reply
start = time.monotonic()
assert await fake.send(b"\x03") == "ok"
assert time.monotonic() - start < 0.5
assert not waiting.done()
waiting.cancel()
async def test_send_msg_with_retry_ack_wait_does_not_block_other_commands(tmp_path):
"""meshcore's own retry loop sends through the wrapped send(), outside the lock."""
bot = _make_bot(tmp_path, min_interval_ms=0)
dispatcher = EventDispatcher()
await dispatcher.start()
try:
handler = CommandHandler()
handler.set_dispatcher(dispatcher)
contact = {"public_key": "ab" * 32, "out_path_len": 0, "out_path": ""}
handler._get_contact_by_prefix = lambda prefix: contact
frames = []
async def radio(data):
data = bytes(data)
frames.append(data[0])
if data[0] == 0x02: # CMD_SEND_TXT_MSG: accepted, but never ACKed
await dispatcher.dispatch(Event(
EventType.MSG_SENT,
{"type": 0, "expected_ack": b"\x01\x02\x03\x04", "suggested_timeout": 1000},
))
else:
await dispatcher.dispatch(Event(EventType.OK, {}))
handler._sender_func = radio
_serialize_command_frames(bot, handler)
dm = asyncio.create_task(handler.send_msg_with_retry(contact, "hi", max_attempts=1))
await asyncio.sleep(0.1) # DM sent, waiting for its ACK
start = time.monotonic()
result = await asyncio.wait_for(handler.send(b"\x14", [EventType.OK]), timeout=0.5)
assert result.type == EventType.OK
assert time.monotonic() - start < 0.5
assert not dm.done()
assert await dm is None # no ACK arrived
assert frames == [0x02, 0x14]
finally:
await dispatcher.stop()
async def test_radio_session_keeps_a_sequence_together(tmp_path):
"""Frames from another task wait until the session ends; frames inside it don't deadlock."""
bot = _make_bot(tmp_path, min_interval_ms=0)
fake = FakeCommands()
_serialize_command_frames(bot, fake)
async def other_sender():
await asyncio.sleep(0.01)
await fake.send(b"other", delay=0.0)
other = asyncio.create_task(other_sender())
async with bot.radio_session():
await fake.send(b"set-scope")
await asyncio.sleep(0.05) # the other task tries to send here
await fake.send(b"send")
await fake.send(b"restore")
await other
assert fake.frames == [b"set-scope", b"send", b"restore", b"other"]
async def test_radio_session_is_reentrant(tmp_path):
bot = _make_bot(tmp_path, min_interval_ms=0)
fake = FakeCommands()
_serialize_command_frames(bot, fake)
async def nested():
async with bot.radio_session():
async with bot.radio_session():
await fake.send(b"\x01")
await asyncio.wait_for(nested(), timeout=1.0)
assert fake.frames == [b"\x01"]
async def test_install_command_serializer_wraps_and_is_idempotent(tmp_path):
bot = _make_bot(tmp_path, min_interval_ms=0)
bot.meshcore = FakeMeshcore()
cmds = bot.meshcore.commands
bot._install_command_serializer()
wrapped = cmds.send
assert getattr(wrapped, "_radio_serialized", False)
assert bot.meshcore.commands is cmds # wrapped in place
# Re-installing must not double-wrap (a nested non-reentrant lock would deadlock).
bot._install_command_serializer()
assert cmds.send is wrapped
assert await asyncio.wait_for(cmds.send(b"\x01"), timeout=1.0) == "ok"
def test_install_command_serializer_noop_without_meshcore(tmp_path):
bot = _make_bot(tmp_path)
bot.meshcore = None
bot._install_command_serializer() # must not raise
assert bot.meshcore is None