xvfb.ts
313 lines · browse/src/xvfb.ts at 3f79d81
1/**
2 * Xvfb (X virtual framebuffer) auto-spawn for headed Chromium on Linux
3 * containers without DISPLAY.
4 *
5 * The motivating use case: a headless container needs to run Chromium in
6 * "headed" mode (visible window) — for example, to run with the
7 * AutomationControlled flag off and pass anti-bot fingerprint checks. Xvfb
8 * provides an off-screen X server that Chromium can render into.
9 *
10 * Design notes:
11 * - Pick a free display dynamically (try :99, :100, :101...). NEVER unlink
12 * /tmp/.X<n>-lock for displays we didn't create — that would steal an
13 * active X server from another process or user.
14 * - Validate orphan Xvfb processes by BOTH /proc/<pid>/cmdline matching
15 * 'Xvfb' AND start-time matching the recorded value. PID reuse is real;
16 * a one-field check would let us send SIGTERM to an unrelated process
17 * that happened to inherit a recycled PID.
18 * - Skip spawn entirely on macOS/Windows (native windowing) and on Linux
19 * when DISPLAY or WAYLAND_DISPLAY is already set (codex F2).
20 */
22import * as fs from 'fs';
23import * as path from 'path';
24import * as os from 'os';
25import { safeKill, isProcessAlive } from './error-handling';
27export interface XvfbHandle {
28 pid: number;
29 startTime: string;
30 display: string; // e.g. ":99"
31 /** Best-effort cleanup. Validates ownership before kill. */
32 close: () => void;
33}
35export interface ShouldSpawnDecision {
36 spawn: boolean;
37 reason: string;
38}
40const DISPLAY_RANGE_START = 99;
41const DISPLAY_RANGE_END = 120;
43/**
44 * Decide whether the daemon should auto-spawn an Xvfb. Pure: takes env +
45 * platform and returns a decision. Easy to unit test.
46 */
47export function shouldSpawnXvfb(env: NodeJS.ProcessEnv, platform: NodeJS.Platform): ShouldSpawnDecision {
48 if (env.BROWSE_HEADED !== '1') return { spawn: false, reason: 'not headed mode' };
49 if (platform !== 'linux') return { spawn: false, reason: `platform ${platform} uses native windowing` };
50 if (env.DISPLAY) return { spawn: false, reason: `DISPLAY=${env.DISPLAY} already set` };
51 if (env.WAYLAND_DISPLAY) return { spawn: false, reason: `WAYLAND_DISPLAY=${env.WAYLAND_DISPLAY} set; Chromium uses Wayland natively` };
52 return { spawn: true, reason: 'linux headed without DISPLAY/WAYLAND_DISPLAY' };
53}
55/**
56 * Probe a display number — return true if no X server is currently listening
57 * on it (i.e., we can safely spawn a new Xvfb there).
58 */
59export function isDisplayFree(displayNum: number): boolean {
60 for (const reservation of [`/tmp/.X11-unix/X${displayNum}`, `/tmp/.X${displayNum}-lock`]) {
61 try { if (fs.lstatSync(reservation, { throwIfNoEntry: false })) return false; }
62 catch { return false; }
63 }
64 // xdpyinfo exits 0 if a display is reachable. Exit non-zero means no
65 // server, which is what we want. xdpyinfo ships in x11-utils, which some
66 // images with Xvfb still lack (first Linux CI run: ENOENT) — fall back to
67 // the X socket/lock files, the same signal X servers themselves use.
68 try {
69 const result = Bun.spawnSync(['xdpyinfo', '-display', `:${displayNum}`], {
70 windowsHide: true,
71 stdout: 'ignore', stderr: 'ignore', timeout: 2000,
72 });
73 return result.exitCode !== 0;
74 } catch {
75 return true;
76 }
77}
79/**
80 * Walk the display range and return the first free one, or null if all
81 * displays in the range are taken.
82 */
83export function pickFreeDisplay(
84 rangeStart: number = DISPLAY_RANGE_START,
85 rangeEnd: number = DISPLAY_RANGE_END,
86): number | null {
87 for (let n = rangeStart; n <= rangeEnd; n++) {
88 if (isDisplayFree(n)) return n;
89 }
90 return null;
91}
93/**
94 * Read the wall-clock start time of a PID via `ps -o lstart=`. Stable across
95 * reads (unlike /proc/stat field 22 which reports jiffies since boot in a
96 * format that's harder to compare). Returns an empty string if the process
97 * is gone or ps fails.
98 */
99export function readPidStartTime(pid: number): string {
100 if (!isProcessAlive(pid)) return '';
101 try {
102 const result = Bun.spawnSync(['ps', '-p', String(pid), '-o', 'lstart='], {
103 windowsHide: true,
104 stdout: 'pipe', stderr: 'pipe', timeout: 2000,
105 });
106 if (result.exitCode !== 0) return '';
107 return result.stdout.toString().trim();
108 } catch {
109 // Bun.spawnSync THROWS when the executable is missing (Windows shells
110 // without an MSYS `ps`). This function's contract is "empty string if
111 // ps fails" — a missing ps must not abort the caller (browser-manager
112 // now calls this on the universal launch path, #2709).
113 return '';
114 }
115}
117/**
118 * Read the cmdline of a PID via /proc/<pid>/cmdline. Returns empty string
119 * if the process is gone or the cmdline isn't readable.
120 */
121export function readPidCmdline(pid: number): string {
122 try {
123 return fs.readFileSync(`/proc/${pid}/cmdline`, 'utf-8').replace(/\0/g, ' ').trim();
124 } catch {
125 // No /proc on darwin — the platform #2709's reap actually targets. Fall
126 // back to ps (same pattern as readPidStartTime above); without this the
127 // reap's cmdline identity gate always saw '' on macOS and the reap was
128 // a structural no-op exactly where the spinning-GPU orphan lives.
129 try {
130 const result = Bun.spawnSync(['ps', '-p', String(pid), '-o', 'command='], {
131 windowsHide: true,
132 stdout: 'pipe', stderr: 'pipe', timeout: 2000,
133 });
134 if (result.exitCode !== 0) return '';
135 return result.stdout.toString().trim();
136 } catch {
137 return '';
138 }
139 }
140}
142/**
143 * Read argv[0] of a PID via /proc/<pid>/cmdline (NUL-separated). Returns
144 * empty string if the process is gone or the cmdline isn't readable.
145 */
146export function readPidArgv0(pid: number): string {
147 try {
148 const raw = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf-8');
149 return raw.split('\0', 1)[0] ?? '';
150 } catch {
151 return '';
152 }
153}
155/**
156 * Validate that PID is still our Xvfb child. Both checks must pass:
157 * 1. argv[0]'s basename IS the Xvfb binary. A substring match over the
158 * whole cmdline is identity-kill poison: any process whose ARGUMENTS
159 * mention xvfb (the test runner executing xvfb.test.ts, an editor with
160 * the file open) would pass and become killable. First Linux CI run
161 * caught exactly that — the suite identified itself as our Xvfb.
162 * 2. Start time matches the recorded value (PID reuse defense)
163 */
164export function isOurXvfb(pid: number, recordedStartTime: string): boolean {
165 if (!pid || !recordedStartTime) return false;
166 const argv0 = readPidArgv0(pid);
167 if (!argv0) return false;
168 const base = argv0.split('/').pop() ?? '';
169 if (base.toLowerCase() !== 'xvfb') return false;
170 const currentStart = readPidStartTime(pid);
171 if (!currentStart) return false;
172 return currentStart === recordedStartTime;
173}
175/**
176 * Spawn Xvfb on the given display. Returns a handle including the validated
177 * start-time so future cleanup can confirm ownership.
178 *
179 * Throws if Xvfb isn't installed (caller should print a platform-specific
180 * install hint).
181 */
182export class XvfbDisplayTakenError extends Error {}
184export async function spawnXvfb(displayNum: number): Promise<XvfbHandle> {
185 const display = `:${displayNum}`;
186 if (!isDisplayFree(displayNum)) throw new XvfbDisplayTakenError(`X display ${display} is already reserved; refusing to replace it`);
187 if (!readPidStartTime(process.pid)) throw new Error('Cannot start Xvfb without process start-time ownership checks');
189 // Spawn detached: Xvfb's lifetime is tied to whether we've explicitly
190 // killed it via the handle's close() method, not to the parent process.
191 // Startup stderr goes to a private file: a holder that takes the display and
192 // exits before we look leaves no lock behind, only Xvfb's own message.
193 const stderrDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-xvfb-'));
194 const stderrPath = path.join(stderrDir, 'stderr.log');
195 const stderrFd = fs.openSync(stderrPath, 'w', 0o600);
196 let proc: ReturnType<typeof Bun.spawn>;
197 try {
198 proc = Bun.spawn(['Xvfb', display, '-screen', '0', '1920x1080x24', '-ac'], {
199 windowsHide: true,
200 stdio: ['ignore', 'ignore', stderrFd],
201 });
202 } catch (err) {
203 fs.rmSync(stderrDir, { recursive: true, force: true });
204 throw err;
205 } finally {
206 fs.closeSync(stderrFd);
207 }
208 proc.unref();
209 const startTime = readPidStartTime(proc.pid);
210 try {
211 return await awaitXvfbReady(proc, displayNum, startTime, stderrPath);
212 } finally {
213 fs.rmSync(stderrDir, { recursive: true, force: true });
214 }
215}
217async function awaitXvfbReady(proc: ReturnType<typeof Bun.spawn>, displayNum: number, startTime: string, stderrPath: string): Promise<XvfbHandle> {
218 const display = `:${displayNum}`;
220 // Wait for the X server to become reachable — Xvfb takes a few hundred ms
221 // to bind. Probe via xdpyinfo with retries.
222 const deadline = Date.now() + 3000;
223 let ready = false;
224 while (Date.now() < deadline) {
225 await Bun.sleep(100);
226 // If Xvfb crashed during startup, fail fast.
227 if (proc.exitCode != null) {
228 let stderr = '';
229 try { stderr = fs.readFileSync(stderrPath, 'utf8'); } catch {}
230 if (!isDisplayFree(displayNum) || /Server is already active for display/.test(stderr)) {
231 throw new XvfbDisplayTakenError(`X display ${display} was reserved by another X server during startup`);
232 }
233 const fatal = stderr.split('\n').map(line => line.replace(/^\(EE\)\s*/, '').trim()).filter(Boolean).slice(-3).join(' ');
234 throw new Error(`Xvfb on ${display} exited during startup (code ${proc.exitCode})${fatal ? `: ${fatal}` : ''}. Hint: install xvfb (apt-get install xvfb / yum install xorg-x11-server-Xvfb).`);
235 }
236 let ownsLock = false;
237 try { ownsLock = Number(fs.readFileSync(`/tmp/.X${displayNum}-lock`, 'utf8').trim()) === proc.pid; } catch {}
238 if (!ownsLock || !isOurXvfb(proc.pid, startTime)) continue;
239 try {
240 ready = Bun.spawnSync(['xdpyinfo', '-display', display], {
241 windowsHide: true, stdout: 'ignore', stderr: 'ignore', timeout: 2000,
242 }).exitCode === 0;
243 } catch {
244 ready = fs.existsSync(`/tmp/.X11-unix/X${displayNum}`);
245 }
246 if (ready) break;
247 }
248 if (!ready) {
249 cleanupXvfb({ pid: proc.pid, startTime, display });
250 throw new Error(`Xvfb on ${display} never became reachable within 3s timeout`);
251 }
253 return {
254 pid: proc.pid,
255 startTime,
256 display,
257 close: () => cleanupXvfb({ pid: proc.pid, startTime, display }),
258 };
259}
261/**
262 * Pick-then-spawn races with any other allocator (a second daemon, a parallel
263 * test shard): both can see the same display free before either Xvfb takes its
264 * lock. The loser moves on to the next free display instead of failing.
265 */
266export async function spawnFreeXvfb(
267 rangeStart: number = DISPLAY_RANGE_START,
268 rangeEnd: number = DISPLAY_RANGE_END,
269): Promise<XvfbHandle> {
270 for (let n = pickFreeDisplay(rangeStart, rangeEnd); n != null; n = pickFreeDisplay(n + 1, rangeEnd)) {
271 try { return await spawnXvfb(n); }
272 catch (err) { if (!(err instanceof XvfbDisplayTakenError)) throw err; }
273 }
274 throw new Error(`no free X display in range :${rangeStart}-:${rangeEnd} — refusing to clobber existing X servers`);
275}
277/**
278 * Cleanup an Xvfb child if it's still ours. Validates ownership first; if
279 * the PID has been recycled or the cmdline doesn't match, leave it alone.
280 *
281 * Best-effort: never throws.
282 */
283function isZombie(pid: number): boolean {
284 try {
285 const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8');
286 return stat.slice(stat.lastIndexOf(')') + 2).startsWith('Z');
287 } catch { return false; }
288}
290export function cleanupXvfb(state: { pid: number; startTime: string; display: string }): void {
291 if (!state.pid) return;
292 if (!isOurXvfb(state.pid, state.startTime)) return;
293 try { safeKill(state.pid, 'SIGTERM'); } catch { /* swallow */ }
294 // Wait briefly for Xvfb to exit, then SIGKILL if still alive.
295 // An exited child stays a zombie until our event loop reaps it, and this
296 // synchronous wait blocks that loop, so a zombie counts as exited here.
297 const deadline = Date.now() + 1000;
298 while (Date.now() < deadline) {
299 if (!isProcessAlive(state.pid) || isZombie(state.pid)) break;
300 Bun.sleepSync(10);
301 }
302 if (isOurXvfb(state.pid, state.startTime)) {
303 try { safeKill(state.pid, 'SIGKILL'); } catch { /* swallow */ }
304 }
305}
307/**
308 * Print a platform-specific install hint and return the message string.
309 * Used by server.ts when Xvfb isn't installed.
310 */
311export function xvfbInstallHint(): string {
312 return 'Xvfb not installed. apt-get install xvfb (Debian/Ubuntu) or yum install xorg-x11-server-Xvfb (RHEL/CentOS). Note: minimal containers (alpine, distroless) may also need fonts, dbus, gtk libs for headed Chromium to render.';
313}