@houwert/conductor 0.28.0 → 0.29.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,90 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseGcEvents = parseGcEvents;
4
+ exports.summariseGc = summariseGc;
5
+ exports.deviceLogcatTimestamp = deviceLogcatTimestamp;
6
+ exports.collectGcSince = collectGcSince;
7
+ /**
8
+ * Android GC-pause scraping.
9
+ *
10
+ * ART logs every collection it considers noteworthy to logcat under the `art`
11
+ * tag; the pause figures in those lines are the stop-the-world portions, which
12
+ * is what shows up as a dropped frame. Scraped rather than sampled because
13
+ * there is no dumpsys surface for pause times.
14
+ */
15
+ const device_js_1 = require("../android/device.js");
16
+ const stats_js_1 = require("../stats.js");
17
+ function toMs(value) {
18
+ const m = value.match(/^([\d.]+)(us|ms|s)$/);
19
+ if (!m)
20
+ return 0;
21
+ const n = Number(m[1]);
22
+ if (m[2] === 'us')
23
+ return n / 1000;
24
+ if (m[2] === 's')
25
+ return n * 1000;
26
+ return n;
27
+ }
28
+ function toBytes(value) {
29
+ if (!value)
30
+ return undefined;
31
+ const m = value.match(/^([\d.]+)(B|KB|MB|GB)$/i);
32
+ if (!m)
33
+ return undefined;
34
+ const mult = { b: 1, kb: 1024, mb: 1024 ** 2, gb: 1024 ** 3 }[m[2].toLowerCase()];
35
+ return Math.round(Number(m[1]) * mult);
36
+ }
37
+ /**
38
+ * Parse ART GC lines out of raw logcat. Tolerates the pause list being one or
39
+ * several values and the units varying between us and ms.
40
+ */
41
+ function parseGcEvents(logcat) {
42
+ const events = [];
43
+ const re = /([A-Za-z][A-Za-z ]*?) GC freed (?:[\d.]+)\(([\d.]+[KMG]?B)\) AllocSpace objects.*?, (?:[\d.]+% free, )?([\d.]+[KMG]?B)\/([\d.]+[KMG]?B), paused ([\d.]+(?:us|ms|s)(?:,\s*[\d.]+(?:us|ms|s))*) total ([\d.]+(?:us|ms|s))/g;
44
+ for (const m of logcat.matchAll(re)) {
45
+ events.push({
46
+ kind: m[1].trim(),
47
+ freedBytes: toBytes(m[2]),
48
+ heapUsedBytes: toBytes(m[3]),
49
+ heapTotalBytes: toBytes(m[4]),
50
+ pausesMs: m[5].split(',').map((p) => toMs(p.trim())),
51
+ totalMs: toMs(m[6]),
52
+ });
53
+ }
54
+ return events;
55
+ }
56
+ function summariseGc(events) {
57
+ const pauses = events.flatMap((e) => e.pausesMs);
58
+ const byKind = new Map();
59
+ for (const e of events) {
60
+ const slot = byKind.get(e.kind) ?? { count: 0, totalPauseMs: 0 };
61
+ slot.count++;
62
+ slot.totalPauseMs += e.pausesMs.reduce((a, b) => a + b, 0);
63
+ byKind.set(e.kind, slot);
64
+ }
65
+ return {
66
+ events: events.length,
67
+ totalPauseMs: (0, stats_js_1.round)(pauses.reduce((a, b) => a + b, 0)),
68
+ pause: (0, stats_js_1.describe)(pauses),
69
+ duration: (0, stats_js_1.describe)(events.map((e) => e.totalMs)),
70
+ byKind: [...byKind.entries()]
71
+ .map(([kind, v]) => ({ kind, count: v.count, totalPauseMs: (0, stats_js_1.round)(v.totalPauseMs) }))
72
+ .sort((a, b) => b.totalPauseMs - a.totalPauseMs),
73
+ };
74
+ }
75
+ /**
76
+ * logcat's `-T` filter wants the device's own clock, which can drift from the
77
+ * host's, so ask the device what time it thinks it is.
78
+ */
79
+ async function deviceLogcatTimestamp(deviceId) {
80
+ const res = await (0, device_js_1.adbShell)(deviceId, ['date', '+%m-%d %H:%M:%S.000']);
81
+ return res.success ? res.stdout.trim() : undefined;
82
+ }
83
+ async function collectGcSince(deviceId, since) {
84
+ const args = ['logcat', '-d'];
85
+ if (since)
86
+ args.push('-T', since);
87
+ args.push('art:I', '*:S');
88
+ const res = await (0, device_js_1.adbShell)(deviceId, args);
89
+ return res.success ? parseGcEvents(res.stdout) : [];
90
+ }
@@ -0,0 +1,366 @@
1
+ "use strict";
2
+ /**
3
+ * Hermes sampling profiler over the Metro CDP connection.
4
+ *
5
+ * `Profiler.start` / `Profiler.stop` return a Chrome `.cpuprofile`, so the raw
6
+ * file drops straight into Chrome DevTools (or `hermes-profile-transformer` for
7
+ * a Chrome trace), while the ranked self/total table is what an agent reads.
8
+ *
9
+ * `start`/`stop` hand the CDP socket to a detached holder process rather than
10
+ * closing it between the two commands: whether Hermes keeps sampling across a
11
+ * dropped debugger session is not contractual, and a holder makes it moot.
12
+ */
13
+ var __importDefault = (this && this.__importDefault) || function (mod) {
14
+ return (mod && mod.__esModule) ? mod : { "default": mod };
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.analyzeCpuProfile = analyzeCpuProfile;
18
+ exports.profileJsRecord = profileJsRecord;
19
+ exports.profileJsStart = profileJsStart;
20
+ exports.profileJsStop = profileJsStop;
21
+ exports.profileJsHold = profileJsHold;
22
+ const child_process_1 = require("child_process");
23
+ const node_fs_1 = require("node:fs");
24
+ const node_os_1 = __importDefault(require("node:os"));
25
+ const node_path_1 = __importDefault(require("node:path"));
26
+ const output_js_1 = require("../output.js");
27
+ const bootstrap_js_1 = require("../drivers/bootstrap.js");
28
+ const metro_cdp_js_1 = require("../drivers/metro-cdp.js");
29
+ const stats_js_1 = require("../stats.js");
30
+ /** Hermes marks its own frames with brackets; anything else is app code. */
31
+ function classifyFrame(name) {
32
+ if (/^\[GC\b/i.test(name) || /garbage collector/i.test(name))
33
+ return 'gc';
34
+ if (name === '[root]' || name === '(root)' || name === '(program)' || name === '(idle)') {
35
+ return 'idle';
36
+ }
37
+ return name.startsWith('[') || name.startsWith('(') ? 'idle' : 'named';
38
+ }
39
+ function shortFile(url) {
40
+ const u = url ?? '';
41
+ return u.includes('/') ? u.slice(u.lastIndexOf('/') + 1) : u;
42
+ }
43
+ function frameKey(frame) {
44
+ const line = frame.lineNumber !== undefined ? `:${frame.lineNumber + 1}` : '';
45
+ return `${frame.functionName || '(anonymous)'} ${shortFile(frame.url)}${line}`;
46
+ }
47
+ /**
48
+ * Self time is the sample's own node; total time credits every function on the
49
+ * stack once per sample, so a recursive function is not counted twice for the
50
+ * same sample.
51
+ */
52
+ function analyzeCpuProfile(profile, top) {
53
+ const byId = new Map();
54
+ const parentOf = new Map();
55
+ for (const node of profile.nodes) {
56
+ byId.set(node.id, node);
57
+ for (const child of node.children ?? [])
58
+ parentOf.set(child, node.id);
59
+ }
60
+ const samples = profile.samples ?? [];
61
+ const deltas = profile.timeDeltas ?? [];
62
+ const self = new Map();
63
+ const total = new Map();
64
+ const hits = new Map();
65
+ const label = new Map();
66
+ const add = (map, key, v) => {
67
+ map.set(key, (map.get(key) ?? 0) + v);
68
+ };
69
+ for (let i = 0; i < samples.length; i++) {
70
+ const node = byId.get(samples[i]);
71
+ if (!node)
72
+ continue;
73
+ const deltaUs = deltas[i] ?? 0;
74
+ const key = frameKey(node.callFrame);
75
+ label.set(key, node.callFrame);
76
+ add(self, key, deltaUs);
77
+ add(hits, key, 1);
78
+ const seen = new Set();
79
+ let cursor = node.id;
80
+ while (cursor !== undefined) {
81
+ const cur = byId.get(cursor);
82
+ if (!cur)
83
+ break;
84
+ const k = frameKey(cur.callFrame);
85
+ label.set(k, cur.callFrame);
86
+ if (!seen.has(k)) {
87
+ seen.add(k);
88
+ add(total, k, deltaUs);
89
+ }
90
+ cursor = parentOf.get(cursor);
91
+ }
92
+ }
93
+ const totalUs = [...self.values()].reduce((a, b) => a + b, 0);
94
+ // Split before ranking: a top-30 list assembled from 5% of the samples is
95
+ // noise dressed as a finding, and the reader cannot tell without this.
96
+ const buckets = { gc: 0, idle: 0, named: 0 };
97
+ for (const [key, us] of self.entries()) {
98
+ buckets[classifyFrame(label.get(key).functionName || '(anonymous)')] += us;
99
+ }
100
+ const share = (v) => (totalUs > 0 ? (0, stats_js_1.round)((v / totalUs) * 100, 1) : 0);
101
+ const attribution = {
102
+ namedJsPercent: share(buckets.named),
103
+ gcPercent: share(buckets.gc),
104
+ idlePercent: share(buckets.idle),
105
+ };
106
+ const notes = [];
107
+ if (totalUs > 0 && attribution.namedJsPercent < 25) {
108
+ notes.push({
109
+ code: 'low-attribution',
110
+ message: `Only ${attribution.namedJsPercent}% of sampled time landed in a named JS function ` +
111
+ `(${attribution.idlePercent}% with an empty JS stack, ${attribution.gcPercent}% in GC). ` +
112
+ `The ranking below is built on what little is left and should not be trusted. ` +
113
+ `A large empty-stack share means the JS thread was idle when sampled — which is itself ` +
114
+ `a result: the bottleneck is not JS. A large GC share is a memory finding; follow it ` +
115
+ `with \`profile memory --track\` rather than a function ranking.`,
116
+ namedJsPercent: attribution.namedJsPercent,
117
+ gcPercent: attribution.gcPercent,
118
+ idlePercent: attribution.idlePercent,
119
+ });
120
+ }
121
+ const ranked = [...self.entries()]
122
+ .map(([key, selfUs]) => {
123
+ const frame = label.get(key);
124
+ const file = shortFile(frame.url) || '(unknown)';
125
+ return {
126
+ name: frame.functionName || '(anonymous)',
127
+ location: frame.lineNumber !== undefined ? `${file}:${frame.lineNumber + 1}` : file,
128
+ selfMs: (0, stats_js_1.round)(selfUs / 1000),
129
+ totalMs: (0, stats_js_1.round)((total.get(key) ?? 0) / 1000),
130
+ selfPercent: totalUs > 0 ? (0, stats_js_1.round)((selfUs / totalUs) * 100) : 0,
131
+ samples: hits.get(key) ?? 0,
132
+ };
133
+ })
134
+ .sort((a, b) => b.selfMs - a.selfMs);
135
+ const sortedDeltas = deltas.filter((d) => d > 0).sort((a, b) => a - b);
136
+ return {
137
+ durationMs: (0, stats_js_1.round)((profile.endTime - profile.startTime) / 1000),
138
+ sampleCount: samples.length,
139
+ medianSampleIntervalMs: sortedDeltas.length > 0 ? (0, stats_js_1.round)(sortedDeltas[Math.floor(sortedDeltas.length / 2)] / 1000) : 0,
140
+ attribution,
141
+ top: ranked.slice(0, top),
142
+ omitted: Math.max(0, ranked.length - top),
143
+ notes,
144
+ };
145
+ }
146
+ async function connect(sessionName, cdpOpts) {
147
+ const platform = await (0, bootstrap_js_1.detectPlatform)(sessionName).catch(() => undefined);
148
+ const deviceId = sessionName !== 'default' ? sessionName : undefined;
149
+ const port = await (0, metro_cdp_js_1.resolveMetroPort)({ port: cdpOpts.port, deviceId, platform });
150
+ const client = new metro_cdp_js_1.MetroCdpClient();
151
+ await client.connect({ port, deviceId, platform, targetIndex: cdpOpts.targetIndex });
152
+ return client;
153
+ }
154
+ async function startSampling(client) {
155
+ // React Native's Fusebox CDP backend answers `Profiler.enable` with
156
+ // -32601 Unsupported method, but implements `Profiler.start` perfectly well.
157
+ // Enabling is a courtesy to backends that require it, never a precondition.
158
+ await client.send('Profiler.enable').catch(() => undefined);
159
+ await client.send('Profiler.start');
160
+ }
161
+ async function stopSampling(client) {
162
+ const res = await client.send('Profiler.stop');
163
+ if (!res?.profile?.nodes) {
164
+ throw new Error('Profiler.stop returned no profile — the runtime may not be Hermes, or sampling never started');
165
+ }
166
+ return res.profile;
167
+ }
168
+ function defaultOut() {
169
+ return node_path_1.default.join(node_os_1.default.tmpdir(), `js-${new Date().toISOString().replace(/[:.]/g, '-')}.cpuprofile`);
170
+ }
171
+ async function writeProfile(out, profile) {
172
+ await node_fs_1.promises.mkdir(node_path_1.default.dirname(out), { recursive: true });
173
+ await node_fs_1.promises.writeFile(out, JSON.stringify(profile));
174
+ }
175
+ function printSummary(summary, out) {
176
+ console.log(`profile js — ${summary.durationMs}ms, ${summary.sampleCount} samples ` +
177
+ `(~${summary.medianSampleIntervalMs}ms apart)`);
178
+ const a = summary.attribution;
179
+ console.log(` sampled time: ${a.namedJsPercent}% named JS ${a.gcPercent}% GC ` +
180
+ `${a.idlePercent}% empty JS stack`);
181
+ console.log(`\n ${'self'.padStart(9)} ${'total'.padStart(9)} ${'%'.padStart(5)} function`);
182
+ for (const f of summary.top) {
183
+ console.log(` ${`${f.selfMs}ms`.padStart(9)} ${`${f.totalMs}ms`.padStart(9)} ` +
184
+ `${f.selfPercent.toFixed(1).padStart(5)} ${f.name} ${f.location}`);
185
+ }
186
+ if (summary.omitted > 0)
187
+ console.log(` ... ${summary.omitted} more (raise --top)`);
188
+ for (const note of summary.notes)
189
+ console.log(`\n note [${note.code}]: ${note.message}`);
190
+ console.log(`\n raw profile → ${out}`);
191
+ console.log(' open it in Chrome DevTools (Performance → Load profile), or convert with ' +
192
+ '`npx hermes-profile-transformer`.');
193
+ }
194
+ async function profileJsRecord(opts, sessionName, jsOpts) {
195
+ const out = jsOpts.out ?? defaultOut();
196
+ let client;
197
+ try {
198
+ client = await connect(sessionName, jsOpts);
199
+ await startSampling(client);
200
+ await new Promise((r) => setTimeout(r, (jsOpts.durationSec ?? 10) * 1000));
201
+ const profile = await stopSampling(client);
202
+ await writeProfile(out, profile);
203
+ const summary = analyzeCpuProfile(profile, jsOpts.top ?? 20);
204
+ if (opts.json)
205
+ (0, output_js_1.printData)({ status: 'ok', out, ...summary }, opts);
206
+ else
207
+ printSummary(summary, out);
208
+ return 0;
209
+ }
210
+ catch (err) {
211
+ (0, output_js_1.printError)(`profile js record — ${err instanceof Error ? err.message : String(err)}`, opts);
212
+ return 1;
213
+ }
214
+ finally {
215
+ client?.close();
216
+ }
217
+ }
218
+ // ── start / stop via a detached holder ────────────────────────────────────────
219
+ function stateDir() {
220
+ return node_path_1.default.join(node_os_1.default.homedir(), '.conductor', 'js-profiles');
221
+ }
222
+ const paths = (session) => ({
223
+ state: node_path_1.default.join(stateDir(), `${session}.json`),
224
+ stop: node_path_1.default.join(stateDir(), `${session}.stop`),
225
+ done: node_path_1.default.join(stateDir(), `${session}.done`),
226
+ });
227
+ async function readJson(file) {
228
+ try {
229
+ return JSON.parse(await node_fs_1.promises.readFile(file, 'utf8'));
230
+ }
231
+ catch {
232
+ return null;
233
+ }
234
+ }
235
+ async function profileJsStart(opts, sessionName, jsOpts) {
236
+ const p = paths(sessionName);
237
+ await node_fs_1.promises.mkdir(stateDir(), { recursive: true });
238
+ const existing = await readJson(p.state);
239
+ if (existing) {
240
+ (0, output_js_1.printError)(`profile js start — already sampling (holder pid ${existing.pid}, since ${existing.startedAt}). ` +
241
+ 'Run `conductor profile js stop` first.', opts);
242
+ return 1;
243
+ }
244
+ const out = jsOpts.out ?? defaultOut();
245
+ await node_fs_1.promises.rm(p.stop, { force: true });
246
+ await node_fs_1.promises.rm(p.done, { force: true });
247
+ const args = [process.argv[1], 'profile', 'js', '_hold', '--out', out];
248
+ if (sessionName !== 'default')
249
+ args.push('--device', sessionName);
250
+ if (jsOpts.port !== undefined)
251
+ args.push('--port', String(jsOpts.port));
252
+ if (jsOpts.targetIndex !== undefined)
253
+ args.push('--target', String(jsOpts.targetIndex));
254
+ const child = (0, child_process_1.spawn)(process.execPath, args, { detached: true, stdio: 'ignore' });
255
+ child.unref();
256
+ // The holder writes the state file once sampling is actually running, so a
257
+ // failure to attach surfaces here rather than at `stop`.
258
+ const deadline = Date.now() + 15000;
259
+ while (Date.now() < deadline) {
260
+ const state = await readJson(p.state);
261
+ if (state) {
262
+ if (opts.json)
263
+ (0, output_js_1.printData)({ status: 'ok', ...state }, opts);
264
+ else
265
+ (0, output_js_1.printSuccess)(`profile js start — sampling (holder pid ${state.pid})`, opts);
266
+ return 0;
267
+ }
268
+ const done = await readJson(p.done);
269
+ if (done?.error) {
270
+ (0, output_js_1.printError)(`profile js start — ${done.error}`, opts);
271
+ await node_fs_1.promises.rm(p.done, { force: true });
272
+ return 1;
273
+ }
274
+ await new Promise((r) => setTimeout(r, 100));
275
+ }
276
+ (0, output_js_1.printError)('profile js start — holder did not report back within 15s', opts);
277
+ return 1;
278
+ }
279
+ async function profileJsStop(opts, sessionName, jsOpts) {
280
+ const p = paths(sessionName);
281
+ const state = await readJson(p.state);
282
+ if (!state) {
283
+ (0, output_js_1.printError)('profile js stop — not sampling (run `conductor profile js start` first)', opts);
284
+ return 1;
285
+ }
286
+ await node_fs_1.promises.writeFile(p.stop, '');
287
+ const deadline = Date.now() + 30000;
288
+ while (Date.now() < deadline) {
289
+ const done = await readJson(p.done);
290
+ if (done) {
291
+ await node_fs_1.promises.rm(p.done, { force: true });
292
+ await node_fs_1.promises.rm(p.state, { force: true });
293
+ if (done.error) {
294
+ (0, output_js_1.printError)(`profile js stop — ${done.error}`, opts);
295
+ return 1;
296
+ }
297
+ const profile = await readJson(done.out);
298
+ if (!profile) {
299
+ (0, output_js_1.printError)(`profile js stop — could not read ${done.out}`, opts);
300
+ return 1;
301
+ }
302
+ const summary = analyzeCpuProfile(profile, jsOpts.top ?? 20);
303
+ if (opts.json)
304
+ (0, output_js_1.printData)({ status: 'ok', out: done.out, ...summary }, opts);
305
+ else
306
+ printSummary(summary, done.out);
307
+ return 0;
308
+ }
309
+ await new Promise((r) => setTimeout(r, 100));
310
+ }
311
+ (0, output_js_1.printError)('profile js stop — holder did not finish within 30s', opts);
312
+ return 1;
313
+ }
314
+ /**
315
+ * Internal: hold the CDP socket open for the duration of a start/stop pair.
316
+ * Not part of the public CLI surface.
317
+ */
318
+ async function profileJsHold(sessionName, jsOpts) {
319
+ const p = paths(sessionName);
320
+ await node_fs_1.promises.mkdir(stateDir(), { recursive: true });
321
+ const out = jsOpts.out ?? defaultOut();
322
+ let client;
323
+ try {
324
+ client = await connect(sessionName, jsOpts);
325
+ await startSampling(client);
326
+ await node_fs_1.promises.writeFile(p.state, JSON.stringify({ pid: process.pid, session: sessionName, out, startedAt: new Date().toISOString() }, null, 2));
327
+ }
328
+ catch (err) {
329
+ await node_fs_1.promises.writeFile(p.done, JSON.stringify({ error: err instanceof Error ? err.message : String(err) }));
330
+ client?.close();
331
+ return 1;
332
+ }
333
+ // Safety valve: never hold a debugger session open indefinitely.
334
+ const deadline = Date.now() + 15 * 60000;
335
+ let stopped = false;
336
+ while (Date.now() < deadline) {
337
+ const stopRequested = await node_fs_1.promises
338
+ .stat(p.stop)
339
+ .then(() => true)
340
+ .catch(() => false);
341
+ if (stopRequested) {
342
+ stopped = true;
343
+ break;
344
+ }
345
+ if (!client.isConnected())
346
+ break;
347
+ await new Promise((r) => setTimeout(r, 200));
348
+ }
349
+ try {
350
+ if (!client.isConnected())
351
+ throw new Error('the app disconnected from Metro while sampling');
352
+ const profile = await stopSampling(client);
353
+ await writeProfile(out, profile);
354
+ await node_fs_1.promises.writeFile(p.done, JSON.stringify({ out, stoppedByRequest: stopped }));
355
+ return 0;
356
+ }
357
+ catch (err) {
358
+ await node_fs_1.promises.writeFile(p.done, JSON.stringify({ error: err instanceof Error ? err.message : String(err) }));
359
+ return 1;
360
+ }
361
+ finally {
362
+ client?.close();
363
+ await node_fs_1.promises.rm(p.stop, { force: true });
364
+ await node_fs_1.promises.rm(p.state, { force: true });
365
+ }
366
+ }