~/.claude.json corruption due to non-atomic file writes (concurrent session race condition)

Resolved 💬 3 comments Opened Mar 17, 2026 by quantsquirrel Closed May 2, 2026

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

  1. Open two terminals
  2. Launch Claude Code in both simultaneously (triggering concurrent numStartups increments and config writes)
  3. One session writes a shorter JSON payload over the existing file
  4. 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:

  1. Writer A opens with O_TRUNC (truncates to 0) and writes 240,551 bytes
  2. Writer B opens with O_TRUNC (truncates again) and writes 239,892 bytes
  3. 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 sessions

View original on GitHub ↗

This issue has 3 comments on GitHub. Read the full discussion on GitHub ↗