Blog · engineering
The crash that only happened after you’d used it
20 July 2026 · ggml, Metal, and _exit(0)
The bug report was maddening because it was so consistent. The Mac app crashed on
⌘Q — “Quenderin quit unexpectedly” — but only if you had sent at
least one message first. Quit before chatting: clean. Chat once, then quit: SIGABRT.
Eleven crash reports came in on a single day, and when we lined them up the llama.cpp stack offsets
were byte-identical across all of them. A byte-identical crash is not a race; it’s
a deterministic path someone walks every single time.
What the crash report actually said
The abort came from ggml_abort, and it fired inside __cxa_finalize —
the C++ runtime phase that runs static destructors at process exit. That’s the tell.
Nothing in our code was running; the process was on its way out, and a global C++ teardown tripped an
assertion deep in the inference library.
Here is the mechanism. After any inference, llama.cpp’s Metal backend keeps a residency-set
worker alive for the lifetime of the process — that’s a performance feature, it avoids
re-warming the GPU on every decode. But it means there is live GPU state when the process exits. AppKit
calls exit() on ⌘Q, exit() runs atexit handlers and
C++ static destructors, and one of those destructors tears down ggml’s Metal context in an order it
doesn’t actually support — so it calls ggml_abort instead of failing quietly. The
first message is what arms it: no inference, no Metal worker, no crash.
The fix is one line, and it’s a blunt one
You cannot fix someone else’s static destructor from the outside. What you can do is
never reach it. _exit(2) terminates the process immediately and, crucially, does not
run atexit handlers or C++ finalizers — it skips the entire teardown that was
aborting. So the macOS app delegate flushes anything that needs flushing, then exits hard:
final class QuenderinMacAppDelegate: NSObject, NSApplicationDelegate {
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
UserDefaults.standard.synchronize() // persist the last of the settings
_exit(0) // skip atexit/finalizers → no ggml_abort
}
}The reason this is safe here, and not just a way to hide a crash, is that Quenderin has nothing
to lose at exit. Every piece of user state is persisted continuously, not on shutdown:
each conversation turn is written as it happens, every agent action is appended to an on-disk ledger, and
settings go through UserDefaults. By the time you press ⌘Q there is no
unsaved work for a graceful teardown to save. So skipping the teardown loses nothing — it just
removes the one thing in it that was broken.
One footgun worth naming: it has to be _exit(0), not exit(0). exit()
is the function that runs the finalizers — using it would reproduce the exact crash. The
underscore is load-bearing.
The limitations, honestly
This is a workaround, and it’s worth being clear about what it does and doesn’t buy:
- It papers over an upstream assert. The real defect is that a library aborts during global teardown instead of tolerating it. We’re routing around that, not fixing it.
- It’s a process-wide sledgehammer.
_exitskips all cleanup — flushing buffers, closing files, releasing OS handles. That’s fine for an app the OS is about to reclaim entirely, but you could not do this inside a library or a plugin that shares a process with code that does need graceful shutdown. It only works because we own the whole process. - It only covers the exit path. The same “ggml aborts instead of returning an
error” shape shows up elsewhere — feed
llama_decodea batch larger thann_batchand it willggml_abortmid-run too. Each one needs its own guard (there, clamping the prompt and prefilling in chunks);_exitdoes nothing for them.
How it should actually be fixed
The clean fixes all move upstream of the workaround:
- An explicit, ordered shutdown. The right shape is a
engine.shutdown()that quiesces the Metal residency worker and frees the ggml context deterministically, before__cxa_finalizeever runs — so exit has nothing left to tear down. That turns_exitfrom a necessity into an optimization. - The upstream assert should tolerate teardown. A backend that keeps global state for the process lifetime should either register no static destructor that touches the GPU, or make that destructor a no-op once the runtime is finalizing. That’s the actual bug, and it belongs in llama.cpp.
- Treat “the engine aborts” as a contract. Once you accept that this library ends the process rather than throwing, you design the whole lifecycle around it: guard every entry point that can trip an assert, and never assume a clean unwind. That framing is what turned a recurring crash into a checklist.
The lesson generalizes past this one library: an inference engine that calls abort()
instead of returning an error is a different kind of dependency. You stop reasoning about return
values and start reasoning about process lifetime — and sometimes the most correct thing you can do
is refuse to run the code that would crash.