Performance: SQLite WAL, lighter startup, per-platform tick rates

PicoCalc plan phase P2, driven by measurements taken on the Lyra as a
paired A/B (git archive HEAD vs the working tree, run side by side
against the same venv).

The headline is the database. Every packet the monitor hears is one
durable commit, and on the SD card each was costing 33.78 ms of fsync.
db._tune() opens in WAL with temp_store=MEMORY: 0.59 ms per commit, a
54x cut. synchronous=NORMAL is requested only once WAL is confirmed
engaged - the pragma returns the mode it settled on rather than raising
when a filesystem refuses it - because under WAL it risks the last
transactions to a power cut, and under the rollback journal it risks the
file itself. This device runs on a battery pack.

Startup: bleak/dbus-fast were already lazy; the real finds were two
cheap *values* dragging heavy subtrees. core/connection.py imports one
constant from core/channels.py, which imported PyCryptodome at module
level (224 ms of CPU-feature probing per run) - now deferred into the
two functions that decrypt. And tools/map.py's register_cli, which runs
for every tool at boot, reached into ui/map_screen.py for a --fraction
default, dragging the basemap's urllib.request -> http.client -> ssl
behind it; DEFAULT_VIEW_FRACTION moves to core/geo.py (pure arithmetic,
already on the boot path) and is re-exported so existing importers are
untouched. 26 modules and 316 ms of import self-time gone.

Per-frame: the corner glow only ever recolours truecolor foregrounds, so
on a 16-slot console it scanned every line of every frame to hand back
the identical list - skipped outright where effects are off, along with
the battery blink and the braille spinner cycle. _has_wide_glyph answers
from the platform when no emoji can be drawn instead of scanning the
composed frame (6.5-9.1 ms per repaint on its own). The header built its
segments twice to measure the compact separator, on exactly the narrow
terminals that take that branch every frame; it now builds once and
measures arithmetically. Together: 101.2 -> 73.2 ms per frame at 53x26,
151.8 -> 110.4 at 53x40, and 72x24 unregressed at 91.7.

That finally gave numbers to set the clocks against. refresh_interval
becomes per-platform tick_s (2.0 s on picocalc: a 1 Hz idle tick alone
was 7-11% of a core doing nothing, and only the header's per-minute
pulse drifts, since screens push their own repaints via invalidate).
The 0.12 s spinner was untenable - 84% of a core at 53x26, and at 53x40
it did not fit in its own interval at all. Five duplicate
_SPINNER_INTERVAL = 0.12 constants across the UI collapse into one
platform-aware spinner_interval() beside the Spinner it paces.

The header refactor was verified equivalent over 540 renders, plain text
and style spans both. The span check earned its keep: the first cut
passed style= to the Text constructor, which makes it the object's base
style and blankets everything appended after, so the version string
would have rendered brand-under-muted. test_header.py only asserted on
.plain and never saw it; it now pins the styling too.

tests/test_performance.py pins each contract as behaviour rather than
timing, including a subprocess guard that fails if Crypto, the network
stack, or the map screen ever creep back onto the startup import graph.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 5 committed 2026-08-01 03:40:42 -04:00
1 parent 8f1fba135e
commit 63cd4e5f96
18 files changed
+462 -84

No files matched your search

+13 -2
View File
@@ -20,8 +20,15 @@ from hashlib import sha256
from typing import Iterable, Optional
from urllib.parse import parse_qs, quote, urlsplit
from Crypto.Cipher import AES
from Crypto.Hash import HMAC, SHA256 as _SHA256
# PyCryptodome is imported inside the two functions that need it, never here. Importing
# it probes the host CPU's crypto feature set (``Crypto.Util._cpu_features``), which costs
# a fifth of a second on the PicoCalc's Cortex-A7 — paid by *every* startup, because this
# module reaches the boot path through a single constant: core.connection imports
# CHANNEL_SLOT_PROBE_CAP, and core.channel_store imports the derivation helpers. Neither
# decrypts anything at import. Deferring the import moves that cost onto the first
# overheard channel frame, where the work is actually wanted; every later call is a
# sys.modules dict hit, and both functions bind the names once per call rather than per
# candidate channel, so the reception path pays effectively nothing.
#: Matches the ``Name: message`` convention channel senders use to identify themselves
#: (the protocol carries no sender field). The name is 1–20 non-colon characters and must
@@ -349,6 +356,8 @@ def identify_channel(
The channel's ``(name, effective key)``, or ``None`` when no known channel's MAC
matches — a channel we don't hold the key for, or a bare fingerprint collision.
"""
from Crypto.Hash import HMAC, SHA256 as _SHA256 # deferred: see the module header
try:
mac = bytes.fromhex(cipher_mac)
msg = bytes.fromhex(crypted)
@@ -392,6 +401,8 @@ def decrypt_channel_text(
The decrypted text and its channel, or ``None`` if no known channel's MAC
matches or the ciphertext is malformed.
"""
from Crypto.Cipher import AES # deferred: see the module header
try:
msg = bytes.fromhex(crypted)
except ValueError:
+12
View File
@@ -24,6 +24,18 @@ EARTH_RADIUS_KM = 6371.0088
#: is ``TILE_PX * 2**zoom`` pixels square at a given zoom.
TILE_PX = 256
#: Default fraction of nodes a map view frames: the densest half, so a few distant outliers
#: don't zoom the whole mesh out to a continent. See :meth:`Viewport.fit`.
#:
#: It lives here, beside the viewport maths it parameterises, rather than in the map screen
#: that made it — the ``map`` subcommand needs it as a ``--fraction`` default at *CLI
#: registration* time, which happens for every tool on every startup. Reaching into
#: :mod:`meshterm.ui.map_screen` for it dragged the whole map stack (and, through the
#: basemap's tile fetcher, ``urllib.request`` → ``http.client`` → ``ssl``) into every run,
#: including runs that never open a map. This module is pure arithmetic and already on the
#: boot path, so the constant is free here.
DEFAULT_VIEW_FRACTION = 0.5
@dataclass(frozen=True, slots=True)
class BBox:
+49 -1
View File
@@ -168,6 +168,52 @@ CREATE INDEX IF NOT EXISTS idx_messages_peer ON messages(peer);
"""
def _tune(conn: sqlite3.Connection) -> None:
"""Apply the durability/throughput pragmas, on the safe side of every trade-off.
MeshTerm writes constantly and in tiny pieces — every packet the monitor hears becomes
an ``observations`` row — and its most constrained host (the PicoCalc) keeps its
database on an SD card, where SQLite's default of a full ``fsync`` per transaction
costs milliseconds *each*. Three pragmas move that cost without moving the risk:
* ``journal_mode=WAL`` — writers append to a log instead of rewriting the rollback
journal, which turns each insert's several synchronous seeks into one append, and
lets a reader (a screen redrawing) run against a writer (the monitor recording)
instead of blocking on it. This one is *persistent*: it lives in the database file,
so it survives into every later connection once set.
* ``synchronous=NORMAL`` — the actual latency win, but **only requested once WAL is
confirmed engaged**. Under WAL, NORMAL risks losing the last few transactions to a
power cut and nothing worse; under the rollback journal it can leave the file
*corrupt*. A PicoCalc running on a battery pack loses power for real, so on any
filesystem that refused WAL this stays at the default ``FULL`` — slow and intact
beats fast and unreadable.
* ``temp_store=MEMORY`` — sorts and temporary b-trees stay in RAM rather than landing
on the card. This workload's temporaries are small (screen-sized query results), so
the memory is bounded and the card sees strictly less traffic.
WAL can legitimately be unavailable — a read-only mount, or a filesystem without the
shared-memory primitive it needs — and SQLite reports that by *returning* the mode it
actually left the database in rather than raising, so the result is inspected instead
of trusted. Either way this is best-effort tuning: a database that will not take the
pragmas is still a perfectly working database, so nothing here is allowed to fail the
open.
Args:
conn: The freshly opened connection, before any schema work.
"""
# Returns the resulting mode as a row — "wal" only if the switch actually took.
try:
mode = conn.execute("PRAGMA journal_mode = WAL;").fetchone()
except sqlite3.Error: # pragma: no cover - filesystem-dependent
mode = None
if mode is not None and str(mode[0]).lower() == "wal":
conn.execute("PRAGMA synchronous = NORMAL;")
try:
conn.execute("PRAGMA temp_store = MEMORY;")
except sqlite3.Error: # pragma: no cover - defensive; no known failure mode
pass
def connect(db_path: Path) -> sqlite3.Connection:
"""Open (creating if needed) the SQLite database and ensure the schema exists.
@@ -175,11 +221,13 @@ def connect(db_path: Path) -> sqlite3.Connection:
db_path: Filesystem location of the database. Parent directories are created.
Returns:
An open connection with ``Row`` factory and foreign keys enabled.
An open connection with ``Row`` factory, foreign keys enabled, and the
durability/throughput pragmas applied (see :func:`_tune`).
"""
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
_tune(conn)
conn.execute("PRAGMA foreign_keys = ON;")
conn.executescript(_SCHEMA)
_migrate(conn)
+18 -4
View File
@@ -72,9 +72,20 @@ class Platform:
header battery blink, the braille spinner. ``False`` drops to static chrome
and a ``LINE`` spinner fallback — cheaper, and the glow already no-ops on a
non-truecolor theme, so this just makes that explicit. Wired in P2/P3.
tick_s: The :class:`~prompt_toolkit.application.Application` refresh interval.
Both platforms start at today's 1.0s; PicoCalc's is revisited in P2 once
on-device CPU numbers exist to tune against.
tick_s: The :class:`~prompt_toolkit.application.Application` refresh interval — the
*idle* repaint cadence only, since every screen pushes its own repaint through
``TuiSession.invalidate`` when its data actually moves. Composing one frame was
measured at 74 ms on the PicoCalc at 53×26 (110 ms at 53×40), so a 1 Hz idle
tick alone costs 7–11% of a core doing nothing; it ticks at half that rate
there. What drifts is cosmetic — the header's per-minute pulse and its packet
counter — and none of it is legible at one-second resolution anyway.
spinner_tick_s: Seconds between frames of a "working" spinner. The single source for
every animated wait in the app. The desktop's 0.12 s is not a rate this hardware
can hold: a PicoCalc frame would claim 62% of a core at 53×26, and at 53×40 its
110 ms of compose would leave under 10 ms of each interval for the work actually
being waited on — and before this phase's savings it did not fit at all, at
152 ms against a 120 ms budget. It slows to a cadence the hardware can hold
comfortably while still reading as alive.
battery: Which source feeds the header's battery gauge — ``"companion"`` (read
from the connected MeshCore device) or ``"host"`` (the PicoCalc's own sysfs
``power_supply`` driver, confirmed present in P0). Wired in P5.
@@ -98,6 +109,7 @@ class Platform:
name_colour: str
effects: bool
tick_s: float
spinner_tick_s: float
battery: str
modifier_watch: bool
@@ -117,6 +129,7 @@ REGULAR = Platform(
name_colour="key",
effects=True,
tick_s=1.0,
spinner_tick_s=0.12,
battery="companion",
modifier_watch=False,
)
@@ -140,7 +153,8 @@ PICOCALC = Platform(
truecolor=False,
name_colour="type",
effects=False,
tick_s=1.0,
tick_s=2.0,
spinner_tick_s=0.5,
battery="host",
modifier_watch=True,
)
+3 -4
View File
@@ -22,7 +22,7 @@ from rich.table import Table
from rich.text import Text
from ..context import AppContext
from ..core.geo import usable_fix
from ..core.geo import DEFAULT_VIEW_FRACTION, usable_fix
from ..core.models import NODE_TYPE_REPEATER
from .base import Tool, ToolResult, register
@@ -64,7 +64,7 @@ class MapTool(Tool):
interactive = isinstance(ctx.ui, TuiUi) and not params.get("static")
if interactive:
from ..ui.map_screen import DEFAULT_VIEW_FRACTION, open_map
from ..ui.map_screen import open_map
await open_map(
ctx, markers, fraction=params.get("fraction", DEFAULT_VIEW_FRACTION)
@@ -103,7 +103,7 @@ class MapTool(Tool):
from ..core.geo import Viewport
from ..ui.map_render import render_map
from ..ui.map_screen import DEFAULT_VIEW_FRACTION, basemap_source
from ..ui.map_screen import basemap_source
cell_w = int(params.get("width") or min(ctx.console.size.width - 2, 160))
cell_h = max(12, cell_w * 4 // 13) # keep roughly the terminal's aspect
@@ -142,7 +142,6 @@ class MapTool(Tool):
app: The Typer application.
"""
from ..cli import run_tool_command
from ..ui.map_screen import DEFAULT_VIEW_FRACTION
@app.command(name=self.name, help=self.help)
def _map(
+4 -6
View File
@@ -34,7 +34,7 @@ from .tui.prompt import (
)
from .tui.render import render_hanging, render_lines, right_aligned_tail
from .tui.screen import CANCEL, Screen
from .tui.spinner import Spinner
from .tui.spinner import Spinner, spinner_interval
if TYPE_CHECKING:
from ..context import AppContext
@@ -56,9 +56,6 @@ _MENTION = re.compile(r"@\[([^\]]{1,20})\]")
_DELIVERED = ("✓", "ok")
_FAILED = ("✗", "err")
#: Seconds between spinner frames on a message that is still awaiting its ack.
_SPINNER_INTERVAL = 0.12
#: The sender-prefix parser, shared app-wide from the protocol layer (the transcript,
#: the conversation picker, the dashboard feed, and the message-paths matcher must all
#: split ``Name: body`` identically). Kept under its old private name for the callers
@@ -684,7 +681,8 @@ class ChatScreen(Screen):
"""Await ``coro`` while animating the delivery spinner on the in-flight message.
A background timer advances the shared spinner and repaints every
:data:`_SPINNER_INTERVAL` seconds, so a pending message's trailing glyph spins until
:func:`~meshterm.ui.tui.spinner.spinner_interval` seconds (the platform's spin
cadence), so a pending message's trailing glyph spins until
the ack resolves. The timer is always cancelled (and awaited, so it can't outlive the
send as a stray pending task) before returning. The spinner is cosmetic, so any hiccup
in the animation is swallowed rather than allowed to break the send.
@@ -693,7 +691,7 @@ class ChatScreen(Screen):
async def animate() -> None:
while True:
await asyncio.sleep(_SPINNER_INTERVAL)
await asyncio.sleep(spinner_interval())
self._spinner.tick()
self._session.invalidate()
+1 -5
View File
@@ -26,7 +26,7 @@ import asyncio
import math
from typing import TYPE_CHECKING, Callable, Optional
from ..core.geo import EARTH_RADIUS_KM, Viewport, clamp_lat
from ..core.geo import DEFAULT_VIEW_FRACTION, EARTH_RADIUS_KM, Viewport, clamp_lat
from ..core.mvt import Layer
from ..services.basemap import BasemapSource
from .map_render import MapMarker, render_map
@@ -49,10 +49,6 @@ _PAN_DIRS: dict[str, tuple[int, int]] = {
#: How far past the tile source's max zoom the display may go (lower tiles are magnified).
_OVERZOOM = 2
#: Default fraction of nodes the map frames on open — the densest half, so a few distant
#: outliers don't zoom the whole mesh out to a continent. See :meth:`geo.Viewport.fit`.
DEFAULT_VIEW_FRACTION = 0.5
#: The zoom an Enter-to-frame homes in at when the matches set no extent of their own — a
#: single node (or several at one spot) has nothing to frame, so Enter zooms to this
#: street-level closeness rather than the fit's neutral default. Capped at the tile
+66 -36
View File
@@ -42,6 +42,7 @@ from .tui import (
TuiSession,
)
from .tui.emoji_width import calibrate as calibrate_emoji_width
from .tui.spinner import spinner_interval
#: Grace period (seconds) allowed for the whole exit sequence — the full-screen unwind,
@@ -61,9 +62,6 @@ _LIVENESS_POLL_S = 2.0
#: can't fire a false "disconnected" prompt (seconds).
_LIVENESS_CONFIRM_S = 0.4
#: Seconds between frames of the reconnect dialog's spinner while it waits for the device.
_RECONNECT_SPINNER_S = 0.12
def _arm_exit_watchdog(seconds: float = _EXIT_WATCHDOG_S) -> None:
"""Guarantee the process terminates even if the exit path wedges.
@@ -126,6 +124,11 @@ _SPARK_MIN_CELLS = 24
#: many packets-per-minute, so a single stray reads as a small nub, not a full column.
_HEADER_ACTIVITY_FLOOR = 3.0
#: The header's default segment separator, and the tighter one it falls back to when the
#: roomy form would squeeze the pulse below :data:`_SPARK_MIN_CELLS` (see :func:`_header`).
_SEP_ROOMY = " · "
_SEP_COMPACT = " · "
def _header(ctx: AppContext, cache: dict, width: int) -> Text:
"""Build the persistent one-line header: who's connected, unread mail, mesh pulse.
@@ -156,15 +159,30 @@ def _header(ctx: AppContext, cache: dict, width: int) -> Text:
# leading separator) before the pulse claims the rest; a wider separator estimate for
# the fit decision is harmless slack.
battery = _battery_segment(ctx)
reserve = (cell_len(" · ") + battery.cell_len) if battery.cell_len else 0
sep = " · "
header = _header_segments(ctx, cache, sep)
if width - header.cell_len - reserve < _SPARK_MIN_CELLS:
sep = " · "
header = _header_segments(ctx, cache, sep)
reserve = (cell_len(sep) + battery.cell_len) if battery.cell_len else 0
# The separator choice changes only the *joins*, never the segments themselves, so the
# segments are built once and each candidate width is arithmetic: one separator per
# segment (each is preceded by one, and a trailing one leads into the pulse). Building
# them twice to measure the second option was pure waste on exactly the narrow terminals
# that take the compact branch every repaint — the PicoCalc's 53 columns among them.
pieces = _header_segments(ctx, cache)
content = sum(piece.cell_len for piece in pieces)
def _measure(sep: str) -> tuple[int, int]:
"""The joined header width and the right-edge reserve, for one separator."""
joined = content + len(pieces) * cell_len(sep)
return joined, (cell_len(sep) + battery.cell_len) if battery.cell_len else 0
sep = _SEP_ROOMY
header_w, reserve = _measure(sep)
if width - header_w - reserve < _SPARK_MIN_CELLS:
sep = _SEP_COMPACT
header_w, reserve = _measure(sep)
header = Text()
for piece in pieces:
header.append_text(piece)
header.append(sep)
# Two dot columns per cell: every cell left of the reserved tail shows two minutes.
room = width - header.cell_len - reserve
room = width - header_w - reserve
if room > 0:
# Buckets seeded from a previous session's stored history draw grey; only
# traffic this session actually heard pulses green.
@@ -189,7 +207,7 @@ def _header(ctx: AppContext, cache: dict, width: int) -> Text:
# gauge land against the edge. With no room for a pulse (a very narrow terminal),
# pad instead so the gauge still sits in the corner rather than trailing the text.
if room <= 0:
header.append(" " * max(0, width - header.cell_len - reserve))
header.append(" " * max(0, width - header_w - reserve))
header.append(sep)
header.append_text(battery)
return header
@@ -220,49 +238,61 @@ def _battery_segment(ctx: AppContext) -> Text:
reading = ctx.battery.reading()
if reading is None:
return Text()
frame = int(time.monotonic() / _BATTERY_ANIM_S)
# Frame 0 is the gauge's resting state: the true fill, unblinking. A platform without
# effects holds it there, so the charging sweep and the low-battery blink never run —
# both exist to catch the eye, and neither is worth a forced repaint (nor, on a 16-slot
# console, a colour swap) on hardware where the cells are dear.
frame = int(time.monotonic() / _BATTERY_ANIM_S) if get_platform().effects else 0
return battery_cell(reading.percent, charging=reading.charging, frame=frame)
def _header_segments(ctx: AppContext, cache: dict, sep: str) -> Text:
"""The header's fixed segments — everything left of the pulse — at one separator width.
def _header_segments(ctx: AppContext, cache: dict) -> list[Text]:
"""The header's fixed segments — everything left of the pulse — unjoined.
Built twice per repaint at worst (roomy first, compact if the row is tight; see
:func:`_header`). The trailing separator is included, so the caller can append the
sparkline directly after it.
Separator-free by design: which separator fits depends on how wide these come out, and
the choice changes nothing about the segments themselves, so :func:`_header` measures
both options arithmetically off this one build and joins once.
Args:
ctx: The shared application context.
cache: The device-label cache (see :func:`_device_label`).
sep: The separator between segments (``" · "`` or the compact ``" · "``).
Returns:
The fixed-left portion of the header row.
The segments in display order — the app mark, the device, then whichever badges
have something to report. :func:`_header` writes one separator after each.
"""
header = Text()
header.append("MeshTerm", style="brand")
header.append(f" v{__version__}", style="muted")
header.append(sep)
# Each segment starts as an unstyled Text and takes its styles per append. A base style
# passed to the constructor would instead blanket everything appended after it, layering
# the mark's brand under the version's muted.
mark = Text()
mark.append("MeshTerm", style="brand")
mark.append(f" v{__version__}", style="muted")
segments = [mark]
device = Text()
if ctx.mock:
header.append("simulator", style="warn")
device.append("simulator", style="warn")
else:
name, where = _device_label(ctx, cache)
header.append(name or "no device", style=None if name else "muted")
device.append(name or "no device", style=None if name else "muted")
if where:
header.append(f" ({where})", style="muted")
device.append(f" ({where})", style="muted")
segments.append(device)
unread = ctx.chat.unread_total()
if unread:
header.append(sep)
header.append("●", style="err")
header.append(f" {unread}", style="warn")
badge = Text()
badge.append("●", style="err")
badge.append(f" {unread}", style="warn")
segments.append(badge)
alerts = ctx.watchtower.unacked_count()
if alerts:
# The Watchtower's badge: a triangle so it never reads as unread mail.
header.append(sep)
header.append("▲", style="err")
header.append(f" {alerts}", style="warn")
header.append(sep)
return header
badge = Text()
badge.append("▲", style="err")
badge.append(f" {alerts}", style="warn")
segments.append(badge)
return segments
def _device_label(ctx: AppContext, cache: dict) -> tuple[str, str]:
@@ -878,7 +908,7 @@ async def _handle_disconnect(ctx: AppContext, session: TuiSession) -> bool:
async def _animate_dialog(session: TuiSession, dialog: ReconnectDialog) -> None:
"""Advance the reconnect dialog's spinner and repaint on a steady cadence, until cancelled."""
while True:
await asyncio.sleep(_RECONNECT_SPINNER_S)
await asyncio.sleep(spinner_interval())
dialog.tick()
session.invalidate()
+3 -6
View File
@@ -46,16 +46,13 @@ from .menus import (
)
from .trace_screen import TracingDialog
from .tui import Choice, Separator
from .tui.spinner import Spinner
from .tui.spinner import Spinner, spinner_interval
from .widgets import _age_seconds, _format_age
if TYPE_CHECKING:
from ..context import AppContext
from ..core.connection import Device
#: Seconds between spinner frames while a remote command is in flight.
_SPINNER_INTERVAL = 0.12
#: Seconds to wait for one remote reply. Repeaters answer over the mesh — multi-hop
#: routes take seconds — so this is deliberately more patient than a local command.
_REPLY_TIMEOUT_S = 10.0
@@ -470,7 +467,7 @@ async def _run_under_dialog(ctx: "AppContext", title: str, work) -> bool:
async def animate() -> None:
while True:
await asyncio.sleep(_SPINNER_INTERVAL)
await asyncio.sleep(spinner_interval())
spinner.tick()
session.invalidate()
@@ -608,7 +605,7 @@ async def _command_line(ctx: "AppContext", device: "Device", node: Contact) -> N
async def animate() -> None:
while True:
await asyncio.sleep(_SPINNER_INTERVAL)
await asyncio.sleep(spinner_interval())
if screen.busy:
screen.tick()
session.invalidate()
+4 -7
View File
@@ -100,16 +100,13 @@ from .menus import back_rows, section_heading
from .theme import snr_style
from .tui.render import render_lines, render_to_ansi
from .tui.screen import ListWindow, Screen
from .tui.spinner import Spinner
from .tui.spinner import Spinner, spinner_interval
from .pathline import PathHop, PathLine, path_line
from .widgets import NodeResolver, _link_text, _route_path, highlighted_hash
if TYPE_CHECKING:
from ..context import AppContext
#: Seconds between spinner frames while a trace or probe is in flight.
_SPINNER_INTERVAL = 0.12
#: How wide the per-hop SNR quality bars draw, in characters. Each character packs
#: two fill steps (see :func:`snr_bar`), so the bar reads at 16-step resolution in
#: half the columns a one-step-per-cell block bar would need.
@@ -525,7 +522,7 @@ class TraceScreen(Screen):
async def _animate(self) -> None:
"""Advance the in-flight spinner and repaint on a steady cadence, until cancelled."""
while True:
await asyncio.sleep(_SPINNER_INTERVAL)
await asyncio.sleep(spinner_interval())
self._spinner.tick()
self._session.invalidate()
@@ -1534,7 +1531,7 @@ async def _open_session(
async def animate() -> None:
while True:
await asyncio.sleep(_SPINNER_INTERVAL)
await asyncio.sleep(spinner_interval())
spinner.tick()
session.invalidate()
@@ -1807,7 +1804,7 @@ async def _open_session(
async def animate() -> None:
while True:
await asyncio.sleep(_SPINNER_INTERVAL)
await asyncio.sleep(spinner_interval())
spinner.tick()
session.invalidate()
+9 -2
View File
@@ -14,6 +14,7 @@ from rich.console import Group, RenderableType
from rich.panel import Panel
from rich.text import Text
from ...platforms import get_platform
from ..theme import hint_style, title_style
from .glow import apply_corner_glow
from .render import render_lines
@@ -189,8 +190,14 @@ def compose_base(
viewport = max(1, rows - header_h - 1 - 2) # minus footer(1) and panel border(2)
panel = _panel(base, cols - 4, viewport, active=True)
footer = Text.from_markup(f"[muted]{footer_hint}[/muted]")
# The glow pass lights the outer frame *and* any tool panels nested in the body.
lines = header_lines + apply_corner_glow(render_lines(Group(panel, footer), cols))
# The glow pass lights the outer frame *and* any tool panels nested in the body. It only
# ever recolours truecolor foregrounds (see glow._advance_fg), so on a platform without
# effects — which is also a platform without truecolor — it would scan every line of
# every frame and hand back the identical list. Skipping it outright is the same picture
# for none of the work. Read live rather than bound at import: set_platform() runs in the
# CLI callback, long after this module is imported (see meshterm.platforms).
body = render_lines(Group(panel, footer), cols)
lines = header_lines + (apply_corner_glow(body) if get_platform().effects else body)
# Guarantee we never exceed the terminal height (pt would otherwise clip unpredictably).
if len(lines) > rows:
lines = lines[:rows]
+11 -1
View File
@@ -234,7 +234,15 @@ def _has_wide_glyph(text: str) -> bool:
marks (``▲●■``) and chart braille are width-1 everywhere, so they never trip this. The
``>= 0x1100`` guard skips the ASCII/Latin bulk of a frame before the width lookup, which
matters because this runs over the whole composed frame on every repaint.
On a platform that draws no emoji the answer is ``False`` by construction — its console
font is a fixed 512-glyph set with no wide glyph in it, so nothing a frame can contain
would return ``True``. Answering from the platform instead of the text skips a
per-character scan of the entire frame on every repaint, and with it the scrub machinery
that a ``True`` would arm, leaving the cheap differential paint permanently in play.
"""
if not get_platform().emoji:
return False
return any(ord(ch) >= 0x1100 and get_cwidth(ch) == 2 for ch in text)
@@ -1112,7 +1120,9 @@ class TuiSession:
key_bindings=self._key_bindings(),
full_screen=True,
mouse_support=False,
refresh_interval=1.0, # keep the live monitor counter in the header ticking
# Keeps the live monitor counter in the header ticking. Per-platform, so a host
# where an idle repaint is expensive can breathe more slowly between frames.
refresh_interval=get_platform().tick_s,
input=self._input,
output=self._resolve_output(),
)
+25 -2
View File
@@ -13,6 +13,23 @@ from typing import Optional
from rich.text import Text
from ...platforms import get_platform
def spinner_interval() -> float:
"""Seconds a "working" animation should wait between frames, on this platform.
The single source of the app's spin cadence — every animated wait sleeps on this rather
than on its own constant, so the rate is one platform decision instead of five copies
drifting apart. Called at each tick rather than read into a module constant: a constant
would freeze whatever platform was active at *import* time, which is always the default
(``set_platform`` runs later, in the CLI callback — see :mod:`meshterm.platforms`).
Returns:
The active platform's :attr:`~meshterm.platforms.Platform.spinner_tick_s`.
"""
return get_platform().spinner_tick_s
class Spinner:
"""An animated spinner that cycles through a set of frame glyphs.
@@ -26,13 +43,19 @@ class Spinner:
#: A plain ASCII cycle for terminals/fonts without Braille glyphs.
LINE = "|/-\\"
def __init__(self, frames: str = BRAILLE, *, style: str = "accent") -> None:
def __init__(self, frames: Optional[str] = None, *, style: str = "accent") -> None:
"""Create a spinner resting on its first frame.
Args:
frames: The glyphs to cycle through, one per animation frame.
frames: The glyphs to cycle through, one per animation frame. Defaults to the
active platform's cycle — :data:`BRAILLE` where effects are on, the
four-frame :data:`LINE` where they aren't. Resolved here, at construction,
so a spinner built after ``set_platform`` picks the right cycle and never
re-decides while it spins.
style: The Rich style :meth:`text` applies to the current glyph.
"""
if frames is None:
frames = self.BRAILLE if get_platform().effects else self.LINE
if not frames:
raise ValueError("a spinner needs at least one frame")
self._frames = frames
+2 -5
View File
@@ -46,15 +46,12 @@ from .theme import name_style, snr_style
from .trace_screen import TracingDialog, _collapse_trace_width, snr_bar
from .tui.render import render_lines, render_to_ansi
from .tui.screen import Screen
from .tui.spinner import Spinner
from .tui.spinner import Spinner, spinner_interval
from .widgets import NodeResolver
if TYPE_CHECKING:
from ..context import AppContext
#: Seconds between spinner frames while the sweep is measuring.
_SPINNER_INTERVAL = 0.12
#: Human phrasing for each optimizer phase reported through ``on_phase``.
_PHASE_LABELS = {
"coarse": "coarse sweep",
@@ -963,7 +960,7 @@ async def _animate(
) -> None:
"""Advance the in-flight dialog's spinner and status on a steady cadence."""
while True:
await asyncio.sleep(_SPINNER_INTERVAL)
await asyncio.sleep(spinner_interval())
spinner.tick()
flight.status = screen.phase_label()
session.invalidate()
+1 -1
View File
@@ -582,7 +582,7 @@ async def test_direct_send_spins_until_the_ack_resolves(monkeypatch) -> None:
"""A pending direct message spins while the send is in flight, then settles on ✓."""
from meshterm.ui.tui.spinner import Spinner
monkeypatch.setattr("meshterm.ui.chat._SPINNER_INTERVAL", 0.005)
monkeypatch.setattr("meshterm.ui.chat.spinner_interval", lambda: 0.005)
session = _StubSession()
release = asyncio.Event()
+2 -2
View File
@@ -704,7 +704,7 @@ async def test_handle_disconnect_auto_reconnects_when_port_returns(
monkeypatch.setattr(connection, "serial_port_present", fake_present)
monkeypatch.setattr(AppContext, "reconnect", fake_reconnect)
monkeypatch.setattr(menu, "_LIVENESS_POLL_S", 0.0)
monkeypatch.setattr(menu, "_RECONNECT_SPINNER_S", 0.0)
monkeypatch.setattr(menu, "spinner_interval", lambda: 0.0)
session = _FakeSession()
try:
@@ -729,7 +729,7 @@ async def test_handle_disconnect_quits_when_user_presses_quit(
# The port never comes back, so the only way out is the Quit button.
monkeypatch.setattr(connection, "serial_port_present", lambda port: False)
monkeypatch.setattr(menu, "_LIVENESS_POLL_S", 0.0)
monkeypatch.setattr(menu, "_RECONNECT_SPINNER_S", 0.0)
monkeypatch.setattr(menu, "spinner_interval", lambda: 0.0)
session = _FakeSession()
+19
View File
@@ -32,6 +32,25 @@ def test_header_shows_the_watchtower_badge() -> None:
assert "▲" not in _header(_ctx(alerts=0), {}, 80).plain
def test_header_segments_style_each_run_on_its_own() -> None:
"""A segment's styles sit side by side, never layered one under the next.
The segments are built as their own ``Text`` objects and joined (see
:func:`~meshterm.ui.menu._header_segments`), which makes it easy to reach for a
constructor ``style=`` — but that becomes the object's *base* style and blankets
everything appended after it, so the version string would render brand-under-muted and
each badge's count would inherit its glyph's error red.
"""
header = _header(_ctx(unread=3, alerts=2), {}, 120)
covering = {
header.plain[span.start : span.end]: span.style
for span in header.spans
if span.style in ("brand", "err")
}
assert covering.get("MeshTerm") == "brand" # not "MeshTerm v1.2.3"
assert set(covering) == {"MeshTerm", "●", "▲"} # neither badge's count is swept in
def test_header_sparkline_fills_the_row_exactly() -> None:
"""Every cell the fixed segments leave is sparkline — no short rows, no overflow."""
for width in (60, 100):
+220
View File
@@ -0,0 +1,220 @@
"""The P2 performance contracts: what must stay cheap, and what must stay off the boot path.
These are behaviour tests, not benchmarks — a wall-clock assertion would be flaky on CI and
would say nothing about *why* a regression happened. Each test instead pins the structural
property the optimisation rests on: the database opens in the mode that makes small writes
cheap, the frame skips the passes that cannot change its pixels, and the startup import graph
stays clear of the three subtrees that were dragging a fifth of a second each onto every run.
The numbers behind these choices were measured on the PicoCalc (Luckfox Lyra, ~1 GHz
Cortex-A7, SD card) and are recorded in the plan's Measurements appendix.
"""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
from rich.text import Text
from meshterm.persistence import db
from meshterm.platforms import PICOCALC, REGULAR, set_platform
from meshterm.ui.tui.frame import compose_base
from meshterm.ui.tui.screen import Screen
from meshterm.ui.tui.session import _has_wide_glyph
from meshterm.ui.tui.spinner import Spinner
# --- the database's write path ------------------------------------------------------
def test_connect_opens_in_wal_mode(tmp_path: Path) -> None:
"""WAL is what turns each tiny insert into an append instead of a journal rewrite."""
conn = db.connect(tmp_path / "wal.db")
assert conn.execute("PRAGMA journal_mode").fetchone()[0].lower() == "wal"
def test_relaxed_sync_is_only_taken_together_with_wal(tmp_path: Path) -> None:
"""``synchronous=NORMAL`` is safe under WAL and corrupting without it — never split.
Under WAL, NORMAL risks losing the last transactions to a power cut; under the rollback
journal it risks the file itself. The PicoCalc runs on a battery pack, so the two
settings travel together or not at all.
"""
conn = db.connect(tmp_path / "sync.db")
journal = conn.execute("PRAGMA journal_mode").fetchone()[0].lower()
synchronous = conn.execute("PRAGMA synchronous").fetchone()[0]
if journal == "wal":
assert synchronous == 1 # NORMAL
else: # pragma: no cover - only on a filesystem that refuses WAL
assert synchronous == 2 # FULL, the safe default left untouched
def test_temp_store_is_memory(tmp_path: Path) -> None:
"""Sorts and temporary b-trees stay in RAM rather than landing on the SD card."""
conn = db.connect(tmp_path / "temp.db")
assert conn.execute("PRAGMA temp_store").fetchone()[0] == 2 # MEMORY
def test_tuning_survives_a_reopen(tmp_path: Path) -> None:
"""WAL is persistent, so a second session inherits it — and with it the relaxed sync."""
path = tmp_path / "reopen.db"
db.connect(path).close()
conn = db.connect(path)
assert conn.execute("PRAGMA journal_mode").fetchone()[0].lower() == "wal"
assert conn.execute("PRAGMA synchronous").fetchone()[0] == 1
# --- what must not be imported at startup -------------------------------------------
#: Subtrees that cost a fifth of a second *each* to import on the Lyra and are needed by no
#: startup path: PyCryptodome probes the CPU's crypto features (reached only through
#: ``core.channels``, whose constants the boot path wanted), and the map screen drags the
#: basemap's tile fetcher — ``urllib.request`` → ``http.client`` → ``ssl`` — behind it.
_MUST_STAY_LAZY = ("Crypto", "urllib.request", "http.client", "meshterm.ui.map_screen")
def test_startup_does_not_import_the_heavy_optional_subtrees() -> None:
"""``import meshterm.cli`` stays clear of crypto, the network stack, and the map.
Run in a subprocess because the assertion is about a *fresh* interpreter's import graph:
by the time this suite is running, half the codebase is already in ``sys.modules``.
"""
probe = (
"import sys, meshterm.cli;"
"print(','.join(sorted(m for m in sys.modules"
f" if any(m == h or m.startswith(h + '.') for h in {_MUST_STAY_LAZY!r}))))"
)
out = subprocess.run(
[sys.executable, "-c", probe], capture_output=True, text=True, check=True
).stdout.strip()
assert out == "", f"these should not load at startup: {out}"
def test_the_deferred_crypto_still_decrypts() -> None:
"""Deferring the import must not have cost the function its names."""
from meshterm.core.channels import decrypt_channel_text, identify_channel
# No known channel matches, but reaching the "no match" answer means HMAC/AES resolved.
assert identify_channel("ab", "cdef", "00" * 16, [("#public", b"\x01" * 16)]) is None
assert decrypt_channel_text("ab", "cdef", "00" * 16, [("#public", b"\x01" * 16)]) is None
def test_map_default_fraction_is_the_same_constant_from_either_module() -> None:
"""The map screen re-exports the geo constant, so existing importers still see one value."""
from meshterm.core.geo import DEFAULT_VIEW_FRACTION as from_geo
from meshterm.ui.map_screen import DEFAULT_VIEW_FRACTION as from_screen
assert from_geo == from_screen
# --- per-frame work the platform can skip -------------------------------------------
class _Body(Screen):
"""A minimal screen, just enough for ``compose_base`` to frame something."""
title = "Perf"
def render_body(self, width: int) -> list[str]: # noqa: D102 - inherited docstring
return ["body"]
def _glow_calls(cols: int = 60, rows: int = 20) -> int:
"""Compose one frame, counting how many times the corner-glow pass ran."""
import meshterm.ui.tui.frame as frame_mod
original = frame_mod.apply_corner_glow
calls = 0
def counting(lines: list[str]) -> list[str]:
nonlocal calls
calls += 1
return original(lines)
frame_mod.apply_corner_glow = counting
try:
compose_base(Text("hdr"), _Body(), "Esc back", cols, rows)
finally:
frame_mod.apply_corner_glow = original
return calls
def test_glow_runs_on_regular() -> None:
"""The desktop frame keeps its lit corners."""
assert _glow_calls() == 1
def test_glow_is_skipped_where_effects_are_off() -> None:
"""The pass only recolours truecolor foregrounds, so on PicoCalc it is pure scan cost."""
set_platform(PICOCALC)
assert _glow_calls() == 0
def test_wide_glyph_scan_short_circuits_without_emoji() -> None:
"""A no-emoji console font has no wide glyph, so the per-frame scan can be skipped."""
waving = "hi \U0001f44b"
assert _has_wide_glyph(waving) is True
set_platform(PICOCALC)
assert _has_wide_glyph(waving) is False
def test_spinner_cycle_follows_the_platform() -> None:
"""Braille where effects are on, the plain LINE cycle where they aren't."""
assert Spinner().frames == Spinner.BRAILLE
set_platform(PICOCALC)
assert Spinner().frames == Spinner.LINE
def test_an_explicit_spinner_cycle_still_wins() -> None:
"""The platform supplies a *default*; a caller that names its frames keeps them."""
set_platform(PICOCALC)
assert Spinner("ab").frames == "ab"
def test_battery_gauge_holds_still_where_effects_are_off() -> None:
"""No charging sweep and no low-battery blink: the gauge draws its resting frame."""
from meshterm.services.battery_service import BatteryReading
from meshterm.ui.menu import _battery_segment
class _Ctx:
class battery: # noqa: N801 - a stand-in for the service, not a real class name
@staticmethod
def reading() -> BatteryReading:
return BatteryReading(millivolts=3300, percent=5, charging=True)
ctx = _Ctx()
set_platform(PICOCALC)
# Frame 0 of a charging sweep is the empty cell; any later frame differs. Two calls at
# different wall-clock instants must agree, which they only can if the clock isn't read.
first = _battery_segment(ctx).plain # type: ignore[arg-type]
second = _battery_segment(ctx).plain # type: ignore[arg-type]
assert first == second
set_platform(REGULAR)
assert _battery_segment(ctx).plain.endswith("5%") # type: ignore[arg-type]
def test_idle_and_spin_cadences_are_slower_where_a_frame_is_dear() -> None:
"""PicoCalc composes a frame in ~74 ms, so both animation clocks have to give.
At the desktop's rates a 1 Hz idle tick alone would spend 7-11% of the core doing
nothing, and a 0.12 s spinner would ask for more frames per second than the hardware
can physically compose. Neither is a free-floating preference: both must stay strictly
slower than regular's.
"""
assert REGULAR.tick_s == 1.0
assert REGULAR.spinner_tick_s == 0.12
assert PICOCALC.tick_s > REGULAR.tick_s
# A frame costs ~110 ms at the taller 53x40 geometry; a spin cadence under that would
# queue repaints faster than they can finish.
assert PICOCALC.spinner_tick_s > 0.12
def test_spinner_interval_reads_the_active_platform() -> None:
"""One source for the spin cadence, re-read per tick so set_platform is never missed."""
from meshterm.ui.tui.spinner import spinner_interval
assert spinner_interval() == REGULAR.spinner_tick_s
set_platform(PICOCALC)
assert spinner_interval() == PICOCALC.spinner_tick_s