feat(codegen): native typed events for the Rust generator

Adds native (zero-CBOR) event support to the Rust generator, mirroring the
cpp_native / Go event path: a per-event `add_<wire>_listener` registrar takes a
closure, boxes it, and registers it through the bare `<lib>_add_event_listener`
(native) entry point. The extern "C" trampoline reads the payload as the raw
C-POD struct and hands the consumer a borrowed idiomatic value via from_c — no
serialization on the hot path.

The node owns the boxed closures in a Mutex<HashMap<id, Box<dyn Any>>> keyed by
listener id so they outlive the call, and `remove_event_listener` drops them and
calls the bare remove entry point. Event externs are only emitted when the
library declares events, so event-free crates stay minimal.

Verified end-to-end: the demo registers a listener, echo fires on_echo_fired
inline, the typed EchoEvent reaches the closure, and removal returns true.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ivan FB 2026-05-31 19:05:38 +02:00
parent 99fdcfdff7
commit 88c7c8b8b7
No known key found for this signature in database
GPG Key ID: DF0C67A04C543270
5 changed files with 107 additions and 9 deletions

View File

@ -27,6 +27,11 @@ complex, schedule all generate and round-trip typed values. `to_c` returns a
holder that owns the `CString`s and C-array backing (heap, so the C struct's raw
pointers stay valid across the move and for the call).
Still to come: native typed events and the native-bare / `_cbor` reconciliation.
Native typed events are supported too: `add_<event>_listener` takes a closure
receiving the payload as a borrowed idiomatic struct (the trampoline reads the
raw C-POD directly — no CBOR), the handle goes through `remove_event_listener`,
and the node owns the boxed closures for their lifetime.
Still to come: the native-bare / `_cbor` filename reconciliation.
Linking is left to the consumer (`-L <dir> -l my_timer` + rpath, as in
`examples/demo.rs`); a build.rs that compiles the dylib can be added later.

View File

@ -2,8 +2,16 @@ use my_timer_native::*;
fn main() {
let node = MyTimerNode::new(TimerConfig { name: "rust-native-gen".into() }).unwrap();
println!("version: {}", node.version().unwrap());
// Native typed event: echo fires on_echo_fired inline with a raw C-POD
// EchoEvent payload, delivered straight to the closure (no CBOR decode).
let h = node.add_on_echo_fired_listener(|e: &EchoEvent| {
println!("event on_echo_fired: message={:?} echo_count={}", e.message, e.echo_count);
});
let r = node.echo(EchoRequest { message: "hello from generated Rust".into(), delay_ms: 5 }).unwrap();
println!("echo: echoed={} timer_name={}", r.echoed, r.timer_name);
println!("removed listener: {}", node.remove_event_listener(h));
let c = node.complex(ComplexRequest {
messages: vec![EchoRequest { message: "one".into(), delay_ms: 0 },

View File

@ -46,7 +46,20 @@ unsafe extern "C" fn cb_my_timer_schedule(ret: c_int, msg: *const c_char, len: u
let _ = tx.send(r);
}
pub struct MyTimerNode { ctx: *mut c_void }
struct OnEchoFiredHandler { f: Box<dyn Fn(&EchoEvent) + Send + Sync> }
unsafe extern "C" fn on_echo_fired_trampoline(ret: c_int, msg: *const c_char, _len: usize, ud: *mut c_void) {
if ud.is_null() || ret != RET_OK || msg.is_null() { return; }
let h = &*(ud as *const OnEchoFiredHandler);
(h.f)(&EchoEvent::from_c(&*(msg as *const ffi::EchoEvent)));
}
#[derive(Clone, Copy)]
pub struct ListenerHandle { pub id: u64 }
pub struct MyTimerNode {
ctx: *mut c_void,
listeners: std::sync::Mutex<std::collections::HashMap<u64, Box<dyn std::any::Any + Send>>>,
}
unsafe impl Send for MyTimerNode {}
unsafe impl Sync for MyTimerNode {}
@ -59,7 +72,7 @@ impl MyTimerNode {
let res = rx.recv().map_err(|_| String::from("callback channel closed"))?;
res?;
if ctx.is_null() { return Err(String::from("my_timer_create returned null")); }
Ok(MyTimerNode { ctx })
Ok(MyTimerNode { ctx, listeners: std::sync::Mutex::new(std::collections::HashMap::new()) })
}
pub fn echo(&self, req: EchoRequest) -> Result<EchoResponse, String> {
@ -111,6 +124,22 @@ impl MyTimerNode {
rx.recv().map_err(|_| String::from("callback channel closed"))?
}
pub fn add_on_echo_fired_listener<F>(&self, handler: F) -> ListenerHandle
where F: Fn(&EchoEvent) + Send + Sync + 'static {
let owned: Box<OnEchoFiredHandler> = Box::new(OnEchoFiredHandler { f: Box::new(handler) });
let raw = &*owned as *const OnEchoFiredHandler as *mut c_void;
let id = unsafe { ffi::my_timer_add_event_listener(self.ctx, b"on_echo_fired\0".as_ptr() as *const c_char, on_echo_fired_trampoline, raw) };
if id != 0 { self.listeners.lock().unwrap().insert(id, owned as Box<dyn std::any::Any + Send>); }
ListenerHandle { id }
}
pub fn remove_event_listener(&self, handle: ListenerHandle) -> bool {
if handle.id == 0 { return false; }
let rc = unsafe { ffi::my_timer_remove_event_listener(self.ctx, handle.id) };
self.listeners.lock().unwrap().remove(&handle.id);
rc == RET_OK
}
}
impl Drop for MyTimerNode {

View File

@ -97,4 +97,6 @@ extern "C" {
pub fn my_timer_complex(ctx: *mut c_void, callback: FFICallback, user_data: *mut c_void, req: ComplexRequest) -> c_int;
pub fn my_timer_schedule(ctx: *mut c_void, callback: FFICallback, user_data: *mut c_void, job: JobSpec, retry: RetryPolicy, schedule: ScheduleConfig) -> 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_remove_event_listener(ctx: *mut c_void, listener_id: u64) -> c_int;
}

View File

@ -81,7 +81,8 @@ proc procSimple(p: FFIProcMeta, types: seq[FFITypeMeta]): bool =
# ── ffi.rs ──────────────────────────────────────────────────────────────────
proc emitFfiRs(
procs: seq[FFIProcMeta], types: seq[FFITypeMeta], libName: string
procs: seq[FFIProcMeta], types: seq[FFITypeMeta], libName: string,
events: seq[FFIEventMeta] = @[]
): string =
var L: seq[string] = @[]
L.add("// Generated by nim-ffi native Rust codegen. Do not edit by hand.")
@ -129,6 +130,11 @@ proc emitFfiRs(
L.add(" pub fn " & p.procName & "(" & ps.join(", ") & ") -> c_int;")
of FFIKind.DTOR:
L.add(" pub fn " & p.procName & "(ctx: *mut c_void) -> c_int;")
if events.len > 0:
# Native event registration: the bare (non-_cbor) listener delivers the
# payload as the raw C-POD struct, so the trampoline reads it directly.
L.add(" pub fn " & libName & "_add_event_listener(ctx: *mut c_void, event_name: *const c_char, callback: FFICallback, user_data: *mut c_void) -> u64;")
L.add(" pub fn " & libName & "_remove_event_listener(ctx: *mut c_void, listener_id: u64) -> c_int;")
L.add("}")
return L.join("\n")
@ -277,9 +283,11 @@ proc rustMethod(procName, libName: string): string =
camelToSnakeCase(bare)
proc emitApiRs(
procs: seq[FFIProcMeta], types: seq[FFITypeMeta], libName: string
procs: seq[FFIProcMeta], types: seq[FFITypeMeta], libName: string,
events: seq[FFIEventMeta] = @[]
): string =
let nodeT = snakeToPascalCase(libName) & "Node" # idiomatic Rust: MyTimerNode
let hasEvents = events.len > 0
var L: seq[string] = @[]
L.add("// Generated by nim-ffi native Rust codegen. Do not edit by hand.")
L.add("#![allow(dead_code)]")
@ -319,8 +327,29 @@ proc emitApiRs(
L.add(" } else { Err(err_text(msg, len)) };")
L.add(" let _ = tx.send(r);")
L.add("}")
# Per-event handler boxes + native trampolines (read the raw C-POD payload).
for e in events:
let pt = capitalizeFirstLetter(e.payloadTypeName)
let hs = snakeToPascalCase(e.wireName) & "Handler"
L.add("")
L.add("struct " & hs & " { f: Box<dyn Fn(&" & pt & ") + Send + Sync> }")
L.add("unsafe extern \"C\" fn " & e.wireName & "_trampoline(ret: c_int, msg: *const c_char, _len: usize, ud: *mut c_void) {")
L.add(" if ud.is_null() || ret != RET_OK || msg.is_null() { return; }")
L.add(" let h = &*(ud as *const " & hs & ");")
L.add(" (h.f)(&" & pt & "::from_c(&*(msg as *const ffi::" & pt & ")));")
L.add("}")
if hasEvents:
L.add("")
L.add("#[derive(Clone, Copy)]")
L.add("pub struct ListenerHandle { pub id: u64 }")
L.add("")
L.add("pub struct " & nodeT & " { ctx: *mut c_void }")
if hasEvents:
L.add("pub struct " & nodeT & " {")
L.add(" ctx: *mut c_void,")
L.add(" listeners: std::sync::Mutex<std::collections::HashMap<u64, Box<dyn std::any::Any + Send>>>,")
L.add("}")
else:
L.add("pub struct " & nodeT & " { ctx: *mut c_void }")
L.add("unsafe impl Send for " & nodeT & " {}")
L.add("unsafe impl Sync for " & nodeT & " {}")
L.add("")
@ -346,7 +375,10 @@ proc emitApiRs(
L.add(" let res = rx.recv().map_err(|_| String::from(\"callback channel closed\"))?;")
L.add(" res?;")
L.add(" if ctx.is_null() { return Err(String::from(\"" & p.procName & " returned null\")); }")
L.add(" Ok(" & nodeT & " { ctx })")
if hasEvents:
L.add(" Ok(" & nodeT & " { ctx, listeners: std::sync::Mutex::new(std::collections::HashMap::new()) })")
else:
L.add(" Ok(" & nodeT & " { ctx })")
L.add(" }")
L.add("")
@ -379,6 +411,28 @@ proc emitApiRs(
L.add(" }")
L.add("")
# event listeners: one typed registrar per event + a remove by handle.
for e in events:
let pt = capitalizeFirstLetter(e.payloadTypeName)
let hs = snakeToPascalCase(e.wireName) & "Handler"
L.add(" pub fn add_" & e.wireName & "_listener<F>(&self, handler: F) -> ListenerHandle")
L.add(" where F: Fn(&" & pt & ") + Send + Sync + 'static {")
L.add(" let owned: Box<" & hs & "> = Box::new(" & hs & " { f: Box::new(handler) });")
L.add(" let raw = &*owned as *const " & hs & " as *mut c_void;")
L.add(" let id = unsafe { ffi::" & libName & "_add_event_listener(self.ctx, b\"" & e.wireName & "\\0\".as_ptr() as *const c_char, " & e.wireName & "_trampoline, raw) };")
L.add(" if id != 0 { self.listeners.lock().unwrap().insert(id, owned as Box<dyn std::any::Any + Send>); }")
L.add(" ListenerHandle { id }")
L.add(" }")
L.add("")
if hasEvents:
L.add(" pub fn remove_event_listener(&self, handle: ListenerHandle) -> bool {")
L.add(" if handle.id == 0 { return false; }")
L.add(" let rc = unsafe { ffi::" & libName & "_remove_event_listener(self.ctx, handle.id) };")
L.add(" self.listeners.lock().unwrap().remove(&handle.id);")
L.add(" rc == RET_OK")
L.add(" }")
L.add("")
# dtor
for p in procs:
if p.kind == FFIKind.DTOR:
@ -402,6 +456,6 @@ proc generateRustNativeCrate*(
"[package]\nname = \"" & libName & "_native\"\nversion = \"0.1.0\"\nedition = \"2021\"\n")
writeFile(outputDir / "src" / "lib.rs",
"mod ffi;\nmod types;\nmod api;\npub use types::*;\npub use api::*;\n")
writeFile(outputDir / "src" / "ffi.rs", emitFfiRs(procs, types, libName))
writeFile(outputDir / "src" / "ffi.rs", emitFfiRs(procs, types, libName, events))
writeFile(outputDir / "src" / "types.rs", emitTypesRs(types))
writeFile(outputDir / "src" / "api.rs", emitApiRs(procs, types, libName))
writeFile(outputDir / "src" / "api.rs", emitApiRs(procs, types, libName, events))