Better behavior and message tracking around repeater contact on a busy mesh

This commit is contained in:
Jack Kingsman
2026-02-23 15:56:45 -08:00
parent 9193d113fe
commit 88d5a76081
2 changed files with 418 additions and 68 deletions
+297 -10
View File
@@ -9,10 +9,19 @@ from meshcore import EventType
from app.database import Database
from app.models import CommandRequest, TelemetryRequest
from app.repository import ContactRepository
from app.routers.contacts import request_telemetry, request_trace, send_repeater_command
from app.routers.contacts import (
_fetch_repeater_response,
request_telemetry,
request_trace,
send_repeater_command,
)
KEY_A = "aa" * 32
# Patch target for the wall-clock wrapper used by _fetch_repeater_response.
# We patch _monotonic (not time.monotonic) to avoid breaking the asyncio event loop.
_MONOTONIC = "app.routers.contacts._monotonic"
@pytest.fixture
async def test_db():
@@ -75,6 +84,181 @@ def _mock_mc():
return mc
def _advancing_clock(start=0.0, step=0.1):
"""Return a callable for _monotonic that advances by `step` each call."""
t = start
def _tick():
nonlocal t
val = t
t += step
return val
return _tick
class TestFetchRepeaterResponse:
"""Tests for the _fetch_repeater_response helper."""
@pytest.mark.asyncio
async def test_returns_matching_cli_response(self):
mc = _mock_mc()
mc.commands.get_msg = AsyncMock(
return_value=_radio_result(
EventType.CONTACT_MSG_RECV,
{"pubkey_prefix": "aaaaaaaaaaaa", "text": "ok", "txt_type": 1},
)
)
with patch(_MONOTONIC, side_effect=_advancing_clock()):
result = await _fetch_repeater_response(mc, "aaaaaaaaaaaa", timeout=5.0)
assert result is not None
assert result.payload["text"] == "ok"
mc.commands.get_msg.assert_awaited_once()
@pytest.mark.asyncio
async def test_rejects_same_sender_non_cli_message(self):
"""A txt_type=0 message from the target repeater is NOT accepted as the CLI response."""
mc = _mock_mc()
non_cli = _radio_result(
EventType.CONTACT_MSG_RECV,
{"pubkey_prefix": "aaaaaaaaaaaa", "text": "chat msg", "txt_type": 0},
)
cli_response = _radio_result(
EventType.CONTACT_MSG_RECV,
{"pubkey_prefix": "aaaaaaaaaaaa", "text": "ver 1.0", "txt_type": 1},
)
mc.commands.get_msg = AsyncMock(side_effect=[non_cli, cli_response])
with patch(_MONOTONIC, side_effect=_advancing_clock()):
result = await _fetch_repeater_response(mc, "aaaaaaaaaaaa", timeout=5.0)
assert result is not None
assert result.payload["text"] == "ver 1.0"
assert mc.commands.get_msg.await_count == 2
@pytest.mark.asyncio
async def test_unrelated_dm_is_skipped(self):
"""Unrelated DMs are skipped (dispatcher already handled them)."""
mc = _mock_mc()
unrelated = _radio_result(
EventType.CONTACT_MSG_RECV,
{"pubkey_prefix": "bbbbbbbbbbbb", "text": "hello", "txt_type": 0},
)
expected = _radio_result(
EventType.CONTACT_MSG_RECV,
{"pubkey_prefix": "aaaaaaaaaaaa", "text": "ver 1.0", "txt_type": 1},
)
mc.commands.get_msg = AsyncMock(side_effect=[unrelated, expected])
with patch(_MONOTONIC, side_effect=_advancing_clock()):
result = await _fetch_repeater_response(mc, "aaaaaaaaaaaa", timeout=5.0)
assert result is not None
assert result.payload["text"] == "ver 1.0"
@pytest.mark.asyncio
async def test_channel_message_is_skipped(self):
mc = _mock_mc()
channel_msg = _radio_result(
EventType.CHANNEL_MSG_RECV,
{"channel_idx": 0, "text": "flood msg"},
)
expected = _radio_result(
EventType.CONTACT_MSG_RECV,
{"pubkey_prefix": "aaaaaaaaaaaa", "text": "ok", "txt_type": 1},
)
mc.commands.get_msg = AsyncMock(side_effect=[channel_msg, expected])
with patch(_MONOTONIC, side_effect=_advancing_clock()):
result = await _fetch_repeater_response(mc, "aaaaaaaaaaaa", timeout=5.0)
assert result is not None
assert result.payload["text"] == "ok"
@pytest.mark.asyncio
async def test_no_more_msgs_retries_then_succeeds(self):
mc = _mock_mc()
no_msgs = _radio_result(EventType.NO_MORE_MSGS)
expected = _radio_result(
EventType.CONTACT_MSG_RECV,
{"pubkey_prefix": "aaaaaaaaaaaa", "text": "ok", "txt_type": 1},
)
mc.commands.get_msg = AsyncMock(side_effect=[no_msgs, expected])
with (
patch(_MONOTONIC, side_effect=_advancing_clock()),
patch("app.routers.contacts.asyncio.sleep", new_callable=AsyncMock),
):
result = await _fetch_repeater_response(mc, "aaaaaaaaaaaa", timeout=5.0)
assert result is not None
assert result.payload["text"] == "ok"
assert mc.commands.get_msg.await_count == 2
@pytest.mark.asyncio
async def test_returns_none_after_deadline(self):
"""Returns None when wall-clock deadline expires."""
mc = _mock_mc()
mc.commands.get_msg = AsyncMock(return_value=_radio_result(EventType.NO_MORE_MSGS))
# Start at 100.0, jump past deadline (timeout=2.0) after 2 get_msg calls
times = iter([100.0, 100.5, 101.0, 103.0])
with (
patch(_MONOTONIC, side_effect=times),
patch("app.routers.contacts.asyncio.sleep", new_callable=AsyncMock),
):
result = await _fetch_repeater_response(mc, "aaaaaaaaaaaa", timeout=2.0)
assert result is None
@pytest.mark.asyncio
async def test_error_retries_then_succeeds(self):
mc = _mock_mc()
error = _radio_result(EventType.ERROR, {"err": "busy"})
expected = _radio_result(
EventType.CONTACT_MSG_RECV,
{"pubkey_prefix": "aaaaaaaaaaaa", "text": "ok", "txt_type": 1},
)
mc.commands.get_msg = AsyncMock(side_effect=[error, expected])
with (
patch(_MONOTONIC, side_effect=_advancing_clock()),
patch("app.routers.contacts.asyncio.sleep", new_callable=AsyncMock),
):
result = await _fetch_repeater_response(mc, "aaaaaaaaaaaa", timeout=5.0)
assert result is not None
assert result.payload["text"] == "ok"
@pytest.mark.asyncio
async def test_high_traffic_does_not_exhaust_budget(self):
"""Many unrelated messages don't prevent eventual success (wall-clock deadline)."""
mc = _mock_mc()
# 20 unrelated DMs followed by the expected CLI response
unrelated = [
_radio_result(
EventType.CONTACT_MSG_RECV,
{"pubkey_prefix": f"{i:012x}", "text": f"msg {i}", "txt_type": 0},
)
for i in range(20)
]
expected = _radio_result(
EventType.CONTACT_MSG_RECV,
{"pubkey_prefix": "aaaaaaaaaaaa", "text": "ver 1.0", "txt_type": 1},
)
mc.commands.get_msg = AsyncMock(side_effect=[*unrelated, expected])
with patch(_MONOTONIC, side_effect=_advancing_clock()):
result = await _fetch_repeater_response(mc, "aaaaaaaaaaaa", timeout=30.0)
assert result is not None
assert result.payload["text"] == "ver 1.0"
assert mc.commands.get_msg.await_count == 21
class TestTelemetryRoute:
@pytest.mark.asyncio
async def test_returns_404_when_contact_missing(self, test_db):
@@ -133,7 +317,15 @@ class TestTelemetryRoute:
)
mc.commands.req_acl_sync = AsyncMock(return_value=[{"key": "def456abc123", "perm": 2}])
mc.commands.send_cmd = AsyncMock(return_value=_radio_result(EventType.OK))
mc.wait_for_event = AsyncMock(side_effect=[None, None]) # two clock attempts, no response
# Clock fetch uses _fetch_repeater_response which calls get_msg() directly.
# Return NO_MORE_MSGS to simulate no clock response.
mc.commands.get_msg = AsyncMock(return_value=_radio_result(EventType.NO_MORE_MSGS))
# Clock is attempted twice, each with timeout=10.0. Provide enough ticks
# for the deadline to expire on each attempt.
clock_ticks = []
for base in (0.0, 100.0):
clock_ticks.extend([base, base + 5.0, base + 11.0])
with (
patch("app.routers.contacts.require_connected", return_value=mc),
@@ -141,6 +333,8 @@ class TestTelemetryRoute:
"app.routers.contacts.prepare_repeater_connection",
new_callable=AsyncMock,
) as mock_prepare,
patch(_MONOTONIC, side_effect=clock_ticks),
patch("app.routers.contacts.asyncio.sleep", new_callable=AsyncMock),
):
response = await request_telemetry(KEY_A, TelemetryRequest(password="pw"))
@@ -174,9 +368,14 @@ class TestRepeaterCommandRoute:
mc = _mock_mc()
await _insert_contact(KEY_A, name="Repeater", contact_type=2)
mc.commands.send_cmd = AsyncMock(return_value=_radio_result(EventType.OK))
mc.wait_for_event = AsyncMock(return_value=None)
mc.commands.get_msg = AsyncMock(return_value=_radio_result(EventType.NO_MORE_MSGS))
with patch("app.routers.contacts.require_connected", return_value=mc):
# Expire the deadline after a couple of ticks
with (
patch("app.routers.contacts.require_connected", return_value=mc),
patch(_MONOTONIC, side_effect=[0.0, 5.0, 25.0]),
patch("app.routers.contacts.asyncio.sleep", new_callable=AsyncMock),
):
response = await send_repeater_command(KEY_A, CommandRequest(command="ver"))
assert response.command == "ver"
@@ -188,15 +387,22 @@ class TestRepeaterCommandRoute:
mc = _mock_mc()
await _insert_contact(KEY_A, name="Repeater", contact_type=2)
mc.commands.send_cmd = AsyncMock(return_value=_radio_result(EventType.OK))
mc.wait_for_event = AsyncMock(return_value=MagicMock())
mc.commands.get_msg = AsyncMock(
return_value=_radio_result(
EventType.CONTACT_MSG_RECV,
{"text": "firmware: v1.2.3", "sender_timestamp": 1700000000},
{
"pubkey_prefix": KEY_A[:12],
"text": "firmware: v1.2.3",
"sender_timestamp": 1700000000,
"txt_type": 1,
},
)
)
with patch("app.routers.contacts.require_connected", return_value=mc):
with (
patch("app.routers.contacts.require_connected", return_value=mc),
patch(_MONOTONIC, side_effect=_advancing_clock()),
):
response = await send_repeater_command(KEY_A, CommandRequest(command="ver"))
assert response.command == "ver"
@@ -208,21 +414,102 @@ class TestRepeaterCommandRoute:
mc = _mock_mc()
await _insert_contact(KEY_A, name="Repeater", contact_type=2)
mc.commands.send_cmd = AsyncMock(return_value=_radio_result(EventType.OK))
mc.wait_for_event = AsyncMock(return_value=MagicMock())
mc.commands.get_msg = AsyncMock(
return_value=_radio_result(
EventType.CONTACT_MSG_RECV,
{"text": "firmware: v1.2.3", "timestamp": 1700000000},
{
"pubkey_prefix": KEY_A[:12],
"text": "firmware: v1.2.3",
"timestamp": 1700000000,
"txt_type": 1,
},
)
)
with patch("app.routers.contacts.require_connected", return_value=mc):
with (
patch("app.routers.contacts.require_connected", return_value=mc),
patch(_MONOTONIC, side_effect=_advancing_clock()),
):
response = await send_repeater_command(KEY_A, CommandRequest(command="ver"))
assert response.command == "ver"
assert response.response == "firmware: v1.2.3"
assert response.sender_timestamp == 1700000000
@pytest.mark.asyncio
async def test_unrelated_dm_during_command_does_not_prevent_success(self, test_db):
"""Unrelated DMs arriving during command wait are skipped; correct response returned."""
mc = _mock_mc()
await _insert_contact(KEY_A, name="Repeater", contact_type=2)
mc.commands.send_cmd = AsyncMock(return_value=_radio_result(EventType.OK))
unrelated = _radio_result(
EventType.CONTACT_MSG_RECV,
{"pubkey_prefix": "bbbbbbbbbbbb", "text": "hello from someone", "txt_type": 0},
)
expected = _radio_result(
EventType.CONTACT_MSG_RECV,
{"pubkey_prefix": KEY_A[:12], "text": "ver 1.0", "txt_type": 1},
)
mc.commands.get_msg = AsyncMock(side_effect=[unrelated, expected])
with (
patch("app.routers.contacts.require_connected", return_value=mc),
patch(_MONOTONIC, side_effect=_advancing_clock()),
):
response = await send_repeater_command(KEY_A, CommandRequest(command="ver"))
assert response.command == "ver"
assert response.response == "ver 1.0"
@pytest.mark.asyncio
async def test_channel_message_during_command_is_skipped(self, test_db):
mc = _mock_mc()
await _insert_contact(KEY_A, name="Repeater", contact_type=2)
mc.commands.send_cmd = AsyncMock(return_value=_radio_result(EventType.OK))
channel_msg = _radio_result(
EventType.CHANNEL_MSG_RECV,
{"channel_idx": 0, "text": "flood msg"},
)
expected = _radio_result(
EventType.CONTACT_MSG_RECV,
{"pubkey_prefix": KEY_A[:12], "text": "ok", "txt_type": 1},
)
mc.commands.get_msg = AsyncMock(side_effect=[channel_msg, expected])
with (
patch("app.routers.contacts.require_connected", return_value=mc),
patch(_MONOTONIC, side_effect=_advancing_clock()),
):
response = await send_repeater_command(KEY_A, CommandRequest(command="ver"))
assert response.command == "ver"
assert response.response == "ok"
@pytest.mark.asyncio
async def test_no_more_msgs_then_response_succeeds(self, test_db):
mc = _mock_mc()
await _insert_contact(KEY_A, name="Repeater", contact_type=2)
mc.commands.send_cmd = AsyncMock(return_value=_radio_result(EventType.OK))
no_msgs = _radio_result(EventType.NO_MORE_MSGS)
expected = _radio_result(
EventType.CONTACT_MSG_RECV,
{"pubkey_prefix": KEY_A[:12], "text": "done", "txt_type": 1},
)
mc.commands.get_msg = AsyncMock(side_effect=[no_msgs, expected])
with (
patch("app.routers.contacts.require_connected", return_value=mc),
patch(_MONOTONIC, side_effect=_advancing_clock()),
patch("app.routers.contacts.asyncio.sleep", new_callable=AsyncMock),
):
response = await send_repeater_command(KEY_A, CommandRequest(command="ver"))
assert response.command == "ver"
assert response.response == "done"
class TestTraceRoute:
@pytest.mark.asyncio