#!/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 --head [--out ] [--scenario ] * * `--scenario` picks the measurement: `typing-under-live-runs` (default) or * `image-thread` (MUL-8182). Both write the same report path, so one run * measures exactly one of them. */ 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"))); const buildCache = join(repoRoot, ".turbo", "perf-build-cache"); const SCENARIOS = { "typing-under-live-runs": { title: "Comment typing under live runs (MUL-7227)", spec: "typing-under-live-runs.spec.ts", metrics: [], }, "image-thread": { title: "Comment typing beside an image-heavy thread (MUL-8182)", spec: "image-heavy-thread.spec.ts", metrics: [ ["layout_shift", "Layout shift (sum)"], ["images_mounted", "Images mounted"], ["originals_served", "Originals fetched"], ["keystrokes_over_frame", "Keystrokes over 16.7 ms"], ["key_frame_p95_ms", "Keydown to frame p95 (ms)"], ["image_bytes_served", "Image bytes fetched"], ["js_heap_mb", "JS heap (MB)"], ], }, }; const scenarioName = flag("scenario", "typing-under-live-runs"); const scenario = SCENARIOS[scenarioName]; if (!baseRef || !scenario) { console.error( `usage: node scripts/perf-compare.mjs --base [--head ] [--out ] [--scenario ${Object.keys(SCENARIOS).join("|")}]`, ); 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", `--cache-dir=${buildCache}`], { 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", `--repeat-each=${process.env.PERF_REPEATS ?? "1"}`, ...(scenario.spec ? [scenario.spec] : [])], { cwd: repoRoot, env: { ...process.env, PLAYWRIGHT_BASE_URL: `http://127.0.0.1:${port}`, PERF_REPORT_PATH: reportPath, PERF_SCENARIO: scenarioName, }, }); } 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, ...scenario.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 [ `## ${scenario.title}`, "", usable ? `${head.samples?.length ?? 1} sample(s) per ref; numeric metrics are medians. This is a report, not a merge gate — small differences may be noise.` : `**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);