The prewarm at connect filled contacts and the two slot probes, so the first Contacts or Trace open still queued a path-hash-mode round trip behind whichever probe was walking the slot table at the time — and the slot walks are measured in seconds. Warm every cached fact, cheapest first: self-info and the routing mode (single round trips, and what every list screen needs), then contacts, then the two probes only the channel manager waits on. The mode gets a lock like its siblings, so a screen opening beside the warm awaits the read in flight instead of issuing a second one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
370 lines
14 KiB
Python
370 lines
14 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 datetime import datetime, timezone
|
|
from types import SimpleNamespace
|
|
|
|
from meshterm.core.models import Contact
|
|
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
|
|
self.capacity_calls = 0
|
|
self.contact_rows: "list[Contact] | None" = None # fixed table, when a test needs one
|
|
|
|
async def get_contacts(self) -> list:
|
|
self.contacts_calls += 1
|
|
if self.contact_rows is not None:
|
|
return list(self.contact_rows)
|
|
return [Contact(name=f"contact-{self.contacts_calls}")] # 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")
|
|
|
|
async def channel_capacity(self) -> int:
|
|
# A fixed hardware constant; the cache must read it exactly once for the session.
|
|
self.capacity_calls += 1
|
|
return 8
|
|
|
|
|
|
def _devstate(
|
|
device: FakeDevice,
|
|
heard: "dict | None" = None,
|
|
messaged: "dict | None" = None,
|
|
) -> DeviceState:
|
|
"""A DeviceState over a fake ctx: device(), a silent logger, and our reception history.
|
|
|
|
``heard`` is node-id → when we last overheard it; ``messaged`` is peer prefix → when it
|
|
last sent us a direct message. Together they are the first-hand evidence the contacts
|
|
merge weighs against the device's advert times.
|
|
"""
|
|
|
|
async def device_getter():
|
|
return device
|
|
|
|
ctx = SimpleNamespace(
|
|
device=device_getter,
|
|
log=SimpleNamespace(debug=lambda *a, **k: None),
|
|
repo=SimpleNamespace(
|
|
last_heard_by_node=lambda: dict(heard or {}),
|
|
last_message_by_peer=lambda: dict(messaged or {}),
|
|
),
|
|
)
|
|
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].name == "contact-2" # now serving the fresh list
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_heard_time_takes_the_later_of_the_device_and_our_own_receptions() -> None:
|
|
"""A contact's heard time is the latest of the device's advert time and our evidence.
|
|
|
|
The device's ``last_advert`` is stamped by the *sender's* clock, so it is hearsay: it can
|
|
be absent (refused upstream by ``models.advert_time``), or plausible-looking yet days
|
|
stale because the node's RTC runs behind. Our own history is first-hand — stamped when we
|
|
received something — so the merge takes whichever is later. A device time still wins
|
|
whenever the firmware caught an advert we never recorded.
|
|
"""
|
|
dev = FakeDevice()
|
|
stale = datetime(2026, 7, 25, 9, 0, tzinfo=timezone.utc)
|
|
fresh = datetime(2026, 7, 29, 12, 0, tzinfo=timezone.utc)
|
|
dev.contact_rows = [
|
|
Contact(name="Bogus-Clock", public_key="aa" * 32, key_prefix="aa" * 6),
|
|
Contact(name="Behind-Clock", public_key="bb" * 32, key_prefix="bb" * 6, last_seen=stale),
|
|
Contact(name="Ahead", public_key="cc" * 32, key_prefix="cc" * 6, last_seen=fresh),
|
|
Contact(name="Quiet", public_key="dd" * 32, key_prefix="dd" * 6),
|
|
]
|
|
ds = _devstate(dev, heard={"aa" * 6: fresh, "bb" * 6: fresh, "cc" * 6: stale})
|
|
|
|
async def run() -> None:
|
|
by_name = {c.name: c for c in await ds.contacts()}
|
|
assert by_name["Bogus-Clock"].last_seen == fresh # filled from our own history
|
|
assert by_name["Behind-Clock"].last_seen == fresh # our proof beats a stale stamp
|
|
assert by_name["Ahead"].last_seen == fresh # a newer device time still stands
|
|
assert by_name["Quiet"].last_seen is None # truly never heard stays never
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_a_direct_message_counts_as_hearing_its_sender() -> None:
|
|
"""An inbound DM updates the heard time — "heard" means received from.
|
|
|
|
A direct message never touches the firmware's ``last_advert`` and is stored as a message
|
|
rather than an observation, so a node we actively chat with could read days stale (and be
|
|
swept by the purge ladder's quiet rungs) while talking to us. The peer prefix the wire
|
|
addressed need not match the contact table's width, so either may be the shorter.
|
|
"""
|
|
dev = FakeDevice()
|
|
stale = datetime(2026, 7, 25, 9, 0, tzinfo=timezone.utc)
|
|
messaged_at = datetime(2026, 7, 29, 12, 0, tzinfo=timezone.utc)
|
|
dev.contact_rows = [
|
|
Contact(name="Chatty", public_key="ab" * 32, key_prefix="ab" * 6, last_seen=stale),
|
|
Contact(name="Silent", public_key="cd" * 32, key_prefix="cd" * 6, last_seen=stale),
|
|
]
|
|
# A six-hex peer against a twelve-hex contact id: the shorter one is the wire's.
|
|
ds = _devstate(dev, messaged={"ababab": messaged_at})
|
|
|
|
async def run() -> None:
|
|
by_name = {c.name: c for c in await ds.contacts()}
|
|
assert by_name["Chatty"].last_seen == messaged_at
|
|
assert by_name["Silent"].last_seen == stale # nobody else is credited
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_a_naive_stored_stamp_still_compares() -> None:
|
|
"""A tz-less row from an older build reads as the UTC the storage contract says it is.
|
|
|
|
Comparing a naive datetime against an aware one raises, which would take the whole
|
|
contacts fetch down rather than one lane.
|
|
"""
|
|
dev = FakeDevice()
|
|
stale = datetime(2026, 7, 25, 9, 0, tzinfo=timezone.utc)
|
|
naive = datetime(2026, 7, 29, 12, 0) # written before timestamps carried a zone
|
|
dev.contact_rows = [
|
|
Contact(name="Legacy", public_key="aa" * 32, key_prefix="aa" * 6, last_seen=stale)
|
|
]
|
|
ds = _devstate(dev, heard={"aa" * 6: naive})
|
|
|
|
async def run() -> None:
|
|
assert (await ds.contacts())[0].last_seen == naive.replace(tzinfo=timezone.utc)
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_a_history_read_failure_leaves_the_contacts_untouched() -> None:
|
|
"""The merge is best-effort: a broken history read never blocks a contacts fetch."""
|
|
dev = FakeDevice()
|
|
device_says = datetime(2026, 7, 28, 9, 0, tzinfo=timezone.utc)
|
|
dev.contact_rows = [Contact(name="Solo", public_key="ee" * 32, key_prefix="ee" * 6,
|
|
last_seen=device_says)]
|
|
ds = _devstate(dev)
|
|
|
|
def boom() -> dict:
|
|
raise RuntimeError("history unavailable")
|
|
|
|
ds._ctx.repo.last_heard_by_node = boom # type: ignore[attr-defined]
|
|
|
|
async def run() -> None:
|
|
assert (await ds.contacts())[0].last_seen == device_says
|
|
|
|
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(); await ds.channel_capacity()
|
|
ds.reset()
|
|
await ds.self_info(); await ds.contacts(); await ds.path_hash_mode(); await ds.channel_capacity()
|
|
|
|
asyncio.run(run())
|
|
assert dev.self_info_calls == 2
|
|
assert dev.contacts_calls == 2
|
|
assert dev.mode_calls == 2
|
|
assert dev.capacity_calls == 2 # capacity is a hardware constant, but a reconnect re-reads it
|
|
|
|
|
|
def test_channel_capacity_is_fetched_once_and_served_from_cache() -> None:
|
|
"""Capacity is a hardware constant: probed once, then reused for the session."""
|
|
dev = FakeDevice()
|
|
ds = _devstate(dev)
|
|
|
|
async def run() -> None:
|
|
for _ in range(3):
|
|
assert await ds.channel_capacity() == 8
|
|
|
|
asyncio.run(run())
|
|
assert dev.capacity_calls == 1
|
|
|
|
|
|
def test_prewarm_fills_every_cache_off_the_read_path() -> None:
|
|
"""prewarm() warms all five facts, so the first screen open hits no wire at all."""
|
|
dev = FakeDevice()
|
|
ds = _devstate(dev)
|
|
|
|
async def run() -> None:
|
|
ds.prewarm()
|
|
await asyncio.gather(*list(ds._tasks)) # let the background warm finish
|
|
# Every cache was filled by the prewarm: self-info and the routing mode once each,
|
|
# contacts once, the channel probe once (idx 0 ok, idx 1 rejected), capacity once.
|
|
assert dev.self_info_calls == 1
|
|
assert dev.mode_calls == 1
|
|
assert dev.contacts_calls == 1
|
|
assert dev.channel_calls == 2
|
|
assert dev.capacity_calls == 1
|
|
# A screen opening now is served from cache — no additional round-trips. The
|
|
# path-hash mode belongs in that list: Contacts and Trace both read it to size the
|
|
# key-hash highlight, and nothing else in the session warms it.
|
|
await ds.contacts()
|
|
await ds.self_info()
|
|
await ds.path_hash_mode()
|
|
await ds.channel_slots()
|
|
await ds.channel_capacity()
|
|
assert dev.self_info_calls == 1
|
|
assert dev.mode_calls == 1
|
|
assert dev.contacts_calls == 1
|
|
assert dev.channel_calls == 2
|
|
assert dev.capacity_calls == 1
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_prewarm_reads_the_cheap_facts_before_the_slot_probes() -> None:
|
|
"""Order is the point: the reads share one link, and the probes are the slow ones.
|
|
|
|
Both slot reads walk the firmware's slot table an index at a time and are measured in
|
|
seconds; the facts every list screen needs are single round-trips. Warming them first
|
|
is what stops a Contacts open a second after connect from queueing behind a slot walk.
|
|
"""
|
|
dev = FakeDevice()
|
|
ds = _devstate(dev)
|
|
order: list[str] = []
|
|
for name in ("get_self_info", "get_path_hash_mode", "get_contacts", "get_channel",
|
|
"channel_capacity"):
|
|
inner = getattr(dev, name)
|
|
|
|
async def traced(*a, _name=name, _inner=inner, **k): # noqa: ANN001, ANN202
|
|
if _name != "get_channel" or "get_channel" not in order:
|
|
order.append(_name)
|
|
return await _inner(*a, **k)
|
|
|
|
setattr(dev, name, traced)
|
|
|
|
async def run() -> None:
|
|
ds.prewarm()
|
|
await asyncio.gather(*list(ds._tasks))
|
|
|
|
asyncio.run(run())
|
|
assert order == [
|
|
"get_self_info", "get_path_hash_mode", "get_contacts",
|
|
"get_channel", "channel_capacity",
|
|
]
|
|
|
|
|
|
def test_a_concurrent_path_hash_read_joins_the_warm_instead_of_racing_it() -> None:
|
|
"""The prewarm now reads the mode, so a screen opening beside it must not re-read."""
|
|
dev = FakeDevice()
|
|
ds = _devstate(dev)
|
|
|
|
async def run() -> None:
|
|
await asyncio.gather(ds.path_hash_mode(), ds.path_hash_mode(), ds.path_hash_mode())
|
|
assert dev.mode_calls == 1
|
|
|
|
asyncio.run(run())
|