mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-10-02 20:37:59 +00:00
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.
231 lines
7.5 KiB
Python
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
|