mirror of
https://github.com/multica-ai/multica.git
synced 2026-09-28 05:13:45 +08:00
* test(perf): measure comment typing while runs stream (MUL-7227) The regression that made typing a comment take tens of seconds was a summary animation replacing a DOM node per streamed message, and nothing we had could see it. The component suite runs in jsdom, which has no style recalculation, no layout and no main-thread contention, so several thousand passing tests said nothing about the thing users felt. #8260 pinned the DOM node reuse; this pins what the user actually waits for. One browser scenario, on the real page: app shell, shared CSS, the ProseMirror composer, inline runs, a production Next.js build. Only HTTP and the WebSocket are synthetic, so no API server, database, daemon, agent or account is involved. Nothing disables animations or trims the DOM — the cost being measured lives in exactly that machinery. Fixed workload, identical for both builds under comparison: 80 comments with prose and code, three running agents, 1000 seeded transcript messages each, 60 ticks of live messages 100ms apart, and 172 keystrokes at 25ms. Messages are scheduled from Node rather than the page, so a stalling build cannot quietly measure less work than the one it is being compared against. `typing_elapsed_ms` is what the user waits for: first keystroke to the editor holding the whole string, main-thread queueing included. Style recalculation, layout, task time and long tasks say where it went. An empty page types very fast, so a run whose messages never reached the UI, or whose composer did not end up holding every character, is reported as `invalid` or `timeout` and never as a duration — including when the scenario blows its budget, which is the case whose numbers matter most. Reports rather than gates. `scripts/perf-compare.mjs` builds and measures two refs in sequence on one machine, each installed against its own lockfile, with the spec, fixture and browser always taken from the running tree so the product is the only difference. The workflow is separate and not required: one sample per ref cannot separate a small regression from machine noise, and the noise floor has not been calibrated on CI hardware yet. Verified against the known regression: restoring the pre-#8260 summary animation moves style recalculation from ~130ms to 1577ms and typing from ~4.9s to 6.2s, against a spread of ~1% across three runs of the fixed build. Disconnecting the message injection reports `timeout: run 0 end marker`, and typing fewer characters reports `timeout: editor content` — neither can pass as fast. #8260's DOM reuse test is untouched and still green. Tests and run configuration only: no product change. Co-authored-by: multica-agent <github@multica.ai> * test(perf): always build for real when comparing two refs (MUL-7227) The comparison reports a build time, and turbo's cache made it read as ~1.7s when the real build is ~60s — understating what this costs CI by a factor of thirty. Worse, a run where one ref hit the cache and the other missed would put that difference in the timing split as if it belonged to the products. `--force` on both sides: two real builds, two comparable numbers. Co-authored-by: multica-agent <github@multica.ai> * test(perf): report cached builds instead of forcing real ones (MUL-7227) Forcing a real build on both sides cost minutes on every local run to remove an ambiguity that a single line of output removes instead. A restored build is byte-identical to the one that produced it, so the cache cannot move the numbers this script collects — only the build time it reports, which is now labelled when it came from the cache. It buys nothing on CI either: the runner is cold every time, so both builds are real whether or not they are forced. Locally a repeat comparison drops from roughly three minutes to under one. Co-authored-by: multica-agent <github@multica.ai> * test(perf): run the comparison on demand, and let it exit (MUL-7227) The comparison never exited on success. The frontend server is spawned detached, and its teardown hung off `process.on("exit")` — but a live child keeps the event loop running, so `exit` never fired and the child was never killed. The report was written and the process then waited on the server it was supposed to stop. On CI that ran until the job was cancelled at sixty minutes; locally it left the servers running after being killed. The failure path only terminated because it happened to call `process.exit(1)`. Teardown is explicit now. Each side stops its server, waits until the process has exited and the port has stopped answering, and removes its worktree before the next side starts — so the second build is also measured on a machine with nothing of the first one running. The script then exits explicitly, and the `exit` and signal handlers remain only as a last resort for cancellation. Checked on every path, with a harness that records the gap between the report and the exit and looks for anything left behind: success, a scenario failure with a server running, an unknown ref, and SIGTERM mid-run all exit promptly with no server, worktree or temp directory remaining. Run through the same harness, the previous script wrote its report, then was still running 247s later and left three servers and two worktrees behind. The workflow is manual only, capped at fifteen minutes. On CI one comparison took 340s — 292s of it the two production builds, 26s the two measurements — which is a lot to spend on every frontend PR for a report that gates nothing. The routine guard stays the component test from #8260, which pins the exact failure mode and runs with the normal suite; this is for changes that touch summary animation, long lists, transcript rendering or global styles. No change to the scenario or its metrics. Co-authored-by: multica-agent <github@multica.ai> * test(perf): only trust this run's report, and wait for the whole server (MUL-7227) Two ways the comparison could report something that was not true. A report left in the output directory by an earlier run was read back when this run's scenario failed before writing one, so two failed scenarios showed the previous run's timings, were marked a usable comparison, and exited 0. And a scenario that wrote `ok` and then failed was passed through too: the exit code was recorded as `spec_failed` and then ignored. Each side's report is now deleted before the scenario runs, the files this script writes are cleared up front, and a failing scenario process overrides an `ok` report — whatever failed after the numbers were written is exactly what nobody has looked at. And a server that stopped answering was taken for one that had stopped. Any fetch error counted as a closed port, a timeout included, and the server was dropped from the last-resort cleanup before anything had been confirmed. With the pnpm wrapper gone but a child still holding the port, the script exited 0 and left two servers running. Teardown now waits for the whole process group to exit — escalating to SIGKILL if it outlives SIGTERM — keeps the server registered until then, and treats the port as free only when a connection is refused, since a hung server still completes the handshake. `scripts/perf-compare.test.sh` drives the real script with a fake pnpm — no build, no browser, 9 seconds — through success, a scenario that writes nothing, a stale report, `ok` followed by failure, a child that ignores SIGTERM, and an unknown ref. Each of the three new cases fails against the previous script for the reason above. It runs in `frontend-build` like the other script tests; the browser comparison itself stays manual. `PERF_STOP_GRACE_MS` sets the SIGTERM grace (default 10s) so the test can exercise the SIGKILL path without waiting on it. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai>
353 lines
14 KiB
JavaScript
Executable File
353 lines
14 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
/**
|
|
* Run the comment-typing performance scenario against two refs and report the
|
|
* difference (MUL-7227).
|
|
*
|
|
* Each ref is checked out, installed and built on its own, then measured once,
|
|
* sequentially, on this machine — one build must never be measured while the
|
|
* other is compiling. The spec, the fixture and the browser always come from
|
|
* the working tree this script runs in, so the two products are the only thing
|
|
* that differs; the base ref does not need to contain the test at all.
|
|
*
|
|
* One sample per ref is a report, not a verdict. A difference near the noise
|
|
* floor means "run it again by hand", not "regression".
|
|
*
|
|
* node scripts/perf-compare.mjs --base <ref> --head <ref> [--out <dir>]
|
|
*/
|
|
import { execFileSync, spawn } from "node:child_process";
|
|
import { mkdtempSync, mkdirSync, rmSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join, resolve } from "node:path";
|
|
import { connect, createServer } from "node:net";
|
|
|
|
const repoRoot = execFileSync("git", ["rev-parse", "--show-toplevel"]).toString().trim();
|
|
const args = process.argv.slice(2);
|
|
const flag = (name, fallback) => {
|
|
const at = args.indexOf(`--${name}`);
|
|
return at >= 0 && args[at + 1] ? args[at + 1] : fallback;
|
|
};
|
|
const baseRef = flag("base");
|
|
const headRef = flag("head", "HEAD");
|
|
const outDir = resolve(flag("out", join(repoRoot, "perf-report")));
|
|
if (!baseRef) {
|
|
console.error("usage: node scripts/perf-compare.mjs --base <ref> [--head <ref>] [--out <dir>]");
|
|
process.exit(2);
|
|
}
|
|
|
|
const run = (cmd, cmdArgs, opts = {}) =>
|
|
execFileSync(cmd, cmdArgs, { stdio: "inherit", ...opts });
|
|
|
|
const delay = (ms) => new Promise((done) => setTimeout(done, ms));
|
|
|
|
/**
|
|
* Servers and checkouts still alive. Each side stops its own before the next
|
|
* one starts; these sets exist only for the last-resort handlers below.
|
|
*/
|
|
const liveServers = new Set();
|
|
const liveCheckouts = new Set();
|
|
|
|
const killGroup = (child, signal) => {
|
|
// Detached, so the server leads its own process group: signalling the group
|
|
// reaches `next-server` as well as the pnpm wrapper that started it.
|
|
try { process.kill(-child.pid, signal); } catch { /* already gone */ }
|
|
};
|
|
|
|
function removeCheckout(checkout) {
|
|
liveCheckouts.delete(checkout);
|
|
try { run("git", ["worktree", "remove", "--force", checkout], { cwd: repoRoot, stdio: "ignore" }); }
|
|
catch { rmSync(checkout, { recursive: true, force: true }); }
|
|
}
|
|
|
|
/** True while any process in the group is still alive. */
|
|
const groupAlive = (pgid) => {
|
|
try {
|
|
process.kill(-pgid, 0);
|
|
return true;
|
|
} catch (error) {
|
|
// ESRCH: no process left in the group. EPERM would mean one exists that
|
|
// we may not signal — alive, as far as teardown is concerned.
|
|
return error.code === "EPERM";
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Whether anything still holds the port. Only a refused connection counts as
|
|
* closed: a server that has stopped answering still accepts the handshake, and
|
|
* a timeout proves nothing either way.
|
|
*/
|
|
const portBound = (port) =>
|
|
new Promise((done) => {
|
|
const socket = connect({ port, host: "127.0.0.1" });
|
|
const settle = (bound) => { socket.destroy(); done(bound); };
|
|
socket.setTimeout(1_000, () => settle(true));
|
|
socket.once("connect", () => settle(true));
|
|
socket.once("error", (error) => settle(error.code !== "ECONNREFUSED"));
|
|
});
|
|
|
|
async function groupExitWithin(pgid, ms) {
|
|
const deadline = Date.now() + ms;
|
|
while (groupAlive(pgid)) {
|
|
if (Date.now() > deadline) return false;
|
|
await delay(100);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Stop a server and wait until every process it started is gone.
|
|
*
|
|
* The pnpm wrapper exiting proves nothing about `next-server` beneath it, so
|
|
* this waits on the whole process group, escalating to SIGKILL if the group
|
|
* outlives SIGTERM. The server stays registered for the last-resort handlers
|
|
* until the group is confirmed gone: dropping it any earlier is what let a
|
|
* surviving child outlive the script. Then the port must refuse connections —
|
|
* not merely stop answering.
|
|
*
|
|
* Teardown is explicit rather than hung off `process.on("exit")`: a live child
|
|
* keeps the event loop running, so `exit` would never fire and the child would
|
|
* never be killed — on CI, until the job was cancelled at sixty minutes.
|
|
*/
|
|
/**
|
|
* How long a server gets to exit on SIGTERM before it is killed. Next exits in
|
|
* well under a second; the margin is for a loaded machine. Overridable so the
|
|
* lifecycle test can prove the SIGKILL path without waiting on it.
|
|
*/
|
|
const STOP_GRACE_MS = Number(process.env.PERF_STOP_GRACE_MS) || 10_000;
|
|
|
|
async function stopServer(child, port) {
|
|
const pgid = child.pid;
|
|
killGroup(child, "SIGTERM");
|
|
if (!(await groupExitWithin(pgid, STOP_GRACE_MS))) {
|
|
killGroup(child, "SIGKILL");
|
|
if (!(await groupExitWithin(pgid, 5_000))) {
|
|
throw new Error(`server process group ${pgid} survived SIGKILL`);
|
|
}
|
|
}
|
|
liveServers.delete(child);
|
|
|
|
const deadline = Date.now() + 5_000;
|
|
while (await portBound(port)) {
|
|
if (Date.now() > deadline) {
|
|
throw new Error(`port ${port} still bound after its server's process group exited`);
|
|
}
|
|
await delay(200);
|
|
}
|
|
}
|
|
|
|
// Last resort only, for a signal (a cancelled CI job) or an exit that happens
|
|
// before a side finished tearing itself down. Both handlers run synchronously
|
|
// and cannot await, which is exactly why the normal path does not use them.
|
|
const emergencyStop = () => {
|
|
for (const child of liveServers) killGroup(child, "SIGKILL");
|
|
liveServers.clear();
|
|
for (const checkout of [...liveCheckouts]) removeCheckout(checkout);
|
|
};
|
|
process.on("exit", emergencyStop);
|
|
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
process.on(signal, () => { emergencyStop(); process.exit(130); });
|
|
}
|
|
|
|
const freePort = () =>
|
|
new Promise((done, fail) => {
|
|
const probe = createServer();
|
|
probe.on("error", fail);
|
|
probe.listen(0, "127.0.0.1", () => {
|
|
const { port } = probe.address();
|
|
probe.close(() => done(port));
|
|
});
|
|
});
|
|
|
|
const waitForServer = async (port, timeoutMs = 120_000) => {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
const response = await fetch(`http://127.0.0.1:${port}/`);
|
|
if (response.status > 0) return;
|
|
} catch { /* not up yet */ }
|
|
await new Promise((done) => setTimeout(done, 500));
|
|
}
|
|
throw new Error(`frontend on :${port} did not become reachable`);
|
|
};
|
|
|
|
const seconds = (from) => Math.round((Date.now() - from) / 100) / 10;
|
|
|
|
async function measure(ref, label) {
|
|
const sha = execFileSync("git", ["rev-parse", ref], { cwd: repoRoot }).toString().trim();
|
|
const checkout = mkdtempSync(join(tmpdir(), `perf-${label}-`));
|
|
liveCheckouts.add(checkout);
|
|
let server;
|
|
let port;
|
|
// Everything this side started is torn down before it returns or throws, so
|
|
// the next side is measured on a machine with nothing of this one running.
|
|
try {
|
|
console.log(`\n=== ${label}: ${ref} (${sha.slice(0, 9)}) ===`);
|
|
run("git", ["worktree", "add", "--detach", checkout, sha], { cwd: repoRoot });
|
|
|
|
// Each product installs against its own lockfile: a build measured with the
|
|
// other ref's dependency tree is not that ref.
|
|
const installStart = Date.now();
|
|
run("pnpm", ["install", "--frozen-lockfile"], { cwd: checkout });
|
|
const installS = seconds(installStart);
|
|
|
|
// REMOTE_API_URL is a runtime setting. Passing it to the build breaks
|
|
// prerendering, and turbo filters it out of the build env anyway.
|
|
//
|
|
// The cache is left on. A restored build is byte-identical to the one that
|
|
// produced it, so it cannot move the numbers this script collects — only the
|
|
// build time it reports. Saying which side was cached costs nothing; forcing
|
|
// two real builds to avoid the ambiguity costs minutes on every local run,
|
|
// and buys nothing on CI, where the runner is always cold anyway.
|
|
const buildStart = Date.now();
|
|
const buildLog = execFileSync(
|
|
"pnpm",
|
|
["exec", "turbo", "build", "--filter=@multica/web"],
|
|
{ cwd: checkout, encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] },
|
|
);
|
|
process.stdout.write(buildLog);
|
|
const buildS = seconds(buildStart);
|
|
const buildCached = /cache hit/.test(buildLog);
|
|
|
|
port = await freePort();
|
|
const startStart = Date.now();
|
|
server = spawn("pnpm", ["--filter", "@multica/web", "start"], {
|
|
cwd: checkout,
|
|
env: { ...process.env, PORT: String(port), REMOTE_API_URL: "http://127.0.0.1:1" },
|
|
stdio: "ignore",
|
|
detached: true,
|
|
});
|
|
liveServers.add(server);
|
|
await waitForServer(port);
|
|
const startS = seconds(startStart);
|
|
|
|
// The spec, fixture and browser come from this working tree, not the ref's.
|
|
const reportPath = join(outDir, `${label}.json`);
|
|
// Only a report this run wrote may be read back. A file left by an earlier
|
|
// run into the same directory would otherwise stand in for a scenario that
|
|
// failed before writing one — stale numbers, presented as today's.
|
|
rmSync(reportPath, { force: true });
|
|
const measureStart = Date.now();
|
|
let scenarioExit = 0;
|
|
try {
|
|
run("pnpm", ["exec", "playwright", "test", "--config=playwright.perf.config.ts"], {
|
|
cwd: repoRoot,
|
|
env: {
|
|
...process.env,
|
|
PLAYWRIGHT_BASE_URL: `http://127.0.0.1:${port}`,
|
|
PERF_REPORT_PATH: reportPath,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
scenarioExit = typeof error.status === "number" ? error.status : 1;
|
|
}
|
|
const measureS = seconds(measureStart);
|
|
|
|
let report;
|
|
try {
|
|
report = JSON.parse(readFileSync(reportPath, "utf8"));
|
|
} catch {
|
|
report = { status: "invalid", invalid: ["the scenario produced no readable report"] };
|
|
}
|
|
// The scenario process has the last word. A report that says `ok` from a
|
|
// run whose process then failed is not a sample: whatever failed after the
|
|
// numbers were written is exactly what nobody has looked at.
|
|
if (scenarioExit !== 0 && report.status === "ok") {
|
|
report.status = "invalid";
|
|
report.invalid = [
|
|
...(report.invalid ?? []),
|
|
`the scenario reported ok but its process exited ${scenarioExit}`,
|
|
];
|
|
}
|
|
const failed = scenarioExit !== 0;
|
|
return {
|
|
...report, ref, sha, spec_failed: failed, build_cached: buildCached,
|
|
timings_s: { install: installS, build: buildS, start: startS, measure: measureS },
|
|
};
|
|
} finally {
|
|
if (server) await stopServer(server, port);
|
|
removeCheckout(checkout);
|
|
}
|
|
}
|
|
|
|
const METRICS = [
|
|
["typing_elapsed_ms", "Typing elapsed (primary)"],
|
|
["scenario_elapsed_ms", "Scenario elapsed"],
|
|
["recalc_style_ms", "Style recalculation"],
|
|
["layout_ms", "Layout"],
|
|
["task_ms", "Main-thread task time"],
|
|
["long_task_count", "Long tasks"],
|
|
["long_task_total_ms", "Long task total"],
|
|
["long_task_max_ms", "Long task max"],
|
|
];
|
|
|
|
function markdown(base, head) {
|
|
const usable = base.status === "ok" && head.status === "ok";
|
|
const rows = METRICS.map(([key, label]) => {
|
|
const a = base[key];
|
|
const b = head[key];
|
|
if (typeof a !== "number" || typeof b !== "number") return `| ${label} | ${a ?? "—"} | ${b ?? "—"} | — | — |`;
|
|
const delta = b - a;
|
|
const ratio = a === 0 ? "N/A" : `${((b / a - 1) * 100).toFixed(1)}%`;
|
|
return `| ${label} | ${a} | ${b} | ${delta >= 0 ? "+" : ""}${delta} | ${ratio} |`;
|
|
});
|
|
const timing = (r) =>
|
|
`install ${r.timings_s.install}s · build ${r.timings_s.build}s${r.build_cached ? " (restored from cache — not a real build)" : ""} · start ${r.timings_s.start}s · measure ${r.timings_s.measure}s`;
|
|
return [
|
|
"## Comment typing under live runs (MUL-7227)",
|
|
"",
|
|
usable
|
|
? "One sample per ref. This is a report, not a merge gate — read a small difference as noise until a second run says otherwise."
|
|
: `**Not a usable comparison.** base: \`${base.status}\`, head: \`${head.status}\`.`,
|
|
"",
|
|
"| Metric | base | head | Δ | ratio |",
|
|
"| --- | ---: | ---: | ---: | ---: |",
|
|
...rows,
|
|
"",
|
|
`- base \`${base.ref}\` (${base.sha.slice(0, 9)}) — ${base.status}${base.invalid?.length ? `: ${base.invalid.join("; ")}` : ""}`,
|
|
`- head \`${head.ref}\` (${head.sha.slice(0, 9)}) — ${head.status}${head.invalid?.length ? `: ${head.invalid.join("; ")}` : ""}`,
|
|
`- fixture \`${head.fixture?.version}\` sha256 \`${head.fixture?.sha256}\` (${head.fixture?.bytes} bytes); same digest on base: ${base.fixture?.sha256 === head.fixture?.sha256}`,
|
|
`- DOM nodes: base ${base.dom_nodes ?? "—"}, head ${head.dom_nodes ?? "—"}; ticks sent: base ${base.ticks_sent ?? "—"}, head ${head.ticks_sent ?? "—"}`,
|
|
`- node ${head.node_version ?? "—"}, chromium ${head.browser_version ?? "—"}`,
|
|
`- base timings: ${timing(base)}`,
|
|
`- head timings: ${timing(head)}`,
|
|
].join("\n");
|
|
}
|
|
|
|
mkdirSync(outDir, { recursive: true });
|
|
// The files this script writes, cleared up front: a comparison that aborts
|
|
// early must not leave the previous run's summary looking like its own.
|
|
for (const name of ["base.json", "head.json", "comparison.json", "comparison.md"]) {
|
|
rmSync(join(outDir, name), { force: true });
|
|
}
|
|
const totalStart = Date.now();
|
|
let exitCode = 1;
|
|
let base;
|
|
let head;
|
|
try {
|
|
base = await measure(baseRef, "base");
|
|
head = await measure(headRef, "head");
|
|
const totalS = seconds(totalStart);
|
|
|
|
const summary = markdown(base, head);
|
|
writeFileSync(join(outDir, "comparison.json"), JSON.stringify({ base, head, total_s: totalS }, null, 2));
|
|
writeFileSync(join(outDir, "comparison.md"), `${summary}\n\n- total wall clock: ${totalS}s\n`);
|
|
console.log(`\n${summary}\n\n- total wall clock: ${totalS}s`);
|
|
console.log(`\nreports written to ${outDir}`);
|
|
|
|
// The comparison itself only fails when a sample is not usable. A slower
|
|
// head is information for the reviewer, not a failure of this script.
|
|
exitCode = base.status === "ok" && head.status === "ok" ? 0 : 1;
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
console.error(`\ncomparison aborted: ${message}`);
|
|
// Keep whatever was measured: the artifact is uploaded on failure too.
|
|
writeFileSync(
|
|
join(outDir, "comparison.json"),
|
|
JSON.stringify({ error: message, base: base ?? null, head: head ?? null }, null, 2),
|
|
);
|
|
}
|
|
|
|
// Explicit, whatever happened above. Each side has already stopped its server
|
|
// and removed its checkout; exiting here makes sure nothing left over — a
|
|
// keep-alive socket, a stray timer — can hold the process open again.
|
|
process.exit(exitCode);
|