feat(browser): add portable Lightpanda deployment and benchmarks (#154360)

* feat(browser): add opt-in Lightpanda semantic profiles

* fix(browser): reject direct selectors for Lightpanda profiles

* feat(browser): add portable Lightpanda deployment and benchmarks

* docs(browser): translate lightweight browser page title

* fix(browser): verify stale targets with a valid navigation control
This commit is contained in:
Vincent Koc 2026-09-23 23:14:16 +08:00 • committed by GitHub
parent a479b6c4b4
commit 645f1bc4ef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 894 additions and 0 deletions

View file

@ -885,6 +885,8 @@ const config = {
"chrome-extension/options.js!",
"chrome-extension/popup.js!",
"scripts/copy-chrome-extension.mjs!",
// The opt-in browser benchmark is documented and invoked directly by path.
"scripts/bench-lightweight.ts!",
]),
[`${BUNDLED_PLUGIN_ROOT_DIR}/canvas`]: bundledPluginWorkspace([
// Package build/copy scripts are invoked from package.json.

View file

@ -0,0 +1,4 @@
664775c7f5ab69cc3189954c7f9345e25c167cb4dace016173e629f9a5e82c42 lightpanda-aarch64-linux
99e67739ed8cf5b985af7cbfa7c76b2bab257b171b2dad21109bd74b4f3bb510 lightpanda-aarch64-macos
1d40801e72c0bc61b2cbd3f3562bcfc46de7b79e0568f33f686b64f2e587610a lightpanda-x86_64-linux
9f8ed2787476e39e9c8ba4890a4971391fb7098bbe6384350b21a3649b485eec lightpanda-x86_64-macos

View file

@ -0,0 +1,4 @@
services:
lightpanda:
ports:
- "127.0.0.1:${LIGHTPANDA_PORT:-9222}:9222"

View file

@ -0,0 +1,16 @@
# Use with compose.host.yaml for a host-native OpenClaw Gateway, or merge with
# the repository's docker-compose.yml for an unpublished container-only endpoint.
services:
lightpanda:
# Official 0.4.1 multi-platform index: linux/amd64 and linux/arm64.
image: docker.io/lightpanda/browser:0.4.1@sha256:73f67d2dc0bc243f3a8c87065c9871c98b121197baf7b650df5da91b33b98f02
environment:
LIGHTPANDA_DISABLE_TELEMETRY: "1"
LIGHTPANDA_DISABLE_CORE_DUMP: "1"
command: ["/bin/lightpanda", "serve", "--host", "0.0.0.0", "--port", "9222"]
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
# The official image already runs as its lightpanda user under tini.
restart: unless-stopped

View file

@ -3891,6 +3891,10 @@
"source": "Remote and hosted browsers",
"target": "远程与托管浏览器"
},
{
"source": "Lightweight browsers",
"target": "轻量级浏览器"
},
{
"source": "Browser security",
"target": "浏览器安全"

View file

@ -2073,6 +2073,7 @@
"tools/browser/existing-session",
"tools/browser/configuration",
"tools/browser/remote",
"tools/browser/lightweight",
"tools/browser/security",
"tools/browser/isolation",
"tools/browser/agent-tools",

View file

@ -0,0 +1,245 @@
---
summary: "Use an externally managed Lightpanda browser for JavaScript and DOM tasks"
title: "Lightweight browsers"
read_when:
- You want browser tasks to use a lightweight engine instead of Chromium
- You run OpenClaw or its browser in Docker
- You need the limits of the Lightpanda browser profile
---
# Lightweight browsers
Lightpanda is an opt-in engine for text and DOM browser tasks. It uses the same
OpenClaw `browser` tool through an explicitly configured profile. It is not a
visual-browser replacement: keep a Chromium profile for screenshots, PDF output,
and applications that require unsupported browser features.
The examples pin Lightpanda **0.4.1**. They do not change your existing browser
profile, install a service, or migrate a logged-in Chrome profile.
## Choose where the engine runs
| OpenClaw location | Lightpanda location | Profile CDP URL |
| ----------------------- | ------------------------------------------------ | ---------------------- |
| Host, including Windows | Docker/Podman with a loopback-published port | `ws://127.0.0.1:9222` |
| Linux or macOS host | Native binary on the same host | `ws://127.0.0.1:9222` |
| Docker Compose | Sidecar in the same Compose project | `ws://lightpanda:9222` |
| WSL | Native Linux binary in the same WSL distribution | `ws://127.0.0.1:9222` |
`localhost` inside an OpenClaw container means that container, not the host and
not the Lightpanda sidecar. Use the service name for container-to-container
connections. On Windows, run Docker Desktop in **Linux container** mode, or run
both OpenClaw and the Linux engine inside WSL. Lightpanda does not publish a native
Windows binary. macOS and Linux have official x86-64 and ARM64 release binaries;
the official container image has Linux amd64 and arm64 variants.
[Upstream installation information](https://github.com/lightpanda-io/browser#install).
## Docker with OpenClaw on the host
From the repository root:
```sh
docker compose -f deploy/lightpanda/compose.yaml -f deploy/lightpanda/compose.host.yaml up -d
docker compose -f deploy/lightpanda/compose.yaml -f deploy/lightpanda/compose.host.yaml exec lightpanda /bin/lightpanda version
```
The sample publishes CDP on `127.0.0.1:9222` only. Set `LIGHTPANDA_PORT` to select
another host port, and update the profile URL to match. The image is pinned by its
multi-platform digest, so Docker selects the host architecture without pulling a
moving `latest` or `nightly` version.
CDP gives a client control over the browser; the sample does not add CDP
authentication. Do not change the loopback binding to a public address. Use an
authenticated tunnel for access from another host.
To stop and remove only this sample's container and network:
```sh
docker compose -f deploy/lightpanda/compose.yaml -f deploy/lightpanda/compose.host.yaml down
```
## Docker Compose with OpenClaw in a container
Merge the sidecar into the repository's existing Compose project:
```sh
docker compose -f docker-compose.yml -f deploy/lightpanda/compose.yaml up -d lightpanda
```
Configure the OpenClaw Gateway with `cdpUrl: "ws://lightpanda:9222"` in the profile
below. Use the same Compose files and project name when starting the Gateway.
No browser port is published to the host in this variant. The containers share
the project's bridge network and retain outbound internet access; the network
is not declared `internal: true` because that would prevent public-site browsing.
Use your normal OpenClaw Docker setup for its state directory, authentication,
and Gateway startup. The sidecar does not mount your OpenClaw state, browser
cookies, or host Docker socket.
For Podman, use an installed Compose provider and verify service-name DNS before
choosing the sidecar URL. A netavark installation without its `aardvark-dns`
helper can start a loopback-published engine while leaving container DNS broken;
successful engine startup does not prove sidecar connectivity.
## Native Linux and macOS
Download the release binary for your operating system and CPU from
[Lightpanda 0.4.1](https://github.com/lightpanda-io/browser/releases/tag/0.4.1).
The sample's `deploy/lightpanda/SHA256SUMS` records the release asset digests.
For Linux x86-64, run from the repository root:
```sh
curl --fail --location --output lightpanda-x86_64-linux https://github.com/lightpanda-io/browser/releases/download/0.4.1/lightpanda-x86_64-linux &&
sha256sum --check --ignore-missing deploy/lightpanda/SHA256SUMS &&
chmod +x lightpanda-x86_64-linux &&
LIGHTPANDA_DISABLE_TELEMETRY=1 LIGHTPANDA_DISABLE_CORE_DUMP=1 ./lightpanda-x86_64-linux serve --host 127.0.0.1 --port 9222
```
For macOS Apple silicon:
```sh
curl --fail --location --output lightpanda-aarch64-macos https://github.com/lightpanda-io/browser/releases/download/0.4.1/lightpanda-aarch64-macos &&
shasum --algorithm 256 --check --ignore-missing deploy/lightpanda/SHA256SUMS &&
chmod +x lightpanda-aarch64-macos &&
LIGHTPANDA_DISABLE_TELEMETRY=1 LIGHTPANDA_DISABLE_CORE_DUMP=1 ./lightpanda-aarch64-macos serve --host 127.0.0.1 --port 9222
```
Use `lightpanda-aarch64-linux` for Linux ARM64 or
`lightpanda-x86_64-macos` for Intel macOS. Only execute the downloaded binary after
its checksum matches. Linux release binaries require glibc; use the official
container image on musl-based systems such as Alpine. These commands run the
engine in the foreground; stop it with Ctrl+C.
## Configure an opt-in profile
Merge this browser block into your existing configuration:
```json5
{
browser: {
profiles: {
lightpanda: {
engine: "lightpanda",
cdpUrl: "ws://127.0.0.1:9222",
attachOnly: true,
},
},
},
}
```
Use `profile: "lightpanda"` on browser tool calls. When the selected workload has
passed your checks, set `browser.defaultProfile` to `"lightpanda"` to make it the
default. Preserve your Chromium profile and select it explicitly for visual or
unsupported work. Restore the previous `defaultProfile` to undo the selection.
`engine` declares the capability contract; a CDP endpoint alone does not imply
Chromium compatibility. `attachOnly` means OpenClaw attaches to the service you
started instead of launching or taking ownership of a local Chrome process.
Do not set `executablePath` to Lightpanda: its CLI is not Chrome's launch CLI.
## Session and capability limits
- A Lightpanda CDP connection owns its page state. Closing the connection, stopping
the container, or restarting the engine loses that state; reconnecting does not
resume the previous page or login.
- One CDP connection supports one page target. Separate connections can coexist,
but a Lightpanda profile is not a general multi-tab Chromium session.
- No automatic cross-engine replay occurs after an action fails. A click or form
submission may already have happened; inspect its outcome before repeating it.
- Lightpanda's text-layout preview is not a rendered screenshot. It cannot prove
CSS, image, font, or visual-layout correctness.
- The verified snapshot path is AI format with `aria` references. The engine
selects those references by default, including efficient snapshot mode.
Explicit role references, selector/frame-scoped snapshots, labeled screenshots,
and the separate `aria` snapshot format are unsupported in this adapter.
- JavaScript and web APIs are not a guarantee that every website will work.
Verify the sites and interaction patterns you actually use.
The engine and its session model are documented in the
[pinned Lightpanda source](https://github.com/lightpanda-io/browser/tree/0.4.1).
See [browser profiles](/tools/browser/profiles) and
[remote browsers](/tools/browser/remote) for the shared profile and routing rules.
## Verification and benchmarks
Engine startup, CDP connectivity, task completion, and full OpenClaw integration
are separate checks. A running container or a successful `Browser.getVersion`
does not prove that snapshots, references, and actions work through OpenClaw.
Run the opt-in synthetic route benchmark from the repository root after
installing development dependencies:
```sh
node --import ./scripts/tsx.mjs extensions/browser/scripts/bench-lightweight.ts --lightpanda /path/to/lightpanda --chromium /path/to/chrome --iterations 10 --output lightweight-benchmark.json
```
Either binary flag can be used alone. The script creates isolated OpenClaw
state and browser data, serves a local form, then verifies navigation, the default
efficient AI snapshot, reference-based typing/clicking, exactly one form
submission, waiting, and text extraction through the browser route dispatcher.
The Chromium baseline uses OpenClaw's managed headless launch flags and disables
the sandbox for this isolated local fixture; it does not change production
browser configuration. Minimal Linux hosts still need Chromium's shared
libraries and fonts. A task-local installation can be selected using
`LD_LIBRARY_PATH` and `FONTCONFIG_FILE` without changing the host's packages.
Lightpanda additionally checks unsupported-operation rejection, its single-page
limit, and stale-target rejection after disconnecting. These checks do not use
an LLM and do not measure model reasoning or end-to-end agent token cost.
`--iterations` accepts 1 through 100 and counts **warm tasks**. A separate first
task includes the initial page open and CDP attachment; every warm task includes
navigation and the same form workflow. Native runs also report process startup
and time from startup through the first completed task. Warm percentiles exclude
the first task. Capability/session checks run after the measurement window.
A combined run uses one Node controller and records engine order; its later
engine can reuse controller modules already loaded by the earlier engine. The
first-task and process-start figures are not cold CLI/controller measurements.
Use separate invocations when comparing independently initialized controllers.
The memory fields are **maximum sampled process-tree PSS/RSS**, not true peaks.
They use Linux `/proc` with sampling attempts every 50 ms and at task boundaries.
Short-lived processes or transient allocations can be missed. Unsupported hosts
and externally managed engines report `null`, as do runs with unreadable process
memory, never a guessed engine-memory
figure. Controller RSS is a separate end-of-workload sample, not incremental
controller overhead; do not add independently sampled maxima and call the sum
total peak host memory.
### An externally managed engine
Use a dedicated engine instance. External mode closes the benchmark's control
connection and its own Chromium tab, but does not stop the engine process:
```sh
node --import ./scripts/tsx.mjs extensions/browser/scripts/bench-lightweight.ts --endpoint ws://127.0.0.1:9222 --engine lightpanda --fixture-bind 0.0.0.0 --fixture-host host.docker.internal --iterations 10 --output lightweight-container-benchmark.json
```
This example addresses a Docker Desktop engine from its host. The synthetic
fixture listener is explicitly exposed on the host so the container can reach
it; `--fixture-host` must name the controller from the browser's network, not
from the controller's own network. Linux Docker needs a reachable host address
or a configured `host-gateway` mapping. The default fixture listener/hostname
remain `127.0.0.1` when those flags are omitted.
External mode reports engine startup and memory as `null`. Capture the pinned
container/binary version separately with the report. A Windows Node controller
can use the same external-engine interface, but Windows Docker runtime behavior
has not been verified for this sample.
Compare the same deterministic tasks with a pinned Chromium baseline. Record
task completion before reporting speed or memory improvements; unsupported or
failed work must not be counted as a successful fast result. Report warm and
cold runs separately, engine versions, host OS/architecture, client overhead,
and whether memory includes the whole process tree or container.
For Docker Desktop, container memory does not include the VM's host overhead.
Do not compare a native-process RSS figure with a container-only figure and call
the difference total host savings. Keep all benchmark fixtures public or local;
do not export an existing logged-in browser profile to make a benchmark pass.
Platform support listed above describes upstream distribution and the deployment
topologies, not a claim that every platform has passed the same runtime tests.
Record actual platform and container-runtime results with the benchmark report.

View file

@ -0,0 +1,5 @@
/** Narrow source-mode surface for the opt-in synthetic browser benchmark. */
export { resolveBrowserConfig } from "./src/browser/config.js";
export { createBrowserRouteContext } from "./src/browser/server-context.js";
export { createBrowserRouteDispatcher } from "./src/browser/routes/dispatcher.js";
export { closePlaywrightBrowserConnection } from "./src/browser/pw-session.js";

View file

@ -0,0 +1,613 @@
/** 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 Run = { engine: Engine; executable: string } | { engine: Engine; endpoint: string };
const { values } = parseArgs({
options: {
lightpanda: { type: "string" },
chromium: { 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,
"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, executable] of [
["chromium", values.chromium],
["lightpanda", values.lightpanda],
] as const) {
if (executable) {
runs.push({ engine, executable: path.resolve(executable) });
}
}
}
assert(
runs.length > 0,
"Pass --lightpanda <binary>, --chromium <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, engine);
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",
});
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,
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),
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();