import { execFileSync } from "node:child_process"; import * as fs from "node:fs"; /** Conservative stale window when start identity is unavailable (R-CTRL-28). */ export const LOCK_STALE_MS = 30 * 60 * 1000; export interface LockOwner { pid: number; processStartIdentity?: string; sessionId: string; hostname: string; claimedAt: string; } /** * Parse `/proc//stat` field 22 (`starttime`). Ported from nicobailon's * session-lease (P12). The command name in field 2 is parenthesized and may * itself contain spaces and parentheses, so fields are counted from the last * `)` rather than by splitting the whole line. */ export function parseLinuxStartTime(stat: string): string | undefined { const close = stat.lastIndexOf(")"); if (close < 0) return undefined; const fields = stat.slice(close + 2).trim().split(/\s+/); // After field 2, field 22 is index 19 of the remainder (state is index 0). const starttime = fields[19]; if (starttime === undefined || !/^\d+$/.test(starttime)) return undefined; return `linux:${starttime}`; } /** Parse `ps -o lstart=` output into a stable identity token. */ export function parseBsdStartTime(lstart: string, platform: string): string | undefined { const value = lstart.trim(); if (value.length === 0) return undefined; return `${platform}:${value}`; } const TRUSTED_PS_PATHS = ["/bin/ps", "/usr/bin/ps"] as const; export interface TrustedPsDeps { exists?: (file: string) => boolean; exec?: (file: string, args: string[]) => string; } /** * Run BSD-style `ps` only from trusted system paths. Process identity is a signal * authorization boundary, so resolving `ps` through PATH would let an unrelated * executable forge the proof used by control and process-group cleanup. */ export function readTrustedPs(args: string[], deps: TrustedPsDeps = {}): string | undefined { const exists = deps.exists ?? fs.existsSync; const exec = deps.exec ?? ((file: string, commandArgs: string[]) => execFileSync(file, commandArgs, { encoding: "utf8", timeout: 2_000, })); for (const ps of TRUSTED_PS_PATHS) { if (!exists(ps)) continue; try { return exec(ps, args); } catch { // Try the other trusted system path before failing closed. } } return undefined; } /** * Process-start identity for `pid`, or undefined when this platform cannot * supply one. Undefined is meaningful: it downgrades reclamation to R-CTRL-28. */ export function getProcessStartIdentity(pid: number): string | undefined { if (process.platform === "linux") { try { return parseLinuxStartTime(fs.readFileSync(`/proc/${pid}/stat`, "utf8")); } catch { return undefined; } } if (process.platform === "darwin" || process.platform === "freebsd" || process.platform === "openbsd") { const out = readTrustedPs(["-o", "lstart=", "-p", String(pid)]); return out === undefined ? undefined : parseBsdStartTime(out, process.platform); } if (process.platform === "win32") { try { const out = execFileSync("wmic", ["process", "where", `ProcessId=${pid}`, "get", "CreationDate"], { encoding: "utf8", timeout: 4000 }); const date = out.split("\n").map((line) => line.trim()).find((line) => /^\d{8}/.test(line)); return date === undefined ? undefined : `win32:${date}`; } catch { return undefined; } } return undefined; } export function isProcessAlive(pid: number): boolean { try { process.kill(pid, 0); return true; } catch (error) { // EPERM means the process exists but belongs to another user. return (error as NodeJS.ErrnoException).code === "EPERM"; } } export interface DeathVerdict { reclaimable: boolean; reason: string; } /** * Positive proof of death (R-CTRL-27). `currentIdentity` is the live identity * of `owner.pid` as observed now, or undefined if it could not be read. */ export function proveDeath( owner: LockOwner, options: { hostname: string; alive: boolean; currentIdentity: string | undefined; now?: number; staleMs?: number; }, ): DeathVerdict { if (owner.hostname !== options.hostname) { return { reclaimable: false, reason: `lock is held on host '${owner.hostname}'; never reclaimed cross-host` }; } if (!options.alive) { if (owner.processStartIdentity !== undefined) { return { reclaimable: true, reason: `pid ${owner.pid} is gone (ESRCH) and start identity was recorded` }; } // R-CTRL-28: no recorded identity, so ESRCH alone could be a reused pid // that has since exited. Require the conservative age window too. const age = (options.now ?? Date.now()) - Date.parse(owner.claimedAt); const staleMs = options.staleMs ?? LOCK_STALE_MS; if (Number.isNaN(age)) { return { reclaimable: false, reason: "owner claimedAt is unparseable; refusing to reclaim" }; } if (age >= staleMs) { return { reclaimable: true, reason: `pid ${owner.pid} is gone and the lock is ${Math.round(age / 60000)}m old with no start identity` }; } return { reclaimable: false, reason: `pid ${owner.pid} is gone but no start identity was recorded and the lock is only ${Math.round(age / 60000)}m old` }; } if (owner.processStartIdentity === undefined || options.currentIdentity === undefined) { return { reclaimable: false, reason: `pid ${owner.pid} is alive and start identity is unavailable on this platform` }; } if (options.currentIdentity !== owner.processStartIdentity) { return { reclaimable: true, reason: `pid ${owner.pid} was reused: start identity ${options.currentIdentity} differs from ${owner.processStartIdentity}` }; } return { reclaimable: false, reason: `pid ${owner.pid} is alive with a matching start identity` }; } /** Owner token used for the per-owner stale tombstone (R-CTRL-30). */ export function ownerToken(owner: LockOwner): string { const raw = `${owner.hostname}-${owner.pid}-${owner.processStartIdentity ?? "noid"}-${owner.claimedAt}`; return raw.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 96); }