Spaces:
Running
Running
File size: 11,074 Bytes
31b3c47 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 | #!/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();
}
|