602 lines
28 KiB
TypeScript
602 lines
28 KiB
TypeScript
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<string, number>;
|
|
densityObserveMs?: Partial<Record<"0" | "10" | "60", number>>;
|
|
}
|
|
|
|
export interface WorkerMethodCounts {
|
|
cdp: Record<string, number>;
|
|
ipc: Record<string, number>;
|
|
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<Arm, TimingSummary>;
|
|
coldStartup: Record<Arm, TimingSummary>;
|
|
workerMethods: Partial<Record<Arm, WorkerMethodCounts>>;
|
|
protocolOperations: Partial<Record<Arm, Record<string, number>>>;
|
|
densityObserve: Record<Arm, Record<"0" | "10" | "60", TimingSummary>>;
|
|
pairedOrder: readonly (readonly [Arm, Arm])[];
|
|
counterStatus: "complete" | "unavailable";
|
|
cleanup: { status: "passed" | "failed"; error?: string; retainedRoot: boolean };
|
|
}
|
|
|
|
interface FixtureServer {
|
|
origin: string;
|
|
close(): Promise<void>;
|
|
}
|
|
|
|
interface ArmHandle {
|
|
arm: Arm;
|
|
session: BrowserSession;
|
|
runtime: BrowserRuntime;
|
|
protocol: Record<string, number>;
|
|
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<PerformanceBenchmarkOptions> {
|
|
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<string, number>, key: string): void {
|
|
target[key] = (target[key] ?? 0) + 1;
|
|
}
|
|
|
|
function countProtocol(child: ReturnType<typeof nodeSpawn>, target: Record<string, number>): 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<string, number>,
|
|
): 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<string, number>,
|
|
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<FixtureServer> {
|
|
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<void>((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<void>((resolve) => server.close(() => resolve()));
|
|
},
|
|
};
|
|
}
|
|
|
|
function launchConfig(origin: string, url: string, browserPath: string): Parameters<BrowserSessionManager["open"]>[0] {
|
|
return {
|
|
allowedOrigins: [origin],
|
|
browser: { mode: "launch", product: "chromium", executablePath: browserPath, headless: true, startUrl: url },
|
|
};
|
|
}
|
|
|
|
function candidate(observation: Awaited<ReturnType<BrowserRuntime["observe"]>>, 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<Partial<Record<"0" | "10" | "60", number>>> {
|
|
const densityObserveMs: Partial<Record<"0" | "10" | "60", number>> = {};
|
|
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<string, number> {
|
|
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<string, number>; ipc: Record<string, number> } {
|
|
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<WorkerMethodCounts | undefined> {
|
|
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<string, number>): Record<string, number> {
|
|
return { ...value };
|
|
}
|
|
|
|
function countDelta(before: Record<string, number>, after: Record<string, number>): Record<string, number> {
|
|
const result: Record<string, number> = {};
|
|
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<string | undefined> {
|
|
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<Arm, Record<"0" | "10" | "60", TimingSummary>> {
|
|
const result = {} as Record<Arm, Record<"0" | "10" | "60", TimingSummary>>;
|
|
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<BenchmarkReport> {
|
|
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<Record<Arm, WorkerMethodCounts>> = {};
|
|
const protocolOperations: Partial<Record<Arm, Record<string, number>>> = {};
|
|
const handles: Partial<Record<Arm, ArmHandle>> = {};
|
|
const setupErrors: Partial<Record<Arm, string>> = {};
|
|
const pairedOrder: Array<readonly [Arm, Arm]> = [];
|
|
let cleanupError: string | undefined;
|
|
let retainedRoot = false;
|
|
let counterUnavailable = false;
|
|
try {
|
|
const armPaths: Record<Arm, string> = { 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<string, number> = {};
|
|
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<string, number> = {};
|
|
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<Arm, TimingSummary> = {
|
|
baseline: summarizeBenchmarkAttempts(attempts, "baseline", "warm"),
|
|
optimized: summarizeBenchmarkAttempts(attempts, "optimized", "warm"),
|
|
};
|
|
const coldStartup: Record<Arm, TimingSummary> = {
|
|
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<string, unknown> = {};
|
|
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<string, string> = { "--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;
|
|
}
|
|
}
|