@linxiraos/pi-utils 1.0.0
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 +449 -0
- package/README.md +36 -0
- package/dist/types/abortable.d.ts +32 -0
- package/dist/types/acp/connection.d.ts +118 -0
- package/dist/types/acp/protocol.d.ts +526 -0
- package/dist/types/acp/schema.d.ts +41 -0
- package/dist/types/acp/stream.d.ts +8 -0
- package/dist/types/acp/transport.d.ts +81 -0
- package/dist/types/acp.d.ts +6 -0
- package/dist/types/async.d.ts +20 -0
- package/dist/types/binary.d.ts +17 -0
- package/dist/types/browsers.d.ts +68 -0
- package/dist/types/chalk.d.ts +125 -0
- package/dist/types/cli.d.ts +134 -0
- package/dist/types/color.d.ts +102 -0
- package/dist/types/dates.d.ts +7 -0
- package/dist/types/dirs.d.ts +282 -0
- package/dist/types/docx/converter.d.ts +46 -0
- package/dist/types/docx/xml.d.ts +26 -0
- package/dist/types/docx/zip.d.ts +6 -0
- package/dist/types/docx.d.ts +11 -0
- package/dist/types/dom/core.d.ts +431 -0
- package/dist/types/dom/parser.d.ts +7 -0
- package/dist/types/dom/selector.d.ts +5 -0
- package/dist/types/dom.d.ts +5 -0
- package/dist/types/env.d.ts +124 -0
- package/dist/types/fetch-retry.d.ts +96 -0
- package/dist/types/file-lock.d.ts +23 -0
- package/dist/types/format.d.ts +37 -0
- package/dist/types/frontmatter.d.ts +46 -0
- package/dist/types/fs-error.d.ts +31 -0
- package/dist/types/glob.d.ts +28 -0
- package/dist/types/headers.d.ts +34 -0
- package/dist/types/index.d.ts +39 -0
- package/dist/types/json-parse.d.ts +74 -0
- package/dist/types/json.d.ts +14 -0
- package/dist/types/logger/rotating-file.d.ts +18 -0
- package/dist/types/logger.d.ts +96 -0
- package/dist/types/loop-phase.d.ts +10 -0
- package/dist/types/lru.d.ts +46 -0
- package/dist/types/marked/core.d.ts +445 -0
- package/dist/types/marked.d.ts +2 -0
- package/dist/types/mermaid-ascii.d.ts +11 -0
- package/dist/types/mime.d.ts +29 -0
- package/dist/types/module-timer.d.ts +1 -0
- package/dist/types/path-tree.d.ts +76 -0
- package/dist/types/path.d.ts +2 -0
- package/dist/types/peek-file.d.ts +29 -0
- package/dist/types/postmortem.d.ts +104 -0
- package/dist/types/process-name.d.ts +7 -0
- package/dist/types/procmgr.d.ts +74 -0
- package/dist/types/prompt.d.ts +18 -0
- package/dist/types/ptree.d.ts +110 -0
- package/dist/types/readability/readability.d.ts +9 -0
- package/dist/types/readability/readerable.d.ts +10 -0
- package/dist/types/readability/types.d.ts +70 -0
- package/dist/types/readability.d.ts +4 -0
- package/dist/types/ring.d.ts +93 -0
- package/dist/types/runtime-install.d.ts +78 -0
- package/dist/types/sanitize-text.d.ts +29 -0
- package/dist/types/snowflake.d.ts +25 -0
- package/dist/types/stderr-guard.d.ts +22 -0
- package/dist/types/stream.d.ts +67 -0
- package/dist/types/tab-spacing.d.ts +24 -0
- package/dist/types/temp.d.ts +17 -0
- package/dist/types/template.d.ts +62 -0
- package/dist/types/timing-buffer.d.ts +22 -0
- package/dist/types/tls-fetch.d.ts +37 -0
- package/dist/types/turndown/gfm.d.ts +11 -0
- package/dist/types/turndown/html.d.ts +5 -0
- package/dist/types/turndown/service.d.ts +21 -0
- package/dist/types/turndown/types.d.ts +70 -0
- package/dist/types/turndown.d.ts +4 -0
- package/dist/types/type-guards.d.ts +5 -0
- package/dist/types/vendor/mermaid-ascii/ascii/ansi.d.ts +41 -0
- package/dist/types/vendor/mermaid-ascii/ascii/canvas.d.ts +89 -0
- package/dist/types/vendor/mermaid-ascii/ascii/class-diagram.d.ts +7 -0
- package/dist/types/vendor/mermaid-ascii/ascii/converter.d.ts +12 -0
- package/dist/types/vendor/mermaid-ascii/ascii/draw.d.ts +66 -0
- package/dist/types/vendor/mermaid-ascii/ascii/edge-bundling.d.ts +48 -0
- package/dist/types/vendor/mermaid-ascii/ascii/edge-routing.d.ts +43 -0
- package/dist/types/vendor/mermaid-ascii/ascii/er-diagram.d.ts +7 -0
- package/dist/types/vendor/mermaid-ascii/ascii/grid.d.ts +56 -0
- package/dist/types/vendor/mermaid-ascii/ascii/index.d.ts +65 -0
- package/dist/types/vendor/mermaid-ascii/ascii/multiline-utils.d.ts +27 -0
- package/dist/types/vendor/mermaid-ascii/ascii/pathfinder.d.ts +17 -0
- package/dist/types/vendor/mermaid-ascii/ascii/sequence.d.ts +7 -0
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/circle.d.ts +11 -0
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/corners.d.ts +34 -0
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/diamond.d.ts +11 -0
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/hexagon.d.ts +11 -0
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/index.d.ts +26 -0
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/rectangle.d.ts +31 -0
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/rounded.d.ts +11 -0
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/special.d.ts +59 -0
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/stadium.d.ts +17 -0
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/state.d.ts +30 -0
- package/dist/types/vendor/mermaid-ascii/ascii/shapes/types.d.ts +55 -0
- package/dist/types/vendor/mermaid-ascii/ascii/types.d.ts +206 -0
- package/dist/types/vendor/mermaid-ascii/ascii/validate.d.ts +51 -0
- package/dist/types/vendor/mermaid-ascii/ascii/xychart.d.ts +2 -0
- package/dist/types/vendor/mermaid-ascii/class/parser.d.ts +6 -0
- package/dist/types/vendor/mermaid-ascii/class/types.d.ts +102 -0
- package/dist/types/vendor/mermaid-ascii/er/parser.d.ts +6 -0
- package/dist/types/vendor/mermaid-ascii/er/types.d.ts +76 -0
- package/dist/types/vendor/mermaid-ascii/index.d.ts +1 -0
- package/dist/types/vendor/mermaid-ascii/multiline-utils.d.ts +9 -0
- package/dist/types/vendor/mermaid-ascii/parser.d.ts +7 -0
- package/dist/types/vendor/mermaid-ascii/sequence/parser.d.ts +6 -0
- package/dist/types/vendor/mermaid-ascii/sequence/types.d.ts +130 -0
- package/dist/types/vendor/mermaid-ascii/text-metrics.d.ts +21 -0
- package/dist/types/vendor/mermaid-ascii/types.d.ts +114 -0
- package/dist/types/vendor/mermaid-ascii/xychart/colors.d.ts +25 -0
- package/dist/types/vendor/mermaid-ascii/xychart/parser.d.ts +6 -0
- package/dist/types/vendor/mermaid-ascii/xychart/types.d.ts +145 -0
- package/dist/types/version.d.ts +18 -0
- package/dist/types/vterm/buffer.d.ts +99 -0
- package/dist/types/vterm/terminal.d.ts +44 -0
- package/dist/types/vterm.d.ts +8 -0
- package/dist/types/which.d.ts +37 -0
- package/dist/types/worker-host.d.ts +51 -0
- package/dist/types/xml.d.ts +31 -0
- package/package.json +59 -0
- package/src/abortable.ts +109 -0
- package/src/acp/connection.ts +344 -0
- package/src/acp/protocol.ts +466 -0
- package/src/acp/schema.ts +160 -0
- package/src/acp/stream.ts +82 -0
- package/src/acp/transport.ts +213 -0
- package/src/acp.ts +6 -0
- package/src/async.ts +91 -0
- package/src/binary.ts +50 -0
- package/src/browsers.ts +501 -0
- package/src/chalk.ts +312 -0
- package/src/cli.ts +529 -0
- package/src/color.ts +302 -0
- package/src/dates.ts +194 -0
- package/src/dirs.ts +1026 -0
- package/src/docx/converter.ts +681 -0
- package/src/docx/xml.ts +166 -0
- package/src/docx/zip.ts +87 -0
- package/src/docx.ts +20 -0
- package/src/dom/core.ts +1254 -0
- package/src/dom/parser.ts +370 -0
- package/src/dom/selector.ts +290 -0
- package/src/dom.ts +33 -0
- package/src/env.ts +400 -0
- package/src/fetch-retry.ts +379 -0
- package/src/file-lock.ts +69 -0
- package/src/format.ts +113 -0
- package/src/frontmatter.ts +205 -0
- package/src/fs-error.ts +56 -0
- package/src/glob.ts +189 -0
- package/src/headers.ts +167 -0
- package/src/index.ts +59 -0
- package/src/json-parse.ts +821 -0
- package/src/json.ts +23 -0
- package/src/logger/rotating-file.ts +149 -0
- package/src/logger.ts +798 -0
- package/src/loop-phase.ts +49 -0
- package/src/lru.ts +185 -0
- package/src/marked/core.ts +1576 -0
- package/src/marked.ts +2 -0
- package/src/mermaid-ascii.ts +31 -0
- package/src/mime.ts +159 -0
- package/src/module-timer.ts +148 -0
- package/src/path-tree.ts +147 -0
- package/src/path.ts +28 -0
- package/src/peek-file.ts +188 -0
- package/src/postmortem.ts +433 -0
- package/src/process-name.ts +57 -0
- package/src/procmgr.ts +247 -0
- package/src/prompt.ts +545 -0
- package/src/ptree.ts +412 -0
- package/src/readability/readability.ts +533 -0
- package/src/readability/readerable.ts +51 -0
- package/src/readability/types.ts +72 -0
- package/src/readability.ts +11 -0
- package/src/ring.ts +169 -0
- package/src/runtime-install.ts +393 -0
- package/src/sanitize-text.ts +96 -0
- package/src/snowflake.ts +121 -0
- package/src/stderr-guard.ts +155 -0
- package/src/stream.ts +454 -0
- package/src/tab-spacing.ts +336 -0
- package/src/temp.ts +134 -0
- package/src/template.ts +586 -0
- package/src/timing-buffer.ts +47 -0
- package/src/tls-fetch.ts +178 -0
- package/src/turndown/gfm.ts +106 -0
- package/src/turndown/html.ts +257 -0
- package/src/turndown/service.ts +334 -0
- package/src/turndown/types.ts +81 -0
- package/src/turndown.ts +5 -0
- package/src/type-guards.ts +17 -0
- package/src/vendor/mermaid-ascii/NOTICE +33 -0
- package/src/vendor/mermaid-ascii/ascii/ansi.ts +409 -0
- package/src/vendor/mermaid-ascii/ascii/canvas.ts +476 -0
- package/src/vendor/mermaid-ascii/ascii/class-diagram.ts +699 -0
- package/src/vendor/mermaid-ascii/ascii/converter.ts +271 -0
- package/src/vendor/mermaid-ascii/ascii/draw.ts +1382 -0
- package/src/vendor/mermaid-ascii/ascii/edge-bundling.ts +328 -0
- package/src/vendor/mermaid-ascii/ascii/edge-routing.ts +297 -0
- package/src/vendor/mermaid-ascii/ascii/er-diagram.ts +441 -0
- package/src/vendor/mermaid-ascii/ascii/grid.ts +578 -0
- package/src/vendor/mermaid-ascii/ascii/index.ts +187 -0
- package/src/vendor/mermaid-ascii/ascii/multiline-utils.ts +78 -0
- package/src/vendor/mermaid-ascii/ascii/pathfinder.ts +277 -0
- package/src/vendor/mermaid-ascii/ascii/sequence.ts +460 -0
- package/src/vendor/mermaid-ascii/ascii/shapes/circle.ts +27 -0
- package/src/vendor/mermaid-ascii/ascii/shapes/corners.ts +127 -0
- package/src/vendor/mermaid-ascii/ascii/shapes/diamond.ts +27 -0
- package/src/vendor/mermaid-ascii/ascii/shapes/hexagon.ts +27 -0
- package/src/vendor/mermaid-ascii/ascii/shapes/index.ts +101 -0
- package/src/vendor/mermaid-ascii/ascii/shapes/rectangle.ts +175 -0
- package/src/vendor/mermaid-ascii/ascii/shapes/rounded.ts +27 -0
- package/src/vendor/mermaid-ascii/ascii/shapes/special.ts +296 -0
- package/src/vendor/mermaid-ascii/ascii/shapes/stadium.ts +114 -0
- package/src/vendor/mermaid-ascii/ascii/shapes/state.ts +192 -0
- package/src/vendor/mermaid-ascii/ascii/shapes/types.ts +73 -0
- package/src/vendor/mermaid-ascii/ascii/types.ts +273 -0
- package/src/vendor/mermaid-ascii/ascii/validate.ts +120 -0
- package/src/vendor/mermaid-ascii/ascii/xychart.ts +875 -0
- package/src/vendor/mermaid-ascii/class/parser.ts +290 -0
- package/src/vendor/mermaid-ascii/class/types.ts +121 -0
- package/src/vendor/mermaid-ascii/er/parser.ts +181 -0
- package/src/vendor/mermaid-ascii/er/types.ts +91 -0
- package/src/vendor/mermaid-ascii/index.ts +14 -0
- package/src/vendor/mermaid-ascii/multiline-utils.ts +30 -0
- package/src/vendor/mermaid-ascii/parser.ts +645 -0
- package/src/vendor/mermaid-ascii/sequence/parser.ts +207 -0
- package/src/vendor/mermaid-ascii/sequence/types.ts +146 -0
- package/src/vendor/mermaid-ascii/text-metrics.ts +71 -0
- package/src/vendor/mermaid-ascii/types.ts +164 -0
- package/src/vendor/mermaid-ascii/xychart/colors.ts +140 -0
- package/src/vendor/mermaid-ascii/xychart/parser.ts +115 -0
- package/src/vendor/mermaid-ascii/xychart/types.ts +150 -0
- package/src/version.ts +99 -0
- package/src/vterm/buffer.ts +218 -0
- package/src/vterm/terminal.ts +773 -0
- package/src/vterm.ts +8 -0
- package/src/which.ts +234 -0
- package/src/worker-host.ts +100 -0
- package/src/xml.ts +313 -0
package/src/peek-file.ts
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read the first `maxBytes` of a file (offset 0) and pass that slice to `op`.
|
|
3
|
+
*
|
|
4
|
+
* Buffers are reused to avoid allocating on every peek: sync uses one growable
|
|
5
|
+
* `Uint8Array`; async uses a small fixed pool of `Buffer`s with a bounded wait
|
|
6
|
+
* queue, falling back to a fresh allocation when the pool and queue are saturated
|
|
7
|
+
* or when `maxBytes` exceeds the pool slot size.
|
|
8
|
+
*/
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
|
|
11
|
+
/** Async pool slot size; larger peeks allocate ad hoc. */
|
|
12
|
+
const POOLED_BUFFER_SIZE = 512;
|
|
13
|
+
const ASYNC_POOL_SIZE = 10;
|
|
14
|
+
/** Cap waiter queue so heavy concurrency does not queue unbounded; overflow uses alloc. */
|
|
15
|
+
const MAX_ASYNC_WAITERS = 4;
|
|
16
|
+
const INITIAL_SYNC_BUFFER_SIZE = 1024;
|
|
17
|
+
const EMPTY_BUFFER = Buffer.alloc(0);
|
|
18
|
+
|
|
19
|
+
const asyncPool = Array.from({ length: ASYNC_POOL_SIZE }, () => Buffer.allocUnsafe(POOLED_BUFFER_SIZE));
|
|
20
|
+
const availableAsyncPoolIndexes = Array.from({ length: ASYNC_POOL_SIZE }, (_, index) => index);
|
|
21
|
+
const asyncPoolWaiters: Array<(index: number) => void> = [];
|
|
22
|
+
let syncPool = new Uint8Array(INITIAL_SYNC_BUFFER_SIZE);
|
|
23
|
+
|
|
24
|
+
/** Returns a pool slot index, or `-1` when the caller should use a standalone buffer. */
|
|
25
|
+
function acquireAsyncPoolIndex(): Promise<number> | number {
|
|
26
|
+
const index = availableAsyncPoolIndexes.pop();
|
|
27
|
+
if (index !== undefined) {
|
|
28
|
+
return index;
|
|
29
|
+
}
|
|
30
|
+
if (asyncPoolWaiters.length >= MAX_ASYNC_WAITERS) {
|
|
31
|
+
return -1;
|
|
32
|
+
}
|
|
33
|
+
const { promise, resolve } = Promise.withResolvers<number>();
|
|
34
|
+
asyncPoolWaiters.push(resolve);
|
|
35
|
+
return promise;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function releaseAsyncPoolIndex(index: number): void {
|
|
39
|
+
if (index < 0) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const waiter = asyncPoolWaiters.shift();
|
|
43
|
+
if (waiter) {
|
|
44
|
+
waiter(index);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
availableAsyncPoolIndexes.push(index);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function withAsyncPoolBuffer<T>(maxBytes: number, op: (buffer: Buffer) => Promise<T>): Promise<T> {
|
|
51
|
+
if (maxBytes <= 0) {
|
|
52
|
+
return op(EMPTY_BUFFER);
|
|
53
|
+
}
|
|
54
|
+
if (maxBytes > POOLED_BUFFER_SIZE) {
|
|
55
|
+
return op(Buffer.allocUnsafe(maxBytes));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const poolIndex = await acquireAsyncPoolIndex();
|
|
59
|
+
const buffer = poolIndex >= 0 ? asyncPool[poolIndex] : Buffer.allocUnsafe(maxBytes);
|
|
60
|
+
try {
|
|
61
|
+
return await op(buffer.subarray(0, maxBytes));
|
|
62
|
+
} finally {
|
|
63
|
+
releaseAsyncPoolIndex(poolIndex);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function withSyncPoolBuffer<T>(maxBytes: number, op: (buffer: Uint8Array) => T): T {
|
|
68
|
+
if (maxBytes <= 0) {
|
|
69
|
+
return op(EMPTY_BUFFER);
|
|
70
|
+
}
|
|
71
|
+
if (maxBytes > syncPool.byteLength) {
|
|
72
|
+
syncPool = new Uint8Array(maxBytes + (maxBytes >> 1));
|
|
73
|
+
}
|
|
74
|
+
return op(syncPool.subarray(0, maxBytes));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Synchronously reads up to `maxBytes` from the start of `filePath` and returns `op(header)`.
|
|
79
|
+
* If the file is shorter, `header` is only the bytes actually read.
|
|
80
|
+
*/
|
|
81
|
+
export function peekFileSync<T>(filePath: string, maxBytes: number, op: (header: Uint8Array) => T): T {
|
|
82
|
+
if (maxBytes <= 0) {
|
|
83
|
+
return op(EMPTY_BUFFER);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const fileHandle = fs.openSync(filePath, "r");
|
|
87
|
+
try {
|
|
88
|
+
return withSyncPoolBuffer(maxBytes, buffer => {
|
|
89
|
+
const bytesRead = fs.readSync(fileHandle, buffer, 0, buffer.byteLength, 0);
|
|
90
|
+
return op(buffer.subarray(0, bytesRead));
|
|
91
|
+
});
|
|
92
|
+
} finally {
|
|
93
|
+
fs.closeSync(fileHandle);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Like {@link peekFileSync} but uses async I/O.
|
|
99
|
+
*/
|
|
100
|
+
export async function peekFile<T>(filePath: string, maxBytes: number, op: (header: Uint8Array) => T): Promise<T> {
|
|
101
|
+
if (maxBytes <= 0) {
|
|
102
|
+
return op(EMPTY_BUFFER);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const fileHandle = await fs.promises.open(filePath, "r");
|
|
106
|
+
try {
|
|
107
|
+
return await withAsyncPoolBuffer(maxBytes, async buffer => {
|
|
108
|
+
const { bytesRead } = await fileHandle.read(buffer, 0, buffer.byteLength, 0);
|
|
109
|
+
return op(buffer.subarray(0, bytesRead));
|
|
110
|
+
});
|
|
111
|
+
} finally {
|
|
112
|
+
await fileHandle.close();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Read up to the last `maxBytes` of `filePath` and pass that slice to `op`.
|
|
118
|
+
*
|
|
119
|
+
* The tail mirror of {@link peekFile}: same pooled-buffer strategy (no per-call
|
|
120
|
+
* allocation for small reads), but the read is positioned at `size - len` so the
|
|
121
|
+
* window ends at EOF. When the file is shorter than `maxBytes`, the whole file is
|
|
122
|
+
* returned. A multi-byte codepoint straddling the leading cut decodes to a
|
|
123
|
+
* replacement char — callers that parse line-oriented tails drop the partial
|
|
124
|
+
* leading line anyway.
|
|
125
|
+
*/
|
|
126
|
+
export async function peekFileTail<T>(filePath: string, maxBytes: number, op: (tail: Uint8Array) => T): Promise<T> {
|
|
127
|
+
if (maxBytes <= 0) {
|
|
128
|
+
return op(EMPTY_BUFFER);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const fileHandle = await fs.promises.open(filePath, "r");
|
|
132
|
+
try {
|
|
133
|
+
const { size } = await fileHandle.stat();
|
|
134
|
+
const len = Math.min(maxBytes, size);
|
|
135
|
+
if (len <= 0) {
|
|
136
|
+
return op(EMPTY_BUFFER);
|
|
137
|
+
}
|
|
138
|
+
return await withAsyncPoolBuffer(len, async buffer => {
|
|
139
|
+
const { bytesRead } = await fileHandle.read(buffer, 0, buffer.byteLength, size - len);
|
|
140
|
+
return op(buffer.subarray(0, bytesRead));
|
|
141
|
+
});
|
|
142
|
+
} finally {
|
|
143
|
+
await fileHandle.close();
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Read up to the first `prefixBytes` and last `suffixBytes` of `filePath`, then
|
|
149
|
+
* pass both slices to `op`.
|
|
150
|
+
*
|
|
151
|
+
* Uses a single open/stat sequence. When the whole file fits in the head window,
|
|
152
|
+
* the tail is sliced from the already-read head bytes instead of issuing a
|
|
153
|
+
* second read.
|
|
154
|
+
*/
|
|
155
|
+
export async function peekFileEnds<T>(
|
|
156
|
+
filePath: string,
|
|
157
|
+
prefixBytes: number,
|
|
158
|
+
suffixBytes: number,
|
|
159
|
+
op: (head: Uint8Array, tail: Uint8Array) => T,
|
|
160
|
+
): Promise<T> {
|
|
161
|
+
if (prefixBytes <= 0 && suffixBytes <= 0) {
|
|
162
|
+
return op(EMPTY_BUFFER, EMPTY_BUFFER);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const fileHandle = await fs.promises.open(filePath, "r");
|
|
166
|
+
try {
|
|
167
|
+
const { size } = await fileHandle.stat();
|
|
168
|
+
const headLen = prefixBytes > 0 ? Math.min(prefixBytes, size) : 0;
|
|
169
|
+
const tailLen = suffixBytes > 0 ? Math.min(suffixBytes, size) : 0;
|
|
170
|
+
|
|
171
|
+
const head = headLen > 0 ? Buffer.allocUnsafe(headLen) : EMPTY_BUFFER;
|
|
172
|
+
const headBytesRead = headLen > 0 ? (await fileHandle.read(head, 0, head.byteLength, 0)).bytesRead : 0;
|
|
173
|
+
const headSlice = head.subarray(0, headBytesRead);
|
|
174
|
+
|
|
175
|
+
if (tailLen <= 0) {
|
|
176
|
+
return op(headSlice, EMPTY_BUFFER);
|
|
177
|
+
}
|
|
178
|
+
if (size <= headLen) {
|
|
179
|
+
return op(headSlice, headSlice.subarray(Math.max(0, headBytesRead - tailLen)));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const tail = Buffer.allocUnsafe(tailLen);
|
|
183
|
+
const { bytesRead: tailBytesRead } = await fileHandle.read(tail, 0, tail.byteLength, size - tailLen);
|
|
184
|
+
return op(headSlice, tail.subarray(0, tailBytesRead));
|
|
185
|
+
} finally {
|
|
186
|
+
await fileHandle.close();
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cleanup and postmortem handler utilities.
|
|
3
|
+
*
|
|
4
|
+
* This module provides a system for registering and running cleanup callbacks
|
|
5
|
+
* in response to process exit, signals, or fatal exceptions. It is intended to
|
|
6
|
+
* allow reliably releasing resources or shutting down subprocesses, files, sockets, etc.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import inspector from "node:inspector";
|
|
11
|
+
import { isMainThread } from "node:worker_threads";
|
|
12
|
+
import * as logger from "./logger";
|
|
13
|
+
import { restoreTerminalStderr } from "./stderr-guard";
|
|
14
|
+
|
|
15
|
+
// Cleanup reasons, in order of priority/meaning.
|
|
16
|
+
export enum Reason {
|
|
17
|
+
PRE_EXIT = "pre_exit", // Pre-exit phase (not used by default)
|
|
18
|
+
EXIT = "exit", // Normal process exit
|
|
19
|
+
SIGINT = "sigint", // Ctrl-C or SIGINT
|
|
20
|
+
SIGTERM = "sigterm", // SIGTERM
|
|
21
|
+
SIGHUP = "sighup", // SIGHUP
|
|
22
|
+
UNCAUGHT_EXCEPTION = "uncaught_exception", // Fatal exception
|
|
23
|
+
UNHANDLED_REJECTION = "unhandled_rejection", // Unhandled promise rejection
|
|
24
|
+
MANUAL = "manual", // Manual cleanup (not triggered by process)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Internal list of active cleanup callbacks (in registration order)
|
|
28
|
+
const callbackList: ((reason: Reason) => Promise<void> | void)[] = [];
|
|
29
|
+
// Tracks cleanup run state (to prevent recursion/reentry issues)
|
|
30
|
+
let cleanupStage: "idle" | "running" | "complete" = "idle";
|
|
31
|
+
const CLEANUP_DEADLINE_MS = 10_000;
|
|
32
|
+
/**
|
|
33
|
+
* Symbol stamped by the extension-load guard onto the throwing replacement it
|
|
34
|
+
* installs over `process.exit` / `process.reallyExit`, carrying the native
|
|
35
|
+
* primitive that replacement shadows.
|
|
36
|
+
*
|
|
37
|
+
* Host-owned shutdown ({@link exitProcess}) reads through it so a signal that
|
|
38
|
+
* lands while the guard is active still terminates the process (#6488), while
|
|
39
|
+
* a signal that lands after the guard has restored the native exit also
|
|
40
|
+
* terminates cleanly (#7393). `Symbol.for` so it survives duplicate module
|
|
41
|
+
* instances across bundles/realms.
|
|
42
|
+
*/
|
|
43
|
+
export const NATIVE_PROCESS_EXIT = Symbol.for("omp.postmortem.nativeProcessExit");
|
|
44
|
+
|
|
45
|
+
type HardExitFn = (code?: number) => never;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Hard-exit the process through the native primitive, resolved on every call.
|
|
49
|
+
*
|
|
50
|
+
* The native exit is deliberately re-resolved here rather than bound at module
|
|
51
|
+
* load: the extension/hook loader's `withHostGuard` transiently swaps
|
|
52
|
+
* `process.reallyExit`/`process.exit` for a stub that throws
|
|
53
|
+
* `ExtensionExitError`, and the shipped bundle defers this module's evaluation
|
|
54
|
+
* until first access — which can land inside that guard window, so binding at
|
|
55
|
+
* init could freeze the throwing stub forever and turn every later shutdown
|
|
56
|
+
* (SIGHUP/SIGINT/fatal) into an unhandled-rejection loop (#7393). When the
|
|
57
|
+
* guard is active the stub carries the native exit under
|
|
58
|
+
* {@link NATIVE_PROCESS_EXIT}; unwrapping it lets a mid-guard signal still exit
|
|
59
|
+
* (#6488). Otherwise the current `process.reallyExit`/`process.exit` is native.
|
|
60
|
+
*/
|
|
61
|
+
function exitProcess(code: number): never {
|
|
62
|
+
const current: HardExitFn = typeof process.reallyExit === "function" ? process.reallyExit : process.exit;
|
|
63
|
+
const behind = Reflect.get(current, NATIVE_PROCESS_EXIT);
|
|
64
|
+
const nativeExit = typeof behind === "function" ? (behind as HardExitFn) : current;
|
|
65
|
+
return nativeExit.call(process, code) as never;
|
|
66
|
+
}
|
|
67
|
+
let cleanupPromise: Promise<void> | undefined;
|
|
68
|
+
let stdioDisconnectRegistrations = 0;
|
|
69
|
+
|
|
70
|
+
/** User-facing command printed before fatal cleanup so interrupted work can be resumed. */
|
|
71
|
+
export interface FatalRecoveryHint {
|
|
72
|
+
/** Stable label identifying the recoverable session or process. */
|
|
73
|
+
label: string;
|
|
74
|
+
/** Complete shell command the user can execute to resume the interrupted work. */
|
|
75
|
+
command: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
type FatalRecoveryHintProvider = () => FatalRecoveryHint | undefined;
|
|
79
|
+
const fatalRecoveryHintProviders = new Set<FatalRecoveryHintProvider>();
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Internal: runs all registered cleanup callbacks for the given reason.
|
|
83
|
+
* Ensures each callback is invoked at most once. Handles errors and prevents reentrancy.
|
|
84
|
+
*
|
|
85
|
+
* Returns a Promise that settles after all cleanups complete or error out.
|
|
86
|
+
*/
|
|
87
|
+
function runCleanup(reason: Reason): Promise<void> {
|
|
88
|
+
switch (cleanupStage) {
|
|
89
|
+
case "idle":
|
|
90
|
+
cleanupStage = "running";
|
|
91
|
+
break;
|
|
92
|
+
case "running":
|
|
93
|
+
return cleanupPromise ?? Promise.resolve();
|
|
94
|
+
case "complete":
|
|
95
|
+
return Promise.resolve();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Call .cleanup() for each callback that is still "armed".
|
|
99
|
+
// Use Promise.try to handle sync/async, but only those armed.
|
|
100
|
+
const promises = callbackList.toReversed().map(callback => {
|
|
101
|
+
return Promise.try(() => callback(reason));
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const cleanupSettled = Promise.allSettled(promises).then(results => {
|
|
105
|
+
for (const result of results) {
|
|
106
|
+
if (result.status === "rejected") {
|
|
107
|
+
const err = result.reason instanceof Error ? result.reason : new Error(String(result.reason));
|
|
108
|
+
logger.error("Cleanup callback failed", { err, stack: err.stack });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
cleanupStage = "complete";
|
|
112
|
+
});
|
|
113
|
+
const deadline = Promise.withResolvers<void>();
|
|
114
|
+
const deadlineTimer = setTimeout(() => {
|
|
115
|
+
logger.error("Cleanup deadline exceeded; proceeding with exit", { reason });
|
|
116
|
+
cleanupStage = "complete";
|
|
117
|
+
deadline.resolve();
|
|
118
|
+
}, CLEANUP_DEADLINE_MS);
|
|
119
|
+
cleanupPromise = Promise.race([cleanupSettled, deadline.promise]).finally(() => {
|
|
120
|
+
clearTimeout(deadlineTimer);
|
|
121
|
+
});
|
|
122
|
+
return cleanupPromise;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Register signal and error event handlers to trigger cleanup before exit.
|
|
126
|
+
// Main thread: full signal handling (SIGINT, SIGTERM, SIGHUP) + exceptions + exit
|
|
127
|
+
// Worker thread: exit only (workers use self.addEventListener for exceptions)
|
|
128
|
+
let inspectorOpened = false;
|
|
129
|
+
|
|
130
|
+
/** Origin of an EPIPE raised by a process communication channel. */
|
|
131
|
+
export type BrokenPipeSource = "ipc-send" | "stdio-write";
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Classify EPIPE errors from worker IPC and stdio without treating unrelated
|
|
135
|
+
* broken pipes as globally recoverable.
|
|
136
|
+
*/
|
|
137
|
+
export function classifyBrokenPipe(err: Error): BrokenPipeSource | undefined {
|
|
138
|
+
if (!("code" in err) || err.code !== "EPIPE" || !("syscall" in err)) return undefined;
|
|
139
|
+
if (err.syscall === "send") return "ipc-send";
|
|
140
|
+
if (err.syscall === "write") return "stdio-write";
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Whether an EPIPE came from an IPC `send()` to an optional worker. */
|
|
145
|
+
export function isIpcSendEpipe(err: Error): boolean {
|
|
146
|
+
return classifyBrokenPipe(err) === "ipc-send";
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Treat unhandled stdout EPIPE rejections as a graceful peer disconnect.
|
|
151
|
+
*
|
|
152
|
+
* Stdio protocol servers call this for their process lifetime so a closed
|
|
153
|
+
* client pipe runs registered cleanup callbacks instead of the fatal path.
|
|
154
|
+
* The returned callback removes the registration.
|
|
155
|
+
*/
|
|
156
|
+
export function registerStdioDisconnectHandling(): () => void {
|
|
157
|
+
let registered = true;
|
|
158
|
+
stdioDisconnectRegistrations++;
|
|
159
|
+
return () => {
|
|
160
|
+
if (!registered) return;
|
|
161
|
+
registered = false;
|
|
162
|
+
stdioDisconnectRegistrations--;
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Well-known key marking an error as an *expected* teardown artifact (e.g. a
|
|
167
|
+
// browser run-scope abort at normal run end). `Symbol.for` so the marker
|
|
168
|
+
// survives duplicate module instances across bundles/realms.
|
|
169
|
+
const EXPECTED_CLEANUP = Symbol.for("omp.expectedCleanupError");
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Mark an error as expected cleanup fallout so the global fatal handlers
|
|
173
|
+
* downgrade it to a log line instead of tearing down the process. Use for
|
|
174
|
+
* abort reasons fired by routine resource teardown (browser run end, tab
|
|
175
|
+
* close) whose rejections may surface on fire-and-forget promises with no
|
|
176
|
+
* consumer. Returns the same error for inline use at the `abort()` callsite.
|
|
177
|
+
*/
|
|
178
|
+
export function markExpectedCleanupError<T extends object>(reason: T): T {
|
|
179
|
+
(reason as Record<PropertyKey, unknown>)[EXPECTED_CLEANUP] = true;
|
|
180
|
+
return reason;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Whether `reason` (or any error in its `cause` chain) was marked via
|
|
185
|
+
* {@link markExpectedCleanupError}. Walks the chain because the unhandled
|
|
186
|
+
* reason is often a wrapper (`AbortError`) with the marked abort reason as
|
|
187
|
+
* its `cause`.
|
|
188
|
+
*/
|
|
189
|
+
export function isExpectedCleanupError(reason: unknown): boolean {
|
|
190
|
+
let current: unknown = reason;
|
|
191
|
+
for (let depth = 0; depth < 8 && current !== null && typeof current === "object"; depth++) {
|
|
192
|
+
if ((current as Record<PropertyKey, unknown>)[EXPECTED_CLEANUP] === true) return true;
|
|
193
|
+
current = (current as { cause?: unknown }).cause;
|
|
194
|
+
}
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Interceptors consulted by the global `unhandledRejection` handler before the fatal path. */
|
|
199
|
+
const rejectionInterceptors = new Set<(reason: unknown) => boolean>();
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Register an interceptor consulted before an unhandled rejection tears the
|
|
203
|
+
* process down. A consuming interceptor owns reporting and keeps the process alive.
|
|
204
|
+
*/
|
|
205
|
+
export function interceptUnhandledRejections(interceptor: (reason: unknown) => boolean): () => void {
|
|
206
|
+
rejectionInterceptors.add(interceptor);
|
|
207
|
+
return () => rejectionInterceptors.delete(interceptor);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Register a synchronous recovery command to print when the process exits
|
|
212
|
+
* through an uncaught exception or unhandled rejection.
|
|
213
|
+
*/
|
|
214
|
+
export function registerFatalRecoveryHint(provider: FatalRecoveryHintProvider): () => void {
|
|
215
|
+
fatalRecoveryHintProviders.add(provider);
|
|
216
|
+
return () => fatalRecoveryHintProviders.delete(provider);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function escapeFatalHintText(value: string): string {
|
|
220
|
+
return value.replace(/[\u0000-\u001f\u007f-\u009f]/gu, char => {
|
|
221
|
+
const code = char.codePointAt(0) ?? 0;
|
|
222
|
+
return `\\u${code.toString(16).padStart(4, "0")}`;
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function formatFatalRecoveryHints(): string {
|
|
227
|
+
const lines: string[] = [];
|
|
228
|
+
const seenCommands = new Set<string>();
|
|
229
|
+
for (const provider of fatalRecoveryHintProviders) {
|
|
230
|
+
try {
|
|
231
|
+
const hint = provider();
|
|
232
|
+
if (!hint?.command || seenCommands.has(hint.command)) continue;
|
|
233
|
+
seenCommands.add(hint.command);
|
|
234
|
+
lines.push(` ${escapeFatalHintText(hint.label)}: ${escapeFatalHintText(hint.command)}`);
|
|
235
|
+
} catch (err) {
|
|
236
|
+
logger.warn("Fatal recovery hint provider failed", { err });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return lines.length > 0 ? `\n[Recovery]\n${lines.join("\n")}\n` : "";
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function formatFatalError(label: string, err: Error): string {
|
|
243
|
+
const name = err.name || "Error";
|
|
244
|
+
const message = err.message || "(no message)";
|
|
245
|
+
const stack = err.stack || "";
|
|
246
|
+
const stackLines = stack.split("\n").slice(1);
|
|
247
|
+
const formattedStack = stackLines.length > 0 ? `\n${stackLines.join("\n")}` : "";
|
|
248
|
+
return `\n[${label}] ${name}: ${message}${formattedStack}\n`;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function exitAfterFatal(label: string, logMessage: string, err: Error, reason: Reason): Promise<void> {
|
|
252
|
+
const forcedExit = setTimeout(() => exitProcess(1), CLEANUP_DEADLINE_MS);
|
|
253
|
+
try {
|
|
254
|
+
restoreTerminalStderr();
|
|
255
|
+
// A revoked terminal can make stream writes raise another fatal error. Use
|
|
256
|
+
// the descriptor directly so failure stays synchronous and contained.
|
|
257
|
+
try {
|
|
258
|
+
fs.writeSync(2, `${formatFatalError(label, err)}${formatFatalRecoveryHints()}`);
|
|
259
|
+
} catch {}
|
|
260
|
+
logger.error(logMessage, { err });
|
|
261
|
+
await runCleanup(reason);
|
|
262
|
+
} finally {
|
|
263
|
+
clearTimeout(forcedExit);
|
|
264
|
+
exitProcess(1);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (isMainThread) {
|
|
269
|
+
process
|
|
270
|
+
.on("SIGINT", async () => {
|
|
271
|
+
await runCleanup(Reason.SIGINT);
|
|
272
|
+
exitProcess(130); // 128 + SIGINT (2)
|
|
273
|
+
})
|
|
274
|
+
.on("SIGUSR1", () => {
|
|
275
|
+
if (inspectorOpened) return;
|
|
276
|
+
inspectorOpened = true;
|
|
277
|
+
inspector.open(undefined, undefined, false);
|
|
278
|
+
const url = inspector.url();
|
|
279
|
+
process.stderr.write(`Inspector opened: ${url}\n`);
|
|
280
|
+
})
|
|
281
|
+
.on("uncaughtException", async err => {
|
|
282
|
+
if (isExpectedCleanupError(err)) {
|
|
283
|
+
logger.warn("Ignoring expected cleanup exception", { err });
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
await exitAfterFatal("Uncaught Exception", "Uncaught exception", err, Reason.UNCAUGHT_EXCEPTION);
|
|
287
|
+
})
|
|
288
|
+
.on("unhandledRejection", async reason => {
|
|
289
|
+
const err = reason instanceof Error ? reason : new Error(String(reason));
|
|
290
|
+
const brokenPipeSource = classifyBrokenPipe(err);
|
|
291
|
+
// EPIPE from an IPC `send()` (`syscall: "send"`) originates from a
|
|
292
|
+
// worker subprocess whose pipe broke between the exit being observed
|
|
293
|
+
// and the next `proc.send()` — a race window that Bun surfaces as an
|
|
294
|
+
// async rejection rather than the synchronous "cannot be used after
|
|
295
|
+
// the process has exited" guard. Every `send()` target is an optional
|
|
296
|
+
// worker subsystem (TTS, STT, tiny-title, MCP servers), so a broken
|
|
297
|
+
// send pipe must never take down the whole session. Log and continue
|
|
298
|
+
// instead of exiting; the owning client detects the dead worker via
|
|
299
|
+
// its own `onExit`/error path and respawns or disables it. See #2997.
|
|
300
|
+
if (brokenPipeSource === "ipc-send") {
|
|
301
|
+
logger.warn("Ignoring EPIPE from worker IPC send; optional subsystem will self-recover", { err });
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (brokenPipeSource === "stdio-write" && stdioDisconnectRegistrations > 0) {
|
|
305
|
+
logger.warn("Stdio peer disconnected; shutting down gracefully", { err });
|
|
306
|
+
await runQuit(0, "native");
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (isExpectedCleanupError(reason)) {
|
|
310
|
+
logger.warn("Ignoring expected cleanup rejection", { err });
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
for (const interceptor of rejectionInterceptors) {
|
|
314
|
+
try {
|
|
315
|
+
if (interceptor(reason)) return;
|
|
316
|
+
} catch (interceptorErr) {
|
|
317
|
+
logger.warn("Unhandled-rejection interceptor threw; continuing with fatal path", {
|
|
318
|
+
err: interceptorErr,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
await exitAfterFatal("Unhandled Rejection", "Unhandled rejection", err, Reason.UNHANDLED_REJECTION);
|
|
323
|
+
})
|
|
324
|
+
.on("exit", async () => {
|
|
325
|
+
void runCleanup(Reason.EXIT); // fire and forget (exit imminent)
|
|
326
|
+
})
|
|
327
|
+
.on("SIGTERM", async () => {
|
|
328
|
+
await runCleanup(Reason.SIGTERM);
|
|
329
|
+
exitProcess(143); // 128 + SIGTERM (15)
|
|
330
|
+
})
|
|
331
|
+
.on("SIGHUP", async () => {
|
|
332
|
+
await runCleanup(Reason.SIGHUP);
|
|
333
|
+
exitProcess(129); // 128 + SIGHUP (1)
|
|
334
|
+
});
|
|
335
|
+
} else {
|
|
336
|
+
// Worker thread: only register exit handler for cleanup.
|
|
337
|
+
// DO NOT register uncaughtException/unhandledRejection handlers here -
|
|
338
|
+
// they would swallow errors before the worker's own handlers (self.addEventListener)
|
|
339
|
+
// can report failures back to the parent thread.
|
|
340
|
+
process.on("exit", () => {
|
|
341
|
+
void runCleanup(Reason.EXIT);
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Register a process cleanup callback, to be run on shutdown, signal, or fatal error.
|
|
347
|
+
*
|
|
348
|
+
* Returns a Callback instance that can be used to cancel (unregister) or manually clean up.
|
|
349
|
+
* If register is called after cleanup already began, invokes callback on a microtask.
|
|
350
|
+
*/
|
|
351
|
+
export function register(id: string, callback: (reason: Reason) => void | Promise<void>): () => void {
|
|
352
|
+
let done = false;
|
|
353
|
+
const exec = (reason: Reason) => {
|
|
354
|
+
if (done) return;
|
|
355
|
+
done = true;
|
|
356
|
+
try {
|
|
357
|
+
return callback(reason);
|
|
358
|
+
} catch (e) {
|
|
359
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
360
|
+
logger.error("Cleanup callback failed", { err, id, stack: err.stack });
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
const cancel = () => {
|
|
365
|
+
const index = callbackList.indexOf(exec);
|
|
366
|
+
if (index >= 0) {
|
|
367
|
+
callbackList.splice(index, 1);
|
|
368
|
+
}
|
|
369
|
+
done = true;
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
if (cleanupStage !== "idle") {
|
|
373
|
+
// Cleanup is already in progress or complete; run late registrations once
|
|
374
|
+
// without re-entering the global cleanup pass.
|
|
375
|
+
logger.debug("Cleanup already started; running late callback once", { id });
|
|
376
|
+
try {
|
|
377
|
+
callback(Reason.MANUAL);
|
|
378
|
+
} catch (e) {
|
|
379
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
380
|
+
logger.error("Cleanup callback failed", { err, id, stack: err.stack });
|
|
381
|
+
}
|
|
382
|
+
return () => {};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Register callback as "armed" (active).
|
|
386
|
+
callbackList.push(exec);
|
|
387
|
+
return cancel;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Runs all cleanup callbacks without exiting.
|
|
392
|
+
* Use this in workers or when you need to clean up but continue execution.
|
|
393
|
+
*/
|
|
394
|
+
export function cleanup(): Promise<void> {
|
|
395
|
+
return runCleanup(Reason.MANUAL);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** Controls how manual process shutdown handles terminal output. */
|
|
399
|
+
export interface QuitOptions {
|
|
400
|
+
/** Wait for buffered stdout before exiting; disable after the terminal has disconnected. */
|
|
401
|
+
drainStdout?: boolean;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async function runQuit(code: number, exitMode: "guarded" | "native", options: QuitOptions = {}): Promise<void> {
|
|
405
|
+
await runCleanup(Reason.MANUAL);
|
|
406
|
+
|
|
407
|
+
if (!isMainThread) {
|
|
408
|
+
return; // Workers: cleanup done, let worker exit naturally
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (options.drainStdout !== false && process.stdout.writableLength > 0) {
|
|
412
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
413
|
+
process.stdout.once("drain", resolve);
|
|
414
|
+
await Promise.race([promise, Bun.sleep(5000)]);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
switch (exitMode) {
|
|
418
|
+
case "guarded":
|
|
419
|
+
return process.exit(code);
|
|
420
|
+
case "native":
|
|
421
|
+
return exitProcess(code);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Runs all cleanup callbacks and exits through the current `process.exit`.
|
|
427
|
+
*
|
|
428
|
+
* In main thread: waits for stdout drain unless disabled, then calls `process.exit()`.
|
|
429
|
+
* In workers: runs cleanup only (process.exit would kill entire process).
|
|
430
|
+
*/
|
|
431
|
+
export function quit(code: number = 0, options: QuitOptions = {}): Promise<void> {
|
|
432
|
+
return runQuit(code, "guarded", options);
|
|
433
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Set the OS-visible process name (`/proc/self/comm`) so `omp` shows up as
|
|
3
|
+
* `omp` — not `bun` — in `ps`, `pgrep`, `killall`, `top`, `htop`, and systemd.
|
|
4
|
+
*
|
|
5
|
+
* Bun's `process.title` setter only stores the value on the JS side; unlike
|
|
6
|
+
* Node/libuv it never calls `prctl(PR_SET_NAME)`, so the kernel's `comm` stays
|
|
7
|
+
* `bun` and process-name-based tooling can't target omp (and `pkill bun` becomes
|
|
8
|
+
* a footgun that kills every Bun process on the machine). We keep the
|
|
9
|
+
* `process.title` assignment (correct getter, future-proof if Bun ever fixes the
|
|
10
|
+
* setter) and additionally drive `prctl` via `bun:ffi` on Linux, mirroring the
|
|
11
|
+
* libc-FFI pattern in `ttyid.ts` / `stderr-guard.ts`.
|
|
12
|
+
*
|
|
13
|
+
* macOS has no clean userspace equivalent for the shebang-run path, and on
|
|
14
|
+
* Windows / compiled binaries the kernel derives the name from the exec'd file,
|
|
15
|
+
* so those paths already report correctly; there we only set `process.title`.
|
|
16
|
+
*/
|
|
17
|
+
import { dlopen, FFIType, ptr } from "bun:ffi";
|
|
18
|
+
import * as os from "node:os";
|
|
19
|
+
|
|
20
|
+
/** `prctl(2)` option that sets the calling thread's `comm` name. */
|
|
21
|
+
const PR_SET_NAME = 15;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Set both the JS `process.title` and — on Linux — the kernel `comm` name.
|
|
25
|
+
*
|
|
26
|
+
* Never throws: `bun:ffi` unavailability or a failed syscall degrades silently
|
|
27
|
+
* to the `process.title`-only behavior, so it is safe to call at startup.
|
|
28
|
+
*/
|
|
29
|
+
export function setProcessName(name: string): void {
|
|
30
|
+
try {
|
|
31
|
+
process.title = name;
|
|
32
|
+
} catch {}
|
|
33
|
+
|
|
34
|
+
if (os.platform() !== "linux") return;
|
|
35
|
+
|
|
36
|
+
// glibc first, then the generic soname for musl-style layouts (see stderr-guard.ts).
|
|
37
|
+
for (const soname of ["libc.so.6", "libc.so"]) {
|
|
38
|
+
try {
|
|
39
|
+
const libc = dlopen(soname, {
|
|
40
|
+
prctl: {
|
|
41
|
+
args: [FFIType.i32, FFIType.ptr, FFIType.u64, FFIType.u64, FFIType.u64],
|
|
42
|
+
returns: FFIType.i32,
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
try {
|
|
46
|
+
// TASK_COMM_LEN is 16 (name + NUL); the kernel truncates the rest.
|
|
47
|
+
const buf = Buffer.from(`${name}\0`, "utf8");
|
|
48
|
+
libc.symbols.prctl(PR_SET_NAME, ptr(buf), 0n, 0n, 0n);
|
|
49
|
+
} finally {
|
|
50
|
+
libc.close();
|
|
51
|
+
}
|
|
52
|
+
return;
|
|
53
|
+
} catch {
|
|
54
|
+
// bun:ffi unavailable or this soname missing; try the next candidate.
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|