@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.
@@ -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;
@@ -1,5 +1,6 @@
1
1
  export { createAbortableStream, once, untilAborted } from "./abortable";
2
2
  export * from "./async";
3
+ export * from "./broken-pipe";
3
4
  export * from "./color";
4
5
  export * from "./dirs";
5
6
  export * from "./env";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/utils",
4
- "version": "0.10.0",
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.0",
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
@@ -1,5 +1,6 @@
1
1
  export { createAbortableStream, once, untilAborted } from "./abortable";
2
2
  export * from "./async";
3
+ export * from "./broken-pipe";
3
4
  export * from "./color";
4
5
  export * from "./dirs";
5
6
  export * from "./env";
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 === Reason.EXIT) {
43
- return Promise.resolve();
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
- // Use Promise.try to handle sync/async, but only those armed.
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
- return Promise.allSettled(promises).then(results => {
58
- for (const result of results) {
59
- if (result.status === "rejected") {
60
- const err = result.reason instanceof Error ? result.reason : new Error(String(result.reason));
61
- logger.error("Cleanup callback failed", { err, stack: err.stack });
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 runCleanup(Reason.SIGINT);
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 err => {
96
- safeStderrWrite(formatFatalError("Uncaught Exception", err));
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
- const err = reason instanceof Error ? reason : new Error(String(reason));
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 runCleanup(Reason.SIGTERM);
204
+ await runCleanupAndWait(Reason.SIGTERM);
113
205
  process.exit(143); // 128 + SIGTERM (15)
114
206
  })
115
207
  .on("SIGHUP", async () => {
116
- await runCleanup(Reason.SIGHUP);
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 (e) {
143
- const err = e instanceof Error ? e : new Error(String(e));
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 (e) {
162
- const err = e instanceof Error ? e : new Error(String(e));
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
- await runCleanup(Reason.MANUAL);
289
+ const cleanupWasRunning = cleanupStage === "running";
290
+ void runCleanup(Reason.MANUAL);
291
+ const completion = cleanupPromise ?? Promise.resolve();
189
292
 
190
293
  if (!isMainThread) {
191
- return; // Workers: cleanup done, let worker exit naturally
294
+ if (!cleanupWasRunning) await completion;
295
+ return;
192
296
  }
193
297
 
194
- if (process.stdout.writableLength > 0) {
195
- const { promise, resolve } = Promise.withResolvers<void>();
196
- process.stdout.once("drain", resolve);
197
- await Promise.race([promise, Bun.sleep(5000)]);
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
- process.exit(code);
312
+ await exitAfterCleanup();
200
313
  }