mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-19 14:00:51 +00:00
465 lines
13 KiB
JavaScript
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();
|