Add radio lock acquire around missing spots, and validate

This commit is contained in:
Jack Kingsman
2026-02-16 19:10:20 -08:00
parent 8ca48cd6bc
commit e8ddba0131
5 changed files with 119 additions and 26 deletions
+8
View File
@@ -6,6 +6,7 @@ and add/remove from radio operations.
Uses httpx.AsyncClient with real in-memory SQLite database.
"""
from contextlib import asynccontextmanager
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
@@ -21,6 +22,11 @@ KEY_B = "bb" * 32 # bbbb...bb
KEY_C = "cc" * 32 # cccc...cc
@asynccontextmanager
async def _noop_radio_operation(*_args, **_kwargs):
yield
@pytest.fixture
async def test_db():
"""Create an in-memory test database with schema + migrations."""
@@ -219,6 +225,7 @@ class TestDeleteContact:
with patch("app.routers.contacts.radio_manager") as mock_rm:
mock_rm.is_connected = False
mock_rm.meshcore = None
mock_rm.radio_operation = _noop_radio_operation
response = await client.delete(f"/api/contacts/{KEY_A}")
@@ -248,6 +255,7 @@ class TestDeleteContact:
with patch("app.routers.contacts.radio_manager") as mock_rm:
mock_rm.is_connected = True
mock_rm.meshcore = mock_mc
mock_rm.radio_operation = _noop_radio_operation
response = await client.delete(f"/api/contacts/{KEY_A}")
+34
View File
@@ -48,6 +48,40 @@ class TestRadioOperationLock:
release.set()
await holder_task
@pytest.mark.asyncio
async def test_blocking_waits_and_acquires_after_release(self):
holder_entered = asyncio.Event()
holder_release = asyncio.Event()
contender_entered = asyncio.Event()
order: list[str] = []
async def holder():
async with radio_manager.radio_operation("holder"):
order.append("holder_enter")
holder_entered.set()
await holder_release.wait()
order.append("holder_exit")
async def contender():
await holder_entered.wait()
async with radio_manager.radio_operation("contender"):
order.append("contender_enter")
contender_entered.set()
holder_task = asyncio.create_task(holder())
contender_task = asyncio.create_task(contender())
await holder_entered.wait()
await asyncio.sleep(0.02)
assert not contender_entered.is_set()
holder_release.set()
await asyncio.wait_for(contender_entered.wait(), timeout=1.0)
await holder_task
await contender_task
assert order == ["holder_enter", "holder_exit", "contender_enter"]
@pytest.mark.asyncio
async def test_suspend_auto_fetch_stops_and_restarts(self):
mc = MagicMock()
+41
View File
@@ -1,5 +1,7 @@
"""Tests for radio router endpoint logic."""
import asyncio
from contextlib import asynccontextmanager
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@@ -18,6 +20,7 @@ from app.routers.radio import (
set_private_key,
update_radio_config,
)
from app.radio import RadioManager
def _radio_result(event_type=EventType.OK, payload=None):
@@ -27,6 +30,11 @@ def _radio_result(event_type=EventType.OK, payload=None):
return result
@asynccontextmanager
async def _noop_radio_operation(*_args, **_kwargs):
yield
def _mock_meshcore_with_info():
mc = MagicMock()
mc.self_info = {
@@ -147,6 +155,34 @@ class TestAdvertise:
assert exc.value.status_code == 500
@pytest.mark.asyncio
async def test_concurrent_advertise_calls_are_serialized(self):
active = 0
max_active = 0
async def fake_send(*, force: bool):
nonlocal active, max_active
assert force is True
active += 1
max_active = max(max_active, active)
await asyncio.sleep(0.05)
active -= 1
return True
isolated_manager = RadioManager()
with (
patch("app.routers.radio.require_connected"),
patch("app.routers.radio.radio_manager", isolated_manager),
patch(
"app.routers.radio.do_send_advertisement",
new_callable=AsyncMock,
side_effect=fake_send,
),
):
await asyncio.gather(send_advertisement(), send_advertisement())
assert max_active == 1
class TestRebootAndReconnect:
@pytest.mark.asyncio
@@ -155,6 +191,7 @@ class TestRebootAndReconnect:
mock_rm.is_connected = True
mock_rm.meshcore = MagicMock()
mock_rm.meshcore.commands.reboot = AsyncMock()
mock_rm.radio_operation = _noop_radio_operation
with patch("app.routers.radio.radio_manager", mock_rm):
result = await reboot_radio()
@@ -168,6 +205,7 @@ class TestRebootAndReconnect:
mock_rm.is_connected = False
mock_rm.meshcore = None
mock_rm.is_reconnecting = True
mock_rm.radio_operation = _noop_radio_operation
with patch("app.routers.radio.radio_manager", mock_rm):
result = await reboot_radio()
@@ -183,6 +221,7 @@ class TestRebootAndReconnect:
mock_rm.is_reconnecting = False
mock_rm.reconnect = AsyncMock(return_value=True)
mock_rm.post_connect_setup = AsyncMock()
mock_rm.radio_operation = _noop_radio_operation
with patch("app.routers.radio.radio_manager", mock_rm):
result = await reboot_radio()
@@ -196,6 +235,7 @@ class TestRebootAndReconnect:
async def test_reconnect_returns_already_connected(self):
mock_rm = MagicMock()
mock_rm.is_connected = True
mock_rm.radio_operation = _noop_radio_operation
with patch("app.routers.radio.radio_manager", mock_rm):
result = await reconnect_radio()
@@ -209,6 +249,7 @@ class TestRebootAndReconnect:
mock_rm.is_connected = False
mock_rm.is_reconnecting = False
mock_rm.reconnect = AsyncMock(return_value=False)
mock_rm.radio_operation = _noop_radio_operation
with patch("app.routers.radio.radio_manager", mock_rm):
with pytest.raises(HTTPException) as exc: