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}