Repository navigation
Add atexit handler for Metal GPU cleanup on NSApplication terminate - #908
Conversation
When macOS terminates the app via NSApplication terminate: (Cmd+Q after long session, Dock quit, system shutdown), the exit() path bypasses our Tauri RunEvent handlers entirely. The C runtime's __cxa_finalize_ranges then destroys the Metal device while our LlamaModel still holds Metal residency sets, triggering the ggml_metal_rsets_free assertion (SIGABRT). Fix: register a libc atexit handler when the embedding model is first loaded. atexit handlers run BEFORE __cxa_finalize_ranges, so we can drop all LlamaState instances (freeing Metal residency sets) and then the LlamaBackend before C++ static destructors run. Architecture: - Each EmbeddingService keeps its own Arc<Mutex<Option<LlamaState>>> - On model load, the Arc is registered in a global LLAMA_STATES vec - The atexit handler iterates all registered states, dropping them - Graceful shutdown still works: it takes the state first, and the atexit handler's .take() returns None (harmless/idempotent) - Tests work: each test gets its own state (no global singleton) Closes #905 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
malibio
left a comment
There was a problem hiding this comment.
Code Review Summary
Verdict: APPROVE (posted as comment — GitHub blocks self-approval)
This is a well-targeted, narrowly scoped fix for a real crash (SIGABRT in ggml_metal_rsets_free) that occurs when NSApplication terminate: bypasses Tauri's RunEvent handlers and the exit() path goes straight to C++ static destructors. The solution — registering a libc::atexit handler that drops Metal-holding state before __cxa_finalize_ranges runs — is the correct approach for this class of problem.
The change is a clear net positive: it fixes a crash with minimal surface area, introduces no regressions to graceful shutdown, and the code is well-documented.
Findings
No Critical Issues
None. The architecture is sound and the implementation is correct.
Suggested Improvements
1. [Improvement] Unbounded growth of LLAMA_STATES registry
packages/nlp-engine/src/embedding.rs, lines 98-103:
register_state_for_cleanup pushes a new Arc clone every time initialize() is called. If an EmbeddingService is shutdown and re-initialized (e.g., during database hot-swap via app_services.rs:214), the old Arc stays in the vec (its inner Option is None, so the atexit handler harmlessly .take()s None). Over many hot-swaps this leaks Arc entries.
For a desktop app with infrequent hot-swaps this is negligible, but a one-line cleanup would make it bulletproof:
fn register_state_for_cleanup(state: &Arc<Mutex<Option<LlamaState>>>) {
let mut states = LLAMA_STATES.lock().unwrap_or_else(|p| p.into_inner());
// Prune already-cleared entries from previous shutdown cycles
states.retain(|s| s.lock().unwrap_or_else(|p| p.into_inner()).is_some());
states.push(Arc::clone(state));
}Not a blocker -- the current code is correct, just not frugal with stale entries.
2. [Improvement] Registration order: register_state_for_cleanup before register_atexit_handler
packages/nlp-engine/src/embedding.rs, lines 390-391:
Currently the state is registered first (line 390), then the atexit handler is registered (line 391). If -- in a truly pathological race -- exit() were called between these two lines, the state would be registered but no atexit handler would exist to clean it up. Reversing the order (register handler first, then state) closes this theoretical window:
register_atexit_handler();
register_state_for_cleanup(&self.state);Extremely unlikely in practice, but trivial to fix and makes the ordering obviously correct.
Nitpicks
Nit: packages/nlp-engine/src/embedding.rs, lines 80, 88 -- The eprintln! calls in the atexit handler are appropriate for a last-resort cleanup path (tracing may be torn down), so this is fine. Just noting for future readers: these are intentionally eprintln! not tracing::info!.
Nit: packages/nlp-engine/Cargo.toml, line 30 -- libc is added as an unconditional dependency but only used under #[cfg(feature = "embedding-service")]. Consider making it optional and gating it on the feature:
libc = { version = "0.2", optional = true }
[features]
embedding-service = ["llama-cpp-2", "libc"]This avoids pulling libc when the crate is compiled without embedding-service. Minor -- libc is lightweight and likely already in the dep tree.
Summary Table
| Category | Status |
|---|---|
| Architecture | Clean -- atexit is the correct mechanism for this problem |
| Correctness | Correct -- idempotent with graceful shutdown path |
| Security | N/A |
| Maintainability | Good -- excellent comments explaining the "why" |
| Testing | Verified via rust tests (788 passed) and manual Cmd+Q |
| Performance | No impact -- atexit registration is O(1), cleanup is O(n) at exit |
| Dependencies | libc is standard, well-maintained, MIT licensed |
- Prune already-taken states in register_state_for_cleanup() to prevent unbounded growth from repeated initialize() calls - Swap order: register atexit handler before state registration so the handler is always in place before any state is tracked Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Review Feedback Addressed✅ Addressed (2/3 recommendations)
⏭️ Skipped (1/3)
Re-Review DecisionDecision: NO RE-REVIEW NEEDED Rationale: Both changes are trivial — a one-line |
…908) * Add atexit handler for Metal GPU cleanup on NSApplication terminate When macOS terminates the app via NSApplication terminate: (Cmd+Q after long session, Dock quit, system shutdown), the exit() path bypasses our Tauri RunEvent handlers entirely. The C runtime's __cxa_finalize_ranges then destroys the Metal device while our LlamaModel still holds Metal residency sets, triggering the ggml_metal_rsets_free assertion (SIGABRT). Fix: register a libc atexit handler when the embedding model is first loaded. atexit handlers run BEFORE __cxa_finalize_ranges, so we can drop all LlamaState instances (freeing Metal residency sets) and then the LlamaBackend before C++ static destructors run. Architecture: - Each EmbeddingService keeps its own Arc<Mutex<Option<LlamaState>>> - On model load, the Arc is registered in a global LLAMA_STATES vec - The atexit handler iterates all registered states, dropping them - Graceful shutdown still works: it takes the state first, and the atexit handler's .take() returns None (harmless/idempotent) - Tests work: each test gets its own state (no global singleton) Closes #905 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Address review: prune stale entries and fix registration order - Prune already-taken states in register_state_for_cleanup() to prevent unbounded growth from repeated initialize() calls - Swap order: register atexit handler before state registration so the handler is always in place before any state is tracked Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Summary
NSApplication terminate:(Cmd+Q after long session, Dock quit, system shutdown), theexit()path bypasses Tauri'sRunEventhandlers entirely. C++ static destructors (__cxa_finalize_ranges) then destroy the Metal device while ourLlamaModelstill holds Metal residency sets, triggeringggml_metal_rsets_freeassertion failure (SIGABRT).libc::atexithandler when the embedding model is first loaded.atexithandlers run before__cxa_finalize_ranges, so we drop allLlamaStateinstances (freeing Metal residency sets) and theLlamaBackendbefore C++ static destructors run.EmbeddingServiceregisters itsArc<Mutex<Option<LlamaState>>>in a globalLLAMA_STATESvec. The atexit handler iterates and clears them all. Graceful shutdown still works (its.take()runs first; atexit's.take()returnsNone).Test plan
cargo check— compiles cleancargo clippy— no warningsbun run rust:test— 788 passed, 0 failed[atexit] LlamaState droppedx3 +[atexit] LlamaBackend droppedCloses #905
🤖 Generated with Claude Code