From 0258ff7f9643a5e4f3c80d191a81d4802eafe025 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Tue, 22 Sep 2026 07:29:50 +0800 Subject: [PATCH] fix(desktop): diagnose and reduce session loading stalls --- desktop/src/api/client.ts | 160 ++++++++++++-- desktop/src/api/sessions.test.ts | 17 ++ desktop/src/api/sessions.ts | 14 +- .../src/components/layout/Sidebar.test.tsx | 32 +++ desktop/src/components/layout/Sidebar.tsx | 15 +- desktop/src/lib/asyncRefreshCoalescer.test.ts | 30 +++ desktop/src/lib/asyncRefreshCoalescer.ts | 28 +++ desktop/src/lib/diagnosticRateLimit.test.ts | 14 ++ desktop/src/lib/diagnosticRateLimit.ts | 30 +++ desktop/src/lib/diagnosticsCapture.test.ts | 35 +++ desktop/src/lib/diagnosticsCapture.ts | 60 ++++++ desktop/src/lib/sessionListPagination.ts | 1 + desktop/src/stores/chatStore.ts | 6 +- desktop/src/stores/sessionStore.test.ts | 14 +- desktop/src/stores/sessionStore.ts | 11 +- desktop/src/stores/tabStore.test.ts | 4 +- src/server/__tests__/filesystem.test.ts | 16 +- .../__tests__/project-session-history.test.ts | 50 +++++ src/server/api/filesystem.ts | 91 ++++++-- src/server/api/sessions.ts | 14 ++ src/server/index.ts | 3 + src/server/middleware/cors.test.ts | 3 + src/server/middleware/cors.ts | 2 + src/server/router.ts | 13 +- .../services/apiPerformanceMonitor.test.ts | 96 +++++++++ src/server/services/apiPerformanceMonitor.ts | 199 ++++++++++++++++++ src/server/services/diagnosticsShare.test.ts | 47 +++++ src/server/services/diagnosticsShare.ts | 47 ++++- src/server/services/projectSessionHistory.ts | 62 ++++++ .../services/sessionCollaborationHost.test.ts | 19 ++ .../services/sessionCollaborationHost.ts | 23 +- src/server/services/sessionService.ts | 6 + src/utils/execFileNoThrow.test.ts | 17 ++ src/utils/execFileNoThrow.ts | 2 +- 34 files changed, 1128 insertions(+), 53 deletions(-) create mode 100644 desktop/src/lib/asyncRefreshCoalescer.test.ts create mode 100644 desktop/src/lib/asyncRefreshCoalescer.ts create mode 100644 desktop/src/lib/diagnosticRateLimit.test.ts create mode 100644 desktop/src/lib/diagnosticRateLimit.ts create mode 100644 desktop/src/lib/diagnosticsCapture.test.ts create mode 100644 desktop/src/lib/sessionListPagination.ts create mode 100644 src/server/services/apiPerformanceMonitor.test.ts create mode 100644 src/server/services/apiPerformanceMonitor.ts create mode 100644 src/utils/execFileNoThrow.test.ts diff --git a/desktop/src/api/client.ts b/desktop/src/api/client.ts index 4b149fe0..b6eacd40 100644 --- a/desktop/src/api/client.ts +++ b/desktop/src/api/client.ts @@ -1,4 +1,5 @@ import { getDesktopHost } from '../lib/desktopHost' +import { createDiagnosticRateLimiter } from '../lib/diagnosticRateLimit' import { isPublicAccessRuntime } from '../lib/publicAccessRuntime' const ENV_BASE_URL = @@ -16,6 +17,20 @@ let desktopServerRecovery: Promise | null = null const DIAGNOSTICS_PATH = '/api/diagnostics/events' const DEFAULT_REQUEST_TIMEOUT_MS = 120_000 const DIAGNOSTICS_REQUEST_TIMEOUT_MS = 5_000 +const SLOW_REQUEST_DIAGNOSTIC_MS = 2_000 +const LARGE_RESPONSE_DIAGNOSTIC_CHARS = 8 * 1024 * 1024 +const API_ANOMALY_DIAGNOSTIC_COOLDOWN_MS = 10_000 +const shouldReportApiAnomaly = createDiagnosticRateLimiter(API_ANOMALY_DIAGNOSTIC_COOLDOWN_MS) + +type RequestTiming = { + startedAt: number + attempts: number + fetchMs: number + recoveryMs: number + responseReadMs: number + responseChars: number + recovered: boolean +} function getErrorMessage(status: number, body: unknown) { if (body && typeof body === 'object' && 'message' in body && typeof body.message === 'string') { @@ -126,6 +141,15 @@ export type ApiRequestOptions = { async function request(method: string, path: string, body?: unknown, options?: ApiRequestOptions): Promise { const headers = buildHeaders() + const timing: RequestTiming = { + startedAt: monotonicNow(), + attempts: 0, + fetchMs: 0, + recoveryMs: 0, + responseReadMs: 0, + responseChars: 0, + recovered: false, + } const controller = new AbortController() const timeoutMs = options?.timeout ?? DEFAULT_REQUEST_TIMEOUT_MS @@ -138,12 +162,20 @@ async function request(method: string, path: string, body?: unknown, options? if (options?.signal?.aborted) abortFromCaller() else options?.signal?.addEventListener('abort', abortFromCaller, { once: true }) try { - const fetchOnce = () => fetch(`${baseUrl}${path}`, { - method, - headers, - body: body !== undefined ? JSON.stringify(body) : undefined, - signal: controller.signal, - }) + const fetchOnce = async () => { + timing.attempts += 1 + const fetchStartedAt = monotonicNow() + try { + return await fetch(`${baseUrl}${path}`, { + method, + headers, + body: body !== undefined ? JSON.stringify(body) : undefined, + signal: controller.signal, + }) + } finally { + timing.fetchMs += monotonicNow() - fetchStartedAt + } + } let res: Response try { res = await fetchOnce() @@ -152,11 +184,18 @@ async function request(method: string, path: string, body?: unknown, options? method !== 'GET' || timedOut || options?.signal?.aborted || - !(error instanceof TypeError) || - !await recoverDesktopServerUrl() + !(error instanceof TypeError) ) { throw error } + const recoveryStartedAt = monotonicNow() + // Recovery may include a sidecar restart. Measure + // it separately from the retried HTTP request so diagnostics can tell a + // slow handler from a slow Electron-host recovery. + const recovered = await recoverDesktopServerUrl() + timing.recoveryMs += monotonicNow() - recoveryStartedAt + if (!recovered) throw error + timing.recovered = true res = await fetchOnce() } if (!res.ok) { @@ -164,12 +203,20 @@ async function request(method: string, path: string, body?: unknown, options? throw new ApiError(res.status, errorBody) } - if (res.status === 204) return undefined as T - return await readJsonBody(res) + if (res.status === 204) { + reportSlowApiRequest(method, path, res, timing) + return undefined as T + } + const responseReadStartedAt = monotonicNow() + const parsed = await readJsonBody(res) + timing.responseReadMs += monotonicNow() - responseReadStartedAt + timing.responseChars = parsed.readChars + reportSlowApiRequest(method, path, res, timing) + return parsed.value } catch (err) { if (timedOut) { const timeoutError = new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s`) - reportApiFailure(method, path, timeoutError) + reportApiFailure(method, path, timeoutError, timing, timeoutMs) throw timeoutError } if (options?.signal?.aborted) { @@ -177,7 +224,7 @@ async function request(method: string, path: string, body?: unknown, options? ? options.signal.reason : new DOMException('The operation was aborted', 'AbortError') } - reportApiFailure(method, path, err) + reportApiFailure(method, path, err, timing, timeoutMs) throw err } finally { clearTimeout(timeout) @@ -185,7 +232,75 @@ async function request(method: string, path: string, body?: unknown, options? } } -async function readJsonBody(res: Response): Promise { +function monotonicNow(): number { + return typeof performance !== 'undefined' ? performance.now() : Date.now() +} + +function roundedMs(value: number): number { + return Math.round(Math.max(0, value) * 10) / 10 +} + +function requestTimingDetails(timing: RequestTiming, timeoutMs?: number) { + return { + durationMs: roundedMs(monotonicNow() - timing.startedAt), + fetchMs: roundedMs(timing.fetchMs), + responseReadMs: roundedMs(timing.responseReadMs), + responseChars: timing.responseChars, + recoveryMs: roundedMs(timing.recoveryMs), + attempts: timing.attempts, + recovered: timing.recovered, + ...(timeoutMs === undefined ? {} : { timeoutMs }), + } +} + +function responseTimingDetails(response: Response) { + const declaredBytes = Number.parseInt(response.headers.get('content-length') ?? '', 10) + const serverTiming = response.headers.get('server-timing') + const serverAppDuration = serverTiming?.match(/(?:^|,)\s*app;dur=([0-9]+(?:\.[0-9]+)?)/i)?.[1] + return { + status: response.status, + requestId: response.headers.get('x-request-id'), + serverTiming, + serverAppMs: serverAppDuration === undefined ? null : Number(serverAppDuration), + declaredBytes: Number.isFinite(declaredBytes) && declaredBytes >= 0 ? declaredBytes : null, + } +} + +function reportSlowApiRequest( + method: string, + path: string, + response: Response, + timing: RequestTiming, +) { + if (path.startsWith('/api/diagnostics')) return + const route = path.split('?', 1)[0]! + const details = { + method, + path, + route, + ...requestTimingDetails(timing), + ...responseTimingDetails(response), + } + const slow = details.durationMs >= SLOW_REQUEST_DIAGNOSTIC_MS + const large = details.responseChars >= LARGE_RESPONSE_DIAGNOSTIC_CHARS + if (!slow && !large) return + const diagnosticType = slow ? 'client_api_request_slow' : 'client_api_response_large' + const decision = shouldReportApiAnomaly(`${diagnosticType}:${method}:${route}`) + if (!decision.report) return + void rawRecordDiagnosticEvent({ + type: diagnosticType, + severity: 'warn', + summary: slow + ? `${method} ${path} took ${details.durationMs}ms` + : `${method} ${path} returned ${details.responseChars} characters`, + details: { + ...details, + suppressedSinceLast: decision.suppressedSinceLast, + }, + }) +} + +async function readJsonBody(res: Response): Promise<{ value: T; readChars: number }> { const contentType = res.headers.get('content-type') const declaredLength = Number.parseInt(res.headers.get('content-length') ?? '', 10) const declaredBytes = Number.isFinite(declaredLength) && declaredLength > 0 @@ -205,7 +320,7 @@ async function readJsonBody(res: Response): Promise { const text = await res.text() try { - return JSON.parse(text) as T + return { value: JSON.parse(text) as T, readChars: text.length } } catch { // A truncated or empty body has no status to report: the request itself // succeeded, so the byte counts are the only usable evidence. @@ -236,14 +351,23 @@ async function recoverDesktopServerUrl(): Promise { return true } -function reportApiFailure(method: string, path: string, error: unknown) { +function reportApiFailure( + method: string, + path: string, + error: unknown, + timing: RequestTiming, + timeoutMs: number, +) { if (path.startsWith('/api/diagnostics')) return + const route = path.split('?', 1)[0]! const details: Record = { method, path, + route, errorName: error instanceof Error ? error.name : typeof error, message: sanitizeDiagnosticValue(error instanceof Error ? error.message : String(error)), + ...requestTimingDetails(timing, timeoutMs), } if (error instanceof ApiError) { @@ -258,6 +382,12 @@ function reportApiFailure(method: string, path: string, error: unknown) { details.emptyBody = error.emptyBody } + const decision = shouldReportApiAnomaly( + `client_api_request_failed:${method}:${route}:${details.errorName}:${details.status ?? 'transport'}`, + ) + if (!decision.report) return + details.suppressedSinceLast = decision.suppressedSinceLast + void rawRecordDiagnosticEvent({ type: 'client_api_request_failed', severity: 'warn', diff --git a/desktop/src/api/sessions.test.ts b/desktop/src/api/sessions.test.ts index cf39563f..29e9e5ea 100644 --- a/desktop/src/api/sessions.test.ts +++ b/desktop/src/api/sessions.test.ts @@ -36,6 +36,23 @@ describe('sessionsApi', () => { expect(init).toMatchObject({ method: 'GET' }) }) + it('requests one bounded sidebar preview per project', async () => { + const response = { + sessions: [], + projects: [{ projectRoot: '/workspace/repo', total: 42 }], + total: 42, + } + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(JSON.stringify(response), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })) + + expect(await sessionsApi.list({ view: 'sidebar', perProjectLimit: 6 })).toEqual(response) + expect(fetchMock.mock.calls[0]?.[0]).toBe( + 'http://127.0.0.1:3456/api/sessions?view=sidebar&perProjectLimit=6', + ) + }) + it('fetches one historical session summary without requesting its messages', async () => { const summary = { id: 'historical-session', diff --git a/desktop/src/api/sessions.ts b/desktop/src/api/sessions.ts index f91c8a9c..2b44ff5b 100644 --- a/desktop/src/api/sessions.ts +++ b/desktop/src/api/sessions.ts @@ -8,6 +8,10 @@ import type { TraceCallRecord, TraceSession } from '../types/trace' export type SessionsResponse = { sessions: SessionListItem[] total: number + projects?: Array<{ + projectRoot: string + total: number + }> index?: LocalIndexStatus } export type ProjectSessionHistoryParams = { @@ -411,11 +415,19 @@ function buildWorkspacePath( } export const sessionsApi = { - list(params?: { project?: string; limit?: number; offset?: number }, options?: ApiRequestOptions) { + list(params?: { + project?: string + limit?: number + offset?: number + view?: 'sidebar' + perProjectLimit?: number + }, options?: ApiRequestOptions) { const query = new URLSearchParams() if (params?.project) query.set('project', params.project) if (params?.limit) query.set('limit', String(params.limit)) if (params?.offset) query.set('offset', String(params.offset)) + if (params?.view) query.set('view', params.view) + if (params?.perProjectLimit) query.set('perProjectLimit', String(params.perProjectLimit)) const qs = query.toString() return api.get(`/api/sessions${qs ? `?${qs}` : ''}`, options) }, diff --git a/desktop/src/components/layout/Sidebar.test.tsx b/desktop/src/components/layout/Sidebar.test.tsx index 7d22a208..5ec9ff57 100644 --- a/desktop/src/components/layout/Sidebar.test.tsx +++ b/desktop/src/components/layout/Sidebar.test.tsx @@ -463,6 +463,7 @@ describe('Sidebar', () => { useSessionStore.setState({ sessions: [], projectHistory: {}, + projectSessionTotals: {}, recentSessionIds: new Set(), recentProjectBoundaries: {}, historicalSessionIds: new Set(), @@ -774,6 +775,37 @@ describe('Sidebar', () => { expect(screen.getByRole('button', { name: 'Collapse display' })).toHaveAttribute('aria-expanded', 'true') }) + it('offers automatic history expansion when the preview contains exactly six rows', async () => { + const base = new Date('2026-05-15T10:00:00.000Z').getTime() + useSessionStore.setState({ + sessions: Array.from({ length: 6 }, (_, index) => ( + makeSession(`alpha-${index + 1}`, `Alpha ${index + 1}`, '/workspace/alpha', new Date(base - index * 1000).toISOString()) + )), + projectSessionTotals: { '/workspace/alpha': 42 }, + }) + + render() + + const expandButton = screen.getByRole('button', { name: 'Expand display' }) + const scroller = screen.getByTestId('sidebar-project-session-list-workspace-alpha') + const outer = screen.getByTestId('sidebar-session-scroll-area') + outer.getBoundingClientRect = () => ({ top: 0, bottom: 900 } as DOMRect) + scroller.getBoundingClientRect = () => ({ top: 100, bottom: 400 } as DOMRect) + Object.defineProperties(scroller, { + clientHeight: { configurable: true, value: 420 }, + scrollHeight: { configurable: true, value: 300 }, + }) + fireEvent.click(expandButton) + + expect(scroller).toHaveClass('max-h-[420px]', 'overflow-y-auto') + await waitFor(() => { + expect(sessionsApiMock.listProjectHistory).toHaveBeenCalledWith( + expect.objectContaining({ projectRoot: '/workspace/alpha', limit: 50 }), + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ) + }) + }) + it('scrolls older sessions into their existing project and releases them on collapse', async () => { const recent = Array.from({ length: 14 }, (_, index) => ( makeSession(`alpha-${index}`, `Alpha ${index}`, '/workspace/alpha', '2026-05-15T10:00:00.000Z') diff --git a/desktop/src/components/layout/Sidebar.tsx b/desktop/src/components/layout/Sidebar.tsx index ccaa72b7..91b16d89 100644 --- a/desktop/src/components/layout/Sidebar.tsx +++ b/desktop/src/components/layout/Sidebar.tsx @@ -15,6 +15,7 @@ import { GlobalSearchModal } from '../search/GlobalSearchModal' import { FindInPageModal } from '../search/FindInPageModal' import { ProjectEditorModal, type ProjectEditorSubmission } from './ProjectEditorModal' import { SidebarTaskList } from './SidebarTaskList' +import { SIDEBAR_PROJECT_SESSION_PREVIEW_LIMIT } from '../../lib/sessionListPagination' import { ProjectSessionList, notifyProjectHistoryAtSidebarBottom } from '@/components/layout/ProjectSessionList' import { buildSidebarTaskGroups, @@ -55,7 +56,7 @@ const PROJECT_PINNED_STORAGE_KEY = 'cc-haha-sidebar-pinned-projects' const PROJECT_HIDDEN_STORAGE_KEY = 'cc-haha-sidebar-hidden-projects' const PROJECT_ORGANIZATION_STORAGE_KEY = 'cc-haha-sidebar-project-organization' const PROJECT_SORT_STORAGE_KEY = 'cc-haha-sidebar-project-sort' -const PROJECT_GROUP_VISIBLE_COUNT = 6 +const PROJECT_GROUP_VISIBLE_COUNT = SIDEBAR_PROJECT_SESSION_PREVIEW_LIMIT type SidebarProjectOrganization = 'project' | 'recentProject' | 'time' type SidebarProjectSortBy = 'createdAt' | 'updatedAt' @@ -115,6 +116,7 @@ export function Sidebar({ const t = useTranslation() const sessions = useSessionStore((s) => s.sessions) const projectHistory = useSessionStore((s) => s.projectHistory) + const projectSessionTotals = useSessionStore((s) => s.projectSessionTotals) const isLoading = useSessionStore((s) => s.isLoading) const indexStatus = useSessionStore((s) => s.indexStatus) const indexBuilding = indexStatus?.mode === 'on' && indexStatus.state === 'building' @@ -1212,7 +1214,12 @@ export function Sidebar({ ? [] : getVisibleProjectSessions(project.sessions, sessionsExpanded, activeTabId) const hiddenCount = project.sessions.length - visibleItems.length - const showSessionFoldControl = project.sessions.length > PROJECT_GROUP_VISIBLE_COUNT + const projectSessionTotal = projectSessionTotals[project.key] + const hasUnloadedSessions = projectSessionTotal === undefined + ? false + : projectSessionTotal > project.sessions.length + const showSessionFoldControl = project.sessions.length > PROJECT_GROUP_VISIBLE_COUNT || + (projectSessionTotal ?? 0) > PROJECT_GROUP_VISIBLE_COUNT const groupIds = project.sessions.map((session) => session.id) const groupSelectedCount = groupIds.filter((id) => selectedSessionIds.has(id)).length const history = projectHistory[project.key] @@ -1336,11 +1343,11 @@ export function Sidebar({ outerScrollRef={sessionScrollAreaRef} testId={`sidebar-project-session-list-${domSafeProjectKey(project.key)}`} expanded={sessionsExpanded} - hasHiddenSessions={hiddenCount > 0} + hasHiddenSessions={hiddenCount > 0 || hasUnloadedSessions} itemCount={visibleItems.length} nextCursor={history?.nextCursor} isLoading={history?.isLoading ?? false} - hasMore={history?.hasMore ?? true} + hasMore={history?.hasMore ?? (projectSessionTotal === undefined || hasUnloadedSessions)} error={history?.error} onExpand={() => setExpandedProjectKeys((current) => new Set([...current, project.key]))} onLoadMore={() => useSessionStore.getState().loadMoreProjectSessions(project.key)} diff --git a/desktop/src/lib/asyncRefreshCoalescer.test.ts b/desktop/src/lib/asyncRefreshCoalescer.test.ts new file mode 100644 index 00000000..966eb6a0 --- /dev/null +++ b/desktop/src/lib/asyncRefreshCoalescer.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from 'vitest' +import { createAsyncRefreshCoalescer } from './asyncRefreshCoalescer' + +function deferred() { + let resolve!: () => void + const promise = new Promise((done) => { resolve = done }) + return { promise, resolve } +} + +describe('async refresh coalescer', () => { + it('bounds a burst to one active and one trailing refresh', async () => { + const first = deferred() + const second = deferred() + const task = vi.fn() + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise) + const request = createAsyncRefreshCoalescer(task) + + const pending = request() + request() + request() + expect(task).toHaveBeenCalledTimes(1) + + first.resolve() + await vi.waitFor(() => expect(task).toHaveBeenCalledTimes(2)) + second.resolve() + await pending + expect(task).toHaveBeenCalledTimes(2) + }) +}) diff --git a/desktop/src/lib/asyncRefreshCoalescer.ts b/desktop/src/lib/asyncRefreshCoalescer.ts new file mode 100644 index 00000000..83a475d7 --- /dev/null +++ b/desktop/src/lib/asyncRefreshCoalescer.ts @@ -0,0 +1,28 @@ +/** + * Collapses a burst into one active refresh plus at most one trailing refresh. + * The trailing run preserves an update that arrived while the first snapshot + * was being read without allowing unbounded concurrent work. + */ +export function createAsyncRefreshCoalescer(task: () => Promise) { + let running: Promise | null = null + let queued = false + + const request = (): Promise => { + queued = true + if (running) return running + + const drain = async () => { + do { + queued = false + await task() + } while (queued) + } + running = drain().finally(() => { + running = null + if (queued) void request() + }) + return running + } + + return request +} diff --git a/desktop/src/lib/diagnosticRateLimit.test.ts b/desktop/src/lib/diagnosticRateLimit.test.ts new file mode 100644 index 00000000..f01eb7b9 --- /dev/null +++ b/desktop/src/lib/diagnosticRateLimit.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { createDiagnosticRateLimiter } from './diagnosticRateLimit' + +describe('diagnostic anomaly rate limiting', () => { + it('reports one sample per key and carries the suppressed count into the next window', () => { + const decide = createDiagnosticRateLimiter(10_000) + + expect(decide('GET:/api/sessions', 0)).toEqual({ report: true, suppressedSinceLast: 0 }) + expect(decide('GET:/api/sessions', 1_000)).toEqual({ report: false, suppressedSinceLast: 1 }) + expect(decide('GET:/api/sessions', 2_000)).toEqual({ report: false, suppressedSinceLast: 2 }) + expect(decide('GET:/api/sessions', 10_000)).toEqual({ report: true, suppressedSinceLast: 2 }) + expect(decide('GET:/api/tasks', 10_001)).toEqual({ report: true, suppressedSinceLast: 0 }) + }) +}) diff --git a/desktop/src/lib/diagnosticRateLimit.ts b/desktop/src/lib/diagnosticRateLimit.ts new file mode 100644 index 00000000..48987de4 --- /dev/null +++ b/desktop/src/lib/diagnosticRateLimit.ts @@ -0,0 +1,30 @@ +export type DiagnosticRateLimitDecision = { + report: boolean + suppressedSinceLast: number +} + +type RateLimitState = { + lastReportedAt: number + suppressed: number +} + +export function createDiagnosticRateLimiter(cooldownMs: number, maxKeys = 200) { + const states = new Map() + return (key: string, now = Date.now()): DiagnosticRateLimitDecision => { + const previous = states.get(key) + if (previous && now - previous.lastReportedAt < cooldownMs) { + previous.suppressed += 1 + return { report: false, suppressedSinceLast: previous.suppressed } + } + + const suppressedSinceLast = previous?.suppressed ?? 0 + states.delete(key) + states.set(key, { lastReportedAt: now, suppressed: 0 }) + while (states.size > maxKeys) { + const oldestKey = states.keys().next().value + if (typeof oldestKey !== 'string') break + states.delete(oldestKey) + } + return { report: true, suppressedSinceLast } + } +} diff --git a/desktop/src/lib/diagnosticsCapture.test.ts b/desktop/src/lib/diagnosticsCapture.test.ts new file mode 100644 index 00000000..b04f8dbd --- /dev/null +++ b/desktop/src/lib/diagnosticsCapture.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it, vi } from 'vitest' +import { startRendererPerformanceMonitor } from './diagnosticsCapture' + +describe('renderer performance diagnostics', () => { + it('records visible event-loop stalls and ignores background timer throttling', async () => { + let now = 0 + let sample: (() => void) | undefined + let visible = true + const record = vi.fn().mockResolvedValue(undefined) + const stop = startRendererPerformanceMonitor({ + now: () => now, + visible: () => visible, + record, + setInterval: ((callback: TimerHandler) => { + sample = callback as () => void + return 1 + }) as typeof window.setInterval, + clearInterval: vi.fn() as unknown as typeof window.clearInterval, + }) + + now = 1_700 + sample?.() + await Promise.resolve() + expect(record).toHaveBeenCalledWith(expect.objectContaining({ + type: 'client_event_loop_stall', + details: expect.objectContaining({ lagMs: 700 }), + })) + + visible = false + now = 15_000 + sample?.() + expect(record).toHaveBeenCalledTimes(1) + stop() + }) +}) diff --git a/desktop/src/lib/diagnosticsCapture.ts b/desktop/src/lib/diagnosticsCapture.ts index ef683451..aa19d661 100644 --- a/desktop/src/lib/diagnosticsCapture.ts +++ b/desktop/src/lib/diagnosticsCapture.ts @@ -2,6 +2,17 @@ import React from 'react' import { rawRecordDiagnosticEvent } from '../api/client' let installed = false +const EVENT_LOOP_SAMPLE_MS = 1_000 +const EVENT_LOOP_STALL_MS = 500 +const EVENT_LOOP_REPORT_COOLDOWN_MS = 10_000 + +type RendererPerformanceMonitorDependencies = { + now?: () => number + visible?: () => boolean + record?: typeof rawRecordDiagnosticEvent + setInterval?: typeof window.setInterval + clearInterval?: typeof window.clearInterval +} export function installClientDiagnosticsCapture() { if (installed || typeof window === 'undefined') return @@ -21,6 +32,55 @@ export function installClientDiagnosticsCapture() { reason: normalizeError(event.reason), }) }) + + startRendererPerformanceMonitor() +} + +/** + * A fetch that is slow while this timer remains punctual points at the server; + * a late timer with otherwise fast transport points at renderer/render work. + * Only delayed samples are persisted, with a cooldown, so normal operation has + * no diagnostics traffic and background-tab timer throttling is ignored. + */ +export function startRendererPerformanceMonitor( + dependencies: RendererPerformanceMonitorDependencies = {}, +): () => void { + const now = dependencies.now ?? (() => performance.now()) + const visible = dependencies.visible ?? (() => document.visibilityState === 'visible') + const record = dependencies.record ?? rawRecordDiagnosticEvent + const schedule = dependencies.setInterval ?? window.setInterval.bind(window) + const cancel = dependencies.clearInterval ?? window.clearInterval.bind(window) + let expectedAt = now() + EVENT_LOOP_SAMPLE_MS + let lastReportedAt = Number.NEGATIVE_INFINITY + const timer = schedule(() => { + const sampledAt = now() + const lagMs = Math.max(0, sampledAt - expectedAt) + expectedAt = sampledAt + EVENT_LOOP_SAMPLE_MS + if (!visible()) return + if ( + lagMs < EVENT_LOOP_STALL_MS || + sampledAt - lastReportedAt < EVENT_LOOP_REPORT_COOLDOWN_MS + ) return + lastReportedAt = sampledAt + const memory = performance as Performance & { + memory?: { usedJSHeapSize?: number; totalJSHeapSize?: number } + } + const toMiB = (bytes: number | undefined) => typeof bytes === 'number' + ? Math.round(bytes / (1024 * 1024) * 10) / 10 + : null + void record({ + type: 'client_event_loop_stall', + severity: 'warn', + summary: `Renderer event loop was delayed by ${Math.round(lagMs * 10) / 10}ms`, + details: { + lagMs: Math.round(lagMs * 10) / 10, + usedJsHeapMiB: toMiB(memory.memory?.usedJSHeapSize), + totalJsHeapMiB: toMiB(memory.memory?.totalJSHeapSize), + hardwareConcurrency: navigator.hardwareConcurrency, + }, + }) + }, EVENT_LOOP_SAMPLE_MS) + return () => cancel(timer) } export function reportReactError(error: unknown, errorInfo: React.ErrorInfo) { diff --git a/desktop/src/lib/sessionListPagination.ts b/desktop/src/lib/sessionListPagination.ts new file mode 100644 index 00000000..793d9eab --- /dev/null +++ b/desktop/src/lib/sessionListPagination.ts @@ -0,0 +1 @@ +export const SIDEBAR_PROJECT_SESSION_PREVIEW_LIMIT = 6 diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts index e7c13ca7..8f2d3d8c 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -17,6 +17,7 @@ import { resolveActiveProviderRuntimeSelection, resolveProviderRuntimeModelId } import { useTabStore } from './tabStore' import { randomSpinnerVerb } from '../config/spinnerVerbs' import { notifyDesktop } from '../lib/desktopNotifications' +import { createAsyncRefreshCoalescer } from '../lib/asyncRefreshCoalescer' import { deriveSessionTitle, isPlaceholderSessionTitle } from '../lib/sessionTitle' import { t } from '../i18n' import { @@ -2027,6 +2028,9 @@ function refreshCompletedTranscriptHistory( } const collaborationHistoryRefreshTimers = new Map>() +const refreshCollaborationSessionList = createAsyncRefreshCoalescer( + () => useSessionStore.getState().fetchSessions(), +) /** Debounced idle-only transcript refetch for collaboration deliveries. */ function scheduleCollaborationHistoryRefresh(sessionId: string): void { @@ -5611,7 +5615,7 @@ export const useChatStore = create((setState, get) => { // refetch is only the idle fallback — a busy session already receives // the delivery through the live replay stream, and refetching mid-turn // on every lifecycle event would churn the streaming view. - void useSessionStore.getState().fetchSessions() + void refreshCollaborationSessionList() const target = (msg.data as { sessionId?: unknown } | undefined)?.sessionId const affectedId = typeof target === 'string' ? target : sessionId const affected = get().sessions[affectedId] diff --git a/desktop/src/stores/sessionStore.test.ts b/desktop/src/stores/sessionStore.test.ts index 15c6c7f1..86dd2d6a 100644 --- a/desktop/src/stores/sessionStore.test.ts +++ b/desktop/src/stores/sessionStore.test.ts @@ -372,13 +372,13 @@ describe('sessionStore', () => { recent, { ...historical, permissionMode: 'plan' }, ]) - expect(listMock).toHaveBeenLastCalledWith({ limit: 400 }) + expect(listMock).toHaveBeenLastCalledWith({ view: 'sidebar', perProjectLimit: 6 }) useTabStore.getState().closeTab(historical.id) await useSessionStore.getState().fetchSessions() expect(useSessionStore.getState().sessions).toEqual([recent]) expect(useSessionStore.getState().historicalSessionIds).toEqual(new Set()) - expect(listMock).toHaveBeenLastCalledWith({ limit: 400 }) + expect(listMock).toHaveBeenLastCalledWith({ view: 'sidebar', perProjectLimit: 6 }) }) it('uses fresh recent metadata when an opened historical session reenters the recent page', async () => { @@ -416,15 +416,19 @@ describe('sessionStore', () => { expect(useSessionStore.getState().sessions).toEqual([]) }) - it('requests a large default session page for noisy history directories', async () => { + it('requests a bounded preview for every project instead of a large global page', async () => { listMock.mockResolvedValue({ sessions: [makeSession('session-newest', '2026-05-07T00:00:03.000Z')], total: 474, + projects: [{ projectRoot: '/workspace/project', total: 474 }], }) await useSessionStore.getState().fetchSessions() - expect(listMock).toHaveBeenCalledWith({ limit: 400 }) + expect(listMock).toHaveBeenCalledWith({ view: 'sidebar', perProjectLimit: 6 }) + expect(useSessionStore.getState().projectSessionTotals).toEqual({ + '/workspace/project': 474, + }) }) it('loads one project below its recent boundary without skipping restored old tabs and retains pages on refresh', async () => { @@ -455,7 +459,7 @@ describe('sessionStore', () => { isLoading: false, error: null, }) - expect(listMock).toHaveBeenLastCalledWith({ limit: 400 }) + expect(listMock).toHaveBeenLastCalledWith({ view: 'sidebar', perProjectLimit: 6 }) }) it('uses cursor pages once, deduplicates overlapping rows, and keeps current titles and runtime metadata', async () => { diff --git a/desktop/src/stores/sessionStore.ts b/desktop/src/stores/sessionStore.ts index 4fc0ddc9..26bad305 100644 --- a/desktop/src/stores/sessionStore.ts +++ b/desktop/src/stores/sessionStore.ts @@ -17,8 +17,8 @@ import type { RuntimeSelection } from '../types/runtime' import { isPlaceholderSessionTitle } from '../lib/sessionTitle' import { invalidateRecentProjectsCache } from '../lib/recentProjectsCache' import { releaseWorkspaceSession } from '../lib/workspace/releaseSession' +import { SIDEBAR_PROJECT_SESSION_PREVIEW_LIMIT } from '../lib/sessionListPagination' -const SESSION_LIST_LIMIT = 400 const PROJECT_HISTORY_PAGE_SIZE = 50 export type ProjectHistoryState = { @@ -52,6 +52,7 @@ type SessionStore = { historicalSessionIds: Set recentSessionIds: Set recentProjectBoundaries: Record + projectSessionTotals: Record projectHistory: Record fetchSessions: (project?: string) => Promise @@ -103,6 +104,7 @@ export const useSessionStore = create((set, get) => ({ historicalSessionIds: new Set(), recentSessionIds: new Set(), recentProjectBoundaries: {}, + projectSessionTotals: {}, projectHistory: {}, fetchSessions: async (project?: string) => { @@ -150,6 +152,9 @@ export const useSessionStore = create((set, get) => ({ ...raw.map((session) => session.id), ]), recentProjectBoundaries: shouldRetainRenderedSessions(indexStatus) ? {} : buildRecentProjectBoundaries(raw), + projectSessionTotals: response.projects + ? Object.fromEntries(response.projects.map((project) => [project.projectRoot, project.total])) + : {}, indexStatus, isLoading: indexStatus?.state === 'building' && sessions.length === 0, } @@ -542,8 +547,8 @@ function removeProjectHistorySessionIds( function buildSessionListParams(project: string | undefined) { return project - ? { project, limit: SESSION_LIST_LIMIT } - : { limit: SESSION_LIST_LIMIT } + ? { project, limit: SIDEBAR_PROJECT_SESSION_PREVIEW_LIMIT } + : { view: 'sidebar' as const, perProjectLimit: SIDEBAR_PROJECT_SESSION_PREVIEW_LIMIT } } function getDefaultSessionPermissionMode(): PermissionMode | undefined { diff --git a/desktop/src/stores/tabStore.test.ts b/desktop/src/stores/tabStore.test.ts index 187e7604..cdfaaefe 100644 --- a/desktop/src/stores/tabStore.test.ts +++ b/desktop/src/stores/tabStore.test.ts @@ -349,7 +349,7 @@ describe('tabStore', () => { useTabStore.getState().closeTab(historical.id) await useSessionStore.getState().fetchSessions() - expect(sessionsApi.list).toHaveBeenLastCalledWith({ limit: 400 }) + expect(sessionsApi.list).toHaveBeenLastCalledWith({ view: 'sidebar', perProjectLimit: 6 }) expect(useSessionStore.getState().sessions).toEqual([historical]) useTabStore.getState().closeTab(traceId) await useSessionStore.getState().fetchSessions() @@ -477,7 +477,7 @@ describe('tabStore', () => { await restoring expect(sessionsApi.list).toHaveBeenNthCalledWith(1, { limit: 200 }) - expect(sessionsApi.list).toHaveBeenNthCalledWith(2, { limit: 400 }) + expect(sessionsApi.list).toHaveBeenNthCalledWith(2, { view: 'sidebar', perProjectLimit: 6 }) expect(useSessionStore.getState().sessions.find((session) => session.id === freshRecent.id)) .toEqual(freshRecent) expect(useSessionRuntimeStore.getState().selections[freshRecent.id]).toEqual({ diff --git a/src/server/__tests__/filesystem.test.ts b/src/server/__tests__/filesystem.test.ts index b36aad3e..120d10eb 100644 --- a/src/server/__tests__/filesystem.test.ts +++ b/src/server/__tests__/filesystem.test.ts @@ -246,7 +246,7 @@ describe('filesystem API', () => { expect(srcPaths.indexOf('src/hooks')).toBeLessThan(srcPaths.indexOf('scripts/quality-gate/baseline/fixtures/cross-module-refactor/src')) }) - it('falls back to ripgrep search outside git and still respects ignore files', async () => { + it('searches non-git trees without materializing every path and still respects ignore files', async () => { const homeFixtureDir = await fsp.mkdtemp(path.join(os.homedir(), 'claude-filesystem-test-')) cleanupDirs.add(homeFixtureDir) await fsp.mkdir(path.join(homeFixtureDir, 'app'), { recursive: true }) @@ -325,6 +325,20 @@ describe('filesystem API', () => { expect(files).toEqual(['a-target/needle.ts']) }) + it('stops a bounded filesystem search when its caller is superseded', async () => { + const fixtureDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'claude-filesystem-abort-')) + cleanupDirs.add(fixtureDir) + await fsp.mkdir(path.join(fixtureDir, 'nested'), { recursive: true }) + await fsp.writeFile(path.join(fixtureDir, 'nested', 'needle.ts'), '') + const controller = new AbortController() + controller.abort() + + await expect(getProjectSearchFiles(fixtureDir, { + signal: controller.signal, + fallbackOptions: { searchQuery: 'needle' }, + })).rejects.toMatchObject({ name: 'AbortError' }) + }) + it('accepts /private/tmp aliases on macOS for browsing and file serving', async () => { if (process.platform !== 'darwin') return diff --git a/src/server/__tests__/project-session-history.test.ts b/src/server/__tests__/project-session-history.test.ts index 94cbcbc9..e7216604 100644 --- a/src/server/__tests__/project-session-history.test.ts +++ b/src/server/__tests__/project-session-history.test.ts @@ -60,7 +60,57 @@ function request(params: Record, method = 'GET') { return handleSessionsApi(new Request(url, { method }), url, ['api', 'sessions', 'project-history']) } +function sidebarRequest(params: Record) { + const url = new URL(`http://127.0.0.1/api/sessions?${new URLSearchParams(params)}`) + return handleSessionsApi(new Request(url), url, ['api', 'sessions']) +} + describe('logical project session history', () => { + it('hydrates only a bounded preview for every logical project', async () => { + const anotherRoot = path.join(configDir, 'another-project') + const rows = await Promise.all([ + seed(0), + seed(1), + seed(2), + seed(3, { root: anotherRoot }), + seed(4, { root: anotherRoot }), + seed(5, { root: anotherRoot }), + ]) + const service = new SessionService(gateway(rows, 'on')) + const internals = service as unknown as { hydrateIndexedSession: (...args: unknown[]) => Promise } + const hydrate = spyOn(internals, 'hydrateIndexedSession') + try { + const preview = await service.listProjectPreviews(2) + + expect(preview.sessions.map(session => session.id)).toEqual([ + idFor(0), idFor(1), idFor(3), idFor(4), + ]) + expect(preview.projects).toEqual([ + { projectRoot, total: 3 }, + { projectRoot: anotherRoot, total: 3 }, + ]) + expect(preview.total).toBe(6) + expect(hydrate).toHaveBeenCalledTimes(4) + } finally { hydrate.mockRestore() } + }) + + it('validates sidebar preview limits at the API boundary', async () => { + await seed(0) + const response = await sidebarRequest({ view: 'sidebar', perProjectLimit: '1' }) + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ + sessions: [expect.objectContaining({ id: idFor(0) })], + projects: [{ projectRoot, total: 1 }], + total: 1, + }) + for (const params of [ + { view: 'unknown' }, + { view: 'sidebar', perProjectLimit: '0' }, + { view: 'sidebar', perProjectLimit: '2junk' }, + ]) expect((await sidebarRequest(params)).status).toBe(400) + }) + it.each(['off', 'on', 'shadow'] as const)('groups root, external and removed worktree transcripts in %s mode', async (mode) => { const rows = [ await seed(0), diff --git a/src/server/api/filesystem.ts b/src/server/api/filesystem.ts index 6bd89c18..dd286644 100644 --- a/src/server/api/filesystem.ts +++ b/src/server/api/filesystem.ts @@ -34,9 +34,11 @@ type ScoredFilesystemEntry = FilesystemEntry & { } const FILE_SEARCH_TIMEOUT_MS = 10_000 +const INTERACTIVE_FILE_SEARCH_TIMEOUT_MS = 2_000 const FILE_SEARCH_FALLBACK_MAX_DIRECTORIES = 5_000 const FILE_SEARCH_FALLBACK_MAX_FILES = 20_000 const VCS_METADATA_DIRECTORY_NAMES = new Set(['.git', '.svn', '.hg', '.bzr', '.jj', '.sl']) +const activeInteractiveSearches = new Map() type ProjectSearchDependencies = { ripGrepFn?: ( @@ -50,6 +52,7 @@ type ProjectSearchDependencies = { maxDirectories?: number maxFiles?: number } + signal?: AbortSignal } type SearchIgnoreContext = { @@ -98,9 +101,9 @@ export function isAllowedFilesystemPath(targetPath: string): boolean { return false } -export async function handleFilesystemRoute(pathname: string, url: URL): Promise { +export async function handleFilesystemRoute(pathname: string, url: URL, signal?: AbortSignal): Promise { if (pathname === '/api/filesystem/browse') { - return handleBrowse(url) + return handleBrowse(url, signal) } if (pathname === '/api/filesystem/file') { @@ -159,7 +162,7 @@ async function handleServeFile(url: URL): Promise { } } -async function handleBrowse(url: URL): Promise { +async function handleBrowse(url: URL, requestSignal?: AbortSignal): Promise { const targetPath = url.searchParams.get('path') || os.homedir() || '/' const resolvedPath = path.resolve(normalizeDriveRootPathForPlatform(targetPath)) const canonicalPath = await canonicalizeExistingFilesystemPath(resolvedPath) @@ -184,10 +187,27 @@ async function handleBrowse(url: URL): Promise { } if (searchQuery) { - const results = await searchFilesystemEntries(canonicalPath, searchQuery, { - includeFiles, - maxResults, - }) + // Composer searches supersede one another while the user types. Abort the + // older scan before starting another one for the same root so broad roots + // such as /tmp cannot accumulate a process and a large candidate array + // for every keystroke. + activeInteractiveSearches.get(canonicalPath)?.abort() + const searchController = new AbortController() + activeInteractiveSearches.set(canonicalPath, searchController) + const signal = combineAbortSignals(requestSignal, searchController.signal) + let results: FilesystemEntry[] + try { + results = await searchFilesystemEntries(canonicalPath, searchQuery, { + includeFiles, + maxResults, + signal, + timeoutMs: INTERACTIVE_FILE_SEARCH_TIMEOUT_MS, + }) + } finally { + if (activeInteractiveSearches.get(canonicalPath) === searchController) { + activeInteractiveSearches.delete(canonicalPath) + } + } return json({ currentPath: canonicalPath, @@ -230,7 +250,13 @@ async function handleBrowse(url: URL): Promise { export async function searchFilesystemEntries( rootPath: string, searchQuery: string, - options: { includeFiles: boolean; includeDirectories?: boolean; maxResults: number }, + options: { + includeFiles: boolean + includeDirectories?: boolean + maxResults: number + signal?: AbortSignal + timeoutMs?: number + }, ): Promise { const normalizedQuery = normalizeSearchText(searchQuery) if (!normalizedQuery) return [] @@ -240,7 +266,10 @@ export async function searchFilesystemEntries( options.includeFiles, options.includeDirectories ?? true, normalizedQuery, + options.signal, + options.timeoutMs, ) + options.signal?.throwIfAborted() const results = candidates .map((entry): ScoredFilesystemEntry | null => { const relativePath = entry.relativePath ?? entry.name @@ -269,13 +298,17 @@ async function getSearchCandidates( includeFiles: boolean, includeDirectories: boolean, searchQuery: string, + signal?: AbortSignal, + timeoutMs?: number, ): Promise { const files = await getProjectSearchFiles(rootPath, { - fallbackOptions: { searchQuery }, + fallbackOptions: { searchQuery, timeoutMs }, + signal, }) const entries = new Map() for (const filePath of files) { + signal?.throwIfAborted() const normalizedFile = normalizeRelativePath(filePath) if (!normalizedFile || !isRelativeInsideRoot(normalizedFile)) continue @@ -311,23 +344,40 @@ export async function getProjectSearchFiles( rootPath: string, dependencies: ProjectSearchDependencies = {}, ): Promise { + dependencies.signal?.throwIfAborted() const respectGitignore = shouldRespectGitignore() - const gitFiles = await getFilesUsingGit(rootPath, respectGitignore) + const gitFiles = await getFilesUsingGit(rootPath, respectGitignore, dependencies.signal) if (gitFiles !== null && gitFiles.length > 0) { return gitFiles } + // For a non-repository interactive search, scanning every file into rg's + // stdout before applying the query is both slower and far more memory hungry. + // The bounded fallback applies the query while walking and stops at its + // deadline/budget. + if (dependencies.fallbackOptions?.searchQuery) { + return getFilesUsingFilesystem( + rootPath, + respectGitignore, + dependencies.fallbackOptions, + dependencies.signal, + ) + } + try { return await getFilesUsingRipgrep( rootPath, respectGitignore, dependencies.ripGrepFn ?? ripGrep, + dependencies.signal, ) } catch { + dependencies.signal?.throwIfAborted() return getFilesUsingFilesystem( rootPath, respectGitignore, dependencies.fallbackOptions, + dependencies.signal, ) } } @@ -338,15 +388,16 @@ function shouldRespectGitignore(): boolean { return projectSettings.respectGitignore ?? globalConfig.respectGitignore ?? true } -async function getFilesUsingGit(rootPath: string, respectGitignore: boolean): Promise { +async function getFilesUsingGit(rootPath: string, respectGitignore: boolean, signal?: AbortSignal): Promise { const repoRoot = findGitRoot(rootPath) if (!repoRoot) return null const trackedResult = await execFileNoThrowWithCwd( gitExe(), ['-c', 'core.quotepath=false', 'ls-files', '--recurse-submodules'], - { timeout: FILE_SEARCH_TIMEOUT_MS, cwd: repoRoot }, + { timeout: FILE_SEARCH_TIMEOUT_MS, cwd: repoRoot, abortSignal: signal }, ) + signal?.throwIfAborted() if (trackedResult.code !== 0) return null const untrackedArgs = respectGitignore @@ -355,7 +406,9 @@ async function getFilesUsingGit(rootPath: string, respectGitignore: boolean): Pr const untrackedResult = await execFileNoThrowWithCwd(gitExe(), untrackedArgs, { timeout: FILE_SEARCH_TIMEOUT_MS, cwd: repoRoot, + abortSignal: signal, }) + signal?.throwIfAborted() const files = [ ...lines(trackedResult.stdout), @@ -377,6 +430,7 @@ async function getFilesUsingRipgrep( rootPath: string, respectGitignore: boolean, ripGrepFn: NonNullable, + signal?: AbortSignal, ): Promise { const rgArgs = [ '--files', @@ -402,8 +456,9 @@ async function getFilesUsingRipgrep( const files = await ripGrepFn( rgArgs, rootPath, - AbortSignal.timeout(FILE_SEARCH_TIMEOUT_MS), + combineAbortSignals(signal, AbortSignal.timeout(FILE_SEARCH_TIMEOUT_MS)), ) + signal?.throwIfAborted() let normalized = files .map(filePath => normalizeRipgrepPath(filePath, rootPath)) .filter((filePath): filePath is string => filePath !== null) @@ -420,6 +475,7 @@ async function getFilesUsingFilesystem( rootPath: string, respectGitignore: boolean, options: NonNullable = {}, + signal?: AbortSignal, ): Promise { const deadline = Date.now() + (options.timeoutMs ?? FILE_SEARCH_TIMEOUT_MS) const maxDirectories = options.maxDirectories ?? FILE_SEARCH_FALLBACK_MAX_DIRECTORIES @@ -440,6 +496,7 @@ async function getFilesUsingFilesystem( files.length < maxFiles && Date.now() < deadline ) { + signal?.throwIfAborted() const current = directories[directoryIndex] directoryIndex += 1 if (!current) continue @@ -453,6 +510,7 @@ async function getFilesUsingFilesystem( } catch { continue } + signal?.throwIfAborted() entries.sort((left, right) => { if (left.isDirectory() !== right.isDirectory()) { @@ -508,6 +566,13 @@ async function getFilesUsingFilesystem( return files } +function combineAbortSignals(...signals: Array): AbortSignal { + const available = signals.filter((signal): signal is AbortSignal => signal !== undefined) + if (available.length === 0) return new AbortController().signal + if (available.length === 1) return available[0]! + return AbortSignal.any(available) +} + function loadDirectorySearchIgnorePatterns( directoryPath: string, includeGitignore: boolean, diff --git a/src/server/api/sessions.ts b/src/server/api/sessions.ts index ef579563..d4da5255 100644 --- a/src/server/api/sessions.ts +++ b/src/server/api/sessions.ts @@ -431,6 +431,7 @@ export async function handleSessionsApi( async function listSessions(req: Request, url: URL): Promise { const project = url.searchParams.get('project') || undefined + const view = url.searchParams.get('view') const requestedLimit = parseInt(url.searchParams.get('limit') || '20', 10) const offset = parseInt(url.searchParams.get('offset') || '0', 10) @@ -442,6 +443,19 @@ async function listSessions(req: Request, url: URL): Promise { } const petAccess = isPetAccessAuthorized(req) + if (!petAccess && view !== null) { + if (view !== 'sidebar') throw ApiError.badRequest('Invalid session list view') + const rawPerProjectLimit = url.searchParams.get('perProjectLimit') ?? '6' + if (!/^\d+$/.test(rawPerProjectLimit)) throw ApiError.badRequest('Invalid perProjectLimit parameter') + const perProjectLimit = Number(rawPerProjectLimit) + if (!Number.isSafeInteger(perProjectLimit) || perProjectLimit <= 0) { + throw ApiError.badRequest('Invalid perProjectLimit parameter') + } + return Response.json({ + ...await sessionService.listProjectPreviews(perProjectLimit), + index: localIndexCoordinator.getPublicStatus(), + }) + } const limit = petAccess ? Math.min(requestedLimit, PET_SESSION_LIMIT) : requestedLimit const result = await sessionService.listSessions({ ...(petAccess ? {} : { project }), diff --git a/src/server/index.ts b/src/server/index.ts index 242e59c6..8ab0e65c 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -28,6 +28,7 @@ import { OPENAI_CODEX_REDIRECT_PATH } from '../services/openaiAuth/client.js' import { ensureDesktopCliLauncherInstalled } from './services/desktopCliLauncherService.js' import { enableConfigs } from '../utils/config.js' import { diagnosticsService } from './services/diagnosticsService.js' +import { apiPerformanceMonitor } from './services/apiPerformanceMonitor.js' import { ensurePersistentStorageUpgraded } from './services/persistentStorageMigrations.js' import { handleStaticH5Request } from './staticH5.js' import { @@ -623,6 +624,7 @@ export function startServer(port = PORT, host = HOST) { const disposeCollaboration = configureSessionCollaborationHost(localConnectHost, server.port) const stop = server.stop.bind(server) server.stop = (closeActiveConnections?: boolean) => { + apiPerformanceMonitor.stop() disposeCollaboration() publicAccess.disable() publicAccessServers.delete(publicAccess) @@ -630,6 +632,7 @@ export function startServer(port = PORT, host = HOST) { } serverPort = server.port ProviderService.setServerPort(serverPort) + apiPerformanceMonitor.start() } catch (error) { publicAccess.disable() publicAccessServers.delete(publicAccess) diff --git a/src/server/middleware/cors.test.ts b/src/server/middleware/cors.test.ts index 7d677242..529a77d4 100644 --- a/src/server/middleware/cors.test.ts +++ b/src/server/middleware/cors.test.ts @@ -28,6 +28,7 @@ describe('resolveCors', () => { 'Access-Control-Allow-Origin': 'https://example.com', 'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + 'Access-Control-Expose-Headers': 'Server-Timing, X-Request-Id', 'Access-Control-Max-Age': '86400', Vary: 'Origin', }, @@ -46,6 +47,7 @@ describe('resolveCors', () => { headers: { 'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + 'Access-Control-Expose-Headers': 'Server-Timing, X-Request-Id', 'Access-Control-Max-Age': '86400', Vary: 'Origin', }, @@ -65,6 +67,7 @@ describe('resolveCors', () => { 'Access-Control-Allow-Origin': 'https://allowed.example.com', 'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + 'Access-Control-Expose-Headers': 'Server-Timing, X-Request-Id', 'Access-Control-Max-Age': '86400', Vary: 'Origin', }, diff --git a/src/server/middleware/cors.ts b/src/server/middleware/cors.ts index 92905ed7..9d946438 100644 --- a/src/server/middleware/cors.ts +++ b/src/server/middleware/cors.ts @@ -8,6 +8,7 @@ export function corsHeaders(origin?: string | null): Record { 'Access-Control-Allow-Origin': allowedOrigin, 'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + 'Access-Control-Expose-Headers': 'Server-Timing, X-Request-Id', 'Access-Control-Max-Age': '86400', Vary: 'Origin', } @@ -17,6 +18,7 @@ function baseCorsHeaders(): Record { return { 'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + 'Access-Control-Expose-Headers': 'Server-Timing, X-Request-Id', 'Access-Control-Max-Age': '86400', Vary: 'Origin', } diff --git a/src/server/router.ts b/src/server/router.ts index e422f6c7..ce5b7c23 100644 --- a/src/server/router.ts +++ b/src/server/router.ts @@ -32,6 +32,7 @@ import { handleMemoryApi } from './api/memory.js' import { handleDesktopUiApi } from './api/desktop-ui.js' import { handleTracesApi } from './api/traces.js' import { handleWorkflowsApi } from './api/workflows.js' +import { apiPerformanceMonitor } from './services/apiPerformanceMonitor.js' import { remoteProviderRouteAllowed, remoteSettingsRouteAllowed, projectRemoteProvider, projectRemoteSettings, replaceRemoteCompatibility, validateRemoteSettingsPatch, type ApiRequestContext } from './remoteBrowserPolicy.js' import { ProviderService } from './services/providerService.js' @@ -39,6 +40,16 @@ import type { SavedProvider } from './types/provider.js' import { remoteProviderNeedsCredentials } from './remoteProviderCredentials.js' export async function handleApiRequest(req: Request, url: URL, context: ApiRequestContext = {}): Promise { + const span = apiPerformanceMonitor.begin(req.method, url.pathname) + try { + return span.complete(await handleApiRequestWithoutPerformance(req, url, context)) + } catch (error) { + span.fail() + throw error + } +} + +async function handleApiRequestWithoutPerformance(req: Request, url: URL, context: ApiRequestContext = {}): Promise { if (!context.remoteBrowser) return routeApiRequest(req, url) const parts = url.pathname.split('/').filter(Boolean) const isProvider = parts[1] === 'providers' @@ -200,7 +211,7 @@ async function routeApiRequest(req: Request, url: URL): Promise { return handleTracesApi(req, url, segments) case 'filesystem': - return handleFilesystemRoute(url.pathname, url) + return handleFilesystemRoute(url.pathname, url, req.signal) default: return Response.json( diff --git a/src/server/services/apiPerformanceMonitor.test.ts b/src/server/services/apiPerformanceMonitor.test.ts new file mode 100644 index 00000000..c3496dbd --- /dev/null +++ b/src/server/services/apiPerformanceMonitor.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it, vi } from 'vitest' +import { ApiPerformanceMonitor } from './apiPerformanceMonitor.js' + +const MEMORY = { + rss: 128 * 1024 * 1024, + heapTotal: 64 * 1024 * 1024, + heapUsed: 32 * 1024 * 1024, + external: 8 * 1024 * 1024, + arrayBuffers: 4 * 1024 * 1024, +} + +describe('ApiPerformanceMonitor', () => { + it('adds correlation timings and records bounded slow-request metadata', async () => { + let now = 100 + const recordEvent = vi.fn() + const monitor = new ApiPerformanceMonitor({ + now: () => now, + cpuUsage: (previous) => previous ? { user: 15_000, system: 4_000 } : { user: 0, system: 0 }, + memoryUsage: () => MEMORY, + recordEvent, + slowRequestMs: 1_000, + }) + + const span = monitor.begin('GET', '/api/sessions') + now = 1_350 + const response = span.complete(Response.json({ sessions: [] })) + await Promise.resolve() + + expect(response.headers.get('server-timing')).toBe('app;dur=1250') + expect(response.headers.get('x-request-id')).toMatch(/^api-/) + expect(recordEvent).toHaveBeenCalledWith(expect.objectContaining({ + type: 'api_request_slow', + details: expect.objectContaining({ + path: '/api/sessions', + route: '/api/sessions', + durationMs: 1_250, + cpuUserMs: 15, + rssMiB: 128, + }), + })) + }) + + it('reports an event-loop stall with the requests that were active during it', async () => { + let now = 0 + let sample: (() => void) | undefined + const recordEvent = vi.fn() + const monitor = new ApiPerformanceMonitor({ + now: () => now, + cpuUsage: () => ({ user: 0, system: 0 }), + memoryUsage: () => MEMORY, + recordEvent, + setInterval: ((callback: TimerHandler) => { + sample = callback as () => void + return 1 as unknown as ReturnType + }) as typeof setInterval, + clearInterval: vi.fn() as unknown as typeof clearInterval, + eventLoopSampleMs: 1_000, + eventLoopStallMs: 500, + }) + + monitor.start() + monitor.begin('GET', '/api/sessions/example/subagents/by-tool/tool-1') + now = 1_800 + sample?.() + await Promise.resolve() + + expect(recordEvent).toHaveBeenCalledWith(expect.objectContaining({ + type: 'server_event_loop_stall', + details: expect.objectContaining({ + lagMs: 800, + activeRequests: 1, + longestActiveRequests: [expect.objectContaining({ + path: '/api/sessions/example/subagents/by-tool/tool-1', + elapsedMs: 1_800, + })], + }), + })) + }) + + it('never recursively reports a slow diagnostics write', () => { + let now = 0 + const recordEvent = vi.fn() + const monitor = new ApiPerformanceMonitor({ + now: () => now, + cpuUsage: () => ({ user: 0, system: 0 }), + memoryUsage: () => MEMORY, + recordEvent, + slowRequestMs: 1, + }) + const span = monitor.begin('POST', '/api/diagnostics/events') + now = 10 + span.complete(new Response(null, { status: 204 })) + + expect(recordEvent).not.toHaveBeenCalled() + }) +}) diff --git a/src/server/services/apiPerformanceMonitor.ts b/src/server/services/apiPerformanceMonitor.ts new file mode 100644 index 00000000..f93d9562 --- /dev/null +++ b/src/server/services/apiPerformanceMonitor.ts @@ -0,0 +1,199 @@ +import { diagnosticsService, type DiagnosticEventInput } from './diagnosticsService.js' + +const DEFAULT_SLOW_REQUEST_MS = 1_000 +const DEFAULT_EVENT_LOOP_SAMPLE_MS = 1_000 +const DEFAULT_EVENT_LOOP_STALL_MS = 500 +const DEFAULT_STALL_REPORT_COOLDOWN_MS = 10_000 + +type ActiveRequest = { + id: string + method: string + path: string + startedAt: number + cpuStartedAt: NodeJS.CpuUsage + concurrentAtStart: number +} + +type MonitorDependencies = { + now?: () => number + cpuUsage?: (previousValue?: NodeJS.CpuUsage) => NodeJS.CpuUsage + memoryUsage?: () => NodeJS.MemoryUsage + recordEvent?: (event: DiagnosticEventInput) => unknown + setInterval?: typeof globalThis.setInterval + clearInterval?: typeof globalThis.clearInterval + slowRequestMs?: number + eventLoopSampleMs?: number + eventLoopStallMs?: number + stallReportCooldownMs?: number +} + +export type ApiRequestPerformanceSpan = { + complete(response: Response): Response + fail(): void +} + +function rounded(value: number): number { + return Math.round(Math.max(0, value) * 10) / 10 +} + +function bytesToMiB(value: number): number { + return rounded(value / (1024 * 1024)) +} + +export class ApiPerformanceMonitor { + private readonly now: () => number + private readonly cpuUsage: (previousValue?: NodeJS.CpuUsage) => NodeJS.CpuUsage + private readonly memoryUsage: () => NodeJS.MemoryUsage + private readonly recordEvent: (event: DiagnosticEventInput) => unknown + private readonly scheduleInterval: typeof globalThis.setInterval + private readonly cancelInterval: typeof globalThis.clearInterval + private readonly slowRequestMs: number + private readonly eventLoopSampleMs: number + private readonly eventLoopStallMs: number + private readonly stallReportCooldownMs: number + private readonly active = new Map() + private sequence = 0 + private timer: ReturnType | undefined + private nextEventLoopSampleAt = 0 + private lastStallReportAt = Number.NEGATIVE_INFINITY + + constructor(dependencies: MonitorDependencies = {}) { + this.now = dependencies.now ?? (() => performance.now()) + this.cpuUsage = dependencies.cpuUsage ?? process.cpuUsage.bind(process) + this.memoryUsage = dependencies.memoryUsage ?? process.memoryUsage.bind(process) + this.recordEvent = dependencies.recordEvent ?? ((event) => diagnosticsService.recordEvent(event)) + this.scheduleInterval = dependencies.setInterval ?? globalThis.setInterval + this.cancelInterval = dependencies.clearInterval ?? globalThis.clearInterval + this.slowRequestMs = dependencies.slowRequestMs ?? DEFAULT_SLOW_REQUEST_MS + this.eventLoopSampleMs = dependencies.eventLoopSampleMs ?? DEFAULT_EVENT_LOOP_SAMPLE_MS + this.eventLoopStallMs = dependencies.eventLoopStallMs ?? DEFAULT_EVENT_LOOP_STALL_MS + this.stallReportCooldownMs = dependencies.stallReportCooldownMs ?? DEFAULT_STALL_REPORT_COOLDOWN_MS + } + + start(): void { + if (this.timer) return + this.nextEventLoopSampleAt = this.now() + this.eventLoopSampleMs + this.timer = this.scheduleInterval(() => this.sampleEventLoop(), this.eventLoopSampleMs) + const unref = (this.timer as unknown as { unref?: () => void }).unref + unref?.call(this.timer) + } + + stop(): void { + if (!this.timer) return + this.cancelInterval(this.timer) + this.timer = undefined + } + + begin(method: string, path: string): ApiRequestPerformanceSpan { + const startedAt = this.now() + const id = `api-${process.pid}-${Math.trunc(startedAt).toString(36)}-${++this.sequence}` + const request: ActiveRequest = { + id, + method, + path, + startedAt, + cpuStartedAt: this.cpuUsage(), + concurrentAtStart: this.active.size + 1, + } + this.active.set(id, request) + let settled = false + + return { + complete: (response) => { + if (settled) return response + settled = true + this.active.delete(id) + const durationMs = this.now() - startedAt + const cpu = this.cpuUsage(request.cpuStartedAt) + const headers = new Headers(response.headers) + headers.set('Server-Timing', `app;dur=${rounded(durationMs)}`) + headers.set('X-Request-Id', id) + if (durationMs >= this.slowRequestMs && !path.startsWith('/api/diagnostics')) { + const declaredBytes = Number.parseInt(headers.get('content-length') ?? '', 10) + this.report({ + type: 'api_request_slow', + severity: 'warn', + summary: `${method} ${path} took ${rounded(durationMs)}ms`, + details: { + requestId: id, + method, + path, + route: path, + status: response.status, + durationMs: rounded(durationMs), + cpuUserMs: rounded(cpu.user / 1_000), + cpuSystemMs: rounded(cpu.system / 1_000), + concurrentAtStart: request.concurrentAtStart, + activeAtFinish: this.active.size, + declaredResponseBytes: Number.isFinite(declaredBytes) && declaredBytes >= 0 + ? declaredBytes + : null, + ...this.memoryDetails(), + }, + }) + } + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }) + }, + fail: () => { + if (settled) return + settled = true + this.active.delete(id) + }, + } + } + + private sampleEventLoop(): void { + const sampledAt = this.now() + const lagMs = Math.max(0, sampledAt - this.nextEventLoopSampleAt) + this.nextEventLoopSampleAt = sampledAt + this.eventLoopSampleMs + if ( + lagMs < this.eventLoopStallMs || + sampledAt - this.lastStallReportAt < this.stallReportCooldownMs + ) return + this.lastStallReportAt = sampledAt + const longestActiveRequests = [...this.active.values()] + .sort((left, right) => left.startedAt - right.startedAt) + .slice(0, 5) + .map((request) => ({ + requestId: request.id, + method: request.method, + path: request.path, + elapsedMs: rounded(sampledAt - request.startedAt), + })) + this.report({ + type: 'server_event_loop_stall', + severity: 'warn', + summary: `Server event loop was delayed by ${rounded(lagMs)}ms`, + details: { + lagMs: rounded(lagMs), + activeRequests: this.active.size, + longestActiveRequests, + ...this.memoryDetails(), + }, + }) + } + + private memoryDetails() { + const memory = this.memoryUsage() + return { + rssMiB: bytesToMiB(memory.rss), + heapUsedMiB: bytesToMiB(memory.heapUsed), + externalMiB: bytesToMiB(memory.external), + arrayBuffersMiB: bytesToMiB(memory.arrayBuffers), + } + } + + private report(event: DiagnosticEventInput): void { + try { + void Promise.resolve(this.recordEvent(event)).catch(() => undefined) + } catch { + // Diagnostics must never make an already slow request fail. + } + } +} + +export const apiPerformanceMonitor = new ApiPerformanceMonitor() diff --git a/src/server/services/diagnosticsShare.test.ts b/src/server/services/diagnosticsShare.test.ts index 5c6e49bf..24c2401f 100644 --- a/src/server/services/diagnosticsShare.test.ts +++ b/src/server/services/diagnosticsShare.test.ts @@ -115,6 +115,53 @@ describe('projectDiagnosticEventForSharing', () => { expect(projected.omittedFields).toContain('details.error.message') expect(projected.omittedFields).toContain('details.error.stack') }) + + test('keeps share-safe performance timings while omitting request paths', () => { + const projected = projectDiagnosticEventForSharing({ + id: 'event-performance-1', + timestamp: '2026-09-21T22:15:00.000Z', + type: 'client_api_request_slow', + severity: 'warn', + summary: 'GET /api/private took 7736ms', + details: { + method: 'GET', + path: '/api/filesystem/browse?path=%2FUsers%2Falice%2Fprivate', + route: '/api/filesystem/browse', + durationMs: 7736, + fetchMs: 7735.6, + responseReadMs: 0.4, + responseChars: 200600, + recoveryMs: 0, + attempts: 1, + recovered: false, + requestId: 'api-60114-1tq5-3122', + serverTiming: 'app;dur=0.2', + serverAppMs: 0.2, + declaredBytes: 211758, + status: 200, + }, + }) + + expect(projected.details).toEqual({ + method: 'GET', + route: '/api/filesystem/browse', + durationMs: 7736, + fetchMs: 7735.6, + responseReadMs: 0.4, + responseChars: 200600, + recoveryMs: 0, + attempts: 1, + recovered: false, + requestId: 'api-60114-1tq5-3122', + serverAppMs: 0.2, + declaredBytes: 211758, + status: 200, + }) + expect(projected.omittedFields).toContain('summary') + expect(projected.omittedFields).toContain('details.path') + expect(projected.omittedFields).toContain('details.serverTiming') + expect(JSON.stringify(projected)).not.toContain('/Users/alice') + }) }) describe('buildDiagnosticsIssueReport', () => { diff --git a/src/server/services/diagnosticsShare.ts b/src/server/services/diagnosticsShare.ts index 82b8f3ee..f567ebbf 100644 --- a/src/server/services/diagnosticsShare.ts +++ b/src/server/services/diagnosticsShare.ts @@ -41,19 +41,48 @@ const CONTENT_BEARING_KEYS = new Set([ ]) const SAFE_SCALAR_KEYS = new Set([ + 'activeatfinish', + 'activerequests', + 'arraybuffersmib', + 'attempts', 'code', + 'concurrentatstart', + 'cpusystemms', + 'cpuuserms', + 'declaredbytes', + 'declaredresponsebytes', + 'durationms', 'errorcategory', 'errorcode', + 'fetchms', + 'externalmib', + 'hardwareconcurrency', + 'heapusedmib', 'is_error', 'isapierrormessage', 'iserror', + 'lagms', + 'method', 'name', + 'recovered', + 'recoveryms', + 'requestid', + 'route', + 'responsechars', + 'responsereadms', + 'rssmib', 'sdkType'.toLowerCase(), + 'serverappms', 'status', 'subtype', + 'suppressedsincelast', + 'timeoutms', + 'totaljsheapmib', + 'usedjsheapmib', ]) const SAFE_METADATA_VALUE_RE = /^[a-z0-9][a-z0-9_.:/ -]{0,127}$/i +const SAFE_API_ROUTE_RE = /^\/api(?:\/[a-z0-9_.:-]{1,64}){1,8}$/i const MAX_SHARED_IDENTIFIER_LENGTH = 256 const MAX_SHARED_METADATA_LENGTH = 512 const URL_RE = /https?:\/\/[^\s<>"')\]}]+/gi @@ -227,7 +256,9 @@ function projectDetails( continue } if (SAFE_SCALAR_KEYS.has(normalizedKey) && isScalar(entry)) { - projected[key] = projectSafeMetadataScalar(entry) + projected[key] = normalizedKey === 'route' && typeof entry === 'string' + ? projectSafeApiRoute(entry) + : projectSafeMetadataScalar(entry) continue } omittedFields.push(entryPath) @@ -269,6 +300,20 @@ function projectSafeMetadataScalar(value: string | number | boolean | null): str return SAFE_METADATA_VALUE_RE.test(sanitized) ? sanitized : '[REDACTED]' } +function projectSafeApiRoute(value: string): string { + if (!SAFE_API_ROUTE_RE.test(value)) return '[REDACTED]' + return value + .split('/') + .map((segment) => { + if ( + /^[0-9a-f]{8}-[0-9a-f-]{27,}$/i.test(segment) || + /^(?:call|toolu)_[a-z0-9_-]{12,}$/i.test(segment) + ) return ':id' + return redactSecrets(segment) === segment ? segment : ':redacted' + }) + .join('/') +} + function sanitizeSharedString(value: string, maxLength = MAX_SHARED_METADATA_LENGTH): string { const sanitized = redactSecrets(value.replace(LEGACY_DIAGNOSTIC_SECRET_RE, '[REDACTED]')) .replace(URL_RE, (candidate) => { diff --git a/src/server/services/projectSessionHistory.ts b/src/server/services/projectSessionHistory.ts index 43bc9a52..9f0c9375 100644 --- a/src/server/services/projectSessionHistory.ts +++ b/src/server/services/projectSessionHistory.ts @@ -11,6 +11,11 @@ export type ProjectHistoryOptions = { beforeId?: string } export type ProjectHistoryPage = { sessions: SessionListItem[]; nextCursor: string | null } +export type ProjectSessionPreviews = { + sessions: SessionListItem[] + projects: Array<{ projectRoot: string; total: number }> + total: number +} export type ProjectHistoryRow = IndexedSessionRow & { logicalProjectRoot: string } type Snapshot = { scope: string; root: string; expiresAt: number; rows: ProjectHistoryRow[] } @@ -106,6 +111,63 @@ export class ProjectSessionHistory { throw new ApiError(409, 'Project history changed during loading; retry from the first page', 'PROJECT_HISTORY_CHANGED') } + /** Return only the newest rows needed to paint each sidebar project group. */ + async listPreviews(requestedLimit = 6): Promise { + if (!Number.isSafeInteger(requestedLimit) || requestedLimit <= 0) { + throw ApiError.badRequest('Invalid per-project limit') + } + const limit = Math.min(20, requestedLimit) + const scope = this.source.scope() + + for (let attempt = 0; attempt < 3; attempt += 1) { + const mutation = this.source.mutation() + const catalog = await this.loadCatalog() + if (scope !== this.source.scope()) throw this.expired() + + const grouped = new Map }>() + for (const row of catalog) { + let project = grouped.get(row.logicalProjectRoot) + if (!project) { + project = { total: 0, rows: [], seen: new Set() } + grouped.set(row.logicalProjectRoot, project) + } + if (project.seen.has(row.id)) continue + project.seen.add(row.id) + project.total += 1 + // Keep a bounded fallback tail so a handful of stale index rows do not + // leave an otherwise healthy project preview empty. + if (project.rows.length < limit * 2) project.rows.push(row) + } + + const sessions: SessionListItem[] = [] + for (const project of grouped.values()) { + let hydrated = 0 + for (const row of project.rows) { + const session = await this.source.hydrate(row) + if (session) { + sessions.push(session) + hydrated += 1 + } + if (hydrated >= limit) break + } + } + if (mutation !== this.source.mutation()) continue + if (scope !== this.source.scope()) throw this.expired() + + sessions.sort((a, b) => Date.parse(b.modifiedAt) - Date.parse(a.modifiedAt) || a.id.localeCompare(b.id)) + const projects = [...grouped.entries()].map(([projectRoot, project]) => ({ + projectRoot, + total: project.total, + })) + return { + sessions, + projects, + total: projects.reduce((sum, project) => sum + project.total, 0), + } + } + throw new ApiError(409, 'Project history changed during loading; retry', 'PROJECT_HISTORY_CHANGED') + } + private async loadCatalog(): Promise { for (let attempt = 0; attempt < 3; attempt += 1) { const key = this.source.revision() diff --git a/src/server/services/sessionCollaborationHost.test.ts b/src/server/services/sessionCollaborationHost.test.ts index 530e9f5f..2ca1e748 100644 --- a/src/server/services/sessionCollaborationHost.test.ts +++ b/src/server/services/sessionCollaborationHost.test.ts @@ -47,6 +47,25 @@ test('terminal failure does not claim an accepted message was consumed', async ( expect((await service.status()).messages[0]?.status).toBe('consumed') }) +test('streaming output does not broadcast collaboration refreshes until collaboration state changes', async () => { + const service = new SessionCollaborationService({ + statePath: join(directory, 'state.json'), + sessions: { exists: async () => true, list: async () => [], read: async () => [], create: async () => ({ sessionId: 'unused' }) }, + runtime: { start: async () => {}, enqueue: async () => {}, stop: async () => {} }, + }) + + expect(await handleSessionCollaborationEvent(service, { + type: 'output', + sessionId: 'worker', + message: { type: 'assistant', content: [{ type: 'text', text: 'stream fragment' }] }, + })).toBe(false) + expect(await handleSessionCollaborationEvent(service, { + type: 'output', + sessionId: 'worker', + message: { type: 'result', is_error: false, result: 'done', uuid: 'result-1' }, + })).toBe(true) +}) + test('host rejects unavailable source workspace explicitly instead of silently choosing another directory', async () => { const previous = process.env.CLAUDE_CONFIG_DIR process.env.CLAUDE_CONFIG_DIR = directory diff --git a/src/server/services/sessionCollaborationHost.ts b/src/server/services/sessionCollaborationHost.ts index f30a2806..b4eb1d2e 100644 --- a/src/server/services/sessionCollaborationHost.ts +++ b/src/server/services/sessionCollaborationHost.ts @@ -128,23 +128,36 @@ export async function getSessionCollaborationService(): Promise { +export async function handleSessionCollaborationEvent(service: SessionCollaborationService, event: SessionTurnEvent): Promise { // A renderer input arrives before the CLI is started. Only reopen its fence // here; the shared admission's committed event proves the SDK can accept the // pending collaboration inbox without racing process startup. - if (event.type === 'user-input') { await service.onUserInput(event.sessionId, { dispatch: false }); return } - if (event.type === 'input-committed') { await service.onSessionState(event.sessionId, 'running'); return } - if (event.type === 'stopped') { await service.onStopped(event.sessionId); return } + if (event.type === 'user-input') { await service.onUserInput(event.sessionId, { dispatch: false }); return false } + if (event.type === 'input-committed') { await service.onSessionState(event.sessionId, 'running'); return false } + if (event.type === 'stopped') { await service.onStopped(event.sessionId); return false } const message = event.message + let collaborationStateChanged = false if (message.type === 'system' && message.subtype === 'session_message_receipt' && message.status === 'consumed') { await service.onMessageConsumed(message.message_id, event.sessionId) + collaborationStateChanged = true } else if (message.type === 'result') { await service.onSessionState(event.sessionId, message.is_error ? 'failed' : 'completed', String(message.result ?? message.errors?.join('\n') ?? '').slice(0, 8000), message.uuid) + collaborationStateChanged = true } else if (message.type === 'control_request' && message.request?.subtype === 'can_use_tool') { await service.onSessionState(event.sessionId, 'blocked', `Waiting for permission: ${message.request.tool_name ?? 'tool'}`, message.request_id) + collaborationStateChanged = true } else if (message.type === 'control_response' || message.type === 'control_cancel_request') { - if (getSessionTurnState(event.sessionId) === 'running') await service.onSessionState(event.sessionId, 'running') + if (getSessionTurnState(event.sessionId) === 'running') { + await service.onSessionState(event.sessionId, 'running') + collaborationStateChanged = true + } } + // The SDK emits every streaming assistant fragment through this observer. + // Broadcasting a collaboration update for fragments that do not mutate the + // collaboration state made every renderer refetch the full session list, + // creating thousands of concurrent requests during multi-agent turns. + if (!collaborationStateChanged) return false sendToSession(event.sessionId, { type: 'system_notification', subtype: 'session_collaboration_updated', data: { sessionId: event.sessionId } }) + return true } diff --git a/src/server/services/sessionService.ts b/src/server/services/sessionService.ts index d74dbcb7..cd2ac94f 100644 --- a/src/server/services/sessionService.ts +++ b/src/server/services/sessionService.ts @@ -81,6 +81,7 @@ import { type ProjectHistoryOptions, type ProjectHistoryPage, type ProjectHistoryRow, + type ProjectSessionPreviews, } from './projectSessionHistory.js' // ============================================================================ @@ -3312,6 +3313,11 @@ export class SessionService { return this.projectHistory.list(options) } + /** Load the newest few sessions for every logical project in one request. */ + listProjectPreviews(perProjectLimit?: number): Promise { + return this.projectHistory.listPreviews(perProjectLimit) + } + private projectHistoryRevision(): string { this.syncSharedMutationEpoch() const scope = this.getConfigDir() diff --git a/src/utils/execFileNoThrow.test.ts b/src/utils/execFileNoThrow.test.ts new file mode 100644 index 00000000..a3d21c6c --- /dev/null +++ b/src/utils/execFileNoThrow.test.ts @@ -0,0 +1,17 @@ +import { expect, test } from 'bun:test' +import { execFileNoThrowWithCwd } from './execFileNoThrow.js' + +test('execFileNoThrowWithCwd cancels a child with the current execa API', async () => { + const controller = new AbortController() + const startedAt = Date.now() + const child = execFileNoThrowWithCwd( + process.execPath, + ['-e', 'setTimeout(() => {}, 30_000)'], + { abortSignal: controller.signal, timeout: 30_000 }, + ) + controller.abort() + + const result = await child + expect(result.code).not.toBe(0) + expect(Date.now() - startedAt).toBeLessThan(5_000) +}) diff --git a/src/utils/execFileNoThrow.ts b/src/utils/execFileNoThrow.ts index c21a6f01..d6bf018e 100644 --- a/src/utils/execFileNoThrow.ts +++ b/src/utils/execFileNoThrow.ts @@ -109,7 +109,7 @@ export function execFileNoThrowWithCwd( // Use execa for cross-platform .bat/.cmd compatibility on Windows execa(file, args, { maxBuffer, - signal: abortSignal, + cancelSignal: abortSignal, timeout: finalTimeout, cwd: finalCwd, env: finalEnv,