# MetalGuard
**English** | [繁體中文](README.zh-TW.md) | [日本語](README.ja.md)
> Stop MLX kernel panics from rebooting your Mac.
MetalGuard is a GPU safety layer for [MLX](https://github.com/ml-explore/mlx) on Apple Silicon. Running MLX models can trip a bug in Apple's `IOGPUFamily` GPU driver that **kernel-panics and reboots your entire Mac** instead of just failing the process. MetalGuard catches the conditions that trigger that bug — before they reach the kernel.
`pip install metal-guard` · zero dependencies · macOS / Apple Silicon · MIT
**Current version: v1.1.0** — see [CHANGELOG.md](CHANGELOG.md) for release history.
---
## Your Mac just kernel-panicked running MLX?
You ran an MLX model and your Mac suddenly restarted. **That is not a hardware fault and not your mistake** — it is a known bug in Apple's GPU driver. Here is the fix, start to finish:
**1. Open Terminal.** Press `⌘ + Space`, type `Terminal`, press `Enter`.
**2. Install metal-guard** — copy this line, paste it into Terminal, press `Enter`:
```bash
pip install metal-guard
```
It has zero dependencies, so it installs in seconds and cannot fail with a missing-package error.
**3. Run it** — type this, press `Enter`:
```bash
metal-guard
```
metal-guard reads the panic report your Mac just wrote, explains in plain language what happened, and offers to install a one-line protection so it does not happen again. Answer `y` when it asks.
**That's it.** The next time an MLX model would have panicked your Mac, metal-guard pauses it with an explanation instead.
> **No `pip`?** `pip` comes with Python. If `pip install metal-guard` says *command not found*, install Python from [python.org](https://www.python.org/downloads/macos/) first, then try again. You can also use [`pipx`](https://pipx.pypa.io/): `pipx install metal-guard`.
---
## What it does
- **Diagnoses the panic.** Reads the macOS panic report, identifies which Apple driver bug it was, and explains it in plain words — no kernel-log decoding required.
- **Prevents the next one.** A reversible shell guard routes risky MLX runs through a cooldown check; models known to panic are flagged before they load.
- **Contains the damage.** Runs MLX in an isolated subprocess, narrows the race windows that trigger the bug, and refuses to restart straight into a panic loop after a reboot.
- **Stays out of the way.** Zero dependencies, advisory by default, and every gate has an off switch.
MetalGuard is a **workaround, not a cure** — the root bug is inside Apple's driver and only Apple can fix it. What MetalGuard does is take your Mac from *"reboots without warning"* to *"pauses with an explanation."*
---
## The problem
Apple's Metal GPU driver on Apple Silicon has a bug: when GPU memory management fails, **the kernel panics the entire machine** instead of gracefully killing the process.
```
panic(cpu 4 caller 0xfffffe0032a550f8):
"completeMemory() prepare count underflow" @IOGPUMemory.cpp:492
```
Any workflow that loads and unloads MLX models in sequence can trip it — the driver's internal reference count underflows and the machine reboots. **This is not your code's fault.** It is a driver-level bug with no fix timeline. See [ml-explore/mlx-lm#883](https://github.com/ml-explore/mlx-lm/issues/883).
| Workload | Risk | Why |
|----------|------|-----|
| Single-model server (LM Studio) | Low | One model, no switching |
| Multi-model pipeline | **High** | Every load/unload transition can panic |
| Long-running server (`mlx_lm.server`) | **High** | KV cache grows unbounded, Metal buffers accumulate |
| Agent framework + tool calling | **High** | 50–100 short `generate()` calls per conversation |
| 24/7 daemon | **Critical** | Memory drift over days, no natural cleanup point |
Searched for one of these error strings? You're in the right place.
If your Mac is panicking / rebooting while running MLX and you searched for any of these, MetalGuard is built for you:
`IOGPUMemory.cpp:492 completeMemory() prepare count underflow` · `IOGPUMemory.cpp:550` kernel panic · `kIOGPUCommandBufferCallbackErrorOutOfMemory` · `mlx::core::gpu::check_error` → `std::terminate` → `abort` (SIGABRT) · `mlx::core::metal::GPUMemoryAllocator` / `fPendingMemorySet` · `IOGPUGroupMemory.cpp:219` pending memory set panic · `IOGPUGroupMemory::remove_memory_object` memory object not found · `mlx_lm.generate` crashes mid-inference · `mlx_lm.server` OOM kernel panic / Mac reboot · `com.apple.iokit.IOGPUFamily` in a panic report · `AGX_RELAX_CDM_CTXSTORE_TIMEOUT` · GPU watchdog killing MLX on MacBook · M1 / M2 / M3 / M4 (Max / Ultra / Pro) kernel panic · long-context (≥ 65k) prefill triggers reboot · back-to-back MLX model loads cause IOGPU underflow panic.
---
## Install
### Just want the `metal-guard` command
```bash
pip install metal-guard
```
This gives you the `metal-guard` and `mlx-safe-python` command-line tools. To keep it isolated from your other Python packages, use [`pipx`](https://pipx.pypa.io/) instead: `pipx install metal-guard`.
### Use it as a library in your own code
`pip install metal-guard` also installs the `metal_guard` Python package:
```python
import metal_guard as mg
verdict = mg.evaluate_panic_cooldown()
print(verdict.exit_code, verdict.reason)
```
### Develop / run the tests
```bash
git clone https://github.com/Harperbot/metal-guard.git
cd metal-guard
pip install -e ".[test]"
pytest -q
```
### Verify the install
```bash
$ metal-guard panic-gate
🟢 PROCEED no recent IOGPU panics
24h=0 72h=0
$ metal-guard status
metal-guard 1.1.0 🟢 OK
mode defensive — defensive mode (default)
panics 0 in last 72h
```
If `metal-guard` is not found after install, your `pip --user` bin directory is probably not on `PATH` — `python3 -m metal_guard_cli panic-gate` works as a fallback.
---
## Using metal-guard
### Command line
| Command | What it does |
|---|---|
| `metal-guard` | First-run wizard: scan for the recent panic, explain it, offer protection |
| `metal-guard diagnose` | Scan for recent kernel panics and explain them (no changes made) |
| `metal-guard guard install` | Install the reversible shell guard (see below) |
| `metal-guard guard uninstall` / `status` | Remove the shell guard / report its state |
| `metal-guard panic-gate` | Cooldown verdict — for use in launchd / CI scripts |
| `metal-guard status` | Full status snapshot |
| `metal-guard postmortem
` | Collect a diagnostic bundle after a panic |
### The shell guard
`metal-guard guard install` adds a single delimited block to your shell rc (`~/.zshrc` or `~/.bashrc`) that routes interactive-shell `python` / `python3` through `mlx-safe-python`. While a panic cooldown is active, MLX runs are paused automatically; otherwise they pass straight through. It is fully reversible — `metal-guard guard uninstall` removes the block cleanly — and covers interactive terminals only (Terminal, iTerm, VS Code), never launchd jobs or scripts. Disable without uninstalling: `export METALGUARD_SHELL_GUARD_DISABLED=1`.
### In Python
```python
from metal_guard import metal_guard, require_cadence_clear, CircuitBreaker
# Refuse back-to-back loads, and refuse new workers after a panic cluster
require_cadence_clear("mlx-community/gemma-4-26b-a4b-it-4bit")
CircuitBreaker().check()
# Register GPU-bound threads so cleanup waits for them
metal_guard.register_thread(thread)
metal_guard.wait_for_threads()
# Safe unload, OOM-protected inference, pre-load headroom check
metal_guard.safe_cleanup() # gc + flush GPU + cooldown
result = metal_guard.oom_protected(generate, model, tokenizer, prompt=p)
metal_guard.ensure_headroom(model_name="my-model-8bit")
```
Hardware-aware defaults in one line:
```python
config = MetalGuard.recommended_config()
metal_guard.start_watchdog(warn_pct=config["watchdog_warn_pct"],
critical_pct=config["watchdog_critical_pct"])
```
Every API is listed under [Reference](#reference) below.
---
## Embedding metal-guard in your app
If you ship an MLX-based app, server, or backend, embedding metal-guard means your users are protected from kernel panics **without installing or configuring anything themselves** — the most reliable way to reach users who would never find a safety tool on their own.
**1. Add it as a dependency.** metal-guard has zero third-party runtime dependencies, so adding it cannot pull in a conflicting package or break your build:
```toml
# pyproject.toml
dependencies = ["metal-guard>=1.1,<2"]
```
**2. Guard the panic-prone transitions.** Wrap model load, unload, and back-to-back inference with the API above — at minimum `require_cadence_clear()` before a load and `metal_guard.safe_cleanup()` after an unload.
**3. Fail safe, not loud.** metal-guard's gates raise typed exceptions (e.g. `SpawnRefused`, `MLXLockConflict`) instead of letting a panic reboot the machine — catch them and degrade gracefully, such as falling back to an API model.
**4. (Optional) Explain panics to your users.** After a reboot, call `metal_guard.parse_panic_reports()` and show users the same plain-language explanation the CLI gives — turning a mysterious crash into a handled event.
metal-guard follows semantic versioning; pin to a compatible range.
---
## 📋 Community Panic Registry — `KNOWN_PANIC_MODELS`
**A community-curated list of MLX models that kernel-panic Apple Silicon Macs in production — with hardware contexts, root-cause hypotheses, and verified workarounds.**
Apple's driver bug has no fix timeline. But **which models trigger it under which workloads is community-knowable** — it is just scattered across GitHub issues, LM Studio bug reports, Discord screenshots, and `panic-full-*.panic` files nobody publishes. MetalGuard gives that knowledge a structured home:
```python
from metal_guard import check_known_panic_model, warn_if_known_panic_model
advisory = check_known_panic_model("mlx-community/gemma-4-31b-it-8bit")
if advisory is not None:
print(advisory["recommendation"])
# → "metal-guard narrows the race window but does NOT eliminate panic on
# this model. Switch backend (Ollama / llama.cpp) or pivot to an MoE variant."
warn_if_known_panic_model(model_id) # fire-and-forget, per-process dedup
```
Each entry carries the `panic_signature` (the exact `IOGPUMemory.cpp:NNN` line to match), `reproductions` (hardware / RAM / time-to-panic / workload), `community` cross-references, an actionable `recommendation`, and `upstream` issue links.
**Hit a panic on a specific model with metal-guard fully engaged?** Your data point is valuable — open a [Known Panic Model report](https://github.com/Harperbot/metal-guard/issues/new?template=known-panic-report.yml). The registry is intentionally conservative: entries require a confirmed reproduction or a clear upstream issue, so working models are not falsely blacklisted.
### A snapshot of a moving target
The registry records models **known** to panic — it cannot record models nobody has reported yet, and every entry reflects what was observed up to a point in time. **A model's absence from the registry is not a safety certificate** — it just means no one has reported it here. If you want to run a local model that isn't listed, **test it yourself first** on your own hardware and workload; if it panics, [report it](https://github.com/Harperbot/metal-guard/issues/new?template=known-panic-report.yml) so the next person is warned.
The panic landscape also moves in the *other* direction. The root bug is upstream, and upstream is not standing still — recent MLX releases have already merged mitigations (e.g. [mlx#3348](https://github.com/ml-explore/mlx/pull/3348), a thread-local `CommandEncoder`), and a future MLX or macOS release could narrow or close the bug entirely. When that happens, a registry entry's "switch backend" advice becomes unnecessary — and metal-guard's `check_version_advisories()` and observer mode (`METALGUARD_MODE=observer`, which relaxes the defensive layers once a fixed MLX runtime is installed) are how you track it. Treat the registry and these advisories as a **point-in-time snapshot, not a permanent verdict** — re-check against the MLX and macOS versions you actually run.
---
## Reference
MetalGuard is organised as **defence layers (L1–L13)** — a defence-in-depth onion: L1–L8 narrow race windows during a run, L9 + L11 short-circuit just before a kernel-level abort, L10 + L12 handle recovery after a panic + reboot, and L13 surfaces it all as a JSON snapshot. See [CHANGELOG.md](CHANGELOG.md) for when each layer landed and the incident that motivated it.
### L1 — Thread tracking
Register any thread that touches Metal so cleanup waits for GPU work to finish before `mx.clear_cache()`.
| API | What it does |
|---|---|
| `metal_guard.register_thread(thread)` | Add a GPU-bound thread to the registry |
| `metal_guard.wait_for_threads(timeout=None) -> int` | Block until registered threads finish; returns count still alive |
### L2 — Safe cleanup
Ordered cleanup that avoids the "main thread freed while worker thread still generating" race — the original panic root cause.
| API | What it does |
|---|---|
| `metal_guard.flush_gpu()` | `mx.eval(sync) + mx.clear_cache()` — only safe after `wait_for_threads()` |
| `metal_guard.safe_cleanup()` | Full sequence: wait → `gc.collect` → flush → cooldown |
| `metal_guard.guarded_cleanup()` | Context manager that runs `safe_cleanup()` on exit |
| `kv_cache_clear_on_pressure(available_gb, growth_rate_gb_per_min)` | Ready-made `on_pressure` callback for the KV monitor |
### L3 — OOM recovery
Turn the raw C++ Metal OOM into a catchable Python exception with automatic cleanup and optional retry.
| API | What it does |
|---|---|
| `metal_guard.oom_protected(fn, *args, max_retries=1, **kwargs)` | Run with OOM catch → cleanup → retry |
| `metal_guard.oom_protected_context()` | Context-manager variant |
| `metal_guard.is_metal_oom(exc) -> bool` | Classify an arbitrary exception |
| `MetalOOMError` | Catchable exception, carries `MemoryStats` |
### L4 — Pre-load memory check
Refuse loads that will not fit, with model-size estimation from the HF model ID.
| API | What it does |
|---|---|
| `metal_guard.can_fit(model_size_gb, overhead_gb=2.0) -> bool` | Non-raising check |
| `metal_guard.require_fit(model_size_gb, model_name, overhead_gb=2.0)` | Clean up, then raise `MemoryError` if it still won't fit |
| `MetalGuard.estimate_model_size_from_name(name)` *(static)* | Parse param count + quantisation → GB estimate |
### L5 — Long-running process safety
For `mlx_lm.server`, agent frameworks, and 24/7 daemons.
| API | What it does |
|---|---|
| `metal_guard.memory_stats() -> MemoryStats` | Snapshot (active / peak / limit / available / pct) |
| `metal_guard.is_pressure_high(threshold_pct=67.0) -> bool` | Quick pressure check |
| `metal_guard.ensure_headroom(model_name, threshold_pct=67.0)` | Clean up if pressure high, no-op otherwise |
| `metal_guard.start_watchdog(interval_secs, warn_pct, critical_pct, on_critical)` | Drift watchdog with escalating response |
| `metal_guard.start_kv_cache_monitor(interval_secs, headroom_gb, growth_rate_warn, on_pressure)` | KV growth monitor, fires before OOM |
| `bench_scoped_load(model_id, ...)` | Context manager for sequential benchmark runs — guarantees unload before next load |
### L6 — Dual-mode switcher
Runtime-selectable defensive vs observer posture, so you can A/B upstream mitigations without changing code.
| API | What it does |
|---|---|
| `current_mode() -> str` | `"defensive"` (default) or `"observer"` |
| `is_defensive() / is_observer() -> bool` | Convenience predicates |
| `describe_mode() -> dict` | Mode name, description, env var |
### L7 — Subprocess isolation
Run MLX in a fresh `multiprocessing` child so a kernel-level abort cannot kill the parent.
| API | What it does |
|---|---|
| `MLXSubprocessRunner(model_id, ...)` | Persistent worker subprocess, respawns on crash |
| `call_model_isolated(model_id, prompt, ...)` | One-shot helper: spawn → generate → shut down |
| `shutdown_all_workers()` | Force-terminate any runners tracked at exit |
| `SubprocessCrashError / SubprocessTimeoutError` | Typed failures for callers |
| `SpawnRefused` | Raised at runner construction when the model's advisory tier is `panic` (override: `METALGUARD_LOCAL_PANIC_MODEL_BLOCK_DISABLED=1`) |
### L8 — Cross-process mutual exclusion
File lock under `MLX_LOCK_PATH` so bench / server / pipeline never initialise Metal on the same box simultaneously.
| API | What it does |
|---|---|
| `acquire_mlx_lock(label, force=False)` | Raise `MLXLockConflict` if held; `force=True` SIGTERMs the holder with timeout + cooldown |
| `release_mlx_lock() -> bool` | Release if this process holds it |
| `read_mlx_lock() -> dict \| None` | Non-blocking inspect; self-heals stale + zombie holders |
| `mlx_exclusive_lock(label)` | Context manager: acquire on enter, release on exit |
### L9 — Cadence, panic ingest, and circuit breaker
The last line of defence after the first eight layers — written in response to a kernel panic that lived *below* the SIGABRT layer: by the time Python saw anything, the machine had already rebooted. The only fix is to avoid the trigger.
| API | What it does |
|---|---|
| `CadenceGuard(path=None, *, min_interval_sec=180)` | Persisted per-model load-timestamp store |
| `require_cadence_clear(model_id, *, min_interval_sec=180)` | Atomic check + mark; raises `CadenceViolation` if a load happened too recently |
| `parse_panic_reports(directory=None, *, since_ts=None)` | Scan macOS panic reports (`/Library/Logs/DiagnosticReports`, `/var/db/PanicReporter`, `~/Library/...`; `.panic` + `.ips`) and classify |
| `ingest_panics_jsonl(*, report_dir=None, jsonl_path=None) -> int` | Dedupe-append to `~/.cache/metal-guard/panics.jsonl` |
| `CircuitBreaker(*, window_sec=3600, panic_threshold=2, cooldown_sec=3600)` | Refuse new workers after a panic cluster |
| `detect_panic_signature(text) -> (name, explanation)` | Classify a panic log: `prepare_count_underflow` / `pending_memory_set` / `remove_memory_object` / `ctxstore_timeout` / `metal_oom` |
### L10 — Panic cooldown gate
After a kernel panic + reboot, launchd auto-respawns plists ~14 minutes later — and the next MLX workload can immediately re-trigger the bug. L10 reads the macOS panic reports and applies a staircase cooldown (1 panic → 2h; ≥2 in 24h or ≥3 in 72h → lockout requiring an explicit ack).
| API | What it does |
|---|---|
| `evaluate_panic_cooldown() -> CooldownVerdict` | Stdlib-only; `verdict.exit_code` ∈ {0=proceed, 2=cooldown, ≥3=gate broken} |
| `scan_recent_panics(hours=72.0) -> list[PanicRecord]` | AND-pattern IOGPU-panic scan |
| `ack_panic_lockout()` | Clear an active lockout |
| `metal-guard panic-gate` / `metal-guard ack` | CLI wrappers for launchd scripts |
Env: `METALGUARD_PANIC_COOLDOWN_STAGE1_H` / `_LOCKOUT_24H_N` / `_LOCKOUT_72H_N` / `_LOCKOUT_MAX_H` / `_GATE_DISABLED=1`.
### L11 — Subprocess orphan monitor
Pre-panic signal: a `SUBPROC_PRE` breadcrumb without a matching `SUBPROC_POST` after 90 s strongly suggests Metal is stuck — kill the worker before the kernel does.
| API | What it does |
|---|---|
| `scan_orphan_subproc_pre(threshold_sec=90.0) -> list[OrphanPre]` | FIFO-paired PRE↔POST scan over the breadcrumb tail |
| `metal-guard orphan-scan [--threshold-sec N]` | CLI wrapper |
### L12 — Postmortem auto-collect
After a panic + reboot, collects the diagnostic bundle into one directory: panic files (capped), the breadcrumb-log tail, `panics.jsonl` history, `mx.metal` stats, and an `index.md` summary — and writes a sentinel cooldown so L10 defers further runs even if the panic reports rotate out.
| API | What it does |
|---|---|
| `run_postmortem(output_dir) -> dict` | Full orchestration; returns paths + panic count |
| `metal-guard postmortem ` | CLI wrapper (kill-switch: `METALGUARD_POSTMORTEM_DISABLED=1`) |
### L13 — Status snapshot
Versioned JSON snapshot for cross-process consumers (menu-bar apps, dashboards, ssh inspection) that should not import `metal_guard` directly.
| API | What it does |
|---|---|
| `get_status_snapshot(*, include_panics=True, breadcrumb_lines=20) -> dict` | Aggregate memory / KV monitor / panics / lock holder / mode / L10 verdict |
| `write_status_snapshot(out_path=None)` | Atomic write to `~/.cache/metal-guard/status.json` |
| `metal-guard status-write [--once \| --interval 30]` | CLI / daemon wrapper |
### Hardware awareness, advisories, audits
| API | What it does |
|---|---|
| `MetalGuard.detect_hardware() -> dict` *(static)* | Chip, GPU memory, recommended working set, tier, IOGPUFamily kext version |
| `MetalGuard.recommended_config() -> dict` *(classmethod)* | Safe defaults for every layer on the detected hardware |
| `check_version_advisories(packages=None) -> list[dict]` | Warn if installed `(mlx, mlx-lm, mlx-vlm, transformers)` versions trip a known advisory |
| `install_upstream_defensive_patches(force=False) -> dict[str, bool]` | Idempotent, version-gated monkey-patches for known upstream regressions |
| `audit_wired_limit() -> dict` | Flag dangerous `iogpu.wired_limit_mb` overrides (mlx-lm#1047) |
| `read_gpu_driver_version() -> str \| None` | IOGPUFamily kext version (mlx#3186) |
### R-series preventive helpers & forensics
| API | What it does |
|---|---|
| `lookup_dims(model_id)` / `estimate_prefill_peak_alloc_gb(...)` / `require_prefill_fit(...)` | GQA-aware prefill ceiling — refuse a prefill before a 30 GB single-alloc panic |
| `recommend_chunk_size(...)` / `describe_prefill_plan(...)` | Advisory prefill chunking |
| `KVGrowthTracker(...)` | Per-request cumulative KV guard — catches a runaway request the global monitor misses |
| `detect_process_mode() -> ProcessMode` | `"server" / "embedded" / "notebook" / "cli" / "subprocess_worker"` |
| `format_panic_for_apple_feedback(forensics, ...)` | Ready-to-paste Apple Feedback Assistant report |
| `metal_guard.breadcrumb(msg)` | Write an fsync'd line to the breadcrumb log |
### Path defaults
All L9 artifacts use `~/.cache/metal-guard/`: `cadence.json` (CadenceGuard), `panics.jsonl` (panic archive), `breaker.json` (CircuitBreaker), `status.json` (L13 snapshot). The breadcrumb log defaults to `logs/metal_breadcrumb.log`; override via `MetalGuard(breadcrumb_path=...)`.
### Architecture
```
┌─────────────────────────────────────────────────┐
│ Your Application Code │
│ Agent loop / Server / Pipeline / Daemon │
└──────────────────┬──────────────────────────────┘
┌──────────────────▼──────────────────────────────┐
│ MetalGuard │
│ L9 Cadence + CircuitBreaker refuse bad loads │
│ L8 Process lock cross-process │
│ L7 Subprocess isolation panic-isolated │
│ L5 Watchdogs drift alerts │
│ L3 OOM recovery catch + retry │
│ L2 Safe cleanup gc + flush │
│ L1 Thread registry wait before free │
│ L10–L13 cooldown / postmortem / status │
└──────────────────┬──────────────────────────────┘
┌──────────────────▼──────────────────────────────┐
│ MLX + Metal Driver │
│ ⚠️ Driver bug: panics instead of OOM │
└─────────────────────────────────────────────────┘
```
---
## When MetalGuard is not enough
If you engage every defence and still see repeat panics on the same model, the race window is wider than a userspace layer can narrow. Two escape hatches, by ROI:
1. **Switch backend.** [Ollama](https://ollama.com/) and [`llama.cpp`](https://github.com/ggml-org/llama.cpp) use Metal under the hood but run a persistent-worker architecture that sidesteps the subprocess teardown race entirely. You lose some raw throughput; you gain "doesn't panic the machine."
2. **Pivot to an MoE model.** Mixture-of-Experts variants (e.g. `mlx-community/gemma-4-26b-a4b-it-4bit`) have a smaller active-parameter footprint per forward pass and a narrower KV trajectory. Community reports converge on MoE as the most reliable same-ecosystem workaround.
MetalGuard is complementary to both — `CadenceGuard` still helps whenever you hot-swap models.
**One hard-learned SOP note.** Anything that imports `torch`, `mlx`, `mlx_lm`, `mlx_vlm`, `sentence_transformers`, `transformers`, `diffusers`, or `accelerate` initialises the Metal backend and can hit the same kernel bug — even an interactive version-check command. During an active cooldown, use `pip show ` or `python -c "import importlib.metadata as m; print(m.version(''))"`; **never** `python -c "import ; print(.__version__)"`.
---
## Limitations — this is a workaround, not a fix
The root bug lives inside Apple's IOGPUFamily kext ([mlx#3186](https://github.com/ml-explore/mlx/issues/3186)) and cannot be patched from Python. MetalGuard **lowers the trigger rate** (avoids the known trigger paths), **contains the blast radius** (subprocess isolation), and **prevents post-reboot cascades** (CircuitBreaker). It does not eliminate panics — especially the uncatchable completion-handler abort ([mlx#3390](https://github.com/ml-explore/mlx/issues/3390)) that fires before any Python signal handler. One production box went from ~1.4 panics/day to zero over a 24 h window after L9 landed — but that is risk-reduction, not elimination. Until Apple ships a fixed kext, this is the upper bound of what a Python-side layer can do.
## Related upstream issues
| Issue | Problem | Layer |
|---|---|---|
| [mlx#3186](https://github.com/ml-explore/mlx/issues/3186) | IOGPUFamily kernel panic (canonical) | L1/L2/L8/L9 |
| [mlx#3346](https://github.com/ml-explore/mlx/issues/3346) | `fPendingMemorySet` second signature | `detect_panic_signature` + L9 |
| [mlx#3348](https://github.com/ml-explore/mlx/pull/3348) | CommandEncoder thread-local (merged) | Advisory-gated observer mode |
| [mlx#3390](https://github.com/ml-explore/mlx/issues/3390) | Uncatchable completion-handler abort | L7 subprocess isolation |
| [mlx-lm#883](https://github.com/ml-explore/mlx-lm/issues/883) | Kernel panic from KV cache growth | L1 thread + L2 safe cleanup |
| [mlx-lm#854](https://github.com/ml-explore/mlx-lm/issues/854) | Server OOM crash | L3 `oom_protected` + L5 |
| [mlx-lm#1047](https://github.com/ml-explore/mlx-lm/issues/1047) | `wired_limit` correlation with panics | `audit_wired_limit` |
## License
MIT