/** * CDP port ownership classification. * * A debug port is a shared, unauthenticated surface: whatever answers on 9222 * can be driven. BRB therefore never guesses. Before attaching it classifies * what actually holds the port, and it attaches only to an instance BRB itself * started -- anything else fails closed, with an explanation instead of a * silent takeover. * * Four states, and what each one means for the caller: * * FREE nothing answers; the configured browser may be launched * BRB_CDP our own managed instance (recorded in a marker file); attach * FOREIGN_CDP someone else's CDP endpoint; refuse, explain, never kill * NON_CDP the port is taken by something that is not CDP; refuse, explain * * Killing the foreign process is explicitly out of scope: it may be the user's * own debugging session, another tool's browser, or a browser holding unsaved * work. BRB reports; the user decides. */ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; export type PortState = "FREE" | "BRB_CDP" | "FOREIGN_CDP" | "NON_CDP"; export interface PortMarker { port: number; kind?: string; executablePath?: string; profileDir?: string; startedAt?: string; } export interface PortProbeResult { state: PortState; /** The browser string reported by CDP, e.g. "Chrome/153.0.8010.53". */ browser?: string; /** Human-readable reason, suitable for doctor output. */ detail: string; /** True only when attaching is allowed (BRB_CDP). */ attach: boolean; } export interface PortProbeDeps { /** Read BRB's marker file. Returns undefined when absent or unreadable. */ readMarker?: (markerPath: string) => PortMarker | undefined; /** Query CDP `/json/version`. `ok=false` means "not a CDP endpoint". */ fetchVersion?: (port: number, timeoutMs: number) => Promise<{ ok: boolean; browser?: string; error?: string }>; /** BRB state root; the marker file lives here. Ownership is only detectable when given. */ stateRoot?: string; /** Marker file location override (tests). */ markerPath?: string; /** Probe timeout. */ timeoutMs?: number; } /** Default marker location: one file describing the instance BRB started. */ export function markerPath(stateRoot: string): string { return join(stateRoot, "browser-cdp.json"); } /** Record that BRB launched the automation browser on this port. */ export function writePortMarker(stateRoot: string, marker: PortMarker): void { const path = markerPath(stateRoot); mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, `${JSON.stringify(marker, null, 2)}\n`, "utf8"); } /** Forget the recorded instance (called when BRB shuts its browser down). */ export function clearPortMarker(stateRoot: string): void { const path = markerPath(stateRoot); if (existsSync(path)) rmSync(path, { force: true }); } function defaultReadMarker(path: string): PortMarker | undefined { try { const parsed = JSON.parse(readFileSync(path, "utf8")) as PortMarker; return typeof parsed?.port === "number" ? parsed : undefined; } catch { return undefined; } } async function defaultFetchVersion( port: number, timeoutMs: number, ): Promise<{ ok: boolean; browser?: string; error?: string }> { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetch(`http://127.0.0.1:${port}/json/version`, { signal: controller.signal }); if (!response.ok) return { ok: false, error: `HTTP ${response.status}` }; const body = (await response.json()) as { Browser?: string }; return { ok: true, browser: body?.Browser }; } catch (error) { const cause = (error as { cause?: { code?: string } })?.cause?.code; const message = error instanceof Error ? error.message : String(error); return { ok: false, error: cause ? `${message} (${cause})` : message }; } finally { clearTimeout(timer); } } /** * Classify what holds a port. * * Ownership comes from BRB's own marker, not from a guessed browser identity: * a marker naming this port is the only proof that BRB may attach. Everything * else is reported, never taken over. */ export async function probePort(port: number, deps: PortProbeDeps = {}): Promise { const timeoutMs = deps.timeoutMs ?? 1500; const markerPathUsed = deps.markerPath ?? (deps.stateRoot ? markerPath(deps.stateRoot) : undefined); const readMarker = deps.readMarker ?? defaultReadMarker; const fetchVersion = deps.fetchVersion ?? defaultFetchVersion; const marker = markerPathUsed ? readMarker(markerPathUsed) : undefined; const version = await fetchVersion(port, timeoutMs); if (!version.ok) { // Something may still hold the port without speaking CDP: `ECONNREFUSED` // means free, anything else means taken by a non-CDP process. // Fail closed on ambiguity: only a definite "nothing is listening" is FREE. // A timeout or a refusal we cannot classify means something holds the port. const refusal = /ECONNREFUSED/i.test(version.error ?? ""); if (refusal) { return { state: "FREE", detail: `端口 ${port} 空闲。`, attach: false }; } return { state: "NON_CDP", detail: `端口 ${port} 被非 CDP 进程占用(${version.error ?? "无响应"})。BRB 不会结束该进程。`, attach: false, }; } if (marker && marker.port === port) { return { state: "BRB_CDP", browser: version.browser, detail: `端口 ${port} 是 BRB 自己启动的实例(${version.browser ?? "unknown"})。`, attach: true, }; } return { state: "FOREIGN_CDP", browser: version.browser, detail: `端口 ${port} 上已有一个不是 BRB 启动的 CDP 端点(${version.browser ?? "unknown"})。` + `无法证明归属,BRB 停止操作;请关闭它或改用 /brb launch。`, attach: false, }; } /** Chrome and Edge report themselves as "Chrome/..." or "Edg/..." over CDP. */ export function browserStringKind(browser?: string): "chrome" | "edge" | "unknown" { if (!browser) return "unknown"; if (/^Edg\//i.test(browser) || /^Microsoft Edge/i.test(browser)) return "edge"; if (/^Chrome\//i.test(browser) || /^Chromium\//i.test(browser)) return "chrome"; return "unknown"; }