@gajae-code/utils 0.10.0 → 0.10.1
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/index.d.ts +1 -0
- package/package.json +2 -2
- package/src/broken-pipe.ts +132 -0
- package/src/index.ts +1 -0
- package/src/postmortem.ts +148 -35
|
@@ -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/index.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@gajae-code/utils",
|
|
4
|
-
"version": "0.10.
|
|
4
|
+
"version": "0.10.1",
|
|
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.10.
|
|
34
|
+
"@gajae-code/natives": "0.10.1",
|
|
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.
|
|
@@ -79,10 +145,43 @@ function formatFatalError(label: string, err: Error): string {
|
|
|
79
145
|
return `\n[${label}] ${name}: ${message}${formattedStack}\n`;
|
|
80
146
|
}
|
|
81
147
|
|
|
148
|
+
async function exitQuietlyForAttributableStdoutEpipe(reason: Reason): Promise<void> {
|
|
149
|
+
if (ordinaryFatalStarted || quietShutdownStarted) return;
|
|
150
|
+
quietShutdownStarted = true;
|
|
151
|
+
// Set the observable status before cleanup can await or trigger another error.
|
|
152
|
+
process.exitCode = BROKEN_PIPE_EXIT_CODE;
|
|
153
|
+
await runCleanupAndWait(reason, { quiet: true });
|
|
154
|
+
// An ordinary fatal that arrived during quiet cleanup takes precedence.
|
|
155
|
+
if (process.exitCode === BROKEN_PIPE_EXIT_CODE) process.exit(BROKEN_PIPE_EXIT_CODE);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function handleFatalError(label: string, reason: unknown, cleanupReason: Reason): Promise<void> {
|
|
159
|
+
if (stdoutEpipeClassifier.isAttributableProcessStdoutEpipe(reason)) {
|
|
160
|
+
await exitQuietlyForAttributableStdoutEpipe(cleanupReason);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// A distinct ordinary fatal must retain its normal diagnostic and status-1
|
|
165
|
+
// contract, including when it arrives while quiet cleanup is still pending.
|
|
166
|
+
ordinaryFatalStarted = true;
|
|
167
|
+
process.exitCode = 1;
|
|
168
|
+
const err = errorForDiagnostic(reason);
|
|
169
|
+
safeStderrWrite(formatFatalError(label, err));
|
|
170
|
+
if (!quietShutdownStarted) {
|
|
171
|
+
logger.error(label === "Uncaught Exception" ? "Uncaught exception" : "Unhandled rejection", {
|
|
172
|
+
err,
|
|
173
|
+
stack: err.stack,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
await runCleanupAndWait(cleanupReason);
|
|
177
|
+
process.exit(1);
|
|
178
|
+
}
|
|
179
|
+
|
|
82
180
|
if (isMainThread) {
|
|
181
|
+
installProcessStdoutWriteClassifier();
|
|
83
182
|
process
|
|
84
183
|
.on("SIGINT", async () => {
|
|
85
|
-
await
|
|
184
|
+
await runCleanupAndWait(Reason.SIGINT);
|
|
86
185
|
process.exit(130); // 128 + SIGINT (2)
|
|
87
186
|
})
|
|
88
187
|
.on("SIGUSR1", () => {
|
|
@@ -92,28 +191,21 @@ if (isMainThread) {
|
|
|
92
191
|
const url = inspector.url();
|
|
93
192
|
safeStderrWrite(`Inspector opened: ${url}\n`);
|
|
94
193
|
})
|
|
95
|
-
.on("uncaughtException", async
|
|
96
|
-
|
|
97
|
-
logger.error("Uncaught exception", { err, stack: err.stack });
|
|
98
|
-
await runCleanup(Reason.UNCAUGHT_EXCEPTION);
|
|
99
|
-
process.exit(1);
|
|
194
|
+
.on("uncaughtException", async error => {
|
|
195
|
+
await handleFatalError("Uncaught Exception", error, Reason.UNCAUGHT_EXCEPTION);
|
|
100
196
|
})
|
|
101
197
|
.on("unhandledRejection", async reason => {
|
|
102
|
-
|
|
103
|
-
safeStderrWrite(formatFatalError("Unhandled Rejection", err));
|
|
104
|
-
logger.error("Unhandled rejection", { err, stack: err.stack });
|
|
105
|
-
await runCleanup(Reason.UNHANDLED_REJECTION);
|
|
106
|
-
process.exit(1);
|
|
198
|
+
await handleFatalError("Unhandled Rejection", reason, Reason.UNHANDLED_REJECTION);
|
|
107
199
|
})
|
|
108
200
|
.on("exit", async () => {
|
|
109
201
|
void runCleanup(Reason.EXIT); // fire and forget (exit imminent)
|
|
110
202
|
})
|
|
111
203
|
.on("SIGTERM", async () => {
|
|
112
|
-
await
|
|
204
|
+
await runCleanupAndWait(Reason.SIGTERM);
|
|
113
205
|
process.exit(143); // 128 + SIGTERM (15)
|
|
114
206
|
})
|
|
115
207
|
.on("SIGHUP", async () => {
|
|
116
|
-
await
|
|
208
|
+
await runCleanupAndWait(Reason.SIGHUP);
|
|
117
209
|
process.exit(129); // 128 + SIGHUP (1)
|
|
118
210
|
});
|
|
119
211
|
} else {
|
|
@@ -139,8 +231,9 @@ export function register(id: string, callback: (reason: Reason) => void | Promis
|
|
|
139
231
|
done = true;
|
|
140
232
|
try {
|
|
141
233
|
return callback(reason);
|
|
142
|
-
} catch (
|
|
143
|
-
|
|
234
|
+
} catch (error) {
|
|
235
|
+
if (quietShutdownStarted) return;
|
|
236
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
144
237
|
logger.error("Cleanup callback failed", { err, id, stack: err.stack });
|
|
145
238
|
}
|
|
146
239
|
};
|
|
@@ -154,12 +247,20 @@ export function register(id: string, callback: (reason: Reason) => void | Promis
|
|
|
154
247
|
};
|
|
155
248
|
|
|
156
249
|
if (cleanupStage !== "idle") {
|
|
250
|
+
if (quietShutdownStarted) {
|
|
251
|
+
queueMicrotask(() => {
|
|
252
|
+
void Promise.try(() => exec(Reason.MANUAL)).catch(() => {});
|
|
253
|
+
});
|
|
254
|
+
return () => {
|
|
255
|
+
done = true;
|
|
256
|
+
};
|
|
257
|
+
}
|
|
157
258
|
// If cleanup is already running/completed, warn and run on microtask.
|
|
158
259
|
logger.warn("Cleanup invoked recursively", { id });
|
|
159
260
|
try {
|
|
160
261
|
callback(Reason.MANUAL);
|
|
161
|
-
} catch (
|
|
162
|
-
const err =
|
|
262
|
+
} catch (error) {
|
|
263
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
163
264
|
logger.error("Cleanup callback failed", { err, id, stack: err.stack });
|
|
164
265
|
}
|
|
165
266
|
return () => {};
|
|
@@ -185,16 +286,28 @@ export function cleanup(): Promise<void> {
|
|
|
185
286
|
* In workers: runs cleanup only (process.exit would kill entire process).
|
|
186
287
|
*/
|
|
187
288
|
export async function quit(code: number = 0): Promise<void> {
|
|
188
|
-
|
|
289
|
+
const cleanupWasRunning = cleanupStage === "running";
|
|
290
|
+
void runCleanup(Reason.MANUAL);
|
|
291
|
+
const completion = cleanupPromise ?? Promise.resolve();
|
|
189
292
|
|
|
190
293
|
if (!isMainThread) {
|
|
191
|
-
|
|
294
|
+
if (!cleanupWasRunning) await completion;
|
|
295
|
+
return;
|
|
192
296
|
}
|
|
193
297
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
process.stdout.
|
|
197
|
-
|
|
298
|
+
const exitAfterCleanup = async (): Promise<void> => {
|
|
299
|
+
await completion;
|
|
300
|
+
if (process.stdout.writableLength > 0) {
|
|
301
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
302
|
+
process.stdout.once("drain", resolve);
|
|
303
|
+
await Promise.race([promise, Bun.sleep(5000)]);
|
|
304
|
+
}
|
|
305
|
+
process.exit(code);
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
if (cleanupWasRunning) {
|
|
309
|
+
void exitAfterCleanup();
|
|
310
|
+
return;
|
|
198
311
|
}
|
|
199
|
-
|
|
312
|
+
await exitAfterCleanup();
|
|
200
313
|
}
|