feat(lanes): per-lane database, Redis, and .env isolation (A1+A2)
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.
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* @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 };
|
||||
Reference in New Issue
Block a user