9d145865dd
Gives each lane its own slot-derived runtime (ports, detached process lifecycle, profile-driven hooks) and its own database/Redis logical index/.env file, so two lanes running the same repo's stack at once no longer share state. Machine-level DB/Redis credentials live at ~/.ccam/secrets.env (mode 0600, never returned by any route); a hook's output is redacted of that password (raw and URL-encoded forms) before it reaches a log file or the lane_hook_output websocket broadcast. Wired into provision/up/reset/remove; reset accepts --keep-db to skip the drop/recreate/migrate/reseed block entirely.
117 lines
4.1 KiB
JavaScript
117 lines
4.1 KiB
JavaScript
/**
|
|
* @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ĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
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<boolean>}
|
|
*/
|
|
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<number[]>} 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 };
|