peachsim 0.1.527
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/README.md +640 -0
- package/cli/app-config.ts +82 -0
- package/cli/app-fonts.ts +388 -0
- package/cli/app-project.ts +59 -0
- package/cli/app-splash.ts +171 -0
- package/cli/app-state-reset.ts +26 -0
- package/cli/auth.ts +184 -0
- package/cli/bin.ts +59 -0
- package/cli/bridge-diagnostics.ts +226 -0
- package/cli/bridge-flow-runner.ts +2806 -0
- package/cli/browser-backend.ts +44 -0
- package/cli/browser-evals.ts +96 -0
- package/cli/cli-update.ts +261 -0
- package/cli/cloud-client.ts +1530 -0
- package/cli/cloud-dispatch.ts +206 -0
- package/cli/cloud-session.ts +227 -0
- package/cli/command-contract.ts +221 -0
- package/cli/command-registry.ts +304 -0
- package/cli/commands/agent-wrapper.ts +1048 -0
- package/cli/commands/agent.ts +439 -0
- package/cli/commands/app-fonts.ts +99 -0
- package/cli/commands/assert.ts +542 -0
- package/cli/commands/auth.ts +71 -0
- package/cli/commands/box/bundle-plane.ts +71 -0
- package/cli/commands/box/checkout-plane.ts +256 -0
- package/cli/commands/box/plane-share.ts +208 -0
- package/cli/commands/box/rnx-command.ts +60 -0
- package/cli/commands/box/search-command.ts +157 -0
- package/cli/commands/box.ts +925 -0
- package/cli/commands/camera.ts +266 -0
- package/cli/commands/cleanup.ts +169 -0
- package/cli/commands/compat.ts +176 -0
- package/cli/commands/config.ts +34 -0
- package/cli/commands/control.ts +2154 -0
- package/cli/commands/cpu-profile.ts +941 -0
- package/cli/commands/daemon-mac-app.ts +165 -0
- package/cli/commands/daemon.ts +800 -0
- package/cli/commands/debug.ts +751 -0
- package/cli/commands/desktop.ts +39 -0
- package/cli/commands/detect.ts +201 -0
- package/cli/commands/detox.ts +481 -0
- package/cli/commands/device.ts +134 -0
- package/cli/commands/diagnose.ts +589 -0
- package/cli/commands/do-chain.ts +408 -0
- package/cli/commands/electron.ts +94 -0
- package/cli/commands/film.ts +379 -0
- package/cli/commands/flow.ts +1240 -0
- package/cli/commands/inspect/actions.ts +516 -0
- package/cli/commands/inspect/core.ts +2086 -0
- package/cli/commands/inspect/count.ts +17 -0
- package/cli/commands/inspect/describe.ts +119 -0
- package/cli/commands/inspect/env.ts +25 -0
- package/cli/commands/inspect/find.ts +169 -0
- package/cli/commands/inspect/get-layout.ts +53 -0
- package/cli/commands/inspect/keyboard.ts +54 -0
- package/cli/commands/inspect/list.ts +175 -0
- package/cli/commands/inspect/memory.ts +215 -0
- package/cli/commands/inspect/redaction.ts +39 -0
- package/cli/commands/inspect/resolve-target.ts +77 -0
- package/cli/commands/inspect/screens.ts +78 -0
- package/cli/commands/inspect/settle.ts +32 -0
- package/cli/commands/inspect/settling.ts +197 -0
- package/cli/commands/inspect/shared.ts +259 -0
- package/cli/commands/inspect/sleep.ts +15 -0
- package/cli/commands/inspect/tree.ts +23 -0
- package/cli/commands/inspect/url.ts +17 -0
- package/cli/commands/inspect/wait-event.ts +211 -0
- package/cli/commands/inspect/wait-idle.ts +25 -0
- package/cli/commands/inspect/wait-ready.ts +87 -0
- package/cli/commands/inspect/wait-selector.ts +59 -0
- package/cli/commands/inspect/wait.ts +32 -0
- package/cli/commands/inspect.ts +4738 -0
- package/cli/commands/install-cli.ts +275 -0
- package/cli/commands/install-desktop.ts +385 -0
- package/cli/commands/login.ts +357 -0
- package/cli/commands/logout.ts +24 -0
- package/cli/commands/maestro-generate.ts +375 -0
- package/cli/commands/maestro.ts +536 -0
- package/cli/commands/mode.ts +58 -0
- package/cli/commands/no-bridge-hint.ts +80 -0
- package/cli/commands/perf.ts +67 -0
- package/cli/commands/permissions.ts +203 -0
- package/cli/commands/platform.ts +410 -0
- package/cli/commands/preview.ts +103 -0
- package/cli/commands/profile.ts +106 -0
- package/cli/commands/react.ts +353 -0
- package/cli/commands/record.ts +1435 -0
- package/cli/commands/report-issue.ts +305 -0
- package/cli/commands/reset.ts +89 -0
- package/cli/commands/runtime.ts +348 -0
- package/cli/commands/screenshot-command.ts +106 -0
- package/cli/commands/screenshot-layers.ts +144 -0
- package/cli/commands/screenshot-mode.ts +38 -0
- package/cli/commands/screenshot.ts +492 -0
- package/cli/commands/screenshots-capture.ts +608 -0
- package/cli/commands/screenshots.ts +127 -0
- package/cli/commands/serve.ts +139 -0
- package/cli/commands/setup.ts +416 -0
- package/cli/commands/shell-boolean-mode.ts +82 -0
- package/cli/commands/skills.ts +468 -0
- package/cli/commands/slides.ts +357 -0
- package/cli/commands/state.ts +94 -0
- package/cli/commands/storage.ts +58 -0
- package/cli/commands/telemetry.ts +55 -0
- package/cli/commands/test.ts +535 -0
- package/cli/commands/three-mode.ts +771 -0
- package/cli/commands/timeline.ts +121 -0
- package/cli/commands/upgrade.ts +167 -0
- package/cli/commands/upload.ts +1203 -0
- package/cli/commands/version.ts +63 -0
- package/cli/commands/welcome.ts +17 -0
- package/cli/commands/what-happened.ts +328 -0
- package/cli/current-sim.ts +217 -0
- package/cli/desktop-companion.ts +315 -0
- package/cli/drivers/electron.ts +70 -0
- package/cli/drivers/index.ts +20 -0
- package/cli/drivers/playwright-provisioning.ts +184 -0
- package/cli/drivers/playwright-sim-host.ts +537 -0
- package/cli/drivers/playwright.ts +209 -0
- package/cli/drivers/registry.ts +65 -0
- package/cli/drivers/types.ts +102 -0
- package/cli/flow-export.ts +55 -0
- package/cli/flow-file.ts +142 -0
- package/cli/flow-live-status.ts +126 -0
- package/cli/flow-session.ts +140 -0
- package/cli/help-core.ts +143 -0
- package/cli/help.ts +44 -0
- package/cli/hidden-runtime-alias.ts +19 -0
- package/cli/hints.ts +214 -0
- package/cli/inspect-notice-state.ts +114 -0
- package/cli/internal-child.ts +176 -0
- package/cli/maestro-js.ts +323 -0
- package/cli/main.ts +704 -0
- package/cli/open-url.ts +8 -0
- package/cli/outbound-endpoints.ts +830 -0
- package/cli/parent-pid.ts +204 -0
- package/cli/parse-args.ts +189 -0
- package/cli/privacy.ts +53 -0
- package/cli/prompt.ts +56 -0
- package/cli/recording-access.ts +108 -0
- package/cli/registry.ts +2 -0
- package/cli/resolve-assets.ts +63 -0
- package/cli/rnx-in-process.ts +98 -0
- package/cli/run-registry.ts +226 -0
- package/cli/run-rnx-core.ts +148 -0
- package/cli/run-rnx.ts +137 -0
- package/cli/runtime-notes.ts +66 -0
- package/cli/runtime-summary.ts +25 -0
- package/cli/secure-file.ts +31 -0
- package/cli/self-invocation.ts +58 -0
- package/cli/send-to-box.ts +109 -0
- package/cli/setup-repository.ts +191 -0
- package/cli/shell-init.ts +98 -0
- package/cli/standalone.ts +4 -0
- package/cli/telemetry.ts +84 -0
- package/cli/ui.ts +68 -0
- package/cli/ws-bridge.ts +1020 -0
- package/detox/attempt-events.cjs +23 -0
- package/detox/colors.ts +54 -0
- package/detox/config-loader.ts +135 -0
- package/detox/element-types.ts +48 -0
- package/detox/expectations.ts +534 -0
- package/detox/gestures.ts +501 -0
- package/detox/index.ts +2694 -0
- package/detox/jest-preset.cjs +29 -0
- package/detox/jest-setup-after-env.cjs +22 -0
- package/detox/matchers.ts +30 -0
- package/detox/motion-change.cjs +109 -0
- package/detox/motion-change.d.cts +35 -0
- package/detox/navigation-lifecycle.ts +290 -0
- package/detox/navigation.ts +43 -0
- package/detox/proof-frame.cjs +114 -0
- package/detox/record-block-execution.cjs +78 -0
- package/detox/screenshots/animated-color-test-rest-norngh.png +0 -0
- package/detox/screenshots/color-test-after-drag-norngh.png +0 -0
- package/detox/screenshots/color-test-rest-norngh.png +0 -0
- package/detox/screenshots/theme-blue-toggle.png +0 -0
- package/detox/screenshots/theme-blue.png +0 -0
- package/detox/screenshots/theme-red-toggle.png +0 -0
- package/detox/screenshots/theme-red.png +0 -0
- package/dist-lib/agent-daemon-client.cjs +502 -0
- package/dist-lib/agent-events.cjs +48 -0
- package/dist-lib/agent-identity.cjs +53 -0
- package/dist-lib/agent-sessions.cjs +759 -0
- package/dist-lib/attached-projects.cjs +526 -0
- package/dist-lib/auth/shared-session.cjs +208 -0
- package/dist-lib/backend-origin.cjs +88 -0
- package/dist-lib/beta.cjs +41 -0
- package/dist-lib/beta.mjs +13 -0
- package/dist-lib/bridge-constants.cjs +161 -0
- package/dist-lib/bridge-contract-input.cjs +451 -0
- package/dist-lib/bridge-contract-input.mjs +414 -0
- package/dist-lib/bridge-contract.cjs +130 -0
- package/dist-lib/bridge-contract.mjs +94 -0
- package/dist-lib/capture-contract.cjs +220 -0
- package/dist-lib/capture-contract.mjs +190 -0
- package/dist-lib/cli-constants.cjs +32 -0
- package/dist-lib/cloud-contract.cjs +194 -0
- package/dist-lib/cloud-contract.mjs +145 -0
- package/dist-lib/cloud.cjs +670 -0
- package/dist-lib/cloud.mjs +639 -0
- package/dist-lib/config.cjs +96 -0
- package/dist-lib/detox/index.cjs +3789 -0
- package/dist-lib/detox/jest-preset.cjs +29 -0
- package/dist-lib/dev-bundle-resolution.cjs +290 -0
- package/dist-lib/home-paths.cjs +638 -0
- package/dist-lib/host/bridge-host.cjs +10903 -0
- package/dist-lib/host/fetch-proxy-handler.cjs +421 -0
- package/dist-lib/host/fetch-proxy-overrides.cjs +63 -0
- package/dist-lib/host/fetch-proxy-overrides.mjs +37 -0
- package/dist-lib/host/replacement-module-handler.cjs +469 -0
- package/dist-lib/host/websocket-proxy.cjs +298 -0
- package/dist-lib/index.cjs +4268 -0
- package/dist-lib/jump-to-source-babel.cjs +93 -0
- package/dist-lib/jump-to-source-native.cjs +112 -0
- package/dist-lib/menu.cjs +646 -0
- package/dist-lib/menu.mjs +604 -0
- package/dist-lib/metro-fingerprint-registry.cjs +186 -0
- package/dist-lib/metro-fingerprint-registry.mjs +156 -0
- package/dist-lib/metro-production-bundle.cjs +329 -0
- package/dist-lib/metro-production-bundle.mjs +295 -0
- package/dist-lib/metro.cjs +904 -0
- package/dist-lib/profiles.cjs +246 -0
- package/dist-lib/public-brand.cjs +39 -0
- package/dist-lib/react-native-host-modules.cjs +51 -0
- package/dist-lib/react-native-host-modules.mjs +25 -0
- package/dist-lib/render-mode.cjs +54 -0
- package/dist-lib/scripts/dev-server-scanner.cjs +1232 -0
- package/dist-lib/sdk.cjs +2433 -0
- package/dist-lib/sdk.mjs +2336 -0
- package/dist-lib/skills.cjs +39132 -0
- package/dist-lib/swift-submit.cjs +92 -0
- package/dist-lib/swift-submit.mjs +65 -0
- package/dist-lib/swift.cjs +1646 -0
- package/dist-lib/vite.cjs +1591 -0
- package/package.json +290 -0
- package/scripts/dev-server-scanner.ts +1291 -0
- package/scripts/optional-demo-registry.ts +113 -0
- package/skills/contrast/SKILL.md +165 -0
- package/skills/rnx-debug/SKILL.md +433 -0
- package/skills/rnx-perf/SKILL.md +399 -0
- package/skills/rnx-setup/SKILL.md +172 -0
- package/skills/rnx-test/SKILL.md +321 -0
- package/skills/rnx-visual/SKILL.md +126 -0
- package/src/agent-daemon-client.ts +410 -0
- package/src/agent-events.ts +71 -0
- package/src/agent-identity.ts +23 -0
- package/src/agent-prompt.ts +71 -0
- package/src/agent-sessions.ts +580 -0
- package/src/app-url.ts +39 -0
- package/src/attached-projects.ts +539 -0
- package/src/auth/shared-session.ts +235 -0
- package/src/backend-origin.ts +70 -0
- package/src/beta.ts +19 -0
- package/src/bridge-constants.ts +175 -0
- package/src/bridge-contract-input.ts +502 -0
- package/src/bridge-contract.ts +558 -0
- package/src/browser-cache.ts +16 -0
- package/src/capture-contract.ts +587 -0
- package/src/cli-constants.ts +1 -0
- package/src/cli-version.ts +40 -0
- package/src/cloud-contract.ts +274 -0
- package/src/cloud.ts +762 -0
- package/src/codex-client.ts +234 -0
- package/src/config.ts +132 -0
- package/src/connect.ts +101 -0
- package/src/dev-bundle-resolution.ts +346 -0
- package/src/dev-server-open.ts +198 -0
- package/src/disk-cleanup.ts +540 -0
- package/src/flow-timeout-scale.ts +15 -0
- package/src/home-paths.ts +806 -0
- package/src/host/agent-host.ts +666 -0
- package/src/host/bridge-host.ts +2788 -0
- package/src/host/fetch-proxy-handler.ts +449 -0
- package/src/host/fetch-proxy-overrides.ts +51 -0
- package/src/host/open-url.ts +89 -0
- package/src/host/plane-host.ts +203 -0
- package/src/host/replacement-module-handler.ts +158 -0
- package/src/host/websocket-proxy.ts +315 -0
- package/src/index.ts +71 -0
- package/src/jump-to-source-babel.ts +135 -0
- package/src/jump-to-source-native.ts +102 -0
- package/src/menu.ts +777 -0
- package/src/metro-fingerprint-registry.ts +302 -0
- package/src/metro-plugin.ts +341 -0
- package/src/metro-production-bundle.ts +471 -0
- package/src/native-dev-bundle-url.ts +118 -0
- package/src/native-seam-manifest.ts +341 -0
- package/src/playwright-host-log.ts +16 -0
- package/src/plist.ts +27 -0
- package/src/poll-until.ts +123 -0
- package/src/profiles.ts +179 -0
- package/src/public-brand.ts +9 -0
- package/src/react-native-host-modules.ts +26 -0
- package/src/render-mode.ts +27 -0
- package/src/rolldown-plugin-swift.ts +240 -0
- package/src/runtime-assets.ts +233 -0
- package/src/runtime-delivery.ts +54 -0
- package/src/screenshots/compose.ts +422 -0
- package/src/screenshots/frame-compose.ts +514 -0
- package/src/screenshots/orchestrate.ts +256 -0
- package/src/screenshots/org-deck.ts +91 -0
- package/src/screenshots/registry.ts +58 -0
- package/src/screenshots/schema.ts +364 -0
- package/src/sdk.ts +15 -0
- package/src/settings-bundle.ts +418 -0
- package/src/shell-bridge-identity.ts +15 -0
- package/src/sim-client.ts +697 -0
- package/src/skills/builtin/a11y-review.ts +132 -0
- package/src/skills/builtin/compat-check.ts +71 -0
- package/src/skills/builtin/maestro-test.ts +121 -0
- package/src/skills/builtin/perf-profile.ts +90 -0
- package/src/skills/builtin/screenshot-all.ts +52 -0
- package/src/skills/builtin/visual-diff.ts +100 -0
- package/src/skills/registry.ts +107 -0
- package/src/skills/types.ts +41 -0
- package/src/swift-submit.ts +185 -0
- package/src/swift.ts +7 -0
- package/src/team-machine-prompt.ts +73 -0
- package/src/vite-plugin-one.ts +264 -0
- package/src/vite-plugin-swift.ts +620 -0
- package/src/vite-plugin.ts +1946 -0
- package/src/worklets-babel.ts +250 -0
|
@@ -0,0 +1,941 @@
|
|
|
1
|
+
// rnx perf cpu — capture sampled CPU traces from the page and every worker.
|
|
2
|
+
//
|
|
3
|
+
// the JS Self-Profiler API (`new Profiler()`) cannot be constructed in a
|
|
4
|
+
// dedicated-worker scope, and rnx runs the guest app in the tenant worker,
|
|
5
|
+
// so the old in-worker approach was unsupported (F28). this implementation
|
|
6
|
+
// attaches Chromium's `Profiler` CDP domain to the page and worker targets —
|
|
7
|
+
// which returns the .cpuprofile shape directly for every execution context.
|
|
8
|
+
//
|
|
9
|
+
// dedicated workers are not top-level CDP targets, so we connect to the page
|
|
10
|
+
// target, `Target.setAutoAttach` to discover the worker child sessions, and
|
|
11
|
+
// route `Profiler.*` to each worker via its sessionId (flatten mode).
|
|
12
|
+
//
|
|
13
|
+
// the user's normal rnx tab runs in plain Chrome without a debug port.
|
|
14
|
+
// the easiest way to get a profilable sim is to open one with a CDP port —
|
|
15
|
+
// it stays driveable over the WS bridge, so you can scroll/navigate while
|
|
16
|
+
// profiling (the playwright driver passes --remote-debugging-port through):
|
|
17
|
+
//
|
|
18
|
+
// rnx open <metro-port> --new --driver playwright --cdp-port 9222
|
|
19
|
+
// rnx perf cpu --cdp-port 9222 --match <metro-port> --duration 5
|
|
20
|
+
//
|
|
21
|
+
// (a standalone CDP Chrome also works for profiling a static page, but it
|
|
22
|
+
// does NOT register on the bridge, so it can't be driven — see printNoCdp.)
|
|
23
|
+
//
|
|
24
|
+
// interact with the app during the capture window. the reference probe that
|
|
25
|
+
// also drives the interaction itself lives at
|
|
26
|
+
// scripts/debug/cdp-worker-cpu-profile.ts.
|
|
27
|
+
|
|
28
|
+
import { createHash } from 'node:crypto'
|
|
29
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
30
|
+
import { dirname, resolve } from 'node:path'
|
|
31
|
+
import { WebSocket } from 'ws'
|
|
32
|
+
import { getCliVersion } from '../../src/cli-version'
|
|
33
|
+
import { IS_STANDALONE } from '../standalone'
|
|
34
|
+
|
|
35
|
+
interface ProfileOptions {
|
|
36
|
+
port?: number
|
|
37
|
+
verbose?: boolean
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
|
|
41
|
+
|
|
42
|
+
// compact CDP client with flatten-mode sessionId routing — one ws to the page
|
|
43
|
+
// target carries the page session plus every auto-attached worker session.
|
|
44
|
+
class Cdp {
|
|
45
|
+
private ws: WebSocket
|
|
46
|
+
private nextId = 1
|
|
47
|
+
private pending = new Map<
|
|
48
|
+
number,
|
|
49
|
+
{
|
|
50
|
+
res: (v: any) => void
|
|
51
|
+
rej: (e: any) => void
|
|
52
|
+
timeout: ReturnType<typeof setTimeout>
|
|
53
|
+
}
|
|
54
|
+
>()
|
|
55
|
+
private listeners = new Set<(method: string, params: any, sessionId?: string) => void>()
|
|
56
|
+
private ready: Promise<void>
|
|
57
|
+
|
|
58
|
+
constructor(wsUrl: string) {
|
|
59
|
+
this.ws = new WebSocket(wsUrl, { maxPayload: 1 << 28 })
|
|
60
|
+
this.ready = new Promise((res, rej) => {
|
|
61
|
+
this.ws.once('open', () => res())
|
|
62
|
+
this.ws.once('error', rej)
|
|
63
|
+
})
|
|
64
|
+
this.ws.on('message', (data) => {
|
|
65
|
+
const msg = JSON.parse(data.toString())
|
|
66
|
+
if (msg.id && this.pending.has(msg.id)) {
|
|
67
|
+
const { res, rej, timeout } = this.pending.get(msg.id)!
|
|
68
|
+
this.pending.delete(msg.id)
|
|
69
|
+
clearTimeout(timeout)
|
|
70
|
+
msg.error ? rej(new Error(JSON.stringify(msg.error))) : res(msg.result)
|
|
71
|
+
} else if (msg.method) {
|
|
72
|
+
for (const l of this.listeners) l(msg.method, msg.params, msg.sessionId)
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
this.ws.on('close', () => {
|
|
76
|
+
for (const [id, request] of this.pending) {
|
|
77
|
+
clearTimeout(request.timeout)
|
|
78
|
+
request.rej(new Error(`CDP connection closed with request ${id} pending`))
|
|
79
|
+
}
|
|
80
|
+
this.pending.clear()
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
waitOpen() {
|
|
85
|
+
return this.ready
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
on(l: (method: string, params: any, sessionId?: string) => void) {
|
|
89
|
+
this.listeners.add(l)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
send(
|
|
93
|
+
method: string,
|
|
94
|
+
params: Record<string, unknown> = {},
|
|
95
|
+
sessionId?: string,
|
|
96
|
+
): Promise<any> {
|
|
97
|
+
const id = this.nextId++
|
|
98
|
+
return new Promise((res, rej) => {
|
|
99
|
+
const timeout = setTimeout(() => {
|
|
100
|
+
this.pending.delete(id)
|
|
101
|
+
rej(new Error(`CDP ${method} timed out after 5s`))
|
|
102
|
+
}, 5_000)
|
|
103
|
+
this.pending.set(id, { res, rej, timeout })
|
|
104
|
+
this.ws.send(
|
|
105
|
+
JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }),
|
|
106
|
+
)
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
close() {
|
|
111
|
+
try {
|
|
112
|
+
this.ws.close()
|
|
113
|
+
} catch {}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
interface CdpTarget {
|
|
118
|
+
type: string
|
|
119
|
+
title?: string
|
|
120
|
+
url: string
|
|
121
|
+
webSocketDebuggerUrl: string
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
interface AttachedTarget {
|
|
125
|
+
attachedAtMs: number
|
|
126
|
+
detached: boolean
|
|
127
|
+
detachedAtMs?: number
|
|
128
|
+
error?: string
|
|
129
|
+
profile?: any
|
|
130
|
+
profileStartedAtMs?: number
|
|
131
|
+
profileStoppedAtMs?: number
|
|
132
|
+
role: string
|
|
133
|
+
sessionId?: string
|
|
134
|
+
started: boolean
|
|
135
|
+
title: string
|
|
136
|
+
type: string
|
|
137
|
+
url: string
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface CpuProfileInteractionBarrier {
|
|
141
|
+
marker: string
|
|
142
|
+
reachedAtEpochMs: number
|
|
143
|
+
releasedBy: string
|
|
144
|
+
releasedAtEpochMs: number
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface CpuProfileCoverage {
|
|
148
|
+
coverageMs: number
|
|
149
|
+
coverageRatio: number
|
|
150
|
+
startedOffsetMs: number | null
|
|
151
|
+
stoppedOffsetMs: number | null
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export interface CpuProfileBrowserExecutableIdentity {
|
|
155
|
+
path: string
|
|
156
|
+
sha256: string
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export interface CpuProfileBrowserIdentity {
|
|
160
|
+
name: string
|
|
161
|
+
version: string
|
|
162
|
+
executable: CpuProfileBrowserExecutableIdentity
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export interface CpuProfileCaptureEvidence {
|
|
166
|
+
label: string
|
|
167
|
+
actionEpochMs: number
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export interface CpuProfileWorkloadIdentity {
|
|
171
|
+
scenario: string
|
|
172
|
+
sourceSha256: string
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export interface CpuProfileWorkloadEvidence {
|
|
176
|
+
browserIdentity: CpuProfileBrowserIdentity
|
|
177
|
+
captures: CpuProfileCaptureEvidence[]
|
|
178
|
+
completionMarker: string
|
|
179
|
+
expectedCaptureLabels: string[]
|
|
180
|
+
runtimeIdentity: Record<string, unknown>
|
|
181
|
+
stage: string
|
|
182
|
+
workloadIdentity: CpuProfileWorkloadIdentity
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function cpuProfileCoverage(
|
|
186
|
+
profileStartedAtMs: number | undefined,
|
|
187
|
+
profileStoppedAtMs: number | undefined,
|
|
188
|
+
windowStartedAtMs: number,
|
|
189
|
+
windowEndedAtMs: number,
|
|
190
|
+
): CpuProfileCoverage {
|
|
191
|
+
const requestedMs = Math.max(0, windowEndedAtMs - windowStartedAtMs)
|
|
192
|
+
if (
|
|
193
|
+
profileStartedAtMs === undefined ||
|
|
194
|
+
profileStoppedAtMs === undefined ||
|
|
195
|
+
requestedMs === 0
|
|
196
|
+
) {
|
|
197
|
+
return {
|
|
198
|
+
coverageMs: 0,
|
|
199
|
+
coverageRatio: 0,
|
|
200
|
+
startedOffsetMs:
|
|
201
|
+
profileStartedAtMs === undefined ? null : profileStartedAtMs - windowStartedAtMs,
|
|
202
|
+
stoppedOffsetMs:
|
|
203
|
+
profileStoppedAtMs === undefined ? null : profileStoppedAtMs - windowStartedAtMs,
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const coverageMs = Math.max(
|
|
207
|
+
0,
|
|
208
|
+
Math.min(profileStoppedAtMs, windowEndedAtMs) -
|
|
209
|
+
Math.max(profileStartedAtMs, windowStartedAtMs),
|
|
210
|
+
)
|
|
211
|
+
return {
|
|
212
|
+
coverageMs,
|
|
213
|
+
coverageRatio: coverageMs / requestedMs,
|
|
214
|
+
startedOffsetMs: profileStartedAtMs - windowStartedAtMs,
|
|
215
|
+
stoppedOffsetMs: profileStoppedAtMs - windowStartedAtMs,
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function cpuProfileRoleLabels(
|
|
220
|
+
targets: readonly { captured: boolean; role: string }[],
|
|
221
|
+
): string[] {
|
|
222
|
+
const labels = new Array<string>(targets.length)
|
|
223
|
+
const roleCounts = new Map<string, number>()
|
|
224
|
+
const assign = (index: number) => {
|
|
225
|
+
const role = targets[index].role
|
|
226
|
+
const count = (roleCounts.get(role) ?? 0) + 1
|
|
227
|
+
roleCounts.set(role, count)
|
|
228
|
+
labels[index] = count === 1 ? role : `${role}-${count}`
|
|
229
|
+
}
|
|
230
|
+
for (let index = 0; index < targets.length; index++) {
|
|
231
|
+
if (targets[index].captured) assign(index)
|
|
232
|
+
}
|
|
233
|
+
for (let index = 0; index < targets.length; index++) {
|
|
234
|
+
if (!targets[index].captured) assign(index)
|
|
235
|
+
}
|
|
236
|
+
return labels
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function cpuProfileTargetOverlapsWindow(
|
|
240
|
+
detachedAtMs: number | undefined,
|
|
241
|
+
windowStartedAtMs: number,
|
|
242
|
+
): boolean {
|
|
243
|
+
return detachedAtMs === undefined || detachedAtMs > windowStartedAtMs
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function cpuProfileActionsWithinWindow(
|
|
247
|
+
actionEpochMs: readonly number[],
|
|
248
|
+
windowStartedAtEpochMs: number,
|
|
249
|
+
windowEndedAtEpochMs: number,
|
|
250
|
+
): boolean {
|
|
251
|
+
return (
|
|
252
|
+
actionEpochMs.length > 0 &&
|
|
253
|
+
actionEpochMs.every(
|
|
254
|
+
(epochMs) =>
|
|
255
|
+
Number.isFinite(epochMs) &&
|
|
256
|
+
epochMs >= windowStartedAtEpochMs &&
|
|
257
|
+
epochMs <= windowEndedAtEpochMs,
|
|
258
|
+
)
|
|
259
|
+
)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function cpuProfileRecord(value: unknown): Record<string, unknown> | null {
|
|
263
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
|
|
264
|
+
return Object.fromEntries(Object.entries(value))
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function cpuProfileNonemptyString(value: unknown): value is string {
|
|
268
|
+
return typeof value === 'string' && value.length > 0
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function cpuProfileSha256(value: unknown): value is string {
|
|
272
|
+
return typeof value === 'string' && /^[a-f0-9]{64}$/.test(value)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function cpuProfileViewportIdentityValid(value: unknown): boolean {
|
|
276
|
+
const viewport = cpuProfileRecord(value)
|
|
277
|
+
return (
|
|
278
|
+
typeof viewport?.width === 'number' &&
|
|
279
|
+
Number.isFinite(viewport.width) &&
|
|
280
|
+
viewport.width > 0 &&
|
|
281
|
+
typeof viewport.height === 'number' &&
|
|
282
|
+
Number.isFinite(viewport.height) &&
|
|
283
|
+
viewport.height > 0 &&
|
|
284
|
+
typeof viewport.devicePixelRatio === 'number' &&
|
|
285
|
+
Number.isFinite(viewport.devicePixelRatio) &&
|
|
286
|
+
viewport.devicePixelRatio > 0
|
|
287
|
+
)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export function readCpuProfileWorkloadEvidence(
|
|
291
|
+
value: unknown,
|
|
292
|
+
): CpuProfileWorkloadEvidence | null {
|
|
293
|
+
const partial = cpuProfileRecord(value)
|
|
294
|
+
const browserIdentity = cpuProfileRecord(partial?.browserIdentity)
|
|
295
|
+
const browserExecutable = cpuProfileRecord(browserIdentity?.executable)
|
|
296
|
+
const runtimeIdentity = cpuProfileRecord(partial?.runtimeIdentity)
|
|
297
|
+
const engineManifest = cpuProfileRecord(runtimeIdentity?.engineManifest)
|
|
298
|
+
const hostPresentation = cpuProfileRecord(runtimeIdentity?.hostPresentation)
|
|
299
|
+
const workloadIdentity = cpuProfileRecord(partial?.workloadIdentity)
|
|
300
|
+
const stage = partial?.stage
|
|
301
|
+
const completionMarker = partial?.completionMarker
|
|
302
|
+
const expectedCaptureLabels = partial?.expectedCaptureLabels
|
|
303
|
+
const captures = partial?.captures
|
|
304
|
+
if (
|
|
305
|
+
!cpuProfileNonemptyString(stage) ||
|
|
306
|
+
!cpuProfileNonemptyString(completionMarker) ||
|
|
307
|
+
stage !== completionMarker ||
|
|
308
|
+
!Array.isArray(expectedCaptureLabels) ||
|
|
309
|
+
expectedCaptureLabels.length === 0 ||
|
|
310
|
+
expectedCaptureLabels.some(
|
|
311
|
+
(label, index, labels) =>
|
|
312
|
+
!cpuProfileNonemptyString(label) || labels.indexOf(label) !== index,
|
|
313
|
+
) ||
|
|
314
|
+
!Array.isArray(captures) ||
|
|
315
|
+
!cpuProfileNonemptyString(browserIdentity?.name) ||
|
|
316
|
+
!cpuProfileNonemptyString(browserIdentity.version) ||
|
|
317
|
+
!cpuProfileNonemptyString(browserExecutable?.path) ||
|
|
318
|
+
!cpuProfileSha256(browserExecutable.sha256) ||
|
|
319
|
+
runtimeIdentity === null ||
|
|
320
|
+
!cpuProfileNonemptyString(engineManifest?.buildIdentity) ||
|
|
321
|
+
!cpuProfileNonemptyString(engineManifest.generation) ||
|
|
322
|
+
hostPresentation === null ||
|
|
323
|
+
!cpuProfileViewportIdentityValid(hostPresentation.rootViewport) ||
|
|
324
|
+
!cpuProfileViewportIdentityValid(hostPresentation.targetViewport) ||
|
|
325
|
+
!cpuProfileNonemptyString(workloadIdentity?.scenario) ||
|
|
326
|
+
workloadIdentity.scenario !== partial?.scenario ||
|
|
327
|
+
!cpuProfileSha256(workloadIdentity.sourceSha256)
|
|
328
|
+
) {
|
|
329
|
+
return null
|
|
330
|
+
}
|
|
331
|
+
const captureEvidence: CpuProfileCaptureEvidence[] = []
|
|
332
|
+
for (const captureValue of captures) {
|
|
333
|
+
const capture = cpuProfileRecord(captureValue)
|
|
334
|
+
const note = cpuProfileRecord(capture?.note)
|
|
335
|
+
if (
|
|
336
|
+
!cpuProfileNonemptyString(capture?.label) ||
|
|
337
|
+
typeof note?.actionEpochMs !== 'number' ||
|
|
338
|
+
!Number.isFinite(note.actionEpochMs)
|
|
339
|
+
) {
|
|
340
|
+
return null
|
|
341
|
+
}
|
|
342
|
+
captureEvidence.push({ label: capture.label, actionEpochMs: note.actionEpochMs })
|
|
343
|
+
}
|
|
344
|
+
if (
|
|
345
|
+
JSON.stringify(captureEvidence.map((capture) => capture.label)) !==
|
|
346
|
+
JSON.stringify(expectedCaptureLabels)
|
|
347
|
+
) {
|
|
348
|
+
return null
|
|
349
|
+
}
|
|
350
|
+
return {
|
|
351
|
+
browserIdentity: {
|
|
352
|
+
name: browserIdentity.name,
|
|
353
|
+
version: browserIdentity.version,
|
|
354
|
+
executable: {
|
|
355
|
+
path: browserExecutable.path,
|
|
356
|
+
sha256: browserExecutable.sha256,
|
|
357
|
+
},
|
|
358
|
+
},
|
|
359
|
+
captures: captureEvidence,
|
|
360
|
+
completionMarker,
|
|
361
|
+
expectedCaptureLabels: [...expectedCaptureLabels],
|
|
362
|
+
runtimeIdentity,
|
|
363
|
+
stage,
|
|
364
|
+
workloadIdentity: {
|
|
365
|
+
scenario: workloadIdentity.scenario,
|
|
366
|
+
sourceSha256: workloadIdentity.sourceSha256,
|
|
367
|
+
},
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function cpuProfileInteractionBarrierValid(
|
|
372
|
+
barrier: CpuProfileInteractionBarrier | null,
|
|
373
|
+
windowStartedAtEpochMs: number,
|
|
374
|
+
windowEndedAtEpochMs: number,
|
|
375
|
+
): boolean {
|
|
376
|
+
return (
|
|
377
|
+
barrier !== null &&
|
|
378
|
+
barrier.marker.length > 0 &&
|
|
379
|
+
barrier.releasedBy === 'profiler' &&
|
|
380
|
+
Number.isFinite(barrier.reachedAtEpochMs) &&
|
|
381
|
+
Number.isFinite(barrier.releasedAtEpochMs) &&
|
|
382
|
+
barrier.reachedAtEpochMs <= barrier.releasedAtEpochMs &&
|
|
383
|
+
barrier.releasedAtEpochMs >= windowStartedAtEpochMs &&
|
|
384
|
+
barrier.releasedAtEpochMs <= windowEndedAtEpochMs
|
|
385
|
+
)
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function readInteractionBarrierState(
|
|
389
|
+
value: unknown,
|
|
390
|
+
): CpuProfileInteractionBarrier | null {
|
|
391
|
+
if (!value || typeof value !== 'object') return null
|
|
392
|
+
const marker = Reflect.get(value, 'marker')
|
|
393
|
+
const reachedAtEpochMs = Reflect.get(value, 'reachedAtEpochMs')
|
|
394
|
+
const releasedBy = Reflect.get(value, 'releasedBy')
|
|
395
|
+
const releasedAtEpochMs = Reflect.get(value, 'releasedAtEpochMs')
|
|
396
|
+
if (
|
|
397
|
+
typeof marker !== 'string' ||
|
|
398
|
+
typeof reachedAtEpochMs !== 'number' ||
|
|
399
|
+
typeof releasedBy !== 'string' ||
|
|
400
|
+
typeof releasedAtEpochMs !== 'number'
|
|
401
|
+
) {
|
|
402
|
+
return null
|
|
403
|
+
}
|
|
404
|
+
return { marker, reachedAtEpochMs, releasedBy, releasedAtEpochMs }
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
export async function runCpuProfile(
|
|
408
|
+
args: string[],
|
|
409
|
+
opts: ProfileOptions,
|
|
410
|
+
): Promise<number> {
|
|
411
|
+
const duration = Number(valueOf(args, '--duration') ?? '5')
|
|
412
|
+
if (!Number.isFinite(duration) || duration <= 0) {
|
|
413
|
+
console.error(' --duration must be a positive number (seconds)')
|
|
414
|
+
return 1
|
|
415
|
+
}
|
|
416
|
+
const sampleInterval = Number(valueOf(args, '--sample-interval') ?? '0.1') // ms
|
|
417
|
+
const interactionBarrierEnabled = args.includes('--interaction-barrier')
|
|
418
|
+
const interactionBarrierTimeoutSeconds = Number(
|
|
419
|
+
valueOf(args, '--interaction-barrier-timeout') ?? '90',
|
|
420
|
+
)
|
|
421
|
+
if (
|
|
422
|
+
!Number.isFinite(interactionBarrierTimeoutSeconds) ||
|
|
423
|
+
interactionBarrierTimeoutSeconds <= 0
|
|
424
|
+
) {
|
|
425
|
+
console.error(' --interaction-barrier-timeout must be positive (seconds)')
|
|
426
|
+
return 1
|
|
427
|
+
}
|
|
428
|
+
const interactionBarrierLeaseMs = Math.min(
|
|
429
|
+
2_147_000_000,
|
|
430
|
+
Math.ceil((interactionBarrierTimeoutSeconds + duration + 5) * 1000),
|
|
431
|
+
)
|
|
432
|
+
const cdpPort = Number(
|
|
433
|
+
valueOf(args, '--cdp-port') ?? process.env.RNX_CDP_PORT ?? '9222',
|
|
434
|
+
)
|
|
435
|
+
// optional substring filter to pick the right page when several sims share
|
|
436
|
+
// the CDP browser (e.g. `--match 8089` or `--match /rn/8089`).
|
|
437
|
+
const match = valueOf(args, '--match') ?? '/rn/'
|
|
438
|
+
const outputArg = valueOf(args, '--output') ?? valueOf(args, '-o')
|
|
439
|
+
const outputPath = resolve(process.cwd(), outputArg ?? '/tmp/rnx.cpuprofile')
|
|
440
|
+
|
|
441
|
+
// locate the page target on the CDP endpoint. NOTE: Chrome's DevTools HTTP
|
|
442
|
+
// `/json` endpoints reject any Host header that isn't `localhost` (a
|
|
443
|
+
// DNS-rebinding guard) — `http://127.0.0.1:<port>/json/list` returns
|
|
444
|
+
// "Not found" / 404, so we must use the `localhost` hostname here. (this is
|
|
445
|
+
// what made the command silently fall into the no-CDP path even with a live
|
|
446
|
+
// CDP browser.)
|
|
447
|
+
let targets: CdpTarget[] = []
|
|
448
|
+
try {
|
|
449
|
+
targets = (await (
|
|
450
|
+
await fetch(`http://localhost:${cdpPort}/json/list`)
|
|
451
|
+
).json()) as CdpTarget[]
|
|
452
|
+
} catch {
|
|
453
|
+
printNoCdp(cdpPort)
|
|
454
|
+
return 1
|
|
455
|
+
}
|
|
456
|
+
const page = targets.find((t) => t.type === 'page' && t.url.includes(match))
|
|
457
|
+
if (!page) {
|
|
458
|
+
console.error(` no page target matching "${match}" on CDP :${cdpPort}`)
|
|
459
|
+
console.error(` open targets: ${targets.map((t) => t.url).join(', ') || '(none)'}`)
|
|
460
|
+
return 1
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const cdp = new Cdp(page.webSocketDebuggerUrl)
|
|
464
|
+
await cdp.waitOpen()
|
|
465
|
+
let interactionBarrierInstalled = false
|
|
466
|
+
const evaluatePage = async (expression: string): Promise<unknown> => {
|
|
467
|
+
const response = await cdp.send('Runtime.evaluate', {
|
|
468
|
+
expression,
|
|
469
|
+
returnByValue: true,
|
|
470
|
+
})
|
|
471
|
+
if (response?.exceptionDetails) {
|
|
472
|
+
throw new Error(
|
|
473
|
+
`page evaluation failed: ${response.exceptionDetails.text ?? 'unknown error'}`,
|
|
474
|
+
)
|
|
475
|
+
}
|
|
476
|
+
return response?.result?.value
|
|
477
|
+
}
|
|
478
|
+
const releaseInteractionBarrier = (releasedBy: 'cleanup' | 'profiler') =>
|
|
479
|
+
evaluatePage(`(() => {
|
|
480
|
+
const release = Reflect.get(globalThis, '__sootsimReleaseCpuProfileBarrier')
|
|
481
|
+
return typeof release === 'function'
|
|
482
|
+
? Reflect.apply(release, globalThis, [${JSON.stringify(releasedBy)}])
|
|
483
|
+
: null
|
|
484
|
+
})()`)
|
|
485
|
+
try {
|
|
486
|
+
if (interactionBarrierEnabled) {
|
|
487
|
+
const installed = await evaluatePage(`(() => {
|
|
488
|
+
const state = {
|
|
489
|
+
marker: null,
|
|
490
|
+
reachedAtEpochMs: null,
|
|
491
|
+
releasedBy: null,
|
|
492
|
+
releasedAtEpochMs: null,
|
|
493
|
+
}
|
|
494
|
+
let releaseGate = () => {}
|
|
495
|
+
const gate = new Promise((resolve) => {
|
|
496
|
+
releaseGate = resolve
|
|
497
|
+
})
|
|
498
|
+
Reflect.set(globalThis, '__sootsimCpuProfileBarrierState', state)
|
|
499
|
+
Reflect.set(globalThis, '__sootsimCpuProfileBarrier', async (marker) => {
|
|
500
|
+
if (state.marker === null) {
|
|
501
|
+
state.marker = String(marker)
|
|
502
|
+
state.reachedAtEpochMs = Date.now()
|
|
503
|
+
}
|
|
504
|
+
await gate
|
|
505
|
+
})
|
|
506
|
+
let lease = null
|
|
507
|
+
const releaseBarrier = (releasedBy) => {
|
|
508
|
+
if (state.releasedAtEpochMs === null) {
|
|
509
|
+
state.releasedAtEpochMs = Date.now()
|
|
510
|
+
state.releasedBy = String(releasedBy)
|
|
511
|
+
if (lease !== null) clearTimeout(lease)
|
|
512
|
+
releaseGate()
|
|
513
|
+
}
|
|
514
|
+
return { ...state }
|
|
515
|
+
}
|
|
516
|
+
lease = setTimeout(
|
|
517
|
+
() => releaseBarrier('lease'),
|
|
518
|
+
${interactionBarrierLeaseMs},
|
|
519
|
+
)
|
|
520
|
+
Reflect.set(globalThis, '__sootsimReleaseCpuProfileBarrier', releaseBarrier)
|
|
521
|
+
return true
|
|
522
|
+
})()`)
|
|
523
|
+
if (installed !== true) {
|
|
524
|
+
console.error(' failed to install the interaction profile barrier')
|
|
525
|
+
return 1
|
|
526
|
+
}
|
|
527
|
+
interactionBarrierInstalled = true
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// discover every child worker. keep detached entries in the manifest: a
|
|
531
|
+
// worker can exit during the capture, and deleting it used to make the
|
|
532
|
+
// command either mislabel the busiest survivor as "tenant" or lose the
|
|
533
|
+
// entire run when Profiler.stop hit the detached session first.
|
|
534
|
+
const workers = new Map<string, AttachedTarget>()
|
|
535
|
+
let captureStarted = false
|
|
536
|
+
let captureWindowClosed = false
|
|
537
|
+
const startPromises: Array<Promise<void>> = []
|
|
538
|
+
const discoveryPromises: Array<Promise<void>> = []
|
|
539
|
+
let attachmentGeneration = 0
|
|
540
|
+
let awaitedDiscoveryCount = 0
|
|
541
|
+
let awaitedStartCount = 0
|
|
542
|
+
const enableRecursiveAutoAttach = (sessionId?: string) =>
|
|
543
|
+
cdp.send(
|
|
544
|
+
'Target.setAutoAttach',
|
|
545
|
+
{
|
|
546
|
+
autoAttach: true,
|
|
547
|
+
waitForDebuggerOnStart: false,
|
|
548
|
+
flatten: true,
|
|
549
|
+
},
|
|
550
|
+
sessionId,
|
|
551
|
+
)
|
|
552
|
+
const startTarget = async (target: AttachedTarget) => {
|
|
553
|
+
try {
|
|
554
|
+
await cdp.send('Profiler.enable', {}, target.sessionId)
|
|
555
|
+
await cdp.send(
|
|
556
|
+
'Profiler.setSamplingInterval',
|
|
557
|
+
{ interval: sampleInterval * 1000 },
|
|
558
|
+
target.sessionId,
|
|
559
|
+
)
|
|
560
|
+
await cdp.send('Profiler.start', {}, target.sessionId)
|
|
561
|
+
target.started = true
|
|
562
|
+
target.profileStartedAtMs = performance.now()
|
|
563
|
+
} catch (error) {
|
|
564
|
+
target.error = error instanceof Error ? error.message : String(error)
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
const waitForStableTargetSetup = async () => {
|
|
568
|
+
while (true) {
|
|
569
|
+
const observedGeneration = attachmentGeneration
|
|
570
|
+
if (awaitedDiscoveryCount < discoveryPromises.length) {
|
|
571
|
+
const pendingDiscoveries = discoveryPromises.slice(awaitedDiscoveryCount)
|
|
572
|
+
awaitedDiscoveryCount = discoveryPromises.length
|
|
573
|
+
await Promise.all(pendingDiscoveries)
|
|
574
|
+
}
|
|
575
|
+
if (awaitedStartCount < startPromises.length) {
|
|
576
|
+
const pendingStarts = startPromises.slice(awaitedStartCount)
|
|
577
|
+
awaitedStartCount = startPromises.length
|
|
578
|
+
await Promise.all(pendingStarts)
|
|
579
|
+
}
|
|
580
|
+
// recursive auto-attach notifications can arrive just after the CDP
|
|
581
|
+
// command resolves. require a quiet generation before releasing work.
|
|
582
|
+
await sleep(50)
|
|
583
|
+
if (
|
|
584
|
+
observedGeneration === attachmentGeneration &&
|
|
585
|
+
awaitedDiscoveryCount === discoveryPromises.length &&
|
|
586
|
+
awaitedStartCount === startPromises.length
|
|
587
|
+
) {
|
|
588
|
+
return
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
cdp.on((method, params) => {
|
|
593
|
+
if (method === 'Target.attachedToTarget' && params.targetInfo?.type === 'worker') {
|
|
594
|
+
if (captureWindowClosed) return
|
|
595
|
+
const sessionId = params.sessionId
|
|
596
|
+
const targetInfo = params.targetInfo
|
|
597
|
+
const target: AttachedTarget = {
|
|
598
|
+
attachedAtMs: performance.now(),
|
|
599
|
+
detached: false,
|
|
600
|
+
role: cpuProfileTargetRole(targetInfo.url, targetInfo.title),
|
|
601
|
+
sessionId,
|
|
602
|
+
started: false,
|
|
603
|
+
title: targetInfo.title ?? '',
|
|
604
|
+
type: targetInfo.type,
|
|
605
|
+
url: targetInfo.url,
|
|
606
|
+
}
|
|
607
|
+
workers.set(sessionId, target)
|
|
608
|
+
attachmentGeneration += 1
|
|
609
|
+
discoveryPromises.push(
|
|
610
|
+
enableRecursiveAutoAttach(sessionId).catch((error: unknown) => {
|
|
611
|
+
target.error = `recursive auto-attach failed: ${error instanceof Error ? error.message : String(error)}`
|
|
612
|
+
}),
|
|
613
|
+
)
|
|
614
|
+
if (captureStarted) startPromises.push(startTarget(target))
|
|
615
|
+
}
|
|
616
|
+
if (method === 'Target.detachedFromTarget') {
|
|
617
|
+
const target = workers.get(params.sessionId)
|
|
618
|
+
if (target) {
|
|
619
|
+
target.detached = true
|
|
620
|
+
target.detachedAtMs = performance.now()
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
})
|
|
624
|
+
await enableRecursiveAutoAttach()
|
|
625
|
+
// auto-attach events arrive asynchronously after the call resolves.
|
|
626
|
+
await sleep(500)
|
|
627
|
+
await waitForStableTargetSetup()
|
|
628
|
+
const pageTarget: AttachedTarget = {
|
|
629
|
+
attachedAtMs: performance.now(),
|
|
630
|
+
detached: false,
|
|
631
|
+
role: 'page',
|
|
632
|
+
started: false,
|
|
633
|
+
title: page.title ?? '',
|
|
634
|
+
type: 'page',
|
|
635
|
+
url: page.url,
|
|
636
|
+
}
|
|
637
|
+
if (interactionBarrierEnabled) {
|
|
638
|
+
console.log(' waiting for the workload interaction barrier…')
|
|
639
|
+
const deadline = performance.now() + interactionBarrierTimeoutSeconds * 1000
|
|
640
|
+
let reached = false
|
|
641
|
+
while (performance.now() < deadline) {
|
|
642
|
+
const value = await evaluatePage(`(() => {
|
|
643
|
+
const state = Reflect.get(globalThis, '__sootsimCpuProfileBarrierState')
|
|
644
|
+
if (!state || typeof state !== 'object') return null
|
|
645
|
+
return {
|
|
646
|
+
marker: Reflect.get(state, 'marker'),
|
|
647
|
+
reachedAtEpochMs: Reflect.get(state, 'reachedAtEpochMs'),
|
|
648
|
+
}
|
|
649
|
+
})()`)
|
|
650
|
+
if (
|
|
651
|
+
value &&
|
|
652
|
+
typeof value === 'object' &&
|
|
653
|
+
typeof Reflect.get(value, 'marker') === 'string' &&
|
|
654
|
+
typeof Reflect.get(value, 'reachedAtEpochMs') === 'number'
|
|
655
|
+
) {
|
|
656
|
+
reached = true
|
|
657
|
+
break
|
|
658
|
+
}
|
|
659
|
+
await sleep(25)
|
|
660
|
+
}
|
|
661
|
+
if (!reached) {
|
|
662
|
+
console.error(
|
|
663
|
+
` workload interaction barrier was not reached within ${interactionBarrierTimeoutSeconds}s`,
|
|
664
|
+
)
|
|
665
|
+
return 1
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
captureStarted = true
|
|
669
|
+
startPromises.push(startTarget(pageTarget))
|
|
670
|
+
for (const target of workers.values()) startPromises.push(startTarget(target))
|
|
671
|
+
await waitForStableTargetSetup()
|
|
672
|
+
const startedCount = [pageTarget, ...workers.values()].filter(
|
|
673
|
+
(target) => target.started,
|
|
674
|
+
).length
|
|
675
|
+
if (startedCount === 0) {
|
|
676
|
+
console.error(' no CDP targets accepted Profiler.start')
|
|
677
|
+
return 1
|
|
678
|
+
}
|
|
679
|
+
const captureWindowStartedAtMs = performance.now()
|
|
680
|
+
const captureWindowStartedAtEpochMs = Date.now()
|
|
681
|
+
const captureWindowEndedAtMs = captureWindowStartedAtMs + duration * 1000
|
|
682
|
+
const captureWindowEndedAtEpochMs = captureWindowStartedAtEpochMs + duration * 1000
|
|
683
|
+
const interactionBarrier = interactionBarrierEnabled
|
|
684
|
+
? readInteractionBarrierState(await releaseInteractionBarrier('profiler'))
|
|
685
|
+
: null
|
|
686
|
+
if (opts.verbose) {
|
|
687
|
+
for (const target of [pageTarget, ...workers.values()]) {
|
|
688
|
+
console.log(
|
|
689
|
+
` target ${target.role} ${target.type} ${target.title || '(untitled)'} ${target.url}`,
|
|
690
|
+
)
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
console.log(
|
|
694
|
+
` recording ${duration}s across ${startedCount} target(s) — interact now…`,
|
|
695
|
+
)
|
|
696
|
+
await sleep(Math.max(0, captureWindowEndedAtMs - performance.now()))
|
|
697
|
+
captureStarted = false
|
|
698
|
+
captureWindowClosed = true
|
|
699
|
+
await Promise.allSettled(startPromises)
|
|
700
|
+
const results = [pageTarget, ...workers.values()]
|
|
701
|
+
await Promise.all(
|
|
702
|
+
results.map(async (target) => {
|
|
703
|
+
if (!target.started) return
|
|
704
|
+
try {
|
|
705
|
+
const { profile } = await cdp.send('Profiler.stop', {}, target.sessionId)
|
|
706
|
+
target.profile = profile
|
|
707
|
+
target.profileStoppedAtMs = performance.now()
|
|
708
|
+
} catch (error) {
|
|
709
|
+
target.error = error instanceof Error ? error.message : String(error)
|
|
710
|
+
target.profileStoppedAtMs = target.detachedAtMs
|
|
711
|
+
}
|
|
712
|
+
await cdp.send('Profiler.disable', {}, target.sessionId).catch(() => {})
|
|
713
|
+
}),
|
|
714
|
+
)
|
|
715
|
+
// querying the page while five high-frequency profilers are still active
|
|
716
|
+
// can itself time out and lengthen the requested sampling interval. stop
|
|
717
|
+
// first, then read the already-recorded workload completion evidence.
|
|
718
|
+
const workloadEvidence = interactionBarrierEnabled
|
|
719
|
+
? readCpuProfileWorkloadEvidence(
|
|
720
|
+
await evaluatePage(
|
|
721
|
+
`Reflect.get(globalThis, '__sootsimInteractionPartialResult') ?? null`,
|
|
722
|
+
),
|
|
723
|
+
)
|
|
724
|
+
: null
|
|
725
|
+
const workloadActionEpochMs =
|
|
726
|
+
workloadEvidence?.captures.map((capture) => capture.actionEpochMs) ?? []
|
|
727
|
+
|
|
728
|
+
mkdirSync(dirname(outputPath), { recursive: true })
|
|
729
|
+
const roleLabels = cpuProfileRoleLabels(
|
|
730
|
+
results.map((target) => ({
|
|
731
|
+
captured: target.profile !== undefined,
|
|
732
|
+
role: target.role,
|
|
733
|
+
})),
|
|
734
|
+
)
|
|
735
|
+
const manifest = results.map((target, index) => {
|
|
736
|
+
const coverage = cpuProfileCoverage(
|
|
737
|
+
target.profileStartedAtMs,
|
|
738
|
+
target.profileStoppedAtMs,
|
|
739
|
+
captureWindowStartedAtMs,
|
|
740
|
+
captureWindowEndedAtMs,
|
|
741
|
+
)
|
|
742
|
+
const role = roleLabels[index]
|
|
743
|
+
const profilePath = target.profile
|
|
744
|
+
? outputPath.replace(/(\.[^.]+)?$/, `.${role}$1`)
|
|
745
|
+
: null
|
|
746
|
+
if (profilePath) {
|
|
747
|
+
writeFileSync(profilePath, JSON.stringify(target.profile))
|
|
748
|
+
console.log(
|
|
749
|
+
` ${role} ${shortUrl(target.url)}: ${target.profile.samples.length} samples → ${profilePath}`,
|
|
750
|
+
)
|
|
751
|
+
} else {
|
|
752
|
+
console.error(
|
|
753
|
+
` ${role} ${shortUrl(target.url)}: no profile${target.error ? ` — ${target.error}` : ''}`,
|
|
754
|
+
)
|
|
755
|
+
}
|
|
756
|
+
if (opts.verbose && target.profile?.samples.length > 0) {
|
|
757
|
+
for (const fn of topSelfTime(target.profile, 12)) {
|
|
758
|
+
console.log(` ${fn.pct.toFixed(1).padStart(5)}% ${fn.name} ${fn.url}`)
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
return {
|
|
762
|
+
role,
|
|
763
|
+
baseRole: target.role,
|
|
764
|
+
type: target.type,
|
|
765
|
+
title: target.title,
|
|
766
|
+
url: target.url,
|
|
767
|
+
detached: target.detached,
|
|
768
|
+
attachedOffsetMs: target.attachedAtMs - captureWindowStartedAtMs,
|
|
769
|
+
detachedOffsetMs:
|
|
770
|
+
target.detachedAtMs === undefined
|
|
771
|
+
? null
|
|
772
|
+
: target.detachedAtMs - captureWindowStartedAtMs,
|
|
773
|
+
requiredForComplete: cpuProfileTargetOverlapsWindow(
|
|
774
|
+
target.detachedAtMs,
|
|
775
|
+
captureWindowStartedAtMs,
|
|
776
|
+
),
|
|
777
|
+
...coverage,
|
|
778
|
+
samples: target.profile?.samples.length ?? 0,
|
|
779
|
+
profilePath,
|
|
780
|
+
error: target.error ?? null,
|
|
781
|
+
}
|
|
782
|
+
})
|
|
783
|
+
const requiredRoles = ['page', 'shell', 'compositor', 'tenant']
|
|
784
|
+
const capturedRoles = new Set(
|
|
785
|
+
manifest
|
|
786
|
+
.filter((target) => target.requiredForComplete && target.profilePath !== null)
|
|
787
|
+
.map((target) => target.baseRole),
|
|
788
|
+
)
|
|
789
|
+
const missingRoles = requiredRoles.filter((role) => !capturedRoles.has(role))
|
|
790
|
+
const interactionBarrierComplete =
|
|
791
|
+
!interactionBarrierEnabled ||
|
|
792
|
+
(cpuProfileInteractionBarrierValid(
|
|
793
|
+
interactionBarrier,
|
|
794
|
+
captureWindowStartedAtEpochMs,
|
|
795
|
+
captureWindowEndedAtEpochMs,
|
|
796
|
+
) &&
|
|
797
|
+
workloadEvidence !== null &&
|
|
798
|
+
cpuProfileActionsWithinWindow(
|
|
799
|
+
workloadActionEpochMs,
|
|
800
|
+
captureWindowStartedAtEpochMs,
|
|
801
|
+
captureWindowEndedAtEpochMs,
|
|
802
|
+
))
|
|
803
|
+
const complete =
|
|
804
|
+
missingRoles.length === 0 &&
|
|
805
|
+
interactionBarrierComplete &&
|
|
806
|
+
manifest.length > 0 &&
|
|
807
|
+
manifest
|
|
808
|
+
.filter((target) => target.requiredForComplete)
|
|
809
|
+
.every(
|
|
810
|
+
(target) =>
|
|
811
|
+
target.profilePath !== null &&
|
|
812
|
+
target.error === null &&
|
|
813
|
+
target.coverageRatio >= 0.99,
|
|
814
|
+
)
|
|
815
|
+
const manifestPath = outputPath.replace(/(\.[^.]+)?$/, '.manifest.json')
|
|
816
|
+
const executableSha256 = IS_STANDALONE
|
|
817
|
+
? createHash('sha256').update(readFileSync(process.execPath)).digest('hex')
|
|
818
|
+
: null
|
|
819
|
+
writeFileSync(
|
|
820
|
+
manifestPath,
|
|
821
|
+
JSON.stringify(
|
|
822
|
+
{
|
|
823
|
+
cli: {
|
|
824
|
+
version: getCliVersion(),
|
|
825
|
+
standalone: IS_STANDALONE,
|
|
826
|
+
executablePath: process.execPath,
|
|
827
|
+
executableSha256,
|
|
828
|
+
argv: process.argv.slice(1),
|
|
829
|
+
},
|
|
830
|
+
page: page.url,
|
|
831
|
+
complete,
|
|
832
|
+
missingRoles,
|
|
833
|
+
requestedDurationMs: duration * 1000,
|
|
834
|
+
captureWindowStartedAt: new Date(captureWindowStartedAtEpochMs).toISOString(),
|
|
835
|
+
captureWindowEndedAt: new Date(captureWindowEndedAtEpochMs).toISOString(),
|
|
836
|
+
interactionBarrier,
|
|
837
|
+
workloadEvidence,
|
|
838
|
+
workloadActionEpochMs,
|
|
839
|
+
manifest,
|
|
840
|
+
},
|
|
841
|
+
null,
|
|
842
|
+
2,
|
|
843
|
+
),
|
|
844
|
+
)
|
|
845
|
+
console.log(` manifest → ${manifestPath}`)
|
|
846
|
+
if (!complete) {
|
|
847
|
+
console.error(
|
|
848
|
+
` incomplete attribution capture${missingRoles.length ? ` — missing ${missingRoles.join(', ')}` : ''}`,
|
|
849
|
+
)
|
|
850
|
+
if (!interactionBarrierComplete) {
|
|
851
|
+
console.error(
|
|
852
|
+
' workload did not complete every expected action inside the interaction profile window',
|
|
853
|
+
)
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
console.log(' open in chrome devtools → Performance → Load profile to inspect.')
|
|
857
|
+
return complete ? 0 : 1
|
|
858
|
+
} finally {
|
|
859
|
+
if (interactionBarrierInstalled) {
|
|
860
|
+
await releaseInteractionBarrier('cleanup').catch(() => {})
|
|
861
|
+
}
|
|
862
|
+
cdp.close()
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
function printNoCdp(cdpPort: number) {
|
|
867
|
+
console.error(` perf cpu needs a CDP-enabled browser on :${cdpPort}.`)
|
|
868
|
+
console.error(
|
|
869
|
+
" the tenant worker can't be profiled via the JS Self-Profiler API (F28),",
|
|
870
|
+
)
|
|
871
|
+
console.error(' so the sim must expose Chrome remote debugging. easiest path —')
|
|
872
|
+
console.error(' open the sim with a CDP port (it stays driveable over the bridge,')
|
|
873
|
+
console.error(' so you can scroll/navigate while profiling):')
|
|
874
|
+
console.error('')
|
|
875
|
+
console.error(
|
|
876
|
+
` rnx open <metro-port> --new --driver playwright --cdp-port ${cdpPort}`,
|
|
877
|
+
)
|
|
878
|
+
console.error(` rnx perf cpu --cdp-port ${cdpPort} --match <metro-port>`)
|
|
879
|
+
console.error('')
|
|
880
|
+
console.error(' or launch a standalone CDP Chrome (not bridge-driveable):')
|
|
881
|
+
console.error('')
|
|
882
|
+
console.error(' "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \\')
|
|
883
|
+
console.error(
|
|
884
|
+
` --remote-debugging-port=${cdpPort} --user-data-dir=/tmp/rnx-cdp \\`,
|
|
885
|
+
)
|
|
886
|
+
console.error(' --no-sandbox --disable-gpu-sandbox \\')
|
|
887
|
+
// never suggest swiftshader — software rendering pegs every core for the
|
|
888
|
+
// browser's lifetime (documented machine-freeze cause). metal is the real
|
|
889
|
+
// rendering path anyway, so the profile is representative.
|
|
890
|
+
console.error(' --use-gl=angle --use-angle=metal \\')
|
|
891
|
+
console.error(' "http://localhost:5173/rn/<metro-port>" &')
|
|
892
|
+
console.error('')
|
|
893
|
+
console.error(' override the port with --cdp-port or $RNX_CDP_PORT.')
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
function shortUrl(url: string): string {
|
|
897
|
+
if (!url) return '(native)'
|
|
898
|
+
return url
|
|
899
|
+
.replace(/^https?:\/\/[^/]+/, '')
|
|
900
|
+
.replace(/\?.*$/, '')
|
|
901
|
+
.slice(-50)
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
export function cpuProfileTargetRole(url: string, title = ''): string {
|
|
905
|
+
const identity = `${title} ${url}`.toLowerCase()
|
|
906
|
+
if (identity.includes('compositor-worker') || identity.includes('sootsim-compositor')) {
|
|
907
|
+
return 'compositor'
|
|
908
|
+
}
|
|
909
|
+
if (identity.includes('shell-worker')) return 'shell'
|
|
910
|
+
if (identity.includes('named-worklet')) return 'worklet'
|
|
911
|
+
if (identity.includes('render-worker') || identity.includes('tenant-worker')) {
|
|
912
|
+
return 'tenant'
|
|
913
|
+
}
|
|
914
|
+
return 'worker'
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
function topSelfTime(profile: any, topN: number) {
|
|
918
|
+
const total = profile.samples.length || 1
|
|
919
|
+
const byFn = new Map<string, { self: number; name: string; url: string }>()
|
|
920
|
+
for (const node of profile.nodes) {
|
|
921
|
+
const f = node.callFrame
|
|
922
|
+
const key = `${f.functionName || '(anonymous)'}@${shortUrl(f.url)}:${f.lineNumber}`
|
|
923
|
+
const cur = byFn.get(key) ?? {
|
|
924
|
+
self: 0,
|
|
925
|
+
name: f.functionName || '(anonymous)',
|
|
926
|
+
url: `${shortUrl(f.url)}:${f.lineNumber}`,
|
|
927
|
+
}
|
|
928
|
+
cur.self += node.hitCount ?? 0
|
|
929
|
+
byFn.set(key, cur)
|
|
930
|
+
}
|
|
931
|
+
return [...byFn.values()]
|
|
932
|
+
.map((v) => ({ ...v, pct: (v.self / total) * 100 }))
|
|
933
|
+
.sort((a, b) => b.self - a.self)
|
|
934
|
+
.slice(0, topN)
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
function valueOf(args: string[], flag: string): string | undefined {
|
|
938
|
+
const i = args.indexOf(flag)
|
|
939
|
+
if (i < 0 || i === args.length - 1) return undefined
|
|
940
|
+
return args[i + 1]
|
|
941
|
+
}
|