> Cinder 0.1.0 documentation: ADR-0003: One C11 shim owning every callback; Mojo pulls events
> Canonical page: https://cinder.ash10.dev/docs/adr/0003-native-shim-pull-model/ · Source: `docs/adr/0003-native-shim-pull-model.md` in the engine repository (engine commit 47c9352, imported 2026-11-09)
> Index of every page for agents: https://cinder.ash10.dev/llms.txt

# 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 in
  `native/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.1` leaves `wgpuInstanceWaitAny` in `src/unimplemented.rs`, so the
  shim exposes `cn_wgpu_pump(instance, device, wait)` (`wgpuInstanceProcessEvents` plus
  `wgpuDevicePoll`) 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 as `qsort`-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.
