Skip to main content

mlx_c_rust/
error.rs

1//! Captured MLX-C error translation.
2//!
3//! MLX C reports an error immediately before returning nonzero on the same
4//! calling thread, so the capture machinery pairs those two observations
5//! thread-locally. This module only translates what MLX-C reported; typed
6//! classification of embedding-specific error markers (for example an
7//! active memory ceiling) is embedding policy: the consumer converts
8//! `MlxCError` values at its own boundary.
9//!
10//! Payload trampolines cannot return Rust errors across the C ABI, so they
11//! park their typed failure in a separate thread-local slot and return
12//! nonzero; status translation prefers that parked failure because it is the
13//! root cause, while MLX-C's status text only reports that a callback errored.
14
15use std::{
16    cell::RefCell,
17    ffi::{CStr, c_char, c_void},
18    ptr,
19    sync::Once,
20};
21
22use thiserror::Error;
23
24use crate::raw;
25
26static ERROR_HANDLER_INSTALLATION: Once = Once::new();
27
28thread_local! {
29    /// MLX C reports an error immediately before returning nonzero on the same
30    /// calling thread. Thread-local storage preserves that operation pairing
31    /// without allowing concurrent worker calls to consume each other's errors.
32    static LAST_MLX_ERROR: RefCell<Option<String>> = const { RefCell::new(None) };
33}
34
35thread_local! {
36    /// A failure raised inside a payload trampoline, parked for the caller's
37    /// status translation. Routing it through MLX's own captured-error
38    /// channel instead would let any nested MLX call on the same thread
39    /// overwrite it before the caller observes the nonzero status, so the
40    /// trampoline stores the typed failure here and returns nonzero.
41    static LAST_CLOSURE_ERROR: RefCell<Option<MlxCError>> = const { RefCell::new(None) };
42}
43
44/// One failed MLX-C operation: what was attempted and what MLX-C reported.
45#[derive(Debug, Error)]
46#[error("MLX operation {operation} failed: {description}")]
47pub struct MlxCError {
48    /// The attempted operation, phrased the way runtime error reports name it.
49    pub operation: &'static str,
50    /// The description MLX-C reported, or a fallback when it reported none.
51    pub description: String,
52}
53
54/// Installs the non-terminating MLX-C error handler exactly once per process.
55///
56/// The handler must be installed before any fallible MLX-C call so failures
57/// carry descriptions instead of bare nonzero statuses.
58pub fn install_non_terminating_error_handler() {
59    ERROR_HANDLER_INSTALLATION.call_once(|| {
60        // SAFETY: The callback follows MLX C's exact ABI, never unwinds, and
61        // stores no borrowed pointers after returning. Null context and
62        // destructor are valid because all state is Rust-owned static state.
63        unsafe {
64            raw::mlx_set_error_handler(Some(capture_mlx_error), ptr::null_mut(), None);
65        }
66    });
67}
68
69/// Translates an MLX-C status into `Ok(())` or a captured `MlxCError`.
70///
71/// # Errors
72/// Returns the captured MLX-C description for the calling thread when the
73/// status is nonzero, with a fallback description when MLX-C reported none.
74pub fn check_status(status: i32, operation: &'static str) -> Result<(), MlxCError> {
75    if status == 0 {
76        clear_captured_mlx_error();
77        clear_closure_error();
78        return Ok(());
79    }
80    if let Some(failure) = take_closure_error() {
81        // The parked trampoline failure is the root cause; the status-side
82        // description is consumed too so it cannot leak into a later
83        // operation's error on this thread.
84        clear_captured_mlx_error();
85        return Err(failure);
86    }
87    let description = take_captured_mlx_error()
88        .unwrap_or_else(|| format!("MLX C returned status {status} without an error message"));
89    Err(MlxCError {
90        operation,
91        description,
92    })
93}
94
95/// Takes the captured MLX-C error description for the calling thread, if any.
96#[must_use]
97pub fn take_captured_mlx_error() -> Option<String> {
98    LAST_MLX_ERROR.with(|last_error| {
99        last_error
100            .try_borrow_mut()
101            .ok()
102            .and_then(|mut writable_error| writable_error.take())
103    })
104}
105
106/// Discards the captured MLX-C error description for the calling thread.
107pub fn clear_captured_mlx_error() {
108    LAST_MLX_ERROR.with(|last_error| {
109        if let Ok(mut writable_error) = last_error.try_borrow_mut() {
110            *writable_error = None;
111        }
112    });
113}
114
115/// Parks a payload-closure failure for the calling thread's status check.
116pub fn set_closure_error(failure: MlxCError) {
117    LAST_CLOSURE_ERROR.with(|last_failure| {
118        if let Ok(mut writable_failure) = last_failure.try_borrow_mut() {
119            *writable_failure = Some(failure);
120        }
121    });
122}
123
124/// Takes the parked payload-closure failure for the calling thread, if any.
125#[must_use]
126pub fn take_closure_error() -> Option<MlxCError> {
127    LAST_CLOSURE_ERROR.with(|last_failure| {
128        last_failure
129            .try_borrow_mut()
130            .ok()
131            .and_then(|mut writable_failure| writable_failure.take())
132    })
133}
134
135/// Discards the parked payload-closure failure for the calling thread.
136pub fn clear_closure_error() {
137    LAST_CLOSURE_ERROR.with(|last_failure| {
138        if let Ok(mut writable_failure) = last_failure.try_borrow_mut() {
139            *writable_failure = None;
140        }
141    });
142}
143
144unsafe extern "C" fn capture_mlx_error(message: *const c_char, _context: *mut c_void) {
145    let description = if message.is_null() {
146        "MLX reported an error without a message".to_owned()
147    } else {
148        // SAFETY: MLX C documents that the callback receives a valid
149        // null-terminated message for the duration of this call.
150        unsafe { CStr::from_ptr(message) }
151            .to_string_lossy()
152            .into_owned()
153    };
154    LAST_MLX_ERROR.with(|last_error| {
155        if let Ok(mut writable_error) = last_error.try_borrow_mut() {
156            *writable_error = Some(description);
157        }
158    });
159}