UNPKG

@phren/cli

Version:

Knowledge layer for AI agents - CLI, MCP server, and data layer

175 lines (174 loc) • 7.17 kB
import * as fs from "fs"; import * as path from "path"; import { debugLog } from "../shared.js"; import { errorMessage } from "../utils.js"; import { logger } from "../logger.js"; // Acquire the file lock, returning true on success or throwing on timeout. function acquireFileLock(lockPath) { const maxWait = Number.parseInt(process.env.PHREN_FILE_LOCK_MAX_WAIT_MS || "5000", 10) || 5000; const pollInterval = Number.parseInt((process.env.PHREN_FILE_LOCK_POLL_MS) || "100", 10) || 100; const staleThreshold = Number.parseInt((process.env.PHREN_FILE_LOCK_STALE_MS) || "30000", 10) || 30000; const waiter = new Int32Array(new SharedArrayBuffer(4)); const sleep = (ms) => Atomics.wait(waiter, 0, 0, ms); fs.mkdirSync(path.dirname(lockPath), { recursive: true }); let waited = 0; let hasLock = false; while (waited < maxWait) { try { fs.writeFileSync(lockPath, `${process.pid}\n${Date.now()}`, { flag: "wx" }); hasLock = true; break; } catch (err) { logger.debug("acquireFileLock", `lockWrite: ${errorMessage(err)}`); try { const stat = fs.statSync(lockPath); if (Date.now() - stat.mtimeMs > staleThreshold) { // Lock mtime exceeds stale threshold — but only delete if the // owning process is dead. Legitimate long operations (e.g. index // rebuilds) can hold locks beyond the threshold. let ownerAlive = false; try { const content = fs.readFileSync(lockPath, "utf-8"); const pid = Number.parseInt(content.split("\n")[0], 10); if (pid > 0 && Number.isFinite(pid)) { try { process.kill(pid, 0); // signal 0: liveness check, no actual signal sent ownerAlive = true; } catch { // process.kill throws if PID doesn't exist → owner is dead } } // pid <= 0 or NaN → treat as stale (unparseable / corrupt lock) } catch { // Can't read lock file (deleted between stat and read) → retry } if (!ownerAlive) { try { fs.unlinkSync(lockPath); } catch { /* already gone */ } continue; } // Owner is alive and legitimately holding a long lock: back off // like every other retry path, or this loop spins at 100% CPU // and `maxWait` never fires. sleep(pollInterval); waited += pollInterval; continue; } } catch (statErr) { logger.debug("acquireFileLock", `staleStat: ${statErr instanceof Error ? statErr.message : String(statErr)}`); sleep(pollInterval); waited += pollInterval; continue; } sleep(pollInterval); waited += pollInterval; } } if (!hasLock) { const msg = `withFileLock: could not acquire lock for "${path.basename(lockPath)}" within ${maxWait}ms`; debugLog(msg); throw new Error(msg); } } function releaseFileLock(lockPath) { try { fs.unlinkSync(lockPath); } catch (err) { logger.debug("releaseFileLock", `${errorMessage(err)}`); } } /** A non-blocking lock for background work. Uses the same files as withFileLock. */ export function tryFileLock(filePath) { const lockPath = filePath + ".lock"; const owner = `${process.pid}\n${Date.now()}\n${Math.random()}`; fs.mkdirSync(path.dirname(lockPath), { recursive: true }); for (let attempt = 0; attempt < 2; attempt++) { try { fs.writeFileSync(lockPath, owner, { flag: "wx", mode: 0o600 }); return () => { try { if (fs.readFileSync(lockPath, "utf8") === owner) fs.unlinkSync(lockPath); } catch { /* already released */ } }; } catch (err) { const code = err.code; // Windows refuses to create a lock another process is deleting (still // open somewhere) with EPERM, EACCES or EBUSY: it is held, not broken. // With no lock file there at all, the error is a real permission problem. if (process.platform === "win32" && (code === "EPERM" || code === "EACCES" || code === "EBUSY")) { let present = true; try { fs.statSync(lockPath); } catch (statErr) { present = statErr.code !== "ENOENT"; } if (present) return null; throw err; } if (code !== "EEXIST") throw err; try { const contents = fs.readFileSync(lockPath, "utf8"); const pid = Number.parseInt(contents.split("\n")[0], 10); if (pid > 0) { try { process.kill(pid, 0); return null; } catch (error) { if (error.code !== "ESRCH") return null; } } else if (Date.now() - fs.statSync(lockPath).mtimeMs < 30_000) { return null; } if (fs.readFileSync(lockPath, "utf8") === contents) fs.unlinkSync(lockPath); } catch { return null; } } } return null; } // Q10: withFileLock now accepts both sync and async callbacks. // When the callback returns a Promise, the lock file is held until the // Promise settles — preventing concurrent processes from seeing partial state. export function withFileLock(filePath, fn) { const lockPath = filePath + ".lock"; acquireFileLock(lockPath); let result; try { result = fn(); } catch (err) { releaseFileLock(lockPath); throw err; } // If the callback returned a Promise, hold the lock until it settles. if (result instanceof Promise) { return result.then((value) => { releaseFileLock(lockPath); return value; }, (err) => { releaseFileLock(lockPath); throw err; }); } releaseFileLock(lockPath); return result; } export function isFiniteNumber(value) { return typeof value === "number" && Number.isFinite(value); } export function hasValidSchemaVersion(data) { return !("schemaVersion" in data) || typeof data.schemaVersion === "number"; }