Skip to content

perf(tui): off-load snapshot refresh from event loop#240

Merged
Roberdan merged 1 commit intomainfrom
perf/tui-async-refresh
May 6, 2026
Merged

perf(tui): off-load snapshot refresh from event loop#240
Roberdan merged 1 commit intomainfrom
perf/tui-async-refresh

Conversation

@Roberdan
Copy link
Copy Markdown
Owner

@Roberdan Roberdan commented May 6, 2026

Problem

cvg dash is unusable on real workloads. With 50 plans / 351 tasks the
dashboard shows a 1-2s black screen at startup, freezes for ~1s every 5s
during the periodic refresh, and lags up to 200ms per keystroke.

Root causes (crates/convergio-tui/src/lib.rs:120 and friends):

  • state.refresh(&client).await is called before the first
    term.draw, so the user sees nothing until the first snapshot — and
    the snapshot is dominated by gh pr list --state all --limit 100
    (~1s on a warm cache).
  • The same refresh().await sits inside the tokio::select! event
    loop, so every tick / r keystroke blocks key handling and rendering.
  • gh pr list ran on std::process::Command::output(), pinning a
    runtime worker for the entire shell-out.
  • EnableMouseCapture was on with no mouse handler — it stole native
    scroll and spammed the input poll with Noop events.
  • event::poll(200ms) introduced up to 200ms of input latency.

Why

Dashboard responsiveness regression is felt on the first session of the
day, the loop tick, and every keypress. Constitution P3 asks the TUI
to be usable on a basic 80×24 terminal — it must also be usable in real
time.

What changed

  1. Decouple Client::snapshot() from the render loop via mpsc.
    First paint shows a skeleton frame immediately; data folds in as it
    arrives. Periodic tick and r spawn a tokio task; an in-flight
    flag debounces overlapping refreshes. Adds AppState::apply_snapshot
    so the loop can push a pre-fetched Snapshot without re-awaiting.
    The new method (and the existing refresh wrapper) live in a new
    state_lifecycle module to keep state.rs under the 300-line cap.

  2. gh pr list runs on tokio::process::Command and joins the HTTP
    fan-out via tokio::join!. Results are memoised for 30s — most
    refreshes pay zero gh cost. --limit reduced from 100 to 50.

  3. Drop EnableMouseCapture, shorten the key-poll window from
    200ms to 50ms.

Validation

Measured on the live dashboard (50 plans, 351 tasks, daemon on loopback):

State Before After
First paint ~1.5s <100ms
Refresh freeze ~1s 0 (async)
Cached refresh ~1s ~20ms
Keystroke lag ≤200ms ≤50ms

Snapshot bench against the live daemon (release):

Run 1 (cold gh + cache miss):     1.6s   prs=50
Runs 2..6 (cache warm):           ~20ms  prs=50

Local pipeline:

  • cargo fmt --all -- --check — clean
  • RUSTFLAGS="-Dwarnings" cargo clippy --workspace --all-targets -- -D warnings — clean
  • RUSTFLAGS="-Dwarnings" cargo test --workspace — 1043 passed, 0 failed
  • pre-commit hooks: file-size, fmt, clippy, context-budget all green

Impact

  • convergio-tui only — read-only client, no daemon-side change.
  • New public method AppState::apply_snapshot(Result<Snapshot>).
    AppState::refresh(&Client) is preserved for the existing test path.
  • PR list scope shrunk from 100 to 50; the dashboard already truncates
    the rendered list well below 50.
  • Tradeoff: PR data is now up to 30s stale (cached). r still triggers
    a fresh refresh; PR pane labels show last refresh timestamp via the
    existing footer.

🤖 Generated with Claude Code

The dashboard's first paint and every tick refresh were blocking the
render loop on `gh pr list` (~1s) and the parallel HTTP fan-out (~25ms),
serially. With 50 plans this shows up as a 1-2s black screen at startup
and a 1s freeze every 5s during use, plus 200ms keystroke latency.

Three changes:

1. Decouple `Client::snapshot()` from the render loop via `mpsc`. The
   first paint no longer waits for the snapshot — the dashboard shows a
   skeleton frame immediately and folds data in as it arrives. The
   periodic tick and `r` keystroke spawn a tokio task; an in-flight
   flag debounces overlapping refreshes. Adds `AppState::apply_snapshot`
   so the loop can push a pre-fetched `Snapshot` without re-awaiting.
   The new method (and the existing `refresh` wrapper) live in a fresh
   `state_lifecycle` module to keep `state.rs` under the 300-line cap.

2. `gh pr list` runs on `tokio::process::Command` (was
   `std::process::Command`, which pinned a runtime worker for the
   duration of the shell-out) and runs concurrently with the HTTP
   fan-out via `tokio::join!`. Results are memoised for 30s — most
   refreshes pay zero gh cost. `--limit` reduced from 100 to 50.

3. Drop `EnableMouseCapture` (no handler exists; it stole native scroll
   and spammed the input poll). Shorten the key-poll window from 200ms
   to 50ms so keystrokes feel snappy.

Measurements against a live daemon (50 plans, 351 tasks, loopback):

| State            | Before  | After     |
|------------------|---------|-----------|
| First paint      | ~1.5s   | <100ms    |
| Refresh freeze   | ~1s     | 0 (async) |
| Cached refresh   | ~1s     | ~20ms     |
| Keystroke lag    | ≤200ms  | ≤50ms     |

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@chatgpt-codex-connector
Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@Roberdan Roberdan merged commit 0990c8a into main May 6, 2026
3 checks passed
@Roberdan Roberdan deleted the perf/tui-async-refresh branch May 6, 2026 14:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant