Drop frequency of contact sync task, make standard polling opt-in only

This commit is contained in:
Jack Kingsman
2026-03-10 14:04:51 -07:00
parent eaee66f836
commit 97997e23e8
22 changed files with 571 additions and 93 deletions
+145
View File
@@ -273,6 +273,33 @@ class TestConnectionMonitor:
mock_broadcast.assert_any_call(True, "TCP: test:4000")
assert rm._setup_complete is True
@pytest.mark.asyncio
async def test_monitor_does_not_retry_while_setup_already_running(self):
"""Monitor leaves an in-progress setup alone instead of queueing another one."""
from app.radio import RadioManager
rm = RadioManager()
mock_mc = MagicMock()
mock_mc.is_connected = True
rm._meshcore = mock_mc
rm._connection_info = "TCP: test:4000"
rm._last_connected = True
rm._setup_complete = False
rm._setup_in_progress = True
rm.post_connect_setup = AsyncMock()
async def _sleep(_seconds: float):
raise asyncio.CancelledError()
with patch("app.radio.asyncio.sleep", side_effect=_sleep):
await rm.start_connection_monitor()
try:
await rm._reconnect_task
finally:
await rm.stop_connection_monitor()
rm.post_connect_setup.assert_not_called()
class TestReconnectLock:
"""Tests for reconnect() lock serialization — no duplicate reconnections."""
@@ -722,3 +749,121 @@ class TestPostConnectSetupOrdering:
await rm.post_connect_setup()
mock_mc.commands.set_flood_scope.assert_awaited_once_with("")
@pytest.mark.asyncio
async def test_message_polling_disabled_by_default(self):
"""Post-connect setup does not start fallback polling unless explicitly enabled."""
from app.models import AppSettings
from app.radio import RadioManager
rm = RadioManager()
mock_mc = MagicMock()
mock_mc.start_auto_message_fetching = AsyncMock()
mock_mc.commands.set_flood_scope = AsyncMock()
rm._meshcore = mock_mc
with (
patch("app.event_handlers.register_event_handlers"),
patch("app.keystore.export_and_store_private_key", new_callable=AsyncMock),
patch("app.radio_sync.sync_radio_time", new_callable=AsyncMock),
patch(
"app.repository.AppSettingsRepository.get",
new_callable=AsyncMock,
return_value=AppSettings(),
),
patch("app.radio_sync.sync_and_offload_all", new_callable=AsyncMock, return_value={}),
patch("app.radio_sync.start_periodic_sync"),
patch("app.radio_sync.send_advertisement", new_callable=AsyncMock, return_value=False),
patch("app.radio_sync.start_periodic_advert"),
patch("app.radio_sync.drain_pending_messages", new_callable=AsyncMock, return_value=0),
patch("app.radio_sync.start_message_polling") as mock_start_message_polling,
):
await rm.post_connect_setup()
mock_start_message_polling.assert_not_called()
@pytest.mark.asyncio
async def test_message_polling_starts_when_env_flag_enabled(self):
"""Post-connect setup starts fallback polling when the env-backed setting is enabled."""
from app.models import AppSettings
from app.radio import RadioManager
rm = RadioManager()
mock_mc = MagicMock()
mock_mc.start_auto_message_fetching = AsyncMock()
mock_mc.commands.set_flood_scope = AsyncMock()
rm._meshcore = mock_mc
with (
patch("app.event_handlers.register_event_handlers"),
patch("app.keystore.export_and_store_private_key", new_callable=AsyncMock),
patch("app.radio_sync.sync_radio_time", new_callable=AsyncMock),
patch(
"app.repository.AppSettingsRepository.get",
new_callable=AsyncMock,
return_value=AppSettings(),
),
patch("app.radio_sync.sync_and_offload_all", new_callable=AsyncMock, return_value={}),
patch("app.radio_sync.start_periodic_sync"),
patch("app.radio_sync.send_advertisement", new_callable=AsyncMock, return_value=False),
patch("app.radio_sync.start_periodic_advert"),
patch("app.radio_sync.drain_pending_messages", new_callable=AsyncMock, return_value=0),
patch("app.radio_sync.start_message_polling") as mock_start_message_polling,
patch("app.services.radio_lifecycle.settings.enable_message_poll_fallback", True),
):
await rm.post_connect_setup()
mock_start_message_polling.assert_called_once()
@pytest.mark.asyncio
async def test_prepare_connected_radio_retries_timeout_once_before_failing(self):
"""Hung post-connect setup gets one retry before surfacing an operator error."""
from app.radio import RadioManager
from app.services.radio_lifecycle import prepare_connected_radio
rm = RadioManager()
rm._connection_info = "Serial: /dev/ttyUSB0"
rm.post_connect_setup = AsyncMock(
side_effect=[asyncio.TimeoutError(), asyncio.TimeoutError()]
)
with (
patch("app.services.radio_lifecycle.logger") as mock_logger,
patch("app.websocket.broadcast_error") as mock_broadcast_error,
patch("app.websocket.broadcast_health") as mock_broadcast_health,
):
with pytest.raises(RuntimeError, match="Post-connect setup timed out"):
await prepare_connected_radio(rm, broadcast_on_success=True)
assert rm.post_connect_setup.await_count == 2
mock_logger.warning.assert_called_once()
mock_logger.error.assert_called_once()
mock_broadcast_error.assert_called_once_with(
"Radio startup appears stuck",
"Initial radio offload took too long. Reboot the radio and restart the server.",
)
mock_broadcast_health.assert_not_called()
@pytest.mark.asyncio
async def test_prepare_connected_radio_succeeds_on_retry_after_timeout(self):
"""A slow first attempt can time out once without failing the reconnect flow."""
from app.radio import RadioManager
from app.services.radio_lifecycle import prepare_connected_radio
rm = RadioManager()
rm._connection_info = "Serial: /dev/ttyUSB0"
rm.post_connect_setup = AsyncMock(side_effect=[asyncio.TimeoutError(), None])
with (
patch("app.services.radio_lifecycle.logger") as mock_logger,
patch("app.websocket.broadcast_error") as mock_broadcast_error,
patch("app.websocket.broadcast_health") as mock_broadcast_health,
):
await prepare_connected_radio(rm, broadcast_on_success=True)
assert rm.post_connect_setup.await_count == 2
assert rm._last_connected is True
mock_logger.warning.assert_called_once()
mock_logger.error.assert_not_called()
mock_broadcast_error.assert_not_called()
mock_broadcast_health.assert_called_once_with(True, "Serial: /dev/ttyUSB0")
+138 -15
View File
@@ -16,6 +16,7 @@ from app.radio_sync import (
_message_poll_loop,
_periodic_advert_loop,
_periodic_sync_loop,
ensure_contact_on_radio,
is_polling_paused,
pause_polling,
sync_radio_time,
@@ -180,10 +181,16 @@ class TestSyncRecentContactsToRadio:
"""Test the sync_recent_contacts_to_radio function."""
@pytest.mark.asyncio
async def test_loads_contacts_not_on_radio(self, test_db):
"""Contacts not on radio are added via add_contact."""
async def test_loads_favorite_contacts_not_on_radio(self, test_db):
"""Favorite contacts not on radio are added via add_contact."""
await _insert_contact(KEY_A, "Alice", last_contacted=2000)
await _insert_contact(KEY_B, "Bob", last_contacted=1000)
await AppSettingsRepository.update(
favorites=[
Favorite(type="contact", id=KEY_A),
Favorite(type="contact", id=KEY_B),
]
)
mock_mc = MagicMock()
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
@@ -202,16 +209,16 @@ class TestSyncRecentContactsToRadio:
assert bob.on_radio is True
@pytest.mark.asyncio
async def test_favorites_loaded_before_recent_contacts(self, test_db):
"""Favorite contacts are loaded first, then recents until limit."""
async def test_fills_remaining_slots_with_recently_contacted_then_advertised(self, test_db):
"""Fill order is favorites, then recent contacts, then recent adverts."""
await _insert_contact(KEY_A, "Alice", last_contacted=100)
await _insert_contact(KEY_B, "Bob", last_contacted=2000)
await _insert_contact("cc" * 32, "Carol", last_contacted=1000)
await _insert_contact("dd" * 32, "Dave", last_advert=3000)
await _insert_contact("ee" * 32, "Eve", last_advert=2500)
# Set max_radio_contacts=2 and add KEY_A as favorite
await AppSettingsRepository.update(
max_radio_contacts=2,
favorites=[Favorite(type="contact", id=KEY_A)],
max_radio_contacts=4, favorites=[Favorite(type="contact", id=KEY_A)]
)
mock_mc = MagicMock()
@@ -223,22 +230,45 @@ class TestSyncRecentContactsToRadio:
radio_manager._meshcore = mock_mc
result = await sync_recent_contacts_to_radio()
assert result["loaded"] == 2
# KEY_A (favorite) should be loaded first, then KEY_B (most recent)
assert result["loaded"] == 4
loaded_keys = [
call.args[0]["public_key"] for call in mock_mc.commands.add_contact.call_args_list
]
assert loaded_keys == [KEY_A, KEY_B]
assert loaded_keys == [KEY_A, KEY_B, "cc" * 32, "dd" * 32]
@pytest.mark.asyncio
async def test_favorite_contact_not_loaded_twice_if_also_recent(self, test_db):
"""A favorite contact that is also recent is loaded only once."""
async def test_advert_fill_skips_repeaters(self, test_db):
"""Recent advert fallback only considers non-repeaters."""
await _insert_contact(KEY_A, "Alice", last_advert=3000, contact_type=2)
await _insert_contact(KEY_B, "Bob", last_advert=2000, contact_type=1)
await AppSettingsRepository.update(max_radio_contacts=1, favorites=[])
mock_mc = MagicMock()
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
mock_result = MagicMock()
mock_result.type = EventType.OK
mock_mc.commands.add_contact = AsyncMock(return_value=mock_result)
radio_manager._meshcore = mock_mc
result = await sync_recent_contacts_to_radio()
assert result["loaded"] == 1
payload = mock_mc.commands.add_contact.call_args.args[0]
assert payload["public_key"] == KEY_B
@pytest.mark.asyncio
async def test_duplicate_favorite_not_loaded_twice(self, test_db):
"""Duplicate favorite entries still load the contact only once."""
await _insert_contact(KEY_A, "Alice", last_contacted=2000)
await _insert_contact(KEY_B, "Bob", last_contacted=1000)
await AppSettingsRepository.update(
max_radio_contacts=2,
favorites=[Favorite(type="contact", id=KEY_A)],
favorites=[
Favorite(type="contact", id=KEY_A),
Favorite(type="contact", id=KEY_A),
],
)
mock_mc = MagicMock()
@@ -260,6 +290,7 @@ class TestSyncRecentContactsToRadio:
async def test_skips_contacts_already_on_radio(self, test_db):
"""Contacts already on radio are counted but not re-added."""
await _insert_contact(KEY_A, "Alice", on_radio=True)
await AppSettingsRepository.update(favorites=[Favorite(type="contact", id=KEY_A)])
mock_mc = MagicMock()
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=MagicMock()) # Found
@@ -319,6 +350,7 @@ class TestSyncRecentContactsToRadio:
async def test_marks_on_radio_when_found_but_not_flagged(self, test_db):
"""Contact found on radio but not flagged gets set_on_radio(True)."""
await _insert_contact(KEY_A, "Alice", on_radio=False)
await AppSettingsRepository.update(favorites=[Favorite(type="contact", id=KEY_A)])
mock_mc = MagicMock()
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=MagicMock()) # Found
@@ -335,6 +367,7 @@ class TestSyncRecentContactsToRadio:
async def test_handles_add_failure(self, test_db):
"""Failed add_contact increments the failed counter."""
await _insert_contact(KEY_A, "Alice")
await AppSettingsRepository.update(favorites=[Favorite(type="contact", id=KEY_A)])
mock_mc = MagicMock()
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
@@ -360,6 +393,7 @@ class TestSyncRecentContactsToRadio:
last_path_len=2,
out_path_hash_mode=1,
)
await AppSettingsRepository.update(favorites=[Favorite(type="contact", id=KEY_A)])
mock_mc = MagicMock()
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
@@ -388,6 +422,7 @@ class TestSyncRecentContactsToRadio:
last_path_len=-125,
out_path_hash_mode=2,
)
await AppSettingsRepository.update(favorites=[Favorite(type="contact", id=KEY_A)])
mock_mc = MagicMock()
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
@@ -412,6 +447,7 @@ class TestSyncRecentContactsToRadio:
so it passes mc directly to avoid deadlock (asyncio.Lock is not reentrant).
"""
await _insert_contact(KEY_A, "Alice", last_contacted=2000)
await AppSettingsRepository.update(favorites=[Favorite(type="contact", id=KEY_A)])
mock_mc = MagicMock()
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
@@ -451,6 +487,7 @@ class TestSyncRecentContactsToRadio:
"""If _meshcore is swapped between pre-check and lock acquisition,
the function uses the new (post-lock) instance, not the stale one."""
await _insert_contact(KEY_A, "Alice", last_contacted=2000)
await AppSettingsRepository.update(favorites=[Favorite(type="contact", id=KEY_A)])
old_mc = MagicMock(name="old_mc")
new_mc = MagicMock(name="new_mc")
@@ -471,6 +508,26 @@ class TestSyncRecentContactsToRadio:
new_mc.commands.add_contact.assert_called_once()
old_mc.commands.add_contact.assert_not_called()
@pytest.mark.asyncio
async def test_ensure_contact_on_radio_loads_single_contact_even_when_not_favorited(
self, test_db
):
"""Targeted sync loads one contact without needing it in favorites."""
await _insert_contact(KEY_A, "Alice", last_contacted=2000)
mock_mc = MagicMock()
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
mock_result = MagicMock()
mock_result.type = EventType.OK
mock_mc.commands.add_contact = AsyncMock(return_value=mock_result)
radio_manager._meshcore = mock_mc
result = await ensure_contact_on_radio(KEY_A, force=True)
assert result["loaded"] == 1
payload = mock_mc.commands.add_contact.call_args.args[0]
assert payload["public_key"] == KEY_A
class TestSyncAndOffloadContacts:
"""Test sync_and_offload_contacts: pull contacts from radio, save to DB, remove from radio."""
@@ -511,7 +568,7 @@ class TestSyncAndOffloadContacts:
@pytest.mark.asyncio
async def test_claims_prefix_messages_for_each_contact(self, test_db):
"""claim_prefix_messages is called for each synced contact."""
"""Prefix message claims still complete via scheduled reconciliation tasks."""
from app.radio_sync import sync_and_offload_contacts
# Pre-insert a message with a prefix key that matches KEY_A
@@ -536,13 +593,73 @@ class TestSyncAndOffloadContacts:
mock_mc.commands.get_contacts = AsyncMock(return_value=mock_get_result)
mock_mc.commands.remove_contact = AsyncMock(return_value=mock_remove_result)
await sync_and_offload_contacts(mock_mc)
created_tasks: list[asyncio.Task] = []
real_create_task = asyncio.create_task
def _capture_task(coro):
task = real_create_task(coro)
created_tasks.append(task)
return task
with patch("app.radio_sync.asyncio.create_task", side_effect=_capture_task):
await sync_and_offload_contacts(mock_mc)
await asyncio.gather(*created_tasks)
# Verify the prefix message was claimed (promoted to full key)
messages = await MessageRepository.get_all(conversation_key=KEY_A)
assert len(messages) == 1
assert messages[0].conversation_key == KEY_A.lower()
@pytest.mark.asyncio
async def test_reconciliation_does_not_block_contact_removal(self, test_db):
"""Slow reconciliation work is scheduled in background, not awaited inline."""
from app.radio_sync import sync_and_offload_contacts
contact_payload = {KEY_A: {"adv_name": "Alice", "type": 1, "flags": 0}}
mock_get_result = MagicMock()
mock_get_result.type = EventType.NEW_CONTACT
mock_get_result.payload = contact_payload
mock_remove_result = MagicMock()
mock_remove_result.type = EventType.OK
mock_mc = MagicMock()
mock_mc.commands.get_contacts = AsyncMock(return_value=mock_get_result)
mock_mc.commands.remove_contact = AsyncMock(return_value=mock_remove_result)
reconcile_started = asyncio.Event()
reconcile_release = asyncio.Event()
created_tasks: list[asyncio.Task] = []
real_create_task = asyncio.create_task
async def _slow_reconcile(*, public_key: str, contact_name: str | None, log):
del public_key, contact_name, log
reconcile_started.set()
await reconcile_release.wait()
def _capture_task(coro):
task = real_create_task(coro)
created_tasks.append(task)
return task
with (
patch("app.radio_sync.reconcile_contact_messages", side_effect=_slow_reconcile),
patch("app.radio_sync.asyncio.create_task", side_effect=_capture_task),
):
result = await sync_and_offload_contacts(mock_mc)
await asyncio.sleep(0)
assert result["synced"] == 1
assert result["removed"] == 1
assert reconcile_started.is_set() is True
assert created_tasks and created_tasks[0].done() is False
mock_mc.commands.remove_contact.assert_awaited_once()
reconcile_release.set()
await asyncio.gather(*created_tasks)
@pytest.mark.asyncio
async def test_handles_remove_failure_gracefully(self, test_db):
"""Failed remove_contact logs warning but continues to next contact."""
@@ -1181,11 +1298,13 @@ class TestPeriodicSyncLoopRaces:
with (
patch("app.radio_sync.radio_manager", rm),
patch("asyncio.sleep", side_effect=mock_sleep),
patch("app.radio_sync.cleanup_expired_acks") as mock_cleanup,
patch("app.radio_sync.sync_and_offload_all", new_callable=AsyncMock) as mock_sync,
patch("app.radio_sync.sync_radio_time", new_callable=AsyncMock) as mock_time,
):
await _periodic_sync_loop()
mock_cleanup.assert_called_once()
mock_sync.assert_not_called()
mock_time.assert_not_called()
assert len(sleep_calls) == 2
@@ -1201,6 +1320,7 @@ class TestPeriodicSyncLoopRaces:
with (
patch("app.radio_sync.radio_manager", rm),
patch("asyncio.sleep", side_effect=mock_sleep),
patch("app.radio_sync.cleanup_expired_acks") as mock_cleanup,
patch("app.radio_sync.sync_and_offload_all", new_callable=AsyncMock) as mock_sync,
patch("app.radio_sync.sync_radio_time", new_callable=AsyncMock) as mock_time,
):
@@ -1208,6 +1328,7 @@ class TestPeriodicSyncLoopRaces:
finally:
lock.release()
mock_cleanup.assert_called_once()
mock_sync.assert_not_called()
mock_time.assert_not_called()
@@ -1222,10 +1343,12 @@ class TestPeriodicSyncLoopRaces:
with (
patch("app.radio_sync.radio_manager", rm),
patch("asyncio.sleep", side_effect=mock_sleep),
patch("app.radio_sync.cleanup_expired_acks") as mock_cleanup,
patch("app.radio_sync.sync_and_offload_all", new_callable=AsyncMock) as mock_sync,
patch("app.radio_sync.sync_radio_time", new_callable=AsyncMock) as mock_time,
):
await _periodic_sync_loop()
mock_cleanup.assert_called_once()
mock_sync.assert_called_once_with(mock_mc)
mock_time.assert_called_once_with(mock_mc)
+43
View File
@@ -525,6 +525,49 @@ class TestContactRepositoryResolvePrefixes:
assert result == {}
class TestContactRepositoryRecentQueries:
"""Test recent-contact selection helpers used for radio fill."""
@pytest.mark.asyncio
async def test_recently_advertised_includes_contacted_contacts(self, test_db):
stale_contacted_fresh_advert = "ab" * 32
advert_only = "cd" * 32
repeater = "ef" * 32
await ContactRepository.upsert(
{
"public_key": stale_contacted_fresh_advert,
"name": "SeenAgain",
"type": 1,
"last_contacted": 100,
"last_advert": 5000,
}
)
await ContactRepository.upsert(
{
"public_key": advert_only,
"name": "AdvertOnly",
"type": 1,
"last_advert": 4000,
}
)
await ContactRepository.upsert(
{
"public_key": repeater,
"name": "Repeater",
"type": 2,
"last_advert": 6000,
}
)
contacts = await ContactRepository.get_recently_advertised_non_repeaters()
assert [contact.public_key for contact in contacts] == [
stale_contacted_fresh_advert,
advert_only,
]
class TestAppSettingsRepository:
"""Test AppSettingsRepository parsing and migration edge cases."""
+3
View File
@@ -153,6 +153,9 @@ class TestOutgoingDMBroadcast:
assert contact_payload["out_path"] == "aa00bb00"
assert contact_payload["out_path_len"] == 2
assert contact_payload["out_path_hash_mode"] == 1
contact = await ContactRepository.get_by_key(pub_key)
assert contact is not None
assert contact.on_radio is True
@pytest.mark.asyncio
async def test_send_dm_prefers_route_override_over_learned_path(self, test_db):
+18 -4
View File
@@ -133,21 +133,35 @@ class TestToggleFavorite:
@pytest.mark.asyncio
async def test_adds_when_not_favorited(self, test_db):
request = FavoriteRequest(type="contact", id="aa" * 32)
result = await toggle_favorite(request)
with (
patch("app.radio_sync.ensure_contact_on_radio", new_callable=AsyncMock) as mock_sync,
patch("app.routers.settings.asyncio.create_task") as mock_create_task,
):
mock_create_task.side_effect = lambda coro: coro.close()
result = await toggle_favorite(request)
assert len(result.favorites) == 1
assert result.favorites[0].type == "contact"
assert result.favorites[0].id == "aa" * 32
mock_sync.assert_called_once_with("aa" * 32, force=True)
mock_create_task.assert_called_once()
@pytest.mark.asyncio
async def test_removes_when_already_favorited(self, test_db):
# Pre-add a favorite
await AppSettingsRepository.add_favorite("channel", "ABCD")
await AppSettingsRepository.add_favorite("contact", "aa" * 32)
request = FavoriteRequest(type="channel", id="ABCD")
result = await toggle_favorite(request)
request = FavoriteRequest(type="contact", id="aa" * 32)
with (
patch("app.radio_sync.ensure_contact_on_radio", new_callable=AsyncMock) as mock_sync,
patch("app.routers.settings.asyncio.create_task") as mock_create_task,
):
mock_create_task.side_effect = lambda coro: coro.close()
result = await toggle_favorite(request)
assert result.favorites == []
mock_sync.assert_not_called()
mock_create_task.assert_not_called()
class TestMigratePreferences: