import { randomUUID } from "node:crypto";
import * as fs from "node:fs/promises";
import { isEnoent, logger } from "@oh-my-pi/pi-utils";
export interface FileLockOptions {
staleMs?: number;
retries?: number;
retryDelayMs?: number;
}
const DEFAULT_OPTIONS: Required<FileLockOptions> = {
staleMs: 10_000,
retries: 50,
retryDelayMs: 100,
};
interface LockInfo {
pid: number;
timestamp: number;
token: string;
}
function getLockPath(filePath: string): string {
return `${filePath}.lock`;
}
async function writeLockInfo(lockPath: string, token: string): Promise<void> {
const info: LockInfo = { pid: process.pid, timestamp: Date.now(), token };
await Bun.write(`${lockPath}/info`, JSON.stringify(info));
}
async function readLockInfo(lockPath: string): Promise<LockInfo | null> {
try {
const content = await fs.readFile(`${lockPath}/info`, "utf-8");
return JSON.parse(content) as LockInfo;
} catch {
return null;
}
}
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
async function isLockStale(lockPath: string, staleMs: number): Promise<boolean> {
const info = await readLockInfo(lockPath);
if (info) {
if (!isProcessAlive(info.pid)) return true;
if (Date.now() - info.timestamp > staleMs) return true;
return false;
}
try {
const stat = await fs.stat(lockPath);
return Date.now() - stat.mtimeMs > staleMs;
} catch (err) {
if (isEnoent(err)) return false;
throw err;
}
}
async function tryAcquireLock(lockPath: string): Promise<string | null> {
try {
await fs.mkdir(lockPath);
const token = randomUUID();
await writeLockInfo(lockPath, token);
return token;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
return null;
}
throw error;
}
}
async function releaseLock(lockPath: string, expectedToken?: string): Promise<void> {
try {
if (expectedToken !== undefined) {
const info = await readLockInfo(lockPath);
if (!info || info.token !== expectedToken) {
logger.debug("file-lock: skipping release for non-owned lock", {
lockPath,
expectedToken,
actualToken: info?.token,
});
return;
}
}
await fs.rm(lockPath, { recursive: true });
} catch {
}
}
async function lockExists(lockPath: string): Promise<boolean> {
try {
await fs.stat(lockPath);
return true;
} catch (err) {
if (isEnoent(err)) return false;
throw err;
}
}
async function acquireLock(filePath: string, options: FileLockOptions = {}): Promise<() => Promise<void>> {
const opts = { ...DEFAULT_OPTIONS, ...options };
const lockPath = getLockPath(filePath);
for (let attempt = 0; attempt < opts.retries; attempt++) {
const token = await tryAcquireLock(lockPath);
if (token !== null) {
return () => releaseLock(lockPath, token);
}
if ((await lockExists(lockPath)) && (await isLockStale(lockPath, opts.staleMs))) {
await releaseLock(lockPath);
continue;
}
await Bun.sleep(opts.retryDelayMs);
}
throw new Error(`Failed to acquire lock for ${filePath} after ${opts.retries} attempts`);
}
export async function withFileLock<T>(
filePath: string,
fn: () => Promise<T>,
options: FileLockOptions = {},
): Promise<T> {
const release = await acquireLock(filePath, options);
try {
return await fn();
} finally {
await release();
}
}
* Test-only handles for the internal lock primitives. These are NOT part of
* the public API — they exist so the contract tests can validate token-keyed
* release semantics and the mkdir-race window without re-implementing them.
*/
export const __internalsForTesting = {
tryAcquireLock,
releaseLock,
readLockInfo,
isLockStale,
getLockPath,
};