mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-21 20:30:47 +02:00
close Windows lock handoff races
This commit is contained in:
+28
-6
@@ -116,7 +116,10 @@ export async function acquireLock(lockPath, options = {}) {
|
|||||||
const staleMs = options.staleMs ?? 120_000;
|
const staleMs = options.staleMs ?? 120_000;
|
||||||
const platform = options.platform ?? process.platform;
|
const platform = options.platform ?? process.platform;
|
||||||
const mkdir = options.mkdir ?? fs.mkdir;
|
const mkdir = options.mkdir ?? fs.mkdir;
|
||||||
|
const remove = options.rm ?? fs.rm;
|
||||||
|
const transientPermissionMs = options.transientPermissionMs ?? 1_000;
|
||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
|
let firstUnconfirmedEpermAt = null;
|
||||||
const token = randomUUID();
|
const token = randomUUID();
|
||||||
await mkdir(path.dirname(lockPath), { recursive: true, mode: 0o700 });
|
await mkdir(path.dirname(lockPath), { recursive: true, mode: 0o700 });
|
||||||
|
|
||||||
@@ -128,10 +131,27 @@ export async function acquireLock(lockPath, options = {}) {
|
|||||||
mkdirError = error;
|
mkdirError = error;
|
||||||
}
|
}
|
||||||
if (mkdirError) {
|
if (mkdirError) {
|
||||||
const contended = mkdirError?.code === "EEXIST";
|
let contended = mkdirError?.code === "EEXIST";
|
||||||
const windowsDeleteRace = platform === "win32" && mkdirError?.code === "EPERM";
|
const windowsEperm = platform === "win32" && mkdirError?.code === "EPERM";
|
||||||
if (!contended && !windowsDeleteRace) throw mkdirError;
|
if (windowsEperm) {
|
||||||
if (contended) await reapStaleLock(lockPath, staleMs, platform);
|
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) {
|
if (Date.now() - started >= timeoutMs) {
|
||||||
const timeout = new Error(`Timed out waiting for lock ${lockPath}`);
|
const timeout = new Error(`Timed out waiting for lock ${lockPath}`);
|
||||||
timeout.code = "LOCK_TIMEOUT";
|
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,
|
// Locks are leases. A stale-lock reaper may already have removed it,
|
||||||
// which readJson represents as null; other failures remain actionable.
|
// which readJson represents as null; other failures remain actionable.
|
||||||
const current = await readJson(path.join(lockPath, "owner.json"), null);
|
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) {
|
} 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;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ describe("runtime cleanup boundary", () => {
|
|||||||
const home = await temporaryHome();
|
const home = await temporaryHome();
|
||||||
const lockPath = path.join(home, "locks", "windows-race.lock");
|
const lockPath = path.join(home, "locks", "windows-race.lock");
|
||||||
let mkdirAttempts = 0;
|
let mkdirAttempts = 0;
|
||||||
|
let releaseOptions: Record<string, unknown> | undefined;
|
||||||
const release = await acquireLock(lockPath, {
|
const release = await acquireLock(lockPath, {
|
||||||
platform: "win32",
|
platform: "win32",
|
||||||
mkdir: async (target: string, options: Record<string, unknown>) => {
|
mkdir: async (target: string, options: Record<string, unknown>) => {
|
||||||
@@ -45,9 +46,25 @@ describe("runtime cleanup boundary", () => {
|
|||||||
}
|
}
|
||||||
return fs.mkdir(target, options);
|
return fs.mkdir(target, options);
|
||||||
},
|
},
|
||||||
|
rm: async (target: string, options: Record<string, unknown>) => {
|
||||||
|
releaseOptions = options;
|
||||||
|
return fs.rm(target, options);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
expect(mkdirAttempts).toBe(2);
|
expect(mkdirAttempts).toBe(2);
|
||||||
await release();
|
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<string, unknown>) => {
|
||||||
|
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;
|
let renameAttempts = 0;
|
||||||
await renameWithRetry("source", "destination", {
|
await renameWithRetry("source", "destination", {
|
||||||
|
|||||||
@@ -390,7 +390,7 @@ describe("GStack 2 managed runtime installer", () => {
|
|||||||
const originalMode = (await fs.stat(cli)).mode & 0o777;
|
const originalMode = (await fs.stat(cli)).mode & 0o777;
|
||||||
await fs.chmod(cli, originalMode === 0o600 ? 0o644 : 0o600);
|
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", 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",
|
code: "INSTALL_VALIDATION_FAILED",
|
||||||
});
|
});
|
||||||
await fs.chmod(cli, originalMode);
|
await fs.chmod(cli, originalMode);
|
||||||
|
|||||||
Reference in New Issue
Block a user