Skip to content

Commit f6afe0f

Browse files
committed
Harden MeshCore messaging against contact-fetch bus hammering.
Cooldown soft-fail cooldown, pause auto-fetch around TX/sync, throttle DM contact refresh, and serve the contact picker from SQLite so live get_contacts cannot wedge channel sends.
1 parent c12da51 commit f6afe0f

5 files changed

Lines changed: 100 additions & 24 deletions

File tree

src/api/routes/messages.py

Lines changed: 6 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,12 @@ async def get_channels():
237237

238238
@router.get("/contacts")
239239
async def get_contacts():
240+
"""Return messaging contacts from the local node roster only.
241+
242+
Does not call live MeshCore ``get_contacts``: that command shares
243+
the companion serial bus with channel TX and was wedging sends
244+
whenever the contact picker or enrichment path refreshed.
245+
"""
240246
contacts = []
241247

242248
_synthetic = {"rf_log", "raw", "mc:channel", "unknown", ""}
@@ -254,21 +260,6 @@ async def get_contacts():
254260
"last_heard": n.get("last_heard", ""),
255261
})
256262

257-
if _meshcore_tx and _meshcore_tx.connected:
258-
mc_contacts = await _meshcore_tx.get_contacts()
259-
for contact in mc_contacts:
260-
pk = contact.get("public_key", "")
261-
canonical = pk[:12].lower() if len(pk) >= 12 else pk.lower()
262-
name = contact.get("name", "")
263-
if not name or name.lower() == canonical:
264-
name = await _resolve_display_name(canonical, "meshcore") or canonical
265-
contacts.append({
266-
"node_id": canonical,
267-
"name": name,
268-
"protocol": "meshcore",
269-
"last_heard": "",
270-
})
271-
272263
return contacts
273264

274265

src/api/server.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1014,6 +1014,8 @@ def _setup_message_interception(
10141014

10151015
mc_name_cache: dict[str, str] = {}
10161016
mc_pubkey_canon: dict[str, str] = {}
1017+
_mc_refresh_state = {"last": 0.0}
1018+
_MC_REFRESH_MIN_INTERVAL_S = 60.0
10171019

10181020
from src.api.channel_hash_resolver import ChannelHashResolver
10191021

@@ -1038,6 +1040,12 @@ async def _refresh_mc_contacts() -> None:
10381040
if not meshcore_tx or not meshcore_tx.connected:
10391041
logger.debug("MC contact refresh skipped: not connected")
10401042
return
1043+
import time as _time
1044+
1045+
now = _time.monotonic()
1046+
if now - _mc_refresh_state["last"] < _MC_REFRESH_MIN_INTERVAL_S:
1047+
return
1048+
_mc_refresh_state["last"] = now
10411049
try:
10421050
contacts = await meshcore_tx.get_contacts()
10431051
for c in contacts:

src/transmit/meshcore_contacts.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,15 @@ def store(self, rows: list[dict]) -> None:
4545
self._rows = list(rows)
4646
self._fetched_at = time.monotonic()
4747

48+
def note_soft_fail(self) -> None:
49+
"""Refresh TTL after a timeout/error so callers back off.
50+
51+
Without this, an empty cache causes every Messages/enrichment
52+
caller to burn another 5s live ``get_contacts`` and wedge the
53+
companion command channel.
54+
"""
55+
self._fetched_at = time.monotonic()
56+
4857
def invalidate(self) -> None:
4958
self._fetched_at = 0.0
5059

src/transmit/meshcore_tx_client.py

Lines changed: 70 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,9 @@ def _fail_timeout(self, reason: str) -> SendResult:
125125
126126
Credit: javastraat/meshpoint b04e91c
127127
"""
128-
self._contact_cache.invalidate()
128+
# Cooldown, do not wipe: keep stale names and stop contact
129+
# fetchers from immediately re-hammering a wedged companion.
130+
self._contact_cache.note_soft_fail()
129131
trigger = getattr(self._source, "_trigger_reconnect", None)
130132
if callable(trigger):
131133
trigger(reason)
@@ -138,14 +140,23 @@ async def _run_tx_command(
138140
success_log: str,
139141
timeout_label: str,
140142
) -> SendResult:
141-
"""Run one companion command under the serial lock."""
143+
"""Run one companion command under the serial lock.
144+
145+
Pauses auto message fetching for the command window so the
146+
library's background poll cannot steal OK/ERROR events
147+
(same pattern as set_radio).
148+
"""
142149
if not self.connected:
143150
return SendResult(success=False, error="Not connected")
144151
try:
145152
async with self._cmd_lock:
146153
if not self.connected or self._mc is None:
147154
return SendResult(success=False, error="Not connected")
148-
result = await asyncio.wait_for(factory(), timeout=10.0)
155+
await self._pause_auto_fetch()
156+
try:
157+
result = await asyncio.wait_for(factory(), timeout=10.0)
158+
finally:
159+
await self._resume_auto_fetch()
149160
except asyncio.TimeoutError:
150161
return self._fail_timeout(timeout_label)
151162
except Exception as exc:
@@ -191,6 +202,42 @@ async def _run_tx_command(
191202
await self._run_post_command()
192203
return SendResult(success=True, event_type=event_type)
193204

205+
async def _pause_auto_fetch(self) -> None:
206+
mc = self._mc
207+
if mc is None:
208+
return
209+
stop = getattr(mc, "stop_auto_message_fetching", None)
210+
if not callable(stop):
211+
return
212+
try:
213+
await stop()
214+
except Exception:
215+
logger.debug("Could not pause MeshCore auto-fetch", exc_info=True)
216+
217+
async def _resume_auto_fetch(self) -> None:
218+
# Prefer the capture-source restart (rebinds subscriptions) when
219+
# bound; otherwise poke the library directly.
220+
restart = getattr(self._source, "restart_auto_fetching", None)
221+
if callable(restart):
222+
try:
223+
await restart()
224+
return
225+
except Exception:
226+
logger.debug(
227+
"Could not restart MeshCore auto-fetch via source",
228+
exc_info=True,
229+
)
230+
mc = self._mc
231+
if mc is None:
232+
return
233+
start = getattr(mc, "start_auto_message_fetching", None)
234+
if not callable(start):
235+
return
236+
try:
237+
await start()
238+
except Exception:
239+
logger.debug("Could not resume MeshCore auto-fetch", exc_info=True)
240+
194241
async def create_connection(
195242
self,
196243
port: str,
@@ -373,10 +420,18 @@ async def sync_channels(self, channel_keys: dict) -> None:
373420
if not self.connected:
374421
logger.debug("sync_channels: not connected, skipping")
375422
return
376-
await MeshcoreChannelSync(
377-
self._mc,
378-
post_command=self._run_post_command,
379-
).sync(channel_keys)
423+
async with self._cmd_lock:
424+
if not self.connected or self._mc is None:
425+
return
426+
await self._pause_auto_fetch()
427+
try:
428+
await MeshcoreChannelSync(
429+
self._mc,
430+
post_command=None,
431+
).sync(channel_keys)
432+
finally:
433+
await self._resume_auto_fetch()
434+
await self._run_post_command()
380435

381436
async def get_contacts(self, *, force: bool = False) -> list[dict]:
382437
"""Retrieve the companion's contact list.
@@ -407,24 +462,30 @@ async def get_contacts(self, *, force: bool = False) -> list[dict]:
407462
)
408463
except asyncio.TimeoutError:
409464
logger.warning("get_contacts timed out waiting for companion")
465+
self._contact_cache.note_soft_fail()
410466
return self._contact_cache.get_stale()
411467
except Exception:
412468
logger.exception("Failed to retrieve MeshCore contacts")
469+
self._contact_cache.note_soft_fail()
413470
return self._contact_cache.get_stale()
414471

415472
contacts = MeshcoreContactParser.from_command_result(result)
416473
soft_fail = result is None or MeshcoreContactParser._is_error_event(
417474
result
418475
)
419476
if soft_fail:
477+
# Stamp TTL even on failure so queued callers do not each
478+
# burn another live 5s get_contacts on a wedged companion.
479+
self._contact_cache.note_soft_fail()
420480
stale = self._contact_cache.get_stale()
421481
if stale:
422482
logger.info(
423483
"get_contacts: soft fail, using stale cache (%d)",
424484
len(stale),
425485
)
426486
return stale
427-
else:
428-
self._contact_cache.store(contacts)
487+
logger.info("get_contacts: soft fail, empty roster (cooling down)")
488+
return []
489+
self._contact_cache.store(contacts)
429490
logger.info("get_contacts: %d contacts parsed", len(contacts))
430491
return contacts

tests/test_meshcore_contacts.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,13 @@ def test_fresh_then_stale(self):
8989
self.assertIsNone(cache.get_fresh())
9090
self.assertEqual(len(cache.get_stale()), 1)
9191

92+
def test_soft_fail_starts_cooldown_with_empty_roster(self):
93+
cache = MeshcoreContactCache(ttl_seconds=60.0)
94+
self.assertIsNone(cache.get_fresh())
95+
cache.note_soft_fail()
96+
# Empty list (not None) so callers skip another live fetch.
97+
self.assertEqual(cache.get_fresh(), [])
98+
9299

93100
if __name__ == "__main__":
94101
unittest.main()

0 commit comments

Comments
 (0)