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.
Files changed (48) hide show
  1. package/README.md +22 -18
  2. package/cli/commands/cpu-profile.ts +556 -50
  3. package/cli/commands/perf.ts +1 -0
  4. package/cli/commands/upload.ts +7 -1
  5. package/dist-lib/agent-daemon-client.cjs +1 -1
  6. package/dist-lib/agent-events.cjs +1 -1
  7. package/dist-lib/agent-identity.cjs +1 -1
  8. package/dist-lib/agent-sessions.cjs +1 -1
  9. package/dist-lib/attached-projects.cjs +1 -1
  10. package/dist-lib/auth/shared-session.cjs +1 -1
  11. package/dist-lib/backend-origin.cjs +1 -1
  12. package/dist-lib/beta.cjs +1 -1
  13. package/dist-lib/beta.mjs +1 -1
  14. package/dist-lib/bridge-constants.cjs +1 -1
  15. package/dist-lib/bridge-contract-input.cjs +1 -1
  16. package/dist-lib/bridge-contract-input.mjs +1 -1
  17. package/dist-lib/bridge-contract.cjs +1 -1
  18. package/dist-lib/bridge-contract.mjs +1 -1
  19. package/dist-lib/cli-constants.cjs +1 -1
  20. package/dist-lib/config.cjs +1 -1
  21. package/dist-lib/detox/index.cjs +1 -1
  22. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  23. package/dist-lib/home-paths.cjs +1 -1
  24. package/dist-lib/host/bridge-host.cjs +1 -1
  25. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  26. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  27. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  28. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  29. package/dist-lib/host/websocket-proxy.cjs +1 -1
  30. package/dist-lib/index.cjs +1 -1
  31. package/dist-lib/jump-to-source-babel.cjs +1 -1
  32. package/dist-lib/menu.cjs +1 -1
  33. package/dist-lib/menu.mjs +1 -1
  34. package/dist-lib/metro-production-bundle.cjs +1 -1
  35. package/dist-lib/metro-production-bundle.mjs +1 -1
  36. package/dist-lib/metro.cjs +1 -1
  37. package/dist-lib/profiles.cjs +1 -1
  38. package/dist-lib/public-brand.cjs +1 -1
  39. package/dist-lib/react-native-host-modules.cjs +1 -1
  40. package/dist-lib/react-native-host-modules.mjs +1 -1
  41. package/dist-lib/render-mode.cjs +1 -1
  42. package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
  43. package/dist-lib/sdk.cjs +1 -1
  44. package/dist-lib/sdk.mjs +1 -1
  45. package/dist-lib/skills.cjs +7 -7
  46. package/dist-lib/vite.cjs +1 -1
  47. package/package.json +2 -2
  48. package/skills/rnx-perf/SKILL.md +68 -2
@@ -1,10 +1,10 @@
1
- // rnx perf cpu — capture a sampled CPU trace from the tenant worker.
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 target instead
7
- // which works for workers and returns the .cpuprofile shape directly.
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 { mkdirSync, writeFileSync } from 'fs'
29
- import { dirname, resolve } from 'path'
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<number, { res: (v: any) => void; rej: (e: any) => void }>()
45
- private listeners = new Set<(method: string, params: any) => void>()
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
- this.pending.set(id, { 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 })
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
- // discover worker child sessions via auto-attach.
146
- const workers = new Map<string, string>() // sessionId -> url
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
- workers.set(params.sessionId, params.targetInfo.url)
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
- if (workers.size === 0) {
161
- console.error(' no worker targets attached — is the app loaded in this tab?')
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
- for (const sid of workers.keys()) {
166
- await cdp.send('Profiler.enable', {}, sid)
167
- await cdp.send(
168
- 'Profiler.setSamplingInterval',
169
- { interval: sampleInterval * 1000 },
170
- sid,
171
- ) // µs
172
- await cdp.send('Profiler.start', {}, sid)
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 ${workers.size} worker(s) — interact now…`,
535
+ ` recording ${duration}s across ${startedCount} target(s) — interact now…`,
176
536
  )
177
- await sleep(duration * 1000)
178
-
179
- const results: Array<{ url: string; profile: any }> = []
180
- for (const [sid, url] of workers) {
181
- const { profile } = await cdp.send('Profiler.stop', {}, sid)
182
- results.push({ url, profile })
183
- }
184
- results.sort((a, b) => b.profile.samples.length - a.profile.samples.length)
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
- // save the busiest worker (the tenant during interaction) to the main path;
188
- // others get a suffix so a multi-worker capture isn't lost.
189
- results.forEach((r, i) => {
190
- const path =
191
- i === 0 ? outputPath : outputPath.replace(/(\.[^.]+)?$/, `.worker${i}$1`)
192
- writeFileSync(path, JSON.stringify(r.profile))
193
- console.log(` ${shortUrl(r.url)}: ${r.profile.samples.length} samples → ${path}`)
194
- if (opts.verbose && r.profile.samples.length > 0) {
195
- for (const fn of topSelfTime(r.profile, 12)) {
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 }>()