src/server/port-reclaim.ts
Rama main · réplica de lectura del repositorio original.
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"
}