import { readBrowserCompositeCaptureRequest, resolveBrowserCompositeCdpClip, } from '../../../sootsim-engine/src/capture/browser-composite' import { createBrowserPointerDispatcher } from '../../../sootsim-engine/src/render-worker/browser-pointer' // the detached browser host, as CJS source. // // it resolves playwright from the absolute path the driver hands it, launches // Chrome for Testing, opens the target url, then keeps itself (and therefore // the browser) alive until the browser disconnects. all inputs arrive through // the environment. // // it stays a source string rather than a module of its own: playwright is // resolved from the user's project at runtime and is not a dependency here, so // there are no types for any of this, and a typed rewrite would be `unknown` // casts end to end. cli/internal-child.ts runs it through node:vm with a real // require, in a child process the CLI spawns as itself. import { DISPOSABLE_BROWSER_CACHE_DIR_NAMES } from '../../src/browser-cache' export const PLAYWRIGHT_SIM_HOST = ` const modulePath = process.env.SOOTSIM_PW_MODULE; const url = process.env.SOOTSIM_PW_URL; // the WS bridge port the sim page must register with, injected below as // window.__sootsimBridgePort so the page skips its shell-port heuristic (which // assumes the default offset and breaks when the dev bridge drifted). const bridgePort = process.env.SOOTSIM_PW_BRIDGE_PORT || ''; const headless = process.env.SOOTSIM_PW_HEADLESS === '1'; const callerUserDataDir = process.env.SOOTSIM_PW_USERDATADIR || ''; // pid of the session that launched us (its topmost non-init ancestor). when // that process exits we tear Chrome down — see startOwnerWatch below. it is a // required lifetime bound: an invalid value aborts before Chrome launches. // SOOTSIM_PW_OWNER_POLL_MS is a test-only override of the 5s poll interval; // production never sets it. const ownerPid = Number(process.env.SOOTSIM_PW_OWNER_PID || 0); const ownerPollMs = Number(process.env.SOOTSIM_PW_OWNER_POLL_MS || 5000); // optional CDP port — exposes Chrome's remote-debugging endpoint so a // bridge-driven sim can also be cpu-profiled (rnx perf cpu --cdp-port). // playwright keeps its own pipe connection; the port is an additional endpoint. const cdpPort = process.env.SOOTSIM_PW_CDP_PORT || ''; // extra chromium launch args, space-separated. a linux GPU runner injects // '--use-angle=vulkan --enable-features=Vulkan' here so chrome binds the // real GPU instead of silently falling back to swiftshader software // rendering (which pegs cores and produces ~1fps canvas capture). const extraChromeArgs = (process.env.SOOTSIM_PW_CHROME_ARGS || '') .split(' ') .filter(Boolean); // Local CanvasKit work must never fall back to Playwright's software // renderer. CI may use the bundled browser for non-interactive coverage, but // local test drives fail closed unless the hardware-backed test browser is // proven after launch. const hardwareGpuRequired = process.env.CI !== '1' && process.env.CI !== 'true'; const softwareRendererPattern = /swiftshader|software(?: rasterizer)?|llvmpipe|lavapipe|softpipe/i; // page viewport as 'x'. the caller sizes it from the device profile; // the fallback is phone-shaped because the page hosts a phone sim — the // playwright default (1280x720 desktop) letterboxes the device frame. const viewportSpec = /^(\\d+)x(\\d+)$/.exec(process.env.SOOTSIM_PW_VIEWPORT || ''); const viewport = viewportSpec ? { width: Number(viewportSpec[1]), height: Number(viewportSpec[2]) } : { width: 600, height: 900 }; // device pixels per css pixel. playwright's default is 1, which makes the // engine allocate its canvas backing store at HALF the resolution a normal // retina-mac chrome would (dpr 2) — the sim looks visibly soft. default to 2 // so a driver-launched sim matches what the user sees in their own browser. const deviceScaleFactor = Number(process.env.SOOTSIM_PW_DSF || 2); const closeTimeoutMs = Number(process.env.SOOTSIM_PW_CLOSE_TIMEOUT_MS || 2500); const connectAckFile = process.env.SOOTSIM_PW_CONNECTED_ACK_FILE || ''; const disposableBrowserCacheDirs = new Set(${JSON.stringify( DISPOSABLE_BROWSER_CACHE_DIR_NAMES, )}); const { existsSync, mkdtempSync, readdirSync, rmSync, unlinkSync } = require('fs'); const { execSync } = require('child_process'); const { tmpdir } = require('os'); const { join } = require('path'); const createBrowserPointerDispatcher = ${createBrowserPointerDispatcher.toString()}; const readBrowserCompositeCaptureRequest = ${readBrowserCompositeCaptureRequest.toString()}; const resolveBrowserCompositeCdpClip = ${resolveBrowserCompositeCdpClip.toString()}; // chrome tree reaper — playwright's browser.close() and even SIGKILLing the // root chrome process do NOT reliably take down the helper tree (gpu, renderer, // network/storage utilities). they reparent to launchd/init and survive, each // one busy enough to peg CPU at 100%+. close() only reaches the root. // // the prior approach was \`pgrep -f ms-playwright/[c]hromium\`. it never matched // system Chrome, so the reaper was a no-op in the common case and every // aborted/closed \`rnx open\` leaked a renderer + gpu helper burning 100%+ // CPU until manually killed (multiple incidents 2026-06). // // fix: identify our chrome tree by its UNIQUE user-data-dir. every helper a // chrome root spawns inherits \`--user-data-dir=\` in its cmdline, so // \`pgrep -f \` finds exactly the tree we launched and nothing else // — Chrome for Testing and bundled playwright chromium alike. as a side benefit we // no longer need the launch / launchPersistentContext fork: we ALWAYS use a // profile dir (mkdtemp'd if the caller didn't pass one, then removed at exit). const reapEnabled = process.platform !== 'win32'; let profileDir = callerUserDataDir; let ephemeralProfile = false; if (!profileDir) { profileDir = mkdtempSync(join(tmpdir(), 'rnx-playwright-host-')); ephemeralProfile = true; } // browser HTTP caches are acceleration data, not profile identity. give each // host a bounded temporary cache so persistent named profiles retain cookies // and storage without multiplying a private cache for every profile. const diskCacheDir = mkdtempSync(join(tmpdir(), 'rnx-playwright-cache-')); const chromePidsByProfile = () => { if (!reapEnabled) return []; try { return execSync('pgrep -f ' + profileDir + ' || true', { stdio: ['ignore', 'pipe', 'ignore'] }) .toString().split('\\n').filter(Boolean).map((p) => Number(p)) .filter((p) => p && p !== process.pid); } catch { return []; } }; const chromeCommandsByProfile = () => { if (!reapEnabled) return []; const pids = chromePidsByProfile(); if (pids.length === 0) return []; try { return execSync('ps -p ' + pids.join(',') + ' -o command=', { stdio: ['ignore', 'pipe', 'ignore'], }).toString().split('\\n').filter(Boolean); } catch { return []; } }; const assertNoUnsafeSwiftShaderFlag = () => { if (!hardwareGpuRequired) return; const unsafe = chromeCommandsByProfile().find((command) => command.includes('--enable-unsafe-swiftshader') || /--use-angle=(?:swiftshader|swiftshader-webgl)(?:\\s|$)/i.test(command) ); if (unsafe) { throw new Error( '[playwright-driver] refused software-rendering Chrome: launched process contains a SwiftShader flag' ); } }; const killTree = (pid) => { let kids = []; try { kids = execSync('pgrep -P ' + pid + ' || true', { stdio: ['ignore', 'pipe', 'ignore'] }) .toString().split('\\n').filter(Boolean); } catch {} for (const k of kids) killTree(Number(k)); try { process.kill(pid, 'SIGKILL'); } catch {} }; const reapChrome = () => { for (const pid of chromePidsByProfile()) killTree(pid); }; const removeDisposableCaches = (root) => { let children = []; try { children = readdirSync(root, { withFileTypes: true }); } catch { return; } for (const child of children) { if (!child.isDirectory() || child.isSymbolicLink()) continue; const childPath = join(root, child.name); if (disposableBrowserCacheDirs.has(child.name)) { try { rmSync(childPath, { recursive: true, force: true }); } catch {} continue; } removeDisposableCaches(childPath); } }; const cleanupProfile = () => { try { rmSync(diskCacheDir, { recursive: true, force: true }); } catch {} if (ephemeralProfile) { try { rmSync(profileDir, { recursive: true, force: true }); } catch {} return; } removeDisposableCaches(profileDir); }; (async () => { const pw = require(modulePath); let context; let keepAlive = null; let connectTimer = null; let connectAckPoll = null; let ownerWatch = null; let connected = false; let shuttingDown = false; const clearConnectTimer = () => { if (connectTimer) { clearTimeout(connectTimer); connectTimer = null; } if (connectAckPoll) { clearInterval(connectAckPoll); connectAckPoll = null; } }; const markConnected = () => { connected = true; clearConnectTimer(); }; const ackFileExists = () => { if (!connectAckFile) return false; try { return existsSync(connectAckFile); } catch { return false; } }; const bounded = async (label, work, timeoutMs) => { let timer = null; try { const timeout = new Promise((resolve) => { timer = setTimeout(() => resolve({ timedOut: true }), timeoutMs); }); if (typeof timer.unref === 'function') timer.unref(); const result = await Promise.race([ Promise.resolve().then(work).then( () => ({ timedOut: false }), (error) => ({ timedOut: false, error }), ), timeout, ]); if (result && result.timedOut) { process.stderr.write('[shutdown-timeout] ' + label + ' exceeded ' + timeoutMs + 'ms\\n'); return false; } if (result && result.error) { process.stderr.write('[shutdown-error] ' + label + ': ' + String((result.error && result.error.message) || result.error) + '\\n'); return false; } return true; } finally { if (timer) clearTimeout(timer); } }; const browserProcess = () => { if (context && typeof context.browser === 'function') { try { const b = context.browser(); if (b && typeof b.process === 'function') return b.process(); } catch {} } return null; }; const processIsAlive = (proc) => { if (!proc || !proc.pid) return false; try { process.kill(proc.pid, 0); return true; } catch (err) { return err && err.code === 'EPERM'; } }; const terminateBrowserProcess = async () => { const proc = browserProcess(); if (!proc || !proc.pid || !processIsAlive(proc)) return; try { proc.kill('SIGTERM'); } catch {} await bounded('browser process exit after SIGTERM', async () => { while (processIsAlive(proc)) { await new Promise((resolve) => setTimeout(resolve, 50)); } }, 600); if (!processIsAlive(proc)) return; try { proc.kill('SIGKILL'); } catch {} }; const shutdown = async (code = 0) => { if (shuttingDown) return; shuttingDown = true; clearConnectTimer(); if (keepAlive) clearInterval(keepAlive); if (ownerWatch) clearInterval(ownerWatch); const timeoutMs = Number.isFinite(closeTimeoutMs) && closeTimeoutMs > 0 ? closeTimeoutMs : 2500; if (context) await bounded('context.close', () => context.close(), timeoutMs); await terminateBrowserProcess(); // close() + root-SIGKILL above handle the graceful case; the helper tree // (gpu/renderer/utilities) reparents and survives every one of those, so // we always sweep by our unique profile dir as the load-bearing teardown. reapChrome(); cleanupProfile(); if (connectAckFile) { try { unlinkSync(connectAckFile); } catch {} } process.exit(code); }; process.once('SIGTERM', () => { void shutdown(0); }); process.once('SIGINT', () => { void shutdown(0); }); // the launch-failure path at the bottom of this script exits without going // through shutdown(); the exit hook (sync sweep) still reaps the tree. process.on('exit', () => { reapChrome(); cleanupProfile(); }); // owner-liveness watchdog — the load-bearing fix for leaked Chrome hosts. // we are spawned detached + unref'd so \`rnx open\` returns immediately, // which means nothing in the OS process tree ties Chrome's lifetime to the // session that asked for it. once the sim connects, the connect-timeout // safety net is cleared, so absent an explicit \`rnx close\` (or the // daemon's idle reaper closing the sim socket) the host would hold Chrome // open forever after its owner exits. poll the owning session's pid and tear // down when it's gone. ESRCH (kill throws) = owner exited; EPERM = alive but // not signalable by us, which still counts as alive. const startOwnerWatch = () => { if (!Number.isInteger(ownerPid) || ownerPid <= 1) { throw new Error('[playwright-driver] missing valid owning process; refusing unsupervised browser host'); } const intervalMs = Number.isFinite(ownerPollMs) && ownerPollMs > 0 ? ownerPollMs : 5000; ownerWatch = setInterval(() => { try { process.kill(ownerPid, 0); } catch (err) { if (err && err.code === 'EPERM') return; process.stderr.write('[owner-gone] owning process ' + ownerPid + ' exited; closing playwright host\\n'); void shutdown(0); } }, intervalMs); if (typeof ownerWatch.unref === 'function') ownerWatch.unref(); }; startOwnerWatch(); // Local rendering uses Playwright's full Chrome for Testing app. On macOS its // com.google.chrome.for.testing identity cannot intercept open/quit events for // the user's com.google.Chrome browser. CI may use chromium_headless_shell; // local rendering fails closed because that shell has no GPU and silently // chooses SwiftShader. NOTE: this is NOT enough to make \`rnx perf cpu\` // work — that profiles the tenant *worker*, and the JS Self-Profiler API // cannot be constructed in a dedicated worker (see // worker-handlers/cpu-profile.ts). cpu-profile needs a CDP-based profiler // instead. if ( hardwareGpuRequired && extraChromeArgs.some((arg) => arg === '--enable-unsafe-swiftshader' || /^--use-angle=(?:swiftshader|swiftshader-webgl)$/i.test(arg) ) ) { throw new Error('[playwright-driver] refused SwiftShader launch arguments'); } // one browser path per environment: the full, separately identified test app // locally; chromium_headless_shell on CI. never launch a branded user browser. const channelsToTry = hardwareGpuRequired ? ['chromium'] : [null]; const launchWith = async (channel) => { const opts = channel ? { headless, channel, viewport, deviceScaleFactor } : { headless, viewport, deviceScaleFactor }; // playwright injects --force-color-profile=srgb by default, which reads // visibly washed-out next to a normal chrome window on a wide-gamut mac // display. strip it so driver-launched sims color-match the user's own // browser (real incident 2026-07-09: "low res and whitewashed" sim). opts.ignoreDefaultArgs = ['--force-color-profile=srgb']; // headless chromium backgrounds/occludes the renderer and throttles rAF + // timers to ~1fps, which silently starves combined/video frame capture // (was 47 frames over 63s -> frozen-looking mp4). keep the render loop at // full rate so the recorder captures real motion. document.hidden stays // false in headless, so assertTabVisibleForCapture never caught this — it // has to be fixed at the launch flags. opts.args = [ // the compositor renders through skia graphite on webgpu, so a launch // without navigator.gpu boots to a fatal instead of a sim. '--enable-unsafe-webgpu', '--disk-cache-dir=' + diskCacheDir, '--disk-cache-size=67108864', '--disable-background-timer-throttling', '--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding', ]; if (channel) { // the full test browser can bind the real platform GPU — force the native // ANGLE backend and strip playwright's --enable-unsafe-swiftshader // default arg. without both, headless chrome silently falls back to // swiftshader software rendering for the CanvasKit/WebGL canvas and // pegs every core. SOOTSIM_PW_CHROME_ARGS is appended later, so an // env-injected --use-angle=... still overrides (last flag wins). const angle = process.platform === 'darwin' ? 'metal' : process.platform === 'linux' ? 'vulkan' : 'default'; opts.args.unshift( '--use-angle=' + angle, '--enable-gpu', '--enable-gpu-rasterization', '--enable-zero-copy', '--ignore-gpu-blocklist', ); opts.ignoreDefaultArgs.push('--enable-unsafe-swiftshader'); } else { // playwright's bundled software chromium exposes no navigator.gpu until // Vulkan is named explicitly, and webgpu is the only backend there is. opts.args.push('--enable-features=Vulkan'); } if (cdpPort) opts.args.push('--remote-debugging-port=' + cdpPort); if (extraChromeArgs.length) opts.args.push(...extraChromeArgs); // headed: size the OS window to the (phone-shaped) viewport too, plus // room for chrome's own toolbar, so the window isn't desktop-shaped // around a letterboxed page. if (!headless) opts.args.push('--window-size=' + viewport.width + ',' + (viewport.height + 88)); // ALWAYS persistent: every chrome process inherits --user-data-dir= // in its cmdline, which is how reapChrome() finds the whole tree (root + // helpers) on shutdown. without a unique dir there is no way to attribute // an orphaned helper back to us, which is the leak we just fixed. return await pw.chromium.launchPersistentContext(profileDir, opts); }; let launchErr = null; let launchedChannel = null; for (const channel of channelsToTry) { try { context = await launchWith(channel); launchedChannel = channel || 'chromium'; launchErr = null; break; } catch (err) { launchErr = err; const msg = err && err.message ? String(err.message) : ''; const isMissingChannel = /not found at/i.test(msg) || /not installed/i.test(msg) || /Chromium distribution/i.test(msg); if (!isMissingChannel) throw err; // unexpected failure: bubble up if (channel) { const next = hardwareGpuRequired ? '; bundled Chromium is disabled for local GPU work\\n' : ', falling back to bundled chromium\\n'; process.stderr.write('[playwright-driver] channel "' + channel + '" not available' + next); } } } if (launchErr) throw launchErr; if (launchedChannel) { process.stderr.write('[playwright-driver] launched ' + launchedChannel + '\\n'); } // grant camera up front so the sim's Camera app reaches the host webcam // through getUserMedia, mirroring a real iOS simulator where the camera just // works. without this the unhandled chrome permission prompt auto-denies and // CameraApp renders its "Camera Access Required" placeholder. await context.grantPermissions(['camera']); const hostMeta = { sootsimHostDriver: 'playwright', sootsimHostPid: process.pid, sootsimHostOwnerPid: ownerPid, sootsimHostStartedAt: Date.now(), }; // inject the resolved bridge port (numeric literal) BEFORE the page runs, so // the shell/engine talk to the daemon this driver owns. addInitScript runs // ahead of every page script, and the vite dev plugin only fills the value in // when it is still unset, so this one wins. plain assignment, NOT // defineProperty: other injectors also assign to it, and a non-writable // property would throw at that assignment and crash boot. const bridgePortInject = bridgePort ? 'window.__sootsimBridgePort = ' + Number(bridgePort) + ';' : ''; await context.addInitScript({ content: 'Object.defineProperty(window, "__sootsimHostMeta", { value: ' + JSON.stringify(hostMeta) + ', configurable: true });' + bridgePortInject, }); await context.exposeBinding('__sootsimHostClose', async () => { await shutdown(0); }); const pointerHosts = new WeakMap(); await context.exposeBinding('__sootsimHostPointer', async (source, value) => { let pending = pointerHosts.get(source.page); if (!pending) { pending = source.page.context().newCDPSession(source.page).then(session => createBrowserPointerDispatcher((method, params) => session.send(method, params))); pointerHosts.set(source.page, pending); } await (await pending)(value); }); await context.exposeBinding('__sootsimHostCapture', async (source, value) => { const request = readBrowserCompositeCaptureRequest(value); const session = await source.page.context().newCDPSession(source.page); try { const result = await session.send('Page.captureScreenshot', { format: 'png', fromSurface: true, captureBeyondViewport: true, clip: resolveBrowserCompositeCdpClip( request, await session.send('Page.getLayoutMetrics'), ), }); return 'data:image/png;base64,' + result.data; } finally { await session.detach(); } }); const connectTimeoutMs = Number(process.env.SOOTSIM_PW_CONNECT_TIMEOUT_MS || 120000); if (connectAckFile) { connectAckPoll = setInterval(() => { if (ackFileExists()) markConnected(); }, 500); if (typeof connectAckPoll.unref === 'function') connectAckPoll.unref(); } connectTimer = setTimeout(() => { if (ackFileExists()) { markConnected(); return; } if (connected) return; process.stderr.write('[connect-timeout] sim did not register with the bridge within ' + connectTimeoutMs + 'ms; closing playwright host\\n'); void shutdown(2); }, Number.isFinite(connectTimeoutMs) && connectTimeoutMs > 0 ? connectTimeoutMs : 120000); if (typeof connectTimer.unref === 'function') connectTimer.unref(); const page = await context.newPage(); if (hardwareGpuRequired) { assertNoUnsafeSwiftShaderFlag(); const gpu = await page.evaluate(() => { const canvas = document.createElement('canvas'); const gl = canvas.getContext('webgl2', { powerPreference: 'high-performance' }) || canvas.getContext('webgl', { powerPreference: 'high-performance' }); if (!gl) return { renderer: null, vendor: null }; const info = gl.getExtension('WEBGL_debug_renderer_info'); return { renderer: info ? String(gl.getParameter(info.UNMASKED_RENDERER_WEBGL)) : null, vendor: info ? String(gl.getParameter(info.UNMASKED_VENDOR_WEBGL)) : null, }; }); if (!gpu.renderer) { throw new Error( '[playwright-driver] could not prove a hardware WebGL renderer; refusing local render-heavy launch' ); } if (softwareRendererPattern.test(gpu.renderer)) { throw new Error('[playwright-driver] refused software WebGL renderer: ' + gpu.renderer); } assertNoUnsafeSwiftShaderFlag(); process.stderr.write( '[playwright-driver] hardware WebGL renderer: ' + gpu.renderer + (gpu.vendor ? ' (' + gpu.vendor + ')' : '') + '\\n' ); } // surface page-side failures (engine boot crash, failed WS registration) // to stderr so a sim that never connects produces a real diagnostic // instead of a silent "timed out waiting for opened sim to connect". page.on('pageerror', (e) => { process.stderr.write('[pageerror] ' + ((e && e.stack) || e) + '\\n'); }); page.on('console', (m) => { if (m.type() === 'error') { process.stderr.write('[console.error] ' + m.text() + '\\n'); } }); page.on('crash', () => process.stderr.write('[page] renderer crashed\\n')); try { await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 }); } catch (err) { process.stderr.write('[goto] ' + String((err && err.message) || err) + '\\n'); } keepAlive = setInterval(() => {}, 1 << 30); // browser disconnect (e.g. user killed chrome from outside) → tear down. // we are persistent-only now, so .browser() is the only handle to it. try { const b = context.browser && context.browser(); if (b && typeof b.on === 'function') b.on('disconnected', () => { void shutdown(0); }); } catch {} context.on('close', () => { void shutdown(0); }); page.on('close', () => { void shutdown(0); }); })().catch((err) => { process.stderr.write(String((err && err.stack) || err) + '\\n'); process.exit(1); }); `