Skip to content

fix(core): make tool file writes atomic and serialize same-path writes - #29244

Open
ranjan-del wants to merge 2 commits into
google-gemini:mainfrom
ranjan-del:fix/29078-concurrent-file-write-race
Open

fix(core): make tool file writes atomic and serialize same-path writes#29244
ranjan-del wants to merge 2 commits into
google-gemini:mainfrom
ranjan-del:fix/29078-concurrent-file-write-race

Conversation

@ranjan-del

Copy link
Copy Markdown

Summary

Parallel tool execution lets two file operations target the same path at once.
Today that silently loses edits: two concurrent replace calls on one file both
read the original content, and whichever writes second discards the other's edit
while both tool calls report success to the model. Neither the user nor the
agent can tell it happened.

This makes tool writes atomic and serializes the read-modify-write per path, and
covers the other three races in #29078. Every one was reproduced on main first,
with a regression test added for each.

Details

Four distinct problems, and what each fix does.

1. Writes were not atomic. fs.writeFile opens with O_TRUNC and then
streams the content back in 512KB chunks, so a concurrent reader sees a
truncated prefix. A 64MB write exposed 129 distinct partial sizes
(0, 524288, 1048576, ...).

StandardFileSystemService.writeTextFile now writes to a uniquely named sibling
temp file and renames it into place, so an observer sees either the old file or
the new one. Two details worth flagging for review:

  • It copies the destination's mode onto the temp file before the rename.
    A fresh temp file does not inherit permissions, so without this, replacing a
    0600 file would silently widen it.
  • The rename retries on EBUSY/EPERM, which Windows raises transiently while
    another process holds the destination open. This mirrors the existing
    backoff in config/projectRegistry.ts.

2. Lost update in replace. EditTool.execute reads via calculateEdit
and writes later in the same method, with await points in between, so two
invocations interleave as read, read, write, write. Editing alpha and beta
in one file concurrently left alpha\nBETA\n.

Fixed with a new in-process per-path async mutex (utils/pathMutex.ts). The
body of execute moved into applyEdit, which runs inside the lock, so the
second edit reads the first one's result and both land. The key is the
already realpath-resolved target, so two spellings of one file share a lock.

3. write_file existence check raced. Two concurrent calls to the same new
path both reported "Successfully created and wrote to new file", so the second
one's diff claimed the file was empty beforehand. Same fix, applyWrite inside
the path lock.

4. Checkpoint snapshots interleaved. GitService.createFileSnapshot runs
add('.'), status(), commit(). Concurrent calls interleaved as
add, add, commit, commit, and because add('.') stages the whole worktree,
one snapshot folded the other's files into its commit. Now serialized per
shadow repository.

Deliberately out of scope

Happy to extend this PR or open follow-ups, whichever you prefer:

  • The lock is in-process only. It does not coordinate with a second Gemini
    CLI process or an external editor writing the same file. That needs an on-disk
    lock (proper-lockfile or an O_EXCL lockfile), which felt like a separate
    decision.
  • SandboxedFileSystemService is unchanged. It writes through a __write
    sandbox command, so sandboxed writes are still not atomic. Fixing it means
    changing the sandbox helper, not just this service.

No user-facing command, flag, or output changes, so no /docs update. No
breaking changes: FileSystemService keeps its existing two-method interface.

Related Issues

Fixes #29078

How to Validate

Reproduce the headline bug on main (before this change):

# packages/core
npx vitest run src/tools/edit.test.ts -t "concurrent edits"
# FAIL: expected 'alpha\nBETA\n' to contain 'ALPHA'

Then with this branch, all four regression tests pass:

# packages/core
npx vitest run \
  src/utils/pathMutex.test.ts \
  src/services/fileSystemService.test.ts \
  src/services/fileSystemService.atomic.test.ts \
  src/services/gitService.test.ts \
  src/tools/edit.test.ts \
  src/tools/write-file.test.ts
# 155 passed

The atomicity test is worth a look, because the obvious version of it does not
work. An async reader polling during the write is only ever scheduled before or
after it, since the write runs on the libuv threadpool. It needs a synchronous
statSync loop on the main thread to actually catch the destination mid-write.
src/services/fileSystemService.atomic.test.ts does that, and guards against a
vacuous pass by asserting the observer ran at least once.

Full gate:

npm run preflight

Note for reviewers running this locally: if your checkout is not a trusted
workspace, 9 tests in packages/cli/src/gemini.test.tsx fail with
FatalUntrustedWorkspaceError regardless of this change. Set
GEMINI_CLI_TRUST_WORKSPACE=true and they pass. I confirmed those same 9 fail
on a pristine stashed tree.

Pre-Merge Checklist

  • Updated relevant documentation and README (if needed) — not needed, no
    user-facing surface change
  • Added/updated tests (if needed) — one regression test per defect, each
    watched failing first
  • Noted breaking changes (if any) — none
  • Validated on required platforms/methods:
    • MacOS
      • npm run
      • npx
      • Docker
      • Podman
      • Seatbelt
    • Windows
      • npm run
      • npx
      • Docker
    • Linux
      • npm run
      • npx
      • Docker

Validated on macOS with npm run only. I do not have Windows, Linux, Docker,
Podman or Seatbelt available here. The Windows-relevant part of this change is
the EBUSY/EPERM rename retry, which I could not exercise on a real Windows
host, so it deserves a careful look or a CI run on Windows before merge.

Why this is a draft

#29078 does not carry the help wanted label, and CONTRIBUTING asks
contributors to discuss the issue and wait for maintainer feedback first. I had
the fix working while investigating, so I am opening this as a draft rather than
sitting on it. Happy to close it, split it up, or take it in a different
direction if a maintainer would rather shape the approach first.

Parallel tool execution lets two file operations target the same path at
once. Four consequences, each reproduced on main before fixing:

- fs.writeFile truncates the destination and then streams it back in
  512KB chunks, so a concurrent reader observes a partially written
  file. A 16MB write exposes dozens of truncated intermediate sizes.
- Two parallel edits both read the original content, and whichever
  writes second discards the other's edit while both tool calls report
  success to the model. Editing "alpha" and "beta" in one file
  concurrently leaves "alpha\nBETA\n".
- Two parallel write_file calls to the same new path both report
  creating it, so the second one's diff claims the file was empty
  beforehand.
- Concurrent createFileSnapshot calls interleave as add, add, commit,
  commit. Since add('.') stages the whole worktree, one snapshot folds
  the other's files into its commit.

StandardFileSystemService.writeTextFile now writes to a uniquely named
sibling temp file and renames it into place, so an observer sees either
the old file or the new one. It copies the destination's permissions
onto the temp file first, so replacing a 0600 file does not widen it,
and retries the rename on transient Windows EBUSY/EPERM.

A new in-process per-path async mutex serializes the read-modify-write
in EditTool and WriteFileTool, keyed on the already realpath-resolved
target, and serializes stage/status/commit in GitService.

Known limits, left for follow-up work:

- The mutex is in-process only. It does not coordinate with a second
  Gemini CLI process or an external editor; that needs an on-disk lock.
- SandboxedFileSystemService still writes through its __write sandbox
  command and is unchanged, so sandboxed writes are not yet atomic.

Fixes google-gemini#29078
@github-actions github-actions Bot added the size/l A large sized PR label Sep 8, 2026
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown

📊 PR Size: size/L

  • Lines changed: 552
  • Additions: +530
  • Deletions: -22
  • Files changed: 11

@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 8, 2026
@ranjan-del
ranjan-del marked this pull request as ready for review September 8, 2026 16:37
@ranjan-del
ranjan-del requested review from a team as code owners September 8, 2026 16:37
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown

🛑 Action Required: Evaluation Approval

Steering changes have been detected in this PR. To prevent regressions, a maintainer must approve the evaluation run before this PR can be merged.

Maintainers:

  1. Go to the Workflow Run Summary.
  2. Click the yellow 'Review deployments' button.
  3. Select the 'eval-gate' environment and click 'Approve'.

Once approved, the evaluation results will be posted here automatically.

@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 critical race conditions in file system and Git operations caused by parallel tool execution. By introducing atomic write patterns and an in-process path-based mutex, it ensures that concurrent file operations are properly serialized, preventing data loss and inconsistent states. These changes improve the reliability of file edits and checkpointing mechanisms without introducing breaking changes to the existing interface.

Highlights

  • Atomic File Writes: Implemented atomic file writes by writing to a temporary sibling file and renaming it into place, preventing partial file states during concurrent reads.
  • In-Process Path Mutex: Introduced a per-path async mutex to serialize read-modify-write operations, preventing race conditions where concurrent edits to the same file would overwrite each other.
  • Git Snapshot Serialization: Serialized Git snapshot operations per shadow repository to prevent interleaving of staging and committing across concurrent snapshots.
  • Windows Compatibility: Added retry logic for renames on Windows to handle transient EBUSY/EPERM errors when files are held open by other processes.
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 introduces concurrency control and atomicity across file operations and git snapshots. It implements an in-process per-path mutex (withPathLock) to serialize concurrent edits, writes, and git snapshots targeting the same paths, preventing race conditions and data loss. Additionally, StandardFileSystemService.writeTextFile is updated to write atomically via a temporary file and rename, including permission preservation and rename retries for Windows compatibility. The feedback suggests wrapping the fs.chmod call in a try-catch block to make permission preservation best-effort, preventing failures on filesystems or environments that do not support permission changes.

Note: Security Review did not run due to the size of the PR.

Comment on lines +63 to +66
const existingMode = await this.getFileMode(filePath);
if (existingMode !== undefined) {
await fs.chmod(tmpPath, existingMode);
}

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

On certain filesystems (such as FAT32, exFAT, or network mounts like NFS/CIFS) or in highly restricted sandbox/container environments, fs.chmod can throw errors like EPERM or ENOTSUP. If fs.chmod fails, the entire write operation will fail, which is a regression compared to non-atomic writes. Wrapping fs.chmod in a try-catch block to make permission preservation best-effort ensures maximum compatibility across diverse environments.

      const existingMode = await this.getFileMode(filePath);
      if (existingMode !== undefined) {
        try {
          await fs.chmod(tmpPath, existingMode);
        } catch {
          // Best effort: some filesystems or restricted environments do not support chmod.
        }
      }

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Confirmed and fixed in 44ae691.

I could not test FAT32 or an NFS mount directly, so I verified it the way I could: forcing chmod to reject with ENOTSUP made writeTextFile reject rather than resolve, and the temp file was deleted. So the write was lost, and since the previous in-place fs.writeFile never called chmod, that was a regression introduced by this PR.

The chmod is now best-effort as you suggested. I also went slightly further, because making it best-effort on its own leaves a second problem: if chmod is the only thing setting the mode and it silently fails, a replacement for a 0600 file lands at the default mode. And even when chmod succeeds, the temp file exists briefly at the default mode with the secret content already in it.

So the destination's mode is now also passed when the temp file is created:

const existingMode = await this.getFileMode(filePath);

await fs.writeFile(tmpPath, content, {
  encoding: 'utf-8',
  ...(existingMode !== undefined ? { mode: existingMode } : {}),
});

if (existingMode !== undefined) {
  try {
    await fs.chmod(tmpPath, existingMode);
  } catch {
    // Keep whatever mode the temp file was created with.
  }
}

Creation mode is masked by umask, so it can only end up more restrictive, never wider. The chmod then corrects that narrowing where it is permitted, and where it is not, we are left more restrictive rather than more permissive, which is the safer direction to fail in.

Two regression tests added: the temp file is created carrying the destination's mode, and a rejecting chmod neither fails the write nor deletes the temp file.

Review feedback on google-gemini#29244: fs.chmod sat inside the try block whose catch
removes the temp file and rethrows, so on filesystems that reject chmod
(FAT32, exFAT, some NFS/CIFS mounts) or in restricted sandboxes raising
EPERM/ENOTSUP, the whole write failed and the content was lost. The
previous in-place fs.writeFile never called chmod, so that was a
regression introduced by the atomic write.

The chmod is now best-effort. The destination's mode is also passed when
the temp file is created, which keeps the mode correct in the common case
and closes the window where a replacement for a 0600 file was briefly
readable through the default mode. Creation mode is masked by umask so it
can only be more restrictive; the chmod that follows corrects that when
it is permitted.

Two regression tests: the temp file is created with the destination's
mode, and a rejecting chmod does not fail the write or delete the temp
file.
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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug(tools): concurrent file writes suffer lost-update race (no atomic write or per-path locking)

1 participant