Skip to content

perf: eliminate idle battery drain (PTY sleep-polling + hidden panes that never stop rendering)#121

Merged
simion merged 2 commits into
simion:mainfrom
MHohlios:feature/performance
Jul 20, 2026
Merged

perf: eliminate idle battery drain (PTY sleep-polling + hidden panes that never stop rendering)#121
simion merged 2 commits into
simion:mainfrom
MHohlios:feature/performance

Conversation

@MHohlios

Copy link
Copy Markdown
Contributor

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:

  • the backend woke the CPU ~1,900 times/second, dead constant across idle/quiet/streaming, preventing deep sleep
  • the webview + GPU kept compositing with nothing on screen changing

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 via while !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 Condvar paired 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 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/prompts constantly, so this ran around the clock in every state. The collapsed bottom split already used display: none for exactly this reason; this PR aligns the other three hiding sites with that existing pattern. display: none also blurs hidden panes, pausing their cursor-blink loops.

Also coalesced: the per-chunk lastOutputAt store 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

Metric Before After Improvement
termic wakeups/s 1,835 81 -96%
termic CPU ms/s 71 18 -75%
WebContent CPU ms/s 48 16 -67%
GPU idle residency 73% 85% +12 pp
Combined power 2.39W 2.04W -15%

Quiet (one visible pane, blinking cursor)

Metric Before After Improvement
termic wakeups/s 1,890 191 -90%
termic CPU ms/s 86 44 -49%
WebContent CPU ms/s 62 58 -6%
GPU idle residency 64% 66% +2 pp
Combined power 2.79W 2.54W -9%

Streaming

Metric Before After Improvement
termic wakeups/s 1,915 228 -88%
termic CPU ms/s 51 38 -26%
WebContent CPU ms/s 426 233 -45%
GPU idle residency 33% 35% +2 pp
Combined power 5.43W 3.42W -37%

Wakeups above idle now track actual output volume instead of a fixed poll ceiling.

Changes

File Change
src-tauri/src/lib.rs Condvar-blocked flusher + exit waiter (was sleep-polling)
src/components/task/MainArea.tsx Inactive tasks: visibility: hidden -> display: none
src/components/task/TaskView.tsx Inactive tabs + background bottom shells: same
src/components/task/TerminalPane.tsx Coalesce lastOutputAt store writes to 1/500ms
docs/performance.md Bear traps 2 and 8 updated to lock in both invariants
CLAUDE.md Both invariants added to "What NOT to do"

Verification

  • make check-all, full vitest suite (340 tests), production build all pass
  • Live-driven (automation bridge): PTY spawn, echo round-trip, data flow into a hidden (display: none) pane, exit event after pty_kill all verified
  • Re-show correctness: the existing zero-geometry-guarded ResizeObservers + on-activate refit effects cover reactivation (same mechanism the collapsed bottom split relies on today)
  • The two commits are independent (one per bug) and can be split into separate PRs if preferred

No new dependencies, no CHANGELOG.md changes. This is likely release-worthy: it's a large battery/thermals improvement for anyone who keeps multiple agent tasks mounted.

@MHohlios
MHohlios force-pushed the feature/performance branch from 33631bb to 2ba39f2 Compare July 19, 2026 19:08
@MHohlios

Copy link
Copy Markdown
Contributor Author

CI note: the Vitest failure is pre-existing on main, not from this diff. termLinkOpener.test.ts > PATH_TOKEN_RE stays fast on a long slash-less/dot-less blob asserts a wall-clock bound of 100ms and takes ~230-240ms on the shared runners; it has failed on main since #117 landed (main push run 29615340324, same assertion). Passes locally in ~48ms. Happy to raise the threshold or restructure the timing guard in a separate PR if wanted.

MHohlios added 2 commits July 19, 2026 15:15
…-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.
@MHohlios
MHohlios force-pushed the feature/performance branch from 2ba39f2 to 424a16e Compare July 19, 2026 19:15
@MHohlios
MHohlios marked this pull request as ready for review July 19, 2026 19:19
@simion
simion merged commit bcb7b52 into simion:main Jul 20, 2026
1 of 2 checks passed
@simion

simion commented Jul 20, 2026

Copy link
Copy Markdown
Owner

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.

@simion

simion commented Jul 20, 2026

Copy link
Copy Markdown
Owner

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.

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.

2 participants