Skip to main content

azul_core/
host_invoker.rs

1//! Host-language callback invoker registry.
2//!
3//! Managed-FFI bindings (Lua, Ruby, Perl, PHP, OCaml, Node, C#, Java, …) can't
4//! generate C-ABI trampolines for callback typedefs that take aggregate args
5//! by value — that's a libffi / LuaJIT FFI / ruby-ffi limitation we can't fix
6//! at the host. This module provides the alternative the user's analysis
7//! settled on: each language registers **one** generic invoker function at
8//! module load time, plus a releaser that fires when a host-language handle
9//! goes out of use.
10//!
11//! Every callback the host registers becomes a `Callback { cb, ctx }` pair
12//! whose `cb` is a *static thunk* in libazul (so by-value args land on a
13//! native frame the way the framework already expects), and whose `ctx` is
14//! a `RefAny` payload that carries an opaque host-language `u64` handle.
15//! The thunk reads `info.get_ctx()`, extracts the handle, and dispatches to
16//! the registered per-kind invoker — which, on the host side, looks up the
17//! callable by id in a host-managed table and runs it. When the RefAny's
18//! refcount drops to zero, the destructor calls back through the registered
19//! releaser so the host can drop its table entry, mirroring Python's
20//! `Py<PyAny>` lifetime story without making libazul link against any host
21//! runtime.
22//!
23//! ## API surface
24//!
25//! - [`AzApp_setHostHandleReleaser`] — register the host's "drop this id"
26//!   callback once per process. Fires when a host-handle [`RefAny`] is
27//!   collected.
28//! - Per callback kind, [`crate::impl_managed_callback!`] expands to:
29//!   - A static thunk (`extern "C" fn`) compiled into libazul.
30//!   - A `<Wrapper>::create_from_host_handle(u64)` constructor.
31//!   - An `AzApp_set<Kind>Invoker(...)` setter for the host-side per-kind
32//!     pointer-arg invoker.
33//!
34//! ## Why a single shared releaser
35//!
36//! Per-kind invokers are necessarily distinct — each callback typedef has
37//! a different signature, so the host has to register a libffi closure per
38//! typedef anyway. The releaser, on the other hand, has the same signature
39//! for every kind (`extern "C" fn(u64)`), so we can share one slot across
40//! all callbacks; the host registers it once and every kind's destructor
41//! routes through it.
42
43use core::ffi::c_void;
44use core::sync::atomic::{AtomicUsize, Ordering};
45
46use azul_css::AzString;
47
48use crate::refany::RefAny;
49
50/// RTTI id stamped into every RefAny created via [`host_handle_to_refany`].
51///
52/// Hosts must not reuse this id for their own user-data RefAnys, otherwise
53/// `refany_to_host_handle` would mis-identify their data as a host handle
54/// and the destructor would call the registered releaser with a bogus id.
55/// The high 32 bits are reserved for azul-internal RTTI ids; the low 32
56/// spell `'H','S','T','H'` so the value reads `0xA20A_4853_5448_5F44`.
57pub const AZ_HOST_HANDLE_RTTI_ID: u64 = 0xA20A_4853_5448_5F44;
58
59/// Heap payload stored inside the [`RefAny`] returned by
60/// [`host_handle_to_refany`]. Just the opaque host-language id — the actual
61/// host callable lives on the host side keyed by this id.
62#[repr(C)]
63pub struct HostHandlePayload {
64    pub id: u64,
65}
66
67/// A single atomic-pointer slot for one registered host-side function
68/// pointer. `0` means "not registered"; the static thunks bail out (returning
69/// the kind's default value) when they see an unregistered slot rather than
70/// transmuting `0` into a fn pointer and crashing.
71#[repr(C)]
72pub struct InvokerSlot {
73    fn_ptr: AtomicUsize,
74}
75
76impl InvokerSlot {
77    /// Create an empty slot. `const` so it can be used to declare `static`
78    /// per-kind slots in `impl_managed_callback!` expansions.
79    pub const fn new() -> Self {
80        Self {
81            fn_ptr: AtomicUsize::new(0),
82        }
83    }
84
85    /// Replace the registered function pointer.
86    ///
87    /// `SeqCst` because the slot is read on every callback fire and we
88    /// don't want any stale-pointer windows after the host swaps invokers
89    /// (rare but legal — e.g. unloading a Lua module that registered).
90    pub fn set(&self, ptr: usize) {
91        self.fn_ptr.store(ptr, Ordering::SeqCst);
92    }
93
94    /// Read the current function pointer; `0` if unregistered.
95    pub fn get(&self) -> usize {
96        self.fn_ptr.load(Ordering::SeqCst)
97    }
98}
99
100impl Default for InvokerSlot {
101    fn default() -> Self {
102        Self::new()
103    }
104}
105
106/// Process-global slot for the host's "drop a handle id" callback. Set via
107/// [`AzApp_setHostHandleReleaser`]. Read by [`host_handle_destructor`]
108/// when a host-handle [`RefAny`]'s last clone drops.
109pub static HOST_HANDLE_RELEASER: InvokerSlot = InvokerSlot::new();
110
111/// Process-global slot for the host's *generic* invoker. Set via
112/// [`AzApp_setGenericInvoker`]. Used as a fallback in macro-generated
113/// per-kind thunks when the per-kind invoker is not registered, and as
114/// the **only** dispatch path for user-defined custom callback kinds in
115/// libffi-restricted hosts (Lua, PHP, koffi, …) that can't easily ship
116/// an upstream `impl_managed_callback!` invocation.
117///
118/// Signature on the host side:
119///
120/// ```c
121/// typedef void (*AzGenericInvoker)(
122///     uint64_t           handle,    /* host-handle id from the RefAny ctx */
123///     const char*        kind,      /* null-terminated wrapper name */
124///     const void* const* args,      /* array of pointers, one per arg, in declared order */
125///     size_t             n_args,    /* args[] length */
126///     void*              ret        /* where to write the return value (kind-specific size) */
127/// );
128/// extern void AzApp_setGenericInvoker(AzGenericInvoker);
129/// ```
130///
131/// The args array carries pointers into the framework's by-value frame
132/// — host code must not retain them past the call. The host decides what
133/// to do per kind from the `kind` string (which matches the wrapper
134/// struct name, e.g. `"Callback"`, `"LayoutCallback"`,
135/// `"ButtonOnClickCallback"`).
136pub static GENERIC_INVOKER: InvokerSlot = InvokerSlot::new();
137
138/// Type alias for the generic invoker callable. Hosts cast a libffi
139/// closure to this signature once at module load.
140pub type AzGenericInvoker = extern "C" fn(
141    handle: u64,
142    kind: *const core::ffi::c_char,
143    args: *const *const c_void,
144    n_args: usize,
145    ret: *mut c_void,
146);
147
148/// Register the generic invoker for user-defined custom callback kinds
149/// or as a fallback for per-kind dispatch. Called once at module load;
150/// subsequent registrations replace the previous slot.
151///
152/// Safety: `invoker` must be a valid [`AzGenericInvoker`] function
153/// pointer for the lifetime of any callback that might be dispatched
154/// through it — typically the whole process.
155#[no_mangle]
156pub extern "C" fn AzApp_setGenericInvoker(invoker: AzGenericInvoker) {
157    GENERIC_INVOKER.set(invoker as usize);
158}
159
160/// Register the host-language releaser. Hosts call this once at module
161/// load time; subsequent registrations replace the previous slot.
162///
163/// `releaser` will be invoked as `releaser(id)` whenever a host-handle
164/// `RefAny` (the kind built by [`host_handle_to_refany`]) drops its last
165/// reference. The host should remove `id` from whatever id→callable table
166/// it maintains.
167///
168/// Safety: `releaser` must be a valid `extern "C" fn(u64)` for the lifetime
169/// of any host-handle [`RefAny`] that may still be alive — typically the
170/// whole process. Passing a function pointer that becomes invalid (e.g.,
171/// from an unloaded library) without first re-registering will cause a
172/// crash on the next collection.
173#[no_mangle]
174pub extern "C" fn AzApp_setHostHandleReleaser(releaser: extern "C" fn(u64)) {
175    HOST_HANDLE_RELEASER.set(releaser as usize);
176}
177
178/// Destructor stamped into every host-handle [`RefAny`]. Reads the payload's
179/// `id` and forwards it to the registered releaser; if no releaser has been
180/// registered (e.g., host hasn't initialized yet, or this is a release-build
181/// dll loaded by a non-managed-FFI consumer) the destructor is a no-op so
182/// the C side doesn't crash.
183extern "C" fn host_handle_destructor(ptr: *mut c_void) {
184    if ptr.is_null() {
185        return;
186    }
187    // SAFETY: the destructor only runs for RefAnys built via
188    // host_handle_to_refany, whose payload type is HostHandlePayload.
189    let payload = unsafe { &*(ptr as *const HostHandlePayload) };
190
191    let releaser_addr = HOST_HANDLE_RELEASER.get();
192    if releaser_addr == 0 {
193        return;
194    }
195    // SAFETY: HOST_HANDLE_RELEASER only ever holds a value that came from
196    // `releaser as usize` in `AzApp_setHostHandleReleaser`, where `releaser`
197    // is an `extern "C" fn(u64)`.
198    let releaser: extern "C" fn(u64) = unsafe { core::mem::transmute(releaser_addr) };
199    releaser(payload.id);
200}
201
202/// Wrap a host-language `u64` handle in a [`RefAny`] suitable for storing
203/// in a callback wrapper's `ctx` field.
204///
205/// The returned RefAny's destructor calls back through the registered
206/// host releaser when the last clone is dropped, giving the host an
207/// opportunity to release whatever its `id` was keying.
208pub fn host_handle_to_refany(id: u64) -> RefAny {
209    let payload = HostHandlePayload { id };
210    let type_name: AzString = "AzHostHandle".into();
211    RefAny::new_c(
212        &payload as *const HostHandlePayload as *const c_void,
213        core::mem::size_of::<HostHandlePayload>(),
214        core::mem::align_of::<HostHandlePayload>(),
215        AZ_HOST_HANDLE_RTTI_ID,
216        type_name,
217        host_handle_destructor,
218        0,
219        0,
220    )
221}
222
223/// Read the host-language id back out of a [`RefAny`] previously created
224/// via [`host_handle_to_refany`]. Returns `None` for any other RefAny, so
225/// a static thunk that mistakenly receives a non-host-handle ctx falls
226/// back to the kind's default value rather than reading random bytes.
227pub fn refany_to_host_handle(refany: &RefAny) -> Option<u64> {
228    if !refany.is_type(AZ_HOST_HANDLE_RTTI_ID) {
229        return None;
230    }
231    let ptr = refany.get_data_ptr() as *const HostHandlePayload;
232    if ptr.is_null() {
233        return None;
234    }
235    // SAFETY: type-id check above guarantees the payload was a HostHandlePayload.
236    Some(unsafe { (*ptr).id })
237}
238
239/// C-ABI: build a [`RefAny`] wrapping a host-language id. Lets managed-FFI
240/// bindings use the same machinery for user data that callbacks already use
241/// — one releaser, one id-keyed table, one lifetime story.
242///
243/// The returned RefAny's destructor fires the releaser registered via
244/// [`AzApp_setHostHandleReleaser`] once the last clone drops, so the host
245/// can drop its `id → value` entry.
246#[no_mangle]
247pub extern "C" fn AzRefAny_newHostHandle(id: u64) -> RefAny {
248    host_handle_to_refany(id)
249}
250
251/// C-ABI: read the host-language id from a [`RefAny`] previously built via
252/// [`AzRefAny_newHostHandle`] (or any other host-handle constructor).
253///
254/// Returns `0` if `refany` is null or wasn't a host handle. Host bindings
255/// must reserve `0` as "no value" — [`host_handle_to_refany`] never produces
256/// `0` if the host's id allocator starts at `1` (the convention used by
257/// every binding in this repo).
258#[no_mangle]
259pub extern "C" fn AzRefAny_getHostHandle(refany: *const RefAny) -> u64 {
260    if refany.is_null() {
261        return 0;
262    }
263    // SAFETY: caller's responsibility per `*const` signature.
264    let r = unsafe { &*refany };
265    refany_to_host_handle(r).unwrap_or(0)
266}
267
268/// Macro that expands to the per-callback-kind boilerplate: a static thunk
269/// (compiled into libazul) that the framework calls with by-value args, a
270/// `<Wrapper>::create_from_host_handle(u64)` constructor, and an
271/// `AzApp_set<Kind>Invoker` setter the host calls once at module load.
272///
273/// All identifiers are passed in explicitly so we don't need a proc-macro
274/// dependency just to concatenate idents. Codegen emits invocations of this
275/// macro from `ir.callback_typedefs`.
276///
277/// Caller responsibilities:
278///
279/// - The wrapper type must have public fields `cb: <typedef>` and
280///   `ctx: OptionRefAny` — that's the standard shape every callback wrapper
281///   in the framework already follows.
282/// - `info_ty` must expose a `.get_ctx() -> OptionRefAny` method (also
283///   standard for `*CallbackInfo` types).
284/// - `default_ret` is returned when:
285///   - the framework invokes the thunk with `OptionRefAny::None` ctx
286///     (host called the typedef directly without going through this path),
287///   - the ctx isn't a host-handle (host registered the wrapper but the
288///     ctx came from somewhere else),
289///   - or no invoker has been registered yet for this kind. Pick a value
290///     that can't be confused with a "real" return — typically the kind's
291///     "do nothing" / "empty body" default.
292#[macro_export]
293macro_rules! impl_managed_callback {
294    // Form 1: simple two-argument callbacks `(RefAny, info) -> ret` —
295    // matches `Callback`, `LayoutCallback`, `ButtonOnClickCallback`,
296    // and the bulk of widget event callbacks. Identical to the
297    // extras-form below with an empty extra-args list.
298    (
299        wrapper:        $wrapper:ty,
300        info_ty:        $info_ty:ty,
301        return_ty:      $ret:ty,
302        default_ret:    $default:expr,
303        invoker_static: $invoker_static:ident,
304        invoker_ty:     $invoker_ty:ident,
305        thunk_fn:       $thunk_fn:ident,
306        setter_fn:      $setter_fn:ident,
307        from_handle_fn: $from_handle_fn:ident,
308    ) => {
309        $crate::impl_managed_callback! {
310            wrapper:        $wrapper,
311            info_ty:        $info_ty,
312            return_ty:      $ret,
313            default_ret:    $default,
314            invoker_static: $invoker_static,
315            invoker_ty:     $invoker_ty,
316            thunk_fn:       $thunk_fn,
317            setter_fn:      $setter_fn,
318            from_handle_fn: $from_handle_fn,
319            extra_args:     [],
320        }
321    };
322    // Form 2: callbacks that take additional state after info — e.g.
323    // `CheckBoxOnToggleCallback(RefAny, CallbackInfo, CheckBoxState)`.
324    // The extras list is forwarded by reference into the host invoker
325    // so libffi-style runtimes never have to handle aggregate-by-value
326    // returns OR aggregate-by-value args.
327    (
328        wrapper:        $wrapper:ty,
329        info_ty:        $info_ty:ty,
330        return_ty:      $ret:ty,
331        default_ret:    $default:expr,
332        invoker_static: $invoker_static:ident,
333        invoker_ty:     $invoker_ty:ident,
334        thunk_fn:       $thunk_fn:ident,
335        setter_fn:      $setter_fn:ident,
336        from_handle_fn: $from_handle_fn:ident,
337        extra_args:     [ $( $extra_name:ident : $extra_ty:ty ),* $(,)? ] $(,)?
338    ) => {
339        /// Process-global slot for this callback kind's host-side invoker.
340        pub static $invoker_static: $crate::host_invoker::InvokerSlot =
341            $crate::host_invoker::InvokerSlot::new();
342
343        /// Pointer-arg variant of this callback kind's typedef.
344        ///
345        /// The host's libffi closure casts to this signature (which all
346        /// managed-FFI runtimes can handle — args and return are passed
347        /// by pointer, no aggregate-by-value anywhere). The static thunk
348        /// in libazul does the by-value plumbing on the C ABI side.
349        ///
350        /// LuaJIT FFI in particular cannot return aggregates larger than
351        /// 8 bytes from a callback, so we use an out-pointer for the
352        /// return value uniformly across kinds — even for `Update` which
353        /// would fit in a register, so the macro stays homogeneous.
354        pub type $invoker_ty = extern "C" fn(
355            handle: u64,
356            data: *const $crate::refany::RefAny,
357            info: *const $info_ty,
358            $( $extra_name : *const $extra_ty , )*
359            out: *mut $ret,
360        );
361
362        /// Register the host-side invoker for this callback kind.
363        #[no_mangle]
364        pub extern "C" fn $setter_fn(invoker: $invoker_ty) {
365            $invoker_static.set(invoker as usize);
366        }
367
368        /// Static thunk compiled into libazul. The framework calls this
369        /// with by-value args; we extract the host handle from `info.ctx`,
370        /// allocate space for the return value on our stack, and forward
371        /// pointers to the registered invoker.
372        extern "C" fn $thunk_fn(
373            data: $crate::refany::RefAny,
374            info: $info_ty,
375            $( $extra_name : $extra_ty , )*
376        ) -> $ret {
377            let ctx = info.get_ctx();
378            let handle = match ctx {
379                $crate::refany::OptionRefAny::Some(ref refany) => {
380                    match $crate::host_invoker::refany_to_host_handle(refany) {
381                        Some(id) => id,
382                        None => return $default,
383                    }
384                }
385                _ => return $default,
386            };
387            let invoker_addr = $invoker_static.get();
388            if invoker_addr == 0 {
389                // Per-kind invoker not registered — fall back to the
390                // generic invoker for hosts that wired up only the
391                // single `AzApp_setGenericInvoker` slot (or for custom
392                // user-defined kinds emitted by a downstream
393                // `impl_managed_callback!` whose host hasn't shipped a
394                // per-kind invoker setter yet).
395                let generic_addr = $crate::host_invoker::GENERIC_INVOKER.get();
396                if generic_addr == 0 {
397                    return $default;
398                }
399                // SAFETY: GENERIC_INVOKER only ever holds an address that
400                // came from `invoker as usize` in `AzApp_setGenericInvoker`,
401                // whose parameter is typed as `AzGenericInvoker`.
402                let generic: $crate::host_invoker::AzGenericInvoker =
403                    unsafe { core::mem::transmute(generic_addr) };
404
405                // Wrapper name as a null-terminated C string. `stringify!`
406                // expands `$wrapper:ty` to e.g. `Callback`,
407                // `ButtonOnClickCallback`, etc. — matching what the host's
408                // dispatch table keys on.
409                const KIND_STR: &str = concat!(stringify!($wrapper), "\0");
410
411                // Build the args array: pointers to each by-value frame
412                // arg, in declared order (data, info, extras…). Lifetime
413                // is the scope of this thunk; the host MUST NOT retain
414                // these pointers past the call. Array size is inferred
415                // (2 base args + however many extras the macro forwarded).
416                let args = [
417                    &data as *const _ as *const core::ffi::c_void,
418                    &info as *const _ as *const core::ffi::c_void,
419                    $( & $extra_name as *const _ as *const core::ffi::c_void , )*
420                ];
421
422                let mut out: $ret = $default;
423                generic(
424                    handle,
425                    KIND_STR.as_ptr() as *const core::ffi::c_char,
426                    args.as_ptr(),
427                    args.len(),
428                    &mut out as *mut _ as *mut core::ffi::c_void,
429                );
430                return out;
431            }
432            // SAFETY: $invoker_static only ever holds a value that came from
433            // `invoker as usize` in `$setter_fn`, where `invoker` has type
434            // `$invoker_ty`.
435            let invoker: $invoker_ty = unsafe { core::mem::transmute(invoker_addr) };
436
437            // Pre-fill `out` with the kind's default so a host that fails
438            // to write to the out-pointer (e.g. a buggy invoker) leaves us
439            // with a sane value rather than uninitialized memory.
440            let mut out: $ret = $default;
441            invoker(
442                handle,
443                &data as *const $crate::refany::RefAny,
444                &info as *const $info_ty,
445                $( & $extra_name as *const $extra_ty , )*
446                &mut out as *mut $ret,
447            );
448            out
449        }
450
451        impl $wrapper {
452            /// Build a wrapper whose `cb` is the static thunk above and
453            /// whose `ctx` carries the host's `u64` handle. The host
454            /// language is responsible for keeping its id→callable table
455            /// in sync with the releaser registered via
456            /// `AzApp_setHostHandleReleaser`.
457            pub fn create_from_host_handle(handle: u64) -> Self {
458                Self {
459                    cb: $thunk_fn,
460                    ctx: $crate::refany::OptionRefAny::Some(
461                        $crate::host_invoker::host_handle_to_refany(handle),
462                    ),
463                }
464            }
465        }
466
467        /// C-ABI export wrapping `<Wrapper>::create_from_host_handle`.
468        #[no_mangle]
469        pub extern "C" fn $from_handle_fn(handle: u64) -> $wrapper {
470            <$wrapper>::create_from_host_handle(handle)
471        }
472    };
473}