From f2ba775844ffef350aca5c1a7079e6f4da0cde6a Mon Sep 17 00:00:00 2001 From: jt Date: Tue, 18 Aug 2026 17:55:36 -0700 Subject: [PATCH] Fix idle CPU usage and cap long-running stream buffers 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 --- src-tauri/src/streaming.rs | 12 +++++- src-tauri/src/websocket.rs | 11 ++++- src/components/ResponsePanel.tsx | 2 +- src/components/ResponseStreamer.tsx | 7 ++++ src/components/SettingsPanel.tsx | 54 ++++++++++++++++++++++++- src/hooks/useStreamingResponse.ts | 13 +++++- src/index.css | 15 +++++++ src/store/settings.ts | 44 ++++++++++++++++++-- src/test/SettingsPanel.test.tsx | 4 ++ src/test/streamBuffer.test.ts | 63 +++++++++++++++++++++++++++++ src/types/index.ts | 2 + src/utils/streaming.ts | 39 ++++++++++++++++++ tailwind.config.js | 3 ++ 13 files changed, 259 insertions(+), 10 deletions(-) create mode 100644 src/test/streamBuffer.test.ts diff --git a/src-tauri/src/streaming.rs b/src-tauri/src/streaming.rs index 1ce6657..d7f4d9a 100644 --- a/src-tauri/src/streaming.rs +++ b/src-tauri/src/streaming.rs @@ -122,11 +122,19 @@ pub async fn stream_sse( let mut current_id: Option = None; let mut current_data: Vec = Vec::new(); let mut cancelled = false; + // Cleared if the cancel sender goes away. `changed()` then resolves + // instantly and forever, so leaving the arm enabled would spin this task at + // 100% of a core for as long as the response body stays open. + let mut cancellable = true; loop { tokio::select! { - _ = cancel_rx.changed() => { - if *cancel_rx.borrow() { + result = cancel_rx.changed(), if cancellable => { + if result.is_err() { + // No cancel can ever arrive now. Keep draining the body so + // the stream still finishes normally, just stop polling. + cancellable = false; + } else if *cancel_rx.borrow() { cancelled = true; break; } diff --git a/src-tauri/src/websocket.rs b/src-tauri/src/websocket.rs index 807bee2..fb39e19 100644 --- a/src-tauri/src/websocket.rs +++ b/src-tauri/src/websocket.rs @@ -116,7 +116,16 @@ pub async fn ws_connect( // Main event loop loop { tokio::select! { - _ = cmd_rx.changed() => { + result = cmd_rx.changed() => { + // The sender lives in `active_ws.connections` under this + // connection id, so an error here means the entry was replaced + // and nothing can ever send or close this socket again. Bail + // out rather than re-polling an arm that is instantly ready, + // which would spin this task at 100% of a core. + if result.is_err() { + break; + } + let cmd = cmd_rx.borrow().clone(); match cmd { WsCommand::Send(data) => { diff --git a/src/components/ResponsePanel.tsx b/src/components/ResponsePanel.tsx index 28038e8..6f45f89 100644 --- a/src/components/ResponsePanel.tsx +++ b/src/components/ResponsePanel.tsx @@ -166,7 +166,7 @@ function ResponsePanelComponent({
- +
diff --git a/src/components/ResponseStreamer.tsx b/src/components/ResponseStreamer.tsx index b031d9a..2f99c39 100644 --- a/src/components/ResponseStreamer.tsx +++ b/src/components/ResponseStreamer.tsx @@ -120,6 +120,13 @@ export function ResponseStreamer({ className="h-full pr-3 [&_[data-radix-scroll-area-thumb]]:bg-accent [&_[data-radix-scroll-area-thumb]]:hover:bg-accent/80" ref={scrollAreaRef} > + {(streaming.truncatedChars ?? 0) > 0 && ( +
+ Showing the most recent output —{' '} + {Math.round((streaming.truncatedChars ?? 0) / 1024).toLocaleString()} KB + trimmed from the start. Raise the stream buffer limit in Settings to keep more. +
+ )}
{streaming.error ? (
diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx
index be9b56c..4200d95 100644
--- a/src/components/SettingsPanel.tsx
+++ b/src/components/SettingsPanel.tsx
@@ -6,7 +6,7 @@ import {
   SheetDescription,
 } from "@/components/ui/sheet"
 import { Button } from "@/components/ui/button"
-import { Settings, RotateCw, Palette, Sliders, RefreshCw, Globe, ShieldCheck } from "lucide-react"
+import { Settings, RotateCw, Palette, Sliders, RefreshCw, Globe, ShieldCheck, Radio } from "lucide-react"
 import { Label } from "@/components/ui/label"
 import { Slider } from "@/components/ui/slider"
 import { Input } from "@/components/ui/input"
@@ -28,8 +28,16 @@ interface SettingsPanelProps {
 
 export const SettingsPanel = forwardRef(
   ({ open, onOpenChange }, _ref) => {
-    const { jsonViewer, updateJSONViewerSettings, network: networkRaw, updateNetworkSettings } = useSettingsStore()
+    const {
+      jsonViewer,
+      updateJSONViewerSettings,
+      network: networkRaw,
+      updateNetworkSettings,
+      streaming: streamingRaw,
+      updateStreamingSettings,
+    } = useSettingsStore()
     const network = networkRaw ?? { timeout: 30, connectTimeout: 10, sslVerification: true, proxy: '' }
+    const streaming = streamingRaw ?? { maxBufferKB: 2048 }
     const { color: themeColor, setColor: setThemeColor } = useThemeStore()
     const themeClass = useThemeClass()
     const [isCheckingUpdate, setIsCheckingUpdate] = useState(false)
@@ -333,6 +341,48 @@ export const SettingsPanel = forwardRef(
                   
+ + + + {/* Streaming Settings */} +
+
+ +
+

Streaming

+

+ How much of a long-running stream to keep on screen +

+
+
+
+
+
+ + + {streaming.maxBufferKB === 0 + ? 'Unlimited' + : `${(streaming.maxBufferKB / 1024).toFixed(1)} MB`} + +
+ + updateStreamingSettings({ maxBufferKB: value }) + } + className="[&_[role=slider]]:bg-primary [&_[role=slider]]:border-primary/80 [&_[role=slider]]:shadow-glow-sm cursor-col-resize" + /> +

+ Keep only the most recent output from a stream, trimming the start once + this limit is passed. Set to 0 to keep everything — a stream left open for + hours will then grow without bound and steadily slow the response view. +

+
+
+
diff --git a/src/hooks/useStreamingResponse.ts b/src/hooks/useStreamingResponse.ts index b8b9aaa..cecb5f1 100644 --- a/src/hooks/useStreamingResponse.ts +++ b/src/hooks/useStreamingResponse.ts @@ -2,7 +2,9 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { invoke } from '@tauri-apps/api/core' import { listen } from '@tauri-apps/api/event' import { StreamChunk, StreamingResponse } from '@/types' +import { useSettingsStore } from '@/store/settings' import { + appendStreamContent, StreamDonePayload, StreamHeaderPayload, StreamRequestOptions, @@ -70,10 +72,19 @@ export function useStreamingResponse() { setStreaming((prev) => { if (!prev) return null + // Read straight from the store rather than subscribing: this fires + // once per chunk and must not re-render on unrelated settings edits. + const { maxBufferKB } = useSettingsStore.getState().streaming + const { content, droppedChars } = appendStreamContent( + prev.currentContent, + chunk.data, + maxBufferKB + ) return { ...prev, chunkCount: prev.chunkCount + 1, - currentContent: prev.currentContent + chunk.data, + currentContent: content, + truncatedChars: (prev.truncatedChars ?? 0) + droppedChars, timing: createStreamingTiming(startTime.current), } }) diff --git a/src/index.css b/src/index.css index a11dc30..785c3a8 100644 --- a/src/index.css +++ b/src/index.css @@ -479,6 +479,21 @@ } } +/* A running animation keeps the WebView2 compositor producing frames for as + long as it lasts, so honour the OS "show animations" setting: with it off, + nothing here should tick in the background. */ +@media (prefers-reduced-motion: reduce) { + + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} + /* ═══════════════════════════════════════════════════ SCROLLBAR STYLING (for supporting browsers) ═══════════════════════════════════════════════════ */ diff --git a/src/store/settings.ts b/src/store/settings.ts index 4845c14..752cd9d 100644 --- a/src/store/settings.ts +++ b/src/store/settings.ts @@ -15,11 +15,21 @@ export interface NetworkSettings { proxy: string // proxy URL or empty string } +export interface StreamingSettings { + /** + * Trailing characters of a stream body to keep, in KB. 0 means unlimited. + * Caps the per-chunk render cost of long-lived streams. + */ + maxBufferKB: number +} + interface SettingsState { jsonViewer: JSONViewerSettings network: NetworkSettings + streaming: StreamingSettings updateJSONViewerSettings: (settings: Partial) => Promise updateNetworkSettings: (settings: Partial) => Promise + updateStreamingSettings: (settings: Partial) => Promise } const SETTINGS_FILE = 'settings.json' @@ -36,20 +46,42 @@ export const defaultNetworkSettings: NetworkSettings = { proxy: '', } +export const defaultStreamingSettings: StreamingSettings = { + maxBufferKB: 2048, +} + export const useSettingsStore = create()( persist( (set, get) => ({ jsonViewer: defaultJSONSettings, network: defaultNetworkSettings, + streaming: defaultStreamingSettings, updateJSONViewerSettings: async (settings) => { const nextSettings = { ...get().jsonViewer, ...settings } set({ jsonViewer: nextSettings }) - await saveToFile(SETTINGS_FILE, { jsonViewer: nextSettings, network: get().network }) + await saveToFile(SETTINGS_FILE, { + jsonViewer: nextSettings, + network: get().network, + streaming: get().streaming, + }) }, updateNetworkSettings: async (settings) => { const nextNetwork = { ...get().network, ...settings } set({ network: nextNetwork }) - await saveToFile(SETTINGS_FILE, { jsonViewer: get().jsonViewer, network: nextNetwork }) + await saveToFile(SETTINGS_FILE, { + jsonViewer: get().jsonViewer, + network: nextNetwork, + streaming: get().streaming, + }) + }, + updateStreamingSettings: async (settings) => { + const nextStreaming = { ...get().streaming, ...settings } + set({ streaming: nextStreaming }) + await saveToFile(SETTINGS_FILE, { + jsonViewer: get().jsonViewer, + network: get().network, + streaming: nextStreaming, + }) } }), { @@ -59,6 +91,7 @@ export const useSettingsStore = create()( const data = await loadFromFile<{ jsonViewer: Partial network?: Partial + streaming?: Partial }>(SETTINGS_FILE, { jsonViewer: defaultJSONSettings }) return { state: { @@ -69,6 +102,10 @@ export const useSettingsStore = create()( network: { ...defaultNetworkSettings, ...(data?.network || {}) + }, + streaming: { + ...defaultStreamingSettings, + ...(data?.streaming || {}) } } } @@ -76,7 +113,8 @@ export const useSettingsStore = create()( setItem: async (_, value) => { await saveToFile(SETTINGS_FILE, { jsonViewer: value.state.jsonViewer, - network: value.state.network + network: value.state.network, + streaming: value.state.streaming }) }, removeItem: () => {} diff --git a/src/test/SettingsPanel.test.tsx b/src/test/SettingsPanel.test.tsx index 3e288e7..1cdd6f5 100644 --- a/src/test/SettingsPanel.test.tsx +++ b/src/test/SettingsPanel.test.tsx @@ -20,6 +20,10 @@ vi.mock('@/store/settings', () => ({ proxy: '', }, updateNetworkSettings: vi.fn(), + streaming: { + maxBufferKB: 2048, + }, + updateStreamingSettings: vi.fn(), })) })) diff --git a/src/test/streamBuffer.test.ts b/src/test/streamBuffer.test.ts new file mode 100644 index 0000000..9d4b1a6 --- /dev/null +++ b/src/test/streamBuffer.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from 'vitest' +import { appendStreamContent } from '@/utils/streaming' + +const KB = 1024 + +describe('appendStreamContent', () => { + it('appends without trimming while under the limit', () => { + const result = appendStreamContent('abc', 'def', 1) + expect(result.content).toBe('abcdef') + expect(result.droppedChars).toBe(0) + }) + + it('treats a limit of 0 as unlimited', () => { + const previous = 'x'.repeat(5 * KB) + const result = appendStreamContent(previous, 'y'.repeat(5 * KB), 0) + expect(result.content).toHaveLength(10 * KB) + expect(result.droppedChars).toBe(0) + }) + + it('treats a negative limit as unlimited', () => { + const result = appendStreamContent('x'.repeat(4 * KB), 'y', -1) + expect(result.droppedChars).toBe(0) + }) + + it('keeps the buffer bounded at the limit', () => { + const result = appendStreamContent('a'.repeat(3 * KB), 'b'.repeat(1 * KB), 2) + expect(result.content.length).toBeLessThanOrEqual(2 * KB) + expect(result.droppedChars).toBe(2 * KB) + }) + + it('retains the most recent output, not the oldest', () => { + const result = appendStreamContent('old'.repeat(KB), 'NEWEST', 1) + expect(result.content.endsWith('NEWEST')).toBe(true) + }) + + it('cuts at a line break so the first visible line is whole', () => { + // 2KB of numbered lines, capped to 1KB: the retained text must start + // cleanly at a line boundary rather than mid-line. + const lines = Array.from({ length: 300 }, (_, i) => `line-${i}`).join('\n') + const result = appendStreamContent(lines, '\nlast', 1) + expect(result.content.startsWith('line-')).toBe(true) + }) + + it('does not hunt for a line break beyond the alignment window', () => { + // No newline anywhere in range, so the cut lands exactly on the limit. + const result = appendStreamContent('z'.repeat(8 * KB), '', 2) + expect(result.content).toHaveLength(2 * KB) + expect(result.droppedChars).toBe(6 * KB) + }) + + it('stays bounded across many successive appends', () => { + let content = '' + let dropped = 0 + for (let i = 0; i < 500; i++) { + const next = appendStreamContent(content, `chunk ${i} payload\n`, 4) + content = next.content + dropped += next.droppedChars + } + expect(content.length).toBeLessThanOrEqual(4 * KB) + expect(dropped).toBeGreaterThan(0) + expect(content.endsWith('chunk 499 payload\n')).toBe(true) + }) +}) diff --git a/src/types/index.ts b/src/types/index.ts index 09c737c..5e01d9f 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -25,6 +25,8 @@ export interface StreamingResponse { headers: Record chunkCount: number currentContent: string + /** Characters dropped off the front of `currentContent` by the buffer cap. */ + truncatedChars?: number isComplete: boolean error?: string timing?: { diff --git a/src/utils/streaming.ts b/src/utils/streaming.ts index a225dfb..40470db 100644 --- a/src/utils/streaming.ts +++ b/src/utils/streaming.ts @@ -58,6 +58,45 @@ export function shouldIgnoreStreamChunk(chunk: Pick