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:
- 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.
- 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.
- 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.
Summary
mlx::core::gpu::check_error()throwsstd::runtime_errorfrom aMTL::CommandBuffer::addCompletedHandlercallback when the command buffer status isMTL::CommandBufferStatusError. The exception is uncaught at the Swift boundary, which immediately callsstd::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:
MLX 0.25.2 causing the UI rendering to be stuck and occasionally crash) — the symptom there isCommand queue creation failedfromStream/Deviceover-creation. My stack trace never enters Stream creation; it dies inside the completion handler itself.Address size faultcrashes on iOS devices #121 (Random Address size fault crashes on iOS devices, CLOSED) — that one was scoped to pre-A14 GPUs (iPhone SE2 / iPad 8, etc.). I am on A17 Pro, the most current Apple GPU.Environment
iPhone16,1, A17 Pro)23E261)0.29.12.29.1(usingMLXLLM)MLXLMCommonstreaming generation, defaultStream/ defaultDeviceUIBackgroundModes = [location, audio]),isIdleTimerDisabled = true, screen often locked while inference runsReproduction
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:
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::CommandBufferStatusErrorfrom a Metal completion handler should not be able to terminate the entire app. The mlx-swift Swift layer should catch the C++ exception (ormlx::core::gpu::check_errorshould not throw across a callback boundary that the host language cannot catch) and surface it as either athrowsSwift API or an explicit error state on thearray/Stream.Actual behavior
abort()fromcom.Metal.CompletionQueueDispatch. The triggered thread (top frames):Exception Type: EXC_CRASH (SIGABRT),Termination Reason: SIGNAL 6 Abort trap: 6,asi: "abort() called". NoApplication Specific Informationbeyond 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:
…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 — guaranteesstd::terminate.Suggestions
A few possibilities, in increasing order of effort:
try { check_error(cbuf); } catch (const std::exception& e) { /* store on Stream/Device error state, log, … */ }. The nexteval()/outputs()call on the consuming side can then re-throw — which Swift can catch.throwsAPI on the synchronous evaluation entry points so apps can recover (skip token, reset stream, fall back) instead of dying.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.