@houwert/conductor 0.29.1 → 0.29.3
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.
|
@@ -647,8 +647,10 @@ async function profileFramesReport(opts, sessionName, frameOpts) {
|
|
|
647
647
|
if (frameOpts.trackSec !== undefined && repeat === 1) {
|
|
648
648
|
notes.push({
|
|
649
649
|
code: 'single-window',
|
|
650
|
-
message: 'One window carries no run-to-run variance, so
|
|
651
|
-
'
|
|
650
|
+
message: 'One window carries no run-to-run variance, so nothing here can be called a change. ' +
|
|
651
|
+
'Measured on a Fire TV Stick, five identical captures of the same screen ranged ' +
|
|
652
|
+
'37.8-72.9% janky and 6.2-13.0ms issueDraw p50 — wide enough to invent a regression ' +
|
|
653
|
+
'or hide one. Treat --repeat 5 as the default on TV, not an option.',
|
|
652
654
|
});
|
|
653
655
|
}
|
|
654
656
|
const report = {
|
package/dist/commands/profile.js
CHANGED
|
@@ -4,9 +4,13 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.REACT_PROFILER_STOP = exports.REACT_PROFILER_READ = exports.REACT_PROFILER_INSTALL = exports.HELP = void 0;
|
|
7
|
+
exports.isUnsymbolised = isUnsymbolised;
|
|
8
|
+
exports.rollupByDso = rollupByDso;
|
|
9
|
+
exports.unsymbolisedPercent = unsymbolisedPercent;
|
|
7
10
|
exports.parseSimpleperfReport = parseSimpleperfReport;
|
|
8
11
|
exports.profileCpu = profileCpu;
|
|
9
12
|
exports.heapGrowth = heapGrowth;
|
|
13
|
+
exports.inferCollections = inferCollections;
|
|
10
14
|
exports.profileMemory = profileMemory;
|
|
11
15
|
exports.profileReactStart = profileReactStart;
|
|
12
16
|
exports.profileReactStop = profileReactStop;
|
|
@@ -36,9 +40,80 @@ const output_js_1 = require("../output.js");
|
|
|
36
40
|
const bootstrap_js_1 = require("../drivers/bootstrap.js");
|
|
37
41
|
const sdk_js_1 = require("../android/sdk.js");
|
|
38
42
|
const runner_js_1 = require("../runner.js");
|
|
43
|
+
const device_js_1 = require("../android/device.js");
|
|
39
44
|
const memory_js_1 = require("./memory.js");
|
|
40
45
|
const metro_cdp_js_1 = require("../drivers/metro-cdp.js");
|
|
41
46
|
const profile_gc_js_1 = require("./profile-gc.js");
|
|
47
|
+
/** simpleperf renders an unresolved address as `libfoo.so[+1a62f8]`. */
|
|
48
|
+
function isUnsymbolised(symbol) {
|
|
49
|
+
return /\[\+[0-9a-fx]+\]\s*$/i.test(symbol);
|
|
50
|
+
}
|
|
51
|
+
function shortDso(dso) {
|
|
52
|
+
return dso.includes('/') ? dso.slice(dso.lastIndexOf('/') + 1) : dso;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Sum a flat symbol table by library.
|
|
56
|
+
*
|
|
57
|
+
* A stripped app library shows up as a dozen `libfoo.so[+offset]` rows that a
|
|
58
|
+
* reader cannot tell apart from a dozen unrelated hotspots. Rolled up, one
|
|
59
|
+
* library dominating is immediately visible — which is the actual finding when
|
|
60
|
+
* a profile has no single hot symbol.
|
|
61
|
+
*/
|
|
62
|
+
function rollupByDso(entries) {
|
|
63
|
+
const byDso = new Map();
|
|
64
|
+
for (const e of entries) {
|
|
65
|
+
const key = shortDso(e.dso);
|
|
66
|
+
const slot = byDso.get(key) ?? { percent: 0, symbols: 0, unsym: 0 };
|
|
67
|
+
slot.percent += e.percent;
|
|
68
|
+
slot.symbols++;
|
|
69
|
+
if (isUnsymbolised(e.symbol))
|
|
70
|
+
slot.unsym++;
|
|
71
|
+
byDso.set(key, slot);
|
|
72
|
+
}
|
|
73
|
+
return [...byDso.entries()]
|
|
74
|
+
.map(([dso, v]) => ({
|
|
75
|
+
dso,
|
|
76
|
+
percent: Math.round(v.percent * 100) / 100,
|
|
77
|
+
symbols: v.symbols,
|
|
78
|
+
unsymbolised: v.unsym === v.symbols,
|
|
79
|
+
}))
|
|
80
|
+
.sort((a, b) => b.percent - a.percent);
|
|
81
|
+
}
|
|
82
|
+
/** Share of the sampled overhead that resolved to no function name. */
|
|
83
|
+
function unsymbolisedPercent(entries) {
|
|
84
|
+
const total = entries.reduce((a, e) => a + e.percent, 0);
|
|
85
|
+
if (total <= 0)
|
|
86
|
+
return 0;
|
|
87
|
+
const unsym = entries.filter((e) => isUnsymbolised(e.symbol)).reduce((a, e) => a + e.percent, 0);
|
|
88
|
+
return Math.round((unsym / total) * 10000) / 100;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Turn a simpleperf failure into something actionable.
|
|
92
|
+
*
|
|
93
|
+
* simpleperf needs the target to be `android:debuggable` or to declare
|
|
94
|
+
* `<profileable android:shell="true"/>`. A stock release APK is neither, and
|
|
95
|
+
* the resulting error says only `exited with 1`.
|
|
96
|
+
*/
|
|
97
|
+
async function explainSimpleperfFailure(deviceId, appId, stderr) {
|
|
98
|
+
// Drop simpleperf's PMU probing chatter; it is present on every run.
|
|
99
|
+
const meaningful = stderr
|
|
100
|
+
.split(/\r?\n/)
|
|
101
|
+
.filter((l) => l.trim() && !/cannot read event type|Failed to read event type/i.test(l))
|
|
102
|
+
.slice(-4)
|
|
103
|
+
.join('\n');
|
|
104
|
+
if (appId) {
|
|
105
|
+
const pkg = await (0, device_js_1.adbShell)(deviceId, ['dumpsys', 'package', appId]);
|
|
106
|
+
if (pkg.success && /^\s*flags=\[/m.test(pkg.stdout) && !/\bDEBUGGABLE\b/.test(pkg.stdout)) {
|
|
107
|
+
return (`simpleperf cannot profile ${appId}: the installed APK is neither debuggable nor ` +
|
|
108
|
+
`profileable.\nsimpleperf needs android:debuggable, or ` +
|
|
109
|
+
`\`<profileable android:shell="true"/>\` inside <application> — the latter is a ` +
|
|
110
|
+
`one-line manifest change that keeps the build a release build and is the right fix ` +
|
|
111
|
+
`for a perf build.\n` +
|
|
112
|
+
(meaningful ? `simpleperf said:\n${meaningful}` : ''));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return `simpleperf record failed.\n${meaningful || '(no diagnostic output)'}`;
|
|
116
|
+
}
|
|
42
117
|
/**
|
|
43
118
|
* Parse `simpleperf report --sort dso,symbol`. The header row is located by its
|
|
44
119
|
* `Overhead` column rather than by line number, since simpleperf prefixes the
|
|
@@ -104,11 +179,13 @@ async function recordAndroidCpu(deviceId, appId, durationSec, out, report) {
|
|
|
104
179
|
else {
|
|
105
180
|
recordArgs.push('-a');
|
|
106
181
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
182
|
+
// Capture rather than inherit: simpleperf prints a wall of `cannot read event
|
|
183
|
+
// type` lines while probing PMU support, and those are not the error. Letting
|
|
184
|
+
// them through buries whatever actually went wrong.
|
|
185
|
+
const rec = await (0, runner_js_1.spawnCommand)(adb, recordArgs, { env });
|
|
186
|
+
if (!rec.success) {
|
|
187
|
+
throw new Error(await explainSimpleperfFailure(deviceId, appId, rec.stderr));
|
|
188
|
+
}
|
|
112
189
|
// Symbolize on-device before pulling: /system/bin/simpleperf resolves against
|
|
113
190
|
// the libraries actually loaded there, which a host-side report cannot do
|
|
114
191
|
// without a matching symfs.
|
|
@@ -181,17 +258,41 @@ async function profileCpu(opts, sessionName, profileOpts) {
|
|
|
181
258
|
(0, output_js_1.printError)(`profile cpu is not supported on platform ${platform ?? '(unknown)'}`, opts);
|
|
182
259
|
return 1;
|
|
183
260
|
}
|
|
261
|
+
const byDso = entries ? rollupByDso(entries) : undefined;
|
|
262
|
+
const unsymPercent = entries ? unsymbolisedPercent(entries) : undefined;
|
|
184
263
|
if (opts.json) {
|
|
185
|
-
(0, output_js_1.printData)({
|
|
264
|
+
(0, output_js_1.printData)({
|
|
265
|
+
out,
|
|
266
|
+
durationSec: profileOpts.durationSec,
|
|
267
|
+
platform,
|
|
268
|
+
symbols: entries,
|
|
269
|
+
byDso,
|
|
270
|
+
unsymbolisedPercent: unsymPercent,
|
|
271
|
+
}, opts);
|
|
186
272
|
}
|
|
187
273
|
else {
|
|
188
274
|
(0, output_js_1.printSuccess)(`profile cpu — recorded ${profileOpts.durationSec}s → ${out}`, opts);
|
|
275
|
+
if (byDso && byDso.length > 0) {
|
|
276
|
+
// Lead with the rollup: a flat profile has no hot symbol, and the real
|
|
277
|
+
// finding is usually which library the samples are spread across.
|
|
278
|
+
console.log(`\n by library`);
|
|
279
|
+
for (const d of byDso.slice(0, 10)) {
|
|
280
|
+
console.log(` ${`${d.percent}%`.padStart(9)} ${d.dso} (${d.symbols} symbol${d.symbols === 1 ? '' : 's'}` +
|
|
281
|
+
`${d.unsymbolised ? ', unsymbolised' : ''})`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
189
284
|
if (entries) {
|
|
190
|
-
console.log(
|
|
285
|
+
console.log(`\n ${'overhead'.padStart(9)} symbol`);
|
|
191
286
|
for (const e of entries) {
|
|
192
287
|
console.log(` ${`${e.percent}%`.padStart(9)} ${e.symbol} [${e.dso}]`);
|
|
193
288
|
}
|
|
194
289
|
}
|
|
290
|
+
if (unsymPercent !== undefined && unsymPercent > 10) {
|
|
291
|
+
console.log(`\n note: ${unsymPercent}% of sampled overhead resolved to a raw address rather ` +
|
|
292
|
+
`than a function — those libraries are stripped. Read the by-library rollup above ` +
|
|
293
|
+
`instead of the symbol table; a dozen \`lib.so[+offset]\` rows are one library, ` +
|
|
294
|
+
`not a dozen findings.`);
|
|
295
|
+
}
|
|
195
296
|
}
|
|
196
297
|
return 0;
|
|
197
298
|
}
|
|
@@ -228,6 +329,37 @@ function heapGrowth(samples) {
|
|
|
228
329
|
function mb(bytes) {
|
|
229
330
|
return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
|
|
230
331
|
}
|
|
332
|
+
/**
|
|
333
|
+
* Infer collections from the heap series rather than from logcat.
|
|
334
|
+
*
|
|
335
|
+
* A heap that shrinks between two samples was collected between them — there is
|
|
336
|
+
* no other mechanism. This matters because ART's GC logging is not reliably on:
|
|
337
|
+
* measured on a Fire TV Stick (Fire OS, API 30), a window in which the Java heap
|
|
338
|
+
* fell 11.8MB produced no `art:I` lines at all. Absence of logged collections is
|
|
339
|
+
* therefore not absence of collection, and the deltas are the sounder signal.
|
|
340
|
+
*/
|
|
341
|
+
function inferCollections(samples, series = 'javaHeapBytes') {
|
|
342
|
+
const values = [];
|
|
343
|
+
for (const s of samples) {
|
|
344
|
+
const app = s.data?.app;
|
|
345
|
+
const v = app?.[series];
|
|
346
|
+
if (typeof v === 'number')
|
|
347
|
+
values.push(v);
|
|
348
|
+
}
|
|
349
|
+
if (values.length < 2)
|
|
350
|
+
return undefined;
|
|
351
|
+
let collections = 0;
|
|
352
|
+
let reclaimedBytes = 0;
|
|
353
|
+
for (let i = 1; i < values.length; i++) {
|
|
354
|
+
const drop = values[i - 1] - values[i];
|
|
355
|
+
// Ignore sampling jitter; only count drops worth a collection.
|
|
356
|
+
if (drop > 256 * 1024) {
|
|
357
|
+
collections++;
|
|
358
|
+
reclaimedBytes += drop;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return { collections, reclaimedBytes, series };
|
|
362
|
+
}
|
|
231
363
|
async function profileMemory(opts, sessionName, profileOpts) {
|
|
232
364
|
const samples = [];
|
|
233
365
|
const platform = await (0, bootstrap_js_1.detectPlatform)(sessionName).catch(() => undefined);
|
|
@@ -257,6 +389,7 @@ async function profileMemory(opts, sessionName, profileOpts) {
|
|
|
257
389
|
}
|
|
258
390
|
});
|
|
259
391
|
const growth = heapGrowth(parsed);
|
|
392
|
+
const inferredGc = isAndroid ? inferCollections(parsed) : undefined;
|
|
260
393
|
let gc;
|
|
261
394
|
if (isAndroid) {
|
|
262
395
|
const events = await (0, profile_gc_js_1.collectGcSince)(sessionName, gcSince);
|
|
@@ -264,7 +397,7 @@ async function profileMemory(opts, sessionName, profileOpts) {
|
|
|
264
397
|
gc = (0, profile_gc_js_1.summariseGc)(events);
|
|
265
398
|
}
|
|
266
399
|
if (opts.json) {
|
|
267
|
-
(0, output_js_1.printData)({ samples: parsed, durationMs: Date.now() - start, growth, gc }, opts);
|
|
400
|
+
(0, output_js_1.printData)({ samples: parsed, durationMs: Date.now() - start, growth, gc, inferredGc }, opts);
|
|
268
401
|
}
|
|
269
402
|
else {
|
|
270
403
|
console.log(`profile memory — ${samples.length} samples over ${profileOpts.trackSec}s`);
|
|
@@ -293,8 +426,20 @@ async function profileMemory(opts, sessionName, profileOpts) {
|
|
|
293
426
|
}
|
|
294
427
|
}
|
|
295
428
|
else if (isAndroid) {
|
|
296
|
-
console.log('\n GC: no ART collections logged in the window.
|
|
297
|
-
|
|
429
|
+
console.log('\n GC: no ART collections logged in the window.');
|
|
430
|
+
if (inferredGc && inferredGc.collections > 0) {
|
|
431
|
+
console.log(` But ${inferredGc.series} fell ${inferredGc.collections} time(s), reclaiming ` +
|
|
432
|
+
`${mb(inferredGc.reclaimedBytes)} — so collections did run and were not logged. ` +
|
|
433
|
+
`ART's logging is off or filtered on this build; trust the heap deltas, not logcat.`);
|
|
434
|
+
}
|
|
435
|
+
else {
|
|
436
|
+
console.log(' Absence of logged collections is not absence of collection — ART logging is ' +
|
|
437
|
+
'off or filtered on some builds. The heap deltas above are the sounder signal.');
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
if (inferredGc && inferredGc.collections > 0 && gc) {
|
|
441
|
+
console.log(` (heap deltas independently imply ${inferredGc.collections} collection(s), ` +
|
|
442
|
+
`${mb(inferredGc.reclaimedBytes)} reclaimed)`);
|
|
298
443
|
}
|
|
299
444
|
}
|
|
300
445
|
return 0;
|
|
@@ -131,7 +131,9 @@ async function discoverTarget(port, host, targetIndex) {
|
|
|
131
131
|
const data = await fetchTargets(port, host);
|
|
132
132
|
const withWs = data.filter((t) => t.webSocketDebuggerUrl);
|
|
133
133
|
if (withWs.length === 0) {
|
|
134
|
-
throw new Error('Metro
|
|
134
|
+
throw new Error('Metro is reachable but has no debugger targets. Most often the app is a release ' +
|
|
135
|
+
'build — release Hermes ships without the inspector and never connects to Metro. ' +
|
|
136
|
+
'Otherwise, check the app is running and pointed at this Metro instance.');
|
|
135
137
|
}
|
|
136
138
|
if (targetIndex !== undefined) {
|
|
137
139
|
if (targetIndex < 0 || targetIndex >= withWs.length) {
|
|
@@ -33,7 +33,17 @@ function selectDebuggerUrl(targets, opts, displayName) {
|
|
|
33
33
|
const host = opts.host ?? 'localhost';
|
|
34
34
|
const withWs = targets.filter((t) => t.webSocketDebuggerUrl);
|
|
35
35
|
if (withWs.length === 0) {
|
|
36
|
-
|
|
36
|
+
// Metro is reachable but nothing registered. The usual cause is not a
|
|
37
|
+
// missing app — it is a release build: release Hermes ships without the
|
|
38
|
+
// inspector and never connects to Metro at all, so asking "is the app
|
|
39
|
+
// running?" sends people to check something that is already true.
|
|
40
|
+
throw new Error(`Metro on ${host}:${port} is reachable but has no debugger targets.\n` +
|
|
41
|
+
`Most often the app is a release build — release Hermes ships without the ` +
|
|
42
|
+
`inspector and never connects to Metro, so no amount of relaunching will help. ` +
|
|
43
|
+
`Use a debug or profiling build for anything that attaches over Metro ` +
|
|
44
|
+
`(\`profile js\`, \`profile react\`, \`debug\`, \`network\`).\n` +
|
|
45
|
+
`Otherwise: check the app is running and foreground, and that it is pointed at ` +
|
|
46
|
+
`this Metro instance rather than another one.`);
|
|
37
47
|
}
|
|
38
48
|
if (opts.targetIndex !== undefined) {
|
|
39
49
|
if (opts.targetIndex < 0 || opts.targetIndex >= withWs.length) {
|
package/package.json
CHANGED
|
@@ -18,9 +18,14 @@ Measure a running app's performance and inspect crashes.
|
|
|
18
18
|
| "Which component re-renders on every input?" | `profile react start` → interact → `profile react stop` |
|
|
19
19
|
| "Memory grows / it stutters periodically" | `profile memory --track 30` |
|
|
20
20
|
|
|
21
|
-
`profile frames
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
Only `profile frames` and `profile memory` work on a **stock release build**.
|
|
22
|
+
`profile cpu` needs `android:debuggable` or `<profileable android:shell="true"/>`
|
|
23
|
+
in the manifest; `profile js` and `profile react` attach over Metro, and release
|
|
24
|
+
Hermes ships without the inspector so there is no target to attach to.
|
|
25
|
+
|
|
26
|
+
On TV, run `profile frames --track` with `--repeat 5`. Identical captures of one
|
|
27
|
+
screen have been measured spanning 37.8-72.9% janky — a single window can invent
|
|
28
|
+
a regression or hide one, and cannot support a `--diff`.
|
|
24
29
|
|
|
25
30
|
## Frame timing & jank (Android, incl. Fire TV / Android TV)
|
|
26
31
|
|