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