feat(ffi): native event delivery + dual-ABI event symbol naming

Events now mirror the native/CBOR split already in place for requests, with the
same symbol-naming convention:

- `<lib>_add_event_listener`      -> NATIVE listener (typed `<T>Pod` by pointer)
- `<lib>_add_event_listener_cbor` -> CBOR listener (EventEnvelope bytes)

Framework: `FFIEventListener` gains a `native` flag; `addEventListener` a
`native` param; a new `dispatchFFIEventDual` template builds the `<T>Pod` once
for native listeners (`nimToPod`/`freePod`) and the CBOR envelope once for the
rest, fanning each out — so a single `{.ffiEvent.}` dispatch serves both kinds.
`declareLibrary` exports both registration entry points.

Generators: the bare `<lib>_add_event_listener` is the native symbol; every
CBOR consumer (C/C++/Go/Rust) now targets `<lib>_add_event_listener_cbor`. The
rename and the generator updates ship together so the bare name is never briefly
broken. Bindings regenerated.

Validated: native-event unit test (typed POD to native + CBOR to cbor listener,
orc/refc/ASAN); full unit suite; C++ e2e 19/19; Go example; existing event
tests unchanged. The per-event *typed* native callback + wildcard router (the
ergonomic consumer surface) is a follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ivan FB 2026-05-31 17:13:31 +02:00
parent c000a8467d
commit 918dd72390
No known key found for this signature in database
GPG Key ID: DF0C67A04C543270
14 changed files with 283 additions and 32 deletions

View File

@ -407,7 +407,7 @@ void* echo_create_cbor(const uint8_t* req_cbor, size_t req_cbor_len, FFICallback
int echo_shout_cbor(void* ctx, FFICallback callback, void* user_data, const uint8_t* req_cbor, size_t req_cbor_len);
int echo_version_cbor(void* ctx, FFICallback callback, void* user_data, const uint8_t* req_cbor, size_t req_cbor_len);
int echo_destroy(void* ctx);
uint64_t echo_add_event_listener(void* ctx, const char* event_name, FFICallback callback, void* user_data);
uint64_t echo_add_event_listener_cbor(void* ctx, const char* event_name, FFICallback callback, void* user_data);
int echo_remove_event_listener(void* ctx, uint64_t listener_id);
} // extern "C"

View File

@ -168,7 +168,7 @@ int my_timer_schedule_cbor(void *ctx, FFICallBack callback, void *userData, cons
int my_timer_destroy(void *ctx);
uint64_t my_timer_add_event_listener(void *ctx, const char *eventName, FFICallBack callback, void *userData);
uint64_t my_timer_add_event_listener_cbor(void *ctx, const char *eventName, FFICallBack callback, void *userData);
int my_timer_remove_event_listener(void *ctx, uint64_t listenerId);
#ifdef __cplusplus

View File

@ -708,7 +708,7 @@ int my_timer_version_cbor(void* ctx, FFICallback callback, void* user_data, cons
int my_timer_complex_cbor(void* ctx, FFICallback callback, void* user_data, const uint8_t* req_cbor, size_t req_cbor_len);
int my_timer_schedule_cbor(void* ctx, FFICallback callback, void* user_data, const uint8_t* req_cbor, size_t req_cbor_len);
int my_timer_destroy(void* ctx);
uint64_t my_timer_add_event_listener(void* ctx, const char* event_name, FFICallback callback, void* user_data);
uint64_t my_timer_add_event_listener_cbor(void* ctx, const char* event_name, FFICallback callback, void* user_data);
int my_timer_remove_event_listener(void* ctx, uint64_t listener_id);
} // extern "C"
@ -846,7 +846,7 @@ public:
ListenerHandle addOnEchoFiredListener(std::function<void(const EchoEvent&)> handler) {
auto owned = std::make_unique<TypedListener<EchoEvent>>(std::move(handler));
auto* raw = owned.get();
const auto id = my_timer_add_event_listener(
const auto id = my_timer_add_event_listener_cbor(
ptr_, "on_echo_fired", &MyTimerCtx::typedTrampoline<EchoEvent>, raw);
if (id == 0) return ListenerHandle{0};
listeners_.emplace(id, std::move(owned));
@ -856,7 +856,7 @@ public:
ListenerHandle addEventListener(std::function<void(int, const std::string&, std::span<const std::uint8_t>)> handler) {
auto owned = std::make_unique<WildcardListener>(std::move(handler));
auto* raw = owned.get();
const auto id = my_timer_add_event_listener(
const auto id = my_timer_add_event_listener_cbor(
ptr_, "", &MyTimerCtx::wildcardTrampoline, raw);
if (id == 0) return ListenerHandle{0};
listeners_.emplace(id, std::move(owned));

View File

@ -10,6 +10,7 @@ package my_timer
#include <pthread.h>
extern void my_timerGoEvent(int ret, char* msg, size_t len, void* userData);
extern uint64_t my_timer_add_event_listener_cbor(void* ctx, const char* eventName, FFICallBack callback, void* userData);
extern void my_timerResultEcho(int ret, char* msg, size_t len, void* ud);
extern void my_timerResultComplex(int ret, char* msg, size_t len, void* ud);
extern void my_timerResultSchedule(int ret, char* msg, size_t len, void* ud);
@ -60,7 +61,7 @@ static int my_timerCall_my_timer_version(void* ctx, My_timerResp* r) {
return rc;
}
static int my_timerCall_my_timer_destroy(void* ctx) { return my_timer_destroy(ctx); }
static uint64_t my_timerRegisterEvents(void* ctx) { return my_timer_add_event_listener(ctx, "", (FFICallBack)my_timerGoEvent, ctx); }
static uint64_t my_timerRegisterEvents(void* ctx) { return my_timer_add_event_listener_cbor(ctx, "", (FFICallBack)my_timerGoEvent, ctx); }
*/
import "C"

View File

@ -219,7 +219,7 @@ impl MyTimerCtx {
owned: Box<dyn std::any::Any + Send>,
) -> ListenerHandle {
let id = unsafe {
ffi::my_timer_add_event_listener(self.ptr, event_name, callback, raw)
ffi::my_timer_add_event_listener_cbor(self.ptr, event_name, callback, raw)
};
if id != 0 {
self.listeners.lock().unwrap().insert(id, owned);

View File

@ -15,6 +15,6 @@ extern "C" {
pub fn my_timer_complex_cbor(ctx: *mut c_void, callback: FFICallback, user_data: *mut c_void, req_cbor: *const u8, req_cbor_len: usize) -> c_int;
pub fn my_timer_schedule_cbor(ctx: *mut c_void, callback: FFICallback, user_data: *mut c_void, req_cbor: *const u8, req_cbor_len: usize) -> c_int;
pub fn my_timer_destroy(ctx: *mut c_void) -> c_int;
pub fn my_timer_add_event_listener(ctx: *mut c_void, event_name: *const c_char, callback: FFICallback, user_data: *mut c_void) -> u64;
pub fn my_timer_add_event_listener_cbor(ctx: *mut c_void, event_name: *const c_char, callback: FFICallback, user_data: *mut c_void) -> u64;
pub fn my_timer_remove_event_listener(ctx: *mut c_void, listener_id: u64) -> c_int;
}

View File

@ -166,7 +166,9 @@ proc generateCHeader*(
)
lines.add("")
# `declareLibrary` always exports the listener-registration ABI.
# `declareLibrary` always exports the listener-registration ABI. The native
# header advertises the native listener: the callback's msg is a typed
# `const <Event>*` (cast it), not CBOR.
lines.add(
"uint64_t " & libName &
"_add_event_listener(void *ctx, const char *eventName, FFICallBack callback, void *userData);"
@ -378,7 +380,7 @@ proc generateCborCHeader*(
lines.add(
"uint64_t " & libName &
"_add_event_listener(void *ctx, const char *eventName, FFICallBack callback, void *userData);"
"_add_event_listener_cbor(void *ctx, const char *eventName, FFICallBack callback, void *userData);"
)
lines.add(
"int " & libName & "_remove_event_listener(void *ctx, uint64_t listenerId);"

View File

@ -178,7 +178,7 @@ proc emitEventDispatcher(
)
lines.add(" auto* raw = owned.get();")
lines.add(
" const auto id = $1_add_event_listener(" % [libName]
" const auto id = $1_add_event_listener_cbor(" % [libName]
)
lines.add(
" ptr_, \"$1\", &$2::typedTrampoline<$3>, raw);" %
@ -204,7 +204,7 @@ proc emitEventDispatcher(
" auto owned = std::make_unique<WildcardListener>(std::move(handler));"
)
lines.add(" auto* raw = owned.get();")
lines.add(" const auto id = $1_add_event_listener(" % [libName])
lines.add(" const auto id = $1_add_event_listener_cbor(" % [libName])
lines.add(
" ptr_, \"\", &$1::wildcardTrampoline, raw);" % [ctxTypeName]
)
@ -429,7 +429,7 @@ proc generateCppHeader*(
# `declareLibrary` always exports the listener-registration ABI. Declare
# it here so the typed event-handler wiring below can call into it.
lines.add(
"uint64_t $1_add_event_listener(void* ctx, const char* event_name, FFICallback callback, void* user_data);" %
"uint64_t $1_add_event_listener_cbor(void* ctx, const char* event_name, FFICallback callback, void* user_data);" %
[libName]
)
lines.add(

View File

@ -370,6 +370,12 @@ proc generateGoFile*(
L.add(
"extern void " & libName & "GoEvent(int ret, char* msg, size_t len, void* userData);"
)
# The Go wrapper consumes events over CBOR; the native header only declares the
# native listener, so forward-declare the CBOR registration we call below.
L.add(
"extern uint64_t " & libName &
"_add_event_listener_cbor(void* ctx, const char* eventName, FFICallBack callback, void* userData);"
)
# One exported Go result callback per struct-returning proc (it reads the typed
# return POD in-callback). Forward-declared here so cgo's `char*` shape matches.
for p in procs:
@ -491,7 +497,7 @@ proc generateGoFile*(
)
L.add(
"static uint64_t " & libName & "RegisterEvents(void* ctx) { return " & libName &
"_add_event_listener(ctx, \"\", (FFICallBack)" & libName & "GoEvent, ctx); }"
"_add_event_listener_cbor(ctx, \"\", (FFICallBack)" & libName & "GoEvent, ctx); }"
)
L.add("*/")
L.add("import \"C\"")

View File

@ -213,7 +213,7 @@ proc generateFFIRs*(procs: seq[FFIProcMeta]): string =
# Listener-registration ABI — emitted on the Nim side by `declareLibrary`,
# always present in the dylib.
lines.add(
" pub fn $1_add_event_listener(ctx: *mut c_void, event_name: *const c_char, callback: FFICallback, user_data: *mut c_void) -> u64;" %
" pub fn $1_add_event_listener_cbor(ctx: *mut c_void, event_name: *const c_char, callback: FFICallback, user_data: *mut c_void) -> u64;" %
[linkLibName]
)
lines.add(
@ -709,7 +709,7 @@ proc generateApiRs*(
lines.add(" ) -> ListenerHandle {")
lines.add(" let id = unsafe {")
lines.add(
" ffi::$1_add_event_listener(self.ptr, event_name, callback, raw)" %
" ffi::$1_add_event_listener_cbor(self.ptr, event_name, callback, raw)" %
[libName]
)
lines.add(" };")

View File

@ -41,6 +41,10 @@ type
id*: uint64
callback*: FFICallBack
userData*: pointer
native*: bool
## true -> deliver the payload as a typed `<T>Pod` by pointer (zero
## serialization, same-process). false -> deliver the CBOR
## `EventEnvelope` bytes (inter-process).
FFIEventRegistry* = object
## Per-context multi-listener registry. `lock` guards every mutation;
@ -88,11 +92,13 @@ proc addEventListener*(
eventName: string,
callback: FFICallBack,
userData: pointer,
native = false,
): uint64 {.raises: [].} =
## Registers `callback` for `eventName` and returns the listener's stable
## id (always non-zero on success). `eventName == ""` registers a wildcard
## listener that receives every dispatched event. Returns 0 if `callback`
## is nil — the only documented failure mode.
## listener that receives every dispatched event. `native` selects the
## payload form delivered on dispatch (typed `<T>Pod` pointer vs CBOR bytes).
## Returns 0 if `callback` is nil — the only documented failure mode.
if callback.isNil():
return 0
@ -101,8 +107,9 @@ proc addEventListener*(
withLock reg.lock:
reg.nextId.inc()
assigned = reg.nextId
let listener =
FFIEventListener(id: assigned, callback: callback, userData: userData)
let listener = FFIEventListener(
id: assigned, callback: callback, userData: userData, native: native
)
if eventName.len == 0:
reg.wildcard.add(listener)
else:
@ -251,3 +258,45 @@ template dispatchFFIEventCbor*(eventName: string, eventPayload: typed) =
listener.callback(
RET_OK, cast[ptr cchar](data), cast[csize_t](dataLen), listener.userData
)
template dispatchFFIEventDual*(eventName: string, eventPayload: typed) =
## Dual-ABI event dispatch used by `{.ffiEvent.}` procs — the event-side
## mirror of the native/CBOR request split. Native listeners get the payload
## as a typed `<T>Pod` by pointer (zero serialization, valid only for the
## callback's lifetime); CBOR listeners get the `EventEnvelope` bytes. Each
## form is built at most once per dispatch and fanned out to its listeners.
##
## `nimToPod`/`freePod` are the per-type POD machinery generated next to each
## `{.ffi.}` event type; `mixin` resolves them in the dispatching module.
mixin nimToPod, freePod
withFFIEventDispatch(eventName, listeners):
var nativeBuilt = false
var nativePod: typeof(nimToPod(eventPayload))
var cborBuilt = false
var cborData: ptr UncheckedArray[byte]
var cborLen: int
defer:
if nativeBuilt:
freePod(nativePod)
if cborBuilt:
cborFreeShared(cborData)
for listener in listeners:
if listener.native:
if not nativeBuilt:
nativePod = nimToPod(eventPayload)
nativeBuilt = true
listener.callback(
RET_OK, cast[ptr cchar](addr nativePod), sizeof(nativePod).csize_t,
listener.userData,
)
else:
if not cborBuilt:
(cborData, cborLen) = cborEncodeShared(
EventEnvelope[typeof(eventPayload)](
eventType: eventName, payload: eventPayload
)
)
cborBuilt = true
listener.callback(
RET_OK, cast[ptr cchar](cborData), cborLen.csize_t, listener.userData
)

View File

@ -134,7 +134,21 @@ macro declareLibrary*(libraryName: static[string], libType: untyped): untyped =
newTree(nnkExprColonExpr, ident("raises"), newTree(nnkBracket)),
)
# {libraryName}_add_event_listener
# Event registration mirrors the request naming convention: the bare
# `<lib>_add_event_listener` is the NATIVE (typed `<T>Pod` payload) entry
# point; `<lib>_add_event_listener_cbor` is the CBOR (EventEnvelope bytes) one.
# Both are exported — native for same-process consumers, CBOR for inter-process.
# Fresh param nodes per proc — AST nodes must not be shared across two procs.
proc evtParams(): seq[NimNode] =
@[
ident("uint64"),
newIdentDefs(ident("ctx"), ctxType.copyNimTree()),
newIdentDefs(ident("eventName"), ident("cstring")),
newIdentDefs(ident("callback"), ident("FFICallBack")),
newIdentDefs(ident("userData"), ident("pointer")),
]
# {libraryName}_add_event_listener (native)
let addName = libraryName & "_add_event_listener"
let addErr = "error: invalid context in " & addName
let addBody = quote:
@ -143,20 +157,33 @@ macro declareLibrary*(libraryName: static[string], libType: untyped): untyped =
echo `addErr`
return ret
let evtName = if eventName.isNil(): "" else: $eventName
ret = addEventListener(
ctx[].eventRegistry, evtName, callback, userData, native = true
)
return ret
stmts.add(
newProc(
name = ident(addName), params = evtParams(), body = addBody,
pragmas = cdeclExportPragma,
)
)
# {libraryName}_add_event_listener_cbor (CBOR / inter-process)
let addCborName = libraryName & "_add_event_listener_cbor"
let addCborErr = "error: invalid context in " & addCborName
let addCborBody = quote:
var ret: uint64 = 0
if isNil(ctx):
echo `addCborErr`
return ret
let evtName = if eventName.isNil(): "" else: $eventName
ret = addEventListener(ctx[].eventRegistry, evtName, callback, userData)
return ret
stmts.add(
newProc(
name = ident(addName),
params = @[
ident("uint64"),
newIdentDefs(ident("ctx"), ctxType),
newIdentDefs(ident("eventName"), ident("cstring")),
newIdentDefs(ident("callback"), ident("FFICallBack")),
newIdentDefs(ident("userData"), ident("pointer")),
],
body = addBody,
name = ident(addCborName), params = evtParams(), body = addCborBody,
pragmas = cdeclExportPragma,
)
)

View File

@ -1887,10 +1887,11 @@ macro ffiEvent*(wireName: static[string], prc: untyped): untyped =
if procName.kind == nnkPostfix:
userProcName = procName[1]
# The generated body: dispatchFFIEventCbor("wire_name", payload).
# The generated body: dispatchFFIEventDual("wire_name", payload) — delivers a
# typed POD to native listeners and CBOR bytes to CBOR listeners.
let wireNameLit = newStrLitNode(wireName)
let dispatchBody =
newStmtList(newCall(ident("dispatchFFIEventCbor"), wireNameLit, payloadParamName))
newStmtList(newCall(ident("dispatchFFIEventDual"), wireNameLit, payloadParamName))
var newParams = newSeq[NimNode]()
newParams.add(formalParams[0]) # return type (typically empty/void)

View File

@ -0,0 +1,165 @@
## Native (zero-serialization) event delivery: a `{.ffiEvent.}` now fans out to
## both kinds of listener at once — native listeners receive the payload as a
## typed `<T>Pod` by pointer, CBOR listeners receive the `EventEnvelope` bytes.
## Mirrors the native/CBOR split already in place for requests.
##
## The CBOR side is covered more broadly in test_event_dispatch; here we pin the
## *native* path and that both fire from one dispatch. Runs under orc + refc.
import std/[locks]
import unittest2
import results
import ffi
type EvtLib = object
type NativeEvt {.ffi.} = object
message: string
count: int
tags: seq[string]
ok: bool
proc onNativeEvt(evt: NativeEvt) {.ffiEvent: "native_evt".}
proc sampleEvt(): NativeEvt =
NativeEvt(message: "hello", count: 7, tags: @["a", "", "ccc"], ok: true)
# A request that fires the event from the FFI thread, then returns.
registerReqFFI(EmitEvtRequest, lib: ptr EvtLib):
proc(): Future[Result[string, string]] {.async.} =
onNativeEvt(sampleEvt())
return ok("emitted")
# --- native listener capture (clone the typed POD off the FFI thread) -------
type NativeCap = object
lock: Lock
cond: Cond
done: bool
ret: cint
pod: NativeEvtPod
hasPod: bool
proc initNativeCap(c: var NativeCap) =
c.lock.initLock()
c.cond.initCond()
proc deinitNativeCap(c: var NativeCap) =
c.cond.deinitCond()
c.lock.deinitLock()
proc waitNativeCap(c: var NativeCap) =
acquire(c.lock)
while not c.done:
wait(c.cond, c.lock)
release(c.lock)
proc nativeEvtCb(
ret: cint, msg: ptr cchar, len: csize_t, ud: pointer
) {.cdecl, gcsafe, raises: [].} =
let c = cast[ptr NativeCap](ud)
acquire(c[].lock)
c[].ret = ret
if ret == RET_OK and not msg.isNil:
# Native ABI: msg is a `ptr NativeEvtPod`; deep-copy it off the FFI thread.
c[].pod = clonePod(cast[ptr NativeEvtPod](msg)[])
c[].hasPod = true
c[].done = true
signal(c[].cond)
release(c[].lock)
# --- CBOR listener capture --------------------------------------------------
type CborCap = object
lock: Lock
cond: Cond
done: bool
ret: cint
msg: array[2048, byte]
msgLen: int
proc initCborCap(c: var CborCap) =
c.lock.initLock()
c.cond.initCond()
proc deinitCborCap(c: var CborCap) =
c.cond.deinitCond()
c.lock.deinitLock()
proc waitCborCap(c: var CborCap) =
acquire(c.lock)
while not c.done:
wait(c.cond, c.lock)
release(c.lock)
proc bytes(c: var CborCap): seq[byte] =
var b = newSeq[byte](c.msgLen)
if c.msgLen > 0:
copyMem(addr b[0], addr c.msg[0], c.msgLen)
return b
proc cborEvtCb(
ret: cint, msg: ptr cchar, len: csize_t, ud: pointer
) {.cdecl, gcsafe, raises: [].} =
let c = cast[ptr CborCap](ud)
acquire(c[].lock)
c[].ret = ret
let n = min(int(len), c[].msg.len)
if n > 0 and not msg.isNil:
copyMem(addr c[].msg[0], msg, n)
c[].msgLen = n
c[].done = true
signal(c[].cond)
release(c[].lock)
# A throwaway response callback to know the request (and thus the dispatch) ran.
proc rspCb(
ret: cint, msg: ptr cchar, len: csize_t, ud: pointer
) {.cdecl, gcsafe, raises: [].} =
let c = cast[ptr CborCap](ud)
acquire(c[].lock)
c[].done = true
signal(c[].cond)
release(c[].lock)
suite "native event delivery":
test "one dispatch delivers a typed POD to native + CBOR to cbor listeners":
var pool: FFIContextPool[EvtLib]
let ctx = pool.createFFIContext().valueOr:
check false
return
defer:
discard pool.destroyFFIContext(ctx)
var ncap: NativeCap
initNativeCap(ncap)
defer:
deinitNativeCap(ncap)
var ccap: CborCap
initCborCap(ccap)
defer:
deinitCborCap(ccap)
# Same event, two listeners of different formats.
check addEventListener(
ctx[].eventRegistry, "native_evt", nativeEvtCb, addr ncap, native = true
) != 0'u64
check addEventListener(
ctx[].eventRegistry, "native_evt", cborEvtCb, addr ccap, native = false
) != 0'u64
var rsp: CborCap
initCborCap(rsp)
defer:
deinitCborCap(rsp)
check sendRequestToFFIThread(ctx, EmitEvtRequest.ffiNewReq(rspCb, addr rsp)).isOk()
waitCborCap(rsp)
waitNativeCap(ncap)
waitCborCap(ccap)
# Native listener: the typed POD round-trips to the original value.
check ncap.ret == RET_OK
check ncap.hasPod
let got = podToNim(ncap.pod)
freePod(ncap.pod)
check got == sampleEvt()
# CBOR listener: the EventEnvelope decodes to the same value.
check ccap.ret == RET_OK
let decoded = cborDecode(bytes(ccap), EventEnvelope[NativeEvt])
check decoded.isOk()
check decoded.value.eventType == "native_evt"
check decoded.value.payload == sampleEvt()