/** * @file TCP port probing for lane runtime allocation. Two questions the runtime * layer needs answered about a local port: is anything listening on it, and if so * which processes. `isListening` decides whether a lane's preferred port is free * and whether its stack actually came up; `listenerPids` names the occupier in an * EPORTBUSY error and backs up `downLane`'s pid-tree kill when a detached child * outlives its recorded parent. * * Deliberately has no opinion about lanes — it takes a port number and returns a * fact, so it can be tested against a throwaway server with no database. * @author Nguyễn Ngọc Trí Vĩ */ const net = require("node:net"); const { execFile } = require("node:child_process"); const { promisify } = require("node:util"); const execFileAsync = promisify(execFile); /** How long to wait for a connect before calling the port free. */ const PROBE_TIMEOUT_MS = Number(process.env.LANE_PORT_PROBE_MS) || 300; /** * Is something accepting TCP connections on this port? * * Connect-based rather than bind-based on purpose: binding to test a port races * with the thing we are about to start (we would have to release the socket * before the hook binds it, and a sibling lane could take it in between), and on * some platforms a successful bind says nothing about a listener already held by * another user. A refused connection is unambiguous — nobody is serving there. * * A timeout counts as "listening": a port that accepts the TCP handshake but * never responds is occupied, and treating it as free would hand a lane a port it * cannot bind. * * @param {number} port - TCP port on the loopback interface. * @returns {Promise} */ function isListening(port) { return new Promise((resolve) => { const socket = new net.Socket(); let settled = false; const done = (result) => { if (settled) return; settled = true; socket.destroy(); resolve(result); }; socket.setTimeout(PROBE_TIMEOUT_MS); socket.once("connect", () => done(true)); socket.once("timeout", () => done(true)); socket.once("error", () => done(false)); socket.connect(port, "127.0.0.1"); }); } /** * Warned once per process when neither `lsof` nor `ss` exists, so a container or * a minimal image does not print the same line on every probe. */ let warnedNoTool = false; /** Parse a newline-separated list of pids, dropping anything non-numeric. */ function parsePidList(stdout) { return [ ...new Set( stdout .split("\n") .map((line) => line.trim()) .filter((line) => /^\d+$/.test(line)) .map(Number) ), ]; } /** * Which processes are listening on this port. * * Best-effort by design: this only ever enriches an error message or adds a * backstop to a kill that has already been attempted through the recorded pid * files. A missing tool must never fail a lane operation, so every failure path * returns an empty array rather than throwing. * * `lsof` first (present on macOS and most Linux installs), then `ss` from * iproute2 (present on minimal Linux images where lsof is not). * * @param {number} port - TCP port on the loopback interface. * @returns {Promise} Listening pids, or [] when unknown. */ async function listenerPids(port) { try { const { stdout } = await execFileAsync("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"]); return parsePidList(stdout); } catch (err) { // lsof exits 1 when nothing matches — that is an answer, not a missing tool. if (err.code === 1) return []; } try { const { stdout } = await execFileAsync("ss", ["-lptnH", `sport = :${port}`]); return [...new Set([...stdout.matchAll(/pid=(\d+)/g)].map((m) => Number(m[1])))]; } catch { /* fall through to the warning */ } if (!warnedNoTool) { warnedNoTool = true; console.warn( "[ports] neither lsof nor ss is available — port occupants cannot be named, " + "and lane down falls back to the recorded pid files alone" ); } return []; } module.exports = { isListening, listenerPids, PROBE_TIMEOUT_MS };