@houwert/conductor 0.13.0 → 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/foreground-app.js +1 -0
- 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 +716 -63
- 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,36 +1,58 @@
|
|
|
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");
|
|
27
|
+
const foreground_app_js_1 = require("./foreground-app.js");
|
|
8
28
|
const output_js_1 = require("../output.js");
|
|
9
29
|
const bootstrap_js_1 = require("../drivers/bootstrap.js");
|
|
10
30
|
const ios_js_1 = require("../drivers/ios.js");
|
|
11
31
|
const android_js_1 = require("../drivers/android.js");
|
|
12
32
|
const web_js_1 = require("../drivers/web.js");
|
|
33
|
+
const hprof_js_1 = require("./hprof.js");
|
|
13
34
|
async function resolveDeviceId(sessionName) {
|
|
14
35
|
if (sessionName !== 'default')
|
|
15
36
|
return sessionName;
|
|
16
37
|
const session = await (0, session_js_1.getSession)(sessionName);
|
|
17
38
|
return session.deviceId ?? (await (0, runner_js_1.detectFirstDevice)());
|
|
18
39
|
}
|
|
19
|
-
async function resolveAppId(explicit, sessionName) {
|
|
40
|
+
async function resolveAppId(explicit, sessionName, deviceId) {
|
|
20
41
|
if (explicit)
|
|
21
42
|
return explicit;
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
// Fall back to whatever's in the foreground.
|
|
43
|
+
// No arg: always resolve from the live foreground app, not the session file.
|
|
44
|
+
// The session's appId reflects the last `launch-app` call, which can be stale
|
|
45
|
+
// if the user switched apps on the device by other means.
|
|
26
46
|
try {
|
|
27
47
|
const driver = await (0, runner_js_1.getDriver)(sessionName);
|
|
28
48
|
if (driver instanceof android_js_1.AndroidDriver)
|
|
29
49
|
return await driver.getForegroundApp();
|
|
30
50
|
if (driver instanceof web_js_1.WebDriver)
|
|
31
51
|
return await driver.runningApp();
|
|
32
|
-
if (driver instanceof ios_js_1.IOSDriver)
|
|
33
|
-
|
|
52
|
+
if (driver instanceof ios_js_1.IOSDriver) {
|
|
53
|
+
const appIds = await (0, foreground_app_js_1.getInstalledAppIds)(deviceId);
|
|
54
|
+
return await driver.runningApp(appIds);
|
|
55
|
+
}
|
|
34
56
|
}
|
|
35
57
|
catch {
|
|
36
58
|
/* ignore */
|
|
@@ -58,7 +80,6 @@ function parseAndroidDumpsysMeminfo(out) {
|
|
|
58
80
|
const pidMatch = out.match(/\*\* MEMINFO in pid (\d+)/);
|
|
59
81
|
const pid = pidMatch ? Number(pidMatch[1]) : undefined;
|
|
60
82
|
// App Summary block — most useful, single-line entries with KB values.
|
|
61
|
-
// e.g. " Java Heap: 12345"
|
|
62
83
|
const summary = out.match(/App Summary[\s\S]*?(?:\n\s*\n|TOTAL:)/);
|
|
63
84
|
if (summary) {
|
|
64
85
|
const block = summary[0];
|
|
@@ -83,25 +104,14 @@ function parseAndroidDumpsysMeminfo(out) {
|
|
|
83
104
|
if (totalSwap)
|
|
84
105
|
app['totalSwapBytes'] = Number(totalSwap[1]) * 1024;
|
|
85
106
|
}
|
|
86
|
-
// Fallback: TOTAL line in the main table — "TOTAL 12345 ..."
|
|
87
107
|
if (app.totalPssBytes === undefined) {
|
|
88
108
|
const total = out.match(/^\s*TOTAL\s+(\d+)/m);
|
|
89
109
|
if (total)
|
|
90
110
|
app.totalPssBytes = Number(total[1]) * 1024;
|
|
91
111
|
}
|
|
92
|
-
// Objects block:
|
|
93
|
-
// Objects
|
|
94
|
-
// Views: 3 ViewRootImpl: 1
|
|
95
|
-
// AppContexts: 2 Activities: 1
|
|
96
|
-
// Assets: 7 AssetManagers: 0
|
|
97
|
-
// Local Binders: 5 Proxy Binders: 17
|
|
98
|
-
// Parcel memory: 1 Parcel count: 4
|
|
99
|
-
// Death Recipients: 0 OpenSSL Sockets: 0
|
|
100
|
-
// WebViews: 0
|
|
101
112
|
const objSection = out.match(/Objects[\s\S]*?(?:\n\s*\n|SQL\s|DATABASES|$)/);
|
|
102
113
|
if (objSection) {
|
|
103
114
|
const text = objSection[0];
|
|
104
|
-
// Capture every "Label: number" pair (labels may have spaces).
|
|
105
115
|
const re = /([A-Za-z][A-Za-z _]*?):\s+(\d+)/g;
|
|
106
116
|
let m;
|
|
107
117
|
while ((m = re.exec(text)) !== null) {
|
|
@@ -113,9 +123,57 @@ function parseAndroidDumpsysMeminfo(out) {
|
|
|
113
123
|
}
|
|
114
124
|
return { app, objects, pid };
|
|
115
125
|
}
|
|
116
|
-
|
|
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) {
|
|
117
175
|
const report = { platform: 'android', deviceId, appId, notes: [] };
|
|
118
|
-
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)() });
|
|
119
177
|
if (meminfo.success) {
|
|
120
178
|
const sys = parseAndroidMeminfo(meminfo.stdout);
|
|
121
179
|
report.system = {
|
|
@@ -129,7 +187,7 @@ async function collectAndroid(deviceId, appId) {
|
|
|
129
187
|
report.notes.push(`/proc/meminfo unavailable: ${meminfo.stderr.trim()}`);
|
|
130
188
|
}
|
|
131
189
|
if (appId) {
|
|
132
|
-
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)() });
|
|
133
191
|
if (dump.success && !dump.stdout.includes('No process found')) {
|
|
134
192
|
const { app, objects, pid } = parseAndroidDumpsysMeminfo(dump.stdout);
|
|
135
193
|
report.app = app;
|
|
@@ -139,6 +197,80 @@ async function collectAndroid(deviceId, appId) {
|
|
|
139
197
|
else {
|
|
140
198
|
report.notes.push(`dumpsys meminfo ${appId} returned no data — app may not be running`);
|
|
141
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
|
+
}
|
|
142
274
|
}
|
|
143
275
|
if (report.notes.length === 0)
|
|
144
276
|
delete report.notes;
|
|
@@ -146,7 +278,6 @@ async function collectAndroid(deviceId, appId) {
|
|
|
146
278
|
}
|
|
147
279
|
// ── iOS / tvOS ────────────────────────────────────────────────────────────────
|
|
148
280
|
function parseVmStat(out) {
|
|
149
|
-
// vm_stat output uses "page size of 16384 bytes" and counts in pages.
|
|
150
281
|
const pageMatch = out.match(/page size of (\d+)/);
|
|
151
282
|
const pageSize = pageMatch ? Number(pageMatch[1]) : 4096;
|
|
152
283
|
const grab = (label) => {
|
|
@@ -186,23 +317,40 @@ async function findIOSPid(deviceId, appId) {
|
|
|
186
317
|
}
|
|
187
318
|
return undefined;
|
|
188
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
|
+
}
|
|
189
332
|
function parseVmmapSummary(out) {
|
|
190
333
|
const regions = {};
|
|
191
334
|
const app = {};
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
//
|
|
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 ...
|
|
195
342
|
const headerIdx = out.indexOf('REGION TYPE');
|
|
196
343
|
if (headerIdx === -1)
|
|
197
344
|
return { app, regions };
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
for (
|
|
201
|
-
const line =
|
|
202
|
-
|
|
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)
|
|
203
351
|
continue;
|
|
204
|
-
if (
|
|
205
|
-
|
|
352
|
+
if (trimmed.startsWith('='))
|
|
353
|
+
continue; // separator row
|
|
206
354
|
if (line.startsWith('TOTAL')) {
|
|
207
355
|
// "TOTAL 5.2G 1.4G 950M ..."
|
|
208
356
|
const m = line.match(/TOTAL\s+\S+\s+(\S+)/);
|
|
@@ -219,13 +367,12 @@ function parseVmmapSummary(out) {
|
|
|
219
367
|
if (resident !== undefined)
|
|
220
368
|
regions[name] = resident;
|
|
221
369
|
}
|
|
222
|
-
// Roll up a few well-known regions into the canonical fields.
|
|
223
370
|
app.nativeHeapBytes = regions['MALLOC'] ?? regions['MALLOC_NANO'];
|
|
224
371
|
app.stackBytes = regions['Stack'];
|
|
225
372
|
return { app, regions };
|
|
226
373
|
}
|
|
227
374
|
function humanToBytes(s) {
|
|
228
|
-
// Matches "1.2G", "234.5M", "950K", "1024", "1.2GB" etc.
|
|
375
|
+
// Matches "1.2G", "234.5M", "950K", "1024", "1.2GB", "0K" etc.
|
|
229
376
|
const m = s.match(/^([\d.]+)\s*([KMGT]?)B?$/i);
|
|
230
377
|
if (!m)
|
|
231
378
|
return undefined;
|
|
@@ -241,10 +388,104 @@ function humanToBytes(s) {
|
|
|
241
388
|
};
|
|
242
389
|
return Math.round(n * (mult[m[2].toUpperCase()] ?? 1));
|
|
243
390
|
}
|
|
244
|
-
|
|
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) {
|
|
245
484
|
const report = { platform, deviceId, appId, notes: [] };
|
|
246
|
-
// System-wide memory: vm_stat from
|
|
247
|
-
|
|
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', []);
|
|
248
489
|
if (vm.success) {
|
|
249
490
|
const sys = parseVmStat(vm.stdout);
|
|
250
491
|
report.system = {
|
|
@@ -284,7 +525,19 @@ async function collectIOS(deviceId, platform, appId) {
|
|
|
284
525
|
};
|
|
285
526
|
}
|
|
286
527
|
}
|
|
287
|
-
//
|
|
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.
|
|
288
541
|
const vmmap = await (0, runner_js_1.spawnCommand)('vmmap', ['-summary', String(pid)]);
|
|
289
542
|
if (vmmap.success) {
|
|
290
543
|
const { app, regions } = parseVmmapSummary(vmmap.stdout);
|
|
@@ -295,14 +548,91 @@ async function collectIOS(deviceId, platform, appId) {
|
|
|
295
548
|
};
|
|
296
549
|
}
|
|
297
550
|
else {
|
|
298
|
-
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
|
+
}
|
|
299
580
|
}
|
|
300
581
|
if (report.notes.length === 0)
|
|
301
582
|
delete report.notes;
|
|
302
583
|
return report;
|
|
303
584
|
}
|
|
304
|
-
|
|
305
|
-
|
|
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) {
|
|
306
636
|
const report = { platform: 'web', deviceId, notes: [] };
|
|
307
637
|
let driver;
|
|
308
638
|
try {
|
|
@@ -330,11 +660,8 @@ async function collectWeb(deviceId, sessionName) {
|
|
|
330
660
|
const m = data.metrics;
|
|
331
661
|
const pm = data.pageMemory;
|
|
332
662
|
report.app = {
|
|
333
|
-
// Heap totals — prefer page-context performance.memory (Chromium) over
|
|
334
|
-
// CDP's JSHeapUsedSize (which can lag). Both are JS heap only.
|
|
335
663
|
nativeHeapBytes: pm?.usedJSHeapSize ?? m['JSHeapUsedSize'],
|
|
336
664
|
codeBytes: m['JSHeapTotalSize'] ? m['JSHeapTotalSize'] - (m['JSHeapUsedSize'] ?? 0) : undefined,
|
|
337
|
-
// Roll the raw CDP metrics into `regions` so they're inspectable verbatim.
|
|
338
665
|
regions: { ...m },
|
|
339
666
|
};
|
|
340
667
|
if (pm) {
|
|
@@ -345,7 +672,6 @@ async function collectWeb(deviceId, sessionName) {
|
|
|
345
672
|
'JS Heap Limit': pm.jsHeapSizeLimit,
|
|
346
673
|
};
|
|
347
674
|
}
|
|
348
|
-
// Object counts — direct CDP equivalents of Android's "Views/Activities/Binders".
|
|
349
675
|
const objectKeys = [
|
|
350
676
|
'Nodes',
|
|
351
677
|
'Documents',
|
|
@@ -361,31 +687,228 @@ async function collectWeb(deviceId, sessionName) {
|
|
|
361
687
|
}
|
|
362
688
|
if (Object.keys(objects).length > 0)
|
|
363
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
|
+
}
|
|
364
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.');
|
|
365
713
|
if (report.notes.length === 0)
|
|
366
714
|
delete report.notes;
|
|
367
715
|
return report;
|
|
368
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
|
+
}
|
|
369
841
|
// ── Formatting ────────────────────────────────────────────────────────────────
|
|
370
842
|
function fmtBytes(n) {
|
|
371
843
|
if (n === undefined)
|
|
372
844
|
return '—';
|
|
373
|
-
|
|
374
|
-
|
|
845
|
+
const sign = n < 0 ? '-' : '';
|
|
846
|
+
const abs = Math.abs(n);
|
|
847
|
+
if (abs < 1024)
|
|
848
|
+
return `${sign}${abs} B`;
|
|
375
849
|
const units = ['KB', 'MB', 'GB', 'TB'];
|
|
376
|
-
let v =
|
|
850
|
+
let v = abs / 1024;
|
|
377
851
|
let i = 0;
|
|
378
852
|
while (v >= 1024 && i < units.length - 1) {
|
|
379
853
|
v /= 1024;
|
|
380
854
|
i++;
|
|
381
855
|
}
|
|
382
|
-
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]}`;
|
|
383
857
|
}
|
|
384
|
-
function
|
|
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;
|
|
883
|
+
}
|
|
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;
|
|
385
906
|
const lines = [];
|
|
386
907
|
lines.push(`Device: ${r.deviceId} (${r.platform})`);
|
|
387
908
|
if (r.appId)
|
|
388
909
|
lines.push(`App: ${r.appId}${r.pid ? ` (pid ${r.pid})` : ''}`);
|
|
910
|
+
if (r.capturedAt)
|
|
911
|
+
lines.push(`Captured: ${r.capturedAt}`);
|
|
389
912
|
if (r.system) {
|
|
390
913
|
lines.push('');
|
|
391
914
|
lines.push('System memory:');
|
|
@@ -407,6 +930,10 @@ function formatReport(r) {
|
|
|
407
930
|
lines.push('');
|
|
408
931
|
lines.push('App memory:');
|
|
409
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)}`);
|
|
410
937
|
if (a.totalPssBytes !== undefined)
|
|
411
938
|
lines.push(` PSS total: ${fmtBytes(a.totalPssBytes)}`);
|
|
412
939
|
if (a.totalRssBytes !== undefined)
|
|
@@ -431,24 +958,46 @@ function formatReport(r) {
|
|
|
431
958
|
lines.push(` System: ${fmtBytes(a.systemBytes)}`);
|
|
432
959
|
if (a.regions && Object.keys(a.regions).length > 0) {
|
|
433
960
|
lines.push('');
|
|
434
|
-
lines.push(
|
|
961
|
+
lines.push(`Top memory regions (resident, top ${top}):`);
|
|
435
962
|
const entries = Object.entries(a.regions)
|
|
436
963
|
.filter(([, v]) => v > 0)
|
|
437
|
-
.sort((
|
|
438
|
-
.slice(0,
|
|
964
|
+
.sort((x, y) => y[1] - x[1])
|
|
965
|
+
.slice(0, top);
|
|
439
966
|
for (const [name, bytes] of entries) {
|
|
440
|
-
lines.push(` ${name.padEnd(
|
|
967
|
+
lines.push(` ${name.padEnd(30)} ${fmtBytes(bytes)}`);
|
|
441
968
|
}
|
|
442
969
|
}
|
|
443
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
|
+
}
|
|
444
986
|
if (r.objects && Object.keys(r.objects).length > 0) {
|
|
445
987
|
lines.push('');
|
|
446
988
|
lines.push('Object counts:');
|
|
447
|
-
const entries = Object.entries(r.objects).sort((
|
|
989
|
+
const entries = Object.entries(r.objects).sort((x, y) => y[1] - x[1]);
|
|
448
990
|
for (const [name, count] of entries) {
|
|
449
991
|
lines.push(` ${name.padEnd(20)} ${count}`);
|
|
450
992
|
}
|
|
451
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
|
+
}
|
|
452
1001
|
if (r.notes && r.notes.length > 0) {
|
|
453
1002
|
lines.push('');
|
|
454
1003
|
for (const n of r.notes)
|
|
@@ -456,8 +1005,94 @@ function formatReport(r) {
|
|
|
456
1005
|
}
|
|
457
1006
|
return lines.join('\n');
|
|
458
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
|
+
}
|
|
459
1066
|
// ── Entry point ───────────────────────────────────────────────────────────────
|
|
460
|
-
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
|
+
}
|
|
461
1096
|
const deviceId = await resolveDeviceId(sessionName);
|
|
462
1097
|
if (!deviceId) {
|
|
463
1098
|
(0, output_js_1.printError)('No device found. Connect a device or start a simulator first.', opts);
|
|
@@ -466,22 +1101,40 @@ async function memory(appIdArg, opts = {}, sessionName = 'default', _memOpts = {
|
|
|
466
1101
|
const platform = await (0, bootstrap_js_1.detectPlatform)(deviceId);
|
|
467
1102
|
let report;
|
|
468
1103
|
if (platform === 'web') {
|
|
469
|
-
report = await collectWeb(deviceId, sessionName);
|
|
1104
|
+
report = await collectWeb(deviceId, sessionName, memOpts);
|
|
470
1105
|
}
|
|
471
1106
|
else {
|
|
472
|
-
const appId = await resolveAppId(appIdArg, sessionName);
|
|
1107
|
+
const appId = await resolveAppId(appIdArg, sessionName, deviceId);
|
|
473
1108
|
if (platform === 'android') {
|
|
474
|
-
report = await collectAndroid(deviceId, appId);
|
|
1109
|
+
report = await collectAndroid(deviceId, appId, memOpts);
|
|
475
1110
|
}
|
|
476
1111
|
else {
|
|
477
|
-
report = await collectIOS(deviceId, platform, appId);
|
|
1112
|
+
report = await collectIOS(deviceId, platform, appId, memOpts);
|
|
478
1113
|
}
|
|
479
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
|
+
}
|
|
480
1133
|
if (opts.json) {
|
|
481
1134
|
(0, output_js_1.printData)({ status: 'ok', ...report }, opts);
|
|
482
1135
|
}
|
|
483
1136
|
else {
|
|
484
|
-
console.log(formatReport(report));
|
|
1137
|
+
console.log(formatReport(report, memOpts));
|
|
485
1138
|
}
|
|
486
1139
|
return 0;
|
|
487
1140
|
}
|