From 927b735a09b7993e51b66ea6381013d5f44aa978 Mon Sep 17 00:00:00 2001 From: am <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Date: Tue, 22 Sep 2026 13:51:59 -0700 Subject: [PATCH 01/10] feat: add sampling profiler launch modes Co-authored-by: Kalvin Chau Signed-off-by: Kalvin Chau --- .gitignore | 1 + biome.json | 1 + justfile | 18 +- scripts/profile-dev.mjs | 564 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 582 insertions(+), 2 deletions(-) create mode 100644 scripts/profile-dev.mjs diff --git a/.gitignore b/.gitignore index bf510807f..44ef02da8 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ blob-report/ coverage/ .pnpm-store/ .vite/ +.profiles/ *.log *.tsbuildinfo diff --git a/biome.json b/biome.json index 70c8772ce..cde7b68c9 100644 --- a/biome.json +++ b/biome.json @@ -13,6 +13,7 @@ "!!.hermit", "!!dist", "!!dist-author", + "!!.profiles", "!!src-tauri/target", "!!src-tauri/gen", "!!target", diff --git a/justfile b/justfile index 4d5959e9f..dbd5720f1 100644 --- a/justfile +++ b/justfile @@ -11,12 +11,26 @@ install: # Run the shared frontend in a browser; forward Vite arguments (e.g. --port 1431). [positional-arguments] web *args: install - pnpm dev "$@" + #!/usr/bin/env bash + if [[ "${1:-}" == "profile" ]]; then + shift + exec node scripts/profile-dev.mjs web "$@" + fi + exec pnpm dev "$@" # Run Tauri; --port selects Vite's port, other arguments pass through to Tauri. [positional-arguments] desktop *args: install - node scripts/desktop-dev.mjs "$@" + #!/usr/bin/env bash + if [[ "${1:-}" == "profile" ]]; then + shift + exec node scripts/profile-dev.mjs desktop "$@" + fi + exec node scripts/desktop-dev.mjs "$@" + +# Remove generated CPU, network, and Instruments profiles. +profile-clean: + rm -rf -- .profiles # Open the design system in a browser; forward Vite arguments (e.g. --port 1444). [positional-arguments] diff --git a/scripts/profile-dev.mjs b/scripts/profile-dev.mjs new file mode 100644 index 000000000..471c1e756 --- /dev/null +++ b/scripts/profile-dev.mjs @@ -0,0 +1,564 @@ +import { spawn } from "node:child_process"; +import { createWriteStream } from "node:fs"; +import { mkdir, writeFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import process from "node:process"; + +const root = fileURLToPath(new URL("../", import.meta.url)); +const target = process.argv[2]; +const profileArgs = process.argv.slice(3); +const network = target === "web" && profileArgs.includes("--network"); +const args = profileArgs.filter((argument) => argument !== "--network"); +if (target !== "web" && target !== "desktop") { + console.error( + "Usage: node scripts/profile-dev.mjs [arguments]", + ); + process.exit(1); +} +if (process.platform !== "darwin") { + console.error("Development profiling currently supports macOS only."); + process.exit(1); +} + +const stamp = new Date() + .toISOString() + .replaceAll(":", "-") + .replace(/\.\d{3}Z$/, "Z"); +const directory = `${root}.profiles/${stamp}-${target}`; +await mkdir(directory, { recursive: true }); + +const children = new Set(); +let stopping; +let forced = false; + +function run(command, commandArgs, options = {}) { + const child = spawn(command, commandArgs, { + cwd: root, + stdio: "inherit", + detached: true, + ...options, + }); + children.add(child); + child.once("exit", () => children.delete(child)); + return child; +} + +function mirror(child) { + child.stdout?.pipe(process.stdout); + child.stderr?.pipe(process.stderr); +} + +function waitForInspector(child) { + return new Promise((resolve, reject) => { + let pending = ""; + const onData = (chunk) => { + pending += chunk; + const match = pending.match(/Debugger listening on (ws:\/\/\S+)/); + if (match) { + child.stderr.off("data", onData); + resolve(match[1]); + } + if (pending.length > 16_384) pending = pending.slice(-8_192); + }; + child.stderr.on("data", onData); + child.once("exit", () => + reject(new Error("Vite exited before its Node inspector became ready.")), + ); + }); +} + +async function commandSucceeds(command, commandArgs) { + return await new Promise((resolve) => { + const child = spawn(command, commandArgs, { + cwd: root, + stdio: "ignore", + }); + child.once("error", () => resolve(false)); + child.once("exit", (code) => resolve(code === 0)); + }); +} + +function inspectorClient(url) { + const socket = new WebSocket(url); + const pending = new Map(); + let nextId = 1; + socket.addEventListener("message", ({ data }) => { + const message = JSON.parse(data); + if (!message.id) return; + const request = pending.get(message.id); + if (!request) return; + pending.delete(message.id); + if (message.error) request.reject(new Error(message.error.message)); + else request.resolve(message.result); + }); + const ready = new Promise((resolve, reject) => { + socket.addEventListener("open", resolve, { once: true }); + socket.addEventListener( + "error", + () => reject(new Error(`Could not connect to Node inspector at ${url}.`)), + { once: true }, + ); + }); + return { + async send(method, params) { + await ready; + return await new Promise((resolve, reject) => { + const id = nextId++; + pending.set(id, { resolve, reject }); + socket.send(JSON.stringify({ id, method, params })); + }); + }, + close() { + socket.close(); + }, + }; +} + +function signal(child, name) { + if (!child.pid || child.exitCode !== null || child.signalCode !== null) + return; + try { + process.kill(-child.pid, name); + } catch (error) { + if (error.code !== "ESRCH") throw error; + } +} + +async function output(command, commandArgs) { + return await new Promise((resolve) => { + const child = spawn(command, commandArgs, { + cwd: root, + stdio: ["ignore", "pipe", "ignore"], + }); + let value = ""; + child.stdout.on("data", (chunk) => (value += chunk)); + child.once("close", () => resolve(value.trim())); + }); +} + +async function recordManifest(extra = {}) { + await writeFile( + `${directory}/manifest.json`, + `${JSON.stringify( + { + target, + startedAt: new Date().toISOString(), + git: { + commit: await output("git", ["rev-parse", "HEAD"]), + dirty: Boolean(await output("git", ["status", "--porcelain"])), + }, + platform: `${process.platform}-${process.arch}`, + node: process.version, + arguments: profileArgs, + ...extra, + }, + null, + 2, + )}\n`, + ); +} + +function waitForExit(child, timeoutMs) { + if (child.exitCode !== null) return Promise.resolve(); + return new Promise((resolve) => { + const timer = setTimeout(resolve, timeoutMs); + child.once("exit", () => { + clearTimeout(timer); + resolve(); + }); + }); +} + +async function stopChildren() { + for (const child of children) signal(child, "SIGINT"); + await Promise.race([ + Promise.all([...children].map((child) => waitForExit(child, 30_000))), + new Promise((resolve) => setTimeout(resolve, 30_000)), + ]); + for (const child of children) signal(child, "SIGTERM"); +} + +function installSignals(stop) { + for (const name of ["SIGINT", "SIGTERM"]) { + process.on(name, () => { + if (stopping) { + forced = true; + for (const child of children) signal(child, "SIGKILL"); + return; + } + stopping = Promise.resolve() + .then(stop) + .catch((error) => { + console.error(error); + process.exitCode = 1; + }); + }); + } +} + +function vitePort(values) { + for (let index = 0; index < values.length; index++) { + if (values[index] === "--port") return Number(values[index + 1]); + if (values[index].startsWith("--port=")) + return Number(values[index].slice(7)); + } + return 1430; +} + +async function waitForServer(url, child) { + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + if (child.exitCode !== null) + throw new Error(`Vite exited before ${url} became ready.`); + try { + const response = await fetch(url); + if (response.ok) return; + } catch {} + await new Promise((resolve) => setTimeout(resolve, 150)); + } + throw new Error(`Timed out waiting for ${url}.`); +} + +function safeUrl(value) { + const url = new URL(value); + return `${url.origin}${url.pathname}`; +} + +function networkRecorder(session) { + const requests = new Map(); + const webSockets = new Map(); + const startedAt = new Date().toISOString(); + let calibration; + + session.on( + "Network.requestWillBeSent", + ({ requestId, request, timestamp, type }) => { + requests.set(requestId, { + requestId, + method: request.method, + type, + url: safeUrl(request.url), + start: timestamp, + }); + }, + ); + session.on( + "Network.responseReceived", + ({ requestId, response, timestamp, type }) => { + const entry = requests.get(requestId); + if (!entry) return; + entry.type = type; + entry.status = response.status; + entry.protocol = response.protocol; + entry.remoteAddress = response.remoteIPAddress + ? `${response.remoteIPAddress}:${response.remotePort}` + : undefined; + entry.response = timestamp; + entry.timing = response.timing; + entry.serverTiming = response.headers?.["server-timing"]; + }, + ); + session.on( + "Network.loadingFinished", + ({ requestId, timestamp, encodedDataLength }) => { + const entry = requests.get(requestId); + if (!entry) return; + entry.end = timestamp; + entry.encodedDataLength = encodedDataLength; + }, + ); + session.on( + "Network.loadingFailed", + ({ requestId, timestamp, errorText, canceled }) => { + const entry = requests.get(requestId); + if (!entry) return; + entry.end = timestamp; + entry.failure = { errorText, canceled }; + }, + ); + session.on("Network.webSocketCreated", ({ requestId, url }) => { + webSockets.set(requestId, { requestId, url: safeUrl(url), frames: [] }); + }); + session.on( + "Network.webSocketWillSendHandshakeRequest", + ({ requestId, timestamp }) => { + const entry = webSockets.get(requestId); + if (entry) entry.start = timestamp; + }, + ); + session.on( + "Network.webSocketHandshakeResponseReceived", + ({ requestId, timestamp, response }) => { + const entry = webSockets.get(requestId); + if (!entry) return; + entry.handshake = timestamp; + entry.status = response.status; + }, + ); + for (const [event, direction] of [ + ["Network.webSocketFrameSent", "sent"], + ["Network.webSocketFrameReceived", "received"], + ]) { + session.on(event, ({ requestId, timestamp, response }) => { + const entry = webSockets.get(requestId); + if (!entry) return; + entry.frames.push({ + direction, + timestamp, + opcode: response.opcode, + bytes: response.payloadData.length, + }); + }); + } + session.on("Network.webSocketClosed", ({ requestId, timestamp }) => { + const entry = webSockets.get(requestId); + if (entry) entry.end = timestamp; + }); + + return { + async start(page) { + await session.send("Network.enable", { maxPostDataSize: 0 }); + await session.send("Performance.enable"); + const { metrics } = await session.send("Performance.getMetrics"); + calibration = { + cdpMonotonicSeconds: metrics.find(({ name }) => name === "Timestamp") + ?.value, + renderer: await page.evaluate(() => ({ + epochMilliseconds: performance.timeOrigin + performance.now(), + monotonicMilliseconds: performance.now(), + })), + }; + }, + async write(clocks) { + await writeFile( + `${directory}/network.json`, + `${JSON.stringify( + { + format: "buzz-network-profile-v1", + startedAt, + calibration: { ...calibration, cpuProfiles: clocks }, + privacy: + "URLs exclude query/fragment; headers exclude everything except Server-Timing; bodies and frame payloads are omitted.", + requests: [...requests.values()], + webSockets: [...webSockets.values()], + }, + null, + 2, + )}\n`, + ); + }, + }; +} + +async function writeProtocolStream(session, handle, destination) { + const output = createWriteStream(destination); + try { + for (;;) { + const { data, base64Encoded, eof } = await session.send("IO.read", { + handle, + }); + output.write(data, base64Encoded ? "base64" : "utf8"); + if (eof) break; + } + await session.send("IO.close", { handle }); + } finally { + await new Promise((resolve, reject) => { + output.once("error", reject); + output.end(resolve); + }); + } +} + +async function profileWeb() { + const port = vitePort(args); + if (!Number.isInteger(port) || port < 1 || port > 65535) + throw new Error("--port must be an integer between 1 and 65535."); + const url = `http://localhost:${port}`; + await recordManifest({ + coverage: [ + "chromium-renderer", + "vite-broker", + ...(network ? ["chromium-network"] : []), + ], + network, + }); + const nodeOptions = [process.env.NODE_OPTIONS, "--inspect=127.0.0.1:0"] + .filter(Boolean) + .join(" "); + const vite = run( + process.execPath, + ["node_modules/vite/bin/vite.js", ...args], + { + env: { ...process.env, NODE_OPTIONS: nodeOptions }, + stdio: ["inherit", "pipe", "pipe"], + }, + ); + const inspectorUrl = waitForInspector(vite); + mirror(vite); + let browser; + let session; + let networkCapture; + let tracingComplete; + let nodeProfiler; + const stop = async () => { + let failure; + try { + let rendererProfile; + let brokerProfile; + if (session) { + ({ profile: rendererProfile } = await session.send("Profiler.stop")); + await writeFile( + `${directory}/chromium-renderer.cpuprofile`, + `${JSON.stringify(rendererProfile)}\n`, + ); + } + if (nodeProfiler) { + ({ profile: brokerProfile } = await nodeProfiler.send("Profiler.stop")); + await writeFile( + `${directory}/vite-broker.cpuprofile`, + `${JSON.stringify(brokerProfile)}\n`, + ); + } + if (networkCapture) { + await networkCapture.write({ + renderer: rendererProfile && { + startTimeMicroseconds: rendererProfile.startTime, + endTimeMicroseconds: rendererProfile.endTime, + }, + broker: brokerProfile && { + startTimeMicroseconds: brokerProfile.startTime, + endTimeMicroseconds: brokerProfile.endTime, + note: "Node inspector monotonic epoch is isolate-specific; align by capture start/end rather than assuming the renderer epoch.", + }, + }); + const complete = tracingComplete; + await session.send("Tracing.end"); + const { stream } = await complete; + await writeProtocolStream( + session, + stream, + `${directory}/chrome-performance.json`, + ); + } + } catch (error) { + failure = error; + } finally { + nodeProfiler?.close(); + await browser?.close(); + await stopChildren(); + } + if (failure) throw failure; + console.log(`\nProfile saved to ${directory}`); + run("open", [directory]); + process.exitCode = forced ? 130 : 0; + }; + installSignals(stop); + await waitForServer(url, vite); + nodeProfiler = inspectorClient(await inspectorUrl); + await nodeProfiler.send("Profiler.enable"); + await nodeProfiler.send("Profiler.setSamplingInterval", { interval: 1000 }); + await nodeProfiler.send("Profiler.start"); + const { chromium } = await import("@playwright/test"); + browser = await chromium.launch({ + channel: "chrome", + headless: false, + handleSIGINT: false, + handleSIGTERM: false, + }); + const page = await browser.newPage(); + session = await page.context().newCDPSession(page); + if (network) { + networkCapture = networkRecorder(session); + await networkCapture.start(page); + tracingComplete = new Promise((resolve) => + session.once("Tracing.tracingComplete", resolve), + ); + await session.send("Tracing.start", { + categories: [ + "devtools.timeline", + "v8.execute", + "loading", + "disabled-by-default-devtools.timeline", + "disabled-by-default-devtools.timeline.frame", + "disabled-by-default-v8.cpu_profiler", + "disabled-by-default-v8.cpu_profiler.hires", + ].join(","), + options: "sampling-frequency=10000", + transferMode: "ReturnAsStream", + }); + } + await session.send("Profiler.enable"); + await session.send("Profiler.setSamplingInterval", { interval: 1000 }); + await session.send("Profiler.start"); + await page.goto(url); + console.log(`\nProfiling ${url}. Press Ctrl-C to stop and open the results.`); + const code = await new Promise((resolve) => + vite.once("exit", (value) => resolve(value ?? 0)), + ); + if (!stopping) { + await stop(); + process.exitCode = code; + } +} + +async function profileDesktop() { + const trace = `${directory}/desktop-time-profile.trace`; + await recordManifest({ + coverage: ["all-native-processes"], + caveat: "JavaScriptCore stacks may not resolve to application JavaScript.", + }); + run("xcrun", [ + "xctrace", + "record", + "--template", + "Time Profiler", + "--all-processes", + "--output", + trace, + "--no-prompt", + ]); + let desktop; + const stop = async () => { + await stopChildren(); + if ( + !(await commandSucceeds("xcrun", [ + "xctrace", + "export", + "--input", + trace, + "--toc", + ])) + ) { + throw new Error( + `Instruments left an incomplete trace at ${trace}; rerun and press Ctrl-C only once.`, + ); + } + console.log(`\nProfile saved to ${trace}`); + run("open", [trace]); + process.exitCode = forced ? 130 : 0; + }; + installSignals(stop); + // xctrace has no machine-readable readiness event without coupling to a custom notification. + // Start compilation immediately: Time Profiler records system-wide, including processes created afterward. + desktop = run(process.execPath, ["scripts/desktop-dev.mjs", ...args]); + console.log( + "\nProfiling the desktop process tree. Press Ctrl-C to stop and open Instruments.", + ); + const code = await new Promise((resolve) => + desktop.once("exit", (value) => resolve(value ?? 0)), + ); + if (!stopping) { + await stop(); + process.exitCode = code; + } +} + +try { + if (target === "web") await profileWeb(); + else await profileDesktop(); + await stopping; +} catch (error) { + console.error(error instanceof Error ? error.message : error); + await stopChildren(); + process.exitCode = 1; +} From 1d33bdbcb13519631c366a274b35f26cf2ceeac0 Mon Sep 17 00:00:00 2001 From: am <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Date: Tue, 22 Sep 2026 16:04:58 -0700 Subject: [PATCH 02/10] fix: preserve dev commands and profiler finalization Co-authored-by: Kalvin Chau Signed-off-by: Kalvin Chau --- docs/contributing.md | 10 ++++++++++ justfile | 14 ++------------ scripts/profile-dev.mjs | 18 +++++++++++++----- 3 files changed, 25 insertions(+), 17 deletions(-) diff --git a/docs/contributing.md b/docs/contributing.md index beffa0bdc..1b6f9a431 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -47,6 +47,12 @@ need their own validation. - `just web [args...]`: install locked dependencies and forward arguments to Vite, e.g. `just web --port 1431 --host 127.0.0.1`. Vite uses the requested port (default: 1430) or the next available port, allowing parallel browser development. + Use `just web profile` for opt-in Chromium and broker CPU profiles, or + `just web profile --network` to additionally record sanitized browser network and + Chrome performance events. Press Ctrl+C to finalize the capture; the command prints + the `.profiles/...-web` output directory. Load `.cpuprofile` files in Chromium + DevTools (**Performance** > **Load profile**), and load `chrome-performance.json` + through the same panel when network capture is enabled. - `just desktop [args...]`: install locked dependencies and forward arguments to Tauri, e.g. `just desktop --port 1431 --no-watch`. Before launching, the adapter builds the pinned agent runtime when missing/outdated, or verifies and reuses it. @@ -61,6 +67,10 @@ need their own validation. implicit runner arguments. Explicit `--config` arguments merge afterward and can override it; keep their development URL and frontend command consistent. Use `--` before runner/application arguments if they contain their own `--port` flag. + On macOS, `just desktop profile` records the process tree with Instruments' Time + Profiler. Press Ctrl+C to finalize and validate the trace; the command prints the + saved `.trace` path, which opens in Instruments. Use `just profile-clean` to remove + all generated web and desktop captures. - `just design [args...]`: install locked dependencies, start the standalone design-system viewer, and open it in your browser. Arguments pass through to Vite, e.g. `just design --port 1444`. The default port is 1442; an occupied port diff --git a/justfile b/justfile index dbd5720f1..11bd27535 100644 --- a/justfile +++ b/justfile @@ -11,22 +11,12 @@ install: # Run the shared frontend in a browser; forward Vite arguments (e.g. --port 1431). [positional-arguments] web *args: install - #!/usr/bin/env bash - if [[ "${1:-}" == "profile" ]]; then - shift - exec node scripts/profile-dev.mjs web "$@" - fi - exec pnpm dev "$@" + if [ "${1:-}" = "profile" ]; then shift; exec node scripts/profile-dev.mjs web "$@"; else exec pnpm dev "$@"; fi # Run Tauri; --port selects Vite's port, other arguments pass through to Tauri. [positional-arguments] desktop *args: install - #!/usr/bin/env bash - if [[ "${1:-}" == "profile" ]]; then - shift - exec node scripts/profile-dev.mjs desktop "$@" - fi - exec node scripts/desktop-dev.mjs "$@" + if [ "${1:-}" = "profile" ]; then shift; exec node scripts/profile-dev.mjs desktop "$@"; else exec node scripts/desktop-dev.mjs "$@"; fi # Remove generated CPU, network, and Instruments profiles. profile-clean: diff --git a/scripts/profile-dev.mjs b/scripts/profile-dev.mjs index 471c1e756..f3e2b5dd5 100644 --- a/scripts/profile-dev.mjs +++ b/scripts/profile-dev.mjs @@ -186,6 +186,9 @@ function installSignals(stop) { for (const child of children) signal(child, "SIGKILL"); return; } + console.log( + `\n${name === "SIGINT" ? "Ctrl-C" : name} received; finalizing profile...`, + ); stopping = Promise.resolve() .then(stop) .catch((error) => { @@ -224,6 +227,13 @@ function safeUrl(value) { return `${url.origin}${url.pathname}`; } +function headerValue(headers, name) { + const target = name.toLowerCase(); + for (const [key, value] of Object.entries(headers ?? {})) + if (key.toLowerCase() === target) return value; + return undefined; +} + function networkRecorder(session) { const requests = new Map(); const webSockets = new Map(); @@ -255,7 +265,7 @@ function networkRecorder(session) { : undefined; entry.response = timestamp; entry.timing = response.timing; - entry.serverTiming = response.headers?.["server-timing"]; + entry.serverTiming = headerValue(response.headers, "server-timing"); }, ); session.on( @@ -449,7 +459,6 @@ async function profileWeb() { } if (failure) throw failure; console.log(`\nProfile saved to ${directory}`); - run("open", [directory]); process.exitCode = forced ? 130 : 0; }; installSignals(stop); @@ -491,7 +500,7 @@ async function profileWeb() { await session.send("Profiler.setSamplingInterval", { interval: 1000 }); await session.send("Profiler.start"); await page.goto(url); - console.log(`\nProfiling ${url}. Press Ctrl-C to stop and open the results.`); + console.log(`\nProfiling ${url}. Press Ctrl-C to stop and save the profile.`); const code = await new Promise((resolve) => vite.once("exit", (value) => resolve(value ?? 0)), ); @@ -534,7 +543,6 @@ async function profileDesktop() { ); } console.log(`\nProfile saved to ${trace}`); - run("open", [trace]); process.exitCode = forced ? 130 : 0; }; installSignals(stop); @@ -542,7 +550,7 @@ async function profileDesktop() { // Start compilation immediately: Time Profiler records system-wide, including processes created afterward. desktop = run(process.execPath, ["scripts/desktop-dev.mjs", ...args]); console.log( - "\nProfiling the desktop process tree. Press Ctrl-C to stop and open Instruments.", + "\nProfiling the desktop process tree. Press Ctrl-C to stop and save the profile.", ); const code = await new Promise((resolve) => desktop.once("exit", (value) => resolve(value ?? 0)), From 9a47fdd223f13e5226e127d22098ca745b8ab3ed Mon Sep 17 00:00:00 2001 From: am <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Date: Tue, 22 Sep 2026 17:06:20 -0700 Subject: [PATCH 03/10] fix: harden profiler capture boundaries Co-authored-by: Kalvin Chau Signed-off-by: Kalvin Chau --- docs/contributing.md | 19 +- justfile | 4 +- scripts/profile-desktop-runner.mjs | 62 ++++ scripts/profile-dev.mjs | 431 ++++++++++++++----------- tests/integration/profile-dev.test.mjs | 36 +++ 5 files changed, 358 insertions(+), 194 deletions(-) create mode 100755 scripts/profile-desktop-runner.mjs create mode 100644 tests/integration/profile-dev.test.mjs diff --git a/docs/contributing.md b/docs/contributing.md index 1b6f9a431..2338ca18a 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -48,11 +48,11 @@ need their own validation. e.g. `just web --port 1431 --host 127.0.0.1`. Vite uses the requested port (default: 1430) or the next available port, allowing parallel browser development. Use `just web profile` for opt-in Chromium and broker CPU profiles, or - `just web profile --network` to additionally record sanitized browser network and - Chrome performance events. Press Ctrl+C to finalize the capture; the command prints - the `.profiles/...-web` output directory. Load `.cpuprofile` files in Chromium - DevTools (**Performance** > **Load profile**), and load `chrome-performance.json` - through the same panel when network capture is enabled. + `just web profile --network` to additionally record sanitized browser network + metadata in `network.json`; payloads, cookies, authorization headers, query strings, + fragments, and WebSocket frame data are omitted. Press Ctrl+C to finalize the + capture; the command prints the `.profiles/...-web` output directory. Load + `.cpuprofile` files in Chromium DevTools (**Performance** > **Load profile**). - `just desktop [args...]`: install locked dependencies and forward arguments to Tauri, e.g. `just desktop --port 1431 --no-watch`. Before launching, the adapter builds the pinned agent runtime when missing/outdated, or verifies and reuses it. @@ -67,10 +67,11 @@ need their own validation. implicit runner arguments. Explicit `--config` arguments merge afterward and can override it; keep their development URL and frontend command consistent. Use `--` before runner/application arguments if they contain their own `--port` flag. - On macOS, `just desktop profile` records the process tree with Instruments' Time - Profiler. Press Ctrl+C to finalize and validate the trace; the command prints the - saved `.trace` path, which opens in Instruments. Use `just profile-clean` to remove - all generated web and desktop captures. + On macOS, `just desktop profile` uses Instruments' Time Profiler to launch and + record only the Buzz application process, not every process on the desktop. Press + Ctrl+C to finalize and validate the trace; the command prints the saved `.trace` + path, which opens in Instruments. Use `just profile-clean` to remove all generated + web and desktop captures. - `just design [args...]`: install locked dependencies, start the standalone design-system viewer, and open it in your browser. Arguments pass through to Vite, e.g. `just design --port 1444`. The default port is 1442; an occupied port diff --git a/justfile b/justfile index 11bd27535..d3864cfc1 100644 --- a/justfile +++ b/justfile @@ -11,12 +11,12 @@ install: # Run the shared frontend in a browser; forward Vite arguments (e.g. --port 1431). [positional-arguments] web *args: install - if [ "${1:-}" = "profile" ]; then shift; exec node scripts/profile-dev.mjs web "$@"; else exec pnpm dev "$@"; fi + if [ "${1:-}" = "profile" ]; then shift; set -m; node scripts/profile-dev.mjs web "$@"; else exec pnpm dev "$@"; fi # Run Tauri; --port selects Vite's port, other arguments pass through to Tauri. [positional-arguments] desktop *args: install - if [ "${1:-}" = "profile" ]; then shift; exec node scripts/profile-dev.mjs desktop "$@"; else exec node scripts/desktop-dev.mjs "$@"; fi + if [ "${1:-}" = "profile" ]; then shift; set -m; node scripts/profile-dev.mjs desktop "$@"; else exec node scripts/desktop-dev.mjs "$@"; fi # Remove generated CPU, network, and Instruments profiles. profile-clean: diff --git a/scripts/profile-desktop-runner.mjs b/scripts/profile-desktop-runner.mjs new file mode 100755 index 000000000..55a46eeea --- /dev/null +++ b/scripts/profile-desktop-runner.mjs @@ -0,0 +1,62 @@ +#!/usr/bin/env node +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import process from "node:process"; + +const trace = process.env.BUZZ_PROFILE_TRACE; +const separator = process.argv.indexOf("--", 2); +const runnerArgs = process.argv.slice(2, separator < 0 ? undefined : separator); +const appArgs = separator < 0 ? [] : process.argv.slice(separator + 1); +if (!trace || runnerArgs[0] !== "run") { + console.error( + "Desktop profiler runner requires a Cargo run command and BUZZ_PROFILE_TRACE.", + ); + process.exit(1); +} + +const build = spawnSync("cargo", ["build", ...runnerArgs.slice(1)], { + stdio: "inherit", +}); +if (build.error) console.error(build.error.message); +if (build.signal) process.kill(process.pid, build.signal); +if (build.status !== 0) process.exit(build.status ?? 1); + +const metadata = spawnSync( + "cargo", + ["metadata", "--no-deps", "--format-version", "1"], + { encoding: "utf8" }, +); +if (metadata.error) console.error(metadata.error.message); +if (metadata.signal) process.kill(process.pid, metadata.signal); +if (metadata.status !== 0) process.exit(metadata.status ?? 1); +const { target_directory: targetDirectory, packages } = JSON.parse( + metadata.stdout, +); +const packageName = packages.find( + (entry) => entry.manifest_path === path.resolve("Cargo.toml"), +)?.name; +if (!packageName) { + console.error("Could not resolve the desktop Cargo package."); + process.exit(1); +} +const target = runnerArgs.includes("--release") ? "release" : "debug"; +const executable = path.join(targetDirectory, target, packageName); +const result = spawnSync( + "xcrun", + [ + "xctrace", + "record", + "--template", + "Time Profiler", + "--output", + trace, + "--launch", + "--", + executable, + ...appArgs, + ], + { stdio: "inherit" }, +); +if (result.error) console.error(result.error.message); +if (result.signal) process.kill(process.pid, result.signal); +process.exit(result.status ?? 1); diff --git a/scripts/profile-dev.mjs b/scripts/profile-dev.mjs index f3e2b5dd5..0196d2b0e 100644 --- a/scripts/profile-dev.mjs +++ b/scripts/profile-dev.mjs @@ -1,34 +1,11 @@ import { spawn } from "node:child_process"; -import { createWriteStream } from "node:fs"; import { mkdir, writeFile } from "node:fs/promises"; import { fileURLToPath } from "node:url"; import process from "node:process"; const root = fileURLToPath(new URL("../", import.meta.url)); -const target = process.argv[2]; -const profileArgs = process.argv.slice(3); -const network = target === "web" && profileArgs.includes("--network"); -const args = profileArgs.filter((argument) => argument !== "--network"); -if (target !== "web" && target !== "desktop") { - console.error( - "Usage: node scripts/profile-dev.mjs [arguments]", - ); - process.exit(1); -} -if (process.platform !== "darwin") { - console.error("Development profiling currently supports macOS only."); - process.exit(1); -} - -const stamp = new Date() - .toISOString() - .replaceAll(":", "-") - .replace(/\.\d{3}Z$/, "Z"); -const directory = `${root}.profiles/${stamp}-${target}`; -await mkdir(directory, { recursive: true }); const children = new Set(); -let stopping; let forced = false; function run(command, commandArgs, options = {}) { @@ -48,22 +25,28 @@ function mirror(child) { child.stderr?.pipe(process.stderr); } -function waitForInspector(child) { +export function waitForInspector(child) { return new Promise((resolve, reject) => { let pending = ""; const onData = (chunk) => { pending += chunk; const match = pending.match(/Debugger listening on (ws:\/\/\S+)/); if (match) { - child.stderr.off("data", onData); + cleanup(); resolve(match[1]); } if (pending.length > 16_384) pending = pending.slice(-8_192); }; + const onExit = () => { + cleanup(); + reject(new Error("Vite exited before its Node inspector became ready.")); + }; + const cleanup = () => { + child.stderr.off("data", onData); + child.off("exit", onExit); + }; child.stderr.on("data", onData); - child.once("exit", () => - reject(new Error("Vite exited before its Node inspector became ready.")), - ); + child.once("exit", onExit); }); } @@ -78,10 +61,17 @@ async function commandSucceeds(command, commandArgs) { }); } -function inspectorClient(url) { +export function inspectorClient(url) { const socket = new WebSocket(url); const pending = new Map(); let nextId = 1; + let terminalError; + const fail = (error) => { + if (terminalError) return; + terminalError = error; + for (const request of pending.values()) request.reject(error); + pending.clear(); + }; socket.addEventListener("message", ({ data }) => { const message = JSON.parse(data); if (!message.id) return; @@ -91,24 +81,46 @@ function inspectorClient(url) { if (message.error) request.reject(new Error(message.error.message)); else request.resolve(message.result); }); + let rejectReady; const ready = new Promise((resolve, reject) => { + rejectReady = reject; socket.addEventListener("open", resolve, { once: true }); socket.addEventListener( "error", - () => reject(new Error(`Could not connect to Node inspector at ${url}.`)), + () => { + const error = new Error( + `Could not connect to Node inspector at ${url}.`, + ); + fail(error); + reject(error); + }, { once: true }, ); }); + socket.addEventListener("close", () => { + const error = new Error("Node inspector connection closed."); + fail(error); + rejectReady(error); + }); return { async send(method, params) { await ready; + if (terminalError) throw terminalError; return await new Promise((resolve, reject) => { const id = nextId++; pending.set(id, { resolve, reject }); - socket.send(JSON.stringify({ id, method, params })); + try { + socket.send(JSON.stringify({ id, method, params })); + } catch (error) { + pending.delete(id); + reject(error); + } }); }, close() { + const error = new Error("Node inspector connection closed."); + fail(error); + rejectReady(error); socket.close(); }, }; @@ -118,7 +130,10 @@ function signal(child, name) { if (!child.pid || child.exitCode !== null || child.signalCode !== null) return; try { - process.kill(-child.pid, name); + const desktopLauncher = child.spawnargs?.includes( + "scripts/desktop-dev.mjs", + ); + process.kill(desktopLauncher ? child.pid : -child.pid, name); } catch (error) { if (error.code !== "ESRCH") throw error; } @@ -136,7 +151,7 @@ async function output(command, commandArgs) { }); } -async function recordManifest(extra = {}) { +async function recordManifest(directory, target, profileArgs, extra = {}) { await writeFile( `${directory}/manifest.json`, `${JSON.stringify( @@ -178,10 +193,13 @@ async function stopChildren() { for (const child of children) signal(child, "SIGTERM"); } -function installSignals(stop) { +function stopController() { + const abort = new AbortController(); + let resolve; + const requested = new Promise((value) => (resolve = value)); for (const name of ["SIGINT", "SIGTERM"]) { process.on(name, () => { - if (stopping) { + if (abort.signal.aborted) { forced = true; for (const child of children) signal(child, "SIGKILL"); return; @@ -189,14 +207,11 @@ function installSignals(stop) { console.log( `\n${name === "SIGINT" ? "Ctrl-C" : name} received; finalizing profile...`, ); - stopping = Promise.resolve() - .then(stop) - .catch((error) => { - console.error(error); - process.exitCode = 1; - }); + abort.abort(); + resolve({ reason: "signal", code: 0 }); }); } + return { abort, requested }; } function vitePort(values) { @@ -208,15 +223,18 @@ function vitePort(values) { return 1430; } -async function waitForServer(url, child) { +async function waitForServer(url, child, signal) { const deadline = Date.now() + 30_000; while (Date.now() < deadline) { + signal.throwIfAborted(); if (child.exitCode !== null) throw new Error(`Vite exited before ${url} became ready.`); try { - const response = await fetch(url); + const response = await fetch(url, { signal }); if (response.ok) return; - } catch {} + } catch (error) { + if (signal.aborted) throw error; + } await new Promise((resolve) => setTimeout(resolve, 150)); } throw new Error(`Timed out waiting for ${url}.`); @@ -234,7 +252,7 @@ function headerValue(headers, name) { return undefined; } -function networkRecorder(session) { +export function networkRecorder(session, directory) { const requests = new Map(); const webSockets = new Map(); const startedAt = new Date().toISOString(); @@ -360,31 +378,16 @@ function networkRecorder(session) { }; } -async function writeProtocolStream(session, handle, destination) { - const output = createWriteStream(destination); - try { - for (;;) { - const { data, base64Encoded, eof } = await session.send("IO.read", { - handle, - }); - output.write(data, base64Encoded ? "base64" : "utf8"); - if (eof) break; - } - await session.send("IO.close", { handle }); - } finally { - await new Promise((resolve, reject) => { - output.once("error", reject); - output.end(resolve); - }); - } +export function webViteArgs(values) { + return values.includes("--strictPort") ? values : [...values, "--strictPort"]; } -async function profileWeb() { +async function profileWeb({ directory, profileArgs, args, network }) { const port = vitePort(args); if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("--port must be an integer between 1 and 65535."); const url = `http://localhost:${port}`; - await recordManifest({ + await recordManifest(directory, "web", profileArgs, { coverage: [ "chromium-renderer", "vite-broker", @@ -392,44 +395,111 @@ async function profileWeb() { ], network, }); + + const control = stopController(); + const cancelled = control.requested.then(() => { + throw new DOMException("Profiling startup cancelled.", "AbortError"); + }); const nodeOptions = [process.env.NODE_OPTIONS, "--inspect=127.0.0.1:0"] .filter(Boolean) .join(" "); const vite = run( process.execPath, - ["node_modules/vite/bin/vite.js", ...args], + ["node_modules/vite/bin/vite.js", ...webViteArgs(args)], { env: { ...process.env, NODE_OPTIONS: nodeOptions }, stdio: ["inherit", "pipe", "pipe"], }, ); const inspectorUrl = waitForInspector(vite); + const viteExit = new Promise((resolve) => + vite.once("exit", (code, signal) => + resolve({ reason: "vite", code: code ?? (signal ? 1 : 0) }), + ), + ); mirror(vite); + let browser; let session; let networkCapture; - let tracingComplete; let nodeProfiler; - const stop = async () => { - let failure; - try { - let rendererProfile; - let brokerProfile; - if (session) { + let rendererStarted = false; + let brokerStarted = false; + let captureStarted = false; + let outcome; + const failures = []; + try { + await waitForServer(url, vite, control.abort.signal); + control.abort.signal.throwIfAborted(); + nodeProfiler = inspectorClient( + await Promise.race([inspectorUrl, cancelled]), + ); + await nodeProfiler.send("Profiler.enable"); + await nodeProfiler.send("Profiler.setSamplingInterval", { interval: 1000 }); + await nodeProfiler.send("Profiler.start"); + brokerStarted = true; + control.abort.signal.throwIfAborted(); + + const { chromium } = await import("@playwright/test"); + const browserLaunch = chromium.launch({ + channel: "chrome", + headless: false, + handleSIGINT: false, + handleSIGTERM: false, + }); + void browserLaunch.then((launched) => { + if (control.abort.signal.aborted && launched !== browser) + return launched.close(); + }); + browser = await Promise.race([browserLaunch, cancelled]); + control.abort.signal.throwIfAborted(); + const page = await browser.newPage(); + session = await page.context().newCDPSession(page); + if (network) { + networkCapture = networkRecorder(session, directory); + await networkCapture.start(page); + } + await session.send("Profiler.enable"); + await session.send("Profiler.setSamplingInterval", { interval: 1000 }); + await session.send("Profiler.start"); + rendererStarted = true; + await page.goto(url); + control.abort.signal.throwIfAborted(); + captureStarted = true; + console.log( + `\nProfiling ${url}. Press Ctrl-C to stop and save the profile.`, + ); + outcome = await Promise.race([control.requested, viteExit]); + } catch (error) { + if (!control.abort.signal.aborted) failures.push(error); + outcome ??= { reason: "startup", code: 1 }; + } finally { + let rendererProfile; + let brokerProfile; + if (rendererStarted) { + try { ({ profile: rendererProfile } = await session.send("Profiler.stop")); await writeFile( `${directory}/chromium-renderer.cpuprofile`, `${JSON.stringify(rendererProfile)}\n`, ); + } catch (error) { + failures.push(error); } - if (nodeProfiler) { + } + if (brokerStarted) { + try { ({ profile: brokerProfile } = await nodeProfiler.send("Profiler.stop")); await writeFile( `${directory}/vite-broker.cpuprofile`, `${JSON.stringify(brokerProfile)}\n`, ); + } catch (error) { + failures.push(error); } - if (networkCapture) { + } + if (networkCapture) { + try { await networkCapture.write({ renderer: rendererProfile && { startTimeMicroseconds: rendererProfile.startTime, @@ -441,132 +511,127 @@ async function profileWeb() { note: "Node inspector monotonic epoch is isolate-specific; align by capture start/end rather than assuming the renderer epoch.", }, }); - const complete = tracingComplete; - await session.send("Tracing.end"); - const { stream } = await complete; - await writeProtocolStream( - session, - stream, - `${directory}/chrome-performance.json`, - ); + } catch (error) { + failures.push(error); } - } catch (error) { - failure = error; - } finally { - nodeProfiler?.close(); + } + nodeProfiler?.close(); + try { await browser?.close(); - await stopChildren(); + } catch (error) { + failures.push(error); } - if (failure) throw failure; - console.log(`\nProfile saved to ${directory}`); - process.exitCode = forced ? 130 : 0; - }; - installSignals(stop); - await waitForServer(url, vite); - nodeProfiler = inspectorClient(await inspectorUrl); - await nodeProfiler.send("Profiler.enable"); - await nodeProfiler.send("Profiler.setSamplingInterval", { interval: 1000 }); - await nodeProfiler.send("Profiler.start"); - const { chromium } = await import("@playwright/test"); - browser = await chromium.launch({ - channel: "chrome", - headless: false, - handleSIGINT: false, - handleSIGTERM: false, - }); - const page = await browser.newPage(); - session = await page.context().newCDPSession(page); - if (network) { - networkCapture = networkRecorder(session); - await networkCapture.start(page); - tracingComplete = new Promise((resolve) => - session.once("Tracing.tracingComplete", resolve), + await stopChildren(); + } + + if (!captureStarted) { + throw new Error( + `Profiling stopped during startup; partial artifacts remain at ${directory}.`, + { cause: failures[0] }, ); - await session.send("Tracing.start", { - categories: [ - "devtools.timeline", - "v8.execute", - "loading", - "disabled-by-default-devtools.timeline", - "disabled-by-default-devtools.timeline.frame", - "disabled-by-default-v8.cpu_profiler", - "disabled-by-default-v8.cpu_profiler.hires", - ].join(","), - options: "sampling-frequency=10000", - transferMode: "ReturnAsStream", - }); } - await session.send("Profiler.enable"); - await session.send("Profiler.setSamplingInterval", { interval: 1000 }); - await session.send("Profiler.start"); - await page.goto(url); - console.log(`\nProfiling ${url}. Press Ctrl-C to stop and save the profile.`); - const code = await new Promise((resolve) => - vite.once("exit", (value) => resolve(value ?? 0)), + if (failures.length) throw failures[0]; + console.log(`\nProfile saved to ${directory}`); + process.exitCode = forced ? 130 : (outcome?.code ?? 0); +} + +function hasRunnerArgument(values) { + return values.some( + (value) => + value === "--runner" || value === "-r" || value.startsWith("--runner="), ); - if (!stopping) { - await stop(); - process.exitCode = code; - } } -async function profileDesktop() { - const trace = `${directory}/desktop-time-profile.trace`; - await recordManifest({ - coverage: ["all-native-processes"], - caveat: "JavaScriptCore stacks may not resolve to application JavaScript.", - }); - run("xcrun", [ - "xctrace", - "record", - "--template", - "Time Profiler", - "--all-processes", - "--output", - trace, - "--no-prompt", - ]); - let desktop; - const stop = async () => { - await stopChildren(); +async function waitForTrace(trace) { + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { if ( - !(await commandSucceeds("xcrun", [ + await commandSucceeds("xcrun", [ "xctrace", "export", "--input", trace, "--toc", - ])) - ) { - throw new Error( - `Instruments left an incomplete trace at ${trace}; rerun and press Ctrl-C only once.`, - ); - } - console.log(`\nProfile saved to ${trace}`); - process.exitCode = forced ? 130 : 0; - }; - installSignals(stop); - // xctrace has no machine-readable readiness event without coupling to a custom notification. - // Start compilation immediately: Time Profiler records system-wide, including processes created afterward. - desktop = run(process.execPath, ["scripts/desktop-dev.mjs", ...args]); - console.log( - "\nProfiling the desktop process tree. Press Ctrl-C to stop and save the profile.", + ]) + ) + return; + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error( + `Instruments left an incomplete trace at ${trace}; rerun and press Ctrl-C only once.`, ); - const code = await new Promise((resolve) => - desktop.once("exit", (value) => resolve(value ?? 0)), +} + +async function profileDesktop({ directory, profileArgs, args }) { + if (hasRunnerArgument(args)) + throw new Error("Desktop profiling owns Tauri's --runner option."); + const trace = `${directory}/desktop-time-profile.trace`; + await recordManifest(directory, "desktop", profileArgs, { + coverage: ["launched-desktop-application"], + caveat: + "Instruments launches and records the Buzz application process instead of all macOS processes. JavaScriptCore stacks may not resolve to application JavaScript.", + }); + const control = stopController(); + const runner = fileURLToPath( + new URL("./profile-desktop-runner.mjs", import.meta.url), + ); + const desktop = run( + process.execPath, + ["scripts/desktop-dev.mjs", "--runner", runner, ...args], + { env: { ...process.env, BUZZ_PROFILE_TRACE: trace }, detached: false }, ); - if (!stopping) { - await stop(); - process.exitCode = code; + const desktopExit = new Promise((resolve) => + desktop.once("exit", (code, signal) => + resolve({ reason: "desktop", code: code ?? (signal ? 1 : 0) }), + ), + ); + console.log( + "\nProfiling the launched Buzz desktop application. Press Ctrl-C to stop and save the profile.", + ); + const outcome = await Promise.race([control.requested, desktopExit]); + await stopChildren(); + await waitForTrace(trace); + console.log(`\nProfile saved to ${trace}`); + process.exitCode = forced ? 130 : outcome.code; +} + +async function main() { + const target = process.argv[2]; + const profileArgs = process.argv.slice(3); + const network = target === "web" && profileArgs.includes("--network"); + const args = profileArgs.filter((argument) => argument !== "--network"); + if (target !== "web" && target !== "desktop") { + console.error( + "Usage: node scripts/profile-dev.mjs [arguments]", + ); + process.exitCode = 1; + return; + } + if (process.platform !== "darwin") { + console.error("Development profiling currently supports macOS only."); + process.exitCode = 1; + return; } + const stamp = new Date() + .toISOString() + .replaceAll(":", "-") + .replace(/\.\d{3}Z$/, "Z"); + const directory = `${root}.profiles/${stamp}-${target}`; + await mkdir(directory, { recursive: true }); + if (target === "web") + await profileWeb({ directory, profileArgs, args, network }); + else await profileDesktop({ directory, profileArgs, args }); } -try { - if (target === "web") await profileWeb(); - else await profileDesktop(); - await stopping; -} catch (error) { - console.error(error instanceof Error ? error.message : error); - await stopChildren(); - process.exitCode = 1; +if ( + process.argv[1] && + import.meta.url === new URL(process.argv[1], "file:").href +) { + try { + await main(); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + await stopChildren(); + process.exitCode = 1; + } } diff --git a/tests/integration/profile-dev.test.mjs b/tests/integration/profile-dev.test.mjs new file mode 100644 index 000000000..6131f8933 --- /dev/null +++ b/tests/integration/profile-dev.test.mjs @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { inspectorClient, webViteArgs } from "../../scripts/profile-dev.mjs"; + +test("web profiling requires the requested Vite port", () => { + assert.deepEqual(webViteArgs(["--port", "1431"]), [ + "--port", + "1431", + "--strictPort", + ]); + assert.deepEqual(webViteArgs(["--strictPort"]), ["--strictPort"]); +}); + +test("inspector requests reject after the transport closes", async () => { + const original = globalThis.WebSocket; + class ClosedSocket extends EventTarget { + send() {} + close() { + this.dispatchEvent(new Event("close")); + } + } + globalThis.WebSocket = ClosedSocket; + try { + const client = inspectorClient("ws://inspector.invalid"); + const request = client.send("Profiler.stop"); + await Promise.resolve(); + client.close(); + await assert.rejects(request, /Node inspector connection closed/); + await assert.rejects( + client.send("Profiler.stop"), + /Node inspector connection closed/, + ); + } finally { + globalThis.WebSocket = original; + } +}); From 25c1e2f76cc01123769c3fd4b4163e16041829cc Mon Sep 17 00:00:00 2001 From: am <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Date: Tue, 22 Sep 2026 17:22:35 -0700 Subject: [PATCH 04/10] fix: close profiler lifecycle gaps Co-authored-by: Kalvin Chau Signed-off-by: Kalvin Chau --- docs/contributing.md | 6 +- scripts/profile-desktop-runner.mjs | 39 +++++-- scripts/profile-dev.mjs | 154 ++++++++++++++++--------- tests/integration/profile-dev.test.mjs | 37 ++++-- 4 files changed, 158 insertions(+), 78 deletions(-) diff --git a/docs/contributing.md b/docs/contributing.md index 2338ca18a..b0b039246 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -68,8 +68,10 @@ need their own validation. override it; keep their development URL and frontend command consistent. Use `--` before runner/application arguments if they contain their own `--port` flag. On macOS, `just desktop profile` uses Instruments' Time Profiler to launch and - record only the Buzz application process, not every process on the desktop. Press - Ctrl+C to finalize and validate the trace; the command prints the saved `.trace` + record only the Buzz native parent process, not every process on the desktop. + WebKit subprocesses and the Vite broker are outside this native trace; use web + profiling when renderer/broker CPU coverage is required. Native file watching is + disabled during capture. Press Ctrl+C to finalize and validate the trace; the path, which opens in Instruments. Use `just profile-clean` to remove all generated web and desktop captures. - `just design [args...]`: install locked dependencies, start the standalone diff --git a/scripts/profile-desktop-runner.mjs b/scripts/profile-desktop-runner.mjs index 55a46eeea..c2fa32d12 100755 --- a/scripts/profile-desktop-runner.mjs +++ b/scripts/profile-desktop-runner.mjs @@ -1,6 +1,5 @@ #!/usr/bin/env node import { spawnSync } from "node:child_process"; -import path from "node:path"; import process from "node:process"; const trace = process.env.BUZZ_PROFILE_TRACE; @@ -14,9 +13,12 @@ if (!trace || runnerArgs[0] !== "run") { process.exit(1); } -const build = spawnSync("cargo", ["build", ...runnerArgs.slice(1)], { - stdio: "inherit", -}); +const build = spawnSync( + "cargo", + ["build", "--message-format=json-render-diagnostics", ...runnerArgs.slice(1)], + { encoding: "utf8" }, +); +if (build.stderr) process.stderr.write(build.stderr); if (build.error) console.error(build.error.message); if (build.signal) process.kill(process.pid, build.signal); if (build.status !== 0) process.exit(build.status ?? 1); @@ -29,18 +31,31 @@ const metadata = spawnSync( if (metadata.error) console.error(metadata.error.message); if (metadata.signal) process.kill(process.pid, metadata.signal); if (metadata.status !== 0) process.exit(metadata.status ?? 1); -const { target_directory: targetDirectory, packages } = JSON.parse( - metadata.stdout, +const rootPackage = JSON.parse(metadata.stdout).packages.find( + ({ manifest_path: manifestPath }) => + manifestPath === `${process.cwd()}/Cargo.toml`, ); -const packageName = packages.find( - (entry) => entry.manifest_path === path.resolve("Cargo.toml"), -)?.name; -if (!packageName) { +if (!rootPackage) { console.error("Could not resolve the desktop Cargo package."); process.exit(1); } -const target = runnerArgs.includes("--release") ? "release" : "debug"; -const executable = path.join(targetDirectory, target, packageName); + +let executable; +for (const line of build.stdout.split("\n")) { + if (!line.startsWith("{")) continue; + const message = JSON.parse(line); + if ( + message.reason === "compiler-artifact" && + message.executable && + message.target?.kind?.includes("bin") && + rootPackage.targets.some(({ name }) => name === message.target.name) + ) + executable = message.executable; +} +if (!executable) { + console.error("Cargo did not report a desktop executable."); + process.exit(1); +} const result = spawnSync( "xcrun", [ diff --git a/scripts/profile-dev.mjs b/scripts/profile-dev.mjs index 0196d2b0e..07f9e7dd6 100644 --- a/scripts/profile-dev.mjs +++ b/scripts/profile-dev.mjs @@ -130,10 +130,7 @@ function signal(child, name) { if (!child.pid || child.exitCode !== null || child.signalCode !== null) return; try { - const desktopLauncher = child.spawnargs?.includes( - "scripts/desktop-dev.mjs", - ); - process.kill(desktopLauncher ? child.pid : -child.pid, name); + process.kill(-child.pid, name); } catch (error) { if (error.code !== "ESRCH") throw error; } @@ -186,10 +183,7 @@ function waitForExit(child, timeoutMs) { async function stopChildren() { for (const child of children) signal(child, "SIGINT"); - await Promise.race([ - Promise.all([...children].map((child) => waitForExit(child, 30_000))), - new Promise((resolve) => setTimeout(resolve, 30_000)), - ]); + await Promise.all([...children].map((child) => waitForExit(child, 30_000))); for (const child of children) signal(child, "SIGTERM"); } @@ -214,34 +208,86 @@ function stopController() { return { abort, requested }; } -function vitePort(values) { +export function normalizeWebViteArgs(values) { + const normalized = []; + let port = 1430; + let sawPort = false; for (let index = 0; index < values.length; index++) { - if (values[index] === "--port") return Number(values[index + 1]); - if (values[index].startsWith("--port=")) - return Number(values[index].slice(7)); + const value = values[index]; + if (value === "--") + throw new Error("Web profiling does not accept Vite arguments after --."); + if (value === "--port") { + if (sawPort) + throw new Error("Web profiling accepts only one --port option."); + sawPort = true; + port = Number(values[++index]); + continue; + } + if (value.startsWith("--port=")) { + if (sawPort) + throw new Error("Web profiling accepts only one --port option."); + sawPort = true; + port = Number(value.slice(7)); + continue; + } + if ( + value === "--strictPort" || + value === "--no-strictPort" || + value.startsWith("--strictPort=") + ) { + if (value !== "--strictPort") + throw new Error("Web profiling requires --strictPort."); + continue; + } + normalized.push(value); } - return 1430; + if (!Number.isInteger(port) || port < 1 || port > 65535) + throw new Error("--port must be an integer between 1 and 65535."); + return { + port, + args: [...normalized, "--port", String(port), "--strictPort"], + }; } -async function waitForServer(url, child, signal) { - const deadline = Date.now() + 30_000; - while (Date.now() < deadline) { - signal.throwIfAborted(); - if (child.exitCode !== null) - throw new Error(`Vite exited before ${url} became ready.`); - try { - const response = await fetch(url, { signal }); - if (response.ok) return; - } catch (error) { - if (signal.aborted) throw error; - } - await new Promise((resolve) => setTimeout(resolve, 150)); - } - throw new Error(`Timed out waiting for ${url}.`); +async function waitForViteReady(url, child, signal) { + await new Promise((resolve, reject) => { + let output = ""; + const onData = (chunk) => { + output = `${output}${chunk}`; + if (output.includes(`Local: ${url}/`)) finish(resolve); + else if (/Port \d+ is (?:already )?in use/.test(output)) + finish(() => reject(new Error(`Vite could not bind ${url}.`))); + if (output.length > 16_384) output = output.slice(-8_192); + }; + const onExit = () => + finish(() => + reject(new Error(`Vite exited before ${url} became ready.`)), + ); + const onAbort = () => + finish(() => + reject(new DOMException("Profiling startup cancelled.", "AbortError")), + ); + const finish = (settle) => { + child.stdout.off("data", onData); + child.stderr.off("data", onData); + child.off("exit", onExit); + signal.removeEventListener("abort", onAbort); + settle(); + }; + child.stdout.on("data", onData); + child.stderr.on("data", onData); + child.once("exit", onExit); + signal.addEventListener("abort", onAbort, { once: true }); + }); + const response = await fetch(url, { signal }); + if (!response.ok) + throw new Error(`Vite at ${url} returned ${response.status}.`); } -function safeUrl(value) { +export function safeUrl(value) { const url = new URL(value); + if (!["http:", "https:", "ws:", "wss:"].includes(url.protocol)) + return `${url.protocol}`; return `${url.origin}${url.pathname}`; } @@ -378,15 +424,9 @@ export function networkRecorder(session, directory) { }; } -export function webViteArgs(values) { - return values.includes("--strictPort") ? values : [...values, "--strictPort"]; -} - async function profileWeb({ directory, profileArgs, args, network }) { - const port = vitePort(args); - if (!Number.isInteger(port) || port < 1 || port > 65535) - throw new Error("--port must be an integer between 1 and 65535."); - const url = `http://localhost:${port}`; + const vite = normalizeWebViteArgs(args); + const url = `http://localhost:${vite.port}`; await recordManifest(directory, "web", profileArgs, { coverage: [ "chromium-renderer", @@ -397,27 +437,25 @@ async function profileWeb({ directory, profileArgs, args, network }) { }); const control = stopController(); - const cancelled = control.requested.then(() => { - throw new DOMException("Profiling startup cancelled.", "AbortError"); - }); const nodeOptions = [process.env.NODE_OPTIONS, "--inspect=127.0.0.1:0"] .filter(Boolean) .join(" "); - const vite = run( + const viteProcess = run( process.execPath, - ["node_modules/vite/bin/vite.js", ...webViteArgs(args)], + ["node_modules/vite/bin/vite.js", ...vite.args], { env: { ...process.env, NODE_OPTIONS: nodeOptions }, stdio: ["inherit", "pipe", "pipe"], }, ); - const inspectorUrl = waitForInspector(vite); + const inspectorUrl = waitForInspector(viteProcess); + void inspectorUrl.catch(() => {}); const viteExit = new Promise((resolve) => - vite.once("exit", (code, signal) => + viteProcess.once("exit", (code, signal) => resolve({ reason: "vite", code: code ?? (signal ? 1 : 0) }), ), ); - mirror(vite); + mirror(viteProcess); let browser; let session; @@ -429,11 +467,9 @@ async function profileWeb({ directory, profileArgs, args, network }) { let outcome; const failures = []; try { - await waitForServer(url, vite, control.abort.signal); + await waitForViteReady(url, viteProcess, control.abort.signal); control.abort.signal.throwIfAborted(); - nodeProfiler = inspectorClient( - await Promise.race([inspectorUrl, cancelled]), - ); + nodeProfiler = inspectorClient(await inspectorUrl); await nodeProfiler.send("Profiler.enable"); await nodeProfiler.send("Profiler.setSamplingInterval", { interval: 1000 }); await nodeProfiler.send("Profiler.start"); @@ -447,11 +483,17 @@ async function profileWeb({ directory, profileArgs, args, network }) { handleSIGINT: false, handleSIGTERM: false, }); - void browserLaunch.then((launched) => { + const lateBrowserCleanup = browserLaunch.then(async (launched) => { if (control.abort.signal.aborted && launched !== browser) - return launched.close(); + await launched.close(); + return launched; }); - browser = await Promise.race([browserLaunch, cancelled]); + browser = await Promise.race([ + lateBrowserCleanup, + control.requested.then(() => undefined), + ]); + if (!browser) + throw new DOMException("Profiling startup cancelled.", "AbortError"); control.abort.signal.throwIfAborted(); const page = await browser.newPage(); session = await page.context().newCDPSession(page); @@ -567,9 +609,9 @@ async function profileDesktop({ directory, profileArgs, args }) { throw new Error("Desktop profiling owns Tauri's --runner option."); const trace = `${directory}/desktop-time-profile.trace`; await recordManifest(directory, "desktop", profileArgs, { - coverage: ["launched-desktop-application"], + coverage: ["launched-desktop-native-application"], caveat: - "Instruments launches and records the Buzz application process instead of all macOS processes. JavaScriptCore stacks may not resolve to application JavaScript.", + "Instruments launches and samples the Buzz native parent process only, not all macOS processes. WebKit subprocesses and the Vite broker are outside this trace; JavaScriptCore stacks may not resolve to application JavaScript.", }); const control = stopController(); const runner = fileURLToPath( @@ -577,8 +619,8 @@ async function profileDesktop({ directory, profileArgs, args }) { ); const desktop = run( process.execPath, - ["scripts/desktop-dev.mjs", "--runner", runner, ...args], - { env: { ...process.env, BUZZ_PROFILE_TRACE: trace }, detached: false }, + ["scripts/desktop-dev.mjs", "--runner", runner, "--no-watch", ...args], + { env: { ...process.env, BUZZ_PROFILE_TRACE: trace } }, ); const desktopExit = new Promise((resolve) => desktop.once("exit", (code, signal) => diff --git a/tests/integration/profile-dev.test.mjs b/tests/integration/profile-dev.test.mjs index 6131f8933..90dd9f924 100644 --- a/tests/integration/profile-dev.test.mjs +++ b/tests/integration/profile-dev.test.mjs @@ -1,14 +1,35 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { inspectorClient, webViteArgs } from "../../scripts/profile-dev.mjs"; +import { + inspectorClient, + normalizeWebViteArgs, + safeUrl, +} from "../../scripts/profile-dev.mjs"; -test("web profiling requires the requested Vite port", () => { - assert.deepEqual(webViteArgs(["--port", "1431"]), [ - "--port", - "1431", - "--strictPort", - ]); - assert.deepEqual(webViteArgs(["--strictPort"]), ["--strictPort"]); +test("web profiling canonicalizes its Vite port and strict-port contract", () => { + assert.deepEqual( + normalizeWebViteArgs(["--host", "127.0.0.1", "--port=1431"]), + { + port: 1431, + args: ["--host", "127.0.0.1", "--port", "1431", "--strictPort"], + }, + ); + assert.throws( + () => normalizeWebViteArgs(["--port", "1431", "--port", "1432"]), + /only one --port/, + ); + assert.throws( + () => normalizeWebViteArgs(["--no-strictPort"]), + /requires --strictPort/, + ); +}); + +test("network URLs redact opaque payloads and strip HTTP secrets", () => { + assert.equal(safeUrl("data:text/plain,PRIVATE_BODY"), "data:"); + assert.equal( + safeUrl("https://user:password@example.com/path?q=secret#fragment"), + "https://example.com/path", + ); }); test("inspector requests reject after the transport closes", async () => { From 82ae75f19e7e77261bc8e625af42c3d364efc5a8 Mon Sep 17 00:00:00 2001 From: am <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Date: Tue, 22 Sep 2026 17:52:23 -0700 Subject: [PATCH 05/10] fix: retain profiler process ownership Co-authored-by: Kalvin Chau Signed-off-by: Kalvin Chau --- scripts/profile-dev.mjs | 97 ++++++++++++++++++++++++----------------- 1 file changed, 58 insertions(+), 39 deletions(-) diff --git a/scripts/profile-dev.mjs b/scripts/profile-dev.mjs index 07f9e7dd6..6d75865ba 100644 --- a/scripts/profile-dev.mjs +++ b/scripts/profile-dev.mjs @@ -1,11 +1,13 @@ import { spawn } from "node:child_process"; import { mkdir, writeFile } from "node:fs/promises"; +import { createServer } from "node:net"; import { fileURLToPath } from "node:url"; import process from "node:process"; const root = fileURLToPath(new URL("../", import.meta.url)); const children = new Set(); +const processGroups = new Set(); let forced = false; function run(command, commandArgs, options = {}) { @@ -16,6 +18,7 @@ function run(command, commandArgs, options = {}) { ...options, }); children.add(child); + if (child.pid) processGroups.add(child.pid); child.once("exit", () => children.delete(child)); return child; } @@ -126,13 +129,13 @@ export function inspectorClient(url) { }; } -function signal(child, name) { - if (!child.pid || child.exitCode !== null || child.signalCode !== null) - return; +function signalGroup(pid, name) { + if (!pid) return; try { - process.kill(-child.pid, name); + process.kill(-pid, name); } catch (error) { if (error.code !== "ESRCH") throw error; + processGroups.delete(pid); } } @@ -182,9 +185,9 @@ function waitForExit(child, timeoutMs) { } async function stopChildren() { - for (const child of children) signal(child, "SIGINT"); + for (const pid of processGroups) signalGroup(pid, "SIGINT"); await Promise.all([...children].map((child) => waitForExit(child, 30_000))); - for (const child of children) signal(child, "SIGTERM"); + for (const pid of processGroups) signalGroup(pid, "SIGTERM"); } function stopController() { @@ -195,7 +198,7 @@ function stopController() { process.on(name, () => { if (abort.signal.aborted) { forced = true; - for (const child of children) signal(child, "SIGKILL"); + for (const pid of processGroups) signalGroup(pid, "SIGKILL"); return; } console.log( @@ -249,39 +252,30 @@ export function normalizeWebViteArgs(values) { }; } -async function waitForViteReady(url, child, signal) { +async function reservePort(port) { + const server = createServer(); await new Promise((resolve, reject) => { - let output = ""; - const onData = (chunk) => { - output = `${output}${chunk}`; - if (output.includes(`Local: ${url}/`)) finish(resolve); - else if (/Port \d+ is (?:already )?in use/.test(output)) - finish(() => reject(new Error(`Vite could not bind ${url}.`))); - if (output.length > 16_384) output = output.slice(-8_192); - }; - const onExit = () => - finish(() => - reject(new Error(`Vite exited before ${url} became ready.`)), - ); - const onAbort = () => - finish(() => - reject(new DOMException("Profiling startup cancelled.", "AbortError")), - ); - const finish = (settle) => { - child.stdout.off("data", onData); - child.stderr.off("data", onData); - child.off("exit", onExit); - signal.removeEventListener("abort", onAbort); - settle(); - }; - child.stdout.on("data", onData); - child.stderr.on("data", onData); - child.once("exit", onExit); - signal.addEventListener("abort", onAbort, { once: true }); + server.once("error", reject); + server.listen({ port, host: "::", ipv6Only: false }, resolve); }); - const response = await fetch(url, { signal }); - if (!response.ok) - throw new Error(`Vite at ${url} returned ${response.status}.`); + return server; +} + +async function waitForViteReady(url, child, signal) { + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + signal.throwIfAborted(); + if (child.exitCode !== null) + throw new Error(`Vite exited before ${url} became ready.`); + try { + const response = await fetch(url, { signal }); + if (response.ok) return; + } catch (error) { + if (signal.aborted) throw error; + } + await new Promise((resolve) => setTimeout(resolve, 150)); + } + throw new Error(`Timed out waiting for ${url}.`); } export function safeUrl(value) { @@ -437,6 +431,15 @@ async function profileWeb({ directory, profileArgs, args, network }) { }); const control = stopController(); + let reservation; + try { + reservation = await reservePort(vite.port); + } catch (error) { + throw new Error(`Profiling port ${vite.port} is already in use.`, { + cause: error, + }); + } + await new Promise((resolve) => reservation.close(resolve)); const nodeOptions = [process.env.NODE_OPTIONS, "--inspect=127.0.0.1:0"] .filter(Boolean) .join(" "); @@ -604,6 +607,17 @@ async function waitForTrace(trace) { ); } +function desktopProfileArgs(values) { + const separator = values.indexOf("--"); + const options = separator < 0 ? values : values.slice(0, separator); + const application = separator < 0 ? [] : values.slice(separator); + return [ + ...options.filter((value) => value !== "--no-watch"), + "--no-watch", + ...application, + ]; +} + async function profileDesktop({ directory, profileArgs, args }) { if (hasRunnerArgument(args)) throw new Error("Desktop profiling owns Tauri's --runner option."); @@ -619,7 +633,12 @@ async function profileDesktop({ directory, profileArgs, args }) { ); const desktop = run( process.execPath, - ["scripts/desktop-dev.mjs", "--runner", runner, "--no-watch", ...args], + [ + "scripts/desktop-dev.mjs", + "--runner", + runner, + ...desktopProfileArgs(args), + ], { env: { ...process.env, BUZZ_PROFILE_TRACE: trace } }, ); const desktopExit = new Promise((resolve) => From 867154fe633a1ad8376209c9c973e447d58c8a83 Mon Sep 17 00:00:00 2001 From: am <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Date: Tue, 22 Sep 2026 17:59:32 -0700 Subject: [PATCH 06/10] fix: wait for profiler group finalization Co-authored-by: Kalvin Chau Signed-off-by: Kalvin Chau --- scripts/profile-dev.mjs | 91 ++++++++++++++++++++++++++++++++--------- 1 file changed, 71 insertions(+), 20 deletions(-) diff --git a/scripts/profile-dev.mjs b/scripts/profile-dev.mjs index 6d75865ba..1a50f1fc2 100644 --- a/scripts/profile-dev.mjs +++ b/scripts/profile-dev.mjs @@ -173,21 +173,33 @@ async function recordManifest(directory, target, profileArgs, extra = {}) { ); } -function waitForExit(child, timeoutMs) { - if (child.exitCode !== null) return Promise.resolve(); - return new Promise((resolve) => { - const timer = setTimeout(resolve, timeoutMs); - child.once("exit", () => { - clearTimeout(timer); - resolve(); - }); - }); +function processGroupAlive(pid) { + try { + process.kill(-pid, 0); + return true; + } catch (error) { + if (error.code === "ESRCH") { + processGroups.delete(pid); + return false; + } + if (error.code === "EPERM") return true; + throw error; + } +} + +async function waitForProcessGroups(timeoutMs) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (![...processGroups].some(processGroupAlive)) return; + await new Promise((resolve) => setTimeout(resolve, 100)); + } } async function stopChildren() { for (const pid of processGroups) signalGroup(pid, "SIGINT"); - await Promise.all([...children].map((child) => waitForExit(child, 30_000))); - for (const pid of processGroups) signalGroup(pid, "SIGTERM"); + await waitForProcessGroups(30_000); + for (const pid of processGroups) + if (processGroupAlive(pid)) signalGroup(pid, "SIGTERM"); } function stopController() { @@ -253,12 +265,44 @@ export function normalizeWebViteArgs(values) { } async function reservePort(port) { - const server = createServer(); - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen({ port, host: "::", ipv6Only: false }, resolve); + const servers = []; + try { + for (const host of ["127.0.0.1", "::1"]) { + const server = createServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, host, resolve); + }); + servers.push(server); + } + return servers; + } catch (error) { + await Promise.all( + servers.map((server) => new Promise((resolve) => server.close(resolve))), + ); + throw error; + } +} + +function viteBindFailure(child) { + return new Promise((_, reject) => { + let output = ""; + const onData = (chunk) => { + output += chunk; + if (/Port \d+ is (?:already )?in use/.test(output)) { + cleanup(); + reject(new Error("Vite could not bind the profiling port.")); + } + if (output.length > 16_384) output = output.slice(-8_192); + }; + const cleanup = () => { + child.stdout.off("data", onData); + child.stderr.off("data", onData); + }; + child.stdout.on("data", onData); + child.stderr.on("data", onData); + child.once("exit", cleanup); }); - return server; } async function waitForViteReady(url, child, signal) { @@ -431,15 +475,19 @@ async function profileWeb({ directory, profileArgs, args, network }) { }); const control = stopController(); - let reservation; + let reservations; try { - reservation = await reservePort(vite.port); + reservations = await reservePort(vite.port); } catch (error) { throw new Error(`Profiling port ${vite.port} is already in use.`, { cause: error, }); } - await new Promise((resolve) => reservation.close(resolve)); + await Promise.all( + reservations.map( + (server) => new Promise((resolve) => server.close(resolve)), + ), + ); const nodeOptions = [process.env.NODE_OPTIONS, "--inspect=127.0.0.1:0"] .filter(Boolean) .join(" "); @@ -470,7 +518,10 @@ async function profileWeb({ directory, profileArgs, args, network }) { let outcome; const failures = []; try { - await waitForViteReady(url, viteProcess, control.abort.signal); + await Promise.race([ + waitForViteReady(url, viteProcess, control.abort.signal), + viteBindFailure(viteProcess), + ]); control.abort.signal.throwIfAborted(); nodeProfiler = inspectorClient(await inspectorUrl); await nodeProfiler.send("Profiler.enable"); From aba050b2ea45844993fe9460566f90f97076bfdf Mon Sep 17 00:00:00 2001 From: am <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Date: Tue, 22 Sep 2026 18:10:35 -0700 Subject: [PATCH 07/10] fix: prove vite listener ownership before profiling Co-authored-by: Kalvin Chau Signed-off-by: Kalvin Chau --- scripts/profile-dev.mjs | 74 ++++++++++---------------- tests/integration/profile-dev.test.mjs | 34 ++++++++++++ vite.config.ts | 10 ++++ 3 files changed, 72 insertions(+), 46 deletions(-) diff --git a/scripts/profile-dev.mjs b/scripts/profile-dev.mjs index 1a50f1fc2..84f83dffd 100644 --- a/scripts/profile-dev.mjs +++ b/scripts/profile-dev.mjs @@ -1,6 +1,6 @@ +import { randomUUID } from "node:crypto"; import { spawn } from "node:child_process"; import { mkdir, writeFile } from "node:fs/promises"; -import { createServer } from "node:net"; import { fileURLToPath } from "node:url"; import process from "node:process"; @@ -264,44 +264,36 @@ export function normalizeWebViteArgs(values) { }; } -async function reservePort(port) { - const servers = []; - try { - for (const host of ["127.0.0.1", "::1"]) { - const server = createServer(); - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(port, host, resolve); - }); - servers.push(server); - } - return servers; - } catch (error) { - await Promise.all( - servers.map((server) => new Promise((resolve) => server.close(resolve))), - ); - throw error; - } -} - -function viteBindFailure(child) { - return new Promise((_, reject) => { +export function viteReadyToken(child, token, signal) { + return new Promise((resolve, reject) => { let output = ""; + const marker = `BUZZ_PROFILE_VITE_READY:${token}`; + const finish = (error) => { + cleanup(); + if (error) reject(error); + else resolve(); + }; const onData = (chunk) => { output += chunk; - if (/Port \d+ is (?:already )?in use/.test(output)) { - cleanup(); - reject(new Error("Vite could not bind the profiling port.")); - } + if (output.includes(marker)) finish(); + else if (/Port \d+ is (?:already )?in use/.test(output)) + finish(new Error("Vite could not bind the profiling port.")); if (output.length > 16_384) output = output.slice(-8_192); }; + const onExit = () => + finish(new Error("Vite exited before owning the profiling port.")); + const onAbort = () => + finish(new DOMException("Profiling startup cancelled.", "AbortError")); const cleanup = () => { child.stdout.off("data", onData); child.stderr.off("data", onData); + child.off("exit", onExit); + signal.removeEventListener("abort", onAbort); }; child.stdout.on("data", onData); child.stderr.on("data", onData); - child.once("exit", cleanup); + child.once("exit", onExit); + signal.addEventListener("abort", onAbort, { once: true }); }); } @@ -475,19 +467,7 @@ async function profileWeb({ directory, profileArgs, args, network }) { }); const control = stopController(); - let reservations; - try { - reservations = await reservePort(vite.port); - } catch (error) { - throw new Error(`Profiling port ${vite.port} is already in use.`, { - cause: error, - }); - } - await Promise.all( - reservations.map( - (server) => new Promise((resolve) => server.close(resolve)), - ), - ); + const readyToken = randomUUID(); const nodeOptions = [process.env.NODE_OPTIONS, "--inspect=127.0.0.1:0"] .filter(Boolean) .join(" "); @@ -495,7 +475,11 @@ async function profileWeb({ directory, profileArgs, args, network }) { process.execPath, ["node_modules/vite/bin/vite.js", ...vite.args], { - env: { ...process.env, NODE_OPTIONS: nodeOptions }, + env: { + ...process.env, + BUZZ_PROFILE_VITE_READY_TOKEN: readyToken, + NODE_OPTIONS: nodeOptions, + }, stdio: ["inherit", "pipe", "pipe"], }, ); @@ -518,10 +502,8 @@ async function profileWeb({ directory, profileArgs, args, network }) { let outcome; const failures = []; try { - await Promise.race([ - waitForViteReady(url, viteProcess, control.abort.signal), - viteBindFailure(viteProcess), - ]); + await viteReadyToken(viteProcess, readyToken, control.abort.signal); + await waitForViteReady(url, viteProcess, control.abort.signal); control.abort.signal.throwIfAborted(); nodeProfiler = inspectorClient(await inspectorUrl); await nodeProfiler.send("Profiler.enable"); diff --git a/tests/integration/profile-dev.test.mjs b/tests/integration/profile-dev.test.mjs index 90dd9f924..1fedf7f10 100644 --- a/tests/integration/profile-dev.test.mjs +++ b/tests/integration/profile-dev.test.mjs @@ -1,9 +1,11 @@ import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; import test from "node:test"; import { inspectorClient, normalizeWebViteArgs, safeUrl, + viteReadyToken, } from "../../scripts/profile-dev.mjs"; test("web profiling canonicalizes its Vite port and strict-port contract", () => { @@ -24,6 +26,38 @@ test("web profiling canonicalizes its Vite port and strict-port contract", () => ); }); +test("web profiling waits for its authenticated Vite listening marker", async () => { + const child = new EventEmitter(); + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + const abort = new AbortController(); + let settled = false; + const ready = viteReadyToken(child, "owned-token", abort.signal).then( + () => (settled = true), + ); + + child.stdout.emit("data", "BUZZ_PROFILE_VITE_READY:other-token\n"); + await Promise.resolve(); + assert.equal(settled, false); + + child.stderr.emit("data", "BUZZ_PROFILE_VITE_READY:owned-token\n"); + await ready; + assert.equal(settled, true); +}); + +test("web profiling rejects Vite bind failure before readiness", async () => { + const child = new EventEmitter(); + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + const ready = viteReadyToken( + child, + "owned-token", + new AbortController().signal, + ); + child.stderr.emit("data", "Error: Port 1430 is already in use\n"); + await assert.rejects(ready, /could not bind/); +}); + test("network URLs redact opaque payloads and strip HTTP secrets", () => { assert.equal(safeUrl("data:text/plain,PRIVATE_BODY"), "data:"); assert.equal( diff --git a/vite.config.ts b/vite.config.ts index a8fbab499..d0c807dc8 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -32,6 +32,16 @@ export default defineConfig(async ({ command, mode }) => { communityAliases: aliases, }), ); + const profileReadyToken = process.env.BUZZ_PROFILE_VITE_READY_TOKEN; + if (profileReadyToken) + plugins.push({ + name: "buzz-profile-ready", + configureServer(server) { + server.httpServer?.once("listening", () => { + console.log(`BUZZ_PROFILE_VITE_READY:${profileReadyToken}`); + }); + }, + }); return { plugins, define: { From bdc436e3de5eea3478b7f59a75390f41c2b99deb Mon Sep 17 00:00:00 2001 From: am <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Date: Tue, 22 Sep 2026 18:20:16 -0700 Subject: [PATCH 08/10] fix: navigate to owned vite listener Co-authored-by: Kalvin Chau Signed-off-by: Kalvin Chau --- scripts/profile-dev.mjs | 42 +++++++++++++++++++++----- tests/integration/profile-dev.test.mjs | 36 +++++++++++++++++----- vite.config.ts | 6 +++- 3 files changed, 68 insertions(+), 16 deletions(-) diff --git a/scripts/profile-dev.mjs b/scripts/profile-dev.mjs index 84f83dffd..5beef29ba 100644 --- a/scripts/profile-dev.mjs +++ b/scripts/profile-dev.mjs @@ -264,19 +264,47 @@ export function normalizeWebViteArgs(values) { }; } +export function viteListenerUrl(address) { + if ( + !address || + typeof address !== "object" || + !Number.isInteger(address.port) || + address.port < 1 || + address.port > 65535 + ) + throw new Error("Vite reported an invalid profiling listener address."); + let host = address.address; + if (host === "0.0.0.0") host = "127.0.0.1"; + else if (host === "::") host = "::1"; + if (typeof host !== "string" || !host) + throw new Error("Vite reported an invalid profiling listener address."); + if (host.includes(":")) host = `[${host}]`; + return `http://${host}:${address.port}`; +} + export function viteReadyToken(child, token, signal) { return new Promise((resolve, reject) => { let output = ""; - const marker = `BUZZ_PROFILE_VITE_READY:${token}`; - const finish = (error) => { + const marker = `BUZZ_PROFILE_VITE_READY:${token}:`; + const finish = (error, url) => { cleanup(); if (error) reject(error); - else resolve(); + else resolve(url); }; const onData = (chunk) => { output += chunk; - if (output.includes(marker)) finish(); - else if (/Port \d+ is (?:already )?in use/.test(output)) + const start = output.indexOf(marker); + const end = start < 0 ? -1 : output.indexOf("\n", start); + if (end >= 0) { + try { + const address = JSON.parse( + output.slice(start + marker.length, end).trim(), + ); + finish(undefined, viteListenerUrl(address)); + } catch (error) { + finish(error); + } + } else if (/Port \d+ is (?:already )?in use/.test(output)) finish(new Error("Vite could not bind the profiling port.")); if (output.length > 16_384) output = output.slice(-8_192); }; @@ -456,7 +484,6 @@ export function networkRecorder(session, directory) { async function profileWeb({ directory, profileArgs, args, network }) { const vite = normalizeWebViteArgs(args); - const url = `http://localhost:${vite.port}`; await recordManifest(directory, "web", profileArgs, { coverage: [ "chromium-renderer", @@ -501,8 +528,9 @@ async function profileWeb({ directory, profileArgs, args, network }) { let captureStarted = false; let outcome; const failures = []; + let url; try { - await viteReadyToken(viteProcess, readyToken, control.abort.signal); + url = await viteReadyToken(viteProcess, readyToken, control.abort.signal); await waitForViteReady(url, viteProcess, control.abort.signal); control.abort.signal.throwIfAborted(); nodeProfiler = inspectorClient(await inspectorUrl); diff --git a/tests/integration/profile-dev.test.mjs b/tests/integration/profile-dev.test.mjs index 1fedf7f10..88432421e 100644 --- a/tests/integration/profile-dev.test.mjs +++ b/tests/integration/profile-dev.test.mjs @@ -5,6 +5,7 @@ import { inspectorClient, normalizeWebViteArgs, safeUrl, + viteListenerUrl, viteReadyToken, } from "../../scripts/profile-dev.mjs"; @@ -26,23 +27,42 @@ test("web profiling canonicalizes its Vite port and strict-port contract", () => ); }); +test("web profiling derives navigation from its owned listener", () => { + assert.equal( + viteListenerUrl({ address: "127.0.0.1", port: 1430 }), + "http://127.0.0.1:1430", + ); + assert.equal( + viteListenerUrl({ address: "::1", port: 1430 }), + "http://[::1]:1430", + ); + assert.equal( + viteListenerUrl({ address: "::", port: 1430 }), + "http://[::1]:1430", + ); +}); + test("web profiling waits for its authenticated Vite listening marker", async () => { const child = new EventEmitter(); child.stdout = new EventEmitter(); child.stderr = new EventEmitter(); const abort = new AbortController(); - let settled = false; - const ready = viteReadyToken(child, "owned-token", abort.signal).then( - () => (settled = true), - ); + const ready = viteReadyToken(child, "owned-token", abort.signal); - child.stdout.emit("data", "BUZZ_PROFILE_VITE_READY:other-token\n"); + child.stdout.emit( + "data", + 'BUZZ_PROFILE_VITE_READY:other-token:{"address":"::1","port":1430}\n', + ); + let settled = false; + void ready.then(() => (settled = true)); await Promise.resolve(); assert.equal(settled, false); - child.stderr.emit("data", "BUZZ_PROFILE_VITE_READY:owned-token\n"); - await ready; - assert.equal(settled, true); + child.stderr.emit( + "data", + 'BUZZ_PROFILE_VITE_READY:owned-token:{"address":"127.0.0.1","port":1430}\n', + ); + assert.equal(await ready, "http://127.0.0.1:1430"); }); test("web profiling rejects Vite bind failure before readiness", async () => { diff --git a/vite.config.ts b/vite.config.ts index d0c807dc8..6234149c0 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -38,7 +38,11 @@ export default defineConfig(async ({ command, mode }) => { name: "buzz-profile-ready", configureServer(server) { server.httpServer?.once("listening", () => { - console.log(`BUZZ_PROFILE_VITE_READY:${profileReadyToken}`); + const address = server.httpServer?.address(); + if (address && typeof address === "object") + console.log( + `BUZZ_PROFILE_VITE_READY:${profileReadyToken}:${JSON.stringify(address)}`, + ); }); }, }); From 2cd0b7d67a08ee8228ada62186513c7d565af700 Mon Sep 17 00:00:00 2001 From: am <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Date: Tue, 22 Sep 2026 18:32:21 -0700 Subject: [PATCH 09/10] fix: constrain web profiling to ipv4 loopback Co-authored-by: Kalvin Chau Signed-off-by: Kalvin Chau --- docs/contributing.md | 4 +++- scripts/profile-dev.mjs | 29 +++++++++++++++++++------- tests/integration/profile-dev.test.mjs | 24 +++++++++++++++------ 3 files changed, 42 insertions(+), 15 deletions(-) diff --git a/docs/contributing.md b/docs/contributing.md index b0b039246..8c1956fb4 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -47,7 +47,9 @@ need their own validation. - `just web [args...]`: install locked dependencies and forward arguments to Vite, e.g. `just web --port 1431 --host 127.0.0.1`. Vite uses the requested port (default: 1430) or the next available port, allowing parallel browser development. - Use `just web profile` for opt-in Chromium and broker CPU profiles, or + Use `just web profile` for opt-in Chromium and broker CPU profiles. Profiling + binds only `127.0.0.1`; wildcard, hostname, and IPv6 `--host` values are rejected + so the captured page and development broker have one unambiguous owner. Use `just web profile --network` to additionally record sanitized browser network metadata in `network.json`; payloads, cookies, authorization headers, query strings, fragments, and WebSocket frame data are omitted. Press Ctrl+C to finalize the diff --git a/scripts/profile-dev.mjs b/scripts/profile-dev.mjs index 5beef29ba..dcba814e9 100644 --- a/scripts/profile-dev.mjs +++ b/scripts/profile-dev.mjs @@ -227,6 +227,7 @@ export function normalizeWebViteArgs(values) { const normalized = []; let port = 1430; let sawPort = false; + let sawHost = false; for (let index = 0; index < values.length; index++) { const value = values[index]; if (value === "--") @@ -245,6 +246,15 @@ export function normalizeWebViteArgs(values) { port = Number(value.slice(7)); continue; } + if (value === "--host" || value.startsWith("--host=")) { + if (sawHost) + throw new Error("Web profiling accepts only one --host option."); + sawHost = true; + const host = value === "--host" ? values[++index] : value.slice(7); + if (host !== "127.0.0.1") + throw new Error("Web profiling requires --host 127.0.0.1."); + continue; + } if ( value === "--strictPort" || value === "--no-strictPort" || @@ -260,7 +270,14 @@ export function normalizeWebViteArgs(values) { throw new Error("--port must be an integer between 1 and 65535."); return { port, - args: [...normalized, "--port", String(port), "--strictPort"], + args: [ + ...normalized, + "--host", + "127.0.0.1", + "--port", + String(port), + "--strictPort", + ], }; } @@ -273,13 +290,9 @@ export function viteListenerUrl(address) { address.port > 65535 ) throw new Error("Vite reported an invalid profiling listener address."); - let host = address.address; - if (host === "0.0.0.0") host = "127.0.0.1"; - else if (host === "::") host = "::1"; - if (typeof host !== "string" || !host) - throw new Error("Vite reported an invalid profiling listener address."); - if (host.includes(":")) host = `[${host}]`; - return `http://${host}:${address.port}`; + if (address.address !== "127.0.0.1") + throw new Error("Vite did not bind the required profiling host."); + return `http://127.0.0.1:${address.port}`; } export function viteReadyToken(child, token, signal) { diff --git a/tests/integration/profile-dev.test.mjs b/tests/integration/profile-dev.test.mjs index 88432421e..564189bd2 100644 --- a/tests/integration/profile-dev.test.mjs +++ b/tests/integration/profile-dev.test.mjs @@ -21,6 +21,18 @@ test("web profiling canonicalizes its Vite port and strict-port contract", () => () => normalizeWebViteArgs(["--port", "1431", "--port", "1432"]), /only one --port/, ); + assert.throws( + () => normalizeWebViteArgs(["--host"]), + /requires --host 127\.0\.0\.1/, + ); + assert.throws( + () => normalizeWebViteArgs(["--host", "0.0.0.0"]), + /requires --host 127\.0\.0\.1/, + ); + assert.throws( + () => normalizeWebViteArgs(["--host=::1"]), + /requires --host 127\.0\.0\.1/, + ); assert.throws( () => normalizeWebViteArgs(["--no-strictPort"]), /requires --strictPort/, @@ -32,13 +44,13 @@ test("web profiling derives navigation from its owned listener", () => { viteListenerUrl({ address: "127.0.0.1", port: 1430 }), "http://127.0.0.1:1430", ); - assert.equal( - viteListenerUrl({ address: "::1", port: 1430 }), - "http://[::1]:1430", + assert.throws( + () => viteListenerUrl({ address: "::1", port: 1430 }), + /required profiling host/, ); - assert.equal( - viteListenerUrl({ address: "::", port: 1430 }), - "http://[::1]:1430", + assert.throws( + () => viteListenerUrl({ address: "0.0.0.0", port: 1430 }), + /required profiling host/, ); }); From afe0bf67383abfe4e3d7b0ba755e70fa92fbf909 Mon Sep 17 00:00:00 2001 From: cyberpunk Date: Wed, 23 Sep 2026 09:14:46 -0700 Subject: [PATCH 10/10] fix: cancel web profiler browser startup promptly Signed-off-by: cyberpunk --- scripts/profile-dev.mjs | 50 +++-- .../fixtures/profile-web/browser.mjs | 50 +++++ .../fixtures/profile-web/driver.mjs | 28 +++ .../integration/fixtures/profile-web/vite.mjs | 11 + tests/integration/profile-dev.test.mjs | 191 +++++++++++++++++- 5 files changed, 312 insertions(+), 18 deletions(-) create mode 100644 tests/integration/fixtures/profile-web/browser.mjs create mode 100644 tests/integration/fixtures/profile-web/driver.mjs create mode 100644 tests/integration/fixtures/profile-web/vite.mjs diff --git a/scripts/profile-dev.mjs b/scripts/profile-dev.mjs index dcba814e9..33be4f3ad 100644 --- a/scripts/profile-dev.mjs +++ b/scripts/profile-dev.mjs @@ -461,17 +461,23 @@ export function networkRecorder(session, directory) { }); return { - async start(page) { - await session.send("Network.enable", { maxPostDataSize: 0 }); - await session.send("Performance.enable"); - const { metrics } = await session.send("Performance.getMetrics"); + async start(page, duringStartup) { + await duringStartup(() => + session.send("Network.enable", { maxPostDataSize: 0 }), + ); + await duringStartup(() => session.send("Performance.enable")); + const { metrics } = await duringStartup(() => + session.send("Performance.getMetrics"), + ); calibration = { cdpMonotonicSeconds: metrics.find(({ name }) => name === "Timestamp") ?.value, - renderer: await page.evaluate(() => ({ - epochMilliseconds: performance.timeOrigin + performance.now(), - monotonicMilliseconds: performance.now(), - })), + renderer: await duringStartup(() => + page.evaluate(() => ({ + epochMilliseconds: performance.timeOrigin + performance.now(), + monotonicMilliseconds: performance.now(), + })), + ), }; }, async write(clocks) { @@ -495,7 +501,7 @@ export function networkRecorder(session, directory) { }; } -async function profileWeb({ directory, profileArgs, args, network }) { +export async function profileWeb({ directory, profileArgs, args, network }) { const vite = normalizeWebViteArgs(args); await recordManifest(directory, "web", profileArgs, { coverage: [ @@ -507,6 +513,14 @@ async function profileWeb({ directory, profileArgs, args, network }) { }); const control = stopController(); + // Browser operations do not accept AbortSignal. Stop waiting immediately and + // let finally close the owning browser; late results must not resume startup. + const duringStartup = async (operation) => { + control.abort.signal.throwIfAborted(); + const result = await Promise.race([operation(), control.requested]); + control.abort.signal.throwIfAborted(); + return result; + }; const readyToken = randomUUID(); const nodeOptions = [process.env.NODE_OPTIONS, "--inspect=127.0.0.1:0"] .filter(Boolean) @@ -553,7 +567,7 @@ async function profileWeb({ directory, profileArgs, args, network }) { brokerStarted = true; control.abort.signal.throwIfAborted(); - const { chromium } = await import("@playwright/test"); + const { chromium } = await duringStartup(() => import("@playwright/test")); const browserLaunch = chromium.launch({ channel: "chrome", headless: false, @@ -572,17 +586,19 @@ async function profileWeb({ directory, profileArgs, args, network }) { if (!browser) throw new DOMException("Profiling startup cancelled.", "AbortError"); control.abort.signal.throwIfAborted(); - const page = await browser.newPage(); - session = await page.context().newCDPSession(page); + const page = await duringStartup(() => browser.newPage()); + session = await duringStartup(() => page.context().newCDPSession(page)); if (network) { networkCapture = networkRecorder(session, directory); - await networkCapture.start(page); + await networkCapture.start(page, duringStartup); } - await session.send("Profiler.enable"); - await session.send("Profiler.setSamplingInterval", { interval: 1000 }); - await session.send("Profiler.start"); + await duringStartup(() => session.send("Profiler.enable")); + await duringStartup(() => + session.send("Profiler.setSamplingInterval", { interval: 1000 }), + ); + await duringStartup(() => session.send("Profiler.start")); rendererStarted = true; - await page.goto(url); + await duringStartup(() => page.goto(url)); control.abort.signal.throwIfAborted(); captureStarted = true; console.log( diff --git a/tests/integration/fixtures/profile-web/browser.mjs b/tests/integration/fixtures/profile-web/browser.mjs new file mode 100644 index 000000000..ba2e26a26 --- /dev/null +++ b/tests/integration/fixtures/profile-web/browser.mjs @@ -0,0 +1,50 @@ +import { setImmediate as nextTurn } from "node:timers/promises"; + +const scenario = JSON.parse(process.env.BUZZ_TEST_SCENARIO); +const release = Promise.withResolvers(); +process.on("message", (message) => { + if (message === "release") release.resolve(); +}); +async function operation(name, value) { + process.send({ type: "call", name }); + if (scenario.held === name) { + process.send({ type: "held", name }); + await release.promise; + // Report after promise continuations have drained, not on an arbitrary delay. + void nextTurn().then(() => process.send({ type: "released" })); + if (scenario.late === "reject") throw new Error("fixture late rejection"); + } + if (scenario.reject === name) throw new Error("fixture rejection"); + return value; +} +const session = { + on() {}, + send(method) { + return operation( + method, + method === "Profiler.stop" + ? { profile: { nodes: [{ id: 1 }], startTime: 1, endTime: 2 } } + : { metrics: [{ name: "Timestamp", value: 1 }] }, + ); + }, +}; +const page = { + context() { + return { newCDPSession: () => operation("newCDPSession", session) }; + }, + evaluate: () => + operation("evaluate", { epochMilliseconds: 1, monotonicMilliseconds: 1 }), + async goto() { + await operation("goto"); + if (!closed) process.send({ type: "navigated" }); + }, +}; +let closed = false; +const browser = { + newPage: () => operation("newPage", page), + async close() { + closed = true; + process.send({ type: "browserClosed" }); + }, +}; +export const chromium = { launch: () => operation("launch", browser) }; diff --git a/tests/integration/fixtures/profile-web/driver.mjs b/tests/integration/fixtures/profile-web/driver.mjs new file mode 100644 index 000000000..6f26df5d8 --- /dev/null +++ b/tests/integration/fixtures/profile-web/driver.mjs @@ -0,0 +1,28 @@ +import { profileWeb } from "./scripts/profile-dev.mjs"; + +// IPC stays open until the parent has observed cleanup and any late completion. +process.on("message", (message) => { + if (message === "finish") setImmediate(() => process.disconnect()); +}); +const log = console.log; +console.log = (...args) => { + log(...args); + if (args[0]?.startsWith("\nProfiling http")) + process.send({ type: "capturing" }); +}; +try { + await profileWeb({ + directory: `${process.cwd()}/profiles`, + profileArgs: [], + args: [], + network: true, + }); + process.send({ type: "settled" }); +} catch (error) { + process.exitCode = 1; + process.send({ + type: "settled", + error: error.message, + cause: error.cause?.message, + }); +} diff --git a/tests/integration/fixtures/profile-web/vite.mjs b/tests/integration/fixtures/profile-web/vite.mjs new file mode 100644 index 000000000..a71c54fdf --- /dev/null +++ b/tests/integration/fixtures/profile-web/vite.mjs @@ -0,0 +1,11 @@ +import { writeFileSync } from "node:fs"; +import { createServer } from "node:http"; + +// A real HTTP server and Node inspector, without the Buzz broker or live identity. +writeFileSync("vite.pid", String(process.pid)); +const server = createServer((_request, response) => response.end("fixture")); +server.listen(0, "127.0.0.1", () => { + console.log( + `BUZZ_PROFILE_VITE_READY:${process.env.BUZZ_PROFILE_VITE_READY_TOKEN}:${JSON.stringify(server.address())}`, + ); +}); diff --git a/tests/integration/profile-dev.test.mjs b/tests/integration/profile-dev.test.mjs index 564189bd2..3d6162325 100644 --- a/tests/integration/profile-dev.test.mjs +++ b/tests/integration/profile-dev.test.mjs @@ -1,5 +1,17 @@ import assert from "node:assert/strict"; -import { EventEmitter } from "node:events"; +import { EventEmitter, once } from "node:events"; +import { fork } from "node:child_process"; +import { + copyFile, + mkdir, + mkdtemp, + readFile, + readdir, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; import test from "node:test"; import { inspectorClient, @@ -121,3 +133,180 @@ test("inspector requests reject after the transport closes", async () => { globalThis.WebSocket = original; } }); + +async function webFixture(t, scenario) { + const directory = await mkdtemp(path.join(tmpdir(), "buzz-profile-web-")); + const fixtures = new URL("./fixtures/profile-web/", import.meta.url); + for (const name of [ + "scripts", + "node_modules/vite/bin", + "node_modules/@playwright/test", + "profiles", + ]) + await mkdir(path.join(directory, name), { recursive: true }); + await copyFile( + new URL("../../scripts/profile-dev.mjs", import.meta.url), + path.join(directory, "scripts/profile-dev.mjs"), + ); + for (const [source, destination] of [ + ["driver.mjs", "driver.mjs"], + ["vite.mjs", "node_modules/vite/bin/vite.js"], + ["browser.mjs", "node_modules/@playwright/test/index.mjs"], + ]) + await copyFile( + new URL(source, fixtures), + path.join(directory, destination), + ); + await writeFile( + path.join(directory, "node_modules/@playwright/test/package.json"), + JSON.stringify({ type: "module", exports: "./index.mjs" }), + ); + const child = fork(path.join(directory, "driver.mjs"), [], { + cwd: directory, + env: { + PATH: process.env.PATH, + BUZZ_TEST_SCENARIO: JSON.stringify(scenario), + }, + stdio: ["ignore", "pipe", "pipe", "ipc"], + }); + const messages = []; + const events = new EventEmitter(); + let log = ""; + for (const stream of [child.stdout, child.stderr]) + stream.on("data", (chunk) => { + log += chunk; + }); + child.on("message", (message) => { + messages.push(message); + events.emit("message"); + }); + const exited = once(child, "exit"); + const wait = async (type) => { + const signal = AbortSignal.timeout(10_000); + while (!messages.some((message) => message.type === type)) { + try { + await once(events, "message", { signal }); + } catch (error) { + throw new Error( + `Missing ${type}: ${JSON.stringify(messages)}\n${log}`, + { cause: error }, + ); + } + } + return messages.find((message) => message.type === type); + }; + t.after(async () => { + if (child.exitCode === null && child.signalCode === null) + child.kill("SIGKILL"); + await exited; + // Only the fixture-owned Vite group may need emergency cleanup on a failed assertion. + const pid = Number( + await readFile(path.join(directory, "vite.pid"), "utf8").catch(() => "0"), + ); + if (pid) { + try { + process.kill(-pid, "SIGKILL"); + } catch (error) { + if (error.code !== "ESRCH") throw error; + } + } + await rm(directory, { recursive: true, force: true }); + }); + return { child, directory, messages, wait, exited, log: () => log }; +} + +for (const held of [ + "launch", + "newPage", + "newCDPSession", + "Network.enable", + "Performance.enable", + "Performance.getMetrics", + "evaluate", + "Profiler.enable", + "Profiler.setSamplingInterval", + "Profiler.start", + "goto", +]) { + for (const late of ["resolve", "reject"]) { + test(`web startup cancels held ${held} before late ${late}`, async (t) => { + const fixture = await webFixture(t, { held, late }); + await fixture.wait("held"); + fixture.child.kill("SIGINT"); + // Cleanup must finish without releasing the stalled startup operation. + const settled = await fixture.wait("settled"); + assert.match(settled.error, /stopped during startup/); + if (held !== "launch") await fixture.wait("browserClosed"); + const pid = Number( + await readFile(path.join(fixture.directory, "vite.pid"), "utf8"), + ); + assert.throws(() => process.kill(-pid, 0), { code: "ESRCH" }); + const calls = fixture.messages.filter(({ type }) => type === "call"); + fixture.child.send("release"); + await fixture.wait("released"); + if (held === "launch" && late === "resolve") + await fixture.wait("browserClosed"); + fixture.child.send("finish"); + assert.deepEqual(await fixture.exited, [1, null], fixture.log()); + assert.deepEqual( + fixture.messages.filter(({ type }) => type === "call"), + calls, + "late completion resumed startup", + ); + assert.equal( + fixture.messages.some(({ type }) => type === "navigated"), + false, + ); + assert.doesNotMatch( + fixture.log(), + /Profile saved to|fixture late rejection/, + ); + }); + } +} + +for (const reject of ["launch", "newPage", "newCDPSession"]) { + test(`web startup cleans up ${reject} rejection`, async (t) => { + const fixture = await webFixture(t, { reject }); + const settled = await fixture.wait("settled"); + assert.match(settled.error, /stopped during startup/); + assert.match(settled.cause, /fixture rejection/); + if (reject !== "launch") await fixture.wait("browserClosed"); + fixture.child.send("finish"); + assert.deepEqual(await fixture.exited, [1, null], fixture.log()); + assert.equal( + fixture.messages.some(({ type }) => type === "navigated"), + false, + ); + }); +} + +test("web capture still finalizes renderer, broker, and network artifacts on Ctrl-C", async (t) => { + const fixture = await webFixture(t, {}); + await fixture.wait("capturing"); + fixture.child.kill("SIGINT"); + assert.equal((await fixture.wait("settled")).error, undefined); + await fixture.wait("browserClosed"); + fixture.child.send("finish"); + assert.deepEqual(await fixture.exited, [0, null], fixture.log()); + assert.deepEqual( + (await readdir(path.join(fixture.directory, "profiles"))).sort(), + [ + "chromium-renderer.cpuprofile", + "manifest.json", + "network.json", + "vite-broker.cpuprofile", + ], + ); + for (const name of ["chromium-renderer", "vite-broker"]) { + const profile = JSON.parse( + await readFile( + path.join(fixture.directory, `profiles/${name}.cpuprofile`), + "utf8", + ), + ); + assert.ok(profile.nodes.length > 0); + assert.ok(profile.endTime >= profile.startTime); + } + assert.match(fixture.log(), /Profile saved to/); +});