Files
wehub-resource-sync cb15c5e0d8
Release / Check for new version (push) Has been cancelled
Release / Build macOS ARM64 (push) Has been cancelled
Release / Build macOS x64 (push) Has been cancelled
Release / Build Linux ARM64 (push) Has been cancelled
Release / Build Linux musl ARM64 (push) Has been cancelled
Release / Build Linux musl x64 (push) Has been cancelled
Release / Build Linux x64 (push) Has been cancelled
Release / Build Windows x64 (push) Has been cancelled
Release / Publish to npm (push) Has been cancelled
Release / Publish sandbox package to npm (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled
CI / Rust (windows-latest - x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Native E2E Tests (push) Has been cancelled
CI / Windows Integration Test (push) Has been cancelled
CI / Global Install (macos-latest) (push) Has been cancelled
CI / Global Install (ubuntu-latest) (push) Has been cancelled
CI / Version Sync Check (push) Has been cancelled
CI / Rust (push) Has been cancelled
CI / Dashboard (push) Has been cancelled
CI / Sandbox Package (push) Has been cancelled
CI / Rust (macos-latest - aarch64-apple-darwin) (push) Has been cancelled
CI / Rust (macos-latest - x86_64-apple-darwin) (push) Has been cancelled
CI / Global Install (windows-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:35:58 +08:00

901 lines
26 KiB
TypeScript

/**
* Node.js Daemon vs Rust Native Daemon benchmark.
*
* Compares the last published npm version (Node.js daemon) against the
* Rust-only build from a given branch, running real agent-browser commands
* inside a Vercel Sandbox.
*
* Captures:
* - Command latency (per-scenario, with warmup + measured iterations + stddev)
* - Cold start time (first launch to daemon ready)
* - Daemon memory (RSS, peak RSS) separated from browser memory
* - Daemon CPU time
* - Process tree (daemon + browser children)
* - Binary and distribution size on disk
*
* Usage:
* pnpm bench # default: 10 iterations, 1 warmup
* pnpm bench -- --iterations 20 # override iterations
* pnpm bench -- --warmup 2 # override warmup count
* pnpm bench -- --json # write results.json
* pnpm bench -- --branch my-branch # override native branch (default: ctate/native-2)
* pnpm bench -- --vcpus 8 # sandbox vCPUs (default: 8, higher = faster Rust build)
*/
import { Sandbox } from "@vercel/sandbox";
import { readFileSync, writeFileSync } from "fs";
import { scenarios, type Scenario } from "./scenarios.js";
// ---------------------------------------------------------------------------
// Env
// ---------------------------------------------------------------------------
function loadEnv() {
try {
const content = readFileSync(".env", "utf-8");
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eq = trimmed.indexOf("=");
if (eq === -1) continue;
const key = trimmed.slice(0, eq);
let val = trimmed.slice(eq + 1);
if (
(val.startsWith('"') && val.endsWith('"')) ||
(val.startsWith("'") && val.endsWith("'"))
) {
val = val.slice(1, -1);
}
process.env[key] = val;
}
} catch {}
}
loadEnv();
const credentials = {
token: process.env.SANDBOX_VERCEL_TOKEN!,
teamId: process.env.SANDBOX_VERCEL_TEAM_ID!,
projectId: process.env.SANDBOX_VERCEL_PROJECT_ID!,
};
if (!credentials.token || !credentials.teamId || !credentials.projectId) {
console.error(
"Missing credentials. Set SANDBOX_VERCEL_TOKEN, SANDBOX_VERCEL_TEAM_ID, SANDBOX_VERCEL_PROJECT_ID in .env",
);
process.exit(1);
}
// ---------------------------------------------------------------------------
// CLI args
// ---------------------------------------------------------------------------
function parseArgs() {
const args = process.argv.slice(2);
let iterations = 10;
let warmup = 1;
let json = false;
let branch = "ctate/native-2";
let vcpus = 8;
for (let i = 0; i < args.length; i++) {
if (args[i] === "--iterations" && args[i + 1]) {
iterations = parseInt(args[++i], 10);
} else if (args[i] === "--warmup" && args[i + 1]) {
warmup = parseInt(args[++i], 10);
} else if (args[i] === "--json") {
json = true;
} else if (args[i] === "--branch" && args[i + 1]) {
branch = args[++i];
} else if (args[i] === "--vcpus" && args[i + 1]) {
vcpus = parseInt(args[++i], 10);
}
}
return { iterations, warmup, json, branch, vcpus };
}
const config = parseArgs();
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const TIMEOUT_MS = 30 * 60 * 1000;
const REPO_URL = "https://github.com/vercel-labs/agent-browser.git";
const CHROMIUM_SYSTEM_DEPS = [
"nss",
"nspr",
"libxkbcommon",
"atk",
"at-spi2-atk",
"at-spi2-core",
"libXcomposite",
"libXdamage",
"libXrandr",
"libXfixes",
"libXcursor",
"libXi",
"libXtst",
"libXScrnSaver",
"libXext",
"mesa-libgbm",
"libdrm",
"mesa-libGL",
"mesa-libEGL",
"cups-libs",
"alsa-lib",
"pango",
"cairo",
"gtk3",
"dbus-libs",
];
// ---------------------------------------------------------------------------
// Sandbox helpers
// ---------------------------------------------------------------------------
type SandboxInstance = InstanceType<typeof Sandbox>;
async function run(
sandbox: SandboxInstance,
cmd: string,
args: string[],
): Promise<string> {
const result = await sandbox.runCommand(cmd, args);
const stdout = await result.stdout();
const stderr = await result.stderr();
if (result.exitCode !== 0) {
throw new Error(
`Command failed (exit ${result.exitCode}): ${cmd} ${args.join(" ")}\n${stderr || stdout}`,
);
}
return stdout;
}
async function shell(sandbox: SandboxInstance, script: string): Promise<string> {
return run(sandbox, "sh", ["-c", script]);
}
async function shellSafe(sandbox: SandboxInstance, script: string): Promise<string> {
const result = await sandbox.runCommand("sh", ["-c", script]);
return (await result.stdout()).trim();
}
// ---------------------------------------------------------------------------
// Stats
// ---------------------------------------------------------------------------
interface Stats {
avgMs: number;
stddevMs: number;
minMs: number;
maxMs: number;
p50Ms: number;
samples: number[];
}
function computeStats(samples: number[]): Stats {
const sorted = [...samples].sort((a, b) => a - b);
const sum = sorted.reduce((a, b) => a + b, 0);
const avg = sum / sorted.length;
const variance =
sorted.reduce((acc, v) => acc + (v - avg) ** 2, 0) / sorted.length;
return {
avgMs: Math.round(avg),
stddevMs: Math.round(Math.sqrt(variance)),
minMs: sorted[0],
maxMs: sorted[sorted.length - 1],
p50Ms: sorted[Math.floor(sorted.length / 2)],
samples: sorted,
};
}
// ---------------------------------------------------------------------------
// Metrics collection
// ---------------------------------------------------------------------------
interface ProcessMetrics {
pid: number;
rssKb: number;
vszKb: number;
cpuPercent: number;
memPercent: number;
cpuTimeSec: number;
command: string;
}
interface DaemonMetrics {
coldStartMs: number;
binarySizeBytes: number;
distributionSizeBytes: number;
daemonProcesses: ProcessMetrics[];
browserProcesses: ProcessMetrics[];
daemonRssKb: number;
browserRssKb: number;
daemonPeakRssKb: number;
daemonCpuTimeSec: number;
totalCpuTimeSec: number;
}
async function findDaemonPids(
sandbox: SandboxInstance,
_session: string,
): Promise<number[]> {
// The daemon process name is "agent-browser" but session/daemon flags are
// env vars, not command-line args, so we can't grep them from `ps`.
// Instead, find all agent-browser processes that look like long-running daemons
// (not short-lived CLI invocations -- those exit immediately).
const raw = await shellSafe(
sandbox,
`pgrep -x agent-browser 2>/dev/null || true`,
);
if (!raw) {
// Fallback: broader match on process name
const fallback = await shellSafe(
sandbox,
`pgrep -f 'agent-browser' 2>/dev/null | head -5 || true`,
);
if (!fallback) return [];
return fallback.split("\n").map(Number).filter(Boolean);
}
return raw.split("\n").map(Number).filter(Boolean);
}
async function collectProcessMetrics(
sandbox: SandboxInstance,
pid: number,
): Promise<ProcessMetrics | null> {
const raw = await shellSafe(
sandbox,
`ps -p ${pid} -o pid=,rss=,vsz=,%cpu=,%mem=,cputime=,comm= 2>/dev/null || true`,
);
if (!raw) return null;
const parts = raw.trim().split(/\s+/);
if (parts.length < 7) return null;
// Parse cputime "HH:MM:SS" or "MM:SS" to seconds
const timeParts = parts[5].split(":").map(Number);
let cpuTimeSec = 0;
if (timeParts.length === 3) {
cpuTimeSec = timeParts[0] * 3600 + timeParts[1] * 60 + timeParts[2];
} else if (timeParts.length === 2) {
cpuTimeSec = timeParts[0] * 60 + timeParts[1];
}
return {
pid: Number(parts[0]),
rssKb: Number(parts[1]),
vszKb: Number(parts[2]),
cpuPercent: Number(parts[3]),
memPercent: Number(parts[4]),
cpuTimeSec,
command: parts.slice(6).join(" "),
};
}
async function getPeakRssKb(
sandbox: SandboxInstance,
pid: number,
): Promise<number> {
const raw = await shellSafe(
sandbox,
`cat /proc/${pid}/status 2>/dev/null | grep VmHWM | awk '{print $2}' || echo 0`,
);
return Number(raw) || 0;
}
async function getChildPids(
sandbox: SandboxInstance,
pid: number,
): Promise<number[]> {
const raw = await shellSafe(
sandbox,
`pgrep -P ${pid} 2>/dev/null || true`,
);
if (!raw) return [];
return raw.split("\n").map(Number).filter(Boolean);
}
async function getAllDescendantPids(
sandbox: SandboxInstance,
pid: number,
): Promise<number[]> {
const all: number[] = [];
const queue = [pid];
while (queue.length > 0) {
const current = queue.shift()!;
all.push(current);
const children = await getChildPids(sandbox, current);
queue.push(...children);
}
return all;
}
async function collectDaemonMetrics(
sandbox: SandboxInstance,
session: string,
coldStartMs: number,
binarySizeBytes: number,
distributionSizeBytes: number,
): Promise<DaemonMetrics> {
// Find daemon PIDs -- the agent-browser process itself
const daemonPids = await findDaemonPids(sandbox, session);
// Also find the full process tree (daemon + Chrome children)
let allPids: number[] = [];
for (const pid of daemonPids) {
const descendants = await getAllDescendantPids(sandbox, pid);
allPids.push(...descendants);
}
allPids = [...new Set(allPids)];
// If no daemon PIDs found via pgrep, fall back to grabbing all
// agent-browser and chrome processes for metrics
if (allPids.length === 0) {
const fallback = await shellSafe(
sandbox,
`ps -eo pid,comm | grep -E 'agent-browser|chrome' | grep -v grep | awk '{print $1}' || true`,
);
if (fallback) {
allPids = fallback.split("\n").map(Number).filter(Boolean);
}
}
const daemonProcs: ProcessMetrics[] = [];
const browserProcs: ProcessMetrics[] = [];
let daemonPeakRssKb = 0;
for (const pid of allPids) {
const metrics = await collectProcessMetrics(sandbox, pid);
if (!metrics) continue;
const isBrowser = /chrome|chromium/i.test(metrics.command);
if (isBrowser) {
browserProcs.push(metrics);
} else {
daemonProcs.push(metrics);
const peak = await getPeakRssKb(sandbox, pid);
daemonPeakRssKb = Math.max(daemonPeakRssKb, peak);
}
}
const daemonRssKb = daemonProcs.reduce((sum, p) => sum + p.rssKb, 0);
const browserRssKb = browserProcs.reduce((sum, p) => sum + p.rssKb, 0);
const daemonCpuTimeSec = daemonProcs.reduce((sum, p) => sum + p.cpuTimeSec, 0);
const allProcs = [...daemonProcs, ...browserProcs];
const totalCpuTimeSec = allProcs.reduce((sum, p) => sum + p.cpuTimeSec, 0);
return {
coldStartMs,
binarySizeBytes,
distributionSizeBytes,
daemonProcesses: daemonProcs,
browserProcesses: browserProcs,
daemonRssKb,
browserRssKb,
daemonPeakRssKb,
daemonCpuTimeSec,
totalCpuTimeSec,
};
}
async function getBinarySize(
sandbox: SandboxInstance,
): Promise<number> {
// Follow symlinks to get the real binary/script size
const raw = await shellSafe(
sandbox,
`stat -L -c %s "$(readlink -f "$(which agent-browser)")" 2>/dev/null || echo 0`,
);
return Number(raw) || 0;
}
async function getDistributionSize(
sandbox: SandboxInstance,
mode: DaemonMode,
): Promise<number> {
if (mode === "node") {
// Total size of the npm package + Playwright browser
const npmPkg = await shellSafe(
sandbox,
`du -sb "$(npm root -g)/agent-browser" 2>/dev/null | awk '{print $1}' || echo 0`,
);
const pwBrowser = await shellSafe(
sandbox,
`du -sb "$HOME/.cache/ms-playwright" 2>/dev/null | awk '{print $1}' || echo 0`,
);
return (Number(npmPkg) || 0) + (Number(pwBrowser) || 0);
} else {
// Rust binary + Chrome for Testing (checks multiple possible cache paths)
const binary = await shellSafe(
sandbox,
`stat -L -c %s "$(readlink -f "$(which agent-browser)")" 2>/dev/null || echo 0`,
);
const chrome = await shellSafe(
sandbox,
[
`size=0`,
`for d in "$HOME/.cache/agent-browser" "$HOME/.cache/ms-playwright" "$HOME/.agent-browser/chrome"; do`,
` if [ -d "$d" ]; then size=$(du -sb "$d" 2>/dev/null | awk '{print $1}'); break; fi`,
`done`,
`echo $size`,
].join("; "),
);
return (Number(binary) || 0) + (Number(chrome) || 0);
}
}
function formatBytes(bytes: number): string {
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${bytes} B`;
}
function formatKb(kb: number): string {
if (kb >= 1024) return `${(kb / 1024).toFixed(1)} MB`;
return `${kb} KB`;
}
// ---------------------------------------------------------------------------
// Scenario runner
// ---------------------------------------------------------------------------
type DaemonMode = "node" | "native";
function daemonEnv(mode: DaemonMode): Record<string, string> {
return { AGENT_BROWSER_SESSION: `bench-${mode}` };
}
async function agentBrowser(
sandbox: SandboxInstance,
args: string[],
mode: DaemonMode,
): Promise<void> {
const result = await sandbox.runCommand({
cmd: "agent-browser",
args,
env: daemonEnv(mode),
});
if (result.exitCode !== 0) {
const stderr = await result.stderr();
const stdout = await result.stdout();
throw new Error(
`agent-browser ${args.join(" ")} failed (exit ${result.exitCode}): ${stderr || stdout}`,
);
}
}
async function timedAgentBrowser(
sandbox: SandboxInstance,
args: string[],
mode: DaemonMode,
): Promise<number> {
const start = Date.now();
const result = await sandbox.runCommand({
cmd: "agent-browser",
args,
env: daemonEnv(mode),
});
const elapsed = Date.now() - start;
if (result.exitCode !== 0) {
const stderr = await result.stderr();
const stdout = await result.stdout();
throw new Error(
`agent-browser ${args.join(" ")} failed (exit ${result.exitCode}): ${stderr || stdout}`,
);
}
return elapsed;
}
interface ScenarioResult {
name: string;
description: string;
stats: Stats;
error?: string;
}
async function runScenario(
sandbox: SandboxInstance,
scenario: Scenario,
mode: DaemonMode,
iterations: number,
warmup: number,
): Promise<ScenarioResult> {
try {
if (scenario.setup) {
for (const cmd of scenario.setup) {
await agentBrowser(sandbox, cmd, mode);
}
}
for (let w = 0; w < warmup; w++) {
for (const cmd of scenario.commands) {
await agentBrowser(sandbox, cmd, mode);
}
}
const samples: number[] = [];
for (let i = 0; i < iterations; i++) {
let totalMs = 0;
for (const cmd of scenario.commands) {
totalMs += await timedAgentBrowser(sandbox, cmd, mode);
}
samples.push(totalMs);
}
if (scenario.teardown) {
for (const cmd of scenario.teardown) {
await agentBrowser(sandbox, cmd, mode);
}
}
return {
name: scenario.name,
description: scenario.description,
stats: computeStats(samples),
};
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
return {
name: scenario.name,
description: scenario.description,
stats: { avgMs: -1, stddevMs: -1, minMs: -1, maxMs: -1, p50Ms: -1, samples: [] },
error: message,
};
}
}
// ---------------------------------------------------------------------------
// Benchmark phases
// ---------------------------------------------------------------------------
interface DaemonResults {
mode: DaemonMode;
label: string;
scenarios: ScenarioResult[];
metrics: DaemonMetrics;
}
async function benchmarkDaemon(
sandbox: SandboxInstance,
mode: DaemonMode,
label: string,
): Promise<DaemonResults> {
console.log(`\n--- ${label} ---`);
// Measure sizes before launch
const binarySizeBytes = await getBinarySize(sandbox);
const distributionSizeBytes = await getDistributionSize(sandbox, mode);
// Cold start: time the first launch (daemon spawn + browser launch)
const coldStartBegin = Date.now();
await agentBrowser(sandbox, ["open", "about:blank"], mode);
const coldStartMs = Date.now() - coldStartBegin;
console.log(` Cold start: ${coldStartMs}ms`);
console.log(` Binary size: ${formatBytes(binarySizeBytes)}`);
console.log(` Distribution size: ${formatBytes(distributionSizeBytes)}`);
// Run all scenarios
const results: ScenarioResult[] = [];
for (const scenario of scenarios) {
process.stdout.write(` ${scenario.name} `);
const result = await runScenario(
sandbox,
scenario,
mode,
config.iterations,
config.warmup,
);
if (result.error) {
console.log(`FAILED: ${result.error.slice(0, 120)}`);
} else {
const dots = ".".repeat(Math.max(1, 30 - scenario.name.length));
const s = result.stats;
console.log(
`${dots} ${s.avgMs}ms avg +/-${s.stddevMs}ms (p50: ${s.p50Ms}ms, min: ${s.minMs}ms, max: ${s.maxMs}ms)`,
);
}
results.push(result);
}
// Collect system metrics after scenarios (daemon is still running)
const session = `bench-${mode}`;
const metrics = await collectDaemonMetrics(
sandbox,
session,
coldStartMs,
binarySizeBytes,
distributionSizeBytes,
);
// Also grab a full process snapshot for context
const psOutput = await shellSafe(
sandbox,
`ps aux --sort=-rss | head -20`,
);
console.log(`\n Process snapshot (top by RSS):`);
for (const line of psOutput.split("\n").slice(0, 10)) {
console.log(` ${line}`);
}
console.log(`\n Daemon processes (${metrics.daemonProcesses.length}):`);
console.log(` RSS: ${formatKb(metrics.daemonRssKb)} (peak: ${formatKb(metrics.daemonPeakRssKb)})`);
console.log(` CPU time: ${metrics.daemonCpuTimeSec.toFixed(1)}s`);
for (const p of metrics.daemonProcesses) {
console.log(` PID ${p.pid}: ${p.command} (RSS: ${formatKb(p.rssKb)}, CPU: ${p.cpuPercent}%)`);
}
console.log(` Browser processes (${metrics.browserProcesses.length}):`);
console.log(` RSS: ${formatKb(metrics.browserRssKb)}`);
for (const p of metrics.browserProcesses) {
console.log(` PID ${p.pid}: ${p.command} (RSS: ${formatKb(p.rssKb)}, CPU: ${p.cpuPercent}%)`);
}
await agentBrowser(sandbox, ["close"], mode);
console.log(` Browser closed.`);
return { mode, label, scenarios: results, metrics };
}
// ---------------------------------------------------------------------------
// Install helpers
// ---------------------------------------------------------------------------
async function installChromiumDeps(sandbox: SandboxInstance) {
console.log("Installing Chromium system dependencies...");
await shell(
sandbox,
`sudo dnf clean all 2>&1 && sudo dnf install -y --skip-broken ${CHROMIUM_SYSTEM_DEPS.join(" ")} 2>&1 && sudo ldconfig 2>&1`,
);
}
async function installNodeDaemon(sandbox: SandboxInstance) {
console.log("Installing agent-browser from npm (Node.js daemon)...");
await run(sandbox, "npm", ["install", "-g", "agent-browser"]);
await run(sandbox, "npx", ["agent-browser", "install"]);
const version = await shell(sandbox, "agent-browser --version 2>&1 || true");
console.log(` version: ${version.trim()}`);
}
async function installNativeDaemon(sandbox: SandboxInstance, branch: string) {
console.log(`\nBuilding native daemon from ${branch}...`);
console.log(" Installing build tools and Rust toolchain...");
const rustStart = Date.now();
await shell(
sandbox,
"sudo dnf install -y gcc gcc-c++ make perl-core openssl-devel 2>&1",
);
await shell(
sandbox,
"curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y 2>&1",
);
console.log(` Rust + build tools installed (${Math.round((Date.now() - rustStart) / 1000)}s)`);
console.log(` Cloning repo (branch: ${branch})...`);
const cloneStart = Date.now();
await shell(
sandbox,
`git clone --depth 1 --branch ${branch} ${REPO_URL} /tmp/agent-browser 2>&1`,
);
console.log(` Cloned (${Math.round((Date.now() - cloneStart) / 1000)}s)`);
console.log(" Building release binary (cargo build --release)...");
const buildStart = Date.now();
await shell(
sandbox,
"source $HOME/.cargo/env && cd /tmp/agent-browser/cli && cargo build --release 2>&1",
);
console.log(` Built (${Math.round((Date.now() - buildStart) / 1000)}s)`);
const npmBinPath = (await shell(sandbox, "which agent-browser")).trim();
console.log(` Replacing ${npmBinPath} with native build...`);
await shell(
sandbox,
`sudo cp /tmp/agent-browser/cli/target/release/agent-browser ${npmBinPath}`,
);
const version = await shell(sandbox, "agent-browser --version 2>&1 || true");
console.log(` version: ${version.trim()}`);
}
// ---------------------------------------------------------------------------
// Output
// ---------------------------------------------------------------------------
function printResults(node: DaemonResults, native: DaemonResults) {
console.log("\n\n========== COMMAND LATENCY ==========\n");
const header =
"Scenario".padEnd(20) + "| Node avg +/-sd | Rust avg +/-sd | Speedup";
const sep = "-".repeat(20) + "|-----------------|-----------------|--------";
console.log(header);
console.log(sep);
for (let i = 0; i < node.scenarios.length; i++) {
const n = node.scenarios[i];
const r = native.scenarios[i];
const name = n.name.padEnd(20);
if (n.error || r.error) {
const nodeVal = n.error ? "FAILED".padEnd(15) : `${n.stats.avgMs}ms`.padEnd(15);
const rustVal = r.error ? "FAILED".padEnd(15) : `${r.stats.avgMs}ms`.padEnd(15);
console.log(`${name}| ${nodeVal} | ${rustVal} | --`);
continue;
}
const nodeVal = `${n.stats.avgMs} +/-${n.stats.stddevMs}ms`.padEnd(15);
const rustVal = `${r.stats.avgMs} +/-${r.stats.stddevMs}ms`.padEnd(15);
const speedup =
r.stats.avgMs > 0
? (n.stats.avgMs / r.stats.avgMs).toFixed(2) + "x"
: "--";
console.log(`${name}| ${nodeVal} | ${rustVal} | ${speedup.padStart(6)}`);
}
console.log("\n\n========== SYSTEM METRICS ==========\n");
const nm = node.metrics;
const rm = native.metrics;
function ratio(a: number, b: number): string {
if (b <= 0) return "--";
return (a / b).toFixed(2) + "x";
}
const metricRows: [string, string, string, string][] = [
[
"Cold start",
`${nm.coldStartMs}ms`,
`${rm.coldStartMs}ms`,
ratio(nm.coldStartMs, rm.coldStartMs),
],
[
"Binary size",
formatBytes(nm.binarySizeBytes),
formatBytes(rm.binarySizeBytes),
ratio(nm.binarySizeBytes, rm.binarySizeBytes),
],
[
"Distribution size",
formatBytes(nm.distributionSizeBytes),
formatBytes(rm.distributionSizeBytes),
ratio(nm.distributionSizeBytes, rm.distributionSizeBytes),
],
[
"Daemon RSS",
formatKb(nm.daemonRssKb),
formatKb(rm.daemonRssKb),
ratio(nm.daemonRssKb, rm.daemonRssKb),
],
[
"Daemon peak RSS",
formatKb(nm.daemonPeakRssKb),
formatKb(rm.daemonPeakRssKb),
ratio(nm.daemonPeakRssKb, rm.daemonPeakRssKb),
],
[
"Browser RSS",
formatKb(nm.browserRssKb),
formatKb(rm.browserRssKb),
ratio(nm.browserRssKb, rm.browserRssKb),
],
[
"Daemon CPU time",
`${nm.daemonCpuTimeSec.toFixed(1)}s`,
`${rm.daemonCpuTimeSec.toFixed(1)}s`,
ratio(nm.daemonCpuTimeSec, rm.daemonCpuTimeSec),
],
[
"Daemon processes",
String(nm.daemonProcesses.length),
String(rm.daemonProcesses.length),
"--",
],
[
"Browser processes",
String(nm.browserProcesses.length),
String(rm.browserProcesses.length),
"--",
],
];
const mHeader =
"Metric".padEnd(20) + "| Node".padEnd(14) + "| Rust".padEnd(14) + "| Ratio";
const mSep = "-".repeat(20) + "|" + "-".repeat(13) + "|" + "-".repeat(13) + "|--------";
console.log(mHeader);
console.log(mSep);
for (const [metric, nodeVal, rustVal, ratio] of metricRows) {
console.log(
`${metric.padEnd(20)}| ${nodeVal.padEnd(12)}| ${rustVal.padEnd(12)}| ${ratio}`,
);
}
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
console.log("agent-browser Daemon Benchmark (Node.js vs Rust Native)");
console.log(`Branch: ${config.branch}`);
console.log(`Iterations: ${config.iterations} (+ ${config.warmup} warmup)`);
console.log(`vCPUs: ${config.vcpus}\n`);
console.log("Creating sandbox...");
const sandbox = await Sandbox.create({
...credentials,
timeout: TIMEOUT_MS,
runtime: "node22",
networkPolicy: "allow-all" as const,
resources: { vcpus: config.vcpus },
});
console.log(`Sandbox: ${sandbox.sandboxId}`);
try {
await installChromiumDeps(sandbox);
// Phase 1: Node.js daemon (from published npm package)
await installNodeDaemon(sandbox);
const nodeResults = await benchmarkDaemon(
sandbox,
"node",
"Node.js Daemon (npm)",
);
// Phase 2: Rust native daemon (built from branch)
await installNativeDaemon(sandbox, config.branch);
const nativeResults = await benchmarkDaemon(
sandbox,
"native",
`Rust Native Daemon (${config.branch})`,
);
printResults(nodeResults, nativeResults);
if (config.json) {
const output = {
timestamp: new Date().toISOString(),
branch: config.branch,
vcpus: config.vcpus,
iterations: config.iterations,
warmup: config.warmup,
node: {
scenarios: nodeResults.scenarios.map((s) => ({
name: s.name,
description: s.description,
...s.stats,
error: s.error,
})),
metrics: nodeResults.metrics,
},
native: {
scenarios: nativeResults.scenarios.map((s) => ({
name: s.name,
description: s.description,
...s.stats,
error: s.error,
})),
metrics: nativeResults.metrics,
},
};
writeFileSync("results.json", JSON.stringify(output, null, 2));
console.log("\nResults written to results.json");
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
console.error(`\nFatal error: ${message}`);
process.exit(1);
} finally {
try {
await sandbox.stop();
console.log("\nSandbox stopped.");
} catch {
console.warn("Warning: failed to stop sandbox.");
}
}
}
main();