diff --git a/runtime/storage.js b/runtime/storage.js index 090cd16ad..f0684f257 100644 --- a/runtime/storage.js +++ b/runtime/storage.js @@ -116,7 +116,10 @@ export async function acquireLock(lockPath, options = {}) { const staleMs = options.staleMs ?? 120_000; const platform = options.platform ?? process.platform; const mkdir = options.mkdir ?? fs.mkdir; + const remove = options.rm ?? fs.rm; + const transientPermissionMs = options.transientPermissionMs ?? 1_000; const started = Date.now(); + let firstUnconfirmedEpermAt = null; const token = randomUUID(); await mkdir(path.dirname(lockPath), { recursive: true, mode: 0o700 }); @@ -128,10 +131,27 @@ export async function acquireLock(lockPath, options = {}) { mkdirError = error; } if (mkdirError) { - const contended = mkdirError?.code === "EEXIST"; - const windowsDeleteRace = platform === "win32" && mkdirError?.code === "EPERM"; - if (!contended && !windowsDeleteRace) throw mkdirError; - if (contended) await reapStaleLock(lockPath, staleMs, platform); + let contended = mkdirError?.code === "EEXIST"; + const windowsEperm = platform === "win32" && mkdirError?.code === "EPERM"; + if (windowsEperm) { + let observed = "unknown"; + try { + observed = (await fs.lstat(lockPath)).isDirectory() ? "directory" : "other"; + } catch (probeError) { + if (probeError?.code !== "ENOENT" && !WINDOWS_TRANSIENT_FS_ERRORS.has(probeError?.code)) throw mkdirError; + } + if (observed === "other") throw mkdirError; + contended = observed === "directory"; + if (!contended) { + firstUnconfirmedEpermAt ??= Date.now(); + if (Date.now() - firstUnconfirmedEpermAt >= transientPermissionMs) throw mkdirError; + } + } + if (!contended && !windowsEperm) throw mkdirError; + if (contended) { + firstUnconfirmedEpermAt = null; + await reapStaleLock(lockPath, staleMs, platform); + } if (Date.now() - started >= timeoutMs) { const timeout = new Error(`Timed out waiting for lock ${lockPath}`); timeout.code = "LOCK_TIMEOUT"; @@ -159,10 +179,12 @@ export async function acquireLock(lockPath, options = {}) { // Locks are leases. A stale-lock reaper may already have removed it, // which readJson represents as null; other failures remain actionable. const current = await readJson(path.join(lockPath, "owner.json"), null); - if (current?.token === token) await fs.rm(lockPath, { recursive: true, force: true }); + if (current?.token === token) { + await remove(lockPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + } }; } catch (error) { - await fs.rm(lockPath, { recursive: true, force: true }).catch(() => {}); + await remove(lockPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }).catch(() => {}); throw error; } } diff --git a/test/gstack2-runtime-cleanup-boundary.test.ts b/test/gstack2-runtime-cleanup-boundary.test.ts index 0b60bfecd..df1e0ac2b 100644 --- a/test/gstack2-runtime-cleanup-boundary.test.ts +++ b/test/gstack2-runtime-cleanup-boundary.test.ts @@ -37,6 +37,7 @@ describe("runtime cleanup boundary", () => { const home = await temporaryHome(); const lockPath = path.join(home, "locks", "windows-race.lock"); let mkdirAttempts = 0; + let releaseOptions: Record | undefined; const release = await acquireLock(lockPath, { platform: "win32", mkdir: async (target: string, options: Record) => { @@ -45,9 +46,25 @@ describe("runtime cleanup boundary", () => { } return fs.mkdir(target, options); }, + rm: async (target: string, options: Record) => { + releaseOptions = options; + return fs.rm(target, options); + }, }); expect(mkdirAttempts).toBe(2); await release(); + expect(releaseOptions).toMatchObject({ recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + + await expect(acquireLock(path.join(home, "locks", "permanently-denied.lock"), { + platform: "win32", + transientPermissionMs: 20, + mkdir: async (target: string, options: Record) => { + if (target.endsWith("permanently-denied.lock")) { + throw Object.assign(new Error("permanent permission denial"), { code: "EPERM" }); + } + return fs.mkdir(target, options); + }, + })).rejects.toMatchObject({ code: "EPERM" }); let renameAttempts = 0; await renameWithRetry("source", "destination", { diff --git a/test/gstack2-runtime-install.test.ts b/test/gstack2-runtime-install.test.ts index d8cb3c49f..d62d605a0 100644 --- a/test/gstack2-runtime-install.test.ts +++ b/test/gstack2-runtime-install.test.ts @@ -390,7 +390,7 @@ describe("GStack 2 managed runtime installer", () => { const originalMode = (await fs.stat(cli)).mode & 0o777; await fs.chmod(cli, originalMode === 0o600 ? 0o644 : 0o600); await expect(validateRuntimeBundle(result.path, { version: "2.0.0", platform: "win32" })).resolves.toBe(true); - await expect(validateRuntimeBundle(result.path, { version: "2.0.0" })).rejects.toMatchObject({ + await expect(validateRuntimeBundle(result.path, { version: "2.0.0", platform: "linux" })).rejects.toMatchObject({ code: "INSTALL_VALIDATION_FAILED", }); await fs.chmod(cli, originalMode);