// rnx perf cpu — capture sampled CPU traces from the page and every worker. // // the JS Self-Profiler API (`new Profiler()`) cannot be constructed in a // dedicated-worker scope, and rnx runs the guest app in the tenant worker, // so the old in-worker approach was unsupported (F28). this implementation // attaches Chromium's `Profiler` CDP domain to the page and worker targets — // which returns the .cpuprofile shape directly for every execution context. // // dedicated workers are not top-level CDP targets, so we connect to the page // target, `Target.setAutoAttach` to discover the worker child sessions, and // route `Profiler.*` to each worker via its sessionId (flatten mode). // // the user's normal rnx tab runs in plain Chrome without a debug port. // the easiest way to get a profilable sim is to open one with a CDP port — // it stays driveable over the WS bridge, so you can scroll/navigate while // profiling (the playwright driver passes --remote-debugging-port through): // // rnx open --new --driver playwright --cdp-port 9222 // rnx perf cpu --cdp-port 9222 --match --duration 5 // // (a standalone CDP Chrome also works for profiling a static page, but it // does NOT register on the bridge, so it can't be driven — see printNoCdp.) // // interact with the app during the capture window. the reference probe that // also drives the interaction itself lives at // scripts/debug/cdp-worker-cpu-profile.ts. import { createHash } from 'node:crypto' import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { dirname, resolve } from 'node:path' import { WebSocket } from 'ws' import { getCliVersion } from '../../src/cli-version' import { IS_STANDALONE } from '../standalone' interface ProfileOptions { port?: number verbose?: boolean } const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) // compact CDP client with flatten-mode sessionId routing — one ws to the page // target carries the page session plus every auto-attached worker session. class Cdp { private ws: WebSocket private nextId = 1 private pending = new Map< number, { res: (v: any) => void rej: (e: any) => void timeout: ReturnType } >() private listeners = new Set<(method: string, params: any, sessionId?: string) => void>() private ready: Promise constructor(wsUrl: string) { this.ws = new WebSocket(wsUrl, { maxPayload: 1 << 28 }) this.ready = new Promise((res, rej) => { this.ws.once('open', () => res()) this.ws.once('error', rej) }) this.ws.on('message', (data) => { const msg = JSON.parse(data.toString()) if (msg.id && this.pending.has(msg.id)) { const { res, rej, timeout } = this.pending.get(msg.id)! this.pending.delete(msg.id) clearTimeout(timeout) msg.error ? rej(new Error(JSON.stringify(msg.error))) : res(msg.result) } else if (msg.method) { for (const l of this.listeners) l(msg.method, msg.params, msg.sessionId) } }) this.ws.on('close', () => { for (const [id, request] of this.pending) { clearTimeout(request.timeout) request.rej(new Error(`CDP connection closed with request ${id} pending`)) } this.pending.clear() }) } waitOpen() { return this.ready } on(l: (method: string, params: any, sessionId?: string) => void) { this.listeners.add(l) } send( method: string, params: Record = {}, sessionId?: string, ): Promise { const id = this.nextId++ return new Promise((res, rej) => { const timeout = setTimeout(() => { this.pending.delete(id) rej(new Error(`CDP ${method} timed out after 5s`)) }, 5_000) this.pending.set(id, { res, rej, timeout }) this.ws.send( JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }), ) }) } close() { try { this.ws.close() } catch {} } } interface CdpTarget { type: string title?: string url: string webSocketDebuggerUrl: string } interface AttachedTarget { attachedAtMs: number detached: boolean detachedAtMs?: number error?: string profile?: any profileStartedAtMs?: number profileStoppedAtMs?: number role: string sessionId?: string started: boolean title: string type: string url: string } export interface CpuProfileInteractionBarrier { marker: string reachedAtEpochMs: number releasedBy: string releasedAtEpochMs: number } export interface CpuProfileCoverage { coverageMs: number coverageRatio: number startedOffsetMs: number | null stoppedOffsetMs: number | null } export interface CpuProfileBrowserExecutableIdentity { path: string sha256: string } export interface CpuProfileBrowserIdentity { name: string version: string executable: CpuProfileBrowserExecutableIdentity } export interface CpuProfileCaptureEvidence { label: string actionEpochMs: number } export interface CpuProfileWorkloadIdentity { scenario: string sourceSha256: string } export interface CpuProfileWorkloadEvidence { browserIdentity: CpuProfileBrowserIdentity captures: CpuProfileCaptureEvidence[] completionMarker: string expectedCaptureLabels: string[] runtimeIdentity: Record stage: string workloadIdentity: CpuProfileWorkloadIdentity } export function cpuProfileCoverage( profileStartedAtMs: number | undefined, profileStoppedAtMs: number | undefined, windowStartedAtMs: number, windowEndedAtMs: number, ): CpuProfileCoverage { const requestedMs = Math.max(0, windowEndedAtMs - windowStartedAtMs) if ( profileStartedAtMs === undefined || profileStoppedAtMs === undefined || requestedMs === 0 ) { return { coverageMs: 0, coverageRatio: 0, startedOffsetMs: profileStartedAtMs === undefined ? null : profileStartedAtMs - windowStartedAtMs, stoppedOffsetMs: profileStoppedAtMs === undefined ? null : profileStoppedAtMs - windowStartedAtMs, } } const coverageMs = Math.max( 0, Math.min(profileStoppedAtMs, windowEndedAtMs) - Math.max(profileStartedAtMs, windowStartedAtMs), ) return { coverageMs, coverageRatio: coverageMs / requestedMs, startedOffsetMs: profileStartedAtMs - windowStartedAtMs, stoppedOffsetMs: profileStoppedAtMs - windowStartedAtMs, } } export function cpuProfileRoleLabels( targets: readonly { captured: boolean; role: string }[], ): string[] { const labels = new Array(targets.length) const roleCounts = new Map() const assign = (index: number) => { const role = targets[index].role const count = (roleCounts.get(role) ?? 0) + 1 roleCounts.set(role, count) labels[index] = count === 1 ? role : `${role}-${count}` } for (let index = 0; index < targets.length; index++) { if (targets[index].captured) assign(index) } for (let index = 0; index < targets.length; index++) { if (!targets[index].captured) assign(index) } return labels } export function cpuProfileTargetOverlapsWindow( detachedAtMs: number | undefined, windowStartedAtMs: number, ): boolean { return detachedAtMs === undefined || detachedAtMs > windowStartedAtMs } export function cpuProfileActionsWithinWindow( actionEpochMs: readonly number[], windowStartedAtEpochMs: number, windowEndedAtEpochMs: number, ): boolean { return ( actionEpochMs.length > 0 && actionEpochMs.every( (epochMs) => Number.isFinite(epochMs) && epochMs >= windowStartedAtEpochMs && epochMs <= windowEndedAtEpochMs, ) ) } function cpuProfileRecord(value: unknown): Record | null { if (!value || typeof value !== 'object' || Array.isArray(value)) return null return Object.fromEntries(Object.entries(value)) } function cpuProfileNonemptyString(value: unknown): value is string { return typeof value === 'string' && value.length > 0 } function cpuProfileSha256(value: unknown): value is string { return typeof value === 'string' && /^[a-f0-9]{64}$/.test(value) } function cpuProfileViewportIdentityValid(value: unknown): boolean { const viewport = cpuProfileRecord(value) return ( typeof viewport?.width === 'number' && Number.isFinite(viewport.width) && viewport.width > 0 && typeof viewport.height === 'number' && Number.isFinite(viewport.height) && viewport.height > 0 && typeof viewport.devicePixelRatio === 'number' && Number.isFinite(viewport.devicePixelRatio) && viewport.devicePixelRatio > 0 ) } export function readCpuProfileWorkloadEvidence( value: unknown, ): CpuProfileWorkloadEvidence | null { const partial = cpuProfileRecord(value) const browserIdentity = cpuProfileRecord(partial?.browserIdentity) const browserExecutable = cpuProfileRecord(browserIdentity?.executable) const runtimeIdentity = cpuProfileRecord(partial?.runtimeIdentity) const engineManifest = cpuProfileRecord(runtimeIdentity?.engineManifest) const hostPresentation = cpuProfileRecord(runtimeIdentity?.hostPresentation) const workloadIdentity = cpuProfileRecord(partial?.workloadIdentity) const stage = partial?.stage const completionMarker = partial?.completionMarker const expectedCaptureLabels = partial?.expectedCaptureLabels const captures = partial?.captures if ( !cpuProfileNonemptyString(stage) || !cpuProfileNonemptyString(completionMarker) || stage !== completionMarker || !Array.isArray(expectedCaptureLabels) || expectedCaptureLabels.length === 0 || expectedCaptureLabels.some( (label, index, labels) => !cpuProfileNonemptyString(label) || labels.indexOf(label) !== index, ) || !Array.isArray(captures) || !cpuProfileNonemptyString(browserIdentity?.name) || !cpuProfileNonemptyString(browserIdentity.version) || !cpuProfileNonemptyString(browserExecutable?.path) || !cpuProfileSha256(browserExecutable.sha256) || runtimeIdentity === null || !cpuProfileNonemptyString(engineManifest?.buildIdentity) || !cpuProfileNonemptyString(engineManifest.generation) || hostPresentation === null || !cpuProfileViewportIdentityValid(hostPresentation.rootViewport) || !cpuProfileViewportIdentityValid(hostPresentation.targetViewport) || !cpuProfileNonemptyString(workloadIdentity?.scenario) || workloadIdentity.scenario !== partial?.scenario || !cpuProfileSha256(workloadIdentity.sourceSha256) ) { return null } const captureEvidence: CpuProfileCaptureEvidence[] = [] for (const captureValue of captures) { const capture = cpuProfileRecord(captureValue) const note = cpuProfileRecord(capture?.note) if ( !cpuProfileNonemptyString(capture?.label) || typeof note?.actionEpochMs !== 'number' || !Number.isFinite(note.actionEpochMs) ) { return null } captureEvidence.push({ label: capture.label, actionEpochMs: note.actionEpochMs }) } if ( JSON.stringify(captureEvidence.map((capture) => capture.label)) !== JSON.stringify(expectedCaptureLabels) ) { return null } return { browserIdentity: { name: browserIdentity.name, version: browserIdentity.version, executable: { path: browserExecutable.path, sha256: browserExecutable.sha256, }, }, captures: captureEvidence, completionMarker, expectedCaptureLabels: [...expectedCaptureLabels], runtimeIdentity, stage, workloadIdentity: { scenario: workloadIdentity.scenario, sourceSha256: workloadIdentity.sourceSha256, }, } } export function cpuProfileInteractionBarrierValid( barrier: CpuProfileInteractionBarrier | null, windowStartedAtEpochMs: number, windowEndedAtEpochMs: number, ): boolean { return ( barrier !== null && barrier.marker.length > 0 && barrier.releasedBy === 'profiler' && Number.isFinite(barrier.reachedAtEpochMs) && Number.isFinite(barrier.releasedAtEpochMs) && barrier.reachedAtEpochMs <= barrier.releasedAtEpochMs && barrier.releasedAtEpochMs >= windowStartedAtEpochMs && barrier.releasedAtEpochMs <= windowEndedAtEpochMs ) } function readInteractionBarrierState( value: unknown, ): CpuProfileInteractionBarrier | null { if (!value || typeof value !== 'object') return null const marker = Reflect.get(value, 'marker') const reachedAtEpochMs = Reflect.get(value, 'reachedAtEpochMs') const releasedBy = Reflect.get(value, 'releasedBy') const releasedAtEpochMs = Reflect.get(value, 'releasedAtEpochMs') if ( typeof marker !== 'string' || typeof reachedAtEpochMs !== 'number' || typeof releasedBy !== 'string' || typeof releasedAtEpochMs !== 'number' ) { return null } return { marker, reachedAtEpochMs, releasedBy, releasedAtEpochMs } } export async function runCpuProfile( args: string[], opts: ProfileOptions, ): Promise { const duration = Number(valueOf(args, '--duration') ?? '5') if (!Number.isFinite(duration) || duration <= 0) { console.error(' --duration must be a positive number (seconds)') return 1 } const sampleInterval = Number(valueOf(args, '--sample-interval') ?? '0.1') // ms const interactionBarrierEnabled = args.includes('--interaction-barrier') const interactionBarrierTimeoutSeconds = Number( valueOf(args, '--interaction-barrier-timeout') ?? '90', ) if ( !Number.isFinite(interactionBarrierTimeoutSeconds) || interactionBarrierTimeoutSeconds <= 0 ) { console.error(' --interaction-barrier-timeout must be positive (seconds)') return 1 } const interactionBarrierLeaseMs = Math.min( 2_147_000_000, Math.ceil((interactionBarrierTimeoutSeconds + duration + 5) * 1000), ) const cdpPort = Number( valueOf(args, '--cdp-port') ?? process.env.RNX_CDP_PORT ?? '9222', ) // optional substring filter to pick the right page when several sims share // the CDP browser (e.g. `--match 8089` or `--match /rn/8089`). const match = valueOf(args, '--match') ?? '/rn/' const outputArg = valueOf(args, '--output') ?? valueOf(args, '-o') const outputPath = resolve(process.cwd(), outputArg ?? '/tmp/rnx.cpuprofile') // locate the page target on the CDP endpoint. NOTE: Chrome's DevTools HTTP // `/json` endpoints reject any Host header that isn't `localhost` (a // DNS-rebinding guard) — `http://127.0.0.1:/json/list` returns // "Not found" / 404, so we must use the `localhost` hostname here. (this is // what made the command silently fall into the no-CDP path even with a live // CDP browser.) let targets: CdpTarget[] = [] try { targets = (await ( await fetch(`http://localhost:${cdpPort}/json/list`) ).json()) as CdpTarget[] } catch { printNoCdp(cdpPort) return 1 } const page = targets.find((t) => t.type === 'page' && t.url.includes(match)) if (!page) { console.error(` no page target matching "${match}" on CDP :${cdpPort}`) console.error(` open targets: ${targets.map((t) => t.url).join(', ') || '(none)'}`) return 1 } const cdp = new Cdp(page.webSocketDebuggerUrl) await cdp.waitOpen() let interactionBarrierInstalled = false const evaluatePage = async (expression: string): Promise => { const response = await cdp.send('Runtime.evaluate', { expression, returnByValue: true, }) if (response?.exceptionDetails) { throw new Error( `page evaluation failed: ${response.exceptionDetails.text ?? 'unknown error'}`, ) } return response?.result?.value } const releaseInteractionBarrier = (releasedBy: 'cleanup' | 'profiler') => evaluatePage(`(() => { const release = Reflect.get(globalThis, '__sootsimReleaseCpuProfileBarrier') return typeof release === 'function' ? Reflect.apply(release, globalThis, [${JSON.stringify(releasedBy)}]) : null })()`) try { if (interactionBarrierEnabled) { const installed = await evaluatePage(`(() => { const state = { marker: null, reachedAtEpochMs: null, releasedBy: null, releasedAtEpochMs: null, } let releaseGate = () => {} const gate = new Promise((resolve) => { releaseGate = resolve }) Reflect.set(globalThis, '__sootsimCpuProfileBarrierState', state) Reflect.set(globalThis, '__sootsimCpuProfileBarrier', async (marker) => { if (state.marker === null) { state.marker = String(marker) state.reachedAtEpochMs = Date.now() } await gate }) let lease = null const releaseBarrier = (releasedBy) => { if (state.releasedAtEpochMs === null) { state.releasedAtEpochMs = Date.now() state.releasedBy = String(releasedBy) if (lease !== null) clearTimeout(lease) releaseGate() } return { ...state } } lease = setTimeout( () => releaseBarrier('lease'), ${interactionBarrierLeaseMs}, ) Reflect.set(globalThis, '__sootsimReleaseCpuProfileBarrier', releaseBarrier) return true })()`) if (installed !== true) { console.error(' failed to install the interaction profile barrier') return 1 } interactionBarrierInstalled = true } // discover every child worker. keep detached entries in the manifest: a // worker can exit during the capture, and deleting it used to make the // command either mislabel the busiest survivor as "tenant" or lose the // entire run when Profiler.stop hit the detached session first. const workers = new Map() let captureStarted = false let captureWindowClosed = false const startPromises: Array> = [] const discoveryPromises: Array> = [] let attachmentGeneration = 0 let awaitedDiscoveryCount = 0 let awaitedStartCount = 0 const enableRecursiveAutoAttach = (sessionId?: string) => cdp.send( 'Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: false, flatten: true, }, sessionId, ) const startTarget = async (target: AttachedTarget) => { try { await cdp.send('Profiler.enable', {}, target.sessionId) await cdp.send( 'Profiler.setSamplingInterval', { interval: sampleInterval * 1000 }, target.sessionId, ) await cdp.send('Profiler.start', {}, target.sessionId) target.started = true target.profileStartedAtMs = performance.now() } catch (error) { target.error = error instanceof Error ? error.message : String(error) } } const waitForStableTargetSetup = async () => { while (true) { const observedGeneration = attachmentGeneration if (awaitedDiscoveryCount < discoveryPromises.length) { const pendingDiscoveries = discoveryPromises.slice(awaitedDiscoveryCount) awaitedDiscoveryCount = discoveryPromises.length await Promise.all(pendingDiscoveries) } if (awaitedStartCount < startPromises.length) { const pendingStarts = startPromises.slice(awaitedStartCount) awaitedStartCount = startPromises.length await Promise.all(pendingStarts) } // recursive auto-attach notifications can arrive just after the CDP // command resolves. require a quiet generation before releasing work. await sleep(50) if ( observedGeneration === attachmentGeneration && awaitedDiscoveryCount === discoveryPromises.length && awaitedStartCount === startPromises.length ) { return } } } cdp.on((method, params) => { if (method === 'Target.attachedToTarget' && params.targetInfo?.type === 'worker') { if (captureWindowClosed) return const sessionId = params.sessionId const targetInfo = params.targetInfo const target: AttachedTarget = { attachedAtMs: performance.now(), detached: false, role: cpuProfileTargetRole(targetInfo.url, targetInfo.title), sessionId, started: false, title: targetInfo.title ?? '', type: targetInfo.type, url: targetInfo.url, } workers.set(sessionId, target) attachmentGeneration += 1 discoveryPromises.push( enableRecursiveAutoAttach(sessionId).catch((error: unknown) => { target.error = `recursive auto-attach failed: ${error instanceof Error ? error.message : String(error)}` }), ) if (captureStarted) startPromises.push(startTarget(target)) } if (method === 'Target.detachedFromTarget') { const target = workers.get(params.sessionId) if (target) { target.detached = true target.detachedAtMs = performance.now() } } }) await enableRecursiveAutoAttach() // auto-attach events arrive asynchronously after the call resolves. await sleep(500) await waitForStableTargetSetup() const pageTarget: AttachedTarget = { attachedAtMs: performance.now(), detached: false, role: 'page', started: false, title: page.title ?? '', type: 'page', url: page.url, } if (interactionBarrierEnabled) { console.log(' waiting for the workload interaction barrier…') const deadline = performance.now() + interactionBarrierTimeoutSeconds * 1000 let reached = false while (performance.now() < deadline) { const value = await evaluatePage(`(() => { const state = Reflect.get(globalThis, '__sootsimCpuProfileBarrierState') if (!state || typeof state !== 'object') return null return { marker: Reflect.get(state, 'marker'), reachedAtEpochMs: Reflect.get(state, 'reachedAtEpochMs'), } })()`) if ( value && typeof value === 'object' && typeof Reflect.get(value, 'marker') === 'string' && typeof Reflect.get(value, 'reachedAtEpochMs') === 'number' ) { reached = true break } await sleep(25) } if (!reached) { console.error( ` workload interaction barrier was not reached within ${interactionBarrierTimeoutSeconds}s`, ) return 1 } } captureStarted = true startPromises.push(startTarget(pageTarget)) for (const target of workers.values()) startPromises.push(startTarget(target)) await waitForStableTargetSetup() const startedCount = [pageTarget, ...workers.values()].filter( (target) => target.started, ).length if (startedCount === 0) { console.error(' no CDP targets accepted Profiler.start') return 1 } const captureWindowStartedAtMs = performance.now() const captureWindowStartedAtEpochMs = Date.now() const captureWindowEndedAtMs = captureWindowStartedAtMs + duration * 1000 const captureWindowEndedAtEpochMs = captureWindowStartedAtEpochMs + duration * 1000 const interactionBarrier = interactionBarrierEnabled ? readInteractionBarrierState(await releaseInteractionBarrier('profiler')) : null if (opts.verbose) { for (const target of [pageTarget, ...workers.values()]) { console.log( ` target ${target.role} ${target.type} ${target.title || '(untitled)'} ${target.url}`, ) } } console.log( ` recording ${duration}s across ${startedCount} target(s) — interact now…`, ) await sleep(Math.max(0, captureWindowEndedAtMs - performance.now())) captureStarted = false captureWindowClosed = true await Promise.allSettled(startPromises) const results = [pageTarget, ...workers.values()] await Promise.all( results.map(async (target) => { if (!target.started) return try { const { profile } = await cdp.send('Profiler.stop', {}, target.sessionId) target.profile = profile target.profileStoppedAtMs = performance.now() } catch (error) { target.error = error instanceof Error ? error.message : String(error) target.profileStoppedAtMs = target.detachedAtMs } await cdp.send('Profiler.disable', {}, target.sessionId).catch(() => {}) }), ) // querying the page while five high-frequency profilers are still active // can itself time out and lengthen the requested sampling interval. stop // first, then read the already-recorded workload completion evidence. const workloadEvidence = interactionBarrierEnabled ? readCpuProfileWorkloadEvidence( await evaluatePage( `Reflect.get(globalThis, '__sootsimInteractionPartialResult') ?? null`, ), ) : null const workloadActionEpochMs = workloadEvidence?.captures.map((capture) => capture.actionEpochMs) ?? [] mkdirSync(dirname(outputPath), { recursive: true }) const roleLabels = cpuProfileRoleLabels( results.map((target) => ({ captured: target.profile !== undefined, role: target.role, })), ) const manifest = results.map((target, index) => { const coverage = cpuProfileCoverage( target.profileStartedAtMs, target.profileStoppedAtMs, captureWindowStartedAtMs, captureWindowEndedAtMs, ) const role = roleLabels[index] const profilePath = target.profile ? outputPath.replace(/(\.[^.]+)?$/, `.${role}$1`) : null if (profilePath) { writeFileSync(profilePath, JSON.stringify(target.profile)) console.log( ` ${role} ${shortUrl(target.url)}: ${target.profile.samples.length} samples → ${profilePath}`, ) } else { console.error( ` ${role} ${shortUrl(target.url)}: no profile${target.error ? ` — ${target.error}` : ''}`, ) } if (opts.verbose && target.profile?.samples.length > 0) { for (const fn of topSelfTime(target.profile, 12)) { console.log(` ${fn.pct.toFixed(1).padStart(5)}% ${fn.name} ${fn.url}`) } } return { role, baseRole: target.role, type: target.type, title: target.title, url: target.url, detached: target.detached, attachedOffsetMs: target.attachedAtMs - captureWindowStartedAtMs, detachedOffsetMs: target.detachedAtMs === undefined ? null : target.detachedAtMs - captureWindowStartedAtMs, requiredForComplete: cpuProfileTargetOverlapsWindow( target.detachedAtMs, captureWindowStartedAtMs, ), ...coverage, samples: target.profile?.samples.length ?? 0, profilePath, error: target.error ?? null, } }) const requiredRoles = ['page', 'shell', 'compositor', 'tenant'] const capturedRoles = new Set( manifest .filter((target) => target.requiredForComplete && target.profilePath !== null) .map((target) => target.baseRole), ) const missingRoles = requiredRoles.filter((role) => !capturedRoles.has(role)) const interactionBarrierComplete = !interactionBarrierEnabled || (cpuProfileInteractionBarrierValid( interactionBarrier, captureWindowStartedAtEpochMs, captureWindowEndedAtEpochMs, ) && workloadEvidence !== null && cpuProfileActionsWithinWindow( workloadActionEpochMs, captureWindowStartedAtEpochMs, captureWindowEndedAtEpochMs, )) const complete = missingRoles.length === 0 && interactionBarrierComplete && manifest.length > 0 && manifest .filter((target) => target.requiredForComplete) .every( (target) => target.profilePath !== null && target.error === null && target.coverageRatio >= 0.99, ) const manifestPath = outputPath.replace(/(\.[^.]+)?$/, '.manifest.json') const executableSha256 = IS_STANDALONE ? createHash('sha256').update(readFileSync(process.execPath)).digest('hex') : null writeFileSync( manifestPath, JSON.stringify( { cli: { version: getCliVersion(), standalone: IS_STANDALONE, executablePath: process.execPath, executableSha256, argv: process.argv.slice(1), }, page: page.url, complete, missingRoles, requestedDurationMs: duration * 1000, captureWindowStartedAt: new Date(captureWindowStartedAtEpochMs).toISOString(), captureWindowEndedAt: new Date(captureWindowEndedAtEpochMs).toISOString(), interactionBarrier, workloadEvidence, workloadActionEpochMs, manifest, }, null, 2, ), ) console.log(` manifest → ${manifestPath}`) if (!complete) { console.error( ` incomplete attribution capture${missingRoles.length ? ` — missing ${missingRoles.join(', ')}` : ''}`, ) if (!interactionBarrierComplete) { console.error( ' workload did not complete every expected action inside the interaction profile window', ) } } console.log(' open in chrome devtools → Performance → Load profile to inspect.') return complete ? 0 : 1 } finally { if (interactionBarrierInstalled) { await releaseInteractionBarrier('cleanup').catch(() => {}) } cdp.close() } } function printNoCdp(cdpPort: number) { console.error(` perf cpu needs a CDP-enabled browser on :${cdpPort}.`) console.error( " the tenant worker can't be profiled via the JS Self-Profiler API (F28),", ) console.error(' so the sim must expose Chrome remote debugging. easiest path —') console.error(' open the sim with a CDP port (it stays driveable over the bridge,') console.error(' so you can scroll/navigate while profiling):') console.error('') console.error( ` rnx open --new --driver playwright --cdp-port ${cdpPort}`, ) console.error(` rnx perf cpu --cdp-port ${cdpPort} --match `) console.error('') console.error(' or launch a standalone CDP Chrome (not bridge-driveable):') console.error('') console.error(' "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \\') console.error( ` --remote-debugging-port=${cdpPort} --user-data-dir=/tmp/rnx-cdp \\`, ) console.error(' --no-sandbox --disable-gpu-sandbox \\') // never suggest swiftshader — software rendering pegs every core for the // browser's lifetime (documented machine-freeze cause). metal is the real // rendering path anyway, so the profile is representative. console.error(' --use-gl=angle --use-angle=metal \\') console.error(' "http://localhost:5173/rn/" &') console.error('') console.error(' override the port with --cdp-port or $RNX_CDP_PORT.') } function shortUrl(url: string): string { if (!url) return '(native)' return url .replace(/^https?:\/\/[^/]+/, '') .replace(/\?.*$/, '') .slice(-50) } export function cpuProfileTargetRole(url: string, title = ''): string { const identity = `${title} ${url}`.toLowerCase() if (identity.includes('compositor-worker') || identity.includes('sootsim-compositor')) { return 'compositor' } if (identity.includes('shell-worker')) return 'shell' if (identity.includes('named-worklet')) return 'worklet' if (identity.includes('render-worker') || identity.includes('tenant-worker')) { return 'tenant' } return 'worker' } function topSelfTime(profile: any, topN: number) { const total = profile.samples.length || 1 const byFn = new Map() for (const node of profile.nodes) { const f = node.callFrame const key = `${f.functionName || '(anonymous)'}@${shortUrl(f.url)}:${f.lineNumber}` const cur = byFn.get(key) ?? { self: 0, name: f.functionName || '(anonymous)', url: `${shortUrl(f.url)}:${f.lineNumber}`, } cur.self += node.hitCount ?? 0 byFn.set(key, cur) } return [...byFn.values()] .map((v) => ({ ...v, pct: (v.self / total) * 100 })) .sort((a, b) => b.self - a.self) .slice(0, topN) } function valueOf(args: string[], flag: string): string | undefined { const i = args.indexOf(flag) if (i < 0 || i === args.length - 1) return undefined return args[i + 1] }