From b0d73dc94f124dd5f356aa07250ce568b79fd2c7 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 6 May 2026 23:16:15 -0700 Subject: [PATCH] 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...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 --- .../claude-accounts/runtime-auth-service.ts | 21 +++++++++++++- src/main/codex-accounts/fs-utils.ts | 28 ++++++++++++------- 2 files changed, 38 insertions(+), 11 deletions(-) diff --git a/src/main/claude-accounts/runtime-auth-service.ts b/src/main/claude-accounts/runtime-auth-service.ts index 3c9f32c3648..898d77cf1df 100644 --- a/src/main/claude-accounts/runtime-auth-service.ts +++ b/src/main/claude-accounts/runtime-auth-service.ts @@ -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 { diff --git a/src/main/codex-accounts/fs-utils.ts b/src/main/codex-accounts/fs-utils.ts index 407176357c4..4fdf225d1ae 100644 --- a/src/main/codex-accounts/fs-utils.ts +++ b/src/main/codex-accounts/fs-utils.ts @@ -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) +}