@milaboratories/pl-crash-recorder 0.3.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 (58) hide show
  1. package/README.md +119 -0
  2. package/dist/data_summary.js +100 -0
  3. package/dist/data_summary.js.map +1 -0
  4. package/dist/digest.js +26 -0
  5. package/dist/digest.js.map +1 -0
  6. package/dist/events.d.ts +149 -0
  7. package/dist/events.d.ts.map +1 -0
  8. package/dist/events.js +13 -0
  9. package/dist/events.js.map +1 -0
  10. package/dist/host_sampler.d.ts +35 -0
  11. package/dist/host_sampler.d.ts.map +1 -0
  12. package/dist/host_sampler.js +54 -0
  13. package/dist/host_sampler.js.map +1 -0
  14. package/dist/index.d.ts +7 -0
  15. package/dist/index.js +6 -0
  16. package/dist/instrument.d.ts +79 -0
  17. package/dist/instrument.d.ts.map +1 -0
  18. package/dist/instrument.js +305 -0
  19. package/dist/instrument.js.map +1 -0
  20. package/dist/machine_memory.js +68 -0
  21. package/dist/machine_memory.js.map +1 -0
  22. package/dist/recorder.d.ts +70 -0
  23. package/dist/recorder.d.ts.map +1 -0
  24. package/dist/recorder.js +278 -0
  25. package/dist/recorder.js.map +1 -0
  26. package/dist/redact.js +141 -0
  27. package/dist/redact.js.map +1 -0
  28. package/dist/sampler.d.ts +8 -0
  29. package/dist/sampler.d.ts.map +1 -0
  30. package/dist/sampler.js +33 -0
  31. package/dist/sampler.js.map +1 -0
  32. package/dist/sampler_thread.d.ts +1 -0
  33. package/dist/sampler_thread.js +53 -0
  34. package/dist/sampler_thread.js.map +1 -0
  35. package/dist/session.d.ts +40 -0
  36. package/dist/session.d.ts.map +1 -0
  37. package/dist/session.js +50 -0
  38. package/dist/session.js.map +1 -0
  39. package/dist/supervisor.d.ts +37 -0
  40. package/dist/supervisor.d.ts.map +1 -0
  41. package/dist/supervisor.js +138 -0
  42. package/dist/supervisor.js.map +1 -0
  43. package/package.json +43 -0
  44. package/src/data_summary.ts +163 -0
  45. package/src/digest.ts +36 -0
  46. package/src/events.ts +166 -0
  47. package/src/host_sampler.ts +83 -0
  48. package/src/index.ts +51 -0
  49. package/src/instrument.ts +480 -0
  50. package/src/machine_memory.ts +70 -0
  51. package/src/recorder.test.ts +334 -0
  52. package/src/recorder.ts +435 -0
  53. package/src/redact.test.ts +155 -0
  54. package/src/redact.ts +213 -0
  55. package/src/sampler.ts +40 -0
  56. package/src/sampler_thread.ts +60 -0
  57. package/src/session.ts +71 -0
  58. package/src/supervisor.ts +183 -0
package/src/redact.ts ADDED
@@ -0,0 +1,213 @@
1
+ import crypto from "node:crypto";
2
+ import { summarizeData, type DataSummary } from "./data_summary";
3
+
4
+ /**
5
+ * Structure-preserving redaction for anything a driver seam is handed.
6
+ *
7
+ * The definition a block model builds is recorded by *shape* rather than by a
8
+ * hand-written digest per definition type. Keys, numbers and the small set of
9
+ * strings that are schema survive verbatim; every other string is replaced by a
10
+ * hash and a length. That keeps the record useful for diagnosis, keeps customer
11
+ * data out of it by default rather than by enumeration, and works unchanged for
12
+ * definition shapes this code has never seen — the V2 query API included.
13
+ *
14
+ * The default for an unrecognised string is to hash it. A new field can
15
+ * therefore make a report less informative, but never make it leak.
16
+ */
17
+
18
+ /** Keys whose string value is schema, kept as written. */
19
+ const SCHEMA_KEYS = new Set(["type", "name", "valueType", "kind", "operator", "mode"]);
20
+
21
+ /** Keys under which every string is schema, at any depth (axis identity). */
22
+ const SCHEMA_SUBTREE_KEYS = new Set(["domain", "contextDomain"]);
23
+
24
+ /** Keys never descended into; summarised by counts instead. */
25
+ const SUMMARISED_KEYS = new Set(["data", "dataInfo"]);
26
+
27
+ /** Keys reduced to a cardinality, because their contents are values. */
28
+ const COUNTED_KEYS = new Set(["references", "parts"]);
29
+
30
+ export type RedactionStats = {
31
+ hashedStrings: number;
32
+ truncatedArrays: number;
33
+ omittedItems: number;
34
+ depthCapped: number;
35
+ opaqueObjects: number;
36
+ budgetExhausted: boolean;
37
+ };
38
+
39
+ type RedactOptions = {
40
+ maxDepth?: number;
41
+ maxArrayItems?: number;
42
+ maxStringLength?: number;
43
+ /** Ceiling on emitted values, so one pathological definition cannot fill the log. */
44
+ maxNodes?: number;
45
+ };
46
+
47
+ type HashedString = { h: string; n: number };
48
+
49
+ /** Redacts a definition, returning the new value and what had to be elided. */
50
+ export function redact(
51
+ value: unknown,
52
+ options: RedactOptions = {},
53
+ ): { value: unknown; stats: RedactionStats } {
54
+ const limits = {
55
+ maxDepth: options.maxDepth ?? 32,
56
+ maxArrayItems: options.maxArrayItems ?? 64,
57
+ maxStringLength: options.maxStringLength ?? 128,
58
+ maxNodes: options.maxNodes ?? 20_000,
59
+ };
60
+ const stats: RedactionStats = {
61
+ hashedStrings: 0,
62
+ truncatedArrays: 0,
63
+ omittedItems: 0,
64
+ depthCapped: 0,
65
+ opaqueObjects: 0,
66
+ budgetExhausted: false,
67
+ };
68
+ const state = { nodes: 0, seen: new WeakSet<object>() };
69
+ return {
70
+ value: walk(value, { key: undefined, schemaSubtree: false, depth: 0 }, limits, stats, state),
71
+ stats,
72
+ };
73
+ }
74
+
75
+ /** Stable short hash plus the original length. Never reversible to the value. */
76
+ function hashString(value: string): HashedString {
77
+ return {
78
+ h: crypto.createHash("sha256").update(value).digest("hex").slice(0, 12),
79
+ n: value.length,
80
+ };
81
+ }
82
+
83
+ /** True for the object form produced in place of a redacted string. */
84
+ export function isHashedString(value: unknown): value is HashedString {
85
+ return typeof value === "object" && value !== null && "h" in value && "n" in value;
86
+ }
87
+
88
+ // Internals
89
+
90
+ type Position = { key: string | undefined; schemaSubtree: boolean; depth: number };
91
+ type Limits = Required<RedactOptions>;
92
+ type State = { nodes: number; seen: WeakSet<object> };
93
+
94
+ function walk(
95
+ value: unknown,
96
+ at: Position,
97
+ limits: Limits,
98
+ stats: RedactionStats,
99
+ state: State,
100
+ ): unknown {
101
+ if (state.nodes++ > limits.maxNodes) {
102
+ stats.budgetExhausted = true;
103
+ return { $budget: true };
104
+ }
105
+
106
+ if (value === null || value === undefined) return value ?? null;
107
+ if (typeof value === "bigint") return Number(value);
108
+ if (typeof value === "number" || typeof value === "boolean") return value;
109
+ if (typeof value === "string") return redactString(value, at, limits, stats);
110
+ if (typeof value !== "object") return { $type: typeof value };
111
+
112
+ if (at.depth >= limits.maxDepth) {
113
+ stats.depthCapped++;
114
+ return { $depth: at.depth };
115
+ }
116
+ // A definition can carry live accessors and other class instances whose
117
+ // internals reference each other; walking those is neither safe nor useful.
118
+ if (state.seen.has(value)) return { $cycle: true };
119
+
120
+ if (Array.isArray(value)) {
121
+ state.seen.add(value);
122
+ return walkArray(value, at, limits, stats, state);
123
+ }
124
+ if (!isPlainObject(value)) {
125
+ stats.opaqueObjects++;
126
+ return { $opaque: className(value) };
127
+ }
128
+
129
+ state.seen.add(value);
130
+ const out: Record<string, unknown> = {};
131
+ for (const [key, child] of Object.entries(value)) {
132
+ if (SUMMARISED_KEYS.has(key)) {
133
+ out[key] = summarizeData(child);
134
+ continue;
135
+ }
136
+ if (COUNTED_KEYS.has(key)) {
137
+ out[key] = { $count: countOf(child) };
138
+ continue;
139
+ }
140
+ out[key] = walk(
141
+ child,
142
+ {
143
+ key,
144
+ schemaSubtree: at.schemaSubtree || SCHEMA_SUBTREE_KEYS.has(key),
145
+ depth: at.depth + 1,
146
+ },
147
+ limits,
148
+ stats,
149
+ state,
150
+ );
151
+ }
152
+ return out;
153
+ }
154
+
155
+ function walkArray(
156
+ value: unknown[],
157
+ at: Position,
158
+ limits: Limits,
159
+ stats: RedactionStats,
160
+ state: State,
161
+ ): unknown[] {
162
+ const kept = value
163
+ .slice(0, limits.maxArrayItems)
164
+ .map((item) =>
165
+ walk(
166
+ item,
167
+ { key: at.key, schemaSubtree: at.schemaSubtree, depth: at.depth + 1 },
168
+ limits,
169
+ stats,
170
+ state,
171
+ ),
172
+ );
173
+ const omitted = value.length - kept.length;
174
+ if (omitted <= 0) return kept;
175
+ // Arrays stay arrays so the rules can still walk join entries; the loss is
176
+ // recorded in the array itself rather than in a side channel.
177
+ stats.truncatedArrays++;
178
+ stats.omittedItems += omitted;
179
+ return [...kept, { $omitted: omitted }];
180
+ }
181
+
182
+ function redactString(
183
+ value: string,
184
+ at: Position,
185
+ limits: Limits,
186
+ stats: RedactionStats,
187
+ ): string | HashedString {
188
+ if (at.schemaSubtree || (at.key !== undefined && SCHEMA_KEYS.has(at.key))) {
189
+ return value.length > limits.maxStringLength
190
+ ? `${value.slice(0, limits.maxStringLength)}…`
191
+ : value;
192
+ }
193
+ stats.hashedStrings++;
194
+ return hashString(value);
195
+ }
196
+
197
+ function countOf(value: unknown): number | undefined {
198
+ if (Array.isArray(value)) return value.length;
199
+ if (isPlainObject(value)) return Object.keys(value).length;
200
+ return undefined;
201
+ }
202
+
203
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
204
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
205
+ const proto = Object.getPrototypeOf(value) as object | null;
206
+ return proto === Object.prototype || proto === null;
207
+ }
208
+
209
+ function className(value: object): string {
210
+ return (value as { constructor?: { name?: string } }).constructor?.name ?? "unknown";
211
+ }
212
+
213
+ export type { DataSummary };
package/src/sampler.ts ADDED
@@ -0,0 +1,40 @@
1
+ import path from "node:path";
2
+ import { Worker } from "node:worker_threads";
3
+ import { SAMPLER_FILE_PREFIX } from "./events";
4
+
5
+ type MemorySamplerOptions = {
6
+ dir: string;
7
+ sessionId: string;
8
+ /** Sampling period; 250 ms is roughly four short appends per second. */
9
+ intervalMs?: number;
10
+ };
11
+
12
+ export type MemorySampler = {
13
+ /** Sibling log the sampler appends to. */
14
+ readonly file: string;
15
+ stop(): void;
16
+ };
17
+
18
+ /**
19
+ * Starts the out-of-band memory sampler for a session.
20
+ *
21
+ * The sampler runs on a thread of its own with a small heap of its own, so it
22
+ * keeps producing readings when the observed thread is blocked and when the
23
+ * observed thread's heap is the thing that is full.
24
+ */
25
+ export function startMemorySampler(options: MemorySamplerOptions): MemorySampler {
26
+ const { dir, sessionId, intervalMs = 250 } = options;
27
+ const file = path.join(dir, `${SAMPLER_FILE_PREFIX}-${sessionId}.ndjson`);
28
+ const worker = new Worker(new URL("./sampler_thread.js", import.meta.url), {
29
+ workerData: { file, intervalMs },
30
+ resourceLimits: { maxOldGenerationSizeMb: 32 },
31
+ });
32
+ // Unreferenced so a sampler that is never stopped cannot hold the process open.
33
+ worker.unref();
34
+ return {
35
+ file,
36
+ stop: () => {
37
+ void worker.terminate();
38
+ },
39
+ };
40
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Memory sampler, run on its own worker thread.
3
+ *
4
+ * It exists because the thread worth watching is the one that blocks. While the
5
+ * middle layer sits inside a synchronous pframes call its own timers do not
6
+ * fire, so its memory series goes dark exactly while memory is growing fastest.
7
+ * This thread stays responsive and keeps the resident-size curve intact right up
8
+ * to the moment the process dies.
9
+ *
10
+ * `rss` and `freeMemory` are process- and machine-wide and so are meaningful
11
+ * from here. Heap figures are per-isolate and would describe only this thread,
12
+ * so they are deliberately not recorded; the observed thread reports its own.
13
+ *
14
+ * Resident size alone understates a process under pressure, because the OS moves
15
+ * its pages into the compressor or out to swap and they stop being resident. The
16
+ * machine's own account of where memory went is therefore sampled alongside it,
17
+ * less often because it costs a subprocess.
18
+ */
19
+
20
+ import fs from "node:fs";
21
+ import os from "node:os";
22
+ import { workerData } from "node:worker_threads";
23
+ import type { SamplerRecord } from "./events";
24
+ import { readMachineMemory } from "./machine_memory";
25
+
26
+ type SamplerWorkerData = { file: string; intervalMs: number; machineIntervalMs?: number };
27
+
28
+ const { file, intervalMs, machineIntervalMs = 1000 } = workerData as SamplerWorkerData;
29
+ const fd = fs.openSync(file, "a");
30
+ let seq = 0;
31
+ let peakRss = 0;
32
+ let machineDueAt = 0;
33
+
34
+ setInterval(() => {
35
+ const rss = process.memoryUsage.rss();
36
+ if (rss > peakRss) peakRss = rss;
37
+ const now = Date.now();
38
+ // Taken on the first tick and then on its own schedule, so the curve keeps its
39
+ // sampling rate while the costlier reading stays occasional.
40
+ const machine = now >= machineDueAt ? readMachineMemory() : undefined;
41
+ if (machine) machineDueAt = now + machineIntervalMs;
42
+ const record: SamplerRecord = {
43
+ seq: ++seq,
44
+ t: Math.round(performance.now() * 1000) / 1000,
45
+ wall: now,
46
+ type: "mem-sampler",
47
+ rss,
48
+ peakRss,
49
+ // The kernel's own high-water mark, which no sampling interval can miss.
50
+ maxRss: process.resourceUsage().maxRSS * 1024,
51
+ freeMemory: os.freemem(),
52
+ totalMemory: os.totalmem(),
53
+ ...(machine ? { machine } : {}),
54
+ };
55
+ try {
56
+ fs.writeSync(fd, `${JSON.stringify(record)}\n`);
57
+ } catch {
58
+ // Sampling must never take the application down.
59
+ }
60
+ }, intervalMs);
package/src/session.ts ADDED
@@ -0,0 +1,71 @@
1
+ import { openRecorder, startSelfSampler, type Recorder } from "./recorder";
2
+ import { startMemorySampler, type MemorySampler } from "./sampler";
3
+ import { createHandleRegistry, type HandleRegistry } from "./instrument";
4
+
5
+ /** Environment variable naming the directory crash logs are written to. */
6
+ export const CRASH_DIR_ENV = "MI_CRASH_RECORDER_DIR";
7
+
8
+ /**
9
+ * Environment variable carrying the session id a supervising parent assigned.
10
+ * Set it alongside {@link CRASH_DIR_ENV} when spawning the worker and pass the
11
+ * same id to `superviseWorker`.
12
+ */
13
+ export const CRASH_SESSION_ENV = "MI_CRASH_RECORDER_SESSION";
14
+
15
+ export type RecordingSessionOptions = {
16
+ /** Overrides the directory from the environment. */
17
+ dir?: string;
18
+ /** Overrides the session id from the environment. */
19
+ sessionId?: string;
20
+ role?: string;
21
+ meta?: Record<string, unknown>;
22
+ samplerIntervalMs?: number;
23
+ selfSamplerIntervalMs?: number;
24
+ };
25
+
26
+ export type RecordingSession = {
27
+ readonly recorder: Recorder;
28
+ readonly sampler: MemorySampler;
29
+ /** Shared so create calls and later data calls agree on handle identity. */
30
+ readonly registry: HandleRegistry;
31
+ close(reason?: string): void;
32
+ };
33
+
34
+ /**
35
+ * Opens a recording session, or returns undefined when recording is not enabled.
36
+ *
37
+ * Recording is opt-in for now: it appends synchronously on every recorded
38
+ * operation, and that cost has not been measured against a real project, so it
39
+ * is switched on by pointing {@link CRASH_DIR_ENV} at a directory rather than
40
+ * being on by default.
41
+ */
42
+ export function openRecordingSession(
43
+ options: RecordingSessionOptions = {},
44
+ ): RecordingSession | undefined {
45
+ const dir = options.dir ?? process.env[CRASH_DIR_ENV];
46
+ if (!dir) return undefined;
47
+
48
+ const recorder = openRecorder({
49
+ dir,
50
+ role: options.role,
51
+ meta: options.meta,
52
+ sessionId: options.sessionId ?? process.env[CRASH_SESSION_ENV] ?? undefined,
53
+ });
54
+ const sampler = startMemorySampler({
55
+ dir,
56
+ sessionId: recorder.sessionId,
57
+ intervalMs: options.samplerIntervalMs,
58
+ });
59
+ const stopSelfSampler = startSelfSampler(recorder, options.selfSamplerIntervalMs);
60
+
61
+ return {
62
+ recorder,
63
+ sampler,
64
+ registry: createHandleRegistry(),
65
+ close(reason) {
66
+ stopSelfSampler();
67
+ sampler.stop();
68
+ recorder.close(reason);
69
+ },
70
+ };
71
+ }
@@ -0,0 +1,183 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { DEATH_FILE_PREFIX, type CrashMarker, type CrashReason } from "./events";
5
+ import { listSessions, sessionIdFromFile } from "./recorder";
6
+
7
+ type CrashMarkerInput = {
8
+ /** Session id the parent assigned to the worker. Omitted, the marker carries no identity. */
9
+ sessionId?: string;
10
+ reason?: CrashReason;
11
+ error?: (Error & { code?: string }) | unknown;
12
+ code?: number;
13
+ signal?: string;
14
+ stderrTail?: string;
15
+ };
16
+
17
+ export type SuperviseOptions = {
18
+ /**
19
+ * The session id handed to the worker at spawn (see `CRASH_SESSION_ENV`).
20
+ * With it the marker names the dying session with certainty. Without it the
21
+ * analyzer has to attribute the marker by timing, and will decline to
22
+ * attribute it at all when more than one session looks dead.
23
+ */
24
+ sessionId?: string;
25
+ onCrash?: (info: {
26
+ kind: "error" | "exit";
27
+ markerFile: string;
28
+ error?: unknown;
29
+ code?: number;
30
+ }) => void;
31
+ };
32
+
33
+ /** Minimal view of a worker, so callers are not forced to import worker_threads. */
34
+ export type SupervisedWorker = {
35
+ on(event: "error", listener: (error: Error) => void): unknown;
36
+ on(event: "exit", listener: (code: number) => void): unknown;
37
+ };
38
+
39
+ /**
40
+ * Records an abnormal end observed from outside the dying thread.
41
+ *
42
+ * A thread that runs out of heap cannot describe its own death: the last reading
43
+ * it wrote predates the blow-up, and when the blow-up is synchronous no sampler
44
+ * tick of its own lands either. The parent is the only place where the cause is
45
+ * known rather than inferred — Node reports `ERR_WORKER_OUT_OF_MEMORY` to it —
46
+ * so the parent writes the verdict down on the dead thread's behalf.
47
+ */
48
+ export function writeCrashMarker(dir: string, input: CrashMarkerInput = {}): string {
49
+ fs.mkdirSync(dir, { recursive: true });
50
+ const error = input.error as (Error & { code?: string }) | undefined;
51
+ // Only an id the parent handed to the worker is certain, and only a certain
52
+ // id goes in `sessionId`. Reading the newest open crash log names whichever
53
+ // session wrote last, which a concurrent live session makes wrong; recorded
54
+ // as identity that would misattribute the death and, worse, stop the session
55
+ // that actually died from claiming the marker. So it is advisory only.
56
+ const marker: CrashMarker = {
57
+ type: "external-crash",
58
+ wall: Date.now(),
59
+ sessionId: input.sessionId,
60
+ guessedSessionId: input.sessionId === undefined ? newestOpenSessionId(dir) : undefined,
61
+ reason: input.reason ?? classifyReason(input),
62
+ errorCode: error?.code,
63
+ errorName: error?.name,
64
+ message: truncate(String(error?.message ?? input.error ?? ""), 2000),
65
+ exitCode: input.code,
66
+ signal: input.signal,
67
+ stderrTail: truncate(input.stderrTail ?? "", 4000),
68
+ memoryAtDeath: memoryNow(),
69
+ };
70
+ const file = path.join(dir, `${DEATH_FILE_PREFIX}-${marker.wall}.ndjson`);
71
+ fs.writeFileSync(file, `${JSON.stringify(marker)}\n`);
72
+ return file;
73
+ }
74
+
75
+ /** Crash markers in a directory, oldest first. */
76
+ export function readCrashMarkers(dir: string): CrashMarker[] {
77
+ let names: string[];
78
+ try {
79
+ names = fs.readdirSync(dir);
80
+ } catch {
81
+ return [];
82
+ }
83
+ const markers: CrashMarker[] = [];
84
+ for (const name of names) {
85
+ if (!name.startsWith(`${DEATH_FILE_PREFIX}-`) || !name.endsWith(".ndjson")) continue;
86
+ try {
87
+ const first = fs.readFileSync(path.join(dir, name), "utf8").split("\n")[0];
88
+ markers.push(JSON.parse(first) as CrashMarker);
89
+ } catch {
90
+ // A marker that cannot be parsed is skipped; it is one line of evidence,
91
+ // not the report.
92
+ }
93
+ }
94
+ return markers.sort((lhs, rhs) => lhs.wall - rhs.wall);
95
+ }
96
+
97
+ /**
98
+ * Attaches crash recording to a middle-layer worker thread.
99
+ *
100
+ * A worker whose isolate exhausts its heap dies alone and the parent receives
101
+ * `ERR_WORKER_OUT_OF_MEMORY`, with or without `resourceLimits`. What
102
+ * `resourceLimits.maxOldGenerationSizeMb` adds is a chosen ceiling: V8's default
103
+ * is several gigabytes, so on a small machine the OS can run out of memory and
104
+ * kill the whole process before V8 ever reports the worker's heap as full — and
105
+ * then there is no parent left to write anything.
106
+ */
107
+ export function superviseWorker(
108
+ worker: SupervisedWorker,
109
+ dir: string,
110
+ options: SuperviseOptions = {},
111
+ ): void {
112
+ // One death fires `error` and then `exit`. Only `error` carries the cause, so
113
+ // a later `exit` must not overwrite it with a bare exit code.
114
+ let recorded = false;
115
+ worker.on("error", (error: Error) => {
116
+ recorded = true;
117
+ const markerFile = writeCrashMarker(dir, { error, sessionId: options.sessionId });
118
+ options.onCrash?.({ kind: "error", error, markerFile });
119
+ });
120
+ worker.on("exit", (code: number) => {
121
+ if (code === 0 || recorded) return;
122
+ const markerFile = writeCrashMarker(dir, {
123
+ reason: "worker-exit",
124
+ code,
125
+ sessionId: options.sessionId,
126
+ });
127
+ options.onCrash?.({ kind: "exit", code, markerFile });
128
+ });
129
+ }
130
+
131
+ // Internals
132
+
133
+ /**
134
+ * The parent's view of memory at the moment it saw the death.
135
+ *
136
+ * The dying thread cannot take this reading, and the sampler's last one predates
137
+ * the end by up to its interval. Taken here it is contemporaneous with the exit
138
+ * code it sits beside, which is what stops an exhausted machine from reading as
139
+ * an ordinary failure.
140
+ *
141
+ * Every reading here is a syscall. The machine's compressor and swap totals are
142
+ * deliberately not among them: on macOS they cost a subprocess, and this runs on
143
+ * the parent's event loop inside the worker's error handler, before the marker
144
+ * is written and before the caller learns of the death. A fork is exactly what
145
+ * becomes slow or impossible on the exhausted machine this code exists for, so
146
+ * the fuller picture is left to the sampler, whose last reading is at most one
147
+ * interval old and sits in the same bundle.
148
+ */
149
+ function memoryNow(): CrashMarker["memoryAtDeath"] {
150
+ try {
151
+ return {
152
+ rss: process.memoryUsage.rss(),
153
+ maxRss: process.resourceUsage().maxRSS * 1024,
154
+ freeMemory: os.freemem(),
155
+ totalMemory: os.totalmem(),
156
+ };
157
+ } catch {
158
+ // A marker without memory is still a marker; failing to take the reading
159
+ // must never cost the record of the death itself.
160
+ return undefined;
161
+ }
162
+ }
163
+
164
+ // Advisory only, for a human reading a directory by hand: the dying session has
165
+ // no terminating record, so among the sessions that look dead this names the one
166
+ // that wrote last. Never used as identity — see `CrashMarker.guessedSessionId`.
167
+ function newestOpenSessionId(dir: string): string | undefined {
168
+ const open = listSessions(dir).find((session) => session.crashed);
169
+ return open ? sessionIdFromFile(open.file) : undefined;
170
+ }
171
+
172
+ function classifyReason({ error, code, signal }: CrashMarkerInput): CrashReason {
173
+ const errorCode = (error as { code?: string } | undefined)?.code;
174
+ if (errorCode === "ERR_WORKER_OUT_OF_MEMORY") return "js-heap-out-of-memory";
175
+ if (signal === "SIGKILL") return "killed-by-os";
176
+ if (signal === "SIGABRT" || code === 134) return "abort-or-fatal-allocation-failure";
177
+ if (typeof code === "number" && code !== 0) return "nonzero-exit";
178
+ return "unknown";
179
+ }
180
+
181
+ function truncate(value: string, limit: number): string {
182
+ return value.length > limit ? `${value.slice(0, limit)}…` : value;
183
+ }