Files
jb fd63b0ccec
fast-browser scenarios / Offline scenario contract tests (push) Failing after 44s
fast-browser scenarios / Local Chromium headless scenarios (push) Failing after 23s
fast-browser-with-jev
2026-09-20 12:24:08 -04:00

1515 lines
60 KiB
TypeScript

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<BrowserSession>;
openSetup(config: FastBrowserConfig, signal: AbortSignal): Promise<BrowserSetupSession>;
get(id: string): BrowserSession;
close(id: string): Promise<void>;
closeAll(): Promise<void>;
}
/** 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<BrowserReadiness | string | number>;
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<string>;
/** Test seam for profile cleanup. */
removeDirectory?: (path: string) => void | Promise<void>;
/** 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<void>;
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<void>;
terminalReason?: string;
}
interface RecentExit {
reason: string;
expiresAt: number;
}
interface PendingOpen {
controller: AbortController;
done: Promise<void>;
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<FastBrowserBrowserProduct, readonly string[]> = {
// 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<void> {
await new Promise<void>((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<BrowserReadiness> {
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<string> {
throwIfAborted(signal);
const remaining = Math.max(1, deadline - Date.now());
let timer: ReturnType<typeof setTimeout> | undefined;
let onAbort: (() => void) | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error("Browser did not become ready before the startup deadline")), remaining);
});
const aborted = new Promise<never>((_, 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<void>((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<void> {
return new Promise<void>((resolve, reject) => {
let timer: ReturnType<typeof setTimeout> | 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<never> {
return new Promise<never>((_, 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<boolean> {
if (child.exited) return true;
let timer: ReturnType<typeof setTimeout> | undefined;
try {
await Promise.race([
child.exitPromise,
new Promise<void>((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<number, LinuxMemberProof>();
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<number>();
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<void> {
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<boolean> {
const deadline = Date.now() + Math.max(0, timeoutMs);
while (true) {
if (!group.isAlive()) return true;
if (Date.now() >= deadline) return false;
await new Promise<void>((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<void> {
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<string> {
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<void> {
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<string, RecordState>();
const pending = new Set<PendingOpen>();
const failedStarts = new Set<FailedStart>();
const recentExits = new Map<string, RecentExit>();
let closingAll: Promise<void> | 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<void> => {
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<void> => {
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<BrowserSetupSession> => {
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<void>((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<void>((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);
}