Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions docs/performance-benchmark.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Runtime benchmark

`scripts/bench-runtime.mjs` measures what CanvasTTY costs while it runs: memory and CPU of the built app,
and the main-process hot paths that grow with the number of cards. Use it before and after a change that
touches terminal output, the canvas, orchestration, redaction or the Even G2 companion.

```sh
npx electron-vite build # the app scenarios measure out/
node scripts/bench-runtime.mjs # 3 runs, medians
node scripts/bench-runtime.mjs --runs 1 --micro-only
node scripts/bench-runtime.mjs --runs 1 --pan-only # cards and the pan only, about 30 s
node scripts/bench-runtime.mjs --terminals 8 --kbps 1024 --flood-seconds 20 --json bench.json
```

Every run gets its own temporary `HOME` (with `GROK_HOME`, `CODEX_HOME`, `CLAUDE_CONFIG_DIR` and the XDG
folders inside it), userData and working folder, removed afterwards. The app runs from `out/` through a
small harness in `scripts/bench-runtime/app/`: its windows are created hidden, off-screen and unfocusable,
native dialogs are answered locally, `safeStorage` is off and `/usr/bin/security` is refused, so a run never
shows a window, takes focus or touches the keychain. Temporary folders go under `/tmp` (or `BENCH_TMPDIR`)
because the app's Unix sockets live in userData and macOS caps a socket path at 104 bytes.

## App scenarios

| Scenario | What happens | Reported |
| --- | --- | --- |
| idle | 8 s after the workspace is ready, 10 s window | RSS per process kind, CPU % per kind |
| terminals | N plain terminal cards (`--terminals`, default 8), 8 s settle, 10 s window | same |
| flood | every card runs `scripts/bench-runtime/flood.mjs` at `--kbps` KB/s (default 1024) | same, peak RSS, MB of `terminal:data` sent to the renderer |
| after | 10 s after the flood ended, 5 s window | same |
| pan | a middle-button drag of 300 moves, 16 ms apart, over the N cards | React commits, components rendered with new props per move, TerminalCard renders per move, renderer main-thread ms per move (script, style, layout, all tasks, from the DevTools Performance domain), the most rendered components, renderer CPU % |

CPU % is per process kind, of one core, from the kernel's per-process CPU time (`ps`) over the window.
"pty" is the shells and whatever runs in them (the flood generators included); "electron" is the app itself.
Component renders are counted by a minimal React DevTools hook the harness installs in the page; it needs no
component names, so a minified build counts the same way.

## Micro-benchmarks

`scripts/bench-runtime/micro.mjs` runs in plain Node against `src/` with fake PTYs:

| Key | Meaning |
| --- | --- |
| `scrollbackRetainedChars` | characters the scrollback chunk array still references after 2 MB of output in 1/4/16 KB chunks (the ring keeps 240 000) |
| `visibleResendBytes8Cards` | bytes sent to the renderer when 8 hidden cards with full scrollback become visible after 1 KB more output each |
| `orchestratorToolCallMs`, `orchestratorToolCallAllocatedBytes` | one `observe_agent` call (ownership check, status, observation) on a canvas of 20 agent cards with full scrollback |
| `observeAgentMs`, `listAgentsMs` | `observe_agent` and `list_agents` alone |
| `evenG2FeedMs`, `evenG2HeadlessTerminals` | Even G2 companion on, 8 cards stream 512 KB each while the glasses show one |
| `kimiFirstLaunchBlockMs` | how long the first Kimi launch blocks the main thread when the CLI takes 1 s to answer `--help` |

The report prints medians; `--json` also keeps every run.
7 changes: 6 additions & 1 deletion electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ export default defineConfig({
},
renderer: {
root: resolve("src/renderer"),
plugins: [react()]
plugins: [react()],
// electron-vite leaves every bundle unminified; the renderer's (React, xterm and the app, about 1.8 MB)
// is parsed on every window load, so it is minified. Source maps stay off, as before.
build: {
minify: "esbuild"
}
}
});
191 changes: 191 additions & 0 deletions scripts/bench-runtime.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
#!/usr/bin/env node
// Runtime cost benchmark: memory and CPU of the built app in hidden windows (idle, N terminal cards, a
// fixed-rate output flood in every card, 10 s after it, a canvas pan) plus micro-benchmarks of the
// main-process hot paths. Every run gets its own throw-away HOME, userData and working folder, and the app
// never reads the keychain or shows a window. See docs/performance-benchmark.md.
//
// npx electron-vite build # the app scenarios measure out/
// node scripts/bench-runtime.mjs [--runs 3] [--terminals 8] [--kbps 1024] [--flood-seconds 20]
// [--micro-only | --app-only | --pan-only] [--json report.json]
import { spawn } from "node:child_process";
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import { tmpdir, cpus, totalmem, release } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";

const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const HERE = join(ROOT, "scripts", "bench-runtime");
// Short temporary paths: the app puts Unix sockets under userData, and macOS caps a socket path at 104 bytes.
const TEMP = process.env.BENCH_TMPDIR || (process.platform === "win32" ? tmpdir() : "/tmp");

function options(argv) {
const result = { runs: 3, terminals: 8, kbps: 1024, floodSeconds: 20, micro: true, app: true, panOnly: false, json: null };
for (let i = 0; i < argv.length; i++) {
const flag = argv[i];
const value = () => argv[++i];
if (flag === "--runs") result.runs = Number(value());
else if (flag === "--terminals") result.terminals = Number(value());
else if (flag === "--kbps") result.kbps = Number(value());
else if (flag === "--flood-seconds") result.floodSeconds = Math.max(15, Number(value()));
else if (flag === "--micro-only") result.app = false;
else if (flag === "--app-only") result.micro = false;
else if (flag === "--pan-only") { result.micro = false; result.panOnly = true; }
else if (flag === "--json") result.json = resolve(value());
else throw new Error(`Unknown option ${flag}`);
}
return result;
}

/** A fresh HOME and every tool home inside it, so nothing reads or writes the person's own configuration. */
function isolatedEnvironment(extra = {}) {
const home = mkdtempSync(join(TEMP, "ctb-home-"));
writeFileSync(join(home, ".zshrc"), "");
return {
home,
env: {
HOME: home, USER: process.env.USER ?? "bench", LOGNAME: process.env.USER ?? "bench", TMPDIR: TEMP, LANG: "en_US.UTF-8",
PATH: `${dirname(process.execPath)}:/usr/bin:/bin:/usr/sbin:/sbin`, SHELL: "/bin/zsh",
GROK_HOME: join(home, ".grok"), CODEX_HOME: join(home, ".codex"), CLAUDE_CONFIG_DIR: join(home, ".claude"),
XDG_CONFIG_HOME: join(home, ".config"), XDG_DATA_HOME: join(home, ".local/share"), XDG_STATE_HOME: join(home, ".local/state"),
...extra
}
};
}

function run(command, args, env, timeoutMs) {
return new Promise((resolvePromise, reject) => {
const child = spawn(command, args, { env, stdio: ["ignore", "pipe", "pipe"] });
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk) => { stdout += chunk; });
child.stderr.on("data", (chunk) => { stderr += chunk; });
const timer = setTimeout(() => child.kill("SIGKILL"), timeoutMs);
child.on("error", reject);
child.on("close", (code) => {
clearTimeout(timer);
if (code === 0) resolvePromise(stdout);
else reject(new Error(`${command} exited ${code}: ${stderr.slice(-2000)}`));
});
});
}

async function microRun() {
const { home, env } = isolatedEnvironment();
try {
const stdout = await run(process.execPath, ["--experimental-strip-types", "--no-warnings", join(HERE, "micro.mjs"), ROOT], env, 300_000);
return JSON.parse(stdout.trim().split("\n").at(-1));
} finally {
rmSync(home, { recursive: true, force: true });
}
}

async function appRun(settings) {
const electron = createRequire(import.meta.url)("electron");
const userData = mkdtempSync(join(TEMP, "ctb-u-"));
const work = mkdtempSync(join(TEMP, "ctb-w-"));
const out = join(userData, "..", `${userData.split("/").at(-1)}-report.json`);
writeFileSync(join(userData, "settings.json"), JSON.stringify({ settingsVersion: 21, locale: "en", sessionRestoreMode: "off" }));
const { home, env } = isolatedEnvironment({
BENCH_ROOT: ROOT, BENCH_OUT: out, BENCH_USERDATA: userData, BENCH_WORK: work,
BENCH_NODE: process.execPath, BENCH_FLOOD: join(HERE, "flood.mjs"), BENCH_KBPS: String(settings.kbps),
BENCH_TERMINALS: String(settings.terminals), BENCH_FLOOD_SECONDS: String(settings.floodSeconds),
BENCH_PAN_ONLY: settings.panOnly ? "1" : "0"
});
try {
let exit = null;
await run(electron, [join(HERE, "app")], env, 240_000 + settings.floodSeconds * 1000).catch((error) => { exit = error; });
const report = existsSync(out) ? JSON.parse(readFileSync(out, "utf8")) : null;
// A crash after the report was complete (while quitting) is recorded, not fatal.
if (!report?.done) throw exit ?? new Error("the benchmark app wrote no report");
if (exit) report.exitAfterReport = exit.message.slice(0, 300);
if (report.window?.visible || report.window?.focused) throw new Error("the app window was visible or focused");
return report;
} finally {
for (const path of [userData, work, home, out]) rmSync(path, { recursive: true, force: true });
}
}

function median(values) {
const numbers = values.filter((value) => typeof value === "number" && Number.isFinite(value)).sort((a, b) => a - b);
if (numbers.length === 0) return null;
const middle = Math.floor(numbers.length / 2);
return numbers.length % 2 ? numbers[middle] : (numbers[middle - 1] + numbers[middle]) / 2;
}

/** The median of every numeric leaf across runs, keeping the report's shape. */
function medianOf(reports) {
const first = reports[0];
if (typeof first === "number") return median(reports);
if (!first || typeof first !== "object" || Array.isArray(first)) return first;
return Object.fromEntries(Object.keys(first).map((key) => [key, medianOf(reports.map((report) => report?.[key]))]));
}

function bundle() {
const assets = join(ROOT, "out", "renderer", "assets");
if (!existsSync(assets)) return null;
const files = readdirSync(assets).map((name) => ({ name, bytes: statSync(join(assets, name)).size }));
const sum = (filter) => files.filter(filter).reduce((total, file) => total + file.bytes, 0);
return {
rendererJsKb: Math.round(sum((file) => file.name.endsWith(".js")) / 1024),
rendererCssKb: Math.round(sum((file) => file.name.endsWith(".css")) / 1024),
rendererImagesKb: Math.round(sum((file) => /\.(png|ico|svg|webp)$/u.test(file.name)) / 1024),
largestImage: files.filter((file) => /\.(png|ico|webp)$/u.test(file.name)).sort((a, b) => b.bytes - a.bytes)[0] ?? null
};
}

function machine() {
let os = `${process.platform} ${release()}`;
try {
if (process.platform === "darwin") {
const version = readFileSync("/System/Library/CoreServices/SystemVersion.plist", "utf8").match(/<key>ProductVersion<\/key>\s*<string>([^<]+)/u)?.[1];
if (version) os = `macOS ${version}`;
}
} catch {}
return { os, cpu: cpus()[0]?.model ?? "unknown", cores: cpus().length, ramGb: Math.round(totalmem() / 1024 ** 3), node: process.version };
}

function table(result) {
const lines = [];
const scenarios = result.app?.scenarios ?? {};
for (const name of ["idle", "terminals", "flood", "after"]) {
const s = scenarios[name];
if (!s) continue;
lines.push(`${name.padEnd(10)} RSS total ${s.rssMb.total} MB (electron ${s.rssMb.electron}, main ${s.rssMb.main}, renderer ${s.rssMb.renderer}, gpu ${s.rssMb.gpu}, pty ${s.rssMb.pty}) peak ${s.peakRssMb} MB | CPU main ${s.cpu.main}% renderer ${s.cpu.renderer}% gpu ${s.cpu.gpu}% utility ${s.cpu.utility}%`);
}
if (scenarios.flood?.terminalDataToRendererMb !== undefined) lines.push(`flood terminal:data to renderer ${scenarios.flood.terminalDataToRendererMb} MB`);
if (scenarios.pan) lines.push(`pan ${JSON.stringify(scenarios.pan)}`);
if (result.micro) lines.push(`micro ${JSON.stringify(result.micro)}`);
if (result.bundle) lines.push(`bundle ${JSON.stringify(result.bundle)}`);
return lines.join("\n");
}

async function main() {
const settings = options(process.argv.slice(2));
if (settings.app && !existsSync(join(ROOT, "out", "main", "index.js"))) {
throw new Error("Build the app first: npx electron-vite build");
}
const result = { machine: machine(), settings, runs: { micro: [], app: [] } };
for (let i = 0; i < settings.runs; i++) {
if (settings.micro) {
process.stderr.write(`micro run ${i + 1}/${settings.runs}\n`);
result.runs.micro.push(await microRun());
}
if (settings.app) {
process.stderr.write(`app run ${i + 1}/${settings.runs}\n`);
const report = await appRun(settings);
if (report.errors.length || report.rendererErrors.length) process.stderr.write(` errors: ${JSON.stringify([...report.errors, ...report.rendererErrors])}\n`);
result.runs.app.push(report);
}
}
if (settings.micro) result.micro = medianOf(result.runs.micro);
if (settings.app) result.app = { scenarios: medianOf(result.runs.app.map((report) => report.scenarios)) };
result.bundle = bundle();
if (settings.json) writeFileSync(settings.json, `${JSON.stringify(result, null, 1)}\n`);
process.stdout.write(`${JSON.stringify(result.machine)}\n${table(result)}\n`);
}

main().catch((error) => {
process.stderr.write(`${error.stack ?? error}\n`);
process.exit(1);
});
Loading
Loading