import { randomBytes } from "node:crypto"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { type LockOwner, getProcessStartIdentity, isProcessAlive, ownerToken, proveDeath } from "./proof-of-death.ts"; export interface LockAcquired { ok: true; owner: LockOwner; reclaimedFrom?: LockOwner; reclaimReason?: string; } export interface LockRefused { ok: false; holder: LockOwner | undefined; reason: string; } export type LockResult = LockAcquired | LockRefused; function ownerFile(lockDir: string): string { return path.join(lockDir, "owner.json"); } function readOwner(lockDir: string): LockOwner | undefined { try { const parsed = JSON.parse(fs.readFileSync(ownerFile(lockDir), "utf8")) as Partial; if (typeof parsed.pid !== "number" || typeof parsed.hostname !== "string") return undefined; return { pid: parsed.pid, processStartIdentity: typeof parsed.processStartIdentity === "string" ? parsed.processStartIdentity : undefined, sessionId: typeof parsed.sessionId === "string" ? parsed.sessionId : "unknown", hostname: parsed.hostname, claimedAt: typeof parsed.claimedAt === "string" ? parsed.claimedAt : new Date(0).toISOString(), }; } catch { return undefined; } } /** * R-CTRL-29: build a candidate directory containing owner.json and `rename` it * onto the final path. `rename` onto an existing non-empty directory fails, * which is the compare-and-swap. A lockfile with a pid inside would have a * read-then-write race instead. */ function tryClaim(lockDir: string, owner: LockOwner): boolean { const candidate = `${lockDir}.candidate-${randomBytes(8).toString("hex")}`; fs.mkdirSync(candidate, { recursive: true, mode: 0o700 }); try { fs.writeFileSync(ownerFile(candidate), `${JSON.stringify(owner, null, 2)}\n`, { encoding: "utf8", mode: 0o600 }); fs.renameSync(candidate, lockDir); return true; } catch { try { fs.rmSync(candidate, { recursive: true, force: true }); } catch { // candidate cleanup is best effort; a leftover candidate is inert } return false; } } /** Acquire the single-orchestrator lock (R-STATE-14). */ export function acquireOrchestratorLock(lockDir: string, sessionId: string): LockResult { fs.mkdirSync(path.dirname(lockDir), { recursive: true, mode: 0o700 }); const self: LockOwner = { pid: process.pid, processStartIdentity: getProcessStartIdentity(process.pid), sessionId, hostname: os.hostname(), claimedAt: new Date().toISOString(), }; if (tryClaim(lockDir, self)) return { ok: true, owner: self }; const holder = readOwner(lockDir); if (holder === undefined) { // A lock directory with no readable owner cannot be proven dead, so it is // not reclaimed automatically: the alternative is deleting a live claim. return { ok: false, holder: undefined, reason: `${lockDir} exists but its owner.json is missing or unreadable. Remove it manually if no orchestrator is running.` }; } if (holder.pid === process.pid && holder.hostname === os.hostname()) { return { ok: true, owner: holder }; } const verdict = proveDeath(holder, { hostname: os.hostname(), alive: isProcessAlive(holder.pid), currentIdentity: getProcessStartIdentity(holder.pid), }); if (!verdict.reclaimable) { return { ok: false, holder, reason: verdict.reason }; } // R-CTRL-30: per-owner tombstone. Every contender that saw this same stale // owner targets the same destination, so exactly one rename wins and a late // contender cannot move a successor's lock. const tombstone = `${lockDir}.stale-${ownerToken(holder)}`; try { fs.renameSync(lockDir, tombstone); } catch { // Another contender already moved it; fall through and race for the claim. } if (tryClaim(lockDir, self)) { return { ok: true, owner: self, reclaimedFrom: holder, reclaimReason: verdict.reason }; } const winner = readOwner(lockDir); return { ok: false, holder: winner, reason: `another session claimed the stale lock first (pid ${winner?.pid ?? "unknown"})` }; } /** Release the lock, but only if this process still owns it. */ export function releaseOrchestratorLock(lockDir: string): void { const holder = readOwner(lockDir); if (holder === undefined || holder.pid !== process.pid || holder.hostname !== os.hostname()) return; try { fs.rmSync(lockDir, { recursive: true, force: true }); } catch { // A failed release leaves a lock that the next session reclaims by proof // of death; throwing here would break session shutdown for no benefit. } } export function describeHolder(holder: LockOwner | undefined): string { if (holder === undefined) return "unknown holder"; return `pid ${holder.pid}, session ${holder.sessionId}, host ${holder.hostname}, claimed ${holder.claimedAt}`; }