import { randomUUID } from "node:crypto"; import { spawn as nodeSpawn, type ChildProcess } from "node:child_process"; import { chmodSync, closeSync, fstatSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, readlinkSync, statSync, unlinkSync, writeSync, } from "node:fs"; import { promises as fsPromises } from "node:fs"; import { tmpdir } from "node:os"; import { delimiter, join } from "node:path"; import { FastBrowserConfigError, type FastBrowserConfig, type FastBrowserBrowserProduct } from "./config.ts"; import { acquireProfileLease, captureOwnedSingletonLock, releaseProfileLease, removeOwnedSingletonLock, removeStaleDevToolsActivePort, type ProfileLease, type ProfileSingletonLockOwnership, } from "./profiles.ts"; /** A browser lifetime owned by one BrowserSessionManager. */ export interface BrowserSession { id: string; config: FastBrowserConfig & { cdpUrl: string }; owned: boolean; signal: AbortSignal; } export interface BrowserSetupSession { id: string; config: FastBrowserConfig; owned: true; signal: AbortSignal; } export interface BrowserSessionManager { open(config: FastBrowserConfig, signal: AbortSignal): Promise; openSetup(config: FastBrowserConfig, signal: AbortSignal): Promise; get(id: string): BrowserSession; close(id: string): Promise; closeAll(): Promise; } /** The intentionally small process surface used by the launch adapter. */ interface OwnedProcessGroup { /** Signal only the verified Chromium leader before descendant escalation. */ killLeader?(signal?: NodeJS.Signals | number): boolean; kill(signal?: NodeJS.Signals | number): boolean; isAlive(): boolean; isOwned(): boolean; } interface ProcessIdentity { pid: number; startTime?: string; } export interface BrowserChildProcess { once(event: string, listener: (...args: any[]) => void): unknown; kill?(signal?: NodeJS.Signals | number): boolean; pid?: number; /** Internal lifecycle seam; production launch attaches a private process group. */ ownedProcessGroup?: OwnedProcessGroup; /** Internal identity used to reject PID reuse during profile handoff. */ ownedProcessIdentity?: ProcessIdentity; } export interface BrowserSpawnOptions { env: NodeJS.ProcessEnv; shell: false; stdio: "ignore"; /** Production launches set this to create an owned POSIX process group. */ detached?: boolean; cwd?: string; } export type BrowserSpawn = ( command: string, args: readonly string[], options: BrowserSpawnOptions, ) => BrowserChildProcess; export interface BrowserReadiness { /** Port from DevToolsActivePort. The websocket path is not passed to CDP. */ port: number; webSocketPath?: string; } export type BrowserReadinessSeam = ( profileDirectory: string, signal: AbortSignal, deadline: number, ) => BrowserReadiness | string | number | Promise; export interface BrowserSessionManagerOptions { /** Test seam; production uses child_process.spawn with shell disabled. */ spawn?: BrowserSpawn; /** Test seam for bounded DevToolsActivePort discovery. */ readiness?: BrowserReadinessSeam; /** Test seam for private per-session profile creation. */ mkdtemp?: (prefix: string) => string | Promise; /** Test seam for profile cleanup. */ removeDirectory?: (path: string) => void | Promise; /** Shared private directory containing process-independent endpoint leases. */ leaseDirectory?: string; /** Parent directory for temporary profiles. */ profileDirectory?: string; /** Test seam for the managed named-profile root. */ profileRoot?: string; /** Startup deadline in milliseconds. */ startupTimeoutMs?: number; /** Close deadline in milliseconds for each bounded shutdown phase. */ closeTimeoutMs?: number; /** Internal test seam for bounded recent-exit diagnostics. */ recentExitTtlMs?: number; /** Resolve an executable without touching a real browser in tests. */ resolveExecutable?: (product: FastBrowserBrowserProduct) => string; /** Optional PATH used while resolving product executables. */ path?: string; /** Internal Linux ownership seam; production uses /proc and process.kill. */ linuxProcessAdapter?: LinuxProcessAdapter; } interface Lease { path: string; fd?: number; device: number; inode: number; } interface TrackedChild { process: BrowserChildProcess; exited: boolean; exitPromise: Promise; failure?: Error; } interface RecordState { kind: "automation" | "setup"; session: BrowserSession | BrowserSetupSession; controller: AbortController; parentSignal: AbortSignal; onParentAbort: () => void; child?: TrackedChild; profileDirectory?: string; disposableProfile: boolean; lease?: Lease; profileLease?: ProfileLease; closePromise?: Promise; terminalReason?: string; } interface RecentExit { reason: string; expiresAt: number; } interface PendingOpen { controller: AbortController; done: Promise; failure?: Error; } interface FailedStart { child?: TrackedChild; profileDirectory?: string; disposableProfile: boolean; lease?: Lease; profileLease?: ProfileLease; } const DEFAULT_STARTUP_TIMEOUT_MS = 10_000; const DEFAULT_CLOSE_TIMEOUT_MS = 2_000; const DEFAULT_RECENT_EXIT_TTL_MS = 30_000; const MAX_RECENT_EXITS = 32; const DEFAULT_PROFILE_DIRECTORY = "fast-browser-profiles-"; const DEFAULT_LEASE_DIRECTORY = "fast-browser-cdp-leases"; const ACTIVE_PORT_FILE = "DevToolsActivePort"; const MAX_ACTIVE_PORT_BYTES = 4_096; const PRODUCT_NAMES: readonly FastBrowserBrowserProduct[] = ["vivaldi", "chrome", "chromium"]; const PRODUCT_EXECUTABLES: Record = { // Keep each product's candidates separate. In particular, a missing Vivaldi // must never silently turn into a Chrome or Chromium session. vivaldi: [ "/usr/bin/vivaldi", "/usr/bin/vivaldi-stable", "/opt/vivaldi/vivaldi", "vivaldi", "vivaldi-stable", ], chrome: [ "/opt/google/chrome/chrome", "/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chrome", "google-chrome", "google-chrome-stable", "chrome", ], chromium: [ "/usr/bin/chromium", "/usr/bin/chromium-browser", "/snap/bin/chromium", "chromium", "chromium-browser", ], }; function abortError(reason?: unknown): Error { const message = reason instanceof Error ? reason.message : (typeof reason === "string" ? reason : "Browser session aborted"); const error = new Error(message || "Browser session aborted"); error.name = "AbortError"; return error; } function throwIfAborted(signal: AbortSignal): void { if (signal.aborted) throw abortError(signal.reason); } function safeExitReason(error?: Error): string { if (!error) return "session closed"; const message = error.message; const ended = /^Browser process (exited|closed) \((\d+|signal [A-Za-z0-9_-]+)\)$/.exec(message); if (ended) return `browser process ${ended[1]} (${ended[2]})`; if (/failed to start/i.test(message)) return "browser process failed to start"; return "browser process ended unexpectedly"; } function positiveInteger(value: unknown, label: string): number { if (!Number.isSafeInteger(value) || (value as number) < 1 || (value as number) > 65_535) { throw new Error(`${label} must be a valid TCP port`); } return value as number; } function loopbackName(hostname: string): string { return hostname.toLowerCase().replace(/^\[|\]$/g, ""); } function parseLoopbackCdpUrl(value: unknown): string { if (typeof value !== "string" || value.length === 0 || value.length > 4_000) { throw new Error("cdpUrl must be an http loopback endpoint"); } let url: URL; try { url = new URL(value); } catch { throw new Error("cdpUrl must be an http loopback endpoint"); } const hostname = loopbackName(url.hostname); if ( url.protocol !== "http:" || !["localhost", "127.0.0.1", "::1"].includes(hostname) || !url.port || url.pathname !== "/" || url.search || url.hash || url.username || url.password ) { throw new Error("cdpUrl must be an http loopback endpoint"); } positiveInteger(Number(url.port), "cdpUrl"); return value; } function endpointPort(value: string): number { const url = new URL(parseLoopbackCdpUrl(value)); return positiveInteger(Number(url.port), "cdpUrl"); } function exactOrigin(value: string): string { let url: URL; try { url = new URL(value); } catch { throw new Error("allowedOrigins must contain exact http(s) origins"); } if ( (url.protocol !== "http:" && url.protocol !== "https:") || url.username || url.password || url.hostname.includes("*") || url.pathname !== "/" || url.search || url.hash ) { throw new Error("allowedOrigins must contain exact http(s) origins"); } return url.origin; } function validateStartUrl(config: FastBrowserConfig): string { const startUrl = config.browser?.startUrl; if (!startUrl) throw new Error("browser.startUrl must be set for launch mode"); let parsed: URL; try { parsed = new URL(startUrl); } catch { throw new Error("browser.startUrl must be an http(s) URL"); } if ( (parsed.protocol !== "http:" && parsed.protocol !== "https:") || parsed.username || parsed.password || parsed.hostname.includes("*") ) throw new Error("browser.startUrl must be an http(s) URL without credentials or wildcards"); const origins = config.allowedOrigins === "*" ? undefined : new Set(config.allowedOrigins.map(exactOrigin)); if (origins && !origins.has(parsed.origin)) { throw new Error("browser.startUrl must use an origin in allowedOrigins"); } return startUrl; } function childEnvironment(): NodeJS.ProcessEnv { // This is deliberately an allowlist. Browser cloud credentials and model // provider keys in the Pi environment do not cross into the browser. const names = [ "PATH", "HOME", "USER", "LOGNAME", "LANG", "TMPDIR", "TMP", "TEMP", "XDG_RUNTIME_DIR", "XDG_SESSION_TYPE", "XDG_CURRENT_DESKTOP", "XDG_SESSION_DESKTOP", "SYSTEMROOT", "COMSPEC", "LD_LIBRARY_PATH", "DYLD_LIBRARY_PATH", "DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY", "DBUS_SESSION_BUS_ADDRESS", "GDK_BACKEND", ]; const env: NodeJS.ProcessEnv = {}; for (const name of names) { const value = globalThis.process?.env[name]; if (value !== undefined) env[name] = value; } for (const [name, value] of Object.entries(globalThis.process?.env ?? {})) { if (name.startsWith("LC_") && value !== undefined) env[name] = value; } env.PYTHONNOUSERSITE = "1"; env.PYTHONUTF8 = "1"; return env; } function launchArguments( profileDirectory: string, startUrl: string, headless: boolean, withCdp: boolean, ): string[] { const args = [`--user-data-dir=${profileDirectory}`]; if (withCdp) { args.push("--remote-debugging-address=127.0.0.1", "--remote-debugging-port=0"); } args.push("--no-first-run", "--no-default-browser-check"); if (headless) args.push("--headless=new"); args.push(startUrl); return args; } function executableExists(path: string): boolean { if (!path.startsWith("/")) return false; try { const stats = statSync(path); return stats.isFile() && (stats.mode & 0o111) !== 0; } catch { return false; } } function resolveExecutable(product: FastBrowserBrowserProduct, options: BrowserSessionManagerOptions): string { if (!PRODUCT_NAMES.includes(product)) { throw new Error("Firefox is not supported; use Vivaldi, Chrome, or Chromium"); } if (options.resolveExecutable) return options.resolveExecutable(product); const pathValue = options.path ?? process.env.PATH ?? ""; const pathEntries = pathValue.split(delimiter).filter(Boolean); for (const candidate of PRODUCT_EXECUTABLES[product]) { if (candidate.startsWith("/")) { if (executableExists(candidate)) return candidate; continue; } for (const directory of pathEntries) { const full = join(directory, candidate); if (executableExists(full)) return full; } } throw new Error(`No ${product} executable found; set browser.executablePath explicitly`); } function leaseDirectoryPath(options: BrowserSessionManagerOptions): string { return options.leaseDirectory ?? join(tmpdir(), DEFAULT_LEASE_DIRECTORY); } function ensurePrivateDirectory(path: string): void { mkdirSync(path, { recursive: true, mode: 0o700 }); const stats = lstatSync(path); if (!stats.isDirectory()) throw new Error("browser session lease directory is not a directory"); if (stats.isSymbolicLink()) throw new Error("browser session lease directory must not be a symlink"); if (typeof process.getuid === "function" && stats.uid !== process.getuid()) { throw new Error("browser session lease directory is not owned by this user"); } // Do not silently make a shared directory private: that could leave another // user's leases looking trustworthy. New directories already have 0700. if ((stats.mode & 0o077) !== 0) throw new Error("browser session lease directory must be private (0700)"); } function acquireLease(endpoint: string, directory: string, sessionId: string): Lease { const port = endpointPort(endpoint); ensurePrivateDirectory(directory); const path = join(directory, `cdp-${port}.lock`); let fd: number; try { // O_EXCL is the cross-process ownership boundary. Existing files, including // stale-looking files and symlinks, are never removed by a new owner. fd = openSync(path, "wx", 0o600); } catch (error) { if ((error as NodeJS.ErrnoException)?.code === "EEXIST") { throw new Error( `CDP endpoint port ${port} already has a lease; it may be stale. Confirm the owning session/process is gone and remove ${path} manually before retrying`, ); } throw new Error(`Unable to acquire CDP endpoint lease for port ${port}`); } try { const metadata = JSON.stringify({ pid: process.pid, sessionId, port }) + "\n"; writeSync(fd, metadata, undefined, "utf8"); const stats = fstatSync(fd); return { path, fd, device: stats.dev, inode: stats.ino }; } catch (error) { try { closeSync(fd); } catch { /* best effort */ } try { unlinkSync(path); } catch { /* this process created it, but fail closed */ } throw error instanceof Error ? error : new Error(String(error)); } } function releaseLease(lease: Lease): void { let failure: Error | undefined; try { const current = lstatSync(lease.path); if (current.dev !== lease.device || current.ino !== lease.inode) { throw new Error(`CDP endpoint lease changed unexpectedly; refusing to remove ${lease.path}`); } unlinkSync(lease.path); } catch (error) { failure = error instanceof Error ? error : new Error(String(error)); } finally { if (lease.fd !== undefined) { const fd = lease.fd; lease.fd = undefined; try { closeSync(fd); } catch (error) { if (!failure) failure = error instanceof Error ? error : new Error(String(error)); } } } if (failure) throw failure; } function normalizeReadiness(value: BrowserReadiness | string | number): string { let port: number; if (typeof value === "number") port = positiveInteger(value, "DevToolsActivePort"); else if (typeof value === "string") { // A readiness seam may return either a complete endpoint or the first line // of DevToolsActivePort. Complete URLs are validated before use. if (value.includes("://")) return parseLoopbackCdpUrl(value); port = positiveInteger(Number(value.trim()), "DevToolsActivePort"); } else { port = positiveInteger(value.port, "DevToolsActivePort"); } return `http://127.0.0.1:${port}`; } async function sleep(delayMs: number, signal: AbortSignal): Promise { await new Promise((resolve, reject) => { let settled = false; const abort = () => { if (settled) return; settled = true; clearTimeout(timer); signal.removeEventListener("abort", abort); reject(abortError(signal.reason)); }; const timer = setTimeout(() => { if (settled) return; settled = true; signal.removeEventListener("abort", abort); resolve(); }, delayMs); signal.addEventListener("abort", abort, { once: true }); }); } async function readDevToolsActivePort(profileDirectory: string, signal: AbortSignal, deadline: number): Promise { const path = join(profileDirectory, ACTIVE_PORT_FILE); while (Date.now() < deadline) { throwIfAborted(signal); try { const bytes = await fsPromises.readFile(path); if (bytes.byteLength > MAX_ACTIVE_PORT_BYTES) throw new Error("DevToolsActivePort is too large"); const lines = bytes.toString("utf8").split(/\r?\n/); const port = positiveInteger(Number(lines[0]?.trim()), "DevToolsActivePort"); return { port, webSocketPath: lines[1]?.trim() }; } catch { if (Date.now() >= deadline) break; await sleep(Math.min(50, Math.max(1, deadline - Date.now())), signal); } } throw new Error("Browser did not expose DevToolsActivePort before the startup deadline"); } async function boundedReadiness( seam: BrowserReadinessSeam, profileDirectory: string, signal: AbortSignal, deadline: number, ): Promise { throwIfAborted(signal); const remaining = Math.max(1, deadline - Date.now()); let timer: ReturnType | undefined; let onAbort: (() => void) | undefined; const timeout = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("Browser did not become ready before the startup deadline")), remaining); }); const aborted = new Promise((_, reject) => { onAbort = () => reject(abortError(signal.reason)); if (signal.aborted) onAbort(); else signal.addEventListener("abort", onAbort, { once: true }); }); try { const value = await Promise.race([seam(profileDirectory, signal, deadline), timeout, aborted]); return normalizeReadiness(value); } finally { if (timer) clearTimeout(timer); if (onAbort) signal.removeEventListener("abort", onAbort); } } function trackChild(child: BrowserChildProcess, onFailure: (state: TrackedChild, error: Error) => void): TrackedChild { let resolveExit!: () => void; const state: TrackedChild = { process: child, exited: false, exitPromise: new Promise((resolve) => { resolveExit = resolve; }), }; const ended = (error?: Error): void => { if (state.exited) return; state.exited = true; if (error) state.failure = error; resolveExit(); onFailure(state, error ?? new Error("Browser process exited")); }; child.once("error", (error: unknown) => { const failure = error instanceof Error ? error : new Error("Browser process failed"); state.failure = failure; onFailure(state, failure); }); child.once("exit", (code: number | null, signalName: string | null) => { ended(new Error(`Browser process exited (${code ?? `signal ${signalName ?? "unknown"}`})`)); }); child.once("close", (code: number | null, signalName: string | null) => { ended(new Error(`Browser process closed (${code ?? `signal ${signalName ?? "unknown"}`})`)); }); return state; } function waitForChildSpawn(child: BrowserChildProcess, signal: AbortSignal, deadline: number): Promise { return new Promise((resolve, reject) => { let timer: ReturnType | undefined; let settled = false; const finish = (failure?: Error): void => { if (settled) return; settled = true; if (timer) clearTimeout(timer); signal.removeEventListener("abort", onAbort); if (failure) reject(failure); else resolve(); }; const onSpawn = () => finish(); const onError = () => finish(new Error("Browser process failed to start")); const onAbort = () => finish(abortError(signal.reason)); child.once("spawn", onSpawn); child.once("error", onError); if (signal.aborted) onAbort(); else signal.addEventListener("abort", onAbort, { once: true }); timer = setTimeout(() => finish(new Error("Browser process did not spawn before the startup deadline")), Math.max(1, deadline - Date.now())); }); } function waitForChildFailure(child: BrowserChildProcess, signal: AbortSignal): Promise { return new Promise((_, reject) => { let done = false; const fail = (message: string): void => { if (done) return; done = true; reject(new Error(message)); }; child.once("error", () => fail("Browser process failed to start")); child.once("exit", (code: number | null, signalName: string | null) => { fail(`Browser process exited before DevTools became ready (${code ?? `signal ${signalName ?? "unknown"}`})`); }); child.once("close", (code: number | null, signalName: string | null) => { fail(`Browser process closed before DevTools became ready (${code ?? `signal ${signalName ?? "unknown"}`})`); }); if (signal.aborted) fail("Browser session aborted"); }); } async function waitForExit(child: TrackedChild, timeoutMs: number): Promise { if (child.exited) return true; let timer: ReturnType | undefined; try { await Promise.race([ child.exitPromise, new Promise((resolve) => { timer = setTimeout(resolve, timeoutMs); }), ]); } finally { if (timer) clearTimeout(timer); } return child.exited; } interface LinuxProcessIdentity { ppid: number; pgrp: number; session: number; startTime: string; state: string; } function linuxProcessIdentity(pid: number): LinuxProcessIdentity | undefined { if (process.platform !== "linux") return undefined; try { const stat = readFileSync(`/proc/${pid}/stat`, "utf8"); const end = stat.lastIndexOf(") "); if (end < 0) return undefined; const fields = stat.slice(end + 2).trim().split(/\s+/); const state = fields[0]; const ppid = Number(fields[1]); const pgrp = Number(fields[2]); const session = Number(fields[3]); const startTime = fields[19]; if (!state || !Number.isSafeInteger(ppid) || !Number.isSafeInteger(pgrp) || !Number.isSafeInteger(session) || !startTime) return undefined; return { ppid, pgrp, session, startTime, state }; } catch { return undefined; } } function linuxProcessExecutable(pid: number): string | undefined { if (process.platform !== "linux") return undefined; try { return readlinkSync(`/proc/${pid}/exe`); } catch { return undefined; } } function linuxProcessUid(pid: number): number | undefined { if (process.platform !== "linux") return undefined; try { const uid = lstatSync(`/proc/${pid}`).uid; return Number.isSafeInteger(uid) ? uid : undefined; } catch { return undefined; } } function linuxProcessCommandLine(pid: number): string | undefined { if (process.platform !== "linux") return undefined; try { return readFileSync(`/proc/${pid}/cmdline`, "utf8").replaceAll("\0", " "); } catch { return undefined; } } interface LinuxGroupMember extends LinuxProcessIdentity { pid: number; uid: number; executable: string; commandLine: string; owned: boolean; } interface LinuxMemberProof { startTime: string; uid: number; session: number; executable: string; owned: boolean; } interface LinuxProcessAdapter { identity(pid: number): LinuxProcessIdentity | undefined; executable(pid: number): string | undefined; uid(pid: number): number | undefined; members(groupId: number): LinuxGroupMember[] | undefined; groupAlive(groupId: number): boolean; killGroup(groupId: number, signal?: NodeJS.Signals | number): boolean; killProcess?(pid: number, signal?: NodeJS.Signals | number): boolean; } function linuxGroupMembers(groupId: number): LinuxGroupMember[] | undefined { if (process.platform !== "linux") return undefined; const members: LinuxGroupMember[] = []; try { for (const name of readdirSync("/proc")) { if (!/^\d+$/.test(name)) continue; const pid = Number(name); const identity = linuxProcessIdentity(pid); if (!identity || identity.pgrp !== groupId) continue; const uid = linuxProcessUid(pid); const executable = linuxProcessExecutable(pid); const commandLine = linuxProcessCommandLine(pid); // A member that disappears or cannot be identified is unknown, not // foreign-or-owned by guess. The caller fails closed on this census. if (uid === undefined || !executable || commandLine === undefined) return undefined; members.push({ pid, ...identity, uid, executable, commandLine, owned: false }); } } catch { return undefined; } return members; } const nativeLinuxProcessAdapter: LinuxProcessAdapter = { identity: linuxProcessIdentity, executable: linuxProcessExecutable, uid: linuxProcessUid, members: linuxGroupMembers, groupAlive(groupId) { try { process.kill(-groupId, 0); } catch (error) { if ((error as NodeJS.ErrnoException)?.code === "ESRCH") return false; throw error; } return true; }, killGroup(groupId, signal) { try { process.kill(-groupId, signal); return true; } catch (error) { if ((error as NodeJS.ErrnoException)?.code === "ESRCH") return false; throw error; } }, killProcess(pid, signal) { try { process.kill(pid, signal); return true; } catch (error) { if ((error as NodeJS.ErrnoException)?.code === "ESRCH") return false; throw error; } }, }; function validOwnedProcessPid(value: unknown): value is number { return typeof value === "number" && Number.isSafeInteger(value) && value > 1 && value !== process.pid; } function attachOwnedProcessGroup(child: BrowserChildProcess, profileDirectory: string, adapter?: LinuxProcessAdapter): boolean { // Do this validation before any /proc probe or negative-PID signal. In // particular, -1/0/self have process-wide meaning on POSIX systems. const candidatePid = child.pid; if (!validOwnedProcessPid(candidatePid)) return false; // Supplied adapters are still subject to the same PID guard in // terminateChild; do not replace a validated internal test adapter here. if (child.ownedProcessGroup) return true; if (process.platform !== "linux") return false; const pid = candidatePid; const processAdapter = adapter ?? nativeLinuxProcessAdapter; const root = processAdapter.identity(pid); const rootExecutable = processAdapter.executable(pid); const rootUid = processAdapter.uid(pid); // Chromium's Linux descendants stay in the detached process group/session. // The leader identity, executable, and UID are captured before any signal; // PID reuse or a non-dedicated session is never treated as owned. if (!root || root.pgrp !== pid || root.session !== pid || !rootExecutable || rootUid === undefined) return false; child.ownedProcessIdentity = { pid, startTime: root.startTime }; const profileArgument = `--user-data-dir=${profileDirectory}`; const knownMemberProofs = new Map(); child.ownedProcessGroup = { isAlive() { return processAdapter.groupAlive(pid); }, killLeader(signal) { // isOwned() intentionally permits a leader that has already exited when // previously-proven descendants remain. Never signal the numeric leader // PID in that state: it may have been reused by an unrelated process. const leaderMatches = (identity: LinuxProcessIdentity | undefined, executable: string | undefined, uid: number | undefined): boolean => ( identity !== undefined && identity.startTime === root.startTime && identity.pgrp === pid && identity.session === pid && executable === rootExecutable && uid === rootUid ); const initialRoot = processAdapter.identity(pid); if (!initialRoot) return false; if (!leaderMatches(initialRoot, processAdapter.executable(pid), processAdapter.uid(pid)) || !processAdapter.killProcess) { throw new Error("browser leader ownership could not be confirmed; preserving session state"); } if (!this.isOwned()) throw new Error("browser process-group ownership could not be confirmed; preserving session state"); // Re-probe immediately after the census. A cached descendant proof may // remain valid after the leader disappears, but it never authorizes a // positive signal to the old numeric leader PID. This PID-based seam is // not an atomic pidfd guarantee; the second proof only narrows the race. const finalRoot = processAdapter.identity(pid); if (!finalRoot) return false; if (!leaderMatches(finalRoot, processAdapter.executable(pid), processAdapter.uid(pid))) { throw new Error("browser leader ownership changed before signaling; preserving session state"); } return processAdapter.killProcess(pid, signal); }, isOwned() { const currentRoot = processAdapter.identity(pid); if (currentRoot && (currentRoot.startTime !== root.startTime || currentRoot.pgrp !== pid || currentRoot.session !== pid)) return false; const members = processAdapter.members(pid); if (!members || members.length === 0) return false; const memberByPid = new Map(members.map((member) => [member.pid, member])); if (currentRoot && !memberByPid.has(pid)) return false; const sameNativeSession = (member: LinuxGroupMember): boolean => ( member.uid === rootUid && member.session === root.session && member.pgrp === pid && member.executable === rootExecutable ); for (const member of members) { const previous = knownMemberProofs.get(member.pid); // A reused member PID invalidates the entire group proof, even if its // replacement looks like Chromium or carries the same profile argv. if (previous && previous.startTime !== member.startTime) return false; member.owned = false; } const owned = new Set(); const currentRootMember = memberByPid.get(pid); if (currentRoot && currentRootMember && currentRootMember.startTime === root.startTime && sameNativeSession(currentRootMember)) { owned.add(pid); } // Preserve provenance captured while the leader was alive. This permits // an already-proven broker/helper to be reparented after leader exit, // but never blesses a newly discovered orphan. for (const member of members) { const previous = knownMemberProofs.get(member.pid); if (previous?.owned && previous.startTime === member.startTime && sameNativeSession(member)) { owned.add(member.pid); } } for (const member of members) { if (member.pid === pid || !sameNativeSession(member)) continue; const profileBearing = member.commandLine.split(/\s+/).includes(profileArgument); // Before leader exit, the dedicated native group is sufficient context // for a newly spawned profile-bearing Chromium child. After exit, a // new child must connect to an already-proven parent/member. if (knownMemberProofs.get(member.pid)?.owned === false) continue; if (profileBearing && (currentRoot !== undefined || owned.has(member.ppid))) { owned.add(member.pid); } } // Resolve ancestry to a fixed point, independent of /proc iteration // order. This is what proves a profile-less --type=broker helper. let changed = true; while (changed) { changed = false; for (const member of members) { if (owned.has(member.pid) || !sameNativeSession(member)) continue; if (knownMemberProofs.get(member.pid)?.owned === false) continue; if (owned.has(member.ppid)) { owned.add(member.pid); changed = true; } } } if (owned.size !== members.length) return false; if (currentRoot) { // Capture stable identity/provenance only under a live verified leader. for (const member of members) { knownMemberProofs.set(member.pid, { startTime: member.startTime, uid: member.uid, session: member.session, executable: member.executable, owned: owned.has(member.pid), }); } } for (const member of members) member.owned = true; return true; }, kill(signal) { if (!this.isOwned()) throw new Error("browser process-group ownership could not be confirmed; preserving session state"); return processAdapter.killGroup(pid, signal); }, }; return true; } async function ensureOwnedProcessGroup( child: BrowserChildProcess, profileDirectory: string, signal: AbortSignal, deadline: number, adapter?: LinuxProcessAdapter, ): Promise { if (child.ownedProcessGroup || child.pid === undefined) return; if (!validOwnedProcessPid(child.pid) || process.platform !== "linux") { throw new Error("browser process-group ownership is unavailable before session handoff; preserving session state"); } const stabilizationDeadline = Math.min(deadline, Date.now() + 1_000); while (Date.now() < stabilizationDeadline) { if (attachOwnedProcessGroup(child, profileDirectory, adapter)) { const group = child.ownedProcessGroup as OwnedProcessGroup | undefined; if (group?.isOwned()) return; // /proc/exe can be absent or transitional around exec. Discard this // census and retry rather than handing out a session with stale proof. child.ownedProcessGroup = undefined; child.ownedProcessIdentity = undefined; } await sleep(Math.min(20, Math.max(1, deadline - Date.now())), signal); } throw new Error("browser process-group ownership did not stabilize before session handoff; preserving session state"); } function captureInitialProcessOwnership(child: BrowserChildProcess): void { const group = child.ownedProcessGroup; if (!group) { if (child.pid !== undefined) throw new Error("browser process-group ownership is unavailable before session handoff; preserving session state"); return; } if (!validOwnedProcessPid(child.pid)) { throw new Error("browser process-group ownership is unavailable before session handoff; preserving session state"); } if (!group.isOwned()) { throw new Error("browser process-group ownership could not be confirmed before session handoff; preserving session state"); } } async function waitForProcessGroup(group: OwnedProcessGroup, timeoutMs: number): Promise { const deadline = Date.now() + Math.max(0, timeoutMs); while (true) { if (!group.isAlive()) return true; if (Date.now() >= deadline) return false; await new Promise((resolve) => setTimeout(resolve, Math.min(20, Math.max(1, deadline - Date.now())))); } } function rootProcessGone(child: TrackedChild): (ownership: ProfileSingletonLockOwnership) => boolean { return (ownership) => { const expected = child.process.ownedProcessIdentity; if (expected && expected.pid === ownership.pid && expected.startTime) { // A reused PID is not equivalent to our exited browser. Keep the marker // and lease if /proc still contains any process at that identity. const current = linuxProcessIdentity(ownership.pid); return current === undefined; } return child.exited; }; } async function terminateChild(child: TrackedChild, timeoutMs: number): Promise { const group = child.process.ownedProcessGroup; // Never honor even an injected/test process-group adapter for malformed, // self, or process-wide POSIX IDs. if (group && !validOwnedProcessPid(child.process.pid)) { throw new Error("owned browser process-group shutdown is unavailable; preserving its session state"); } if (group) { if (await waitForProcessGroup(group, 0)) return; if (!group.isOwned()) throw new Error("browser process-group ownership could not be confirmed; preserving session state"); // Give the verified Chromium leader the first bounded opportunity to // coordinate its own descendants. A naturally exited leader is never // signaled by numeric PID; the owned-group path below handles survivors. if (!child.exited && group.killLeader) { let leaderSignaled = false; try { leaderSignaled = group.killLeader("SIGTERM"); } catch (error) { if (!(error as NodeJS.ErrnoException)?.code && !(error instanceof Error && /already exiting/i.test(error.message))) throw error; } if (leaderSignaled) { if (await waitForProcessGroup(group, timeoutMs)) return; if (!group.isOwned()) throw new Error("browser process-group ownership could not be confirmed after leader shutdown; preserving session state"); } } try { group.kill("SIGTERM"); } catch (error) { if (!(error as NodeJS.ErrnoException)?.code && !(error instanceof Error && /already exiting/i.test(error.message))) throw error; } if (await waitForProcessGroup(group, timeoutMs)) return; if (!group.isOwned()) throw new Error("browser process-group ownership could not be confirmed before SIGKILL; preserving session state"); try { group.kill("SIGKILL"); } catch (error) { if (!(error as NodeJS.ErrnoException)?.code && !(error instanceof Error && /already exiting/i.test(error.message))) throw error; } if (!(await waitForProcessGroup(group, timeoutMs))) { throw new Error("Browser process-group termination could not be confirmed; preserving its session state"); } return; } // Test doubles without a pid retain the original root-only path. A real // child without a verified process group (including Windows, or a custom // non-detached spawn) must fail closed rather than release a profile after // killing only its root. if (child.process.pid !== undefined) { throw new Error("owned browser process-group shutdown is unavailable; preserving its session state"); } if (child.exited) return; if (!child.process.kill) throw new Error("Browser process cannot be terminated safely"); try { child.process.kill("SIGTERM"); } catch { /* already exiting */ } if (await waitForExit(child, timeoutMs)) return; try { child.process.kill("SIGKILL"); } catch { /* already exiting */ } if (!(await waitForExit(child, timeoutMs))) { throw new Error("Browser process termination could not be confirmed; preserving its session state"); } } function secureProfileDirectory(path: string): void { const before = lstatSync(path); if (!before.isDirectory() || before.isSymbolicLink()) { throw new Error("browser session profile is not a private directory"); } if (typeof process.getuid === "function" && before.uid !== process.getuid()) { throw new Error("browser session profile is not owned by this user"); } chmodSync(path, 0o700); const after = lstatSync(path); if (!after.isDirectory() || after.isSymbolicLink() || (after.mode & 0o077) !== 0) { throw new Error("browser session profile is not private (0700)"); } } function makeProfileCreator(options: BrowserSessionManagerOptions): (prefix: string) => Promise { if (options.mkdtemp) return async (prefix) => await options.mkdtemp!(prefix); const root = options.profileDirectory ?? tmpdir(); mkdirSync(root, { recursive: true, mode: 0o700 }); return async (prefix) => await fsPromises.mkdtemp(join(root, prefix)); } function makeProfileRemover(options: BrowserSessionManagerOptions): (path: string) => Promise { if (options.removeDirectory) return async (path) => { await options.removeDirectory!(path); }; return async (path) => { await fsPromises.rm(path, { recursive: true, force: true }); }; } function asBrowserChild(value: ChildProcess): BrowserChildProcess { return value; } function createManager(options: BrowserSessionManagerOptions): BrowserSessionManager { const records = new Map(); const pending = new Set(); const failedStarts = new Set(); const recentExits = new Map(); let closingAll: Promise | undefined; const startupTimeoutMs = options.startupTimeoutMs ?? DEFAULT_STARTUP_TIMEOUT_MS; const closeTimeoutMs = options.closeTimeoutMs ?? DEFAULT_CLOSE_TIMEOUT_MS; const recentExitTtlMs = options.recentExitTtlMs ?? DEFAULT_RECENT_EXIT_TTL_MS; if (!Number.isInteger(startupTimeoutMs) || startupTimeoutMs < 1 || startupTimeoutMs > 120_000) { throw new Error("startupTimeoutMs is out of bounds"); } if (!Number.isInteger(closeTimeoutMs) || closeTimeoutMs < 1 || closeTimeoutMs > 120_000) { throw new Error("closeTimeoutMs is out of bounds"); } if (!Number.isInteger(recentExitTtlMs) || recentExitTtlMs < 1 || recentExitTtlMs > 120_000) { throw new Error("recentExitTtlMs is out of bounds"); } const spawnProcess = options.spawn ?? ((command, args, spawnOptions) => asBrowserChild(nodeSpawn(command, [...args], { ...spawnOptions, detached: true, }))); const createProfile = makeProfileCreator(options); const removeProfile = makeProfileRemover(options); const pruneRecentExits = (now = Date.now()): void => { for (const [id, exit] of recentExits) { if (exit.expiresAt <= now) recentExits.delete(id); } }; const rememberRecentExit = (record: RecordState): void => { pruneRecentExits(); recentExits.delete(record.session.id); recentExits.set(record.session.id, { reason: record.terminalReason ?? "session closed", expiresAt: Date.now() + recentExitTtlMs, }); while (recentExits.size > MAX_RECENT_EXITS) { const oldest = recentExits.keys().next().value; if (typeof oldest !== "string") break; recentExits.delete(oldest); } }; const getRecord = (id: string): RecordState => { if (typeof id !== "string" || !id) throw new Error("Unknown browser session"); const record = records.get(id); if (record) return record; pruneRecentExits(); if (recentExits.has(id)) throw new Error(`Browser session is no longer available: ${recentExits.get(id)!.reason}`); throw new Error("Unknown browser session"); }; const cleanupFailedStart = async (start: FailedStart): Promise => { const failures: Error[] = []; let profileLockOwnershipReady = true; if (start.profileLease) { try { captureOwnedSingletonLock(start.profileLease, start.child?.process.pid, start.child?.process.ownedProcessIdentity?.startTime); } catch (error) { profileLockOwnershipReady = false; failures.push(error instanceof Error ? error : new Error(String(error))); } } if (start.child) await terminateChild(start.child, closeTimeoutMs); if (start.lease) { try { releaseLease(start.lease); start.lease = undefined; } catch (error) { failures.push(error instanceof Error ? error : new Error(String(error))); } } if (start.profileLease) { try { if (!profileLockOwnershipReady) throw new Error("browser profile SingletonLock ownership is uncertain; retaining the profile lease"); removeOwnedSingletonLock(start.profileLease, start.child ? rootProcessGone(start.child) : () => true); releaseProfileLease(start.profileLease); start.profileLease = undefined; } catch (error) { failures.push(error instanceof Error ? error : new Error(String(error))); } } if (start.disposableProfile && start.profileDirectory) { try { await removeProfile(start.profileDirectory); start.profileDirectory = undefined; } catch (error) { failures.push(error instanceof Error ? error : new Error(String(error))); } } if (failures.length) throw new AggregateError(failures, "Failed browser startup cleanup failed; retry closeAll"); failedStarts.delete(start); }; const closeRecord = (record: RecordState): Promise => { if (record.closePromise) return record.closePromise; const promise = (async () => { record.terminalReason ??= "session closed"; record.controller.abort(abortError("Browser session closed")); let profileLockOwnershipReady = true; if (record.profileLease) { try { captureOwnedSingletonLock(record.profileLease, record.child?.process.pid, record.child?.process.ownedProcessIdentity?.startTime); } catch (error) { profileLockOwnershipReady = false; } } // A failed termination deliberately skips all ownership cleanup. The // record, lease, and profile remain available for a later close retry. if (record.child) await terminateChild(record.child, closeTimeoutMs); const failures: Error[] = []; if (record.lease) { try { releaseLease(record.lease); record.lease = undefined; } catch (error) { failures.push(error instanceof Error ? error : new Error(String(error))); } } if (record.profileLease) { try { if (!profileLockOwnershipReady) throw new Error("browser profile SingletonLock ownership is uncertain; retaining the profile lease"); removeOwnedSingletonLock(record.profileLease, record.child ? rootProcessGone(record.child) : () => true); releaseProfileLease(record.profileLease); record.profileLease = undefined; } catch (error) { failures.push(error instanceof Error ? error : new Error(String(error))); } } if (record.disposableProfile && record.profileDirectory) { try { await removeProfile(record.profileDirectory); record.profileDirectory = undefined; } catch (error) { failures.push(error instanceof Error ? error : new Error(String(error))); } } if (failures.length) throw new AggregateError(failures, "Browser session cleanup failed; retry close"); record.parentSignal.removeEventListener("abort", record.onParentAbort); rememberRecentExit(record); records.delete(record.session.id); })(); record.closePromise = promise; void promise.catch(() => { // Allow an explicit retry after an unkillable child or failed cleanup. if (record.closePromise === promise) record.closePromise = undefined; }); return promise; }; const openSetup = async (config: FastBrowserConfig, parentSignal: AbortSignal): Promise => { if (closingAll) throw new Error("Browser session manager is closing; retry after cleanup completes"); throwIfAborted(parentSignal); const browser = config.browser; if (!browser || browser.mode !== "launch") { throw new FastBrowserConfigError("Manual setup requires browser.mode = launch. In /fast-browser, edit global or project settings and set Browser mode to launch. Changes save automatically."); } if (!browser.profile) { throw new FastBrowserConfigError("Manual setup requires a browser.profile name. In /fast-browser, set Browser profile in global or project settings. Changes save automatically."); } if (!browser.product) throw new FastBrowserConfigError("browser.product must be set for launch mode"); if (browser.product === ("firefox" as FastBrowserBrowserProduct) || !PRODUCT_NAMES.includes(browser.product)) { throw new Error("Firefox is not supported; use Vivaldi, Chrome, or Chromium"); } const startUrl = validateStartUrl(config); const controller = new AbortController(); const pendingDone = { resolve: undefined as (() => void) | undefined }; const done = new Promise((resolve) => { pendingDone.resolve = resolve; }); const pendingEntry: PendingOpen = { controller, done }; pending.add(pendingEntry); const sessionId = randomUUID(); let profileLease: ProfileLease | undefined; let profileDirectory: string | undefined; let child: TrackedChild | undefined; let lifecycleReady = false; let recordRef: RecordState | undefined; const onChildFailure = (_state: TrackedChild, error: Error): void => { if (!lifecycleReady && !recordRef) return; controller.abort(error); if (recordRef) { recordRef.terminalReason ??= safeExitReason(error); void closeRecord(recordRef).catch(() => undefined); } }; const onParentAbort = (): void => { controller.abort(abortError(parentSignal.reason)); if (recordRef) { recordRef.terminalReason ??= "session aborted"; void closeRecord(recordRef).catch(() => undefined); } }; parentSignal.addEventListener("abort", onParentAbort, { once: true }); try { profileLease = acquireProfileLease(browser.profile, { profileRoot: options.profileRoot }); profileDirectory = profileLease.profileDirectory; throwIfAborted(controller.signal); const executable = browser.executablePath ?? resolveExecutable(browser.product, options); const childProcess = spawnProcess(executable, launchArguments(profileDirectory, startUrl, false, false), { env: childEnvironment(), shell: false, stdio: "ignore", detached: true, }); child = trackChild(childProcess, onChildFailure); const deadline = Date.now() + startupTimeoutMs; await waitForChildSpawn(childProcess, controller.signal, deadline); await ensureOwnedProcessGroup(childProcess, profileDirectory, controller.signal, deadline, options.linuxProcessAdapter); lifecycleReady = true; if (child.exited || child.failure) throw child.failure ?? new Error("Browser process exited during manual setup startup"); captureInitialProcessOwnership(childProcess); throwIfAborted(controller.signal); const session: BrowserSetupSession = { id: sessionId, config: { ...config, browser: { ...browser, headless: false } }, owned: true, signal: controller.signal, }; const record: RecordState = { kind: "setup", session, controller, parentSignal, onParentAbort, disposableProfile: false, child, profileDirectory, profileLease, }; records.set(sessionId, record); recordRef = record; if (parentSignal.aborted) void closeRecord(record).catch(() => undefined); return session; } catch (error) { controller.abort(abortError(error)); const original = error instanceof Error ? error : new Error(String(error)); const failedStart: FailedStart = { disposableProfile: false, ...(child ? { child } : {}), ...(profileDirectory ? { profileDirectory } : {}), ...(profileLease ? { profileLease } : {}), }; let cleanupFailure: Error | undefined; if (failedStart.profileLease || failedStart.child) { failedStarts.add(failedStart); try { await cleanupFailedStart(failedStart); } catch (failure) { cleanupFailure = failure instanceof Error ? failure : new Error(String(failure)); } } parentSignal.removeEventListener("abort", onParentAbort); if (cleanupFailure) { const failure = new AggregateError([original, cleanupFailure], "Browser setup startup cleanup failed"); pendingEntry.failure = failure; throw failure; } throw original; } finally { pending.delete(pendingEntry); pendingDone.resolve?.(); } }; const manager: BrowserSessionManager = { async open(config, parentSignal) { if (closingAll) throw new Error("Browser session manager is closing; retry after cleanup completes"); if (!parentSignal || typeof parentSignal.addEventListener !== "function") { throw new Error("BrowserSessionManager.open requires an AbortSignal"); } throwIfAborted(parentSignal); if (!config || (config.allowedOrigins !== "*" && (!Array.isArray(config.allowedOrigins) || config.allowedOrigins.length === 0))) { throw new Error("allowedOrigins must be '*' or a non-empty origin array"); } const mode = config.browser?.mode ?? "attach"; if (mode !== "attach" && mode !== "launch") throw new Error("browser.mode must be attach or launch"); const controller = new AbortController(); const pendingDone = { resolve: undefined as (() => void) | undefined }; const done = new Promise((resolve) => { pendingDone.resolve = resolve; }); const pendingEntry: PendingOpen = { controller, done }; pending.add(pendingEntry); const sessionId = randomUUID(); let profileDirectory: string | undefined; let child: TrackedChild | undefined; let lease: Lease | undefined; let profileLease: ProfileLease | undefined; let disposableProfile = false; let parentAborted = false; let lifecycleReady = false; let recordRef: RecordState | undefined; const onChildFailure = (_state: TrackedChild, error: Error): void => { if (!lifecycleReady && !recordRef) return; controller.abort(error); if (recordRef) { recordRef.terminalReason ??= safeExitReason(error); void closeRecord(recordRef).catch(() => undefined); } }; const onParentAbort = (): void => { parentAborted = true; controller.abort(abortError(parentSignal.reason)); if (recordRef) { recordRef.terminalReason ??= "session aborted"; void closeRecord(recordRef).catch(() => undefined); } }; parentSignal.addEventListener("abort", onParentAbort, { once: true }); try { const launch = mode === "launch"; let endpoint: string; let owned = false; if (!launch) { if (config.browser && (config.browser.executablePath !== undefined || config.browser.headless !== undefined || config.browser.startUrl !== undefined || config.browser.profile !== undefined)) { throw new Error("browser launch settings require mode launch"); } endpoint = parseLoopbackCdpUrl(config.cdpUrl); lease = acquireLease(endpoint, leaseDirectoryPath(options), sessionId); } else { if (!config.browser?.product) throw new Error("browser.product must be set for launch mode"); const product = config.browser.product; if (product === ("firefox" as FastBrowserBrowserProduct) || !PRODUCT_NAMES.includes(product)) { throw new Error("Firefox is not supported; use Vivaldi, Chrome, or Chromium"); } const startUrl = validateStartUrl(config); if (config.browser.profile) { profileLease = acquireProfileLease(config.browser.profile, { profileRoot: options.profileRoot }); profileDirectory = profileLease.profileDirectory; // This is safe only after the exclusive profile lease and the // SingletonLock check in acquireProfileLease. removeStaleDevToolsActivePort(profileLease); } else { profileDirectory = await createProfile(`${DEFAULT_PROFILE_DIRECTORY}${sessionId}-`); secureProfileDirectory(profileDirectory); disposableProfile = true; } throwIfAborted(controller.signal); const executable = config.browser.executablePath ?? resolveExecutable(product, options); const childProcess = spawnProcess(executable, launchArguments(profileDirectory, startUrl, config.browser.headless === true, true), { env: childEnvironment(), shell: false, stdio: "ignore", detached: true, }); child = trackChild(childProcess, onChildFailure); const deadline = Date.now() + startupTimeoutMs; await waitForChildSpawn(childProcess, controller.signal, deadline); const readinessSeam = options.readiness; const readiness = readinessSeam ? boundedReadiness(readinessSeam, profileDirectory, controller.signal, deadline) : boundedReadiness( (directory, signal, until) => readDevToolsActivePort(directory, signal, until), profileDirectory, controller.signal, deadline, ); endpoint = await Promise.race([readiness, waitForChildFailure(childProcess, controller.signal)]); lifecycleReady = true; if (child.exited || child.failure) throw child.failure ?? new Error("Browser process exited before DevTools became ready"); await ensureOwnedProcessGroup(childProcess, profileDirectory, controller.signal, deadline, options.linuxProcessAdapter); owned = true; captureInitialProcessOwnership(childProcess); throwIfAborted(controller.signal); lease = acquireLease(endpoint, leaseDirectoryPath(options), sessionId); } throwIfAborted(controller.signal); const session: BrowserSession = { id: sessionId, config: { ...config, cdpUrl: endpoint, ...(config.browser ? { browser: { ...config.browser } } : {}) }, owned, signal: controller.signal, }; const record: RecordState = { kind: "automation", session, controller, parentSignal, onParentAbort, disposableProfile, ...(child ? { child } : {}), ...(profileDirectory ? { profileDirectory } : {}), ...(lease ? { lease } : {}), ...(profileLease ? { profileLease } : {}), }; records.set(sessionId, record); recordRef = record; // A parent abort can race the records.set above. The record's close path // owns the process/profile and removes itself from this manager. if (parentAborted || parentSignal.aborted) void closeRecord(record).catch(() => undefined); return session; } catch (error) { controller.abort(abortError(error)); const original = error instanceof Error ? error : new Error(String(error)); const failedStart: FailedStart = { disposableProfile, ...(child ? { child } : {}), ...(profileDirectory ? { profileDirectory } : {}), ...(lease ? { lease } : {}), ...(profileLease ? { profileLease } : {}), }; const hasResources = Boolean(failedStart.child || failedStart.profileDirectory || failedStart.lease || failedStart.profileLease); let cleanupFailure: Error | undefined; if (hasResources) { failedStarts.add(failedStart); try { await cleanupFailedStart(failedStart); } catch (failure) { cleanupFailure = failure instanceof Error ? failure : new Error(String(failure)); } } parentSignal.removeEventListener("abort", onParentAbort); if (cleanupFailure) { const failure = new AggregateError([original, cleanupFailure], "Browser session startup cleanup failed"); pendingEntry.failure = failure; throw failure; } throw original; } finally { pending.delete(pendingEntry); pendingDone.resolve?.(); } }, openSetup, get(id) { const record = getRecord(id); if (record.kind === "setup") { throw new Error(`Browser setup session ${id} is manual-only and cannot be automated`); } return record.session as BrowserSession; }, async close(id) { await closeRecord(getRecord(id)); }, closeAll() { if (closingAll) return closingAll; const promise = (async () => { const current = [...records.values()]; const pendingSnapshot = [...pending]; for (const entry of pendingSnapshot) entry.controller.abort(abortError("Browser session manager closing")); const results = await Promise.allSettled(current.map((entry) => closeRecord(entry))); await Promise.allSettled(pendingSnapshot.map((entry) => entry.done)); const failedStartSnapshot = [...failedStarts]; const failedStartResults = await Promise.allSettled( failedStartSnapshot.map((entry) => cleanupFailedStart(entry)), ); const failures = results.flatMap((result) => result.status === "rejected" ? [result.reason instanceof Error ? result.reason : new Error(String(result.reason))] : []); for (const entry of pendingSnapshot) if (entry.failure) failures.push(entry.failure); failures.push(...failedStartResults.flatMap((result) => result.status === "rejected" ? [result.reason instanceof Error ? result.reason : new Error(String(result.reason))] : [])); if (failures.length) throw new AggregateError(failures, "Browser session manager cleanup failed; retry closeAll"); })(); closingAll = promise; void promise.then( () => { if (closingAll === promise) closingAll = undefined; }, () => { if (closingAll === promise) closingAll = undefined; }, ); return promise; }, }; return manager; } /** Create a manager whose sessions and process leases are isolated by ID. */ export function createBrowserSessionManager(options: BrowserSessionManagerOptions = {}): BrowserSessionManager { return createManager(options); }