From c47c2c5def0ab7a888060f0c5b1c3d4011accb60 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 6 May 2026 23:48:31 -0400 Subject: [PATCH] fix(build-hooks): handle Windows EPERM/EBUSY on rename, fall back to copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POSIX rename(2) atomically replaces dest even when readers hold open handles. Windows MoveFileEx (which fs.renameSync uses with MOVEFILE_REPLACE_EXISTING) cannot — it throws EPERM/EBUSY when another process has the destination open. Concurrent install.js readers and antivirus scanners are realistic triggers; both release within ms. renameAtomicWithRetry() preserves the bare renameSync call on POSIX (no overhead) and on Windows retries up to 4 times with 10/30/90/270ms backoff, then falls back to copyFileSync + unlinkSync. If even copy fails because dest is hard-locked, log a non-fatal warning and leave the prior dest in place — a subsequent build retries from a fresh state. The build no longer crashes on Windows transient locking. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/build-hooks.js | 57 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/scripts/build-hooks.js b/scripts/build-hooks.js index 3cdcf9864..2b1b5451a 100644 --- a/scripts/build-hooks.js +++ b/scripts/build-hooks.js @@ -36,6 +36,61 @@ const HOOKS_TO_COPY = [ 'gsd-phase-boundary.sh' ]; +// Sync millisecond sleep using Atomics.wait on a throwaway SharedArrayBuffer. +// Used between Windows rename retries; this script is sync end-to-end so +// setTimeout would not work. Total worst-case backoff across MAX_ATTEMPTS +// is bounded (~400ms) — acceptable for a one-shot build script. +function sleepSync(ms) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +/** + * Atomic-replace via fs.renameSync, with Windows-only retry and fallback. + * + * POSIX rename(2) atomically replaces dest even when readers hold open + * handles on it. Windows MoveFileEx (which fs.renameSync uses with + * MOVEFILE_REPLACE_EXISTING) cannot — it throws EPERM/EBUSY when another + * process has the destination open. Concurrent install.js readers and + * antivirus scanners are the realistic triggers; both release handles + * within milliseconds, so a short backoff resolves the race. After + * retries are exhausted, fall back to copy-then-unlink (re-introduces + * the truncate-then-write race for this single file but keeps the build + * moving rather than crashing). If even copy fails because dest is hard- + * locked, log a non-fatal warning and leave the prior dest in place — a + * subsequent build invocation will retry from a fresh state. + */ +function renameAtomicWithRetry(stagedDest, dest, hook) { + if (process.platform !== 'win32') { + fs.renameSync(stagedDest, dest); + return; + } + const BACKOFFS_MS = [10, 30, 90, 270]; + for (let attempt = 0; attempt <= BACKOFFS_MS.length; attempt++) { + try { + fs.renameSync(stagedDest, dest); + return; + } catch (e) { + const transient = e && (e.code === 'EPERM' || e.code === 'EBUSY'); + if (!transient) throw e; + if (attempt < BACKOFFS_MS.length) { + sleepSync(BACKOFFS_MS[attempt]); + continue; + } + // Retries exhausted; fall back to copy-then-unlink. + try { + fs.copyFileSync(stagedDest, dest); + try { fs.unlinkSync(stagedDest); } catch (_) { /* tolerate */ } + console.warn(`\x1b[33m! ${hook}: rename failed (${e.code}) after ${BACKOFFS_MS.length} retries; used copy-fallback\x1b[0m`); + return; + } catch (fallbackErr) { + try { fs.unlinkSync(stagedDest); } catch (_) { /* tolerate */ } + console.warn(`\x1b[33m! ${hook}: rename + copy fallback both failed (${e.code} → ${fallbackErr.code || fallbackErr.message}); leaving prior dest in place\x1b[0m`); + return; + } + } + } +} + /** * Validate JavaScript syntax without executing the file. * Catches SyntaxError (duplicate const, missing brackets, etc.) @@ -106,7 +161,7 @@ function build() { if (hook.endsWith('.sh')) { try { fs.chmodSync(stagedDest, 0o755); } catch (e) { /* Windows */ } } - fs.renameSync(stagedDest, dest); + renameAtomicWithRetry(stagedDest, dest, hook); } // Best-effort cleanup of the staging dir. If concurrent builders are still