Files
hyperframes/scripts/check-cli-process-ownership.mjs
Miguel Ángel 1289734770 fix(cli): a finished capture exits even when a site's DNS never answers (#5983)
* fix(cli): a finished capture exits even when a site's DNS never answers

Capture resolved every page request with the system lookup and gave up after
10 s, but a system lookup cannot be cancelled: the abandoned one kept the
process alive until the OS resolver timed out, so a capture that had printed
its result could run past the app's 5-minute cap and lose its folder.
Resolve through c-ares instead, as hostAnswers does, and cancel it at the
same 10 s deadline.

* test(cli): a capture lookup that answers must exit right after too

Dropping the timer cleanup kept every successful lookup alive 10 s and no test noticed.

* fix(cli): capture falls back to the system lookup and exits once its result is out

c-ares answers first. When it has no answer, the system lookup (hosts file, OS-only DNS)
runs in a child process killed at the 10 s deadline, cached per host, because Node waits
for an in-process system lookup even in process.exit. Capture then asks the CLI to exit
100 ms after printing its result, as render does.

* fix(cli): capture reads the hosts file first and kills its lookup child on exit

The hosts file answers before DNS, as the system lookup does (overrides and 0.0.0.0
blocks keep working). A host whose system answer is cached skips c-ares, a caller that
joins a running lookup keeps its own deadline, and a pending lookup child is killed when
the CLI exits.

* fix(cli): capture's forced exit waits until a slow reader has the whole result

The root exit now waits for stdout and stderr to hand over what is already
written before it calls process.exit, so a large JSON result on a slow pipe
arrives complete. A hostname listed several times in the hosts file now has
every address checked, not only the first.

* fix(cli): keep process exit in the CLI root and let test fixtures exit

The output wait is now a helper that only waits; cli.ts still owns the
process.exit. The process-ownership check treats test fixtures like tests,
since each one runs as its own process.

* chore(cli): run the process-ownership check before every commit

A process.exit outside a process root used to pass the local hooks and fail
only in CI's Preflight.
2026-10-21 02:46:17 +00:00

70 lines
2.4 KiB
JavaScript

// fallow-ignore-file complexity
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
import { join, relative } from "node:path";
import { fileURLToPath } from "node:url";
import ts from "typescript";
const ROOT = join(import.meta.dirname, "..");
export function listDirectProcessTermination(source, filename = "source.ts") {
const sourceFile = ts.createSourceFile(filename, source, ts.ScriptTarget.Latest, true);
const issues = [];
// AST traversal mirrors the small set of process-termination spellings.
// fallow-ignore-next-line complexity
function visit(node) {
if (
ts.isPropertyAccessExpression(node) &&
ts.isIdentifier(node.expression) &&
node.expression.text === "process" &&
(node.name.text === "exit" || node.name.text === "exitCode")
) {
const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
issues.push(
`${filename}:${position.line + 1}:${position.character + 1} uses process.${node.name.text}`,
);
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
return issues;
}
// Each is the root of its own process: the CLI, the warm check process, and the check client in bin.
const PROCESS_ROOTS = ["cli.ts", "warmCheck.ts", "warmCheckClient.ts"];
function listTypeScriptFiles(directory, roots) {
// fallow-ignore-next-line complexity
return readdirSync(directory).flatMap((name) => {
const path = join(directory, name);
if (statSync(path).isDirectory()) return listTypeScriptFiles(path, roots);
if (!path.endsWith(".ts") || /\.(test|fixture)\.ts$/.test(path) || roots.includes(path)) {
return [];
}
return [path];
});
}
export function checkCliProcessOwnership(root = ROOT) {
const sourceRoot = join(root, "packages/cli/src");
if (!existsSync(sourceRoot)) return [];
const roots = PROCESS_ROOTS.map((name) => join(sourceRoot, name));
return listTypeScriptFiles(sourceRoot, roots).flatMap((path) =>
listDirectProcessTermination(readFileSync(path, "utf8"), relative(root, path)),
);
}
function main() {
const issues = checkCliProcessOwnership();
if (issues.length > 0) {
console.error("CLI process ownership violations:");
issues.forEach((issue) => console.error(`- ${issue}`));
process.exitCode = 1;
return;
}
console.log("CLI process ownership verified: only process roots terminate the process.");
}
if (process.argv[1] === fileURLToPath(import.meta.url)) main();