Skip to content

perf: a fleet at rest still repainted twelve times a second - #149

Merged
navbytes merged 1 commit into
mainfrom
perf/calm-repaint
Aug 21, 2026
Merged

perf: a fleet at rest still repainted twelve times a second#149
navbytes merged 1 commit into
mainfrom
perf/calm-repaint

Conversation

@navbytes

Copy link
Copy Markdown
Owner

#146 stopped roost painting a frame it did not need, and set the budget
for the frames it does need at one C5 spinner frame — 80ms, because the
spinner is the finest thing on screen that moves with no event behind
it. That budget then applied whether or not any spinner was actually on
screen, and a fleet at rest is what roost spends most of its life being:
every pane idle or waiting, nobody at the keyboard, nothing moving at
all. Twelve frames a second of it.

So the budget is now two-tier. App::animating asks the one question
that separates them — is any pane's displayed status Working,
which is the single reason roost draws a spinner — and 500ms is spent
instead when the answer is no. Everything else time-driven is coarse (a
flash expiring, F1's hint window closing, a badge age tag ticking over a
minute), so half a second late is not a difference anyone can see.

idle, N shell panes, 200x50, release, no input
                    CPU     host bytes
before #144        2.75%       888 B/s
after  #144        1.70%       896 B/s
after  #146        0.60%       302 B/s
this change        0.20%        60 B/s

13x off roost's idle cost end to end, and 15x off its idle terminal
traffic. Flat in the pane count at every step — it was always fixed
per-frame chrome.

animating is not allowed to be load-bearing. CALM_REPAINT fires
unconditionally and does not consult it, so the property #146 was built
on is unchanged: there is no state in which the screen stops updating,
including one nobody thought to mark dirty or to teach animating
about. Getting it wrong costs a spinner stepping twice a second — a
stutter, never a freeze. It reads display_status (what the chrome that
draws the spinner reads) over runtimes (workspace-wide, because the
tab strip's summary and C27's roster spin for panes that are not on
screen), and it is a closure at the call site so the fleet walk happens
at most once per 80ms and never on an iteration that was going to paint
anyway.

Under load nothing changes: a pane producing output marks the loop dirty
and paints on the spot. tests/firehose.rs measures echo latency
p50=19ms max=57ms against its 250ms bound.

Gated by animating_is_true_exactly_while_the_fleet_has_a_spinner_on_ screen (including a pane working in a tab that is not on screen, and an
exited pane not counting) and by the extended
the_repaint_budget_always_fires_and_never_outruns_the_spinner, which
pins the ceiling firing on its own, the fine budget being the finer one,
and the fleet walk being asked only where its answer matters.

#146 stopped roost painting a frame it did not need, and set the budget
for the frames it does need at one C5 spinner frame — 80ms, because the
spinner is the finest thing on screen that moves with no event behind
it. That budget then applied whether or not any spinner was actually on
screen, and a fleet at rest is what roost spends most of its life being:
every pane idle or waiting, nobody at the keyboard, nothing moving at
all. Twelve frames a second of it.

So the budget is now two-tier. `App::animating` asks the one question
that separates them — is any pane's **displayed** status `Working`,
which is the single reason roost draws a spinner — and 500ms is spent
instead when the answer is no. Everything else time-driven is coarse (a
flash expiring, F1's hint window closing, a badge age tag ticking over a
minute), so half a second late is not a difference anyone can see.

    idle, N shell panes, 200x50, release, no input
                        CPU     host bytes
    before #144        2.75%       888 B/s
    after  #144        1.70%       896 B/s
    after  #146        0.60%       302 B/s
    this change        0.20%        60 B/s

13x off roost's idle cost end to end, and 15x off its idle terminal
traffic. Flat in the pane count at every step — it was always fixed
per-frame chrome.

**`animating` is not allowed to be load-bearing.** `CALM_REPAINT` fires
unconditionally and does not consult it, so the property #146 was built
on is unchanged: there is no state in which the screen stops updating,
including one nobody thought to mark dirty or to teach `animating`
about. Getting it wrong costs a spinner stepping twice a second — a
stutter, never a freeze. It reads `display_status` (what the chrome that
draws the spinner reads) over `runtimes` (workspace-wide, because the
tab strip's summary and C27's roster spin for panes that are not on
screen), and it is a closure at the call site so the fleet walk happens
at most once per 80ms and never on an iteration that was going to paint
anyway.

Under load nothing changes: a pane producing output marks the loop dirty
and paints on the spot. `tests/firehose.rs` measures echo latency
p50=19ms max=57ms against its 250ms bound.

Gated by `animating_is_true_exactly_while_the_fleet_has_a_spinner_on_
screen` (including a pane working in a tab that is not on screen, and an
exited pane not counting) and by the extended
`the_repaint_budget_always_fires_and_never_outruns_the_spinner`, which
pins the ceiling firing on its own, the fine budget being the finer one,
and the fleet walk being asked only where its answer matters.
@navbytes
navbytes merged commit f9e1635 into main Aug 21, 2026
2 checks passed
@navbytes
navbytes deleted the perf/calm-repaint branch August 21, 2026 04:56
navbytes added a commit that referenced this pull request Aug 21, 2026
…e request (#155)

v0.1.10 shipped at 01:48 UTC today, before any of this session's work
landed, so everything below is unreleased. Touching
`.github/release-request` dispatches Release, which builds four targets,
creates the `v0.1.11` tag from Cargo.toml itself, and publishes with
SHA256SUMS.txt.

**Two silent data-loss bugs.**

- `Alt+w` on the last pane — whose own prompt calls it "quit roost" —
  removed the pane and *then* quit, so `shutdown` saved a tab whose
  layout named a pane its map no longer had. The repair minted a blank
  `shell` spec on next launch, and the session id, cwd, title and note
  of the pane you quit on were gone. `Alt+q` on the same pane kept all
  of them. (#147)
- A recycled pane id inherited the dead pane's session-detection clock.
  `last_shell_seen` and `pending_detect` were the only two
  `PaneId`-keyed maps `close_pane_id` did not prune, so a pane taking
  that id could scan hours back and resume a stranger's conversation,
  permanently. (#141)

**Idle cost down ~13x.** 2.75% CPU and 888 B/s of terminal traffic, to
0.2–0.4% and ~62 B/s, flat in the pane count at every step because it
was all fixed per-frame chrome: a `String` allocated per cell per frame
(#140); the quick-launch picker's `$PATH` probe running ~1,500 `stat(2)`
a second with no dialog on screen, plus the whole help table, every
frame (#144); an unconditional 30 fps repaint (#146); and 12 fps even
with nothing animating (#149).

**Desktop notifications changed channel, and this one is user-visible.**
They used to fork `osascript`, which macOS attributes to **Script
Editor** — there is no flag for that; a CLI cannot post under its own
name. roost now asks the terminal instead, with a bell and an `OSC 9`,
which is the mechanism SPEC-parity P2 already named. Ghostty, iTerm2,
WezTerm and kitty raise a real notification from it, attributed to
themselves, and it survives ssh. **A host that ignores OSC 9 (Apple
Terminal, Alacritty) now gets only the bell.** (#152)

And a pane's notification no longer arrives twice: there were two
emitters, invisible as a duplicate only while one of them was the
`osascript` fork. The named one won, so one banner, saying which pane,
and silence for the pane you are already looking at. (#153)

**Three new fuzzers** over input surfaces nothing reached before — the
mouse router, the key router, and paste — each walking a layout being
reshaped underneath it, drawing a real frame and re-checking the layout
fuzzer's own invariants after every step. The mouse one found #147 on
seed 15. (#147, #148)

**rustfmt and clippy, enforced in CI** (#150), tuned to the style the
tree already had rather than imposed: the config was picked by measuring
churn (556 hunks vs 1,632 for the naive setting). The lint set is in
`Cargo.toml`'s `[lints]` tables so it binds locally too, and three
candidate lints were measured and rejected. It found ten `unsafe` blocks
missing the `SAFETY:` comment every other one in the tree has.

Plus: the reformat kept out of `git blame` (#151), and three test gates
that blamed roost for the machine being out of ptys (#154).

Verified before cutting: 1,048 tests, clippy clean at `-D warnings`,
`fmt --check` clean. Both agent adapters checked end to end against live
binaries (`pi --session …`, `claude --resume …`), the session-resolution
table across all five adapters, the 45s status decays timed against the
clock, and two 15-minute soaks (~78k random keystrokes each, ~1,800
panes) with no orphan, no leak and no unreadable workspace.

Note for after the merge: `HOMEBREW_TAP_TOKEN` is still unset, so
release.yml will skip the tap sync again (it did for v0.1.10). The
formula needs the usual hand-sync.
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