@gajae-code/utils 0.11.8 → 0.11.10
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/postmortem.d.ts +25 -0
- package/package.json +2 -2
- package/src/postmortem.ts +106 -0
|
@@ -8,6 +8,31 @@ export declare enum Reason {
|
|
|
8
8
|
UNHANDLED_REJECTION = "unhandled_rejection",// Unhandled promise rejection
|
|
9
9
|
MANUAL = "manual"
|
|
10
10
|
}
|
|
11
|
+
/** Cap for the durable crash log; it is reset past this so a crash loop cannot fill the disk. */
|
|
12
|
+
export declare const CRASH_LOG_MAX_BYTES: number;
|
|
13
|
+
/**
|
|
14
|
+
* Per-record budget so a single oversized error body cannot bypass the file
|
|
15
|
+
* cap: every persisted record is truncated to this many bytes (UTF-8 safe,
|
|
16
|
+
* with a marker) before the append/reset decision.
|
|
17
|
+
*/
|
|
18
|
+
export declare const CRASH_RECORD_MAX_BYTES: number;
|
|
19
|
+
/**
|
|
20
|
+
* Append a fatal-crash record to the dedicated, rotation-immune crash log
|
|
21
|
+
* (`~/.gjc/agent/gjc-crash.log`).
|
|
22
|
+
*
|
|
23
|
+
* The daily logger file is gzip-archived at date rollover by every gjc process
|
|
24
|
+
* independently; that shared-archive race can truncate a day's log to an empty
|
|
25
|
+
* `.gz`, destroying the `logger.error` crash record written here. This
|
|
26
|
+
* append-only file is never rotated, so a crash stays diagnosable regardless.
|
|
27
|
+
*
|
|
28
|
+
* Fully defensive: it never throws (a failing crash writer must not mask the
|
|
29
|
+
* original fatal) and uses synchronous IO so the record lands before
|
|
30
|
+
* `process.exit`. Returns the path written, or `undefined` on failure.
|
|
31
|
+
*/
|
|
32
|
+
export declare function recordFatalCrash(label: string, reason: unknown, options?: {
|
|
33
|
+
path?: string;
|
|
34
|
+
now?: Date;
|
|
35
|
+
}): string | undefined;
|
|
11
36
|
/**
|
|
12
37
|
* Register a process cleanup callback, to be run on shutdown, signal, or fatal error.
|
|
13
38
|
*
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@gajae-code/utils",
|
|
4
|
-
"version": "0.11.
|
|
4
|
+
"version": "0.11.10",
|
|
5
5
|
"description": "Shared utilities for pi packages",
|
|
6
6
|
"homepage": "https://gajae-code.com",
|
|
7
7
|
"author": "Yeachan-Heo",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"fmt": "biome format --write ."
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@gajae-code/natives": "0.11.
|
|
34
|
+
"@gajae-code/natives": "0.11.10",
|
|
35
35
|
"beautiful-mermaid": "^1.1.3",
|
|
36
36
|
"handlebars": "^4.7.9",
|
|
37
37
|
"winston": "^3.19.0",
|
package/src/postmortem.ts
CHANGED
|
@@ -5,9 +5,12 @@
|
|
|
5
5
|
* in response to process exit, signals, or fatal exceptions. It is intended to
|
|
6
6
|
* allow reliably releasing resources or shutting down subprocesses, files, sockets, etc.
|
|
7
7
|
*/
|
|
8
|
+
import * as fs from "node:fs";
|
|
8
9
|
import inspector from "node:inspector";
|
|
10
|
+
import * as path from "node:path";
|
|
9
11
|
import { isMainThread } from "node:worker_threads";
|
|
10
12
|
import { BROKEN_PIPE_EXIT_CODE, createProcessStdoutEpipeClassifier } from "./broken-pipe";
|
|
13
|
+
import { getCrashLogPath } from "./dirs";
|
|
11
14
|
import * as logger from "./logger";
|
|
12
15
|
import { safeStderrWrite } from "./safe-stderr";
|
|
13
16
|
|
|
@@ -204,6 +207,104 @@ function formatFatalError(label: string, err: Error): string {
|
|
|
204
207
|
const formattedStack = stackLines.length > 0 ? `\n${stackLines.join("\n")}` : "";
|
|
205
208
|
return `\n[${label}] ${name}: ${message}${formattedStack}\n`;
|
|
206
209
|
}
|
|
210
|
+
/** Cap for the durable crash log; it is reset past this so a crash loop cannot fill the disk. */
|
|
211
|
+
export const CRASH_LOG_MAX_BYTES = 512 * 1024;
|
|
212
|
+
/**
|
|
213
|
+
* Per-record budget so a single oversized error body cannot bypass the file
|
|
214
|
+
* cap: every persisted record is truncated to this many bytes (UTF-8 safe,
|
|
215
|
+
* with a marker) before the append/reset decision.
|
|
216
|
+
*/
|
|
217
|
+
export const CRASH_RECORD_MAX_BYTES = 64 * 1024;
|
|
218
|
+
const CRASH_RECORD_TRUNCATION_MARKER = "\n… [crash record truncated]\n\n";
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Best-effort scrub of credential material from a crash record before it is
|
|
222
|
+
* persisted indefinitely. Covers bearer/basic-style headers, key=value or
|
|
223
|
+
* JSON key forms of common credential names, and well-known vendor token
|
|
224
|
+
* shapes. Normal messages and stack frames are untouched; matches are
|
|
225
|
+
* replaced in place so surrounding diagnostic context survives.
|
|
226
|
+
*/
|
|
227
|
+
function redactCrashSecrets(text: string): string {
|
|
228
|
+
let redacted = text;
|
|
229
|
+
redacted = redacted.replace(/\b(?:Bearer|Basic|Token)\s+[A-Za-z0-9._~+/=-]{8,}/gi, "«redacted-auth»");
|
|
230
|
+
redacted = redacted.replace(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g, "«redacted-jwt»");
|
|
231
|
+
redacted = redacted.replace(/\bsk-[A-Za-z0-9_-]{8,}\b/g, "«redacted-api-key»");
|
|
232
|
+
redacted = redacted.replace(/\bgh[opsur]_[A-Za-z0-9]{16,}\b/g, "«redacted-github-token»");
|
|
233
|
+
redacted = redacted.replace(/\bxox[baprs]-[A-Za-z0-9-]{8,}\b/g, "«redacted-slack-token»");
|
|
234
|
+
redacted = redacted.replace(/\bAKIA[0-9A-Z]{16}\b/g, "«redacted-aws-key»");
|
|
235
|
+
redacted = redacted.replace(
|
|
236
|
+
/(["']?(?:api[_-]?key|apikey|access[_-]?token|refresh[_-]?token|id[_-]?token|client[_-]?secret|secret[_-]?key|password|passwd|authorization)["']?\s*[=:]\s*["']?)[^\s"',;}\]]{8,}/gi,
|
|
237
|
+
"$1«redacted»",
|
|
238
|
+
);
|
|
239
|
+
return redacted;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Bound one record to CRASH_RECORD_MAX_BYTES without splitting a UTF-8
|
|
244
|
+
* sequence. Keeps the header (timestamp/label/message) at the front, where
|
|
245
|
+
* the diagnostic value is highest.
|
|
246
|
+
*/
|
|
247
|
+
function boundCrashRecord(report: string): string {
|
|
248
|
+
if (Buffer.byteLength(report, "utf8") <= CRASH_RECORD_MAX_BYTES) return report;
|
|
249
|
+
const bytes = Buffer.from(report, "utf8");
|
|
250
|
+
const budget = CRASH_RECORD_MAX_BYTES - Buffer.byteLength(CRASH_RECORD_TRUNCATION_MARKER, "utf8");
|
|
251
|
+
let end = budget;
|
|
252
|
+
// Drop trailing continuation bytes of a truncated multi-byte sequence.
|
|
253
|
+
while (end > 0 && (bytes[end - 1] & 0xc0) === 0x80) end--;
|
|
254
|
+
// Drop the now-incomplete lead byte, if any.
|
|
255
|
+
if (end > 0 && bytes[end - 1] >= 0xc0) end--;
|
|
256
|
+
return bytes.subarray(0, end).toString("utf8") + CRASH_RECORD_TRUNCATION_MARKER;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Append a fatal-crash record to the dedicated, rotation-immune crash log
|
|
261
|
+
* (`~/.gjc/agent/gjc-crash.log`).
|
|
262
|
+
*
|
|
263
|
+
* The daily logger file is gzip-archived at date rollover by every gjc process
|
|
264
|
+
* independently; that shared-archive race can truncate a day's log to an empty
|
|
265
|
+
* `.gz`, destroying the `logger.error` crash record written here. This
|
|
266
|
+
* append-only file is never rotated, so a crash stays diagnosable regardless.
|
|
267
|
+
*
|
|
268
|
+
* Fully defensive: it never throws (a failing crash writer must not mask the
|
|
269
|
+
* original fatal) and uses synchronous IO so the record lands before
|
|
270
|
+
* `process.exit`. Returns the path written, or `undefined` on failure.
|
|
271
|
+
*/
|
|
272
|
+
export function recordFatalCrash(
|
|
273
|
+
label: string,
|
|
274
|
+
reason: unknown,
|
|
275
|
+
options: { path?: string; now?: Date } = {},
|
|
276
|
+
): string | undefined {
|
|
277
|
+
try {
|
|
278
|
+
const err = errorForDiagnostic(reason);
|
|
279
|
+
const target = options.path ?? getCrashLogPath();
|
|
280
|
+
const now = options.now ?? new Date();
|
|
281
|
+
const report = boundCrashRecord(
|
|
282
|
+
`${now.toISOString()} pid=${process.pid} [${label}] ` +
|
|
283
|
+
`${err.name || "Error"}: ${redactCrashSecrets(err.message || "(no message)")}\n` +
|
|
284
|
+
`${redactCrashSecrets(err.stack ?? "")}\n\n`,
|
|
285
|
+
);
|
|
286
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
287
|
+
let existingSize = 0;
|
|
288
|
+
try {
|
|
289
|
+
existingSize = fs.statSync(target).size;
|
|
290
|
+
} catch {}
|
|
291
|
+
// Reset (rather than append) when the file would exceed the cap so the
|
|
292
|
+
// newest crash is always retained without unbounded growth. Every record
|
|
293
|
+
// is individually bounded above, so no single crash can bypass the cap.
|
|
294
|
+
if (existingSize + Buffer.byteLength(report, "utf8") > CRASH_LOG_MAX_BYTES) {
|
|
295
|
+
fs.writeFileSync(target, report, { mode: 0o600 });
|
|
296
|
+
} else {
|
|
297
|
+
fs.appendFileSync(target, report, { mode: 0o600 });
|
|
298
|
+
}
|
|
299
|
+
// A pre-existing file may carry looser permissions; enforce owner-only.
|
|
300
|
+
try {
|
|
301
|
+
fs.chmodSync(target, 0o600);
|
|
302
|
+
} catch {}
|
|
303
|
+
return target;
|
|
304
|
+
} catch {
|
|
305
|
+
return undefined;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
207
308
|
|
|
208
309
|
async function exitQuietlyForAttributableStdoutEpipe(reason: Reason): Promise<void> {
|
|
209
310
|
if (ordinaryFatalStarted || quietShutdownStarted) return;
|
|
@@ -226,7 +327,12 @@ async function handleFatalError(label: string, reason: unknown, cleanupReason: R
|
|
|
226
327
|
ordinaryFatalStarted = true;
|
|
227
328
|
process.exitCode = 1;
|
|
228
329
|
const err = errorForDiagnostic(reason);
|
|
330
|
+
// Persist first: the rotation-immune record must land before any
|
|
331
|
+
// best-effort stderr output, so a slow or failing stderr cannot cost the
|
|
332
|
+
// crash record. Cleanup (which may itself hang or fail) runs afterwards.
|
|
333
|
+
const crashLogPath = recordFatalCrash(label, err);
|
|
229
334
|
safeStderrWrite(formatFatalError(label, err));
|
|
335
|
+
if (crashLogPath) safeStderrWrite(`[${label}] crash recorded at ${crashLogPath}\n`);
|
|
230
336
|
if (!quietShutdownStarted) {
|
|
231
337
|
logger.error(label === "Uncaught Exception" ? "Uncaught exception" : "Unhandled rejection", {
|
|
232
338
|
err,
|