fix(cli): speed up --resume / /resume session listing#26487
fix(cli): speed up --resume / /resume session listing#26487dimssu wants to merge 2 commits intogoogle-gemini:mainfrom
Conversation
Listings of saved chat sessions used to stream and JSON.parse every session file just to extract the metadata used by the resume picker. On Windows that produced a 10-15 s freeze before the UI showed at all, because the per-file fs.existsSync wave on a hot path also blocked the event loop and prevented Ink from rendering the loading state. Maintain a small <base>.meta.json sidecar next to every chat file with only the fields the listing needs: ids, timestamps, kind, summary, message counts, and the first user message. The sidecar is written atomically (tmp + rename) on init / pushMessage / updateMetadata / rewindTo, and removed alongside the chat file in both deletion paths. The listing prefers the sidecar; on miss, malformed content, or version mismatch it falls back to the existing JSONL parse and opportunistically backfills the sidecar so the next listing hits the fast path. Search mode (includeFullContent) still streams the full chat file. Also drop the synchronous fs.existsSync(filePath) early-return in loadConversationRecord. fs.createReadStream + the for-await readline already error via the existing try/catch, and we silently swallow ENOENT there to keep log parity. This unblocks the event loop in the fallback path and lets the session-browser dialog render its loading state immediately. Closes google-gemini#26478
cleanupExpiredSessions deletes chat files via fs.unlink directly rather than going through ChatRecordingService.deleteSession, so the sidecar introduced for the resume listing fast path was being orphaned when a session expired. Remove the matching .meta.json alongside each unlinked chat file (ENOENT is silent for parity with the chat-file unlink), and add a lazyMigrate option to getAllSessionFiles so the cleanup pass doesn't backfill sidecars for sessions it's about to delete. Also keeps the existing sessionCleanup integration tests valid: their on-disk assertions count files in the chats directory and now the cleanup pass leaves no sidecar artifacts behind.
Summary of ChangesHello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request addresses significant performance degradation in the CLI's session listing functionality, particularly on Windows where file system operations are costly. By introducing a lightweight metadata sidecar, the application can now retrieve necessary session information without parsing entire JSONL chat files. This change ensures that listing sessions remains sub-second regardless of the number of saved conversations, while maintaining full compatibility with existing data. Highlights
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here. Footnotes
|
There was a problem hiding this comment.
Code Review
This pull request introduces a metadata sidecar system to optimize session listing by storing essential session information in separate .meta.json files, thereby avoiding the need to parse large JSONL chat files. The changes include logic for creating, updating, and deleting these sidecars within the ChatRecordingService and sessionUtils. My feedback focuses on the use of synchronous file system operations in the sidecar writing logic, which can block the event loop and degrade performance; I recommend converting these operations to be asynchronous.
| export function writeSessionMetadataSidecar( | ||
| jsonlPath: string, | ||
| conversation: ConversationRecordWithCounts, | ||
| ): void { | ||
| const sidecar = buildSidecarFromConversation(conversation); | ||
| const finalPath = getSessionMetadataSidecarPath(jsonlPath); | ||
| const tmpPath = finalPath + '.tmp'; | ||
| try { | ||
| fs.writeFileSync(tmpPath, JSON.stringify(sidecar)); | ||
| fs.renameSync(tmpPath, finalPath); | ||
| } catch (error) { | ||
| if (isNodeError(error) && error.code === 'ENOSPC') return; | ||
| debugLogger.error('Error writing session metadata sidecar:', error); | ||
| } | ||
| } |
There was a problem hiding this comment.
The writeSessionMetadataSidecar function uses synchronous file system operations (writeFileSync, renameSync). Since this function is called on every message recorded and during session listing (lazy migration), it can block the event loop and cause UI stutters, especially on Windows where file system operations are slow (as noted in the PR description). It should be converted to use fs.promises to perform these operations asynchronously.
| export function writeSessionMetadataSidecar( | |
| jsonlPath: string, | |
| conversation: ConversationRecordWithCounts, | |
| ): void { | |
| const sidecar = buildSidecarFromConversation(conversation); | |
| const finalPath = getSessionMetadataSidecarPath(jsonlPath); | |
| const tmpPath = finalPath + '.tmp'; | |
| try { | |
| fs.writeFileSync(tmpPath, JSON.stringify(sidecar)); | |
| fs.renameSync(tmpPath, finalPath); | |
| } catch (error) { | |
| if (isNodeError(error) && error.code === 'ENOSPC') return; | |
| debugLogger.error('Error writing session metadata sidecar:', error); | |
| } | |
| } | |
| export async function writeSessionMetadataSidecar( | |
| jsonlPath: string, | |
| conversation: ConversationRecordWithCounts, | |
| ): Promise<void> { | |
| const sidecar = buildSidecarFromConversation(conversation); | |
| const finalPath = getSessionMetadataSidecarPath(jsonlPath); | |
| const tmpPath = finalPath + '.tmp'; | |
| try { | |
| await fs.promises.writeFile(tmpPath, JSON.stringify(sidecar)); | |
| await fs.promises.rename(tmpPath, finalPath); | |
| } catch (error) { | |
| if (isNodeError(error) && error.code === 'ENOSPC') return; | |
| debugLogger.error('Error writing session metadata sidecar:', error); | |
| } | |
| } |
References
- Use asynchronous file system operations (e.g., fs.promises.readFile) instead of synchronous ones (e.g., fs.readFileSync) to avoid blocking the event loop.
| if (!options.includeFullContent && options.lazyMigrate !== false) { | ||
| writeSessionMetadataSidecar(filePath, content); | ||
| } |
There was a problem hiding this comment.
The call to writeSessionMetadataSidecar is currently synchronous and blocks the event loop during session listing. When many sessions need migration, this will cause a noticeable hang, contradicting the performance goals of this PR. Once writeSessionMetadataSidecar is made asynchronous, this call should be awaited to ensure the sidecar is written without blocking the event loop.
| if (!options.includeFullContent && options.lazyMigrate !== false) { | |
| writeSessionMetadataSidecar(filePath, content); | |
| } | |
| if (!options.includeFullContent && options.lazyMigrate !== false) { | |
| await writeSessionMetadataSidecar(filePath, content); | |
| } |
References
- Use asynchronous file system operations (e.g., fs.promises.readFile) instead of synchronous ones (e.g., fs.readFileSync) to avoid blocking the event loop.
Summary
gemini --resumeand the interactive/resumecommand hang for ~10–15 s on Windows before any UI appears. Profile-guided fix: stop streaming and JSON.parse'ing every saved chat file just to extract listing metadata.Details
Root cause (in
loadConversationRecordatpackages/core/src/services/chatRecordingService.ts:106andgetAllSessionFilesatpackages/cli/src/utils/sessionUtils.ts:246):getAllSessionFilesfans out viaPromise.allover every*.jsonlin~/.gemini/projects/<hash>/chats/, callingloadConversationRecord(file, { metadataOnly: true })per file.loadConversationRecorddid:fs.existsSync(filePath)(line 118) — with N parallel calls all on one JS thread, the existsSync wave blocked the event loop. That's why evenSessionBrowserLoadingdoesn't paint during the hang.readline+JSON.parseper line, just to computemessageCount,firstUserMessage,hasUserOrAssistantMessage, and the latest$set.On Windows, AppData is typically scanned by Defender / indexed, so each fs syscall costs 100–200 ms. With 10–30 saved sessions that easily lands at 10–15 s before first paint, exactly matching the report.
Fix:
<base>.meta.jsonnext to each chat file, containing only the listing-needed fields (sessionId, projectHash, startTime, lastUpdated, kind, summary, directories, messageCount, userMessageCount, hasUserOrAssistantMessage, firstUserMessage;version: 1for future migrations). Written atomically (tmp + rename) byChatRecordingServiceon init / pushMessage / updateMetadata / rewindTo, and removed alongside the.jsonlindeleteSessionAndArtifacts,deleteCurrentSessionAsync, andcleanupExpiredSessions. The retention cleanup pass uses a newlazyMigrate: falseflag ongetAllSessionFilesso it doesn't backfill sidecars for sessions it's about to delete.fs.existsSyncinloadConversationRecord. Thefor awaitoverreadlinealready errors via the existingtry/catch; ENOENT is now silently swallowed in the catch to keep log parity.getAllSessionFilesprefers the sidecar; on miss / malformed / version mismatch it falls back to the existing JSONL stream parse and opportunistically backfills the sidecar (lazy migration, no upgrade step needed). Search mode (includeFullContent: true) still streams full files — unchanged.Net effect: listing goes from O(N × full-file-parse) to O(N × tiny-file-read). All three forms (
latest,<uuid>,<index>) become sub-second; numeric-index ordering is unchanged because the sidecar still exposesstartTime.No public API or persisted-format changes for the
.jsonlitself;.meta.jsonis a new but additive on-disk file the user can safely delete (it'll be regenerated).Related Issues
Closes #26478
How to Validate
npm ci && npm run buildnpm run startand hold a few short conversations to seed~/.gemini/projects/<hash>/chats/with 10+ sessions of varying sizes. Confirm*.meta.jsonfiles appear next to each.jsonl.time node bundle/gemini.js --resume(latest)time node bundle/gemini.js --resume <uuid>time node bundle/gemini.js --resume 1(numeric index)/resume— the dialog should render itsLoading…state immediately and populate within a few hundred ms.rma*.meta.json, run/resume, confirm the listing still works and the sidecar is rewritten afterwards./resume, delete a session, confirm both the.jsonland.meta.jsonare removed.general.sessionRetention.enabled: true, restart and verify expired sessions remove both.jsonland.meta.json.Targeted unit tests:
npm test -w @google/gemini-cli-core -- chatRecordingService.testnpm test -w @google/gemini-cli -- sessionUtils.test sessionCleanup.integration.testFull suites:
npm run test --workspace @google/gemini-cli-core→ 7214 / 7214 passnpm run test --workspace @google/gemini-cli→ 6750 / 6750 passnpm run typecheck✅ —npm run lint:ci✅Pre-Merge Checklist