jeopi-utils 16.4.2 → 16.4.4

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/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.4.3] - 2026-07-22
6
+
7
+ ### Added
8
+
9
+ - Added a terminal stderr guard (`suppressTerminalStderr`/`restoreTerminalStderr`): dup2-redirects fd 2 to the jeopi log file while a TUI owns the terminal so macOS runtime diagnostics cannot paint into the viewport. The postmortem fatal handlers restore fd 2 before printing so crash reports stay visible. Ported from oh-my-pi (upstream `4eaca82fa` by @Kormákur, `17486d200` by @Kormákur).
10
+
11
+ ### Fixed
12
+
13
+ - Fixed Mermaid ASCII routing to stop unbounded pathfinder searches when an edge attachment point is unreachable. ([#5293](https://github.com/can1357/oh-my-pi/issues/5293), upstream `cc2041ab7`).
14
+
5
15
  ## [16.2.24] - 2026-07-03
6
16
 
7
17
  ### Added
@@ -26,6 +26,7 @@ export { AbortError, ChildProcess, Exception, NonZeroExitError } from "./ptree";
26
26
  export * from "./runtime-install";
27
27
  export * from "./sanitize-text";
28
28
  export * from "./snowflake";
29
+ export * from "./stderr-guard";
29
30
  export * from "./stream";
30
31
  export * from "./tab-spacing";
31
32
  export * from "./temp";
@@ -0,0 +1,22 @@
1
+ export interface SuppressTerminalStderrOptions {
2
+ /** Redirect target path; defaults to today's jeopi log file, then /dev/null. */
3
+ redirectPath?: string;
4
+ /** Bypass the macOS + same-terminal gate. Tests only. */
5
+ force?: boolean;
6
+ }
7
+ /**
8
+ * Redirect fd 2 away from the terminal while the TUI owns the viewport.
9
+ * Returns true when suppression is (already) active. No-op — returning
10
+ * false — off macOS, when stderr does not target the stdout terminal, or
11
+ * when the libc fd ops are unavailable.
12
+ */
13
+ export declare function suppressTerminalStderr(options?: SuppressTerminalStderrOptions): boolean;
14
+ /**
15
+ * Re-point fd 2 at the saved terminal stderr. Safe to call unconditionally:
16
+ * no-op when suppression is not active. Called at every terminal-ownership
17
+ * release and by the postmortem fatal handlers before they print, so crash
18
+ * reports reach the real terminal.
19
+ */
20
+ export declare function restoreTerminalStderr(): void;
21
+ /** Whether fd 2 is currently redirected away from the terminal. */
22
+ export declare function isTerminalStderrSuppressed(): boolean;
@@ -6,7 +6,7 @@ import type { GridCoord, AsciiNode } from './types';
6
6
  export declare function heuristic(a: GridCoord, b: GridCoord): number;
7
7
  /**
8
8
  * Find a path from `from` to `to` on the grid using A*.
9
- * Returns the path as an array of GridCoords, or null if no path exists.
9
+ * Returns the path as an array of GridCoords, or null if no bounded path exists.
10
10
  */
11
11
  export declare function getPath(grid: Map<string, AsciiNode>, from: GridCoord, to: GridCoord): GridCoord[] | null;
12
12
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "jeopi-utils",
4
- "version": "16.4.2",
4
+ "version": "16.4.4",
5
5
  "description": "Shared utilities for pi packages",
6
6
  "homepage": "https://github.com/akillness/jeopi",
7
7
  "author": "Can Boluk",
@@ -31,7 +31,7 @@
31
31
  "fmt": "biome format --write ."
32
32
  },
33
33
  "dependencies": {
34
- "jeopi-natives": "16.4.2",
34
+ "jeopi-natives": "16.4.4",
35
35
  "handlebars": "^4.7.9",
36
36
  "winston": "^3.19.0",
37
37
  "winston-daily-rotate-file": "^5.0.0"
package/src/index.ts CHANGED
@@ -26,6 +26,7 @@ export { AbortError, ChildProcess, Exception, NonZeroExitError } from "./ptree";
26
26
  export * from "./runtime-install";
27
27
  export * from "./sanitize-text";
28
28
  export * from "./snowflake";
29
+ export * from "./stderr-guard";
29
30
  export * from "./stream";
30
31
  export * from "./tab-spacing";
31
32
  export * from "./temp";
package/src/postmortem.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  import inspector from "node:inspector";
9
9
  import { isMainThread } from "node:worker_threads";
10
10
  import { logger } from ".";
11
+ import { restoreTerminalStderr } from "./stderr-guard";
11
12
 
12
13
  // Cleanup reasons, in order of priority/meaning.
13
14
  export enum Reason {
@@ -101,6 +102,11 @@ if (isMainThread) {
101
102
  process.stderr.write(`Inspector opened: ${url}\n`);
102
103
  })
103
104
  .on("uncaughtException", async err => {
105
+ // fd 2 may be redirected to the log while a TUI owns the terminal
106
+ // (stderr-guard); re-point it at the real terminal so the fatal
107
+ // report is visible. Terminal modes are restored moments later by
108
+ // the terminal-restore cleanup callback inside runCleanup().
109
+ restoreTerminalStderr();
104
110
  process.stderr.write(formatFatalError("Uncaught Exception", err));
105
111
  logger.error("Uncaught exception", { err });
106
112
  await runCleanup(Reason.UNCAUGHT_EXCEPTION);
@@ -121,6 +127,8 @@ if (isMainThread) {
121
127
  logger.warn("Ignoring EPIPE from worker IPC send; optional subsystem will self-recover", { err });
122
128
  return;
123
129
  }
130
+ // See uncaughtException above: surface the report on the real stderr.
131
+ restoreTerminalStderr();
124
132
  process.stderr.write(formatFatalError("Unhandled Rejection", err));
125
133
  logger.error("Unhandled rejection", { err });
126
134
  await runCleanup(Reason.UNHANDLED_REJECTION);
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Terminal stderr guard: keeps unmanaged fd-2 writes off the terminal while a
3
+ * TUI owns the viewport.
4
+ *
5
+ * On macOS, runtime diagnostics are written by the platform directly to file
6
+ * descriptor 2 at arbitrary times — e.g. libmalloc's "MallocStackLogging:
7
+ * can't turn off malloc stack logging because it was not enabled" when the OS
8
+ * broadcasts a memory-diagnostic event to long-lived processes. Those bytes
9
+ * bypass the renderer and paint straight into the viewport. Stripping the
10
+ * MallocStackLogging* env vars (cli.ts) only protects child processes; it
11
+ * cannot stop libmalloc inside THIS process from logging.
12
+ *
13
+ * Fix (mirrors openai/codex#24459): while the TUI owns the terminal, dup fd 2
14
+ * aside and dup2 a redirect target over it; restore the saved fd whenever
15
+ * terminal ownership is released (external editor, Ctrl+Z suspend, shutdown,
16
+ * crash restore). Unlike codex we redirect to the jeopi log file — not
17
+ * /dev/null — so the diagnostics stay greppable and Bun native-crash reports
18
+ * (which abort before any JS cleanup can restore fd 2) are preserved.
19
+ *
20
+ * Only dup/dup2 go through bun:ffi. fcntl is deliberately avoided: it is
21
+ * variadic, and the arm64-darwin ABI passes variadic arguments on the stack,
22
+ * so a fixed-arity FFI signature would read garbage for the third argument.
23
+ */
24
+ import { dlopen, FFIType } from "bun:ffi";
25
+ import * as fs from "node:fs";
26
+ import * as path from "node:path";
27
+ import { getLogPath } from "./dirs";
28
+
29
+ const STDOUT_FILENO = 1;
30
+ const STDERR_FILENO = 2;
31
+
32
+ interface LibcFdOps {
33
+ dup(fd: number): number;
34
+ dup2(oldFd: number, newFd: number): number;
35
+ }
36
+
37
+ let libcFdOpsCache: LibcFdOps | null | undefined;
38
+
39
+ function libcFdOps(): LibcFdOps | null {
40
+ if (libcFdOpsCache !== undefined) return libcFdOpsCache;
41
+ libcFdOpsCache = null;
42
+ if (process.platform === "win32") return null;
43
+ // Darwin: dyld resolves libSystem from the shared cache. Linux: glibc
44
+ // first, then the generic soname for musl-style layouts.
45
+ const candidates =
46
+ process.platform === "darwin" ? ["libSystem.B.dylib", "/usr/lib/libSystem.B.dylib"] : ["libc.so.6", "libc.so"];
47
+ for (const candidate of candidates) {
48
+ try {
49
+ const libc = dlopen(candidate, {
50
+ dup: { args: [FFIType.i32], returns: FFIType.i32 },
51
+ dup2: { args: [FFIType.i32, FFIType.i32], returns: FFIType.i32 },
52
+ });
53
+ libcFdOpsCache = libc.symbols;
54
+ return libcFdOpsCache;
55
+ } catch {
56
+ // Try the next candidate; the guard stays inert if none load.
57
+ }
58
+ }
59
+ return libcFdOpsCache;
60
+ }
61
+
62
+ /**
63
+ * True when fd 2 writes would land on the same terminal the TUI paints to:
64
+ * both stdout and stderr are ttys backed by the same device file. A stderr
65
+ * the user already redirected (`2>file`, `2>/dev/null`, a different tty) must
66
+ * keep flowing untouched.
67
+ */
68
+ function stderrSharesStdoutTerminal(): boolean {
69
+ if (!process.stdout.isTTY || !process.stderr.isTTY) return false;
70
+ try {
71
+ const stdoutStat = fs.fstatSync(STDOUT_FILENO);
72
+ const stderrStat = fs.fstatSync(STDERR_FILENO);
73
+ return stdoutStat.dev === stderrStat.dev && stdoutStat.ino === stderrStat.ino;
74
+ } catch {
75
+ return false;
76
+ }
77
+ }
78
+
79
+ /** Saved dup of the real stderr while suppression is active, else null. */
80
+ let savedStderrFd: number | null = null;
81
+
82
+ export interface SuppressTerminalStderrOptions {
83
+ /** Redirect target path; defaults to today's jeopi log file, then /dev/null. */
84
+ redirectPath?: string;
85
+ /** Bypass the macOS + same-terminal gate. Tests only. */
86
+ force?: boolean;
87
+ }
88
+
89
+ /**
90
+ * Redirect fd 2 away from the terminal while the TUI owns the viewport.
91
+ * Returns true when suppression is (already) active. No-op — returning
92
+ * false — off macOS, when stderr does not target the stdout terminal, or
93
+ * when the libc fd ops are unavailable.
94
+ */
95
+ export function suppressTerminalStderr(options?: SuppressTerminalStderrOptions): boolean {
96
+ if (savedStderrFd !== null) return true;
97
+ if (!options?.force && (process.platform !== "darwin" || !stderrSharesStdoutTerminal())) {
98
+ return false;
99
+ }
100
+ const libc = libcFdOps();
101
+ if (!libc) return false;
102
+
103
+ let redirectFd: number;
104
+ try {
105
+ const redirectPath = options?.redirectPath ?? getLogPath();
106
+ // getLogsDir() only computes the path; the logger creates it lazily, so
107
+ // on a fresh profile ~/.jeopi/logs may not exist yet. Create it here so
108
+ // diagnostics land in the log instead of falling through to /dev/null.
109
+ fs.mkdirSync(path.dirname(redirectPath), { recursive: true });
110
+ redirectFd = fs.openSync(redirectPath, "a");
111
+ } catch {
112
+ try {
113
+ redirectFd = fs.openSync("/dev/null", "w");
114
+ } catch {
115
+ return false;
116
+ }
117
+ }
118
+
119
+ const saved = libc.dup(STDERR_FILENO);
120
+ if (saved === -1) {
121
+ fs.closeSync(redirectFd);
122
+ return false;
123
+ }
124
+ if (libc.dup2(redirectFd, STDERR_FILENO) === -1) {
125
+ fs.closeSync(redirectFd);
126
+ fs.closeSync(saved);
127
+ return false;
128
+ }
129
+ fs.closeSync(redirectFd);
130
+ savedStderrFd = saved;
131
+ return true;
132
+ }
133
+
134
+ /**
135
+ * Re-point fd 2 at the saved terminal stderr. Safe to call unconditionally:
136
+ * no-op when suppression is not active. Called at every terminal-ownership
137
+ * release and by the postmortem fatal handlers before they print, so crash
138
+ * reports reach the real terminal.
139
+ */
140
+ export function restoreTerminalStderr(): void {
141
+ if (savedStderrFd === null) return;
142
+ const saved = savedStderrFd;
143
+ savedStderrFd = null;
144
+ libcFdOps()?.dup2(saved, STDERR_FILENO);
145
+ try {
146
+ fs.closeSync(saved);
147
+ } catch {
148
+ // The dup'ed fd is process-owned; a close failure leaves nothing to recover.
149
+ }
150
+ }
151
+
152
+ /** Whether fd 2 is currently redirected away from the terminal. */
153
+ export function isTerminalStderrSuppressed(): boolean {
154
+ return savedStderrFd !== null;
155
+ }
@@ -108,21 +108,75 @@ const MOVE_DIRS: GridCoord[] = [
108
108
  { x: 0, y: -1 },
109
109
  ]
110
110
 
111
- /** Check if a grid cell is unoccupied and has non-negative coordinates. */
112
- function isFreeInGrid(grid: Map<string, AsciiNode>, c: GridCoord): boolean {
113
- if (c.x < 0 || c.y < 0) return false
111
+ interface SearchBounds {
112
+ minX: number
113
+ maxX: number
114
+ minY: number
115
+ maxY: number
116
+ expansionLimit: number
117
+ }
118
+
119
+ const MIN_ROUTING_MARGIN = 8
120
+ const MIN_EXPANSION_BUDGET = 256
121
+ const MAX_EXPANSION_BUDGET = 50_000
122
+
123
+ function searchBoundsFor(grid: Map<string, AsciiNode>, from: GridCoord, to: GridCoord): SearchBounds {
124
+ let minX = Math.min(from.x, to.x)
125
+ let maxX = Math.max(from.x, to.x)
126
+ let minY = Math.min(from.y, to.y)
127
+ let maxY = Math.max(from.y, to.y)
128
+
129
+ for (const key of grid.keys()) {
130
+ const comma = key.indexOf(',')
131
+ if (comma === -1) continue
132
+
133
+ const x = Number(key.slice(0, comma))
134
+ const y = Number(key.slice(comma + 1))
135
+ if (!Number.isFinite(x) || !Number.isFinite(y)) continue
136
+
137
+ minX = Math.min(minX, x)
138
+ maxX = Math.max(maxX, x)
139
+ minY = Math.min(minY, y)
140
+ maxY = Math.max(maxY, y)
141
+ }
142
+
143
+ const width = maxX - minX + 1
144
+ const height = maxY - minY + 1
145
+ const margin = Math.max(MIN_ROUTING_MARGIN, Math.ceil(Math.max(width, height) / 2))
146
+ const boundedMinX = Math.max(0, minX - margin)
147
+ const boundedMaxX = maxX + margin
148
+ const boundedMinY = Math.max(0, minY - margin)
149
+ const boundedMaxY = maxY + margin
150
+ const area = (boundedMaxX - boundedMinX + 1) * (boundedMaxY - boundedMinY + 1)
151
+
152
+ return {
153
+ minX: boundedMinX,
154
+ maxX: boundedMaxX,
155
+ minY: boundedMinY,
156
+ maxY: boundedMaxY,
157
+ expansionLimit: Math.min(MAX_EXPANSION_BUDGET, Math.max(MIN_EXPANSION_BUDGET, area * 4)),
158
+ }
159
+ }
160
+
161
+ /** Check if a grid cell is unoccupied and inside the bounded routing area. */
162
+ function isFreeInGrid(grid: Map<string, AsciiNode>, c: GridCoord, bounds: SearchBounds): boolean {
163
+ if (c.x < bounds.minX || c.x > bounds.maxX || c.y < bounds.minY || c.y > bounds.maxY) {
164
+ return false
165
+ }
166
+
114
167
  return !grid.has(gridKey(c))
115
168
  }
116
169
 
117
170
  /**
118
171
  * Find a path from `from` to `to` on the grid using A*.
119
- * Returns the path as an array of GridCoords, or null if no path exists.
172
+ * Returns the path as an array of GridCoords, or null if no bounded path exists.
120
173
  */
121
174
  export function getPath(
122
175
  grid: Map<string, AsciiNode>,
123
176
  from: GridCoord,
124
177
  to: GridCoord,
125
178
  ): GridCoord[] | null {
179
+ const bounds = searchBoundsFor(grid, from, to)
126
180
  const pq = new MinHeap()
127
181
  pq.push({ coord: from, priority: 0 })
128
182
 
@@ -132,7 +186,13 @@ export function getPath(
132
186
  const cameFrom = new Map<string, GridCoord | null>()
133
187
  cameFrom.set(gridKey(from), null)
134
188
 
189
+ let expansions = 0
190
+
135
191
  while (pq.length > 0) {
192
+ if (expansions++ >= bounds.expansionLimit) {
193
+ return null
194
+ }
195
+
136
196
  const current = pq.pop()!.coord
137
197
 
138
198
  if (gridCoordEquals(current, to)) {
@@ -151,8 +211,11 @@ export function getPath(
151
211
  for (const dir of MOVE_DIRS) {
152
212
  const next: GridCoord = { x: current.x + dir.x, y: current.y + dir.y }
153
213
 
214
+ const insideBounds = next.x >= bounds.minX && next.x <= bounds.maxX
215
+ && next.y >= bounds.minY && next.y <= bounds.maxY
216
+
154
217
  // Allow moving to the destination even if it's occupied (it's a node boundary)
155
- if (!isFreeInGrid(grid, next) && !gridCoordEquals(next, to)) {
218
+ if (!insideBounds || (!isFreeInGrid(grid, next, bounds) && !gridCoordEquals(next, to))) {
156
219
  continue
157
220
  }
158
221