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.
This commit is contained in:
程序员阿江(Relakkes) 2026-05-29 14:07:30 +08:00
parent d9d0d20782
commit 7f74de711f
3 changed files with 230 additions and 48 deletions

View File

@ -1655,7 +1655,7 @@ fn start_server_sidecar(app: &AppHandle) -> Result<ServerRuntime, String> {
});
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();

View File

@ -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<unknown>
scanSessionListSummary: (...args: unknown[]) => Promise<unknown>
}
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<unknown>
scanSessionListSummary: (...args: unknown[]) => Promise<unknown>
}
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 () => {

View File

@ -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<SessionListSummary> {
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<string, unknown>).workDir === 'string') {
latestWorkDir = normalizeDriveRootPathForPlatform(
(entry as Record<string, unknown>).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<string, unknown>)?.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<string, unknown>): Promise<void> {
const line = JSON.stringify(entry) + '\n'
await fs.appendFile(filePath, line, 'utf-8')
@ -398,7 +536,25 @@ export class SessionService {
): Promise<string | null> {
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<string | null> {
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<string, unknown>) => 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, {