Two termination boundaries in the browser tool leaked browser-owned OS resources into the long-lived coding-agent process. 1. Aborted 'open' published an orphan. #open wrapped acquisition in untilAborted, which rejects its outer wrapper on abort but lets the inner launch resolve in the background; acquireBrowser then unconditionally stored the resolved handle in the module-global browsers map. releaseAllTabs walks tabs, not browsers, so the refCount:0 handle stayed alive to process exit. 2. Session dispose had no browser teardown. Browser/tab state lives in module-global maps, and AgentSession.dispose() had no hook to walk them, so headless/spawned Chromium the session opened survived it. acquireBrowser now short-circuits before launch on a pre-aborted signal and disposes the handle when the launch completes after abort. TabSession records the creating session's id (opts.ownerSessionId, threaded through BrowserTool.#open), preserved across reuse so a subagent re-driving an existing tab does not yank teardown responsibility. AgentSession.dispose() invokes releaseTabsForOwner bounded by withTimeout(3s), mirroring the async-job/MCP disposal pattern. Regression tests exercise both boundaries via spied CmuxSocketClient (no real puppeteer/socket) and cover: pre-aborted open short-circuit, aborted-mid-launch cleanup, releaseTabsForOwner reaping only owned tabs, and reuse preserving original ownership. Fixes #3963
267 lines
8.5 KiB
TypeScript
267 lines
8.5 KiB
TypeScript
import * as path from "node:path";
|
|
import { logger } from "@oh-my-pi/pi-utils";
|
|
import type { Subprocess } from "bun";
|
|
import type { Browser, CDPSession } from "puppeteer-core";
|
|
import { ToolAbortError, ToolError } from "../tool-errors";
|
|
import { findFreeCdpPort, findReusableCdp, gracefulKillTreeOnce, killExistingByPath, waitForCdp } from "./attach";
|
|
import type { CmuxKind } from "./cmux/rpc";
|
|
import { CmuxSocketClient } from "./cmux/socket-client";
|
|
import { BROWSER_PROTOCOL_TIMEOUT_MS, launchHeadlessBrowser, loadPuppeteer, type UserAgentOverride } from "./launch";
|
|
|
|
export type PuppeteerBrowserKind =
|
|
| { kind: "headless"; headless: boolean }
|
|
| { kind: "spawned"; path: string }
|
|
| { kind: "connected"; cdpUrl: string };
|
|
|
|
export type BrowserKind = PuppeteerBrowserKind | CmuxKind;
|
|
|
|
export type BrowserKindTag = BrowserKind["kind"];
|
|
|
|
interface BrowserHandleCommon {
|
|
key: string;
|
|
kind: BrowserKind;
|
|
refCount: number;
|
|
}
|
|
|
|
export interface PuppeteerBrowserHandle extends BrowserHandleCommon {
|
|
kind: PuppeteerBrowserKind;
|
|
browser: Browser;
|
|
cdpUrl?: string;
|
|
pid?: number;
|
|
subprocess?: Subprocess;
|
|
stealth: { browserSession: CDPSession | null; override: UserAgentOverride | null };
|
|
}
|
|
|
|
export interface CmuxBrowserHandle extends BrowserHandleCommon {
|
|
kind: CmuxKind;
|
|
client: CmuxSocketClient;
|
|
surface?: string;
|
|
}
|
|
|
|
export type BrowserHandle = PuppeteerBrowserHandle | CmuxBrowserHandle;
|
|
|
|
const browsers = new Map<string, BrowserHandle>();
|
|
|
|
function browserKey(kind: BrowserKind): string {
|
|
switch (kind.kind) {
|
|
case "headless":
|
|
return `headless:${kind.headless ? "1" : "0"}`;
|
|
case "spawned":
|
|
return `spawned:${kind.path}`;
|
|
case "connected":
|
|
return `connected:${kind.cdpUrl}`;
|
|
case "cmux":
|
|
return `cmux:${kind.socketPath}`;
|
|
}
|
|
}
|
|
|
|
export interface AcquireBrowserOptions {
|
|
cwd: string;
|
|
viewport?: { width: number; height: number; deviceScaleFactor?: number };
|
|
appArgs?: string[];
|
|
signal?: AbortSignal;
|
|
}
|
|
|
|
export async function acquireBrowser(kind: BrowserKind, opts: AcquireBrowserOptions): Promise<BrowserHandle> {
|
|
const key = browserKey(kind);
|
|
const existing = browsers.get(key);
|
|
if (existing) {
|
|
if ("client" in existing) return existing;
|
|
if (existing.browser.connected) return existing;
|
|
browsers.delete(key);
|
|
await disposeBrowserHandle(existing, { kill: false });
|
|
}
|
|
// Short-circuit before launching: the tool wrapper's `untilAborted` only
|
|
// rejects its outer promise on abort; without this check `openBrowserHandle`
|
|
// would still fire and its result would land in `browsers` below.
|
|
if (opts.signal?.aborted) throw new ToolAbortError("Browser open aborted");
|
|
|
|
const handle = await openBrowserHandle(kind, opts);
|
|
// The launch may resolve AFTER the caller has already aborted (the outer
|
|
// `untilAborted` rejects immediately on abort but does not cancel the
|
|
// inner promise, and `launchHeadlessBrowser` does not accept a signal).
|
|
// Without this branch the completed handle sits in `browsers` at
|
|
// refCount:0 forever — no tab ever takes a hold, `releaseBrowser` never
|
|
// fires, and `releaseAllTabs` walks `tabs`, not `browsers`, so the
|
|
// orphaned Chromium/app process / puppeteer handle survives to process
|
|
// exit. (Issue #3963.)
|
|
if (opts.signal?.aborted) {
|
|
await disposeBrowserHandle(handle, { kill: kind.kind === "spawned" }).catch(err => {
|
|
logger.debug("Failed to dispose orphan browser after abort", {
|
|
error: err instanceof Error ? err.message : String(err),
|
|
});
|
|
});
|
|
throw new ToolAbortError("Browser open aborted");
|
|
}
|
|
browsers.set(key, handle);
|
|
return handle;
|
|
}
|
|
|
|
export function normalizeConnectedCdpUrl(rawCdpUrl: string): string {
|
|
const cdpUrl = rawCdpUrl.replace(/\/+$/, "");
|
|
if (/^wss?:\/\//i.test(cdpUrl)) {
|
|
throw new ToolError(
|
|
"browser app.cdp_url must be the HTTP CDP discovery endpoint (for example http://127.0.0.1:9222), not a ws:// browser websocket URL.",
|
|
);
|
|
}
|
|
return cdpUrl;
|
|
}
|
|
|
|
async function openBrowserHandle(kind: BrowserKind, opts: AcquireBrowserOptions): Promise<BrowserHandle> {
|
|
if (kind.kind === "cmux") {
|
|
const client = new CmuxSocketClient({ socketPath: kind.socketPath, password: kind.password });
|
|
await client.connect();
|
|
return {
|
|
key: browserKey(kind),
|
|
kind,
|
|
client,
|
|
surface: kind.surface,
|
|
refCount: 0,
|
|
};
|
|
}
|
|
if (kind.kind === "headless") {
|
|
const browser = await launchHeadlessBrowser({ headless: kind.headless, viewport: opts.viewport });
|
|
return {
|
|
key: browserKey(kind),
|
|
kind,
|
|
browser,
|
|
refCount: 0,
|
|
stealth: { browserSession: null, override: null },
|
|
};
|
|
}
|
|
if (kind.kind === "connected") {
|
|
const cdpUrl = normalizeConnectedCdpUrl(kind.cdpUrl);
|
|
await waitForCdp(cdpUrl, 5_000, opts.signal);
|
|
const puppeteer = await loadPuppeteer();
|
|
const browser = await puppeteer.connect({
|
|
browserURL: cdpUrl,
|
|
defaultViewport: null,
|
|
protocolTimeout: BROWSER_PROTOCOL_TIMEOUT_MS,
|
|
});
|
|
return {
|
|
key: browserKey(kind),
|
|
kind,
|
|
browser,
|
|
cdpUrl,
|
|
refCount: 0,
|
|
stealth: { browserSession: null, override: null },
|
|
};
|
|
}
|
|
|
|
const exe = kind.path;
|
|
if (!path.isAbsolute(exe)) {
|
|
throw new ToolError(
|
|
`app.path must be absolute (got ${JSON.stringify(exe)}). Pass the binary inside Foo.app/Contents/MacOS/, not the .app bundle.`,
|
|
);
|
|
}
|
|
const reused = await findReusableCdp(exe, opts.signal);
|
|
let cdpUrl: string;
|
|
let pid: number;
|
|
let subprocess: Subprocess | undefined;
|
|
if (reused) {
|
|
logger.debug("Reusing existing CDP endpoint for attach", { exe, pid: reused.pid, cdpUrl: reused.cdpUrl });
|
|
cdpUrl = reused.cdpUrl;
|
|
pid = reused.pid;
|
|
} else {
|
|
const killed = await killExistingByPath(exe, opts.signal);
|
|
if (killed > 0) logger.debug("Killed existing instances before attach", { exe, killed });
|
|
const port = await findFreeCdpPort();
|
|
const launchArgs = [...(opts.appArgs ?? []), `--remote-debugging-port=${port}`];
|
|
const child = Bun.spawn([exe, ...launchArgs], {
|
|
stdout: "ignore",
|
|
stderr: "ignore",
|
|
stdin: "ignore",
|
|
});
|
|
child.unref();
|
|
subprocess = child;
|
|
pid = child.pid;
|
|
cdpUrl = `http://127.0.0.1:${port}`;
|
|
try {
|
|
await waitForCdp(cdpUrl, 30_000, opts.signal);
|
|
} catch (err) {
|
|
await gracefulKillTreeOnce(child.pid).catch(() => undefined);
|
|
if (err instanceof ToolAbortError) throw err;
|
|
if (err instanceof Error && err.name === "AbortError") throw err;
|
|
throw new ToolError(`Failed to attach to ${path.basename(exe)} on ${cdpUrl}: ${(err as Error).message}`);
|
|
}
|
|
}
|
|
|
|
const puppeteer = await loadPuppeteer();
|
|
let browser: Browser;
|
|
try {
|
|
browser = await puppeteer.connect({
|
|
browserURL: cdpUrl,
|
|
defaultViewport: null,
|
|
protocolTimeout: BROWSER_PROTOCOL_TIMEOUT_MS,
|
|
});
|
|
} catch (err) {
|
|
if (subprocess) await gracefulKillTreeOnce(subprocess.pid);
|
|
throw new ToolError(`Connected to ${cdpUrl} but puppeteer.connect failed: ${(err as Error).message}`);
|
|
}
|
|
return {
|
|
key: browserKey(kind),
|
|
kind,
|
|
browser,
|
|
cdpUrl,
|
|
pid,
|
|
subprocess,
|
|
refCount: 0,
|
|
stealth: { browserSession: null, override: null },
|
|
};
|
|
}
|
|
|
|
export function holdBrowser(handle: BrowserHandle): void {
|
|
handle.refCount++;
|
|
}
|
|
|
|
export async function releaseBrowser(handle: BrowserHandle, opts: { kill: boolean }): Promise<void> {
|
|
handle.refCount = Math.max(0, handle.refCount - 1);
|
|
if (handle.refCount === 0) {
|
|
// Only evict if the registry still points at THIS handle. After a disconnect,
|
|
// `acquireBrowser` may have already replaced the entry with a fresh live handle
|
|
// under the same key; deleting blindly would orphan that new browser.
|
|
if (browsers.get(handle.key) === handle) browsers.delete(handle.key);
|
|
await disposeBrowserHandle(handle, opts);
|
|
}
|
|
}
|
|
|
|
async function disposeBrowserHandle(handle: BrowserHandle, opts: { kill: boolean }): Promise<void> {
|
|
if ("client" in handle) {
|
|
handle.client.close();
|
|
return;
|
|
}
|
|
if (handle.kind.kind === "headless") {
|
|
if (handle.browser.connected) {
|
|
try {
|
|
await handle.browser.close();
|
|
} catch (err) {
|
|
logger.debug("Failed to close headless browser", { error: (err as Error).message });
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
if (handle.kind.kind === "connected") {
|
|
if (handle.browser.connected) {
|
|
try {
|
|
handle.browser.disconnect();
|
|
} catch (err) {
|
|
logger.debug("Failed to disconnect from remote browser", { error: (err as Error).message });
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
if (handle.browser.connected) {
|
|
try {
|
|
handle.browser.disconnect();
|
|
} catch (err) {
|
|
logger.debug("Failed to disconnect from spawned browser", { error: (err as Error).message });
|
|
}
|
|
}
|
|
if (opts.kill && handle.pid !== undefined) await gracefulKillTreeOnce(handle.pid);
|
|
}
|
|
|
|
/** Test-only accessor for the module-global browsers map. */
|
|
export function getBrowsersMapForTest(): ReadonlyMap<string, BrowserHandle> {
|
|
return browsers;
|
|
}
|