Fix idle CPU usage and cap long-running stream buffers - #15
Merged
Conversation
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 finished — 3 comment(s). See the review below. |
ionite34
approved these changes
Aug 19, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-softanimation 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: bothso it settles at rest opacity instead of popping back to 1. It still draws the eye on mount, then stops for good.iterationsnull(infinite)3activeDuration6000msprefers-reduced-motionis 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 polledwatch::Receiver::changed()with a_pattern that also matchesErr. 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 duplicateHashMap::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
useSettingsStore.getState()rather than subscribing — that callback fires once per chunk, and subscribing would re-render the tree on unrelated settings edits.tryParseStreamingJsonof being quadratic. Benchmarked it: it isn't.JSON.parsebails at byte 0 for non-JSON SSE, ~0ms even at 1MB. The real cost was the DOM node, which is what the cap addresses.ResponseStreamerare stillinfinite, 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 forappendStreamContentcovering unlimited/negative limits, boundedness across 500 successive appends, line-break alignment, and most-recent-output retentioncargo test— 24 passedpnpm exec tsc --noEmitcleanpnpm exec eslintclean on all changed filesiterations: 3/activeDuration: 6000ms(wasnull/ infinite), resting opacity 0.6; Settings → Streaming renders with the 2.0 MB default and a slider bound to 2048document.timelineis frozen there and animation progress can't be timed — I verified the bounded timing model rather than observing the stop.🤖 Generated with Claude Code