mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-09 14:38:59 +02:00
fix(browse): tame the macOS headless GPU spin + reap the lock-less headless Chromium on stop (#2709)
Two defects in one report. On macOS 26 / Apple Silicon the headless-shell GPU process pegs ~800% CPU indefinitely after real page work and --disable-gpu alone is not enough; the reporter validated that adding --disable-software-rasterizer/--disable-gpu-compositing/--disable-gpu-watchdog drops it to 0.0% with screenshots still working. The flag block is a pure platform-parameterized function (unit-tested on any host), darwin-gated, headless-only (buildGStackLaunchArgs feeds the headed/GBrowser paths where GPU-off is wrong), with a GSTACK_DISABLE_GPU=off escape. Separately: the headless launch has no userDataDir, so it never writes the SingletonLock that killOrphanChromium walks — 'browse stop' reported success while the orphan kept spinning. The daemon now records the launched child's pid + wall-clock start time in the state file (the xvfbPid/xvfbStartTime contract), and stop paths reap a survivor only after verifying BOTH the recorded start time and a Chromium-looking cmdline — a recycled PID, even one running a different legitimate Chromium, is never killed (identity tests include the coreutils-shebang trap that defeats argv0 renames). macOS efficacy is per the reporter's validation; live re-verification on Apple silicon is tracked in TODOS.md. Refs #2709 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
079b36c7f8
commit
78cd06e157
@@ -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<string, string> = {};
|
||||
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,
|
||||
|
||||
@@ -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<void> {
|
||||
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.
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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/<pid>/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<number> {
|
||||
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' });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user