@sayknow-cli/utils 0.3.12 → 0.3.15
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 +27 -0
- package/dist/types/cli.d.ts +3 -3
- package/dist/types/frontmatter.d.ts +2 -2
- package/dist/types/index.d.ts +1 -0
- package/dist/types/which.d.ts +3 -1
- package/package.json +3 -3
- package/src/broken-pipe.ts +132 -0
- package/src/index.ts +1 -0
- package/src/postmortem.ts +159 -60
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* True when an error from a writer that owns its sink reports that the peer
|
|
3
|
+
* closed that sink. This intentionally accepts structural error objects rather
|
|
4
|
+
* than requiring an `Error` instance.
|
|
5
|
+
*/
|
|
6
|
+
export declare function isKnownSinkPeerClosedError(error: unknown): boolean;
|
|
7
|
+
/**
|
|
8
|
+
* Backward-compatible local-sink classifier for output writers. Callers must
|
|
9
|
+
* use it only when they own the sink that produced the error.
|
|
10
|
+
*/
|
|
11
|
+
export declare function isBrokenPipeError(error: unknown): boolean;
|
|
12
|
+
/**
|
|
13
|
+
* A classifier for process-level stdout `EPIPE` errors. Its direct-write
|
|
14
|
+
* evidence is private to each factory instance, so only the owner that
|
|
15
|
+
* intercepted `process.stdout.write` can mark an error for this classifier.
|
|
16
|
+
*/
|
|
17
|
+
export interface ProcessStdoutEpipeClassifier {
|
|
18
|
+
markDirectProcessStdoutWriteError(error: unknown): void;
|
|
19
|
+
isAttributableProcessStdoutEpipe(error: unknown): boolean;
|
|
20
|
+
}
|
|
21
|
+
export declare function createProcessStdoutEpipeClassifier(): ProcessStdoutEpipeClassifier;
|
|
22
|
+
/**
|
|
23
|
+
* Exit code for a producer terminated because its output pipe broke:
|
|
24
|
+
* 128 + SIGPIPE (13), matching what shells report for SIGPIPE-killed tools
|
|
25
|
+
* in `foo | head`-style pipelines.
|
|
26
|
+
*/
|
|
27
|
+
export declare const BROKEN_PIPE_EXIT_CODE = 141;
|
package/dist/types/cli.d.ts
CHANGED
|
@@ -31,10 +31,10 @@ interface ArgInput {
|
|
|
31
31
|
/** Builders that match the `Flags.*()` / `Args.*()` API from oclif. */
|
|
32
32
|
export declare const Flags: {
|
|
33
33
|
string<T extends FlagInput>(opts?: T): FlagDescriptor<"string"> & T;
|
|
34
|
-
boolean<
|
|
35
|
-
integer<
|
|
34
|
+
boolean<T extends FlagInput>(opts?: T): FlagDescriptor<"boolean"> & T;
|
|
35
|
+
integer<T extends FlagInput & {
|
|
36
36
|
default?: number;
|
|
37
|
-
}>(opts?:
|
|
37
|
+
}>(opts?: T): FlagDescriptor<"integer"> & T;
|
|
38
38
|
};
|
|
39
39
|
export declare const Args: {
|
|
40
40
|
string<T extends ArgInput>(opts?: T): ArgDescriptor & T;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export declare class FrontmatterError extends Error {
|
|
2
|
-
readonly source?: unknown;
|
|
3
|
-
constructor(error: Error, source?: unknown);
|
|
2
|
+
readonly source?: unknown | undefined;
|
|
3
|
+
constructor(error: Error, source?: unknown | undefined);
|
|
4
4
|
toString(): string;
|
|
5
5
|
}
|
|
6
6
|
export interface FrontmatterOptions {
|
package/dist/types/index.d.ts
CHANGED
package/dist/types/which.d.ts
CHANGED
|
@@ -26,7 +26,8 @@ export interface WhichOptions extends Bun.WhichOptions {
|
|
|
26
26
|
*/
|
|
27
27
|
cache?: WhichCachePolicy;
|
|
28
28
|
}
|
|
29
|
-
|
|
29
|
+
declare function darwinWhich(command: string, _options?: Bun.WhichOptions): string | null;
|
|
30
|
+
export declare const whichFresh: typeof darwinWhich;
|
|
30
31
|
/**
|
|
31
32
|
* Locate binary on PATH (with flexible caching).
|
|
32
33
|
*
|
|
@@ -35,3 +36,4 @@ export declare const whichFresh: typeof Bun.which;
|
|
|
35
36
|
* @returns Filesystem path if found, else null
|
|
36
37
|
*/
|
|
37
38
|
export declare function $which(command: string, options?: WhichOptions): string | null;
|
|
39
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@sayknow-cli/utils",
|
|
4
|
-
"version": "0.3.
|
|
4
|
+
"version": "0.3.15",
|
|
5
5
|
"description": "Shared utilities for pi packages",
|
|
6
6
|
"homepage": "https://sayknow-cli.com",
|
|
7
7
|
"author": "jaybeyond",
|
|
@@ -24,14 +24,14 @@
|
|
|
24
24
|
"types": "./dist/types/index.d.ts",
|
|
25
25
|
"scripts": {
|
|
26
26
|
"check": "biome check . && bun run check:types",
|
|
27
|
-
"check:types": "
|
|
27
|
+
"check:types": "tsc -p tsconfig.json --noEmit",
|
|
28
28
|
"lint": "biome lint .",
|
|
29
29
|
"test": "bun test",
|
|
30
30
|
"fix": "biome check --write --unsafe .",
|
|
31
31
|
"fmt": "biome format --write ."
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@sayknow-cli/natives": "0.3.
|
|
34
|
+
"@sayknow-cli/natives": "0.3.15",
|
|
35
35
|
"beautiful-mermaid": "^1.1.3",
|
|
36
36
|
"handlebars": "^4.7.9",
|
|
37
37
|
"winston": "^3.19.0",
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Broken-pipe classification for writers that own a local output sink and for
|
|
3
|
+
* the process-wide postmortem fallback.
|
|
4
|
+
*
|
|
5
|
+
* A local writer may classify its own sink closure from the error code. The
|
|
6
|
+
* process-wide fallback is deliberately stricter: an `EPIPE` must be
|
|
7
|
+
* attributable to process stdout before it receives SIGPIPE-style shutdown.
|
|
8
|
+
*/
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
|
|
11
|
+
const KNOWN_SINK_PEER_CLOSED_CODES = new Set(["EPIPE", "ERR_STREAM_DESTROYED"]);
|
|
12
|
+
|
|
13
|
+
type ErrorProperty = "code" | "fd" | "syscall";
|
|
14
|
+
|
|
15
|
+
interface ErrorPropertyRead {
|
|
16
|
+
available: boolean;
|
|
17
|
+
value: unknown;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isObjectLike(value: unknown): value is object {
|
|
21
|
+
return value !== null && (typeof value === "object" || typeof value === "function");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function readErrorProperty(error: object, property: ErrorProperty): ErrorPropertyRead {
|
|
25
|
+
try {
|
|
26
|
+
return { available: true, value: Reflect.get(error, property) };
|
|
27
|
+
} catch {
|
|
28
|
+
return { available: false, value: undefined };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isUsableFileDescriptor(value: unknown): value is number {
|
|
33
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
34
|
+
}
|
|
35
|
+
interface FileIdentity {
|
|
36
|
+
dev: number | bigint;
|
|
37
|
+
ino: number | bigint;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function fileIdentity(fd: number): FileIdentity | undefined {
|
|
41
|
+
try {
|
|
42
|
+
const stat = fs.fstatSync(fd);
|
|
43
|
+
return { dev: stat.dev, ino: stat.ino };
|
|
44
|
+
} catch {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function sameFileIdentity(left: FileIdentity, right: FileIdentity): boolean {
|
|
50
|
+
return left.dev === right.dev && left.ino === right.ino;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* True when an error from a writer that owns its sink reports that the peer
|
|
55
|
+
* closed that sink. This intentionally accepts structural error objects rather
|
|
56
|
+
* than requiring an `Error` instance.
|
|
57
|
+
*/
|
|
58
|
+
export function isKnownSinkPeerClosedError(error: unknown): boolean {
|
|
59
|
+
if (!isObjectLike(error)) return false;
|
|
60
|
+
const code = readErrorProperty(error, "code");
|
|
61
|
+
return code.available && typeof code.value === "string" && KNOWN_SINK_PEER_CLOSED_CODES.has(code.value);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Backward-compatible local-sink classifier for output writers. Callers must
|
|
66
|
+
* use it only when they own the sink that produced the error.
|
|
67
|
+
*/
|
|
68
|
+
export function isBrokenPipeError(error: unknown): boolean {
|
|
69
|
+
return isKnownSinkPeerClosedError(error);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A classifier for process-level stdout `EPIPE` errors. Its direct-write
|
|
74
|
+
* evidence is private to each factory instance, so only the owner that
|
|
75
|
+
* intercepted `process.stdout.write` can mark an error for this classifier.
|
|
76
|
+
*/
|
|
77
|
+
export interface ProcessStdoutEpipeClassifier {
|
|
78
|
+
markDirectProcessStdoutWriteError(error: unknown): void;
|
|
79
|
+
isAttributableProcessStdoutEpipe(error: unknown): boolean;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function createProcessStdoutEpipeClassifier(): ProcessStdoutEpipeClassifier {
|
|
83
|
+
const directProcessStdoutWriteErrors = new WeakSet<object>();
|
|
84
|
+
const initialStdoutIdentity = isUsableFileDescriptor(process.stdout.fd)
|
|
85
|
+
? fileIdentity(process.stdout.fd)
|
|
86
|
+
: undefined;
|
|
87
|
+
|
|
88
|
+
const hasCurrentStdoutIdentity = (fd: number): boolean => {
|
|
89
|
+
if (!initialStdoutIdentity) return false;
|
|
90
|
+
let stdoutFd: number;
|
|
91
|
+
try {
|
|
92
|
+
stdoutFd = process.stdout.fd;
|
|
93
|
+
} catch {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
if (!isUsableFileDescriptor(stdoutFd)) return false;
|
|
97
|
+
const currentStdoutIdentity = fileIdentity(stdoutFd);
|
|
98
|
+
const errorFdIdentity = fileIdentity(fd);
|
|
99
|
+
if (!currentStdoutIdentity || !errorFdIdentity) return false;
|
|
100
|
+
if (!sameFileIdentity(currentStdoutIdentity, initialStdoutIdentity)) return false;
|
|
101
|
+
return fd === stdoutFd || sameFileIdentity(errorFdIdentity, currentStdoutIdentity);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
markDirectProcessStdoutWriteError(error: unknown): void {
|
|
106
|
+
if (isObjectLike(error)) directProcessStdoutWriteErrors.add(error);
|
|
107
|
+
},
|
|
108
|
+
isAttributableProcessStdoutEpipe(error: unknown): boolean {
|
|
109
|
+
if (!isObjectLike(error)) return false;
|
|
110
|
+
|
|
111
|
+
const code = readErrorProperty(error, "code");
|
|
112
|
+
if (!code.available || code.value !== "EPIPE") return false;
|
|
113
|
+
|
|
114
|
+
if (directProcessStdoutWriteErrors.has(error)) return true;
|
|
115
|
+
|
|
116
|
+
const syscall = readErrorProperty(error, "syscall");
|
|
117
|
+
if (!syscall.available || syscall.value !== "write") return false;
|
|
118
|
+
|
|
119
|
+
const fd = readErrorProperty(error, "fd");
|
|
120
|
+
if (!fd.available || !isUsableFileDescriptor(fd.value)) return false;
|
|
121
|
+
|
|
122
|
+
return hasCurrentStdoutIdentity(fd.value);
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Exit code for a producer terminated because its output pipe broke:
|
|
129
|
+
* 128 + SIGPIPE (13), matching what shells report for SIGPIPE-killed tools
|
|
130
|
+
* in `foo | head`-style pipelines.
|
|
131
|
+
*/
|
|
132
|
+
export const BROKEN_PIPE_EXIT_CODE = 141;
|
package/src/index.ts
CHANGED
package/src/postmortem.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import inspector from "node:inspector";
|
|
9
9
|
import { isMainThread } from "node:worker_threads";
|
|
10
|
+
import { BROKEN_PIPE_EXIT_CODE, createProcessStdoutEpipeClassifier } from "./broken-pipe";
|
|
10
11
|
import * as logger from "./logger";
|
|
11
12
|
import { safeStderrWrite } from "./safe-stderr";
|
|
12
13
|
|
|
@@ -22,10 +23,24 @@ export enum Reason {
|
|
|
22
23
|
MANUAL = "manual", // Manual cleanup (not triggered by process)
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
interface CleanupOptions {
|
|
27
|
+
quiet?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type StdoutWriteCallback = (error?: Error | null) => void;
|
|
31
|
+
|
|
25
32
|
// Internal list of active cleanup callbacks (in registration order)
|
|
26
33
|
const callbackList: ((reason: Reason) => Promise<void> | void)[] = [];
|
|
27
34
|
// Tracks cleanup run state (to prevent recursion/reentry issues)
|
|
28
35
|
let cleanupStage: "idle" | "running" | "complete" = "idle";
|
|
36
|
+
let cleanupPromise: Promise<void> | undefined;
|
|
37
|
+
let quietShutdownStarted = false;
|
|
38
|
+
let ordinaryFatalStarted = false;
|
|
39
|
+
const stdoutEpipeClassifier = createProcessStdoutEpipeClassifier();
|
|
40
|
+
|
|
41
|
+
function shouldSuppressCleanupLogging(quiet: boolean): boolean {
|
|
42
|
+
return quiet || quietShutdownStarted;
|
|
43
|
+
}
|
|
29
44
|
|
|
30
45
|
/**
|
|
31
46
|
* Internal: runs all registered cleanup callbacks for the given reason.
|
|
@@ -33,36 +48,87 @@ let cleanupStage: "idle" | "running" | "complete" = "idle";
|
|
|
33
48
|
*
|
|
34
49
|
* Returns a Promise that settles after all cleanups complete or error out.
|
|
35
50
|
*/
|
|
36
|
-
function runCleanup(reason: Reason): Promise<void> {
|
|
51
|
+
function runCleanup(reason: Reason, options: CleanupOptions = {}): Promise<void> {
|
|
52
|
+
const quiet = options.quiet === true;
|
|
37
53
|
switch (cleanupStage) {
|
|
38
54
|
case "idle":
|
|
39
55
|
cleanupStage = "running";
|
|
40
56
|
break;
|
|
41
57
|
case "running":
|
|
42
|
-
if (reason
|
|
43
|
-
|
|
58
|
+
if (reason !== Reason.EXIT && !shouldSuppressCleanupLogging(quiet)) {
|
|
59
|
+
logger.error("Cleanup invoked recursively", { stack: new Error().stack });
|
|
44
60
|
}
|
|
45
|
-
logger.error("Cleanup invoked recursively", { stack: new Error().stack });
|
|
46
61
|
return Promise.resolve();
|
|
47
62
|
case "complete":
|
|
48
63
|
return Promise.resolve();
|
|
49
64
|
}
|
|
50
65
|
|
|
66
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
67
|
+
cleanupPromise = promise;
|
|
68
|
+
|
|
51
69
|
// Call .cleanup() for each callback that is still "armed".
|
|
52
|
-
//
|
|
70
|
+
// Assign the shared completion promise first so synchronous re-entry joins it.
|
|
53
71
|
const promises = callbackList.toReversed().map(callback => {
|
|
54
72
|
return Promise.try(() => callback(reason));
|
|
55
73
|
});
|
|
56
74
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
if (
|
|
60
|
-
const
|
|
61
|
-
|
|
75
|
+
void Promise.allSettled(promises).then(results => {
|
|
76
|
+
try {
|
|
77
|
+
if (!shouldSuppressCleanupLogging(quiet)) {
|
|
78
|
+
for (const result of results) {
|
|
79
|
+
if (result.status === "rejected") {
|
|
80
|
+
const err = result.reason instanceof Error ? result.reason : new Error(String(result.reason));
|
|
81
|
+
logger.error("Cleanup callback failed", { err, stack: err.stack });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
62
84
|
}
|
|
85
|
+
} finally {
|
|
86
|
+
cleanupStage = "complete";
|
|
87
|
+
resolve();
|
|
63
88
|
}
|
|
64
|
-
cleanupStage = "complete";
|
|
65
89
|
});
|
|
90
|
+
return promise;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function runCleanupAndWait(reason: Reason, options: CleanupOptions = {}): Promise<void> {
|
|
94
|
+
void runCleanup(reason, options);
|
|
95
|
+
await (cleanupPromise ?? Promise.resolve());
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function installProcessStdoutWriteClassifier(): void {
|
|
99
|
+
const originalWrite = process.stdout.write.bind(process.stdout);
|
|
100
|
+
const markCallback = (callback: StdoutWriteCallback): StdoutWriteCallback => {
|
|
101
|
+
return error => {
|
|
102
|
+
stdoutEpipeClassifier.markDirectProcessStdoutWriteError(error);
|
|
103
|
+
callback(error);
|
|
104
|
+
};
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const markedWrite = (
|
|
108
|
+
chunk: string | Uint8Array,
|
|
109
|
+
encoding?: BufferEncoding | StdoutWriteCallback,
|
|
110
|
+
callback?: StdoutWriteCallback,
|
|
111
|
+
): boolean => {
|
|
112
|
+
try {
|
|
113
|
+
if (typeof encoding === "function") return originalWrite(chunk, markCallback(encoding));
|
|
114
|
+
if (callback) {
|
|
115
|
+
return typeof chunk === "string"
|
|
116
|
+
? originalWrite(chunk, encoding, markCallback(callback))
|
|
117
|
+
: originalWrite(chunk, markCallback(callback));
|
|
118
|
+
}
|
|
119
|
+
if (encoding === undefined) return originalWrite(chunk);
|
|
120
|
+
return typeof chunk === "string" ? originalWrite(chunk, encoding) : originalWrite(chunk);
|
|
121
|
+
} catch (error) {
|
|
122
|
+
stdoutEpipeClassifier.markDirectProcessStdoutWriteError(error);
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
process.stdout.write = markedWrite as typeof process.stdout.write;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function errorForDiagnostic(reason: unknown): Error {
|
|
131
|
+
return reason instanceof Error ? reason : new Error(String(reason));
|
|
66
132
|
}
|
|
67
133
|
|
|
68
134
|
// Register signal and error event handlers to trigger cleanup before exit.
|
|
@@ -80,15 +146,10 @@ function formatFatalError(label: string, err: Error): string {
|
|
|
80
146
|
}
|
|
81
147
|
|
|
82
148
|
/**
|
|
83
|
-
* True for terminal/pipe write failures (EIO, EPIPE, EBADF, …) on the write side.
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
* shutdown can surface as an uncaughtException on a later tick — after cleanup has already
|
|
88
|
-
* begun. Treating that as fatal logs a scary stack and exits nonzero, which a hosting
|
|
89
|
-
* terminal renders as a crash/restart loop. When it happens mid-shutdown it is a benign
|
|
90
|
-
* artifact, not a real fault. We only match write-side syscalls so genuine bugs (a read
|
|
91
|
-
* EIO, a logic error that happens to carry one of these codes) are never masked.
|
|
149
|
+
* True for terminal/pipe/disk write failures (EIO, EPIPE, EBADF, …) on the write side.
|
|
150
|
+
* Covers external-volume stalls and terminal hangups that surface as a late uncaught
|
|
151
|
+
* exception mid-shutdown — a benign artifact upstream's stdout-EPIPE classifier misses.
|
|
152
|
+
* Only write-side syscalls match so genuine read/logic bugs are never masked.
|
|
92
153
|
*/
|
|
93
154
|
function isBenignIoWriteError(err: unknown): boolean {
|
|
94
155
|
if (!err || typeof err !== "object") return false;
|
|
@@ -103,11 +164,50 @@ function isBenignIoWriteError(err: unknown): boolean {
|
|
|
103
164
|
return isIoCode && (syscall === undefined || syscall === "write" || syscall === "writev");
|
|
104
165
|
}
|
|
105
166
|
|
|
167
|
+
async function exitQuietlyForAttributableStdoutEpipe(reason: Reason): Promise<void> {
|
|
168
|
+
if (ordinaryFatalStarted || quietShutdownStarted) return;
|
|
169
|
+
quietShutdownStarted = true;
|
|
170
|
+
// Set the observable status before cleanup can await or trigger another error.
|
|
171
|
+
process.exitCode = BROKEN_PIPE_EXIT_CODE;
|
|
172
|
+
await runCleanupAndWait(reason, { quiet: true });
|
|
173
|
+
// An ordinary fatal that arrived during quiet cleanup takes precedence.
|
|
174
|
+
if (process.exitCode === BROKEN_PIPE_EXIT_CODE) process.exit(BROKEN_PIPE_EXIT_CODE);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function handleFatalError(label: string, reason: unknown, cleanupReason: Reason): Promise<void> {
|
|
178
|
+
if (cleanupStage !== "idle" && isBenignIoWriteError(reason)) {
|
|
179
|
+
logger.debug("Ignoring benign terminal I/O write error during shutdown", {
|
|
180
|
+
code: (reason as { code?: unknown }).code,
|
|
181
|
+
syscall: (reason as { syscall?: unknown }).syscall,
|
|
182
|
+
});
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (stdoutEpipeClassifier.isAttributableProcessStdoutEpipe(reason)) {
|
|
186
|
+
await exitQuietlyForAttributableStdoutEpipe(cleanupReason);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// A distinct ordinary fatal must retain its normal diagnostic and status-1
|
|
191
|
+
// contract, including when it arrives while quiet cleanup is still pending.
|
|
192
|
+
ordinaryFatalStarted = true;
|
|
193
|
+
process.exitCode = 1;
|
|
194
|
+
const err = errorForDiagnostic(reason);
|
|
195
|
+
safeStderrWrite(formatFatalError(label, err));
|
|
196
|
+
if (!quietShutdownStarted) {
|
|
197
|
+
logger.error(label === "Uncaught Exception" ? "Uncaught exception" : "Unhandled rejection", {
|
|
198
|
+
err,
|
|
199
|
+
stack: err.stack,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
await runCleanupAndWait(cleanupReason);
|
|
203
|
+
process.exit(1);
|
|
204
|
+
}
|
|
205
|
+
|
|
106
206
|
if (isMainThread) {
|
|
207
|
+
installProcessStdoutWriteClassifier();
|
|
107
208
|
process
|
|
108
209
|
.on("SIGINT", async () => {
|
|
109
|
-
|
|
110
|
-
await runCleanup(Reason.SIGINT);
|
|
210
|
+
await runCleanupAndWait(Reason.SIGINT);
|
|
111
211
|
process.exit(130); // 128 + SIGINT (2)
|
|
112
212
|
})
|
|
113
213
|
.on("SIGUSR1", () => {
|
|
@@ -117,43 +217,21 @@ if (isMainThread) {
|
|
|
117
217
|
const url = inspector.url();
|
|
118
218
|
safeStderrWrite(`Inspector opened: ${url}\n`);
|
|
119
219
|
})
|
|
120
|
-
.on("uncaughtException", async
|
|
121
|
-
|
|
122
|
-
// already tearing down — e.g. the controlling terminal hung up, or the volume
|
|
123
|
-
// the runtime lives on stalled. Bun surfaces that on a later tick, after cleanup
|
|
124
|
-
// has begun. Treat it as a benign shutdown artifact: log at debug and let the
|
|
125
|
-
// in-progress shutdown own the exit, instead of logging a fatal error and exiting
|
|
126
|
-
// nonzero (which a hosting terminal renders as a crash/restart loop).
|
|
127
|
-
if (cleanupStage !== "idle" && isBenignIoWriteError(err)) {
|
|
128
|
-
logger.debug("Ignoring benign terminal I/O write error during shutdown", {
|
|
129
|
-
code: (err as { code?: unknown }).code,
|
|
130
|
-
syscall: (err as { syscall?: unknown }).syscall,
|
|
131
|
-
});
|
|
132
|
-
return;
|
|
133
|
-
}
|
|
134
|
-
safeStderrWrite(formatFatalError("Uncaught Exception", err));
|
|
135
|
-
logger.error("Uncaught exception", { err, stack: err.stack });
|
|
136
|
-
await runCleanup(Reason.UNCAUGHT_EXCEPTION);
|
|
137
|
-
process.exit(1);
|
|
220
|
+
.on("uncaughtException", async error => {
|
|
221
|
+
await handleFatalError("Uncaught Exception", error, Reason.UNCAUGHT_EXCEPTION);
|
|
138
222
|
})
|
|
139
223
|
.on("unhandledRejection", async reason => {
|
|
140
|
-
|
|
141
|
-
safeStderrWrite(formatFatalError("Unhandled Rejection", err));
|
|
142
|
-
logger.error("Unhandled rejection", { err, stack: err.stack });
|
|
143
|
-
await runCleanup(Reason.UNHANDLED_REJECTION);
|
|
144
|
-
process.exit(1);
|
|
224
|
+
await handleFatalError("Unhandled Rejection", reason, Reason.UNHANDLED_REJECTION);
|
|
145
225
|
})
|
|
146
226
|
.on("exit", async () => {
|
|
147
227
|
void runCleanup(Reason.EXIT); // fire and forget (exit imminent)
|
|
148
228
|
})
|
|
149
229
|
.on("SIGTERM", async () => {
|
|
150
|
-
|
|
151
|
-
await runCleanup(Reason.SIGTERM);
|
|
230
|
+
await runCleanupAndWait(Reason.SIGTERM);
|
|
152
231
|
process.exit(143); // 128 + SIGTERM (15)
|
|
153
232
|
})
|
|
154
233
|
.on("SIGHUP", async () => {
|
|
155
|
-
|
|
156
|
-
await runCleanup(Reason.SIGHUP);
|
|
234
|
+
await runCleanupAndWait(Reason.SIGHUP);
|
|
157
235
|
process.exit(129); // 128 + SIGHUP (1)
|
|
158
236
|
});
|
|
159
237
|
} else {
|
|
@@ -179,8 +257,9 @@ export function register(id: string, callback: (reason: Reason) => void | Promis
|
|
|
179
257
|
done = true;
|
|
180
258
|
try {
|
|
181
259
|
return callback(reason);
|
|
182
|
-
} catch (
|
|
183
|
-
|
|
260
|
+
} catch (error) {
|
|
261
|
+
if (quietShutdownStarted) return;
|
|
262
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
184
263
|
logger.error("Cleanup callback failed", { err, id, stack: err.stack });
|
|
185
264
|
}
|
|
186
265
|
};
|
|
@@ -194,12 +273,20 @@ export function register(id: string, callback: (reason: Reason) => void | Promis
|
|
|
194
273
|
};
|
|
195
274
|
|
|
196
275
|
if (cleanupStage !== "idle") {
|
|
276
|
+
if (quietShutdownStarted) {
|
|
277
|
+
queueMicrotask(() => {
|
|
278
|
+
void Promise.try(() => exec(Reason.MANUAL)).catch(() => {});
|
|
279
|
+
});
|
|
280
|
+
return () => {
|
|
281
|
+
done = true;
|
|
282
|
+
};
|
|
283
|
+
}
|
|
197
284
|
// If cleanup is already running/completed, warn and run on microtask.
|
|
198
285
|
logger.warn("Cleanup invoked recursively", { id });
|
|
199
286
|
try {
|
|
200
287
|
callback(Reason.MANUAL);
|
|
201
|
-
} catch (
|
|
202
|
-
const err =
|
|
288
|
+
} catch (error) {
|
|
289
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
203
290
|
logger.error("Cleanup callback failed", { err, id, stack: err.stack });
|
|
204
291
|
}
|
|
205
292
|
return () => {};
|
|
@@ -225,16 +312,28 @@ export function cleanup(): Promise<void> {
|
|
|
225
312
|
* In workers: runs cleanup only (process.exit would kill entire process).
|
|
226
313
|
*/
|
|
227
314
|
export async function quit(code: number = 0): Promise<void> {
|
|
228
|
-
|
|
315
|
+
const cleanupWasRunning = cleanupStage === "running";
|
|
316
|
+
void runCleanup(Reason.MANUAL);
|
|
317
|
+
const completion = cleanupPromise ?? Promise.resolve();
|
|
229
318
|
|
|
230
319
|
if (!isMainThread) {
|
|
231
|
-
|
|
320
|
+
if (!cleanupWasRunning) await completion;
|
|
321
|
+
return;
|
|
232
322
|
}
|
|
233
323
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
process.stdout.
|
|
237
|
-
|
|
324
|
+
const exitAfterCleanup = async (): Promise<void> => {
|
|
325
|
+
await completion;
|
|
326
|
+
if (process.stdout.writableLength > 0) {
|
|
327
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
328
|
+
process.stdout.once("drain", resolve);
|
|
329
|
+
await Promise.race([promise, Bun.sleep(5000)]);
|
|
330
|
+
}
|
|
331
|
+
process.exit(code);
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
if (cleanupWasRunning) {
|
|
335
|
+
void exitAfterCleanup();
|
|
336
|
+
return;
|
|
238
337
|
}
|
|
239
|
-
|
|
338
|
+
await exitAfterCleanup();
|
|
240
339
|
}
|