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_at atomic — no vt100 lock needed to notice new output.
  • Status transitions (a quiet Busy → Waiting produces 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.

Idle work per second, before vs. after On a fully idle screen, frame paints drop from about 100 to 4 per second, and session-status table scans drop from about 100 to near zero per second. 100 50 0 before after (idle) 100 ~4 frame paints / s 100 ≈0 DB status scans / s
Idle-screen work per second. Demand-driven rendering (ADR-P1) takes paints from ~100 fps to the 4 fps forced-redraw floor; the session-status cache (ADR-P6) takes the per-tick 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 / scrollback are 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; a startup line is logged to thurbox.log with a phase breakdown (config_init_ms / db_open_ms / extension_heal_ms / restore_ms) that sums to first_frame_ms, plus per-backend restore_discover and per-session restore_adopt / adopt_split lines when session restore is the long pole.
  • Binary size — a non-gating CI job records the release thurbox / thurbox-cli sizes 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 the release-with-debug profile) and cargo bloat --release --crates ad hoc; neither is a project dependency.
Startup time to first frame, by phase A representative cold start restoring five sessions reaches the first frame in about 226 milliseconds; session restore is roughly 71 percent of it, while config load, database open, and extension heal are a few milliseconds each. restore ≈ 71% config load — 30 ms db open — 1 ms extension heal — 2 ms session restore — 160 ms init + first paint — 33 ms
Where startup time goes, from a real 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.