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