fix: harden workspace registry refresh and snapshots

This commit is contained in:
2026-08-03 22:33:39 +02:00
parent 2087fbb0c9
commit 553bb41138
4 changed files with 312 additions and 42 deletions
+89 -14
View File
@@ -1,6 +1,8 @@
import { execFile } from "node:child_process";
import { constants, lstatSync, mkdirSync, openSync, closeSync, unlinkSync } from "node:fs";
import { access, lstat, mkdir } from "node:fs/promises";
import {
closeSync, constants, lstatSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync,
} from "node:fs";
import { mkdir } from "node:fs/promises";
import { basename, isAbsolute, join } from "node:path";
import { promisify } from "node:util";
import type { WorkspaceErrorCode, WorkspaceRegistryConfig } from "./types.js";
@@ -83,9 +85,14 @@ export class GitWorkspaceRepository {
}
async ensureLayout(): Promise<void> {
for (const path of [this.root, this.snapshotsPath, this.statePath, this.locksPath, this.hooksPath]) {
await mkdir(path, { recursive: true, mode: 0o700 });
assertDirectory(path);
try {
for (const path of [this.root, this.snapshotsPath, this.statePath, this.locksPath, this.hooksPath]) {
await mkdir(path, { recursive: true, mode: 0o700 });
assertDirectory(path);
}
} catch (error) {
if (error instanceof WorkspaceRegistryError) throw error;
throw new WorkspaceRegistryError("git_unavailable", "Workspace registry storage is unavailable");
}
}
@@ -162,11 +169,25 @@ export class GitWorkspaceRepository {
}
private async refresh(): Promise<void> {
if ((await this.git(["status", "--porcelain"])).trim() !== "") {
throw new WorkspaceRegistryError("workspace_stale", "Workspace checkout has local changes");
}
if (this.config.remoteUrl) {
await this.git(["remote", "set-url", "origin", "--", this.config.remoteUrl]);
}
await this.git(["fetch", "--no-tags", "origin", this.config.branch]);
await this.git(["merge", "--ff-only", "FETCH_HEAD"]);
const remoteHead = (await this.git(["rev-parse", "FETCH_HEAD"])).trim();
const localHead = (await this.git(["rev-parse", "HEAD"])).trim();
if (localHead !== remoteHead) {
const commonAncestor = (await this.git(["merge-base", "HEAD", "FETCH_HEAD"])).trim();
if (commonAncestor !== localHead) {
throw new WorkspaceRegistryError("git_non_fast_forward", "Workspace checkout diverged from remote");
}
await this.git(["merge", "--ff-only", "FETCH_HEAD"]);
}
if ((await this.git(["rev-parse", "HEAD"])).trim() !== remoteHead) {
throw new WorkspaceRegistryError("git_non_fast_forward", "Workspace checkout does not match remote");
}
}
private async git(args: string[]): Promise<string> {
@@ -207,18 +228,21 @@ export class WorkspaceRepositoryLock {
this.queue = new Promise<void>((resolve) => { releaseQueue = resolve; });
await previous;
mkdirSync(this.locksPath, { recursive: true, mode: 0o700 });
assertDirectory(this.locksPath);
let descriptor: number | undefined;
const lockPath = join(this.locksPath, "repository.lock");
try {
descriptor = openSync(lockPath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600);
return await operation();
mkdirSync(this.locksPath, { recursive: true, mode: 0o700 });
assertDirectory(this.locksPath);
} catch (error) {
if (typeof error === "object" && error !== null && "code" in error && error.code === "EEXIST") {
throw new WorkspaceRegistryError("workspace_stale", "Workspace registry is busy");
}
throw error;
throw new WorkspaceRegistryError("git_unavailable", "Workspace registry lock is unavailable");
}
try {
descriptor = this.acquire(lockPath);
} catch (error) {
throw this.lockError(error);
}
try {
return await operation();
} finally {
if (descriptor !== undefined) closeSync(descriptor);
if (descriptor !== undefined) {
@@ -227,4 +251,55 @@ export class WorkspaceRepositoryLock {
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;
}
}
unlinkSync(lockPath);
return true;
} catch {
return false;
}
}
private lockError(error: unknown): WorkspaceRegistryError {
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");
}
}