perf: eliminate idle battery drain (PTY sleep-polling + hidden panes that never stop rendering)#121
Conversation
33631bb to
2ba39f2
Compare
|
CI note: the Vitest failure is pre-existing on main, not from this diff. |
…-polling
The PTY flusher looped `sleep(8ms)` unconditionally (125 timer wakeups/s
per PTY, around the clock, even with zero output) and the exit waiter
drained via `while !done { sleep(1ms) }` (~1,000 wakeups/s, and forever
if an orphaned grandchild kept the PTY slave open past SIGKILL). Together
they held the backend at ~1,950 wakeups/s in every state, keeping the CPU
out of deep sleep.
Both now block on a Condvar paired with the shared output buffer: the
reader notifies on data and on EOF, the flusher parks while the buffer is
empty and keeps the 8ms coalescing window only while output flows, and
the waiter waits on the same condvar for the reader's drain signal.
The done flag is stored and notified under the buffer mutex; both
consumers check it under that mutex before parking, so the store cannot
land between a consumer's check and its wait() (lost wakeup).
A quiet PTY costs zero timer wakeups; event batching is unchanged.
Measured on a 55-task workspace (powermetrics, 10x1s samples): idle
wakeups 1,835/s -> 81/s, idle CPU 71 -> 18 ms/s, and the quiet/streaming
deltas now track actual output volume instead of a fixed poll ceiling.
…stop rendering Inactive tasks (MainArea), inactive tabs (TaskView), and background bottom-split shells were hidden with `visibility: hidden`. xterm's renderer only pauses on zero geometry (its IntersectionObserver keys on geometry, not visibility), so every mounted background terminal kept executing full WebGL draws for each TUI repaint — idle agents redraw spinners and prompts constantly — pinning the GPU and burning webview CPU in every app state. The collapsed bottom split already used `display: none` for exactly this reason; this aligns the other three hiding sites with that pattern. display:none also blurs hidden panes, which pauses their cursor-blink loops. Also coalesces the per-chunk `lastOutputAt` store patch in the PTY data handler to one write per 500ms. A streaming agent delivers up to ~125 chunks/s and each patch re-rendered every tabs subscriber (TabBar, sidebar rows) at chunk rate per terminal. Nothing in-app reads the timestamp at finer granularity (settled detection uses a ref); the write stays because the automation bridge asserts PTY liveness through it. Re-show on activation is covered by the existing zero-geometry-guarded ResizeObservers and the on-activate refit effects. Verified live via the automation bridge, including the one genuinely-new geometry regime: a prompt-spawned agent tab created on a background (display:none) task opens at 0x0, spawns with clamped dims, and lays out correctly on first reveal. Measured on a 55-task workspace (powermetrics, 10x1s samples): idle GPU idle residency 73% -> 85%, idle WebContent 48 -> 16 ms/s, streaming WebContent 426 -> 233 ms/s, streaming combined power 5.4W -> 3.4W, quiet combined power now ~= idle. Docs: performance.md bear traps 2 and 8 updated (display:none invariant, condvar-driven PTY flusher), CLAUDE.md "What NOT to do" gains both.
2ba39f2 to
424a16e
Compare
|
Merged, thanks. The powermetrics before/after per state and the comment explaining why done is stored under the buffer mutex answered every question the review would have asked; both bugs verified by hand against the condvar protocol and the refit guards. One follow-up on main (43149ee). The lastOutputAt coalescing had no trailing write: a burst whose final chunk landed inside the 500ms window never reached the store, leaving the value stale by up to 500ms after output stops. Nothing in-app reads it that finely, but the e2e bridge asserts PTY liveness by watching it advance, so two interactions within 500ms could flake. A trailing timer now stamps the final value when the window closes. |
|
Second follow-up on main (5bd720b). The display:none switch surfaced a real regression: WKWebView zeroes scroll offsets inside a display:none subtree, so switching away from an editor tab and back snapped it to the top. Terminals were immune (xterm re-syncs its viewport from buffer state), which is why the live verification didn't catch it. Editor and diff panes now record their scroll offsets while visible and re-apply them when the box comes back; docs/performance.md bear trap 2 notes the cost for future scrollables. |
Problem
Termic drains battery even when idle. On a 55-task workspace, powermetrics showed the app costing real power in every state, including sitting untouched on the Dashboard:
Two independent bugs, one per process.
Bug 1: backend sleep-polling (
src-tauri/src/lib.rs)The PTY flusher thread looped
sleep(8ms)unconditionally: 125 timer wakeups/s per PTY, forever, even with zero output. The exit waiter drained viawhile !done { sleep(1ms) }(~1,000 wakeups/s), and if a SIGKILLed agent left an orphan holding the PTY slave, the reader never hit EOF and that spin survived pane close, permanently.Fix: both threads now block on a
Condvarpaired with the shared output buffer. The reader notifies on data/EOF; the flusher parks while the buffer is empty and keeps the 8ms coalescing window only while output flows; the waiter blocks on the same condvar. A quiet PTY costs zero timer wakeups. Event batching is unchanged.Bug 2: hidden panes never stop rendering (frontend)
Inactive tasks (
MainArea), inactive tabs (TaskView), and background bottom-split shells were hidden withvisibility: hidden. xterm's renderer only pauses on zero geometry (its IntersectionObserver keys on geometry, not visibility), so every mounted background terminal kept executing full WebGL draws for each TUI repaint. Idle agents redraw spinners/prompts constantly, so this ran around the clock in every state. The collapsed bottom split already useddisplay: nonefor exactly this reason; this PR aligns the other three hiding sites with that existing pattern.display: nonealso blurs hidden panes, pausing their cursor-blink loops.Also coalesced: the per-chunk
lastOutputAtstore patch in the PTY data handler (up to ~125 writes/s per streaming terminal, each re-rendering TabBar/sidebar) now writes at most once per 500ms. Nothing reads it at finer granularity (settled detection uses a ref).Before / after
powermetrics, 10x1s samples per state, 55-task workspace with agents idle at prompts, Apple Silicon. (pp = percentage points; GPU idle residency is higher-is-better.)
Idle
Quiet (one visible pane, blinking cursor)
Streaming
Wakeups above idle now track actual output volume instead of a fixed poll ceiling.
Changes
src-tauri/src/lib.rssrc/components/task/MainArea.tsxvisibility: hidden->display: nonesrc/components/task/TaskView.tsxsrc/components/task/TerminalPane.tsxlastOutputAtstore writes to 1/500msdocs/performance.mdCLAUDE.mdVerification
make check-all, full vitest suite (340 tests), production build all passdisplay: none) pane, exit event afterpty_killall verifiedNo new dependencies, no
CHANGELOG.mdchanges. This is likely release-worthy: it's a large battery/thermals improvement for anyone who keeps multiple agent tasks mounted.