Host-Invoker Pattern
Why this exists
Azul's callback typedefs pass aggregates by value:
pub type CallbackType = extern "C" fn(RefAny, CallbackInfo) -> Update;
pub type LayoutCallbackType = extern "C" fn(RefAny, LayoutCallbackInfo) -> Dom;
pub type ButtonOnClickCallbackType
= extern "C" fn(RefAny, CallbackInfo) -> Update;
That's perfectly C-ABI — every C and C++ user can pass a &extern "C" fn
straight in. But every managed-FFI binding we ship — LuaJIT FFI,
ruby-ffi, PHP FFI, koffi (Node), CFFI (Common Lisp), FFI::Platypus
(Perl), ctypes Foreign.funptr (OCaml) — sits on top of libffi, and
libffi's closure builder cannot synthesise a C-callable trampoline whose
signature has aggregate-by-value args. A naive
ffi.cast('AzCallbackType', luaFn) either silently produces a junk
pointer or refuses to parse the typedef.
We can't fix libffi. What we ship instead is a C-side adapter built
into libazul: a per-callback-kind static thunk whose signature is
aggregate-by-value (so the framework can call it without changes), and
which forwards to a host-side closure whose signature is pointer-args
only (so libffi can synthesise it).
Architecture in one picture
managed-FFI host (Lua / PHP / Node / …)
┌───────────────────────────────────────────────────────────┐
│ user code: │
│ function on_click(data, info) … end │
│ │
│ registerCallback('Callback', on_click) ┐ │
│ └─► allocate id, stash fn in handles[id] │
│ └─► call AzCallback_createFromHostHandle(id) ─────────┼──┐
│ │ │
│ registered libffi closure (pointer args only): │ │
│ callback_invoker(id, *RefAny, *CallbackInfo, *Update) │ │
│ fn = handles[id]; ret = fn(...); *out = ret │ │
│ ▲ │ │
└──────┼─────────────────────────────────────────────────────┘ │
│ │
│ (registered once at module load via │
│ AzApp_setCallbackInvoker) │
│ │
▼ │
┌───────────────────────────────────────────────────────────┐ │
│ libazul (Rust) │ │
│ │ │
│ AzCallback { cb: az_callback_thunk, ctx: <handle> } ◄───┼───┘
│ │
│ az_callback_thunk(data: RefAny, info: CallbackInfo) │
│ -> Update { │
│ let handle = info.get_ctx().refany_to_host_handle(); │
│ let invoker = CALLBACK_INVOKER.get(); │
│ let mut out = Update::DoNothing; │
│ invoker(handle, &data, &info, &mut out); │
│ out │
│ } │
└───────────────────────────────────────────────────────────┘
The arrows in red ink: the framework calls cb with by-value args
(works because the thunk is compiled by Rust); the thunk calls the
registered libffi closure with pointer args (works because libffi can
synthesise that). The user's host-language function never sees the
aggregate-by-value version of the typedef.
The Rust side: impl_managed_callback!
core/src/host_invoker.rs defines:
AzApp_setHostHandleReleaser(extern "C" fn(u64))— process-global hook fired when a host-handleRefAny's last clone drops. Lets the host drop itsid → callabletable entry.AzRefAny_newHostHandle(u64) -> AzRefAny+AzRefAny_getHostHandle(*const AzRefAny) -> u64— same id-keyed path serves user data, so callbacks andrefanyCreateshare one releaser and one map.- The macro
impl_managed_callback! { … }— expands per kind to a static thunk, anAzApp_set<Kind>Invokersetter, and anAz<Wrapper>_createFromHostHandle(u64)constructor.
A typical invocation:
azul_core::impl_managed_callback! {
wrapper: ButtonOnClickCallback,
info_ty: CallbackInfo,
return_ty: Update,
default_ret: Update::DoNothing,
invoker_static: BUTTON_ON_CLICK_INVOKER,
invoker_ty: AzButtonOnClickCallbackInvoker,
thunk_fn: az_button_on_click_callback_thunk,
setter_fn: AzApp_setButtonOnClickCallbackInvoker,
from_handle_fn: AzButtonOnClickCallback_createFromHostHandle,
}
For widget callbacks that take state extras
(e.g. (RefAny, CallbackInfo, CheckBoxState) -> Update), append:
extra_args: [ state: CheckBoxState ],
The macro forwards every extra by pointer through the libffi closure —
no aggregate-by-value anywhere on the host-facing signature. It also
expands a handful of Display/Debug/Clone impls that the wrapper
struct needs to satisfy repr(C)'s derive bounds.
default_ret is what the thunk returns when:
- the framework called the typedef directly without going through this
path (
OptionRefAny::Nonectx), - the ctx came from somewhere that isn't a host-handle RefAny,
- or no invoker has been registered yet for this kind.
Pick a value that can't be confused with a „real“ return — typically the kind's „do nothing“ / „empty body“ default.
Where the ctx is preserved (critical)
The host-handle is carried in the wrapper's ctx: OptionRefAny field.
Three call sites in the framework would otherwise drop it:
dll/src/desktop/shell2/common/layout.rs::regenerate_layout— callsinfo.set_callable_ptr(&layout_callback.ctx)before invoking.layout/src/window.rs::invoke_single_callback—CallbackInfoRefData.ctx = callback.ctx.clone()(was hard-codedNoneonce upon a time).layout/src/callbacks.rs::Callback::from_core— preserves ctx when reconstructing from the storage form.
If info.get_ctx() returns None inside the thunk, the host-invoker
falls back to default_ret and the user's callback never fires. That's
the symptom to look for if you add a kind and the dispatch silently
no-ops.
How to register a new widget callback
Three steps. Once any one is wrong the host-language side either won't codegen (silent allowlist filter) or won't dispatch (silent ctx loss).
1. Apply impl_managed_callback! next to the widget's impl_widget_callback!
// layout/src/widgets/my_widget.rs
pub type MyWidgetOnFooCallbackType =
extern "C" fn(RefAny, CallbackInfo, MyWidgetState) -> Update;
impl_widget_callback!(
MyWidgetOnFoo,
OptionMyWidgetOnFoo,
MyWidgetOnFooCallback,
MyWidgetOnFooCallbackType
);
azul_core::impl_managed_callback! {
wrapper: MyWidgetOnFooCallback,
info_ty: CallbackInfo,
return_ty: Update,
default_ret: Update::DoNothing,
invoker_static: MY_WIDGET_ON_FOO_INVOKER,
invoker_ty: AzMyWidgetOnFooCallbackInvoker,
thunk_fn: az_my_widget_on_foo_callback_thunk,
setter_fn: AzApp_setMyWidgetOnFooCallbackInvoker,
from_handle_fn: AzMyWidgetOnFooCallback_createFromHostHandle,
extra_args: [ state: MyWidgetState ],
}
The convention: invoker_static is SCREAMING_SNAKE,
invoker_ty / setter / handle-fn keep the Az prefix and the wrapper
name verbatim, thunk_fn is snake_case. The codegen does not parse
these names — they only need to be unique within core::host_invoker.
2. Register the wrapper in HOST_INVOKER_KINDS
// doc/src/codegen/v2/managed_host_invoker.rs
pub const HOST_INVOKER_KINDS: &[&str] = &[
"Callback",
// …existing entries…
"MyWidgetOnFooCallback",
];
Every managed-FFI adapter (lang_lua/managed.rs, lang_ruby/managed.rs,
…) iterates this list, so adding the entry here is enough — no per-language
edit needed.
The wrapper name here is the struct name without the Az prefix
and without the Type suffix. (The codegen helper strips them in
wrapper_name(cb).)
3. Rebuild the dll, rerun codegen
cargo build --release -p azul-dll
cargo run --bin azul-doc -- codegen all
That re-emits azul.lua, azul.rb, Azul.php, azul.js, azul.lisp,
Azul.cs, AzulHostInvoker.java, Azul.kt, and Azul.psm1 with the
new kind wired up automatically. There's no per-language „register“
step; every adapter walks the same allowlist.
How to add a new language adapter
Three tiers, picked by the host language's FFI capabilities.
Tier A — Native struct-by-value + closures
Languages that can synthesise a C-callable function pointer from a
host-side closure with aggregate args:
C# / .NET (P/Invoke + delegates), Java/Kotlin (JNA Callback), Python
(PyO3, compiled in), Haskell (foreign export ccall "wrapper"), Zig,
Go (with cgo).
These don't need the host-invoker pattern, but apply it anyway for
uniformity. lang_csharp/managed.rs is the reference. The shape:
- A sibling
NativeMethodsManagedclass (or namespace) holding[DllImport]declarations for the host-invoker C-ABI exports. - A static
HostInvokerclass withRegisterCallback(...)factories per kind, plusRefanyCreate(value)/RefanyGet(refanyPtr). - Per-kind delegate types matching the libffi pointer-arg invoker signature.
- GC-pinning is one static
List<Delegate>soMarshal.GetFunctionPointerForDelegate(delegate)can't have its trampoline collected.
Tier B — No closures, struct-by-value works
Static-procedure languages — Fortran, COBOL, Ada, Pascal, FreeBASIC,
VB6, Algol 68. Closures don't exist; the user defines a static
procedure and stashes per-instance state in the RefAny.
These don't need a managed.rs at all. The codegen emits ordinary
bind(c) / Convention(C) / cdecl; declarations against the
production azul.h and the user passes c_funloc(my_proc) directly.
The host-invoker is unused; the framework's RefAny refcount handles
lifetime.
Tier C — Libffi-restricted (the host-invoker tier)
Languages whose FFI library can't synthesise aggregate-by-value trampolines: Lua (LuaJIT FFI), Ruby (ruby-ffi), Perl (FFI::Platypus), PHP (built-in FFI), OCaml (ctypes), Node (koffi — Bun/Deno are technically capable but ride along for uniformity), Common Lisp (CFFI), Smalltalk (Pharo UnifiedFFI).
These need a per-language lang_<X>/managed.rs. Reference: any of
lang_lua/managed.rs or lang_php/managed.rs. The shape:
- cdef declarations for the host-invoker C-ABI exports — splice
into the language's cdef block. Reuse
managed_host_invoker::emit_cdef_block(out, ir)for a C-syntax payload that LuaJIT, PHP FFI, koffi, and CFFI all accept. - Per-kind libffi closure registration at module load — a closure
per kind whose signature is
(u64 id, …pointer args…, T* out). The closure looks up_handles[id]and dispatches. registerCallback(kind, fn)— allocates a host-handle id, stashesfn, returnsAz<Wrapper>_createFromHostHandle(id).refanyCreate(value)/refanyGet(refany)— same id-keyed path so user data and callbacks share one lifetime story.
Wire the adapter into lang_<X>/mod.rs between the existing types/functions
emitters and the wrapper emitter — the wrappers will reference
registerCallback once you teach them to (see „Wrapper substitution“
below).
Wrapper substitution (idiomatic call sites)
By default a Tier C user has to write:
local on_click_cb = azul.registerCallback('Callback', on_click)
button:set_on_click(data:clone(), on_click_cb)
The wrapper-emitter substitution in lang_<X>/wrappers.rs lets the user
write:
button:set_on_click(data:clone(), on_click) -- closure handed in directly
The substitution rule, implemented in lang_lua/wrappers.rs::emit_callback_pin_lines:
- For every method arg whose IR
callback_infoisSome, prependarg = azul._register_callback('<Wrapper>', arg)before the C call. - Special-case: when the C ABI takes the raw fn pointer typedef (e.g.
WindowCreateOptions::create(LayoutCallbackType)— passes the cb but drops ctx), bypass via_default()+ direct field assignment so the host-handle ctx survives.
Tier A languages can do the same substitution targeting their native delegate type. Tier B doesn't need it — static procedures don't have an „is this a closure?“ question.
Why PHP is different
Of every libffi-adjacent FFI binding we ship, PHP is the unique
outlier: standard php-ffi rejects closure-to-fnpointer entirely, by
design. The php-ffi authors closed this off for memory-safety reasons
— a PHP closure can be GC'd while C still holds the function pointer.
We can confirm: every other binding in the matrix below supports closure-as-fnpointer for at least pointer-arg signatures (the host-invoker pattern's bread and butter):
| Binding | Closure-as-fnpointer | Since |
|---|---|---|
| LuaJIT FFI | ffi.cast(typedef, fn) |
LuaJIT 2.0 (~2010) |
| ruby-ffi | FFI::Function.new |
ffi gem 1.0 (~2009) |
| Python ctypes | CFUNCTYPE |
Python 2.5 (2006) |
| CFFI (Common Lisp) | defcallback |
0.1 (~2005) |
| Perl FFI::Platypus | closure |
0.20 (~2015) |
| OCaml ctypes | Foreign.funptr |
0.1 (~2013) |
| Node koffi | koffi.register |
v1 (~2022) |
Bun bun:ffi |
JSCallback |
Bun 0.5 (~2023) |
| Deno | UnsafeCallback |
1.30 (~2023) |
| C# P/Invoke | Marshal.GetFunctionPointerForDelegate |
.NET 1.0 |
| Java/Kotlin JNA | Callback interface |
JNA 1.0 |
| PHP FFI | never — by design | 7.4 (2019) → 8.5+ |
The status is unchanged across PHP 7.4 / 8.0 / 8.1 / 8.2 / 8.3 / 8.4 / 8.5. There's no version-conditional fallback to enable.
PHP workarounds (today, all imperfect)
dstogov/php-ffi-callback— a third-party PECL-style extension by the original php-ffi author that addsFFI::createCallback(). Requires native compilation against your specific PHP point release.php-ffi-callableby 7php — similar third-party extension, also source-only.- Polling — Azul could expose a thread-safe event ring buffer that PHP polls each tick. No callbacks at all. Reshapes the API model.
- Native PHP extension (preferred long-term, see below).
The current lang_php/managed.rs adapter goes through the host-invoker
plumbing and works for the non-callback half of the API: POD wrappers,
RefAny construction, FFI::cast for type conversion, raw FFI dispatch.
Anything that reaches Azul::registerCallback(...) will fatal at the
$ffi->cast(typedef, $closure) call until one of the workarounds is
in play.
Planned: php-extension Cargo feature (future work)
The cleanest long-term answer mirrors how Python is wired today.
The python-extension Cargo feature compiles azul-dll as a Python
extension via PyO3, using the python-extension
feature flag in dll/Cargo.toml:
python-extension = ["build-dll", "pyo3", "use_pyo3_logger", "link-static"]
The codegen emits target/codegen/python_api.rs carrying
#[pyclass] / #[pymethods] / #[pymodule] annotations over the same
IR; dll/src/lib.rs includes that file under
#[cfg(feature = "python-extension")] and re-exports PyInit_azul. A
single cargo build --release -p azul-dll --features python-extension
yields a libazul.dylib that loads as a Python extension. Closures
work natively because they're dispatched inside the same interpreter
Rust has access to via PyO3.
The PHP analog is ext-php-rs
— the PyO3-shaped Rust crate for writing PHP extensions. The plan:
# dll/Cargo.toml (planned)
ext-php-rs = { version = "0.13", optional = true }
php-extension = ["build-dll", "ext-php-rs", "link-static"]
doc/src/codegen/v2/lang_php_extension/ # new emitter, mirrors lang_python.rs
# walks the IR, emits #[php_class],
# #[php_function], #[php_module]
target/codegen/php_api.rs # generated annotated Rust source
dll/src/lib.rs # gated `mod php { include!(...) }`
# + `pub use php::module_entry;`
User-side: php -d extension=azul.so hello-world.php. The whole
lang_php/ FFI-based adapter becomes a fallback for users who can't
or don't want to install a native extension.
Friction differences vs. Python
| PyO3 | ext-php-rs | |
|---|---|---|
| ABI stability | Python abi3 — one binary works across Py 3.7+ |
No abi3 — every PHP point release (8.0/8.1/8.2/8.3/8.4/8.5) needs its own .so |
| Distribution | PyPI binary wheels via pip install |
PECL is source-only; binary distribution requires our own channel |
| Maturity | Massive ecosystem | Smaller (1k★) but actively maintained |
Proposed CI shape
The php-extension artifact is conditionally built in a separate
„full release“ CI job — not on the per-PR test path. Per-PR PHP
testing continues to use the FFI-based Azul.php adapter (which
covers everything except callbacks). The full-release job runs the
N-version PHP matrix (currently 8.0–8.5 = 6 builds) in parallel and
publishes the resulting azul-php-{version}-{platform}.so artifacts
alongside the regular release.
Scope estimate
Roughly a one- to two-day spike:
- ~1 day:
ext-php-rsintegration indll/Cargo.toml+build.rshook +lib.rsgate + alang_php_extensioncodegen emitter modeled line-for-line onlang_python.rs. - ~½ day: per-PHP-version CI matrix (separate job, full-release only) + binary publishing flow.
- ~½ day: api.json install-instructions update, hello-world.php update, internals doc cross-link from this section.
Tracked separately. The host-invoker pattern's pure-FFI path stays the primary documented entrypoint for languages where it actually works (every binding in the table above except PHP).
Generic byte-buffer invoker (fallback path)
The macro-generated thunks have a second dispatch path that fires when no per-kind invoker is registered: the generic invoker. Hosts can register one libffi closure that handles every kind, with the wrapper name carried as a string and args carried as a pointer array.
C-ABI shape:
typedef void (*AzGenericInvoker)(
uint64_t handle, /* host-handle id */
const char* kind, /* null-terminated wrapper name */
const void* const* args, /* one pointer per by-value arg, in declared order */
size_t n_args, /* args[] length */
void* ret /* where to write the return value */
);
extern void AzApp_setGenericInvoker(AzGenericInvoker);
When the per-kind invoker slot is empty, the macro's thunk packs
(&data, &info, &extras…) into a stack array and forwards through
AzApp_setGenericInvoker. The host decides what to do per kind from the
kind string.
Two use cases this supports:
- Single dispatch site for every kind. Hosts that prefer one libffi
closure to cover all kinds (and dispatch internally on the kind name)
can skip per-kind
AzApp_set<Kind>Invokercalls entirely. This shrinks the prelude in languages where every libffi cast costs noticeable code bytes. - User-defined custom kinds. A user in a Tier-C host can add a new
callback kind by emitting an
impl_managed_callback!invocation in a downstream Rust crate (or shipping their own static thunks via a small C trampoline) without ever touchingHOST_INVOKER_KINDS. The generic invoker fires whenever the per-kind setter wasn't called.
Tests in core/tests/host_invoker.rs cover the slot registration path;
the integration path is exercised through examples/lua/hello-world.lua
when AzApp_setCallbackInvoker is registered (per-kind path) and
through any host that registers only AzApp_setGenericInvoker instead.
Tests
Process-global slots make these tests serialise on a Mutex, but the
coverage is enough:
host_handle_to_refany(id)round-trips throughrefany_to_host_handle.- The destructor stamped into host-handle RefAnys forwards the id to the registered releaser exactly once, when the last clone drops.
refany_to_host_handlereturnsNonefor unrelated RefAnys (so a user-data RefAny accidentally fed into a callback's ctx slot can't free a foreign id).- The macro-generated thunks short-circuit safely when no invoker has been registered yet.
cargo test -p azul-core --test host_invoker
The end-to-end „thunk fires invoker with the right by-value args“ path
is exercised through examples/lua/hello-world.lua — the click counter
increments through on_click → Lua → counter mutation → next-frame layout cb, which only works if every layer of the host-invoker
plumbing is wired correctly.