settings.json is written with an unlocked, non-atomic read-modify-write: concurrent sessions tear it ("Settings file failed to parse") and silently drop each other's keys (2.1.258, with repro)
Summary
~/.claude/settings.json is persisted with a plain truncating writeFile — no file lock, no tmp+rename. With more than one session live, two writes interleave and a reader can observe a file that is not valid JSON. Claude Code then reports "Settings file failed to parse" and falls back to defaults, silently dropping permission rules and hooks. The same unlocked read-modify-write also lets one session discard another's keys.
The write site is still non-atomic in 2.1.258. I have a deterministic reproduction below (1.3% of reads torn under load) and nine occurrences in the wild on one machine over four weeks.
Environment
- Claude Code CLI 2.1.258; VS Code extension 2.1.246 → 2.1.258 (all affected)
- Ubuntu 24.04.4 LTS on WSL2, kernel 6.18.33.2, node v24.18.1
- Several concurrent sessions across several VS Code windows — up to ten Claude Code processes at once, across two or three extension versions
The code
extension.js, writeUserSettingsAndPush (2.1.258; ZQ = require("fs/promises"), GJ = require("path")):
if (!Q) {
let W = GJ.join(h7(), "settings.json"),
K = await this.readSettingsFileFromDisk(W); // read
for (let [U, G] of Object.entries(J))
if (G === null) delete K[U]; else K[U] = G; // modify
await ZQ.mkdir(GJ.dirname(W), {recursive: !0}),
await ZQ.writeFile(W, JSON.stringify(K, null, 2) + `\n`), // write
this.cachedUserSettings = K
}
No lock is held across the read and the write, and writeFile truncates in place rather than writing a sibling and rename(2)-ing it over the target.
Two bugs in that one path
1. Torn file — a reader can observe a partial write.
B: open(O_TRUNC) -> length 0
A: write(131 bytes) -> length 131 (A opened before B truncated)
B: write(121 bytes) -> length 131: B's 121 bytes, then A's last 10
The result is a complete, valid JSON object followed by the tail of a longer earlier version. Different sessions and versions serialise different key sets, so the two writes differ in length — exactly the condition that leaves a tail. The zero-length window between truncate and write is the other observable shape.
2. Lost update. Two sessions each read, mutate and write; the later write wins and silently discards the other session's key. I believe this is what #76749 and #82167 describe as "stale in-memory config re-persisted" — same unlocked read-modify-write, different symptom.
Reproduction
Six writers doing what writeUserSettingsAndPush does — alternating a short and a long serialisation of the same object — plus one reader sampling the file. No Claude Code needed; this is the writeFile pattern in isolation.
writer.cjs:
const fsp = require("fs/promises"), P = require("path");
const target = P.join(process.env.CFG, "settings.json");
const tag = process.argv[2], n = parseInt(process.argv[3], 10);
const shortDoc = {model: "opus[1m]", who: tag};
const longDoc = {model: "opus[1m]", who: tag, effortLevel: "xhigh",
syntaxHighlightingDisabled: true, switchModelsOnFlag: false,
padding: "x".repeat(200)};
(async () => {
for (let i = 0; i < n; i++)
await fsp.writeFile(target, JSON.stringify(i % 2 ? longDoc : shortDoc, null, 2) + "\n");
})();
reader.py:
import json, sys, time, collections
path, secs = sys.argv[1], float(sys.argv[2])
end = time.time() + secs
reads = bad = empty = 0
shapes = collections.Counter()
while time.time() < end:
with open(path, "rb") as fh: raw = fh.read()
reads += 1
try: json.loads(raw.decode("utf-8"))
except Exception:
bad += 1
if not raw.strip(): empty += 1; continue
try:
_, idx = json.JSONDecoder().raw_decode(raw.decode("utf-8", "replace"))
shapes["valid JSON + %d stale trailing bytes" % (len(raw) - idx)] += 1
except Exception: shapes["otherwise malformed"] += 1
print("reads=%d torn=%d (0-byte: %d)" % (reads, bad, empty))
for k, n in shapes.most_common(4): print(" %5d x %s" % (n, k))
Run:
mkdir -p /tmp/cfg && printf '{"model":"opus[1m]"}\n' > /tmp/cfg/settings.json
python3 reader.py /tmp/cfg/settings.json 6 &
for i in 1 2 3 4 5 6; do CFG=/tmp/cfg node writer.cjs w$i 60 & done; wait
Result on the machine above:
reads=213861 torn=2791 (0-byte: 2498)
293 x valid JSON + 313 stale trailing bytes
1.3% of reads observed an unparseable settings file, in both shapes seen in the wild. Replacing the writers' writeFile with write-to-sibling + rename(2) takes this to 0 torn out of 162,217 reads under the same load.
Evidence from the wild
Nine occurrences between 2026-08-02 and 2026-08-31 on one machine, four banked verbatim — the same two shapes the reproduction produces:
| file | size | shape |
|---|---|---|
| settings.json.corrupt.20260830T193002 | 131 B | valid object + 10-byte stale tail |
| settings.json.corrupt.20260830T211332 | 0 B | caught mid-truncate |
| settings.json.corrupt.20260830T233720 | 131 B | valid object + 10-byte stale tail |
| settings.json.corrupt.20260831T175847 | 130 B | valid object + 10-byte stale tail |
A banked example — 131 bytes where 121 were written, the last 10 being the tail of the previous, longer serialisation:
{
"effortLevel": "xhigh",
"syntaxHighlightingDisabled": true,
"model": "opus[1m]",
"switchModelsOnFlag": false
}
false
}
A monitor logged the live sessions at repair time: for 2026-08-30 21:13:32, ten Claude Code processes across extension versions 2.1.246 and 2.1.247; for 2026-08-31 17:58:47, five on 2.1.251.
Impact
readSettingsFileFromDisk throws Settings file is not valid JSON and the session falls back to defaults. On a settings file carrying permissions and hooks, the permission posture of every subsequent session changes silently behind a dismissable banner — the same impact described in #79403 and #78764.
Suggested fix
- Write atomically: serialise to a temp file in the same directory,
fsync, thenrename(2)over the target. A reader then sees either the old file or the new one, never a torn one. This alone removes every shape above. - Hold an advisory lock (an
O_EXCLlockfile, orflock) across the read-modify-write, so concurrent sessions cannot silently clobber each other's keys. - Optionally, validate after writing and roll back on parse failure, as #79403 suggests.
Workaround in place here, and why it is a bad one
chattr +i on the file, so the bad write fails at the kernel instead of landing, plus an inotify watcher that repairs it within ~185 ms for any moment the flag is off. But the immutable flag also blocks the legitimate write from the model picker, so the extension has to be patched locally to route its write through an atomic installer — and that patch breaks on every extension update, each time presenting as:
Failed to set model: EPERM: operation not permitted, open '/home/lukas/.claude/settings.json'
I would not recommend this to anyone; it is only tolerable because the alternative is losing hooks and permission rules at random. A tmp+rename upstream makes the whole apparatus unnecessary.
Possibly the same root cause
#79403 (VS Code model toggle corrupts settings.json — same symptom, diagnosed there as a partial/append write), #82167 and #76749 (lost update / "stale in-memory config"), #2810.