diff --git a/scripts/gentle-ai-installer.mjs b/scripts/gentle-ai-installer.mjs index c696acf63..f97453a8f 100644 --- a/scripts/gentle-ai-installer.mjs +++ b/scripts/gentle-ai-installer.mjs @@ -558,6 +558,40 @@ async function backupBundlePaths(runtimeRoot) { return backups; } +// On Windows, publishing the freshly built bundle races a transient handle +// lock on the built binary: the installer executes it right before publishing +// (`assertExactGentleAiVersion`), and a directory rename can return +// ERROR_ACCESS_DENIED (node EPERM) while the image handle of the just-exited +// process is still being released (scanner activity can contribute on a brand +// new unsigned binary). The window is short (measured ~0.2-1.5 s) but the +// publish rename is single-shot, so the whole update fails deterministically +// on Windows. A bounded retry overrides that window; the bundle's integrity +// manifest still guards the end state, and non-transient errors fail through +// immediately (they report a real defect, not a race). +const TRANSIENT_RENAME_ERROR_CODES = new Set(["EPERM", "EBUSY", "EACCES"]); +const DEFAULT_RENAME_RETRY_ATTEMPTS = 5; +const DEFAULT_RENAME_RETRY_BASE_DELAY_MS = 200; + +async function renameWithTransientRetry(from, to, options = {}) { + const attempts = options.attempts ?? DEFAULT_RENAME_RETRY_ATTEMPTS; + const baseDelayMs = options.baseDelayMs ?? DEFAULT_RENAME_RETRY_BASE_DELAY_MS; + const underlyingRename = options.rename ?? rename; + const sleep = options.sleep ?? defaultSleep; + let lastError; + for (let attempt = 1; attempt <= attempts; attempt++) { + try { + await underlyingRename(from, to); + return; + } catch (error) { + lastError = error; + const code = error && typeof error === "object" ? error.code : undefined; + if (!TRANSIENT_RENAME_ERROR_CODES.has(code)) throw error; + if (attempt < attempts) await sleep(baseDelayMs * attempt); + } + } + throw lastError; +} + async function recoverInterruptedPublication(runtimeRoot, bundleIsValid, options) { const backups = await backupBundlePaths(runtimeRoot); if (backups.length === 0) return; @@ -567,7 +601,7 @@ async function recoverInterruptedPublication(runtimeRoot, bundleIsValid, options const live = versionBundlePath(runtimeRoot); const liveExists = await realBundleDirectory(live, runtimeRoot, "live bundle"); if (!liveExists) { - try { await (options.rename ?? rename)(backup, live); } + try { await (options.rename ?? renameWithTransientRetry)(backup, live); } catch (error) { throw bundleRecoveryError(runtimeRoot, `could not restore valid backup ${backup}: ${error instanceof Error ? error.message : String(error)}`); } return; } @@ -585,7 +619,7 @@ async function cleanupStaleStagingBundles(runtimeRoot) { } async function publishBundle(runtimeRoot, stagingDirectory, options) { - const versionDirectory = join(runtimeRoot, `v${INSTALLER_VERSION}`), renameFile = options.rename ?? rename; + const versionDirectory = join(runtimeRoot, `v${INSTALLER_VERSION}`), renameFile = options.rename ?? renameWithTransientRetry; const backupDirectory = join(runtimeRoot, `.v${INSTALLER_VERSION}.backup-${process.pid}-${Date.now()}`); let movedPrior = false; try { @@ -650,7 +684,11 @@ async function installWindowsGentleAiFromGoSumdb(options, packageRoot, architect }); } -async function installSignedRelease(options, packageRoot, platform, arch, asset) { +async function defaultSleep(milliseconds) { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +function installSignedRelease(options, packageRoot, platform, arch, asset) { return withInstallLock(packageRoot, options, async (runtimeRoot) => { await recoverInterruptedPublication(runtimeRoot, (directory) => existingSignedBundleMatches(directory, asset, platform), options); await cleanupStaleStagingBundles(runtimeRoot); @@ -681,6 +719,13 @@ async function installSignedRelease(options, packageRoot, platform, arch, asset) }); } +export { + DEFAULT_RENAME_RETRY_ATTEMPTS, + DEFAULT_RENAME_RETRY_BASE_DELAY_MS, + TRANSIENT_RENAME_ERROR_CODES, + renameWithTransientRetry, +}; + export async function installGentleAi(options = {}) { const packageRoot = options.packageRoot ?? resolveGentleAiInstallerPackageRoot(); const platform = options.platform ?? process.platform, arch = options.arch ?? process.arch; diff --git a/tests/gentle-ai-installer.test.ts b/tests/gentle-ai-installer.test.ts index 1acc59a48..d9e421dd6 100644 --- a/tests/gentle-ai-installer.test.ts +++ b/tests/gentle-ai-installer.test.ts @@ -15,6 +15,7 @@ import { downloadGentleAiAsset, gentleAiAssetForm, installGentleAi, + renameWithTransientRetry, resolveGentleAiInstallerPackageRoot, resolveGentleAiReleaseAsset, trustedSystemExtractor, @@ -581,6 +582,53 @@ test("Windows concurrent installs fail closed until normal release, then reuse t assert.equal(fixture.calls.filter((call) => call.file === fixture.goPath && call.arguments_[0] === "install").length, 1); }); +test("bundle publication retries transient Windows handle locks and succeeds within budget", async () => { + let attempts = 0; + const flakyRename = async () => { + attempts += 1; + if (attempts < 3) { + const error = new Error("simulated transient handle lock"); + error.code = "EPERM"; + throw error; + } + return undefined; + }; + await assert.doesNotReject(() => + renameWithTransientRetry("from", "to", { rename: flakyRename, attempts: 5, baseDelayMs: 1, sleep: () => Promise.resolve() }), + ); + assert.equal(attempts, 3); +}); + +test("bundle publication fails closed when the transient lock outlasts the retry budget", async () => { + let attempts = 0; + const alwaysLocked = async () => { + attempts += 1; + const error = new Error("simulated persistent handle lock"); + error.code = "EBUSY"; + throw error; + }; + await assert.rejects( + () => renameWithTransientRetry("from", "to", { rename: alwaysLocked, attempts: 3, baseDelayMs: 1, sleep: () => Promise.resolve() }), + /simulated persistent handle lock/, + ); + assert.equal(attempts, 3); +}); + +test("bundle publication never retries non-transient rename errors", async () => { + let attempts = 0; + const invalidPath = async () => { + attempts += 1; + const error = new Error("simulated invalid argument"); + error.code = "EINVAL"; + throw error; + }; + await assert.rejects( + () => renameWithTransientRetry("from", "to", { rename: invalidPath, attempts: 5, baseDelayMs: 1, sleep: () => Promise.resolve() }), + /simulated invalid argument/, + ); + assert.equal(attempts, 1); +}); + test("Windows source publication rolls back a prior bundle when final directory swap fails", async () => { const packageRoot = await mkdtemp(join(tmpdir(), "gentle-pi-installer-rollback-")); const versionDirectory = join(packageRoot, ".gentle-ai", "v3.4.0");