mirror of
https://github.com/stablyai/orca.git
synced 2026-10-12 16:03:03 +00:00
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:
@@ -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> {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user