Skip to content

fix(cli): speed up --resume / /resume session listing#26487

Open
dimssu wants to merge 2 commits intogoogle-gemini:mainfrom
dimssu:fix/issue-26478-resume-perf
Open

fix(cli): speed up --resume / /resume session listing#26487
dimssu wants to merge 2 commits intogoogle-gemini:mainfrom
dimssu:fix/issue-26478-resume-perf

Conversation

@dimssu
Copy link
Copy Markdown
Contributor

@dimssu dimssu commented May 5, 2026

Summary

gemini --resume and the interactive /resume command 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 loadConversationRecord at packages/core/src/services/chatRecordingService.ts:106 and getAllSessionFiles at packages/cli/src/utils/sessionUtils.ts:246):

  1. getAllSessionFiles fans out via Promise.all over every *.jsonl in ~/.gemini/projects/<hash>/chats/, calling loadConversationRecord(file, { metadataOnly: true }) per file.
  2. Per file, loadConversationRecord did:
    • A synchronous fs.existsSync(filePath) (line 118) — with N parallel calls all on one JS thread, the existsSync wave blocked the event loop. That's why even SessionBrowserLoading doesn't paint during the hang.
    • Streamed the entire JSONL through readline + JSON.parse per line, just to compute messageCount, 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:

  1. Per-session metadata sidecar <base>.meta.json next to each chat file, containing only the listing-needed fields (sessionId, projectHash, startTime, lastUpdated, kind, summary, directories, messageCount, userMessageCount, hasUserOrAssistantMessage, firstUserMessage; version: 1 for future migrations). Written atomically (tmp + rename) by ChatRecordingService on init / pushMessage / updateMetadata / rewindTo, and removed alongside the .jsonl in deleteSessionAndArtifacts, deleteCurrentSessionAsync, and cleanupExpiredSessions. The retention cleanup pass uses a new lazyMigrate: false flag on getAllSessionFiles so it doesn't backfill sidecars for sessions it's about to delete.
  2. Drop the fs.existsSync in loadConversationRecord. The for await over readline already errors via the existing try/catch; ENOENT is now silently swallowed in the catch to keep log parity.
  3. Read path in getAllSessionFiles prefers 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 exposes startTime.

No public API or persisted-format changes for the .jsonl itself; .meta.json is a new but additive on-disk file the user can safely delete (it'll be regenerated).

Related Issues

Closes #26478

How to Validate

  1. npm ci && npm run build
  2. Populate session history: npm run start and hold a few short conversations to seed ~/.gemini/projects/<hash>/chats/ with 10+ sessions of varying sizes. Confirm *.meta.json files appear next to each .jsonl.
  3. Time the resume forms (all should reach first paint sub-second on macOS, dramatically faster on Windows):
    • time node bundle/gemini.js --resume (latest)
    • time node bundle/gemini.js --resume <uuid>
    • time node bundle/gemini.js --resume 1 (numeric index)
  4. From inside the CLI, run /resume — the dialog should render its Loading… state immediately and populate within a few hundred ms.
  5. Lazy migration check: rm a *.meta.json, run /resume, confirm the listing still works and the sidecar is rewritten afterwards.
  6. Delete check: from /resume, delete a session, confirm both the .jsonl and .meta.json are removed.
  7. Retention check (optional): with general.sessionRetention.enabled: true, restart and verify expired sessions remove both .jsonl and .meta.json.

Targeted unit tests:

  • npm test -w @google/gemini-cli-core -- chatRecordingService.test
  • npm test -w @google/gemini-cli -- sessionUtils.test sessionCleanup.integration.test

Full suites:

  • npm run test --workspace @google/gemini-cli-core → 7214 / 7214 pass
  • npm run test --workspace @google/gemini-cli → 6750 / 6750 pass
  • npm run typecheck ✅ — npm run lint:ci

Pre-Merge Checklist

  • Updated relevant documentation and README (no doc changes needed — sidecar is internal)
  • Added/updated tests (sidecar lifecycle, lazy migration, version mismatch, kind=subagent skip, sessionCleanup orphan removal, and missing-file ENOENT path)
  • Noted breaking changes — none
  • Validated on required platforms/methods:
    • MacOS
      • npm run
      • npx
      • Docker
      • Podman
      • Seatbelt
    • Windows (the platform of original concern — would value a maintainer spot-check here)
      • npm run
      • npx
      • Docker
    • Linux
      • npm run
      • npx
      • Docker

dimssu added 2 commits May 5, 2026 09:57
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.
@dimssu dimssu requested a review from a team as a code owner May 5, 2026 04:53
@gemini-code-assist
Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, 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

  • Performance Optimization: Introduced a metadata sidecar file (*.meta.json) for each chat session to avoid expensive full-file streaming and parsing during session listing, significantly reducing startup time on Windows.
  • Lazy Migration: Implemented a lazy migration strategy that backfills missing sidecar files during normal operation, ensuring backward compatibility without requiring a bulk upgrade step.
  • Event Loop Stability: Replaced synchronous file existence checks with asynchronous stream handling to prevent blocking the event loop during session discovery.
Using Gemini Code Assist

The 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 /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

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 .gemini/ folder in the base of the repository. Detailed instructions can be found here.

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

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +412 to +426
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);
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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.

Suggested change
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
  1. Use asynchronous file system operations (e.g., fs.promises.readFile) instead of synchronous ones (e.g., fs.readFileSync) to avoid blocking the event loop.

Comment on lines +365 to +367
if (!options.includeFullContent && options.lazyMigrate !== false) {
writeSessionMetadataSidecar(filePath, content);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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.

Suggested change
if (!options.includeFullContent && options.lazyMigrate !== false) {
writeSessionMetadataSidecar(filePath, content);
}
if (!options.includeFullContent && options.lazyMigrate !== false) {
await writeSessionMetadataSidecar(filePath, content);
}
References
  1. Use asynchronous file system operations (e.g., fs.promises.readFile) instead of synchronous ones (e.g., fs.readFileSync) to avoid blocking the event loop.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

/resume or --resume hangs for ~15s

1 participant