@houwert/conductor 0.29.0 → 0.29.1

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.
@@ -73,18 +73,25 @@ function num(out, re) {
73
73
  function parseGfxinfoSummary(out) {
74
74
  const header = out.match(/\*\* Graphics info for pid (\d+) \[([^\]]+)\]/);
75
75
  const janky = out.match(/Janky frames:\s*(\d+)\s*\(([\d.]+)%\)/);
76
+ const totalFrames = num(out, /Total frames rendered:\s*(\d+)/);
77
+ // With nothing drawn the platform still prints percentiles, filled from the
78
+ // top histogram bucket — "p99 4950ms" for a window in which no frame existed.
79
+ // A percentile over zero frames is not a slow frame, it is no frame, and it
80
+ // must not be representable as a value.
81
+ const drew = totalFrames !== undefined && totalFrames > 0;
82
+ const pctile = (re) => (drew ? num(out, re) : undefined);
76
83
  // The GPU block repeats these labels as "50th gpu percentile", so matching the
77
84
  // exact phrase keeps us on the UI-thread figures.
78
85
  return {
79
86
  pid: header ? Number(header[1]) : undefined,
80
87
  packageName: header ? header[2] : undefined,
81
- totalFrames: num(out, /Total frames rendered:\s*(\d+)/),
88
+ totalFrames,
82
89
  jankyFrames: janky ? Number(janky[1]) : undefined,
83
- jankyPercent: janky ? Number(janky[2]) : undefined,
84
- platformP50Ms: num(out, /50th percentile:\s*(\d+)ms/),
85
- platformP90Ms: num(out, /90th percentile:\s*(\d+)ms/),
86
- platformP95Ms: num(out, /95th percentile:\s*(\d+)ms/),
87
- platformP99Ms: num(out, /99th percentile:\s*(\d+)ms/),
90
+ jankyPercent: drew && janky ? Number(janky[2]) : undefined,
91
+ platformP50Ms: pctile(/50th percentile:\s*(\d+)ms/),
92
+ platformP90Ms: pctile(/90th percentile:\s*(\d+)ms/),
93
+ platformP95Ms: pctile(/95th percentile:\s*(\d+)ms/),
94
+ platformP99Ms: pctile(/99th percentile:\s*(\d+)ms/),
88
95
  missedVsync: num(out, /Number Missed Vsync:\s*(\d+)/),
89
96
  highInputLatency: num(out, /Number High input latency:\s*(\d+)/),
90
97
  slowUiThread: num(out, /Number Slow UI thread:\s*(\d+)/),
@@ -589,6 +596,14 @@ async function profileFramesReport(opts, sessionName, frameOpts) {
589
596
  `(hardware acceleration must be on; WebView-only or SurfaceView-only apps report nothing)`, opts);
590
597
  return 1;
591
598
  }
599
+ if (summary.totalFrames === 0) {
600
+ notes.push({
601
+ code: 'no-frames',
602
+ message: `${appId} drew no frames in this window, so there is nothing to measure — not ` +
603
+ `smooth, not janky. The app may be idle, backgrounded, or rendering through a ` +
604
+ `SurfaceView/WebView that HWUI does not count. Drive it, or check it is foreground.`,
605
+ });
606
+ }
592
607
  if (!clockAnchor) {
593
608
  notes.push({
594
609
  code: 'no-clock-anchor',
@@ -27,6 +27,15 @@ const output_js_1 = require("../output.js");
27
27
  const bootstrap_js_1 = require("../drivers/bootstrap.js");
28
28
  const metro_cdp_js_1 = require("../drivers/metro-cdp.js");
29
29
  const stats_js_1 = require("../stats.js");
30
+ /** Hermes marks its own frames with brackets; anything else is app code. */
31
+ function classifyFrame(name) {
32
+ if (/^\[GC\b/i.test(name) || /garbage collector/i.test(name))
33
+ return 'gc';
34
+ if (name === '[root]' || name === '(root)' || name === '(program)' || name === '(idle)') {
35
+ return 'idle';
36
+ }
37
+ return name.startsWith('[') || name.startsWith('(') ? 'idle' : 'named';
38
+ }
30
39
  function shortFile(url) {
31
40
  const u = url ?? '';
32
41
  return u.includes('/') ? u.slice(u.lastIndexOf('/') + 1) : u;
@@ -82,6 +91,33 @@ function analyzeCpuProfile(profile, top) {
82
91
  }
83
92
  }
84
93
  const totalUs = [...self.values()].reduce((a, b) => a + b, 0);
94
+ // Split before ranking: a top-30 list assembled from 5% of the samples is
95
+ // noise dressed as a finding, and the reader cannot tell without this.
96
+ const buckets = { gc: 0, idle: 0, named: 0 };
97
+ for (const [key, us] of self.entries()) {
98
+ buckets[classifyFrame(label.get(key).functionName || '(anonymous)')] += us;
99
+ }
100
+ const share = (v) => (totalUs > 0 ? (0, stats_js_1.round)((v / totalUs) * 100, 1) : 0);
101
+ const attribution = {
102
+ namedJsPercent: share(buckets.named),
103
+ gcPercent: share(buckets.gc),
104
+ idlePercent: share(buckets.idle),
105
+ };
106
+ const notes = [];
107
+ if (totalUs > 0 && attribution.namedJsPercent < 25) {
108
+ notes.push({
109
+ code: 'low-attribution',
110
+ message: `Only ${attribution.namedJsPercent}% of sampled time landed in a named JS function ` +
111
+ `(${attribution.idlePercent}% with an empty JS stack, ${attribution.gcPercent}% in GC). ` +
112
+ `The ranking below is built on what little is left and should not be trusted. ` +
113
+ `A large empty-stack share means the JS thread was idle when sampled — which is itself ` +
114
+ `a result: the bottleneck is not JS. A large GC share is a memory finding; follow it ` +
115
+ `with \`profile memory --track\` rather than a function ranking.`,
116
+ namedJsPercent: attribution.namedJsPercent,
117
+ gcPercent: attribution.gcPercent,
118
+ idlePercent: attribution.idlePercent,
119
+ });
120
+ }
85
121
  const ranked = [...self.entries()]
86
122
  .map(([key, selfUs]) => {
87
123
  const frame = label.get(key);
@@ -101,8 +137,10 @@ function analyzeCpuProfile(profile, top) {
101
137
  durationMs: (0, stats_js_1.round)((profile.endTime - profile.startTime) / 1000),
102
138
  sampleCount: samples.length,
103
139
  medianSampleIntervalMs: sortedDeltas.length > 0 ? (0, stats_js_1.round)(sortedDeltas[Math.floor(sortedDeltas.length / 2)] / 1000) : 0,
140
+ attribution,
104
141
  top: ranked.slice(0, top),
105
142
  omitted: Math.max(0, ranked.length - top),
143
+ notes,
106
144
  };
107
145
  }
108
146
  async function connect(sessionName, cdpOpts) {
@@ -114,7 +152,10 @@ async function connect(sessionName, cdpOpts) {
114
152
  return client;
115
153
  }
116
154
  async function startSampling(client) {
117
- await client.send('Profiler.enable');
155
+ // React Native's Fusebox CDP backend answers `Profiler.enable` with
156
+ // -32601 Unsupported method, but implements `Profiler.start` perfectly well.
157
+ // Enabling is a courtesy to backends that require it, never a precondition.
158
+ await client.send('Profiler.enable').catch(() => undefined);
118
159
  await client.send('Profiler.start');
119
160
  }
120
161
  async function stopSampling(client) {
@@ -134,13 +175,18 @@ async function writeProfile(out, profile) {
134
175
  function printSummary(summary, out) {
135
176
  console.log(`profile js — ${summary.durationMs}ms, ${summary.sampleCount} samples ` +
136
177
  `(~${summary.medianSampleIntervalMs}ms apart)`);
137
- console.log(` ${'self'.padStart(9)} ${'total'.padStart(9)} ${'%'.padStart(5)} function`);
178
+ const a = summary.attribution;
179
+ console.log(` sampled time: ${a.namedJsPercent}% named JS ${a.gcPercent}% GC ` +
180
+ `${a.idlePercent}% empty JS stack`);
181
+ console.log(`\n ${'self'.padStart(9)} ${'total'.padStart(9)} ${'%'.padStart(5)} function`);
138
182
  for (const f of summary.top) {
139
183
  console.log(` ${`${f.selfMs}ms`.padStart(9)} ${`${f.totalMs}ms`.padStart(9)} ` +
140
184
  `${f.selfPercent.toFixed(1).padStart(5)} ${f.name} ${f.location}`);
141
185
  }
142
186
  if (summary.omitted > 0)
143
187
  console.log(` ... ${summary.omitted} more (raise --top)`);
188
+ for (const note of summary.notes)
189
+ console.log(`\n note [${note.code}]: ${note.message}`);
144
190
  console.log(`\n raw profile → ${out}`);
145
191
  console.log(' open it in Chrome DevTools (Performance → Load profile), or convert with ' +
146
192
  '`npx hermes-profile-transformer`.');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@houwert/conductor",
3
- "version": "0.29.0",
3
+ "version": "0.29.1",
4
4
  "description": "CLI tool for mobile app interactions — optimized for AI agents",
5
5
  "license": "MIT",
6
6
  "repository": {