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:
2026-08-04 10:03:40 +07:00
parent d71086f677
commit 9d145865dd
19 changed files with 3265 additions and 12 deletions
+116
View File
@@ -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 };