nexus / scripts /preview.mjs
betterwithage's picture
deploy(hf): sync szl-holdings/nexus@00edebc993282efd5b43b0144b3d3d27ab43e264 derived COPY set
31b3c47 verified
Raw History Blame Contribute Delete
11.1 kB
#!/usr/bin/env node
/**
* Owns :8081, the built-output QA preview.
*
* `vite preview` is strictPort, so a preview left over from an earlier turn
* both fails the next start and keeps serving the previous build's output.
* Every restart therefore kills the current port owner first, whoever started
* it. Owners come from /proc, so this runs only inside the Linux sandbox.
*
* node scripts/preview.mjs stop|restart
*/
import { spawn } from "node:child_process";
import {
existsSync,
mkdirSync,
openSync,
readFileSync,
readdirSync,
readlinkSync,
rmSync,
writeFileSync,
} from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const PREVIEW_PORT = 8081;
const PREVIEW_URL = `http://127.0.0.1:${PREVIEW_PORT}/`;
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
const PID_FILE = join(ROOT, ".grok/preview.pid");
const LOG_FILE = join(ROOT, ".grok/preview.log");
const READY_TIMEOUT_MS = Number(process.env.PREVIEW_READY_TIMEOUT_MS || 60000);
const GRACE_MS = 3000;
const POLL_MS = 100;
export function parsePreviewArgs(argv) {
const [action, ...rest] = argv;
if (!action) return { error: "usage: node scripts/preview.mjs stop|restart" };
if (rest.length > 0) return { error: `unexpected argument: ${rest[0]}` };
if (!["stop", "restart"].includes(action)) {
return { error: `unknown action: ${action} (expected stop or restart)` };
}
return { action };
}
export function parsePid(text) {
const pid = Number.parseInt(String(text ?? "").trim(), 10);
// pid 1 is the sandbox init — never the preview, and dangerous to signal.
return Number.isInteger(pid) && pid > 1 ? pid : null;
}
/** pgid of a process from its /proc/<pid>/stat line. */
export function parsePgid(stat) {
const line = String(stat ?? "");
// The comm field is parenthesised and may itself contain spaces; state, ppid
// and pgrp are the three fields after it.
const end = line.lastIndexOf(") ");
if (end === -1) return null;
const pgid = Number.parseInt(line.slice(end + 2).split(/\s+/)[2], 10);
return Number.isInteger(pgid) && pgid > 0 ? pgid : null;
}
const TCP_LISTEN = "0A";
/** Socket inodes of the LISTEN sockets on `port` in a /proc/net/tcp{,6} dump. */
export function parseListenerInodes(procNetTcp, port) {
const wanted = `:${port.toString(16).toUpperCase().padStart(4, "0")}`;
const inodes = [];
for (const line of String(procNetTcp ?? "").split("\n")) {
const cols = line.trim().split(/\s+/);
if (cols.length < 10 || cols[3] !== TCP_LISTEN || !cols[1].endsWith(wanted)) continue;
if (/^\d+$/.test(cols[9])) inodes.push(cols[9]);
}
return inodes;
}
export function looksLikePreviewProcess(cmdline) {
// /proc/<pid>/cmdline is NUL-separated.
const argv = String(cmdline ?? "")
.split("\0")
.filter(Boolean)
.join(" ");
// The sandbox service runs scripts/preview-thumbnail.mjs in this box, and
// this script can be running concurrently: neither is ever a target.
if (/\bpreview[\w-]*\.mjs\b/.test(argv)) return false;
// The `npm run preview` wrapper (`npm-cli.js run preview`) and its vite child.
// `preview` must be the whole script name: `run preview:stop`/`preview:restart`
// are this tooling's own wrappers, and `vite build --outDir preview-dist` is
// not a server.
return /\brun\s+preview(?:\s|$)/.test(argv) || /\bvite\b\s+preview\b/.test(argv);
}
/**
* Pids to signal. Port owners are owners by definition; the pidfile pid is only
* a claim left by an earlier run — pids are re-used across hibernate/revive, so
* signal it only when its command line still looks like the preview.
*/
export function previewOwners({ portPids, pidFilePid, cmdlineOf }) {
const owners = new Set(portPids);
if (
pidFilePid !== null &&
!owners.has(pidFilePid) &&
looksLikePreviewProcess(cmdlineOf(pidFilePid))
) {
owners.add(pidFilePid);
}
return [...owners];
}
async function waitForExit(pids, { isAlive, sleep, timeoutMs, pollMs }) {
let remaining = pids.filter((pid) => isAlive(pid));
for (let waited = 0; remaining.length > 0 && waited < timeoutMs; waited += pollMs) {
await sleep(pollMs);
remaining = remaining.filter((pid) => isAlive(pid));
}
return remaining;
}
/**
* SIGTERM every live pid, then SIGKILL whatever outlives the grace period.
* Returns `{ signalled, killed, stubborn }` — `stubborn` is still alive after
* the SIGKILL wait, which means the port is not reliably free.
*/
export async function terminatePids(
pids,
{ kill, isAlive, sleep, graceMs = GRACE_MS, pollMs = POLL_MS },
) {
const signalled = pids.filter((pid) => isAlive(pid));
for (const pid of signalled) kill(pid, "SIGTERM");
const killed = await waitForExit(signalled, { isAlive, sleep, timeoutMs: graceMs, pollMs });
for (const pid of killed) kill(pid, "SIGKILL");
const stubborn = await waitForExit(killed, { isAlive, sleep, timeoutMs: graceMs, pollMs });
return { signalled, killed, stubborn };
}
/**
* What `stop` reports. `after` is the post-kill port check: `unattributed: true`
* means a listener exists whose pid could not be resolved, so it may not claim
* the port is free.
*/
export function stopOutcome({ signalled, stubborn, after }) {
const held = [...new Set([...stubborn, ...after.pids])];
if (held.length > 0) {
return { ok: false, error: `port ${PREVIEW_PORT} is still held by pid(s) ${held.join(", ")}` };
}
if (after.unattributed) {
return {
ok: false,
error: `port ${PREVIEW_PORT} is held by a process this script cannot see`,
};
}
const message =
signalled.length > 0
? `stopped pid(s) ${signalled.join(", ")} — port ${PREVIEW_PORT} is free`
: `nothing was listening on ${PREVIEW_PORT}`;
return { ok: true, message };
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
function isAlive(pid) {
try {
process.kill(pid, 0);
return true;
} catch (err) {
return err?.code === "EPERM";
}
}
function pgidOf(pid) {
try {
return parsePgid(readFileSync(`/proc/${pid}/stat`, "utf8"));
} catch {
return null;
}
}
function killPid(pid, signal) {
// restart() detaches the server into its own process group, so signal the
// group to reach `vite` under the `npm` wrapper. Only for a leader: `-pid` on
// a pid that leads no group still reaches any unrelated group numbered pid.
if (pgidOf(pid) === pid) {
try {
process.kill(-pid, signal);
return;
} catch {
// The group exited between the pgid read and the signal.
}
}
try {
process.kill(pid, signal);
} catch {
// Exited between the liveness check and the signal.
}
}
function cmdlineOf(pid) {
try {
return readFileSync(`/proc/${pid}/cmdline`, "utf8");
} catch {
// Usually a dead pid — the stale pidfile this corroboration exists for.
return "";
}
}
function readPidFile() {
try {
return parsePid(readFileSync(PID_FILE, "utf8"));
} catch {
return null;
}
}
function pidsForSocketInodes(inodes) {
const targets = new Set([...inodes].map((inode) => `socket:[${inode}]`));
const pids = [];
for (const entry of readdirSync("/proc")) {
const pid = parsePid(entry);
if (pid === null || pid === process.pid) continue;
let fds;
try {
fds = readdirSync(`/proc/${pid}/fd`);
} catch {
// Exited mid-scan, or owned by another user.
continue;
}
for (const fd of fds) {
try {
if (targets.has(readlinkSync(`/proc/${pid}/fd/${fd}`))) {
pids.push(pid);
break;
}
} catch {
// fd closed mid-scan.
}
}
}
return pids;
}
/**
* `{ pids, unattributed }` — `unattributed: true` when the port has a listener
* whose owning pid could not be resolved (an fd dir we may not read).
*/
function portOwners() {
const inodes = new Set();
for (const file of ["/proc/net/tcp", "/proc/net/tcp6"]) {
let dump;
try {
dump = readFileSync(file, "utf8");
} catch {
// tcp6 is absent when the box has no IPv6.
continue;
}
for (const inode of parseListenerInodes(dump, PREVIEW_PORT)) inodes.add(inode);
}
const pids = inodes.size > 0 ? pidsForSocketInodes(inodes) : [];
return { pids, unattributed: inodes.size > 0 && pids.length === 0 };
}
async function stop(announce = true) {
const owners = previewOwners({
portPids: portOwners().pids,
pidFilePid: readPidFile(),
cmdlineOf,
});
const { signalled, stubborn } = await terminatePids(owners, { kill: killPid, isAlive, sleep });
const outcome = stopOutcome({ signalled, stubborn, after: portOwners() });
if (!outcome.ok) {
// Keep the pidfile: a survivor the port scan cannot attribute leaves it as
// the only record a retry could use.
console.error(`[preview] ${outcome.error}`);
return false;
}
rmSync(PID_FILE, { force: true });
if (announce) console.log(`[preview] ${outcome.message}`);
return true;
}
async function waitForReady(failure) {
const deadline = Date.now() + READY_TIMEOUT_MS;
while (Date.now() < deadline && failure() === null) {
try {
// Any HTTP response means the server is bound; a 404 is still ready.
await fetch(PREVIEW_URL, { signal: AbortSignal.timeout(2000) });
return true;
} catch {
await sleep(250);
}
}
return false;
}
async function restart() {
if (!(await stop())) return 1;
mkdirSync(dirname(LOG_FILE), { recursive: true });
const log = openSync(LOG_FILE, "a");
const child = spawn("npm", ["run", "preview"], {
cwd: ROOT,
detached: true,
stdio: ["ignore", log, log],
});
child.unref();
writeFileSync(PID_FILE, `${child.pid}\n`);
let failure = null;
child.on("error", (err) => {
failure = `npm run preview could not be spawned: ${err.message}`;
});
child.on("exit", (code, signal) => {
failure = `npm run preview exited early (${signal ?? `code ${code}`})`;
});
if (!(await waitForReady(() => failure))) {
const secs = Math.round(READY_TIMEOUT_MS / 1000);
const why =
failure ??
`nothing answered on ${PREVIEW_URL} within ${secs}s — check that vite.config.ts ` +
`still sets preview.port ${PREVIEW_PORT}`;
console.error(`[preview] ${why} — see ${LOG_FILE}`);
// A server that binds a few seconds later would serve a build the agent has
// already been told to distrust.
await stop(false);
return 1;
}
console.log(`[preview] serving ${PREVIEW_URL} (pid ${child.pid}, log ${LOG_FILE})`);
return 0;
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const args = parsePreviewArgs(process.argv.slice(2));
if (args.error) {
console.error(`[preview] ${args.error}`);
process.exit(1);
}
if (!existsSync("/proc/self")) {
console.error("[preview] no /proc — this script only runs inside the sandbox");
process.exit(1);
}
process.exitCode = args.action === "stop" ? ((await stop()) ? 0 : 1) : await restart();
}