f1f6516056
- Removed export leakage by demoting many helper and const symbols to module-local scope. - Renamed underscore-prefixed internals and cache fields, then updated related references and `satisfies never` checks. - Deleted obsolete logic branches and helpers, including harmony-stream interruption flow and unused benchmark runtime helpers. - Updated Biome config and manifests by broadening lint coverage and removing an unused `@napi-rs/cli` dev dependency. - Adjusted tests and utilities to use renamed test helpers and remove redundant private test-only helpers/locals.
176 lines
6.1 KiB
TypeScript
176 lines
6.1 KiB
TypeScript
import * as net from "node:net";
|
|
import { Process, ProcessStatus } from "@oh-my-pi/pi-natives";
|
|
import type { Browser, Page } from "puppeteer-core";
|
|
import { ToolError, throwIfAborted } from "../tool-errors";
|
|
|
|
const ATTACH_TARGET_SKIP_PATTERN =
|
|
/request[\s_-]?handler|devtools|background[\s_-]?(?:page|host)|service[\s_-]?worker/i;
|
|
|
|
/**
|
|
* Allocate an unused TCP port on 127.0.0.1 by binding to port 0 and reading
|
|
* back the kernel-assigned port. There's a small race between close and the
|
|
* subsequent bind in the launched app, but Chromium's listener will retry.
|
|
*/
|
|
export async function findFreeCdpPort(): Promise<number> {
|
|
const { promise, resolve, reject } = Promise.withResolvers<number>();
|
|
const server = net.createServer();
|
|
server.unref();
|
|
server.once("error", reject);
|
|
server.listen(0, "127.0.0.1", () => {
|
|
const addr = server.address();
|
|
if (addr && typeof addr === "object" && typeof addr.port === "number") {
|
|
const port = addr.port;
|
|
server.close(closeErr => (closeErr ? reject(closeErr) : resolve(port)));
|
|
} else {
|
|
server.close();
|
|
reject(new Error("Failed to allocate ephemeral CDP port"));
|
|
}
|
|
});
|
|
return promise;
|
|
}
|
|
|
|
/** Poll `${cdpUrl}/json/version` until it responds with 200, with abort + timeout support. */
|
|
export async function waitForCdp(cdpUrl: string, timeoutMs: number, signal?: AbortSignal): Promise<void> {
|
|
const deadline = Date.now() + timeoutMs;
|
|
let lastErr: unknown;
|
|
const probeUrl = `${cdpUrl.replace(/\/+$/, "")}/json/version`;
|
|
while (Date.now() < deadline) {
|
|
throwIfAborted(signal);
|
|
const probeTimeout = AbortSignal.timeout(2000);
|
|
const probeSignal = signal ? AbortSignal.any([signal, probeTimeout]) : probeTimeout;
|
|
try {
|
|
const res = await fetch(probeUrl, { signal: probeSignal });
|
|
if (res.ok) {
|
|
await res.body?.cancel();
|
|
return;
|
|
}
|
|
lastErr = new Error(`HTTP ${res.status}`);
|
|
await res.body?.cancel();
|
|
} catch (err) {
|
|
if (signal?.aborted) throwIfAborted(signal);
|
|
lastErr = err;
|
|
}
|
|
await Bun.sleep(150);
|
|
}
|
|
throw new ToolError(
|
|
`Timed out waiting for CDP endpoint ${cdpUrl}${lastErr instanceof Error ? `: ${lastErr.message}` : ""}`,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Pull a `--remote-debugging-port=<n>` value out of an argv array (Chromium
|
|
* accepts both `--flag=value` and `--flag value`). Returns null if absent or
|
|
* malformed.
|
|
*/
|
|
function findCdpPortInArgs(args: string[]): number | null {
|
|
for (const arg of args) {
|
|
const m = /^--remote-debugging-port=(\d+)$/.exec(arg);
|
|
if (m) {
|
|
const port = Number.parseInt(m[1]!, 10);
|
|
if (Number.isFinite(port) && port > 0) return port;
|
|
}
|
|
}
|
|
for (let i = 0; i < args.length - 1; i++) {
|
|
if (args[i] === "--remote-debugging-port") {
|
|
const port = Number.parseInt(args[i + 1]!, 10);
|
|
if (Number.isFinite(port) && port > 0) return port;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** One-shot probe: returns true when `/json/version` answers 200 within the timeout. */
|
|
async function probeCdpAt(port: number, signal?: AbortSignal): Promise<boolean> {
|
|
const probeTimeout = AbortSignal.timeout(1500);
|
|
const probeSignal = signal ? AbortSignal.any([signal, probeTimeout]) : probeTimeout;
|
|
try {
|
|
const res = await fetch(`http://127.0.0.1:${port}/json/version`, { signal: probeSignal });
|
|
await res.body?.cancel();
|
|
return res.ok;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* If any running instance of `exe` was launched with `--remote-debugging-port`
|
|
* and that endpoint actually answers, return it so attach can reuse it instead
|
|
* of killing and respawning. Idempotent re-attaches are the common case.
|
|
*/
|
|
export async function findReusableCdp(
|
|
exe: string,
|
|
signal?: AbortSignal,
|
|
): Promise<{ cdpUrl: string; pid: number } | null> {
|
|
const candidates = Process.fromPath(exe).filter(p => p.status() === ProcessStatus.Running);
|
|
for (const proc of candidates) {
|
|
let args: string[];
|
|
try {
|
|
args = proc.args();
|
|
} catch {
|
|
continue;
|
|
}
|
|
const port = findCdpPortInArgs(args);
|
|
if (port === null) continue;
|
|
if (await probeCdpAt(port, signal)) {
|
|
return { cdpUrl: `http://127.0.0.1:${port}`, pid: proc.pid };
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Pick the best page target on an attached browser. Without a matcher, prefer
|
|
* a page that doesn't look like a helper window (devtools, request handler,
|
|
* background pages); with a matcher, return the first url+title substring hit.
|
|
*/
|
|
export async function pickElectronTarget(browser: Browser, matcher?: string): Promise<Page> {
|
|
const pages = await browser.pages();
|
|
if (!pages.length) {
|
|
throw new ToolError("No page targets available on the attached browser");
|
|
}
|
|
const enriched = await Promise.all(
|
|
pages.map(async page => ({
|
|
page,
|
|
url: page.url(),
|
|
title: ((await page.title().catch(() => "")) ?? "").trim(),
|
|
})),
|
|
);
|
|
if (matcher) {
|
|
const needle = matcher.toLowerCase();
|
|
const hit = enriched.find(p => p.url.toLowerCase().includes(needle) || p.title.toLowerCase().includes(needle));
|
|
if (hit) return hit.page;
|
|
const summary = enriched.map(p => `- ${p.title || "(untitled)"} ${p.url}`).join("\n");
|
|
throw new ToolError(`No page target matched ${JSON.stringify(matcher)}. Available pages:\n${summary}`);
|
|
}
|
|
return (
|
|
enriched.find(p => !ATTACH_TARGET_SKIP_PATTERN.test(p.url) && !ATTACH_TARGET_SKIP_PATTERN.test(p.title))?.page ??
|
|
enriched[0]!.page
|
|
);
|
|
}
|
|
|
|
/**
|
|
* SIGTERM the process tree, wait briefly, then SIGKILL anything still alive.
|
|
* Single-process variant for our own spawned children.
|
|
*/
|
|
export async function gracefulKillTreeOnce(pid: number, gracePeriodMs = 2000): Promise<void> {
|
|
const process = Process.fromPid(pid);
|
|
if (!process) return;
|
|
await process.terminate({ gracefulMs: gracePeriodMs, timeoutMs: 500 });
|
|
}
|
|
|
|
/**
|
|
* Multi-process variant for attach: find every PID running `executablePath`
|
|
* (single-instance apps may keep an orphan around) and tear them all down.
|
|
*/
|
|
export async function killExistingByPath(executablePath: string, signal?: AbortSignal): Promise<number> {
|
|
const processes = Process.fromPath(executablePath);
|
|
if (!processes.length) return 0;
|
|
const results = await Promise.all(
|
|
processes.map(async process => {
|
|
throwIfAborted(signal);
|
|
return await process.terminate({ gracefulMs: 3000, timeoutMs: 1000 });
|
|
}),
|
|
);
|
|
return results.length;
|
|
}
|