@houwert/conductor 0.28.0 → 0.29.0

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.
@@ -3,25 +3,61 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.HELP = void 0;
6
+ exports.REACT_PROFILER_STOP = exports.REACT_PROFILER_READ = exports.REACT_PROFILER_INSTALL = exports.HELP = void 0;
7
+ exports.parseSimpleperfReport = parseSimpleperfReport;
7
8
  exports.profileCpu = profileCpu;
9
+ exports.heapGrowth = heapGrowth;
8
10
  exports.profileMemory = profileMemory;
9
11
  exports.profileReactStart = profileReactStart;
10
12
  exports.profileReactStop = profileReactStop;
11
13
  exports.HELP = ` profile cpu --duration <s> [--out <path>]
12
14
  Record a CPU trace (iOS: xctrace, Android: simpleperf)
15
+ --report [--top N] Android: also symbolize the trace into a ranked table
13
16
  profile memory --track <s> [--interval <ms>] [<appId>]
14
17
  Sample memory for N seconds, report deltas
18
+ Android: also reports heap growth and GC pauses
19
+ profile frames reset [<appId>] Android: zero the app's gfxinfo frame counters
20
+ profile frames report [<appId>] Android: jank / frame timing since the last reset
21
+ --track <s> [--interval <ms>] Reset, sample for N seconds, then report
22
+ --save-baseline <name> / --diff <name> / --baselines
23
+ Save, compare against, and list frame baselines
24
+ profile js record --duration <s> Sample Hermes JS CPU and rank functions
25
+ profile js start / stop [--top N] Same, bracketing a flow you drive yourself
26
+ --out <path> Where to write the raw .cpuprofile
15
27
  profile react start Install a React commit-profiler hook in the JS runtime
16
- profile react stop [--top N] Stop and summarise captured React commits`;
28
+ --max-commits <n> Commit ring-buffer size (default 500)
29
+ --max-components <n> Component records kept per commit (default 200)
30
+ profile react stop [--top N] Stop and summarise captured React commits
31
+ --timeline Include per-commit component detail in --json`;
17
32
  const child_process_1 = require("child_process");
18
33
  const os_1 = __importDefault(require("os"));
19
34
  const path_1 = __importDefault(require("path"));
20
35
  const output_js_1 = require("../output.js");
21
36
  const bootstrap_js_1 = require("../drivers/bootstrap.js");
22
37
  const sdk_js_1 = require("../android/sdk.js");
38
+ const runner_js_1 = require("../runner.js");
23
39
  const memory_js_1 = require("./memory.js");
24
40
  const metro_cdp_js_1 = require("../drivers/metro-cdp.js");
41
+ const profile_gc_js_1 = require("./profile-gc.js");
42
+ /**
43
+ * Parse `simpleperf report --sort dso,symbol`. The header row is located by its
44
+ * `Overhead` column rather than by line number, since simpleperf prefixes the
45
+ * table with a variable-length preamble.
46
+ */
47
+ function parseSimpleperfReport(out) {
48
+ const lines = out.split(/\r?\n/);
49
+ const headerIndex = lines.findIndex((l) => /^\s*Overhead\b/.test(l));
50
+ if (headerIndex === -1)
51
+ return [];
52
+ const entries = [];
53
+ for (const line of lines.slice(headerIndex + 1)) {
54
+ const m = line.match(/^\s*([\d.]+)%\s+(\S+)\s+(.+?)\s*$/);
55
+ if (!m)
56
+ continue;
57
+ entries.push({ percent: Number(m[1]), dso: m[2], symbol: m[3] });
58
+ }
59
+ return entries;
60
+ }
25
61
  function defaultTracePath(prefix, ext) {
26
62
  const ts = new Date().toISOString().replace(/[:.]/g, '-');
27
63
  return path_1.default.join(os_1.default.tmpdir(), `${prefix}-${ts}.${ext}`);
@@ -47,7 +83,7 @@ async function recordIosCpu(deviceId, appId, durationSec, out) {
47
83
  proc.on('error', reject);
48
84
  });
49
85
  }
50
- async function recordAndroidCpu(deviceId, appId, durationSec, out) {
86
+ async function recordAndroidCpu(deviceId, appId, durationSec, out, report) {
51
87
  const adb = (0, sdk_js_1.resolveAndroidTool)('adb');
52
88
  const env = (0, sdk_js_1.androidSpawnEnv)();
53
89
  const remote = `/data/local/tmp/conductor-perf-${Date.now()}.data`;
@@ -73,6 +109,31 @@ async function recordAndroidCpu(deviceId, appId, durationSec, out) {
73
109
  proc.on('close', (code) => code === 0 ? resolve() : reject(new Error(`simpleperf record exited with ${code}`)));
74
110
  proc.on('error', reject);
75
111
  });
112
+ // Symbolize on-device before pulling: /system/bin/simpleperf resolves against
113
+ // the libraries actually loaded there, which a host-side report cannot do
114
+ // without a matching symfs.
115
+ let entries;
116
+ if (report) {
117
+ const res = await (0, runner_js_1.spawnCommand)(adb, [
118
+ '-s',
119
+ deviceId,
120
+ 'shell',
121
+ 'simpleperf',
122
+ 'report',
123
+ '-i',
124
+ remote,
125
+ '--sort',
126
+ 'dso,symbol',
127
+ '--percent-limit',
128
+ '0.1',
129
+ ], { env });
130
+ if (!res.success) {
131
+ throw new Error(`simpleperf report failed on-device (${res.stderr.trim() || 'no output'}). ` +
132
+ `The raw recording is still at ${out}; symbolize it on the host with the NDK's ` +
133
+ `simpleperf/report.py.`);
134
+ }
135
+ entries = parseSimpleperfReport(res.stdout).slice(0, report.top);
136
+ }
76
137
  await new Promise((resolve, reject) => {
77
138
  const proc = (0, child_process_1.spawn)(adb, ['-s', deviceId, 'pull', remote, out], { stdio: 'inherit', env });
78
139
  proc.on('close', (code) => code === 0 ? resolve() : reject(new Error(`adb pull exited with ${code}`)));
@@ -83,6 +144,7 @@ async function recordAndroidCpu(deviceId, appId, durationSec, out) {
83
144
  proc.on('close', () => resolve());
84
145
  proc.on('error', () => resolve());
85
146
  });
147
+ return entries;
86
148
  }
87
149
  async function profileCpu(opts, sessionName, profileOpts) {
88
150
  if (sessionName === 'default') {
@@ -92,26 +154,45 @@ async function profileCpu(opts, sessionName, profileOpts) {
92
154
  const platform = await (0, bootstrap_js_1.detectPlatform)(sessionName).catch(() => null);
93
155
  const isIos = platform === 'ios' || platform === 'tvos';
94
156
  const out = profileOpts.out ?? defaultTracePath('cpu', isIos ? 'trace' : 'perf.data');
157
+ let entries;
95
158
  try {
96
159
  if (isIos) {
160
+ if (profileOpts.report) {
161
+ (0, output_js_1.printError)('profile cpu --report is Android-only. Export the iOS trace with ' +
162
+ '`xcrun xctrace export --input <trace> --toc` to see what it holds.', opts);
163
+ return 1;
164
+ }
97
165
  await recordIosCpu(sessionName, profileOpts.appId, profileOpts.durationSec, out);
98
166
  }
99
167
  else if (platform === 'android') {
100
- await recordAndroidCpu(sessionName, profileOpts.appId, profileOpts.durationSec, out);
168
+ entries = await recordAndroidCpu(sessionName, profileOpts.appId, profileOpts.durationSec, out, profileOpts.report ? { top: profileOpts.top ?? 30 } : undefined);
101
169
  }
102
170
  else if (platform === 'vega') {
103
- // No native CPU-trace tooling (xctrace/simpleperf) on Vega yet.
104
- (0, output_js_1.printError)('profile cpu is not yet supported on vega (Amazon Fire TV)', opts);
171
+ // Vega is Amazon's own OS, not Android — no simpleperf, no dumpsys. A
172
+ // physical Fire TV Stick runs Fire OS (Android) over adb and is a
173
+ // different target entirely, where all the Android tooling applies.
174
+ (0, output_js_1.printError)('profile cpu is not supported on vega (Amazon Vega OS — no simpleperf on device).\n' +
175
+ 'A physical Fire TV Stick runs Fire OS, which is Android: connect it with ' +
176
+ '`adb connect <ip>` and conductor treats it as an android device, where ' +
177
+ '`profile cpu`, `profile frames` and `profile memory` all work.', opts);
105
178
  return 1;
106
179
  }
107
180
  else {
108
181
  (0, output_js_1.printError)(`profile cpu is not supported on platform ${platform ?? '(unknown)'}`, opts);
109
182
  return 1;
110
183
  }
111
- if (opts.json)
112
- (0, output_js_1.printData)({ out, durationSec: profileOpts.durationSec, platform }, opts);
113
- else
184
+ if (opts.json) {
185
+ (0, output_js_1.printData)({ out, durationSec: profileOpts.durationSec, platform, symbols: entries }, opts);
186
+ }
187
+ else {
114
188
  (0, output_js_1.printSuccess)(`profile cpu — recorded ${profileOpts.durationSec}s → ${out}`, opts);
189
+ if (entries) {
190
+ console.log(` ${'overhead'.padStart(9)} symbol`);
191
+ for (const e of entries) {
192
+ console.log(` ${`${e.percent}%`.padStart(9)} ${e.symbol} [${e.dso}]`);
193
+ }
194
+ }
195
+ }
115
196
  return 0;
116
197
  }
117
198
  catch (err) {
@@ -119,8 +200,41 @@ async function profileCpu(opts, sessionName, profileOpts) {
119
200
  return 1;
120
201
  }
121
202
  }
203
+ /** Growth of each numeric field of `report.app` across the tracked window. */
204
+ function heapGrowth(samples) {
205
+ const series = new Map();
206
+ for (const s of samples) {
207
+ const app = s.data?.app;
208
+ if (!app)
209
+ continue;
210
+ for (const [k, v] of Object.entries(app)) {
211
+ if (typeof v !== 'number')
212
+ continue;
213
+ const arr = series.get(k) ?? [];
214
+ arr.push(v);
215
+ series.set(k, arr);
216
+ }
217
+ }
218
+ return [...series.entries()]
219
+ .map(([key, values]) => ({
220
+ key,
221
+ startBytes: values[0],
222
+ endBytes: values[values.length - 1],
223
+ deltaBytes: values[values.length - 1] - values[0],
224
+ peakBytes: Math.max(...values),
225
+ }))
226
+ .sort((a, b) => b.deltaBytes - a.deltaBytes);
227
+ }
228
+ function mb(bytes) {
229
+ return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
230
+ }
122
231
  async function profileMemory(opts, sessionName, profileOpts) {
123
232
  const samples = [];
233
+ const platform = await (0, bootstrap_js_1.detectPlatform)(sessionName).catch(() => undefined);
234
+ const isAndroid = platform === 'android';
235
+ // Note the device's own clock before sampling so the logcat window lines up
236
+ // with it even when the host clock has drifted.
237
+ const gcSince = isAndroid ? await (0, profile_gc_js_1.deviceLogcatTimestamp)(sessionName) : undefined;
124
238
  const start = Date.now();
125
239
  const end = start + profileOpts.trackSec * 1000;
126
240
  while (Date.now() < end) {
@@ -142,8 +256,15 @@ async function profileMemory(opts, sessionName, profileOpts) {
142
256
  return { at: s.at, data: null };
143
257
  }
144
258
  });
259
+ const growth = heapGrowth(parsed);
260
+ let gc;
261
+ if (isAndroid) {
262
+ const events = await (0, profile_gc_js_1.collectGcSince)(sessionName, gcSince);
263
+ if (events.length > 0)
264
+ gc = (0, profile_gc_js_1.summariseGc)(events);
265
+ }
145
266
  if (opts.json) {
146
- (0, output_js_1.printData)({ samples: parsed, durationMs: Date.now() - start }, opts);
267
+ (0, output_js_1.printData)({ samples: parsed, durationMs: Date.now() - start, growth, gc }, opts);
147
268
  }
148
269
  else {
149
270
  console.log(`profile memory — ${samples.length} samples over ${profileOpts.trackSec}s`);
@@ -156,6 +277,25 @@ async function profileMemory(opts, sessionName, profileOpts) {
156
277
  : '(parse error)';
157
278
  console.log(` t+${(p.at / 1000).toFixed(1)}s ${summary}`);
158
279
  }
280
+ if (growth.length > 0) {
281
+ console.log('\n heap growth over the window');
282
+ for (const g of growth.slice(0, 8)) {
283
+ const sign = g.deltaBytes >= 0 ? '+' : '';
284
+ console.log(` ${g.key.padEnd(20)} ${mb(g.startBytes)} → ${mb(g.endBytes)} ` +
285
+ `${sign}${mb(g.deltaBytes)} peak ${mb(g.peakBytes)}`);
286
+ }
287
+ }
288
+ if (gc) {
289
+ console.log(`\n GC: ${gc.events} collection(s), ${gc.totalPauseMs}ms of stop-the-world pause total`);
290
+ console.log(` pause p50 ${gc.pause.p50Ms}ms p95 ${gc.pause.p95Ms}ms max ${gc.pause.maxMs}ms`);
291
+ for (const k of gc.byKind) {
292
+ console.log(` ${k.kind.padEnd(34)} ${String(k.count).padStart(4)}x ${k.totalPauseMs}ms`);
293
+ }
294
+ }
295
+ else if (isAndroid) {
296
+ console.log('\n GC: no ART collections logged in the window. ART only logs collections it ' +
297
+ 'considers noteworthy, so a quiet window here is a real signal.');
298
+ }
159
299
  }
160
300
  return 0;
161
301
  }
@@ -175,67 +315,190 @@ async function captureStdout(fn) {
175
315
  return chunks.join('');
176
316
  }
177
317
  // ── React profiler ────────────────────────────────────────────────────────────
178
- const REACT_PROFILER_INSTALL = `
318
+ /**
319
+ * Injected commit profiler.
320
+ *
321
+ * The subtle part is deciding which fibers actually rendered in a given commit.
322
+ * React does not clear `actualDuration` on fibers it left alone, so "has a
323
+ * non-zero actualDuration" is true for anything that rendered at any point in
324
+ * the past, and counting those every commit inflates both render counts and
325
+ * durations. What React does clear is `actualStartTime`: `createWorkInProgress`
326
+ * resets it to -1 and `startProfilerTimer` stamps it as work begins, and a
327
+ * render pass always begins at the root. So the root's own `actualStartTime` is
328
+ * the start of this pass, and a fiber rendered in it exactly when its start time
329
+ * is at or after the root's. Anchoring on the root rather than on a running
330
+ * maximum keeps this independent of whichever clock React's `now()` is bound to.
331
+ *
332
+ * That render passes always walk down from the root also lets us prune: a fiber
333
+ * that did not render cannot contain one that did, so the walk costs the size of
334
+ * the rendered set, not the size of the tree.
335
+ *
336
+ * `actualDuration` bubbles up, so a fiber's own cost is its duration minus that
337
+ * of the children that rendered alongside it. Both are reported: `selfMs` is
338
+ * additive across components, `totalMs` is subtree-inclusive and double-counts
339
+ * by design.
340
+ */
341
+ const REACT_PROFILER_INSTALL = (maxCommits, maxComponents) => `
179
342
  (() => {
180
343
  if (globalThis.__CONDUCTOR_REACT_PROFILER__) {
181
344
  return { installed: true, already: true };
182
345
  }
183
346
  const hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
184
- if (!hook) return { installed: false, error: 'No React DevTools hook (Hermes only?)' };
185
- const commits = [];
186
- const MAX = 500;
187
- const orig = hook.onCommitFiberRoot;
188
- hook.onCommitFiberRoot = function(rendererID, root, priorityLevel) {
347
+ if (!hook) return { installed: false, error: 'No React DevTools hook on this runtime' };
348
+
349
+ var state = {
350
+ commits: [],
351
+ maxCommits: ${maxCommits},
352
+ maxComponents: ${maxComponents},
353
+ droppedCommits: 0,
354
+ droppedComponents: 0,
355
+ skippedCommits: 0,
356
+ profilingSupported: null
357
+ };
358
+
359
+ // Detect up front whether this build carries React's timing instrumentation,
360
+ // so profile react start can fail loudly rather than leaving it to stop.
361
+ var rendererCount = 0;
362
+ try {
363
+ if (hook.renderers && hook.getFiberRoots) {
364
+ hook.renderers.forEach(function (_r, id) {
365
+ rendererCount++;
366
+ var roots = hook.getFiberRoots(id);
367
+ if (!roots) return;
368
+ roots.forEach(function (root) {
369
+ if (state.profilingSupported === null) {
370
+ state.profilingSupported = root.current.actualDuration !== undefined;
371
+ }
372
+ });
373
+ });
374
+ }
375
+ } catch (e) {}
376
+
377
+ var orig = hook.onCommitFiberRoot;
378
+ hook.onCommitFiberRoot = function (rendererID, root, priorityLevel) {
189
379
  try {
190
- const entry = { at: Date.now(), rendererID, components: [] };
191
- let node = root.current;
192
- const stack = [{ fiber: node, depth: 0 }];
193
- let count = 0;
194
- while (stack.length && count < 200) {
195
- const { fiber, depth } = stack.pop();
196
- if (!fiber) continue;
197
- const dur = fiber.actualDuration ?? 0;
198
- if (dur > 0) {
199
- const name = (fiber.type && (fiber.type.displayName || fiber.type.name)) || (typeof fiber.type === 'string' ? fiber.type : null);
380
+ var rootFiber = root.current;
381
+ if (state.profilingSupported === null) {
382
+ state.profilingSupported = rootFiber.actualDuration !== undefined;
383
+ }
384
+ var passStart = rootFiber.actualStartTime;
385
+ if (state.profilingSupported && !(typeof passStart === 'number' && passStart >= 0)) {
386
+ // No start stamp on the root means we cannot tell this commit's work
387
+ // from earlier work; skipping is better than reporting stale fibers.
388
+ state.skippedCommits++;
389
+ } else if (state.profilingSupported) {
390
+ var components = [];
391
+ var droppedHere = 0;
392
+
393
+ var visit = function (fiber, depth) {
394
+ var start = fiber.actualStartTime;
395
+ if (!(typeof start === 'number' && start >= passStart)) return 0;
396
+
397
+ var childSum = 0;
398
+ var child = fiber.child;
399
+ while (child) {
400
+ childSum += visit(child, depth + 1);
401
+ child = child.sibling;
402
+ }
403
+
404
+ var dur = fiber.actualDuration || 0;
405
+ var name =
406
+ (fiber.type && (fiber.type.displayName || fiber.type.name)) ||
407
+ (typeof fiber.type === 'string' ? fiber.type : null);
200
408
  if (name) {
201
- entry.components.push({ name, depth, actualDuration: dur, selfDuration: fiber.selfBaseDuration ?? 0 });
202
- count++;
409
+ if (components.length < state.maxComponents) {
410
+ components.push({
411
+ name: name,
412
+ depth: depth,
413
+ actualDuration: dur,
414
+ selfDuration: Math.max(0, dur - childSum)
415
+ });
416
+ } else {
417
+ droppedHere++;
418
+ }
203
419
  }
420
+ return dur;
421
+ };
422
+
423
+ visit(rootFiber, 0);
424
+ state.droppedComponents += droppedHere;
425
+ state.commits.push({
426
+ at: Date.now(),
427
+ rendererID: rendererID,
428
+ durationMs: rootFiber.actualDuration || 0,
429
+ truncated: droppedHere > 0,
430
+ droppedComponents: droppedHere,
431
+ components: components
432
+ });
433
+ while (state.commits.length > state.maxCommits) {
434
+ state.commits.shift();
435
+ state.droppedCommits++;
204
436
  }
205
- if (fiber.child) stack.push({ fiber: fiber.child, depth: depth + 1 });
206
- if (fiber.sibling) stack.push({ fiber: fiber.sibling, depth });
207
437
  }
208
- commits.push(entry);
209
- if (commits.length > MAX) commits.shift();
210
438
  } catch (e) {}
211
439
  if (typeof orig === 'function') return orig.apply(this, arguments);
212
440
  };
441
+
213
442
  globalThis.__CONDUCTOR_REACT_PROFILER__ = {
214
443
  installed: true,
215
- commits,
216
- uninstall: () => { hook.onCommitFiberRoot = orig; }
444
+ state: state,
445
+ uninstall: function () { hook.onCommitFiberRoot = orig; }
446
+ };
447
+ return {
448
+ installed: true,
449
+ already: false,
450
+ renderers: rendererCount,
451
+ profilingSupported: state.profilingSupported
217
452
  };
218
- return { installed: true, already: false };
219
453
  })()
220
454
  `;
221
- const REACT_PROFILER_READ = (top) => `
455
+ exports.REACT_PROFILER_INSTALL = REACT_PROFILER_INSTALL;
456
+ const REACT_PROFILER_READ = (top, timeline) => `
222
457
  (() => {
223
458
  const p = globalThis.__CONDUCTOR_REACT_PROFILER__;
224
- if (!p) return { installed: false, commits: [] };
225
- const commits = p.commits.slice();
459
+ if (!p) return { installed: false };
460
+ const s = p.state;
226
461
  const byName = {};
227
- for (const c of commits) {
462
+ for (const c of s.commits) {
228
463
  for (const comp of c.components) {
229
- byName[comp.name] = byName[comp.name] ?? { name: comp.name, totalMs: 0, renders: 0 };
230
- byName[comp.name].totalMs += comp.actualDuration;
231
- byName[comp.name].renders += 1;
464
+ const e = byName[comp.name] || (byName[comp.name] = {
465
+ name: comp.name, selfMs: 0, totalMs: 0, renders: 0
466
+ });
467
+ e.selfMs += comp.selfDuration;
468
+ e.totalMs += comp.actualDuration;
469
+ e.renders += 1;
232
470
  }
233
471
  }
234
- const top = Object.values(byName).sort((a, b) => b.totalMs - a.totalMs).slice(0, ${top});
235
- return { installed: true, commits, totalCommits: commits.length, top };
472
+ const ranked = Object.keys(byName).map(function (k) { return byName[k]; })
473
+ .sort(function (a, b) { return b.selfMs - a.selfMs; });
474
+ const commits = s.commits.map(function (c) {
475
+ const out = {
476
+ at: c.at,
477
+ durationMs: c.durationMs,
478
+ componentCount: c.components.length,
479
+ droppedComponents: c.droppedComponents
480
+ };
481
+ if (${timeline ? 'true' : 'false'}) out.components = c.components;
482
+ return out;
483
+ });
484
+ return {
485
+ installed: true,
486
+ profilingSupported: s.profilingSupported,
487
+ totalCommits: s.commits.length,
488
+ droppedCommits: s.droppedCommits,
489
+ skippedCommits: s.skippedCommits,
490
+ droppedComponents: s.droppedComponents,
491
+ truncated: s.droppedCommits > 0 || s.droppedComponents > 0,
492
+ maxCommits: s.maxCommits,
493
+ maxComponents: s.maxComponents,
494
+ componentsRanked: ranked.length,
495
+ top: ranked.slice(0, ${top}),
496
+ commits: commits
497
+ };
236
498
  })()
237
499
  `;
238
- const REACT_PROFILER_STOP = `
500
+ exports.REACT_PROFILER_READ = REACT_PROFILER_READ;
501
+ exports.REACT_PROFILER_STOP = `
239
502
  (() => {
240
503
  const p = globalThis.__CONDUCTOR_REACT_PROFILER__;
241
504
  if (!p) return { installed: false };
@@ -244,62 +507,113 @@ const REACT_PROFILER_STOP = `
244
507
  return { installed: true, stopped: true };
245
508
  })()
246
509
  `;
247
- async function profileReactStart(opts, sessionName, cdpOpts) {
510
+ const NO_PROFILING_BUILD = 'this build has no React profiling data (fibers carry no actualDuration). ' +
511
+ 'The React commit profiler needs a dev or profiling build; a release build ' +
512
+ 'strips the timing instrumentation. Use `profile js` or `profile frames` to ' +
513
+ 'measure a release build.';
514
+ async function connectCdp(sessionName, cdpOpts) {
515
+ const platform = await (0, bootstrap_js_1.detectPlatform)(sessionName).catch(() => undefined);
516
+ const client = new metro_cdp_js_1.MetroCdpClient();
517
+ await client.connect({
518
+ port: cdpOpts.port,
519
+ deviceId: sessionName !== 'default' ? sessionName : undefined,
520
+ platform,
521
+ targetIndex: cdpOpts.targetIndex,
522
+ });
523
+ return client;
524
+ }
525
+ async function profileReactStart(opts, sessionName, reactOpts) {
526
+ let client;
248
527
  try {
249
- const platform = await (0, bootstrap_js_1.detectPlatform)(sessionName).catch(() => undefined);
250
- const client = new metro_cdp_js_1.MetroCdpClient();
251
- await client.connect({
252
- port: cdpOpts.port ?? 8081,
253
- deviceId: sessionName !== 'default' ? sessionName : undefined,
254
- platform,
255
- targetIndex: cdpOpts.targetIndex,
256
- });
257
- const result = await client.evaluate(REACT_PROFILER_INSTALL);
258
- client.close();
528
+ client = await connectCdp(sessionName, reactOpts);
529
+ const result = await client.evaluate((0, exports.REACT_PROFILER_INSTALL)(reactOpts.maxCommits ?? 500, reactOpts.maxComponents ?? 200));
259
530
  if (!result.installed) {
260
531
  (0, output_js_1.printError)(`profile react start — ${result.error ?? 'install failed'}`, opts);
261
532
  return 1;
262
533
  }
534
+ if (result.profilingSupported === false) {
535
+ await client.evaluate(exports.REACT_PROFILER_STOP);
536
+ (0, output_js_1.printError)(`profile react start — ${NO_PROFILING_BUILD}`, opts);
537
+ return 1;
538
+ }
263
539
  if (opts.json)
264
- (0, output_js_1.printData)(result, opts);
265
- else
540
+ (0, output_js_1.printData)({ status: 'ok', ...result }, opts);
541
+ else {
266
542
  (0, output_js_1.printSuccess)(`profile react start — ${result.already ? 'already installed' : 'installed'}`, opts);
543
+ if (result.profilingSupported === null) {
544
+ console.log(' note: nothing has rendered yet, so the build could not be checked for profiling ' +
545
+ 'support. `profile react stop` will say so if it turns out to be a release build.');
546
+ }
547
+ }
267
548
  return 0;
268
549
  }
269
550
  catch (err) {
270
551
  (0, output_js_1.printError)(`profile react start — ${err instanceof Error ? err.message : String(err)}`, opts);
271
552
  return 1;
272
553
  }
554
+ finally {
555
+ client?.close();
556
+ }
273
557
  }
274
- async function profileReactStop(opts, sessionName, cdpOpts, top) {
558
+ async function profileReactStop(opts, sessionName, reactOpts, top) {
559
+ let client;
275
560
  try {
276
- const platform = await (0, bootstrap_js_1.detectPlatform)(sessionName).catch(() => undefined);
277
- const client = new metro_cdp_js_1.MetroCdpClient();
278
- await client.connect({
279
- port: cdpOpts.port ?? 8081,
280
- deviceId: sessionName !== 'default' ? sessionName : undefined,
281
- platform,
282
- targetIndex: cdpOpts.targetIndex,
283
- });
284
- const read = await client.evaluate(REACT_PROFILER_READ(top));
285
- await client.evaluate(REACT_PROFILER_STOP);
286
- client.close();
561
+ client = await connectCdp(sessionName, reactOpts);
562
+ const read = await client.evaluate((0, exports.REACT_PROFILER_READ)(top, reactOpts.timeline ?? false));
563
+ await client.evaluate(exports.REACT_PROFILER_STOP);
287
564
  if (!read.installed) {
288
565
  (0, output_js_1.printError)('profile react stop — profiler was not installed', opts);
289
566
  return 1;
290
567
  }
291
- if (opts.json)
292
- (0, output_js_1.printData)(read, opts);
293
- else {
294
- console.log(`profile react — ${read.totalCommits ?? 0} commit(s)`);
295
- for (const t of read.top ?? []) {
296
- console.log(` ${t.totalMs.toFixed(1)}ms ${t.renders}x ${t.name}`);
297
- }
568
+ if (read.profilingSupported === false) {
569
+ (0, output_js_1.printError)(`profile react stop — ${NO_PROFILING_BUILD}`, opts);
570
+ return 1;
571
+ }
572
+ if (read.profilingSupported === null || (read.totalCommits ?? 0) === 0) {
573
+ (0, output_js_1.printError)('profile react stop — no commits were captured. Either nothing re-rendered while the ' +
574
+ 'profiler was installed, or the app reloaded (which drops the hook).', opts);
575
+ return 1;
298
576
  }
577
+ if (opts.json)
578
+ (0, output_js_1.printData)({ status: 'ok', ...read }, opts);
579
+ else
580
+ printReactReport(read);
299
581
  return 0;
300
582
  }
301
583
  catch (err) {
302
584
  (0, output_js_1.printError)(`profile react stop — ${err instanceof Error ? err.message : String(err)}`, opts);
303
585
  return 1;
304
586
  }
587
+ finally {
588
+ client?.close();
589
+ }
590
+ }
591
+ function printReactReport(read) {
592
+ console.log(`profile react — ${read.totalCommits ?? 0} commit(s)`);
593
+ console.log(` ${'self'.padStart(9)} ${'total'.padStart(9)} renders component`);
594
+ for (const t of read.top ?? []) {
595
+ console.log(` ${`${t.selfMs.toFixed(1)}ms`.padStart(9)} ${`${t.totalMs.toFixed(1)}ms`.padStart(9)} ` +
596
+ `${String(t.renders).padStart(7)} ${t.name}`);
597
+ }
598
+ console.log(' self is additive across components; total is subtree-inclusive and double-counts parents.');
599
+ const commits = read.commits ?? [];
600
+ if (commits.length > 0) {
601
+ const slowest = [...commits].sort((a, b) => b.durationMs - a.durationMs).slice(0, 5);
602
+ const t0 = commits[0].at;
603
+ console.log('\n slowest commits');
604
+ for (const c of slowest) {
605
+ console.log(` t+${((c.at - t0) / 1000).toFixed(2)}s ${c.durationMs.toFixed(1)}ms ` +
606
+ `${c.componentCount} component(s)`);
607
+ }
608
+ console.log(' full per-commit timeline is in --json output (add --timeline for components).');
609
+ }
610
+ if ((read.skippedCommits ?? 0) > 0) {
611
+ console.log(`\n skipped ${read.skippedCommits} commit(s) whose root fiber carried no render start ` +
612
+ 'stamp — their work could not be told apart from earlier renders.');
613
+ }
614
+ if (read.truncated) {
615
+ console.log(`\n TRUNCATED: dropped ${read.droppedCommits ?? 0} commit(s) beyond --max-commits ` +
616
+ `${read.maxCommits} and ${read.droppedComponents ?? 0} component record(s) beyond ` +
617
+ `--max-components ${read.maxComponents}. Raise those limits or profile a shorter window.`);
618
+ }
305
619
  }