1515 lines
60 KiB
TypeScript
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);
|
|
}
|