Description
Starting with Gemini CLI 0.33.0, gemini --version (and any gemini command) triggers a concurrent startup race condition that produces:
Failed to save project registry to /home/runner/.gemini/projects.json: Error: ENOENT: no such file or directory, rename '/home/runner/.gemini/projects.json.tmp' -> '/home/runner/.gemini/projects.json'
Root Cause
In gemini.tsx, the CLI runs two cleanup functions concurrently:
await Promise.all([
cleanupCheckpoints(), // index 0
cleanupToolOutputFiles(settings), // index 1
]);
Both create separate Storage → ProjectRegistry instances targeting the same ~/.gemini/projects.json. When the file doesn't exist, both call ProjectRegistry.save() without locking (the proper-lockfile lock in getShortId() only engages after the initial save). Both write to the same projects.json.tmp path:
- A:
writeFile('projects.json.tmp') → succeeds
- B:
writeFile('projects.json.tmp') → overwrites A's file
- A:
rename('projects.json.tmp', 'projects.json') → succeeds, tmp file gone
- B:
rename('projects.json.tmp', 'projects.json') → ENOENT — A already renamed it
Workaround
Pre-seed ~/.gemini/projects.json with {"projects":{}} before running any gemini command. This causes both concurrent callers to skip the unguarded initial save() and go straight to proper-lockfile's lock(), which properly serializes access.
mkdir -p "${HOME}/.gemini"
if [[ ! -f "${HOME}/.gemini/projects.json" ]]; then
echo '{"projects":{}}' > "${HOME}/.gemini/projects.json"
fi
Upstream Fix
The ideal fix would be in Gemini CLI itself — either share a single Storage/ProjectRegistry instance across both cleanup calls, or use a unique tmp file name (e.g., with PID or random suffix) in ProjectRegistry.save().
Environment
- Gemini CLI version: 0.33.0
- Runner: GitHub-hosted (Ubuntu 24.04)
- Node.js: 20.20.0
Description
Starting with Gemini CLI 0.33.0,
gemini --version(and any gemini command) triggers a concurrent startup race condition that produces:Root Cause
In
gemini.tsx, the CLI runs two cleanup functions concurrently:Both create separate
Storage→ProjectRegistryinstances targeting the same~/.gemini/projects.json. When the file doesn't exist, both callProjectRegistry.save()without locking (theproper-lockfilelock ingetShortId()only engages after the initial save). Both write to the sameprojects.json.tmppath:writeFile('projects.json.tmp')→ succeedswriteFile('projects.json.tmp')→ overwrites A's filerename('projects.json.tmp', 'projects.json')→ succeeds, tmp file gonerename('projects.json.tmp', 'projects.json')→ ENOENT — A already renamed itWorkaround
Pre-seed
~/.gemini/projects.jsonwith{"projects":{}}before running anygeminicommand. This causes both concurrent callers to skip the unguarded initialsave()and go straight toproper-lockfile'slock(), which properly serializes access.Upstream Fix
The ideal fix would be in Gemini CLI itself — either share a single
Storage/ProjectRegistryinstance across both cleanup calls, or use a unique tmp file name (e.g., with PID or random suffix) inProjectRegistry.save().Environment