fix(meshcore): force full contact re-fetch on DM resolve miss

_resolve_contact relied on ensure_contacts(), which is a no-op once the
lib's contact mirror is populated — so firmware-side auto-added senders
never synced in and every reply dropped with "could not resolve contact".
On a prefix-lookup miss, force get_contacts(lastmod=0) to pull the full
roster from the radio, then retry the lookup. Lets AIDA reply to anyone
the firmware has (auto-added or previously heard) even when the cached
mirror is stale.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Johnson 2026-07-06 06:05:42 +00:00
commit 9044ac0c21
2 changed files with 74 additions and 10 deletions

View file

@ -121,22 +121,49 @@ class MeshCoreTransport(MeshTransport):
def _resolve_contact(self, dest: str):
"""Resolve a pubkey prefix (or key) to the full MeshCore contact dict.
Refreshes the roster first (ensure_contacts) so the lib can upgrade the
6-byte prefix to the full 32-byte key. Returns None if the contact can't
be resolved.
Fast path: if the lib's cached contact mirror already has a match for
*dest*, return it immediately with no serial round-trip.
On a cache miss, force a full contact re-fetch directly from the
firmware (``get_contacts(lastmod=0)``). ``lastmod=0`` fetches ALL
contacts, bypassing the no-op cache guard in ``ensure_contacts()``
(which skips the fetch once ``self._contacts`` is already populated).
This pulls in any contacts the firmware has auto-added since the last
sync (e.g. the sender of an inbound DM). The lib merges the result
into its ``_contacts`` cache automatically via its CONTACTS/NEXT_CONTACT
event handler, so a subsequent ``get_contact_by_key_prefix`` will find
the freshly-learned entry.
Returns None if the firmware genuinely has no such contact.
"""
if self._mc is None:
return None
ensure = getattr(self._mc, "ensure_contacts", None)
if ensure is not None:
try:
self._run_coro(ensure(), timeout=15)
except Exception:
logger.debug("MeshCore: ensure_contacts (resolve) failed", exc_info=True)
# Fast path: contact already in the lib's cached mirror.
try:
contact = self._mc.get_contact_by_key_prefix(dest)
if contact is not None:
return contact
except Exception:
logger.debug(
"MeshCore: get_contact_by_key_prefix (fast path) failed for %s", dest, exc_info=True
)
return None
# Miss: force a full re-fetch so the firmware's auto-added contacts
# (including the sender of the inbound DM we're about to reply to)
# are merged into the lib's cache before we retry.
try:
self._run_coro(self._mc.commands.get_contacts(lastmod=0), timeout=15)
except Exception:
logger.debug(
"MeshCore: get_contacts(lastmod=0) refetch failed for %s", dest, exc_info=True
)
# Retry with the freshly merged roster.
try:
return self._mc.get_contact_by_key_prefix(dest)
except Exception:
logger.debug("MeshCore: get_contact_by_key_prefix failed for %s", dest, exc_info=True)
logger.debug(
"MeshCore: get_contact_by_key_prefix (retry) failed for %s", dest, exc_info=True
)
return None
def _establish_direct_path(self, contact: dict, dst: str) -> None:

View file

@ -911,3 +911,40 @@ class TestAutoAddContacts:
t._on_new_contact(event) # must not raise
finally:
_cleanup(t)
# ---------------------------------------------------------------------------
# 13. _resolve_contact — cache-miss refetch behavior
# ---------------------------------------------------------------------------
class TestResolveContact:
"""_resolve_contact: fast path (hit) and miss → refetch → retry behavior."""
_FAKE_CONTACT = {"public_key": "a" * 64, "adv_name": "RemoteNode", "out_path_len": -1}
def test_miss_triggers_refetch_and_returns_contact_on_retry(self):
"""On a cache miss, get_contacts(lastmod=0) is called exactly once and
the contact returned on the subsequent retry is passed back to the caller."""
t, mc, _ = _transport_with_mock_mc()
try:
# First call returns None (miss); second call (after refetch) finds it.
mc.get_contact_by_key_prefix.side_effect = [None, self._FAKE_CONTACT]
mc.commands.get_contacts = AsyncMock(return_value=None)
result = t._resolve_contact("7d4e07237294")
assert result == self._FAKE_CONTACT
mc.commands.get_contacts.assert_awaited_once_with(lastmod=0)
finally:
_cleanup(t)
def test_hit_skips_refetch(self):
"""When the first lookup returns a contact, get_contacts is NOT called
(fast path no unnecessary serial round-trip to the radio)."""
t, mc, _ = _transport_with_mock_mc()
try:
mc.get_contact_by_key_prefix.return_value = self._FAKE_CONTACT
mc.commands.get_contacts = AsyncMock(return_value=None)
result = t._resolve_contact("7d4e07237294")
assert result == self._FAKE_CONTACT
mc.commands.get_contacts.assert_not_awaited()
finally:
_cleanup(t)