e287a8c6e2
The shared-browser CDP liveness probes (probeEndpoint, waitForCdp, probeCdpAt) used a bare fetch() against the loopback DevTools endpoint. Bun's fetch honors HTTP_PROXY/HTTPS_PROXY and forwards even 127.0.0.1 requests to the proxy unless NO_PROXY covers them, so a local proxy (e.g. Clash) that 502s internal addresses made a healthy daemon look dead and ensureSharedBrowser tore it down. Replace the three probes with probeCdpStatus(), a raw-TCP HTTP/1.1 GET that never routes through a proxy and resolves to the response status (or null on unreachable/aborted/timeout). Fixes #8567
136 lines
5.4 KiB
TypeScript
136 lines
5.4 KiB
TypeScript
/**
|
|
* Shared automation Chromium owned by the per-project daemon broker.
|
|
*
|
|
* Instead of every omp process launching (and sometimes orphaning) a private
|
|
* Chromium, the headless browser kind attaches to one broker-supervised Chrome
|
|
* per project directory — sessions and subagents each open their own tabs in
|
|
* it. The broker stops the daemon when the last omp client in the project
|
|
* exits, so Chrome can never outlive omp, and concurrent acquisitions across
|
|
* processes converge on a single launch instead of a launch storm.
|
|
*/
|
|
import * as fs from "node:fs/promises";
|
|
import * as path from "node:path";
|
|
import { logger } from "@oh-my-pi/pi-utils";
|
|
import { daemonClientForProject } from "../../launch/client";
|
|
import { describeQuietly, stopQuietly, waitReady } from "../../launch/ensure";
|
|
import { daemonRuntimeDir } from "../../launch/paths";
|
|
import type { DaemonSnapshot } from "../../launch/protocol";
|
|
import { throwIfAborted } from "../tool-errors";
|
|
import { probeCdpStatus } from "./attach";
|
|
import { resolveSharedBrowserLaunchSpec } from "./launch";
|
|
|
|
/** Chrome prints this on stderr once the CDP listener is up; the broker's ready probe captures the line. */
|
|
const READY_LOG_PATTERN = String.raw`DevTools listening on ws://\S+`;
|
|
const READY_TIMEOUT_MS = 30_000;
|
|
const PROBE_TIMEOUT_MS = 1_500;
|
|
/** describe→start rounds before giving up; bounds cross-process start races and wedged-Chrome replacement. */
|
|
const ENSURE_ATTEMPTS = 3;
|
|
|
|
/** Broker-owned browser endpoint one omp process can attach to. */
|
|
export interface SharedBrowserEndpoint {
|
|
wsEndpoint: string;
|
|
daemonName: string;
|
|
/** Canonical project directory owning the broker (used to address later stop requests). */
|
|
projectDir: string;
|
|
}
|
|
|
|
/** Stable broker daemon name for the shared automation browser. */
|
|
export function sharedBrowserDaemonName(headless: boolean): string {
|
|
return headless ? "omp.browser.headless" : "omp.browser.headed";
|
|
}
|
|
|
|
function wsEndpointOf(snapshot: DaemonSnapshot | undefined): string | undefined {
|
|
return snapshot?.readyMatch?.match(/ws:\/\/\S+/)?.[0];
|
|
}
|
|
|
|
/** CDP liveness probe: the ws endpoint host must answer /json/version. */
|
|
async function probeEndpoint(wsEndpoint: string): Promise<boolean> {
|
|
let host: string;
|
|
try {
|
|
host = new URL(wsEndpoint).host;
|
|
} catch {
|
|
return false;
|
|
}
|
|
const status = await probeCdpStatus(`http://${host}/json/version`, { timeoutMs: PROBE_TIMEOUT_MS });
|
|
return status !== null && status >= 200 && status < 300;
|
|
}
|
|
|
|
/**
|
|
* Ensure the project-shared automation Chromium is running and reachable,
|
|
* launching it under the daemon broker when needed. Idempotent across
|
|
* processes: losers of the start race adopt the winner's endpoint on the next
|
|
* describe round. Returns null when the shared path is unavailable (no
|
|
* resolvable Chromium, broker failure, or a daemon that never becomes
|
|
* reachable); callers fall back to a process-local launch.
|
|
*/
|
|
export async function ensureSharedBrowser(opts: {
|
|
projectDir: string;
|
|
headless: boolean;
|
|
viewport?: { width: number; height: number };
|
|
signal?: AbortSignal;
|
|
}): Promise<SharedBrowserEndpoint | null> {
|
|
const client = await daemonClientForProject(opts.projectDir);
|
|
const name = sharedBrowserDaemonName(opts.headless);
|
|
// Stable profile under the broker's runtime dir: reused across launches, and
|
|
// never contended by pre-daemon Chromiums that used throwaway temp profiles.
|
|
const userDataDir = path.join(daemonRuntimeDir(client.projectDir), `${name}.profile`);
|
|
const launch = await resolveSharedBrowserLaunchSpec({
|
|
headless: opts.headless,
|
|
userDataDir,
|
|
viewport: opts.viewport,
|
|
});
|
|
if (!launch) return null;
|
|
await fs.mkdir(userDataDir, { recursive: true });
|
|
for (let attempt = 0; attempt < ENSURE_ATTEMPTS; attempt++) {
|
|
throwIfAborted(opts.signal);
|
|
const existing = await describeQuietly(client, name, "Shared browser", opts.signal);
|
|
if (existing && existing.state !== "exited" && existing.state !== "failed") {
|
|
const settled =
|
|
existing.readyAt !== undefined ? existing : await waitReady(client, name, "Shared browser", opts.signal);
|
|
const wsEndpoint = wsEndpointOf(settled);
|
|
if (wsEndpoint && (await probeEndpoint(wsEndpoint))) {
|
|
return { wsEndpoint, daemonName: name, projectDir: client.projectDir };
|
|
}
|
|
// Live record but unreachable Chrome (wedged, or readiness never
|
|
// matched): replace it rather than handing out a dead endpoint.
|
|
await stopQuietly(client, name, "Shared browser", opts.signal);
|
|
continue;
|
|
}
|
|
try {
|
|
const started = await client.request(
|
|
{
|
|
op: "start",
|
|
spec: {
|
|
name,
|
|
application: launch.executablePath,
|
|
args: launch.args,
|
|
env: {},
|
|
cwd: client.projectDir,
|
|
pty: false,
|
|
ready: { log: READY_LOG_PATTERN, timeoutMs: READY_TIMEOUT_MS },
|
|
restart: "no",
|
|
persist: false,
|
|
detached: false,
|
|
},
|
|
},
|
|
opts.signal,
|
|
);
|
|
if (started.op !== "start") continue;
|
|
const wsEndpoint = started.readyTimedOut ? undefined : wsEndpointOf(started.daemon);
|
|
if (wsEndpoint && (await probeEndpoint(wsEndpoint))) {
|
|
return { wsEndpoint, daemonName: name, projectDir: client.projectDir };
|
|
}
|
|
await stopQuietly(client, name, "Shared browser", opts.signal);
|
|
} catch (error) {
|
|
throwIfAborted(opts.signal);
|
|
// Lost a cross-process start race ("already starting/ready"); the next
|
|
// describe round adopts the winner's endpoint.
|
|
logger.debug("Shared browser start contention", {
|
|
name,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
}
|
|
}
|
|
return null;
|
|
}
|