From 7f74de711fc2c88ccac5f1359f14c887f656b4dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Fri, 29 May 2026 14:07:30 +0800 Subject: [PATCH] fix: reduce session list memory pressure (#649) Large desktop installs can accumulate huge JSONL transcripts, and the sidebar session list previously loaded each requested transcript into memory on cache misses. The main branch cache from #651 handles repeated hot refreshes, so this change keeps that cache and replaces cache-miss list metadata extraction with a sequential streaming summary scan for paginated files only. It also makes Windows sidecar shutdown prefer taskkill /T so stale child processes are less likely to survive app stop paths. Constraint: Must preserve main's short session-list cache from #651 while reducing cache-miss memory growth. Rejected: Rely only on the #651 cache | cache expiry, first load, and pagination still hit full JSONL reads. Rejected: Add a persistent summary index | larger persistence migration risk than needed for this issue. Confidence: medium Scope-risk: moderate Directive: Do not replace the streaming scanner with full readJsonlFile calls without re-running large-transcript pressure tests. Tested: bun test src/server/__tests__/sessions.test.ts Tested: bun run check:server Tested: bun run check:native Tested: bun run check:desktop Tested: synthetic pressure test with 140 sessions x 1200 JSONL entries showed lower RSS growth than main baseline. Not-tested: Windows live taskkill /T process-tree cleanup. --- desktop/src-tauri/src/lib.rs | 25 ++- src/server/__tests__/sessions.test.ts | 30 ++-- src/server/services/sessionService.ts | 223 ++++++++++++++++++++++---- 3 files changed, 230 insertions(+), 48 deletions(-) diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 4884c078..df28e10a 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -1655,7 +1655,7 @@ fn start_server_sidecar(app: &AppHandle) -> Result { }); if let Err(err) = wait_for_server(control_host, port) { - let _ = child.kill(); + kill_sidecar_child(child); return Err(format_server_startup_error(&err, &startup_logs)); } @@ -1672,7 +1672,7 @@ fn stop_server_sidecar(app: &AppHandle) { }; if let Some(runtime) = guard.runtime.take() { - let _ = runtime.child.kill(); + kill_sidecar_child(runtime.child); } } @@ -1799,10 +1799,29 @@ fn stop_adapters_sidecar(app: &AppHandle) { return; }; for child in guard.drain(..) { - let _ = child.kill(); + kill_sidecar_child(child); } } +fn kill_sidecar_child(child: CommandChild) { + #[cfg(target_os = "windows")] + { + let pid = child.pid().to_string(); + if StdCommand::new("taskkill") + .args(["/F", "/T", "/PID", &pid]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|status| status.success()) + .unwrap_or(false) + { + return; + } + } + + let _ = child.kill(); +} + #[cfg(unix)] fn kill_stale_unix_adapter_sidecars() { let current_pid = std::process::id(); diff --git a/src/server/__tests__/sessions.test.ts b/src/server/__tests__/sessions.test.ts index 4c1cc262..af5d4b4e 100644 --- a/src/server/__tests__/sessions.test.ts +++ b/src/server/__tests__/sessions.test.ts @@ -541,7 +541,7 @@ describe('SessionService', () => { expect(page2.sessions).toHaveLength(1) }) - it('should only parse the requested page when listing many sessions', async () => { + it('should only scan the requested page when listing many sessions', async () => { for (let i = 0; i < 12; i++) { const id = `1000000${i.toString(16)}-bbbb-cccc-dddd-eeeeeeeeeeee` const filePath = await writeSessionFile('-tmp-many-sessions', id, [ @@ -553,20 +553,20 @@ describe('SessionService', () => { } const serviceWithSpy = service as unknown as { - readJsonlFile: (...args: unknown[]) => Promise + scanSessionListSummary: (...args: unknown[]) => Promise } - const originalReadJsonlFile = serviceWithSpy.readJsonlFile.bind(service) - let readCount = 0 - serviceWithSpy.readJsonlFile = async (...args) => { - readCount += 1 - return originalReadJsonlFile(...args) + const originalScanSessionListSummary = serviceWithSpy.scanSessionListSummary.bind(service) + let scanCount = 0 + serviceWithSpy.scanSessionListSummary = async (...args) => { + scanCount += 1 + return originalScanSessionListSummary(...args) } const result = await service.listSessions({ limit: 3, offset: 0 }) expect(result.total).toBe(12) expect(result.sessions).toHaveLength(3) - expect(readCount).toBe(3) + expect(scanCount).toBe(3) }) it('should reuse cached list metadata for repeated requests', async () => { @@ -581,20 +581,20 @@ describe('SessionService', () => { } const serviceWithSpy = service as unknown as { - readJsonlFile: (...args: unknown[]) => Promise + scanSessionListSummary: (...args: unknown[]) => Promise } - const originalReadJsonlFile = serviceWithSpy.readJsonlFile.bind(service) - let readCount = 0 - serviceWithSpy.readJsonlFile = async (...args) => { - readCount += 1 - return originalReadJsonlFile(...args) + const originalScanSessionListSummary = serviceWithSpy.scanSessionListSummary.bind(service) + let scanCount = 0 + serviceWithSpy.scanSessionListSummary = async (...args) => { + scanCount += 1 + return originalScanSessionListSummary(...args) } const first = await service.listSessions({ limit: 3, offset: 0 }) const second = await service.listSessions({ limit: 3, offset: 0 }) expect(first.sessions.map((session) => session.id)).toEqual(second.sessions.map((session) => session.id)) - expect(readCount).toBe(3) + expect(scanCount).toBe(3) }) it('should invalidate cached list metadata after writes', async () => { diff --git a/src/server/services/sessionService.ts b/src/server/services/sessionService.ts index 44478519..663045f3 100644 --- a/src/server/services/sessionService.ts +++ b/src/server/services/sessionService.ts @@ -5,9 +5,11 @@ * 确保 Desktop App 与 CLI 的数据完全互通。 */ +import { createReadStream } from 'node:fs' import * as fs from 'node:fs/promises' import * as path from 'node:path' import * as os from 'node:os' +import { createInterface } from 'node:readline' import { ApiError } from '../middleware/errorHandler.js' import { sanitizePath as sanitizePortablePath } from '../../utils/sessionStoragePortable.js' import type { FileHistorySnapshot } from '../../utils/fileHistory.js' @@ -220,6 +222,16 @@ type PersistedWorktreeSession = { hookBased?: boolean } +type SessionListSummary = { + title: string + createdAt: string + messageCount: number + workDir: string | null + permissionMode?: string + repository?: PreparedSessionWorkspace['repository'] + worktreeSession?: PersistedWorktreeSession | null +} + const VALID_SESSION_PERMISSION_MODES = new Set([ 'default', 'acceptEdits', @@ -321,6 +333,132 @@ export class SessionService { return entries } + private async scanSessionListSummary( + filePath: string, + projectDir: string, + stat: { birthtime: Date }, + ): Promise { + let createdAt = stat.birthtime.toISOString() + let hasCreatedAt = false + let messageCount = 0 + let firstUserTitle: string | null = null + let goalTitle: string | null = null + let aiTitle: string | null = null + let customTitle: string | null = null + let latestWorkDir: string | null = null + let latestCwd: string | null = null + let permissionMode: string | undefined + let repository: PreparedSessionWorkspace['repository'] | undefined + let worktreeSession: PersistedWorktreeSession | null | undefined + + const stream = createReadStream(filePath, { encoding: 'utf8' }) + const lines = createInterface({ + input: stream, + crlfDelay: Infinity, + }) + + try { + for await (const line of lines) { + const trimmed = line.trim() + if (!trimmed) continue + + let entry: RawEntry + try { + entry = JSON.parse(trimmed) as RawEntry + } catch { + continue + } + + if (!hasCreatedAt && entry.timestamp) { + createdAt = entry.timestamp + hasCreatedAt = true + } + + if ( + (entry.type === 'user' || entry.type === 'assistant') && + entry.message?.role + ) { + messageCount += 1 + } + + if (entry.type === 'session-meta') { + if (typeof (entry as Record).workDir === 'string') { + latestWorkDir = normalizeDriveRootPathForPlatform( + (entry as Record).workDir as string, + ) + } + if ( + typeof entry.permissionMode === 'string' && + VALID_SESSION_PERMISSION_MODES.has(entry.permissionMode) + ) { + permissionMode = entry.permissionMode + } + } + + if (typeof entry.cwd === 'string' && entry.cwd.trim()) { + latestCwd = normalizeDriveRootPathForPlatform(entry.cwd) + } + + const candidateRepository = (entry as Record)?.repository + if (candidateRepository && typeof candidateRepository === 'object') { + repository = candidateRepository as PreparedSessionWorkspace['repository'] + } + + if (entry.type === 'worktree-state') { + if (entry.worktreeSession === null) { + worktreeSession = null + } else if ( + entry.worktreeSession && + typeof entry.worktreeSession === 'object' && + typeof entry.worktreeSession.worktreePath === 'string' && + typeof entry.worktreeSession.worktreeName === 'string' + ) { + worktreeSession = entry.worktreeSession + } + } + + if (entry.type === 'custom-title' && entry.customTitle) { + customTitle = String(entry.customTitle) + } + + if (!goalTitle) { + goalTitle = this.goalCreationCommandTitle(entry) + } + + if (entry.type === 'ai-title' && entry.aiTitle) { + const title = cleanSessionTitleSource(String(entry.aiTitle)) + if (title) aiTitle = title + } + + if ( + !firstUserTitle && + entry.type === 'user' && + !entry.isMeta && + entry.message?.role === 'user' + ) { + firstUserTitle = this.extractUserMessageTitle(entry.message.content) + } + } + } finally { + lines.close() + stream.destroy() + } + + return { + title: customTitle || + goalTitle || + aiTitle || + firstUserTitle || + 'Untitled Session', + createdAt, + messageCount, + workDir: latestWorkDir || latestCwd || this.desanitizePath(projectDir), + ...(permissionMode ? { permissionMode } : {}), + ...(repository ? { repository } : {}), + ...(worktreeSession !== undefined ? { worktreeSession } : {}), + } + } + private async appendJsonlEntry(filePath: string, entry: Record): Promise { const line = JSON.stringify(entry) + '\n' await fs.appendFile(filePath, line, 'utf-8') @@ -398,7 +536,25 @@ export class SessionService { ): Promise { const worktreeSession = this.resolveWorktreeSessionFromEntries(entries) const repository = this.resolveRepositoryFromEntries(entries) + return this.resolveProjectRootFromSessionMetadata({ + worktreeSession, + repository, + workDir, + fallbackProjectDir, + }) + } + private async resolveProjectRootFromSessionMetadata({ + worktreeSession, + repository, + workDir, + fallbackProjectDir, + }: { + worktreeSession?: PersistedWorktreeSession | null + repository?: PreparedSessionWorkspace['repository'] + workDir: string | null + fallbackProjectDir?: string + }): Promise { const candidate = worktreeSession?.originalCwd || repository?.repoRoot || workDir || @@ -947,6 +1103,23 @@ export class SessionService { return 'Untitled Session' } + private extractUserMessageTitle(content: unknown): string | null { + let text: string | undefined + if (typeof content === 'string') { + text = content + } else if (Array.isArray(content)) { + const textBlock = content.find( + (block: Record) => block.type === 'text' && typeof block.text === 'string' + ) + if (textBlock) text = textBlock.text as string + } + if (!text) return null + + const title = cleanSessionTitleSource(text) + if (!title) return null + return title.length > 80 ? `${title.slice(0, 80)}...` : title + } + // -------------------------------------------------------------------------- // Session file discovery // -------------------------------------------------------------------------- @@ -1364,48 +1537,38 @@ export class SessionService { const limit = options?.limit ?? 50 const paginatedFiles = filesWithStats.slice(offset, offset + limit) - // Build session list items with metadata from file stats & first entries - const items = (await Promise.all(paginatedFiles.map(async ({ filePath, projectDir, sessionId, stat }) => { + // Build session list items with metadata from file stats & a streaming + // transcript summary. Keep this sequential so large JSONL files are not + // loaded into memory concurrently by the sidebar's frequent refresh. + const items: SessionListItem[] = [] + for (const { filePath, projectDir, sessionId, stat } of paginatedFiles) { try { - const entries = await this.readJsonlFile(filePath) - const workDir = this.resolveWorkDirFromEntries(entries, projectDir) - const permissionMode = this.resolvePermissionModeFromEntries(entries) - const projectRoot = await this.resolveProjectRootFromEntries(entries, workDir, projectDir) + const summary = await this.scanSessionListSummary(filePath, projectDir, stat) + const workDir = summary.workDir + const projectRoot = await this.resolveProjectRootFromSessionMetadata({ + worktreeSession: summary.worktreeSession, + repository: summary.repository, + workDir, + fallbackProjectDir: projectDir, + }) const workDirExists = await this.pathExists(workDir) - // Count transcript messages only (user + assistant) - const messageCount = entries.filter( - (e) => (e.type === 'user' || e.type === 'assistant') && e.message?.role - ).length - - const title = this.extractTitle(entries) - - // Find the earliest timestamp from entries, fallback to file birthtime - let createdAt = stat.birthtime.toISOString() - for (const e of entries) { - if (e.timestamp) { - createdAt = e.timestamp - break - } - } - - return { + items.push({ id: sessionId, - title, - createdAt, + title: summary.title, + createdAt: summary.createdAt, modifiedAt: stat.mtime.toISOString(), - messageCount, + messageCount: summary.messageCount, projectPath: projectDir, projectRoot, workDir, workDirExists, - permissionMode, - } + permissionMode: summary.permissionMode, + }) } catch { // Skip unreadable files - return null } - }))).filter((item): item is SessionListItem => item !== null) + } const result = { sessions: items, total } this.sessionListCache.set(cacheKey, {