mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
fix(studio-server): a history claim on Windows waits out a lock file being deleted (#5923)
* fix(studio-server): a history claim on Windows waits out a lock file being deleted * style(studio-server): drop the two history-lock comments, names carry them * style(studio-server): keep the history-lock test comment under 120 columns
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
import { chmodSync, existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { HistoryBusyError, takeHistoryOwnership } from "./ownerLock";
|
||||
|
||||
const platform = process.platform;
|
||||
const homes: string[] = [];
|
||||
// Root reads a mode-000 file anyway, so the refused read below needs an ordinary user.
|
||||
const canRefuseRead = process.platform !== "win32" && process.getuid?.() !== 0;
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, "platform", { value: platform });
|
||||
for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** A home whose owner.pid a live process holds, refuses reads, and vanishes after `ms`, as Windows does mid-delete. */
|
||||
function deletingOwner(ms: number): string {
|
||||
const home = mkdtempSync(join(tmpdir(), "hf-owner-lock-"));
|
||||
homes.push(home);
|
||||
const lock = join(home, "owner.pid");
|
||||
writeFileSync(lock, String(process.ppid));
|
||||
chmodSync(lock, 0o000);
|
||||
setTimeout(() => rmSync(lock, { force: true }), ms).unref();
|
||||
return home;
|
||||
}
|
||||
|
||||
describe.skipIf(!canRefuseRead)("takeHistoryOwnership", () => {
|
||||
it("on Windows, waits out an owner.pid it cannot read while another process deletes it", async () => {
|
||||
Object.defineProperty(process, "platform", { value: "win32" });
|
||||
const home = deletingOwner(200);
|
||||
const release = await takeHistoryOwnership(home, 5_000);
|
||||
expect(existsSync(join(home, "owner.pid"))).toBe(true);
|
||||
release();
|
||||
});
|
||||
|
||||
it("still fails at once on an unreadable lock outside Windows", async () => {
|
||||
await expect(takeHistoryOwnership(deletingOwner(200), 5_000)).rejects.toThrow(/EACCES/);
|
||||
});
|
||||
|
||||
it("still gives up at the deadline while the lock stays unreadable", async () => {
|
||||
Object.defineProperty(process, "platform", { value: "win32" });
|
||||
await expect(takeHistoryOwnership(deletingOwner(60_000), 300)).rejects.toThrow(/EACCES/);
|
||||
});
|
||||
|
||||
it("still gives up at the deadline while a live owner holds the lock", async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), "hf-owner-lock-"));
|
||||
homes.push(home);
|
||||
writeFileSync(join(home, "owner.pid"), String(process.ppid));
|
||||
await expect(takeHistoryOwnership(home, 100)).rejects.toThrow(HistoryBusyError);
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,14 @@ export class HistoryBusyError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
const WINDOWS_BUSY = new Set(["EPERM", "EBUSY", "EACCES"]);
|
||||
|
||||
function busyOnWindows(error: unknown): boolean {
|
||||
return (
|
||||
process.platform === "win32" && WINDOWS_BUSY.has((error as NodeJS.ErrnoException).code ?? "")
|
||||
);
|
||||
}
|
||||
|
||||
function alive(pid: number): boolean {
|
||||
if (!Number.isInteger(pid) || pid <= 0) return false;
|
||||
try {
|
||||
@@ -92,6 +100,17 @@ function servedBy(file: string, urlFile: string, pid: number): string | undefine
|
||||
}
|
||||
}
|
||||
|
||||
type Attempt = "claimed" | "retry" | "wait";
|
||||
|
||||
function attemptClaim(file: string, urlFile: string, deadline: number): Attempt {
|
||||
if (claim(file)) return "claimed";
|
||||
const pid = ownerOf(file);
|
||||
if (pid === null) return "retry";
|
||||
if (!alive(pid) && evictDeadOwner(file)) return "retry";
|
||||
if (Date.now() >= deadline) throw new HistoryBusyError(pid, servedBy(file, urlFile, pid));
|
||||
return "wait";
|
||||
}
|
||||
|
||||
/**
|
||||
* One process at a time keeps a project's history open (a second opener would fork the log). Waits up to `waitMs`
|
||||
* for the owner to close, takes over a dead owner's lock. `url` is where this process serves the history's routes.
|
||||
@@ -106,19 +125,20 @@ export async function takeHistoryOwnership(
|
||||
const deadline = Date.now() + waitMs;
|
||||
mkdirSync(home, { recursive: true });
|
||||
for (;;) {
|
||||
if (claim(file)) {
|
||||
advertise(urlFile, url);
|
||||
let held = true;
|
||||
return () => {
|
||||
if (held && servedBy(file, urlFile, process.pid)) rmSync(urlFile, { force: true });
|
||||
if (held) releaseOwn(file);
|
||||
held = false;
|
||||
};
|
||||
let next: Attempt = "wait";
|
||||
try {
|
||||
next = attemptClaim(file, urlFile, deadline);
|
||||
} catch (error) {
|
||||
if (!busyOnWindows(error) || Date.now() >= deadline) throw error;
|
||||
}
|
||||
const pid = ownerOf(file);
|
||||
if (pid === null) continue;
|
||||
if (!alive(pid) && evictDeadOwner(file)) continue;
|
||||
if (Date.now() >= deadline) throw new HistoryBusyError(pid, servedBy(file, urlFile, pid));
|
||||
await new Promise((settle) => setTimeout(settle, 50));
|
||||
if (next === "claimed") break;
|
||||
if (next === "wait") await new Promise((settle) => setTimeout(settle, 50));
|
||||
}
|
||||
advertise(urlFile, url);
|
||||
let held = true;
|
||||
return () => {
|
||||
if (held && servedBy(file, urlFile, process.pid)) rmSync(urlFile, { force: true });
|
||||
if (held) releaseOwn(file);
|
||||
held = false;
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user