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>
176 lines
6.5 KiB
Python
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
|