@milaboratories/pl-flight-recorder 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +119 -0
  2. package/dist/analyze.d.ts +133 -0
  3. package/dist/analyze.d.ts.map +1 -0
  4. package/dist/analyze.js +525 -0
  5. package/dist/analyze.js.map +1 -0
  6. package/dist/data_summary.d.ts +28 -0
  7. package/dist/data_summary.d.ts.map +1 -0
  8. package/dist/data_summary.js +73 -0
  9. package/dist/data_summary.js.map +1 -0
  10. package/dist/digest.d.ts +28 -0
  11. package/dist/digest.d.ts.map +1 -0
  12. package/dist/digest.js +55 -0
  13. package/dist/digest.js.map +1 -0
  14. package/dist/events.d.ts +89 -0
  15. package/dist/events.d.ts.map +1 -0
  16. package/dist/events.js +12 -0
  17. package/dist/events.js.map +1 -0
  18. package/dist/index.d.ts +13 -0
  19. package/dist/index.js +13 -0
  20. package/dist/instrument.d.ts +82 -0
  21. package/dist/instrument.d.ts.map +1 -0
  22. package/dist/instrument.js +313 -0
  23. package/dist/instrument.js.map +1 -0
  24. package/dist/recorder.d.ts +82 -0
  25. package/dist/recorder.d.ts.map +1 -0
  26. package/dist/recorder.js +293 -0
  27. package/dist/recorder.js.map +1 -0
  28. package/dist/redact.d.ts +53 -0
  29. package/dist/redact.d.ts.map +1 -0
  30. package/dist/redact.js +145 -0
  31. package/dist/redact.js.map +1 -0
  32. package/dist/report.d.ts +6 -0
  33. package/dist/report.d.ts.map +1 -0
  34. package/dist/report.js +377 -0
  35. package/dist/report.js.map +1 -0
  36. package/dist/rules.d.ts +73 -0
  37. package/dist/rules.d.ts.map +1 -0
  38. package/dist/rules.js +245 -0
  39. package/dist/rules.js.map +1 -0
  40. package/dist/sampler.d.ts +22 -0
  41. package/dist/sampler.d.ts.map +1 -0
  42. package/dist/sampler.js +33 -0
  43. package/dist/sampler.js.map +1 -0
  44. package/dist/sampler_thread.d.ts +1 -0
  45. package/dist/sampler_thread.js +41 -0
  46. package/dist/sampler_thread.js.map +1 -0
  47. package/dist/session.d.ts +40 -0
  48. package/dist/session.d.ts.map +1 -0
  49. package/dist/session.js +50 -0
  50. package/dist/session.js.map +1 -0
  51. package/dist/supervisor.d.ts +58 -0
  52. package/dist/supervisor.d.ts.map +1 -0
  53. package/dist/supervisor.js +108 -0
  54. package/dist/supervisor.js.map +1 -0
  55. package/package.json +43 -0
  56. package/src/analyze.test.ts +539 -0
  57. package/src/analyze.ts +795 -0
  58. package/src/data_summary.ts +110 -0
  59. package/src/digest.ts +49 -0
  60. package/src/events.ts +102 -0
  61. package/src/index.ts +104 -0
  62. package/src/instrument.ts +442 -0
  63. package/src/recorder.ts +397 -0
  64. package/src/redact.test.ts +155 -0
  65. package/src/redact.ts +213 -0
  66. package/src/report.ts +512 -0
  67. package/src/rules.test.ts +182 -0
  68. package/src/rules.ts +383 -0
  69. package/src/sampler.ts +40 -0
  70. package/src/sampler_thread.ts +45 -0
  71. package/src/session.ts +69 -0
  72. package/src/supervisor.ts +150 -0
package/src/analyze.ts ADDED
@@ -0,0 +1,795 @@
1
+ import path from "node:path";
2
+ import type {
3
+ CrashMarker,
4
+ FlightRecord,
5
+ MemorySnapshot,
6
+ SamplerRecord,
7
+ SessionEnvironment,
8
+ } from "./events";
9
+ import { SAMPLER_FILE_PREFIX, SESSION_END_RECORD, SESSION_RECORD } from "./events";
10
+ import { listSessions, readSession, sessionIdFromFile, sessionStartFromId } from "./recorder";
11
+ import { readCrashMarkers } from "./supervisor";
12
+ import { inputRowsMax, joinShapes, structuralFindings, type FindingSeverity } from "./rules";
13
+
14
+ /**
15
+ * Turns a flight log into an attributed cause.
16
+ *
17
+ * Three independent lines of evidence are combined: which operation was still
18
+ * running when the process died (an unmatched begin record), where memory
19
+ * actually went (resident growth across each completed operation, and which
20
+ * region grew — JS heap, off-heap buffers, or native), and what the join tree
21
+ * looked like before any data was touched. Any one alone is suggestive;
22
+ * together they name a specific call in a specific block.
23
+ */
24
+
25
+ export const THRESHOLDS = {
26
+ /** Fraction of the heap ceiling above which the heap counts as exhausted. */
27
+ heapPressure: 0.85,
28
+ nativeGrowthBytes: 512 * 1024 * 1024,
29
+ amplification: 10,
30
+ unboundedRows: 1_000_000,
31
+ returnedBytes: 256 * 1024 * 1024,
32
+ inlineEntries: 1_000_000,
33
+ stallMs: 2000,
34
+ /** Backward tolerance when matching a marker by time, for parent/worker clock drift. */
35
+ clockToleranceMs: 1000,
36
+ /** How many open sessions are considered as rivals for an unattributed marker. */
37
+ maxRivalSessions: 8,
38
+ /** A machine-memory claim needs the process to actually be large. */
39
+ machineRssShare: 0.25,
40
+ } as const;
41
+
42
+ export type Finding = {
43
+ rule: string;
44
+ severity: FindingSeverity;
45
+ detail: string;
46
+ seq?: number;
47
+ path?: string;
48
+ join?: string;
49
+ source?: string;
50
+ block?: string;
51
+ };
52
+
53
+ export type MemoryAnalysis = {
54
+ samplerPresent: boolean;
55
+ sampleCount: number;
56
+ rssSeries: { wall: number; rss?: number; freeMemory?: number }[];
57
+ peakRss: number;
58
+ rssAtDeath?: number;
59
+ rssGrowth: number;
60
+ heapUsedAtDeath?: number;
61
+ heapLimit?: number;
62
+ heapPressure?: number;
63
+ heapGrowth?: number;
64
+ externalGrowth?: number;
65
+ arrayBuffersGrowth?: number;
66
+ worstStallMs: number;
67
+ freeMemoryAtDeath?: number;
68
+ totalMemory?: number;
69
+ };
70
+
71
+ export type OperationSummary = {
72
+ op: string;
73
+ seq: number;
74
+ wall: number;
75
+ info: Record<string, unknown>;
76
+ end?: Record<string, unknown>;
77
+ ms?: number;
78
+ failed?: boolean;
79
+ rssDelta?: number;
80
+ heapDelta?: number;
81
+ };
82
+
83
+ export type RenderSummary = {
84
+ seq: number;
85
+ blockId?: string;
86
+ block?: string;
87
+ key?: string;
88
+ end?: boolean;
89
+ failed?: boolean;
90
+ ms?: number;
91
+ stats?: { serOutBytes?: number; serInBytes?: number; [key: string]: unknown };
92
+ };
93
+
94
+ export type Verdict = {
95
+ outcome: string;
96
+ peakRss: number;
97
+ where: string;
98
+ memoryRegion?: string;
99
+ likelyCause?: string;
100
+ summary: string;
101
+ };
102
+
103
+ export type SessionAnalysis = {
104
+ file: string;
105
+ sessionId: string;
106
+ crashed: boolean;
107
+ truncatedTail: boolean;
108
+ endedReason?: string;
109
+ crashMarker?: CrashMarker;
110
+ env?: SessionEnvironment;
111
+ role?: string;
112
+ meta?: Record<string, unknown>;
113
+ recordCount: number;
114
+ /**
115
+ * How many times the log rotated. Each rotation re-emits the session header,
116
+ * so environment and metadata survive, but operations older than the retained
117
+ * segments do not — which is why the count is reported rather than implied.
118
+ */
119
+ rotations: number;
120
+ memory: MemoryAnalysis;
121
+ /** Completed operations ranked by resident growth. */
122
+ attribution: OperationSummary[];
123
+ inFlight: OperationSummary[];
124
+ /** The innermost operation that started and never returned. */
125
+ inFlightAtDeath?: OperationSummary;
126
+ renders: RenderSummary[];
127
+ findings: Finding[];
128
+ verdict: Verdict;
129
+ timeline: Record<string, unknown>[];
130
+ };
131
+
132
+ /** Analyzes the newest crashed session in a directory, else the newest session. */
133
+ export function analyzeLatest(
134
+ dir: string,
135
+ options: { preferCrashed?: boolean } = {},
136
+ ): SessionAnalysis | undefined {
137
+ const { preferCrashed = true } = options;
138
+ const sessions = listSessions(dir);
139
+ if (sessions.length === 0) return undefined;
140
+ const target = (preferCrashed ? sessions.find((s) => s.crashed) : undefined) ?? sessions[0];
141
+ return analyzeSession(target.file, dir);
142
+ }
143
+
144
+ /** Analyzes one flight log, merging the sibling sampler series when present. */
145
+ export function analyzeSession(file: string, dir: string = path.dirname(file)): SessionAnalysis {
146
+ const { records, truncatedTail } = readSession(file);
147
+ const header = (records.find((r) => r.type === SESSION_RECORD) ?? {}) as FlightRecord & {
148
+ env?: SessionEnvironment;
149
+ role?: string;
150
+ meta?: Record<string, unknown>;
151
+ };
152
+ const sessionId = sessionIdFromFile(file);
153
+ const samples = readSamples(path.join(dir, `${SAMPLER_FILE_PREFIX}-${sessionId}.ndjson`));
154
+
155
+ const ended = records.find((r) => r.type === SESSION_END_RECORD);
156
+ const attribution = ended
157
+ ? { marker: undefined, ambiguous: false }
158
+ : findCrashMarker(dir, sessionId, records);
159
+ const crashMarker = attribution.marker;
160
+
161
+ const memory = analyzeMemory(records, samples, header.env);
162
+ const operations = pairOperations(records);
163
+ const inFlight = operations.filter((op) => !op.end);
164
+
165
+ const findings = [
166
+ ...classifyCrashMarker(crashMarker),
167
+ ...ambiguousMarkerFinding(attribution.ambiguous),
168
+ ...classifyMemory(memory, header.env, crashMarker),
169
+ ...collectStructural(records),
170
+ ...collectEmpirical(records, operations, definitionBySeq(records)),
171
+ ...stallFindings(memory),
172
+ ].sort(bySeverity);
173
+
174
+ return {
175
+ file,
176
+ sessionId,
177
+ crashed: !ended,
178
+ truncatedTail,
179
+ endedReason: ended?.reason as string | undefined,
180
+ crashMarker,
181
+ env: header.env,
182
+ role: header.role,
183
+ meta: header.meta,
184
+ recordCount: records.length,
185
+ rotations: records.filter((r) => r.type === SESSION_RECORD && r.continuation === true).length,
186
+ memory,
187
+ attribution: operations
188
+ .filter((op) => typeof op.rssDelta === "number")
189
+ .sort((lhs, rhs) => (rhs.rssDelta ?? 0) - (lhs.rssDelta ?? 0))
190
+ .slice(0, 12),
191
+ inFlight,
192
+ inFlightAtDeath: inFlight.at(-1),
193
+ renders: summarizeRenders(records),
194
+ findings,
195
+ verdict: buildVerdict({
196
+ crashed: !ended,
197
+ memory,
198
+ inFlight,
199
+ findings,
200
+ crashMarker,
201
+ blockOf: enclosingRenders(records),
202
+ }),
203
+ timeline: records.slice(-40).map(compactRecord),
204
+ };
205
+ }
206
+
207
+ /** Thousands separators, or `unknown` when the count was never observed. */
208
+ export function formatCount(value: number | undefined): string {
209
+ return typeof value === "number" ? value.toLocaleString("en-US") : "unknown";
210
+ }
211
+
212
+ /** Binary byte units, or `unknown`. */
213
+ export function formatBytes(value: number | undefined): string {
214
+ if (typeof value !== "number") return "unknown";
215
+ const units = ["B", "KiB", "MiB", "GiB", "TiB"];
216
+ let index = 0;
217
+ let scaled = Math.abs(value);
218
+ while (scaled >= 1024 && index < units.length - 1) {
219
+ scaled /= 1024;
220
+ index++;
221
+ }
222
+ const digits = scaled < 10 && index > 0 ? 1 : 0;
223
+ return `${value < 0 ? "-" : ""}${scaled.toFixed(digits)} ${units[index]}`;
224
+ }
225
+
226
+ // Internals
227
+
228
+ const CLOCK_TOLERANCE_MS = THRESHOLDS.clockToleranceMs;
229
+ const MAX_RIVAL_SESSIONS = THRESHOLDS.maxRivalSessions;
230
+
231
+ const SEVERITY_ORDER: Record<FindingSeverity, number> = {
232
+ critical: 0,
233
+ high: 1,
234
+ medium: 2,
235
+ low: 3,
236
+ };
237
+
238
+ const REGION_RULES = [
239
+ "js-heap-exhaustion-confirmed",
240
+ "js-heap-exhaustion",
241
+ "off-heap-buffer-growth",
242
+ "native-allocation-growth",
243
+ "machine-memory-exhausted",
244
+ ];
245
+
246
+ const CAUSE_RULES = [
247
+ "cross-join",
248
+ "axis-domain-mismatch",
249
+ "join-amplification",
250
+ "unbounded-getData",
251
+ "huge-inline-column",
252
+ ];
253
+
254
+ /**
255
+ * The marker for a session is the one whose assigned id names it.
256
+ *
257
+ * Failing that, a marker with no id is attributed by time: a session that died
258
+ * stopped writing, so the marker lands at or just after its last record, while a
259
+ * session that survived kept writing past it. That test only separates them when
260
+ * the other sessions actually did keep writing. When two sessions both look
261
+ * dead, no attribution is made at all — an unattributed marker is reported as
262
+ * such, which is honest, where naming the wrong session is not.
263
+ */
264
+ function findCrashMarker(
265
+ dir: string,
266
+ sessionId: string,
267
+ records: FlightRecord[],
268
+ ): { marker?: CrashMarker; ambiguous: boolean } {
269
+ const markers = readCrashMarkers(dir);
270
+ const assigned = markers.find((marker) => isAssigned(marker) && marker.sessionId === sessionId);
271
+ if (assigned) return { marker: assigned, ambiguous: false };
272
+
273
+ const thisStart = sessionStartFromId(sessionId);
274
+ const lastWall = records.at(-1)?.wall ?? 0;
275
+ const others = openSessionEnds(dir).filter((session) => session.sessionId !== sessionId);
276
+
277
+ for (const marker of markers) {
278
+ if (isAssigned(marker)) continue;
279
+ // A marker cannot predate the session it belongs to; the backward tolerance
280
+ // only absorbs clock drift between the parent and the worker.
281
+ if (marker.wall < Math.max(thisStart, lastWall - CLOCK_TOLERANCE_MS)) continue;
282
+ const rivals = others.filter(
283
+ (session) => marker.wall >= Math.max(session.start, session.lastWall - CLOCK_TOLERANCE_MS),
284
+ );
285
+ if (rivals.length > 0) return { marker: undefined, ambiguous: true };
286
+ return { marker, ambiguous: false };
287
+ }
288
+ return { marker: undefined, ambiguous: false };
289
+ }
290
+
291
+ /**
292
+ * An older recorder put a guess in `sessionId` and labelled it. Such an id is
293
+ * not identity, so it is read back as an unattributed marker.
294
+ */
295
+ function isAssigned(marker: CrashMarker): boolean {
296
+ return marker.sessionId !== undefined && marker.sessionIdSource !== "guessed";
297
+ }
298
+
299
+ /** Last recorded wall clock of every session that has no terminating record. */
300
+ function openSessionEnds(dir: string): { sessionId: string; start: number; lastWall: number }[] {
301
+ return listSessions(dir)
302
+ .filter((session) => session.crashed)
303
+ .slice(0, MAX_RIVAL_SESSIONS)
304
+ .map((session) => {
305
+ const id = sessionIdFromFile(session.file);
306
+ let lastWall = 0;
307
+ try {
308
+ lastWall = readSession(session.file).records.at(-1)?.wall ?? 0;
309
+ } catch {
310
+ // A session whose log cannot be read cannot rival anything.
311
+ }
312
+ return { sessionId: id, start: sessionStartFromId(id), lastWall };
313
+ });
314
+ }
315
+
316
+ function readSamples(file: string): SamplerRecord[] {
317
+ try {
318
+ return readSession(file).records as unknown as SamplerRecord[];
319
+ } catch {
320
+ return [];
321
+ }
322
+ }
323
+
324
+ function analyzeMemory(
325
+ records: FlightRecord[],
326
+ samples: SamplerRecord[],
327
+ env: SessionEnvironment | undefined,
328
+ ): MemoryAnalysis {
329
+ const selfSeries = records
330
+ .filter((record) => record.mem)
331
+ .map((record) => ({ wall: record.wall, ...record.mem! }));
332
+ const rssSeries = samples.length
333
+ ? samples.map((s) => ({ wall: s.wall, rss: s.rss, freeMemory: s.freeMemory }))
334
+ : selfSeries.map((s) => ({ wall: s.wall, rss: s.rss }));
335
+
336
+ const last = selfSeries.at(-1);
337
+ const lastSample = samples.at(-1);
338
+ const heapLimit = last?.heapLimit ?? env?.heapLimit;
339
+
340
+ return {
341
+ samplerPresent: samples.length > 0,
342
+ sampleCount: rssSeries.length,
343
+ rssSeries,
344
+ peakRss: Math.max(0, ...rssSeries.map((s) => s.rss ?? 0)),
345
+ rssAtDeath: lastSample?.rss ?? last?.rss,
346
+ rssGrowth: rssSeries.length ? (rssSeries.at(-1)?.rss ?? 0) - (rssSeries[0].rss ?? 0) : 0,
347
+ heapUsedAtDeath: last?.heapUsed,
348
+ heapLimit,
349
+ heapPressure:
350
+ last?.heapUsed && heapLimit ? Math.round((last.heapUsed / heapLimit) * 100) / 100 : undefined,
351
+ heapGrowth: growth(selfSeries, "heapUsed"),
352
+ externalGrowth: growth(selfSeries, "external"),
353
+ arrayBuffersGrowth: growth(selfSeries, "arrayBuffers"),
354
+ worstStallMs: Math.max(
355
+ 0,
356
+ ...records
357
+ .filter((record) => record.type === "mem-self")
358
+ .map((record) => (record.stallMs as number | undefined) ?? 0),
359
+ ),
360
+ freeMemoryAtDeath: lastSample?.freeMemory,
361
+ totalMemory: lastSample?.totalMemory ?? env?.totalMemory,
362
+ };
363
+ }
364
+
365
+ function growth(series: Record<string, number | undefined>[], key: string): number | undefined {
366
+ const values = series
367
+ .map((entry) => entry[key])
368
+ .filter((value): value is number => typeof value === "number");
369
+ return values.length ? values[values.length - 1] - values[0] : undefined;
370
+ }
371
+
372
+ // Pairs each begin record with its end or error by sequence number. Unmatched
373
+ // begins are what was running when the log stopped.
374
+ function pairOperations(records: FlightRecord[]): OperationSummary[] {
375
+ const begins = new Map<number, OperationSummary>();
376
+ const beginMemory = new Map<number, MemorySnapshot>();
377
+ const operations: OperationSummary[] = [];
378
+ for (const record of records) {
379
+ if (record.type.endsWith("-begin")) {
380
+ // A begin rewritten into a rotated segment repeats its sequence number;
381
+ // the operation is already known and must not be counted twice.
382
+ if (begins.has(record.seq)) continue;
383
+ const summary: OperationSummary = {
384
+ op: record.type.replace(/-begin$/, ""),
385
+ seq: record.seq,
386
+ wall: record.wall,
387
+ info: compactRecord(record),
388
+ };
389
+ begins.set(record.seq, summary);
390
+ if (record.mem) beginMemory.set(record.seq, record.mem);
391
+ operations.push(summary);
392
+ continue;
393
+ }
394
+ if (!record.type.endsWith("-end") && !record.type.endsWith("-error")) continue;
395
+ const summary = record.begin === undefined ? undefined : begins.get(record.begin);
396
+ if (!summary) continue;
397
+ summary.end = compactRecord(record);
398
+ summary.ms = record.ms as number | undefined;
399
+ summary.failed = record.type.endsWith("-error");
400
+ const beginMem = beginMemory.get(summary.seq);
401
+ if (beginMem && record.mem) {
402
+ summary.rssDelta = record.mem.rss - beginMem.rss;
403
+ summary.heapDelta = record.mem.heapUsed - beginMem.heapUsed;
404
+ }
405
+ }
406
+ return operations;
407
+ }
408
+
409
+ function collectStructural(records: FlightRecord[]): Finding[] {
410
+ const enclosing = enclosingRenders(records);
411
+ const out: Finding[] = [];
412
+ for (const record of records) {
413
+ // Rules run here rather than at record time, so they can be revised against
414
+ // logs that already exist and cost nothing on the hot path.
415
+ for (const finding of structuralFindings(recordedDef(record))) {
416
+ out.push({
417
+ rule: finding.rule,
418
+ severity: finding.severity,
419
+ detail: finding.detail,
420
+ path: finding.path,
421
+ join: finding.join,
422
+ source: record.type,
423
+ seq: record.seq,
424
+ block: (record.blockId as string | undefined) ?? enclosing.get(record.seq),
425
+ });
426
+ }
427
+ }
428
+ return out;
429
+ }
430
+
431
+ /**
432
+ * Maps each record's sequence number to the block whose render was open at that
433
+ * point. Driver calls carry no block identity of their own — the driver does not
434
+ * know which model asked — so the enclosing render span supplies it.
435
+ */
436
+ function enclosingRenders(records: FlightRecord[]): Map<number, string> {
437
+ const out = new Map<number, string>();
438
+ const open: { seq: number; blockId?: string }[] = [];
439
+ for (const record of records) {
440
+ if (record.type === "render-begin") {
441
+ // A render open across a rotation is written twice with one sequence
442
+ // number: once in the segment that was overwritten, once carried into the
443
+ // new one. Pushing both would leave a copy open after the render returned,
444
+ // and every later driver call would be blamed on a block that had finished.
445
+ if (!open.some((entry) => entry.seq === record.seq)) {
446
+ open.push({ seq: record.seq, blockId: record.blockId as string | undefined });
447
+ }
448
+ } else if (record.type === "render-end" || record.type === "render-error") {
449
+ const index = open.findIndex((entry) => entry.seq === record.begin);
450
+ if (index >= 0) open.splice(index, 1);
451
+ }
452
+ const innermost = open.at(-1);
453
+ if (innermost?.blockId) out.set(record.seq, innermost.blockId);
454
+ }
455
+ return out;
456
+ }
457
+
458
+ function collectEmpirical(
459
+ records: FlightRecord[],
460
+ operations: OperationSummary[],
461
+ definitions: Map<number, unknown>,
462
+ ): Finding[] {
463
+ const out: Finding[] = [];
464
+ const beginBySeq = new Map(records.map((record) => [record.seq, record]));
465
+ for (const record of records) {
466
+ if (record.type === "getShape-end") {
467
+ // The observed row count is compared against what the definition of the
468
+ // table declared as input, which is looked up here rather than carried on
469
+ // the record.
470
+ const begin = record.begin === undefined ? undefined : beginBySeq.get(record.begin);
471
+ const joinSeq = begin?.joinSeq as number | undefined;
472
+ const declared = joinSeq === undefined ? undefined : inputRowsMax(definitions.get(joinSeq));
473
+ const rows = record.rows as number | undefined;
474
+ const amplification =
475
+ typeof rows === "number" && typeof declared === "number" && declared > 0
476
+ ? Math.round((rows / declared) * 100) / 100
477
+ : undefined;
478
+ if ((amplification ?? 0) >= THRESHOLDS.amplification) {
479
+ out.push({
480
+ rule: "join-amplification",
481
+ severity: "critical",
482
+ seq: record.seq,
483
+ detail: `join produced ${formatCount(rows)} rows from at most ${formatCount(
484
+ declared,
485
+ )} declared input rows (x${amplification})`,
486
+ });
487
+ }
488
+ }
489
+ const tableRows = (record.tableRows as number | undefined) ?? 0;
490
+ if (
491
+ record.type === "getData-begin" &&
492
+ record.unbounded &&
493
+ tableRows > THRESHOLDS.unboundedRows
494
+ ) {
495
+ out.push({
496
+ rule: "unbounded-getData",
497
+ severity: "critical",
498
+ seq: record.seq,
499
+ detail: `getData with no row range on a ${formatCount(tableRows)}-row table pulls the whole table into the JS heap`,
500
+ });
501
+ }
502
+ const returnedBytes = (record.returnedBytes as number | undefined) ?? 0;
503
+ if (record.type === "getData-end" && returnedBytes >= THRESHOLDS.returnedBytes) {
504
+ out.push({
505
+ rule: "large-getData-result",
506
+ severity: "high",
507
+ seq: record.seq,
508
+ detail: `${formatBytes(returnedBytes)} of column data returned into JS in one call`,
509
+ });
510
+ }
511
+ if (record.type.startsWith("createP")) {
512
+ for (const inline of inlineColumns(recordedDef(record))) {
513
+ if ((inline.entries ?? 0) < THRESHOLDS.inlineEntries) continue;
514
+ out.push({
515
+ rule: "huge-inline-column",
516
+ severity: "high",
517
+ seq: record.seq,
518
+ detail: `model passed an inline column of ${formatCount(inline.entries)} entries (~${formatBytes(
519
+ inline.approxBytes,
520
+ )}) through the sandbox`,
521
+ });
522
+ }
523
+ }
524
+ }
525
+ for (const op of operations) {
526
+ if ((op.rssDelta ?? 0) < THRESHOLDS.nativeGrowthBytes) continue;
527
+ out.push({
528
+ rule: "operation-memory-spike",
529
+ severity: "high",
530
+ seq: op.seq,
531
+ detail: `${op.op} grew RSS by ${formatBytes(op.rssDelta)} (heap ${formatBytes(op.heapDelta ?? 0)})`,
532
+ });
533
+ }
534
+ return out;
535
+ }
536
+
537
+ function classifyCrashMarker(marker: CrashMarker | undefined): Finding[] {
538
+ if (!marker) return [];
539
+ const explanation: Record<string, string> = {
540
+ "js-heap-out-of-memory":
541
+ "the supervisor received ERR_WORKER_OUT_OF_MEMORY: the middle-layer thread exceeded its V8 heap limit",
542
+ "abort-or-fatal-allocation-failure":
543
+ "the process aborted on a fatal allocation failure (V8 fatal out-of-memory, or a failed native allocation)",
544
+ "killed-by-os":
545
+ "the OS killed the process (SIGKILL), which is what an out-of-memory kill looks like",
546
+ };
547
+ const firstLine = marker.message ? marker.message.split("\n")[0] : "";
548
+ return [
549
+ {
550
+ rule:
551
+ marker.reason === "js-heap-out-of-memory"
552
+ ? "js-heap-exhaustion-confirmed"
553
+ : `crash-${marker.reason}`,
554
+ severity: "critical",
555
+ source: "supervisor",
556
+ detail: `${explanation[marker.reason] ?? marker.reason}${firstLine ? ` — ${firstLine}` : ""}`,
557
+ },
558
+ ];
559
+ }
560
+
561
+ function ambiguousMarkerFinding(ambiguous: boolean): Finding[] {
562
+ if (!ambiguous) return [];
563
+ return [
564
+ {
565
+ rule: "unattributed-crash-marker",
566
+ severity: "medium",
567
+ detail:
568
+ "a crash marker sits in this session's time window, but another session in the same directory also stopped writing around then, so it is not attributed to either — spawn the worker with an assigned session id to remove the ambiguity",
569
+ },
570
+ ];
571
+ }
572
+
573
+ function classifyMemory(
574
+ memory: MemoryAnalysis,
575
+ env: SessionEnvironment | undefined,
576
+ crashMarker: CrashMarker | undefined,
577
+ ): Finding[] {
578
+ const out: Finding[] = [];
579
+
580
+ // On macOS `os.freemem()` sits near zero at all times because the kernel keeps
581
+ // free pages in the file cache, so a low reading alone means nothing: the
582
+ // process itself has to be large before the OS can plausibly have killed it.
583
+ const rssShare = memory.totalMemory ? (memory.rssAtDeath ?? 0) / memory.totalMemory : 0;
584
+ if (
585
+ memory.freeMemoryAtDeath !== undefined &&
586
+ memory.totalMemory &&
587
+ memory.freeMemoryAtDeath < memory.totalMemory * 0.03 &&
588
+ rssShare > THRESHOLDS.machineRssShare
589
+ ) {
590
+ out.push({
591
+ rule: "machine-memory-exhausted",
592
+ severity: "critical",
593
+ detail: `process held ${formatBytes(memory.rssAtDeath)} (${Math.round(rssShare * 100)}%) of ${formatBytes(
594
+ memory.totalMemory,
595
+ )} with ${formatBytes(memory.freeMemoryAtDeath)} free — the OS, not V8, ended the process`,
596
+ });
597
+ }
598
+
599
+ // The last in-thread heap reading predates a synchronous blow-up, so a low
600
+ // reading is not evidence of a healthy heap. Say so rather than conclude.
601
+ if (
602
+ !crashMarker &&
603
+ memory.heapPressure !== undefined &&
604
+ memory.heapPressure < THRESHOLDS.heapPressure &&
605
+ memory.worstStallMs >= THRESHOLDS.stallMs
606
+ ) {
607
+ out.push({
608
+ rule: "heap-reading-stale",
609
+ severity: "medium",
610
+ detail: `last JS heap reading is ${formatBytes(memory.heapUsedAtDeath)} but the thread was blocked for ${Math.round(
611
+ memory.worstStallMs,
612
+ )}ms before the log ends, so the heap was never sampled near the crash`,
613
+ });
614
+ }
615
+
616
+ if ((memory.heapPressure ?? 0) >= THRESHOLDS.heapPressure) {
617
+ const flag = env?.maxOldSpaceSize ? ` (--max-old-space-size=${env.maxOldSpaceSize})` : "";
618
+ out.push({
619
+ rule: "js-heap-exhaustion",
620
+ severity: "critical",
621
+ detail: `JS heap at ${Math.round((memory.heapPressure ?? 0) * 100)}% of its ${formatBytes(
622
+ memory.heapLimit,
623
+ )} limit${flag}`,
624
+ });
625
+ }
626
+
627
+ const offHeap = (memory.externalGrowth ?? 0) + (memory.arrayBuffersGrowth ?? 0);
628
+ const heapGrowth = memory.heapGrowth ?? 0;
629
+ if (memory.rssGrowth >= THRESHOLDS.nativeGrowthBytes && heapGrowth < memory.rssGrowth / 4) {
630
+ const offHeapDominant = offHeap >= THRESHOLDS.nativeGrowthBytes;
631
+ out.push({
632
+ rule: offHeapDominant ? "off-heap-buffer-growth" : "native-allocation-growth",
633
+ severity: "critical",
634
+ detail: offHeapDominant
635
+ ? `RSS grew ${formatBytes(memory.rssGrowth)} while the JS heap grew ${formatBytes(
636
+ heapGrowth,
637
+ )}; off-heap ArrayBuffer/external allocation grew ${formatBytes(offHeap)} — the growth is buffers handed out by the pframes engine, not JavaScript objects. Raising --max-old-space-size will not help.`
638
+ : `RSS grew ${formatBytes(memory.rssGrowth)} while the JS heap grew only ${formatBytes(
639
+ heapGrowth,
640
+ )} — the allocation is native (pframes engine / Arrow buffers), not JavaScript. Raising --max-old-space-size will not help.`,
641
+ });
642
+ }
643
+ return out;
644
+ }
645
+
646
+ function stallFindings(memory: MemoryAnalysis): Finding[] {
647
+ if (memory.worstStallMs < THRESHOLDS.stallMs) return [];
648
+ return [
649
+ {
650
+ rule: "event-loop-stall",
651
+ severity: "medium",
652
+ detail: `the recorded thread was blocked for ${Math.round(
653
+ memory.worstStallMs,
654
+ )}ms — synchronous work (model evaluation, or a blocking native call)`,
655
+ },
656
+ ];
657
+ }
658
+
659
+ function summarizeRenders(records: FlightRecord[]): RenderSummary[] {
660
+ const open = new Map<number, RenderSummary>();
661
+ const out: RenderSummary[] = [];
662
+ for (const record of records) {
663
+ if (record.type === "render-begin") {
664
+ // A render open across a rotation is written twice under one sequence
665
+ // number; the carried copy must not become a second, never-finished render.
666
+ if (open.has(record.seq)) continue;
667
+ const summary: RenderSummary = {
668
+ seq: record.seq,
669
+ blockId: record.blockId as string | undefined,
670
+ block: record.block as string | undefined,
671
+ key: record.key as string | undefined,
672
+ };
673
+ open.set(record.seq, summary);
674
+ out.push(summary);
675
+ continue;
676
+ }
677
+ if (record.type !== "render-end" && record.type !== "render-error") continue;
678
+ const summary = record.begin === undefined ? undefined : open.get(record.begin);
679
+ if (!summary) continue;
680
+ summary.end = true;
681
+ summary.ms = record.ms as number | undefined;
682
+ summary.stats = record.stats as RenderSummary["stats"];
683
+ summary.failed = record.type === "render-error";
684
+ }
685
+ return out;
686
+ }
687
+
688
+ function buildVerdict(input: {
689
+ crashed: boolean;
690
+ memory: MemoryAnalysis;
691
+ inFlight: OperationSummary[];
692
+ findings: Finding[];
693
+ crashMarker?: CrashMarker;
694
+ /** Block whose render was open at each sequence number. */
695
+ blockOf: Map<number, string>;
696
+ }): Verdict {
697
+ const { crashed, memory, inFlight, findings, crashMarker, blockOf } = input;
698
+ const gun = inFlight.at(-1);
699
+ const region = findings.find((finding) => REGION_RULES.includes(finding.rule));
700
+ const cause = findings.find((finding) => CAUSE_RULES.includes(finding.rule));
701
+
702
+ // A driver call carries no block identity of its own, so it is taken from the
703
+ // render that was open around it rather than from whichever finding happens
704
+ // to have one.
705
+ const blockId =
706
+ (gun?.info?.blockId as string | undefined) ??
707
+ (gun === undefined ? undefined : blockOf.get(gun.seq)) ??
708
+ findings.find((finding) => finding.block)?.block;
709
+ return {
710
+ outcome: crashed
711
+ ? `session ended without shutdown${
712
+ crashMarker ? ` — supervisor reported ${crashMarker.reason}` : " (no supervisor marker)"
713
+ }`
714
+ : "clean shutdown",
715
+ peakRss: memory.peakRss,
716
+ where: gun
717
+ ? `${gun.op} started at seq ${gun.seq} and never returned${blockId ? ` (block ${blockId})` : ""}`
718
+ : "no operation was in flight",
719
+ memoryRegion: region?.rule,
720
+ likelyCause: cause?.rule ?? findings[0]?.rule,
721
+ summary: [
722
+ crashed ? "Process died without running shutdown." : "Session closed normally.",
723
+ gun ? `Last unfinished operation: ${gun.op} (seq ${gun.seq}).` : undefined,
724
+ region?.detail,
725
+ cause ? `Probable cause: ${cause.rule} — ${cause.detail}` : undefined,
726
+ ]
727
+ .filter(Boolean)
728
+ .join(" "),
729
+ };
730
+ }
731
+
732
+ function inlineColumns(
733
+ def: unknown,
734
+ acc: { entries?: number; approxBytes?: number }[] = [],
735
+ ): { entries?: number; approxBytes?: number }[] {
736
+ if (!def || typeof def !== "object") return acc;
737
+ const node = def as Record<string, unknown>;
738
+ const data = node.data as { kind?: string; entries?: number; approxBytes?: number } | undefined;
739
+ if (data?.kind === "inline") acc.push(data);
740
+ for (const value of Object.values(node)) {
741
+ if (Array.isArray(value)) {
742
+ for (const child of value) inlineColumns(child, acc);
743
+ } else if (value && typeof value === "object") {
744
+ inlineColumns(value, acc);
745
+ }
746
+ }
747
+ return acc;
748
+ }
749
+
750
+ function compactRecord(record: FlightRecord): Record<string, unknown> {
751
+ const { mem, def, ...rest } = record;
752
+ const out: Record<string, unknown> = { ...rest };
753
+ if (mem) out.rss = mem.rss;
754
+ if (def) out.defSummary = summarizeDef(def);
755
+ return out;
756
+ }
757
+
758
+ function summarizeDef(digest: unknown): Record<string, unknown> {
759
+ const typed = digest as { kind?: string; redaction?: { bytes?: number } } | undefined;
760
+ const def = recordedDefFrom(digest);
761
+ const outermost = joinShapes(def)[0];
762
+ return {
763
+ kind: typed?.kind,
764
+ bytes: typed?.redaction?.bytes,
765
+ join: outermost?.join,
766
+ children: outermost?.childCount,
767
+ sharedAxes: outermost?.sharedAxes.length,
768
+ inputRowsMax: outermost?.inputRowsMax ?? inputRowsMax(def),
769
+ rowsUpperBound: outermost?.rowsUpperBound,
770
+ };
771
+ }
772
+
773
+ /** The redacted definition inside a record, if it carries one. */
774
+ function recordedDef(record: FlightRecord): unknown {
775
+ return recordedDefFrom(record.def);
776
+ }
777
+
778
+ function recordedDefFrom(digest: unknown): unknown {
779
+ if (!digest || typeof digest !== "object") return undefined;
780
+ return (digest as { def?: unknown }).def;
781
+ }
782
+
783
+ /** Definition of each creation call, keyed by the sequence number of its record. */
784
+ function definitionBySeq(records: FlightRecord[]): Map<number, unknown> {
785
+ const out = new Map<number, unknown>();
786
+ for (const record of records) {
787
+ const def = recordedDef(record);
788
+ if (def !== undefined) out.set(record.seq, def);
789
+ }
790
+ return out;
791
+ }
792
+
793
+ function bySeverity(lhs: Finding, rhs: Finding): number {
794
+ return (SEVERITY_ORDER[lhs.severity] ?? 9) - (SEVERITY_ORDER[rhs.severity] ?? 9);
795
+ }