Skip to content

fix(cli): make persistent state writes failure-safe - #29402

Closed
Oscar-Williams wants to merge 5 commits into
google-gemini:mainfrom
Oscar-Williams:fix/persistent-state-atomic-save
Closed

Oscar-Williams wants to merge 5 commits into
google-gemini:mainfrom
Oscar-Williams:fix/persistent-state-atomic-save

Conversation

@Oscar-Williams

@Oscar-Williams Oscar-Williams commented Sep 18, 2026 •

Copy link
Copy Markdown

Summary

Make PersistentState writes failure-safe so an interrupted save cannot replace state.json with truncated JSON and silently clear the CLI's persistent state.

Details

  • Write to a unique sibling temp file, fsync it, then publish with an atomic rename.
  • Use one asynchronous fs.promises file handle per save and serialize queued saves to keep the event loop responsive while preserving call order.
  • Keep the previous valid state in state.json.bak before replacement.
  • Preserve an unreadable primary as state.json.corrupt and restore a valid backup when available.
  • Synchronously republish a recovered backup before load() returns, so the recovery survives an immediate process exit.
  • The synchronous fallback opens the temporary file once with wx, writes through that descriptor, fsyncs it, and then closes it before the atomic rename.
  • Keep commentJson and other write paths outside this focused fix; they are separate follow-ups.
  • The PR is ready for maintainer review; the repository contribution rules record no assignment or help-wanted prerequisite.

Related Issues

Fixes #29307

How to Validate

From packages/cli:

npx vitest run --pool=forks --maxWorkers=1 --minWorkers=1 --no-file-parallelism src/utils/persistentState.test.ts src/utils/persistentState.integration.test.ts src/utils/commentJson.test.ts

The focused suite covers the miss-to-save path, asynchronous file-handle writes, serialized saves, real filesystem backup creation, corrupt-file preservation, durable backup recovery, and adjacent JSON writes. npm run typecheck --workspace @google/gemini-cli and npm run build --workspace @google/gemini-cli also pass on Windows.

The focused persistentState.test.ts file passes 7/7; its recovery case asserts the synchronous republish and the single-descriptor openSync('wx') → writeSync → fsyncSync → closeSync sequence.

AI assistance was used for implementation and automated validation; no manual contributor test is claimed.

Pre-Merge Checklist

  • Updated relevant documentation and README (not needed for this internal persistence fix)
  • Added/updated tests (unit and real filesystem regression coverage)
  • Noted breaking changes (none)
  • Validated on required platforms/methods:
    • MacOS
      • npm run
      • npx
      • Docker
      • Podman
      • Seatbelt
    • Windows
      • npm run
      • npx
      • Docker
    • Linux
      • npm run
      • npx
      • Docker

@google-cla

google-cla Bot commented Sep 18, 2026

Copy link
Copy Markdown

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

@github-actions github-actions Bot added the size/m A medium sized PR label Sep 18, 2026
@github-actions

github-actions Bot commented Sep 18, 2026 •

Copy link
Copy Markdown

📊 PR Size: size/L

  • Lines changed: 375
  • Additions: +347
  • Deletions: -28
  • Files changed: 8

@gemini-cli gemini-cli Bot added priority/p1 Important and should be addressed in the near term. area/core Issues related to User Interface, OS Support, Core Functionality labels Sep 18, 2026
@Oscar-Williams
Oscar-Williams marked this pull request as ready for review September 19, 2026 05:20
@Oscar-Williams
Oscar-Williams requested a review from a team as a code owner September 19, 2026 05:20
@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 enhances the reliability of the CLI's persistent state management by introducing failure-safe write operations. By utilizing atomic file system operations and implementing a robust backup and recovery workflow, the changes protect the application's state from data loss caused by unexpected interruptions or file corruption.

Highlights

  • Atomic State Writes: Implemented a temporary file writing strategy with fsync and atomic renaming to prevent state truncation during interrupted save operations.
  • Backup and Recovery: Added automatic creation of state.json.bak files and a recovery mechanism that restores from backups if the primary state file becomes corrupt.
  • Corrupt State Preservation: Ensured that corrupted state files are preserved with a .corrupt suffix for debugging purposes before attempting a recovery.
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 the 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 counterproductive. 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. ↩

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

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.

Code Review

This pull request enhances PersistentState with atomic file saving, backup creation, and automatic recovery from corrupt state files. It also adds comprehensive unit and integration tests to validate these behaviors. The reviewer feedback suggests refactoring the synchronous file system operations in the save mechanism to use the asynchronous fs.promises API to prevent blocking the event loop, and updating the corresponding tests to match this asynchronous pattern.

Comment on lines +125 to +135
temporaryPath = `${filePath}.${randomUUID()}.tmp`;
fs.writeFileSync(temporaryPath, JSON.stringify(this.cache, null, 2), {
encoding: 'utf-8',
flag: 'wx',
});

// A writable descriptor is required for fsyncSync on Windows.
fileDescriptor = fs.openSync(temporaryPath, 'r+');
fs.fsyncSync(fileDescriptor);
fs.closeSync(fileDescriptor);
fileDescriptor = undefined;

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

Opening the temporary file twice introduces unnecessary I/O overhead and is prone to race conditions on Windows. To ensure robustness and non-blocking behavior, use the asynchronous fs.promises API. Open the file handle once with 'wx', write to it, sync, and close it using the promise-based file handle methods.

      temporaryPath = `${filePath}.${randomUUID()}.tmp`;
      const fileHandle = await fs.promises.open(temporaryPath, 'wx');
      await fileHandle.writeFile(JSON.stringify(this.cache, null, 2), 'utf-8');
      await fileHandle.sync();
      await fileHandle.close();
References
  1. Use asynchronous file system operations instead of synchronous ones to avoid blocking the event loop.

Comment on lines +50 to +72
it('should save state through a temporary file and publish it atomically', () => {
vi.mocked(fs.existsSync).mockReturnValue(false);
persistentState.set('defaultBannerShownCount', { banner1: 1 });

expect(fs.mkdirSync).toHaveBeenCalledWith(path.normalize(mockDir), {
recursive: true,
});
expect(fs.writeFileSync).toHaveBeenCalledWith(
mockFilePath,
expect.stringMatching(/state\.json\..+\.tmp$/),
JSON.stringify({ defaultBannerShownCount: { banner1: 1 } }, null, 2),
{ encoding: 'utf-8', flag: 'wx' },
);
expect(fs.openSync).toHaveBeenCalledWith(
expect.stringMatching(/state\.json\..+\.tmp$/),
'r+',
);
expect(fs.fsyncSync).toHaveBeenCalled();
expect(fs.closeSync).toHaveBeenCalled();
expect(fs.renameSync).toHaveBeenCalledWith(
expect.stringMatching(/state\.json\..+\.tmp$/),
mockFilePath,
);
});

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

Update the test expectations to match the asynchronous file handle pattern. Since this is a test environment, synchronous mocks are acceptable for setup.

  it('should save state through a temporary file and publish it atomically', async () => {
    vi.mocked(fs.existsSync).mockReturnValue(false);
    const mockHandle = { writeFile: vi.fn(), sync: vi.fn(), close: vi.fn() };
    vi.mocked(fs.promises.open).mockResolvedValue(mockHandle as any);
    persistentState.set('defaultBannerShownCount', { banner1: 1 });

    expect(fs.promises.open).toHaveBeenCalledWith(
      expect.stringMatching(/state\.json\..+\.tmp$/),
      'wx',
    );
    expect(mockHandle.writeFile).toHaveBeenCalledWith(
      JSON.stringify({ defaultBannerShownCount: { banner1: 1 } }, null, 2),
      'utf-8',
    );
    expect(mockHandle.sync).toHaveBeenCalled();
    expect(mockHandle.close).toHaveBeenCalled();
  });
References
  1. For test environment setup, it is acceptable to use synchronous operations within an async function.

@github-actions github-actions Bot added the size/l A large sized PR label Sep 19, 2026
@Oscar-Williams

Copy link
Copy Markdown
Author

Addressed the Gemini Code Assist feedback in commit 6259af325.

  • PersistentState.save() now uses one asynchronous fs.promises file handle (wx → writeFile → sync → close).
  • Queued saves preserve call order while keeping filesystem work off the event loop.
  • Unit and real-filesystem tests now await persistence and cover consecutive saves.

Focused CLI tests: 22 passed. CLI typecheck and build pass on Windows. Please re-review the updated head.

@Oscar-Williams

Copy link
Copy Markdown
Author

@gemini-code-assist review

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

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.

Code Review

This pull request updates the PersistentState class to perform atomic file writes using temporary files, maintain a backup of the previous state, and automatically recover from corrupted state files by restoring from the backup. It also serializes consecutive saves using a promise queue, changing the set method to return a Promise<void>, which requires updating its usage across various UI components and hooks. The review feedback identifies a critical issue where a restored backup state could be silently lost if the CLI process exits before a new save is triggered, and suggests performing a synchronous save immediately after recovery to ensure data durability.

Comment on lines +46 to +51
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
this.cache = JSON.parse(content);
} catch (error) {
this.cache = this.recoverFromCorruptState(filePath, 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.

critical

If state.json is corrupt, recoverFromCorruptState successfully renames it to state.json.corrupt and restores the backup state into this.cache in memory. However, if the CLI process exits without calling set() (which triggers save()), the restored state is never written back to state.json. On the next run, state.json will be missing entirely, and load() will initialize this.cache to {} because it only attempts recovery if state.json exists. This results in the restored backup state being silently lost on the subsequent run.

To fix this, we should trigger a synchronous save of the restored backup state back to state.json immediately after recovery. In error handling contexts like this, synchronous file I/O is preferred over asynchronous methods to ensure data durability, especially since the CLI process might terminate unexpectedly.

Suggested change
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
this.cache = JSON.parse(content);
} catch (error) {
this.cache = this.recoverFromCorruptState(filePath, error);
}
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
this.cache = JSON.parse(content);
} catch (error) {
this.cache = this.recoverFromCorruptState(filePath, error);
this.saveSync();
}
References
  1. In contexts like test logging or error handling, prefer synchronous file I/O over asynchronous methods to ensure data durability and log capture when the process might terminate unexpectedly.
  2. The use of synchronous file I/O from persistentState is acceptable within React hooks for this CLI application.

@Oscar-Williams

Copy link
Copy Markdown
Author

Addressed the follow-up recovery durability finding in commit 76c16a378.

When a corrupt state.json is recovered from a valid backup, the restored cache is now synchronously written back through the same temp-file + fsync + rename path before load() returns. The integration test verifies a fresh PersistentState instance can read the restored state without another set() call.

Focused CLI tests remain 22 passed; CLI typecheck and build pass on Windows. Please re-review the updated head.

@Oscar-Williams

Copy link
Copy Markdown
Author

@gemini-code-assist review

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

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.

Code Review

This pull request refactors PersistentState to perform atomic file writes asynchronously using a temporary file, fsync, and rename operations. It introduces a backup system (.bak), corruption recovery (.corrupt), and a promise queue to serialize consecutive saves, updating the set method to return a Promise<void>. UI components and hooks have been updated to handle the promise, and new integration and unit tests have been added. The reviewer identified a potential race condition on Windows in the synchronous fallback saveSync() method, where reopening a recently closed file to sync it could fail due to file locking, and suggested writing directly to the opened file descriptor instead.

Comment on lines +127 to +135
fs.writeFileSync(temporaryPath, JSON.stringify(this.cache, null, 2), {
encoding: 'utf-8',
flag: 'wx',
});

fileDescriptor = fs.openSync(temporaryPath, 'r+');
fs.fsyncSync(fileDescriptor);
fs.closeSync(fileDescriptor);
fileDescriptor = undefined;

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

In saveSync(), writing the file using fs.writeFileSync and then immediately reopening it with fs.openSync(..., 'r+') to perform fsyncSync creates a race condition on Windows. On Windows, background processes like anti-virus scanners or search indexers often open and lock newly created/modified files immediately after they are closed. Reopening the file immediately after writeFileSync closes it can result in transient EACCES (Permission Denied) errors. To prevent this, open the file descriptor once with 'wx', write the content, fsyncSync it, and then close it.

Suggested change
fs.writeFileSync(temporaryPath, JSON.stringify(this.cache, null, 2), {
encoding: 'utf-8',
flag: 'wx',
});
fileDescriptor = fs.openSync(temporaryPath, 'r+');
fs.fsyncSync(fileDescriptor);
fs.closeSync(fileDescriptor);
fileDescriptor = undefined;
fileDescriptor = fs.openSync(temporaryPath, 'wx');
fs.writeSync(fileDescriptor, JSON.stringify(this.cache, null, 2));
fs.fsyncSync(fileDescriptor);
fs.closeSync(fileDescriptor);
fileDescriptor = undefined;
References
  1. The use of synchronous file I/O from persistentState is acceptable within React hooks for this CLI application, contrary to general advice against blocking operations.

@Oscar-Williams

Copy link
Copy Markdown
Author

Addressed the Windows recovery-path review in commit 2f4b9fe00.

saveSync() now opens the temp file once with wx, writes through that descriptor, fsyncs it, and closes it before the atomic rename. This avoids reopening a just-created file while retaining synchronous recovery durability.

Focused CLI tests remain 22 passed; CLI typecheck and build pass on Windows. Please re-review the updated head.

@Oscar-Williams

Copy link
Copy Markdown
Author

@gemini-code-assist review

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

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.

Code Review

This pull request refactors the PersistentState class to ensure atomic, asynchronous state persistence. State updates are now written to a temporary file, synced, and atomically renamed to the target file, with the previous state preserved as a backup (.bak). Additionally, a corruption recovery mechanism has been introduced to restore from the backup if the main state file becomes corrupted, and consecutive saves are serialized using a promise queue. Consequently, the set method now returns a Promise<void>, and its call sites across the codebase have been updated to handle this change. Comprehensive unit and integration tests have also been added to verify these filesystem behaviors. I have no feedback to provide as there are no review comments to address.

@gemini-cli

gemini-cli Bot commented Sep 26, 2026

Copy link
Copy Markdown
Contributor

Hi there! Thank you for your interest in contributing to Gemini CLI.

To ensure we maintain high code quality and focus on our prioritized roadmap, we only guarantee review and consideration of pull requests for issues that are explicitly labeled as 'help wanted'.

This PR will be closed in 7 days if it remains without that designation. We encourage you to find and contribute to existing 'help wanted' issues in our backlog! Thank you for your understanding.

@Oscar-Williams

Copy link
Copy Markdown
Author

The current head addresses the file-handle and recovery-durability review, and the CLA check is now passing. #29307 still lacks help wanted. Could a maintainer confirm whether this state-persistence fix is wanted and apply the designation if appropriate? I will leave the acceptance decision with the maintainers.

@gemini-cli

gemini-cli Bot commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

This pull request is being closed as it has been open for 14 days without a 'help wanted' designation. We encourage you to find and contribute to existing 'help wanted' issues in our backlog! Thank you for your understanding.

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

Labels

area/core Issues related to User Interface, OS Support, Core Functionality priority/p1 Important and should be addressed in the near term. size/l A large sized PR size/m A medium sized PR status/pr-nudge-sent

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug(cli): non-atomic state.json write can corrupt and silently wipe persistent state

2 participants