/** * Browser profiles — one per run, never shared, never inherited. * * ── Why a run owns its profile instead of borrowing a shared one ───────────── * A run mints its own physical NextGraph user and must not inherit the previous run's. That * discipline is not an optimisation: a wallet reused across runs ACCUMULATES — every run * leaves behind the identities and documents it created, nothing removes them, and a cold * resynchronisation is O(the user's size). A wallet kept for a month took 286 s on a single * sync step against 250 s a week earlier, and the drift was invisible because it was never * measured against a stable baseline. A fresh user per run makes that duration comparable * from one run to the next instead of a number that only ever grows. * * The obvious way to get a fresh user is to WIPE a profile at a fixed path — which is what * this harness used to do, and it is why it needed a lock. A wipe destroys a profile that * another run may be using, so runs had to be serialised, and a suite belonging to a * consuming application — run from its own checkout, against the same broker — collided with * ours exactly as two of ours would, invisibly to both. The lock could never have fixed that: * it guarded one repository's idea of a path. * * A directory of its own removes the problem rather than exporting it. There is nothing to * serialise, concurrent runs are independent by construction, and "one physical user per run, * never reused" stops being a rule anyone can forget — a directory that did not exist a * moment ago cannot hold a previous run's user. * * What the profile still IS, and must remain: persistent for the WHOLE run. A run opens * several browser contexts over it in sequence (a reconnection is exactly that), and the * contracts about reconnecting faithfully and not forking an account are checks on that * persistence. */ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; /** A profile directory this run owns and will remove. */ export interface RunProfile { /** The directory to launch a persistent context on. */ readonly dir: string; /** What it is for, as it appears in logs. */ readonly purpose: string; /** * Kill whatever still holds it, then remove it. Idempotent, and also run automatically when * the process leaves (see below), so a killed run cleans up after itself. */ discard(): void; } const live = new Set(); let leavingHandlersInstalled = false; /** Is that process still alive? Signal 0 tests for existence without touching it. */ export function isAlive(pid: number): boolean { try { process.kill(pid, 0); return true; } catch (e) { // EPERM means it exists and is someone else's — still alive, still holding it. return (e as NodeJS.ErrnoException).code === "EPERM"; } } /** * The Chromium still holding `dir`, if any. Chromium names the holder itself: `SingletonLock` * is a symlink to `-`. */ function holderOf(dir: string): number | null { let target: string; try { target = fs.readlinkSync(path.join(dir, "SingletonLock")); } catch { return null; // no lock, nothing holding it } const pid = Number(target.slice(target.lastIndexOf("-") + 1)); return Number.isInteger(pid) && pid > 0 && isAlive(pid) ? pid : null; } /** * A profile directory of this run's own, under the system temp dir. * * Under the TEMP dir and not the repository, deliberately: a profile holds a wallet, a wallet * is an identity, and an identity must never end up committed. It also means two checkouts of * the same suite cannot land on the same path. */ export function newRunProfile(purpose: string): RunProfile { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ng-e2e-profile-")); let discarded = false; const profile: RunProfile = { dir, purpose, discard: () => { if (discarded) return; discarded = true; live.delete(profile); // A run that fails or is killed leaves its Chromium ALIVE: `BrowserContext.close()` in // the teardown gives up after its bound, and a `kill -9` on the runner never gets there // at all. Nothing else will ever want this directory, so the orphan cannot poison a // later run the way it used to — but it would sit on the host's memory for ever, and a // loaded host is how this suite manufactures its own flakiness. Each run therefore // clears its OWN leftovers, which is the one moment where it is certainly safe. const holder = holderOf(dir); if (holder !== null) { try { process.kill(holder, "SIGKILL"); } catch { /* gone between the check and the signal */ } } try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* a temp dir the OS will collect anyway */ } }, }; live.add(profile); installLeavingHandlers(); return profile; } /** * Discard every profile still live when the process leaves — including the ways out nobody * plans for. * * `exit` covers the normal end and `process.exit()`, which is how these suites finish; the * signal handlers cover Ctrl-C and `kill`, which is how a hung run ends. Everything here is * synchronous, because an `exit` handler is the only thing that runs at that point. */ function installLeavingHandlers(): void { if (leavingHandlersInstalled) return; leavingHandlersInstalled = true; process.on("exit", () => { for (const profile of [...live]) profile.discard(); }); for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) { process.on(signal, () => { for (const profile of [...live]) profile.discard(); process.exit(130); }); } }