fix(claude): skip redundant credential rewrites and use real sleep on rename retry

Closes #1507

On Windows, starting a worktree could fail with:
  Error invoking remote method 'pty:spawn': EPERM: operation not permitted,
  rename '...\.credentials.json.<pid>.<uuid>.tmp' -> '...\.credentials.json'

Root cause: every Claude pty spawn calls prepareForClaudeLaunch ->
syncForCurrentSelection, which unconditionally atomically rewrites
~/.claude/.credentials.json (and the oauthAccount field of ~/.claude.json).
On Windows the rename step intermittently fails with EPERM/EBUSY when a
sibling Claude CLI process or AV briefly holds the destination open. The
existing retry (3 attempts, ~150 ms total, busy-wait) is both too short
and pegs a CPU core on the Electron main thread.

Fixes:

  1. writeRuntimeCredentials / writeJson now no-op when the target content
     is already identical (in-memory check first, on-disk check as fallback
     to handle the first sync after app restart). The credentials file
     only actually changes on token refresh / account switch, so the hot
     path no longer touches disk and the contention window disappears.

  2. renameWithRetry now retries 6 times on Windows (~750 ms total),
     also handles EBUSY, and uses Atomics.wait on a SharedArrayBuffer for
     a real OS-level thread sleep instead of a Date.now() spin loop \u2014 so
     IPC and the renderer aren't frozen during backoff.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-05-06 23:16:15 -07:00
co-authored by Orca
parent f441ee8f3d
commit b0d73dc94f
2 changed files with 38 additions and 11 deletions
@@ -312,15 +312,34 @@ export class ClaudeRuntimeAuthService {
}
private writeRuntimeCredentials(contents: string): void {
// Why: every Claude pty spawn syncs auth, but credentials rarely change.
// Skipping the atomic rewrite when contents are unchanged avoids EPERM
// contention on Windows when a sibling Claude CLI or AV briefly holds
// .credentials.json open (issue #1507). The in-memory check covers the
// hot path; the on-disk check covers the first spawn after app restart.
if (this.lastWrittenCredentialsJson === contents) {
return
}
const credentialsPath = this.pathResolver.getRuntimePaths().credentialsPath
mkdirSync(dirname(credentialsPath), { recursive: true })
if (existsSync(credentialsPath) && readFileSync(credentialsPath, 'utf-8') === contents) {
this.lastWrittenCredentialsJson = contents
return
}
writeFileAtomically(credentialsPath, contents, { mode: 0o600 })
this.lastWrittenCredentialsJson = contents
}
private writeJson(targetPath: string, value: unknown): void {
const serialized = `${JSON.stringify(value, null, 2)}\n`
mkdirSync(dirname(targetPath), { recursive: true })
writeFileAtomically(targetPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 })
// Why: same Windows contention reasoning as writeRuntimeCredentials —
// skip the atomic rewrite when the on-disk content already matches
// (issue #1507).
if (existsSync(targetPath) && readFileSync(targetPath, 'utf-8') === serialized) {
return
}
writeFileAtomically(targetPath, serialized, { mode: 0o600 })
}
private readJsonObject(targetPath: string): Record<string, unknown> {
+18 -10
View File
@@ -39,26 +39,34 @@ export function writeFileAtomically(
}
}
// Why: on Windows, renameSync can fail with EPERM/EACCES if another process
// (antivirus, Codex CLI) holds the target file open. A short retry avoids
// transient failures without masking real permission errors.
// Why: on Windows, renameSync can fail with EPERM/EACCES/EBUSY if another
// process (antivirus, Claude CLI, Codex CLI) holds the target file open.
// A short retry avoids transient failures without masking real permission
// errors. Total backoff (~750ms) covers typical AV scan windows seen in
// issue #1507.
function renameWithRetry(source: string, target: string): void {
const maxAttempts = process.platform === 'win32' ? 3 : 1
const maxAttempts = process.platform === 'win32' ? 6 : 1
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
renameSync(source, target)
return
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
if (attempt < maxAttempts && (code === 'EPERM' || code === 'EACCES')) {
const delayMs = attempt * 50
const until = Date.now() + delayMs
while (Date.now() < until) {
/* busy-wait: setTimeout is async and callers must stay sync */
}
if (attempt < maxAttempts && (code === 'EPERM' || code === 'EACCES' || code === 'EBUSY')) {
sleepSync(attempt * 50)
continue
}
throw error
}
}
}
// Why: writeFileAtomically is a sync API called from sync code paths, but we
// need to actually yield the Electron main thread during retry backoff so
// IPC and the renderer aren't frozen. Atomics.wait on a private
// SharedArrayBuffer is a real OS-level sleep — unlike a Date.now() spin loop,
// which pegs a CPU core and starves the event loop.
const sleepBuffer = new Int32Array(new SharedArrayBuffer(4))
function sleepSync(ms: number): void {
Atomics.wait(sleepBuffer, 0, 0, ms)
}