ADR-0003: One C11 shim owning every callback; Mojo pulls events
Status: Accepted, 2026-11-08 (callback-in-Mojo question deferred to spike 4)
Context #
The pinned libraries deliver results through C callbacks: wgpu async requests (adapter, device, buffer
map, queue work done), device-lost and uncaptured-error handlers, Jolt contact listeners (called from
Jolt's worker threads), miniaudio data callbacks (called from the audio device thread), SDL log hooks.
Mojo 1.2.0 can declare abi("C") thin functions, but whether they are safe to invoke from a foreign
thread with no Mojo runtime context is undocumented.
Decision #
native/builds a single shared library,libcinder_native, with a flat C11 ABI declared innative/include/cinder_native.h. It wraps the C++ dependencies (Jolt through joltc, the ImGui backends) and registers every callback itself.- Each callback writes a fixed-size, plain-data event into a fixed-capacity lock-free ring owned by the shim (SPSC where the producer is a single known thread, MPSC for Jolt contacts). The rings never allocate after creation and report overflow through a counter that Mojo checks each frame.
- Mojo drains the rings once per frame on the main thread (
cinder/platform/events,cinder/gfx/async,cinder/physics/contacts,cinder/audio/commands). - Mojo code is never executed inside a C callback. If spike 4 proves that Mojo
abi("C")functions work as callbacks on both platforms, including from foreign threads under ASan/TSan, a follow-up ADR may permit it for specific, main-thread-only callbacks. Until then the rule is absolute.
Consequences #
- Latency of one frame on async GPU results; acceptable for resource creation and readback.
- The shim is the only place that needs C++; everything above it is C ABI, which keeps bindings generation simple and the layout test complete.
- ASan/UBSan runs cover the shim and all callback paths in CI (CTest).
Amendments (2026-11-08, after spikes 1, 2 and 4) #
- Waiting. wgpu-native
v29.0.1.1leaveswgpuInstanceWaitAnyinsrc/unimplemented.rs, so the shim exposescn_wgpu_pump(instance, device, wait)(wgpuInstanceProcessEventspluswgpuDevicePoll) and Mojo awaits a result by pumping and draining its event queue with a deadline (spikes/common/ffi_util.mojo: await_event). This is the pull model with no blocking primitive at all and is what the engine's frame loop will do once per frame. - Spike 4 result. A Mojo
def ... abi("C")function is invoked correctly by C both on the calling thread and on a freshly created native thread (spikes/ffi/test_ffi_spikes.mojo). The pull model is nevertheless kept: it keeps all threading inside C where ASan/TSan cover it, and every library's callback contract (which thread, re-entrancy) stays the shim's problem. Mojo callbacks remain reserved for synchronous, same-thread C APIs such asqsort-style comparators. - Lifetimes. Any Mojo value handed to C by pointer must stay visibly live past the call
(
std.benchmark.keep), because untracked-origin pointers do not extend lifetimes (docs/MOJO_NOTES.md). The safe layer stores descriptors in engine-owned memory for this reason.