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> = {}): 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[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 { 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[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[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[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[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[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((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(); });