Skip to content

Fix idle CPU usage and cap long-running stream buffers - #15

Merged
mohnjiles merged 1 commit into
mainfrom
fix-idle-cpu-usage
Aug 19, 2026
Merged

Fix idle CPU usage and cap long-running stream buffers#15
mohnjiles merged 1 commit into
mainfrom
fix-idle-cpu-usage

Conversation

@mohnjiles

Copy link
Copy Markdown
Member

Summary

LitePost sat at ~10% CPU overnight while doing nothing, all of it in the WebView2 processes.

Instrumenting the idle page showed the React tree does zero work at rest — 0 timers, rAF callbacks, DOM mutations or long tasks over 12s — but exactly one thing never stopped: an infinite pulse-soft animation on the ResponsePanel "No response yet" icon. An infinite CSS animation keeps the WebView2 compositor producing frames forever, so the renderer never goes idle. And that empty state is precisely what's on screen when the app is left alone.

The fix:

  • The empty-state accent now uses a finite pulse-soft-intro — 3 iterations, fill: both so it settles at rest opacity instead of popping back to 1. It still draws the eye on mount, then stops for good.

    before after
    iterations null (infinite) 3
    activeDuration 6000ms
  • prefers-reduced-motion is now honoured globally, so the OS "show animations" setting becomes a real escape hatch for anything else that animates.

Two other things turned up during the investigation:

  • A latent hot-loop landmine. Both tokio::select! loops polled watch::Receiver::changed() with a _ pattern that also matches Err. If the paired sender were ever dropped while the loop was alive, changed() would resolve instantly and forever — spinning the task at 100% of a core with no backstop (the SSE side has a 300s request timeout; the WebSocket side has nothing). Not reachable today, since both ids are fresh UUIDs and the only drop path is a duplicate HashMap::insert, but there was no guard. The WebSocket loop now breaks — an orphaned socket can no longer be sent to or closed — while the SSE loop disables the arm and keeps draining, so a live stream is never truncated on a condition that says nothing about the response body.

  • Unbounded stream buffers. Stream bodies accumulated without limit, re-rendering an ever-growing DOM node once per chunk, so a stream left open for hours degraded steadily. New Stream Buffer Limit setting (Settings → Streaming, 0–20 MB, default 2 MB, 0 = unlimited) keeps the trailing window, preferring to cut on a line break so the first visible line isn't a fragment. The stream view shows how much was trimmed.

Notes for review

  • The streaming hook reads the limit via useSettingsStore.getState() rather than subscribing — that callback fires once per chunk, and subscribing would re-render the tree on unrelated settings edits.
  • I'd initially suspected the per-chunk tryParseStreamingJson of being quadratic. Benchmarked it: it isn't. JSON.parse bails at byte 0 for non-JSON SSE, ~0ms even at 1MB. The real cost was the DOM node, which is what the cap addresses.
  • The "live" pulse indicators in ResponseStreamer are still infinite, deliberately — they only exist while a stream is genuinely open. Same mechanism though, so a quiet-but-open SSE connection will still tick. Worth a follow-up if that turns out to matter.

Test plan

  • pnpm exec vitest run — 430 passed (46 files), including 8 new tests for appendStreamContent covering unlimited/negative limits, boundedness across 500 successive appends, line-break alignment, and most-recent-output retention
  • cargo test — 24 passed
  • pnpm exec tsc --noEmit clean
  • pnpm exec eslint clean on all changed files
  • Verified in a running dev server: empty-state animation reports iterations: 3 / activeDuration: 6000ms (was null / infinite), resting opacity 0.6; Settings → Streaming renders with the 2.0 MB default and a slider bound to 2048
  • Worth a manual check in the real Tauri window: leave the app idle and confirm the WebView2 processes drop to ~0% CPU. The browser preview pane renders its tab hidden, so document.timeline is frozen there and animation progress can't be timed — I verified the bounded timing model rather than observing the stop.

🤖 Generated with Claude Code

LitePost sat at ~10% CPU overnight while doing nothing, all of it in the
WebView2 processes. Instrumenting the idle page showed the React tree does
zero work at rest (0 timers, rAF callbacks, DOM mutations or long tasks over
12s), but exactly one thing never stopped: an infinite `pulse-soft` animation
on the ResponsePanel "No response yet" icon. An infinite CSS animation keeps
the WebView2 compositor producing frames forever, so the renderer never goes
idle -- and that empty state is precisely what is on screen when the app is
left alone.

- Give the empty-state accent a finite `pulse-soft-intro` (3 iterations,
  fill both, so it settles at rest opacity instead of popping back to 1).
  It still draws the eye on mount, then stops for good.
- Honour `prefers-reduced-motion` globally, so the OS "show animations"
  setting becomes a real escape hatch for anything else that animates.

Two other things turned up during the investigation:

- Both `tokio::select!` loops polled `watch::Receiver::changed()` with a `_`
  pattern that also matches `Err`. If the paired sender were ever dropped
  while the loop was alive, `changed()` would resolve instantly and forever,
  spinning the task at 100% of a core with no backstop (the SSE side has a
  300s request timeout; the WebSocket side has nothing). Not reachable today
  since both ids are fresh UUIDs and the only drop path is a duplicate
  `HashMap::insert`, but there was no guard. The WebSocket loop now breaks --
  an orphaned socket can no longer be sent to or closed -- while the SSE loop
  disables the arm and keeps draining, so a live stream is never truncated on
  a condition that says nothing about the response body.

- Stream bodies accumulated without bound, re-rendering an ever-growing DOM
  node once per chunk. A stream left open for hours would degrade steadily.
  Added a "Stream Buffer Limit" setting (Settings > Streaming, 0-20 MB,
  default 2 MB, 0 = unlimited) that keeps the trailing window, preferring to
  cut on a line break so the first visible line is not a fragment. The stream
  view shows how much was trimmed. The hook reads the limit via getState()
  rather than subscribing, since that callback fires once per chunk and
  subscribing would re-render the tree on unrelated settings edits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@lykos-reviewer

lykos-reviewer Bot commented Aug 19, 2026

Copy link
Copy Markdown

Lykos Reviewer finished — 3 comment(s). See the review below.

@lykos-reviewer lykos-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

heya, Lykos here :3 poking through this with the pack. those are some sharp catches on the idle CPU usage and the watch channel hot-loop landmines! the stream buffer limit is also a really nice touch for app stability during long sessions.

Comment thread src/components/ResponseStreamer.tsx
Comment thread src/components/SettingsPanel.tsx
Comment thread src/index.css
@mohnjiles
mohnjiles merged commit 08a4dc3 into main Aug 19, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants