@link-assistant/hive-mind 1.32.1 → 1.32.3
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 +16 -0
- package/package.json +1 -1
- package/src/exit-handler.lib.mjs +115 -0
- package/src/solve.mjs +11 -11
- package/src/telegram-bot.mjs +6 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 1.32.3
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 04cf237: fix: properly drain active handles at exit to prevent indefinite process hang (Issue #1431)
|
|
8
|
+
|
|
9
|
+
Root causes identified and fixed: process.stdin (ReadStream) was never unreferenced; undici's global connection pool (Socket×2) was never closed; surviving command-stream child processes (ChildProcess) were never unreferenced; process.stdout/stderr (WriteStream×2) were not unreferenced on non-TTY descriptors.
|
|
10
|
+
|
|
11
|
+
Added drainHandles() in exit-handler.lib.mjs that unrefs/closes all four handle types before process.exit(). Added logActiveHandles() export with per-handle detail (fd, path, pid, remoteAddress) that always logs to the log file. Added no-leaked-streams ESLint rule to catch bare createReadStream/createWriteStream calls whose return value is discarded — the stream companion to the existing no-leaked-timers rule.
|
|
12
|
+
|
|
13
|
+
## 1.32.2
|
|
14
|
+
|
|
15
|
+
### Patch Changes
|
|
16
|
+
|
|
17
|
+
- 695954c: Remove duplication of locked options in /solve and /hive command responses by showing only user-provided options in the Options line, adding emoji prefix for visual distinction, and adding empty line separator between URL and options
|
|
18
|
+
|
|
3
19
|
## 1.32.1
|
|
4
20
|
|
|
5
21
|
### Patch Changes
|
package/package.json
CHANGED
package/src/exit-handler.lib.mjs
CHANGED
|
@@ -66,12 +66,127 @@ const showExitMessage = async (reason = 'Process exiting', code = 0) => {
|
|
|
66
66
|
await logFunction(`📁 Full log file: ${currentLogPath}`);
|
|
67
67
|
};
|
|
68
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Drain and unref active Node.js handles so the event loop can exit naturally.
|
|
71
|
+
*
|
|
72
|
+
* Issue #1431: After all work completes, several handle types keep the event loop
|
|
73
|
+
* alive and prevent the process from exiting on its own:
|
|
74
|
+
*
|
|
75
|
+
* - ReadStream — process.stdin is never unreferenced. Node keeps it open so the
|
|
76
|
+
* process can receive user input, but a CLI tool is done with input
|
|
77
|
+
* at this point. Calling .unref() signals that this handle should
|
|
78
|
+
* not prevent exit.
|
|
79
|
+
*
|
|
80
|
+
* - Socket (×2) — Node 18+ built-in fetch() uses undici internally. Each HTTP
|
|
81
|
+
* request leaves a keep-alive socket in undici's global connection
|
|
82
|
+
* pool. Calling getGlobalDispatcher().close() drains and destroys
|
|
83
|
+
* all pooled connections.
|
|
84
|
+
*
|
|
85
|
+
* - ChildProcess — command-stream spawns child processes. The handle stays alive
|
|
86
|
+
* until the OS reclaims the process entry. Calling .unref() on
|
|
87
|
+
* each surviving child lets Node exit without waiting for them.
|
|
88
|
+
*
|
|
89
|
+
* - WriteStream (×2) — process.stdout and process.stderr are always-open writable
|
|
90
|
+
* streams. On non-TTY file descriptors (e.g. pipes, redirects)
|
|
91
|
+
* they can keep the event loop alive. Calling .unref() is safe
|
|
92
|
+
* because we have already finished all output at this point.
|
|
93
|
+
*
|
|
94
|
+
* All of these are "unref" fixes — the handles are not forcibly destroyed, just
|
|
95
|
+
* marked as non-blocking so the event loop considers the process idle once all real
|
|
96
|
+
* async work is done. This is the idiomatic Node.js pattern for CLI tools.
|
|
97
|
+
*/
|
|
98
|
+
const drainHandles = async () => {
|
|
99
|
+
// 1. Unref process.stdin so a dangling ReadStream cannot block exit.
|
|
100
|
+
try {
|
|
101
|
+
if (process.stdin && !process.stdin.destroyed) {
|
|
102
|
+
process.stdin.unref();
|
|
103
|
+
}
|
|
104
|
+
} catch {
|
|
105
|
+
// Ignore — stdin may already be closed
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 2. Close undici's global dispatcher to drain keep-alive HTTP sockets (Socket handles).
|
|
109
|
+
// Node 18+ built-in fetch uses undici; each fetch() call may leave a socket in the
|
|
110
|
+
// pool. getGlobalDispatcher().close() is the documented way to drain them.
|
|
111
|
+
try {
|
|
112
|
+
const { getGlobalDispatcher } = await import('undici');
|
|
113
|
+
const dispatcher = getGlobalDispatcher();
|
|
114
|
+
if (dispatcher && typeof dispatcher.close === 'function') {
|
|
115
|
+
await Promise.race([
|
|
116
|
+
dispatcher.close(),
|
|
117
|
+
new Promise(resolve => setTimeout(resolve, 1000)), // hard 1s deadline
|
|
118
|
+
]);
|
|
119
|
+
}
|
|
120
|
+
} catch {
|
|
121
|
+
// undici may not be available in all Node versions — safe to ignore
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// 3. Unref surviving child processes from command-stream.
|
|
125
|
+
// These are typically already-exited but their OS handle entry lingers.
|
|
126
|
+
try {
|
|
127
|
+
for (const handle of process._getActiveHandles()) {
|
|
128
|
+
if (handle?.constructor?.name === 'ChildProcess' && typeof handle.unref === 'function') {
|
|
129
|
+
handle.unref();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
} catch {
|
|
133
|
+
// _getActiveHandles is a private V8 API — safe to ignore
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// 4. Unref stdout/stderr on non-TTY descriptors.
|
|
137
|
+
// On a TTY these are already non-blocking; on pipes/redirects they keep the loop alive.
|
|
138
|
+
try {
|
|
139
|
+
if (process.stdout && !process.stdout.isTTY && typeof process.stdout.unref === 'function') {
|
|
140
|
+
process.stdout.unref();
|
|
141
|
+
}
|
|
142
|
+
if (process.stderr && !process.stderr.isTTY && typeof process.stderr.unref === 'function') {
|
|
143
|
+
process.stderr.unref();
|
|
144
|
+
}
|
|
145
|
+
} catch {
|
|
146
|
+
// Ignore
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Log active handles and requests for diagnostics.
|
|
152
|
+
* Always logs if there are unexpected handles (not just in verbose mode),
|
|
153
|
+
* treating lingering handles as a warning-level signal.
|
|
154
|
+
*
|
|
155
|
+
* @param {Function|null} log - Optional logging function; falls back to console.warn
|
|
156
|
+
*/
|
|
157
|
+
export const logActiveHandles = async (log = null) => {
|
|
158
|
+
try {
|
|
159
|
+
const handles = process._getActiveHandles();
|
|
160
|
+
const requests = process._getActiveRequests();
|
|
161
|
+
if (handles.length === 0 && requests.length === 0) return;
|
|
162
|
+
|
|
163
|
+
const emit = log || (msg => console.warn(msg));
|
|
164
|
+
await emit(`\n🔍 Active Node.js handles at exit (${handles.length} handles, ${requests.length} requests):`);
|
|
165
|
+
for (const h of handles) {
|
|
166
|
+
const name = h.constructor?.name || typeof h;
|
|
167
|
+
// Extra detail for streams: show fd and path/remoteAddress if available
|
|
168
|
+
const detail = [h.fd != null ? `fd=${h.fd}` : null, h.path ? `path=${h.path}` : null, h.remoteAddress ? `remote=${h.remoteAddress}:${h.remotePort}` : null, h.pid != null ? `pid=${h.pid}` : null, h.spawnfile ? `file=${h.spawnfile}` : null].filter(Boolean).join(', ');
|
|
169
|
+
await emit(` Handle: ${name}${detail ? ` (${detail})` : ''}`);
|
|
170
|
+
}
|
|
171
|
+
for (const r of requests) {
|
|
172
|
+
await emit(` Request: ${r.constructor?.name || typeof r}`);
|
|
173
|
+
}
|
|
174
|
+
} catch {
|
|
175
|
+
// _getActiveHandles is a private V8 API — safe to ignore if unavailable
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
|
|
69
179
|
/**
|
|
70
180
|
* Safe exit function that ensures log path is shown
|
|
71
181
|
*/
|
|
72
182
|
export const safeExit = async (code = 0, reason = 'Process completed') => {
|
|
73
183
|
await showExitMessage(reason, code);
|
|
74
184
|
|
|
185
|
+
// Issue #1431: Drain/unref active handles so the event loop exits naturally.
|
|
186
|
+
// This resolves the root causes of dangling ReadStream (stdin), Socket (undici),
|
|
187
|
+
// ChildProcess (command-stream), and WriteStream (stdout/stderr) handles.
|
|
188
|
+
await drainHandles();
|
|
189
|
+
|
|
75
190
|
// Close Sentry to flush any pending events and allow the process to exit cleanly.
|
|
76
191
|
// Use Promise.race with a hard timeout to guarantee sentry.close() never hangs
|
|
77
192
|
// indefinitely — the 2000ms hint passed to sentry.close() is forwarded to internal
|
package/src/solve.mjs
CHANGED
|
@@ -76,7 +76,7 @@ const { startWatchMode } = watchLib;
|
|
|
76
76
|
const { startAutoRestartUntilMergeable } = await import('./solve.auto-merge.lib.mjs');
|
|
77
77
|
const { runAutoEnsureRequirements } = await import('./solve.auto-ensure.lib.mjs');
|
|
78
78
|
const exitHandler = await import('./exit-handler.lib.mjs');
|
|
79
|
-
const { initializeExitHandler, installGlobalExitHandlers, safeExit } = exitHandler;
|
|
79
|
+
const { initializeExitHandler, installGlobalExitHandlers, safeExit, logActiveHandles } = exitHandler;
|
|
80
80
|
const { createInterruptWrapper } = await import('./solve.interrupt.lib.mjs');
|
|
81
81
|
const getResourceSnapshot = memoryCheck.getResourceSnapshot;
|
|
82
82
|
|
|
@@ -1450,14 +1450,14 @@ try {
|
|
|
1450
1450
|
// closeSentry() uses a hard Promise.race deadline so it cannot block indefinitely.
|
|
1451
1451
|
await closeSentry();
|
|
1452
1452
|
|
|
1453
|
-
// Issue #
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1453
|
+
// Issue #1431: Log active handles before draining.
|
|
1454
|
+
// Always logged to file and console so future hangs are immediately visible in logs.
|
|
1455
|
+
// drainHandles() inside safeExit() will unref/close these before process.exit().
|
|
1456
|
+
await logActiveHandles(msg => log(msg));
|
|
1457
|
+
|
|
1458
|
+
// Issue #1431: safeExit() calls drainHandles() to unref/close known handle types
|
|
1459
|
+
// (process.stdin ReadStream, undici Socket pool, command-stream ChildProcess,
|
|
1460
|
+
// process.stdout/stderr WriteStreams) so the event loop exits naturally, then
|
|
1461
|
+
// calls process.exit(0) as a deterministic safety net.
|
|
1462
|
+
await safeExit(0, 'Process completed');
|
|
1463
1463
|
}
|
package/src/telegram-bot.mjs
CHANGED
|
@@ -991,8 +991,9 @@ async function handleSolveCommand(ctx) {
|
|
|
991
991
|
const normalizedUrl = validation.parsed.normalized;
|
|
992
992
|
|
|
993
993
|
const requester = buildUserMention({ user: ctx.from, parseMode: 'Markdown' });
|
|
994
|
-
|
|
995
|
-
|
|
994
|
+
// Issue #1228: Show only user-provided options (exclude locked overrides to avoid duplication)
|
|
995
|
+
const userOptionsText = userArgs.slice(1).join(' ') || 'none';
|
|
996
|
+
let infoBlock = `Requested by: ${requester}\nURL: ${escapeMarkdown(normalizedUrl)}\n\n🛠 Options: ${userOptionsText}`;
|
|
996
997
|
if (solveOverrides.length > 0) infoBlock += `\n🔒 Locked options: ${solveOverrides.join(' ')}`;
|
|
997
998
|
const solveQueue = getSolveQueue({ verbose: VERBOSE });
|
|
998
999
|
|
|
@@ -1160,8 +1161,9 @@ async function handleHiveCommand(ctx) {
|
|
|
1160
1161
|
|
|
1161
1162
|
const requester = buildUserMention({ user: ctx.from, parseMode: 'Markdown' });
|
|
1162
1163
|
const escapedUrl = escapeMarkdown(args[0]);
|
|
1163
|
-
|
|
1164
|
-
|
|
1164
|
+
// Issue #1228: Show only user-provided options (exclude locked overrides to avoid duplication)
|
|
1165
|
+
const userOptionsText = normalizedArgs.slice(1).join(' ') || 'none';
|
|
1166
|
+
let infoBlock = `Requested by: ${requester}\nURL: ${escapedUrl}\n\n🛠 Options: ${userOptionsText}`;
|
|
1165
1167
|
if (hiveOverrides.length > 0) {
|
|
1166
1168
|
infoBlock += `\n🔒 Locked options: ${hiveOverrides.join(' ')}`;
|
|
1167
1169
|
}
|