Repository: ggml-org/llama.cpp
Pull request: metal: handle command buffer failures gracefully in synchronize

## Summary

Replace `GGML_ABORT("fatal error")` in `ggml_metal_synchronize()` with a sticky error flag and early return. This prevents the host process from crashing when a Metal command buffer fails during synchronize.

## Problem

On iOS, backgrounding an app causes the OS to revoke GPU access. Any pending Metal command buffers fail with `MTLCommandBufferStatusError` / `kIOGPUCommandBufferCallbackErrorBackgroundExecutionNotPermitted`. The current code calls `GGML_ABORT("fatal error")` here, which kills the process.

Same thing can happen with macOS eGPU disconnect, Metal OOM, or any other command buffer failure between graph dispatch and synchronization.

## Fix

Added a `bool has_error` field to the Metal context struct. When `ggml_metal_synchronize()` detects a failed command buffer, it:

1. Logs the error details (status code, localized description)
2. Sets `ctx->has_error = true`
3. Returns instead of aborting

On subsequent calls, `ggml_metal_graph_compute()` checks `has_error` at entry and returns `GGML_STATUS_FAILED` immediately. Recovery requires recreating the backend.

This matches how `ggml_metal_graph_compute()` already handles command buffer failures in its capture/debug path, where it returns `GGML_STATUS_FAILED` rather than aborting.

Failed extra command buffers (`cmd_bufs_ext`) are properly released on the error path to avoid Metal object leaks.

### Timing note

Because the backend `synchronize` interface returns `void`, the first failure detected during synchronize can't be reported to the caller of the current compute iteration. The sticky flag ensures all subsequent compute calls fail immediately. Propagating errors from synchronize would require an interface change, which could be a follow-up.

## Testing

Tested on iPhone 12 (A14 GPU, iOS 26.2):
- Started GPU-accelerated Whisper transcription via Metal
- Backgrounded the app during active Metal compute
- iOS revoked GPU access
- Before fix: `GGML_ABORT` killed the process
- After fix: error logged, `whisper_full` returned `-6`, app continued running

Console output from device:
```
ggml_metal_synchronize: error: command buffer 0 failed with status 5
error: Insufficient Permission (to submit GPU work from background)
  (00000006:kIOGPUCommandBufferCallbackErrorBackgroundExecutionNotPermitted)
ggml_metal_graph_compute: backend is in error state from a previous
  command buffer failure - recreate the backend to recover
```

Also built and tested on macOS (Apple M3 Pro). All existing tests pass, Metal device detected and functional.

## Changes

- `ggml/src/ggml-metal/ggml-metal-context.m`: 22 insertions, 2 deletions (single file)

Relates to #16998, ggml-org/whisper.cpp#3531

Changed files:
filename: ggml/src/ggml-metal/ggml-metal-context.m
patch: @@ -75,6 +75,10 @@
     // abort ggml_metal_graph_compute if callback returns true
     ggml_abort_callback abort_callback;
     void *              abort_callback_data;
+
+    // error state - set when a command buffer fails during synchronize
+    // once set, graph_compute will return GGML_STATUS_FAILED until the backend is recreated
+    bool has_error;
 };
 
 ggml_metal_t ggml_metal_init(ggml_metal_device_t dev) {
@@ -158,6 +162,8 @@ ggml_metal_t ggml_metal_init(ggml_metal_device_t dev) {
     res->capture_started = false;
     res->capture_scope = nil;
 
+    res->has_error = false;
+
     res->gf = nil;
     res->encode_async = nil;
     for (int i = 0; i < GGML_METAL_MAX_COMMAND_BUFFERS; ++i) {
@@ -246,7 +252,8 @@ void ggml_metal_synchronize(ggml_metal_t ctx) {
                 if (status == MTLCommandBufferStatusError) {
                     GGML_LOG_ERROR("error: %s\n", [[cmd_buf error].localizedDescription UTF8String]);
                 }
-                GGML_ABORT("fatal error");
+                ctx->has_error = true;
+                return;
             }
         }
     }
@@ -262,7 +269,15 @@ void ggml_metal_synchronize(ggml_metal_t ctx) {
                 if (status == MTLCommandBufferStatusError) {
                     GGML_LOG_ERROR("error: %s\n", [[cmd_buf error].localizedDescription UTF8String]);
                 }
-                GGML_ABORT("fatal error");
+
+                // release this and all remaining command buffers before returning
+                for (size_t j = i; j < ctx->cmd_bufs_ext.count; ++j) {
+                    [ctx->cmd_bufs_ext[j] release];
+                }
+                [ctx->cmd_bufs_ext removeAllObjects];
+
+                ctx->has_error = true;
+                return;
             }
 
             [cmd_buf release];
@@ -414,6 +429,11 @@ bool ggml_metal_cpy_tensor_async(ggml_metal_t ctx_src, ggml_metal_t ctx_dst, con
 }
 
 enum ggml_status ggml_metal_graph_compute(ggml_metal_t ctx, struct ggml_cgraph * gf) {
+    if (ctx->has_error) {
+        GGML_LOG_ERROR("%s: backend is in error state from a previous command buffer failure - recreate the backend to recover\n", __func__);
+        return GGML_STATUS_FAILED;
+    }
+
     // number of nodes encoded by the main thread (empirically determined)
     const int n_main = MAX(64, 0.1*gf->n_nodes);