import { ChildProcess } from 'child_process'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; /** * Resource-discipline guard for the real-app Joplin E2E harness. * * The harness launches a real Joplin desktop (Electron) under Xvfb. Two failure modes threaten the * developer's live desktop session (16 GB laptop, real Joplin already running): * * 1. A SIGKILLed / crashed run skips Playwright's per-spec `afterAll`, leaking the Joplin process * tree, the Xvfb server, `/tmp/.X-lock` files and `e2e/.profiles/profile-*` dirs. * 2. Two repos / worktrees / sessions can each start a run and stack multiple Joplin instances, * exhausting RAM (this contributed to two desktop collapses on 2026-08-21). * * This module provides, wired from `playwright.config.ts` (globalSetup/globalTeardown) and from * `launch.ts` (spawn tracking): * * - a machine-wide lock shared by ALL joplin-plugin E2E repos, so only one run happens at a time * (a run that finds the lock held queues behind the holder instead of failing on the spot); * - a deterministic pre-run orphan sweep that reaps leftovers from previous dead runs; * - a soft RAM gate that aborts locally when memory is too low (warn-only under CI); * - best-effort in-process teardown on SIGINT/SIGTERM/uncaughtException/exit. * * SAFETY: every process match is anchored on THIS repo's absolute `.e2e-cache/squashfs-root` path * AND on the process being an orphan (reparented to init). The developer's real desktop Joplin runs * from `/tmp/.mount_XXXXXX/joplin` and can NEVER match, nor a sibling repo's `.e2e-cache` path — and * the orphan condition means a LIVE run of this same checkout is never a target either. * * LOCKSTEP: the machine-wide LOCK PROTOCOL below — its constants, its staleness rules and its * reclaim sequence — is kept in SEMANTIC lockstep with the sibling harnesses (cockpit / harper / * ridgeline); the repos stop excluding each other the moment those semantics diverge. The rest of * this file is neither byte-identical nor required to be: the sweeps, the teardown and the logging * style have each evolved to fit their own repo. */ // --- Self-contained repo paths (guard.ts lives in /e2e/). --------------------------------- const REPO_ROOT = path.resolve(__dirname, '..'); const CACHE_DIR = path.join(REPO_ROOT, '.e2e-cache'); /** Absolute path of the extracted Joplin binary tree — the ONLY anchor used to match processes. */ const EXTRACT_DIR = path.join(CACHE_DIR, 'squashfs-root'); const PROFILES_ROOT = path.join(REPO_ROOT, 'e2e', '.profiles'); // --- Machine-wide lock (shared by every joplin-plugin E2E repo on this machine). ----------------- // PROTOCOL — must stay identical in every sibling repo, or the repos stop excluding each other: // * the lock is the DIRECTORY below (mkdir is an atomic test-and-set on every filesystem); // * the holder writes its pid into `/pid`; a lock whose pid is not alive is stale and may be // reclaimed; `/owner` is an advisory extra (repo path + start time) a waiter reports and a // sibling repo that does not write it is still fully compatible; // * the holder removes the directory to release; // * a stale lock is broken only from under the reclaim lock below, and only after re-checking the // lock directory's identity there — see reclaimStaleLock(). const LOCK_DIR = path.join(os.homedir(), '.cache', 'joplin-plugin-e2e.lock'); const LOCK_PID_FILE = path.join(LOCK_DIR, 'pid'); const LOCK_OWNER_FILE = path.join(LOCK_DIR, 'owner'); /** * Reclaim intent lock — the fix for the stale-reclaim race. * * Breaking a stale lock is a judge-then-rename sequence, and rename(2) is atomic but UNCONDITIONAL: * it moves whatever sits at the path, not the incarnation the verdict was formed about. Two * acquirers that both judged the SAME stale lock therefore each renamed "the lock" aside, and the * loser carried off the winner's freshly created LIVE lock, leaving the path free for a third * mkdir — two runs, one lock (reproduced in 20-40% of six-way races). * * mkdir is the only compare-and-swap a filesystem offers, so the judge-then-rename sequence is * serialised behind a SECOND mkdir: only the holder of this directory may break a stale lock, and * it re-forms its verdict while holding it. * * This directory is itself broken by the SAME rename-and-prove sequence, never by an unconditional * remove — breaking it the sloppy way would just move the original race down one level. It is * breakable only when its holder is dead, or when it never named one and has sat past its TTL: a * reclaimer that is merely slow (suspended, blocked on a hung filesystem) is waited out rather than * broken, and LOCK_RETRY_CAP bounds that wait with a diagnostic rather than a hang. */ const LOCK_RECLAIM_DIR = `${LOCK_DIR}.reclaim`; const LOCK_RECLAIM_PID_FILE = path.join(LOCK_RECLAIM_DIR, 'pid'); /** * How long a reclaim lock that names NO pid may sit before it counts as stranded. It is held for a * handful of syscalls and names its holder immediately, so this is four orders of magnitude of * headroom. A reclaim lock that DOES name a pid is judged by that pid alone, never by age. */ const LOCK_RECLAIM_TTL_MS = 10_000; /** * How many 'retry' rounds acquireLock() tolerates before declaring the lock pathological. A retry * costs 50 ms, so this is a 20 s ceiling — deliberately well past LOCK_RECLAIM_TTL_MS, so a reclaim * lock stranded pid-less always self-heals before an acquirer gives up on it. */ const LOCK_RETRY_CAP = 400; /** * How long to queue behind a live run before giving up (`E2E_LOCK_WAIT_MS` overrides; 0 = fail fast). * Two sibling repos are routinely driven from two sessions, and a run that simply waits its turn is * worth far more than one that aborts and leaves a human to poll by hand. The budget is added to the * suite's globalTimeout locally (see playwright.config.ts), so waiting never eats the suite's time. */ export const LOCK_WAIT_MS = resolveLockWaitMs(); const LOCK_POLL_MS = 2_000; const LOCK_PROGRESS_MS = 30_000; /** * A lock whose `pid` file has not appeared yet is presumed LIVE for this long. The holder writes its * pid microseconds after the mkdir, so a pid-less lock is almost always a run that has just this * instant taken it — reading that as "stale" would let a second run break a live lock (observed with * five acquirers polling in lockstep). Only a pid-less lock older than this is debris. */ const LOCK_PID_GRACE_MS = 30_000; function resolveLockWaitMs(): number { const raw = process.env.E2E_LOCK_WAIT_MS; if (raw === undefined || raw.trim() === '') return 10 * 60_000; const parsed = Number(raw); return Number.isFinite(parsed) && parsed >= 0 ? parsed : 10 * 60_000; } /** The screen geometry the harness passes to Xvfb via `xvfb-run --server-args` — a run signature. */ const XVFB_SIGNATURE = '-screen 0 1920x1080x24'; /** Soft RAM floor: below this MemAvailable a fresh Joplin launch risks an OOM/desktop collapse. */ const RAM_FLOOR_KB = 3 * 1024 * 1024; // 3 GiB function log(msg: string): void { // eslint-disable-next-line no-console console.log(`[e2e-guard] ${msg}`); } function warn(msg: string): void { // eslint-disable-next-line no-console console.warn(`[e2e-guard] ${msg}`); } function sleep(ms: number): Promise { return new Promise((r) => setTimeout(r, ms)); } /** True if a process with this pid currently exists (EPERM still means it exists). */ function pidAlive(pid: number): boolean { try { process.kill(pid, 0); return true; } catch (err) { return (err as NodeJS.ErrnoException).code === 'EPERM'; } } // ================================================================================================ // /proc scanning (Linux). On any platform without /proc the sweep is simply a no-op. // ================================================================================================ interface ProcInfo { pid: number; ppid: number; /** Raw NUL-separated argv (safe for substring matching; split on \0 for exact args). */ cmdline: string; } /** * /proc entries this sweep could not read for lack of permission. A sweep scans /proc more than * once, so the counter is module-scoped and reported ONCE per sweep by reportProcDenied() rather * than once per scan (let alone once per entry). */ let procDenied = 0; /** One summary line per sweep rather than per-entry noise. Resets the counter. */ function reportProcDenied(): void { if (procDenied === 0) return; const n = procDenied; procDenied = 0; warn( `could not read ${n} /proc entr${n === 1 ? 'y' : 'ies'} (permission denied): leftover E2E ` + `processes owned by another user, or hidden by a hidepid mount, are invisible to the sweep` ); } function readProc(): ProcInfo[] { const out: ProcInfo[] = []; let entries: string[]; try { entries = fs.readdirSync('/proc'); } catch { return out; // no /proc — nothing to sweep } // A process that exits between readdir and read is routine and silent; a permission denial is // not — it means the sweep is BLIND to that process, so it is counted and reported once per // sweep rather than per entry. for (const name of entries) { if (!/^\d+$/.test(name)) continue; const pid = Number(name); let cmdline: string; try { cmdline = fs.readFileSync(`/proc/${pid}/cmdline`).toString('utf8'); } catch (err) { const code = (err as NodeJS.ErrnoException).code; if (code === 'EACCES' || code === 'EPERM') procDenied++; continue; // process vanished / not readable } if (cmdline.length === 0) continue; // kernel threads have empty cmdline let ppid = -1; try { const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8'); // comm (field 2) may contain spaces/parens — parse the fields after the last ')'. const afterComm = stat.slice(stat.lastIndexOf(')') + 2).split(' '); ppid = Number(afterComm[1]); // [0]=state, [1]=ppid } catch { ppid = -1; } out.push({ pid, ppid, cmdline }); } return out; } // ================================================================================================ // Pre-run orphan sweep (the deterministic half of the hardening). // ================================================================================================ /** * (a) Kill every ORPHANED leftover process whose cmdline references THIS repo's extracted Joplin * tree. Both conditions matter, and the second is not redundant: the path alone also matches a * CONCURRENT run of this same checkout, so if the machine-wide lock is ever lost — or a sibling * worktree on an older protocol races it — a second run would SIGKILL the first run's live Joplin * tree mid-test. A live run's Joplin was spawned by a live Playwright worker, so it has a real * parent; only a run that died leaves its Joplin reparented to init. Same condition the Xvfb sweep * below has always used. */ function sweepJoplinProcesses(selfPids: Set): number { let killed = 0; for (const p of readProc()) { if (selfPids.has(p.pid)) continue; if (p.ppid !== 1) continue; // only orphans; a live run's Joplin has a live parent // Path-anchored: matches this repo's main Joplin AND its renderer/gpu/zygote children (all exec // the same binary under EXTRACT_DIR). Cannot match the real desktop Joplin (/tmp/.mount_XXXXXX) // nor a sibling repo's .e2e-cache tree. if (!p.cmdline.includes(EXTRACT_DIR)) continue; try { process.kill(p.pid, 'SIGKILL'); killed++; log(`swept leftover Joplin process pid ${p.pid}`); } catch { /* already gone */ } } return killed; } /** (b) Kill orphaned (PPID 1) Xvfb servers matching the harness signature; clear their stale locks. */ async function sweepOrphanXvfb(selfPids: Set): Promise { for (const p of readProc()) { if (selfPids.has(p.pid)) continue; if (p.ppid !== 1) continue; // only orphans reparented to init; a live run's Xvfb has a live parent if (!p.cmdline.includes('Xvfb')) continue; if (!p.cmdline.includes(XVFB_SIGNATURE)) continue; const args = p.cmdline.split('\0').filter(Boolean); const display = args.find((a) => /^:\d+$/.test(a)) ?? null; const dispNum = display ? display.slice(1) : null; // Never touch the real X display (:0): it is Xorg, not Xvfb, but guard defensively anyway. if (dispNum === '0') continue; try { process.kill(p.pid, 'SIGKILL'); log(`swept orphaned Xvfb pid ${p.pid} (display ${display ?? '?'})`); } catch { continue; } if (dispNum === null) continue; // Only remove a display's lock once its Xvfb is confirmed dead. for (let i = 0; i < 20 && pidAlive(p.pid); i++) await sleep(50); if (pidAlive(p.pid)) { warn(`Xvfb pid ${p.pid} did not exit; leaving /tmp/.X${dispNum}-lock in place`); continue; } for (const stale of [`/tmp/.X${dispNum}-lock`, `/tmp/.X11-unix/X${dispNum}`]) { try { if (fs.existsSync(stale)) { fs.rmSync(stale, { force: true }); log(`removed stale ${stale}`); } } catch { /* ignore */ } } } } /** (c) Remove stale throwaway profile dirs left by dead runs. */ function sweepStaleProfiles(): void { let entries: string[]; try { entries = fs.readdirSync(PROFILES_ROOT); } catch { return; // no profiles dir yet } for (const name of entries) { if (!name.startsWith('profile-')) continue; const dir = path.join(PROFILES_ROOT, name); try { fs.rmSync(dir, { recursive: true, force: true }); log(`removed stale profile dir ${dir}`); } catch (err) { warn(`could not remove stale profile ${dir}: ${(err as Error).message}`); } } } /** * (d) Remove lock debris beside the lock: `.stale--` directories a reclaim moved * aside but failed to delete, and a reclaim intent lock stranded by a process that died mid-break. * Both are inert, but nothing else ever removes them, so they accumulate in ~/.cache. */ function sweepLockDebris(): void { const parent = path.dirname(LOCK_DIR); const base = path.basename(LOCK_DIR); // Both kinds of rename-aside debris: from breaking the lock, and from breaking the intent lock. const stalePrefixes = [`${base}.stale-`, `${base}.reclaim.stale-`]; let entries: string[]; try { entries = fs.readdirSync(parent); } catch { return; } for (const name of entries) { if (!stalePrefixes.some((prefix) => name.startsWith(prefix))) continue; const debris = path.join(parent, name); try { fs.rmSync(debris, { recursive: true, force: true }); log(`removed stale-lock debris ${debris}`); } catch (err) { warn(`could not remove stale-lock debris ${debris}: ${(err as Error).message}`); } } // We hold the lock, so no legitimate reclaim can be in flight: a reclaim lock here is debris. if (reclaimLockIsStranded()) { try { fs.rmSync(LOCK_RECLAIM_DIR, { recursive: true, force: true }); log(`removed stranded E2E reclaim lock ${LOCK_RECLAIM_DIR}`); } catch { /* its TTL still bounds it */ } } } /** Run the full deterministic sweep. Call AFTER acquiring the lock (sole owner of these resources). */ export async function sweepOrphans(): Promise { const selfPids = new Set( [process.pid, process.ppid].filter((n) => Number.isInteger(n) && n > 0) ); const killed = sweepJoplinProcesses(selfPids); if (killed > 0) { log(`swept ${killed} leftover Joplin process(es); waiting for profile locks to release`); await sleep(500); } await sweepOrphanXvfb(selfPids); sweepStaleProfiles(); sweepLockDebris(); reportProcDenied(); } // ================================================================================================ // Machine-wide lock. // ================================================================================================ let weOwnLock = false; /** The incarnation of LOCK_DIR this process created; see incarnationOf() and releaseLock(). */ let ourLockIncarnation: string | null = null; /** The incarnation of LOCK_RECLAIM_DIR this process took; see releaseReclaimLock(). */ let ourReclaimIncarnation: string | null = null; /** Parse a pid file written by the lock protocol: null when absent, empty or not a pid. */ function readPidFile(file: string): number | null { try { const pid = Number(fs.readFileSync(file, 'utf8').trim()); return Number.isInteger(pid) && pid > 0 ? pid : null; } catch { return null; } } function readLockPid(): number | null { return readPidFile(LOCK_PID_FILE); } /** * When a lock directory was created. NOT mtime: writing `pid` and `owner` INTO the directory updates * its mtime, so an mtime-derived age silently resets the pid grace below. btime is stamped once at * mkdir and no later write moves it (verified on this machine's btrfs $HOME and on tmpfs). * Filesystems that record no btime report 0 or the epoch; there we fall back to mtime, which is the * behaviour this guard has always had. */ function createdMs(st: fs.Stats): number { const birth = st.birthtimeMs; return birth > 0 && birth <= Date.now() + 1_000 ? birth : st.mtimeMs; } /** How long the lock directory has existed, or Infinity when it cannot be stat'ed. */ function lockAgeMs(): number { try { return Date.now() - createdMs(fs.statSync(LOCK_DIR)); } catch { return Infinity; } } /** * A token identifying one INCARNATION of a lock directory, so a reclaim can prove that what it * carried off is the same directory its verdict was formed about. Inode numbers are recycled after * a delete, so the creation timestamp is folded in: two incarnations would have to share a device, * an inode AND a sub-millisecond birth time to be confused. rename(2) preserves all three, so the * token survives the move aside. */ function incarnationOf(dir: string): string | null { try { const st = fs.statSync(dir); return `${st.dev}:${st.ino}:${createdMs(st)}`; } catch { return null; } } /** The holder's advisory description (" since