210 lines
5.5 KiB
TypeScript
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);
|
|
});
|
|
}
|
|
}
|