From 88c7c8b8b76598502e2d05475a65244c570b5d48 Mon Sep 17 00:00:00 2001 From: Ivan FB Date: Sun, 31 May 2026 19:05:38 +0200 Subject: [PATCH] feat(codegen): native typed events for the Rust generator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds native (zero-CBOR) event support to the Rust generator, mirroring the cpp_native / Go event path: a per-event `add__listener` registrar takes a closure, boxes it, and registers it through the bare `_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>> 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 --- examples/timer/rust_native_bindings/README.md | 7 +- .../rust_native_bindings/examples/demo.rs | 8 +++ .../timer/rust_native_bindings/src/api.rs | 33 +++++++++- .../timer/rust_native_bindings/src/ffi.rs | 2 + ffi/codegen/rust_native.nim | 66 +++++++++++++++++-- 5 files changed, 107 insertions(+), 9 deletions(-) diff --git a/examples/timer/rust_native_bindings/README.md b/examples/timer/rust_native_bindings/README.md index 1b4f55d..ffc7b0f 100644 --- a/examples/timer/rust_native_bindings/README.md +++ b/examples/timer/rust_native_bindings/README.md @@ -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__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 -l my_timer` + rpath, as in `examples/demo.rs`); a build.rs that compiles the dylib can be added later. diff --git a/examples/timer/rust_native_bindings/examples/demo.rs b/examples/timer/rust_native_bindings/examples/demo.rs index e4b3815..390da20 100644 --- a/examples/timer/rust_native_bindings/examples/demo.rs +++ b/examples/timer/rust_native_bindings/examples/demo.rs @@ -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 }, diff --git a/examples/timer/rust_native_bindings/src/api.rs b/examples/timer/rust_native_bindings/src/api.rs index 38b2b30..24421f1 100644 --- a/examples/timer/rust_native_bindings/src/api.rs +++ b/examples/timer/rust_native_bindings/src/api.rs @@ -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 } +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>>, +} 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 { @@ -111,6 +124,22 @@ impl MyTimerNode { rx.recv().map_err(|_| String::from("callback channel closed"))? } + pub fn add_on_echo_fired_listener(&self, handler: F) -> ListenerHandle + where F: Fn(&EchoEvent) + Send + Sync + 'static { + let owned: Box = 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); } + 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 { diff --git a/examples/timer/rust_native_bindings/src/ffi.rs b/examples/timer/rust_native_bindings/src/ffi.rs index 7ada8fd..34063f2 100644 --- a/examples/timer/rust_native_bindings/src/ffi.rs +++ b/examples/timer/rust_native_bindings/src/ffi.rs @@ -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; } \ No newline at end of file diff --git a/ffi/codegen/rust_native.nim b/ffi/codegen/rust_native.nim index bc0a75f..1761217 100644 --- a/ffi/codegen/rust_native.nim +++ b/ffi/codegen/rust_native.nim @@ -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 }") + 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>>,") + 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(&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); }") + 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))