Skip to main content

lean_rs/
callback.rs

1//! Rust callback handles for Lean-to-Rust interop.
2//!
3//! This module owns the same-process callback ABI. It owns the Rust side of the
4//! callback ABI: handle lifetime, trampoline selection, payload decoding,
5//! stale-handle checks, and panic containment. Lean receives two `USize`
6//! values, an opaque handle and the crate-owned trampoline, then calls back
7//! into Rust with one of the sealed payload shapes supported by this crate.
8//!
9//! The public surface deliberately does not accept a user-supplied function
10//! pointer. Callers register a Rust closure and pass the returned
11//! [`LeanCallbackHandle`]'s ABI values to a Lean export. The handle must stay
12//! alive until Lean can no longer call it.
13
14// SAFETY DOC: string callbacks receive a borrowed Lean `String` object from
15// the generic interop shim. The trampoline validates the Lean shape, copies
16// bytes into an owned Rust `String`, and never decrements the borrowed object.
17#![allow(unsafe_code)]
18
19use std::collections::HashMap;
20use std::marker::PhantomData;
21use std::num::NonZeroUsize;
22use std::rc::Rc;
23use std::slice;
24use std::sync::atomic::{AtomicUsize, Ordering};
25use std::sync::{Arc, Mutex, OnceLock};
26
27use lean_rs_sys::lean_object;
28use lean_rs_sys::object::{lean_is_scalar, lean_is_string};
29use lean_rs_sys::string::{lean_string_cstr, lean_string_size};
30
31use crate::abi::traits::{TryFromLean, conversion_error};
32use crate::error::panic::catch_callback_panic;
33use crate::error::{LeanError, LeanResult};
34use crate::runtime::obj::Obj;
35
36type ProgressCallbackFn = dyn Fn(LeanProgressTick) -> LeanCallbackFlow + Send + Sync + 'static;
37type StringCallbackFn = dyn Fn(LeanStringEvent) -> LeanCallbackFlow + Send + Sync + 'static;
38type ScopedProgressCallbackFn<'a> = dyn Fn(LeanProgressTick) -> LeanCallbackFlow + Send + Sync + 'a;
39
40const PAYLOAD_PROGRESS_TICK: u8 = 0;
41const PAYLOAD_STRING: u8 = 1;
42
43static NEXT_ID: AtomicUsize = AtomicUsize::new(1);
44static REGISTRY: OnceLock<Mutex<HashMap<usize, Arc<CallbackEntry>>>> = OnceLock::new();
45
46/// Payload type accepted by a [`LeanCallbackHandle`].
47///
48/// This trait is sealed. Downstream crates can use the payload types provided
49/// by `lean-rs`, but cannot implement new callback ABI shapes. That keeps
50/// Lean object lifetimes, payload decoding, wrong-payload checks, and
51/// trampoline safety inside this crate.
52#[allow(private_bounds, reason = "standard sealed-trait pattern keeps payload ABI private")]
53pub trait LeanCallbackPayload: private::Sealed + Send + Sync + 'static {}
54
55/// Counter payload for progress-like callback ticks.
56///
57/// `lean-rs-host` maps this payload into host progress events; `lean-rs`
58/// itself attaches no theorem-prover policy to the counters.
59#[derive(Clone, Copy, Debug, Eq, PartialEq)]
60pub struct LeanProgressTick {
61    /// Current item, tick, or phase-local counter supplied by Lean.
62    pub current: u64,
63    /// Total item count or phase-local bound supplied by Lean.
64    pub total: u64,
65}
66
67/// String payload delivered by Lean and copied before user code runs.
68#[derive(Clone, Debug, Eq, PartialEq)]
69pub struct LeanStringEvent {
70    /// Owned UTF-8 string copied from Lean before invoking the callback.
71    pub value: String,
72}
73
74/// Flow decision returned by a Rust callback.
75///
76/// Lean shims should continue their callback loop only when the trampoline
77/// returns [`LeanCallbackStatus::Ok`]. Returning [`Stop`](Self::Stop) asks the
78/// Lean loop to stop cleanly and return [`LeanCallbackStatus::Stopped`).
79#[derive(Clone, Copy, Debug, Eq, PartialEq)]
80pub enum LeanCallbackFlow {
81    /// Continue the Lean-side callback loop.
82    Continue,
83    /// Stop the Lean-side callback loop without treating the callback as a
84    /// panic or stale-handle failure.
85    Stop,
86}
87
88/// Status returned by the Rust callback trampoline to Lean.
89///
90/// Lean shims should treat any value other than [`Ok`](Self::Ok) as a request
91/// to stop the current callback loop and return the status to Rust.
92#[derive(Clone, Copy, Debug, Eq, PartialEq)]
93#[repr(u8)]
94pub enum LeanCallbackStatus {
95    /// The callback ran successfully.
96    Ok = 0,
97    /// Lean called an id that is no longer registered.
98    StaleHandle = 1,
99    /// The registered Rust callback panicked and the trampoline contained it.
100    Panic = 2,
101    /// Lean called a handle through a trampoline for the wrong payload type.
102    WrongPayload = 3,
103    /// The registered Rust callback asked Lean to stop cleanly.
104    Stopped = 4,
105}
106
107impl LeanCallbackStatus {
108    /// Decode a status byte returned by a Lean callback shim.
109    #[must_use]
110    pub const fn from_abi(value: u8) -> Option<Self> {
111        match value {
112            0 => Some(Self::Ok),
113            1 => Some(Self::StaleHandle),
114            2 => Some(Self::Panic),
115            3 => Some(Self::WrongPayload),
116            4 => Some(Self::Stopped),
117            _ => None,
118        }
119    }
120
121    /// Encode this status for the Lean `UInt8` ABI.
122    #[must_use]
123    pub const fn as_abi(self) -> u8 {
124        self as u8
125    }
126
127    /// Stable diagnostic text for callback-shim status handling.
128    #[must_use]
129    pub const fn description(self) -> &'static str {
130        match self {
131            Self::Ok => "callback completed successfully",
132            Self::StaleHandle => "Lean called a callback handle after Rust dropped it",
133            Self::Panic => "Rust callback panicked and the trampoline contained the panic",
134            Self::WrongPayload => "Lean called a callback handle through the wrong payload trampoline",
135            Self::Stopped => "Rust callback asked Lean to stop the callback loop",
136        }
137    }
138}
139
140/// RAII registration for a Rust callback Lean may invoke.
141///
142/// Register with a supported payload specialization, pass
143/// [`LeanCallbackHandle::abi_parts`] to a Lean export whose first two arguments
144/// are `USize`, and keep the handle alive until the Lean side cannot call it
145/// again. Dropping the handle unregisters its id; a later Lean call with the
146/// same stale id returns [`LeanCallbackStatus::StaleHandle`] instead of
147/// dereferencing freed Rust memory.
148///
149/// The callback runs synchronously on the Lean-bound thread that invoked the
150/// Lean export. It must not call back into the same `LeanSession` or re-enter
151/// the same Lean call stack. Rust panics are caught inside the trampoline and
152/// recorded as [`LeanError`] with [`crate::HostStage::CallbackPanic`]; aborting
153/// panics and Lean internal panics remain process-scoped.
154///
155/// `LeanCallbackHandle` is [`Send`] and [`Sync`] because registry lookup clones
156/// an internal [`Arc`] before running the callback, and registration/removal is
157/// guarded by a mutex. The registered closure must therefore be
158/// `Send + Sync + 'static`.
159pub struct LeanCallbackHandle<P: LeanCallbackPayload> {
160    id: NonZeroUsize,
161    entry: Arc<CallbackEntry>,
162    _payload: PhantomData<fn(P)>,
163}
164
165/// Scoped progress callback registration for one synchronous Lean call.
166///
167/// Unlike [`LeanCallbackHandle<LeanProgressTick>`], the registered closure may
168/// borrow from the caller. `lean-rs` keeps that borrowed context alive until
169/// after the callback handle is unregistered, so host crates do not need raw
170/// context pointers or stack-lifetime trampolines to report progress.
171///
172/// Lean must not store the `(handle, trampoline)` pair or invoke it after the
173/// synchronous export call has returned. A stale call after drop returns
174/// [`LeanCallbackStatus::StaleHandle`], but retaining the values beyond the
175/// call violates the scoped callback contract.
176pub struct LeanProgressCallback<'a> {
177    handle: LeanCallbackHandle<LeanProgressTick>,
178    #[allow(
179        dead_code,
180        reason = "keeps the borrowed callback context alive until after handle drop"
181    )]
182    context: Box<ScopedProgressContext<'a>>,
183    _not_send_or_sync: PhantomData<Rc<()>>,
184}
185
186struct ScopedProgressContext<'a> {
187    callback: Box<ScopedProgressCallbackFn<'a>>,
188}
189
190impl<P: LeanCallbackPayload> std::fmt::Debug for LeanCallbackHandle<P> {
191    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
192        f.debug_struct("LeanCallbackHandle")
193            .field("id", &self.id)
194            .finish_non_exhaustive()
195    }
196}
197
198impl LeanCallbackHandle<LeanProgressTick> {
199    /// Register a Rust callback for progress tick payloads.
200    ///
201    /// # Errors
202    ///
203    /// Returns [`LeanError::Host`] with diagnostic code
204    /// [`crate::LeanDiagnosticCode::Internal`] if the registry cannot allocate
205    /// a fresh nonzero id. This requires exhausting the process-size `usize`
206    /// id space while many handles are still live.
207    pub fn register<F>(callback: F) -> LeanResult<Self>
208    where
209        F: Fn(LeanProgressTick) -> LeanCallbackFlow + Send + Sync + 'static,
210    {
211        register_entry(CallbackEntry::new_progress(callback))
212    }
213}
214
215impl LeanCallbackHandle<LeanStringEvent> {
216    /// Register a Rust callback for string payloads.
217    ///
218    /// The Lean string is copied into an owned [`String`] before user code
219    /// runs, so no Lean object lifetime escapes the trampoline.
220    ///
221    /// # Errors
222    ///
223    /// Returns [`LeanError::Host`] with diagnostic code
224    /// [`crate::LeanDiagnosticCode::Internal`] if the registry cannot allocate
225    /// a fresh nonzero id.
226    pub fn register<F>(callback: F) -> LeanResult<Self>
227    where
228        F: Fn(LeanStringEvent) -> LeanCallbackFlow + Send + Sync + 'static,
229    {
230        register_entry(CallbackEntry::new_string(callback))
231    }
232}
233
234impl<'a> LeanProgressCallback<'a> {
235    /// Register a progress callback whose closure may borrow from the caller.
236    ///
237    /// The returned value must stay alive until Lean can no longer invoke the
238    /// callback. Dropping it unregisters the handle before releasing the
239    /// borrowed callback context.
240    ///
241    /// # Errors
242    ///
243    /// Returns [`LeanError::Host`] with diagnostic code
244    /// [`crate::LeanDiagnosticCode::Internal`] if the callback registry cannot
245    /// allocate a fresh nonzero id.
246    pub fn register<F>(callback: F) -> LeanResult<Self>
247    where
248        F: Fn(LeanProgressTick) -> LeanCallbackFlow + Send + Sync + 'a,
249    {
250        let context = Box::new(ScopedProgressContext {
251            callback: Box::new(callback),
252        });
253        let context_addr = std::ptr::from_ref(&*context).addr();
254        let handle = LeanCallbackHandle::<LeanProgressTick>::register(move |event| {
255            // SAFETY: `context_addr` points at the `ScopedProgressContext`
256            // owned by the `LeanProgressCallback` being constructed. The
257            // struct declares `handle` before `context`, so drop unregisters
258            // the callback id before the borrowed context is released. Safe
259            // callers cannot move the scoped registration across threads, and
260            // Lean may invoke the handle only during the synchronous call for
261            // which this value is alive.
262            let context = unsafe { &*(context_addr as *const ScopedProgressContext<'_>) };
263            (context.callback)(event)
264        })?;
265        Ok(Self {
266            handle,
267            context,
268            _not_send_or_sync: PhantomData,
269        })
270    }
271
272    /// Return `(handle, trampoline)` for Lean progress exports using the
273    /// standard two-`USize` callback ABI.
274    #[must_use]
275    pub fn abi_parts(&self) -> (usize, usize) {
276        self.handle.abi_parts()
277    }
278
279    /// Decode a Lean progress-shim result.
280    ///
281    /// Host progress shims return `Except UInt8 T`, with `Except.ok` carrying
282    /// the real result and `Except.error` carrying a [`LeanCallbackStatus`]
283    /// byte. This method maps callback panic, stale-handle, wrong-payload, and
284    /// stop statuses into `LeanError` while the callback handle is still live,
285    /// so callers do not need to inspect raw status bytes.
286    ///
287    /// # Errors
288    ///
289    /// Returns a conversion error if the object is not a Lean `Except UInt8 T`
290    /// shape, or a host error if the callback status reports a contained panic
291    /// or callback invariant failure.
292    pub fn decode_result<'lean, T>(&self, obj: Obj<'lean>) -> LeanResult<T>
293    where
294        T: TryFromLean<'lean>,
295    {
296        match Result::<T, u8>::try_from_lean(obj)? {
297            Ok(value) => Ok(value),
298            Err(status) => {
299                self.progress_status_to_result(status)?;
300                Err(LeanError::internal(
301                    "progress shim returned Except.error with successful callback status",
302                ))
303            }
304        }
305    }
306
307    fn progress_status_to_result(&self, status: u8) -> LeanResult<()> {
308        match LeanCallbackStatus::from_abi(status) {
309            Some(LeanCallbackStatus::Ok) => Ok(()),
310            Some(LeanCallbackStatus::StaleHandle) => {
311                Err(LeanError::internal("Lean progress shim called a stale callback handle"))
312            }
313            Some(LeanCallbackStatus::WrongPayload) => Err(LeanError::internal(
314                "Lean progress shim called a callback handle through the wrong payload trampoline",
315            )),
316            Some(LeanCallbackStatus::Stopped) => Err(LeanError::internal(
317                "progress callback asked Lean to stop, but progress callbacks do not define stop semantics",
318            )),
319            Some(LeanCallbackStatus::Panic) => Err(self.handle.last_error().unwrap_or_else(|| {
320                LeanError::internal("progress callback panicked without recording a callback error")
321            })),
322            None => Err(conversion_error(format!(
323                "Lean progress shim returned unknown callback status byte {status}"
324            ))),
325        }
326    }
327}
328
329impl<P: LeanCallbackPayload> LeanCallbackHandle<P> {
330    /// Opaque `USize` handle to pass as the first Lean callback argument.
331    #[must_use]
332    pub fn abi_handle(&self) -> usize {
333        self.id.get()
334    }
335
336    /// Crate-owned trampoline value to pass as the second Lean callback
337    /// argument.
338    ///
339    /// Callers may pass this value to Lean, but they never construct or supply
340    /// a trampoline function pointer themselves.
341    #[must_use]
342    pub fn abi_trampoline(&self) -> usize {
343        P::trampoline()
344    }
345
346    /// Return `(handle, trampoline)` for Lean exports using the standard
347    /// two-`USize` callback ABI.
348    #[must_use]
349    pub fn abi_parts(&self) -> (usize, usize) {
350        (self.abi_handle(), self.abi_trampoline())
351    }
352
353    /// Last Rust error recorded by this callback handle.
354    ///
355    /// This is currently populated when the callback panics and the trampoline
356    /// returns [`LeanCallbackStatus::Panic`]. Stale-handle calls happen after
357    /// the handle was dropped, so no live handle exists to store that status.
358    #[must_use]
359    pub fn last_error(&self) -> Option<LeanError> {
360        self.entry.last_error()
361    }
362}
363
364impl<P: LeanCallbackPayload> Drop for LeanCallbackHandle<P> {
365    fn drop(&mut self) {
366        if let Some(registry) = REGISTRY.get()
367            && let Ok(mut guard) = registry.lock()
368        {
369            drop(guard.remove(&self.id.get()));
370        }
371    }
372}
373
374enum CallbackEntryKind {
375    Progress(Box<ProgressCallbackFn>),
376    String(Box<StringCallbackFn>),
377}
378
379struct CallbackEntry {
380    kind: CallbackEntryKind,
381    last_error: Mutex<Option<LeanError>>,
382}
383
384impl std::fmt::Debug for CallbackEntry {
385    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
386        f.debug_struct("CallbackEntry").finish_non_exhaustive()
387    }
388}
389
390impl CallbackEntry {
391    fn new_progress<F>(callback: F) -> Self
392    where
393        F: Fn(LeanProgressTick) -> LeanCallbackFlow + Send + Sync + 'static,
394    {
395        Self {
396            kind: CallbackEntryKind::Progress(Box::new(callback)),
397            last_error: Mutex::new(None),
398        }
399    }
400
401    fn new_string<F>(callback: F) -> Self
402    where
403        F: Fn(LeanStringEvent) -> LeanCallbackFlow + Send + Sync + 'static,
404    {
405        Self {
406            kind: CallbackEntryKind::String(Box::new(callback)),
407            last_error: Mutex::new(None),
408        }
409    }
410
411    fn report_progress(&self, event: LeanProgressTick) -> LeanCallbackStatus {
412        let CallbackEntryKind::Progress(callback) = &self.kind else {
413            return LeanCallbackStatus::WrongPayload;
414        };
415        let result = catch_callback_panic(|| Ok(callback(event)));
416        self.flow_or_panic(result)
417    }
418
419    fn report_string(&self, event: LeanStringEvent) -> LeanCallbackStatus {
420        let CallbackEntryKind::String(callback) = &self.kind else {
421            return LeanCallbackStatus::WrongPayload;
422        };
423        let result = catch_callback_panic(|| Ok(callback(event)));
424        self.flow_or_panic(result)
425    }
426
427    fn flow_or_panic(&self, result: LeanResult<LeanCallbackFlow>) -> LeanCallbackStatus {
428        match result {
429            Ok(LeanCallbackFlow::Continue) => LeanCallbackStatus::Ok,
430            Ok(LeanCallbackFlow::Stop) => LeanCallbackStatus::Stopped,
431            Err(err) => {
432                if let Ok(mut last_error) = self.last_error.lock() {
433                    *last_error = Some(err);
434                }
435                LeanCallbackStatus::Panic
436            }
437        }
438    }
439
440    fn last_error(&self) -> Option<LeanError> {
441        self.last_error.lock().ok().and_then(|guard| guard.clone())
442    }
443}
444
445fn register_entry<P: LeanCallbackPayload>(entry: CallbackEntry) -> LeanResult<LeanCallbackHandle<P>> {
446    let entry = Arc::new(entry);
447    let registry = registry();
448    let mut guard = registry
449        .lock()
450        .map_err(|_| LeanError::internal("callback registry mutex was poisoned during registration"))?;
451    let id = allocate_id(&guard)?;
452    let previous = guard.insert(id.get(), Arc::clone(&entry));
453    debug_assert!(previous.is_none(), "fresh callback id collided with an existing entry");
454    drop(guard);
455    Ok(LeanCallbackHandle {
456        id,
457        entry,
458        _payload: PhantomData,
459    })
460}
461
462fn registry() -> &'static Mutex<HashMap<usize, Arc<CallbackEntry>>> {
463    REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
464}
465
466fn allocate_id(guard: &HashMap<usize, Arc<CallbackEntry>>) -> LeanResult<NonZeroUsize> {
467    for _ in 0..1024 {
468        let raw = NEXT_ID.fetch_add(1, Ordering::Relaxed);
469        let Some(id) = NonZeroUsize::new(raw) else {
470            continue;
471        };
472        if !guard.contains_key(&id.get()) {
473            return Ok(id);
474        }
475    }
476    Err(LeanError::internal(
477        "callback registry could not allocate a fresh nonzero handle id",
478    ))
479}
480
481extern "C" fn progress_trampoline(
482    handle: usize,
483    payload_tag: u8,
484    arg0: u64,
485    arg1: u64,
486    _payload: *mut lean_object,
487) -> u8 {
488    if payload_tag != PAYLOAD_PROGRESS_TICK {
489        return LeanCallbackStatus::WrongPayload.as_abi();
490    }
491    let entry = registry().lock().ok().and_then(|guard| guard.get(&handle).cloned());
492    let Some(entry) = entry else {
493        return LeanCallbackStatus::StaleHandle.as_abi();
494    };
495    entry
496        .report_progress(LeanProgressTick {
497            current: arg0,
498            total: arg1,
499        })
500        .as_abi()
501}
502
503extern "C" fn string_trampoline(
504    handle: usize,
505    payload_tag: u8,
506    _arg0: u64,
507    _arg1: u64,
508    payload: *mut lean_object,
509) -> u8 {
510    if payload_tag != PAYLOAD_STRING {
511        return LeanCallbackStatus::WrongPayload.as_abi();
512    }
513    let entry = registry().lock().ok().and_then(|guard| guard.get(&handle).cloned());
514    let Some(entry) = entry else {
515        return LeanCallbackStatus::StaleHandle.as_abi();
516    };
517    let Some(value) = decode_string_payload(payload) else {
518        return LeanCallbackStatus::WrongPayload.as_abi();
519    };
520    entry.report_string(LeanStringEvent { value }).as_abi()
521}
522
523fn decode_string_payload(payload: *mut lean_object) -> Option<String> {
524    if payload.is_null() {
525        return None;
526    }
527    // SAFETY: scalar check inspects pointer bits only and is valid for every
528    // Lean-shaped value the trampoline may receive.
529    if unsafe { lean_is_scalar(payload) } {
530        return None;
531    }
532    // SAFETY: the generic string callback shim passes `payload : @& String`.
533    // Wrong-payload tests route through null/scalar-shaped payloads or a
534    // mismatched handle and return before this heap predicate.
535    if !unsafe { lean_is_string(payload) } {
536        return None;
537    }
538    // SAFETY: kind verified; the string is borrowed for the duration of the
539    // extern call. Copy the bytes into Rust before invoking user code so no
540    // Lean object lifetime escapes the trampoline.
541    let bytes = unsafe {
542        let size_with_nul = lean_string_size(payload);
543        let len = size_with_nul.saturating_sub(1);
544        let data = lean_string_cstr(payload).cast::<u8>();
545        slice::from_raw_parts(data, len)
546    };
547    String::from_utf8(bytes.to_vec()).ok()
548}
549
550mod private {
551    use super::{LeanProgressTick, LeanStringEvent, progress_trampoline, string_trampoline};
552
553    pub trait Sealed {
554        fn trampoline() -> usize;
555    }
556
557    impl Sealed for LeanProgressTick {
558        fn trampoline() -> usize {
559            progress_trampoline as *const () as usize
560        }
561    }
562
563    impl Sealed for LeanStringEvent {
564        fn trampoline() -> usize {
565            string_trampoline as *const () as usize
566        }
567    }
568}
569
570impl LeanCallbackPayload for LeanProgressTick {}
571impl LeanCallbackPayload for LeanStringEvent {}
572
573#[cfg(test)]
574mod tests {
575    use super::{LeanCallbackFlow, LeanCallbackHandle, LeanCallbackStatus, LeanProgressTick, LeanStringEvent};
576
577    #[test]
578    fn callback_handle_is_send_sync() {
579        fn assert_send_sync<T: Send + Sync>() {}
580        assert_send_sync::<LeanCallbackHandle<LeanProgressTick>>();
581        assert_send_sync::<LeanCallbackHandle<LeanStringEvent>>();
582    }
583
584    #[test]
585    fn status_bytes_round_trip() {
586        assert_eq!(LeanCallbackStatus::from_abi(0), Some(LeanCallbackStatus::Ok));
587        assert_eq!(LeanCallbackStatus::from_abi(1), Some(LeanCallbackStatus::StaleHandle),);
588        assert_eq!(LeanCallbackStatus::from_abi(2), Some(LeanCallbackStatus::Panic));
589        assert_eq!(LeanCallbackStatus::from_abi(3), Some(LeanCallbackStatus::WrongPayload));
590        assert_eq!(LeanCallbackStatus::from_abi(4), Some(LeanCallbackStatus::Stopped));
591        assert_eq!(LeanCallbackStatus::from_abi(5), None);
592    }
593
594    #[test]
595    fn flow_is_explicit() {
596        assert_ne!(LeanCallbackFlow::Continue, LeanCallbackFlow::Stop);
597    }
598}