Files
6af2268bd9 MUL-7227 test(perf): measure comment typing while agent runs stream (#8276)
* 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>
2026-09-11 13:30:21 +08:00

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);