Skip to content

feat(ffi): experimental reverse FFI — host-implemented plugin interfaces ({.ffiReverse.} / {.ffiReverseEvent.}) - #154

Draft
NagyZoltanPeter wants to merge 4 commits into
masterfrom
experimental_reverse_ffi_for_plugins
Draft

feat(ffi): experimental reverse FFI — host-implemented plugin interfaces ({.ffiReverse.} / {.ffiReverseEvent.})#154
NagyZoltanPeter wants to merge 4 commits into
masterfrom
experimental_reverse_ffi_for_plugins

Conversation

@NagyZoltanPeter

Copy link
Copy Markdown
Collaborator

Closes #153

Experimental reverse FFI: a library can declare interfaces the host fulfils at runtime (plugin/SPI direction), and event handlers the host emits into. Design and phase-by-phase plan: REVERSE_FFI_PLAN.md (option B — delivery on the event dispatch thread, async any-thread reply ABI).

What's in here

Pragmas

# Host-implemented interface: bodyless, awaited from any {.ffi.} handler.
proc fetchHostClock(precision: string): Future[Result[HostClock, string]] {.ffiReverse.}

# Host-emitted event: the body is the handler, run on the FFI processing thread.
proc onHostTick(tickNo: int) {.ffiReverseEvent.} =
  lastHostTick = tickNo

Runtime (ffi/ffi_reverse.nim + thread wiring)

  • The caller stub CBOR-encodes args, parks a chronos future keyed by a monotonic call id, and rides the existing event ring (QueuedEvent gains a kind; ekListener = 0 keeps zero-init paths intact). The FFI processing thread stays free while the call is parked.
  • The host impl is invoked on the event dispatch thread (registry mirrors the event registry's snapshot-dispatch/wait-out semantics; set_impl(NULL) unregisters and waits an in-flight invocation out).
  • Replies come from any host thread via <lib>_reverse_reply: c_malloc'd CBOR into an intrusive mailbox, woken through the existing reqSignal (no extra ThreadSignalPtr — refc cannot close them), decoded on the FFI thread's own heap. No Nim ref ever crosses a thread boundary; behavior is identical under refc and orc.
  • Mandatory deadline per call (ReverseCallTimeoutMs, per-proc {.ffiReverse("wire", timeout = ms).}); unfulfilled interfaces fail fast; late/stale replies are dropped by call-id lookup.
  • Recycle/shutdown fail parked reverse calls before the drain, so a handler awaiting the host cannot hold drainOngoing until its deadline and trip the fix(ffi): quarantine a context whose {.ffiDtor.} did not finish #151 DrainTimeout quarantine.
  • {.ffiReverseEvent.} is sugar over the one-way request path: generated <lib>_emit_<wire> enqueues a fire-and-forget request; the return code reports the enqueue only.

C bindings (CBOR ABI)

Raw exports plus typed ctx-wrapper sugar: _ctx_set_<wire>_impl, _decode_<wire>_args, _ctx_reverse_reply_<wire> (payload-less for void replies), _ctx_reverse_reply_err, _ctx_emit_<wire>. An abi = c library with reverse declarations fails C binding generation loudly.

Example + docs

examples/timer gains fetch_host_clock / on_host_tick with driver methods; all four binding sets regenerated. README documents the pragmas and a semantics table; CHANGELOG entry added.

Verification

Check Result
nimble test (all unit tests, orc + refc) 60 binaries green, 0 failures
New unit tests (test_ffi_reverse_state, test_ffi_reverse, test_ffi_reverse_macro) 23 tests: state, ring, timeout, late-reply drop, recycle-with-inflight, generated exports end-to-end
test_c_codegen +7 reverse header assertions
nimble test_c_e2e C host: typed args decode + inline reply on the dispatch thread, fail-fast, unregister, reverse event
nimble test_cpp_e2e, nimble check_bindings green
ASAN+UBSAN and TSAN, orc × refc, on the three reverse test files clean

Note: test_event_dispatch fails under ASAN+orc identically on master (cross-thread table dealloc in deinitEventRegistry; possibly the local mixed-toolchain setup) — pre-existing, untouched here.

Out of scope (follow-ups)

cpp/rust/cddl reverse codegen, abi = c wire shape for reverse, {.ffiHandle.} params in reverse calls, host-side cancellation, re-entrant reverse calls from inside an impl.

🤖 Generated with Claude Code

NagyZoltanPeter and others added 2 commits August 21, 2026 16:03
…rseEvent.}) (#153)

Host-implemented plugin interfaces, delivered on the event dispatch thread
with an async any-thread reply ABI:

- ffi/ffi_reverse.nim: impl registry (snapshot-dispatch semantics like the
  event registry), monotonic call ids, c_malloc reply mailbox, and the
  FFI-thread call/drain helpers (ffiReverseCall parks a chronos future under
  a deadline; the thread keeps processing requests).
- Event ring records gain a kind (ekListener = 0 keeps zero-init paths) so
  reverse invocations ride the existing SPSC queue; a full ring fails the
  caller's future instead of tripping the sticky stuck flag.
- Replies wake the FFI thread through the existing reqSignal (no 7th
  ThreadSignalPtr; refc cannot close them) and are decoded on its own heap —
  no Nim ref ever crosses threads, identical under refc and orc.
- Recycle/shutdown fail pending reverse calls up front, so a parked handler
  cannot hold drainOngoing until its deadline and trip a DrainTimeout
  quarantine.
- {.ffiReverse.}: bodyless proc -> async caller stub + generated
  <lib>_set_<wire>_impl export; declareLibrary emits <lib>_reverse_reply.
- {.ffiReverseEvent.}: proc body becomes the handler; sugar over the one-way
  request path with a generated fire-and-forget <lib>_emit_<wire> export.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…153)

- The CBOR C header declares the raw reverse exports (FFIReverseImpl typedef,
  <lib>_set_<wire>_impl, <lib>_reverse_reply, <lib>_emit_<wire>) plus typed
  ctx-wrapper sugar: _ctx_set_*_impl, _decode_*_args, _ctx_reverse_reply_*
  (payload-less for void replies), _ctx_reverse_reply_err and _ctx_emit_*,
  with buffer adapters for the reverse-direction types.
- An `abi = c` library with reverse declarations fails C binding generation
  loudly instead of emitting an unusable header.
- examples/timer gains a fetch_host_clock {.ffiReverse.} interface, an
  on_host_tick {.ffiReverseEvent.} handler and driver methods; all four
  binding sets regenerated (cpp/rust/cddl carry only the new forward methods).
- The C e2e suite covers the full host side: typed args decode + inline reply
  on the dispatch thread, fail-fast on an unfulfilled interface, unregister,
  and a host-emitted reverse event observed through a normal method.
- README (pragma table + "Reverse FFI" section with a semantics table) and
  CHANGELOG.

Verified: nimble test (60/60 green, orc+refc), test_c_e2e, test_cpp_e2e,
check_bindings; the three reverse unit test files are ASAN+UBSAN- and
TSAN-clean under both orc and refc. Note: tests/unit/test_event_dispatch
fails under ASAN+orc identically on master (pre-existing, not from this
change).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
NagyZoltanPeter and others added 2 commits August 21, 2026 16:54
…stack (#153)

The reverse-FFI ring record added kind (enum) + callId (uint64) fields, growing
QueuedEvent 40 -> 56 bytes. The ring is embedded 1024x per context and 32x per
pool, so FFIContextPool grew ~2.0 MB -> ~2.5 MB — past the 2 MB mingw-w64 stack
reserve — and every Windows CI suite whose tests stack-allocate a pool
(test_ffi_context, test_gc_compat, test_event_listener_reentrancy, the recycle
suite) crashed at the first test frame with no output. Linux/macOS were safe
behind 8 MB stacks, and suites using the module-global declareLibrary pool
passed everywhere.

Fix: kind packs into the existing padding next to the HeapOwned bools, and the
call id rides as an 8-byte native-endian prefix of the ekReverse payload
(ReverseCallIdPrefixLen) instead of a per-slot field. QueuedEvent lands at 32
bytes — 8 below master — and the pool at ~1.7 MB.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
nph 0.7.0 (the version the linters workflow pins), no functional change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: reverse FFI — host-implemented plugin interfaces ({.ffiReverse.} / {.ffiReverseEvent.})

1 participant