/** * U21 (upgrade 2026-10-13): process-level lock guard for the async run-lock * path (dual guard supplement in locks.ts `withRunLock`). * * A lock owned by the kernel for the holder's lifetime: on Linux/Android an * ABSTRACT unix socket (address `\0pi-crew-`) vanishes with its * process, so there is no stale state to take over and no race between two * would-be owners. On Windows a named pipe has the same kernel-lifetime * property. Other unix platforms (darwin/BSD) need a socket FILE: removed on * process exit; a leftover one nobody accepts on is taken over. * * NO environment variable is involved — the lock is acquired in-process and * the lock NAME is derived from the protected path by the caller. This is a * deliberate deviation from the vendored source (which passes a lock name to * forked children via env): pi-crew's child env allowlist strips PI_CREW_* * ambient keys (§12.2.1), and nothing about the lock needs to be inherited. * * NAME CONVENTION: the socket/pipe namespace is system-wide, NOT per-user — * callers namespace their names by uid when the protected path can be shared * across users (see locks.ts `runLockSocketName`, F2 2026-10-14) so two users * on the same path do not collide. * * VENDORED (adapted, not copied verbatim) from `@earendil-works/chord` (MIT), * packages/chord/src/manager.ts:82-170 @ a4d040d30 — see NOTICE.md. * Adaptations beyond renaming the namespace: * - `server.unref()` — a held lock must never keep the extension host * process alive on its own (chord relies on its driver lifecycle instead); * - `release()` force-closes lingering probe connections so it can never * hang a caller's `finally` (chord only `end()`s accepted sockets); * - typed `ProcessLockHeldError` carrying `reason: "locked"` instead of the * vendor's generic error class. */ import { createHash } from "node:crypto"; import * as fs from "node:fs"; import * as net from "node:net"; import * as os from "node:os"; import * as path from "node:path"; /** Handle for a held process lock; `release()` is idempotent. */ export interface ProcessLockHandle { release(): Promise; } /** * Thrown when the lock is held by a LIVE owner (the kernel answered the listen * with EADDRINUSE and, on file sockets, a probe confirmed somebody accepts). * `reason: "locked"` mirrors the vendor's structured-error contract. */ export class ProcessLockHeldError extends Error { readonly reason = "locked" as const; constructor(name: string) { super(`process lock '${name}' is held by another process`); this.name = "ProcessLockHeldError"; } } /** Type guard for the held-error shape (contention signal for callers). */ export function isProcessLockHeldError(error: unknown): error is ProcessLockHeldError { return error instanceof ProcessLockHeldError; } /** sun_path holds 104 bytes on macOS and the BSDs, including the terminating NUL. */ const MAX_SOCKET_PATH_BYTES = 103; function lockKey(name: string): string { return createHash("sha256").update(name).digest("hex").slice(0, 32); } /** * The lock socket's file path: in the temporary directory, or — when that path * does not fit in sun_path (a deep TMPDIR, e.g. a macOS per-user one) — in a * private per-user directory under /tmp that only its owner can access. * (Non-Linux/non-Windows branch only; Linux uses abstract sockets.) */ function fileSocketAddress(key: string): string { const preferred = path.join(os.tmpdir(), `pi-crew-${key}.sock`); if (Buffer.byteLength(preferred) <= MAX_SOCKET_PATH_BYTES) return preferred; const uid = process.getuid?.() ?? 0; const directory = `/tmp/pi-crew-${uid}`; fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); const stats = fs.lstatSync(directory); if (!stats.isDirectory() || stats.uid !== uid || (stats.mode & 0o077) !== 0) { throw new Error(`process-lock: lock directory ${directory} must be a directory only its owner can access`); } return path.join(directory, `pi-crew-${key}.sock`); } interface SocketAddress { /** Listen address: `\0…` abstract (Linux), `\\.\pipe\…` (Windows) or a file path. */ address: string; /** True when the address is a filesystem socket file (needs exit cleanup + corpse takeover). */ file: boolean; } function socketAddress(name: string): SocketAddress { const key = lockKey(name); if (process.platform === "win32") { return { address: `\\\\.\\pipe\\pi-crew-${key}`, file: false }; } if (process.platform === "linux" || process.platform === "android") { // Abstract namespace — kernel-owned, vanishes with the process. return { address: `\0pi-crew-${key}`, file: false }; } return { address: fileSocketAddress(key), file: true }; } /** R9-OPS2 (2026-10-17): age threshold for the stale-socket sweep (mtime). */ export const STALE_SOCKET_SWEEP_AGE_MS = 24 * 60 * 60 * 1000; let staleSocketSweepDone = false; /** R9-OPS2: sweep stale pi-crew/pc socket FILES from a directory (default * os.tmpdir()). Only entries that lstat as actual unix socket files, older * than STALE_SOCKET_SWEEP_AGE_MS, and not the excluded current path are * unlinked. Best-effort throughout — a sweep failure never breaks the lock * path. Returns the number of files removed (tests). */ export function sweepStaleSocketFiles(options?: { now?: number; tmpDir?: string; excludePath?: string }): number { const tmpDir = options?.tmpDir ?? os.tmpdir(); const now = options?.now ?? Date.now(); let removed = 0; try { for (const name of fs.readdirSync(tmpDir)) { if (!name.endsWith(".sock")) continue; if (!name.startsWith("pi-crew-") && !name.startsWith("pc-")) continue; const full = path.join(tmpDir, name); if (options?.excludePath !== undefined && full === options.excludePath) continue; try { const stat = fs.lstatSync(full); if (!stat.isSocket()) continue; // socket FILES only — never touch anything else if (now - stat.mtimeMs < STALE_SOCKET_SWEEP_AGE_MS) continue; // fresh — keep fs.unlinkSync(full); removed += 1; } catch { /* best-effort per file */ } } } catch { /* best-effort: unreadable dir etc. */ } if (removed > 0) { // ONE line — R9-OPS2 explicitly asks for a single log line, not a flood. console.warn(`[pi-crew] swept ${removed} stale socket file(s) older than 24h from ${tmpDir}`); } return removed; } function ensureStaleSocketSweep(currentSocketPath?: string): void { if (staleSocketSweepDone) return; staleSocketSweepDone = true; sweepStaleSocketFiles(currentSocketPath !== undefined ? { excludePath: currentSocketPath } : undefined); } /** * Probe whether a live server accepts connections on `address` (leftover-corpse * check). A single refusal is NOT trusted on file-socket platforms: macOS can * return ECONNREFUSED transiently against a LIVE listener (e.g. a momentarily * full listen backlog under load — live-caught in CI 2026-10-14: a foreign * child flipped a held lock to "corpse", DELETED the live socket file and * rebound, stealing the lock). We require TAKEOVER_REFUSALS consecutive * refusals, spaced TAKEOVER_RETRY_MS apart, before declaring a corpse. */ const TAKEOVER_REFUSALS = 3; const TAKEOVER_RETRY_MS = 150; /** One connect-probe: true = somebody accepts; false = refused (corpse-ish). */ function acceptingOnce(address: string): Promise { return new Promise((resolve) => { const socket = net.connect(address); socket.unref(); socket.once("connect", () => { socket.destroy(); resolve(true); }); socket.once("error", (error: NodeJS.ErrnoException) => { // ECONNREFUSED/ENOENT → a corpse nobody serves: caller may take over. resolve(error.code !== "ECONNREFUSED" && error.code !== "ENOENT"); }); }); } async function accepting(address: string): Promise { for (let attempt = 0; attempt < TAKEOVER_REFUSALS; attempt++) { if (attempt > 0) await new Promise((r) => setTimeout(r, TAKEOVER_RETRY_MS)); if (await acceptingOnce(address)) return true; } return false; } /** * Acquire the process lock for `name`. Resolves with a handle once THIS process * owns the kernel socket; rejects with {@link ProcessLockHeldError} * (`reason: "locked"`) when a live owner holds it. On file-socket platforms a * leftover socket file from a crashed holder is probed and taken over. */ export async function acquireProcessLock(name: string): Promise { const { address, file } = socketAddress(name); // R9-OPS2 (2026-10-17): once per process, sweep stale socket FILES left // behind by crashed sessions (observed: 299 stale pc-*.sock in /tmp). The // CURRENT lock's own path is excluded; abstract sockets leave no file. ensureStaleSocketSweep(file ? address : undefined); const server = net.createServer((socket) => socket.end()); for (;;) { try { await new Promise((resolve, reject) => { const onError = (error: Error): void => reject(error); server.once("error", onError); server.listen(address, () => { server.removeListener("error", onError); resolve(); }); }); break; } catch (error) { if ((error as NodeJS.ErrnoException).code !== "EADDRINUSE") { try { server.close(); } catch { /* never listened — nothing to close */ } throw error; } // EADDRINUSE: a live owner holds the kernel object — EXCEPT on file // sockets, where a crashed holder may have left the file behind. if (!file || (await accepting(address))) { try { server.close(); } catch { /* never listened — nothing to close */ } throw new ProcessLockHeldError(name); } // Corpse file nobody serves — remove it and listen again. fs.rmSync(address, { force: true }); } } // A held lock must never keep the (long-lived extension host) process alive // on its own: the socket dies with the process either way, which is exactly // the lock's lifetime contract. server.unref(); // Post-listen errors on a lock server are near-impossible (abstract sockets // are kernel-owned), but an UNHANDLED 'error' event would crash the host — // observe instead of crash. The lock stays held until release()/exit. server.on("error", () => { /* observed only — see comment above */ }); const removeFile = (): void => { try { fs.unlinkSync(address); } catch { /* already gone */ } }; if (file) process.once("exit", removeFile); let released = false; return { release: () => new Promise((resolve, reject) => { if (released) { resolve(); return; } released = true; if (file) process.removeListener("exit", removeFile); // Force-close any lingering probe connections so the close // callback always settles (a hung release would hang the run-lock // `finally` that awaits it). (server as net.Server & { closeAllConnections?: () => void }).closeAllConnections?.(); server.close((error) => (error ? reject(error) : resolve())); }), }; }