mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 09:39:25 +00:00
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:
parent
a479b6c4b4
commit
645f1bc4ef
9 changed files with 894 additions and 0 deletions
|
|
@ -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.
|
||||
|
|
|
|||
4
deploy/lightpanda/SHA256SUMS
Normal file
4
deploy/lightpanda/SHA256SUMS
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
664775c7f5ab69cc3189954c7f9345e25c167cb4dace016173e629f9a5e82c42 lightpanda-aarch64-linux
|
||||
99e67739ed8cf5b985af7cbfa7c76b2bab257b171b2dad21109bd74b4f3bb510 lightpanda-aarch64-macos
|
||||
1d40801e72c0bc61b2cbd3f3562bcfc46de7b79e0568f33f686b64f2e587610a lightpanda-x86_64-linux
|
||||
9f8ed2787476e39e9c8ba4890a4971391fb7098bbe6384350b21a3649b485eec lightpanda-x86_64-macos
|
||||
4
deploy/lightpanda/compose.host.yaml
Normal file
4
deploy/lightpanda/compose.host.yaml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
services:
|
||||
lightpanda:
|
||||
ports:
|
||||
- "127.0.0.1:${LIGHTPANDA_PORT:-9222}:9222"
|
||||
16
deploy/lightpanda/compose.yaml
Normal file
16
deploy/lightpanda/compose.yaml
Normal 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
|
||||
|
|
@ -3891,6 +3891,10 @@
|
|||
"source": "Remote and hosted browsers",
|
||||
"target": "远程与托管浏览器"
|
||||
},
|
||||
{
|
||||
"source": "Lightweight browsers",
|
||||
"target": "轻量级浏览器"
|
||||
},
|
||||
{
|
||||
"source": "Browser security",
|
||||
"target": "浏览器安全"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
245
docs/tools/browser/lightweight.md
Normal file
245
docs/tools/browser/lightweight.md
Normal 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.
|
||||
5
extensions/browser/benchmark-api.ts
Normal file
5
extensions/browser/benchmark-api.ts
Normal 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";
|
||||
613
extensions/browser/scripts/bench-lightweight.ts
Normal file
613
extensions/browser/scripts/bench-lightweight.ts
Normal 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();
|
||||
Loading…
Add table
Add a link
Reference in a new issue