Files
MeshTerm/tests/test_connection_channels.py
T
Jean-Pierre MartineauandClaude Opus 5.5 326f6f5479 Another command's refusal no longer ends the channel list early
The meshcore library does not serialize commands, and every command
waiting for "OK or ERROR" takes the first ERROR to arrive. At connect the
channel prewarm runs beside the clock sync, which this companion refuses
as malformed; a slot read handed that refusal took it for "no such slot",
ended the probe there, and cached a list missing every channel after it
for the rest of the session.

Only the firmware's NOT_FOUND (or a code-less refusal from old firmware)
now ends the list. Any other refusal is asked again once, and a second
one, or no answer at all, is a failed read the probe reports as
unfinished and does not cache.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 22:48:08 -04:00

176 lines
6.5 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""Regression tests for serialized, index-validated channel reads.
The meshcore library's ``get_channel`` waits for "the next CHANNEL_INFO event" with no
correlation to the slot it asked for, and the dispatcher fans that event to every in-flight
waiter. Two concurrent reads therefore both resolve on the first response and one caller
silently gets the other's channel — which misfiles that channel's messages. :class:`MeshCoreDevice`
guards against this by serializing reads and verifying the response is for the slot requested.
"""
from __future__ import annotations
import asyncio
import pytest
from meshterm.core.connection import DeviceCommandError, MeshCoreDevice
class _Event:
"""Minimal stand-in for a meshcore CHANNEL_INFO event."""
def __init__(self, payload: dict) -> None:
self.payload = payload
def is_error(self) -> bool:
return False
class _CrosstalkCommands:
"""Fake ``commands`` that reproduces the uncorrelated-response race if reads overlap.
Each call records itself as in-flight and yields control. If a second read starts while
the first is still outstanding, both return the payload of whichever finishes first —
exactly the library behavior that misroutes messages. Serialized callers never overlap, so
each gets its own channel back.
"""
def __init__(self) -> None:
self.in_flight: list[int] = []
self.peak_concurrency = 0
async def get_channel(self, index: int) -> _Event:
self.in_flight.append(index)
self.peak_concurrency = max(self.peak_concurrency, len(self.in_flight))
try:
await asyncio.sleep(0) # a real await point where interleaving would happen
# Model "the first response resolves everyone": the crossed answer is the
# oldest in-flight request, not necessarily this one.
served = self.in_flight[0]
return _Event(
{
"channel_idx": served,
"channel_name": f"chan{served}",
"channel_secret": bytes([served]) * 16,
}
)
finally:
self.in_flight.remove(index)
class _MismatchCommands:
"""Fake ``commands`` that always answers with the wrong slot index."""
async def get_channel(self, index: int) -> _Event:
wrong = index + 1
return _Event(
{
"channel_idx": wrong,
"channel_name": f"chan{wrong}",
"channel_secret": bytes([wrong]) * 16,
}
)
def _device_with(commands: object) -> MeshCoreDevice:
device = MeshCoreDevice("COM-TEST")
device._mc = type("_MC", (), {"commands": commands})()
return device
async def test_get_channel_serializes_concurrent_reads() -> None:
"""Concurrent reads never overlap, so each returns its own slot (no cross-talk)."""
commands = _CrosstalkCommands()
device = _device_with(commands)
results = await asyncio.gather(*(device.get_channel(i) for i in range(8)))
assert commands.peak_concurrency == 1
for i, payload in enumerate(results):
assert payload is not None
assert payload["channel_idx"] == i
assert payload["channel_name"] == f"chan{i}"
async def test_get_channel_rejects_response_for_a_different_slot() -> None:
"""A response whose index doesn't match the request is refused, never returned as-is."""
device = _device_with(_MismatchCommands())
with pytest.raises(DeviceCommandError, match="returned slot"):
await device.get_channel(0)
class _Refusal:
"""A meshcore ERROR event: a refusal with a companion error code, or a timeout's."""
def __init__(self, payload: dict) -> None:
self.payload = payload
def is_error(self) -> bool:
return True
class _ScriptedCommands:
"""Fake ``commands`` answering each slot from a queue of scripted replies.
``replies[i]`` is a list of what successive reads of slot ``i`` get: a channel name, or
an error payload. A slot with no script is past the end — the firmware's NOT_FOUND.
"""
def __init__(self, replies: dict[int, list]) -> None:
self.replies = {i: list(r) for i, r in replies.items()}
self.reads: list[int] = []
async def get_channel(self, index: int): # noqa: ANN201
self.reads.append(index)
queue = self.replies.get(index)
reply = queue.pop(0) if queue else {"error_code": 2}
if isinstance(reply, dict):
return _Refusal(reply)
return _Event({"channel_idx": index, "channel_name": reply, "channel_secret": bytes(16)})
#: What the clock sync's refusal looks like when a concurrent channel read is handed it.
_MALFORMED = {"error_code": 6}
async def test_a_borrowed_refusal_does_not_end_the_slot_probe() -> None:
"""Another command's error, delivered to a slot read, is asked past — not a list's end.
The library hands every waiter the first ERROR to arrive, so at connect the channel
prewarm could take the clock sync's "malformed" as slot 2 being past the end, and cache
a list missing every channel after it.
"""
from meshterm.core.channel_probe import probe_channel_slots
commands = _ScriptedCommands({0: ["Public"], 1: ["ops"], 2: [_MALFORMED, "#montreal"]})
slots, complete = await probe_channel_slots(_device_with(commands))
assert [s.name for s in slots] == ["Public", "ops", "#montreal"]
assert complete
assert commands.reads.count(2) == 2
async def test_a_refusal_that_repeats_is_a_failed_read_not_a_layout() -> None:
"""Two refusals in a row that aren't NOT_FOUND leave the probe unfinished (not cached)."""
from meshterm.core.channel_probe import probe_channel_slots
commands = _ScriptedCommands({0: ["Public"], 1: [_MALFORMED, _MALFORMED], 2: ["ops"]})
slots, complete = await probe_channel_slots(_device_with(commands))
assert [s.name for s in slots] == ["Public"]
assert not complete
async def test_no_answer_is_a_failed_read_and_not_found_is_the_end() -> None:
"""A timeout never reads as the end of the slots; the firmware's NOT_FOUND does."""
from meshterm.core.channel_probe import probe_channel_slots
timeout = {"reason": "no_event_received"}
slots, complete = await probe_channel_slots(
_device_with(_ScriptedCommands({0: ["Public"], 1: [timeout]}))
)
assert [s.name for s in slots] == ["Public"] and not complete
slots, complete = await probe_channel_slots(_device_with(_ScriptedCommands({0: ["Public"]})))
assert [s.name for s in slots] == ["Public"] and complete