openclaw/extensions/browser/scripts/bench-lightweight.ts
Vincent Koc d4eccd8345
feat(browser): support lightweight Docker browser installs (#154608)
* refactor(browser): share engine adapters for Chromium and Lightpanda

Refresh signed stack identities without changing any source tree.

Previous signed head: bed85c558d

PR06 CI tested stale synthetic merge22409995, whose main base80c3c742 predates Peter Steinberger's canonical static-validation fix82411a34. Fresh synchronization must check the repaired merge context; neither old failed CI nor old passing runtime proof is relabeled. Preserve every source/test/dependency blob and previously qualified registry/catalog fixture correction.

* improve(browser): default managed Chromium to efficient headless launches

Refresh signed stack identities without changing any source tree.

Previous signed head: b6cda11dd8

PR06 CI tested stale synthetic merge22409995, whose main base80c3c742 predates Peter Steinberger's canonical static-validation fix82411a34. Fresh synchronization must check the repaired merge context; neither old failed CI nor old passing runtime proof is relabeled. Preserve every source/test/dependency blob and previously qualified registry/catalog fixture correction.

* improve(browser): trim Docker browser images and harden Lightpanda sidecar

Refresh signed stack identities without changing any source tree.

Previous signed head: e6d320e30a

PR06 CI tested stale synthetic merge22409995, whose main base80c3c742 predates Peter Steinberger's canonical static-validation fix82411a34. Fresh synchronization must check the repaired merge context; neither old failed CI nor old passing runtime proof is relabeled. Preserve every source/test/dependency blob and previously qualified registry/catalog fixture correction.

* test(agents): retain canonical restored-run settlement fixture

* test(agents): preserve upstream requester-disposal completion ordering

* fix(browser): preserve shared Chromium discovery and Lightpanda lifecycle

* fix(browser): align lightweight image integration

Preserve the current shared browser discovery helper and renderer fixtures after baseline integration. Leave Lightpanda resource ceilings with operators and keep selected-image proof wording version neutral.
2026-10-24 15:20:21 +08:00

621 lines
24 KiB
TypeScript

/** Synthetic, opt-in browser-route compatibility and sampled process-tree benchmark. */
import assert from "node:assert/strict";
import { spawn, type ChildProcess } from "node:child_process";
import { once } from "node:events";
import fs from "node:fs/promises";
import http from "node:http";
import path from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { parseArgs } from "node:util";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
type Engine = "chromium" | "lightpanda";
type Distribution = "chromium" | "chromium-headless-shell" | "lightpanda";
type Run =
| { engine: Engine; distribution: Distribution; executable: string }
| { engine: Engine; endpoint: string };
const { values } = parseArgs({
options: {
lightpanda: { type: "string" },
chromium: { type: "string" },
"headless-shell": { type: "string" },
endpoint: { type: "string" },
engine: { type: "string" },
"fixture-bind": { type: "string", default: "127.0.0.1" },
"fixture-host": { type: "string", default: "127.0.0.1" },
iterations: { type: "string", default: "10" },
output: { type: "string" },
},
});
const iterations = Number(values.iterations);
assert(
Number.isSafeInteger(iterations) && iterations > 0 && iterations <= 100,
"--iterations must be an integer from 1 through 100.",
);
const runs: Run[] = [];
if (values.endpoint) {
assert(
!values.lightpanda && !values.chromium && !values["headless-shell"],
"Use either native binaries or an external --endpoint, not both.",
);
assert(
values.engine === "chromium" || values.engine === "lightpanda",
"--endpoint requires --engine chromium|lightpanda.",
);
assert(
["http:", "https:", "ws:", "wss:"].includes(new URL(values.endpoint).protocol),
"--endpoint must be a CDP HTTP or WebSocket URL.",
);
runs.push({ engine: values.engine, endpoint: values.endpoint });
} else {
assert(!values.engine, "--engine requires --endpoint.");
for (const [engine, distribution, executable] of [
["chromium", "chromium", values.chromium],
["chromium", "chromium-headless-shell", values["headless-shell"]],
["lightpanda", "lightpanda", values.lightpanda],
] as const) {
if (executable) {
runs.push({ engine, distribution, executable: path.resolve(executable) });
}
}
}
assert(
runs.length > 0,
"Pass --lightpanda <binary>, --chromium <binary>, --headless-shell <binary>, or --endpoint <url> --engine <engine>.",
);
async function freePort() {
const server = http.createServer();
try {
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address();
assert(address && typeof address !== "string");
return address.port;
} finally {
if (server.listening) {
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
}
}
}
async function treeMemory(rootPid: number): Promise<{ rssKiB: number; pssKiB: number } | null> {
if (process.platform !== "linux") {
return null;
}
const entries = await fs.readdir("/proc").catch(() => null);
if (!entries) {
return null;
}
let discoveryUnavailable = false;
const processes = await Promise.all(
entries
.filter((entry) => /^\d+$/.test(entry))
.map(async (entry) => {
try {
const stat = await fs.readFile(`/proc/${entry}/stat`, "utf8");
return {
pid: Number(entry),
ppid: Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[1]),
};
} catch (error) {
if (
!(
error instanceof Error &&
"code" in error &&
(error.code === "ENOENT" || error.code === "ESRCH")
)
) {
discoveryUnavailable = true;
}
return null;
}
}),
);
if (discoveryUnavailable) {
return null;
}
const owned = new Set([rootPid]);
for (;;) {
const before = owned.size;
for (const proc of processes) {
if (proc && owned.has(proc.ppid)) {
owned.add(proc.pid);
}
}
if (before === owned.size) {
break;
}
}
let rssKiB = 0;
let pssKiB = 0;
for (const pid of owned) {
try {
const rollup = await fs.readFile(`/proc/${pid}/smaps_rollup`, "utf8");
const rss = /^Rss:\s+(\d+)/m.exec(rollup)?.[1];
const pss = /^Pss:\s+(\d+)/m.exec(rollup)?.[1];
if (!rss || !pss) {
return null;
}
rssKiB += Number(rss);
pssKiB += Number(pss);
} catch (error) {
const vanished =
error instanceof Error &&
"code" in error &&
(error.code === "ENOENT" || error.code === "ESRCH");
if (pid === rootPid || !vanished) {
return null;
}
// Descendants can exit between process discovery and their sample.
}
}
return { rssKiB, pssKiB };
}
async function main() {
const cancellation = new AbortController();
const interrupt = () => cancellation.abort(new Error("Benchmark interrupted"));
process.once("SIGINT", interrupt);
process.once("SIGTERM", interrupt);
let scratch: string | undefined;
let submissions = 0;
const fixture = http.createServer((req, res) => {
if (req.url === "/submit") {
if (req.method !== "POST") {
res.writeHead(405).end();
return;
}
submissions++;
res.end("ok");
return;
}
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.end(`<!doctype html><html><head><title>Browser fixture</title></head><body>
<h1>Browser fixture</h1><label>Name <input id="name"></label>
<button id="save">Save</button><p id="result">Waiting</p>
<script>document.getElementById('save').onclick = async () => {
await fetch('/submit', {method: 'POST'});
document.getElementById('result').textContent = 'Saved: ' + document.getElementById('name').value;
};</script></body></html>`);
});
try {
// openclaw-temp-dir: allow isolated CLI benchmark owns and removes its scratch tree
scratch = await fs.mkdtemp(
path.join(resolvePreferredOpenClawTmpDir(), "openclaw-browser-bench-"),
);
const scratchDir = scratch;
process.env.OPENCLAW_STATE_DIR = path.join(scratchDir, "state");
process.env.OPENCLAW_CONFIG_PATH = path.join(scratchDir, "openclaw.json");
await fs.writeFile(process.env.OPENCLAW_CONFIG_PATH, "{}");
const {
resolveBrowserConfig,
createBrowserRouteContext,
createBrowserRouteDispatcher,
closePlaywrightBrowserConnection,
} = await import("../benchmark-api.js");
cancellation.signal.throwIfAborted();
fixture.listen(0, values["fixture-bind"]);
await once(fixture, "listening");
const address = fixture.address();
assert(address && typeof address !== "string");
const fixtureUrl = new URL(`http://${values["fixture-host"]}:${address.port}/`);
assert(
!fixtureUrl.username &&
!fixtureUrl.password &&
fixtureUrl.pathname === "/" &&
!fixtureUrl.search &&
!fixtureUrl.hash,
"--fixture-host must be a hostname or bracketed IP address.",
);
async function run(spec: Run) {
const { engine } = spec;
const port = "executable" in spec ? await freePort() : undefined;
const cdpUrl =
"endpoint" in spec
? spec.endpoint
: `${engine === "chromium" ? "http" : "ws"}://127.0.0.1:${port}`;
let proc: ChildProcess | undefined;
let processExited = Promise.resolve();
let spawnFailure: Error | undefined;
let stderr = "";
let maxSampledPssKiB = 0;
let maxSampledRssKiB = 0;
let sampleCount = 0;
let memoryUnavailable = false;
let sampling: Promise<void> | undefined;
let timer: ReturnType<typeof setInterval> | undefined;
let closeOwnedContext: (() => Promise<void>) | undefined;
const started = performance.now();
const sample = () => {
if (sampling) {
return sampling;
}
sampling = (async () => {
const memory = proc?.pid ? await treeMemory(proc.pid) : null;
if (memory) {
sampleCount++;
maxSampledPssKiB = Math.max(maxSampledPssKiB, memory.pssKiB);
maxSampledRssKiB = Math.max(maxSampledRssKiB, memory.rssKiB);
} else {
memoryUnavailable = true;
}
})().finally(() => {
sampling = undefined;
});
return sampling;
};
const signalProcess = (signal: NodeJS.Signals) => {
if (!proc?.pid) {
return;
}
try {
if (process.platform === "win32") {
proc.kill(signal);
} else {
process.kill(-proc.pid, signal);
}
} catch (error) {
if (!(error instanceof Error && "code" in error && error.code === "ESRCH")) {
throw error;
}
}
};
try {
let startupMs: number | null = null;
let engineVersion: string | null = null;
if ("executable" in spec) {
const engineHome = path.join(scratchDir, spec.distribution);
await fs.mkdir(engineHome);
const args =
engine === "lightpanda"
? ["serve", "--host", "127.0.0.1", "--port", String(port)]
: [
// Match OpenClaw's managed headless launch defaults, with a
// scratch profile and the container-friendly no-sandbox flag.
"--headless=new",
"--disable-gpu",
"--no-sandbox",
...(process.platform === "linux" ? ["--disable-dev-shm-usage"] : []),
"--no-first-run",
"--no-default-browser-check",
"--disable-sync",
"--disable-background-networking",
"--disable-component-update",
"--disable-features=Translate,MediaRouter",
"--disable-session-crashed-bubble",
"--hide-crash-restore-bubble",
"--password-store=basic",
"--no-proxy-server",
...(process.platform === "darwin" ? ["--use-mock-keychain"] : []),
`--user-data-dir=${engineHome}`,
`--remote-debugging-port=${port}`,
];
proc = spawn(spec.executable, args, {
env: {
PATH: process.env.PATH ?? "",
HOME: engineHome,
TMPDIR: scratchDir,
LIGHTPANDA_DISABLE_TELEMETRY: "true",
LIGHTPANDA_DISABLE_CORE_DUMP: "1",
...(process.env.LD_LIBRARY_PATH
? { LD_LIBRARY_PATH: process.env.LD_LIBRARY_PATH }
: {}),
...(process.env.FONTCONFIG_FILE
? { FONTCONFIG_FILE: process.env.FONTCONFIG_FILE }
: {}),
...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}),
},
// The benchmark owns this group so failed runs also clean up browser descendants.
detached: process.platform !== "win32",
stdio: ["ignore", "ignore", "pipe"],
});
proc.stderr?.on("data", (data: Buffer) => {
stderr = (stderr + data.toString()).slice(-4000);
});
processExited = new Promise<void>((resolve) => {
proc?.once("error", (error) => {
spawnFailure = error;
resolve();
});
proc?.once("exit", () => resolve());
});
timer = setInterval(() => {
void sample();
}, 50);
const startupDeadline = AbortSignal.any([
cancellation.signal,
AbortSignal.timeout(15000),
]);
for (;;) {
startupDeadline.throwIfAborted();
if (spawnFailure) {
throw spawnFailure;
}
if (proc.exitCode !== null || proc.signalCode !== null) {
throw new Error(`${engine} exited: ${stderr}`);
}
try {
const fetched = await fetchWithSsrFGuard({
url: `http://127.0.0.1:${port}/json/version`,
signal: AbortSignal.any([startupDeadline, AbortSignal.timeout(500)]),
policy: { allowedHostnames: ["127.0.0.1"], dangerouslyAllowPrivateNetwork: true },
maxRedirects: 0,
});
try {
const { response } = fetched;
if (response.ok) {
const info: unknown = await response.json();
if (info && typeof info === "object") {
if (
engine === "lightpanda" &&
"Lightpanda-Version" in info &&
typeof info["Lightpanda-Version"] === "string"
) {
engineVersion = info["Lightpanda-Version"];
}
if (
engine === "chromium" &&
"Browser" in info &&
typeof info.Browser === "string"
) {
engineVersion = info.Browser;
}
}
break;
}
} finally {
await fetched.release();
}
} catch {
// Only engine startup readiness retries; workflow actions never replay.
}
await delay(50, undefined, { signal: startupDeadline });
}
startupMs = performance.now() - started;
}
const resolved = resolveBrowserConfig({
defaultProfile: "bench",
evaluateEnabled: true,
snapshotDefaults: { mode: "efficient" },
ssrfPolicy: {
allowedHostnames: [fixtureUrl.hostname],
dangerouslyAllowPrivateNetwork: true,
},
profiles: { bench: { engine, cdpUrl, attachOnly: true } },
});
const state = { server: null, port: 0, resolved, profiles: new Map() };
const ctx = createBrowserRouteContext({
getState: () => state,
refreshConfigFromDisk: false,
});
const dispatcher = createBrowserRouteDispatcher(ctx);
const dispatch = (
method: "GET" | "POST" | "DELETE",
route: string,
body?: unknown,
query?: Record<string, unknown>,
) =>
dispatcher.dispatch({
method,
path: route,
body,
query,
signal: AbortSignal.any([cancellation.signal, AbortSignal.timeout(15000)]),
});
const request = async (
method: "GET" | "POST" | "DELETE",
route: string,
body?: unknown,
query?: Record<string, unknown>,
) => {
const result = await dispatch(method, route, body, query);
if (result.status !== 200) {
const diagnostic = await dispatch("GET", "/doctor").catch((error: unknown) => ({
error: String(error),
}));
assert.fail(
`${engine} ${route}: ${JSON.stringify(result)}; doctor: ${JSON.stringify(diagnostic)}; engine exit: ${proc?.exitCode ?? proc?.signalCode ?? "running/external"}; stderr: ${stderr}`,
);
}
assert(result.body && typeof result.body === "object");
// SAFETY: The assertion proves a non-null object; each property remains unknown.
return result.body as Record<string, unknown>;
};
let targetId = "";
closeOwnedContext = async () => {
try {
// External Chromium outlives this run; remove only the tab we created.
if (engine === "chromium" && targetId) {
await ctx.forProfile().closeTab(targetId, { exactTargetId: true });
}
} finally {
await ctx.forProfile().stopRunningBrowser();
}
};
const task = async (index: number, first: boolean) => {
const start = performance.now();
if (first) {
const opened = await request("POST", "/tabs/open", { url: fixtureUrl.href });
assert(typeof opened.targetId === "string" && opened.targetId);
targetId = opened.targetId;
} else {
await request("POST", "/navigate", { targetId, url: fixtureUrl.href });
}
const snapshot = await request("GET", "/snapshot", undefined, {
targetId,
format: "ai",
mode: "efficient",
});
const text = String(snapshot.snapshot);
const nameRef = /textbox[^\n]*\[ref=((?:f\d+)?e\d+)\]/.exec(text)?.[1];
const saveRef = /button "Save"[^\n]*\[ref=((?:f\d+)?e\d+)\]/.exec(text)?.[1];
assert(nameRef && saveRef, text);
await request("POST", "/act", {
targetId,
kind: "type",
ref: nameRef,
text: `Case ${index}`,
});
const before = submissions;
await request("POST", "/act", { targetId, kind: "click", ref: saveRef });
await request("POST", "/act", { targetId, kind: "wait", text: `Saved: Case ${index}` });
const result = await request("GET", "/text", undefined, { targetId });
assert(JSON.stringify(result).includes(`Saved: Case ${index}`));
assert.equal(submissions, before + 1, "Submission must occur exactly once");
return { durationMs: performance.now() - start, snapshotBytes: Buffer.byteLength(text) };
};
// The first task includes CDP attachment and initial page open. Warm tasks
// all include navigation and use the same remaining route sequence.
const first = await task(0, true);
const coldStartToFirstCompletionMs = proc ? performance.now() - started : null;
const tasks = [];
for (let index = 1; index <= iterations; index++) {
tasks.push(await task(index, false));
await sample();
}
clearInterval(timer);
await sample();
const controllerRssAfterWorkloadMiB = process.memoryUsage().rss / 1024 / 1024;
const checks: Record<string, boolean> = { workflow: true, exactlyOnceSubmission: true };
if (engine === "lightpanda") {
for (const route of [
"/screenshot",
"/pdf",
"/download",
"/hooks/file-chooser",
"/hooks/dialog",
"/screencast",
"/set/media",
]) {
const result = await dispatch("POST", route, { targetId });
assert.equal(result.status, 501, `${route}: ${JSON.stringify(result)}`);
}
checks.unsupportedCapabilities = true;
const second = await dispatch("POST", "/tabs/open", { url: fixtureUrl.href });
assert.notEqual(second.status, 200);
checks.singlePageLimit = true;
await closePlaywrightBrowserConnection({ cdpUrl });
const replacement = await request("POST", "/tabs/open", { url: fixtureUrl.href });
assert(typeof replacement.targetId === "string" && replacement.targetId);
assert.notEqual(replacement.targetId, targetId);
const navigated = await request("POST", "/navigate", {
targetId: replacement.targetId,
url: fixtureUrl.href,
});
assert.equal(navigated.targetId, replacement.targetId);
const stale = await dispatch("POST", "/navigate", { targetId, url: fixtureUrl.href });
assert.equal(stale.status, 404, JSON.stringify(stale));
assert(stale.body && typeof stale.body === "object" && "error" in stale.body);
assert(typeof stale.body.error === "string");
assert.match(stale.body.error, /^tab not found(?::|$)/);
checks.staleTargetRejected = true;
}
const sorted = tasks.map((item) => item.durationMs).toSorted((a, b) => a - b);
return {
engine,
distribution: "distribution" in spec ? spec.distribution : null,
engineVersion,
connectionMode: proc ? "spawned" : "external",
warmIterations: iterations,
checks,
startupMs,
firstTaskMs: first.durationMs,
coldStartToFirstCompletionMs,
warmP50Ms: sorted[Math.ceil(sorted.length * 0.5) - 1],
warmP95Ms: sorted[Math.ceil(sorted.length * 0.95) - 1],
warmTaskMs: tasks.map((item) => item.durationMs),
meanWarmSnapshotBytes:
tasks.reduce((sum, item) => sum + item.snapshotBytes, 0) / iterations,
engineMaxSampledPssMiB:
sampleCount && !memoryUnavailable ? maxSampledPssKiB / 1024 : null,
engineMaxSampledRssMiB:
sampleCount && !memoryUnavailable ? maxSampledRssKiB / 1024 : null,
memorySamples: sampleCount,
controllerRssAfterWorkloadMiB,
};
} catch (error) {
process.stderr.write(`${String(error)}\nEngine stderr: ${stderr}\n`);
throw error;
} finally {
clearInterval(timer);
await sampling;
try {
try {
await closeOwnedContext?.();
} finally {
await closePlaywrightBrowserConnection({ cdpUrl });
}
} finally {
if (proc?.pid && !spawnFailure) {
if (
process.platform === "win32" &&
proc.exitCode === null &&
proc.signalCode === null
) {
const killer = spawn("taskkill", ["/PID", String(proc.pid), "/T", "/F"], {
stdio: "ignore",
});
await new Promise<void>((resolve) => {
killer.once("exit", () => resolve());
killer.once("error", () => resolve());
});
}
signalProcess("SIGTERM");
const killTimer = setTimeout(() => signalProcess("SIGKILL"), 3000);
try {
await processExited;
} finally {
clearTimeout(killTimer);
signalProcess("SIGKILL");
}
}
}
}
}
const results = [];
for (const spec of runs) {
results.push(await run(spec));
}
const report = JSON.stringify(
{
platform: process.platform,
arch: process.arch,
node: process.version,
memoryMethod:
"Maximum sampled Linux /proc descendant-tree PSS/RSS, not a true peak; sampling attempts every 50 ms plus task boundaries. Excludes controller. Null for external engines and unsupported hosts. Controller RSS is a separate end-of-workload sample, not incremental overhead or a peak.",
workload:
"Local synthetic form through OpenClaw routes, no LLM. One first task includes initial page open/attachment; each measured warm task includes navigation, default efficient AI snapshot (Lightpanda selects aria refs), typing, exactly one submission, wait and text extraction. Capability/session checks run after measurement.",
engineOrder: runs.map((spec) => spec.engine),
distributionOrder: runs.map((spec) => ("distribution" in spec ? spec.distribution : null)),
results,
},
null,
2,
);
if (values.output) {
await fs.writeFile(values.output, report + "\n");
}
process.stdout.write(report + "\n");
} finally {
process.removeListener("SIGINT", interrupt);
process.removeListener("SIGTERM", interrupt);
fixture.closeAllConnections();
if (fixture.listening) {
await new Promise<void>((resolve) => {
fixture.close(() => resolve());
});
}
if (scratch) {
await fs.rm(scratch, { recursive: true, force: true });
}
}
}
await main();