@gravitylabsllc/porthole 0.1.0 → 0.2.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.
Files changed (55) hide show
  1. package/README.md +123 -0
  2. package/dist/adb.js +430 -21
  3. package/dist/adb.js.map +1 -1
  4. package/dist/args.js +144 -0
  5. package/dist/args.js.map +1 -0
  6. package/dist/capture.js +139 -30
  7. package/dist/capture.js.map +1 -1
  8. package/dist/cli.js +221 -62
  9. package/dist/cli.js.map +1 -1
  10. package/dist/device.js +337 -4
  11. package/dist/device.js.map +1 -1
  12. package/dist/index.js +2030 -377
  13. package/dist/index.js.map +1 -1
  14. package/dist/moment.js +240 -0
  15. package/dist/moment.js.map +1 -0
  16. package/dist/perfetto.js +826 -0
  17. package/dist/perfetto.js.map +1 -0
  18. package/dist/report.js +68 -7
  19. package/dist/report.js.map +1 -1
  20. package/dist/save.js +252 -0
  21. package/dist/save.js.map +1 -0
  22. package/dist/sessions.js +704 -0
  23. package/dist/sessions.js.map +1 -0
  24. package/dist/system.js +169 -0
  25. package/dist/system.js.map +1 -0
  26. package/dist/systrace.js +198 -0
  27. package/dist/systrace.js.map +1 -0
  28. package/dist/timeline.js +731 -29
  29. package/dist/timeline.js.map +1 -1
  30. package/dist/trace.js +317 -27
  31. package/dist/trace.js.map +1 -1
  32. package/dist/watermark.js +220 -0
  33. package/dist/watermark.js.map +1 -0
  34. package/package.json +10 -4
  35. package/src/adb.ts +583 -0
  36. package/src/args.ts +177 -0
  37. package/src/capture.ts +292 -0
  38. package/src/cli.ts +367 -0
  39. package/src/device.ts +635 -0
  40. package/src/index.ts +2545 -0
  41. package/src/moment.ts +306 -0
  42. package/src/perfetto.ts +972 -0
  43. package/src/report.ts +285 -0
  44. package/src/save.ts +322 -0
  45. package/src/sessions.ts +894 -0
  46. package/src/system.ts +221 -0
  47. package/src/systrace.ts +258 -0
  48. package/src/timeline.ts +1036 -0
  49. package/src/trace.ts +769 -0
  50. package/src/watermark.ts +337 -0
  51. package/ui/dist/assets/index-DtnyBXCM.css +1 -0
  52. package/ui/dist/assets/index-h7VNB9Fl.js +70 -0
  53. package/ui/dist/index.html +2 -2
  54. package/ui/dist/assets/index--1mlZuNZ.css +0 -1
  55. package/ui/dist/assets/index-BeVGHRFm.js +0 -68
@@ -0,0 +1,826 @@
1
+ // Copyright 2026 Gravity Labs
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { spawn } from "node:child_process";
4
+ import { randomBytes } from "node:crypto";
5
+ import { existsSync, readdirSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ /**
8
+ * A small set of questions asked of a system trace, and what the answers mean.
9
+ *
10
+ * Deliberately questions rather than a query interface. Handing an agent SQL
11
+ * over trace_processor recreates the problem `findings` exists to solve: a
12
+ * hundred tables, no idea which to reach for, and an answer assembled from
13
+ * whichever guess came back non-empty. These are the questions worth asking
14
+ * about a window Porthole has already said is interesting.
15
+ *
16
+ * The real value is not what they confirm but what they rule out. Porthole can
17
+ * say a frame was late and that composition dominated it; it cannot say whether
18
+ * the device was also starving the app of CPU, blocking it on I/O, or busy
19
+ * compiling its own bytecode. A trace can answer all three, and an answer of
20
+ * "no" to each is what turns a suspicion into a conclusion.
21
+ */
22
+ /** Rows come back from trace_processor as strings, including the numbers. */
23
+ const n = (value) => {
24
+ const parsed = Number(value);
25
+ return Number.isFinite(parsed) ? parsed : 0;
26
+ };
27
+ const ms = (nanos) => Math.round(nanos / 1e5) / 10;
28
+ /**
29
+ * GRA-113: which of these five can carry a `window` on the findings they
30
+ * produce, versus which can only ever be `spanning`.
31
+ *
32
+ * `jank`, `binder`, `render` and `slices` group real, individually-timestamped
33
+ * occurrences — a slice, a binder transaction, a frame's own deadline record —
34
+ * so `MIN(ts)`/`MAX(ts)` alongside their existing `GROUP BY` is the envelope
35
+ * those occurrences actually happened in, not an invented one. They are
36
+ * **point-placeable**, and their SQL below carries those two columns through
37
+ * to `interpret()`.
38
+ *
39
+ * `thread_states` is different in kind, not just missing a column: it answers
40
+ * "how much of the window did the main thread spend in each state", which is
41
+ * a duration summed across however many disjoint stretches the scheduler
42
+ * visited that state — there is no `ts` a single row could add that would
43
+ * mean anything, because the row is not about one occurrence. This is the
44
+ * ticket's own example of a finding that must never be drawn as a point, and
45
+ * every finding `interpret()` derives from it (`trace-main-thread-contention`)
46
+ * is unconditionally **spanning**.
47
+ */
48
+ export const QUESTIONS = [
49
+ {
50
+ id: "jank",
51
+ asks: "which frames missed their deadline, and by how much",
52
+ sql: `SELECT jank_type, COUNT(*) AS COUNT, MIN(dur) AS "MIN(dur)",
53
+ MAX(dur) AS "MAX(dur)", AVG(dur) AS "AVG(dur)",
54
+ MIN(ts) AS "MIN(ts)", MAX(ts) AS "MAX(ts)"
55
+ FROM actual_frame_timeline_slice
56
+ JOIN process USING(upid)
57
+ WHERE ts >= $from AND ts <= $to AND process.name = $package
58
+ GROUP BY jank_type ORDER BY MAX(dur) DESC`,
59
+ },
60
+ {
61
+ id: "thread_states",
62
+ asks: "whether the app was running, waiting for a CPU, or blocked",
63
+ sql: `SELECT thread.name AS thread_name, thread.is_main_thread AS is_main_thread,
64
+ thread_state.state AS state, thread_state.io_wait AS io_wait,
65
+ COUNT(*) AS COUNT, SUM(thread_state.dur) AS "SUM(dur)"
66
+ FROM thread_state
67
+ JOIN thread USING(utid)
68
+ JOIN process USING(upid)
69
+ WHERE thread_state.ts >= $from AND thread_state.ts <= $to
70
+ AND process.name = $package
71
+ GROUP BY 1, 2, 3, 4 ORDER BY SUM(thread_state.dur) DESC`,
72
+ },
73
+ {
74
+ id: "binder",
75
+ asks: "which other processes the app called into, and for how long",
76
+ // android_binder_txns lives in the standard library, not the base schema,
77
+ // so the module has to be pulled in or the query fails to compile.
78
+ sql: `INCLUDE PERFETTO MODULE android.binder;
79
+ SELECT COALESCE(server_process, 'unknown') AS target,
80
+ COUNT(*) AS COUNT, SUM(client_dur) AS "SUM(dur)",
81
+ MAX(client_dur) AS "MAX(dur)",
82
+ MIN(client_ts) AS "MIN(ts)", MAX(client_ts) AS "MAX(ts)"
83
+ FROM android_binder_txns
84
+ WHERE client_ts >= $from AND client_ts <= $to
85
+ AND client_process = $package
86
+ GROUP BY target ORDER BY SUM(client_dur) DESC LIMIT 20`,
87
+ },
88
+ {
89
+ id: "render",
90
+ asks: "what the render thread and the GPU were doing",
91
+ sql: `INCLUDE PERFETTO MODULE slices.with_context;
92
+ SELECT name, thread_name, COUNT(*) AS COUNT, SUM(dur) AS "SUM(dur)",
93
+ MIN(ts) AS "MIN(ts)", MAX(ts) AS "MAX(ts)"
94
+ FROM thread_slice
95
+ WHERE ts >= $from AND ts <= $to
96
+ AND process_name = $package
97
+ AND thread_name IN ('RenderThread', 'GPU completion', 'hwuiTask0', 'hwuiTask1')
98
+ GROUP BY 1, 2 ORDER BY SUM(dur) DESC LIMIT 30`,
99
+ },
100
+ {
101
+ id: "slices",
102
+ asks: "what the app was actually doing, by total time",
103
+ // self_dur is not a column, though the trace viewer shows it as one: it
104
+ // is dur minus whatever the slice's children took. Without it a parent
105
+ // that did nothing but call two slow children looks like the slow thing.
106
+ sql: `INCLUDE PERFETTO MODULE slices.with_context;
107
+ SELECT s.name AS name, COUNT(*) AS COUNT, SUM(s.dur) AS "SUM(dur)",
108
+ SUM(
109
+ s.dur - COALESCE(
110
+ (SELECT SUM(child.dur) FROM slice AS child WHERE child.parent_id = s.id), 0
111
+ )
112
+ ) AS "SUM(self_dur)",
113
+ MIN(s.ts) AS "MIN(ts)", MAX(s.ts) AS "MAX(ts)"
114
+ FROM thread_slice AS s
115
+ WHERE s.ts >= $from AND s.ts <= $to AND s.process_name = $package
116
+ GROUP BY 1 ORDER BY SUM(s.dur) DESC LIMIT 200`,
117
+ },
118
+ ];
119
+ /**
120
+ * Work in the app's process that the app did not write.
121
+ *
122
+ * These are the ones worth naming because a developer reading their own trace
123
+ * attributes everything in their process to their own code. ART compiling
124
+ * bytecode is not the app being slow; it is the app being new.
125
+ */
126
+ const NOT_YOUR_CODE = [
127
+ {
128
+ match: /^Compiling baseline|^JIT compiling|^Compile /i,
129
+ what: "ART compiling bytecode",
130
+ note: "This is a cold process compiling as it runs. It goes away once the profile is warm, " +
131
+ "so a first run after install is not representative of what users see.",
132
+ },
133
+ {
134
+ match: /^GC:|concurrent copying|^HeapTaskDaemon/i,
135
+ what: "garbage collection",
136
+ note: "Time here is a consequence of allocation rate, which `memory` reports.",
137
+ },
138
+ {
139
+ match: /^binder transaction/i,
140
+ what: "waiting on another process",
141
+ note: "The time was spent in whatever was called, not in the app.",
142
+ },
143
+ ];
144
+ /**
145
+ * The envelope of a set of rows' own `MIN(ts)`/`MAX(ts)`, converted through
146
+ * `toUptimeMs` — or undefined when there is nothing to place a window with:
147
+ * no rows, rows from a question that never selected `ts` at all (the
148
+ * `thread_states` case QUESTIONS' own comment explains), or a caller that
149
+ * gave `interpret` no way to convert boot-clock ns in the first place. Any of
150
+ * those is `place` below's cue to fall back to `spanning: true` — never a
151
+ * finding with neither.
152
+ */
153
+ function rowsWindow(rows, toUptimeMs) {
154
+ if (!toUptimeMs || rows.length === 0)
155
+ return undefined;
156
+ const mins = rows.map((r) => r["MIN(ts)"]).filter((v) => v !== undefined && v !== null);
157
+ const maxs = rows.map((r) => r["MAX(ts)"]).filter((v) => v !== undefined && v !== null);
158
+ if (mins.length === 0 || maxs.length === 0)
159
+ return undefined;
160
+ const from = toUptimeMs(Math.min(...mins.map(n)));
161
+ const to = toUptimeMs(Math.max(...maxs.map(n)));
162
+ return from === null || to === null ? undefined : { from, to };
163
+ }
164
+ /** `window` when one could be placed, `spanning: true` when it could not — the two states GRA-113 AC1 allows, and the only two a finding may ever carry. */
165
+ function place(window) {
166
+ return window ? { window } : { spanning: true };
167
+ }
168
+ /**
169
+ * Turns the rows into findings, in the same vocabulary the rest of the tools
170
+ * use — including declining to claim causation from adjacency.
171
+ *
172
+ * `toUptimeMs` is optional and, when omitted, every finding below still comes
173
+ * out `spanning: true` rather than lacking a placement entirely: a caller
174
+ * that has not wired up a converter (an older test fixture, `interpret`
175
+ * exercised directly) gets an honest "cannot be placed", never a silently
176
+ * missing field.
177
+ */
178
+ export function interpret(rows, toUptimeMs) {
179
+ const findings = [];
180
+ // --- what the frame timeline says --------------------------------------
181
+ const missed = (rows.jank ?? []).filter((r) => /missed|jank/i.test(String(r.jank_type ?? "")));
182
+ const worstMiss = missed.reduce((worst, r) => Math.max(worst, n(r["MAX(dur)"])), 0);
183
+ if (missed.length > 0) {
184
+ findings.push({
185
+ id: "trace-frame-deadline",
186
+ severity: "error",
187
+ confidence: "observed",
188
+ title: `the frame timeline recorded ${missed
189
+ .map((r) => `${r.COUNT}× ${r.jank_type}`)
190
+ .join(", ")}`,
191
+ detail: `Worst frame ${ms(worstMiss)}ms. This is Android's own classification, not an inference.`,
192
+ evidence: { worstMs: ms(worstMiss) },
193
+ ...place(rowsWindow(missed, toUptimeMs)),
194
+ });
195
+ }
196
+ // --- what the scheduler says, which is mostly used to rule things out ---
197
+ const states = rows.thread_states ?? [];
198
+ const mainRunnable = states.filter((r) => isMainThread(r) && isRunnable(r.state));
199
+ const mainIo = states.filter((r) => isMainThread(r) && isIoWait(r));
200
+ const runnableMs = ms(mainRunnable.reduce((sum, r) => sum + n(r["SUM(dur)"]), 0));
201
+ const ioMs = ms(mainIo.reduce((sum, r) => sum + n(r["SUM(dur)"]), 0));
202
+ if (states.length > 0) {
203
+ const starved = worstMiss > 0 && runnableMs > ms(worstMiss) * 0.2;
204
+ findings.push({
205
+ id: "trace-main-thread-contention",
206
+ severity: starved ? "warning" : "note",
207
+ confidence: "observed",
208
+ title: starved
209
+ ? `the main thread spent ${runnableMs}ms runnable but not scheduled`
210
+ : `the main thread was not waiting for a CPU (${runnableMs}ms runnable)`,
211
+ detail: starved
212
+ ? "Something else on the device was holding the cores. The app's own work is not the whole story."
213
+ : `And not blocked on I/O (${ioMs}ms). Whatever made it late, it was work the app itself was doing.`,
214
+ evidence: { runnableMs, ioMs },
215
+ // Always spanning — see QUESTIONS' own comment on `thread_states`.
216
+ spanning: true,
217
+ });
218
+ }
219
+ // --- what was running, and how much of it the app did not write --------
220
+ const slices = rows.slices ?? [];
221
+ for (const rule of NOT_YOUR_CODE) {
222
+ const matched = slices.filter((r) => rule.match.test(String(r.name ?? "")));
223
+ if (matched.length === 0)
224
+ continue;
225
+ const total = ms(matched.reduce((sum, r) => sum + n(r["SUM(dur)"]), 0));
226
+ if (total < 5)
227
+ continue;
228
+ findings.push({
229
+ id: `trace-${rule.what.replace(/\s+/g, "-")}`,
230
+ severity: "note",
231
+ confidence: "observed",
232
+ title: `${total}ms of ${rule.what} in this window`,
233
+ detail: rule.note,
234
+ count: matched.reduce((sum, r) => sum + n(r.COUNT), 0),
235
+ evidence: { totalMs: total },
236
+ ...place(rowsWindow(matched, toUptimeMs)),
237
+ });
238
+ }
239
+ // --- who else the app was waiting on ------------------------------------
240
+ const binder = rows.binder ?? [];
241
+ if (binder.length > 0) {
242
+ const total = ms(binder.reduce((sum, r) => sum + n(r["SUM(dur)"]), 0));
243
+ const worst = binder.reduce((a, b) => (n(b["MAX(dur)"]) > n(a["MAX(dur)"]) ? b : a));
244
+ const worstMs = ms(n(worst["MAX(dur)"]));
245
+ // A long single transaction is a stall in someone else's process wearing
246
+ // the app's name; many short ones are chatter, which is a different fix.
247
+ const blocking = worstMs >= 8;
248
+ if (total >= 5) {
249
+ findings.push({
250
+ id: "trace-binder",
251
+ severity: blocking ? "warning" : "note",
252
+ confidence: "observed",
253
+ title: blocking
254
+ ? `a ${worstMs}ms call into ${worst.target} blocked the app`
255
+ : `${total}ms across ${binder.length} process(es) the app called into`,
256
+ detail: blocking
257
+ ? "The time was spent in the other process, not in this one. Nothing in the app's own " +
258
+ "code will make it faster; the call has to move off the critical path."
259
+ : `Busiest: ${worst.target}. Short and frequent rather than blocking.`,
260
+ count: binder.reduce((sum, r) => sum + n(r.COUNT), 0),
261
+ evidence: { totalMs: total, worstMs, worstTarget: String(worst.target ?? "") },
262
+ // The blocking case names one target's own group of calls; chatter
263
+ // summarises every target, so its window is the envelope of all of them.
264
+ ...place(rowsWindow(blocking ? [worst] : binder, toUptimeMs)),
265
+ });
266
+ }
267
+ }
268
+ // --- the half of the frame that is not the main thread -------------------
269
+ const render = rows.render ?? [];
270
+ if (render.length > 0) {
271
+ const total = ms(render.reduce((sum, r) => sum + n(r["SUM(dur)"]), 0));
272
+ const worst = render.reduce((a, b) => (n(b["SUM(dur)"]) > n(a["SUM(dur)"]) ? b : a));
273
+ findings.push({
274
+ id: "trace-render",
275
+ severity: "note",
276
+ confidence: "observed",
277
+ title: `${total}ms on the render path, mostly ${worst.name}`,
278
+ detail: "Work after the main thread has handed the frame over. Large numbers here point at " +
279
+ "overdraw, an expensive shader or a big texture upload rather than at composition — " +
280
+ "and `recompositions` will have nothing to say about any of them.",
281
+ count: render.reduce((sum, r) => sum + n(r.COUNT), 0),
282
+ evidence: { totalMs: total, worst: String(worst.name ?? "") },
283
+ ...place(rowsWindow(render, toUptimeMs)),
284
+ });
285
+ }
286
+ return findings.sort((a, b) => rank(b.severity) - rank(a.severity));
287
+ }
288
+ /**
289
+ * Whether a row is the app's main thread.
290
+ *
291
+ * `is_main_thread` is the trace's own answer and needs no guessing, but rows
292
+ * exported from the trace viewer carry a process name instead, so both are
293
+ * accepted. Asking neither — which is what this did, by reading a column the
294
+ * query never selected — makes every main-thread reading come back 0ms, and
295
+ * 0ms runnable reads as "the scheduler was not the problem".
296
+ */
297
+ function isMainThread(row) {
298
+ const flag = row.is_main_thread;
299
+ if (flag !== undefined && flag !== null)
300
+ return String(flag) === "1" || flag === true;
301
+ const thread = String(row.thread_name ?? "");
302
+ const process = String(row["ANY(process_name)"] ?? row.process_name ?? "");
303
+ if (!thread || !process)
304
+ return false;
305
+ // "com.example.shop" arrives as "om.example.shop": comm is 16 bytes with a
306
+ // terminator, so a long package name loses its leading characters.
307
+ return process.endsWith(thread) || thread.endsWith(process);
308
+ }
309
+ /**
310
+ * Ready to run, and not running.
311
+ *
312
+ * trace_processor returns the kernel's letters — R, R+ — while the trace viewer
313
+ * spells them out. Matching only the spelled-out form meant the letters never
314
+ * matched anything, so contention was invisible on every real trace and visible
315
+ * only in the fixtures.
316
+ */
317
+ function isRunnable(state) {
318
+ const value = String(state ?? "");
319
+ return value === "R" || value === "R+" || value.startsWith("Runnable");
320
+ }
321
+ /** Blocked in the kernel on I/O: D with the io_wait flag, or the long name. */
322
+ function isIoWait(row) {
323
+ const state = String(row.state ?? "");
324
+ if (/Uninterruptible Sleep \(IO\)/i.test(state))
325
+ return true;
326
+ return state.startsWith("D") && String(row.io_wait ?? "") === "1";
327
+ }
328
+ const rank = (severity) => severity === "error" ? 3 : severity === "warning" ? 2 : 1;
329
+ // ---------------------------------------------------------------------------
330
+ // running them
331
+ // ---------------------------------------------------------------------------
332
+ /** trace_processor_shell, if the machine happens to have one. */
333
+ export function findTraceProcessor() {
334
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
335
+ const candidates = [
336
+ process.env.PORTHOLE_TRACE_PROCESSOR,
337
+ ...portholeCached(home),
338
+ join(home, ".perfetto", "trace_processor_shell"),
339
+ join(home, ".perfetto", "trace_processor_shell.exe"),
340
+ "/usr/local/bin/trace_processor_shell",
341
+ ].filter(Boolean);
342
+ return candidates.find((c) => existsSync(c)) ?? null;
343
+ }
344
+ /**
345
+ * Copies `portholeTraceProcessor` put in ~/.porthole, newest version first.
346
+ *
347
+ * Read rather than hardcoded so the MCP server does not have to be republished
348
+ * in lockstep with the plugin's pinned version — whatever the plugin fetched
349
+ * last is what gets used. An explicitly set PORTHOLE_TRACE_PROCESSOR still wins,
350
+ * and so does nothing at all: an empty or missing directory yields no candidates.
351
+ */
352
+ function portholeCached(home) {
353
+ const root = join(home, ".porthole", "trace-processor");
354
+ let versions;
355
+ try {
356
+ versions = readdirSync(root);
357
+ }
358
+ catch {
359
+ return [];
360
+ }
361
+ return versions
362
+ .sort(byVersionDescending)
363
+ .flatMap((v) => [
364
+ join(root, v, "trace_processor_shell"),
365
+ join(root, v, "trace_processor_shell.exe"),
366
+ ]);
367
+ }
368
+ /** v58.2 above v58.1 above v9.0 — numerically, so v10 does not sort under v9. */
369
+ function byVersionDescending(a, b) {
370
+ const parts = (v) => v.replace(/^v/, "").split(".").map(Number);
371
+ const [ax, bx] = [parts(a), parts(b)];
372
+ for (let i = 0; i < Math.max(ax.length, bx.length); i++) {
373
+ const diff = (bx[i] ?? 0) - (ax[i] ?? 0);
374
+ if (diff !== 0 && !Number.isNaN(diff))
375
+ return diff;
376
+ }
377
+ return b.localeCompare(a);
378
+ }
379
+ /**
380
+ * The one line of trace_processor's stderr worth repeating.
381
+ *
382
+ * Everything it says goes to stderr, most of it progress — load percentages and
383
+ * timestamped `file.cc:NN` chatter. Taking the first line reported "Loading
384
+ * trace: 0.00 MB" as the reason a question failed, which is not a reason.
385
+ */
386
+ export function why(stderr, error) {
387
+ const lines = (stderr ?? "")
388
+ .split(/\r?\n/)
389
+ .map((l) => l.replace(/^\[[\d.]+\]\s+\S+?\.cc:\d+\s*/, "").trim())
390
+ .filter((l) => l.length > 0 && !l.startsWith("Loading trace:"));
391
+ const complaint = lines.find((l) => /error|unable|no such|syntax|failed/i.test(l));
392
+ return (complaint ?? lines[lines.length - 1] ?? error?.message ?? "failed").slice(0, 200);
393
+ }
394
+ /**
395
+ * trace_processor prints CSV, not TSV.
396
+ *
397
+ * Splitting on tabs produced exactly one column per row whose key was the
398
+ * entire header line, and every reading of it came back zero — which looked
399
+ * like a quiet app rather than a parser that had never worked. The unit tests
400
+ * did not catch it because they run on JSON exported from the trace viewer,
401
+ * which is the right data in the wrong shape.
402
+ */
403
+ export function parseRows(stdout) {
404
+ const lines = stdout.trim().split(/\r?\n/);
405
+ if (lines.length < 2)
406
+ return [];
407
+ const header = parseCsvLine(lines[0]);
408
+ const rows = [];
409
+ // A row is not a line. trace_processor writes an embedded newline inside
410
+ // a quoted value literally, so a slice named across two lines arrives as
411
+ // two lines, and reading each as its own row produced one truncated row
412
+ // and one garbage row where there should have been one. Lines are joined
413
+ // until the row has as many cells as the header — the one fact the stream
414
+ // does give reliably, since every statement prints every column. That
415
+ // leaves the single-column case ambiguous by construction (a one-cell row
416
+ // is complete after one line whatever it contains), which is the price of
417
+ // a writer that neither escapes quotes nor terminates rows.
418
+ let pending = null;
419
+ const emit = (text) => {
420
+ const cells = parseCsvLine(text);
421
+ rows.push(Object.fromEntries(header.map((key, i) => [key, cells[i] ?? null])));
422
+ };
423
+ for (const line of lines.slice(1)) {
424
+ if (pending === null) {
425
+ if (line.length === 0)
426
+ continue;
427
+ pending = line;
428
+ }
429
+ else {
430
+ pending += "\n" + line;
431
+ }
432
+ if (parseCsvLine(pending).length >= header.length) {
433
+ emit(pending);
434
+ pending = null;
435
+ }
436
+ }
437
+ if (pending !== null)
438
+ emit(pending);
439
+ return rows;
440
+ }
441
+ /**
442
+ * One CSV row, tolerating trace_processor's quoting.
443
+ *
444
+ * It does not double the quotes inside a quoted field — `he said "hi"` comes
445
+ * out as `"he said "hi""` — so a strict reader either fails or truncates. What
446
+ * is unambiguous is where a field ends: at a quote followed by a comma, or a
447
+ * quote at the end of the line. Everything between is the value.
448
+ */
449
+ function parseCsvLine(line) {
450
+ const cells = [];
451
+ let i = 0;
452
+ while (i <= line.length) {
453
+ if (line[i] === '"') {
454
+ let end = i + 1;
455
+ while (end < line.length && !(line[end] === '"' && (end === line.length - 1 || line[end + 1] === ","))) {
456
+ end++;
457
+ }
458
+ const value = line.slice(i + 1, end);
459
+ // trace_processor writes SQL NULL as the literal [NULL].
460
+ cells.push(value === "[NULL]" ? null : value);
461
+ i = end + 2;
462
+ }
463
+ else {
464
+ const comma = line.indexOf(",", i);
465
+ const end = comma === -1 ? line.length : comma;
466
+ cells.push(line.slice(i, end));
467
+ i = end + 1;
468
+ }
469
+ if (i > line.length)
470
+ break;
471
+ }
472
+ return cells;
473
+ }
474
+ /** Exported so tests can build a marker line without duplicating the format. */
475
+ export const MARKER_PREFIX = "porthole:";
476
+ /**
477
+ * The text a marker statement selects: the prefix, a nonce, the question id.
478
+ *
479
+ * The nonce is what makes a marker unforgeable. trace_processor's CSV neither
480
+ * escapes an embedded quote nor terminates a row, so a slice name can contain
481
+ * a quote followed by a newline and put whatever it likes on a line of its
482
+ * own — including the exact two lines a marker prints. A fixed marker text is
483
+ * therefore reachable from `Trace.beginSection`: shown against the real
484
+ * binary, where a forged pair inside one question's data re-keyed its rows as
485
+ * the next question's answer and nothing said so. A slice name captured
486
+ * before this process started cannot contain sixteen hex characters chosen
487
+ * after it, which closes the whole class rather than the one shape tested.
488
+ */
489
+ export function markerText(nonce, id) {
490
+ return `${MARKER_PREFIX}${nonce}:${id}`;
491
+ }
492
+ /** Sixteen hex characters, fresh per script. */
493
+ export function newNonce() {
494
+ return randomBytes(8).toString("hex");
495
+ }
496
+ /**
497
+ * How long one trace_processor invocation gets before it is presumed wedged.
498
+ *
499
+ * 60s is generous against the numbers actually observed: the real pinned
500
+ * v58.2 binary loaded a 10.96MB capture in 0.27s (about 40MB/s), so even a
501
+ * 150MB capture — the "order of magnitude bigger than 10-16MB" a 120s
502
+ * recording was said to produce — should load in a handful of seconds.
503
+ * Overridable per call, and by PORTHOLE_TRACE_TIMEOUT_MS for whoever needs a
504
+ * shorter fuse without recompiling.
505
+ */
506
+ const DEFAULT_TIMEOUT_MS = Number(process.env.PORTHOLE_TRACE_TIMEOUT_MS) || 60_000;
507
+ /** A fresh copy each call: `.replace` on a shared `g` regex leaves `lastIndex` behind it. */
508
+ function includeModulePattern() {
509
+ return /^\s*INCLUDE\s+PERFETTO\s+MODULE\s+([\w.]+)\s*;\s*$/gim;
510
+ }
511
+ /**
512
+ * Pulls every `INCLUDE PERFETTO MODULE` out of the questions and deduplicates
513
+ * them, so a batched script declares each module once no matter how many
514
+ * questions need it — today that is `slices.with_context`, wanted by both
515
+ * `render` and `slices`.
516
+ *
517
+ * This is its own named, tested function rather than a side effect of
518
+ * building the batch script because it is not incidental cleanup: GRA-61
519
+ * (five more trace questions) and GRA-85 (a project's own question) both add
520
+ * to the question set, and both need this exact hoisting to keep working.
521
+ * Get it wrong here — say, by hoisting only the first module a question
522
+ * declares — and the failure will not show up until one of those tickets
523
+ * adds a question with two.
524
+ */
525
+ export function hoistModules(questions) {
526
+ const modules = [];
527
+ const seen = new Set();
528
+ const hoisted = questions.map((question) => {
529
+ let match;
530
+ const finder = includeModulePattern();
531
+ while ((match = finder.exec(question.sql))) {
532
+ if (!seen.has(match[1])) {
533
+ seen.add(match[1]);
534
+ modules.push(match[1]);
535
+ }
536
+ }
537
+ return { id: question.id, asks: question.asks, sql: question.sql.replace(includeModulePattern(), "").trim() };
538
+ });
539
+ return { modules, questions: hoisted };
540
+ }
541
+ /** `$from`/`$to`/`$package` substitution, factored out so batching and a single question share it. */
542
+ function substitute(sql, packageName, fromNs, toNs) {
543
+ return sql
544
+ .replace(/\$from/g, String(Math.round(fromNs)))
545
+ .replace(/\$to/g, String(Math.round(toNs)))
546
+ .replace(/\$package/g, `'${packageName.replace(/'/g, "''")}'`);
547
+ }
548
+ /**
549
+ * One script: the hoisted modules, then every question preceded by a marker
550
+ * that names it.
551
+ *
552
+ * The marker is its own statement — `SELECT 'porthole:<id>' AS marker` — not
553
+ * an extra column tacked onto the question's own SELECT. Tacking it on was
554
+ * the tempting shortcut and the one the sentinel-row approach is named for
555
+ * gone wrong: a column of literal values sits in the same CSV stream as
556
+ * whatever the question actually returns, and trace_processor's CSV neither
557
+ * escapes an embedded quote nor distinguishes a NULL from the literal text
558
+ * `[NULL]`. A slice name containing a quote, or a row that is `[NULL]` in
559
+ * every selected column, is indistinguishable from the marker under that
560
+ * scheme. Giving the marker its own statement instead means it is always its
561
+ * own block — one column literally named `marker`, one row.
562
+ *
563
+ * "Nothing a real question could produce by accident" turned out to be true
564
+ * only of accidents. A value can put the marker's two lines into the stream
565
+ * on purpose, or by the misfortune of a name containing a quote and a
566
+ * newline, because the writer escapes neither. So the marker also carries a
567
+ * nonce — see [markerText] — which no value in a trace recorded before this
568
+ * call can contain.
569
+ */
570
+ function buildScript(modules, questions, packageName, fromNs, toNs, nonce) {
571
+ const lines = [];
572
+ for (const module of modules)
573
+ lines.push(`INCLUDE PERFETTO MODULE ${module};`);
574
+ for (const question of questions) {
575
+ lines.push(`SELECT '${markerText(nonce, question.id).replace(/'/g, "''")}' AS marker;`);
576
+ lines.push(`${substitute(question.sql, packageName, fromNs, toNs)};`);
577
+ }
578
+ return lines.join("\n");
579
+ }
580
+ /**
581
+ * Walks stdout line by line looking for marker pairs, rather than
582
+ * pre-splitting the whole stream on blank lines.
583
+ *
584
+ * This used to split stdout on `/\r?\n\r?\n/` on the assumption that a blank
585
+ * line is always a statement boundary. It usually is, but trace_processor
586
+ * writes a field's embedded newline literally, inside the quotes, and
587
+ * `slices`' `s.name` is a developer's own atrace section name — arbitrary
588
+ * text reachable through `Trace.beginSection`. A value containing two
589
+ * consecutive newlines therefore contains what looks exactly like a block
590
+ * boundary, splitting that value's own data block in half: confirmed end to
591
+ * end against the real binary, where it silently truncated one question's row
592
+ * and then reported the next question as unanswered, because the leftover
593
+ * half of the corrupted block was mistaken for its marker.
594
+ *
595
+ * Scanning for the literal pair — a line that is exactly `"marker"`
596
+ * immediately followed by a line that is exactly the marker text for the
597
+ * expected id — finds the next boundary correctly no matter how many blank
598
+ * lines (or garbled pieces of a multi-line value) sit inside the block before
599
+ * it, because no match is accepted unless both lines match exactly.
600
+ *
601
+ * Exact is not the same as unforgeable. Scanning every line, rather than only
602
+ * block boundaries, means a value that contains the pair on lines of its own
603
+ * would be accepted mid-block — and a value can, since the writer escapes
604
+ * nothing. What stops it is the nonce in the marker text, not the scan: the
605
+ * expected id line includes sixteen characters chosen after the trace was
606
+ * recorded. Both the header token and the nonce-bearing id are checked;
607
+ * dropping either check is what the `matchBatch` tests guard against.
608
+ */
609
+ export function matchBatch(stdout, ids, nonce) {
610
+ const lines = stdout.split(/\r?\n/);
611
+ const rows = new Map();
612
+ const isMarkerFor = (i, id) => {
613
+ if (lines[i] !== '"marker"')
614
+ return false;
615
+ return lines[i + 1] === `"${markerText(nonce, id)}"`;
616
+ };
617
+ let cursor = 0;
618
+ let questionIndex = 0;
619
+ while (questionIndex < ids.length) {
620
+ let markerAt = -1;
621
+ for (let i = cursor; i < lines.length - 1; i++) {
622
+ if (isMarkerFor(i, ids[questionIndex])) {
623
+ markerAt = i;
624
+ break;
625
+ }
626
+ }
627
+ if (markerAt === -1)
628
+ break;
629
+ const dataStart = markerAt + 2;
630
+ const nextId = ids[questionIndex + 1];
631
+ let dataEnd = lines.length;
632
+ if (nextId !== undefined) {
633
+ for (let i = dataStart; i < lines.length - 1; i++) {
634
+ if (isMarkerFor(i, nextId)) {
635
+ dataEnd = i;
636
+ break;
637
+ }
638
+ }
639
+ }
640
+ const block = lines.slice(dataStart, dataEnd).join("\n").trim();
641
+ // A marker with nothing after it — the data block is empty — is what a
642
+ // mid-script failure or a killed process both look like from here;
643
+ // telling those apart is `askTrace`'s job, not this function's.
644
+ if (block.length === 0)
645
+ break;
646
+ rows.set(ids[questionIndex], parseRows(block));
647
+ cursor = dataEnd;
648
+ questionIndex += 1;
649
+ }
650
+ return { rows, answered: questionIndex };
651
+ }
652
+ /**
653
+ * One trace_processor_shell invocation, run asynchronously so it cannot
654
+ * freeze the rest of the server while the trace loads.
655
+ *
656
+ * `spawn`, not the `spawnSync` this replaced: the old code blocked Node's one
657
+ * thread for the entire run, which meant nothing read the device socket,
658
+ * nothing answered MCP, and the timeline WebSocket went silent for as long as
659
+ * loading took — five times, once per question, with no way back from a
660
+ * wedged binary except killing the server. The timeout below is that way
661
+ * back: no output within `timeoutMs` and the child is killed and the caller
662
+ * is told how long it waited and against which trace, rather than left to
663
+ * keep waiting on something that will never answer.
664
+ *
665
+ * Takes `args` rather than assuming `["query", "-f", "-", trace]` itself so
666
+ * this function can be exercised directly, against a real process, without
667
+ * needing a trace_processor-shaped binary to do it: the tests drive it with
668
+ * plain `cmd.exe`, which Windows will spawn directly the way `askTrace`
669
+ * spawns the real binary, and which can be told to succeed, fail or hang on
670
+ * demand. `askTrace` is still the only caller that decides what those args
671
+ * actually are for a real trace.
672
+ */
673
+ /**
674
+ * The `maxBuffer` the `spawnSync` this replaced enforced, carried forward:
675
+ * `spawn`'s streams have no such limit on their own, and a wedged or
676
+ * mistaken query that never stops producing rows would otherwise grow
677
+ * `stdout` without bound instead of failing loudly.
678
+ */
679
+ const MAX_STDOUT_BYTES = 32 * 1024 * 1024;
680
+ export function runScript(binary, args, sql, timeoutMs) {
681
+ return new Promise((resolvePromise) => {
682
+ const start = Date.now();
683
+ const child = spawn(binary, args);
684
+ child.stdout.setEncoding("utf8");
685
+ child.stderr.setEncoding("utf8");
686
+ let stdout = "";
687
+ let stderr = "";
688
+ let timedOut = false;
689
+ let settled = false;
690
+ const timer = setTimeout(() => {
691
+ timedOut = true;
692
+ child.kill();
693
+ }, timeoutMs);
694
+ const finish = (result) => {
695
+ if (settled)
696
+ return;
697
+ settled = true;
698
+ clearTimeout(timer);
699
+ resolvePromise({ ...result, elapsedMs: Date.now() - start });
700
+ };
701
+ child.stdout.on("data", (chunk) => {
702
+ if (settled)
703
+ return;
704
+ stdout += chunk;
705
+ if (stdout.length > MAX_STDOUT_BYTES) {
706
+ child.kill();
707
+ finish({
708
+ code: null,
709
+ stdout,
710
+ stderr,
711
+ timedOut: false,
712
+ spawnError: new Error(`trace_processor produced more than ${MAX_STDOUT_BYTES} bytes of stdout without finishing; ` +
713
+ "killed rather than let it grow without bound"),
714
+ });
715
+ }
716
+ });
717
+ child.stderr.on("data", (chunk) => {
718
+ if (!settled)
719
+ stderr += chunk;
720
+ });
721
+ // Writing to a child that never started, or that the timer above has
722
+ // already killed, throws EPIPE on the stream itself rather than through
723
+ // the promise this function returns — unhandled, that crashes the whole
724
+ // process over a condition 'close'/'error' below already report. This
725
+ // listener's only job is to stop node treating the write as a second,
726
+ // uncaught failure.
727
+ child.stdin.on("error", () => { });
728
+ child.stdin.write(sql);
729
+ child.stdin.end();
730
+ child.on("close", (code) => finish({ code, stdout, stderr, timedOut }));
731
+ child.on("error", (error) => finish({ code: null, stdout, stderr, timedOut, spawnError: error }));
732
+ });
733
+ }
734
+ /**
735
+ * The batching loop itself, taking `run` as a parameter rather than calling
736
+ * `runScript` directly.
737
+ *
738
+ * Everything this loop needs to prove — that a batch answers everything in
739
+ * one call when it can, that a failing question does not take the other four
740
+ * with it, that a timeout stops the whole call instead of retrying into the
741
+ * same hang — is a property of this loop, not of `spawn` or of
742
+ * trace_processor_shell. Testing it against the real binary is what caught
743
+ * the mid-script-abort behaviour in the first place, and a couple of tests
744
+ * still do that against a real capture. But a suite that can only prove
745
+ * "the other four still answer" by shipping a broken query at a 77MB
746
+ * platform-specific download is not a suite that runs everywhere the code
747
+ * does, so `run` is swappable: production wires up the real `runScript`,
748
+ * tests wire up a plain async function that speaks the same marker protocol
749
+ * without spawning anything trace_processor-shaped at all.
750
+ */
751
+ export async function runBatch(questions, modules, options, run) {
752
+ let pending = questions;
753
+ const rows = {};
754
+ const unanswered = [];
755
+ while (pending.length > 0) {
756
+ const nonce = newNonce();
757
+ const script = buildScript(modules, pending, options.packageName, options.fromNs, options.toNs, nonce);
758
+ const result = await run(options.binary, ["query", "-f", "-", options.trace], script, options.timeoutMs);
759
+ if (result.spawnError) {
760
+ // The binary itself did not run — a bad path, not a bad question.
761
+ // Every question in this batch is equally unanswered and retrying
762
+ // would fail the same way, so say so once each and stop.
763
+ for (const question of pending) {
764
+ unanswered.push(`${question.asks} — could not run trace_processor: ${result.spawnError.message}`);
765
+ }
766
+ break;
767
+ }
768
+ const { rows: batchRows, answered } = matchBatch(result.stdout, pending.map((q) => q.id), nonce);
769
+ for (const [id, questionRows] of batchRows) {
770
+ rows[id] = questionRows;
771
+ }
772
+ if (answered >= pending.length)
773
+ break;
774
+ if (result.timedOut) {
775
+ for (const question of pending.slice(answered)) {
776
+ unanswered.push(`${question.asks} — trace_processor did not answer within ${result.elapsedMs}ms querying ` +
777
+ `${options.trace}; it may be wedged, so nothing after it was retried`);
778
+ }
779
+ break;
780
+ }
781
+ const failed = pending[answered];
782
+ unanswered.push(`${failed.asks} — ${why(result.stderr, undefined)}`);
783
+ pending = pending.slice(answered + 1);
784
+ }
785
+ return { rows, unanswered };
786
+ }
787
+ /**
788
+ * Puts the five questions to a trace, scoped to one window and one process,
789
+ * in one trace_processor_shell invocation rather than five.
790
+ *
791
+ * Trace loading, not querying, is what a real capture costs — the fixtures
792
+ * in this repo are 10-16MB and the ticket that prompted this said a 120s
793
+ * capture runs an order of magnitude bigger. The code this replaced paid
794
+ * that load five times, once per question, synchronously, which is the
795
+ * compounding version of the same mistake: it also froze the one thread the
796
+ * rest of the MCP server runs on for as long as each load took.
797
+ *
798
+ * Substitution rather than bound parameters, as before: trace_processor's
799
+ * shell takes a file of SQL and no bindings. The package name is the only
800
+ * string that reaches it and it is quoted; the window bounds are numbers by
801
+ * the time they arrive.
802
+ *
803
+ * One script cannot isolate a failure by itself — confirmed against the real
804
+ * binary, not assumed: it aborts the entire run on the first statement that
805
+ * errors, so a naive concatenation answers zero of the four questions after
806
+ * a failing one, not four. That is why `runBatch` is a loop rather than one
807
+ * spawn: a failure removes the failed question from the batch, keeps
808
+ * whatever already answered, and reruns only the remainder. The trace
809
+ * reloads again, but only once per failure — the common case, all five
810
+ * compile, is still one load, and a bad question costs one extra load for
811
+ * the rest rather than four lost answers.
812
+ *
813
+ * A timeout is a different kind of event and is handled differently on
814
+ * purpose: it does not mean one question was bad, it means trace_processor
815
+ * itself is wedged, and rerunning the remainder would just wedge again. So a
816
+ * timeout ends the whole call — everything still pending is reported
817
+ * unanswered with one shared reason naming the trace and how long it
818
+ * waited — rather than retrying into the same hang one question at a time.
819
+ */
820
+ export async function askTrace(options) {
821
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
822
+ const { modules, questions } = hoistModules(QUESTIONS);
823
+ const { rows, unanswered } = await runBatch(questions, modules, { ...options, timeoutMs }, runScript);
824
+ return { findings: interpret(rows, options.toUptimeMs), unanswered };
825
+ }
826
+ //# sourceMappingURL=perfetto.js.map