@sayknow-cli/utils 0.7.11 → 0.7.13
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/dist/types/broken-pipe.d.ts +4 -3
- package/dist/types/canonical-log-dir.d.ts +20 -0
- package/dist/types/cli-metadata.d.ts +33 -0
- package/dist/types/cli.d.ts +12 -0
- package/dist/types/crash-fingerprint.d.ts +82 -0
- package/dist/types/crash-journal.d.ts +84 -0
- package/dist/types/crash-redaction.d.ts +20 -0
- package/dist/types/dirs.d.ts +127 -36
- package/dist/types/env-file.d.ts +59 -0
- package/dist/types/env.d.ts +19 -1
- package/dist/types/error-classification.d.ts +12 -0
- package/dist/types/fetch-retry.d.ts +6 -3
- package/dist/types/header-value.d.ts +10 -0
- package/dist/types/index.d.ts +6 -0
- package/dist/types/logger.d.ts +22 -8
- package/dist/types/native-process.d.ts +12 -0
- package/dist/types/procmgr.d.ts +1 -35
- package/dist/types/safe-error.d.ts +20 -0
- package/dist/types/sanitize-text.d.ts +10 -0
- package/dist/types/shell-config.d.ts +58 -0
- package/dist/types/stream.d.ts +2 -1
- package/dist/types/which.d.ts +1 -1
- package/package.json +2 -2
- package/src/broken-pipe.ts +16 -4
- package/src/canonical-log-dir.ts +59 -0
- package/src/cli-metadata.ts +74 -0
- package/src/cli.ts +30 -2
- package/src/crash-fingerprint.ts +294 -0
- package/src/crash-journal.ts +292 -0
- package/src/crash-redaction.ts +558 -0
- package/src/dirs.ts +583 -165
- package/src/env-file.ts +326 -20
- package/src/env.ts +321 -35
- package/src/error-classification.ts +31 -0
- package/src/fetch-retry.ts +23 -10
- package/src/frontmatter.ts +28 -2
- package/src/glob.ts +13 -10
- package/src/header-value.ts +15 -0
- package/src/index.ts +6 -0
- package/src/logger.ts +252 -97
- package/src/native-process.ts +19 -0
- package/src/postmortem.ts +1 -55
- package/src/procmgr.ts +16 -212
- package/src/ptree.ts +3 -2
- package/src/safe-error.ts +40 -0
- package/src/sanitize-text.ts +14 -0
- package/src/shell-config.ts +329 -0
- package/src/stream.ts +32 -1
- package/src/which.ts +6 -5
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Crash fingerprinting, algorithm v1.
|
|
3
|
+
*
|
|
4
|
+
* A fingerprint is a stable, versioned identity for a crash *class*, computed
|
|
5
|
+
* at `recordFatalCrash` time from the already-captured diagnostic text (error
|
|
6
|
+
* name, message, stack) and nothing else. It exists so that N crash-loop
|
|
7
|
+
* records collapse into one countable signature.
|
|
8
|
+
*
|
|
9
|
+
* ## Privacy posture
|
|
10
|
+
*
|
|
11
|
+
* The fingerprint is a **public, pseudonymous correlation token**. It is
|
|
12
|
+
* deterministic over low-entropy inputs, therefore dictionary-testable, and it
|
|
13
|
+
* links the same crash class across installs and accounts. It is NOT a
|
|
14
|
+
* confidentiality control. Only *normalized* text is hashed — absolute paths,
|
|
15
|
+
* home directories, uuids/hex/long digit runs and everything
|
|
16
|
+
* `redactCrashSecrets` rewrites are replaced with placeholders before hashing —
|
|
17
|
+
* so a fingerprint never encodes a secret or a raw filesystem path.
|
|
18
|
+
*
|
|
19
|
+
* ## Canonical serialization (v1)
|
|
20
|
+
*
|
|
21
|
+
* Fields are length-prefixed UTF-8 (`<byteLength>:<bytes>`) so no delimiter
|
|
22
|
+
* ambiguity exists between an empty field and a missing one:
|
|
23
|
+
*
|
|
24
|
+
* "skc-crash-fp.v1" | errorName | messageClass | frame0 | frame1 | frame2
|
|
25
|
+
*
|
|
26
|
+
* Up to three normalized in-app frames participate; absent frames are omitted
|
|
27
|
+
* (the length prefix of the preceding fields keeps the encoding unambiguous).
|
|
28
|
+
* The digest is sha256 truncated to its first 16 bytes, published as 32
|
|
29
|
+
* lowercase hex characters (128 bits).
|
|
30
|
+
*/
|
|
31
|
+
import { createHash } from "node:crypto";
|
|
32
|
+
import * as os from "node:os";
|
|
33
|
+
import * as path from "node:path";
|
|
34
|
+
import { redactCrashSecrets } from "./crash-redaction";
|
|
35
|
+
|
|
36
|
+
/** Algorithm version recorded beside every emitted fingerprint. */
|
|
37
|
+
export const CRASH_FINGERPRINT_VERSION = 1;
|
|
38
|
+
/** Hex length of a published fingerprint (128 bits). */
|
|
39
|
+
export const CRASH_FINGERPRINT_HEX_LENGTH = 32;
|
|
40
|
+
/** Matches exactly a published fingerprint. */
|
|
41
|
+
export const CRASH_FINGERPRINT_PATTERN = /^[0-9a-f]{32}$/;
|
|
42
|
+
/** Marker used for the machine-readable identity line of each crash record. */
|
|
43
|
+
export const CRASH_RECORD_MARKER = "skc-crash-record.v1";
|
|
44
|
+
/** Marker embedded in an external issue body, outside crash-derived blocks. */
|
|
45
|
+
export const CRASH_ISSUE_MARKER_PREFIX = "skc-crash-fp.v1:";
|
|
46
|
+
/** Literal frame used when a stack carries no in-app frame at all. */
|
|
47
|
+
export const NO_APP_FRAME = "<no-app-frame>";
|
|
48
|
+
|
|
49
|
+
/** Byte caps applied before hashing, so a huge throwable cannot dominate cost. */
|
|
50
|
+
const MESSAGE_CLASS_MAX_BYTES = 512;
|
|
51
|
+
const FRAME_MAX_BYTES = 256;
|
|
52
|
+
const MAX_FRAMES = 3;
|
|
53
|
+
|
|
54
|
+
export interface CrashFingerprintInput {
|
|
55
|
+
readonly name: string;
|
|
56
|
+
readonly message: string;
|
|
57
|
+
readonly stack: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface CrashFingerprint {
|
|
61
|
+
/** 32 lowercase hex characters. */
|
|
62
|
+
readonly fingerprint: string;
|
|
63
|
+
/** Algorithm version (`fpv`). */
|
|
64
|
+
readonly version: number;
|
|
65
|
+
readonly errorName: string;
|
|
66
|
+
/** Normalized, placeholder-substituted message class (safe to display). */
|
|
67
|
+
readonly messageClass: string;
|
|
68
|
+
/** Normalized in-app frames that participated in the digest. */
|
|
69
|
+
readonly frames: readonly string[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface CrashFingerprintOptions {
|
|
73
|
+
/** Install root used to relativize in-app frames. Defaults to the SKC install root. */
|
|
74
|
+
readonly installRoot?: string;
|
|
75
|
+
/** Home directory used for `<home>` substitution. Defaults to `os.homedir()`. */
|
|
76
|
+
readonly homeDir?: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Root of this installation, used to relativize frames.
|
|
81
|
+
*
|
|
82
|
+
* Source checkouts resolve to the workspace root (this file lives at
|
|
83
|
+
* `packages/utils/src/`); an npm install resolves to the `node_modules` root.
|
|
84
|
+
* Both are stable per install *shape*, which is what frame identity needs;
|
|
85
|
+
* compiled binaries never reach here because their frames are BunFS paths.
|
|
86
|
+
*/
|
|
87
|
+
function defaultInstallRoot(): string {
|
|
88
|
+
return path.resolve(import.meta.dir, "..", "..", "..");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function truncateUtf8(text: string, maxBytes: number): string {
|
|
92
|
+
if (Buffer.byteLength(text, "utf8") <= maxBytes) return text;
|
|
93
|
+
const bytes = Buffer.from(text, "utf8");
|
|
94
|
+
let end = maxBytes;
|
|
95
|
+
while (end > 0 && (bytes[end - 1] & 0xc0) === 0x80) end--;
|
|
96
|
+
if (end > 0 && bytes[end - 1] >= 0xc0) end--;
|
|
97
|
+
return bytes.subarray(0, end).toString("utf8");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const UUID_PATTERN = /\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b/g;
|
|
101
|
+
const HEX_RUN_PATTERN = /\b[0-9a-fA-F]{8,}\b/g;
|
|
102
|
+
const DIGIT_RUN_PATTERN = /\d{4,}/g;
|
|
103
|
+
// UNC, Windows drive-letter, BunFS (posix + windows compiled forms), POSIX.
|
|
104
|
+
const PATH_PATTERN =
|
|
105
|
+
/\\\\[^\s"'`,;)\]]+|\b[A-Za-z]:\\[^\s"'`,;)\]]*|\/\$bunfs\/[^\s"'`,;)\]]*|(?<![\w.~])\/[^\s"'`,;)\]]*/g;
|
|
106
|
+
|
|
107
|
+
function normalizeSeparators(value: string): string {
|
|
108
|
+
return value.replace(/\\/g, "/");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Replace absolute path-like tokens with `<home>` / `<path>`.
|
|
113
|
+
*
|
|
114
|
+
* Semantically meaningful codes are untouched: this rule only fires on tokens
|
|
115
|
+
* that are recognizably absolute filesystem paths.
|
|
116
|
+
*/
|
|
117
|
+
export function replaceAbsolutePaths(text: string, homeDir: string = os.homedir()): string {
|
|
118
|
+
const normalizedHome = normalizeSeparators(homeDir).replace(/\/+$/, "");
|
|
119
|
+
return text.replace(PATH_PATTERN, match => {
|
|
120
|
+
if (match === "/") return match;
|
|
121
|
+
const normalized = normalizeSeparators(match);
|
|
122
|
+
if (normalizedHome.length > 0 && (normalized === normalizedHome || normalized.startsWith(`${normalizedHome}/`)))
|
|
123
|
+
return "<home>";
|
|
124
|
+
return "<path>";
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Typed message normalization.
|
|
130
|
+
*
|
|
131
|
+
* Deliberately *not* "strip all digits": HTTP statuses, exit codes and errno
|
|
132
|
+
* names are the difference between distinct crash classes, so runs of three or
|
|
133
|
+
* fewer digits and alphabetic error codes survive verbatim (`404` stays
|
|
134
|
+
* distinct from `500`). Only high-entropy identifiers are collapsed.
|
|
135
|
+
*/
|
|
136
|
+
export function normalizeCrashMessage(message: string, options: CrashFingerprintOptions = {}): string {
|
|
137
|
+
const homeDir = options.homeDir ?? os.homedir();
|
|
138
|
+
let normalized = redactCrashSecrets(message);
|
|
139
|
+
normalized = replaceAbsolutePaths(normalized, homeDir);
|
|
140
|
+
normalized = normalized.replace(UUID_PATTERN, "<uuid>");
|
|
141
|
+
normalized = normalized.replace(HEX_RUN_PATTERN, match => (/[a-fA-F]/.test(match) ? "<hex>" : "<num>"));
|
|
142
|
+
normalized = normalized.replace(DIGIT_RUN_PATTERN, "<num>");
|
|
143
|
+
normalized = normalized.replace(/\s+/g, " ").trim();
|
|
144
|
+
return truncateUtf8(normalized, MESSAGE_CLASS_MAX_BYTES);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
interface ParsedFrame {
|
|
148
|
+
readonly functionName: string;
|
|
149
|
+
readonly location: string;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function parseStackFrame(line: string): ParsedFrame | undefined {
|
|
153
|
+
const trimmed = line.trim();
|
|
154
|
+
const atMatch = /^at\s+(.*)$/.exec(trimmed);
|
|
155
|
+
if (!atMatch) return undefined;
|
|
156
|
+
const rest = atMatch[1] ?? "";
|
|
157
|
+
const parenIndex = rest.lastIndexOf(" (");
|
|
158
|
+
let functionName = "<anonymous>";
|
|
159
|
+
let location = rest;
|
|
160
|
+
if (parenIndex >= 0 && rest.endsWith(")")) {
|
|
161
|
+
functionName = rest.slice(0, parenIndex).trim();
|
|
162
|
+
location = rest.slice(parenIndex + 2, -1).trim();
|
|
163
|
+
}
|
|
164
|
+
if (!location) return undefined;
|
|
165
|
+
return { functionName, location };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function stripLocation(location: string): string {
|
|
169
|
+
let value = location;
|
|
170
|
+
if (value.startsWith("file://")) value = Bun.fileURLToPath(value);
|
|
171
|
+
// Drop `:line:col` / `:line` suffixes: they churn on every release.
|
|
172
|
+
value = value.replace(/:\d+(?::\d+)?$/, "");
|
|
173
|
+
return value;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function normalizeFunctionName(name: string): string {
|
|
177
|
+
let value = name.replace(/^(?:async|new)\s+/, "").trim();
|
|
178
|
+
value = value.replace(/\s+\[as\s+[^\]]+\]$/, "");
|
|
179
|
+
if (!value || value === "<anonymous>") return "<anonymous>";
|
|
180
|
+
return value;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function relativizeInApp(location: string, installRoot: string): string | undefined {
|
|
184
|
+
const normalized = normalizeSeparators(location);
|
|
185
|
+
if (!normalized || normalized === "native" || normalized === "<anonymous>") return undefined;
|
|
186
|
+
if (/^(?:node|bun):/.test(normalized)) return undefined;
|
|
187
|
+
if (/(?:^|\/)node_modules\//.test(normalized)) return undefined;
|
|
188
|
+
// Compiled-binary frames: posix `/$bunfs/root/...` and windows `B:\~BUN\root\...`.
|
|
189
|
+
const bunfs = /^(?:[A-Za-z]:\/~BUN|\/\$bunfs)\/root\/(.*)$/.exec(normalized);
|
|
190
|
+
if (bunfs) return bunfs[1] ?? undefined;
|
|
191
|
+
const root = normalizeSeparators(installRoot).replace(/\/+$/, "");
|
|
192
|
+
if (root.length > 0 && normalized.startsWith(`${root}/`)) return normalized.slice(root.length + 1);
|
|
193
|
+
return undefined;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Normalized in-app frames, newest first, capped at three.
|
|
198
|
+
*
|
|
199
|
+
* A frame is `<install-root-relative path>#<function>` with no line or column
|
|
200
|
+
* numbers. Stacks with no in-app frame yield the single literal
|
|
201
|
+
* `<no-app-frame>`; distinct roots can merge under that literal, which is an
|
|
202
|
+
* accepted and documented v1 property.
|
|
203
|
+
*/
|
|
204
|
+
export function normalizeCrashFrames(stack: string, options: CrashFingerprintOptions = {}): string[] {
|
|
205
|
+
const installRoot = options.installRoot ?? defaultInstallRoot();
|
|
206
|
+
const frames: string[] = [];
|
|
207
|
+
for (const line of stack.split("\n")) {
|
|
208
|
+
if (frames.length >= MAX_FRAMES) break;
|
|
209
|
+
const parsed = parseStackFrame(line);
|
|
210
|
+
if (!parsed) continue;
|
|
211
|
+
const relative = relativizeInApp(stripLocation(parsed.location), installRoot);
|
|
212
|
+
if (relative === undefined) continue;
|
|
213
|
+
frames.push(truncateUtf8(`${relative}#${normalizeFunctionName(parsed.functionName)}`, FRAME_MAX_BYTES));
|
|
214
|
+
}
|
|
215
|
+
return frames.length > 0 ? frames : [NO_APP_FRAME];
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function canonicalSerialization(fields: readonly string[]): Buffer {
|
|
219
|
+
const parts: Buffer[] = [];
|
|
220
|
+
for (const field of fields) {
|
|
221
|
+
const bytes = Buffer.from(field, "utf8");
|
|
222
|
+
parts.push(Buffer.from(`${bytes.byteLength}:`, "utf8"), bytes);
|
|
223
|
+
}
|
|
224
|
+
return Buffer.concat(parts);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function computeFingerprint(
|
|
228
|
+
input: CrashFingerprintInput,
|
|
229
|
+
options: CrashFingerprintOptions,
|
|
230
|
+
includeMessage: boolean,
|
|
231
|
+
): CrashFingerprint {
|
|
232
|
+
const errorName = truncateUtf8(normalizeCrashMessage(input.name, options) || "Error", 128);
|
|
233
|
+
const messageClass = normalizeCrashMessage(input.message, options);
|
|
234
|
+
const frames = normalizeCrashFrames(input.stack, options);
|
|
235
|
+
const identity = includeMessage
|
|
236
|
+
? ["skc-crash-fp.v1", errorName, messageClass, ...frames]
|
|
237
|
+
: ["skc-crash-fp.v1", "handled", errorName, frames[0] ?? NO_APP_FRAME];
|
|
238
|
+
const digest = createHash("sha256").update(canonicalSerialization(identity)).digest();
|
|
239
|
+
return {
|
|
240
|
+
fingerprint: digest.subarray(0, CRASH_FINGERPRINT_HEX_LENGTH / 2).toString("hex"),
|
|
241
|
+
version: CRASH_FINGERPRINT_VERSION,
|
|
242
|
+
errorName,
|
|
243
|
+
messageClass,
|
|
244
|
+
frames,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Compute the v1 fingerprint of an already-captured fatal diagnostic. */
|
|
249
|
+
export function computeCrashFingerprint(
|
|
250
|
+
input: CrashFingerprintInput,
|
|
251
|
+
options: CrashFingerprintOptions = {},
|
|
252
|
+
): CrashFingerprint {
|
|
253
|
+
return computeFingerprint(input, options, true);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Compute the stable identity used for a handled tool error.
|
|
258
|
+
*
|
|
259
|
+
* Tool failures often carry command output or other per-occurrence detail in
|
|
260
|
+
* their message. That text is useful in the record body but is not the failure
|
|
261
|
+
* identity: handled errors group by error class and the first in-app frame
|
|
262
|
+
* where the failure originated, rather than the full wrapper stack.
|
|
263
|
+
*/
|
|
264
|
+
export function computeHandledErrorFingerprint(
|
|
265
|
+
input: CrashFingerprintInput,
|
|
266
|
+
options: CrashFingerprintOptions = {},
|
|
267
|
+
): CrashFingerprint {
|
|
268
|
+
return computeFingerprint(input, options, false);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** The machine-readable identity line appended to every new crash record. */
|
|
272
|
+
export function formatCrashRecordMarker(fingerprint: string, version: number, recordId: string): string {
|
|
273
|
+
return `${CRASH_RECORD_MARKER} fp:${fingerprint} fpv:${version} id:${recordId}`;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export interface CrashRecordMarker {
|
|
277
|
+
readonly fingerprint: string;
|
|
278
|
+
readonly version: number;
|
|
279
|
+
readonly recordId: string;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Parse an identity line. Records written before this feature carry no marker
|
|
284
|
+
* and are therefore `unmatchable`: this parser never guesses at them.
|
|
285
|
+
*/
|
|
286
|
+
export function parseCrashRecordMarker(line: string): CrashRecordMarker | undefined {
|
|
287
|
+
const match = new RegExp(`^${CRASH_RECORD_MARKER} fp:([0-9a-f]{32}) fpv:(\\d{1,3}) id:([0-9a-f]{8,32})$`).exec(
|
|
288
|
+
line.trim(),
|
|
289
|
+
);
|
|
290
|
+
if (!match) return undefined;
|
|
291
|
+
const version = Number(match[2]);
|
|
292
|
+
if (!Number.isSafeInteger(version) || version < 1) return undefined;
|
|
293
|
+
return { fingerprint: match[1] as string, version, recordId: match[3] as string };
|
|
294
|
+
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Append-only crash event journal.
|
|
3
|
+
*
|
|
4
|
+
* The fatal path is the most hostile place in the process: it runs while the
|
|
5
|
+
* program is already broken, possibly out of disk, possibly re-entered by a
|
|
6
|
+
* crash inside the crash handler. So the fatal path does exactly one thing
|
|
7
|
+
* here — append a single bounded line with `O_APPEND` — and never parses,
|
|
8
|
+
* locks, renames or reads. Aggregation into `skc-crash-index.json` happens at
|
|
9
|
+
* the next startup, under a cross-process lock, far away from the fatal path.
|
|
10
|
+
*
|
|
11
|
+
* The journal, not the index, is the source of increments: a lost index can be
|
|
12
|
+
* rebuilt from journal events, and concurrent compactors cannot drop counts.
|
|
13
|
+
*/
|
|
14
|
+
import * as fs from "node:fs";
|
|
15
|
+
import * as path from "node:path";
|
|
16
|
+
import { CRASH_FINGERPRINT_PATTERN } from "./crash-fingerprint";
|
|
17
|
+
|
|
18
|
+
/** Hard cap for one journal line, including its newline. */
|
|
19
|
+
export const CRASH_EVENT_MAX_BYTES = 512;
|
|
20
|
+
/** Journal line format tag. */
|
|
21
|
+
export const CRASH_EVENT_KIND = "skc-crash-event.v1";
|
|
22
|
+
/** Preview cap for the message class carried by an event. */
|
|
23
|
+
export const CRASH_EVENT_MESSAGE_MAX_BYTES = 256;
|
|
24
|
+
|
|
25
|
+
/** Bounded execution provenance carried only on newly written occurrences. */
|
|
26
|
+
export type CrashProvenance = "product" | "eval" | "bun_test";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Classify only process-level harness modes that are explicit and stable.
|
|
30
|
+
* Everything else is product provenance, including source checkouts, CLI
|
|
31
|
+
* invocations, SDK/ACP hosts, and native-loader failures.
|
|
32
|
+
*/
|
|
33
|
+
export function detectCrashProvenance(
|
|
34
|
+
argv: readonly string[] = process.argv,
|
|
35
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
36
|
+
execArgv: readonly string[] = process.execArgv,
|
|
37
|
+
): CrashProvenance {
|
|
38
|
+
if (env.BUN_TEST !== undefined) return "bun_test";
|
|
39
|
+
if (execArgv[0] === "-e" || execArgv[0] === "--eval" || argv[0] === "-e" || argv[0] === "--eval") return "eval";
|
|
40
|
+
return "product";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type CrashEvent =
|
|
44
|
+
| CrashOccurrenceEvent
|
|
45
|
+
| CrashRefusedEvent
|
|
46
|
+
| CrashReportedEvent
|
|
47
|
+
| CrashRelayedEvent
|
|
48
|
+
| CrashAcknowledgedEvent
|
|
49
|
+
| CrashNudgedEvent;
|
|
50
|
+
|
|
51
|
+
export interface CrashOccurrenceEvent {
|
|
52
|
+
readonly kind: "occurrence";
|
|
53
|
+
readonly fingerprint: string;
|
|
54
|
+
readonly fpv: number;
|
|
55
|
+
readonly recordId: string;
|
|
56
|
+
readonly at: number;
|
|
57
|
+
readonly errorName: string;
|
|
58
|
+
readonly messageClass: string;
|
|
59
|
+
/** Legacy events omit this and are treated as product crashes. */
|
|
60
|
+
readonly provenance?: CrashProvenance;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface CrashRefusedEvent {
|
|
64
|
+
readonly kind: "refused";
|
|
65
|
+
readonly fingerprint: string;
|
|
66
|
+
readonly fpv: number;
|
|
67
|
+
readonly recordId: string;
|
|
68
|
+
readonly contractVersion: string;
|
|
69
|
+
readonly at: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface CrashReportedEvent {
|
|
73
|
+
readonly kind: "reported";
|
|
74
|
+
readonly fingerprint: string;
|
|
75
|
+
readonly at: number;
|
|
76
|
+
readonly issueUrl: string;
|
|
77
|
+
/** Set when the submission was a "+1" comment rather than a new issue. */
|
|
78
|
+
readonly commented?: boolean;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface CrashRelayedEvent {
|
|
82
|
+
readonly kind: "relayed";
|
|
83
|
+
readonly fingerprint: string;
|
|
84
|
+
readonly at: number;
|
|
85
|
+
/** Sentry event id accepted upstream: 32 lowercase hex. */
|
|
86
|
+
readonly eventId: string;
|
|
87
|
+
/** Exact occurrence record represented by the accepted envelope. Absent on legacy pre-record-id relay lines. */
|
|
88
|
+
readonly recordId?: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface CrashAcknowledgedEvent {
|
|
92
|
+
readonly kind: "acknowledged";
|
|
93
|
+
readonly fingerprint: string;
|
|
94
|
+
readonly at: number;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface CrashNudgedEvent {
|
|
98
|
+
readonly kind: "nudged";
|
|
99
|
+
readonly at: number;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function truncateUtf8(text: string, maxBytes: number): string {
|
|
103
|
+
if (Buffer.byteLength(text, "utf8") <= maxBytes) return text;
|
|
104
|
+
const bytes = Buffer.from(text, "utf8");
|
|
105
|
+
let end = maxBytes;
|
|
106
|
+
while (end > 0 && (bytes[end - 1] & 0xc0) === 0x80) end--;
|
|
107
|
+
if (end > 0 && bytes[end - 1] >= 0xc0) end--;
|
|
108
|
+
return bytes.subarray(0, end).toString("utf8");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Strip control characters so one event line can never contain a newline. */
|
|
112
|
+
function sanitizeEventText(text: string): string {
|
|
113
|
+
return text.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Serialize one event to a single journal line (newline included), bounded to
|
|
118
|
+
* `CRASH_EVENT_MAX_BYTES`. Oversized message previews are shortened, and if the
|
|
119
|
+
* line still does not fit the preview is dropped entirely rather than the event.
|
|
120
|
+
*/
|
|
121
|
+
export function formatCrashEventLine(event: CrashEvent): string {
|
|
122
|
+
const build = (messageClass?: string): string => {
|
|
123
|
+
const body: Record<string, unknown> =
|
|
124
|
+
event.kind === "occurrence"
|
|
125
|
+
? {
|
|
126
|
+
k: "occurrence",
|
|
127
|
+
fp: event.fingerprint,
|
|
128
|
+
fpv: event.fpv,
|
|
129
|
+
id: event.recordId,
|
|
130
|
+
at: event.at,
|
|
131
|
+
n: sanitizeEventText(truncateUtf8(event.errorName, 64)),
|
|
132
|
+
...(event.provenance && event.provenance !== "product" ? { p: event.provenance } : {}),
|
|
133
|
+
...(messageClass === undefined ? {} : { m: messageClass }),
|
|
134
|
+
}
|
|
135
|
+
: event.kind === "reported"
|
|
136
|
+
? {
|
|
137
|
+
k: "reported",
|
|
138
|
+
fp: event.fingerprint,
|
|
139
|
+
at: event.at,
|
|
140
|
+
u: sanitizeEventText(truncateUtf8(event.issueUrl, 256)),
|
|
141
|
+
...(event.commented ? { c: 1 } : {}),
|
|
142
|
+
}
|
|
143
|
+
: event.kind === "refused"
|
|
144
|
+
? {
|
|
145
|
+
k: "refused",
|
|
146
|
+
fp: event.fingerprint,
|
|
147
|
+
v: event.fpv,
|
|
148
|
+
r: event.recordId,
|
|
149
|
+
c: sanitizeEventText(truncateUtf8(event.contractVersion, 64)),
|
|
150
|
+
at: event.at,
|
|
151
|
+
}
|
|
152
|
+
: event.kind === "relayed"
|
|
153
|
+
? { k: "relayed", fp: event.fingerprint, at: event.at, e: event.eventId, r: event.recordId }
|
|
154
|
+
: event.kind === "acknowledged"
|
|
155
|
+
? { k: "acknowledged", fp: event.fingerprint, at: event.at }
|
|
156
|
+
: { k: "nudged", at: event.at };
|
|
157
|
+
return `${CRASH_EVENT_KIND} ${JSON.stringify(body)}\n`;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
if (event.kind !== "occurrence") return truncateLine(build());
|
|
161
|
+
let preview = sanitizeEventText(truncateUtf8(event.messageClass, CRASH_EVENT_MESSAGE_MAX_BYTES));
|
|
162
|
+
let line = build(preview);
|
|
163
|
+
while (Buffer.byteLength(line, "utf8") > CRASH_EVENT_MAX_BYTES && preview.length > 0) {
|
|
164
|
+
preview = preview.slice(0, Math.floor(preview.length / 2));
|
|
165
|
+
line = build(preview);
|
|
166
|
+
}
|
|
167
|
+
if (Buffer.byteLength(line, "utf8") > CRASH_EVENT_MAX_BYTES) line = build();
|
|
168
|
+
return truncateLine(line);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Final safety net: an event line never exceeds the cap, even if malformed. */
|
|
172
|
+
function truncateLine(line: string): string {
|
|
173
|
+
if (Buffer.byteLength(line, "utf8") <= CRASH_EVENT_MAX_BYTES) return line;
|
|
174
|
+
return `${truncateUtf8(line.trimEnd(), CRASH_EVENT_MAX_BYTES - 1)}\n`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function isSafeTimestamp(value: unknown): value is number {
|
|
178
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Parse one journal line. Anything unexpected yields `undefined`, never a throw. */
|
|
182
|
+
export function parseCrashEventLine(line: string): CrashEvent | undefined {
|
|
183
|
+
const trimmed = line.trim();
|
|
184
|
+
if (!trimmed.startsWith(`${CRASH_EVENT_KIND} `)) return undefined;
|
|
185
|
+
if (Buffer.byteLength(trimmed, "utf8") > CRASH_EVENT_MAX_BYTES) return undefined;
|
|
186
|
+
let parsed: unknown;
|
|
187
|
+
try {
|
|
188
|
+
parsed = JSON.parse(trimmed.slice(CRASH_EVENT_KIND.length + 1)) as unknown;
|
|
189
|
+
} catch {
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
|
|
193
|
+
const body = parsed as Record<string, unknown>;
|
|
194
|
+
if (!isSafeTimestamp(body.at)) return undefined;
|
|
195
|
+
const at = body.at;
|
|
196
|
+
const fingerprint = typeof body.fp === "string" && CRASH_FINGERPRINT_PATTERN.test(body.fp) ? body.fp : undefined;
|
|
197
|
+
|
|
198
|
+
switch (body.k) {
|
|
199
|
+
case "occurrence": {
|
|
200
|
+
if (!fingerprint) return undefined;
|
|
201
|
+
if (typeof body.id !== "string" || !/^[0-9a-f]{8,32}$/.test(body.id)) return undefined;
|
|
202
|
+
if (typeof body.fpv !== "number" || !Number.isSafeInteger(body.fpv) || body.fpv < 1) return undefined;
|
|
203
|
+
const errorName = typeof body.n === "string" ? sanitizeEventText(body.n) : "Error";
|
|
204
|
+
const messageClass = typeof body.m === "string" ? sanitizeEventText(body.m) : "";
|
|
205
|
+
const provenance = body.p === undefined ? undefined : body.p;
|
|
206
|
+
if (provenance !== undefined && provenance !== "product" && provenance !== "eval" && provenance !== "bun_test")
|
|
207
|
+
return undefined;
|
|
208
|
+
return {
|
|
209
|
+
kind: "occurrence",
|
|
210
|
+
fingerprint,
|
|
211
|
+
fpv: body.fpv,
|
|
212
|
+
recordId: body.id,
|
|
213
|
+
at,
|
|
214
|
+
errorName,
|
|
215
|
+
messageClass,
|
|
216
|
+
...(provenance === undefined ? {} : { provenance }),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
case "reported": {
|
|
220
|
+
if (!fingerprint) return undefined;
|
|
221
|
+
if (typeof body.u !== "string" || body.u.length === 0) return undefined;
|
|
222
|
+
return { kind: "reported", fingerprint, at, issueUrl: sanitizeEventText(body.u), commented: body.c === 1 };
|
|
223
|
+
}
|
|
224
|
+
case "refused": {
|
|
225
|
+
if (!fingerprint) return undefined;
|
|
226
|
+
if (typeof body.r !== "string" || !/^[0-9a-f]{8,32}$/.test(body.r)) return undefined;
|
|
227
|
+
if (typeof body.v !== "number" || !Number.isSafeInteger(body.v) || body.v < 1) return undefined;
|
|
228
|
+
if (typeof body.c !== "string" || body.c.length === 0 || /[\u0000-\u001f\u007f-\u009f]/.test(body.c))
|
|
229
|
+
return undefined;
|
|
230
|
+
return { kind: "refused", fingerprint, fpv: body.v, recordId: body.r, contractVersion: body.c, at };
|
|
231
|
+
}
|
|
232
|
+
case "relayed": {
|
|
233
|
+
if (!fingerprint) return undefined;
|
|
234
|
+
if (typeof body.e !== "string" || !/^[0-9a-f]{32}$/.test(body.e)) return undefined;
|
|
235
|
+
if (body.r !== undefined && (typeof body.r !== "string" || !/^[0-9a-f]{8,32}$/.test(body.r))) return undefined;
|
|
236
|
+
return {
|
|
237
|
+
kind: "relayed",
|
|
238
|
+
fingerprint,
|
|
239
|
+
at,
|
|
240
|
+
eventId: body.e,
|
|
241
|
+
...(typeof body.r === "string" ? { recordId: body.r } : {}),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
case "acknowledged": {
|
|
245
|
+
if (!fingerprint) return undefined;
|
|
246
|
+
return { kind: "acknowledged", fingerprint, at };
|
|
247
|
+
}
|
|
248
|
+
case "nudged":
|
|
249
|
+
return { kind: "nudged", at };
|
|
250
|
+
default:
|
|
251
|
+
return undefined;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Append one event synchronously with `O_APPEND`. Never throws.
|
|
257
|
+
*
|
|
258
|
+
* No parse, no lock, no rename, no read: the only I/O is one bounded write to
|
|
259
|
+
* an append-only file, so concurrent writers interleave whole lines instead of
|
|
260
|
+
* corrupting each other's records.
|
|
261
|
+
*/
|
|
262
|
+
export function appendCrashEvent(event: CrashEvent, journalPath: string): boolean {
|
|
263
|
+
try {
|
|
264
|
+
const line = formatCrashEventLine(event);
|
|
265
|
+
fs.mkdirSync(path.dirname(journalPath), { recursive: true });
|
|
266
|
+
const fd = fs.openSync(journalPath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_APPEND, 0o600);
|
|
267
|
+
try {
|
|
268
|
+
fs.writeSync(fd, line);
|
|
269
|
+
} finally {
|
|
270
|
+
fs.closeSync(fd);
|
|
271
|
+
}
|
|
272
|
+
return true;
|
|
273
|
+
} catch {
|
|
274
|
+
// A failing journal write must never mask or delay the original fatal.
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
let fatalJournalLatched = false;
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Fatal-path entry point: at most one journal append per process lifetime.
|
|
283
|
+
*
|
|
284
|
+
* A crash raised while handling a crash must not spend the process's remaining
|
|
285
|
+
* moments on journal bookkeeping, so the latch is never cleared — the process
|
|
286
|
+
* is exiting anyway. Returns whether this call wrote a line.
|
|
287
|
+
*/
|
|
288
|
+
export function appendFatalCrashEvent(event: CrashEvent, journalPath: string): boolean {
|
|
289
|
+
if (fatalJournalLatched) return false;
|
|
290
|
+
fatalJournalLatched = true;
|
|
291
|
+
return appendCrashEvent(event, journalPath);
|
|
292
|
+
}
|