aaif-goose / aaif-goose/goose

Dictation Metal-probe panics on macOS 14 ("class MTLResidencySetDescriptor could not be found")

Ouverte
#11,688 1 commentaire 0 réactions 1 personne assignée Réclamée par @jbg Voir sur GitHub
Langage dominant
Rust
Étoiles
54.2k
Forks
6.2k
Merge moyen
3 j 2 h
PR mergées (30 j)
262

Description

**Describe the bug**

On macOS 14.x, any code path that probes for a Metal device for local dictation
(`candle_core::Device::new_metal(0)`, used by `crates/goose/src/dictation/whisper.rs` to pick a
Whisper model size and to select the transcription device) panics instead of returning an `Err`.

The panic happens in `objc2-metal` (0.3.2, pulled in transitively by `candle-core` 0.11.0), which
does an unconditional Objective-C class lookup for `MTLResidencySetDescriptor` — a class that does
not exist in the Metal framework shipped with macOS 14; it was introduced in macOS 15. Because the
lookup panics rather than returning `None`/`Err`, `Device::new_metal(0)` never gets the chance to
report "Metal unavailable" the way its API contract implies — the whole call unwinds instead.

In the desktop app this fires inside a `tokio-rt-worker` thread while handling a dictation-config
request. The panic takes down that in-flight async task without crashing the whole process, which
leaves the frontend hung waiting on a response that will never arrive — observed side effects
included Session History stuck on loading skeletons and the model-switcher no longer responding
until the app was restarted.

This is a different failure mode from #7674 (closed): that one was a *build-time* dyld symbol
resolution error when linking against the macOS 14 SDK, fixed by pinning the build SDK. This one is
a *runtime* Objective-C class lookup on a class that simply doesn't exist pre-macOS 15 — the binary
links and starts fine, so the SDK-pin workaround has no effect on it.

---

**To Reproduce**
Steps to reproduce the behavior:
1. Run the desktop app on macOS 14.x (tested on 14.8.3).
2. Open Settings — this lands on the "Chat" tab, which mounts the Dictation settings component and
fetches the dictation config for all providers, including `Local`.
3. Fetching the `Local` provider's default model calls `whisper::recommend_model()`, which calls
`Device::new_metal(0)` to decide whether a GPU is available.
4. The backend logs a panic and the request that triggered it never completes.

---

**Expected behavior**
`Device::new_metal(0)` (or the caller) should treat "Metal API unavailable on this OS version" as a
graceful `Err`/`None`, matching its documented contract, rather than panicking and abandoning the
in-flight async task.

---

**Screenshots**
N/A (backend-only panic, visible in log output):
```
thread 'tokio-rt-worker' panicked at objc2-metal-0.3.2/src/generated/MTLResidencySet.rs:10:1:
class MTLResidencySetDescriptor could not be found
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
```

---

**Suggested fix**

The most durable fix is upstream in `objc2-metal`/`candle-core` (don't probe a class that doesn't
exist pre-macOS 15, or return `Err` instead of panicking). Short of that, `goose` itself can defend
both `Device::new_metal(0)` call sites in `crates/goose/src/dictation/whisper.rs` with
`std::panic::catch_unwind`, treating a caught panic the same as the `Err` the call should have
produced:

```rust
/// `Device::new_metal` is documented to return `Err` when Metal is unavailable, but on some
/// OS/driver combinations (observed: macOS 14, where a Metal API introduced in macOS 15 is
/// looked up unconditionally) the underlying objc2 binding panics instead of erroring. Every
/// caller here only wants a best-effort "is Metal usable" signal, so treat a panic the same as
/// the `Err` it should have been rather than taking the whole request down with it.
fn try_new_metal_device() -> Option {
match std::panic::catch_unwind(|| Device::new_metal(0)) {
Ok(Ok(device)) => Some(device),
Ok(Err(_)) | Err(_) => None,
}
}
```

`recommend_model()`'s capability check becomes
`Device::new_cuda(0).is_ok() || try_new_metal_device().is_some()`, and the device-selection branch
in `WhisperTranscriber::new_with_tokenizer()` becomes
`} else if let Some(device) = try_new_metal_device() { ... }`, both replacing a bare
`Device::new_metal(0)` call. This is a minimal, local mitigation — it doesn't fix the underlying
class-lookup gap, just stops it from taking down an in-flight request. Happy to open a PR with this
change if useful.

---

**Please provide the following information**
- **OS & Arch:** macOS 14.8.3 (Sonoma), arm64
- **Interface:** UI (desktop)
- **Version:** 1.48.0 (also present at least back through recent releases carrying `candle-core` 0.11.0 / `objc2-metal` 0.3.2)
- **Extensions enabled:** N/A — reproduces with no extensions, purely from opening Settings
- **Provider & Model:** N/A — this is the local dictation/Whisper capability probe, independent of the chat provider/model in use

---

**Additional context**
Root cause is upstream in `objc2-metal` 0.3.2 (via `candle-core` 0.11.0): the
`MTLResidencySetDescriptor` lookup is unconditional rather than falling back when the class is
absent. A version bump once upstream fixes the lookup, or an OS-version guard before probing Metal
at all, would be the more durable long-term fix; see "Suggested fix" above for the local mitigation
applied downstream in the meantime.

Guide de contribution

Ouvrir le guide de contribution

Évaluation

Cette issue n'a pas encore été évaluée.

Recevez les nouvelles issues par e-mail

Un résumé court des issues GitHub adaptées aux débutants.