~/.claude.json corruption due to non-atomic file writes (concurrent session race condition)
Bug Description
~/.claude.json becomes unparseable when two Claude Code sessions write to it concurrently. The new JSON payload is shorter than the existing file, and stale trailing bytes from the previous version persist, invalidating the JSON structure.
On next startup:
Claude configuration file at ~/.claude.json is corrupted: JSON Parse error: Unable to parse JSON string
The built-in recovery mechanism detects the corruption and offers a restore from backup — this works correctly. However, the corruption itself should not occur.
Reproduction Steps
- Open two terminals
- Launch Claude Code in both simultaneously (triggering concurrent
numStartupsincrements and config writes) - One session writes a shorter JSON payload over the existing file
- Inspect
~/.claude.json— the file contains valid JSON followed by stale bytes from the previous version
This reliably reproduces when sessions start within a few seconds of each other.
Byte-Level Evidence
A captured corruption event was analyzed at the byte level:
| Region | Contents |
|--------|----------|
| Bytes 0 – 239,891 | Complete, valid JSON (v2, "numStartups": 2438) |
| Bytes 239,892 – 240,539 | 648 bytes of stale tail from v1 ("numStartups": 2437) |
Proof: The trailing garbage (}ilableSubscription": false, "remoteDialogSeen": true, ...) matches backup_v1[239903:] bit-for-bit, confirming the old file content was not replaced when the shorter payload was written.
Timeline:
14:23:21Z v1 backup created (240,551 bytes, numStartups=2437)
+19.5s New session writes v2 (239,892 bytes, numStartups=2438)
14:23:40Z Corruption detected → corrupted file backed up
14:27:53Z Recovery from backup → healthy state restored
Root Cause
The write path for ~/.claude.json is not atomic. Node.js fs.writeFileSync with the default 'w' flag does include O_TRUNC, so a single writer would truncate correctly. However, when two sessions race:
- Writer A opens with
O_TRUNC(truncates to 0) and writes 240,551 bytes - Writer B opens with
O_TRUNC(truncates again) and writes 239,892 bytes - Depending on interleaving, the result can be a hybrid file with A's trailing bytes surviving after B's shorter payload
The fundamental issue is that truncate + write is not an atomic operation — another process can observe or interfere with intermediate states.
Suggested Fix
Use the atomic "write-to-temp, then rename" pattern. rename() is atomic on POSIX systems (macOS, Linux):
const tmpPath = `${configPath}.tmp.${process.pid}`;
try {
fs.writeFileSync(tmpPath, data, 'utf8');
fs.renameSync(tmpPath, configPath); // atomic on same filesystem
} catch (err) {
try { fs.unlinkSync(tmpPath); } catch {}
throw err;
}
Alternatively, the write-file-atomic npm package provides a drop-in replacement with the same semantics.
Existing Mitigation
The corruption-detection and backup-restore mechanism worked flawlessly — it detected the invalid JSON, preserved the corrupted file for analysis, and offered seamless recovery. Atomic writes would prevent the recovery path from ever needing to trigger.
Environment
Claude Code CLI: 2.1.77
OS: macOS 26.2 (Build 25C56) arm64
Node: v25.6.1
File system: APFS
Trigger: 2 concurrent terminal sessionsThis issue has 3 comments on GitHub. Read the full discussion on GitHub ↗