Performance
How thurbox stays responsive and light, across four dimensions: input latency, runtime CPU /
render cost, startup time, and memory / binary size. The guiding principle is
measure first — every optimization is backed by a deterministic counter or
a concrete code fact, and proven by a test that fails if it regresses. Full rationale lives in
docs/PERFORMANCE.md.
Demand-Driven Rendering #
The render loop paints a frame only when the UI is dirty or a forced-redraw floor
elapsed — not on every iteration. The previous loop called
terminal.draw unconditionally at ~100 fps even on a completely idle screen, the
single largest idle-CPU cost in a long-lived TUI. State now drives the paint:
- Input marks the UI dirty, so a keypress paints on the next iteration (latency unchanged).
-
Agent output marks it dirty via a lock-free rolling signature over each
session's
last_output_atatomic — no vt100 lock needed to notice new output. - Status transitions (a quiet
Busy → Waitingproduces no output) request a redraw explicitly. - Everything time-driven — the live clock/metrics, cursor blink, an expiring toast — is covered by a 250 ms forced-redraw floor.
The loop still spins every ≤10 ms (poll input, check output, tick), but the expensive work — layout, the vt100 render, panel rebuilds — is skipped when idle. Idle paints drop from ~100 fps to ~4 fps while input and output repaints stay immediate: responsiveness is unchanged and idle CPU drops by roughly 25×.
Perf Counters #
Performance regressions are caught by counting, not timing. Wall-clock benchmarks
are flaky on shared CI runners; a counter assertion is exact and reproducible, and proves the
mechanism (work was skipped) rather than a wobbly proxy (it was fast today). The
in-process acceptance harness asserts on these wall-clock-free counters, gating the normal
cargo nextest run --all.
| Counter | Meaning |
|---|---|
frames_rendered |
a frame painted |
redraws_requested / redraws_skipped |
loop iterations that painted vs. skipped |
ordered_sessions_rebuilds |
session-list order rebuilt vs. served from cache |
parser_locks_render |
central pane locked a vt100 parser to render |
status_refreshes / automation_entries_built |
per-tick status pass, automations-pane builds |
hook_state_loads |
the session-status rows were actually reloaded from the DB (gated on a data_version change — stays flat while idle) |
external_poll_checks / external_poll_reloads |
the cross-instance PRAGMA data_version poll ran / found a change and reloaded shared state |
Session-Order Cache #
The session-list ordering (grouping, sorting, parent/child nesting) is a pure function of four per-session fields — repo names, manual order, id, and parent — and never of status. It is cached, keyed by a content signature (a hash of exactly those fields). On a frame where the signature is unchanged, the order is reused, skipping the grouping HashMap, two sort passes, nest recursion, and label allocations; only the cheap O(n) remap of refs and selection runs each frame.
Being content-derived, the cache is self-invalidating: any change that alters the order alters the hash, so there is no manual “mark dirty” call site to forget. This matters most while an agent streams output and the screen repaints every frame.
Session-Status Cache #
Session status (working / blocked / done / idle) is driven by agent hooks persisted in SQLite.
Deriving it used to re-scan the sessions table on every tick (~100×/s),
even on an idle screen where nothing changed. The rows are now cached and reloaded
only when the database's PRAGMA data_version moves — a single in-memory counter
read, far cheaper than an indexed scan plus row mapping. The per-tick derivation (the
Working spinner, the output-quiescence fallback, the done/seen logic) still runs every tick against
the cache, so status latency is unchanged.
An external thurbox-cli session signal commits on another connection, which bumps
data_version and is picked up on the next tick. The two writes that don't move
this connection's version — marking a done session
seen, and clearing state on restart — are handled explicitly (write-through and
cache invalidation), so the cache can never serve a stale status. The
hook_state_loads counter proves it: flat while idle, +1 per external signal.
sessions-table scan to near zero. Input and output latency are unchanged — the
loop still wakes every ≤10 ms, it just does less.
Deliberately Not Optimized #
Measuring first also means not adding complexity where the data says it won't pay off:
-
Scrollback round-trip — vt100's
set_scrollback/scrollbackare O(1), and demand-driven rendering already bounds how often it runs. Caching it would add borrow complexity for no measurable gain. - Automations-pane entries — the only repeated parse is the cron humanizer, the countdown must stay live, and automations are typically a handful. Left as-is.
- vt100 parser lock contention — demand-driven rendering collapses idle-frame UI locks to near zero, shrinking the contention window for free. Cloning the (large) vt100 screen for lock-free rendering would cost more than the brief lock it removes.
Measuring Performance #
The gating automated tests are the counter assertions above. Heavier measurement is opt-in and local, so the PR gate stays fast and free of flaky timing or extra dependencies:
-
Startup — launch with
THURBOX_PERF_LOG=1; astartupline is logged tothurbox.logwith a phase breakdown (config_init_ms/db_open_ms/extension_heal_ms/restore_ms) that sums tofirst_frame_ms, plus per-backendrestore_discoverand per-sessionrestore_adopt/adopt_splitlines when session restore is the long pole. -
Binary size — a non-gating CI job records the release
thurbox/thurbox-clisizes to the job summary and an artifact, so growth is visible without blocking merges. The release profile is already LTO-tuned and stripped. -
CPU / size attribution — run
cargo flamegraph(with therelease-with-debugprofile) andcargo bloat --release --cratesad hoc; neither is a project dependency.
THURBOX_PERF_LOG=1 run restoring five sessions
(~226 ms to first frame). The breakdown is what motivated measuring restore before any
parallelization — and what showed it is bounded by tmux's serialized control-mode
connection rather than by parallelizable work. Numbers vary with session count and scrollback.