[BUG] Silent settings.json data loss: 5s time-only echo suppression masks external writes, stale per-process cache clobbers on next save
Summary
Claude Code silently loses data in ~/.claude/settings.json when a second legitimate process writes the file shortly after Claude Code's own write. Nothing corrupts the JSON — well-formed settings changes simply vanish, in both directions (external additions dropped, and Claude Code's own /model selection silently reverted).
Two mechanisms combine:
- Full-file saves from a per-process in-memory cache. A settings save (e.g. the
/modeldefault-save) merges the partial update into the process's cached settings object and writes the entire merged object back withJSON.stringify(obj, null, 2)via atomic staging + rename. The file is only read from disk when the cache is empty. - Time-only echo suppression in the settings file-watcher. The watcher that should reload the cache on external changes discards any change event landing within 5000 ms of Claude Code's own write, treating it as an echo of that write — decided by timestamp only, with no content comparison. The debouncer additionally coalesces multiple change events into a single (suppressed) event.
An external write that lands inside that 5-second window is silently masked. The process's cache then diverges from disk for the rest of its lifetime — hours or days for a long-lived TUI session or the desktop app's embedded runtime — and its next settings save rewrites the file from the stale cache: it drops whatever the external writer added and resurrects whatever it removed. Last write wins; nobody notices.
Deterministic reproduction (3 phases)
Tested against the real CLI binary, driven via expect, with CLAUDE_CONFIG_DIR pointed at a scratch config directory (a copy — never a live settings file). External edits were made the polite way: write a temp file, then atomic rename over settings.json.
- Phase 1 (control, fresh cache): start Claude Code, trigger a settings save (
/effort). All keys round-trip, including an unknown probe key and fields Claude Code does not manage. Preservation is fine when the cache is fresh. - Phase 2 (external edit OUTSIDE the window): add a new top-level key by atomic rename 20 seconds before Claude Code's next settings write. The key survives — the watcher reload works correctly outside the window.
- Phase 3 (external edit INSIDE the window): make the identical edit ~2–3 seconds after a Claude Code settings write. The key is verifiably on disk — then Claude Code's next settings write (~30 seconds later) silently drops it, while the phase-2 key survives. Reproduces deterministically.
Phase 2 vs phase 3 isolates the echo-suppression window as the mechanism: same edit, same atomic-rename method, opposite outcome depending only on timing relative to Claude Code's own write.
Versions
- CLI 2.1.240 (macOS, Bun-compiled binary): reproduced experimentally as above.
- Desktop-app embedded runtime 2.1.247: same save path confirmed by inspection of the embedded JS (same cache-merge on save, full-file stringify, 5000 ms time-only suppression, debounce coalescing); not separately experiment-driven.
Real-world impact observed
One user's ~/.claude/settings.json is shared by long-lived terminal CLI sessions, the desktop app's embedded Claude Code runtime, and a second legitimate writer process (a tool that registers a SessionStart hook by atomically rewriting the file). Reconstructed from history:
- Four top-level keys (
outputStyle,minimumVersion,editorMode,inputNeededNotifEnabled) transiently vanished when a/modeldefault-save was issued from a Claude Code process holding a stale cache. - ~23 minutes later, a different Claude Code process (holding an even older cache) rewrote the file: it restored the four keys — and silently reverted the user's
/modelselection made earlier that day.
Every writer involved is content-faithful in isolation; all of the loss comes from the masking window plus the last-write-wins full-file protocol. Because a later stale write often "heals" an earlier one, the damage is mostly invisible — until a hook registration, notification preference, or model selection quietly disappears.
Suggested directions
- Decide "is this my own echo?" by content (hash the bytes just written and compare), not by timestamp alone; or
- Re-read and merge from disk at save time (read-modify-write) instead of trusting the per-process cache; or
- Both. As it stands, any second legitimate writer — including another Claude Code process — is a data-loss hazard even when it uses atomic, content-faithful rewrites.
Environment
- macOS (Apple Silicon)
- Settings file at the default
~/.claude/settings.json - No settings corruption involved: every observed state was valid JSON