import { randomUUID } from "node:crypto"; import { lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, writeFileSync, } from "node:fs"; import { join, resolve } from "node:path"; const LOCK_FILE = "interactive.lock"; const LOCK_DIR = "interactive.lock.d"; export type InteractiveSessionLockConflict = { acquired: false; lockPath: string; pid: number | null; startedAt: string | null; sessionFile: string | null; message: string; }; export type InteractiveSessionLockHandle = { acquired: true; lockPath: string; release(): void; }; export type InteractiveSessionLockResult = | InteractiveSessionLockHandle | InteractiveSessionLockConflict; type InteractiveSessionLockData = { pid: number; ppid: number; cwd: string; startedAt: string; sessionFile?: string; token: string; }; /** * Acquire the per-repository interactive-session lock. * * Purpose: prevent two TUI sessions from mutating the same repo-scoped SF state * at the same time while still recovering automatically from dead lock owners. * * Consumer: cli.ts before InteractiveMode starts. */ export function acquireInteractiveSessionLock( basePath: string, sessionFile?: string, ): InteractiveSessionLockResult { const cwd = resolve(basePath); const sfDir = join(cwd, ".sf"); const lockDir = join(sfDir, LOCK_DIR); const lockPath = join(sfDir, LOCK_FILE); const token = randomUUID(); const data: InteractiveSessionLockData = { pid: process.pid, ppid: process.ppid, cwd, startedAt: new Date().toISOString(), ...(sessionFile ? { sessionFile } : {}), token, }; mkdirSync(sfDir, { recursive: true }); for (let attempt = 0; attempt < 2; attempt++) { try { mkdirSync(lockDir); writeFileSync(lockPath, JSON.stringify(data, null, 2), "utf-8"); const release = () => releaseInteractiveSessionLock(basePath, token); registerInteractiveLockExitHandler(release); return { acquired: true, lockPath, release }; } catch (err) { if ((err as NodeJS.ErrnoException)?.code !== "EEXIST") throw err; const existing = readInteractiveSessionLockData(lockPath); if (!isInteractiveLockOwnerAlive(existing, cwd)) { removeInteractiveLock(lockPath, lockDir); continue; } return { acquired: false, lockPath, pid: existing?.pid ?? null, startedAt: existing?.startedAt ?? null, sessionFile: existing?.sessionFile ?? null, message: formatInteractiveSessionLockConflict(existing, lockPath), }; } } const existing = readInteractiveSessionLockData(lockPath); return { acquired: false, lockPath, pid: existing?.pid ?? null, startedAt: existing?.startedAt ?? null, sessionFile: existing?.sessionFile ?? null, message: formatInteractiveSessionLockConflict(existing, lockPath), }; } /** * Release a per-repository interactive-session lock if it is still ours. * * Purpose: avoid deleting another live TUI session's lock after a fast restart * or PID reuse. * * Consumer: cli.ts finally blocks and the process exit handler registered by * acquireInteractiveSessionLock(). */ export function releaseInteractiveSessionLock( basePath: string, token: string, ): void { const cwd = resolve(basePath); const lockPath = join(cwd, ".sf", LOCK_FILE); const lockDir = join(cwd, ".sf", LOCK_DIR); const existing = readInteractiveSessionLockData(lockPath); if (existing?.token !== token) return; removeInteractiveLock(lockPath, lockDir); } function readInteractiveSessionLockData( lockPath: string, ): InteractiveSessionLockData | null { try { const parsed = JSON.parse(readFileSync(lockPath, "utf-8")); if ( parsed && typeof parsed === "object" && typeof parsed.pid === "number" && typeof parsed.cwd === "string" && typeof parsed.startedAt === "string" && typeof parsed.token === "string" ) { return parsed as InteractiveSessionLockData; } } catch { /* stale or corrupt lock metadata */ } return null; } function isInteractiveLockOwnerAlive( data: InteractiveSessionLockData | null, expectedCwd: string, ): boolean { if (!data || data.pid <= 0 || data.pid === process.pid) return false; try { process.kill(data.pid, 0); } catch { return false; } const ownerCwd = readProcessCwd(data.pid); if (ownerCwd && ownerCwd !== expectedCwd) return false; return true; } function readProcessCwd(pid: number): string | null { try { const link = readlinkSync(`/proc/${pid}/cwd`); return resolve(link); } catch { return null; } } function removeInteractiveLock(lockPath: string, lockDir: string): void { try { rmSync(lockPath, { force: true }); } catch { /* best-effort */ } try { const stat = lstatSync(lockDir); if (stat.isDirectory()) rmSync(lockDir, { recursive: true, force: true }); } catch { /* best-effort */ } } function formatInteractiveSessionLockConflict( data: InteractiveSessionLockData | null, lockPath: string, ): string { const pid = data?.pid ? String(data.pid) : "unknown"; const startedAt = data?.startedAt ? ` started ${data.startedAt}` : ""; const session = data?.sessionFile ? `\n[sf] Active session: ${data.sessionFile}` : ""; return ( `[sf] Another interactive sf session is already running for this repo (PID ${pid}${startedAt}).` + session + `\n[sf] Close that terminal first, or remove stale lock ${lockPath} if the process is gone.` ); } function registerInteractiveLockExitHandler(release: () => void): void { process.once("exit", release); for (const signal of ["SIGINT", "SIGTERM"] as const) { process.once(signal, () => { release(); process.exit(signal === "SIGINT" ? 130 : 143); }); } }