fix: make workspace registry lock process-bound

This commit is contained in:
2026-08-03 22:42:57 +02:00
parent 553bb41138
commit e2d1117614
3 changed files with 128 additions and 67 deletions
+71 -63
View File
@@ -1,7 +1,5 @@
import { execFile } from "node:child_process";
import {
closeSync, constants, lstatSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync,
} from "node:fs";
import { execFile, spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { lstatSync, mkdirSync } from "node:fs";
import { mkdir } from "node:fs/promises";
import { basename, isAbsolute, join } from "node:path";
import { promisify } from "node:util";
@@ -228,78 +226,88 @@ export class WorkspaceRepositoryLock {
this.queue = new Promise<void>((resolve) => { releaseQueue = resolve; });
await previous;
let descriptor: number | undefined;
let holder: ChildProcessWithoutNullStreams | undefined;
const lockPath = join(this.locksPath, "repository.lock");
try {
mkdirSync(this.locksPath, { recursive: true, mode: 0o700 });
assertDirectory(this.locksPath);
} catch (error) {
throw new WorkspaceRegistryError("git_unavailable", "Workspace registry lock is unavailable");
}
try {
descriptor = this.acquire(lockPath);
} catch (error) {
throw this.lockError(error);
}
try {
try {
mkdirSync(this.locksPath, { recursive: true, mode: 0o700 });
assertDirectory(this.locksPath);
} catch (error) {
throw new WorkspaceRegistryError("git_unavailable", "Workspace registry lock is unavailable");
}
try {
holder = await this.acquire(lockPath);
} catch (error) {
throw this.lockError(error);
}
return await operation();
} finally {
if (descriptor !== undefined) closeSync(descriptor);
if (descriptor !== undefined) {
try { unlinkSync(lockPath); } catch { /* stale lock cleanup is retried by the operator */ }
}
releaseQueue();
}
}
private acquire(lockPath: string): number {
try {
return this.createProcessLock(lockPath);
} catch (error) {
if (!this.recoverDeadProcessLock(lockPath, error)) throw error;
return this.createProcessLock(lockPath);
}
}
private createProcessLock(lockPath: string): number {
const descriptor = openSync(lockPath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600);
try {
writeFileSync(descriptor, JSON.stringify({ pid: process.pid }), "utf8");
return descriptor;
} catch (error) {
closeSync(descriptor);
try { unlinkSync(lockPath); } catch { /* incomplete lock is never treated as recoverable */ }
throw error;
}
}
private recoverDeadProcessLock(lockPath: string, error: unknown): boolean {
if (!(typeof error === "object" && error !== null && "code" in error && error.code === "EEXIST")) {
return false;
}
try {
const record = JSON.parse(readFileSync(lockPath, "utf8")) as { pid?: unknown };
const pid = record.pid;
if (typeof pid !== "number" || !Number.isSafeInteger(pid) || pid <= 0 || pid === process.pid) return false;
try {
process.kill(pid, 0);
return false;
} catch (probeError) {
if (!(typeof probeError === "object" && probeError !== null && "code" in probeError && probeError.code === "ESRCH")) {
return false;
}
if (holder !== undefined) await this.release(holder);
} finally {
releaseQueue();
}
unlinkSync(lockPath);
return true;
} catch {
return false;
}
}
private async acquire(lockPath: string): Promise<ChildProcessWithoutNullStreams> {
try {
const entry = lstatSync(lockPath);
if (!entry.isFile() || entry.isSymbolicLink()) throw new Error("invalid lock path");
} catch (error) {
if (!(typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT")) {
throw error;
}
}
const holder = spawn("python3", ["-c", WorkspaceRepositoryLock.HOLDER_PROGRAM, lockPath], {
stdio: ["pipe", "pipe", "pipe"],
});
await new Promise<void>((resolve, reject) => {
let output = "";
const fail = (error: WorkspaceRegistryError) => {
holder.stdout.removeAllListeners("data");
reject(error);
};
holder.once("error", () => fail(new WorkspaceRegistryError("git_unavailable", "Workspace registry lock is unavailable")));
holder.once("exit", (code) => {
fail(new WorkspaceRegistryError(
code === 73 ? "workspace_stale" : "git_unavailable",
code === 73 ? "Workspace registry is busy" : "Workspace registry lock is unavailable",
));
});
holder.stdout.on("data", (chunk: Buffer) => {
output += chunk.toString("utf8");
if (output === "locked\n") {
holder.stdout.removeAllListeners("data");
resolve();
}
});
});
return holder;
}
private async release(holder: ChildProcessWithoutNullStreams): Promise<void> {
if (!holder.stdin.destroyed) holder.stdin.end();
await new Promise<void>((resolve) => holder.once("exit", () => resolve()));
}
private lockError(error: unknown): WorkspaceRegistryError {
if (error instanceof WorkspaceRegistryError) return error;
if (typeof error === "object" && error !== null && "code" in error && error.code === "EEXIST") {
return new WorkspaceRegistryError("workspace_stale", "Workspace registry is busy");
}
return new WorkspaceRegistryError("git_unavailable", "Workspace registry lock is unavailable");
}
private static readonly HOLDER_PROGRAM = [
"import fcntl, os, sys",
"fd = os.open(sys.argv[1], os.O_RDWR | os.O_CREAT | getattr(os, 'O_NOFOLLOW', 0), 0o600)",
"try:",
" fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)",
"except BlockingIOError:",
" sys.exit(73)",
"sys.stdout.write('locked\\n')",
"sys.stdout.flush()",
"sys.stdin.buffer.read()",
].join("\n");
}