Files
claude-code-haha/scripts/perf/session-open-benchmark.ts
T
程序员阿江-Relakkes 66f9938f36 fix(sessions): stop full transcript parses on every session lookup (#1430)
Every session route resolves its transcript first, and that lookup parsed
the whole file to rank candidates even when only one file matched. The
desktop pages a long session's history request by request, so reopening
it cost one full parse per page and grew quadratically with file size.

Skip the content check when a single file matches, and stop a multi-file
check at the first conversation record. Ranking and API responses are
unchanged.
2026-10-03 21:58:11 +08:00

206 lines
9.4 KiB
TypeScript

/**
* Replays the requests the desktop sends when a session is opened, against a
* real server booted in a quality-gate sandbox, and reports how long each one
* takes. Local diagnostic only: it is not part of any PR lane.
*
* bun run scripts/perf/session-open-benchmark.ts --transcript a.jsonl [--transcript b.jsonl] [--rounds 2] [--sidebar]
*
* Each `--transcript` is copied (read-only) into the sandbox and concatenated
* into one session, with `sessionId`/`cwd` rewritten so nothing points back at
* the source. The sandbox is deleted afterwards.
*/
import { createReadStream, createWriteStream, existsSync, mkdirSync, readFileSync } from 'node:fs'
import { stat } from 'node:fs/promises'
import { createServer } from 'node:net'
import { join, resolve } from 'node:path'
import { createInterface } from 'node:readline'
import { createQualityGateSandbox } from '../quality-gate/sandbox.js'
import { sanitizePath } from '../../src/utils/sessionStoragePortable.js'
const REQUEST_TIMEOUT_MS = 120_000
const SESSION_ID = '0b0e1c5e-5e55-4a11-9b0b-0000000b0e1c'
type Timing = { name: string; ms: number; status: number | 'timeout' | 'error'; bytes: number }
function parseArgs(argv: string[]) {
const transcripts: string[] = []
let rounds = 2
let sidebar = false
let keep = false
for (let index = 0; index < argv.length; index++) {
const arg = argv[index]
if (arg === '--transcript') transcripts.push(resolve(argv[++index]!))
else if (arg === '--rounds') rounds = Number(argv[++index])
else if (arg === '--sidebar') sidebar = true
else if (arg === '--keep') keep = true
else throw new Error(`Unknown argument: ${arg}`)
}
if (transcripts.length === 0) throw new Error('Pass at least one --transcript <path>')
return { transcripts, rounds, sidebar, keep }
}
async function freePort(): Promise<number> {
return new Promise((resolvePort, reject) => {
const server = createServer()
server.once('error', reject)
server.listen(0, '127.0.0.1', () => {
const address = server.address()
server.close(() => resolvePort(typeof address === 'object' && address ? address.port : 0))
})
})
}
async function copyTranscripts(sources: string[], target: string, workDir: string) {
const output = createWriteStream(target)
let records = 0
let conversation = 0
for (const source of sources) {
const lines = createInterface({ input: createReadStream(source), crlfDelay: Infinity })
for await (const line of lines) {
if (!line.trim()) continue
let text = line
try {
const entry = JSON.parse(line) as Record<string, unknown>
if ('sessionId' in entry) entry.sessionId = SESSION_ID
if ('cwd' in entry) entry.cwd = workDir
const message = entry.message as { role?: unknown } | undefined
if ((entry.type === 'user' || entry.type === 'assistant') && message?.role) conversation++
text = JSON.stringify(entry)
} catch {
// Keep malformed lines byte-for-byte; the server must tolerate them.
}
if (!output.write(text + '\n')) await new Promise(done => output.once('drain', done))
records++
}
}
await new Promise<void>((done, reject) => output.end((error?: Error | null) => error ? reject(error) : done()))
return { records, conversation, bytes: (await stat(target)).size }
}
async function timed(base: string, name: string, path: string): Promise<{ timing: Timing; body: unknown }> {
const started = performance.now()
try {
const response = await fetch(`${base}${path}`, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) })
const text = await response.text()
let body: unknown = null
try { body = JSON.parse(text) } catch { /* non-JSON error bodies are still timed */ }
return { timing: { name, ms: performance.now() - started, status: response.status, bytes: text.length }, body }
} catch (error) {
const status = (error as Error).name === 'TimeoutError' ? 'timeout' : 'error'
return { timing: { name, ms: performance.now() - started, status, bytes: 0 }, body: null }
}
}
type HistoryPage = { messages?: unknown[]; page?: { nextCursor?: string | null } }
/** Mirrors desktop/src/api/sessions.ts getFullHistory: mode=full, then every cursor page. */
async function fullHistory(base: string, timings: Timing[]) {
const started = performance.now()
const first = await timed(base, 'messages?mode=full', `/api/sessions/${SESSION_ID}/messages?mode=full`)
timings.push(first.timing)
let cursor = (first.body as HistoryPage | null)?.page?.nextCursor ?? null
let messages = (first.body as HistoryPage | null)?.messages?.length ?? 0
let pages = 0
let slowestPage = 0
let failed = first.timing.status !== 200
while (cursor && !failed) {
const page = await timed(base, 'messages?cursor', `/api/sessions/${SESSION_ID}/messages?${new URLSearchParams({ cursor })}`)
pages++
slowestPage = Math.max(slowestPage, page.timing.ms)
if (page.timing.status !== 200) {
timings.push(page.timing)
failed = true
break
}
messages += (page.body as HistoryPage | null)?.messages?.length ?? 0
cursor = (page.body as HistoryPage | null)?.page?.nextCursor ?? null
}
timings.push({ name: `history total (${pages} cursor pages, slowest ${Math.round(slowestPage)}ms, ${messages} msgs)`, ms: performance.now() - started, status: failed ? 'error' : 200, bytes: 0 })
}
async function openSession(base: string, sidebar: boolean): Promise<Timing[]> {
const timings: Timing[] = []
const id = SESSION_ID
const burst: Array<Promise<void>> = [
fullHistory(base, timings),
...[
['summary', `/api/sessions/${id}/summary`],
['git-info', `/api/sessions/${id}/git-info`],
['slash-commands', `/api/sessions/${id}/slash-commands`],
['chat/status', `/api/sessions/${id}/chat/status`],
['inspection(context)', `/api/sessions/${id}/inspection?includeContext=1&contextOnly=1`],
['workspace/status#1', `/api/sessions/${id}/workspace/status`],
['team plan', `/api/teams/session/${id}/plan`],
].map(([name, path]) => timed(base, name!, path!).then(result => { timings.push(result.timing) })),
]
if (sidebar) burst.push(timed(base, 'sidebar list', '/api/sessions?limit=60&perProjectLimit=6').then(result => { timings.push(result.timing) }))
await Promise.all(burst)
// MessageList asks for these only after history is ready.
await Promise.all([
timed(base, 'turn-checkpoints', `/api/sessions/${id}/turn-checkpoints`),
timed(base, 'workspace/status#2', `/api/sessions/${id}/workspace/status`),
].map(request => request.then(result => { timings.push(result.timing) })))
return timings
}
async function main() {
const options = parseArgs(process.argv.slice(2))
for (const transcript of options.transcripts) {
if (!existsSync(transcript)) throw new Error(`Transcript not found: ${transcript}`)
}
const sandbox = createQualityGateSandbox({ label: 'session-open' })
const workDir = join(sandbox.home, 'workdir')
mkdirSync(workDir, { recursive: true })
const projectDir = join(sandbox.configDir, 'projects', sanitizePath(workDir))
mkdirSync(projectDir, { recursive: true })
const target = join(projectDir, `${SESSION_ID}.jsonl`)
let server: ReturnType<typeof Bun.spawn> | undefined
try {
const copied = await copyTranscripts(options.transcripts, target, workDir)
console.log(`transcript: ${copied.records} records, ${copied.conversation} conversation messages, ${(copied.bytes / 1048576).toFixed(1)} MiB`)
const port = await freePort()
const base = `http://127.0.0.1:${port}`
const logPath = join(sandbox.home, 'server.log')
server = Bun.spawn(['bun', 'run', 'src/server/index.ts', '--host', '127.0.0.1', '--port', String(port)], {
env: { ...sandbox.env, SERVER_PORT: String(port), CLAUDE_CLI_PATH: resolve('src/server/__tests__/fixtures/mock-sdk-cli.ts') },
stdout: Bun.file(logPath),
stderr: Bun.file(logPath),
})
const bootDeadline = Date.now() + 60_000
while (true) {
try {
if ((await fetch(`${base}/health`)).ok) break
} catch { /* not listening yet */ }
if (Date.now() > bootDeadline) throw new Error(`Server did not start; see ${logPath}`)
await Bun.sleep(200)
}
for (let round = 1; round <= options.rounds; round++) {
const started = performance.now()
const timings = await openSession(base, options.sidebar)
console.log(`\nround ${round}: ${((performance.now() - started) / 1000).toFixed(1)}s wall`)
for (const timing of timings.sort((a, b) => b.ms - a.ms)) {
const flag = timing.status === 'timeout' || timing.ms >= REQUEST_TIMEOUT_MS ? ' <-- exceeds 120s' : ''
console.log(` ${(timing.ms / 1000).toFixed(2).padStart(8)}s ${String(timing.status).padEnd(7)} ${timing.name}${flag}`)
}
}
const diagnostics = join(sandbox.configDir, 'cc-haha', 'diagnostics', 'diagnostics.jsonl')
if (existsSync(diagnostics)) {
const stalls = readFileSync(diagnostics, 'utf8').split('\n').filter(line => line.includes('event_loop'))
if (stalls.length) console.log(`\nevent-loop stall diagnostics: ${stalls.length}`)
}
} finally {
server?.kill()
await server?.exited
const mutations = sandbox.detectUserStateMutations()
if (mutations.length) console.error(`Real user state changed: ${mutations.join(', ')}`)
if (options.keep) console.log(`sandbox kept at ${sandbox.home}`)
else sandbox.cleanup()
if (mutations.length) process.exitCode = 1
}
}
await main()