Skip to content

Commit 5b18e10

Browse files
committed
Fix ONNX shutdown crash: let event loop drain instead of process.exit()
Calling process.exit(0) triggers C++ atexit handlers including the ONNX global thread pool destructor, which crashes on macOS with: libc++abi: terminating due to uncaught exception of type std::__1::system_error: mutex lock failed: Invalid argument Root cause: when process.exit() is called explicitly, the V8 GC has not run, so ONNX InferenceSession objects are still live when the global thread pool destructor fires, causing invalid mutex state. Fix: remove process.exit(0) from all shutdown handlers (serve, mcp workspace, mcp single-project). After cleanup, the event loop drains naturally — V8 GC disposes sessions before native teardown, so the thread pool destructor runs cleanly. A ref'd 5s force timer guards against hangs. Also revert disposeModels() and session_options workarounds (ineffective).
1 parent dba262d commit 5b18e10

3 files changed

Lines changed: 7 additions & 21 deletions

File tree

src/cli/index.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -222,9 +222,9 @@ program
222222
shuttingDown = true;
223223
process.stderr.write('[mcp] Shutting down...\n');
224224
const forceTimer = setTimeout(() => { process.stderr.write('[mcp] Shutdown timeout, force exit\n'); process.exit(1); }, 5000);
225-
forceTimer.unref();
226225
try { await manager.shutdown(); } catch { /* ignore */ }
227-
process.exit(0);
226+
clearTimeout(forceTimer);
227+
// Let event loop drain naturally — avoids ONNX global thread pool destructor crash on macOS
228228
}
229229

230230
process.on('SIGINT', () => { void shutdown(); });
@@ -304,7 +304,6 @@ program
304304
process.stderr.write('[mcp] Shutdown timeout, force exit\n');
305305
process.exit(1);
306306
}, 5000);
307-
forceTimer.unref();
308307
try {
309308
if (watcher) await watcher.close();
310309
if (indexer) await indexer.drain();
@@ -314,7 +313,8 @@ program
314313
saveFileIndexGraph(fileIndexGraph, project.graphMemory, embeddingFingerprint(ge.files));
315314
saveTaskGraph(taskGraph, project.graphMemory, embeddingFingerprint(ge.tasks));
316315
} catch { /* ignore */ }
317-
process.exit(0);
316+
clearTimeout(forceTimer);
317+
// Let event loop drain naturally — avoids ONNX global thread pool destructor crash on macOS
318318
}
319319

320320
process.on('SIGINT', () => { void shutdown(); });
@@ -479,7 +479,6 @@ program
479479
process.stderr.write('[serve] Shutdown timeout, force exit\n');
480480
process.exit(1);
481481
}, 5000);
482-
forceTimer.unref();
483482
try {
484483
httpServer.close();
485484
// Destroy all open connections (including WebSocket) so the server can close
@@ -490,7 +489,8 @@ program
490489
await configWatcher.close();
491490
await manager.shutdown();
492491
} catch { /* ignore */ }
493-
process.exit(0);
492+
clearTimeout(forceTimer);
493+
// Let event loop drain naturally — avoids ONNX global thread pool destructor crash on macOS
494494
}
495495

496496
process.on('SIGINT', () => { void shutdown(); });

src/lib/embedder.ts

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,6 @@ export async function loadModel(
4040

4141
const pipeOpts: Record<string, unknown> = {};
4242
if (config.dtype) pipeOpts.dtype = config.dtype;
43-
// Use per-session threads (intraOpNumThreads: 1) instead of the global ONNX thread pool.
44-
// The global thread pool destructor crashes on macOS (libc++ mutex Invalid argument) during
45-
// process exit. With intraOpNumThreads: 1 the global pool is never created.
46-
pipeOpts.session_options = { intraOpNumThreads: 1, interOpNumThreads: 1 };
4743

4844
const pipe = await pipeline('feature-extraction', config.model, pipeOpts);
4945
_pipeCache.set(cacheKey, pipe);
@@ -100,15 +96,6 @@ export function resetEmbedder(): void {
10096
_pipeCache.clear();
10197
}
10298

103-
/** Dispose all loaded ONNX pipelines. Call before process.exit() to avoid macOS mutex crash. */
104-
export async function disposeModels(): Promise<void> {
105-
for (const pipe of _pipeCache.values()) {
106-
try { await pipe.dispose(); } catch { /* ignore */ }
107-
}
108-
_models.clear();
109-
_pipeCache.clear();
110-
}
111-
11299
// Vectors are L2-normalized → dot product = cosine similarity
113100
export function cosineSimilarity(a: number[], b: number[]): number {
114101
if (a.length !== b.length) return 0;

src/lib/project-manager.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { EventEmitter } from 'events';
2-
import { loadModel, embed, embedQuery, disposeModels } from '@/lib/embedder';
2+
import { loadModel, embed, embedQuery } from '@/lib/embedder';
33
import { loadGraph, saveGraph, type DocGraph, DocGraphManager } from '@/graphs/docs';
44
import { loadCodeGraph, saveCodeGraph, type CodeGraph, CodeGraphManager } from '@/graphs/code';
55
import { loadKnowledgeGraph, saveKnowledgeGraph, KnowledgeGraphManager } from '@/graphs/knowledge';
@@ -463,7 +463,6 @@ export class ProjectManager extends EventEmitter {
463463
}
464464
this.projects.clear();
465465
this.workspaces.clear();
466-
await disposeModels();
467466
process.stderr.write('[project-manager] Shutdown complete\n');
468467
}
469468

0 commit comments

Comments
 (0)