Files
NetMesh/scripts/xterm-keyword-highlight-throughput.live.test.cjs
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

367 lines
16 KiB
JavaScript

"use strict";
/* global process, __dirname, console */
if (!process.versions.electron) {
const test = require("node:test");
test("keyword highlighting keeps sustained output responsive", {
skip: "run with Electron so the real WebGL renderer is available",
}, () => {});
} else {
const assert = require("node:assert/strict");
const childProcess = require("node:child_process");
const fs = require("node:fs");
const path = require("node:path");
const electron = require("electron");
const esbuild = require("esbuild");
const tempDirBridge = require("../electron/bridges/tempDirBridge.cjs");
const appRoot = path.resolve(__dirname, "..");
const mainRef = process.env.NETCATTY_TERMINAL_PERF_MAIN_REF ?? "origin/main";
const chunkCount = Number.parseInt(process.env.NETCATTY_TERMINAL_PERF_CHUNKS ?? "1600", 10);
const roundCount = Number.parseInt(process.env.NETCATTY_TERMINAL_PERF_ROUNDS ?? "3", 10);
const scrollback = Number.parseInt(
process.env.NETCATTY_TERMINAL_PERF_SCROLLBACK ?? "50000",
10,
);
const userData = fs.mkdtempSync(`${tempDirBridge.getTempFilePath("xterm-highlight-throughput")}-`);
electron.app.setPath("userData", userData);
electron.app.commandLine.appendSwitch("js-flags", "--expose-gc");
electron.app.on("window-all-closed", () => {});
let window = null;
const cleanup = (exitCode) => {
if (window && !window.isDestroyed()) window.destroy();
try {
fs.rmSync(userData, { recursive: true, force: true });
} catch (error) {
console.warn("Unable to remove xterm throughput test data:", error);
} finally {
electron.app.exit(exitCode);
}
};
const buildModule = (source, resolveDir, plugins = []) => esbuild.buildSync({
stdin: { contents: source, loader: "ts", resolveDir },
bundle: true,
format: "cjs",
platform: "browser",
target: "chrome142",
write: false,
plugins,
}).outputFiles[0].text;
const buildMainModule = async () => {
const entryPath = "components/terminal/__keywordHighlightThroughputEntry.ts";
const entrySource = [
'export * from "./keywordHighlight";',
'export { noteTerminalOutputPressureData } from "./runtime/terminalOutputPressure";',
].join("\n");
const resolveMainFile = (repoPath) => {
const candidates = repoPath === entryPath
? [entryPath]
: [repoPath, `${repoPath}.ts`, `${repoPath}.tsx`, `${repoPath}/index.ts`, `${repoPath}/index.tsx`];
for (const candidate of candidates) {
if (candidate === entryPath) return candidate;
const exists = childProcess.spawnSync(
"git",
["cat-file", "-e", `${mainRef}:${candidate}`],
{ cwd: appRoot, stdio: "ignore" },
).status === 0;
if (exists) return candidate;
}
throw new Error(`Unable to resolve ${mainRef} source: ${repoPath}`);
};
const readMainFile = (repoPath) => repoPath === entryPath
? entrySource
: childProcess.execFileSync("git", ["show", `${mainRef}:${repoPath}`], {
cwd: appRoot,
encoding: "utf8",
});
const mainPlugin = {
name: "main-source",
setup(build) {
build.onResolve({ filter: /.*/ }, (args) => {
if (args.kind !== "entry-point") return undefined;
return { path: entryPath, namespace: "main-source" };
});
build.onResolve({ filter: /^\.\.?\// }, (args) => ({
path: resolveMainFile(
path.posix.normalize(path.posix.join(path.posix.dirname(args.importer), args.path)),
),
namespace: "main-source",
}));
build.onLoad({ filter: /.*/, namespace: "main-source" }, (args) => ({
contents: readMainFile(args.path),
loader: args.path.endsWith(".json") ? "json" : args.path.endsWith(".tsx") ? "tsx" : "ts",
resolveDir: path.posix.dirname(args.path),
}));
},
};
return (await esbuild.build({
entryPoints: [entryPath],
bundle: true,
format: "cjs",
platform: "browser",
target: "chrome142",
write: false,
plugins: [mainPlugin],
})).outputFiles[0].text;
};
void electron.app.whenReady().then(async () => {
const oldBundle = await buildMainModule();
const currentBundle = buildModule([
`export * from ${JSON.stringify(path.join(appRoot, "components/terminal/keywordHighlight.ts"))};`,
`export { noteTerminalOutputPressureData } from ${JSON.stringify(path.join(appRoot, "components/terminal/runtime/terminalOutputPressure.ts"))};`,
].join("\n"), appRoot);
window = new electron.BrowserWindow({
show: process.env.NETCATTY_TERMINAL_PERF_SHOW_WINDOW === "1",
width: 1000,
height: 640,
paintWhenInitiallyHidden: true,
webPreferences: {
backgroundThrottling: false,
contextIsolation: false,
nodeIntegration: true,
sandbox: false,
},
});
await window.loadURL(
"data:text/html;charset=utf-8," + encodeURIComponent(
"<!doctype html><style>html,body,#terminal{width:920px;height:560px;margin:0}</style><div id=terminal></div>",
),
);
const xtermPath = require.resolve("@xterm/xterm", { paths: [appRoot] });
const webglPath = require.resolve("@xterm/addon-webgl", { paths: [appRoot] });
const result = await window.webContents.executeJavaScript(`(async () => {
const { Terminal } = require(${JSON.stringify(xtermPath)});
const { WebglAddon } = require(${JSON.stringify(webglPath)});
const loadBundle = source => {
const loaded = { exports: {} };
((module, exports) => { eval(source); })(loaded, loaded.exports);
return loaded.exports;
};
const oldModule = loadBundle(${JSON.stringify(oldBundle)});
const currentModule = loadBundle(${JSON.stringify(currentBundle)});
const rules = [
{ id: "info", label: "Info", patterns: ["INFO"], color: "#60A5FA", enabled: true },
{ id: "warn", label: "Warn", patterns: ["WARN"], color: "#FBBF24", enabled: true },
{ id: "error", label: "Error", patterns: ["ERROR", "failed"], color: "#F87171", enabled: true },
{ id: "ip", label: "IP", patterns: ["10\\\\.2\\\\.\\\\d+\\\\.\\\\d+"], color: "#4ADE80", enabled: true },
];
const makeChunk = index => {
let chunk = "";
for (let line = 0; line < 64; line += 1) {
chunk += "2026-08-13 INFO worker=" + (line % 32) + " WARN ERROR failed from 10.2."
+ ((index + line) % 255) + "." + ((index * 7 + line) % 255) + " payload="
+ "x".repeat(24) + "\\r\\n";
}
return chunk;
};
const chunks = Array.from({ length: ${chunkCount} }, (_, index) => makeChunk(index));
const totalChars = chunks.reduce((total, chunk) => total + chunk.length, 0);
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
const run = async kind => {
document.getElementById("terminal").replaceChildren();
globalThis.gc?.();
await wait(20);
const v8 = require("node:v8");
const heapBefore = v8.getHeapStatistics().used_heap_size;
const term = new Terminal({
allowProposedApi: true,
cols: 120,
cursorBlink: false,
rows: 40,
scrollback: ${scrollback},
});
term.open(document.getElementById("terminal"));
let renderer = "dom";
try {
term.loadAddon(new WebglAddon());
renderer = "webgl";
} catch {}
const selectedModule = kind === "old" ? oldModule : currentModule;
const Highlighter = kind === "old"
? oldModule.KeywordHighlighter
: currentModule.KeywordHighlighter;
const highlighter = kind === "raw" ? null : new Highlighter(term);
highlighter?.setRules(rules, true);
const sustainedOnly = process.env.NETCATTY_TERMINAL_PERF_SUSTAINED_ONLY === "1";
let maxPendingPristineBytes = 0;
const pendingSample = setInterval(() => {
maxPendingPristineBytes = Math.max(
maxPendingPristineBytes,
highlighter?.pendingPristineBytes ?? 0,
);
}, 5);
const write = data => new Promise(resolve => term.write(data, resolve));
const callbackLatencies = [];
const heartbeatLatencies = [];
let heartbeatAt = performance.now();
const heartbeat = setInterval(() => {
const now = performance.now();
heartbeatLatencies.push(now - heartbeatAt);
heartbeatAt = now;
}, 10);
let renders = 0;
const renderDisposable = term.onRender(() => { renders += 1; });
const streamStarted = performance.now();
for (let index = 0; index < chunks.length; index += 1) {
const chunk = chunks[index];
selectedModule.noteTerminalOutputPressureData(term, chunk);
const callbackStarted = performance.now();
await write(chunk);
callbackLatencies.push(performance.now() - callbackStarted);
if (index % 16 === 15) await wait(0);
}
const streamMs = performance.now() - streamStarted;
const rendersAtStreamEnd = renders;
const quietStarted = performance.now();
await wait(sustainedOnly ? 0 : 700);
const quietDelayMs = performance.now() - quietStarted;
const streamHeartbeatCount = heartbeatLatencies.length;
const streamMaxHeartbeatMs = Math.max(0, ...heartbeatLatencies);
const rendersBeforeSettle = renders;
const settleStarted = performance.now();
if (!sustainedOnly) await highlighter?.whenSettled?.();
const settleWaitMs = performance.now() - settleStarted;
const rendersDuringSettle = renders - rendersBeforeSettle;
const quietWorkMs = performance.now() - quietStarted - (sustainedOnly ? 0 : 700);
let paintTimedOut = false;
if (!sustainedOnly) {
await Promise.race([
new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))),
wait(1000).then(() => { paintTimedOut = true; }),
]);
}
const rendersAfterSettle = renders - rendersBeforeSettle - rendersDuringSettle;
const quietCatchUpMs = performance.now() - quietStarted - (sustainedOnly ? 0 : 700);
clearInterval(heartbeat);
clearInterval(pendingSample);
globalThis.gc?.();
await wait(20);
const heapAfter = v8.getHeapStatistics().used_heap_size;
callbackLatencies.sort((left, right) => left - right);
const percentile = value => callbackLatencies[Math.min(
callbackLatencies.length - 1,
Math.floor(callbackLatencies.length * value),
)];
const state = {
kind,
renderer,
streamMs,
mibPerSecond: totalChars / 1024 / 1024 / (streamMs / 1000),
callbackP50Ms: percentile(0.5),
callbackP95Ms: percentile(0.95),
callbackP99Ms: percentile(0.99),
maxHeartbeatMs: streamMaxHeartbeatMs,
maxCatchUpHeartbeatMs: Math.max(0, ...heartbeatLatencies.slice(streamHeartbeatCount)),
quietCatchUpMs,
quietWorkMs,
quietDelayMs,
settleWaitMs,
paintTimedOut,
rendersDuringStream: rendersAtStreamEnd,
rendersDuringSettle,
rendersAfterSettle,
heapDeltaMiB: (heapAfter - heapBefore) / 1024 / 1024,
rebuildCount: highlighter?.rebuildCount ?? 0,
rebuildTimings: highlighter?.lastRebuildTimings ?? {},
maxPendingPristineBytes,
};
renderDisposable.dispose();
highlighter?.dispose();
term.dispose();
return state;
};
const rounds = [];
for (let round = 0; round < ${roundCount}; round += 1) {
for (const kind of ["raw", "old", "new"]) rounds.push(await run(kind));
}
return { totalChars, chunks: chunks.length, rounds };
})()`, true);
if (process.env.NETCATTY_TERMINAL_PERF_REQUIRE_WEBGL === "1") {
for (const round of result.rounds) assert.equal(round.renderer, "webgl", JSON.stringify(round));
}
const median = values => values.sort((left, right) => left - right)[Math.floor(values.length / 2)];
const byKind = kind => result.rounds.filter(round => round.kind === kind);
const oldStreamMs = median(byKind("old").map(round => round.streamMs));
const newStreamMs = median(byKind("new").map(round => round.streamMs));
const oldP99Ms = median(byKind("old").map(round => round.callbackP99Ms));
const newP99Ms = median(byKind("new").map(round => round.callbackP99Ms));
const oldHeartbeatMs = median(byKind("old").map(round => round.maxHeartbeatMs));
const newHeartbeatMs = median(byKind("new").map(round => round.maxHeartbeatMs));
const newCatchUpHeartbeatMs = median(byKind("new").map(round => round.maxCatchUpHeartbeatMs));
const newQuietCatchUpMs = median(byKind("new").map(round => round.quietCatchUpMs));
// Catch-up is deliberately deferred until output becomes quiet. Keep a
// strict event-loop stall limit, while allowing total work to scale with
// the retained history size (5s at 10k lines, 10s at 50k lines).
const maxQuietCatchUpMs = Math.max(5000, scrollback / 5);
const rawStreamMs = median(byKind("raw").map(round => round.streamMs));
const rawP99Ms = median(byKind("raw").map(round => round.callbackP99Ms));
assert.ok(
newStreamMs <= oldStreamMs * 1.1,
`new sustained throughput regressed more than 10%: ${JSON.stringify(result)}`,
);
assert.ok(
newP99Ms <= oldP99Ms * 1.15,
`new p99 write latency regressed more than 15%: ${JSON.stringify(result)}`,
);
assert.ok(
newHeartbeatMs <= Math.max(75, oldHeartbeatMs * 3),
`new event-loop stall regressed: ${JSON.stringify(result)}`,
);
assert.ok(
newCatchUpHeartbeatMs <= 350,
`quiet catch-up blocked the event loop for over 350 ms: ${JSON.stringify(result)}`,
);
// Product gate is vs main decorations (10% above). Raw xterm is a sanity
// bound only: cell-color wrap + DOM CI is typically ~15-18% over raw.
assert.ok(
newStreamMs <= rawStreamMs * 1.25,
`new sustained throughput regressed more than 25% versus raw xterm: ${JSON.stringify(result)}`,
);
assert.ok(
newP99Ms <= Math.max(10, rawP99Ms * 1.25),
`new p99 write latency regressed versus raw xterm: ${JSON.stringify(result)}`,
);
assert.ok(
newQuietCatchUpMs <= maxQuietCatchUpMs,
`quiet-period catch-up exceeded ${maxQuietCatchUpMs} ms: ${JSON.stringify(result)}`,
);
if (process.env.NETCATTY_TERMINAL_PERF_SUSTAINED_ONLY !== "1") {
assert.equal(
byKind("new").every(round => round.rebuildCount === 1),
true,
`bulk output must catch up exactly once after becoming quiet: ${JSON.stringify(result)}`,
);
assert.equal(
byKind("new").every(round => !round.paintTimedOut && round.rendersDuringSettle <= 1),
true,
`quiet catch-up must repaint atomically: ${JSON.stringify(result)}`,
);
assert.equal(
byKind("new").every(round => (round.rendersAfterSettle ?? 0) <= 1),
true,
`quiet catch-up must not keep painting after settle: ${JSON.stringify(result)}`,
);
}
assert.equal(
byKind("new").every(round => round.maxPendingPristineBytes <= 12 * 1024 * 1024),
true,
`pristine backlog must stay bounded: ${JSON.stringify(result)}`,
);
process.stdout.write(`XTERM_KEYWORD_HIGHLIGHT_THROUGHPUT ${JSON.stringify(result)}\n`);
cleanup(0);
}).catch((error) => {
console.error(error);
cleanup(1);
});
}