返回 DeepSeek-Reasonix
run.mjs
根目录 / desktop / frontend / bench / run.mjs
1 #!/usr/bin/env node
2 // Real-DOM performance benchmark for the session-switch/history pipeline
3 // (Phase F). Drives the production build (vite preview) in real Chromium via
4 // Playwright against the ?mock=bench dev-mock fixtures (see makeMockApp in
5 // src/lib/bridge.ts): a tool-dense 38-turn session (~3.2k messages) and a
6 // markdown-heavy 46-turn session (one ~500KiB answer + oversized code block).
7 //
8 // Scenario A: cold open — surface first paint and time-to-interactive
9 // (composer enabled + first transcript slice rendered).
10 // Scenario B: N alternating switches between the two heaviest sessions —
11 // per-switch latency, long-task/event-timing stats, INP-ish input probes, and
12 // a forced-GC retained-heap + cache-budget check against the warmup baseline.
13 //
14 // Usage:
15 // pnpm build # once (the bench reuses dist/; REASONIX_BENCH_BUILD=1 forces a rebuild)
16 // pnpm test:bench
17 //
18 // Gates (env-overridable, defaults = plan values):
19 // REASONIX_BENCH_FIRST_PAINT_P95_MS (100)
20 // REASONIX_BENCH_INTERACTIVE_P95_MS (300)
21 // REASONIX_BENCH_INP_P95_MS (200)
22 // REASONIX_BENCH_LONGTASK_P95_MS (50)
23 // REASONIX_BENCH_LONGTASK_MAX_MS (500)
24 // REASONIX_BENCH_MARKDOWN_PARSE_MAX_MS (3000)
25 // REASONIX_BENCH_IDLE_CPU_PERCENT (3)
26 // REASONIX_BENCH_HEAP_GROWTH_MIB (20)
27 // REASONIX_BENCH_SWITCHES (100)
28 // REASONIX_BENCH_COLD_RUNS (5)
29 // REASONIX_BENCH_WARMUP (6)
30 // REASONIX_BENCH_PORT (4617)
31 //
32 // Exit code is 0 when every gate passes, 1 otherwise. Results are written to
33 // bench/results.json and summarized on stdout.
34
35 import { spawn } from "node:child_process";
36 import { existsSync, writeFileSync } from "node:fs";
37 import path from "node:path";
38 import { fileURLToPath } from "node:url";
39 import http from "node:http";
40 import { selectSession } from "./app-page-actions.mjs";
41
42 const frontendDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
43 // Keep the browser download inside the repo when the caller did not pin one.
44 process.env.PLAYWRIGHT_BROWSERS_PATH = !process.env.PLAYWRIGHT_BROWSERS_PATH || process.env.PLAYWRIGHT_BROWSERS_PATH === ".pw-browsers"
45 ? path.join(frontendDir, ".pw-browsers")
46 : process.env.PLAYWRIGHT_BROWSERS_PATH;
47
48 const { chromium } = await import("playwright");
49
50 function numEnv(name, fallback) {
51 const raw = process.env[name];
52 const value = raw === undefined ? NaN : Number(raw);
53 return Number.isFinite(value) && value > 0 ? value : fallback;
54 }
55
56 const GATES = {
57 firstPaintP95Ms: numEnv("REASONIX_BENCH_FIRST_PAINT_P95_MS", 100),
58 interactiveP95Ms: numEnv("REASONIX_BENCH_INTERACTIVE_P95_MS", 300),
59 inpP95Ms: numEnv("REASONIX_BENCH_INP_P95_MS", 200),
60 longTaskP95Ms: numEnv("REASONIX_BENCH_LONGTASK_P95_MS", 50),
61 longTaskMaxMs: numEnv("REASONIX_BENCH_LONGTASK_MAX_MS", 500),
62 markdownParseMaxMs: numEnv("REASONIX_BENCH_MARKDOWN_PARSE_MAX_MS", 3000),
63 idleCpuPercent: numEnv("REASONIX_BENCH_IDLE_CPU_PERCENT", 3),
64 heapGrowthMiB: numEnv("REASONIX_BENCH_HEAP_GROWTH_MIB", 20),
65 };
66 const SWITCHES = Math.round(numEnv("REASONIX_BENCH_SWITCHES", 100));
67 const COLD_RUNS = Math.round(numEnv("REASONIX_BENCH_COLD_RUNS", 5));
68 const WARMUP_SWITCHES = Math.round(numEnv("REASONIX_BENCH_WARMUP", 6));
69 const PORT = Math.round(numEnv("REASONIX_BENCH_PORT", 4617));
70
71 // Cache budgets mirrored from transcriptStore.ts defaults (asserted via the
72 // __reasonixPerf debug hook, which reports the store's own numbers).
73 const BODY_BUDGET_BYTES = 32 << 20;
74 const MARKDOWN_BUDGET_BYTES = 16 << 20;
75 const MAX_RESIDENT_SESSIONS = 3;
76
77 const PAGE_URL = `http://127.0.0.1:${PORT}/?mock=bench&bench=1`;
78 // Single-surface (workbench) layout: switches are sidebar topic clicks driving
79 // the ticketed StartTopicActivation flow. Active state is the --active class
80 // on the topic row; the transcript marker text proves the target session's
81 // first slice rendered.
82 const TAB = {
83 // Markers must be in the STUCK-TO-BOTTOM viewport (the virtual list only
84 // mounts overscan rows): the tools session ends on its last read_file card,
85 // the markdown session on the oversized code block (worker-parsed).
86 markdown: { label: "bench:markdown-46t", marker: "generated migration" },
87 tools: { label: "bench:tools-38t", marker: "pkg-41/mod.go" },
88 };
89
90 function percentile(values, p) {
91 if (values.length === 0) return undefined;
92 const sorted = [...values].sort((a, b) => a - b);
93 const index = Math.min(sorted.length - 1, Math.ceil((p / 100) * sorted.length) - 1);
94 return sorted[Math.max(0, index)];
95 }
96
97 function statsOf(values) {
98 const nums = values.filter((v) => Number.isFinite(v));
99 return {
100 n: nums.length,
101 p50: percentile(nums, 50),
102 p95: percentile(nums, 95),
103 max: nums.length ? Math.max(...nums) : undefined,
104 };
105 }
106
107 async function waitForServer(url, timeoutMs = 30_000) {
108 const deadline = Date.now() + timeoutMs;
109 for (;;) {
110 const ok = await new Promise((resolve) => {
111 const req = http.get(url, (res) => {
112 res.resume();
113 resolve(res.statusCode !== undefined && res.statusCode < 500);
114 });
115 req.on("error", () => resolve(false));
116 req.setTimeout(2000, () => {
117 req.destroy();
118 resolve(false);
119 });
120 });
121 if (ok) return;
122 if (Date.now() > deadline) throw new Error(`preview server did not start at ${url}`);
123 await new Promise((resolve) => setTimeout(resolve, 200));
124 }
125 }
126
127 async function ensureBuild() {
128 const distIndex = path.join(frontendDir, "dist", "index.html");
129 const force = process.env.REASONIX_BENCH_BUILD === "1";
130 if (existsSync(distIndex) && !force) return;
131 console.log("[bench] building frontend (vite build)…");
132 await new Promise((resolve, reject) => {
133 const child = spawn("pnpm", ["build"], { cwd: frontendDir, stdio: "inherit" });
134 child.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`pnpm build exited ${code}`))));
135 });
136 }
137
138 async function startPreview() {
139 const child = spawn("pnpm", ["exec", "vite", "preview", "--port", String(PORT), "--strictPort", "--host", "127.0.0.1"], {
140 cwd: frontendDir,
141 stdio: ["ignore", "pipe", "pipe"],
142 });
143 let stderr = "";
144 child.stderr.on("data", (chunk) => {
145 const message = chunk.toString();
146 stderr += message;
147 process.stderr.write(`[preview] ${message}`);
148 });
149 await new Promise((resolve, reject) => {
150 let stdout = "";
151 const timeout = setTimeout(() => {
152 cleanup();
153 child.kill();
154 reject(new Error(`preview server did not announce readiness on port ${PORT}`));
155 }, 30_000);
156 const cleanup = () => {
157 clearTimeout(timeout);
158 child.stdout.off("data", onStdout);
159 child.off("exit", onExit);
160 };
161 const onStdout = (chunk) => {
162 stdout += chunk.toString();
163 if (!/\bLocal:\s+http/.test(stdout)) return;
164 cleanup();
165 resolve();
166 };
167 const onExit = (code, signal) => {
168 cleanup();
169 const detail = stderr.trim();
170 reject(new Error(
171 `preview server exited before readiness (code ${code ?? "null"}, signal ${signal ?? "none"})${detail ? `: ${detail}` : ""}`,
172 ));
173 };
174 child.stdout.on("data", onStdout);
175 child.once("exit", onExit);
176 });
177 await waitForServer(`http://127.0.0.1:${PORT}/`);
178 return child;
179 }
180
181 // Installed before any app script: long-task, event-timing (INP-ish) and paint
182 // collectors. All bounded by the scenario durations (a few minutes).
183 const COLLECTOR_INIT = () => {
184 const metrics = { longTasks: [], events: [], paints: [] };
185 window.__benchMetrics = metrics;
186 try {
187 new PerformanceObserver((list) => {
188 for (const entry of list.getEntries()) metrics.longTasks.push({ start: entry.startTime, duration: entry.duration });
189 }).observe({ type: "longtask", buffered: true });
190 } catch { /* longtask unsupported */ }
191 try {
192 new PerformanceObserver((list) => {
193 for (const entry of list.getEntries()) metrics.events.push({ name: entry.name, duration: entry.duration });
194 }).observe({ type: "event", durationThreshold: 16, buffered: true });
195 } catch { /* event timing unsupported */ }
196 try {
197 new PerformanceObserver((list) => {
198 for (const entry of list.getEntries()) metrics.paints.push({ name: entry.name, start: entry.startTime });
199 }).observe({ type: "paint", buffered: true });
200 } catch { /* paint timing unsupported */ }
201 };
202
203 async function forceGcAndHeap(cdp, page) {
204 await cdp.send("HeapProfiler.enable").catch(() => {});
205 await cdp.send("HeapProfiler.collectGarbage").catch(() => {});
206 await page.waitForTimeout(150);
207 await cdp.send("HeapProfiler.collectGarbage").catch(() => {});
208 await page.waitForTimeout(150);
209 return page.evaluate(() => {
210 const memory = performance.memory;
211 return {
212 usedJSHeapBytes: memory ? memory.usedJSHeapSize : undefined,
213 totalJSHeapBytes: memory ? memory.totalJSHeapSize : undefined,
214 domNodes: document.getElementsByTagName("*").length,
215 };
216 });
217 }
218
219 async function rendererTaskDuration(cdp) {
220 await cdp.send("Performance.enable").catch(() => {});
221 const response = await cdp.send("Performance.getMetrics");
222 return response.metrics.find((metric) => metric.name === "TaskDuration")?.value ?? 0;
223 }
224
225 const INTERACTIVE_FN = () => {
226 const input = document.querySelector("textarea.composer__input:not([aria-hidden=true])");
227 const inputReady = Boolean(input && !input.disabled);
228 const rows = document.querySelectorAll(".chat-node").length;
229 return inputReady && rows > 0;
230 };
231
232 async function coldOpenOnce(browser) {
233 const context = await browser.newContext();
234 const page = await context.newPage();
235 await page.addInitScript(COLLECTOR_INIT);
236 await page.goto(PAGE_URL, { waitUntil: "domcontentloaded" });
237 await page.waitForFunction(INTERACTIVE_FN, undefined, { timeout: 30_000, polling: "raf" });
238 const result = await page.evaluate(() => {
239 const paints = (window.__benchMetrics?.paints ?? []);
240 const fcp = paints.find((p) => p.name === "first-contentful-paint") ?? paints.find((p) => p.name === "first-paint");
241 return { firstPaintMs: fcp ? fcp.start : undefined, interactiveMs: performance.now() };
242 });
243 await context.close();
244 return result;
245 }
246
247 async function waitForSessionVisible(page, tab, timeoutMs = 15_000) {
248 await page.waitForFunction(
249 ({ label, marker }) => {
250 const active = document.querySelector('.project-tree__topic--active .project-tree__topic-label');
251 if (!active || !active.textContent?.includes(label)) return false;
252 const transcript = document.querySelector(".transcript");
253 return Boolean(transcript && transcript.textContent?.includes(marker));
254 },
255 { label: tab.label, marker: tab.marker },
256 { timeout: timeoutMs, polling: "raf" },
257 );
258 }
259
260 async function switchTo(page, tab) {
261 const startedAt = await page.evaluate(() => performance.now());
262 await selectSession(page, tab.label);
263 await waitForSessionVisible(page, tab);
264 const settledAt = await page.evaluate(() => performance.now());
265 return settledAt - startedAt;
266 }
267
268 // Let in-flight worker parses finish so the parsed-markdown cache is populated
269 // and exercised (a fast switch-away cancels pending parses by design). Runs
270 // AFTER the switch-latency measurement so click→first-render stays pure.
271 async function settleMarkdownWorker(page, timeoutMs = 10_000) {
272 await page
273 .waitForFunction(() => (window.__reasonixPerf?.stats()?.markdownWorker?.pending ?? 0) === 0, undefined, {
274 timeout: timeoutMs,
275 polling: 100,
276 })
277 .catch(() => {});
278 }
279
280 // Giant history rows retain only a viewport-driven Markdown tail. Stabilize
281 // worker/React publication around heap/DOM snapshots without forcing cold
282 // blocks into the DOM; scrolling the older sentinel owns those later pages.
283 async function settleMarkdownMounts(page, timeoutMs = 10_000) {
284 try {
285 await page.waitForFunction(() => (
286 [...document.querySelectorAll(".transcript [data-markdown-blocks]")].every((element) => (
287 Number(element.getAttribute("data-markdown-blocks")) > 0
288 ))
289 ), undefined, { timeout: timeoutMs, polling: 100 });
290 } catch (error) {
291 const counts = await page.evaluate(() => (
292 [...document.querySelectorAll(".transcript [data-markdown-blocks]")].map((element) => ({
293 total: element.getAttribute("data-markdown-blocks"),
294 visible: element.getAttribute("data-markdown-visible-blocks"),
295 }))
296 ));
297 throw new Error(`active Markdown mounts did not settle: ${JSON.stringify(counts)}`, { cause: error });
298 }
299 }
300
301 async function main() {
302 await ensureBuild();
303 const preview = await startPreview();
304 const report = {
305 startedAt: new Date().toISOString(),
306 url: PAGE_URL,
307 gates: GATES,
308 switches: SWITCHES,
309 coldRuns: COLD_RUNS,
310 checks: [],
311 scenarioA: {},
312 scenarioB: {},
313 };
314 const check = (name, value, gate, pass, unit = "ms") => {
315 report.checks.push({ name, value, gate, pass, unit });
316 console.log(` ${pass ? "PASS" : "FAIL"} ${name}: ${typeof value === "number" ? value.toFixed(1) : value}${unit} (gate ${gate}${unit})`);
317 };
318
319 const browser = await chromium.launch({
320 headless: true,
321 args: ["--enable-precise-memory-info", "--disable-dev-shm-usage"],
322 });
323 try {
324 // ── Scenario A: cold open ──────────────────────────────────────────────
325 console.log(`[bench] scenario A: ${COLD_RUNS} cold opens of the markdown-heavy session…`);
326 const coldFirstPaint = [];
327 const coldInteractive = [];
328 for (let i = 0; i < COLD_RUNS; i += 1) {
329 const { firstPaintMs, interactiveMs } = await coldOpenOnce(browser);
330 if (firstPaintMs !== undefined) coldFirstPaint.push(firstPaintMs);
331 coldInteractive.push(interactiveMs);
332 }
333 report.scenarioA = { firstPaintMs: statsOf(coldFirstPaint), interactiveMs: statsOf(coldInteractive) };
334 check("A first-paint P95", report.scenarioA.firstPaintMs.p95, GATES.firstPaintP95Ms, report.scenarioA.firstPaintMs.p95 <= GATES.firstPaintP95Ms);
335 check("A interactive P95", report.scenarioA.interactiveMs.p95, GATES.interactiveP95Ms, report.scenarioA.interactiveMs.p95 <= GATES.interactiveP95Ms);
336
337 // ── Scenario B: alternating heaviest-session switches ──────────────────
338 console.log(`[bench] scenario B: ${SWITCHES} alternating switches (warmup ${WARMUP_SWITCHES})…`);
339 const context = await browser.newContext();
340 const page = await context.newPage();
341 await page.addInitScript(COLLECTOR_INIT);
342 await page.goto(PAGE_URL, { waitUntil: "domcontentloaded" });
343 await page.waitForFunction(INTERACTIVE_FN, undefined, { timeout: 30_000, polling: "raf" });
344 await page.waitForFunction(() => Boolean(window.__reasonixPerf), { timeout: 10_000 });
345 const cdp = await context.newCDPSession(page);
346
347 // Warmup: fill the LRU with both sessions, then settle back on the
348 // markdown tab so the baseline and the final reading show the same view.
349 for (let i = 0; i < WARMUP_SWITCHES; i += 1) {
350 const target = i % 2 === 0 ? TAB.tools : TAB.markdown;
351 await switchTo(page, target);
352 if (target === TAB.markdown) await settleMarkdownWorker(page);
353 }
354 await settleMarkdownMounts(page);
355 const baseline = await forceGcAndHeap(cdp, page);
356 const baselineStats = await page.evaluate(() => window.__reasonixPerf.stats());
357 await page.evaluate(() => {
358 window.__benchMetrics.longTasks.length = 0;
359 window.__benchMetrics.events.length = 0;
360 window.__reasonixPerf.reset();
361 });
362
363 const switchLatencies = [];
364 for (let i = 0; i < SWITCHES; i += 1) {
365 // First switch leaves the markdown tab (warmup ended there); even count
366 // lands back on markdown so the final heap/DOM reading compares like for
367 // like with the baseline.
368 const target = i % 2 === 0 ? TAB.tools : TAB.markdown;
369 switchLatencies.push(await switchTo(page, target));
370 if (target === TAB.markdown) await settleMarkdownWorker(page);
371 if ((i + 1) % 10 === 0) {
372 // INP-ish probe: real key events against the composer while the
373 // pipeline is warm; event-timing entries capture the latency.
374 const composer = page.locator("textarea.composer__input:not([aria-hidden=true])");
375 await composer.click();
376 await page.keyboard.type("x", { delay: 5 });
377 await page.keyboard.press("Backspace");
378 }
379 }
380
381 await settleMarkdownMounts(page);
382 // Session activation is complete and the worker is drained. Sustained task
383 // time here is background UI churn; the original regression kept
384 // committing Markdown blocks after the visible switch had finished.
385 const idleSampleMs = 3_000;
386 const idleTaskStart = await rendererTaskDuration(cdp);
387 await page.waitForTimeout(idleSampleMs);
388 const idleTaskEnd = await rendererTaskDuration(cdp);
389 const idleMainThreadPercent = ((idleTaskEnd - idleTaskStart) * 1000 / idleSampleMs) * 100;
390
391 // Freeze interaction metrics before the explicit HeapProfiler GC below.
392 // The GC is part of retained-heap measurement, not the user switching
393 // workflow, and can itself create a >50ms task on a loaded test host.
394 const { finalStats, activations, benchMetrics } = await page.evaluate(() => ({
395 finalStats: window.__reasonixPerf.stats(),
396 activations: window.__reasonixPerf.activations(),
397 benchMetrics: {
398 longTasks: window.__benchMetrics.longTasks.map((t) => t.duration),
399 events: window.__benchMetrics.events.map((e) => e.duration),
400 },
401 }));
402 const final = await forceGcAndHeap(cdp, page);
403 await context.close();
404
405 const switchStats = statsOf(switchLatencies);
406 const readyStats = statsOf(
407 activations.filter((a) => a.outcome === "ready" && a.settledAtMs !== undefined).map((a) => a.settledAtMs - a.requestedAtMs),
408 );
409 const longTaskStats = statsOf(benchMetrics.longTasks);
410 const eventStats = statsOf(benchMetrics.events);
411 const heapGrowthBytes = (final.usedJSHeapBytes ?? 0) - (baseline.usedJSHeapBytes ?? 0);
412 const heapGrowthMiB = heapGrowthBytes / (1 << 20);
413 const cache = finalStats?.transcriptCache ?? {};
414 const worker = finalStats?.markdownWorker ?? {};
415
416 report.scenarioB = {
417 switchMs: switchStats,
418 activationReadyMs: readyStats,
419 longTasksMs: longTaskStats,
420 inputEventsMs: eventStats,
421 heap: {
422 baselineUsedMiB: (baseline.usedJSHeapBytes ?? 0) / (1 << 20),
423 finalUsedMiB: (final.usedJSHeapBytes ?? 0) / (1 << 20),
424 growthMiB: heapGrowthMiB,
425 },
426 domNodes: { baseline: baseline.domNodes, final: final.domNodes },
427 transcriptCache: cache,
428 markdownWorker: worker,
429 idleMainThreadPercent,
430 baselineCache: baselineStats?.transcriptCache,
431 };
432
433 check("B switch latency P95 (click→target session rendered)", switchStats.p95, GATES.interactiveP95Ms, switchStats.p95 <= GATES.interactiveP95Ms);
434 if (readyStats.n > 0) {
435 check("B activation ready P95", readyStats.p95, GATES.interactiveP95Ms, readyStats.p95 <= GATES.interactiveP95Ms);
436 }
437 check("B input-event P95 (INP-ish)", eventStats.p95 ?? 0, GATES.inpP95Ms, (eventStats.p95 ?? 0) <= GATES.inpP95Ms);
438 check("B long-task P95", longTaskStats.p95 ?? 0, GATES.longTaskP95Ms, (longTaskStats.p95 ?? 0) <= GATES.longTaskP95Ms);
439 check("B long-task max", longTaskStats.max ?? 0, GATES.longTaskMaxMs, (longTaskStats.max ?? 0) <= GATES.longTaskMaxMs);
440 check("B markdown Worker completed parses (minimum)", worker.completed ?? 0, 1, (worker.completed ?? 0) >= 1, "");
441 check(
442 "B markdown Worker max parse",
443 worker.maxParseMs ?? 0,
444 GATES.markdownParseMaxMs,
445 (worker.completed ?? 0) >= 1 && (worker.maxParseMs ?? Infinity) <= GATES.markdownParseMaxMs,
446 );
447 check("B settled renderer task time", idleMainThreadPercent, GATES.idleCpuPercent, idleMainThreadPercent <= GATES.idleCpuPercent, "%");
448 check("B retained heap growth", heapGrowthMiB, GATES.heapGrowthMiB, heapGrowthMiB <= GATES.heapGrowthMiB, "MiB");
449 check(
450 "B cache: body bytes within budget",
451 (cache.bodyBytes ?? 0) / (1 << 20),
452 BODY_BUDGET_BYTES / (1 << 20),
453 (cache.bodyBytes ?? 0) <= BODY_BUDGET_BYTES,
454 "MiB",
455 );
456 check(
457 "B cache: markdown bytes within budget",
458 (cache.markdownBytes ?? 0) / (1 << 20),
459 MARKDOWN_BUDGET_BYTES / (1 << 20),
460 (cache.markdownBytes ?? 0) <= MARKDOWN_BUDGET_BYTES,
461 "MiB",
462 );
463 check("B cache: resident sessions", cache.residentSessions ?? 0, MAX_RESIDENT_SESSIONS, (cache.residentSessions ?? 0) <= MAX_RESIDENT_SESSIONS, "");
464 const domGrowthPct = baseline.domNodes > 0 ? ((final.domNodes - baseline.domNodes) / baseline.domNodes) * 100 : 0;
465 check("B DOM node growth vs warmup baseline", domGrowthPct, 10, domGrowthPct <= 10, "%");
466 } finally {
467 await browser.close();
468 preview.kill();
469 }
470
471 report.finishedAt = new Date().toISOString();
472 const failed = report.checks.filter((c) => !c.pass);
473 report.verdict = failed.length === 0 ? "PASS" : "FAIL";
474 writeFileSync(path.join(frontendDir, "bench", "results.json"), JSON.stringify(report, null, 2));
475 console.log(`\n[bench] verdict: ${report.verdict} (${failed.length} gate${failed.length === 1 ? "" : "s"} failed) — results in bench/results.json`);
476 if (failed.length > 0) process.exitCode = 1;
477 }
478
479 main().catch((err) => {
480 console.error(`[bench] fatal: ${err instanceof Error ? err.stack ?? err.message : err}`);
481 process.exitCode = 2;
482 });
483
483 lines Plain Text