fix(desktop): diagnose and reduce session loading stalls

This commit is contained in:
程序员阿江(Relakkes)
2026-09-22 07:29:50 +08:00
parent 31e6f7f600
commit 0258ff7f96
34 changed files with 1128 additions and 53 deletions
+145 -15
View File
@@ -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<string> | 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<T>(method: string, path: string, body?: unknown, options?: ApiRequestOptions): Promise<T> {
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<T>(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<T>(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<T>(method: string, path: string, body?: unknown, options?
throw new ApiError(res.status, errorBody)
}
if (res.status === 204) return undefined as T
return await readJsonBody<T>(res)
if (res.status === 204) {
reportSlowApiRequest(method, path, res, timing)
return undefined as T
}
const responseReadStartedAt = monotonicNow()
const parsed = await readJsonBody<T>(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<T>(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<T>(method: string, path: string, body?: unknown, options?
}
}
async function readJsonBody<T>(res: Response): Promise<T> {
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<T>(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<T>(res: Response): Promise<T> {
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<boolean> {
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<string, unknown> = {
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',
+17
View File
@@ -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',
+13 -1
View File
@@ -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<SessionsResponse>(`/api/sessions${qs ? `?${qs}` : ''}`, options)
},
@@ -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(<Sidebar />)
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')
+11 -4
View File
@@ -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)}
@@ -0,0 +1,30 @@
import { describe, expect, it, vi } from 'vitest'
import { createAsyncRefreshCoalescer } from './asyncRefreshCoalescer'
function deferred() {
let resolve!: () => void
const promise = new Promise<void>((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)
})
})
+28
View File
@@ -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<void>) {
let running: Promise<void> | null = null
let queued = false
const request = (): Promise<void> => {
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
}
@@ -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 })
})
})
+30
View File
@@ -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<string, RateLimitState>()
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 }
}
}
@@ -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()
})
})
+60
View File
@@ -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) {
+1
View File
@@ -0,0 +1 @@
export const SIDEBAR_PROJECT_SESSION_PREVIEW_LIMIT = 6
+5 -1
View File
@@ -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<string, ReturnType<typeof setTimeout>>()
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<ChatStore>((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]
+9 -5
View File
@@ -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 () => {
+8 -3
View File
@@ -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<string>
recentSessionIds: Set<string>
recentProjectBoundaries: Record<string, RecentProjectBoundary>
projectSessionTotals: Record<string, number>
projectHistory: Record<string, ProjectHistoryState>
fetchSessions: (project?: string) => Promise<void>
@@ -103,6 +104,7 @@ export const useSessionStore = create<SessionStore>((set, get) => ({
historicalSessionIds: new Set(),
recentSessionIds: new Set(),
recentProjectBoundaries: {},
projectSessionTotals: {},
projectHistory: {},
fetchSessions: async (project?: string) => {
@@ -150,6 +152,9 @@ export const useSessionStore = create<SessionStore>((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 {
+2 -2
View File
@@ -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({
+15 -1
View File
@@ -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
@@ -60,7 +60,57 @@ function request(params: Record<string, string>, method = 'GET') {
return handleSessionsApi(new Request(url, { method }), url, ['api', 'sessions', 'project-history'])
}
function sidebarRequest(params: Record<string, string>) {
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<unknown> }
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),
+78 -13
View File
@@ -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<string, AbortController>()
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<Response> {
export async function handleFilesystemRoute(pathname: string, url: URL, signal?: AbortSignal): Promise<Response> {
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<Response> {
}
}
async function handleBrowse(url: URL): Promise<Response> {
async function handleBrowse(url: URL, requestSignal?: AbortSignal): Promise<Response> {
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<Response> {
}
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<Response> {
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<FilesystemEntry[]> {
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<FilesystemEntry[]> {
const files = await getProjectSearchFiles(rootPath, {
fallbackOptions: { searchQuery },
fallbackOptions: { searchQuery, timeoutMs },
signal,
})
const entries = new Map<string, FilesystemEntry>()
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<string[]> {
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<string[] | null> {
async function getFilesUsingGit(rootPath: string, respectGitignore: boolean, signal?: AbortSignal): Promise<string[] | null> {
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<ProjectSearchDependencies['ripGrepFn']>,
signal?: AbortSignal,
): Promise<string[]> {
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<ProjectSearchDependencies['fallbackOptions']> = {},
signal?: AbortSignal,
): Promise<string[]> {
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 | undefined>): 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,
+14
View File
@@ -431,6 +431,7 @@ export async function handleSessionsApi(
async function listSessions(req: Request, url: URL): Promise<Response> {
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<Response> {
}
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 }),
+3
View File
@@ -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)
+3
View File
@@ -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',
},
+2
View File
@@ -8,6 +8,7 @@ export function corsHeaders(origin?: string | null): Record<string, string> {
'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<string, string> {
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',
}
+12 -1
View File
@@ -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<Response> {
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<Response> {
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<Response> {
return handleTracesApi(req, url, segments)
case 'filesystem':
return handleFilesystemRoute(url.pathname, url)
return handleFilesystemRoute(url.pathname, url, req.signal)
default:
return Response.json(
@@ -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<typeof setInterval>
}) 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()
})
})
@@ -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<string, ActiveRequest>()
private sequence = 0
private timer: ReturnType<typeof globalThis.setInterval> | 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()
@@ -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', () => {
+46 -1
View File
@@ -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) => {
@@ -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<ProjectSessionPreviews> {
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<string, { total: number; rows: ProjectHistoryRow[]; seen: Set<string> }>()
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<ProjectHistoryRow[]> {
for (let attempt = 0; attempt < 3; attempt += 1) {
const key = this.source.revision()
@@ -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
@@ -128,23 +128,36 @@ export async function getSessionCollaborationService(): Promise<SessionCollabora
return current.service
}
export async function handleSessionCollaborationEvent(service: SessionCollaborationService, event: SessionTurnEvent): Promise<void> {
export async function handleSessionCollaborationEvent(service: SessionCollaborationService, event: SessionTurnEvent): Promise<boolean> {
// 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
}
+6
View File
@@ -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<ProjectSessionPreviews> {
return this.projectHistory.listPreviews(perProjectLimit)
}
private projectHistoryRevision(): string {
this.syncSharedMutationEpoch()
const scope = this.getConfigDir()
+17
View File
@@ -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)
})
+1 -1
View File
@@ -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,