import { execFileSync } from 'node:child_process'; /** Reclaim the listen port from a STALE COPY OF OURSELVES. * * The EADDRINUSE handler's assumption — "the old instance is draining, it will free the port, the * service manager restarts us" — holds only while something still owns that old process. It doesn't * when the server's launchd/systemd parent dies first: the server reparents to pid 1, nothing will * ever signal it, and it holds the port FOREVER. The replacement then dies on every respawn while * the orphan keeps serving the OLD code, so `kickstart -k` looks successful and changes nothing * (observed on a production deployment 2026-08-28: a deploy "restarted" the service four times, and the process * answering :3032 was three hours and one version old). * * Reclaiming is deliberately narrow — an over-broad "kill whatever holds my port" is how a deploy * takes down an unrelated service. Every condition must hold: the holder is orphaned (ppid 1, so no * manager owns it), it runs THIS deployment's entrypoint from THIS working directory, and it isn't * us. Anything else (a sibling deployment, a manager-owned process mid-drain, an unrelated server) * is left alone and the caller keeps today's exit-and-let-the-manager-retry behavior. */ export function reclaimStalePort(port: number, opts: { entrypoint?: string; cwd?: string; termGraceMs?: number } = {}): boolean { const entrypoint = opts.entrypoint ?? 'src/main.ts'; const cwd = opts.cwd ?? process.cwd(); const grace = opts.termGraceMs ?? 5000; // Any state, not just LISTEN: an orphan that closed its listener inside a drain it never finished // still OWNS the socket, and looking only for LISTEN made it invisible while it blocked every // replacement (a production deployment 2026-09-06: ppid-1 orphan, listener closed, port held for six minutes and // counting). `isStaleSelf` is what keeps this narrow — a bystander is never a candidate. const holders = [...new Set([...listeners(port), ...portUsers(port)])] .filter((pid) => pid !== process.pid && isStaleSelf(pid, entrypoint, cwd)); if (!holders.length) return false; for (const pid of holders) { console.warn(`[server] port ${port} held by ORPHANED stale instance pid=${pid} (ppid 1, same cwd + entrypoint) — reclaiming`); signal(pid, 'SIGTERM'); if (!waitGone(pid, grace)) { console.warn(`[server] pid=${pid} ignored SIGTERM for ${grace}ms — SIGKILL`); signal(pid, 'SIGKILL'); waitGone(pid, 2000); } } const free = [...new Set([...listeners(port), ...portUsers(port)])].filter((pid) => pid !== process.pid).length === 0; console.warn(`[server] port ${port} ${free ? 'reclaimed' : 'STILL held after reclaim'}`); return free; } /** Every process holding the port in ANY socket state (a half-closed orphan included). */ function portUsers(port: number): number[] { return sh('lsof', ['-ti', `tcp:${port}`]) .split('\n').map((l) => Number(l.trim())).filter((n) => Number.isInteger(n) && n > 0); } function listeners(port: number): number[] { return sh('lsof', ['-ti', `tcp:${port}`, '-sTCP:LISTEN']) .split('\n').map((l) => Number(l.trim())).filter((n) => Number.isInteger(n) && n > 0); } /** Orphaned (ppid 1) + our entrypoint + our cwd. All three, or it is not ours to kill. */ function isStaleSelf(pid: number, entrypoint: string, cwd: string): boolean { if (sh('ps', ['-o', 'ppid=', '-p', String(pid)]).trim() !== '1') return false; if (!sh('ps', ['-o', 'command=', '-p', String(pid)]).includes(entrypoint)) return false; // `lsof -d cwd -Fn` prints the cwd on an `n`-prefixed line. const holderCwd = sh('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn']) .split('\n').find((l) => l.startsWith('n'))?.slice(1).trim(); return !!holderCwd && holderCwd === cwd; } function signal(pid: number, sig: NodeJS.Signals): void { try { process.kill(pid, sig); } catch (err: any) { console.warn(`[server] ${sig} pid=${pid} failed: ${err?.message ?? err}`); } } function waitGone(pid: number, ms: number): boolean { const deadline = Date.now() + ms; while (Date.now() < deadline) { try { process.kill(pid, 0); } catch { return true; } // Synchronous on purpose: this runs inside the listen-error path, before the event loop is // serving anything, and the caller must decide bind-or-exit before returning. Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100); } try { process.kill(pid, 0); return false; } catch { return true; } } function sh(cmd: string, args: string[]): string { try { return execFileSync(cmd, args, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }); } catch { return ''; } // non-zero exit = no match (lsof) or no such pid (ps) — both mean "nothing there" }