mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-09 14:38:59 +02:00
fix(browse): XProtect launch-kill self-heal — classify, quarantine-clear, bounded reinstall (P0 #2554)
macOS XProtect definition updates can start SIGKILLing the exact Chromium revision the lockfile pins (xprotectd killed revision 1208's headless shell at spawn; the failure surfaced as a generic launch timeout). New browse/src/xprotect-heal.ts heals it, once per process: - Classifier (F9): positive signatures sourced from the #2554 report + Playwright's launch-error format (signal=SIGKILL process-exit lines, and launch timeout WITH a <launched> marker), negative-checked FIRST against missing executable, spawn EACCES/EPERM, Linux sandbox denials, and plain exitCode=1 crashes. darwin-gated. - Heal (F4 one-shot, in-memory flag): clears com.apple.quarantine via `xattr -dr` on chromium* revision dirs in the Playwright cache ONLY — never a GSTACK_CHROMIUM_PATH bundle (probePoisonedChromiumBundle's scope contract, double-gated at the call sites via usesCustomExecutable). - Reinstall (E1/ENG-OV3): `bunx playwright install --force chromium` run FROM THE GSTACK INSTALL ROOT — the root whose node_modules/playwright-core/browsers.json pins the SAME chromium revision our embedded playwright-core expects (a cwd-resolved bunx would fetch latest and heal to the wrong revision). Bounded at 120s with a process-GROUP SIGKILL on timeout; on any heal failure the caller gets the ORIGINAL launch error + manual `bunx playwright install chromium` guidance — the CLI never hangs. - Verification (F9): post-install asserts the REGISTRY-derived executable path exists (the revision dir playwright-core 1.62.1 expects), not merely install exit 0. - Logging (F11): every action emits one structured stderr line ([browse:xprotect-heal] JSON). All three launch sites in browser-manager.ts (headless launch, headed launchPersistentContext, handoff relaunch) route through launchWithXProtectHeal with one post-heal retry. setup's ensure_playwright_browser failure path gains the same quarantine-clear (_clear_playwright_quarantine, Darwin-only, Playwright cache scope) before its Chromium reinstall. Tests: browse/test/xprotect-heal.test.ts — 33 pass (classifier both polarities, one-shot guard incl. failed-heal consumption, custom-executable scope, registry-revision expectation vs playwright-core browsers.json, install-root revision matching, quarantine-clear scope, wrapper retry + guidance surfacing). browser-manager unit/custom-chromium: 36 pass. bridge-chromium-e2e real-launch smoke: 3 pass. setup-windows-fallback ln-invariant: 9 pass. bash -n setup: clean. Fixes #2554. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
2851535f3b
commit
822de7d0c3
@@ -22,6 +22,7 @@ import { emitActivity } from './activity';
|
||||
import { validateNavigationUrl } from './url-validation';
|
||||
import { TabSession, type RefEntry } from './tab-session';
|
||||
import { resolveChromiumProfile, cleanSingletonLocks } from './config';
|
||||
import { launchWithXProtectHeal } from './xprotect-heal';
|
||||
import { withCdpSession } from './cdp-bridge';
|
||||
import type { MemorySnapshot, MemoryStructureStats, MemoryTabSnapshot, MemoryProcess } from './memory-snapshot';
|
||||
|
||||
@@ -458,7 +459,12 @@ export class BrowserManager {
|
||||
console.log(`[browse] Extensions loaded from: ${extensionsDir}`);
|
||||
}
|
||||
|
||||
this.browser = await chromium.launch({
|
||||
// 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
|
||||
// once, then retry this launch once. This headless path always uses the
|
||||
// Playwright cache (no executablePath), so the heal is never scoped out.
|
||||
this.browser = await launchWithXProtectHeal(() => chromium.launch({
|
||||
headless: useHeadless,
|
||||
// On Windows, Chromium's sandbox fails when the server is spawned through
|
||||
// the Bun→Node process chain (GitHub #276). Disable it — local daemon
|
||||
@@ -468,7 +474,7 @@ export class BrowserManager {
|
||||
chromiumSandbox: shouldEnableChromiumSandbox(),
|
||||
...(launchArgs.length > 0 ? { args: launchArgs } : {}),
|
||||
...(this.proxyConfig ? { proxy: this.proxyConfig } : {}),
|
||||
});
|
||||
}));
|
||||
|
||||
// Chromium disconnect → distinguish clean user-quit from crash. Both
|
||||
// events look identical to Playwright (one 'disconnected' fires), but
|
||||
@@ -651,7 +657,11 @@ export class BrowserManager {
|
||||
// three more (--disable-popup-blocking, --disable-component-update,
|
||||
// --disable-default-apps — each a documented automation tell per Patchright).
|
||||
const { STEALTH_IGNORE_DEFAULT_ARGS } = await import('./stealth');
|
||||
this.context = await chromium.launchPersistentContext(userDataDir, {
|
||||
// XProtect self-heal wrapper (P0 #2554). usesCustomExecutable scopes the
|
||||
// heal out when GSTACK_CHROMIUM_PATH supplies the bundle — that bundle
|
||||
// belongs to the wrapper/embedder and is never quarantine-cleared or
|
||||
// reinstalled over (probePoisonedChromiumBundle's scope contract).
|
||||
this.context = await launchWithXProtectHeal(() => chromium.launchPersistentContext(userDataDir, {
|
||||
headless: false,
|
||||
// Match the sandbox policy used by launch() above. Without this,
|
||||
// Playwright auto-adds --no-sandbox on every headed launch and the user
|
||||
@@ -663,7 +673,7 @@ export class BrowserManager {
|
||||
...(executablePath ? { executablePath } : {}),
|
||||
...(this.proxyConfig ? { proxy: this.proxyConfig } : {}),
|
||||
ignoreDefaultArgs: STEALTH_IGNORE_DEFAULT_ARGS,
|
||||
});
|
||||
}), { usesCustomExecutable: Boolean(executablePath) });
|
||||
this.browser = this.context.browser();
|
||||
this.connectionMode = 'headed';
|
||||
this.intentionalDisconnect = false;
|
||||
@@ -1702,7 +1712,10 @@ export class BrowserManager {
|
||||
// The handoff path (headless → headed re-launch) takes the same
|
||||
// anti-detection posture.
|
||||
const { STEALTH_IGNORE_DEFAULT_ARGS } = await import('./stealth');
|
||||
newContext = await chromium.launchPersistentContext(userDataDir, {
|
||||
// XProtect self-heal wrapper (P0 #2554): handoff always launches the
|
||||
// Playwright-cache bundle (no executablePath), so the heal applies
|
||||
// exactly as in launch()/launchHeaded().
|
||||
newContext = await launchWithXProtectHeal(() => chromium.launchPersistentContext(userDataDir, {
|
||||
headless: false,
|
||||
// Match the sandbox policy used by launchHeaded() / launch(). The
|
||||
// handoff path is the headless→headed re-launch and shares the same
|
||||
@@ -1713,7 +1726,7 @@ export class BrowserManager {
|
||||
...(this.proxyConfig ? { proxy: this.proxyConfig } : {}),
|
||||
ignoreDefaultArgs: STEALTH_IGNORE_DEFAULT_ARGS,
|
||||
timeout: 15000,
|
||||
});
|
||||
}));
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return `ERROR: Cannot open headed browser — ${msg}. Headless browser still running.`;
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
/**
|
||||
* XProtect launch-kill self-heal (P0 #2554).
|
||||
*
|
||||
* macOS XProtect definition updates can start killing the exact Chromium
|
||||
* revision the committed bun.lock pins (observed: revision 1208 under
|
||||
* playwright 1.58.2 — xprotectd SIGKILLs chrome-headless-shell at spawn, so
|
||||
* the failure surfaces as a Playwright launch timeout or a "Browser closed"
|
||||
* error carrying `signal=SIGKILL`, never anything naming XProtect).
|
||||
*
|
||||
* The heal, in order, at most ONCE per process (F4):
|
||||
* 1. Classify the launch failure against the XProtect kill signature
|
||||
* (positive AND negative fixtures under test, F9).
|
||||
* 2. Clear com.apple.quarantine on the Playwright cache bundles ONLY —
|
||||
* a GSTACK_CHROMIUM_PATH bundle belongs to the wrapper/embedder and is
|
||||
* never touched (same scope contract as probePoisonedChromiumBundle).
|
||||
* 3. Force-reinstall Chromium FROM THE GSTACK INSTALL ROOT (ENG-OV3: the
|
||||
* root whose node_modules pins the same playwright-core our compiled
|
||||
* binary embeds — a cwd-resolved `bunx playwright install` would fetch
|
||||
* the LATEST playwright's revision, which the embedded playwright-core
|
||||
* won't find, and the one-shot guard would then block the retry).
|
||||
* The install is BOUNDED (~120s, process-GROUP kill on timeout; E1).
|
||||
* 4. Verify the revision dir the embedded playwright-core EXPECTS exists
|
||||
* post-heal (registry-derived expectation, not merely install exit 0).
|
||||
*
|
||||
* Every action emits one structured stderr line (F11). When the heal cannot
|
||||
* complete (offline, timeout, no install root, one-shot spent), the caller
|
||||
* surfaces the ORIGINAL launch error plus manual
|
||||
* `bunx playwright install chromium` guidance — the CLI never hangs on it.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { spawn } from 'child_process';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
/** F11: one structured stderr line per self-heal action. */
|
||||
function logHeal(action: string, fields: Record<string, unknown> = {}): void {
|
||||
console.error(`[browse:xprotect-heal] ${JSON.stringify({ action, ...fields })}`);
|
||||
}
|
||||
|
||||
// ─── Classifier (F9: positives AND negatives) ────────────────────────────
|
||||
|
||||
/**
|
||||
* Failure shapes that are definitively NOT an XProtect kill. Checked before
|
||||
* the positives so an ambiguous message never triggers a pointless reinstall:
|
||||
* - missing executable (browser was never installed / cache wiped)
|
||||
* - spawn-level permission errors (EACCES / EPERM / ENOENT)
|
||||
* - Linux sandbox denials (wrong OS anyway, but the text is distinctive)
|
||||
*/
|
||||
const NEGATIVE_SIGNATURES: RegExp[] = [
|
||||
/executable doesn't exist/i,
|
||||
/spawn\s+\S+\s+(EACCES|EPERM|ENOENT)/i,
|
||||
/\b(EACCES|EPERM)\b/,
|
||||
/no usable sandbox/i,
|
||||
/failed to move to new namespace/i,
|
||||
/suid sandbox helper/i,
|
||||
];
|
||||
|
||||
/**
|
||||
* Failure shapes an OS-level kill produces (sourced from the #2554 report
|
||||
* plus Playwright's launch-error format): the browser process SPAWNED, then
|
||||
* died to SIGKILL, or never became ready (launch timeout with a `<launched>`
|
||||
* marker — the report's visible symptom, since xprotectd kills the child
|
||||
* without Playwright ever learning why).
|
||||
*/
|
||||
const POSITIVE_SIGNATURES: RegExp[] = [
|
||||
/<process did exit:[^>]*signal=SIGKILL/i,
|
||||
/signal[:=]\s*['"]?SIGKILL/i,
|
||||
];
|
||||
|
||||
/**
|
||||
* True when a launch failure message matches the macOS XProtect kill
|
||||
* signature. Platform-gated: XProtect exists only on darwin.
|
||||
*/
|
||||
export function isXProtectKillSignature(
|
||||
message: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): boolean {
|
||||
if (platform !== 'darwin') return false;
|
||||
if (!message) return false;
|
||||
for (const neg of NEGATIVE_SIGNATURES) {
|
||||
if (neg.test(message)) return false;
|
||||
}
|
||||
for (const pos of POSITIVE_SIGNATURES) {
|
||||
if (pos.test(message)) return true;
|
||||
}
|
||||
// XProtect kill at spawn also surfaces as a launch timeout where the
|
||||
// process DID launch (<launched> marker present) but never became ready —
|
||||
// this is the exact symptom the #2554 report describes.
|
||||
return /timeout \d+\s*ms exceeded/i.test(message) && /<launched>/i.test(message);
|
||||
}
|
||||
|
||||
// ─── Playwright cache path helpers (pure) ────────────────────────────────
|
||||
|
||||
const REVISION_DIR_RE = /^chromium(?:_headless_shell)?-\d+$/;
|
||||
|
||||
/**
|
||||
* Walk up from a Chromium executable to its Playwright cache revision dir
|
||||
* (e.g. …/ms-playwright/chromium-1234 or …/chromium_headless_shell-1234).
|
||||
* Returns null when the executable is not in the standard cache layout.
|
||||
*/
|
||||
export function findPlaywrightRevisionDir(executablePath: string): string | null {
|
||||
let dir = path.dirname(executablePath);
|
||||
for (let i = 0; i < 8; i++) {
|
||||
if (REVISION_DIR_RE.test(path.basename(dir))) return dir;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Chromium revision the EMBEDDED playwright-core expects, derived from
|
||||
* the registry-computed executable path (chromium.executablePath() embeds
|
||||
* the revision from playwright-core's browsers.json — it is not read from
|
||||
* disk, so it stays correct even when nothing is installed yet).
|
||||
*/
|
||||
export function expectedChromiumRevision(executablePath: string): string | null {
|
||||
const revDir = findPlaywrightRevisionDir(executablePath);
|
||||
if (!revDir) return null;
|
||||
const m = path.basename(revDir).match(/-(\d+)$/);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the gstack install root whose node_modules pins the SAME
|
||||
* playwright-core revision our binary embeds (ENG-OV3). Candidates:
|
||||
* the dev checkout (source runs) and the global ./setup install. A candidate
|
||||
* qualifies only when its playwright-core/browsers.json chromium revision
|
||||
* matches — running the reinstall anywhere else heals to the WRONG revision.
|
||||
*/
|
||||
export function findGstackInstallRoot(
|
||||
expectedRevision: string,
|
||||
candidates?: string[],
|
||||
): string | null {
|
||||
const roots = candidates ?? [
|
||||
// Dev checkout: browse/src/ → repo root. In the compiled binary
|
||||
// __dirname points into the bunfs bundle and won't exist on disk,
|
||||
// so this candidate simply fails the existsSync below.
|
||||
path.resolve(__dirname, '..', '..'),
|
||||
// Global install root (the ./setup target).
|
||||
path.join(process.env.HOME || '', '.claude', 'skills', 'gstack'),
|
||||
];
|
||||
for (const root of roots) {
|
||||
try {
|
||||
const browsersJson = path.join(root, 'node_modules', 'playwright-core', 'browsers.json');
|
||||
if (!fs.existsSync(browsersJson)) continue;
|
||||
const parsed = JSON.parse(fs.readFileSync(browsersJson, 'utf-8'));
|
||||
const rev = parsed?.browsers?.find((b: { name?: string }) => b?.name === 'chromium')?.revision;
|
||||
if (String(rev) === String(expectedRevision)) return root;
|
||||
} catch {
|
||||
continue; // unreadable/malformed candidate — try the next one
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Quarantine clear ────────────────────────────────────────────────────
|
||||
|
||||
function defaultRunXattr(target: string): number | null {
|
||||
const res = Bun.spawnSync(['xattr', '-dr', 'com.apple.quarantine', target], {
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
timeout: 10_000,
|
||||
});
|
||||
return res.exitCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear com.apple.quarantine on every chromium* revision dir in the
|
||||
* Playwright cache (the headless shell is what XProtect actually killed in
|
||||
* #2554; the headed bundle rides along so a later headed launch doesn't
|
||||
* re-trip). Scope contract mirrors probePoisonedChromiumBundle: NEVER act
|
||||
* on a GSTACK_CHROMIUM_PATH bundle — that belongs to the wrapper/embedder.
|
||||
* Best-effort: xattr failures are logged, never thrown (the forced
|
||||
* reinstall below is the real heal).
|
||||
*/
|
||||
export function clearQuarantineOnPlaywrightCache(
|
||||
executablePath: string,
|
||||
runXattr: (target: string) => number | null = defaultRunXattr,
|
||||
): boolean {
|
||||
const customPath = process.env.GSTACK_CHROMIUM_PATH;
|
||||
if (customPath && path.resolve(executablePath) === path.resolve(customPath)) {
|
||||
logHeal('quarantine-clear-skipped', { reason: 'custom-chromium-path' });
|
||||
return false;
|
||||
}
|
||||
const revDir = findPlaywrightRevisionDir(executablePath);
|
||||
if (!revDir) {
|
||||
logHeal('quarantine-clear-skipped', { reason: 'not-in-playwright-cache', executablePath });
|
||||
return false;
|
||||
}
|
||||
const cacheRoot = path.dirname(revDir);
|
||||
let cleared = 0;
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = fs.readdirSync(cacheRoot);
|
||||
} catch (err) {
|
||||
logHeal('quarantine-clear-skipped', {
|
||||
reason: 'cache-unreadable',
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!REVISION_DIR_RE.test(entry)) continue;
|
||||
const target = path.join(cacheRoot, entry);
|
||||
try {
|
||||
const exitCode = runXattr(target);
|
||||
// Non-zero usually means "no such xattr" — nothing to clear, fine.
|
||||
logHeal('quarantine-clear', { target, exitCode });
|
||||
cleared++;
|
||||
} catch (err) {
|
||||
logHeal('quarantine-clear', {
|
||||
target,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
return cleared > 0;
|
||||
}
|
||||
|
||||
// ─── Bounded forced reinstall (E1) ───────────────────────────────────────
|
||||
|
||||
export const XPROTECT_REINSTALL_TIMEOUT_MS = 120_000;
|
||||
|
||||
export interface ReinstallResult {
|
||||
ok: boolean;
|
||||
reason?: string;
|
||||
exitCode?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `bunx playwright install --force chromium` from the gstack install
|
||||
* root, bounded at ~120s. The child gets its own process group (detached)
|
||||
* so a timeout kills the WHOLE tree (bunx → playwright CLI → download
|
||||
* workers), never leaving a zombie download saturating the network.
|
||||
*/
|
||||
export function runBoundedChromiumReinstall(
|
||||
installRoot: string,
|
||||
timeoutMs: number = XPROTECT_REINSTALL_TIMEOUT_MS,
|
||||
): Promise<ReinstallResult> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
let child: ReturnType<typeof spawn>;
|
||||
try {
|
||||
child = spawn('bunx', ['playwright', 'install', '--force', 'chromium'], {
|
||||
cwd: installRoot,
|
||||
detached: true, // own process group → group-kill on timeout
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
} catch (err) {
|
||||
resolve({ ok: false, reason: `spawn-error: ${err instanceof Error ? err.message : String(err)}` });
|
||||
return;
|
||||
}
|
||||
let stderrTail = '';
|
||||
child.stderr?.on('data', (d: Buffer) => {
|
||||
stderrTail = (stderrTail + String(d)).slice(-2000);
|
||||
});
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try {
|
||||
if (child.pid) process.kill(-child.pid, 'SIGKILL'); // whole group
|
||||
} catch (err: unknown) {
|
||||
if ((err as NodeJS.ErrnoException)?.code !== 'ESRCH') {
|
||||
try { child.kill('SIGKILL'); } catch { /* already gone */ }
|
||||
}
|
||||
}
|
||||
resolve({ ok: false, reason: 'timeout' });
|
||||
}, timeoutMs);
|
||||
child.on('error', (err) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve({ ok: false, reason: `spawn-error: ${err.message}` });
|
||||
});
|
||||
child.on('exit', (code) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
if (code === 0) {
|
||||
resolve({ ok: true, exitCode: code });
|
||||
} else {
|
||||
resolve({
|
||||
ok: false,
|
||||
reason: `install-exit-${code}${stderrTail ? `: ${stderrTail.slice(-300)}` : ''}`,
|
||||
exitCode: code,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ─── One-shot orchestration (F4) ─────────────────────────────────────────
|
||||
|
||||
let healAttempted = false;
|
||||
|
||||
/** Test seam only — production never resets the one-shot guard. */
|
||||
export function resetXProtectHealForTests(): void {
|
||||
healAttempted = false;
|
||||
}
|
||||
|
||||
export interface XProtectHealDeps {
|
||||
platform?: NodeJS.Platform;
|
||||
executablePath?: () => string;
|
||||
clearQuarantine?: (execPath: string) => boolean;
|
||||
installRoot?: (expectedRevision: string) => string | null;
|
||||
runReinstall?: (installRoot: string) => Promise<ReinstallResult>;
|
||||
verifyInstalled?: (execPath: string) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt the XProtect self-heal for a classified launch failure.
|
||||
*
|
||||
* Returns true when the heal completed AND the revision dir the embedded
|
||||
* playwright-core expects exists on disk — the caller should retry the
|
||||
* launch exactly once. Returns false when the error doesn't match the
|
||||
* signature, the launch used a custom executable, the one-shot guard
|
||||
* already fired, or any heal step failed (the caller then surfaces the
|
||||
* original error + manual guidance).
|
||||
*/
|
||||
export async function maybeHealXProtectKill(
|
||||
err: unknown,
|
||||
opts: { usesCustomExecutable?: boolean } = {},
|
||||
deps: XProtectHealDeps = {},
|
||||
): Promise<boolean> {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (!isXProtectKillSignature(message, deps.platform ?? process.platform)) return false;
|
||||
if (opts.usesCustomExecutable) {
|
||||
// A GSTACK_CHROMIUM_PATH bundle belongs to the wrapper/embedder — never
|
||||
// quarantine-clear or reinstall over it (probePoisonedChromiumBundle's
|
||||
// scope contract).
|
||||
logHeal('skip', { reason: 'custom-executable' });
|
||||
return false;
|
||||
}
|
||||
if (healAttempted) {
|
||||
logHeal('skip', { reason: 'already-attempted-this-process' });
|
||||
return false;
|
||||
}
|
||||
healAttempted = true; // F4: at most one heal per process, even on failure
|
||||
logHeal('classified', { signature: 'xprotect-kill' });
|
||||
|
||||
const execPath = (deps.executablePath ?? (() => chromium.executablePath()))();
|
||||
(deps.clearQuarantine ?? clearQuarantineOnPlaywrightCache)(execPath);
|
||||
|
||||
const revision = expectedChromiumRevision(execPath);
|
||||
if (!revision) {
|
||||
logHeal('reinstall-skipped', { reason: 'no-revision-in-path', execPath });
|
||||
return false;
|
||||
}
|
||||
const root = (deps.installRoot ?? findGstackInstallRoot)(revision);
|
||||
if (!root) {
|
||||
// No install root pins our revision — a cwd-resolved install would heal
|
||||
// to the WRONG revision (ENG-OV3), so surface guidance instead.
|
||||
logHeal('reinstall-skipped', { reason: 'no-install-root', revision });
|
||||
return false;
|
||||
}
|
||||
|
||||
logHeal('reinstall-start', { installRoot: root, revision, timeoutMs: XPROTECT_REINSTALL_TIMEOUT_MS });
|
||||
const result = await (deps.runReinstall ?? runBoundedChromiumReinstall)(root);
|
||||
if (!result.ok) {
|
||||
logHeal('reinstall-failed', { reason: result.reason });
|
||||
return false;
|
||||
}
|
||||
|
||||
// F9/ENG-OV3: assert the revision dir the embedded playwright-core
|
||||
// EXPECTS exists post-heal — install exit 0 alone can mean "installed the
|
||||
// wrong revision" when resolution went sideways.
|
||||
const verify = deps.verifyInstalled ?? ((p: string) => fs.existsSync(p));
|
||||
if (!verify(execPath)) {
|
||||
logHeal('verify-failed', { expected: execPath });
|
||||
return false;
|
||||
}
|
||||
logHeal('reinstall-ok', { installRoot: root, revision });
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Original launch error + manual remediation, for classified failures the
|
||||
* heal could not fix (offline, timeout, one-shot spent, no install root).
|
||||
*/
|
||||
export function buildXProtectGuidance(originalMessage: string): string {
|
||||
return (
|
||||
`${originalMessage}\n` +
|
||||
'[browse] This launch failure matches the macOS XProtect kill signature (#2554): ' +
|
||||
"the OS killed Playwright's Chromium at spawn. Automatic self-heal did not complete. " +
|
||||
'Fix manually: run `bunx playwright install chromium` from your gstack install ' +
|
||||
'(the directory whose node_modules pins playwright — ~/.claude/skills/gstack for ' +
|
||||
'global installs), then retry.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a Playwright launch call with the XProtect self-heal: on a classified
|
||||
* failure, heal once and retry the launch once. On a classified failure the
|
||||
* heal could not fix, throw the ORIGINAL error text augmented with manual
|
||||
* guidance. Unclassified failures pass through untouched.
|
||||
*/
|
||||
export async function launchWithXProtectHeal<T>(
|
||||
doLaunch: () => Promise<T>,
|
||||
opts: { usesCustomExecutable?: boolean } = {},
|
||||
deps: XProtectHealDeps = {},
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await doLaunch();
|
||||
} catch (err) {
|
||||
const healed = await maybeHealXProtectKill(err, opts, deps);
|
||||
if (healed) {
|
||||
logHeal('retry-launch', {});
|
||||
return await doLaunch();
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (isXProtectKillSignature(message, deps.platform ?? process.platform)) {
|
||||
throw new Error(buildXProtectGuidance(message), { cause: err });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
/**
|
||||
* XProtect launch-kill self-heal (P0 #2554) — unit tests.
|
||||
*
|
||||
* F9: the classifier is tested with POSITIVE signatures (sourced from the
|
||||
* #2554 report + Playwright's launch-error format) AND NEGATIVES (missing
|
||||
* executable, EPERM/EACCES, sandbox denial, plain crash) so a generic launch
|
||||
* failure can never trigger a pointless reinstall.
|
||||
*
|
||||
* F4: the one-shot guard is pinned — at most one heal attempt per process,
|
||||
* even when the heal fails.
|
||||
*
|
||||
* ENG-OV3/F9: the post-heal verification target is REGISTRY-derived (the
|
||||
* revision playwright-core's browsers.json expects), not disk-derived, and
|
||||
* the install-root finder rejects roots pinning a different revision.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { chromium } from 'playwright';
|
||||
import {
|
||||
isXProtectKillSignature,
|
||||
findPlaywrightRevisionDir,
|
||||
expectedChromiumRevision,
|
||||
findGstackInstallRoot,
|
||||
clearQuarantineOnPlaywrightCache,
|
||||
maybeHealXProtectKill,
|
||||
launchWithXProtectHeal,
|
||||
resetXProtectHealForTests,
|
||||
buildXProtectGuidance,
|
||||
} from '../src/xprotect-heal';
|
||||
|
||||
const REPO_ROOT = path.resolve(import.meta.dir, '..', '..');
|
||||
|
||||
// ─── Fixtures: POSITIVE signatures (real Playwright error shapes for an
|
||||
// OS-level SIGKILL at spawn — what xprotectd does per the #2554 report) ────
|
||||
|
||||
const SIGKILL_BROWSER_CLOSED = `browserType.launch: Browser closed.
|
||||
==================== Browser output: ====================
|
||||
<launched> pid=48213
|
||||
[pid=48213] <process did exit: exitCode=null, signal=SIGKILL>
|
||||
[pid=48213] starting temporary directories cleanup
|
||||
=========================== logs ===========================`;
|
||||
|
||||
const SIGKILL_PERSISTENT_CONTEXT = `browserType.launchPersistentContext: Target page, context or browser has been closed
|
||||
Browser logs:
|
||||
<launched> pid=9021
|
||||
[pid=9021] <process did exit: exitCode=null, signal=SIGKILL>`;
|
||||
|
||||
// The #2554 report's visible symptom: the kill surfaces as a launch timeout
|
||||
// where the process DID spawn (<launched>) but never became ready.
|
||||
const LAUNCH_TIMEOUT_AFTER_SPAWN = `browserType.launch: Timeout 180000ms exceeded.
|
||||
=========================== logs ===========================
|
||||
<launched> pid=51677
|
||||
============================================================`;
|
||||
|
||||
// ─── Fixtures: NEGATIVE signatures (F9) ──────────────────────────────────
|
||||
|
||||
const MISSING_EXECUTABLE = `browserType.launch: Executable doesn't exist at /Users/dev/Library/Caches/ms-playwright/chromium_headless_shell-1234/chrome-mac-arm64/headless_shell
|
||||
╔═══════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download browsers:║
|
||||
║ bunx playwright install ║
|
||||
╚═══════════════════════════════════════════════════════╝`;
|
||||
|
||||
const SPAWN_EACCES = `browserType.launch: spawn /Users/dev/Library/Caches/ms-playwright/chromium-1234/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing EACCES`;
|
||||
|
||||
const EPERM_FAILURE = `browserType.launch: Browser closed.
|
||||
==================== Browser output: ====================
|
||||
Error: EPERM: operation not permitted, open '/Users/dev/Library/Caches/ms-playwright/.links/lock'`;
|
||||
|
||||
const SANDBOX_DENIAL = `browserType.launch: Browser closed.
|
||||
==================== Browser output: ====================
|
||||
<launched> pid=7211
|
||||
[pid=7211][err] Failed to move to new namespace: PID namespaces supported, Network namespace supported, but failed: errno = Operation not permitted
|
||||
[pid=7211] <process did exit: exitCode=1, signal=null>`;
|
||||
|
||||
const PLAIN_CRASH_EXIT_1 = `browserType.launch: Browser closed.
|
||||
==================== Browser output: ====================
|
||||
<launched> pid=3300
|
||||
[pid=3300] <process did exit: exitCode=1, signal=null>`;
|
||||
|
||||
// ─── Classifier ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('isXProtectKillSignature — positives (darwin)', () => {
|
||||
it('classifies SIGKILL in a Browser closed error', () => {
|
||||
expect(isXProtectKillSignature(SIGKILL_BROWSER_CLOSED, 'darwin')).toBe(true);
|
||||
});
|
||||
|
||||
it('classifies SIGKILL in a launchPersistentContext error', () => {
|
||||
expect(isXProtectKillSignature(SIGKILL_PERSISTENT_CONTEXT, 'darwin')).toBe(true);
|
||||
});
|
||||
|
||||
it('classifies a launch timeout where the process spawned (<launched>)', () => {
|
||||
expect(isXProtectKillSignature(LAUNCH_TIMEOUT_AFTER_SPAWN, 'darwin')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isXProtectKillSignature — negatives (F9)', () => {
|
||||
it('rejects a missing executable', () => {
|
||||
expect(isXProtectKillSignature(MISSING_EXECUTABLE, 'darwin')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects spawn EACCES', () => {
|
||||
expect(isXProtectKillSignature(SPAWN_EACCES, 'darwin')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects EPERM failures', () => {
|
||||
expect(isXProtectKillSignature(EPERM_FAILURE, 'darwin')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects Linux sandbox denials even with a <launched> marker', () => {
|
||||
expect(isXProtectKillSignature(SANDBOX_DENIAL, 'darwin')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a plain crash (exitCode=1, no signal)', () => {
|
||||
expect(isXProtectKillSignature(PLAIN_CRASH_EXIT_1, 'darwin')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a bare timeout with no <launched> marker (process never spawned)', () => {
|
||||
expect(isXProtectKillSignature('browserType.launch: Timeout 180000ms exceeded.', 'darwin')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects empty messages', () => {
|
||||
expect(isXProtectKillSignature('', 'darwin')).toBe(false);
|
||||
});
|
||||
|
||||
it('is platform-gated: the SIGKILL signature on linux/win32 is NOT XProtect', () => {
|
||||
expect(isXProtectKillSignature(SIGKILL_BROWSER_CLOSED, 'linux')).toBe(false);
|
||||
expect(isXProtectKillSignature(SIGKILL_BROWSER_CLOSED, 'win32')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Cache path helpers ──────────────────────────────────────────────────
|
||||
|
||||
describe('findPlaywrightRevisionDir', () => {
|
||||
it('finds the revision dir for the headed bundle layout', () => {
|
||||
const p = '/Users/dev/Library/Caches/ms-playwright/chromium-1234/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing';
|
||||
expect(findPlaywrightRevisionDir(p)).toBe('/Users/dev/Library/Caches/ms-playwright/chromium-1234');
|
||||
});
|
||||
|
||||
it('finds the revision dir for the headless shell layout', () => {
|
||||
const p = '/Users/dev/Library/Caches/ms-playwright/chromium_headless_shell-1234/chrome-mac-arm64/headless_shell';
|
||||
expect(findPlaywrightRevisionDir(p)).toBe('/Users/dev/Library/Caches/ms-playwright/chromium_headless_shell-1234');
|
||||
});
|
||||
|
||||
it('returns null outside the Playwright cache layout', () => {
|
||||
expect(findPlaywrightRevisionDir('/Applications/GStack Browser.app/Contents/MacOS/Chromium')).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('expectedChromiumRevision — registry-derived expectation (F9/ENG-OV3)', () => {
|
||||
it('matches the revision playwright-core browsers.json declares for chromium', () => {
|
||||
const browsersJson = JSON.parse(fs.readFileSync(
|
||||
path.join(REPO_ROOT, 'node_modules', 'playwright-core', 'browsers.json'), 'utf-8',
|
||||
));
|
||||
const registryRevision = browsersJson.browsers.find((b: { name: string }) => b.name === 'chromium').revision;
|
||||
// chromium.executablePath() is computed from the embedded registry (not
|
||||
// read from disk) — the heal's post-install verification target is
|
||||
// therefore the revision dir playwright-core EXPECTS, which is exactly
|
||||
// what a wrong-revision heal would fail.
|
||||
expect(expectedChromiumRevision(chromium.executablePath())).toBe(registryRevision);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findGstackInstallRoot (ENG-OV3: revision-matched roots only)', () => {
|
||||
let tmpRoot: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'xprotect-root-'));
|
||||
const pwCore = path.join(tmpRoot, 'node_modules', 'playwright-core');
|
||||
fs.mkdirSync(pwCore, { recursive: true });
|
||||
fs.writeFileSync(path.join(pwCore, 'browsers.json'), JSON.stringify({
|
||||
browsers: [{ name: 'chromium', revision: '1234' }],
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('accepts a root whose pinned playwright-core expects the same revision', () => {
|
||||
expect(findGstackInstallRoot('1234', [tmpRoot])).toBe(tmpRoot);
|
||||
});
|
||||
|
||||
it('rejects a root pinning a DIFFERENT revision (wrong-revision heal guard)', () => {
|
||||
expect(findGstackInstallRoot('9999', [tmpRoot])).toBe(null);
|
||||
});
|
||||
|
||||
it('rejects roots without node_modules/playwright-core', () => {
|
||||
const bare = fs.mkdtempSync(path.join(os.tmpdir(), 'xprotect-bare-'));
|
||||
try {
|
||||
expect(findGstackInstallRoot('1234', [bare])).toBe(null);
|
||||
} finally {
|
||||
fs.rmSync(bare, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('resolves the dev checkout by default (its node_modules pins our revision)', () => {
|
||||
const browsersJson = JSON.parse(fs.readFileSync(
|
||||
path.join(REPO_ROOT, 'node_modules', 'playwright-core', 'browsers.json'), 'utf-8',
|
||||
));
|
||||
const registryRevision = browsersJson.browsers.find((b: { name: string }) => b.name === 'chromium').revision;
|
||||
const root = findGstackInstallRoot(registryRevision);
|
||||
expect(root).not.toBe(null);
|
||||
expect(fs.existsSync(path.join(root!, 'node_modules', 'playwright-core', 'browsers.json'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Quarantine-clear scope contract ─────────────────────────────────────
|
||||
|
||||
describe('clearQuarantineOnPlaywrightCache', () => {
|
||||
let tmpCache: string;
|
||||
let execPath: string;
|
||||
const savedCustomPath = process.env.GSTACK_CHROMIUM_PATH;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpCache = fs.mkdtempSync(path.join(os.tmpdir(), 'xprotect-cache-'));
|
||||
for (const dir of ['chromium-1234', 'chromium_headless_shell-1234', 'firefox-5678', 'webkit-2222']) {
|
||||
fs.mkdirSync(path.join(tmpCache, dir), { recursive: true });
|
||||
}
|
||||
execPath = path.join(tmpCache, 'chromium-1234', 'chrome-mac-arm64', 'App.app', 'Contents', 'MacOS', 'chromium');
|
||||
delete process.env.GSTACK_CHROMIUM_PATH;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpCache, { recursive: true, force: true });
|
||||
if (savedCustomPath === undefined) delete process.env.GSTACK_CHROMIUM_PATH;
|
||||
else process.env.GSTACK_CHROMIUM_PATH = savedCustomPath;
|
||||
});
|
||||
|
||||
it('clears every chromium* revision dir, never firefox/webkit', () => {
|
||||
const cleared: string[] = [];
|
||||
const ok = clearQuarantineOnPlaywrightCache(execPath, (target) => {
|
||||
cleared.push(path.basename(target));
|
||||
return 0;
|
||||
});
|
||||
expect(ok).toBe(true);
|
||||
expect(cleared.sort()).toEqual(['chromium-1234', 'chromium_headless_shell-1234']);
|
||||
});
|
||||
|
||||
it('NEVER touches a GSTACK_CHROMIUM_PATH bundle (embedder scope contract)', () => {
|
||||
process.env.GSTACK_CHROMIUM_PATH = execPath;
|
||||
const cleared: string[] = [];
|
||||
const ok = clearQuarantineOnPlaywrightCache(execPath, (target) => {
|
||||
cleared.push(target);
|
||||
return 0;
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
expect(cleared).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips executables outside the Playwright cache layout', () => {
|
||||
const cleared: string[] = [];
|
||||
const ok = clearQuarantineOnPlaywrightCache('/Applications/Foo.app/Contents/MacOS/foo', (target) => {
|
||||
cleared.push(target);
|
||||
return 0;
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
expect(cleared).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── One-shot heal orchestration (F4) ────────────────────────────────────
|
||||
|
||||
function makeDeps(counters: { installs: number; quarantines: number }, overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
platform: 'darwin' as NodeJS.Platform,
|
||||
executablePath: () => '/tmp/ms-playwright/chromium-1234/chrome-mac-arm64/App.app/Contents/MacOS/chromium',
|
||||
clearQuarantine: () => { counters.quarantines++; return true; },
|
||||
installRoot: () => '/tmp/fake-gstack-root',
|
||||
runReinstall: async () => { counters.installs++; return { ok: true }; },
|
||||
verifyInstalled: () => true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('maybeHealXProtectKill', () => {
|
||||
beforeEach(() => resetXProtectHealForTests());
|
||||
|
||||
it('heals a classified failure: quarantine-clear + reinstall + verify', async () => {
|
||||
const counters = { installs: 0, quarantines: 0 };
|
||||
const healed = await maybeHealXProtectKill(new Error(SIGKILL_BROWSER_CLOSED), {}, makeDeps(counters));
|
||||
expect(healed).toBe(true);
|
||||
expect(counters.quarantines).toBe(1);
|
||||
expect(counters.installs).toBe(1);
|
||||
});
|
||||
|
||||
it('F4: runs AT MOST ONCE per process, even across distinct errors', async () => {
|
||||
const counters = { installs: 0, quarantines: 0 };
|
||||
expect(await maybeHealXProtectKill(new Error(SIGKILL_BROWSER_CLOSED), {}, makeDeps(counters))).toBe(true);
|
||||
expect(await maybeHealXProtectKill(new Error(LAUNCH_TIMEOUT_AFTER_SPAWN), {}, makeDeps(counters))).toBe(false);
|
||||
expect(counters.installs).toBe(1);
|
||||
});
|
||||
|
||||
it('F4: a FAILED heal also consumes the one-shot (no reinstall loops)', async () => {
|
||||
const counters = { installs: 0, quarantines: 0 };
|
||||
const failing = makeDeps(counters, { runReinstall: async () => { counters.installs++; return { ok: false, reason: 'timeout' }; } });
|
||||
expect(await maybeHealXProtectKill(new Error(SIGKILL_BROWSER_CLOSED), {}, failing)).toBe(false);
|
||||
expect(await maybeHealXProtectKill(new Error(SIGKILL_BROWSER_CLOSED), {}, makeDeps(counters))).toBe(false);
|
||||
expect(counters.installs).toBe(1);
|
||||
});
|
||||
|
||||
it('an unclassified error does NOT consume the one-shot', async () => {
|
||||
const counters = { installs: 0, quarantines: 0 };
|
||||
expect(await maybeHealXProtectKill(new Error(MISSING_EXECUTABLE), {}, makeDeps(counters))).toBe(false);
|
||||
expect(counters.installs).toBe(0);
|
||||
// Guard not consumed — a real signature afterwards still heals.
|
||||
expect(await maybeHealXProtectKill(new Error(SIGKILL_BROWSER_CLOSED), {}, makeDeps(counters))).toBe(true);
|
||||
});
|
||||
|
||||
it('never heals over a custom executable (GSTACK_CHROMIUM_PATH scope)', async () => {
|
||||
const counters = { installs: 0, quarantines: 0 };
|
||||
const healed = await maybeHealXProtectKill(
|
||||
new Error(SIGKILL_BROWSER_CLOSED),
|
||||
{ usesCustomExecutable: true },
|
||||
makeDeps(counters),
|
||||
);
|
||||
expect(healed).toBe(false);
|
||||
expect(counters.quarantines).toBe(0);
|
||||
expect(counters.installs).toBe(0);
|
||||
});
|
||||
|
||||
it('fails the heal when no install root pins our revision (ENG-OV3)', async () => {
|
||||
const counters = { installs: 0, quarantines: 0 };
|
||||
const deps = makeDeps(counters, { installRoot: () => null });
|
||||
expect(await maybeHealXProtectKill(new Error(SIGKILL_BROWSER_CLOSED), {}, deps)).toBe(false);
|
||||
expect(counters.installs).toBe(0);
|
||||
});
|
||||
|
||||
it('fails the heal when post-install verification misses the expected revision dir (F9)', async () => {
|
||||
const counters = { installs: 0, quarantines: 0 };
|
||||
const deps = makeDeps(counters, { verifyInstalled: () => false });
|
||||
expect(await maybeHealXProtectKill(new Error(SIGKILL_BROWSER_CLOSED), {}, deps)).toBe(false);
|
||||
expect(counters.installs).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Launch wrapper ──────────────────────────────────────────────────────
|
||||
|
||||
describe('launchWithXProtectHeal', () => {
|
||||
beforeEach(() => resetXProtectHealForTests());
|
||||
|
||||
it('retries the launch exactly once after a successful heal', async () => {
|
||||
const counters = { installs: 0, quarantines: 0 };
|
||||
let attempts = 0;
|
||||
const result = await launchWithXProtectHeal(async () => {
|
||||
attempts++;
|
||||
if (attempts === 1) throw new Error(SIGKILL_BROWSER_CLOSED);
|
||||
return 'browser';
|
||||
}, {}, makeDeps(counters));
|
||||
expect(result).toBe('browser');
|
||||
expect(attempts).toBe(2);
|
||||
expect(counters.installs).toBe(1);
|
||||
});
|
||||
|
||||
it('surfaces the ORIGINAL error + manual guidance when the heal fails (E1)', async () => {
|
||||
const counters = { installs: 0, quarantines: 0 };
|
||||
const deps = makeDeps(counters, { runReinstall: async () => ({ ok: false, reason: 'timeout' }) });
|
||||
let thrown: Error | null = null;
|
||||
try {
|
||||
await launchWithXProtectHeal(async () => { throw new Error(SIGKILL_BROWSER_CLOSED); }, {}, deps);
|
||||
} catch (err) {
|
||||
thrown = err as Error;
|
||||
}
|
||||
expect(thrown).not.toBe(null);
|
||||
// Original launch error text preserved…
|
||||
expect(thrown!.message).toContain('signal=SIGKILL');
|
||||
// …plus the manual remediation.
|
||||
expect(thrown!.message).toContain('bunx playwright install chromium');
|
||||
});
|
||||
|
||||
it('passes unclassified failures through untouched', async () => {
|
||||
const counters = { installs: 0, quarantines: 0 };
|
||||
let thrown: Error | null = null;
|
||||
try {
|
||||
await launchWithXProtectHeal(async () => { throw new Error(MISSING_EXECUTABLE); }, {}, makeDeps(counters));
|
||||
} catch (err) {
|
||||
thrown = err as Error;
|
||||
}
|
||||
expect(thrown!.message).toBe(MISSING_EXECUTABLE);
|
||||
expect(counters.installs).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildXProtectGuidance', () => {
|
||||
it('carries both the original message and the manual command', () => {
|
||||
const out = buildXProtectGuidance('original launch error');
|
||||
expect(out).toContain('original launch error');
|
||||
expect(out).toContain('bunx playwright install chromium');
|
||||
expect(out).toContain('#2554');
|
||||
});
|
||||
});
|
||||
@@ -360,6 +360,24 @@ ensure_playwright_browser() {
|
||||
_wait_with_deadline $! 90
|
||||
}
|
||||
|
||||
# P0 #2554: a macOS XProtect definition update can start SIGKILLing the
|
||||
# Chromium revision the lockfile pins, which surfaces here as a failed launch
|
||||
# probe. Clear com.apple.quarantine on the Playwright cache bundles ONLY —
|
||||
# never a GSTACK_CHROMIUM_PATH bundle (that belongs to the wrapper/embedder;
|
||||
# same scope contract as browse's probePoisonedChromiumBundle) — so the
|
||||
# reinstall below produces a launchable browser. Best-effort and macOS-only.
|
||||
_clear_playwright_quarantine() {
|
||||
[ "$(uname -s)" = "Darwin" ] || return 0
|
||||
local cache_root="${PLAYWRIGHT_BROWSERS_PATH:-$HOME/Library/Caches/ms-playwright}"
|
||||
[ -d "$cache_root" ] || return 0
|
||||
local d
|
||||
for d in "$cache_root"/chromium-* "$cache_root"/chromium_headless_shell-*; do
|
||||
[ -d "$d" ] || continue
|
||||
echo " clearing com.apple.quarantine on $(basename "$d") (XProtect self-heal, #2554)" >&2
|
||||
xattr -dr com.apple.quarantine "$d" 2>/dev/null || true
|
||||
done
|
||||
}
|
||||
|
||||
# Ensure a color-emoji font is installed (Linux only).
|
||||
#
|
||||
# Chromium renders emoji code points as .notdef "tofu" (▯) when no color-emoji
|
||||
@@ -629,6 +647,10 @@ fi
|
||||
|
||||
if ! ensure_playwright_browser; then
|
||||
echo "Installing Playwright Chromium..."
|
||||
# XProtect self-heal (#2554): the probe failure may be the OS killing the
|
||||
# cached Chromium, not a missing install. Clear quarantine on the Playwright
|
||||
# cache bundles before reinstalling so the fresh fetch launches clean.
|
||||
_clear_playwright_quarantine
|
||||
_PW_LOCK="${TMPDIR:-/tmp}/gstack-playwright-install.lock"
|
||||
# Stale-lock self-heal: a SIGKILL'd prior setup leaves the lock dir behind
|
||||
# forever (mkdir mutexes have no owner). If the recorded holder PID is dead,
|
||||
|
||||
Reference in New Issue
Block a user