Files
MeshTerm/tests/test_device_state.py
T
Jean-Pierre MartineauandClaude Opus 4.8 736b420a4f Device state: session-cache the reads screens make on open
Opening a screen re-read the same stable facts from the companion every time — the contacts table, self-info, the path-hash mode, the channel-slot probe. Over Bluetooth each is a round-trip, and on a busy node two dominate: get_contacts (~2.4 s with hundreds of contacts) and the channel probe (~2.6 s, it walks every slot index), so navigation stalled for seconds on each screen. Measured on hardware; and asyncio.gather makes it worse, not better — the meshcore BLE UART serializes commands, so concurrent reads collide.

DeviceState (ctx.devstate) reads each fact once and serves it from cache. Contacts use stale-while-revalidate on a 90 s TTL — a read past it returns the cached list instantly and refreshes behind it, never blocking navigation on the slow call. Self-info, path-hash mode, and channel slots are held until an in-app write invalidates them: config apply drops self-info + mode, the channel editor drops channels, a reconnect resets everything. The hot screen openers (nodes, dashboard, chat, trace, TX, map, atlas, time machine, courier, watchtower) now read through the cache. The first screen opened in a session still pays the one read — shared across every screen — and every open after is instant. Transport-agnostic, so serial benefits too; harmless on --mock.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 22:09:14 -04:00

161 lines
5.6 KiB
Python

"""DeviceState session-cache tests: read once, serve cached, invalidate on write.
The cache exists to keep screen navigation off the slow radio round-trips (contacts,
self-info, the channel probe). These tests pin the two behaviours that make it correct:
stable facts are fetched once and reused until an in-app write invalidates them, and the
contacts list refreshes in the background once it ages past the TTL without ever blocking a
read. A fake device counts round-trips so "served from cache" is assertable without hardware.
"""
from __future__ import annotations
import asyncio
import time
from types import SimpleNamespace
from meshterm.services.device_state import DeviceState, _CONTACTS_TTL_S
class FakeDevice:
"""A stand-in device that counts how often each cached read hits the wire."""
def __init__(self) -> None:
self.contacts_calls = 0
self.self_info_calls = 0
self.mode_calls = 0
self.channel_calls = 0
async def get_contacts(self) -> list:
self.contacts_calls += 1
return [f"contact-{self.contacts_calls}"] # a fresh identity per fetch, to spot refreshes
async def get_self_info(self) -> dict:
self.self_info_calls += 1
return {"name": "node", "tx_power": 20}
async def get_path_hash_mode(self) -> int:
self.mode_calls += 1
return 2
async def get_channel(self, idx: int):
# One configured slot at index 0, then the firmware "rejects" index 1 to end the probe.
self.channel_calls += 1
if idx == 0:
return {"channel_name": "public", "channel_secret": b"\x00" * 16}
raise RuntimeError("out of range")
def _devstate(device: FakeDevice) -> DeviceState:
"""A DeviceState over a fake ctx exposing just device() and a silent logger."""
async def device_getter():
return device
ctx = SimpleNamespace(
device=device_getter,
log=SimpleNamespace(debug=lambda *a, **k: None),
)
return DeviceState(ctx) # type: ignore[arg-type]
def test_stable_facts_are_fetched_once_and_served_from_cache() -> None:
"""self-info, path-hash mode, and channels read the radio once, then reuse the value."""
dev = FakeDevice()
ds = _devstate(dev)
async def run() -> None:
for _ in range(3):
assert (await ds.self_info())["name"] == "node"
assert await ds.path_hash_mode() == 2
assert [s.name for s in await ds.channel_slots()] == ["public"]
asyncio.run(run())
assert dev.self_info_calls == 1
assert dev.mode_calls == 1
# The channel probe ran once (idx 0 ok, idx 1 rejected) and was cached wholesale.
assert dev.channel_calls == 2
def test_contacts_served_from_cache_within_ttl() -> None:
"""Repeated contacts reads inside the TTL hit the wire exactly once."""
dev = FakeDevice()
ds = _devstate(dev)
async def run() -> None:
first = await ds.contacts()
for _ in range(5):
assert await ds.contacts() is first # same cached list object
asyncio.run(run())
assert dev.contacts_calls == 1
def test_contacts_refresh_in_background_past_ttl() -> None:
"""Past the TTL a read returns the stale list instantly and refreshes behind it."""
dev = FakeDevice()
ds = _devstate(dev)
async def run() -> None:
stale = await ds.contacts() # first fetch
assert dev.contacts_calls == 1
# Age the cache past the TTL, then read: the read must return immediately (the stale
# copy) and schedule a background refresh rather than block on the slow call.
ds._contacts_at = time.monotonic() - _CONTACTS_TTL_S - 1
served = await ds.contacts()
assert served is stale # served the old list, did not block on a re-fetch
# Let the scheduled background refresh run.
await asyncio.gather(*list(ds._tasks))
assert dev.contacts_calls == 2 # refreshed behind the read
assert (await ds.contacts())[0] == "contact-2" # now serving the fresh list
asyncio.run(run())
def test_force_bypasses_the_contacts_cache() -> None:
"""A forced read always re-fetches, for the rare caller that needs it fresh."""
dev = FakeDevice()
ds = _devstate(dev)
async def run() -> None:
await ds.contacts()
await ds.contacts(force=True)
asyncio.run(run())
assert dev.contacts_calls == 2
def test_invalidation_forces_a_re_read() -> None:
"""Each invalidate drops exactly its own entry so the next read re-fetches it."""
dev = FakeDevice()
ds = _devstate(dev)
async def run() -> None:
await ds.self_info(); await ds.path_hash_mode(); await ds.channel_slots(); await ds.contacts()
# invalidate_config drops self-info + path-hash mode together (the config editor's write).
ds.invalidate_config()
await ds.self_info(); await ds.path_hash_mode()
assert dev.self_info_calls == 2 and dev.mode_calls == 2
# channels and contacts were untouched by that invalidation.
assert dev.channel_calls == 2 and dev.contacts_calls == 1
ds.invalidate_channels()
await ds.channel_slots()
assert dev.channel_calls == 4 # probed again
asyncio.run(run())
def test_reset_clears_everything() -> None:
"""A reconnect's reset drops the whole cache so every fact is re-read on next use."""
dev = FakeDevice()
ds = _devstate(dev)
async def run() -> None:
await ds.self_info(); await ds.contacts(); await ds.path_hash_mode()
ds.reset()
await ds.self_info(); await ds.contacts(); await ds.path_hash_mode()
asyncio.run(run())
assert dev.self_info_calls == 2
assert dev.contacts_calls == 2
assert dev.mode_calls == 2