Blog · engineering

The crash that only happened after you’d used it

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:

How it should actually be fixed

The clean fixes all move upstream of the workaround:

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.