perf: fix streaming lag, memory leaks, stuck spinners, and proxy timeout (#483)
* perf: fix streaming lag, memory leaks, and proxy timeout - PERF-001: Batch all streaming parts via requestAnimationFrame instead of per-token Zustand set() calls (~100/sec → 1 per frame) - PERF-002: Fix direct state.sessionMemoryState mutation inside set() callback - PERF-003: Debounce messageStore→sessionStore subscription via rAF + 500ms title computation delay - PERF-004: Stabilize SSE callbacks with refs to prevent reconnection storms; add 5-min stuck session idle timeout - PERF-005: Bound messageCache (500 max, LRU eviction), cap registry Maps, cleanup on session eviction - PERF-006: Replace toast duration: Infinity with 30s + id-based dedup - Fix proxy timeout: POST /session/:id/message 45s → 4min (matches CLI) - Add vitest + jsdom test infrastructure (61 tests across 7 files) Addresses: #476 (stuck spinner), #358 (34GB memory leak), #190 (browser lag) * perf: virtualize tool output rendering (read, edit, write) - PERF-007: Replace per-line <SyntaxHighlighter> with VirtualizedCodeBlock: - ONE Prism.highlight() call for entire file instead of N per-line calls - @tanstack/react-virtual renders only visible rows (~30 vs 2000+) - Applied to: ToolPart (read, DiffPreview, WriteInputPreview) and ToolOutputDialog (unified diff, read content) - PERF-008: Memoize parseReadToolOutput/parseDiffToUnified via useMemo to prevent re-parsing on every re-render A 2000-line file read now mounts ~30 DOM nodes instead of 2000 SyntaxHighlighter instances, eliminating the main-thread blocking that caused UI freezes during file operations. * fix: resolve lint errors (unused vars in tests and VirtualizedCodeBlock) * chore: trim PR scope to core perf fixes * fix: restore tool-card highlight stability --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
989593ed72
commit
3cd6d051cb
@@ -3,7 +3,7 @@ import { opencodeClient, type RoutedOpencodeEvent } from '@/lib/opencode/client'
|
||||
import { saveSessionCursor } from '@/lib/messageCursorPersistence';
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
import { useMessageStore } from '@/stores/messageStore';
|
||||
import { getMessageLimit } from '@/stores/types/sessionTypes';
|
||||
import { getMessageLimit, STUCK_SESSION_TIMEOUT_MS } from '@/stores/types/sessionTypes';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useUIStore, type EventStreamStatus } from '@/stores/useUIStore';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
@@ -85,6 +85,8 @@ const isIdNewer = (id: string, referenceId: string): boolean => {
|
||||
return currentSortable > referenceSortable;
|
||||
};
|
||||
|
||||
const MAX_MESSAGE_CACHE_SIZE = 500;
|
||||
const MESSAGE_CACHE_EVICT_COUNT = 100;
|
||||
const messageCache = new Map<string, { sessionId: string; message: { info: Message; parts: Part[] } | null }>();
|
||||
const getMessageFromStore = (sessionId: string, messageId: string): { info: Message; parts: Part[] } | null => {
|
||||
const cacheKey = `${sessionId}:${messageId}`;
|
||||
@@ -96,7 +98,16 @@ const getMessageFromStore = (sessionId: string, messageId: string): { info: Mess
|
||||
const storeState = useSessionStore.getState();
|
||||
const sessionMessages = storeState.messages.get(sessionId) || [];
|
||||
const message = sessionMessages.find(m => m.info.id === messageId) || null;
|
||||
|
||||
|
||||
if (messageCache.size >= MAX_MESSAGE_CACHE_SIZE) {
|
||||
// Evict oldest entries (Map preserves insertion order)
|
||||
let count = 0;
|
||||
for (const key of messageCache.keys()) {
|
||||
if (count++ >= MESSAGE_CACHE_EVICT_COUNT) break;
|
||||
messageCache.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
messageCache.set(cacheKey, { sessionId, message });
|
||||
return message;
|
||||
};
|
||||
@@ -581,6 +592,9 @@ export const useEventStream = () => {
|
||||
const prevDirectory = previousSessionDirectoryRef.current;
|
||||
|
||||
if (prevSessionId && nextSessionId && prevSessionId !== nextSessionId) {
|
||||
// Clear the message cache on session switch to free memory
|
||||
messageCache.clear();
|
||||
|
||||
if (prevDirectory && nextDirectory && prevDirectory !== nextDirectory) {
|
||||
// Removed: void refreshSessionStatus();
|
||||
}
|
||||
@@ -1465,8 +1479,9 @@ export const useEventStream = () => {
|
||||
|
||||
import('sonner').then(({ toast }) => {
|
||||
toast.warning('Permission required', {
|
||||
id: toastKey,
|
||||
description: sessionTitle,
|
||||
duration: Infinity,
|
||||
duration: 30000,
|
||||
action: {
|
||||
label: 'Open',
|
||||
onClick: () => {
|
||||
@@ -1536,8 +1551,9 @@ export const useEventStream = () => {
|
||||
|
||||
import('sonner').then(({ toast }) => {
|
||||
toast.info('Input needed', {
|
||||
id: toastKey,
|
||||
description: sessionTitle,
|
||||
duration: Infinity,
|
||||
duration: 30000,
|
||||
action: {
|
||||
label: 'Open',
|
||||
onClick: () => {
|
||||
@@ -1638,6 +1654,29 @@ export const useEventStream = () => {
|
||||
updateSessionStatus,
|
||||
]);
|
||||
|
||||
// --- Stable callback refs (Part A) ---
|
||||
// Keep refs up to date with the latest version of each callback.
|
||||
// This lets startStream use stable wrappers with empty deps so SSE connections
|
||||
// are NOT torn down on every session switch.
|
||||
const handleEventRef = React.useRef(handleEvent);
|
||||
React.useEffect(() => {
|
||||
handleEventRef.current = handleEvent;
|
||||
}, [handleEvent]);
|
||||
|
||||
const bootstrapStateRef = React.useRef(bootstrapState);
|
||||
React.useEffect(() => {
|
||||
bootstrapStateRef.current = bootstrapState;
|
||||
}, [bootstrapState]);
|
||||
|
||||
// Stable wrappers — identity never changes, so startStream deps stay minimal.
|
||||
const stableHandleEvent = React.useCallback((event: EventData) => {
|
||||
handleEventRef.current(event);
|
||||
}, []); // intentionally empty deps
|
||||
|
||||
const stableBootstrapState = React.useCallback((reason: string) => {
|
||||
return bootstrapStateRef.current(reason);
|
||||
}, []); // intentionally empty deps
|
||||
|
||||
const shouldHoldConnection = React.useCallback(() => {
|
||||
const currentVisibility = resolveVisibilityState();
|
||||
visibilityStateRef.current = currentVisibility;
|
||||
@@ -1732,12 +1771,12 @@ export const useEventStream = () => {
|
||||
// Removed: void refreshSessionStatus();
|
||||
|
||||
if (shouldRefresh) {
|
||||
void bootstrapState('sse_reconnected');
|
||||
void stableBootstrapState('sse_reconnected');
|
||||
} else {
|
||||
const sessionId = currentSessionIdRef.current;
|
||||
if (sessionId) {
|
||||
setTimeout(() => {
|
||||
scheduleSoftResync(sessionId, 'sse_reconnected', getMessageLimit())
|
||||
scheduleSoftResyncRef.current(sessionId, 'sse_reconnected', getMessageLimit())
|
||||
.then(() => requestSessionMetadataRefresh(sessionId))
|
||||
.catch((error: unknown) => {
|
||||
console.warn('[useEventStream] Failed to resync messages after reconnect:', error);
|
||||
@@ -1776,7 +1815,7 @@ export const useEventStream = () => {
|
||||
? { ...baseProperties, directory: event.directory }
|
||||
: baseProperties;
|
||||
|
||||
handleEvent({
|
||||
stableHandleEvent({
|
||||
type: typeof (payload as { type?: unknown }).type === 'string' ? (payload as { type: string }).type : '',
|
||||
properties,
|
||||
});
|
||||
@@ -1808,13 +1847,11 @@ export const useEventStream = () => {
|
||||
stopStream,
|
||||
publishStatus,
|
||||
checkConnection,
|
||||
scheduleSoftResync,
|
||||
requestSessionMetadataRefresh,
|
||||
handleEvent,
|
||||
stableHandleEvent,
|
||||
stableBootstrapState,
|
||||
effectiveDirectory,
|
||||
|
||||
debugConnectionState,
|
||||
bootstrapState
|
||||
]);
|
||||
|
||||
const scheduleReconnect = React.useCallback((hint?: string) => {
|
||||
@@ -2026,8 +2063,27 @@ export const useEventStream = () => {
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
// Part B: Idle timeout recovery — scan for sessions stuck in 'busy'/'retry'
|
||||
// with no recent SSE events and force-reset them to 'idle'.
|
||||
const stuckCheckInterval = setInterval(() => {
|
||||
const sessionStatus = useSessionStore.getState().sessionStatus;
|
||||
if (!sessionStatus) return;
|
||||
const now = Date.now();
|
||||
sessionStatus.forEach((status, sessionId) => {
|
||||
if (status.type !== 'busy' && status.type !== 'retry') return;
|
||||
const lastMsgAt = lastMessageEventBySessionRef.current.get(sessionId) ?? 0;
|
||||
const busyTooLong = now - lastMsgAt > STUCK_SESSION_TIMEOUT_MS;
|
||||
const noRecentEvents = now - lastMsgAt > 60000;
|
||||
if (busyTooLong && noRecentEvents) {
|
||||
console.warn('[useEventStream] Session stuck in busy state, forcing idle:', sessionId);
|
||||
updateSessionStatus(sessionId, { type: 'idle' }, 'timeout_recovery');
|
||||
}
|
||||
});
|
||||
}, 30000); // check every 30s
|
||||
|
||||
return () => {
|
||||
clearTimeout(startTimer);
|
||||
clearInterval(stuckCheckInterval);
|
||||
|
||||
void desktopActivityHandler;
|
||||
|
||||
@@ -2086,5 +2142,6 @@ export const useEventStream = () => {
|
||||
maybeBootstrapIfStale,
|
||||
resyncMessages,
|
||||
scheduleSoftResync,
|
||||
updateSessionStatus,
|
||||
]);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user