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) +}