Patch up some vagaries and maintain best-effort loading.

This commit is contained in:
Jack Kingsman
2026-04-19 00:46:57 -07:00
parent c098f9eeb5
commit 09f807230b
3 changed files with 267 additions and 19 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ If the audit finds a mismatch, you'll see an error in the application UI and you
RemoteTerm loads favorite and recently active contacts onto the radio so that the radio can automatically acknowledge incoming DMs on your behalf. To do this, it first enumerates the radio's existing contact table, then reconciles it with the desired working set.
On BLE connections with many contacts (or radios with large contact tables from organic advertisements), the initial contact enumeration may take longer than the default timeout. If this happens, the app will automatically retry with an extended 60-second timeout. You may see a toast warning that the radio is temporarily unresponsive during this process.
On BLE connections with many contacts (or radios with large contact tables from organic advertisements), the initial contact enumeration may time out. If this happens, the app will still attempt to load your favorites and recent contacts onto the radio on a best-effort basis, but without a full snapshot of what's already on the radio, some adds may be redundant or fail.
If the radio's contact table is already full (from contacts added by advertisements or another client), the app may not be able to load all desired contacts. In this case you'll see a warning that auto-DM acking may not work for all contacts. To resolve this:
+36 -18
View File
@@ -482,30 +482,31 @@ async def sync_and_offload_all(mc: MeshCore) -> dict:
# Ensure default channels exist
await ensure_default_channels()
contact_reconcile_started = False
if "error" in contacts_result and not autoevict:
# Without confirmed autoevict support we cannot reconcile blindly.
logger.warning("Skipping background contact reconcile — could not enumerate radio contacts")
snapshot_failed = "error" in contacts_result
if snapshot_failed and not autoevict:
logger.warning(
"Radio contact snapshot failedattempting best-effort contact "
"loading without a full picture of what's already on the radio"
)
broadcast_error(
"Could not enumerate radio contacts",
"Contact loading skipped — DM auto-acking for favorites and recent "
"contacts may not work, but sending and receiving is not affected. "
"Set MESHCORE_LOAD_WITH_AUTOEVICT=true to load contacts without "
"needing to read the radio first. See 'Contact Loading Issues' in "
"the Advanced Setup documentation.",
"Loading favorites and recent contacts on a best-effort basis — "
"some adds may be redundant or fail if the radio's contact table "
"is already full. Set MESHCORE_LOAD_WITH_AUTOEVICT=true for more "
"reliable loading without needing to read the radio first. "
"See 'Contact Loading Issues' in the Advanced Setup documentation.",
)
else:
start_background_contact_reconciliation(
initial_radio_contacts=contacts_result.get("radio_contacts", {}),
expected_mc=mc,
autoevict=autoevict,
)
contact_reconcile_started = True
start_background_contact_reconciliation(
initial_radio_contacts=contacts_result.get("radio_contacts", {}),
expected_mc=mc,
autoevict=autoevict,
)
return {
"contacts": contacts_result,
"channels": channels_result,
"contact_reconcile_started": contact_reconcile_started,
"contact_reconcile_started": True,
}
@@ -1180,6 +1181,8 @@ async def _reconcile_radio_contacts_in_background(
failed = 0
table_full = False
autoevict_next_index = 0
autoevict_full_pass_retries = 0
_MAX_AUTOEVICT_RETRIES = 3
try:
while True:
@@ -1187,6 +1190,8 @@ async def _reconcile_radio_contacts_in_background(
logger.info("Stopping background contact reconcile: radio transport changed")
break
# Pre-lock snapshot for quick-exit checks; authoritative list is
# re-fetched inside the radio lock below.
selected_contacts = await get_contacts_selected_for_radio_sync()
desired_fill_contacts = [
contact for contact in selected_contacts if len(contact.public_key) >= 64
@@ -1282,6 +1287,9 @@ async def _reconcile_radio_contacts_in_background(
if budget > 0:
if autoevict:
# Budget is consumed by the slice bound rather than
# per-operation decrement — autoevict skips the
# removal phase so the full budget is always available.
batch_contacts = desired_fill_contacts[
autoevict_next_index : autoevict_next_index + budget
]
@@ -1408,7 +1416,7 @@ async def _reconcile_radio_contacts_in_background(
"things are broken on your radio."
)
broadcast_error(
"Could not load all desired contacts onto the radio for auto-DM ack: ",
"Could not load all desired contacts onto the radio for auto-DM ack",
"Despite having auto-evict enabled, we got a contact-table-full error "
"from your radio. DM auto-ack is likely unavailable.",
)
@@ -1432,6 +1440,16 @@ async def _reconcile_radio_contacts_in_background(
if autoevict and autoevict_pass_complete:
if autoevict_pass_failed:
autoevict_full_pass_retries += 1
if autoevict_full_pass_retries >= _MAX_AUTOEVICT_RETRIES:
logger.warning(
"Background contact blind fill giving up after %d full passes "
"with persistent failures (loaded %d, failed %d)",
autoevict_full_pass_retries,
loaded,
failed,
)
break
autoevict_next_index = 0
else:
logger.info(
+230
View File
@@ -15,6 +15,7 @@ from meshcore.events import Event
import app.radio_sync as radio_sync
from app.radio import RadioManager, radio_manager
from app.radio_sync import (
_enable_autoevict_on_radio,
_message_poll_loop,
_periodic_advert_loop,
_periodic_sync_loop,
@@ -551,6 +552,66 @@ class TestSyncAndOffloadAll:
)
assert result["contact_reconcile_started"] is True
@pytest.mark.asyncio
async def test_autoevict_success_passes_flag_to_reconcile(self, test_db):
mock_mc = MagicMock()
radio_contacts = {KEY_A: {"public_key": KEY_A}}
with (
patch.object(radio_sync.settings, "load_with_autoevict", True),
patch(
"app.radio_sync._enable_autoevict_on_radio",
new=AsyncMock(return_value=True),
),
patch(
"app.radio_sync.sync_contacts_from_radio",
new=AsyncMock(return_value={"synced": 1, "radio_contacts": radio_contacts}),
),
patch(
"app.radio_sync.sync_and_offload_channels",
new=AsyncMock(return_value={"synced": 0, "cleared": 0}),
),
patch("app.radio_sync.ensure_default_channels", new=AsyncMock()),
patch("app.radio_sync.start_background_contact_reconciliation") as mock_start,
):
result = await sync_and_offload_all(mock_mc)
mock_start.assert_called_once_with(
initial_radio_contacts=radio_contacts,
expected_mc=mock_mc,
autoevict=True,
)
assert result["contact_reconcile_started"] is True
@pytest.mark.asyncio
async def test_best_effort_reconcile_when_snapshot_fails(self, test_db):
"""When sync_contacts_from_radio errors, reconcile still starts with empty snapshot."""
mock_mc = MagicMock()
with (
patch(
"app.radio_sync.sync_contacts_from_radio",
new=AsyncMock(return_value={"synced": 0, "radio_contacts": {}, "error": "timeout"}),
),
patch(
"app.radio_sync.sync_and_offload_channels",
new=AsyncMock(return_value={"synced": 0, "cleared": 0}),
),
patch("app.radio_sync.ensure_default_channels", new=AsyncMock()),
patch("app.radio_sync.start_background_contact_reconciliation") as mock_start,
patch("app.radio_sync.broadcast_error") as mock_broadcast,
):
result = await sync_and_offload_all(mock_mc)
mock_start.assert_called_once_with(
initial_radio_contacts={},
expected_mc=mock_mc,
autoevict=False,
)
assert result["contact_reconcile_started"] is True
mock_broadcast.assert_called_once()
assert "best-effort" in mock_broadcast.call_args.args[1]
@pytest.mark.asyncio
async def test_advert_fill_skips_repeaters(self, test_db):
"""Recent advert fallback only considers non-repeaters."""
@@ -829,6 +890,81 @@ class TestSyncAndOffloadAll:
assert payload["public_key"] == KEY_A
class TestEnableAutoevictOnRadio:
"""Test _enable_autoevict_on_radio read-modify-write flow."""
@pytest.mark.asyncio
async def test_sets_flag_when_not_already_set(self):
mc = MagicMock()
mc.commands.get_autoadd_config = AsyncMock(
return_value=MagicMock(type=EventType.OK, payload={"config": 0x00})
)
mc.commands.set_autoadd_config = AsyncMock(return_value=MagicMock(type=EventType.OK))
result = await _enable_autoevict_on_radio(mc)
assert result is True
mc.commands.set_autoadd_config.assert_awaited_once_with(0x01)
@pytest.mark.asyncio
async def test_noop_when_already_enabled(self):
mc = MagicMock()
mc.commands.get_autoadd_config = AsyncMock(
return_value=MagicMock(type=EventType.OK, payload={"config": 0x01})
)
mc.commands.set_autoadd_config = AsyncMock()
result = await _enable_autoevict_on_radio(mc)
assert result is True
mc.commands.set_autoadd_config.assert_not_awaited()
@pytest.mark.asyncio
async def test_preserves_other_flags(self):
mc = MagicMock()
mc.commands.get_autoadd_config = AsyncMock(
return_value=MagicMock(type=EventType.OK, payload={"config": 0x04})
)
mc.commands.set_autoadd_config = AsyncMock(return_value=MagicMock(type=EventType.OK))
result = await _enable_autoevict_on_radio(mc)
assert result is True
mc.commands.set_autoadd_config.assert_awaited_once_with(0x05)
@pytest.mark.asyncio
async def test_returns_false_on_get_error(self):
mc = MagicMock()
mc.commands.get_autoadd_config = AsyncMock(
return_value=MagicMock(type=EventType.ERROR, payload=None)
)
result = await _enable_autoevict_on_radio(mc)
assert result is False
@pytest.mark.asyncio
async def test_returns_false_on_set_failure(self):
mc = MagicMock()
mc.commands.get_autoadd_config = AsyncMock(
return_value=MagicMock(type=EventType.OK, payload={"config": 0x00})
)
mc.commands.set_autoadd_config = AsyncMock(return_value=MagicMock(type=EventType.ERROR))
result = await _enable_autoevict_on_radio(mc)
assert result is False
@pytest.mark.asyncio
async def test_returns_false_on_exception(self):
mc = MagicMock()
mc.commands.get_autoadd_config = AsyncMock(side_effect=RuntimeError("timeout"))
result = await _enable_autoevict_on_radio(mc)
assert result is False
class TestBackgroundContactReconcile:
"""Test the yielding background contact reconcile loop."""
@@ -922,6 +1058,100 @@ class TestBackgroundContactReconcile:
]
assert loaded_keys == [KEY_A, KEY_B]
@pytest.mark.asyncio
async def test_autoevict_table_full_breaks_with_error(self, test_db):
"""TABLE_FULL during autoevict stops the loop and broadcasts an error."""
await _insert_contact(KEY_A, "Alice", last_contacted=2000)
alice = await ContactRepository.get_by_key(KEY_A)
assert alice is not None
mock_mc = MagicMock()
mock_mc.is_connected = True
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
table_full_result = MagicMock(type=EventType.ERROR, payload={"error_code": 3})
mock_mc.commands.add_contact = AsyncMock(return_value=table_full_result)
radio_manager._meshcore = mock_mc
@asynccontextmanager
async def _radio_operation(*args, **kwargs):
del args, kwargs
yield mock_mc
with (
patch.object(
radio_sync.radio_manager,
"radio_operation",
side_effect=lambda *args, **kwargs: _radio_operation(*args, **kwargs),
),
patch("app.radio_sync.CONTACT_RECONCILE_BATCH_SIZE", 10),
patch(
"app.radio_sync.get_contacts_selected_for_radio_sync",
side_effect=[[alice], [alice]],
),
patch("app.radio_sync.asyncio.sleep", new=AsyncMock()),
patch("app.radio_sync.broadcast_error") as mock_broadcast,
):
await radio_sync._reconcile_radio_contacts_in_background(
initial_radio_contacts={},
expected_mc=mock_mc,
autoevict=True,
)
mock_broadcast.assert_called_once()
assert "auto-evict" in mock_broadcast.call_args.args[1].lower()
@pytest.mark.asyncio
async def test_autoevict_retry_cap_stops_after_max_retries(self, test_db):
"""Autoevict gives up after _MAX_AUTOEVICT_RETRIES full passes with failures."""
await _insert_contact(KEY_A, "Alice", last_contacted=2000)
alice = await ContactRepository.get_by_key(KEY_A)
assert alice is not None
mock_mc = MagicMock()
mock_mc.is_connected = True
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
# Every add fails with a non-TABLE_FULL error
fail_result = MagicMock(type=EventType.ERROR, payload={"error_code": 99})
mock_mc.commands.add_contact = AsyncMock(return_value=fail_result)
radio_manager._meshcore = mock_mc
@asynccontextmanager
async def _radio_operation(*args, **kwargs):
del args, kwargs
yield mock_mc
call_count = 0
async def _get_selected():
nonlocal call_count
call_count += 1
return [alice]
with (
patch.object(
radio_sync.radio_manager,
"radio_operation",
side_effect=lambda *args, **kwargs: _radio_operation(*args, **kwargs),
),
patch("app.radio_sync.CONTACT_RECONCILE_BATCH_SIZE", 10),
patch(
"app.radio_sync.get_contacts_selected_for_radio_sync",
side_effect=_get_selected,
),
patch("app.radio_sync.asyncio.sleep", new=AsyncMock()),
):
await radio_sync._reconcile_radio_contacts_in_background(
initial_radio_contacts={},
expected_mc=mock_mc,
autoevict=True,
)
# 2 calls per iteration (pre-lock + in-lock), 3 retries = 6 calls,
# plus 1 pre-lock call on the initial iteration = at most 8.
# The key assertion: it terminates rather than looping forever.
assert mock_mc.commands.add_contact.await_count <= 4
assert call_count <= 8
@pytest.mark.asyncio
async def test_yields_radio_lock_every_two_contact_operations(self, test_db):
await _insert_contact(KEY_A, "Alice", last_contacted=3000)