@gajae-code/utils 0.11.1 → 0.11.2

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.
@@ -36,6 +36,8 @@ export declare function formatBunRuntimeError(opts: {
36
36
  }): string;
37
37
  export declare function resolveEquivalentPath(inputPath: string): string;
38
38
  export declare function normalizePathForComparison(inputPath: string, platform?: NodeJS.Platform): string;
39
+ /** Return whether a relative path crosses above its root or is unexpectedly absolute. */
40
+ export declare function relativePathEscapesRoot(relative: string): boolean;
39
41
  export declare function pathIsWithin(root: string, candidate: string): boolean;
40
42
  export declare function relativePathWithinRoot(root: string, candidate: string): string | null;
41
43
  /** Get the project directory. */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/utils",
4
- "version": "0.11.1",
4
+ "version": "0.11.2",
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.1",
34
+ "@gajae-code/natives": "0.11.2",
35
35
  "beautiful-mermaid": "^1.1.3",
36
36
  "handlebars": "^4.7.9",
37
37
  "winston": "^3.19.0",
package/src/dirs.ts CHANGED
@@ -114,11 +114,16 @@ export function normalizePathForComparison(inputPath: string, platform: NodeJS.P
114
114
  return platform === "win32" ? resolvedPath.toLowerCase() : resolvedPath;
115
115
  }
116
116
 
117
+ /** Return whether a relative path crosses above its root or is unexpectedly absolute. */
118
+ export function relativePathEscapesRoot(relative: string): boolean {
119
+ return relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative);
120
+ }
121
+
117
122
  export function pathIsWithin(root: string, candidate: string): boolean {
118
123
  const normalizedRoot = normalizePathForComparison(root);
119
124
  const normalizedCandidate = normalizePathForComparison(candidate);
120
125
  const relative = path.relative(normalizedRoot, normalizedCandidate);
121
- return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
126
+ return !relativePathEscapesRoot(relative);
122
127
  }
123
128
 
124
129
  export function relativePathWithinRoot(root: string, candidate: string): string | null {
package/src/postmortem.ts CHANGED
@@ -55,7 +55,11 @@ function runCleanup(reason: Reason, options: CleanupOptions = {}): Promise<void>
55
55
  cleanupStage = "running";
56
56
  break;
57
57
  case "running":
58
- if (reason !== Reason.EXIT && !shouldSuppressCleanupLogging(quiet)) {
58
+ // Exit-bound waiters (signals, fatals, quit) legitimately join the
59
+ // in-flight cleanup via `cleanupPromise`; only a genuine manual
60
+ // recursion (a cleanup callback calling cleanup()) is a bug worth a
61
+ // diagnostic.
62
+ if (reason === Reason.MANUAL && !shouldSuppressCleanupLogging(quiet)) {
59
63
  logger.error("Cleanup invoked recursively", { stack: new Error().stack });
60
64
  }
61
65
  return Promise.resolve();
@@ -90,9 +94,65 @@ function runCleanup(reason: Reason, options: CleanupOptions = {}): Promise<void>
90
94
  return promise;
91
95
  }
92
96
 
93
- async function runCleanupAndWait(reason: Reason, options: CleanupOptions = {}): Promise<void> {
97
+ /**
98
+ * Finite cleanup-liveness contract for every exit-bound wait.
99
+ *
100
+ * Governed waits: signal handlers (SIGINT/SIGTERM/SIGHUP), fatal handlers
101
+ * (uncaught exception / unhandled rejection), the quiet stdout-EPIPE exit, and
102
+ * `quit()`. Each waits at most `resolveCleanupDeadlineMs()` for the shared
103
+ * in-flight cleanup before exiting with its own unchanged exit code (130/143/
104
+ * 129, 1 for fatals, BROKEN_PIPE_EXIT_CODE, or quit's `code`).
105
+ *
106
+ * Ungoverned: `cleanup()` (Reason.MANUAL without exit) is caller-owned and
107
+ * unbounded, and Reason.EXIT stays fire-and-forget (exit is imminent).
108
+ *
109
+ * On expiry the stage is forced to "complete" so late re-entries no-op, a
110
+ * single diagnostic goes to stderr and the error log (suppressed during quiet
111
+ * broken-pipe shutdown), and late callback settlement is ignored — rejections
112
+ * were already routed through Promise.allSettled, so none can become unhandled.
113
+ *
114
+ * The deadline defaults to 5000 ms and can be overridden with
115
+ * `GJC_CLEANUP_DEADLINE_MS` (finite values >= 0; anything else falls back to
116
+ * the default).
117
+ */
118
+ const DEFAULT_CLEANUP_DEADLINE_MS = 5_000;
119
+
120
+ function resolveCleanupDeadlineMs(): number {
121
+ const raw = process.env.GJC_CLEANUP_DEADLINE_MS;
122
+ if (raw === undefined || raw.trim() === "") return DEFAULT_CLEANUP_DEADLINE_MS;
123
+ const parsed = Number(raw);
124
+ if (!Number.isFinite(parsed) || parsed < 0) return DEFAULT_CLEANUP_DEADLINE_MS;
125
+ return parsed;
126
+ }
127
+
128
+ async function awaitCleanupWithDeadline(reason: Reason, options: CleanupOptions = {}): Promise<void> {
129
+ const pending = cleanupPromise;
130
+ if (!pending || cleanupStage === "complete") return;
131
+ const deadlineMs = resolveCleanupDeadlineMs();
132
+ let timer: ReturnType<typeof setTimeout> | undefined;
133
+ const timedOut = await Promise.race([
134
+ pending.then(() => false),
135
+ new Promise<boolean>(resolve => {
136
+ // Deliberately referenced: the timer is also the liveness floor that
137
+ // keeps the process alive until the bounded wait settles, so an
138
+ // otherwise-empty event loop cannot exit 0 underneath a governed wait.
139
+ timer = setTimeout(() => resolve(true), deadlineMs);
140
+ }),
141
+ ]);
142
+ if (timer) clearTimeout(timer);
143
+ if (!timedOut) return;
144
+ // Force the terminal stage so late settlement and re-entries are no-ops.
145
+ cleanupStage = "complete";
146
+ if (!shouldSuppressCleanupLogging(options.quiet === true)) {
147
+ const diagnostic = `[postmortem] cleanup deadline (${deadlineMs}ms) expired for ${reason}; exiting without waiting for remaining callbacks.\n`;
148
+ safeStderrWrite(diagnostic);
149
+ logger.error("Cleanup deadline expired", { reason, deadlineMs });
150
+ }
151
+ }
152
+
153
+ async function runCleanupBounded(reason: Reason, options: CleanupOptions = {}): Promise<void> {
94
154
  void runCleanup(reason, options);
95
- await (cleanupPromise ?? Promise.resolve());
155
+ await awaitCleanupWithDeadline(reason, options);
96
156
  }
97
157
 
98
158
  function installProcessStdoutWriteClassifier(): void {
@@ -150,7 +210,7 @@ async function exitQuietlyForAttributableStdoutEpipe(reason: Reason): Promise<vo
150
210
  quietShutdownStarted = true;
151
211
  // Set the observable status before cleanup can await or trigger another error.
152
212
  process.exitCode = BROKEN_PIPE_EXIT_CODE;
153
- await runCleanupAndWait(reason, { quiet: true });
213
+ await runCleanupBounded(reason, { quiet: true });
154
214
  // An ordinary fatal that arrived during quiet cleanup takes precedence.
155
215
  if (process.exitCode === BROKEN_PIPE_EXIT_CODE) process.exit(BROKEN_PIPE_EXIT_CODE);
156
216
  }
@@ -173,7 +233,7 @@ async function handleFatalError(label: string, reason: unknown, cleanupReason: R
173
233
  stack: err.stack,
174
234
  });
175
235
  }
176
- await runCleanupAndWait(cleanupReason);
236
+ await runCleanupBounded(cleanupReason);
177
237
  process.exit(1);
178
238
  }
179
239
 
@@ -181,7 +241,7 @@ if (isMainThread) {
181
241
  installProcessStdoutWriteClassifier();
182
242
  process
183
243
  .on("SIGINT", async () => {
184
- await runCleanupAndWait(Reason.SIGINT);
244
+ await runCleanupBounded(Reason.SIGINT);
185
245
  process.exit(130); // 128 + SIGINT (2)
186
246
  })
187
247
  .on("SIGUSR1", () => {
@@ -201,11 +261,11 @@ if (isMainThread) {
201
261
  void runCleanup(Reason.EXIT); // fire and forget (exit imminent)
202
262
  })
203
263
  .on("SIGTERM", async () => {
204
- await runCleanupAndWait(Reason.SIGTERM);
264
+ await runCleanupBounded(Reason.SIGTERM);
205
265
  process.exit(143); // 128 + SIGTERM (15)
206
266
  })
207
267
  .on("SIGHUP", async () => {
208
- await runCleanupAndWait(Reason.SIGHUP);
268
+ await runCleanupBounded(Reason.SIGHUP);
209
269
  process.exit(129); // 128 + SIGHUP (1)
210
270
  });
211
271
  } else {
@@ -296,7 +356,7 @@ export async function quit(code: number = 0): Promise<void> {
296
356
  }
297
357
 
298
358
  const exitAfterCleanup = async (): Promise<void> => {
299
- await completion;
359
+ await awaitCleanupWithDeadline(Reason.MANUAL);
300
360
  if (process.stdout.writableLength > 0) {
301
361
  const { promise, resolve } = Promise.withResolvers<void>();
302
362
  process.stdout.once("drain", resolve);