singularity-forge/src/interactive-session-lock.ts
2026-05-05 20:55:53 +02:00

210 lines
5.5 KiB
TypeScript

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);
});
}
}