diff --git a/scripts/lib/session-bridge.js b/scripts/lib/session-bridge.js index b50216fc..19033d63 100644 --- a/scripts/lib/session-bridge.js +++ b/scripts/lib/session-bridge.js @@ -76,13 +76,58 @@ function writeBridgeAtomic(sessionId, data) { const tmp = `${target}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`; fs.writeFileSync(tmp, JSON.stringify(data), 'utf8'); try { - fs.renameSync(tmp, target); + renameWithRetry(tmp, target); } catch (err) { try { fs.unlinkSync(tmp); } catch { /* ignore */ } throw err; } } +/** + * Replace a file via rename, retrying briefly on transient OS-level errors. + * + * POSIX `rename(2)` is atomic between source and destination, so concurrent + * writers each rename onto the same target without conflict. Windows + * `MoveFileExW` is different: it fails with EPERM/EACCES/EBUSY if the + * target is currently being renamed by *another* process — a short race + * window that fires reliably under our PostToolUse + statusline concurrency. + * + * To stay portable, retry up to 5 times with exponential backoff (20 ms, + * 40, 80, 160, 320) on the Windows-only transient codes. POSIX runs hit + * the first try and exit immediately. Other error codes (ENOENT, ENOSPC, + * EROFS, …) re-throw without retry — they are not transient. + * + * Sleep uses `Atomics.wait` on a throwaway SharedArrayBuffer so the + * retry path does not busy-spin the CPU. This works on the main thread + * in Node ≥ 17 (and on workers in earlier versions). + * + * @param {string} tmp + * @param {string} target + */ +function renameWithRetry(tmp, target) { + const RETRY_CODES = new Set(['EPERM', 'EACCES', 'EBUSY']); + const MAX_ATTEMPTS = 5; + for (let attempt = 0; ; attempt++) { + try { + fs.renameSync(tmp, target); + return; + } catch (err) { + if (attempt + 1 >= MAX_ATTEMPTS || !RETRY_CODES.has(err.code)) { + throw err; + } + const delayMs = 20 << attempt; + try { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delayMs); + } catch { + // Atomics.wait throws on the main thread in some older runtimes; + // fall back to a brief busy-wait so the retry path still has a delay. + const until = Date.now() + delayMs; + while (Date.now() < until) { /* spin */ } + } + } + } +} + /** * Resolve session ID from environment variables. * @returns {string|null} Sanitized session ID or null @@ -97,6 +142,7 @@ module.exports = { getBridgePath, readBridge, writeBridgeAtomic, + renameWithRetry, resolveSessionId, MAX_SESSION_ID_LENGTH };