Files
openclaw/scripts/check-gateway-watch-regression.mjs

465 lines
13 KiB
JavaScript

#!/usr/bin/env node
import { spawn, spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import process from "node:process";
const DEFAULTS = {
outputDir: path.join(process.cwd(), ".local", "gateway-watch-regression"),
windowMs: 10_000,
sigkillGraceMs: 10_000,
cpuWarnMs: 1_000,
cpuFailMs: 8_000,
distRuntimeFileGrowthMax: 200,
distRuntimeByteGrowthMax: 2 * 1024 * 1024,
keepLogs: true,
skipBuild: false,
};
function parseArgs(argv) {
const options = { ...DEFAULTS };
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
const next = argv[i + 1];
const readValue = () => {
if (!next) {
throw new Error(`Missing value for ${arg}`);
}
i += 1;
return next;
};
switch (arg) {
case "--output-dir":
options.outputDir = path.resolve(readValue());
break;
case "--window-ms":
options.windowMs = Number(readValue());
break;
case "--sigkill-grace-ms":
options.sigkillGraceMs = Number(readValue());
break;
case "--cpu-warn-ms":
options.cpuWarnMs = Number(readValue());
break;
case "--cpu-fail-ms":
options.cpuFailMs = Number(readValue());
break;
case "--dist-runtime-file-growth-max":
options.distRuntimeFileGrowthMax = Number(readValue());
break;
case "--dist-runtime-byte-growth-max":
options.distRuntimeByteGrowthMax = Number(readValue());
break;
case "--skip-build":
options.skipBuild = true;
break;
default:
throw new Error(`Unknown argument: ${arg}`);
}
}
return options;
}
function ensureDir(dirPath) {
fs.mkdirSync(dirPath, { recursive: true });
}
function normalizePath(filePath) {
return filePath.replaceAll("\\", "/");
}
function listTreeEntries(rootName) {
const rootPath = path.join(process.cwd(), rootName);
if (!fs.existsSync(rootPath)) {
return [`${rootName} (missing)`];
}
const entries = [rootName];
const queue = [rootPath];
while (queue.length > 0) {
const current = queue.pop();
if (!current) {
continue;
}
const dirents = fs.readdirSync(current, { withFileTypes: true });
for (const dirent of dirents) {
const fullPath = path.join(current, dirent.name);
const relativePath = normalizePath(path.relative(process.cwd(), fullPath));
entries.push(relativePath);
if (dirent.isDirectory()) {
queue.push(fullPath);
}
}
}
return entries.toSorted((a, b) => a.localeCompare(b));
}
function humanBytes(bytes) {
if (bytes < 1024) {
return `${bytes}B`;
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)}K`;
}
if (bytes < 1024 * 1024 * 1024) {
return `${(bytes / (1024 * 1024)).toFixed(1)}M`;
}
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}G`;
}
function snapshotTree(rootName) {
const rootPath = path.join(process.cwd(), rootName);
const stats = {
exists: fs.existsSync(rootPath),
files: 0,
directories: 0,
symlinks: 0,
entries: 0,
apparentBytes: 0,
};
if (!stats.exists) {
return stats;
}
const queue = [rootPath];
while (queue.length > 0) {
const current = queue.pop();
if (!current) {
continue;
}
const currentStats = fs.lstatSync(current);
stats.entries += 1;
if (currentStats.isDirectory()) {
stats.directories += 1;
for (const dirent of fs.readdirSync(current, { withFileTypes: true })) {
queue.push(path.join(current, dirent.name));
}
continue;
}
if (currentStats.isSymbolicLink()) {
stats.symlinks += 1;
continue;
}
if (currentStats.isFile()) {
stats.files += 1;
stats.apparentBytes += currentStats.size;
}
}
return stats;
}
function writeSnapshot(snapshotDir) {
ensureDir(snapshotDir);
const pathEntries = [...listTreeEntries("dist"), ...listTreeEntries("dist-runtime")];
fs.writeFileSync(path.join(snapshotDir, "paths.txt"), `${pathEntries.join("\n")}\n`, "utf8");
const dist = snapshotTree("dist");
const distRuntime = snapshotTree("dist-runtime");
const snapshot = {
generatedAt: new Date().toISOString(),
dist,
distRuntime,
};
fs.writeFileSync(
path.join(snapshotDir, "snapshot.json"),
`${JSON.stringify(snapshot, null, 2)}\n`,
);
fs.writeFileSync(
path.join(snapshotDir, "stats.txt"),
[
`generated_at: ${snapshot.generatedAt}`,
"",
"[dist]",
`files: ${dist.files}`,
`directories: ${dist.directories}`,
`symlinks: ${dist.symlinks}`,
`entries: ${dist.entries}`,
`apparent_bytes: ${dist.apparentBytes}`,
`apparent_human: ${humanBytes(dist.apparentBytes)}`,
"",
"[dist-runtime]",
`files: ${distRuntime.files}`,
`directories: ${distRuntime.directories}`,
`symlinks: ${distRuntime.symlinks}`,
`entries: ${distRuntime.entries}`,
`apparent_bytes: ${distRuntime.apparentBytes}`,
`apparent_human: ${humanBytes(distRuntime.apparentBytes)}`,
"",
].join("\n"),
"utf8",
);
return snapshot;
}
function runCheckedCommand(command, args) {
const result = spawnSync(command, args, {
cwd: process.cwd(),
stdio: "inherit",
env: process.env,
});
if (typeof result.status === "number" && result.status === 0) {
return;
}
throw new Error(`${command} ${args.join(" ")} failed with status ${result.status ?? "unknown"}`);
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function buildTimedWatchCommand(pidFilePath, timeFilePath, isolatedHomeDir) {
const shellSource = [
'echo "$$" > "$OPENCLAW_WATCH_PID_FILE"',
"exec node scripts/watch-node.mjs gateway --force --allow-unconfigured",
].join("\n");
const env = {
OPENCLAW_WATCH_PID_FILE: pidFilePath,
HOME: isolatedHomeDir,
OPENCLAW_HOME: isolatedHomeDir,
};
if (process.platform === "darwin") {
return {
command: "/usr/bin/time",
args: ["-lp", "-o", timeFilePath, "/bin/sh", "-lc", shellSource],
env,
};
}
return {
command: "/usr/bin/time",
args: [
"-f",
"__TIMING__ user=%U sys=%S elapsed=%e",
"-o",
timeFilePath,
"/bin/sh",
"-lc",
shellSource,
],
env,
};
}
function parseTimingFile(timeFilePath) {
const text = fs.readFileSync(timeFilePath, "utf8");
if (process.platform === "darwin") {
const user = Number(text.match(/^user\s+([0-9.]+)/m)?.[1] ?? "NaN");
const sys = Number(text.match(/^sys\s+([0-9.]+)/m)?.[1] ?? "NaN");
const elapsed = Number(text.match(/^real\s+([0-9.]+)/m)?.[1] ?? "NaN");
return {
userSeconds: user,
sysSeconds: sys,
elapsedSeconds: elapsed,
};
}
const match = text.match(/__TIMING__ user=([0-9.]+) sys=([0-9.]+) elapsed=([0-9.]+)/);
return {
userSeconds: Number(match?.[1] ?? "NaN"),
sysSeconds: Number(match?.[2] ?? "NaN"),
elapsedSeconds: Number(match?.[3] ?? "NaN"),
};
}
async function runTimedWatch(options, outputDir) {
const pidFilePath = path.join(outputDir, "watch.pid");
const timeFilePath = path.join(outputDir, "watch.time.log");
const isolatedHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-gateway-watch-"));
fs.writeFileSync(path.join(outputDir, "watch.home.txt"), `${isolatedHomeDir}\n`, "utf8");
const stdoutPath = path.join(outputDir, "watch.stdout.log");
const stderrPath = path.join(outputDir, "watch.stderr.log");
const { command, args, env } = buildTimedWatchCommand(pidFilePath, timeFilePath, isolatedHomeDir);
const child = spawn(command, args, {
cwd: process.cwd(),
env: { ...process.env, ...env },
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout?.on("data", (chunk) => {
stdout += String(chunk);
});
child.stderr?.on("data", (chunk) => {
stderr += String(chunk);
});
const exitPromise = new Promise((resolve) => {
child.on("exit", (code, signal) => resolve({ code, signal }));
});
let watchPid = null;
for (let attempt = 0; attempt < 50; attempt += 1) {
if (fs.existsSync(pidFilePath)) {
watchPid = Number(fs.readFileSync(pidFilePath, "utf8").trim());
break;
}
await sleep(100);
}
await sleep(options.windowMs);
if (watchPid) {
try {
process.kill(watchPid, "SIGTERM");
} catch {
// ignore
}
}
const gracefulExit = await Promise.race([
exitPromise,
sleep(options.sigkillGraceMs).then(() => null),
]);
if (gracefulExit === null) {
if (watchPid) {
try {
process.kill(watchPid, "SIGKILL");
} catch {
// ignore
}
}
}
const exit = (await exitPromise) ?? { code: null, signal: null };
fs.writeFileSync(stdoutPath, stdout, "utf8");
fs.writeFileSync(stderrPath, stderr, "utf8");
const timing = fs.existsSync(timeFilePath)
? parseTimingFile(timeFilePath)
: { userSeconds: Number.NaN, sysSeconds: Number.NaN, elapsedSeconds: Number.NaN };
return {
exit,
timing,
stdoutPath,
stderrPath,
timeFilePath,
};
}
function parsePathFile(filePath) {
return fs
.readFileSync(filePath, "utf8")
.split("\n")
.map((line) => line.trimEnd())
.filter(Boolean);
}
function writeDiffArtifacts(outputDir, preDir, postDir) {
const diffDir = path.join(outputDir, "diff");
ensureDir(diffDir);
const prePaths = parsePathFile(path.join(preDir, "paths.txt"));
const postPaths = parsePathFile(path.join(postDir, "paths.txt"));
const preSet = new Set(prePaths);
const postSet = new Set(postPaths);
const added = postPaths.filter((entry) => !preSet.has(entry));
const removed = prePaths.filter((entry) => !postSet.has(entry));
fs.writeFileSync(path.join(diffDir, "added-paths.txt"), `${added.join("\n")}\n`, "utf8");
fs.writeFileSync(path.join(diffDir, "removed-paths.txt"), `${removed.join("\n")}\n`, "utf8");
return { added, removed };
}
function fail(message) {
console.error(`FAIL: ${message}`);
}
async function main() {
const options = parseArgs(process.argv.slice(2));
ensureDir(options.outputDir);
if (!options.skipBuild) {
runCheckedCommand("pnpm", ["build"]);
}
const preDir = path.join(options.outputDir, "pre");
const pre = writeSnapshot(preDir);
const watchDir = path.join(options.outputDir, "watch");
ensureDir(watchDir);
const watchResult = await runTimedWatch(options, watchDir);
const postDir = path.join(options.outputDir, "post");
const post = writeSnapshot(postDir);
const diff = writeDiffArtifacts(options.outputDir, preDir, postDir);
const distRuntimeFileGrowth = post.distRuntime.files - pre.distRuntime.files;
const distRuntimeByteGrowth = post.distRuntime.apparentBytes - pre.distRuntime.apparentBytes;
const distRuntimeAddedPaths = diff.added.filter((entry) =>
entry.startsWith("dist-runtime/"),
).length;
const cpuMs = Math.round((watchResult.timing.userSeconds + watchResult.timing.sysSeconds) * 1000);
const watchTriggeredBuild =
fs
.readFileSync(watchResult.stderrPath, "utf8")
.includes("Building TypeScript (dist is stale).") ||
fs
.readFileSync(watchResult.stdoutPath, "utf8")
.includes("Building TypeScript (dist is stale).");
const summary = {
windowMs: options.windowMs,
watchTriggeredBuild,
cpuMs,
cpuWarnMs: options.cpuWarnMs,
cpuFailMs: options.cpuFailMs,
distRuntimeFileGrowth,
distRuntimeFileGrowthMax: options.distRuntimeFileGrowthMax,
distRuntimeByteGrowth,
distRuntimeByteGrowthMax: options.distRuntimeByteGrowthMax,
distRuntimeAddedPaths,
addedPaths: diff.added.length,
removedPaths: diff.removed.length,
watchExit: watchResult.exit,
timing: watchResult.timing,
};
fs.writeFileSync(
path.join(options.outputDir, "summary.json"),
`${JSON.stringify(summary, null, 2)}\n`,
);
console.log(JSON.stringify(summary, null, 2));
const failures = [];
if (distRuntimeFileGrowth > options.distRuntimeFileGrowthMax) {
failures.push(
`dist-runtime file growth ${distRuntimeFileGrowth} exceeded max ${options.distRuntimeFileGrowthMax}`,
);
}
if (distRuntimeByteGrowth > options.distRuntimeByteGrowthMax) {
failures.push(
`dist-runtime apparent byte growth ${distRuntimeByteGrowth} exceeded max ${options.distRuntimeByteGrowthMax}`,
);
}
if (!Number.isFinite(cpuMs)) {
failures.push("failed to parse CPU timing from the bounded gateway:watch run");
} else if (cpuMs > options.cpuFailMs) {
failures.push(
`LOUD ALARM: gateway:watch used ${cpuMs}ms CPU in ${options.windowMs}ms window, above loud-alarm threshold ${options.cpuFailMs}ms`,
);
} else if (cpuMs > options.cpuWarnMs) {
failures.push(
`gateway:watch used ${cpuMs}ms CPU in ${options.windowMs}ms window, above target ${options.cpuWarnMs}ms`,
);
}
if (failures.length > 0) {
for (const message of failures) {
fail(message);
}
fail(
"Possible duplicate dist-runtime graph regression: this can reintroduce split runtime personalities where plugins and core observe different global state, including Telegram missing /voice, /phone, or /pair.",
);
process.exit(1);
}
process.exit(0);
}
await main();