anthropics / anthropics/claude-code

[BUG] Windows: `.claude.json` is saved as a whole-file read-modify-write behind a best-effort lock that is abandoned after 15 s — entries written by one process disappear again, trust flags included

オープン
#92,908 コメント 0 件 リアクション 0 件 担当者 0 名 GitHub で見る
area:core area:permissions bug data-loss has repro platform:windows
主要言語
Python
スター
145k
フォーク
23.1k
PR マージ指標
PR 指標を取得中

説明

## Summary

Every change to `~/.claude.json` is a **read-modify-write of the entire file**. The only cross-process protection is an advisory lock created with `mkdir`, and it is given up in three ways: after a 15 s wait the writer proceeds **without** it, a lock older than 10 s may be **deleted and taken over** by another writer, and if `mkdir` fails outright the writer proceeds without it too. Nothing surfaces above `warn`, the file stays valid JSON, and no error reaches the user — entries simply vanish.

On this machine the lock was abandoned or stolen **8 times between 2026-08-25 and today**, the most recent one an hour after the current build was installed. In the same period `hasTrustDialogAccepted` keys were written and are gone again, which is why granting workspace trust here holds for seconds to minutes and then has to be granted anew.

## Environment

| | |
|---|---|
| OS | Windows 11 Enterprise 10.0.26200 |
| Claude Desktop | code read in **1.46388.4**, and re-checked unchanged in **1.49585.0** (current, installed 2026-09-08 18:42) |
| Host CLI | 2.1.260 |
| Workload | many parallel sessions across worktrees of several repositories, driven from the desktop app |
| `~/.claude.json` | 215.7 KB, 334 project keys (2026-09-08) |

The quotes below are from 1.46388.4. In 1.49585.0 the same functions exist with renamed identifiers and identical bodies — the lock is `eCn`, the trust writer `ZSn`, and the constants are `var QSn = 1e4, $Sn = 15e3`. So this is not a stale reading of an old build.

## The write path

`zvn` sets trust; it wraps the generic saver `Wvn`:

```js
var Rvn = new pr; // in-process mutex only
function zvn(e, t) {
return Rvn.runExclusive(() => Wvn(n => ({
...n, projects: { ...n.projects, ...Object.fromEntries(e.map(k => [k, t(yz(n.projects, k) ?? {…})])) }
})));
}
```

`Wvn` takes the lock, reads the whole config, applies the mutator, writes the whole file back:

```js
async function Wvn(e) {
let t = _z(); // ~/.claude.json

let i = await Hvn(t); // acquire lock, returns release fn
try {
let n = e(await Uvn(t)), // read whole config from disk, then mutate
i = Object.fromEntries(Object.entries(n).filter(…));
try { await copyFile(t, `${t}.backup`) } catch …
await Vr(r, i); // write whole file
} catch (e) { throw log.error(`Failed to save config: ${e}`), e }
finally { …; await i() } // release
}
```

`Rvn.runExclusive` serialises writers **inside one process**. Across processes the only guard is `Hvn`:

```js
var Bvn = 1e4, Vvn = 15e3;
async function Hvn(e) {
let t = `${e}.lock`, n = async () => { await rm(t, {recursive:true, force:true}).catch(…) },
r = Date.now() + Vvn;
for (;;) {
try { await mkdir(t, {mode: 448}); break }
catch (err) {
if (err.code !== "EEXIST")
return log.warn(`Cannot create ${t}: ${err}; writing without it`), async () => {};
}
let st = await stat(t).catch(() => null);
if (st && Date.now() - st.mtimeMs > Bvn) await n(); // steal a lock older than 10 s
if (Date.now() > r)
return log.warn(`Timed out waiting for ${t}; writing without it`), async () => {}; // give up after 15 s
await setTimeout(15);
}
let i = await stat(t).catch(() => null);
return async () => { // release
let e2 = await stat(t).catch(() => null);
if (i && e2 && (e2.ino !== i.ino || e2.birthtimeMs !== i.birthtimeMs)) {
log.warn(`${t} was taken over during the save; leaving it`); return;
}
await n();
};
}
```

`Uvn` does a fresh `readFile` + `JSON.parse` on every call, so the snapshot itself is not stale — the exposure is the ordinary read-modify-write window. That window is not small here: it spans parsing a 215 KB file, the merge, a 215 KB `copyFile` backup, and the write. Two writers whose windows overlap both start from a valid state, the second one to write wins, and everything the first added in between is gone. Preventing that overlap is exactly what the lock is for, and it is abandoned in the three ways above. The 10 s steal reaches the same result from the other side: a save that takes longer than 10 s loses its lock to a second writer, and the two then run concurrently by design.

`Vr` itself writes atomically — `.tmp`, `fsync`, `rename`, with retries and an in-place fallback only after repeated rename failures. That is worth stating because it explains why the file was never corrupt: atomic replacement rules out torn writes, but it performs no comparison against the state that was read, so it cannot prevent a lost update.

## Evidence from this machine

All eight lock-integrity events from the desktop log (`%LOCALAPPDATA%\Claude\Logs\main*.log`), verbatim except for the redacted user path:

```
2026-08-25 10:20:58 [warn] Cannot create .lock: Error: EPERM: operation not permitted, mkdir '.lock'; writing without it
2026-08-25 10:21:20 [warn] Timed out waiting for .lock; writing without it
2026-09-03 23:56:08 [warn] Timed out waiting for .lock; writing without it
2026-09-04 23:21:27 [warn] .lock was taken over during the save; leaving it
2026-09-06 01:22:22 [warn] Timed out waiting for .lock; writing without it
2026-09-06 09:38:01 [warn] Timed out waiting for .lock; writing without it
2026-09-06 14:41:54 [warn] .lock was taken over during the save; leaving it
2026-09-08 19:42:45 [warn] Timed out waiting for .lock; writing without it
```

Which build produced which, from `Squirrel-Update.log`:

| build | active | events |
|---|---|---|
| 1.34493.1 | 24.08 01:53 → 25.08 20:34 | the two on 08-25 |
| 1.44121.4 | 03.09 07:53 → 04.09 06:46 | 09-03 23:56 |
| 1.46388.3 | 04.09 20:04 → 05.09 05:28 | 09-04 23:21 |
| **1.46388.4** | 05.09 05:28 → 08.09 18:42 | the three on 09-06 — this is the build I read |
| **1.49585.0** | from 08.09 18:42 | 09-08 19:42, one hour after install |

I cannot verify the 08-25 and 09-03 events against their builds, because Squirrel has deleted those app directories. The message wording is identical across all of them, and the logic is identical in the two builds I can still read.

No `Failed to save config` and no `Failed to parse config` appears anywhere in these logs, so nothing was ever corrupted — only content was lost.

Matching losses, from a sampler of my own that reads `~/.claude.json` when a trust denial is logged (the raw series lives in my logs, not in the app's own artefacts, so this part is my measurement rather than something you can reproduce from a fresh install):

- `2026-09-02 15:02:20 Saved workspace trust for C:\\` → both spellings of that directory present and `true` across 13 distinct file versions over the following four minutes → hours later only the other spelling remained.
- In one log window a backslash base-repo key was written for 8 spellings of 7 directories. **None of those 8 keys is in the store now.**
- A key added by hand with the app closed did not survive seven minutes after reopening.

## What this looks like from the outside

I have to grant workspace trust before I can start anything, and it does not stick. Often the dialog does not appear at all and the session fails with *"Workspace requires trust approval before starting a session."* instead. When a dialog does appear and its processing visibly takes a moment, I can send prompts again — **and other workspaces that were failing start working too**, which is what a whole-file write of the shared `projects` map would do. The window lasts seconds to minutes, and it gets worse the more sessions and workspaces run in parallel. That correlation is what pointed me at the lock.

## What I could not establish

**Who the competing writer is.** The desktop and the host CLI both maintain `~/.claude.json`. The CLI binary has its own config-save code and a lockfile library, but the two `writing without it` strings I can find in it refer to `known_marketplaces.json`, and I could not determine whether it ever takes this same `.lock`. If it does not, every CLI write is unsynchronised against the desktop by construction. That is one grep in your own source.

## Expected behaviour

- **Do not write without the lock.** A failed save is recoverable and visible; silently dropping another process's entries is neither. If a fallback has to exist, make the unlocked path compare-and-swap rather than overwrite: keep the size and mtime seen at read time and abort if the file changed underneath.
- **Raise or remove the 10 s steal**, or have the holder refresh the lock's mtime while it works. As written, any save slower than 10 s invites a concurrent writer, and on a loaded machine with a 215 KB config that is not a lot.
- **Log the loss, not just the lock.** `writing without it` is a warning about the lock; what matters to a user is that entries were dropped. Comparing key counts across the merge would catch it.
- **Consider not keeping this in one shared file.** Per-project state in per-project files removes the write conflict entirely, and would stop unrelated projects from taking each other down.

Related: #88418 and #72640 concern unnormalised project keys in the same file — a different defect that interacts with this one, because a key written under one spelling and then dropped looks exactly like a normalisation problem from the outside.

コントリビューションガイド

このリポジトリのコントリビューションガイドは索引されていません

調査の方向性

Start by locating the current source equivalents of Wvn, Hvn, Uvn, and Vr, then inspect the host CLI config-save path and the %LOCALAPPDATA%\Claude\Logs\main*.log messages. Done should preserve concurrent ~/.claude.json updates, avoid proceeding silently without a valid lock, and cover the failure and lock-takeover cases with regression tests.

索引モデルが issue の本文から書いたものです。

評価

技術スタック
javascript, node.js
領域
cli, desktop-dev
issue の種類
バグ
難易度
5/5
見積もり時間
1週間以上
活発さ
活発
明瞭さ
おおむね明確
初心者へのやさしさ
35/100

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。