From e10a9573b995fee8b582dac0d5d0f5faec1767ff Mon Sep 17 00:00:00 2001 From: JB Date: Mon, 21 Sep 2026 13:25:44 -0400 Subject: [PATCH] test(fast-browser): add paired local browser performance benchmark --- .../fast-browser/fixtures/performance.html | 109 ++++ .../performance-benchmark.test.ts | 68 ++ .../fast-browser/performance-benchmark.ts | 601 ++++++++++++++++++ extensions/fast-browser/performance_probe.py | 119 ++++ 4 files changed, 897 insertions(+) create mode 100644 extensions/fast-browser/fixtures/performance.html create mode 100644 extensions/fast-browser/performance-benchmark.test.ts create mode 100644 extensions/fast-browser/performance-benchmark.ts create mode 100644 extensions/fast-browser/performance_probe.py diff --git a/extensions/fast-browser/fixtures/performance.html b/extensions/fast-browser/fixtures/performance.html new file mode 100644 index 0000000..d9e977e --- /dev/null +++ b/extensions/fast-browser/fixtures/performance.html @@ -0,0 +1,109 @@ + + + + + Fast-browser performance fixture + + + +
+

Local form validation

+

Fixture ready

+

Profile draft loaded

+ +

+
+
+ + + diff --git a/extensions/fast-browser/performance-benchmark.test.ts b/extensions/fast-browser/performance-benchmark.test.ts new file mode 100644 index 0000000..2982532 --- /dev/null +++ b/extensions/fast-browser/performance-benchmark.test.ts @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { mkdtemp, writeFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { + pairedArmOrder, + safeFailure, + summarizeBenchmarkAttempts, + validWorkerCounters, + summarizeTimings, + validateBenchmarkOptions, + type BenchmarkAttempt, +} from "./performance-benchmark.ts"; + +async function files(): Promise<{ root: string; paths: string[] }> { + const root = await mkdtemp(join("/tmp", "fast-browser-performance-test-")); + const paths = await Promise.all(["baseline.py", "optimized.py", "python", "chromium"].map(async (name) => { + const path = join(root, name); + await writeFile(path, "# test\n", { mode: 0o700 }); + return path; + })); + return { root, paths }; +} + +test("timing aggregation reports bounded p50 and p95 without wall-clock assumptions", () => { + assert.deepEqual(summarizeTimings([10, 20, 30, 40]), { count: 4, p50: 25, p95: 38.5 }); + assert.deepEqual(summarizeTimings([]), { count: 0, p50: null, p95: null }); +}); + +test("paired order alternates AB and BA while retaining failed attempts", () => { + assert.deepEqual(pairedArmOrder(1), ["baseline", "optimized"]); + assert.deepEqual(pairedArmOrder(2), ["optimized", "baseline"]); +}); + +test("attempt aggregation excludes failures from timing summaries but retains them for accounting", () => { + const attempts: BenchmarkAttempt[] = [ + { arm: "baseline", phase: "warm", sample: 1, status: "passed", elapsedMs: 10, startupMs: null }, + { arm: "baseline", phase: "warm", sample: 2, status: "failed", elapsedMs: null, startupMs: null, error: "worker failed" }, + { arm: "baseline", phase: "warm", sample: 3, status: "passed", elapsedMs: 30, startupMs: null }, + ]; + assert.deepEqual(summarizeBenchmarkAttempts(attempts, "baseline", "warm"), { count: 2, p50: 20, p95: 29 }); + assert.equal(attempts.filter((attempt) => attempt.status === "failed").length, 1); +}); + +test("benchmark input bounds reject unbounded samples and candidate sets", async () => { + const { root, paths } = await files(); + try { + assert.throws(() => validateBenchmarkOptions({ baselineWorkerPath: paths[0]!, optimizedWorkerPath: paths[1]!, pythonPath: paths[2]!, browserPath: paths[3]!, samples: 41 }), /samples/); + assert.throws(() => validateBenchmarkOptions({ baselineWorkerPath: paths[0]!, optimizedWorkerPath: paths[1]!, pythonPath: paths[2]!, browserPath: paths[3]!, candidateCounts: [1] }), /candidate counts/); + const valid = validateBenchmarkOptions({ baselineWorkerPath: paths[0]!, optimizedWorkerPath: paths[1]!, pythonPath: paths[2]!, browserPath: paths[3]!, samples: 1, coldSamples: 1, candidateCounts: [0, 10, 60] }); + assert.equal(valid.samples, 1); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("missing or malformed worker counters are unavailable", () => { + assert.equal(validWorkerCounters({ cdp: { "Page.enable": 1 }, ipc: { _send: 2 } }), true); + assert.equal(validWorkerCounters({ cdp: { "Page.enable": -1 }, ipc: { _send: 2 } }), false); + assert.equal(validWorkerCounters({ cdp: { "Page.enable": 1 }, ipc: { "bad key": 2 } }), false); + assert.equal(validWorkerCounters(undefined), false); +}); + +test("report failures use fixed safe categories and never include raw error text", () => { + const safe = safeFailure("popup-close", "unverified"); + assert.equal(safe, "popup-close:unverified"); + assert.doesNotMatch(safe, /https?:|secret|Ada|tmp|performance/); +}); diff --git a/extensions/fast-browser/performance-benchmark.ts b/extensions/fast-browser/performance-benchmark.ts new file mode 100644 index 0000000..4d6d5db --- /dev/null +++ b/extensions/fast-browser/performance-benchmark.ts @@ -0,0 +1,601 @@ +import assert from "node:assert/strict"; +import { spawn as nodeSpawn, spawnSync } from "node:child_process"; +import { existsSync, readFileSync, statSync } from "node:fs"; +import { chmod, mkdir, mkdtemp, readFile, rm } from "node:fs/promises"; +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createBrowserTransport, type ChildProcessLike, type SpawnLike } from "./bridge.ts"; +import { createBrowserSessionManager, type BrowserSession, type BrowserSessionManager } from "./browser-session.ts"; +import type { BrowserRuntime, BrowserTransportFactory, Candidate, PrivateArtifactStore } from "./contracts.ts"; +import { createBrowserRuntimeFactory } from "./runtime.ts"; + +const ROOT = dirname(fileURLToPath(import.meta.url)); +const CURRENT_WORKER = join(ROOT, "browser_worker.py"); +const PROBE = join(ROOT, "performance_probe.py"); +const FIXTURE = join(ROOT, "fixtures", "performance.html"); +const DEFAULT_PYTHON = join(process.cwd(), ".pi", "fast-browser-demo", "venv", "bin", "python"); +const DEFAULT_BROWSER = "/usr/bin/chromium"; +const DEFAULT_COUNTS = [0, 10, 60] as const; +const DEFAULT_SAMPLES = 20; +const DEFAULT_COLD_SAMPLES = 1; +const MAX_SAMPLES = 40; +const MAX_COLD_SAMPLES = 5; +const MAX_COUNTS = 3; + +type Arm = "baseline" | "optimized"; +type Phase = "warm" | "cold-start"; + +export interface PerformanceBenchmarkOptions { + baselineWorkerPath: string; + optimizedWorkerPath?: string; + pythonPath?: string; + browserPath?: string; + samples?: number; + coldSamples?: number; + candidateCounts?: readonly number[]; +} + +export interface TimingSummary { + count: number; + p50: number | null; + p95: number | null; +} + +export interface BenchmarkAttempt { + arm: Arm; + phase: Phase; + sample: number; + order?: 1 | 2; + status: "passed" | "failed"; + elapsedMs: number | null; + startupMs: number | null; + error?: string; + protocolOperations?: Record; + densityObserveMs?: Partial>; +} + +export interface WorkerMethodCounts { + cdp: Record; + ipc: Record; + cdpCalls: number; + ipcCalls: number; +} + +export interface BenchmarkReport { + ok: boolean; + measurement: { workerMethods: "selected Python worker CDP/IPC helper methods"; protocolOperations: "TypeScript JSONL operation names"; warmPriming: "one density=0 observe per arm, excluded from warm samples"; coldStartup: "browser session open plus first worker observe"; runtimeNarrowingMeasured: false }; + samples: number; + coldSamples: number; + candidateCounts: readonly number[]; + attempts: readonly BenchmarkAttempt[]; + warm: Record; + coldStartup: Record; + workerMethods: Partial>; + protocolOperations: Partial>>; + densityObserve: Record>; + pairedOrder: readonly (readonly [Arm, Arm])[]; + counterStatus: "complete" | "unavailable"; + cleanup: { status: "passed" | "failed"; error?: string; retainedRoot: boolean }; +} + +interface FixtureServer { + origin: string; + close(): Promise; +} + +interface ArmHandle { + arm: Arm; + session: BrowserSession; + runtime: BrowserRuntime; + protocol: Record; + countFile: string; + closed: boolean; +} + +const EMPTY_ARTIFACT_STORE: PrivateArtifactStore = { + async open() { + throw new Error("benchmark evidence is disabled"); + }, + async close() { /* no-op */ }, +}; + +type FailureStage = "cli" | "cold-start" | "warm-setup" | "warm-flow" | "density-observe" | "popup-close" | "counter" | "cleanup"; +type FailureCategory = "failed" | "unverified" | "unavailable"; + +export function safeFailure(stage: FailureStage, category: FailureCategory = "failed"): string { + return `${stage}:${category}`; +} + +class BenchmarkFailure extends Error { + readonly stage: FailureStage; + readonly category: FailureCategory; + + constructor(stage: FailureStage, category: FailureCategory) { + super(`${stage}:${category}`); + this.stage = stage; + this.category = category; + } +} + +function failureFor(error: unknown, fallback: FailureStage): string { + return error instanceof BenchmarkFailure ? safeFailure(error.stage, error.category) : safeFailure(fallback); +} + +function boundedInteger(value: unknown, label: string, min: number, max: number): number { + if (!Number.isSafeInteger(value) || (value as number) < min || (value as number) > max) { + throw new Error(`${label} must be an integer between ${min} and ${max}`); + } + return value as number; +} + +function requiredFile(value: string, label: string): string { + if (typeof value !== "string" || value.length === 0 || value.length > 4_000 || value.includes("\0")) { + throw new Error(`${label} is invalid`); + } + if (!existsSync(value) || !statSync(value).isFile()) throw new Error(`${label} is not a file`); + return value; +} + +export function validateBenchmarkOptions(options: PerformanceBenchmarkOptions): Required { + if (!options || typeof options !== "object") throw new Error("benchmark options are required"); + const baselineWorkerPath = requiredFile(options.baselineWorkerPath, "baseline worker path"); + const optimizedWorkerPath = requiredFile(options.optimizedWorkerPath ?? CURRENT_WORKER, "optimized worker path"); + const pythonPath = requiredFile(options.pythonPath ?? process.env.FAST_BROWSER_PYTHON ?? DEFAULT_PYTHON, "Python path"); + const browserPath = requiredFile(options.browserPath ?? DEFAULT_BROWSER, "browser path"); + const samples = boundedInteger(options.samples ?? DEFAULT_SAMPLES, "samples", 1, MAX_SAMPLES); + const coldSamples = boundedInteger(options.coldSamples ?? DEFAULT_COLD_SAMPLES, "cold samples", 1, MAX_COLD_SAMPLES); + const candidateCounts = [...(options.candidateCounts ?? DEFAULT_COUNTS)]; + if (candidateCounts.length < 1 || candidateCounts.length > MAX_COUNTS || candidateCounts.some((value) => ![0, 10, 60].includes(value))) { + throw new Error("candidate counts must be a non-empty subset of 0, 10, and 60"); + } + return { baselineWorkerPath, optimizedWorkerPath, pythonPath, browserPath, samples, coldSamples, candidateCounts }; +} + +function percentile(values: readonly number[], fraction: number): number | null { + if (values.length === 0) return null; + const sorted = [...values].sort((a, b) => a - b); + const position = (sorted.length - 1) * fraction; + const lower = Math.floor(position); + const upper = Math.ceil(position); + if (lower === upper) return sorted[lower]!; + return sorted[lower]! + (sorted[upper]! - sorted[lower]!) * (position - lower); +} + +export function summarizeTimings(values: readonly number[]): TimingSummary { + return { count: values.length, p50: percentile(values, 0.5), p95: percentile(values, 0.95) }; +} + +function increment(target: Record, key: string): void { + target[key] = (target[key] ?? 0) + 1; +} + +function countProtocol(child: ReturnType, target: Record): void { + const stdin = child.stdin; + if (!stdin) throw new Error("benchmark worker stdin is unavailable"); + const write = stdin.write.bind(stdin); + stdin.write = ((data: string | Uint8Array, ...args: unknown[]) => { + const text = typeof data === "string" ? data : Buffer.from(data).toString("utf8"); + for (const line of text.split("\n")) { + if (!line) continue; + try { + const wire = JSON.parse(line) as { operation?: { op?: unknown } }; + const op = wire.operation?.op; + if (typeof op === "string" && /^[A-Za-z][A-Za-z0-9]{0,40}$/.test(op)) increment(target, op); + } catch { /* bridge owns malformed-request handling */ } + } + return write(data, ...(args as [])); + }) as typeof stdin.write; +} + +function instrumentedSpawn( + workerPath: string, + countFile: string, + protocol: Record, +): SpawnLike { + return (command, args, options) => { + const child = nodeSpawn(command, [ + ...args, + "--performance-worker", workerPath, + "--performance-count-file", countFile, + ], options as never); + countProtocol(child, protocol); + return child as unknown as ChildProcessLike; + }; +} + +function createInstrumentedRuntime( + armWorkerPath: string, + countFile: string, + protocol: Record, + session: BrowserSession, + origin: string, + pythonPath: string, + signal: AbortSignal, +): BrowserRuntime { + const transportFactory: BrowserTransportFactory = { + create(options, parentSignal) { + return createBrowserTransport({ + ...options, + workerPath: PROBE, + spawn: instrumentedSpawn(armWorkerPath, countFile, protocol), + }, parentSignal); + }, + }; + return createBrowserRuntimeFactory(transportFactory).create({ + sessionId: session.id, + cdpUrl: session.config.cdpUrl, + allowedOrigins: [origin], + python: pythonPath, + artifacts: EMPTY_ARTIFACT_STORE, + }, signal); +} + +async function createFixtureServer(): Promise { + const template = readFileSync(FIXTURE, "utf8"); + const server = createServer((request: IncomingMessage, response: ServerResponse) => { + const path = new URL(request.url ?? "/", "http://127.0.0.1").pathname; + if (path !== "/performance.html") { + response.writeHead(404); + response.end(); + return; + } + response.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }); + response.end(template); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("performance fixture did not expose a port"); + return { + origin: `http://127.0.0.1:${address.port}`, + async close() { + server.closeAllConnections?.(); + await new Promise((resolve) => server.close(() => resolve())); + }, + }; +} + +function launchConfig(origin: string, url: string, browserPath: string): Parameters[0] { + return { + allowedOrigins: [origin], + browser: { mode: "launch", product: "chromium", executablePath: browserPath, headless: true, startUrl: url }, + }; +} + +function candidate(observation: Awaited>, name: string, action: "click" | "fill"): Candidate { + const matches = observation.candidates.filter((item) => item.name === name && item.actions.includes(action)); + assert.equal(matches.length, 1, `expected one ${action} candidate`); + return matches[0]!; +} + +async function runWarmFlow(handle: ArmHandle, origin: string, candidateCounts: readonly number[], signal: AbortSignal): Promise>> { + const densityObserveMs: Partial> = {}; + for (const count of candidateCounts) { + const density = await handle.runtime.navigate({ url: `${origin}/performance.html?mode=density&count=${count}` }, signal); + assert.equal(density.status, "committed"); + const observeStarted = Date.now(); + try { + const observation = await handle.runtime.observe(signal); + densityObserveMs[String(count) as "0" | "10" | "60"] = Date.now() - observeStarted; + assert.equal(observation.candidates.length, count); + } catch { + throw new BenchmarkFailure("density-observe", "failed"); + } + } + const page = await handle.runtime.navigate({ url: `${origin}/performance.html?mode=flow&count=10` }, signal); + assert.equal(page.status, "committed"); + const tabs = await handle.runtime.listTabs(signal); + const main = tabs.find((tab) => tab.selected); + assert(main, "main benchmark tab is unavailable"); + const mainTab = main; + + let observation = await handle.runtime.observe(signal); + const field = candidate(observation, "Display name", "fill"); + const filled = await handle.runtime.act({ observationId: observation.observationId, candidateId: field.id, action: "fill", text: "Ada Lovelace" }, signal); + assert.equal(filled.verified, true); + + observation = await handle.runtime.observe(signal); + const save = candidate(observation, "Save profile", "click"); + const saved = await handle.runtime.act({ observationId: observation.observationId, candidateId: save.id, action: "click", postcondition: { kind: "text", value: "Profile saved" } }, signal); + assert.equal(saved.verified, true); + const saveAssert = await handle.runtime.assert({ assertion: { kind: "visibleText", match: "contains", value: "Profile saved" }, wait: "bounded" }, signal); + assert.equal(saveAssert.status, "satisfied"); + + observation = await handle.runtime.observe(signal); + const previewButton = candidate(observation, "Preview profile", "click"); + const previewRequested = await handle.runtime.act({ observationId: observation.observationId, candidateId: previewButton.id, action: "click", postcondition: { kind: "text", value: "Preview ready" } }, signal); + assert.equal(previewRequested.verified, true); + + observation = await handle.runtime.observe(signal); + const immediate = candidate(observation, "Immediate action", "click"); + const clicked = await handle.runtime.act({ observationId: observation.observationId, candidateId: immediate.id, action: "click", postcondition: { kind: "text", value: "Immediate complete" } }, signal); + assert.equal(clicked.verified, true); + const immediateAssert = await handle.runtime.assert({ assertion: { kind: "visibleText", match: "contains", value: "Immediate complete" }, wait: "none" }, signal); + assert.equal(immediateAssert.status, "satisfied"); + + observation = await handle.runtime.observe(signal); + const delayed = candidate(observation, "Delayed action", "click"); + const delayedResult = await handle.runtime.act({ observationId: observation.observationId, candidateId: delayed.id, action: "click", postcondition: { kind: "text", value: "Delayed complete" } }, signal); + assert.equal(delayedResult.verified, true); + const delayedAssert = await handle.runtime.assert({ assertion: { kind: "visibleText", match: "contains", value: "Delayed complete" }, wait: "bounded" }, signal); + assert.equal(delayedAssert.status, "satisfied"); + + observation = await handle.runtime.observe(signal); + const popupButton = candidate(observation, "Open popup", "click"); + const popupRequest = await handle.runtime.act({ observationId: observation.observationId, candidateId: popupButton.id, action: "click", postcondition: { kind: "text", value: "Popup requested" } }, signal); + assert.equal(popupRequest.verified, true); + const popup = await handle.runtime.waitPopup({ openerTabId: mainTab.tabId }, signal); + assert.equal(popup.status, "found"); + assert(popup.tab); + const popupTab = popup.tab; + const popupRead = await handle.runtime.read({ tabId: popupTab.tabId }, signal); + assert.match(popupRead.visibleText, /Popup ready/); + const popupReady = await handle.runtime.assert({ tabId: popupTab.tabId, assertion: { kind: "visibleText", match: "contains", value: "Popup ready" }, wait: "bounded" }, signal); + assert.equal(popupReady.status, "satisfied"); + try { + await handle.runtime.closeTab({ tabId: popupTab.tabId }, signal); + } catch { + throw new BenchmarkFailure("popup-close", "unverified"); + } + const preview = await handle.runtime.assert({ assertion: { kind: "visibleText", match: "contains", value: "Preview ready: Ada Lovelace" }, wait: "bounded" }, signal); + assert.equal(preview.status, "satisfied"); + return densityObserveMs; +} + +function validCounterMap(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value) + && Object.entries(value).every(([key, count]) => /^[A-Za-z0-9_.-]{1,120}$/.test(key) && Number.isSafeInteger(count) && count >= 0); +} + +export function validWorkerCounters(value: unknown): value is { cdp: Record; ipc: Record } { + if (!value || typeof value !== "object") return false; + const counters = value as { cdp?: unknown; ipc?: unknown }; + return validCounterMap(counters.cdp) && validCounterMap(counters.ipc); +} + +async function readWorkerCounts(path: string): Promise { + try { + const value = JSON.parse(await readFile(path, "utf8")) as { cdp?: unknown; ipc?: unknown }; + if (!validWorkerCounters(value)) return undefined; + const cdp = value.cdp; + const ipc = value.ipc; + return { + cdp, + ipc, + cdpCalls: Object.values(cdp).reduce((sum, count) => sum + count, 0), + ipcCalls: Object.values(ipc).reduce((sum, count) => sum + count, 0), + }; + } catch { + return undefined; + } +} + +function ownedProcessLines(root: string): string[] | undefined { + const result = spawnSync("ps", ["-eo", "pid=,args="], { encoding: "utf8", timeout: 5_000 }); + if (result.status !== 0) return undefined; + return String(result.stdout).split(/\r?\n/).filter((line) => line.includes(root)); +} + +function snapshotCounts(value: Record): Record { + return { ...value }; +} + +function countDelta(before: Record, after: Record): Record { + const result: Record = {}; + for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) { + const difference = (after[key] ?? 0) - (before[key] ?? 0); + if (difference > 0) result[key] = difference; + } + return result; +} + +async function closeHandle(handle: ArmHandle | undefined): Promise { + if (!handle || handle.closed) return undefined; + handle.closed = true; + try { + await handle.runtime.close(); + return undefined; + } catch { + return safeFailure("cleanup"); + } +} + +export function pairedArmOrder(sample: number): readonly [Arm, Arm] { + return sample % 2 === 1 ? ["baseline", "optimized"] : ["optimized", "baseline"]; +} + +function attemptsFor(attempts: readonly BenchmarkAttempt[], arm: Arm, phase: Phase): number[] { + return attempts.filter((item) => item.arm === arm && item.phase === phase && item.status === "passed" && item.elapsedMs !== null).map((item) => item.elapsedMs!); +} + +export function summarizeBenchmarkAttempts(attempts: readonly BenchmarkAttempt[], arm: Arm, phase: Phase): TimingSummary { + return summarizeTimings(attemptsFor(attempts, arm, phase)); +} + +function densitySummaries(attempts: readonly BenchmarkAttempt[]): Record> { + const result = {} as Record>; + for (const arm of ["baseline", "optimized"] as const) { + result[arm] = {} as Record<"0" | "10" | "60", TimingSummary>; + for (const count of ["0", "10", "60"] as const) { + result[arm][count] = summarizeTimings(attempts + .filter((attempt) => attempt.arm === arm && attempt.phase === "warm" && attempt.status === "passed" && attempt.densityObserveMs?.[count] !== undefined) + .map((attempt) => attempt.densityObserveMs![count]!)); + } + } + return result; +} + +export async function runPerformanceBenchmark(options: PerformanceBenchmarkOptions): Promise { + const config = validateBenchmarkOptions(options); + const fixture = await createFixtureServer(); + const root = await mkdtemp(join(tmpdir(), "fast-browser-performance-")); + await mkdir(join(root, "leases"), { recursive: true, mode: 0o700 }); + await chmod(join(root, "leases"), 0o700); + const manager = createBrowserSessionManager({ profileDirectory: join(root, "profiles"), leaseDirectory: join(root, "leases"), startupTimeoutMs: 20_000, closeTimeoutMs: 4_000 }); + const controller = new AbortController(); + const attempts: BenchmarkAttempt[] = []; + const workerMethods: Partial> = {}; + const protocolOperations: Partial>> = {}; + const handles: Partial> = {}; + const setupErrors: Partial> = {}; + const pairedOrder: Array = []; + let cleanupError: string | undefined; + let retainedRoot = false; + let counterUnavailable = false; + try { + const armPaths: Record = { baseline: config.baselineWorkerPath, optimized: config.optimizedWorkerPath }; + for (const arm of ["baseline", "optimized"] as const) { + for (let sample = 1; sample <= config.coldSamples; sample += 1) { + const countFile = join(root, `${arm}-cold-${sample}.json`); + const protocol: Record = {}; + let session: BrowserSession | undefined; + let runtime: BrowserRuntime | undefined; + const started = Date.now(); + let startupMs: number | null = null; + try { + const opened = Date.now(); + session = await manager.open(launchConfig(fixture.origin, `${fixture.origin}/performance.html?mode=density&count=10`, config.browserPath), controller.signal); + const first = Date.now(); + runtime = createInstrumentedRuntime(armPaths[arm], countFile, protocol, session, fixture.origin, config.pythonPath, controller.signal); + const observation = await runtime.observe(controller.signal); + assert.equal(observation.candidates.length, 10); + startupMs = first - opened + (Date.now() - first); + attempts.push({ arm, phase: "cold-start", sample, status: "passed", elapsedMs: Date.now() - started, startupMs, protocolOperations: { ...protocol } }); + } catch (error) { + attempts.push({ arm, phase: "cold-start", sample, status: "failed", elapsedMs: null, startupMs, error: safeFailure("cold-start"), protocolOperations: { ...protocol } }); + } finally { + try { await runtime?.close(); } catch { cleanupError ??= safeFailure("cleanup"); } + if (session) await manager.close(session.id).catch(() => { cleanupError ??= safeFailure("cleanup"); }); + const counts = await readWorkerCounts(countFile); + if (counts) workerMethods[arm] = counts; + else counterUnavailable = true; + } + } + try { + const countFile = join(root, `${arm}-warm.json`); + const protocol: Record = {}; + const session = await manager.open(launchConfig(fixture.origin, `${fixture.origin}/performance.html?mode=density&count=0`, config.browserPath), controller.signal); + const runtime = createInstrumentedRuntime(armPaths[arm], countFile, protocol, session, fixture.origin, config.pythonPath, controller.signal); + const handle: ArmHandle = { arm, session, runtime, protocol, countFile, closed: false }; + handles[arm] = handle; + await runtime.observe(controller.signal); // prime the persistent worker; excluded from warm samples + } catch (error) { + const failedHandle = handles[arm]; + if (failedHandle) { + await closeHandle(failedHandle); + await manager.close(failedHandle.session.id).catch(() => {}); + } + delete handles[arm]; + setupErrors[arm] = safeFailure("warm-setup"); + } + } + + for (let sample = 1; sample <= config.samples; sample += 1) { + const order = pairedArmOrder(sample); + pairedOrder.push(order); + for (let position = 0; position < order.length; position += 1) { + const arm = order[position]!; + const handle = handles[arm]; + const before = handle ? snapshotCounts(handle.protocol) : {}; + const started = Date.now(); + try { + if (!handle) throw new Error("arm setup failed"); + const densityObserveMs = await runWarmFlow(handle, fixture.origin, config.candidateCounts, controller.signal); + attempts.push({ arm, phase: "warm", sample, order: (position + 1) as 1 | 2, status: "passed", elapsedMs: Date.now() - started, startupMs: null, protocolOperations: countDelta(before, handle.protocol), densityObserveMs }); + } catch (error) { + attempts.push({ arm, phase: "warm", sample, order: (position + 1) as 1 | 2, status: "failed", elapsedMs: null, startupMs: null, error: handle ? failureFor(error, "warm-flow") : (setupErrors[arm] ?? safeFailure("warm-setup")), protocolOperations: handle ? countDelta(before, handle.protocol) : {} }); + } + } + } + } finally { + for (const arm of ["baseline", "optimized"] as const) { + const handle = handles[arm]; + const error = await closeHandle(handle); + if (error) cleanupError ??= error; + if (handle) { + const counts = await readWorkerCounts(handle.countFile); + if (counts) workerMethods[arm] = counts; + else counterUnavailable = true; + protocolOperations[arm] = { ...handle.protocol }; + } + } + await manager.closeAll().catch(() => { cleanupError ??= safeFailure("cleanup"); }); + await fixture.close().catch(() => { cleanupError ??= safeFailure("cleanup"); }); + const leftovers = ownedProcessLines(root); + if (leftovers === undefined || leftovers.length > 0) { + cleanupError ??= safeFailure("cleanup", "unavailable"); + retainedRoot = true; + } + if (!cleanupError) { + await rm(root, { recursive: true, force: true }).catch(() => { + cleanupError ??= safeFailure("cleanup"); + retainedRoot = true; + }); + } else { + retainedRoot = true; + } + } + const warm: Record = { + baseline: summarizeBenchmarkAttempts(attempts, "baseline", "warm"), + optimized: summarizeBenchmarkAttempts(attempts, "optimized", "warm"), + }; + const coldStartup: Record = { + baseline: summarizeTimings(attempts.filter((item) => item.arm === "baseline" && item.phase === "cold-start" && item.status === "passed" && item.startupMs !== null).map((item) => item.startupMs!)), + optimized: summarizeTimings(attempts.filter((item) => item.arm === "optimized" && item.phase === "cold-start" && item.status === "passed" && item.startupMs !== null).map((item) => item.startupMs!)), + }; + return { + ok: !cleanupError && !counterUnavailable && workerMethods.baseline !== undefined && workerMethods.optimized !== undefined && attempts.length === config.samples * 2 + config.coldSamples * 2 && attempts.every((item) => item.status === "passed"), + measurement: { workerMethods: "selected Python worker CDP/IPC helper methods", protocolOperations: "TypeScript JSONL operation names", warmPriming: "one density=0 observe per arm, excluded from warm samples", coldStartup: "browser session open plus first worker observe", runtimeNarrowingMeasured: false }, + samples: config.samples, + coldSamples: config.coldSamples, + candidateCounts: config.candidateCounts, + attempts, + warm, + coldStartup, + workerMethods, + protocolOperations, + densityObserve: densitySummaries(attempts), + pairedOrder, + counterStatus: !counterUnavailable && workerMethods.baseline && workerMethods.optimized ? "complete" : "unavailable", + cleanup: cleanupError ? { status: "failed", error: cleanupError, retainedRoot } : { status: "passed", retainedRoot: false }, + }; +} + +function parseArgs(argv: readonly string[]): PerformanceBenchmarkOptions & { help?: boolean } { + const result: Record = {}; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]!; + if (arg === "--help" || arg === "-h") return { baselineWorkerPath: "", help: true }; + const values: Record = { "--baseline-worker": "baselineWorkerPath", "--optimized-worker": "optimizedWorkerPath", "--python": "pythonPath", "--browser": "browserPath", "--samples": "samples", "--cold-samples": "coldSamples", "--counts": "candidateCounts" }; + const key = values[arg]; + if (!key || index + 1 >= argv.length) throw new Error(`unknown or incomplete option: ${arg}`); + const value = argv[++index]!; + result[key] = key === "samples" || key === "coldSamples" ? Number(value) : key === "candidateCounts" ? value.split(",").map(Number) : value; + } + if (typeof result.baselineWorkerPath !== "string") throw new Error("--baseline-worker is required"); + return result as unknown as PerformanceBenchmarkOptions; +} + +function printReport(report: BenchmarkReport): void { + console.log(JSON.stringify(report, null, 2)); +} + +if (import.meta.main) { + try { + const parsed = parseArgs(process.argv.slice(2)); + if (parsed.help) { + console.log("usage: node --experimental-strip-types performance-benchmark.ts --baseline-worker PATH [--optimized-worker PATH] [--samples N] [--cold-samples N] [--counts 0,10,60]"); + } else { + const report = await runPerformanceBenchmark(parsed); + printReport(report); + if (!report.ok) process.exitCode = 1; + } + } catch (error) { + console.error(safeFailure("cli")); + process.exitCode = 1; + } +} diff --git a/extensions/fast-browser/performance_probe.py b/extensions/fast-browser/performance_probe.py new file mode 100644 index 0000000..fe465fb --- /dev/null +++ b/extensions/fast-browser/performance_probe.py @@ -0,0 +1,119 @@ +"""Private benchmark wrapper for counting browser-harness calls. + +The wrapper is deliberately a transport-only probe. It records method names and +counts, never CDP arguments, URLs, DOM values, page text, or screenshots. The +selected worker is executed unchanged after the helper module is wrapped. +""" +from __future__ import annotations + +import importlib +import json +import os +from pathlib import Path +import runpy +import sys +import tempfile +from collections import Counter +from typing import Any + + +def _take_flag(argv: list[str], flag: str) -> tuple[str | None, list[str]]: + remaining: list[str] = [] + value: str | None = None + index = 0 + while index < len(argv): + item = argv[index] + if item == flag: + if value is not None or index + 1 >= len(argv): + raise ValueError(f"{flag} must be supplied once") + value = argv[index + 1] + index += 2 + continue + remaining.append(item) + index += 1 + return value, remaining + + +def _method_name(value: Any) -> str: + if isinstance(value, str) and value and len(value) <= 120 and all( + character.isalnum() or character in "._-" for character in value + ): + return value + return "unknown" + + +def _wrap_helpers(counts: dict[str, Counter[str]] | None = None) -> dict[str, Counter[str]]: + helpers = importlib.import_module("browser_harness.helpers") + counts = counts or {"cdp": Counter(), "ipc": Counter()} + + original_cdp = getattr(helpers, "cdp", None) + if callable(original_cdp): + def counted_cdp(*args: Any, **kwargs: Any) -> Any: + method = _method_name(args[0] if args else None) + counts["cdp"][method] += 1 + return original_cdp(*args, **kwargs) + helpers.cdp = counted_cdp + + original_send = getattr(helpers, "_send", None) + if callable(original_send): + def counted_send(*args: Any, **kwargs: Any) -> Any: + # Do not inspect the request. The IPC method is intentionally the + # fixed helper method name rather than an untrusted operation value. + counts["ipc"]["_send"] += 1 + return original_send(*args, **kwargs) + helpers._send = counted_send + return counts + + +def _write_counts(path: str, counts: dict[str, Counter[str]]) -> None: + payload = { + "cdp": dict(sorted(counts["cdp"].items())), + "ipc": dict(sorted(counts["ipc"].items())), + } + destination = Path(path) + destination.parent.mkdir(parents=True, exist_ok=True) + fd, temporary = tempfile.mkstemp(prefix="performance-count-", dir=str(destination.parent)) + try: + with os.fdopen(fd, "w", encoding="utf-8") as stream: + json.dump(payload, stream, separators=(",", ":"), sort_keys=True) + stream.write("\n") + os.chmod(temporary, 0o600) + os.replace(temporary, destination) + os.chmod(destination, 0o600) + finally: + try: + os.unlink(temporary) + except FileNotFoundError: + pass + + +def main(argv: list[str] | None = None) -> int: + raw = list(sys.argv[1:] if argv is None else argv) + worker_path, raw = _take_flag(raw, "--performance-worker") + count_path, worker_argv = _take_flag(raw, "--performance-count-file") + if not worker_path or not count_path: + raise SystemExit("performance_probe requires --performance-worker and --performance-count-file") + if not Path(worker_path).is_file(): + raise SystemExit("performance worker path is not a file") + counts: dict[str, Counter[str]] = {"cdp": Counter(), "ipc": Counter()} + try: + sys.argv = [worker_path, *worker_argv] + namespace = runpy.run_path(worker_path, run_name="performance_worker") + backend = namespace.get("RealBrowserBackend") + main_function = namespace.get("main") + if backend is None or not callable(main_function): + raise SystemExit("performance worker does not expose its expected entry points") + original_load = backend._load_harness + def load_and_probe(instance: Any) -> Any: + result = original_load(instance) + _wrap_helpers(counts) + return result + backend._load_harness = load_and_probe + main_function(worker_argv) + finally: + _write_counts(count_path, counts) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())