// lock-file.mjs — Exclusive lock with PID + mtime stale-detection. // Zero dependencies. Uses fs.writeFileSync('wx') for atomic exclusive create. // Stale-detection is OR-based: stale if PID is dead OR mtime exceeds threshold. // Either condition alone is enough to break the lock — handles SIGKILL orphans // (mtime alone) and PID-reuse races (mtime alone) and crashed-then-replaced // runs (PID alone). Long runs may opt-in to mtime refresh via refreshIntervalMs. import { writeFileSync, readFileSync, statSync, unlinkSync, utimesSync } from 'node:fs'; import { hostname } from 'node:os'; import { join } from 'node:path'; import { getCacheDir } from './cross-platform-paths.mjs'; const DEFAULT_STALE_THRESHOLD_MS = 60 * 60 * 1000; // 1 hour const DEFAULT_LOCK_NAME = 'kb-update.lock'; /** * Check whether a PID identifies a live process. * @param {number} pid — POSIX process id * @returns {boolean} */ export function isPidAlive(pid) { if (typeof pid !== 'number' || !Number.isFinite(pid) || pid <= 0) { return false; } try { process.kill(pid, 0); return true; } catch (err) { // EPERM means the process exists but we lack signal permission — still alive. return err && err.code === 'EPERM'; } } function safeReadLock(lockPath) { try { return JSON.parse(readFileSync(lockPath, 'utf8')); } catch { return null; } } function lockMtimeMs(lockPath) { try { return statSync(lockPath).mtimeMs; } catch { return null; } } function writeLockFile(lockPath) { writeFileSync( lockPath, JSON.stringify({ pid: process.pid, started: Date.now(), host: hostname(), version: 1, }), { flag: 'wx', encoding: 'utf8' } ); } /** * Acquire an exclusive lock. Throws ELOCKED if held by a live, fresh holder. * Cleans up stale locks (dead PID OR mtime older than staleThresholdMs). * * @param {string} [lockPath] — absolute lock-file path; defaults to /kb-update.lock * @param {object} [opts] * @param {number} [opts.staleThresholdMs] — default 3600000 (1h) * @param {number} [opts.refreshIntervalMs] — if > 0, periodically utimes the lock * @param {boolean} [opts.registerCleanup] — default true; install exit/signal handlers * @returns {{lockPath: string, release: () => void}} */ export function acquireLock(lockPath, opts = {}) { const staleThresholdMs = opts.staleThresholdMs ?? DEFAULT_STALE_THRESHOLD_MS; const refreshIntervalMs = opts.refreshIntervalMs ?? 0; const registerCleanup = opts.registerCleanup ?? true; const path = lockPath || join(getCacheDir('ms-ai-architect'), DEFAULT_LOCK_NAME); try { writeLockFile(path); } catch (err) { if (!err || err.code !== 'EEXIST') throw err; const data = safeReadLock(path); const mtime = lockMtimeMs(path); const holderPid = typeof data?.pid === 'number' ? data.pid : null; const pidAlive = holderPid != null ? isPidAlive(holderPid) : false; const ageMs = mtime != null ? Date.now() - mtime : Infinity; const stale = !pidAlive || ageMs > staleThresholdMs; if (!stale) { const e = new Error( `Lock held by PID ${holderPid} (started ${data?.started ?? 'unknown'})` ); e.code = 'ELOCKED'; e.holderPid = holderPid; throw e; } try { unlinkSync(path); } catch { // best-effort } writeLockFile(path); // retry once } let refreshTimer = null; let released = false; const release = () => { if (released) return; released = true; if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; } try { const data = safeReadLock(path); if (!data || data.pid === process.pid) { unlinkSync(path); } } catch { // best-effort } }; if (refreshIntervalMs > 0) { refreshTimer = setInterval(() => { try { const now = new Date(); utimesSync(path, now, now); } catch { // best-effort } }, refreshIntervalMs); if (typeof refreshTimer.unref === 'function') { refreshTimer.unref(); } } if (registerCleanup) { const onExit = () => release(); process.once('exit', onExit); process.once('SIGINT', () => { release(); process.exit(130); }); process.once('SIGTERM', () => { release(); process.exit(143); }); process.once('SIGHUP', () => { release(); process.exit(129); }); process.once('uncaughtException', (err) => { release(); console.error(err); process.exit(1); }); } return { lockPath: path, release }; }