import { randomUUID } from "node:crypto"; import { chmod, mkdir, open, readFile, rename, rm, stat, writeFile } from "node:fs/promises"; import { basename, dirname, join } from "node:path"; export type FileLockOptions = { timeoutMs?: number; retryDelayMs?: number; staleMs?: number; now?: () => number; /** Optional observability seam; called after an exclusive-acquire collision. */ onContention?: () => void; }; type LockRecord = { token: string; pid: number; acquiredAt: number }; const DEFAULT_TIMEOUT_MS = 1_000; const DEFAULT_RETRY_DELAY_MS = 20; const DEFAULT_STALE_MS = 30_000; export class FileLockTimeoutError extends Error { constructor(path: string, timeoutMs: number) { super(`Timed out waiting ${timeoutMs}ms for lock: ${path}`); this.name = "FileLockTimeoutError"; } } const pause = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); async function ownerOnly(path: string): Promise { if (process.platform !== "win32") await chmod(path, 0o600); } /** mkdir's mode applies only to directories created by this call; existing parents retain their permissions. */ async function makeParent(path: string): Promise { await mkdir(dirname(path), { recursive: true, mode: 0o700 }); } function lockRecord(token: string, now: () => number): LockRecord { return { token, pid: process.pid, acquiredAt: now() }; } function parseLockRecord(text: string): LockRecord | undefined { try { const parsed: unknown = JSON.parse(text); if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined; const { token, pid, acquiredAt } = parsed as Partial; if (typeof token !== "string" || token.length === 0 || typeof pid !== "number" || !Number.isSafeInteger(pid) || pid <= 0 || typeof acquiredAt !== "number" || !Number.isFinite(acquiredAt)) return undefined; return { token, pid, acquiredAt }; } catch { return undefined; } } /** Only ESRCH proves this local process no longer owns a lock; every other outcome fails closed. */ function isDefinitelyDead(pid: number): boolean { try { process.kill(pid, 0); return false; } catch (error) { return error instanceof Error && (error as NodeJS.ErrnoException).code === "ESRCH"; } } async function ownsLock(path: string, token: string): Promise { try { return parseLockRecord(await readFile(path, "utf8"))?.token === token; } catch (error) { if (isMissing(error)) return false; throw error; } } async function acquireExclusive(path: string, token: string, now: () => number): Promise { try { const handle = await open(path, "wx", 0o600); try { await handle.writeFile(JSON.stringify(lockRecord(token, now))); if (process.platform !== "win32") await handle.chmod(0o600); } finally { await handle.close(); } return true; } catch (error) { if (isAlreadyExists(error)) return false; throw error; } } /** * Run a short critical section that serializes stale-lock recovery and normal * release. It deliberately never reclaims its own guard: a crashed recovery * process fails closed (later calls time out) rather than deleting a live * successor lock. A future hardening milestone can add a platform-specific recovery policy if needed. */ async function withRecoveryGuard( lockPath: string, fn: () => Promise, options: Required, ): Promise { const guardPath = `${lockPath}.recovery`; const token = randomUUID(); const started = options.now(); while (!(await acquireExclusive(guardPath, token, options.now))) { if (options.now() - started >= options.timeoutMs) { throw new FileLockTimeoutError(guardPath, options.timeoutMs); } await pause(options.retryDelayMs); } let callbackFailed = false; try { return await fn(); } catch (error) { callbackFailed = true; throw error; } finally { // While the recovery guard exists, no other recovery/release operation can // replace the primary lock. Deleting this guard is therefore safe only when // its token still matches; a future recovery protocol must preserve this. try { if (await ownsLock(guardPath, token)) await rm(guardPath); } catch (releaseError) { if (!callbackFailed) throw releaseError; } } } /** * Claim a stale primary lock only after its local owner is definitely gone, * then remove only that claimed inode. Malformed metadata, a live PID, and * liveness checks that cannot prove ESRCH all fail closed. */ async function recoverStaleLock(lockPath: string, options: Required): Promise { return withRecoveryGuard(lockPath, async () => { let age: number; let record: LockRecord | undefined; try { const [info, text] = await Promise.all([stat(lockPath), readFile(lockPath, "utf8")]); age = options.now() - info.mtimeMs; record = parseLockRecord(text); } catch (error) { if (isMissing(error)) return false; throw error; } if (age < options.staleMs || !record || !isDefinitelyDead(record.pid)) return false; // The primary lock still occupies lockPath while the recovery guard is // held, so a normal acquirer cannot create a successor before this rename. // Only the claimant removes the unique renamed file afterwards. const claimed = `${lockPath}.stale.${randomUUID()}`; try { await rename(lockPath, claimed); } catch (error) { if (isMissing(error)) return false; throw error; } await rm(claimed, { force: true }); return true; }, options); } async function releaseOwnedLock(lockPath: string, token: string, options: Required): Promise { await withRecoveryGuard(lockPath, async () => { if (await ownsLock(lockPath, token)) await rm(lockPath); }, options); } /** * Acquire an exclusive sibling lock using `open(..., "wx")`. Stale recovery * claims only an old lock with a definitely-dead local PID under a separate * recovery guard, so concurrent recoverers cannot delete a successor lock. */ export async function withFileLock( path: string, fn: () => Promise, options: FileLockOptions = {}, ): Promise { const resolved: Required = { timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS, retryDelayMs: options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS, staleMs: options.staleMs ?? DEFAULT_STALE_MS, now: options.now ?? Date.now, onContention: options.onContention ?? (() => {}), }; const lockPath = `${path}.lock`; const started = resolved.now(); const token = randomUUID(); await makeParent(path); while (!(await acquireExclusive(lockPath, token, resolved.now))) { try { resolved.onContention(); } catch { // Observability must not weaken the locking guarantee. } await recoverStaleLock(lockPath, resolved); if (resolved.now() - started >= resolved.timeoutMs) { throw new FileLockTimeoutError(lockPath, resolved.timeoutMs); } await pause(resolved.retryDelayMs); } let callbackFailed = false; let callbackError: unknown; let value!: T; try { value = await fn(); } catch (error) { callbackFailed = true; callbackError = error; } try { await releaseOwnedLock(lockPath, token, resolved); } catch (releaseError) { if (!callbackFailed) throw releaseError; } if (callbackFailed) throw callbackError; return value; } function isAlreadyExists(error: unknown): boolean { return error instanceof Error && (error as NodeJS.ErrnoException).code === "EEXIST"; } function isMissing(error: unknown): boolean { return error instanceof Error && (error as NodeJS.ErrnoException).code === "ENOENT"; } export type JsonTransactionOptions = FileLockOptions & { initial: () => T; update: (latest: T) => T | Promise; parse?: (text: string) => T; stringify?: (value: T) => string; }; /** Reload-under-lock JSON transaction primitive backing scoped store transactions. */ export async function updateJsonFile(path: string, options: JsonTransactionOptions): Promise { const parse = options.parse ?? ((text: string) => JSON.parse(text) as T); const stringify = options.stringify ?? ((value: T) => JSON.stringify(value)); return withFileLock(path, async () => { let current = options.initial(); try { current = parse(await readFile(path, "utf8")); } catch (error) { if (!isMissing(error)) throw error; } const next = await options.update(current); await atomicWriteText(path, stringify(next)); return next; }, options); } /** Atomic replacement with a unique temp path; no process shares a temp name. */ export async function atomicWriteText(path: string, text: string): Promise { await makeParent(path); const temp = join(dirname(path), `.${basename(path)}.${randomUUID()}.tmp`); try { await writeFile(temp, text, { mode: 0o600, flag: "wx" }); await ownerOnly(temp); await rename(temp, path); await ownerOnly(path); } finally { await rm(temp, { force: true }); } }