rnxsim 0.1.379 → 0.1.381
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/README.md +22 -18
- package/cli/commands/cpu-profile.ts +556 -50
- package/cli/commands/perf.ts +1 -0
- package/cli/commands/upload.ts +7 -1
- package/dist-lib/agent-daemon-client.cjs +1 -1
- package/dist-lib/agent-events.cjs +1 -1
- package/dist-lib/agent-identity.cjs +1 -1
- package/dist-lib/agent-sessions.cjs +1 -1
- package/dist-lib/attached-projects.cjs +1 -1
- package/dist-lib/auth/shared-session.cjs +1 -1
- package/dist-lib/backend-origin.cjs +1 -1
- package/dist-lib/beta.cjs +1 -1
- package/dist-lib/beta.mjs +1 -1
- package/dist-lib/bridge-constants.cjs +1 -1
- package/dist-lib/bridge-contract-input.cjs +1 -1
- package/dist-lib/bridge-contract-input.mjs +1 -1
- package/dist-lib/bridge-contract.cjs +1 -1
- package/dist-lib/bridge-contract.mjs +1 -1
- package/dist-lib/cli-constants.cjs +1 -1
- package/dist-lib/config.cjs +1 -1
- package/dist-lib/detox/index.cjs +1 -1
- package/dist-lib/dev-bundle-resolution.cjs +1 -1
- package/dist-lib/home-paths.cjs +1 -1
- package/dist-lib/host/bridge-host.cjs +1 -1
- package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
- package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
- package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
- package/dist-lib/host/replacement-module-handler.cjs +1 -1
- package/dist-lib/host/websocket-proxy.cjs +1 -1
- package/dist-lib/index.cjs +1 -1
- package/dist-lib/jump-to-source-babel.cjs +1 -1
- package/dist-lib/menu.cjs +1 -1
- package/dist-lib/menu.mjs +1 -1
- package/dist-lib/metro-production-bundle.cjs +1 -1
- package/dist-lib/metro-production-bundle.mjs +1 -1
- package/dist-lib/metro.cjs +1 -1
- package/dist-lib/profiles.cjs +1 -1
- package/dist-lib/public-brand.cjs +1 -1
- package/dist-lib/react-native-host-modules.cjs +1 -1
- package/dist-lib/react-native-host-modules.mjs +1 -1
- package/dist-lib/render-mode.cjs +1 -1
- package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
- package/dist-lib/sdk.cjs +1 -1
- package/dist-lib/sdk.mjs +1 -1
- package/dist-lib/skills.cjs +7 -7
- package/dist-lib/vite.cjs +1 -1
- package/package.json +2 -2
- package/skills/rnx-perf/SKILL.md +68 -2
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
// rnx perf cpu — capture
|
|
1
|
+
// rnx perf cpu — capture sampled CPU traces from the page and every worker.
|
|
2
2
|
//
|
|
3
3
|
// the JS Self-Profiler API (`new Profiler()`) cannot be constructed in a
|
|
4
4
|
// dedicated-worker scope, and sootsim runs the guest app in the tenant worker,
|
|
5
5
|
// so the old in-worker approach was unsupported (F28). this implementation
|
|
6
|
-
// attaches Chromium's `Profiler` CDP domain to the worker
|
|
7
|
-
// which
|
|
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
8
|
//
|
|
9
9
|
// dedicated workers are not top-level CDP targets, so we connect to the page
|
|
10
10
|
// target, `Target.setAutoAttach` to discover the worker child sessions, and
|
|
@@ -25,9 +25,12 @@
|
|
|
25
25
|
// also drives the interaction itself lives at
|
|
26
26
|
// scripts/debug/cdp-worker-cpu-profile.ts.
|
|
27
27
|
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
28
|
+
import { createHash } from 'node:crypto'
|
|
29
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
30
|
+
import { dirname, resolve } from 'node:path'
|
|
30
31
|
import { WebSocket } from 'ws'
|
|
32
|
+
import { getCliVersion } from '../../src/cli-version'
|
|
33
|
+
import { IS_STANDALONE } from '../standalone'
|
|
31
34
|
|
|
32
35
|
interface ProfileOptions {
|
|
33
36
|
port?: number
|
|
@@ -41,8 +44,15 @@ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
|
|
|
41
44
|
class Cdp {
|
|
42
45
|
private ws: WebSocket
|
|
43
46
|
private nextId = 1
|
|
44
|
-
private pending = new Map<
|
|
45
|
-
|
|
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>()
|
|
46
56
|
private ready: Promise<void>
|
|
47
57
|
|
|
48
58
|
constructor(wsUrl: string) {
|
|
@@ -54,12 +64,20 @@ class Cdp {
|
|
|
54
64
|
this.ws.on('message', (data) => {
|
|
55
65
|
const msg = JSON.parse(data.toString())
|
|
56
66
|
if (msg.id && this.pending.has(msg.id)) {
|
|
57
|
-
const { res, rej } = this.pending.get(msg.id)!
|
|
67
|
+
const { res, rej, timeout } = this.pending.get(msg.id)!
|
|
58
68
|
this.pending.delete(msg.id)
|
|
69
|
+
clearTimeout(timeout)
|
|
59
70
|
msg.error ? rej(new Error(JSON.stringify(msg.error))) : res(msg.result)
|
|
60
71
|
} else if (msg.method) {
|
|
61
|
-
for (const l of this.listeners) l(msg.method, msg.params)
|
|
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`))
|
|
62
79
|
}
|
|
80
|
+
this.pending.clear()
|
|
63
81
|
})
|
|
64
82
|
}
|
|
65
83
|
|
|
@@ -67,7 +85,7 @@ class Cdp {
|
|
|
67
85
|
return this.ready
|
|
68
86
|
}
|
|
69
87
|
|
|
70
|
-
on(l: (method: string, params: any) => void) {
|
|
88
|
+
on(l: (method: string, params: any, sessionId?: string) => void) {
|
|
71
89
|
this.listeners.add(l)
|
|
72
90
|
}
|
|
73
91
|
|
|
@@ -78,7 +96,11 @@ class Cdp {
|
|
|
78
96
|
): Promise<any> {
|
|
79
97
|
const id = this.nextId++
|
|
80
98
|
return new Promise((res, rej) => {
|
|
81
|
-
|
|
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 })
|
|
82
104
|
this.ws.send(
|
|
83
105
|
JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }),
|
|
84
106
|
)
|
|
@@ -94,10 +116,135 @@ class Cdp {
|
|
|
94
116
|
|
|
95
117
|
interface CdpTarget {
|
|
96
118
|
type: string
|
|
119
|
+
title?: string
|
|
97
120
|
url: string
|
|
98
121
|
webSocketDebuggerUrl: string
|
|
99
122
|
}
|
|
100
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 function cpuProfileCoverage(
|
|
155
|
+
profileStartedAtMs: number | undefined,
|
|
156
|
+
profileStoppedAtMs: number | undefined,
|
|
157
|
+
windowStartedAtMs: number,
|
|
158
|
+
windowEndedAtMs: number,
|
|
159
|
+
): CpuProfileCoverage {
|
|
160
|
+
const requestedMs = Math.max(0, windowEndedAtMs - windowStartedAtMs)
|
|
161
|
+
if (
|
|
162
|
+
profileStartedAtMs === undefined ||
|
|
163
|
+
profileStoppedAtMs === undefined ||
|
|
164
|
+
requestedMs === 0
|
|
165
|
+
) {
|
|
166
|
+
return {
|
|
167
|
+
coverageMs: 0,
|
|
168
|
+
coverageRatio: 0,
|
|
169
|
+
startedOffsetMs:
|
|
170
|
+
profileStartedAtMs === undefined ? null : profileStartedAtMs - windowStartedAtMs,
|
|
171
|
+
stoppedOffsetMs:
|
|
172
|
+
profileStoppedAtMs === undefined ? null : profileStoppedAtMs - windowStartedAtMs,
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const coverageMs = Math.max(
|
|
176
|
+
0,
|
|
177
|
+
Math.min(profileStoppedAtMs, windowEndedAtMs) -
|
|
178
|
+
Math.max(profileStartedAtMs, windowStartedAtMs),
|
|
179
|
+
)
|
|
180
|
+
return {
|
|
181
|
+
coverageMs,
|
|
182
|
+
coverageRatio: coverageMs / requestedMs,
|
|
183
|
+
startedOffsetMs: profileStartedAtMs - windowStartedAtMs,
|
|
184
|
+
stoppedOffsetMs: profileStoppedAtMs - windowStartedAtMs,
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function cpuProfileActionsWithinWindow(
|
|
189
|
+
actionEpochMs: readonly number[],
|
|
190
|
+
windowStartedAtEpochMs: number,
|
|
191
|
+
windowEndedAtEpochMs: number,
|
|
192
|
+
): boolean {
|
|
193
|
+
return (
|
|
194
|
+
actionEpochMs.length > 0 &&
|
|
195
|
+
actionEpochMs.every(
|
|
196
|
+
(epochMs) =>
|
|
197
|
+
Number.isFinite(epochMs) &&
|
|
198
|
+
epochMs >= windowStartedAtEpochMs &&
|
|
199
|
+
epochMs <= windowEndedAtEpochMs,
|
|
200
|
+
)
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function cpuProfileInteractionBarrierValid(
|
|
205
|
+
barrier: CpuProfileInteractionBarrier | null,
|
|
206
|
+
windowStartedAtEpochMs: number,
|
|
207
|
+
windowEndedAtEpochMs: number,
|
|
208
|
+
): boolean {
|
|
209
|
+
return (
|
|
210
|
+
barrier !== null &&
|
|
211
|
+
barrier.marker.length > 0 &&
|
|
212
|
+
barrier.releasedBy === 'profiler' &&
|
|
213
|
+
Number.isFinite(barrier.reachedAtEpochMs) &&
|
|
214
|
+
Number.isFinite(barrier.releasedAtEpochMs) &&
|
|
215
|
+
barrier.reachedAtEpochMs <= barrier.releasedAtEpochMs &&
|
|
216
|
+
barrier.releasedAtEpochMs >= windowStartedAtEpochMs &&
|
|
217
|
+
barrier.releasedAtEpochMs <= windowEndedAtEpochMs
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function readInteractionBarrierState(
|
|
222
|
+
value: unknown,
|
|
223
|
+
): CpuProfileInteractionBarrier | null {
|
|
224
|
+
if (!value || typeof value !== 'object') return null
|
|
225
|
+
const marker = Reflect.get(value, 'marker')
|
|
226
|
+
const reachedAtEpochMs = Reflect.get(value, 'reachedAtEpochMs')
|
|
227
|
+
const releasedBy = Reflect.get(value, 'releasedBy')
|
|
228
|
+
const releasedAtEpochMs = Reflect.get(value, 'releasedAtEpochMs')
|
|
229
|
+
if (
|
|
230
|
+
typeof marker !== 'string' ||
|
|
231
|
+
typeof reachedAtEpochMs !== 'number' ||
|
|
232
|
+
typeof releasedBy !== 'string' ||
|
|
233
|
+
typeof releasedAtEpochMs !== 'number'
|
|
234
|
+
) {
|
|
235
|
+
return null
|
|
236
|
+
}
|
|
237
|
+
return { marker, reachedAtEpochMs, releasedBy, releasedAtEpochMs }
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function readActionEpochs(value: unknown): number[] {
|
|
241
|
+
if (!Array.isArray(value)) return []
|
|
242
|
+
return value.filter(
|
|
243
|
+
(epochMs): epochMs is number =>
|
|
244
|
+
typeof epochMs === 'number' && Number.isFinite(epochMs),
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
|
|
101
248
|
export async function runCpuProfile(
|
|
102
249
|
args: string[],
|
|
103
250
|
opts: ProfileOptions,
|
|
@@ -108,6 +255,21 @@ export async function runCpuProfile(
|
|
|
108
255
|
return 1
|
|
109
256
|
}
|
|
110
257
|
const sampleInterval = Number(valueOf(args, '--sample-interval') ?? '0.1') // ms
|
|
258
|
+
const interactionBarrierEnabled = args.includes('--interaction-barrier')
|
|
259
|
+
const interactionBarrierTimeoutSeconds = Number(
|
|
260
|
+
valueOf(args, '--interaction-barrier-timeout') ?? '90',
|
|
261
|
+
)
|
|
262
|
+
if (
|
|
263
|
+
!Number.isFinite(interactionBarrierTimeoutSeconds) ||
|
|
264
|
+
interactionBarrierTimeoutSeconds <= 0
|
|
265
|
+
) {
|
|
266
|
+
console.error(' --interaction-barrier-timeout must be positive (seconds)')
|
|
267
|
+
return 1
|
|
268
|
+
}
|
|
269
|
+
const interactionBarrierLeaseMs = Math.min(
|
|
270
|
+
2_147_000_000,
|
|
271
|
+
Math.ceil((interactionBarrierTimeoutSeconds + duration + 5) * 1000),
|
|
272
|
+
)
|
|
111
273
|
const cdpPort = Number(
|
|
112
274
|
valueOf(args, '--cdp-port') ?? process.env.RNX_CDP_PORT ?? '9222',
|
|
113
275
|
)
|
|
@@ -141,65 +303,396 @@ export async function runCpuProfile(
|
|
|
141
303
|
|
|
142
304
|
const cdp = new Cdp(page.webSocketDebuggerUrl)
|
|
143
305
|
await cdp.waitOpen()
|
|
306
|
+
let interactionBarrierInstalled = false
|
|
307
|
+
const evaluatePage = async (expression: string): Promise<unknown> => {
|
|
308
|
+
const response = await cdp.send('Runtime.evaluate', {
|
|
309
|
+
expression,
|
|
310
|
+
returnByValue: true,
|
|
311
|
+
})
|
|
312
|
+
if (response?.exceptionDetails) {
|
|
313
|
+
throw new Error(
|
|
314
|
+
`page evaluation failed: ${response.exceptionDetails.text ?? 'unknown error'}`,
|
|
315
|
+
)
|
|
316
|
+
}
|
|
317
|
+
return response?.result?.value
|
|
318
|
+
}
|
|
319
|
+
const releaseInteractionBarrier = (releasedBy: 'cleanup' | 'profiler') =>
|
|
320
|
+
evaluatePage(`(() => {
|
|
321
|
+
const release = Reflect.get(globalThis, '__sootsimReleaseCpuProfileBarrier')
|
|
322
|
+
return typeof release === 'function'
|
|
323
|
+
? Reflect.apply(release, globalThis, [${JSON.stringify(releasedBy)}])
|
|
324
|
+
: null
|
|
325
|
+
})()`)
|
|
144
326
|
try {
|
|
145
|
-
|
|
146
|
-
|
|
327
|
+
if (interactionBarrierEnabled) {
|
|
328
|
+
const installed = await evaluatePage(`(() => {
|
|
329
|
+
const state = {
|
|
330
|
+
marker: null,
|
|
331
|
+
reachedAtEpochMs: null,
|
|
332
|
+
releasedBy: null,
|
|
333
|
+
releasedAtEpochMs: null,
|
|
334
|
+
}
|
|
335
|
+
let releaseGate = () => {}
|
|
336
|
+
const gate = new Promise((resolve) => {
|
|
337
|
+
releaseGate = resolve
|
|
338
|
+
})
|
|
339
|
+
Reflect.set(globalThis, '__sootsimCpuProfileBarrierState', state)
|
|
340
|
+
Reflect.set(globalThis, '__sootsimCpuProfileBarrier', async (marker) => {
|
|
341
|
+
if (state.marker === null) {
|
|
342
|
+
state.marker = String(marker)
|
|
343
|
+
state.reachedAtEpochMs = Date.now()
|
|
344
|
+
}
|
|
345
|
+
await gate
|
|
346
|
+
})
|
|
347
|
+
let lease = null
|
|
348
|
+
const releaseBarrier = (releasedBy) => {
|
|
349
|
+
if (state.releasedAtEpochMs === null) {
|
|
350
|
+
state.releasedAtEpochMs = Date.now()
|
|
351
|
+
state.releasedBy = String(releasedBy)
|
|
352
|
+
if (lease !== null) clearTimeout(lease)
|
|
353
|
+
releaseGate()
|
|
354
|
+
}
|
|
355
|
+
return { ...state }
|
|
356
|
+
}
|
|
357
|
+
lease = setTimeout(
|
|
358
|
+
() => releaseBarrier('lease'),
|
|
359
|
+
${interactionBarrierLeaseMs},
|
|
360
|
+
)
|
|
361
|
+
Reflect.set(globalThis, '__sootsimReleaseCpuProfileBarrier', releaseBarrier)
|
|
362
|
+
return true
|
|
363
|
+
})()`)
|
|
364
|
+
if (installed !== true) {
|
|
365
|
+
console.error(' failed to install the interaction profile barrier')
|
|
366
|
+
return 1
|
|
367
|
+
}
|
|
368
|
+
interactionBarrierInstalled = true
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// discover every child worker. keep detached entries in the manifest: a
|
|
372
|
+
// worker can exit during the capture, and deleting it used to make the
|
|
373
|
+
// command either mislabel the busiest survivor as "tenant" or lose the
|
|
374
|
+
// entire run when Profiler.stop hit the detached session first.
|
|
375
|
+
const workers = new Map<string, AttachedTarget>()
|
|
376
|
+
let captureStarted = false
|
|
377
|
+
let captureWindowClosed = false
|
|
378
|
+
const startPromises: Array<Promise<void>> = []
|
|
379
|
+
const discoveryPromises: Array<Promise<void>> = []
|
|
380
|
+
let attachmentGeneration = 0
|
|
381
|
+
let awaitedDiscoveryCount = 0
|
|
382
|
+
let awaitedStartCount = 0
|
|
383
|
+
const enableRecursiveAutoAttach = (sessionId?: string) =>
|
|
384
|
+
cdp.send(
|
|
385
|
+
'Target.setAutoAttach',
|
|
386
|
+
{
|
|
387
|
+
autoAttach: true,
|
|
388
|
+
waitForDebuggerOnStart: false,
|
|
389
|
+
flatten: true,
|
|
390
|
+
},
|
|
391
|
+
sessionId,
|
|
392
|
+
)
|
|
393
|
+
const startTarget = async (target: AttachedTarget) => {
|
|
394
|
+
try {
|
|
395
|
+
await cdp.send('Profiler.enable', {}, target.sessionId)
|
|
396
|
+
await cdp.send(
|
|
397
|
+
'Profiler.setSamplingInterval',
|
|
398
|
+
{ interval: sampleInterval * 1000 },
|
|
399
|
+
target.sessionId,
|
|
400
|
+
)
|
|
401
|
+
await cdp.send('Profiler.start', {}, target.sessionId)
|
|
402
|
+
target.started = true
|
|
403
|
+
target.profileStartedAtMs = performance.now()
|
|
404
|
+
} catch (error) {
|
|
405
|
+
target.error = error instanceof Error ? error.message : String(error)
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
const waitForStableTargetSetup = async () => {
|
|
409
|
+
while (true) {
|
|
410
|
+
const observedGeneration = attachmentGeneration
|
|
411
|
+
if (awaitedDiscoveryCount < discoveryPromises.length) {
|
|
412
|
+
const pendingDiscoveries = discoveryPromises.slice(awaitedDiscoveryCount)
|
|
413
|
+
awaitedDiscoveryCount = discoveryPromises.length
|
|
414
|
+
await Promise.all(pendingDiscoveries)
|
|
415
|
+
}
|
|
416
|
+
if (awaitedStartCount < startPromises.length) {
|
|
417
|
+
const pendingStarts = startPromises.slice(awaitedStartCount)
|
|
418
|
+
awaitedStartCount = startPromises.length
|
|
419
|
+
await Promise.all(pendingStarts)
|
|
420
|
+
}
|
|
421
|
+
// recursive auto-attach notifications can arrive just after the CDP
|
|
422
|
+
// command resolves. require a quiet generation before releasing work.
|
|
423
|
+
await sleep(50)
|
|
424
|
+
if (
|
|
425
|
+
observedGeneration === attachmentGeneration &&
|
|
426
|
+
awaitedDiscoveryCount === discoveryPromises.length &&
|
|
427
|
+
awaitedStartCount === startPromises.length
|
|
428
|
+
) {
|
|
429
|
+
return
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
147
433
|
cdp.on((method, params) => {
|
|
148
434
|
if (method === 'Target.attachedToTarget' && params.targetInfo?.type === 'worker') {
|
|
149
|
-
|
|
435
|
+
if (captureWindowClosed) return
|
|
436
|
+
const sessionId = params.sessionId
|
|
437
|
+
const targetInfo = params.targetInfo
|
|
438
|
+
const target: AttachedTarget = {
|
|
439
|
+
attachedAtMs: performance.now(),
|
|
440
|
+
detached: false,
|
|
441
|
+
role: cpuProfileTargetRole(targetInfo.url, targetInfo.title),
|
|
442
|
+
sessionId,
|
|
443
|
+
started: false,
|
|
444
|
+
title: targetInfo.title ?? '',
|
|
445
|
+
type: targetInfo.type,
|
|
446
|
+
url: targetInfo.url,
|
|
447
|
+
}
|
|
448
|
+
workers.set(sessionId, target)
|
|
449
|
+
attachmentGeneration += 1
|
|
450
|
+
discoveryPromises.push(
|
|
451
|
+
enableRecursiveAutoAttach(sessionId).catch((error: unknown) => {
|
|
452
|
+
target.error = `recursive auto-attach failed: ${error instanceof Error ? error.message : String(error)}`
|
|
453
|
+
}),
|
|
454
|
+
)
|
|
455
|
+
if (captureStarted) startPromises.push(startTarget(target))
|
|
456
|
+
}
|
|
457
|
+
if (method === 'Target.detachedFromTarget') {
|
|
458
|
+
const target = workers.get(params.sessionId)
|
|
459
|
+
if (target) {
|
|
460
|
+
target.detached = true
|
|
461
|
+
target.detachedAtMs = performance.now()
|
|
462
|
+
}
|
|
150
463
|
}
|
|
151
|
-
if (method === 'Target.detachedFromTarget') workers.delete(params.sessionId)
|
|
152
|
-
})
|
|
153
|
-
await cdp.send('Target.setAutoAttach', {
|
|
154
|
-
autoAttach: true,
|
|
155
|
-
waitForDebuggerOnStart: false,
|
|
156
|
-
flatten: true,
|
|
157
464
|
})
|
|
465
|
+
await enableRecursiveAutoAttach()
|
|
158
466
|
// auto-attach events arrive asynchronously after the call resolves.
|
|
159
467
|
await sleep(500)
|
|
160
|
-
|
|
161
|
-
|
|
468
|
+
await waitForStableTargetSetup()
|
|
469
|
+
const pageTarget: AttachedTarget = {
|
|
470
|
+
attachedAtMs: performance.now(),
|
|
471
|
+
detached: false,
|
|
472
|
+
role: 'page',
|
|
473
|
+
started: false,
|
|
474
|
+
title: page.title ?? '',
|
|
475
|
+
type: 'page',
|
|
476
|
+
url: page.url,
|
|
477
|
+
}
|
|
478
|
+
if (interactionBarrierEnabled) {
|
|
479
|
+
console.log(' waiting for the workload interaction barrier…')
|
|
480
|
+
const deadline = performance.now() + interactionBarrierTimeoutSeconds * 1000
|
|
481
|
+
let reached = false
|
|
482
|
+
while (performance.now() < deadline) {
|
|
483
|
+
const value = await evaluatePage(`(() => {
|
|
484
|
+
const state = Reflect.get(globalThis, '__sootsimCpuProfileBarrierState')
|
|
485
|
+
if (!state || typeof state !== 'object') return null
|
|
486
|
+
return {
|
|
487
|
+
marker: Reflect.get(state, 'marker'),
|
|
488
|
+
reachedAtEpochMs: Reflect.get(state, 'reachedAtEpochMs'),
|
|
489
|
+
}
|
|
490
|
+
})()`)
|
|
491
|
+
if (
|
|
492
|
+
value &&
|
|
493
|
+
typeof value === 'object' &&
|
|
494
|
+
typeof Reflect.get(value, 'marker') === 'string' &&
|
|
495
|
+
typeof Reflect.get(value, 'reachedAtEpochMs') === 'number'
|
|
496
|
+
) {
|
|
497
|
+
reached = true
|
|
498
|
+
break
|
|
499
|
+
}
|
|
500
|
+
await sleep(25)
|
|
501
|
+
}
|
|
502
|
+
if (!reached) {
|
|
503
|
+
console.error(
|
|
504
|
+
` workload interaction barrier was not reached within ${interactionBarrierTimeoutSeconds}s`,
|
|
505
|
+
)
|
|
506
|
+
return 1
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
captureStarted = true
|
|
510
|
+
startPromises.push(startTarget(pageTarget))
|
|
511
|
+
for (const target of workers.values()) startPromises.push(startTarget(target))
|
|
512
|
+
await waitForStableTargetSetup()
|
|
513
|
+
const startedCount = [pageTarget, ...workers.values()].filter(
|
|
514
|
+
(target) => target.started,
|
|
515
|
+
).length
|
|
516
|
+
if (startedCount === 0) {
|
|
517
|
+
console.error(' no CDP targets accepted Profiler.start')
|
|
162
518
|
return 1
|
|
163
519
|
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
520
|
+
const captureWindowStartedAtMs = performance.now()
|
|
521
|
+
const captureWindowStartedAtEpochMs = Date.now()
|
|
522
|
+
const captureWindowEndedAtMs = captureWindowStartedAtMs + duration * 1000
|
|
523
|
+
const captureWindowEndedAtEpochMs = captureWindowStartedAtEpochMs + duration * 1000
|
|
524
|
+
const interactionBarrier = interactionBarrierEnabled
|
|
525
|
+
? readInteractionBarrierState(await releaseInteractionBarrier('profiler'))
|
|
526
|
+
: null
|
|
527
|
+
if (opts.verbose) {
|
|
528
|
+
for (const target of [pageTarget, ...workers.values()]) {
|
|
529
|
+
console.log(
|
|
530
|
+
` target ${target.role} ${target.type} ${target.title || '(untitled)'} ${target.url}`,
|
|
531
|
+
)
|
|
532
|
+
}
|
|
173
533
|
}
|
|
174
534
|
console.log(
|
|
175
|
-
` recording ${duration}s across ${
|
|
535
|
+
` recording ${duration}s across ${startedCount} target(s) — interact now…`,
|
|
176
536
|
)
|
|
177
|
-
await sleep(
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
537
|
+
await sleep(Math.max(0, captureWindowEndedAtMs - performance.now()))
|
|
538
|
+
captureStarted = false
|
|
539
|
+
captureWindowClosed = true
|
|
540
|
+
await Promise.allSettled(startPromises)
|
|
541
|
+
const results = [pageTarget, ...workers.values()]
|
|
542
|
+
await Promise.all(
|
|
543
|
+
results.map(async (target) => {
|
|
544
|
+
if (!target.started) return
|
|
545
|
+
try {
|
|
546
|
+
const { profile } = await cdp.send('Profiler.stop', {}, target.sessionId)
|
|
547
|
+
target.profile = profile
|
|
548
|
+
target.profileStoppedAtMs = performance.now()
|
|
549
|
+
} catch (error) {
|
|
550
|
+
target.error = error instanceof Error ? error.message : String(error)
|
|
551
|
+
target.profileStoppedAtMs = target.detachedAtMs
|
|
552
|
+
}
|
|
553
|
+
await cdp.send('Profiler.disable', {}, target.sessionId).catch(() => {})
|
|
554
|
+
}),
|
|
555
|
+
)
|
|
556
|
+
// querying the page while five high-frequency profilers are still active
|
|
557
|
+
// can itself time out and lengthen the requested sampling interval. stop
|
|
558
|
+
// first, then read the already-recorded action timestamps.
|
|
559
|
+
const workloadActionEpochMs = interactionBarrierEnabled
|
|
560
|
+
? readActionEpochs(
|
|
561
|
+
await evaluatePage(`(() => {
|
|
562
|
+
const partial = Reflect.get(globalThis, '__sootsimInteractionPartialResult')
|
|
563
|
+
const captures = partial && typeof partial === 'object'
|
|
564
|
+
? Reflect.get(partial, 'captures')
|
|
565
|
+
: null
|
|
566
|
+
if (!Array.isArray(captures)) return []
|
|
567
|
+
return captures.map((capture) => {
|
|
568
|
+
if (!capture || typeof capture !== 'object') return null
|
|
569
|
+
const note = Reflect.get(capture, 'note')
|
|
570
|
+
return note && typeof note === 'object'
|
|
571
|
+
? Reflect.get(note, 'actionEpochMs')
|
|
572
|
+
: null
|
|
573
|
+
})
|
|
574
|
+
})()`),
|
|
575
|
+
)
|
|
576
|
+
: []
|
|
185
577
|
|
|
186
578
|
mkdirSync(dirname(outputPath), { recursive: true })
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
579
|
+
const roleCounts = new Map<string, number>()
|
|
580
|
+
const manifest = results.map((target) => {
|
|
581
|
+
const coverage = cpuProfileCoverage(
|
|
582
|
+
target.profileStartedAtMs,
|
|
583
|
+
target.profileStoppedAtMs,
|
|
584
|
+
captureWindowStartedAtMs,
|
|
585
|
+
captureWindowEndedAtMs,
|
|
586
|
+
)
|
|
587
|
+
const priorCount = roleCounts.get(target.role) ?? 0
|
|
588
|
+
roleCounts.set(target.role, priorCount + 1)
|
|
589
|
+
const role = priorCount === 0 ? target.role : `${target.role}-${priorCount + 1}`
|
|
590
|
+
const profilePath = target.profile
|
|
591
|
+
? outputPath.replace(/(\.[^.]+)?$/, `.${role}$1`)
|
|
592
|
+
: null
|
|
593
|
+
if (profilePath) {
|
|
594
|
+
writeFileSync(profilePath, JSON.stringify(target.profile))
|
|
595
|
+
console.log(
|
|
596
|
+
` ${role} ${shortUrl(target.url)}: ${target.profile.samples.length} samples → ${profilePath}`,
|
|
597
|
+
)
|
|
598
|
+
} else {
|
|
599
|
+
console.error(
|
|
600
|
+
` ${role} ${shortUrl(target.url)}: no profile${target.error ? ` — ${target.error}` : ''}`,
|
|
601
|
+
)
|
|
602
|
+
}
|
|
603
|
+
if (opts.verbose && target.profile?.samples.length > 0) {
|
|
604
|
+
for (const fn of topSelfTime(target.profile, 12)) {
|
|
196
605
|
console.log(` ${fn.pct.toFixed(1).padStart(5)}% ${fn.name} ${fn.url}`)
|
|
197
606
|
}
|
|
198
607
|
}
|
|
608
|
+
return {
|
|
609
|
+
role,
|
|
610
|
+
type: target.type,
|
|
611
|
+
title: target.title,
|
|
612
|
+
url: target.url,
|
|
613
|
+
detached: target.detached,
|
|
614
|
+
attachedOffsetMs: target.attachedAtMs - captureWindowStartedAtMs,
|
|
615
|
+
...coverage,
|
|
616
|
+
samples: target.profile?.samples.length ?? 0,
|
|
617
|
+
profilePath,
|
|
618
|
+
error: target.error ?? null,
|
|
619
|
+
}
|
|
199
620
|
})
|
|
621
|
+
const requiredRoles = ['page', 'shell', 'compositor', 'tenant']
|
|
622
|
+
const capturedRoles = new Set(
|
|
623
|
+
manifest
|
|
624
|
+
.filter((target) => target.profilePath !== null)
|
|
625
|
+
.map((target) => target.role),
|
|
626
|
+
)
|
|
627
|
+
const missingRoles = requiredRoles.filter((role) => !capturedRoles.has(role))
|
|
628
|
+
const interactionBarrierComplete =
|
|
629
|
+
!interactionBarrierEnabled ||
|
|
630
|
+
(cpuProfileInteractionBarrierValid(
|
|
631
|
+
interactionBarrier,
|
|
632
|
+
captureWindowStartedAtEpochMs,
|
|
633
|
+
captureWindowEndedAtEpochMs,
|
|
634
|
+
) &&
|
|
635
|
+
cpuProfileActionsWithinWindow(
|
|
636
|
+
workloadActionEpochMs,
|
|
637
|
+
captureWindowStartedAtEpochMs,
|
|
638
|
+
captureWindowEndedAtEpochMs,
|
|
639
|
+
))
|
|
640
|
+
const complete =
|
|
641
|
+
missingRoles.length === 0 &&
|
|
642
|
+
interactionBarrierComplete &&
|
|
643
|
+
manifest.length > 0 &&
|
|
644
|
+
manifest.every(
|
|
645
|
+
(target) =>
|
|
646
|
+
target.profilePath !== null &&
|
|
647
|
+
target.error === null &&
|
|
648
|
+
target.coverageRatio >= 0.99,
|
|
649
|
+
)
|
|
650
|
+
const manifestPath = outputPath.replace(/(\.[^.]+)?$/, '.manifest.json')
|
|
651
|
+
const executableSha256 = IS_STANDALONE
|
|
652
|
+
? createHash('sha256').update(readFileSync(process.execPath)).digest('hex')
|
|
653
|
+
: null
|
|
654
|
+
writeFileSync(
|
|
655
|
+
manifestPath,
|
|
656
|
+
JSON.stringify(
|
|
657
|
+
{
|
|
658
|
+
cli: {
|
|
659
|
+
version: getCliVersion(),
|
|
660
|
+
standalone: IS_STANDALONE,
|
|
661
|
+
executablePath: process.execPath,
|
|
662
|
+
executableSha256,
|
|
663
|
+
argv: process.argv.slice(1),
|
|
664
|
+
},
|
|
665
|
+
page: page.url,
|
|
666
|
+
complete,
|
|
667
|
+
missingRoles,
|
|
668
|
+
requestedDurationMs: duration * 1000,
|
|
669
|
+
captureWindowStartedAt: new Date(captureWindowStartedAtEpochMs).toISOString(),
|
|
670
|
+
captureWindowEndedAt: new Date(captureWindowEndedAtEpochMs).toISOString(),
|
|
671
|
+
interactionBarrier,
|
|
672
|
+
workloadActionEpochMs,
|
|
673
|
+
manifest,
|
|
674
|
+
},
|
|
675
|
+
null,
|
|
676
|
+
2,
|
|
677
|
+
),
|
|
678
|
+
)
|
|
679
|
+
console.log(` manifest → ${manifestPath}`)
|
|
680
|
+
if (!complete) {
|
|
681
|
+
console.error(
|
|
682
|
+
` incomplete attribution capture${missingRoles.length ? ` — missing ${missingRoles.join(', ')}` : ''}`,
|
|
683
|
+
)
|
|
684
|
+
if (!interactionBarrierComplete) {
|
|
685
|
+
console.error(
|
|
686
|
+
' workload actions did not fall inside the interaction profile window',
|
|
687
|
+
)
|
|
688
|
+
}
|
|
689
|
+
}
|
|
200
690
|
console.log(' open in chrome devtools → Performance → Load profile to inspect.')
|
|
201
|
-
return 0
|
|
691
|
+
return complete ? 0 : 1
|
|
202
692
|
} finally {
|
|
693
|
+
if (interactionBarrierInstalled) {
|
|
694
|
+
await releaseInteractionBarrier('cleanup').catch(() => {})
|
|
695
|
+
}
|
|
203
696
|
cdp.close()
|
|
204
697
|
}
|
|
205
698
|
}
|
|
@@ -242,6 +735,19 @@ function shortUrl(url: string): string {
|
|
|
242
735
|
.slice(-50)
|
|
243
736
|
}
|
|
244
737
|
|
|
738
|
+
export function cpuProfileTargetRole(url: string, title = ''): string {
|
|
739
|
+
const identity = `${title} ${url}`.toLowerCase()
|
|
740
|
+
if (identity.includes('compositor-worker') || identity.includes('sootsim-compositor')) {
|
|
741
|
+
return 'compositor'
|
|
742
|
+
}
|
|
743
|
+
if (identity.includes('shell-worker')) return 'shell'
|
|
744
|
+
if (identity.includes('named-worklet')) return 'worklet'
|
|
745
|
+
if (identity.includes('render-worker') || identity.includes('tenant-worker')) {
|
|
746
|
+
return 'tenant'
|
|
747
|
+
}
|
|
748
|
+
return 'worker'
|
|
749
|
+
}
|
|
750
|
+
|
|
245
751
|
function topSelfTime(profile: any, topN: number) {
|
|
246
752
|
const total = profile.samples.length || 1
|
|
247
753
|
const byFn = new Map<string, { self: number; name: string; url: string }>()
|