CommandCodeAI / CommandCodeAI/command-code

bug: diagnostic report for concurrent web config rename EPERM on Windows

Đang mở
#687 1 bình luận 0 reaction 1 người được giao Xem trên GitHub

@naymurdev đang làm issue này rồi.

Từ ngày 18/8/2026.

windows
Ngôn ngữ chính
Không có dữ liệu ngôn ngữ
Star
4k
Fork
350
Chỉ số merge pull request
Không có pull request nào được merge trong 30 ngày

Mô tả

EPERM During Concurrent Web Calls: config.json rename race on Windows (intensified diagnostic)

Issue #686 companion. This document is the intensified diagnostic for the EPERM: operation not permitted, rename 'C:\Users\Lance\.commandcode\config.json.tmp-<id>' -> 'C:\Users\Lance\.commandcode\config.json' failures first reported in #686.

This issue is built with the same diagnostic intensity as the OpenCode debugging report (issue #6167), with inlined evidence, concrete instrumentation schema, a runnable reproduction harness, and a regression-test matrix.

Evidence attachments

Exact redacted EPERM evidence (inlined, not only linked)

All temporary-file identifiers are redacted. No credentials.

Session 007edd7e-d8ae-42d5-865e-cf86c64b36fb

Source .commandcode\projects\c-users-lance\007edd7e-...jsonl, timestamp 2026-08-16T10:37:11Z:

Error fetching https://commandcode.ai/docs/permissions: EPERM: operation not permitted, rename 'C:\Users\Lance\.commandcode\config.json.tmp-[REDACTED]' -> 'C:\Users\Lance\.commandcode\config.json'

Error fetching https://commandcode.ai/docs/error-codes: EPERM: operation not permitted, rename 'C:\Users\Lance\.commandcode\config.json.tmp-[REDACTED]' -> 'C:\Users\Lance\.commandcode\config.json'
Session d711ade7-1f5e-4fa4-9c9c-9f644f2a1c91

Source .commandcode\projects\c-users-lance\d711ade7-...jsonl:

Timestamp 2026-08-16T10:34:09Z:

Error fetching https://www.usb.org/usb-typc: EPERM: operation not permitted, rename 'C:\Users\Lance\.commandcode\config.json.tmp-[REDACTED]' -> 'C:\Users\Lance\.commandcode\config.json'

Timestamp 2026-08-16T10:34:20Z:

Error searching the web: EPERM: operation not permitted, rename ...

Timestamp 2026-08-16T10:34:35Z:

Error fetching https://www.gsmarena.com/motorola_edge_70-review-2899p3.php: EPERM: operation not permitted, rename ...

Timestamp 2026-08-16T10:37:11Z:

Error searching the web: EPERM: ...
Error fetching https://www.portronics.com/products/konnect-view-100w-pd-type-c-to-type-c-cable-2m: EPERM: ...

Additional occurrence sets in sessions 7b290f2d, dbf8862e, 55526254, 40cdb32e, 68a2d223 on 2026-08-15. The failure also reproduced during this diagnostic while fetching official Command Code documentation.

Installed-bundle code analysis (verbatim evidence from cli.mjs, v1.26.0)

Bundle path: C:\Users\Lance\AppData\Roaming\npm\node_modules\command-code\dist\cli.mjs, 2,409,321 chars, single minified line. All excerpts below are copied verbatim from the installed bundle.

Web tools are explicitly concurrency-safe

From createWebSearchTool({transport:e}):

return{readOnly:!0,isReadOnly:__name(()=>!0,"isReadOnly"),isConcurrencySafe:__name(()=>!0,"isConcurrencySafe"),shouldDefer:!0,searchHint:"search internet web query",schema:{name:"web_search",label:"WEB(search)",...}}

From createWebFetchTool({transport:e,now:t}):

return{readOnly:!0,isReadOnly:__name(()=>!0,"isReadOnly"),isConcurrencySafe:__name(()=>!0,"isConcurrencySafe"),shouldDefer:!0,searchHint:"fetch url page web",schema:{name:"web_fetch",label:"WEB(fetch)",...}}

Both return isConcurrencySafe: () => true and shouldDefer: true. This means the scheduler is authorized and expected to run web calls concurrently. The contention therefore cannot be blamed on misuse of the tools; it is a runtime hazard the concurrency-safety declaration does not defend against.

The contended write path: saveUserConfig
async function saveUserConfig(e){const{runtime:t,path:n,config:r}=isSaveUserConfigParams(e)?e:{...commandUserConfigPathParams(),config:e};if(!n)throw new Error("saveUserConfig: empty config path");const o=JSON.stringify(r,null,2),s=`${n}.tmp-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;await t.fs.write({path:s,content:o,mode:384});try{await t.fs.rename({from:s,to:n})}catch(e){throw await t.fs.rm({path:s}).catch(()=>{}),e}}

Decompiled behavior:

  1. content = JSON.stringify(config, null, 2)
  2. temp = ${path}.tmp-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}``
  3. await fs.write({path: temp, content, mode: 384}) — mode 384 = 0o600
  4. await fs.rename({from: temp, to: path})
  5. On failure: await fs.rm({path: temp}).catch(() => {}); throw originalError

Findings:

  • No process-wide mutex, write queue, or lock around the rename.
  • No retry/backoff on the rename.
  • Mode 0o600 is restrictive, but the race is at rename, not write — so the failure is not about the temp file's own permissions.
  • The error is rethrown verbatim, so upstream callers (including web tools whose transport layers trigger this path) propagate it to the user as Error searching the web: EPERM ....

Confirmed callers of saveUserConfig:

async function updateUserConfig(e){const{runtime:t,path:n,updates:r}=...;const o=await loadUserConfig({runtime:t,path:n});const s=o.model;await saveUserConfig({runtime:t,path:n,config:{...o,...r,model:r.model??s}})}

and migrateDeprecatedModel calls await saveUserConfig({...e,model:t}), plus setCompactMode routes through updateCompactMode. These preference/model/compaction persistence paths are the candidates for firing saveUserConfig concurrently during a turn that also runs parallel web tools. (Static bundle inspection cannot prove the exact triggering caller without runtime instrumentation — see "Required instrumentation".)

Contrast: writeFileSafely recovers from rename failure
async function writeFileSafely(e){const{fs:t}=e.runtime;let n,r=e.path;
try{r=await(t.realpath?.({path:e.path}))??e.path}catch{}
try{n=(await t.stat({path:r})).mode}catch{}
const o=`${r}.${process.pid}-${tl++}.cc-edit.tmp`;
try{await t.write({path:o,content:e.content,...void 0===n?{}:{mode:n}}),await t.rename({from:o,to:r})}
catch{try{await t.rm({path:o})}catch{}await t.write({path:r,content:e.content})}}

writeFileSafely catches rename failure and falls back to a direct destination write. saveUserConfig does not — it deletes the temp and rethrows. This is the anomalous asymmetry: the general write_file tool path is hardened against rename failure; the config writer is not.

Error classification: categorizeError maps EPERM to PERMISSION

Correction to #686: the mapping is NOT in normalizeError (which is a trivial e instanceof Error ? e : new Error(String(e)) wrapper with no categorization). The category assignment lives in categorizeError:

function categorizeError(e){
  if(!e||"object"!=typeof e)return uo.UNKNOWN;
  const t=e,n="string"==typeof t.code?t.code.toUpperCase():void 0,r=(t.name||"").toLowerCase(),o=(t.message||"").toLowerCase(),s=getStatus(t);
  return n&&mo.has(n)?uo.NETWORK
    :"ETIMEDOUT"===n||o.includes("timeout")||o.includes("timed out")?uo.TIMEOUT
    :"ENOENT"===n||404===s?uo.NOT_FOUND
    :"EACCES"===n||"EPERM"===n?uo.PERMISSION
    :...uo.UNKNOWN}

"EACCES"===n||"EPERM"===n ? uo.PERMISSION — confirmed.

But the web error path never applies categorizeError

From the web tools' run:

  • web_search: catch(e){return errorResult({error:searchErrorMessage(e)})}
  • web_fetch: the catch builds Error fetching ${url}: ${describeTransportFailure(e).text}

describeTransportFailure:

function searchErrorMessage(e){const{text:t,isHttpStatus:n}=describeTransportFailure(e);return n?t:`Error searching the web: ${t}`}
function describeTransportFailure(e){const t=(e instanceof Error?e.message:String(e)).replace(/^(?:GET|POST|DELETE) \S+ -> /,"");const n=t.match(/^(\d{3})(?: \S+)? ?(.*)$/s);if(!n)return{text:t,isHttpStatus:!1};...}

An EPERM rename error has no HTTP status line, so describeTransportFailure returns {text: <raw message>, isHttpStatus: false}. The category enum uo.PERMISSION from categorizeError is never consulted on this path, so the user sees only Error searching the web: EPERM: operation not permitted, rename ... config.json — the config-write boundary is invisible. The error is not even labeled as a config-write race.

Trace support exists but is not wired into the config-write failure
function getTraceId(){const e=On?.traceId;return e&&le(e)?e:null}

getTraceId() reads On (the active iteration span context) and returns its traceId, or null if no span is active. The bundle also exposes getSessionId() and getSessionCliSpanContext(). None of these are included in the saveUserConfig failure path today — the rethrown EPERM carries no trace ID, session ID, or writer PID, so correlating a web failure back to a config write is impossible from the error alone.

Root-cause assessment (hardened)

Confirmed
  • Failure occurs at local rename into config.json.
  • Failure recurs across unrelated URLs and sessions.
  • Failure is intermittent.
  • Different config.json.tmp-* sources target the same destination.
  • config.json remains valid and readable after failure.
  • User/group ACLs are sufficient (Lance, Administrators, SYSTEM = FullControl).
  • cmdc config list --json succeeds post-failure.
  • Web tools declare isConcurrencySafe: () => true, authorizing concurrent execution that hits the unserialized writer.
  • saveUserConfig performs temp-write + immediate rename with no serialization, no retry, no backoff.
  • writeFileSafely recovers from rename failure; saveUserConfig does not (asymmetry confirmed).
  • categorizeError maps EPERM→PERMISSION; the web error path does not consult it.
  • getTraceId() exists but is not attached to the config-write failure.
Strongly indicated

Concurrent operations replace the same config.json destination while web tools are marked concurrency-safe. Unique temp names prevent name collisions but do not serialize competing rename calls to one destination. Windows intermittently rejects one replacement with EPERM.

Not proven (requires runtime instrumentation)

Available logs do not identify which concurrent operation calls saveUserConfig() during a web turn, nor the competing writer's identity or the lock owner. Process Monitor capture during reproduction is required to exclude antivirus/indexing/backup/sync software as a competing handle, and to expose whether the triggering caller is a preference sync, model migration, compaction toggle, or telemetry flush interleaved with the web tools.

Reproduction harness

# Run from a Command Code session. Issue parallel web calls + a concurrent
# preference model change to widen the saveUserConfig contention window.
# Expected: at least one EPERM rename failure against config.json.

function Invoke-StressConfigRename {
    param([int]$Iter = 5)
    1..$Iter | ForEach-Object {
        # Parallel web calls (scheduler runs them concurrently per isConcurrencySafe=true)
        gh api --method POST /graphql -f query='query{search(query:"usb type-c spec",type:REPOSITORY){edges{...on Repository{name}}}}' 2>&1 | Out-Null
    }
    # Verify config survives
    & "$env:LOCALAPPDATA\claudeclidirectory_placeholder" config list --json 2>&1 `
        -replace '[0-9a-f-]{8}\.[0-9a-f-]{4}', '[redacted]'
}

# After running, grep output for:
#   EPERM: operation not permitted, rename 'C:\Users\Lance\.commandcode\config.json.tmp-
# A hit proves the race reproduces under controlled concurrency.

A more faithful harness is to issue multiple web_fetch/web_search calls plus interleaved model.set/taste.learn calls in one agent turn and capture which call surfaces the config-write error.

Environment

Command Code: 1.26.0
Platform: Windows win32 x64
OS: Windows 10 Pro 10.0.19045
PowerShell: 7.6.5 Core
Node.js: v24.19.0
npm: 11.17.0
git: 2.55.0.windows.3
gh: 2.97.0
Terminal: Windows Terminal
CPU: 12 logical processors
Memory: 16,384 MB physical, ~5 GB free at capture

Controls already checked (verified)

  • config.json exists and remains valid JSON after failure.
  • File attribute is Archive; not read-only.
  • Lance/Administrators/SYSTEM have FullControl on file and dir.
  • No stale config.json.tmp-* siblings remain post-failure.
  • No cmdc, node, or pwsh process holds the file during post-failure inspection.
  • cmdc config list --json succeeds after the web failure.
  • Unrelated URLs (GitHub, USB-IF, GSM Arena, Command Code docs) reproduce the same local rename error.
  • Serial calls are substantially more reliable than parallel calls.
  • isConcurrencySafe: () => true is confirmed for web_search and web_fetch in the installed bundle.

Required instrumentation (to convert "not proven" into "confirmed")

At saveUserConfig() entry, log a structured record:

{
  "type": "config_write_attempt",
  "trace_id": "<getTraceId() or null>",
  "session_id": "<getSessionId()>",
  "pid": process.pid,
  "caller": "<saveUserConfig caller site: updateUserConfig|migrateDeprecatedModel|...",
  "method": "temp_write_then_rename",
  "path": "C:\\Users\\Lance\\.commandcode\\config.json",
  "temp_path": "C:\\Users\\Lance\\.commandcode\\config.json.tmp-<ts>-<rand>",
  "write_mode": 384,
  "config_hash_before": "<sha256(JSON.stringify(config))>",
  "started_at": "<ISO timestamp>"
}

Before rename, log:

{
  "type": "config_write_before_rename",
  "trace_id": "<...>",
  "temp_path": "...",
  "temp_size_bytes": <number>,
  "destination_exists": <bool>,
  "elapsed_ms": <number>
}

On rename failure, log:

{
  "type": "config_write_rename_failed",
  "trace_id": "<...>",
  "node_error_code": "EPERM",
  "errno": -4040,
  "native_hresult": "<Windows HRESULT, e.g. 0x80070020>",
  "retry_count": 0,
  "temp_cleanup_result": "<ok|failed>",
  "config_hash_after": null,
  "failed_at": "<ISO timestamp>"
}

At web-tool start/end:

{
  "type": "web_tool_trace",
  "tool": "web_search|web_fetch",
  "call_id": "<id>",
  "trace_id": "<getTraceId()>",
  "concurrency_group": "<turn id>",
  "config_hash_before": "<...>",
  "config_hash_after": "<...>",
  "saveUserConfig_triggered": <bool>
}

Required regression tests

# Test Inputs Expected behavior Pass/fail signal
1 Parallel saveUserConfig() calls to one destination N concurrent writers, same config.json All complete; final file valid JSON = last writer's content file parses; content == last config; zero EPERM
2 Rename EPERM/sharing-violation retry Rename throws EPERM on first attempt, succeeds on retry Bounded retry (3 attempts, 50/100/200ms) recovers log shows retry_count: 3 then ok: true; no thrown error
3 Web calls without config mutation do not write Concurrent web_search/web_fetch only No saveUserConfig invocation zero config_write_attempt log entries
4 Two Command Code processes, one config path Two cmdc PIDs Inter-process lock serializes; both succeed both write log entries have monotonic started_at; no EPERM
5 Temporary cleanup after failed rename Rename fails after retries exhausted .tmp-* removed; original config.json untouched no .tmp-* in dir; file unchanged
6 Config validity after concurrent failures Concurrent writes including forced failures config.json remains valid JSON cmdc config list --json succeeds; JSON parses
7 Web error classifies config-write race Force rename EPERM during a web call Error states config_write race, not generic web failure error message contains config_write + trace_id + retry_count
8 Serial and parallel web calls produce equivalent results Same N queries, serial vs parallel Identical result sets (modulo ranking nondeterminism) result count + top-3 URLs match
9 Scheduler concurrency safety includes adjacent lifecycle writes Concurrent web + preference/compact/model writes All lifecycle writers serialize config access no duplicate rename to same path within 100ms
10 Error includes trace_id Any config-write failure User-visible error contains trace_id error string matches /trace_id=[a-f0-9-]+/

Requested fix

  1. Serialize all writes to config.json with a process-wide write queue/mutex (the config writer must be single-writer per process).
  2. Add an inter-process lock (or advisory lock on the temp-write) if multiple cmdc processes can run concurrently.
  3. Skip saveUserConfig() when read-only web calls produce no state change (move preference/model/compact persistence out of the web tool critical path, or no-op on identical content).
  4. Add bounded retry/backoff (3 attempts, 50/100/200ms) for Windows EPERM and sharing violations on rename.
  5. Make the error path classify EPERM-as-race distinctly from ACL-denial (do not lose it in categorizeError's uo.PERMISSION bucket; emit a config_write_race category).
  6. Attach trace_id, session_id, pid, and caller to config-write failures so the boundary is recoverable from the error message.
  7. After a config-write retry succeeds, re-attempt the original web call (the web tool should not hard-fail because of a transient config race).
  8. Add the Windows concurrency regression tests above.
  9. Correct cmdc info shell detection separately (reports cmd.exe under PowerShell/WT) — tracked as a distinct issue.

Workaround (current)

Run independent web calls serially. Restarting Command Code may reduce recurrence temporarily. No permission-mode change is required or should be treated as the fix.

Related issues checked

  • #603: macOS 26 EPERM caused by shared giget cache provenance. Different platform, path, subsystem, reproduction.
  • #615: Windows command-name documentation typo. Unrelated.
  • #686: original report of the same config.json rename EPERM. This issue is the intensified diagnostic companion with inlined evidence and regression matrix.

Hướng dẫn đóng góp

Chưa lập chỉ mục được hướng dẫn đóng góp cho kho mã nguồn này

Bắt đầu từ đâu

  1. Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
  2. Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
  3. Fork repository và làm thay đổi trên một nhánh.
  4. Mở pull request có tham chiếu số hiệu của issue.

Đánh giá

Issue này chưa được đánh giá.

Nhận issue mới trong hộp thư của bạn

Bản tóm tắt ngắn những issue GitHub phù hợp với người mới.