955 lines
39 KiB
TypeScript
955 lines
39 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { EventEmitter } from "node:events";
|
|
import { existsSync, mkdtempSync, mkdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
import { hostname, tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import test from "node:test";
|
|
import { createBrowserSessionManager, type BrowserChildProcess, type BrowserSpawn } from "./browser-session.ts";
|
|
import { FastBrowserConfigError, type FastBrowserConfig } from "./config.ts";
|
|
import { profileDirectory } from "./profiles.ts";
|
|
|
|
class FakeChild extends EventEmitter implements BrowserChildProcess {
|
|
constructor() {
|
|
super();
|
|
queueMicrotask(() => this.emit("spawn"));
|
|
}
|
|
pid?: number;
|
|
ownedProcessGroup?: BrowserChildProcess["ownedProcessGroup"];
|
|
killed: string[] = [];
|
|
killBehavior?: (signal: string) => void;
|
|
kill(signal?: NodeJS.Signals | number): boolean {
|
|
const value = String(signal);
|
|
this.killed.push(value);
|
|
if (this.killBehavior) this.killBehavior(value);
|
|
else this.emit("close", null, signal);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
function dirs() {
|
|
const root = mkdtempSync(join(tmpdir(), "fast-browser-session-test-"));
|
|
const leases = join(root, "leases");
|
|
mkdirSync(leases, { mode: 0o700 });
|
|
return { root, leases, cleanup: () => rmSync(root, { recursive: true, force: true }) };
|
|
}
|
|
|
|
function config(overrides: Partial<NonNullable<FastBrowserConfig["browser"]>> = {}): FastBrowserConfig {
|
|
return {
|
|
allowedOrigins: ["https://fixture.test"],
|
|
browser: {
|
|
mode: "launch",
|
|
product: "vivaldi",
|
|
executablePath: "/fixture/vivaldi",
|
|
startUrl: "https://fixture.test/start",
|
|
...overrides,
|
|
},
|
|
};
|
|
}
|
|
|
|
class FakeProcessGroup {
|
|
alive = true;
|
|
owned = true;
|
|
leaderKilled: string[] = [];
|
|
killed: string[] = [];
|
|
killBehavior?: (signal: string) => void;
|
|
leaderKillBehavior?: (signal: string) => void;
|
|
killLeader(signal?: NodeJS.Signals | number): boolean {
|
|
const value = String(signal);
|
|
this.leaderKilled.push(value);
|
|
this.leaderKillBehavior?.(value);
|
|
return true;
|
|
}
|
|
kill(signal?: NodeJS.Signals | number): boolean {
|
|
const value = String(signal);
|
|
this.killed.push(value);
|
|
this.killBehavior?.(value);
|
|
return true;
|
|
}
|
|
isAlive(): boolean { return this.alive; }
|
|
isOwned(): boolean { return this.owned; }
|
|
}
|
|
|
|
function launchFake(children: FakeChild[], seen: { command: string; args: readonly string[]; options: Parameters<BrowserSpawn>[2] }[]): BrowserSpawn {
|
|
return (command, args, options) => {
|
|
const child = new FakeChild();
|
|
children.push(child);
|
|
seen.push({ command, args, options });
|
|
return child;
|
|
};
|
|
}
|
|
|
|
async function waitUntil(check: () => boolean, timeoutMs = 250): Promise<void> {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (!check()) {
|
|
if (Date.now() >= deadline) throw new Error("test condition did not become true before the deadline");
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
}
|
|
}
|
|
|
|
function fakeLinuxProcessAdapter(options: {
|
|
foreign?: boolean;
|
|
foreignUid?: boolean;
|
|
foreignSession?: boolean;
|
|
foreignExecutable?: boolean;
|
|
reordered?: boolean;
|
|
reparentBroker?: boolean;
|
|
lateOrphan?: boolean;
|
|
executableMisses?: number;
|
|
executableTransitions?: number;
|
|
reuseAfterOwnedCensus?: boolean;
|
|
reusedRoot?: boolean;
|
|
} = {}) {
|
|
const pid = process.pid + 1;
|
|
const executable = "/fixture/chromium";
|
|
const uid = 1000;
|
|
let profile = "/fixture/profile";
|
|
let rootAlive = true;
|
|
let groupAlive = true;
|
|
let executableReads = 0;
|
|
let leaderReuseArmed = false;
|
|
let leaderOwnedCensusCount = 0;
|
|
let leaderReused = false;
|
|
const killed: string[] = [];
|
|
const leaderKilled: string[] = [];
|
|
const root = () => ({ pid, ppid: 1, pgrp: pid, session: pid, startTime: "root", state: "S", uid, executable, commandLine: `${executable} --user-data-dir=${profile}`, owned: false });
|
|
const gpu = () => ({ pid: pid + 1, ppid: pid, pgrp: pid, session: pid, startTime: "gpu", state: "S", uid, executable, commandLine: `${executable} --type=gpu-process --user-data-dir=${profile}`, owned: false });
|
|
const broker = () => ({ pid: pid + 2, ppid: options.reparentBroker && !rootAlive ? 1 : pid + 1, pgrp: pid, session: pid, startTime: "broker", state: "S", uid, executable, commandLine: `${executable} --type=broker`, owned: false });
|
|
const lateOrphan = () => ({ pid: pid + 4, ppid: pid + 1, pgrp: pid, session: pid, startTime: "late-orphan", state: "S", uid, executable, commandLine: `${executable} --user-data-dir=${profile} --type=late-helper`, owned: false });
|
|
const foreign = () => ({
|
|
pid: pid + 3,
|
|
ppid: 999_999,
|
|
pgrp: pid,
|
|
session: options.foreignSession ? pid + 99 : pid,
|
|
startTime: "foreign",
|
|
state: "S",
|
|
uid: options.foreignUid ? uid + 1 : uid,
|
|
executable: options.foreignExecutable ? "/fixture/other" : executable,
|
|
commandLine: `${executable} --type=broker`,
|
|
owned: false,
|
|
});
|
|
return {
|
|
pid,
|
|
killed,
|
|
get executableReads() { return executableReads; },
|
|
armLeaderReuse() {
|
|
leaderReuseArmed = true;
|
|
leaderOwnedCensusCount = 0;
|
|
},
|
|
setProfile(value: string) { profile = value; },
|
|
rootExit() { rootAlive = false; },
|
|
adapter: {
|
|
identity(value: number) {
|
|
if (value !== pid) return undefined;
|
|
if (leaderReused) return { ...root(), startTime: "foreign-root" };
|
|
if (rootAlive) return root();
|
|
return options.reusedRoot ? { ...root(), startTime: "foreign-root" } : undefined;
|
|
},
|
|
executable(value: number) {
|
|
if (value === pid) {
|
|
const read = executableReads++;
|
|
if (read < (options.executableMisses ?? 0)) return undefined;
|
|
if (read < (options.executableMisses ?? 0) + (options.executableTransitions ?? 0)) return "/fixture/pre-exec";
|
|
}
|
|
return executable;
|
|
},
|
|
uid(_value: number) { return uid; },
|
|
members(_groupId: number) {
|
|
const members = !rootAlive && options.lateOrphan ? [broker(), lateOrphan()] : [gpu(), broker()];
|
|
if (rootAlive) members.unshift(root());
|
|
if (options.foreign) members.push(foreign());
|
|
if (leaderReuseArmed && options.reuseAfterOwnedCensus) {
|
|
leaderOwnedCensusCount += 1;
|
|
if (leaderOwnedCensusCount === 2) leaderReused = true;
|
|
}
|
|
return options.reordered ? members.reverse() : members;
|
|
},
|
|
groupAlive(_groupId: number) { return groupAlive; },
|
|
killGroup(_groupId: number, signal?: NodeJS.Signals | number) {
|
|
killed.push(String(signal));
|
|
groupAlive = false;
|
|
return true;
|
|
},
|
|
killProcess(_pid: number, signal?: NodeJS.Signals | number) {
|
|
leaderKilled.push(String(signal));
|
|
groupAlive = false;
|
|
return true;
|
|
},
|
|
},
|
|
leaderKilled,
|
|
};
|
|
}
|
|
|
|
test("malformed child PIDs never reach process-group or root signals", async () => {
|
|
for (const pid of [0, 1, -1, process.pid, Number.NaN, 1.5, Number.MAX_SAFE_INTEGER + 1]) {
|
|
const p = dirs();
|
|
let child!: FakeChild;
|
|
const manager = createBrowserSessionManager({
|
|
leaseDirectory: p.leases,
|
|
spawn: () => {
|
|
child = new FakeChild();
|
|
child.pid = pid;
|
|
child.ownedProcessGroup = new FakeProcessGroup();
|
|
return child;
|
|
},
|
|
mkdtemp: (prefix) => mkdtempSync(join(p.root, prefix)),
|
|
readiness: async () => 19199,
|
|
});
|
|
await assert.rejects(manager.open(config(), new AbortController().signal), /startup cleanup failed/);
|
|
assert.deepEqual(child.killed, []);
|
|
await assert.rejects(manager.closeAll(), /cleanup failed/);
|
|
assert.deepEqual(child.killed, []);
|
|
p.cleanup();
|
|
}
|
|
});
|
|
|
|
test("owned Linux census accepts a profile-less Chromium broker through owned ancestry", async () => {
|
|
const p = dirs();
|
|
const fixture = fakeLinuxProcessAdapter({ reordered: true, reparentBroker: true });
|
|
let child!: FakeChild;
|
|
const manager = createBrowserSessionManager({
|
|
leaseDirectory: p.leases,
|
|
linuxProcessAdapter: fixture.adapter,
|
|
spawn: () => { child = new FakeChild(); child.pid = fixture.pid; return child; },
|
|
mkdtemp: (prefix) => mkdtempSync(join(p.root, prefix)),
|
|
readiness: async (directory) => { fixture.setProfile(directory); return 19200; },
|
|
});
|
|
const session = await manager.open(config(), new AbortController().signal);
|
|
fixture.rootExit();
|
|
child.emit("close", 0, null);
|
|
await waitUntil(() => fixture.killed.length === 1);
|
|
assert.deepEqual(fixture.killed, ["SIGTERM"]);
|
|
await waitUntil(() => {
|
|
try { manager.get(session.id); return false; }
|
|
catch (error) { return error instanceof Error && /no longer available/.test(error.message); }
|
|
});
|
|
assert.throws(() => manager.get(session.id), /no longer available: browser process closed/);
|
|
p.cleanup();
|
|
});
|
|
|
|
test("startup retries ownership while the executable identity stabilizes", async () => {
|
|
const p = dirs();
|
|
const fixture = fakeLinuxProcessAdapter({ executableMisses: 2 });
|
|
const manager = createBrowserSessionManager({
|
|
leaseDirectory: p.leases,
|
|
linuxProcessAdapter: fixture.adapter,
|
|
spawn: () => { const child = new FakeChild(); child.pid = fixture.pid; return child; },
|
|
mkdtemp: (prefix) => mkdtempSync(join(p.root, prefix)),
|
|
readiness: async (directory) => { fixture.setProfile(directory); return 19201; },
|
|
});
|
|
const session = await manager.open(config(), new AbortController().signal);
|
|
assert.ok(fixture.executableReads >= 3);
|
|
await manager.close(session.id);
|
|
p.cleanup();
|
|
});
|
|
|
|
test("a transitioning executable identity is retried before handoff", async () => {
|
|
const p = dirs();
|
|
const fixture = fakeLinuxProcessAdapter({ executableTransitions: 1 });
|
|
const manager = createBrowserSessionManager({
|
|
leaseDirectory: p.leases,
|
|
linuxProcessAdapter: fixture.adapter,
|
|
spawn: () => { const child = new FakeChild(); child.pid = fixture.pid; return child; },
|
|
mkdtemp: (prefix) => mkdtempSync(join(p.root, prefix)),
|
|
readiness: async (directory) => { fixture.setProfile(directory); return 19202; },
|
|
});
|
|
const session = await manager.open(config(), new AbortController().signal);
|
|
assert.ok(fixture.executableReads >= 2);
|
|
await manager.close(session.id);
|
|
p.cleanup();
|
|
});
|
|
|
|
test("persistent executable uncertainty does not hand out an uncloseable session", async () => {
|
|
const p = dirs();
|
|
const fixture = fakeLinuxProcessAdapter({ executableMisses: Number.MAX_SAFE_INTEGER });
|
|
const manager = createBrowserSessionManager({
|
|
leaseDirectory: p.leases,
|
|
linuxProcessAdapter: fixture.adapter,
|
|
spawn: () => { const child = new FakeChild(); child.pid = fixture.pid; return child; },
|
|
mkdtemp: (prefix) => mkdtempSync(join(p.root, prefix)),
|
|
readiness: async (directory) => { fixture.setProfile(directory); return 19202; },
|
|
});
|
|
await assert.rejects(manager.open(config(), new AbortController().signal), /startup cleanup failed/);
|
|
assert.deepEqual(fixture.killed, []);
|
|
await assert.rejects(manager.closeAll(), /cleanup failed/);
|
|
p.cleanup();
|
|
});
|
|
|
|
test("manual setup captures process provenance before later root exit", async () => {
|
|
const p = dirs();
|
|
const fixture = fakeLinuxProcessAdapter({ reordered: true, reparentBroker: true });
|
|
let child!: FakeChild;
|
|
const manager = createBrowserSessionManager({
|
|
profileRoot: join(p.root, "profiles"),
|
|
leaseDirectory: p.leases,
|
|
linuxProcessAdapter: fixture.adapter,
|
|
spawn: (_command, args) => {
|
|
fixture.setProfile(String(args.find((arg) => arg.startsWith("--user-data-dir="))).slice("--user-data-dir=".length));
|
|
child = new FakeChild();
|
|
child.pid = fixture.pid;
|
|
queueMicrotask(() => child.emit("spawn"));
|
|
return child;
|
|
},
|
|
});
|
|
const session = await manager.openSetup(config({ profile: "evebot" }), new AbortController().signal);
|
|
fixture.rootExit();
|
|
child.emit("close", 0, null);
|
|
await waitUntil(() => fixture.killed.length === 1);
|
|
assert.throws(() => manager.get(session.id), /no longer available: browser process closed/);
|
|
p.cleanup();
|
|
});
|
|
|
|
test("foreign UID/session/executable/parent lookalikes remain unkillable", async () => {
|
|
for (const variant of [{}, { foreignUid: true }, { foreignSession: true }, { foreignExecutable: true }]) {
|
|
const p = dirs();
|
|
const fixture = fakeLinuxProcessAdapter({ foreign: true, ...variant });
|
|
const manager = createBrowserSessionManager({
|
|
leaseDirectory: p.leases,
|
|
linuxProcessAdapter: fixture.adapter,
|
|
spawn: () => { const child = new FakeChild(); child.pid = fixture.pid; return child; },
|
|
mkdtemp: (prefix) => mkdtempSync(join(p.root, prefix)),
|
|
readiness: async (directory) => { fixture.setProfile(directory); return 19202; },
|
|
});
|
|
await assert.rejects(manager.open(config(), new AbortController().signal), /startup cleanup failed/);
|
|
assert.deepEqual(fixture.killed, []);
|
|
await assert.rejects(manager.closeAll(), /cleanup failed/);
|
|
p.cleanup();
|
|
}
|
|
});
|
|
|
|
test("a new member with an absent reused parent remains unkillable after leader exit", async () => {
|
|
const p = dirs();
|
|
const fixture = fakeLinuxProcessAdapter({ lateOrphan: true, reparentBroker: true });
|
|
let child!: FakeChild;
|
|
const manager = createBrowserSessionManager({
|
|
leaseDirectory: p.leases,
|
|
linuxProcessAdapter: fixture.adapter,
|
|
spawn: () => { child = new FakeChild(); child.pid = fixture.pid; return child; },
|
|
mkdtemp: (prefix) => mkdtempSync(join(p.root, prefix)),
|
|
readiness: async (directory) => { fixture.setProfile(directory); return 19203; },
|
|
});
|
|
const session = await manager.open(config(), new AbortController().signal);
|
|
fixture.rootExit();
|
|
child.emit("close", 0, null);
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
assert.deepEqual(fixture.killed, []);
|
|
assert.doesNotThrow(() => manager.get(session.id));
|
|
await assert.rejects(manager.closeAll(), /cleanup failed/);
|
|
p.cleanup();
|
|
});
|
|
|
|
test("a leader exit before the first ownership census remains fail-closed", async () => {
|
|
const p = dirs();
|
|
const fixture = fakeLinuxProcessAdapter();
|
|
const manager = createBrowserSessionManager({
|
|
leaseDirectory: p.leases,
|
|
linuxProcessAdapter: fixture.adapter,
|
|
spawn: () => { const child = new FakeChild(); child.pid = fixture.pid; return child; },
|
|
mkdtemp: (prefix) => mkdtempSync(join(p.root, prefix)),
|
|
readiness: async (directory) => { fixture.setProfile(directory); fixture.rootExit(); return 19203; },
|
|
});
|
|
await assert.rejects(manager.open(config(), new AbortController().signal), /startup cleanup failed/);
|
|
assert.deepEqual(fixture.killed, []);
|
|
await assert.rejects(manager.closeAll(), /cleanup failed/);
|
|
p.cleanup();
|
|
});
|
|
|
|
test("a reused Linux leader identity remains unkillable", async () => {
|
|
const p = dirs();
|
|
const fixture = fakeLinuxProcessAdapter({ reusedRoot: true });
|
|
let child!: FakeChild;
|
|
const manager = createBrowserSessionManager({
|
|
leaseDirectory: p.leases,
|
|
linuxProcessAdapter: fixture.adapter,
|
|
spawn: () => { child = new FakeChild(); child.pid = fixture.pid; return child; },
|
|
mkdtemp: (prefix) => mkdtempSync(join(p.root, prefix)),
|
|
readiness: async (directory) => { fixture.setProfile(directory); return 19204; },
|
|
});
|
|
const session = await manager.open(config(), new AbortController().signal);
|
|
fixture.rootExit();
|
|
child.emit("close", 0, null);
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
assert.deepEqual(fixture.killed, []);
|
|
assert.doesNotThrow(() => manager.get(session.id));
|
|
await assert.rejects(manager.closeAll(), /cleanup failed/);
|
|
p.cleanup();
|
|
});
|
|
|
|
test("isolates launched sessions and cleans one without touching another", async () => {
|
|
const p = dirs();
|
|
const children: FakeChild[] = [];
|
|
const seen: { command: string; args: readonly string[]; options: Parameters<BrowserSpawn>[2] }[] = [];
|
|
let port = 19200;
|
|
const manager = createBrowserSessionManager({
|
|
leaseDirectory: p.leases,
|
|
spawn: launchFake(children, seen),
|
|
mkdtemp: (prefix) => mkdtempSync(join(p.root, prefix)),
|
|
readiness: async () => port++,
|
|
});
|
|
const first = await manager.open(config(), new AbortController().signal);
|
|
const second = await manager.open(config(), new AbortController().signal);
|
|
assert.notEqual(first.id, second.id);
|
|
assert.notEqual(first.config.cdpUrl, second.config.cdpUrl);
|
|
assert.equal(first.owned, true);
|
|
assert.match(seen[0]!.args.join(" "), /--remote-debugging-port=0/);
|
|
await manager.close(first.id);
|
|
assert.equal(children[0]!.killed.length, 1);
|
|
assert.equal(children[1]!.killed.length, 0);
|
|
assert.doesNotThrow(() => manager.get(second.id));
|
|
await manager.closeAll();
|
|
p.cleanup();
|
|
});
|
|
|
|
test("unrestricted origin policy permits a launch start URL on any HTTP(S) origin", async () => {
|
|
const p = dirs();
|
|
const manager = createBrowserSessionManager({
|
|
leaseDirectory: p.leases,
|
|
spawn: launchFake([], []),
|
|
mkdtemp: (prefix) => mkdtempSync(join(p.root, prefix)),
|
|
readiness: async () => 19201,
|
|
});
|
|
const session = await manager.open({
|
|
...config({ startUrl: "https://other.example/start" }),
|
|
allowedOrigins: "*",
|
|
}, new AbortController().signal);
|
|
await manager.close(session.id);
|
|
p.cleanup();
|
|
});
|
|
|
|
test("headless is explicit and Vivaldi has no product fallback", async () => {
|
|
const p = dirs();
|
|
const seen: { command: string; args: readonly string[]; options: Parameters<BrowserSpawn>[2] }[] = [];
|
|
const manager = createBrowserSessionManager({
|
|
leaseDirectory: p.leases,
|
|
spawn: launchFake([], seen),
|
|
mkdtemp: (prefix) => mkdtempSync(join(p.root, prefix)),
|
|
readiness: async () => 19201,
|
|
});
|
|
await manager.open(config({ headless: true }), new AbortController().signal);
|
|
assert.equal(seen[0]!.command, "/fixture/vivaldi");
|
|
assert.ok(seen[0]!.args.includes("--headless=new"));
|
|
assert.ok(seen[0]!.args.some((arg) => arg.startsWith("--user-data-dir=")));
|
|
assert.equal(seen[0]!.args.some((arg) => arg.startsWith("--disable-") || arg === "--password-store=basic" || arg === "--use-mock-keychain"), false);
|
|
await manager.closeAll();
|
|
p.cleanup();
|
|
});
|
|
|
|
test("launch environment excludes provider keys but keeps desktop context", async () => {
|
|
const p = dirs();
|
|
const seen: { command: string; args: readonly string[]; options: Parameters<BrowserSpawn>[2] }[] = [];
|
|
const previous = {
|
|
TYPESAFE_API_KEY: process.env.TYPESAFE_API_KEY,
|
|
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
|
|
XDG_SESSION_TYPE: process.env.XDG_SESSION_TYPE,
|
|
};
|
|
process.env.TYPESAFE_API_KEY = "secret-provider-key";
|
|
process.env.OPENAI_API_KEY = "secret-openai-key";
|
|
process.env.XDG_SESSION_TYPE = "wayland";
|
|
try {
|
|
const manager = createBrowserSessionManager({
|
|
leaseDirectory: p.leases,
|
|
spawn: launchFake([], seen),
|
|
mkdtemp: (prefix) => mkdtempSync(join(p.root, prefix)),
|
|
readiness: async () => 19308,
|
|
});
|
|
await manager.open(config(), new AbortController().signal);
|
|
assert.equal(seen[0]!.options.env.TYPESAFE_API_KEY, undefined);
|
|
assert.equal(seen[0]!.options.env.OPENAI_API_KEY, undefined);
|
|
assert.equal(seen[0]!.options.env.XDG_SESSION_TYPE, "wayland");
|
|
await manager.closeAll();
|
|
} finally {
|
|
if (previous.TYPESAFE_API_KEY === undefined) delete process.env.TYPESAFE_API_KEY;
|
|
else process.env.TYPESAFE_API_KEY = previous.TYPESAFE_API_KEY;
|
|
if (previous.OPENAI_API_KEY === undefined) delete process.env.OPENAI_API_KEY;
|
|
else process.env.OPENAI_API_KEY = previous.OPENAI_API_KEY;
|
|
if (previous.XDG_SESSION_TYPE === undefined) delete process.env.XDG_SESSION_TYPE;
|
|
else process.env.XDG_SESSION_TYPE = previous.XDG_SESSION_TYPE;
|
|
p.cleanup();
|
|
}
|
|
});
|
|
|
|
test("attach leases normalize loopback aliases across managers", async () => {
|
|
const p = dirs();
|
|
const first = createBrowserSessionManager({ leaseDirectory: p.leases });
|
|
const second = createBrowserSessionManager({ leaseDirectory: p.leases });
|
|
const base = { allowedOrigins: ["https://fixture.test"], cdpUrl: "http://localhost:19300" };
|
|
const session = await first.open(base, new AbortController().signal);
|
|
assert.equal(session.owned, false);
|
|
await assert.rejects(second.open({ ...base, cdpUrl: "http://127.0.0.1:19300" }, new AbortController().signal), /already has a lease.*stale/i);
|
|
await first.close(session.id);
|
|
const reopened = await second.open({ ...base, cdpUrl: "http://[::1]:19300" }, new AbortController().signal);
|
|
await second.close(reopened.id);
|
|
p.cleanup();
|
|
});
|
|
|
|
test("named automation profiles retain data and remove stale CDP metadata only after leasing", async () => {
|
|
const p = dirs();
|
|
const root = join(p.root, "profiles");
|
|
const profile = profileDirectory("evebot", { profileRoot: root });
|
|
writeFileSync(join(profile, "DevToolsActivePort"), "9222\n/devtools/browser/stale\n");
|
|
writeFileSync(join(profile, "sentinel-cookie.txt"), "persistent-login-cookie");
|
|
const seen: { command: string; args: readonly string[]; options: Parameters<BrowserSpawn>[2] }[] = [];
|
|
const manager = createBrowserSessionManager({
|
|
leaseDirectory: p.leases,
|
|
profileRoot: root,
|
|
spawn: launchFake([], seen),
|
|
readiness: async (directory) => {
|
|
assert.equal(existsSync(join(directory, "DevToolsActivePort")), false);
|
|
return 19301;
|
|
},
|
|
});
|
|
const session = await manager.open(config({ profile: "evebot" }), new AbortController().signal);
|
|
await manager.close(session.id);
|
|
assert.equal(existsSync(profile), true);
|
|
assert.equal(readFileSync(join(profile, "sentinel-cookie.txt"), "utf8"), "persistent-login-cookie");
|
|
|
|
const reopenedManager = createBrowserSessionManager({
|
|
leaseDirectory: p.leases,
|
|
profileRoot: root,
|
|
spawn: launchFake([], []),
|
|
readiness: async (directory) => {
|
|
assert.equal(readFileSync(join(directory, "sentinel-cookie.txt"), "utf8"), "persistent-login-cookie");
|
|
return 19305;
|
|
},
|
|
});
|
|
const reopened = await reopenedManager.open(config({ profile: "evebot" }), new AbortController().signal);
|
|
await reopenedManager.close(reopened.id);
|
|
|
|
const failedManager = createBrowserSessionManager({
|
|
leaseDirectory: p.leases,
|
|
profileRoot: root,
|
|
spawn: launchFake([], []),
|
|
readiness: async () => { throw new Error("startup failure"); },
|
|
});
|
|
await assert.rejects(failedManager.open(config({ profile: "evebot" }), new AbortController().signal), /startup failure/);
|
|
assert.equal(readFileSync(join(profile, "sentinel-cookie.txt"), "utf8"), "persistent-login-cookie");
|
|
p.cleanup();
|
|
});
|
|
|
|
test("manual setup without saved launch/profile settings gives actionable configuration errors", async () => {
|
|
const manager = createBrowserSessionManager({
|
|
spawn: () => { throw new Error("must not launch for missing configuration"); },
|
|
});
|
|
const signal = new AbortController().signal;
|
|
for (const [input, field] of [
|
|
[{ allowedOrigins: ["https://fixture.test"], cdpUrl: "http://127.0.0.1:9222" }, "browser.mode"],
|
|
[config(), "browser.profile"],
|
|
] as const) {
|
|
await assert.rejects(manager.openSetup(input as FastBrowserConfig, signal), (error: unknown) => {
|
|
assert(error instanceof FastBrowserConfigError);
|
|
assert(error.message.includes(field));
|
|
assert.match(error.message, /Changes save automatically/);
|
|
return true;
|
|
});
|
|
}
|
|
});
|
|
|
|
test("manual setup is visible, has no CDP flags, and cannot be automated", async () => {
|
|
const p = dirs();
|
|
const root = join(p.root, "profiles");
|
|
let child!: FakeChild;
|
|
const seen: { command: string; args: readonly string[]; options: Parameters<BrowserSpawn>[2] }[] = [];
|
|
const manager = createBrowserSessionManager({
|
|
profileRoot: root,
|
|
spawn: (command, args, options) => {
|
|
child = new FakeChild();
|
|
seen.push({ command, args, options });
|
|
queueMicrotask(() => child.emit("spawn"));
|
|
return child;
|
|
},
|
|
});
|
|
const input = config({ profile: "evebot", headless: true });
|
|
const session = await manager.openSetup(input, new AbortController().signal);
|
|
assert.equal(session.owned, true);
|
|
assert.equal(session.config.browser?.headless, false);
|
|
assert.equal(input.browser?.headless, true);
|
|
assert.equal(seen[0]!.args.some((arg) => arg.includes("remote-debugging") || arg.startsWith("--headless")), false);
|
|
assert.throws(() => manager.get(session.id), /manual-only.*cannot be automated/i);
|
|
const automatedManager = createBrowserSessionManager({
|
|
profileRoot: root,
|
|
leaseDirectory: join(p.root, "automation-leases"),
|
|
spawn: launchFake([], []),
|
|
readiness: async () => 19306,
|
|
});
|
|
await assert.rejects(automatedManager.open(config({ profile: "evebot" }), new AbortController().signal), /active or stale lease/i);
|
|
await manager.close(session.id);
|
|
assert.equal(existsSync(profileDirectory("evebot", { profileRoot: root })), true);
|
|
const automated = await automatedManager.open(config({ profile: "evebot" }), new AbortController().signal);
|
|
await automatedManager.close(automated.id);
|
|
p.cleanup();
|
|
});
|
|
|
|
test("different named profiles can launch in parallel", async () => {
|
|
const p = dirs();
|
|
const root = join(p.root, "profiles");
|
|
let port = 19307;
|
|
const manager = createBrowserSessionManager({
|
|
profileRoot: root,
|
|
leaseDirectory: p.leases,
|
|
spawn: () => new FakeChild(),
|
|
readiness: async () => port++,
|
|
});
|
|
const [first, second] = await Promise.all([
|
|
manager.open(config({ profile: "evebot" }), new AbortController().signal),
|
|
manager.open(config({ profile: "otherbot" }), new AbortController().signal),
|
|
]);
|
|
assert.notEqual(first.config.cdpUrl, second.config.cdpUrl);
|
|
await manager.closeAll();
|
|
p.cleanup();
|
|
});
|
|
|
|
test("post-ready exit aborts and cleans its session", async () => {
|
|
const p = dirs();
|
|
let child!: FakeChild;
|
|
const manager = createBrowserSessionManager({
|
|
leaseDirectory: p.leases,
|
|
spawn: () => { child = new FakeChild(); return child; },
|
|
mkdtemp: (prefix) => mkdtempSync(join(p.root, prefix)),
|
|
readiness: async () => 19302,
|
|
});
|
|
const session = await manager.open(config(), new AbortController().signal);
|
|
child.emit("close", 0, null);
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
assert.equal(session.signal.aborted, true);
|
|
assert.throws(() => manager.get(session.id), /no longer available: browser process closed \(0\)/);
|
|
p.cleanup();
|
|
});
|
|
|
|
test("recent exit diagnostics are safe and expire", async () => {
|
|
const p = dirs();
|
|
let child!: FakeChild;
|
|
const manager = createBrowserSessionManager({
|
|
leaseDirectory: p.leases,
|
|
recentExitTtlMs: 50,
|
|
spawn: () => { child = new FakeChild(); return child; },
|
|
mkdtemp: (prefix) => mkdtempSync(join(p.root, prefix)),
|
|
readiness: async () => 19315,
|
|
});
|
|
const session = await manager.open(config(), new AbortController().signal);
|
|
child.emit("error", new Error("stderr=https://secret.example/private?token=secret"));
|
|
await waitUntil(() => {
|
|
try { manager.get(session.id); return false; }
|
|
catch (error) { return error instanceof Error && /no longer available/.test(error.message); }
|
|
});
|
|
assert.throws(() => manager.get(session.id), (error: unknown) => {
|
|
assert(error instanceof Error);
|
|
assert.match(error.message, /browser process/);
|
|
assert.doesNotMatch(error.message, /stderr|https?:|secret/i);
|
|
return true;
|
|
});
|
|
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
assert.throws(() => manager.get(session.id), /Unknown browser session/);
|
|
p.cleanup();
|
|
});
|
|
|
|
test("normal close and parent abort retain safe closed-session reasons", async () => {
|
|
const p = dirs();
|
|
const manager = createBrowserSessionManager({
|
|
leaseDirectory: p.leases,
|
|
spawn: launchFake([], []),
|
|
mkdtemp: (prefix) => mkdtempSync(join(p.root, prefix)),
|
|
readiness: async () => 19316,
|
|
});
|
|
const normal = await manager.open(config(), new AbortController().signal);
|
|
await manager.close(normal.id);
|
|
assert.throws(() => manager.get(normal.id), /no longer available: session closed/);
|
|
|
|
const parent = new AbortController();
|
|
const aborted = await manager.open(config(), parent.signal);
|
|
parent.abort(new Error("secret=https://private.example/token"));
|
|
await waitUntil(() => {
|
|
try { manager.get(aborted.id); return false; }
|
|
catch (error) { return error instanceof Error && /no longer available/.test(error.message); }
|
|
});
|
|
assert.throws(() => manager.get(aborted.id), (error: unknown) => {
|
|
assert(error instanceof Error);
|
|
assert.match(error.message, /session aborted/);
|
|
assert.doesNotMatch(error.message, /secret|https?:|private/i);
|
|
return true;
|
|
});
|
|
p.cleanup();
|
|
});
|
|
|
|
test("a root exit does not release a live owned descendant group", async () => {
|
|
const p = dirs();
|
|
const group = new FakeProcessGroup();
|
|
let child!: FakeChild;
|
|
const manager = createBrowserSessionManager({
|
|
leaseDirectory: p.leases,
|
|
spawn: () => {
|
|
child = new FakeChild();
|
|
child.pid = process.pid + 1;
|
|
child.ownedProcessGroup = group;
|
|
return child;
|
|
},
|
|
mkdtemp: (prefix) => mkdtempSync(join(p.root, prefix)),
|
|
readiness: async () => 19309,
|
|
});
|
|
const session = await manager.open(config(), new AbortController().signal);
|
|
child.emit("close", 0, null);
|
|
await waitUntil(() => group.killed.length === 1);
|
|
assert.deepEqual(group.leaderKilled, []);
|
|
assert.deepEqual(group.killed, ["SIGTERM"]);
|
|
assert.equal(group.alive, true);
|
|
assert.doesNotThrow(() => manager.get(session.id));
|
|
group.alive = false;
|
|
await assert.doesNotReject(manager.closeAll());
|
|
assert.throws(() => manager.get(session.id), /no longer available: browser process closed \(0\)/);
|
|
p.cleanup();
|
|
});
|
|
|
|
test("owned group cleanup escalates to bounded SIGKILL", async () => {
|
|
const p = dirs();
|
|
const group = new FakeProcessGroup();
|
|
group.leaderKillBehavior = () => { /* keep descendants alive for escalation */ };
|
|
group.killBehavior = (signal) => { if (signal === "SIGKILL") group.alive = false; };
|
|
const manager = createBrowserSessionManager({
|
|
leaseDirectory: p.leases,
|
|
closeTimeoutMs: 10,
|
|
spawn: () => {
|
|
const child = new FakeChild();
|
|
child.pid = process.pid + 1;
|
|
child.ownedProcessGroup = group;
|
|
return child;
|
|
},
|
|
mkdtemp: (prefix) => mkdtempSync(join(p.root, prefix)),
|
|
readiness: async () => 19310,
|
|
});
|
|
const session = await manager.open(config(), new AbortController().signal);
|
|
await manager.close(session.id);
|
|
assert.deepEqual(group.leaderKilled, ["SIGTERM"]);
|
|
assert.deepEqual(group.killed, ["SIGTERM", "SIGKILL"]);
|
|
p.cleanup();
|
|
});
|
|
|
|
test("verified leader shutdown precedes descendant escalation", async () => {
|
|
const p = dirs();
|
|
const group = new FakeProcessGroup();
|
|
group.leaderKillBehavior = () => { group.alive = false; };
|
|
const manager = createBrowserSessionManager({
|
|
leaseDirectory: p.leases,
|
|
closeTimeoutMs: 10,
|
|
spawn: () => {
|
|
const child = new FakeChild();
|
|
child.pid = process.pid + 1;
|
|
child.ownedProcessGroup = group;
|
|
return child;
|
|
},
|
|
mkdtemp: (prefix) => mkdtempSync(join(p.root, prefix)),
|
|
readiness: async () => 19317,
|
|
});
|
|
const session = await manager.open(config(), new AbortController().signal);
|
|
await manager.close(session.id);
|
|
assert.deepEqual(group.leaderKilled, ["SIGTERM"]);
|
|
assert.deepEqual(group.killed, []);
|
|
p.cleanup();
|
|
});
|
|
|
|
test("native ownership adapter signals the verified leader before its group", async () => {
|
|
const p = dirs();
|
|
const fixture = fakeLinuxProcessAdapter();
|
|
const manager = createBrowserSessionManager({
|
|
leaseDirectory: p.leases,
|
|
linuxProcessAdapter: fixture.adapter,
|
|
spawn: () => { const child = new FakeChild(); child.pid = fixture.pid; return child; },
|
|
mkdtemp: (prefix) => mkdtempSync(join(p.root, prefix)),
|
|
readiness: async (directory) => { fixture.setProfile(directory); return 19318; },
|
|
});
|
|
const session = await manager.open(config(), new AbortController().signal);
|
|
await manager.close(session.id);
|
|
assert.deepEqual(fixture.leaderKilled, ["SIGTERM"]);
|
|
assert.deepEqual(fixture.killed, []);
|
|
p.cleanup();
|
|
});
|
|
|
|
test("leader reuse between census and final probe produces no positive leader signal", async () => {
|
|
const p = dirs();
|
|
const fixture = fakeLinuxProcessAdapter({ reuseAfterOwnedCensus: true });
|
|
const manager = createBrowserSessionManager({
|
|
leaseDirectory: p.leases,
|
|
linuxProcessAdapter: fixture.adapter,
|
|
spawn: () => { const child = new FakeChild(); child.pid = fixture.pid; return child; },
|
|
mkdtemp: (prefix) => mkdtempSync(join(p.root, prefix)),
|
|
readiness: async (directory) => { fixture.setProfile(directory); return 19319; },
|
|
});
|
|
const session = await manager.open(config(), new AbortController().signal);
|
|
fixture.armLeaderReuse();
|
|
await assert.rejects(manager.close(session.id), /leader ownership changed|ownership could not be confirmed/);
|
|
assert.deepEqual(fixture.leaderKilled, []);
|
|
assert.deepEqual(fixture.killed, []);
|
|
p.cleanup();
|
|
});
|
|
|
|
test("leader exit skips reused leader PID and escalates only to the proven group", async () => {
|
|
const p = dirs();
|
|
const fixture = fakeLinuxProcessAdapter({ reparentBroker: true });
|
|
let child!: FakeChild;
|
|
const manager = createBrowserSessionManager({
|
|
leaseDirectory: p.leases,
|
|
linuxProcessAdapter: fixture.adapter,
|
|
spawn: () => { child = new FakeChild(); child.pid = fixture.pid; return child; },
|
|
mkdtemp: (prefix) => mkdtempSync(join(p.root, prefix)),
|
|
readiness: async (directory) => { fixture.setProfile(directory); return 19319; },
|
|
});
|
|
const session = await manager.open(config(), new AbortController().signal);
|
|
fixture.rootExit();
|
|
await manager.close(session.id);
|
|
assert.deepEqual(fixture.leaderKilled, []);
|
|
assert.deepEqual(fixture.killed, ["SIGTERM"]);
|
|
p.cleanup();
|
|
});
|
|
|
|
test("reused leader identity refuses both leader and group signals", async () => {
|
|
const p = dirs();
|
|
const fixture = fakeLinuxProcessAdapter({ reusedRoot: true });
|
|
const manager = createBrowserSessionManager({
|
|
leaseDirectory: p.leases,
|
|
linuxProcessAdapter: fixture.adapter,
|
|
spawn: () => { const child = new FakeChild(); child.pid = fixture.pid; return child; },
|
|
mkdtemp: (prefix) => mkdtempSync(join(p.root, prefix)),
|
|
readiness: async (directory) => { fixture.setProfile(directory); return 19320; },
|
|
});
|
|
const session = await manager.open(config(), new AbortController().signal);
|
|
fixture.rootExit();
|
|
await assert.rejects(manager.close(session.id), /ownership could not be confirmed/);
|
|
assert.deepEqual(fixture.leaderKilled, []);
|
|
assert.deepEqual(fixture.killed, []);
|
|
p.cleanup();
|
|
});
|
|
|
|
test("SIGKILL waits for confirmation and unkillable cleanup preserves state", async () => {
|
|
const p = dirs();
|
|
let child!: FakeChild;
|
|
const manager = createBrowserSessionManager({
|
|
leaseDirectory: p.leases,
|
|
closeTimeoutMs: 30,
|
|
spawn: () => {
|
|
child = new FakeChild();
|
|
child.killBehavior = (signal) => { if (signal === "SIGKILL") setTimeout(() => child.emit("close", null, signal), 15); };
|
|
return child;
|
|
},
|
|
mkdtemp: (prefix) => mkdtempSync(join(p.root, prefix)),
|
|
readiness: async () => 19303,
|
|
});
|
|
const session = await manager.open(config(), new AbortController().signal);
|
|
await manager.close(session.id);
|
|
assert.deepEqual(child.killed, ["SIGTERM", "SIGKILL"]);
|
|
p.cleanup();
|
|
});
|
|
|
|
test("uncertain SingletonLock cleanup keeps the named profile lease", async () => {
|
|
const p = dirs();
|
|
const root = join(p.root, "profiles");
|
|
let child!: FakeChild;
|
|
const manager = createBrowserSessionManager({
|
|
profileRoot: root,
|
|
leaseDirectory: p.leases,
|
|
spawn: () => {
|
|
child = new FakeChild();
|
|
child.pid = 29991;
|
|
const group = new FakeProcessGroup();
|
|
group.leaderKillBehavior = () => { group.alive = false; };
|
|
group.killBehavior = () => { group.alive = false; child.emit("close", null, "SIGTERM"); };
|
|
child.ownedProcessGroup = group;
|
|
return child;
|
|
},
|
|
readiness: async () => {
|
|
symlinkSync(`${hostname()}-29992`, join(root, "evebot", "SingletonLock"));
|
|
return 19311;
|
|
},
|
|
});
|
|
const session = await manager.open(config({ profile: "evebot" }), new AbortController().signal);
|
|
await assert.rejects(manager.close(session.id), /cleanup failed|SingletonLock/i);
|
|
assert.equal(readlinkSync(join(root, "evebot", "SingletonLock")), `${hostname()}-29992`);
|
|
const second = createBrowserSessionManager({ profileRoot: root, leaseDirectory: join(p.root, "other-leases"), spawn: launchFake([], []), readiness: async () => 19312 });
|
|
await assert.rejects(second.open(config({ profile: "evebot" }), new AbortController().signal), /active or stale lease/i);
|
|
// The foreign marker remains untouched; fixture cleanup removes the temp root.
|
|
assert.equal(readlinkSync(join(root, "evebot", "SingletonLock")), `${hostname()}-29992`);
|
|
p.cleanup();
|
|
});
|
|
|
|
test("a proven owned marker is removed and the named profile can be reused", async () => {
|
|
const p = dirs();
|
|
const root = join(p.root, "profiles");
|
|
let nextPid = 29993;
|
|
const spawn = () => {
|
|
const child = new FakeChild();
|
|
child.pid = nextPid++;
|
|
const group = new FakeProcessGroup();
|
|
group.leaderKillBehavior = () => { group.alive = false; child.emit("close", null, "SIGTERM"); };
|
|
group.killBehavior = () => { group.alive = false; child.emit("close", null, "SIGTERM"); };
|
|
child.ownedProcessGroup = group;
|
|
return child;
|
|
};
|
|
let firstChild!: FakeChild;
|
|
const manager = createBrowserSessionManager({
|
|
profileRoot: root,
|
|
leaseDirectory: p.leases,
|
|
spawn: () => {
|
|
firstChild = spawn();
|
|
return firstChild;
|
|
},
|
|
readiness: async () => {
|
|
symlinkSync(`${hostname()}-${firstChild.pid}`, join(root, "evebot", "SingletonLock"));
|
|
return 19313;
|
|
},
|
|
});
|
|
const first = await manager.open(config({ profile: "evebot" }), new AbortController().signal);
|
|
await manager.close(first.id);
|
|
assert.equal(existsSync(join(root, "evebot", "SingletonLock")), false);
|
|
const reopenedManager = createBrowserSessionManager({
|
|
profileRoot: root,
|
|
leaseDirectory: join(p.root, "reopen-leases"),
|
|
spawn: launchFake([], []),
|
|
readiness: async () => 19314,
|
|
});
|
|
const reopened = await reopenedManager.open(config({ profile: "evebot" }), new AbortController().signal);
|
|
await reopenedManager.close(reopened.id);
|
|
p.cleanup();
|
|
});
|
|
|
|
test("failed startup retains an unkillable child for closeAll retry", async () => {
|
|
const p = dirs();
|
|
const controller = new AbortController();
|
|
let child!: FakeChild;
|
|
const profiles: string[] = [];
|
|
const manager = createBrowserSessionManager({
|
|
closeTimeoutMs: 5,
|
|
spawn: () => { child = new FakeChild(); child.killBehavior = () => undefined; return child; },
|
|
mkdtemp: (prefix) => { const path = mkdtempSync(join(p.root, prefix)); profiles.push(path); return path; },
|
|
readiness: async (_path, signal) => {
|
|
await new Promise<void>((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }));
|
|
throw new Error("startup cancelled");
|
|
},
|
|
});
|
|
const opening = manager.open(config(), controller.signal);
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
controller.abort();
|
|
await assert.rejects(opening, /startup cleanup failed/);
|
|
assert.equal(existsSync(profiles[0]!), true);
|
|
await assert.rejects(manager.closeAll(), /cleanup failed/);
|
|
child.emit("close", null, "SIGKILL");
|
|
await manager.closeAll();
|
|
assert.equal(existsSync(profiles[0]!), false);
|
|
p.cleanup();
|
|
});
|
|
|
|
test("closeAll blocks opens while cleaning and reports cleanup failures", async () => {
|
|
const p = dirs();
|
|
let child!: FakeChild;
|
|
const manager = createBrowserSessionManager({
|
|
leaseDirectory: p.leases,
|
|
closeTimeoutMs: 5,
|
|
spawn: () => { child = new FakeChild(); child.killBehavior = () => undefined; return child; },
|
|
mkdtemp: (prefix) => mkdtempSync(join(p.root, prefix)),
|
|
readiness: async () => 19304,
|
|
});
|
|
const session = await manager.open(config(), new AbortController().signal);
|
|
const closing = manager.closeAll();
|
|
await assert.rejects(manager.open(config(), new AbortController().signal), /manager is closing/);
|
|
await assert.rejects(closing, /cleanup failed/);
|
|
assert.doesNotThrow(() => manager.get(session.id));
|
|
child.emit("close", null, "SIGKILL");
|
|
await manager.closeAll();
|
|
p.cleanup();
|
|
});
|