Skip to content

[Bug] check_error() throws std::runtime_error from completion handler → uncaught SIGABRT on iPhone 15 Pro / iOS 26.4 (Qwen3 1.7B 4bit) #407

Description

@kitadesign

Summary

mlx::core::gpu::check_error() throws std::runtime_error from a MTL::CommandBuffer::addCompletedHandler callback when the command buffer status is MTL::CommandBufferStatusError. The exception is uncaught at the Swift boundary, which immediately calls std::terminate()abort() — the entire app dies (SIGABRT, bug type 309) with no chance for Swift code to recover.

I am hitting this reliably on iPhone 15 Pro (A17 Pro) / iOS 26.4.2 after roughly 2 minutes of continuous LLM inference (Qwen3 1.7B 4bit). The crash always fires from com.Metal.CompletionQueueDispatch, often while the device is screen-locked.

This appears to be distinct from the existing reports:

Environment

Item Value
Device iPhone 15 Pro (iPhone16,1, A17 Pro)
OS iPhone OS 26.4.2 (build 23E261)
mlx-swift 0.29.1
mlx-swift-examples 2.29.1 (using MLXLLM)
Model Qwen3 1.7B 4-bit (via Hugging Face Hub)
Inference setup MLXLMCommon streaming generation, default Stream / default Device
App profile Background-capable (UIBackgroundModes = [location, audio]), isIdleTimerDisabled = true, screen often locked while inference runs

Reproduction

Sustained streaming inference of Qwen3 1.7B 4bit (one ~150-token rewrite every 10–30s, kicked off automatically on a 60s GPS-driven scan loop). The crash typically fires within ~2 minutes of repeated generate(...) calls. Crash also reproduces with the screen locked (this is a walking-radio app).

Approximate timing from the crash log:

procLaunch:   2026-05-15 12:30:55
procExit:     2026-05-15 12:32:56   ← ~2 min later
isLocked:     1
wasUnlockedSinceBoot: 1

I do not currently have an isolated minimal repro — happy to put one together if it helps, but the stack trace is unambiguous and the source path is short, so I wanted to get this on your radar first.

Expected behavior

A MTL::CommandBufferStatusError from a Metal completion handler should not be able to terminate the entire app. The mlx-swift Swift layer should catch the C++ exception (or mlx::core::gpu::check_error should not throw across a callback boundary that the host language cannot catch) and surface it as either a throws Swift API or an explicit error state on the array / Stream.

Actual behavior

abort() from com.Metal.CompletionQueueDispatch. The triggered thread (top frames):

# 0 __pthread_kill +8
# 1 pthread_kill +268
# 2 abort +148
# 3 __abort_message +132
# 4 demangling_terminate_handler() +272
# 5 _objc_terminate() +172
# 6 std::__terminate(void (*)()) +16
# 7 __cxxabiv1::failed_throw(__cxxabiv1::__cxa_exception*) +88
# 8 __cxa_throw +92
# 9 mlx::core::gpu::check_error(MTL::CommandBuffer*) +476
#10 mlx::core::gpu::eval(mlx::core::array&)::$_1::operator()(MTL::CommandBuffer*) const +28
#11–#16 std::function plumbing
#17 invocation function for block in MTL::CommandBuffer::addCompletedHandler(...) +52
#18 MTLDispatchListApply +52
#19 -[_MTLCommandBuffer didCompleteWithStartTime:endTime:error:] +608
#20 -[IOGPUMetalCommandBuffer didCompleteWithStartTime:endTime:error:] +220
#21 -[_MTLCommandQueue commandBufferDidComplete:startTime:completionTime:error:] +108
...

Exception Type: EXC_CRASH (SIGABRT), Termination Reason: SIGNAL 6 Abort trap: 6, asi: "abort() called". No Application Specific Information beyond that — the C++ std::runtime_error::what() ("[METAL] Command buffer execution failed: ...") is not surfaced in the crash report, so I unfortunately do not have the underlying Metal error string.

Source pointer

The throwing function is here:

// mlx/backend/metal/eval.cpp
inline void check_error(MTL::CommandBuffer* cbuf) {
  if (cbuf->status() == MTL::CommandBufferStatusError) {
    std::ostringstream msg;
    msg << "[METAL] Command buffer execution failed: "
        << cbuf->error()->localizedDescription()->utf8String();
    throw std::runtime_error(msg.str());
  }
}

…and the call site is the completed-handler in mlx::core::gpu::eval(mlx::core::array&). Throwing from inside an addCompletedHandler block on iOS — where Swift sits above and cannot catch C++ exceptions — guarantees std::terminate.

Suggestions

A few possibilities, in increasing order of effort:

  1. Catch + log + flag: Wrap the body of the addCompletedHandler in a try { check_error(cbuf); } catch (const std::exception& e) { /* store on Stream/Device error state, log, … */ }. The next eval() / outputs() call on the consuming side can then re-throw — which Swift can catch.
  2. Surface via mlx-swift: Expose the captured error as a Swift-side throws API on the synchronous evaluation entry points so apps can recover (skip token, reset stream, fall back) instead of dying.
  3. Document the iOS hazard: Note in the README that running mlx-swift inference under sustained load on iOS can hit Metal-side errors that today are unrecoverable, and recommend a watchdog / restart strategy until the catch path lands.

Happy to test patches on this device — just let me know what you'd like to see.

Underlying Metal error

I did not capture the localized description string, but given the trigger pattern (sustained ~2 min of Qwen3 inference, often with the screen locked, on A17 Pro), I suspect resource pressure on Metal — possibly the same family as #237 even if the surfaced symptom is different. If there is a way to pull the error string (e.g. signposts, a debug build that logs before throwing), I can re-run and capture it.


For context, the host app is a SwiftUI walking-radio app that rewrites Wikipedia extracts via an on-device LLM. We have since fully removed the mlx-swift dependency in favor of Apple Foundation Models specifically because of this crash. If a fix lands, I would happily revisit MLX as the secondary LLM.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions