@houwert/conductor 0.13.1 → 0.14.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.
- package/dist/android/sdk.js +149 -0
- package/dist/commands/delete-device.js +17 -5
- package/dist/commands/device-pool.js +2 -1
- package/dist/commands/download-app.js +3 -2
- package/dist/commands/hprof.js +422 -0
- package/dist/commands/install-app.js +2 -9
- package/dist/commands/list-apps.js +2 -1
- package/dist/commands/list-devices.js +27 -3
- package/dist/commands/memory.js +706 -55
- package/dist/commands/run-parallel.js +2 -1
- package/dist/commands/start-device.js +12 -14
- package/dist/commands/stop-device.js +2 -1
- package/dist/daemon/server.js +2 -0
- package/dist/daemon/web-server.js +38 -0
- package/dist/drivers/android.js +6 -3
- package/dist/drivers/bootstrap.js +48 -9
- package/dist/drivers/log-sources/android.js +6 -6
- package/dist/drivers/log-sources/metro-discovery.js +8 -2
- package/dist/drivers/web.js +16 -2
- package/dist/index.js +26 -1
- package/dist/runner.js +9 -3
- package/package.json +1 -1
package/dist/commands/memory.js
CHANGED
|
@@ -1,9 +1,28 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
2
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
6
|
exports.HELP = void 0;
|
|
4
7
|
exports.memory = memory;
|
|
5
|
-
exports.HELP = ` memory [<appId>] Show device + app memory usage
|
|
8
|
+
exports.HELP = ` memory [<appId>] Show device + app memory usage
|
|
9
|
+
--objects Include per-class object counts (iOS: heap; slower)
|
|
10
|
+
--leaks Run leak detection (iOS only; slow, can pause the app)
|
|
11
|
+
--all Shorthand for --objects --leaks
|
|
12
|
+
--top <n> Limit object/region tables (default 20)
|
|
13
|
+
--save <name> Save the report as a snapshot
|
|
14
|
+
--diff <name> Diff snapshot <name> vs current
|
|
15
|
+
--diff <name> --vs <other> Diff two saved snapshots
|
|
16
|
+
--snapshots List saved snapshots
|
|
17
|
+
--no-gc Skip the pre-measurement GC (web only)
|
|
18
|
+
--filter <regex> Filter object/class tables by name (regex)
|
|
19
|
+
--growth-only In diff output, only show positive deltas (leak-hunting)
|
|
20
|
+
--json Emit JSON instead of formatted text`;
|
|
21
|
+
const node_fs_1 = require("node:fs");
|
|
22
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
23
|
+
const node_os_1 = __importDefault(require("node:os"));
|
|
6
24
|
const runner_js_1 = require("../runner.js");
|
|
25
|
+
const sdk_js_1 = require("../android/sdk.js");
|
|
7
26
|
const session_js_1 = require("../session.js");
|
|
8
27
|
const foreground_app_js_1 = require("./foreground-app.js");
|
|
9
28
|
const output_js_1 = require("../output.js");
|
|
@@ -11,6 +30,7 @@ const bootstrap_js_1 = require("../drivers/bootstrap.js");
|
|
|
11
30
|
const ios_js_1 = require("../drivers/ios.js");
|
|
12
31
|
const android_js_1 = require("../drivers/android.js");
|
|
13
32
|
const web_js_1 = require("../drivers/web.js");
|
|
33
|
+
const hprof_js_1 = require("./hprof.js");
|
|
14
34
|
async function resolveDeviceId(sessionName) {
|
|
15
35
|
if (sessionName !== 'default')
|
|
16
36
|
return sessionName;
|
|
@@ -60,7 +80,6 @@ function parseAndroidDumpsysMeminfo(out) {
|
|
|
60
80
|
const pidMatch = out.match(/\*\* MEMINFO in pid (\d+)/);
|
|
61
81
|
const pid = pidMatch ? Number(pidMatch[1]) : undefined;
|
|
62
82
|
// App Summary block — most useful, single-line entries with KB values.
|
|
63
|
-
// e.g. " Java Heap: 12345"
|
|
64
83
|
const summary = out.match(/App Summary[\s\S]*?(?:\n\s*\n|TOTAL:)/);
|
|
65
84
|
if (summary) {
|
|
66
85
|
const block = summary[0];
|
|
@@ -85,25 +104,14 @@ function parseAndroidDumpsysMeminfo(out) {
|
|
|
85
104
|
if (totalSwap)
|
|
86
105
|
app['totalSwapBytes'] = Number(totalSwap[1]) * 1024;
|
|
87
106
|
}
|
|
88
|
-
// Fallback: TOTAL line in the main table — "TOTAL 12345 ..."
|
|
89
107
|
if (app.totalPssBytes === undefined) {
|
|
90
108
|
const total = out.match(/^\s*TOTAL\s+(\d+)/m);
|
|
91
109
|
if (total)
|
|
92
110
|
app.totalPssBytes = Number(total[1]) * 1024;
|
|
93
111
|
}
|
|
94
|
-
// Objects block:
|
|
95
|
-
// Objects
|
|
96
|
-
// Views: 3 ViewRootImpl: 1
|
|
97
|
-
// AppContexts: 2 Activities: 1
|
|
98
|
-
// Assets: 7 AssetManagers: 0
|
|
99
|
-
// Local Binders: 5 Proxy Binders: 17
|
|
100
|
-
// Parcel memory: 1 Parcel count: 4
|
|
101
|
-
// Death Recipients: 0 OpenSSL Sockets: 0
|
|
102
|
-
// WebViews: 0
|
|
103
112
|
const objSection = out.match(/Objects[\s\S]*?(?:\n\s*\n|SQL\s|DATABASES|$)/);
|
|
104
113
|
if (objSection) {
|
|
105
114
|
const text = objSection[0];
|
|
106
|
-
// Capture every "Label: number" pair (labels may have spaces).
|
|
107
115
|
const re = /([A-Za-z][A-Za-z _]*?):\s+(\d+)/g;
|
|
108
116
|
let m;
|
|
109
117
|
while ((m = re.exec(text)) !== null) {
|
|
@@ -115,9 +123,57 @@ function parseAndroidDumpsysMeminfo(out) {
|
|
|
115
123
|
}
|
|
116
124
|
return { app, objects, pid };
|
|
117
125
|
}
|
|
118
|
-
|
|
126
|
+
function parseAndroidUnreachable(out) {
|
|
127
|
+
// dumpsys meminfo --unreachable <pid> output:
|
|
128
|
+
// Unreachable memory:
|
|
129
|
+
// 1234 bytes in 5 unreachable allocations
|
|
130
|
+
// ABI: 'arm64'
|
|
131
|
+
//
|
|
132
|
+
// 192 bytes unreachable at 12abcdef
|
|
133
|
+
// first 32 bytes of contents:
|
|
134
|
+
// ...
|
|
135
|
+
// #00 pc 000... /system/lib64/libc.so (malloc+24)
|
|
136
|
+
// #01 pc 000... /data/app/.../lib/libfoo.so (foo_init+32)
|
|
137
|
+
const summary = out.match(/(\d+)\s+bytes?\s+in\s+(\d+)\s+unreachable\s+allocations?/i);
|
|
138
|
+
const totalBytes = summary ? Number(summary[1]) : 0;
|
|
139
|
+
const totalCount = summary ? Number(summary[2]) : 0;
|
|
140
|
+
// Aggregate by the most-specific (last user) library frame in each backtrace.
|
|
141
|
+
// System libs (libc, liblog, libart) are usually the immediate allocator —
|
|
142
|
+
// the user library a frame or two up tells us what's actually leaking.
|
|
143
|
+
const byClass = new Map();
|
|
144
|
+
const blocks = out.split(/(?=^\s*\d+\s+bytes?\s+unreachable\s+at)/m);
|
|
145
|
+
for (const block of blocks) {
|
|
146
|
+
const head = block.match(/^\s*(\d+)\s+bytes?\s+unreachable\s+at/m);
|
|
147
|
+
if (!head)
|
|
148
|
+
continue;
|
|
149
|
+
const size = Number(head[1]);
|
|
150
|
+
// Find first non-libc/-liblog/-libart frame in the backtrace.
|
|
151
|
+
const frames = [...block.matchAll(/#\d+\s+pc\s+\S+\s+(\/\S+?\.so)\s+\(([^)+]+)/g)];
|
|
152
|
+
let owner = '<unknown>';
|
|
153
|
+
for (const f of frames) {
|
|
154
|
+
const lib = f[1];
|
|
155
|
+
const sym = f[2];
|
|
156
|
+
if (/lib(c|m|art|log|dl|cutils)\.so$/.test(lib))
|
|
157
|
+
continue;
|
|
158
|
+
owner = `${sym} [${lib.split('/').pop()}]`;
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
if (owner === '<unknown>' && frames[0]) {
|
|
162
|
+
owner = `${frames[0][2]} [${frames[0][1].split('/').pop()}]`;
|
|
163
|
+
}
|
|
164
|
+
const prev = byClass.get(owner) ?? { count: 0, bytes: 0 };
|
|
165
|
+
prev.count += 1;
|
|
166
|
+
prev.bytes += size;
|
|
167
|
+
byClass.set(owner, prev);
|
|
168
|
+
}
|
|
169
|
+
const classes = [...byClass.entries()]
|
|
170
|
+
.map(([cls, v]) => ({ class: cls, count: v.count, bytes: v.bytes }))
|
|
171
|
+
.sort((a, b) => b.bytes - a.bytes);
|
|
172
|
+
return { totalCount, totalBytes, classes };
|
|
173
|
+
}
|
|
174
|
+
async function collectAndroid(deviceId, appId, opts) {
|
|
119
175
|
const report = { platform: 'android', deviceId, appId, notes: [] };
|
|
120
|
-
const meminfo = await (0, runner_js_1.spawnCommand)('adb', ['-s', deviceId, 'shell', 'cat', '/proc/meminfo']);
|
|
176
|
+
const meminfo = await (0, runner_js_1.spawnCommand)((0, sdk_js_1.resolveAndroidTool)('adb'), ['-s', deviceId, 'shell', 'cat', '/proc/meminfo'], { env: (0, sdk_js_1.androidSpawnEnv)() });
|
|
121
177
|
if (meminfo.success) {
|
|
122
178
|
const sys = parseAndroidMeminfo(meminfo.stdout);
|
|
123
179
|
report.system = {
|
|
@@ -131,7 +187,7 @@ async function collectAndroid(deviceId, appId) {
|
|
|
131
187
|
report.notes.push(`/proc/meminfo unavailable: ${meminfo.stderr.trim()}`);
|
|
132
188
|
}
|
|
133
189
|
if (appId) {
|
|
134
|
-
const dump = await (0, runner_js_1.spawnCommand)('adb', ['-s', deviceId, 'shell', 'dumpsys', 'meminfo', appId]);
|
|
190
|
+
const dump = await (0, runner_js_1.spawnCommand)((0, sdk_js_1.resolveAndroidTool)('adb'), ['-s', deviceId, 'shell', 'dumpsys', 'meminfo', appId], { env: (0, sdk_js_1.androidSpawnEnv)() });
|
|
135
191
|
if (dump.success && !dump.stdout.includes('No process found')) {
|
|
136
192
|
const { app, objects, pid } = parseAndroidDumpsysMeminfo(dump.stdout);
|
|
137
193
|
report.app = app;
|
|
@@ -141,6 +197,80 @@ async function collectAndroid(deviceId, appId) {
|
|
|
141
197
|
else {
|
|
142
198
|
report.notes.push(`dumpsys meminfo ${appId} returned no data — app may not be running`);
|
|
143
199
|
}
|
|
200
|
+
// --leaks → dumpsys meminfo --unreachable <pid>. Requires root on stock
|
|
201
|
+
// images; on user builds it returns "Unreachable memory check not supported".
|
|
202
|
+
if (opts.leaks && report.pid) {
|
|
203
|
+
const unreach = await (0, runner_js_1.spawnCommand)('adb', [
|
|
204
|
+
'-s',
|
|
205
|
+
deviceId,
|
|
206
|
+
'shell',
|
|
207
|
+
'dumpsys',
|
|
208
|
+
'meminfo',
|
|
209
|
+
'--unreachable',
|
|
210
|
+
String(report.pid),
|
|
211
|
+
]);
|
|
212
|
+
if (unreach.success && unreach.stdout.includes('Unreachable memory:')) {
|
|
213
|
+
report.leaks = parseAndroidUnreachable(unreach.stdout);
|
|
214
|
+
}
|
|
215
|
+
else if (unreach.stdout.includes('not supported')) {
|
|
216
|
+
report.notes.push('dumpsys --unreachable not supported on this build (needs userdebug/root).');
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
report.notes.push(`--unreachable unavailable: ${unreach.stderr.trim() || 'no output'}`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
// --objects → trigger `am dumpheap` and pull the .hprof. We don't parse
|
|
223
|
+
// HPROF binary; the file is meant to be opened in Android Studio's Memory
|
|
224
|
+
// Profiler. The path is recorded so it shows up in the report notes.
|
|
225
|
+
if (opts.objects && report.pid) {
|
|
226
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
227
|
+
const remote = `/data/local/tmp/conductor-${appId}-${ts}.hprof`;
|
|
228
|
+
const dumpRes = await (0, runner_js_1.spawnCommand)('adb', [
|
|
229
|
+
'-s',
|
|
230
|
+
deviceId,
|
|
231
|
+
'shell',
|
|
232
|
+
'am',
|
|
233
|
+
'dumpheap',
|
|
234
|
+
String(report.pid),
|
|
235
|
+
remote,
|
|
236
|
+
]);
|
|
237
|
+
if (dumpRes.success) {
|
|
238
|
+
await new Promise((r) => setTimeout(r, 1500)); // dumpheap is async; wait for flush
|
|
239
|
+
const localDir = node_path_1.default.join(node_os_1.default.homedir(), '.conductor', 'heap-dumps');
|
|
240
|
+
await node_fs_1.promises.mkdir(localDir, { recursive: true });
|
|
241
|
+
const localFile = node_path_1.default.join(localDir, node_path_1.default.basename(remote));
|
|
242
|
+
const pull = await (0, runner_js_1.spawnCommand)('adb', ['-s', deviceId, 'pull', remote, localFile]);
|
|
243
|
+
if (pull.success) {
|
|
244
|
+
await (0, runner_js_1.spawnCommand)('adb', ['-s', deviceId, 'shell', 'rm', remote]);
|
|
245
|
+
// Parse the HPROF binary for per-class instance counts/bytes — same
|
|
246
|
+
// shape as iOS `heap` and Web V8 snapshot output, so it slots into
|
|
247
|
+
// the existing snapshot/diff workflow.
|
|
248
|
+
try {
|
|
249
|
+
const buf = await node_fs_1.promises.readFile(localFile);
|
|
250
|
+
const { classes, totals, heaps } = (0, hprof_js_1.parseHprof)(buf);
|
|
251
|
+
if (classes.length > 0)
|
|
252
|
+
report.objectClasses = classes;
|
|
253
|
+
report.heapTotals = totals;
|
|
254
|
+
const heapsNote = heaps
|
|
255
|
+
? ' ' +
|
|
256
|
+
Object.entries(heaps)
|
|
257
|
+
.map(([h, v]) => `${h}: ${v.count.toLocaleString()} obj / ${(v.bytes / 1048576).toFixed(1)} MB`)
|
|
258
|
+
.join(', ')
|
|
259
|
+
: '';
|
|
260
|
+
report.notes.push(`Heap dump saved → ${localFile}${heapsNote} (also openable in Android Studio: Profiler → Memory → Import Heap Dump)`);
|
|
261
|
+
}
|
|
262
|
+
catch (err) {
|
|
263
|
+
report.notes.push(`Heap dump saved → ${localFile} but parse failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
report.notes.push(`adb pull heap dump failed: ${pull.stderr.trim()}`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
report.notes.push(`am dumpheap failed: ${dumpRes.stderr.trim()}`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
144
274
|
}
|
|
145
275
|
if (report.notes.length === 0)
|
|
146
276
|
delete report.notes;
|
|
@@ -148,7 +278,6 @@ async function collectAndroid(deviceId, appId) {
|
|
|
148
278
|
}
|
|
149
279
|
// ── iOS / tvOS ────────────────────────────────────────────────────────────────
|
|
150
280
|
function parseVmStat(out) {
|
|
151
|
-
// vm_stat output uses "page size of 16384 bytes" and counts in pages.
|
|
152
281
|
const pageMatch = out.match(/page size of (\d+)/);
|
|
153
282
|
const pageSize = pageMatch ? Number(pageMatch[1]) : 4096;
|
|
154
283
|
const grab = (label) => {
|
|
@@ -188,23 +317,40 @@ async function findIOSPid(deviceId, appId) {
|
|
|
188
317
|
}
|
|
189
318
|
return undefined;
|
|
190
319
|
}
|
|
320
|
+
function parseFootprint(out) {
|
|
321
|
+
// Header: "Plex [15843]: 64-bit Footprint: 801 MB (16384 bytes per page)"
|
|
322
|
+
// Values can be "B", "KB", "MB", "GB" with a space between number and unit.
|
|
323
|
+
const numUnit = '([\\d.]+\\s*[KMGT]?B)';
|
|
324
|
+
const header = out.match(new RegExp(`Footprint:\\s+${numUnit}`));
|
|
325
|
+
const footprintBytes = header ? humanToBytes(header[1].replace(/\s+/g, '')) : undefined;
|
|
326
|
+
// TOTAL line: " 801 MB 70 MB 4960 KB 5577 TOTAL"
|
|
327
|
+
// Columns: Dirty | Clean | Reclaimable | Regions | "TOTAL"
|
|
328
|
+
const total = out.match(new RegExp(`^\\s*${numUnit}\\s+${numUnit}\\s+${numUnit}\\s+\\d+\\s+TOTAL\\s*$`, 'm'));
|
|
329
|
+
const dirtyBytes = total ? humanToBytes(total[1].replace(/\s+/g, '')) : undefined;
|
|
330
|
+
return { footprintBytes, dirtyBytes };
|
|
331
|
+
}
|
|
191
332
|
function parseVmmapSummary(out) {
|
|
192
333
|
const regions = {};
|
|
193
334
|
const app = {};
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
//
|
|
335
|
+
// Find the per-region body. Layout:
|
|
336
|
+
// VIRTUAL RESIDENT DIRTY ...
|
|
337
|
+
// REGION TYPE SIZE SIZE SIZE ...
|
|
338
|
+
// =========== ======= ======== ===== ...
|
|
339
|
+
// Accelerate framework 128K 128K 128K ...
|
|
340
|
+
// ...
|
|
341
|
+
// TOTAL 5.2G 1.4G 950M ...
|
|
197
342
|
const headerIdx = out.indexOf('REGION TYPE');
|
|
198
343
|
if (headerIdx === -1)
|
|
199
344
|
return { app, regions };
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
for (
|
|
203
|
-
const line =
|
|
204
|
-
|
|
345
|
+
const lines = out.slice(headerIdx).split('\n');
|
|
346
|
+
// Skip the REGION TYPE header line and the ====== separator that follows.
|
|
347
|
+
for (let i = 1; i < lines.length; i++) {
|
|
348
|
+
const line = lines[i].trimEnd();
|
|
349
|
+
const trimmed = line.trim();
|
|
350
|
+
if (!trimmed)
|
|
205
351
|
continue;
|
|
206
|
-
if (
|
|
207
|
-
|
|
352
|
+
if (trimmed.startsWith('='))
|
|
353
|
+
continue; // separator row
|
|
208
354
|
if (line.startsWith('TOTAL')) {
|
|
209
355
|
// "TOTAL 5.2G 1.4G 950M ..."
|
|
210
356
|
const m = line.match(/TOTAL\s+\S+\s+(\S+)/);
|
|
@@ -221,13 +367,12 @@ function parseVmmapSummary(out) {
|
|
|
221
367
|
if (resident !== undefined)
|
|
222
368
|
regions[name] = resident;
|
|
223
369
|
}
|
|
224
|
-
// Roll up a few well-known regions into the canonical fields.
|
|
225
370
|
app.nativeHeapBytes = regions['MALLOC'] ?? regions['MALLOC_NANO'];
|
|
226
371
|
app.stackBytes = regions['Stack'];
|
|
227
372
|
return { app, regions };
|
|
228
373
|
}
|
|
229
374
|
function humanToBytes(s) {
|
|
230
|
-
// Matches "1.2G", "234.5M", "950K", "1024", "1.2GB" etc.
|
|
375
|
+
// Matches "1.2G", "234.5M", "950K", "1024", "1.2GB", "0K" etc.
|
|
231
376
|
const m = s.match(/^([\d.]+)\s*([KMGT]?)B?$/i);
|
|
232
377
|
if (!m)
|
|
233
378
|
return undefined;
|
|
@@ -243,10 +388,104 @@ function humanToBytes(s) {
|
|
|
243
388
|
};
|
|
244
389
|
return Math.round(n * (mult[m[2].toUpperCase()] ?? 1));
|
|
245
390
|
}
|
|
246
|
-
|
|
391
|
+
// ── iOS heap (per-class object counts) ────────────────────────────────────────
|
|
392
|
+
function parseHeap(out) {
|
|
393
|
+
const classes = [];
|
|
394
|
+
let totals;
|
|
395
|
+
// Locate the "All zones: N nodes (B bytes)" totals line.
|
|
396
|
+
const totalsMatch = out.match(/All zones:\s+(\d+)\s+nodes\s+\((\d+)\s+bytes\)/);
|
|
397
|
+
if (totalsMatch) {
|
|
398
|
+
totals = { count: Number(totalsMatch[1]), bytes: Number(totalsMatch[2]) };
|
|
399
|
+
}
|
|
400
|
+
// Find the table header row. Heap prints:
|
|
401
|
+
// COUNT BYTES AVG CLASS_NAME ... TYPE BINARY
|
|
402
|
+
// ===== ===== === ========== ... ==== ======
|
|
403
|
+
// 549284 450043193 819.3 non-object
|
|
404
|
+
// 33305 1813840 54.5 CFString ObjC CoreFoundation
|
|
405
|
+
const headerIdx = out.search(/^\s*COUNT\s+BYTES\s+AVG\s+CLASS_NAME/m);
|
|
406
|
+
if (headerIdx === -1)
|
|
407
|
+
return { classes, totals };
|
|
408
|
+
const body = out.slice(headerIdx).split('\n').slice(2); // skip header + ===
|
|
409
|
+
for (const raw of body) {
|
|
410
|
+
const line = raw.trimEnd();
|
|
411
|
+
const trimmed = line.trim();
|
|
412
|
+
if (!trimmed)
|
|
413
|
+
break; // blank line ends the table
|
|
414
|
+
if (trimmed.startsWith('='))
|
|
415
|
+
continue;
|
|
416
|
+
// Match: leading count, bytes, avg, then the rest. CLASS_NAME / TYPE / BINARY
|
|
417
|
+
// are space-separated but the class name itself can contain spaces and angle
|
|
418
|
+
// brackets, so split off the trailing TYPE+BINARY columns from the right.
|
|
419
|
+
const m = line.match(/^\s*(\d+)\s+(\d+)\s+([\d.]+)\s+(.*)$/);
|
|
420
|
+
if (!m)
|
|
421
|
+
continue;
|
|
422
|
+
const count = Number(m[1]);
|
|
423
|
+
const bytes = Number(m[2]);
|
|
424
|
+
const rest = m[4];
|
|
425
|
+
// Trailing two whitespace-separated tokens are TYPE and BINARY (BINARY may
|
|
426
|
+
// be missing for "non-object" entries). Detect by looking at the last 1-2
|
|
427
|
+
// tokens; if the last token looks like a known TYPE, BINARY is absent.
|
|
428
|
+
const KNOWN_TYPES = new Set(['ObjC', 'Swift', 'C', 'C++', 'CFType']);
|
|
429
|
+
let className = rest;
|
|
430
|
+
let type;
|
|
431
|
+
let binary;
|
|
432
|
+
// Try: "<class> TYPE BINARY"
|
|
433
|
+
const trail2 = rest.match(/^(.*?)\s{2,}(\S+)\s+(\S+)\s*$/);
|
|
434
|
+
if (trail2 && KNOWN_TYPES.has(trail2[2])) {
|
|
435
|
+
className = trail2[1].trim();
|
|
436
|
+
type = trail2[2];
|
|
437
|
+
binary = trail2[3];
|
|
438
|
+
}
|
|
439
|
+
else {
|
|
440
|
+
// Try: "<class> TYPE" (no binary)
|
|
441
|
+
const trail1 = rest.match(/^(.*?)\s{2,}(\S+)\s*$/);
|
|
442
|
+
if (trail1 && KNOWN_TYPES.has(trail1[2])) {
|
|
443
|
+
className = trail1[1].trim();
|
|
444
|
+
type = trail1[2];
|
|
445
|
+
}
|
|
446
|
+
else {
|
|
447
|
+
className = rest.trim();
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
if (!className)
|
|
451
|
+
continue;
|
|
452
|
+
classes.push({ class: className, count, bytes, type, binary });
|
|
453
|
+
}
|
|
454
|
+
classes.sort((a, b) => b.bytes - a.bytes);
|
|
455
|
+
return { classes, totals };
|
|
456
|
+
}
|
|
457
|
+
// ── iOS leaks ─────────────────────────────────────────────────────────────────
|
|
458
|
+
function parseLeaks(out) {
|
|
459
|
+
// `leaks` prints e.g.:
|
|
460
|
+
// Process 12345: 42 leaks for 16384 total leaked bytes.
|
|
461
|
+
// Followed by per-leak detail lines:
|
|
462
|
+
// Leak: 0x12345 size=128 zone: ... Class: NSConcreteData
|
|
463
|
+
// We aggregate by class.
|
|
464
|
+
const summary = out.match(/(\d+)\s+leaks?\s+for\s+(\d+)\s+total\s+leaked\s+bytes/i);
|
|
465
|
+
const totalCount = summary ? Number(summary[1]) : 0;
|
|
466
|
+
const totalBytes = summary ? Number(summary[2]) : 0;
|
|
467
|
+
const byClass = new Map();
|
|
468
|
+
const re = /Leak:\s*0x[0-9a-f]+\s+size=(\d+)[^\n]*?(?:\s(?:Class|Type):\s*(\S+))?/gi;
|
|
469
|
+
let m;
|
|
470
|
+
while ((m = re.exec(out)) !== null) {
|
|
471
|
+
const size = Number(m[1]);
|
|
472
|
+
const cls = m[2] ?? '<unknown>';
|
|
473
|
+
const prev = byClass.get(cls) ?? { count: 0, bytes: 0 };
|
|
474
|
+
prev.count += 1;
|
|
475
|
+
prev.bytes += size;
|
|
476
|
+
byClass.set(cls, prev);
|
|
477
|
+
}
|
|
478
|
+
const classes = [...byClass.entries()]
|
|
479
|
+
.map(([cls, v]) => ({ class: cls, count: v.count, bytes: v.bytes }))
|
|
480
|
+
.sort((a, b) => b.bytes - a.bytes);
|
|
481
|
+
return { totalCount, totalBytes, classes };
|
|
482
|
+
}
|
|
483
|
+
async function collectIOS(deviceId, platform, appId, opts) {
|
|
247
484
|
const report = { platform, deviceId, appId, notes: [] };
|
|
248
|
-
// System-wide memory: vm_stat from
|
|
249
|
-
|
|
485
|
+
// System-wide memory: vm_stat from the host. Simulators share host RAM, and
|
|
486
|
+
// `vm_stat` is a host macOS binary — it isn't present inside the simulator
|
|
487
|
+
// runtime, so `xcrun simctl spawn <id> vm_stat` fails with ENOENT.
|
|
488
|
+
const vm = await (0, runner_js_1.spawnCommand)('vm_stat', []);
|
|
250
489
|
if (vm.success) {
|
|
251
490
|
const sys = parseVmStat(vm.stdout);
|
|
252
491
|
report.system = {
|
|
@@ -286,7 +525,19 @@ async function collectIOS(deviceId, platform, appId) {
|
|
|
286
525
|
};
|
|
287
526
|
}
|
|
288
527
|
}
|
|
289
|
-
//
|
|
528
|
+
// footprint — phys footprint + dirty memory totals. This is the actionable
|
|
529
|
+
// OOM number on iOS (jetsam compares this against the per-app limit), so
|
|
530
|
+
// surface it before vmmap region detail.
|
|
531
|
+
const fp = await (0, runner_js_1.spawnCommand)('footprint', [String(pid)]);
|
|
532
|
+
if (fp.success) {
|
|
533
|
+
const { footprintBytes, dirtyBytes } = parseFootprint(fp.stdout);
|
|
534
|
+
report.app = {
|
|
535
|
+
...(report.app ?? {}),
|
|
536
|
+
...(footprintBytes !== undefined ? { footprintBytes } : {}),
|
|
537
|
+
...(dirtyBytes !== undefined ? { dirtyBytes } : {}),
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
// vmmap -summary for region breakdown.
|
|
290
541
|
const vmmap = await (0, runner_js_1.spawnCommand)('vmmap', ['-summary', String(pid)]);
|
|
291
542
|
if (vmmap.success) {
|
|
292
543
|
const { app, regions } = parseVmmapSummary(vmmap.stdout);
|
|
@@ -297,14 +548,91 @@ async function collectIOS(deviceId, platform, appId) {
|
|
|
297
548
|
};
|
|
298
549
|
}
|
|
299
550
|
else {
|
|
300
|
-
report.notes.push(
|
|
551
|
+
report.notes.push(`vmmap unavailable: ${vmmap.stderr.trim() || 'unknown error'}`);
|
|
552
|
+
}
|
|
553
|
+
// heap for per-class object counts/bytes.
|
|
554
|
+
if (opts.objects) {
|
|
555
|
+
const h = await (0, runner_js_1.spawnCommand)('heap', [String(pid)]);
|
|
556
|
+
if (h.success) {
|
|
557
|
+
const { classes, totals } = parseHeap(h.stdout);
|
|
558
|
+
if (classes.length > 0)
|
|
559
|
+
report.objectClasses = classes;
|
|
560
|
+
if (totals)
|
|
561
|
+
report.heapTotals = totals;
|
|
562
|
+
}
|
|
563
|
+
else {
|
|
564
|
+
report.notes.push(`heap unavailable: ${h.stderr.trim() || 'unknown error'}`);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
// leaks for leak detection.
|
|
568
|
+
if (opts.leaks) {
|
|
569
|
+
const l = await (0, runner_js_1.spawnCommand)('leaks', [String(pid)]);
|
|
570
|
+
// `leaks` exits non-zero when leaks are found; treat any output as success.
|
|
571
|
+
if (l.stdout && l.stdout.includes('leaks for')) {
|
|
572
|
+
report.leaks = parseLeaks(l.stdout);
|
|
573
|
+
}
|
|
574
|
+
else if (l.success) {
|
|
575
|
+
report.leaks = { totalCount: 0, totalBytes: 0, classes: [] };
|
|
576
|
+
}
|
|
577
|
+
else {
|
|
578
|
+
report.notes.push(`leaks unavailable: ${l.stderr.trim() || 'unknown error'}`);
|
|
579
|
+
}
|
|
301
580
|
}
|
|
302
581
|
if (report.notes.length === 0)
|
|
303
582
|
delete report.notes;
|
|
304
583
|
return report;
|
|
305
584
|
}
|
|
306
|
-
|
|
307
|
-
|
|
585
|
+
function parseV8HeapSnapshot(snap) {
|
|
586
|
+
const meta = snap.snapshot.meta;
|
|
587
|
+
const nf = meta.node_fields;
|
|
588
|
+
const stride = nf.length;
|
|
589
|
+
const typeIdx = nf.indexOf('type');
|
|
590
|
+
const nameIdx = nf.indexOf('name');
|
|
591
|
+
const sizeIdx = nf.indexOf('self_size');
|
|
592
|
+
const typeNames = meta.node_types[0]; // e.g. ["hidden","array","string","object","code","closure","regexp","number","native","synthetic","concatenated string","sliced string","symbol","bigint"]
|
|
593
|
+
const nodes = snap.nodes;
|
|
594
|
+
const strings = snap.strings;
|
|
595
|
+
// For "object", "closure", "native" → name is the constructor / function /
|
|
596
|
+
// C++ class name. For other types, group under "<type>".
|
|
597
|
+
const namedTypes = new Set(['object', 'closure', 'native']);
|
|
598
|
+
const namedTypeIds = new Set();
|
|
599
|
+
typeNames.forEach((t, i) => {
|
|
600
|
+
if (namedTypes.has(t))
|
|
601
|
+
namedTypeIds.add(i);
|
|
602
|
+
});
|
|
603
|
+
const byClass = new Map();
|
|
604
|
+
let totalCount = 0;
|
|
605
|
+
let totalBytes = 0;
|
|
606
|
+
for (let i = 0; i < nodes.length; i += stride) {
|
|
607
|
+
const t = nodes[i + typeIdx];
|
|
608
|
+
const size = nodes[i + sizeIdx];
|
|
609
|
+
const nameId = nodes[i + nameIdx];
|
|
610
|
+
totalCount++;
|
|
611
|
+
totalBytes += size;
|
|
612
|
+
let label;
|
|
613
|
+
if (namedTypeIds.has(t)) {
|
|
614
|
+
const tName = typeNames[t];
|
|
615
|
+
const ctor = strings[nameId] || '<anonymous>';
|
|
616
|
+
label = tName === 'object' ? ctor : `${ctor} [${tName}]`;
|
|
617
|
+
}
|
|
618
|
+
else {
|
|
619
|
+
label = `<${typeNames[t] ?? 'unknown'}>`;
|
|
620
|
+
}
|
|
621
|
+
const prev = byClass.get(label);
|
|
622
|
+
if (prev) {
|
|
623
|
+
prev.count++;
|
|
624
|
+
prev.bytes += size;
|
|
625
|
+
}
|
|
626
|
+
else {
|
|
627
|
+
byClass.set(label, { count: 1, bytes: size });
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
const classes = [...byClass.entries()]
|
|
631
|
+
.map(([cls, v]) => ({ class: cls, count: v.count, bytes: v.bytes }))
|
|
632
|
+
.sort((a, b) => b.bytes - a.bytes);
|
|
633
|
+
return { classes, totals: { count: totalCount, bytes: totalBytes } };
|
|
634
|
+
}
|
|
635
|
+
async function collectWeb(deviceId, sessionName, opts) {
|
|
308
636
|
const report = { platform: 'web', deviceId, notes: [] };
|
|
309
637
|
let driver;
|
|
310
638
|
try {
|
|
@@ -332,11 +660,8 @@ async function collectWeb(deviceId, sessionName) {
|
|
|
332
660
|
const m = data.metrics;
|
|
333
661
|
const pm = data.pageMemory;
|
|
334
662
|
report.app = {
|
|
335
|
-
// Heap totals — prefer page-context performance.memory (Chromium) over
|
|
336
|
-
// CDP's JSHeapUsedSize (which can lag). Both are JS heap only.
|
|
337
663
|
nativeHeapBytes: pm?.usedJSHeapSize ?? m['JSHeapUsedSize'],
|
|
338
664
|
codeBytes: m['JSHeapTotalSize'] ? m['JSHeapTotalSize'] - (m['JSHeapUsedSize'] ?? 0) : undefined,
|
|
339
|
-
// Roll the raw CDP metrics into `regions` so they're inspectable verbatim.
|
|
340
665
|
regions: { ...m },
|
|
341
666
|
};
|
|
342
667
|
if (pm) {
|
|
@@ -347,7 +672,6 @@ async function collectWeb(deviceId, sessionName) {
|
|
|
347
672
|
'JS Heap Limit': pm.jsHeapSizeLimit,
|
|
348
673
|
};
|
|
349
674
|
}
|
|
350
|
-
// Object counts — direct CDP equivalents of Android's "Views/Activities/Binders".
|
|
351
675
|
const objectKeys = [
|
|
352
676
|
'Nodes',
|
|
353
677
|
'Documents',
|
|
@@ -363,31 +687,228 @@ async function collectWeb(deviceId, sessionName) {
|
|
|
363
687
|
}
|
|
364
688
|
if (Object.keys(objects).length > 0)
|
|
365
689
|
report.objects = objects;
|
|
690
|
+
// --objects → take a real V8 heap snapshot, parse class counts/bytes, and
|
|
691
|
+
// save the .heapsnapshot file so it can be opened in Chrome DevTools.
|
|
692
|
+
if (opts.objects) {
|
|
693
|
+
try {
|
|
694
|
+
const snapText = await driver.heapSnapshot({ gc: opts.gc !== false });
|
|
695
|
+
const snap = JSON.parse(snapText);
|
|
696
|
+
const { classes, totals } = parseV8HeapSnapshot(snap);
|
|
697
|
+
if (classes.length > 0)
|
|
698
|
+
report.objectClasses = classes;
|
|
699
|
+
report.heapTotals = totals;
|
|
700
|
+
const dumpsDir = node_path_1.default.join(node_os_1.default.homedir(), '.conductor', 'heap-dumps');
|
|
701
|
+
await node_fs_1.promises.mkdir(dumpsDir, { recursive: true });
|
|
702
|
+
const safeUrl = (data.url || 'page').replace(/[^\w.-]+/g, '_').slice(0, 60);
|
|
703
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
704
|
+
const file = node_path_1.default.join(dumpsDir, `${safeUrl}-${ts}.heapsnapshot`);
|
|
705
|
+
await node_fs_1.promises.writeFile(file, snapText);
|
|
706
|
+
report.notes.push(`Heap snapshot saved → ${file} (open in Chrome DevTools: Memory → Load profile)`);
|
|
707
|
+
}
|
|
708
|
+
catch (err) {
|
|
709
|
+
report.notes.push(`Heap snapshot failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
366
712
|
report.notes.push('Web memory is per-page (Performance.getMetrics + performance.memory). Run-wide RSS for the browser process is not exposed via CDP.');
|
|
367
713
|
if (report.notes.length === 0)
|
|
368
714
|
delete report.notes;
|
|
369
715
|
return report;
|
|
370
716
|
}
|
|
717
|
+
// ── Snapshot save / load / diff ───────────────────────────────────────────────
|
|
718
|
+
function snapshotsDir() {
|
|
719
|
+
return node_path_1.default.join(node_os_1.default.homedir(), '.conductor', 'memory-snapshots');
|
|
720
|
+
}
|
|
721
|
+
function snapshotPath(name) {
|
|
722
|
+
// Allow callers to pass either a bare name or a filename / path.
|
|
723
|
+
if (name.endsWith('.json') || name.includes('/'))
|
|
724
|
+
return name;
|
|
725
|
+
return node_path_1.default.join(snapshotsDir(), `${name}.json`);
|
|
726
|
+
}
|
|
727
|
+
async function saveSnapshot(name, report) {
|
|
728
|
+
const dir = snapshotsDir();
|
|
729
|
+
await node_fs_1.promises.mkdir(dir, { recursive: true });
|
|
730
|
+
const file = snapshotPath(name);
|
|
731
|
+
await node_fs_1.promises.writeFile(file, JSON.stringify(report, null, 2));
|
|
732
|
+
return file;
|
|
733
|
+
}
|
|
734
|
+
async function loadSnapshot(name) {
|
|
735
|
+
const file = snapshotPath(name);
|
|
736
|
+
const raw = await node_fs_1.promises.readFile(file, 'utf8');
|
|
737
|
+
return JSON.parse(raw);
|
|
738
|
+
}
|
|
739
|
+
async function listSnapshots() {
|
|
740
|
+
const dir = snapshotsDir();
|
|
741
|
+
let files;
|
|
742
|
+
try {
|
|
743
|
+
files = await node_fs_1.promises.readdir(dir);
|
|
744
|
+
}
|
|
745
|
+
catch {
|
|
746
|
+
return [];
|
|
747
|
+
}
|
|
748
|
+
const out = [];
|
|
749
|
+
for (const f of files) {
|
|
750
|
+
if (!f.endsWith('.json'))
|
|
751
|
+
continue;
|
|
752
|
+
const file = node_path_1.default.join(dir, f);
|
|
753
|
+
try {
|
|
754
|
+
const stat = await node_fs_1.promises.stat(file);
|
|
755
|
+
const r = JSON.parse(await node_fs_1.promises.readFile(file, 'utf8'));
|
|
756
|
+
out.push({
|
|
757
|
+
name: f.replace(/\.json$/, ''),
|
|
758
|
+
capturedAt: r.capturedAt,
|
|
759
|
+
appId: r.appId,
|
|
760
|
+
platform: r.platform,
|
|
761
|
+
size: stat.size,
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
catch {
|
|
765
|
+
/* skip malformed */
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
out.sort((a, b) => (a.capturedAt ?? '').localeCompare(b.capturedAt ?? ''));
|
|
769
|
+
return out;
|
|
770
|
+
}
|
|
771
|
+
function diffNumberMap(a, b) {
|
|
772
|
+
const keys = new Set([...Object.keys(a ?? {}), ...Object.keys(b ?? {})]);
|
|
773
|
+
const out = [];
|
|
774
|
+
for (const k of keys) {
|
|
775
|
+
const before = a?.[k] ?? 0;
|
|
776
|
+
const after = b?.[k] ?? 0;
|
|
777
|
+
if (before === after)
|
|
778
|
+
continue;
|
|
779
|
+
out.push({ key: k, before, after, delta: after - before });
|
|
780
|
+
}
|
|
781
|
+
out.sort((x, y) => Math.abs(y.delta) - Math.abs(x.delta));
|
|
782
|
+
return out;
|
|
783
|
+
}
|
|
784
|
+
function diffClasses(a, b) {
|
|
785
|
+
const idx = (arr) => {
|
|
786
|
+
const m = new Map();
|
|
787
|
+
for (const c of arr ?? [])
|
|
788
|
+
m.set(c.class, c);
|
|
789
|
+
return m;
|
|
790
|
+
};
|
|
791
|
+
const A = idx(a);
|
|
792
|
+
const B = idx(b);
|
|
793
|
+
const keys = new Set([...A.keys(), ...B.keys()]);
|
|
794
|
+
const out = [];
|
|
795
|
+
for (const k of keys) {
|
|
796
|
+
const x = A.get(k);
|
|
797
|
+
const y = B.get(k);
|
|
798
|
+
const beforeCount = x?.count ?? 0;
|
|
799
|
+
const afterCount = y?.count ?? 0;
|
|
800
|
+
const beforeBytes = x?.bytes ?? 0;
|
|
801
|
+
const afterBytes = y?.bytes ?? 0;
|
|
802
|
+
if (beforeCount === afterCount && beforeBytes === afterBytes)
|
|
803
|
+
continue;
|
|
804
|
+
out.push({
|
|
805
|
+
class: k,
|
|
806
|
+
beforeCount,
|
|
807
|
+
afterCount,
|
|
808
|
+
beforeBytes,
|
|
809
|
+
afterBytes,
|
|
810
|
+
deltaCount: afterCount - beforeCount,
|
|
811
|
+
deltaBytes: afterBytes - beforeBytes,
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
out.sort((x, y) => Math.abs(y.deltaBytes) - Math.abs(x.deltaBytes));
|
|
815
|
+
return out;
|
|
816
|
+
}
|
|
817
|
+
function buildDiff(a, b, aLabel, bLabel) {
|
|
818
|
+
const numericApp = (r) => {
|
|
819
|
+
const out = {};
|
|
820
|
+
for (const [k, v] of Object.entries(r.app ?? {})) {
|
|
821
|
+
if (typeof v === 'number')
|
|
822
|
+
out[k] = v;
|
|
823
|
+
}
|
|
824
|
+
return out;
|
|
825
|
+
};
|
|
826
|
+
return {
|
|
827
|
+
before: { name: aLabel, capturedAt: a.capturedAt },
|
|
828
|
+
after: { name: bLabel, capturedAt: b.capturedAt },
|
|
829
|
+
app: diffNumberMap(numericApp(a), numericApp(b)),
|
|
830
|
+
regions: diffNumberMap(a.app?.regions, b.app?.regions),
|
|
831
|
+
objects: diffNumberMap(a.objects, b.objects),
|
|
832
|
+
objectClasses: diffClasses(a.objectClasses, b.objectClasses),
|
|
833
|
+
leaksDelta: a.leaks || b.leaks
|
|
834
|
+
? {
|
|
835
|
+
count: (b.leaks?.totalCount ?? 0) - (a.leaks?.totalCount ?? 0),
|
|
836
|
+
bytes: (b.leaks?.totalBytes ?? 0) - (a.leaks?.totalBytes ?? 0),
|
|
837
|
+
}
|
|
838
|
+
: undefined,
|
|
839
|
+
};
|
|
840
|
+
}
|
|
371
841
|
// ── Formatting ────────────────────────────────────────────────────────────────
|
|
372
842
|
function fmtBytes(n) {
|
|
373
843
|
if (n === undefined)
|
|
374
844
|
return '—';
|
|
375
|
-
|
|
376
|
-
|
|
845
|
+
const sign = n < 0 ? '-' : '';
|
|
846
|
+
const abs = Math.abs(n);
|
|
847
|
+
if (abs < 1024)
|
|
848
|
+
return `${sign}${abs} B`;
|
|
377
849
|
const units = ['KB', 'MB', 'GB', 'TB'];
|
|
378
|
-
let v =
|
|
850
|
+
let v = abs / 1024;
|
|
379
851
|
let i = 0;
|
|
380
852
|
while (v >= 1024 && i < units.length - 1) {
|
|
381
853
|
v /= 1024;
|
|
382
854
|
i++;
|
|
383
855
|
}
|
|
384
|
-
return `${v.toFixed(v >= 100 ? 0 : v >= 10 ? 1 : 2)} ${units[i]}`;
|
|
856
|
+
return `${sign}${v.toFixed(v >= 100 ? 0 : v >= 10 ? 1 : 2)} ${units[i]}`;
|
|
857
|
+
}
|
|
858
|
+
function fmtSignedBytes(n) {
|
|
859
|
+
if (n === 0)
|
|
860
|
+
return '±0';
|
|
861
|
+
return (n > 0 ? '+' : '') + fmtBytes(n);
|
|
862
|
+
}
|
|
863
|
+
function fmtSignedInt(n) {
|
|
864
|
+
if (n === 0)
|
|
865
|
+
return '±0';
|
|
866
|
+
return (n > 0 ? '+' : '') + String(n);
|
|
867
|
+
}
|
|
868
|
+
function makeFilter(pattern) {
|
|
869
|
+
if (!pattern)
|
|
870
|
+
return undefined;
|
|
871
|
+
try {
|
|
872
|
+
const re = new RegExp(pattern, 'i');
|
|
873
|
+
return (s) => re.test(s);
|
|
874
|
+
}
|
|
875
|
+
catch {
|
|
876
|
+
// Invalid regex → no filter (safer than throwing mid-render).
|
|
877
|
+
return undefined;
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
function filterClasses(arr, pattern) {
|
|
881
|
+
const f = makeFilter(pattern);
|
|
882
|
+
return f ? arr.filter((c) => f(c.class)) : arr;
|
|
385
883
|
}
|
|
386
|
-
function
|
|
884
|
+
function filterEntries(arr, pattern, growthOnly) {
|
|
885
|
+
const f = makeFilter(pattern);
|
|
886
|
+
return arr.filter((e) => {
|
|
887
|
+
if (growthOnly && e.delta <= 0)
|
|
888
|
+
return false;
|
|
889
|
+
if (f && !f(e.key))
|
|
890
|
+
return false;
|
|
891
|
+
return true;
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
function filterClassDiffs(arr, pattern, growthOnly) {
|
|
895
|
+
const f = makeFilter(pattern);
|
|
896
|
+
return arr.filter((c) => {
|
|
897
|
+
if (growthOnly && c.deltaBytes <= 0)
|
|
898
|
+
return false;
|
|
899
|
+
if (f && !f(c.class))
|
|
900
|
+
return false;
|
|
901
|
+
return true;
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
function formatReport(r, opts = {}) {
|
|
905
|
+
const top = opts.top ?? 20;
|
|
387
906
|
const lines = [];
|
|
388
907
|
lines.push(`Device: ${r.deviceId} (${r.platform})`);
|
|
389
908
|
if (r.appId)
|
|
390
909
|
lines.push(`App: ${r.appId}${r.pid ? ` (pid ${r.pid})` : ''}`);
|
|
910
|
+
if (r.capturedAt)
|
|
911
|
+
lines.push(`Captured: ${r.capturedAt}`);
|
|
391
912
|
if (r.system) {
|
|
392
913
|
lines.push('');
|
|
393
914
|
lines.push('System memory:');
|
|
@@ -409,6 +930,10 @@ function formatReport(r) {
|
|
|
409
930
|
lines.push('');
|
|
410
931
|
lines.push('App memory:');
|
|
411
932
|
const a = r.app;
|
|
933
|
+
if (a.footprintBytes !== undefined)
|
|
934
|
+
lines.push(` Footprint: ${fmtBytes(a.footprintBytes)} (jetsam target on iOS)`);
|
|
935
|
+
if (a.dirtyBytes !== undefined)
|
|
936
|
+
lines.push(` Dirty: ${fmtBytes(a.dirtyBytes)}`);
|
|
412
937
|
if (a.totalPssBytes !== undefined)
|
|
413
938
|
lines.push(` PSS total: ${fmtBytes(a.totalPssBytes)}`);
|
|
414
939
|
if (a.totalRssBytes !== undefined)
|
|
@@ -433,24 +958,46 @@ function formatReport(r) {
|
|
|
433
958
|
lines.push(` System: ${fmtBytes(a.systemBytes)}`);
|
|
434
959
|
if (a.regions && Object.keys(a.regions).length > 0) {
|
|
435
960
|
lines.push('');
|
|
436
|
-
lines.push(
|
|
961
|
+
lines.push(`Top memory regions (resident, top ${top}):`);
|
|
437
962
|
const entries = Object.entries(a.regions)
|
|
438
963
|
.filter(([, v]) => v > 0)
|
|
439
|
-
.sort((
|
|
440
|
-
.slice(0,
|
|
964
|
+
.sort((x, y) => y[1] - x[1])
|
|
965
|
+
.slice(0, top);
|
|
441
966
|
for (const [name, bytes] of entries) {
|
|
442
|
-
lines.push(` ${name.padEnd(
|
|
967
|
+
lines.push(` ${name.padEnd(30)} ${fmtBytes(bytes)}`);
|
|
443
968
|
}
|
|
444
969
|
}
|
|
445
970
|
}
|
|
971
|
+
if (r.objectClasses && r.objectClasses.length > 0) {
|
|
972
|
+
const filtered = filterClasses(r.objectClasses, opts.filter);
|
|
973
|
+
lines.push('');
|
|
974
|
+
if (r.heapTotals) {
|
|
975
|
+
lines.push(`Heap objects (${r.heapTotals.count.toLocaleString()} nodes, ${fmtBytes(r.heapTotals.bytes)} total) — top ${top} by bytes${opts.filter ? ` (filter: /${opts.filter}/)` : ''}:`);
|
|
976
|
+
}
|
|
977
|
+
else {
|
|
978
|
+
lines.push(`Heap objects (top ${top} by bytes${opts.filter ? ` (filter: /${opts.filter}/)` : ''}):`);
|
|
979
|
+
}
|
|
980
|
+
lines.push(` ${'COUNT'.padStart(8)} ${'BYTES'.padStart(10)} CLASS`);
|
|
981
|
+
for (const c of filtered.slice(0, top)) {
|
|
982
|
+
const name = c.binary ? `${c.class} [${c.binary}]` : c.class;
|
|
983
|
+
lines.push(` ${String(c.count).padStart(8)} ${fmtBytes(c.bytes).padStart(10)} ${name}`);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
446
986
|
if (r.objects && Object.keys(r.objects).length > 0) {
|
|
447
987
|
lines.push('');
|
|
448
988
|
lines.push('Object counts:');
|
|
449
|
-
const entries = Object.entries(r.objects).sort((
|
|
989
|
+
const entries = Object.entries(r.objects).sort((x, y) => y[1] - x[1]);
|
|
450
990
|
for (const [name, count] of entries) {
|
|
451
991
|
lines.push(` ${name.padEnd(20)} ${count}`);
|
|
452
992
|
}
|
|
453
993
|
}
|
|
994
|
+
if (r.leaks) {
|
|
995
|
+
lines.push('');
|
|
996
|
+
lines.push(`Leaks: ${r.leaks.totalCount} leak${r.leaks.totalCount === 1 ? '' : 's'} (${fmtBytes(r.leaks.totalBytes)})`);
|
|
997
|
+
for (const l of r.leaks.classes.slice(0, top)) {
|
|
998
|
+
lines.push(` ${String(l.count).padStart(6)} ${fmtBytes(l.bytes).padStart(10)} ${l.class}`);
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
454
1001
|
if (r.notes && r.notes.length > 0) {
|
|
455
1002
|
lines.push('');
|
|
456
1003
|
for (const n of r.notes)
|
|
@@ -458,8 +1005,94 @@ function formatReport(r) {
|
|
|
458
1005
|
}
|
|
459
1006
|
return lines.join('\n');
|
|
460
1007
|
}
|
|
1008
|
+
function formatDiff(d, opts = {}) {
|
|
1009
|
+
const top = opts.top ?? 20;
|
|
1010
|
+
const lines = [];
|
|
1011
|
+
const tag = [];
|
|
1012
|
+
if (opts.growthOnly)
|
|
1013
|
+
tag.push('growth-only');
|
|
1014
|
+
if (opts.filter)
|
|
1015
|
+
tag.push(`filter: /${opts.filter}/`);
|
|
1016
|
+
const tagStr = tag.length > 0 ? ` [${tag.join(', ')}]` : '';
|
|
1017
|
+
lines.push(`Diff: ${d.before.name} → ${d.after.name}${tagStr}`);
|
|
1018
|
+
if (d.before.capturedAt || d.after.capturedAt) {
|
|
1019
|
+
lines.push(` ${d.before.capturedAt ?? '?'} → ${d.after.capturedAt ?? '?'}`);
|
|
1020
|
+
}
|
|
1021
|
+
// App memory deltas — only filtered by growth-only (key is e.g. "totalRssBytes",
|
|
1022
|
+
// not user-meaningful for regex filtering).
|
|
1023
|
+
const appRows = opts.growthOnly ? d.app.filter((e) => e.delta > 0) : d.app;
|
|
1024
|
+
if (appRows.length > 0) {
|
|
1025
|
+
lines.push('');
|
|
1026
|
+
lines.push('App memory deltas:');
|
|
1027
|
+
for (const e of appRows.slice(0, top)) {
|
|
1028
|
+
lines.push(` ${e.key.padEnd(20)} ${fmtBytes(e.before).padStart(10)} → ${fmtBytes(e.after).padStart(10)} (${fmtSignedBytes(e.delta)})`);
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
const regionRows = filterEntries(d.regions, opts.filter, opts.growthOnly);
|
|
1032
|
+
if (regionRows.length > 0) {
|
|
1033
|
+
lines.push('');
|
|
1034
|
+
lines.push(`Region deltas (top ${top} by |Δ|):`);
|
|
1035
|
+
for (const e of regionRows.slice(0, top)) {
|
|
1036
|
+
lines.push(` ${e.key.padEnd(30)} ${fmtBytes(e.before).padStart(10)} → ${fmtBytes(e.after).padStart(10)} (${fmtSignedBytes(e.delta)})`);
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
const classRows = filterClassDiffs(d.objectClasses, opts.filter, opts.growthOnly);
|
|
1040
|
+
if (classRows.length > 0) {
|
|
1041
|
+
lines.push('');
|
|
1042
|
+
lines.push(`Class deltas (top ${top} by |Δ bytes|):`);
|
|
1043
|
+
lines.push(` ${'Δ COUNT'.padStart(8)} ${'Δ BYTES'.padStart(10)} ${'AFTER COUNT'.padStart(11)} ${'AFTER BYTES'.padStart(11)} CLASS`);
|
|
1044
|
+
for (const c of classRows.slice(0, top)) {
|
|
1045
|
+
lines.push(` ${fmtSignedInt(c.deltaCount).padStart(8)} ${fmtSignedBytes(c.deltaBytes).padStart(10)} ${String(c.afterCount).padStart(11)} ${fmtBytes(c.afterBytes).padStart(11)} ${c.class}`);
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
const objRows = filterEntries(d.objects, opts.filter, opts.growthOnly);
|
|
1049
|
+
if (objRows.length > 0) {
|
|
1050
|
+
lines.push('');
|
|
1051
|
+
lines.push('Object count deltas:');
|
|
1052
|
+
for (const e of objRows.slice(0, top)) {
|
|
1053
|
+
lines.push(` ${e.key.padEnd(20)} ${String(e.before).padStart(8)} → ${String(e.after).padStart(8)} (${fmtSignedInt(e.delta)})`);
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
if (d.leaksDelta && (d.leaksDelta.count !== 0 || d.leaksDelta.bytes !== 0)) {
|
|
1057
|
+
if (!opts.growthOnly || d.leaksDelta.bytes > 0) {
|
|
1058
|
+
lines.push('');
|
|
1059
|
+
lines.push(`Leaks delta: ${fmtSignedInt(d.leaksDelta.count)} leak(s), ${fmtSignedBytes(d.leaksDelta.bytes)}`);
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
if (lines.length <= 2)
|
|
1063
|
+
lines.push('', 'No differences detected.');
|
|
1064
|
+
return lines.join('\n');
|
|
1065
|
+
}
|
|
461
1066
|
// ── Entry point ───────────────────────────────────────────────────────────────
|
|
462
|
-
async function memory(appIdArg, opts = {}, sessionName = 'default',
|
|
1067
|
+
async function memory(appIdArg, opts = {}, sessionName = 'default', memOpts = {}) {
|
|
1068
|
+
// List snapshots — no device required.
|
|
1069
|
+
if (memOpts.listSnapshots) {
|
|
1070
|
+
const list = await listSnapshots();
|
|
1071
|
+
if (opts.json) {
|
|
1072
|
+
(0, output_js_1.printData)({ status: 'ok', snapshots: list }, opts);
|
|
1073
|
+
}
|
|
1074
|
+
else if (list.length === 0) {
|
|
1075
|
+
console.log(`No snapshots saved. Try: conductor memory --save baseline`);
|
|
1076
|
+
}
|
|
1077
|
+
else {
|
|
1078
|
+
console.log(`Snapshots in ${snapshotsDir()}:`);
|
|
1079
|
+
for (const s of list) {
|
|
1080
|
+
console.log(` ${s.name.padEnd(24)} ${(s.platform ?? '?').padEnd(8)} ${s.appId ?? ''} ${s.capturedAt ?? ''}`);
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
return 0;
|
|
1084
|
+
}
|
|
1085
|
+
// Diff between two saved snapshots.
|
|
1086
|
+
if (memOpts.diff && memOpts.diffOther) {
|
|
1087
|
+
const a = await loadSnapshot(memOpts.diff);
|
|
1088
|
+
const b = await loadSnapshot(memOpts.diffOther);
|
|
1089
|
+
const d = buildDiff(a, b, memOpts.diff, memOpts.diffOther);
|
|
1090
|
+
if (opts.json)
|
|
1091
|
+
(0, output_js_1.printData)({ status: 'ok', diff: d }, opts);
|
|
1092
|
+
else
|
|
1093
|
+
console.log(formatDiff(d, memOpts));
|
|
1094
|
+
return 0;
|
|
1095
|
+
}
|
|
463
1096
|
const deviceId = await resolveDeviceId(sessionName);
|
|
464
1097
|
if (!deviceId) {
|
|
465
1098
|
(0, output_js_1.printError)('No device found. Connect a device or start a simulator first.', opts);
|
|
@@ -468,22 +1101,40 @@ async function memory(appIdArg, opts = {}, sessionName = 'default', _memOpts = {
|
|
|
468
1101
|
const platform = await (0, bootstrap_js_1.detectPlatform)(deviceId);
|
|
469
1102
|
let report;
|
|
470
1103
|
if (platform === 'web') {
|
|
471
|
-
report = await collectWeb(deviceId, sessionName);
|
|
1104
|
+
report = await collectWeb(deviceId, sessionName, memOpts);
|
|
472
1105
|
}
|
|
473
1106
|
else {
|
|
474
1107
|
const appId = await resolveAppId(appIdArg, sessionName, deviceId);
|
|
475
1108
|
if (platform === 'android') {
|
|
476
|
-
report = await collectAndroid(deviceId, appId);
|
|
1109
|
+
report = await collectAndroid(deviceId, appId, memOpts);
|
|
477
1110
|
}
|
|
478
1111
|
else {
|
|
479
|
-
report = await collectIOS(deviceId, platform, appId);
|
|
1112
|
+
report = await collectIOS(deviceId, platform, appId, memOpts);
|
|
480
1113
|
}
|
|
481
1114
|
}
|
|
1115
|
+
report.capturedAt = new Date().toISOString();
|
|
1116
|
+
// Diff current report against a saved snapshot.
|
|
1117
|
+
if (memOpts.diff) {
|
|
1118
|
+
const a = await loadSnapshot(memOpts.diff);
|
|
1119
|
+
const d = buildDiff(a, report, memOpts.diff, 'current');
|
|
1120
|
+
if (memOpts.save)
|
|
1121
|
+
await saveSnapshot(memOpts.save, report);
|
|
1122
|
+
if (opts.json)
|
|
1123
|
+
(0, output_js_1.printData)({ status: 'ok', diff: d, current: report }, opts);
|
|
1124
|
+
else
|
|
1125
|
+
console.log(formatDiff(d, memOpts));
|
|
1126
|
+
return 0;
|
|
1127
|
+
}
|
|
1128
|
+
if (memOpts.save) {
|
|
1129
|
+
const file = await saveSnapshot(memOpts.save, report);
|
|
1130
|
+
if (!opts.json)
|
|
1131
|
+
console.error(`Saved snapshot → ${file}`);
|
|
1132
|
+
}
|
|
482
1133
|
if (opts.json) {
|
|
483
1134
|
(0, output_js_1.printData)({ status: 'ok', ...report }, opts);
|
|
484
1135
|
}
|
|
485
1136
|
else {
|
|
486
|
-
console.log(formatReport(report));
|
|
1137
|
+
console.log(formatReport(report, memOpts));
|
|
487
1138
|
}
|
|
488
1139
|
return 0;
|
|
489
1140
|
}
|