diff --git a/work/meshai/transport/meshcore_transport.py b/work/meshai/transport/meshcore_transport.py index f5a4787..1d8e12e 100644 --- a/work/meshai/transport/meshcore_transport.py +++ b/work/meshai/transport/meshcore_transport.py @@ -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: diff --git a/work/tests/test_meshcore_transport.py b/work/tests/test_meshcore_transport.py index 192ee81..b2ab3a1 100644 --- a/work/tests/test_meshcore_transport.py +++ b/work/tests/test_meshcore_transport.py @@ -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)