diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index 5a3800718..d08c41ab9 100644 --- a/browse/src/browser-manager.ts +++ b/browse/src/browser-manager.ts @@ -23,6 +23,30 @@ import { validateNavigationUrl } from './url-validation'; import { TabSession, type RefEntry } from './tab-session'; import { resolveChromiumProfile, cleanSingletonLocks } from './config'; import { launchWithXProtectHeal } from './xprotect-heal'; +import { readPidStartTime } from './xvfb'; + +/** + * Headless GPU flags (#2709): on macOS 26 / Apple Silicon the headless-shell + * GPU process can peg ~800% CPU indefinitely after real page work, and + * --disable-gpu alone is not enough — the software-compositing GPU process + * still spawns and still spins. The reporter validated this exact flag set + * drops it to 0.0% with screenshots still working. darwin-gated: on Linux CI + * and Windows the GPU process behaves, and the flags are a mild automation + * tell. GSTACK_DISABLE_GPU=off opts out. Pure function (platform + env + * injected) so the darwin behavior is unit-testable on any host. Deliberately + * NOT in buildGStackLaunchArgs: that feeds the headed/GBrowser paths too, + * where GPU-off is user-visibly wrong. + */ +export function headlessGpuArgs(platform: string, env: NodeJS.ProcessEnv): string[] { + if (platform !== 'darwin') return []; + if ((env.GSTACK_DISABLE_GPU || '').toLowerCase() === 'off') return []; + return [ + '--disable-gpu', + '--disable-software-rasterizer', + '--disable-gpu-compositing', + '--disable-gpu-watchdog', + ]; +} import { withCdpSession } from './cdp-bridge'; import type { MemorySnapshot, MemoryStructureStats, MemoryTabSnapshot, MemoryProcess } from './memory-snapshot'; @@ -261,6 +285,17 @@ export class BrowserManager { private nextTabId: number = 1; private extraHeaders: Record = {}; private customUserAgent: string | null = null; + // #2709: identity of the Chromium child WE launched (headless path). The + // headless launch has no userDataDir, so killOrphanChromium's SingletonLock + // walk is a structural no-op for it — `browse stop` reported success while + // the orphaned GPU process kept spinning. PID alone is not identity (reuse); + // start time makes the later reap safe (same contract as xvfbPid/xvfbStartTime). + private chromiumProcInfo: { pid: number; startTime: string } | null = null; + + /** PID + start-time of the launched Chromium child, when we own one. */ + getChromiumProcInfo(): { pid: number; startTime: string } | null { + return this.chromiumProcInfo; + } // ─── Viewport + deviceScaleFactor (context options) ────────── // Tracked at the manager level so recreateContext() preserves them. @@ -479,6 +514,12 @@ export class BrowserManager { console.log(`[browse] Extensions loaded from: ${extensionsDir}`); } + // #2709: headless-only — the extensions path above forces headed mode, + // and headed/GBrowser sessions must keep the GPU. + if (useHeadless) { + launchArgs.push(...headlessGpuArgs(process.platform, process.env)); + } + // XProtect self-heal wrapper (P0 #2554): a macOS definition update can // start SIGKILLing the pinned Chromium at spawn. On the classified // signature, clear quarantine on the Playwright cache + force-reinstall @@ -519,6 +560,15 @@ export class BrowserManager { void handleChromiumDisconnect(this.browser); }); + // #2709: record the child's identity so the CLI can reap a survivor after + // daemon shutdown. `.process()` exists here — we launched this browser. + { + const proc = typeof this.browser.process === 'function' ? this.browser.process() : null; + this.chromiumProcInfo = proc?.pid + ? { pid: proc.pid, startTime: readPidStartTime(proc.pid) } + : null; + } + const contextOptions: BrowserContextOptions = { viewport: { width: this.currentViewport.width, height: this.currentViewport.height }, deviceScaleFactor: this.deviceScaleFactor, diff --git a/browse/src/cli.ts b/browse/src/cli.ts index 1ba6c3afc..942b56148 100644 --- a/browse/src/cli.ts +++ b/browse/src/cli.ts @@ -13,6 +13,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { spawn as nodeSpawn } from 'child_process'; import { safeUnlink, safeUnlinkQuiet, safeKill, isProcessAlive } from './error-handling'; +import { readPidStartTime, readPidCmdline } from './xvfb'; import { writeSecureFile, mkdirSecure } from './file-permissions'; import { resolveConfig, ensureStateDir, readVersionHash, isPairAgentEnabled, resolveChromiumProfile } from './config'; import { parseProxyConfig, computeConfigHash, ProxyConfigError } from './proxy-config'; @@ -131,6 +132,9 @@ interface ServerState { xvfbPid?: number; xvfbStartTime?: number; xvfbDisplay?: string; + /** Launched-Chromium identity for post-stop reaping (#2709). */ + chromiumPid?: number; + chromiumStartTime?: string; } // ─── State File ──────────────────────────────────────────────── @@ -293,6 +297,32 @@ async function killOrphanChromium(profileDir: string = chromiumProfileDir()): Pr } } +/** + * Reap the launched Chromium recorded in the state file (#2709). The headless + * launch has no userDataDir, so it never writes the SingletonLock that + * killOrphanChromium walks — `browse stop` reported success while the + * orphaned GPU process kept spinning (~800% CPU on macOS 26). Identity is + * verified TWO ways before any signal — start time matches the recorded + * value AND the executable looks like Chromium — so a recycled PID (even one + * now running a different, legitimate Chromium) is never killed. + */ +export async function reapRecordedChromium(state: { + chromiumPid?: number; + chromiumStartTime?: string; +}): Promise { + const pid = state.chromiumPid; + if (!pid || !isProcessAlive(pid)) return; + if (!state.chromiumStartTime || readPidStartTime(pid) !== state.chromiumStartTime) return; + const cmd = readPidCmdline(pid).toLowerCase(); + if (!/chrom|headless_shell/.test(cmd)) return; + safeKill(pid, 'SIGTERM'); + await new Promise(r => setTimeout(r, 1000)); + if (isProcessAlive(pid)) { + safeKill(pid, 'SIGKILL'); + await new Promise(r => setTimeout(r, 500)); + } +} + /** Total wall-clock budget for the busy-vs-dead health probe (#2219, * decision F10). The old ~1s window (3 × 250ms) was shorter than how long a * daemon stays unresponsive while Chromium chews a heavy dev-mode page with a @@ -1823,7 +1853,10 @@ Refs: After 'snapshot', use @e1, @e2... as selectors: await killServer(stopState.pid); // Reap the orphaned Chromium child + clear its profile locks so the // NEXT launch is clean (same cleanup as the disconnect force path). + // The headless child has no SingletonLock — reap it via the recorded + // identity too (#2709). await killOrphanChromium(); + await reapRecordedChromium(stopState); cleanChromiumProfileLocks(); safeUnlinkQuiet(config.stateFile); console.log('Daemon stopped (forced — tabs/cookies/logins discarded).'); @@ -1913,6 +1946,14 @@ Refs: After 'snapshot', use @e1, @e2... as selectors: await sendCommand(state, command, commandArgs); + // #2709: after a graceful stop, the daemon has closed Chromium via + // Playwright — but on macOS 26 the GPU process can survive that close and + // spin at ~800% CPU forever. The state snapshot read above still carries + // the launched child's identity; reap a verified survivor. + if (command === 'stop') { + await reapRecordedChromium(state); + } + // #1781: `focus` means "show me the window". The server-side focus activates // the page via CDP, but on macOS the app can still sit on another Space — pull // it to the user's current Space too. diff --git a/browse/src/server.ts b/browse/src/server.ts index f0823cc97..87192bcdc 100644 --- a/browse/src/server.ts +++ b/browse/src/server.ts @@ -3188,6 +3188,14 @@ export async function start() { // daemon launch on this state file) can validate-then-cleanup orphans // without clobbering a recycled PID. ...(xvfb ? { xvfbPid: xvfb.pid, xvfbStartTime: xvfb.startTime, xvfbDisplay: xvfb.display } : {}), + // #2709: launched-Chromium identity (pid + start time) so `browse stop` + // can reap a survivor — the headless launch has no SingletonLock for + // killOrphanChromium to walk, and on macOS 26 the orphaned GPU process + // kept spinning at ~800% CPU after the daemon exited. + ...(() => { + const info = browserManager.getChromiumProcInfo(); + return info ? { chromiumPid: info.pid, chromiumStartTime: info.startTime } : {}; + })(), }; const tmpFile = tmpStatePath(); fs.writeFileSync(tmpFile, JSON.stringify(state, null, 2), { mode: 0o600 }); diff --git a/browse/test/headless-gpu-and-reap.test.ts b/browse/test/headless-gpu-and-reap.test.ts new file mode 100644 index 000000000..1610f318c --- /dev/null +++ b/browse/test/headless-gpu-and-reap.test.ts @@ -0,0 +1,106 @@ +/** + * #2709 — two defects, one issue: + * + * 1. headlessGpuArgs: on macOS 26 / Apple Silicon the headless GPU process + * pegs ~800% CPU after real page work; --disable-gpu alone is not enough. + * The flag block is a pure platform-parameterized function so the darwin + * behavior (and the GSTACK_DISABLE_GPU=off escape) is testable on any host. + * + * 2. reapRecordedChromium: the headless launch has no SingletonLock, so + * killOrphanChromium was a structural no-op for it and `browse stop` + * reported success while the child spun on. The reap verifies identity two + * ways (recorded start time AND a Chromium-looking cmdline) before any + * signal — a recycled PID is never killed. + */ +import { describe, expect, test } from 'bun:test'; +import { spawn } from 'node:child_process'; +import { headlessGpuArgs } from '../src/browser-manager'; +import { reapRecordedChromium } from '../src/cli'; +import { readPidStartTime } from '../src/xvfb'; +import { isProcessAlive } from '../src/error-handling'; + +describe('headlessGpuArgs (#2709)', () => { + test('darwin gets the validated four-flag set', () => { + expect(headlessGpuArgs('darwin', {})).toEqual([ + '--disable-gpu', + '--disable-software-rasterizer', + '--disable-gpu-compositing', + '--disable-gpu-watchdog', + ]); + }); + + test('GSTACK_DISABLE_GPU=off opts out (case-insensitive)', () => { + expect(headlessGpuArgs('darwin', { GSTACK_DISABLE_GPU: 'off' })).toEqual([]); + expect(headlessGpuArgs('darwin', { GSTACK_DISABLE_GPU: 'OFF' })).toEqual([]); + }); + + test('non-darwin platforms are untouched', () => { + expect(headlessGpuArgs('linux', {})).toEqual([]); + expect(headlessGpuArgs('win32', {})).toEqual([]); + }); +}); + +// /proc-based identity — Linux-only (CI + this repo's dev boxes); the +// darwin-side behavior is identical code over the same ps/proc helpers. +describe.skipIf(process.platform !== 'linux')('reapRecordedChromium identity gate (#2709)', () => { + // A script whose PATH carries the chromium shape — `exec -a` renames don't + // survive this distro's coreutils shebang re-exec, but the interpreter line + // in /proc//cmdline always includes the script path. + const fs = require('node:fs') as typeof import('node:fs'); + const os = require('node:os') as typeof import('node:os'); + const path = require('node:path') as typeof import('node:path'); + + function spawnFakeChromium(): Promise { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'reap-test-')); + const script = path.join(dir, 'headless_shell'); + fs.writeFileSync(script, '#!/bin/bash\nsleep 30 &\nwait\n', { mode: 0o755 }); + return new Promise((resolve, reject) => { + const child = spawn(script, [], { detached: true, stdio: 'ignore' }); + child.unref(); + child.once('spawn', () => resolve(child.pid!)); + child.once('error', reject); + }); + } + + test('kills the child when pid + start time + cmdline all match', async () => { + const pid = await spawnFakeChromium(); + await new Promise(r => setTimeout(r, 100)); + const startTime = readPidStartTime(pid); + expect(startTime).not.toBe(''); + await reapRecordedChromium({ chromiumPid: pid, chromiumStartTime: startTime }); + expect(isProcessAlive(pid)).toBe(false); + }, 15_000); + + test('never kills when the recorded start time mismatches (PID reuse)', async () => { + const pid = await spawnFakeChromium(); + await new Promise(r => setTimeout(r, 100)); + try { + await reapRecordedChromium({ + chromiumPid: pid, + chromiumStartTime: 'Mon Jan 1 00:00:00 1990', + }); + expect(isProcessAlive(pid)).toBe(true); + } finally { + try { process.kill(pid, 'SIGKILL'); } catch { /* already gone */ } + } + }, 15_000); + + test('never kills a non-Chromium process even with a matching start time', async () => { + const child = spawn('sleep', ['30'], { detached: true, stdio: 'ignore' }); + child.unref(); + await new Promise(r => setTimeout(r, 100)); + const pid = child.pid!; + try { + const startTime = readPidStartTime(pid); + await reapRecordedChromium({ chromiumPid: pid, chromiumStartTime: startTime }); + expect(isProcessAlive(pid)).toBe(true); + } finally { + try { process.kill(pid, 'SIGKILL'); } catch { /* already gone */ } + } + }, 15_000); + + test('absent or dead pid is a quiet no-op', async () => { + await reapRecordedChromium({}); + await reapRecordedChromium({ chromiumPid: 999999999, chromiumStartTime: 'x' }); + }); +});