@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.
- package/README.md +123 -0
- package/dist/adb.js +430 -21
- package/dist/adb.js.map +1 -1
- package/dist/args.js +144 -0
- package/dist/args.js.map +1 -0
- package/dist/capture.js +139 -30
- package/dist/capture.js.map +1 -1
- package/dist/cli.js +221 -62
- package/dist/cli.js.map +1 -1
- package/dist/device.js +337 -4
- package/dist/device.js.map +1 -1
- package/dist/index.js +2030 -377
- package/dist/index.js.map +1 -1
- package/dist/moment.js +240 -0
- package/dist/moment.js.map +1 -0
- package/dist/perfetto.js +826 -0
- package/dist/perfetto.js.map +1 -0
- package/dist/report.js +68 -7
- package/dist/report.js.map +1 -1
- package/dist/save.js +252 -0
- package/dist/save.js.map +1 -0
- package/dist/sessions.js +704 -0
- package/dist/sessions.js.map +1 -0
- package/dist/system.js +169 -0
- package/dist/system.js.map +1 -0
- package/dist/systrace.js +198 -0
- package/dist/systrace.js.map +1 -0
- package/dist/timeline.js +731 -29
- package/dist/timeline.js.map +1 -1
- package/dist/trace.js +317 -27
- package/dist/trace.js.map +1 -1
- package/dist/watermark.js +220 -0
- package/dist/watermark.js.map +1 -0
- package/package.json +10 -4
- package/src/adb.ts +583 -0
- package/src/args.ts +177 -0
- package/src/capture.ts +292 -0
- package/src/cli.ts +367 -0
- package/src/device.ts +635 -0
- package/src/index.ts +2545 -0
- package/src/moment.ts +306 -0
- package/src/perfetto.ts +972 -0
- package/src/report.ts +285 -0
- package/src/save.ts +322 -0
- package/src/sessions.ts +894 -0
- package/src/system.ts +221 -0
- package/src/systrace.ts +258 -0
- package/src/timeline.ts +1036 -0
- package/src/trace.ts +769 -0
- package/src/watermark.ts +337 -0
- package/ui/dist/assets/index-DtnyBXCM.css +1 -0
- package/ui/dist/assets/index-h7VNB9Fl.js +70 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/index--1mlZuNZ.css +0 -1
- package/ui/dist/assets/index-BeVGHRFm.js +0 -68
package/dist/perfetto.js
ADDED
|
@@ -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
|