Do some utterly disgusting MC library munging to deal with contacts coming out of sync

This commit is contained in:
Jack Kingsman
2026-02-24 20:41:21 -08:00
parent 71359e437f
commit 81c166bb8d
2 changed files with 97 additions and 0 deletions

View File

@@ -116,6 +116,30 @@ async def sync_and_offload_contacts(mc: MeshCore) -> dict:
remove_result = await mc.commands.remove_contact(contact_data)
if remove_result.type == EventType.OK:
removed += 1
# LIBRARY INTERNAL FIXUP: The MeshCore library's
# commands.remove_contact() sends the remove command over
# the wire but does NOT update the library's in-memory
# contact cache (mc._contacts). This is a gap in the
# library — there's no public API to clear a single
# contact from the cache, and the library only refreshes
# it on a full get_contacts() call.
#
# Why this matters: sync_recent_contacts_to_radio() uses
# mc.get_contact_by_key_prefix() to check whether a
# contact is already loaded on the radio. That method
# searches mc._contacts. If we don't evict the removed
# contact from the cache here, get_contact_by_key_prefix()
# will still find it and skip the add_contact() call —
# meaning contacts never get loaded back onto the radio
# after offload. The result: no DM ACKs, degraded routing
# for potentially minutes until the next periodic sync
# refreshes the cache from the (now-empty) radio.
#
# We access mc._contacts directly because the library
# exposes it as a read-only property (mc.contacts) with
# no removal API. The dict is keyed by public_key string.
mc._contacts.pop(public_key, None)
else:
logger.warning(
"Failed to remove contact %s: %s", public_key[:12], remove_result.payload

View File

@@ -555,6 +555,79 @@ class TestSyncAndOffloadContacts:
assert contact is not None
assert contact.on_radio is False
@pytest.mark.asyncio
async def test_evicts_removed_contacts_from_library_cache(self, test_db):
"""Successfully removed contacts are evicted from mc._contacts.
The MeshCore library's remove_contact() command does not update the
library's in-memory _contacts cache. If we don't evict manually,
sync_recent_contacts_to_radio() will find stale entries via
get_contact_by_key_prefix() and skip re-adding contacts to the radio.
"""
from app.radio_sync import sync_and_offload_contacts
contact_payload = {
KEY_A: {"adv_name": "Alice", "type": 1, "flags": 0},
KEY_B: {"adv_name": "Bob", "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)
# Seed the library's in-memory cache with the same contacts —
# simulating what happens after get_contacts() populates it.
mock_mc._contacts = {
KEY_A: {"public_key": KEY_A, "adv_name": "Alice"},
KEY_B: {"public_key": KEY_B, "adv_name": "Bob"},
}
await sync_and_offload_contacts(mock_mc)
# Both contacts should have been evicted from the library cache
assert KEY_A not in mock_mc._contacts
assert KEY_B not in mock_mc._contacts
assert mock_mc._contacts == {}
@pytest.mark.asyncio
async def test_failed_remove_does_not_evict_from_library_cache(self, test_db):
"""Contacts that fail to remove from radio stay in mc._contacts.
We only evict from the cache on successful removal — if the radio
still has the contact, the cache should reflect that.
"""
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_fail_result = MagicMock()
mock_fail_result.type = EventType.ERROR
mock_fail_result.payload = {"error": "busy"}
mock_mc = MagicMock()
mock_mc.commands.get_contacts = AsyncMock(return_value=mock_get_result)
mock_mc.commands.remove_contact = AsyncMock(return_value=mock_fail_result)
mock_mc._contacts = {
KEY_A: {"public_key": KEY_A, "adv_name": "Alice"},
}
await sync_and_offload_contacts(mock_mc)
# Contact should still be in the cache since removal failed
assert KEY_A in mock_mc._contacts
class TestSyncAndOffloadChannels:
"""Test sync_and_offload_channels: pull channels from radio, save to DB, clear from radio."""