mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 03:43:11 +08:00
fix(desktop): diagnose and reduce session loading stalls
This commit is contained in:
+145
-15
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const SIDEBAR_PROJECT_SESSION_PREVIEW_LIMIT = 6
|
||||
@@ -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]
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user