feat(codegen): native C++ typed event handlers

Adds the ergonomic native event surface to the C++ generator:
`node.On<Event>(std::function<void(const <Payload>&)>)` registers a native
listener; a per-event extern "C" trampoline reads the typed POD
(`fromC(*reinterpret_cast<const ::<Payload>*>(msg))`) and invokes the handler —
no CBOR. The handler is owned by the node (a `std::map` of `ListenerBase`) so
its address stays valid until `removeEventListener`.

The example registers `OnEchoFired` and receives a typed `EchoEvent` when Echo
fires it. Verified end-to-end and ASAN-clean.

With this the native C++ generator covers the full surface: requests
(scalar/string/bool/seq/Option/nested), typed struct returns, and typed events.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ivan FB 2026-05-31 18:23:02 +02:00
parent 2302e5fb7d
commit 1fd1ad07bb
No known key found for this signature in database
GPG Key ID: DF0C67A04C543270
4 changed files with 100 additions and 1 deletions

View File

@ -34,6 +34,8 @@ echo, complex, schedule all generate and round-trip typed values (ASAN-clean).
`toC` uses a holder that owns the C-array backing while string pointers borrow
the C++ argument (valid for the call's duration; the library deep-copies).
Still to come: **native typed events** (`On<Event>` handlers) and the
Native typed events are supported too: `node.On<Event>(handler)` registers a
native listener and the typed payload arrives via `fromC` (no CBOR). Still to
come: the
native-bare / `_cbor` filename reconciliation (matching the C headers). Today
this emits `my_timer_native.hpp` so it coexists with the CBOR `my_timer.hpp`.

View File

@ -6,8 +6,13 @@ int main() {
my_timer::My_timerNode node(my_timer::TimerConfig{"cpp-native-gen"});
std::cout << "version: " << node.Version() << "\n";
my_timer::EchoEvent gotEvt;
bool got = false;
node.OnEchoFired([&](const my_timer::EchoEvent& e){ gotEvt = e; got = true; });
auto r = node.Echo(my_timer::EchoRequest{"hello from generated C++", 5});
std::cout << "echo: echoed=" << r.echoed << " timerName=" << r.timerName << "\n";
if (got) std::cout << "event OnEchoFired: message=\"" << gotEvt.message << "\" echoCount=" << gotEvt.echoCount << "\n";
// seq + Option params (ComplexRequest), typed ComplexResponse return.
my_timer::ComplexRequest creq;

View File

@ -7,7 +7,10 @@
#include "my_timer.h"
#include <cstdint>
#include <functional>
#include <future>
#include <map>
#include <memory>
#include <optional>
#include <stdexcept>
#include <string>
@ -289,7 +292,16 @@ struct AckCapture {
inline std::string rawText(const char* msg, std::size_t len) {
return (msg && len) ? std::string(msg, len) : std::string();
}
// Event listener storage: a heap handler kept alive by the node so the
// native callback's userData stays valid until removed.
struct ListenerBase { virtual ~ListenerBase() = default; };
template <typename T> struct EventListener : ListenerBase {
std::function<void(const T&)> handler;
explicit EventListener(std::function<void(const T&)> h)
: handler(std::move(h)) {}
};
} // namespace detail
struct ListenerHandle { std::uint64_t id = 0; };
extern "C" {
inline void my_timer_native_ack(int ret, const char* msg, std::size_t len, void* ud) {
@ -326,6 +338,11 @@ inline void my_timer_native_my_timer_schedule(int ret, const char* msg, std::siz
else c->err = detail::rawText(msg, len);
c->done.set_value();
}
inline void my_timer_evt_OnEchoFired(int ret, const char* msg, std::size_t, void* ud) {
auto* l = static_cast<detail::EventListener<EchoEvent>*>(ud);
if (ret == RET_OK && l->handler)
l->handler(fromC(*reinterpret_cast<const ::EchoEvent*>(msg)));
}
} // extern "C"
class My_timerNode {
@ -385,12 +402,29 @@ class My_timerNode {
return cap.value;
}
ListenerHandle OnEchoFired(std::function<void(const EchoEvent&)> handler) {
auto l = std::make_unique<detail::EventListener<EchoEvent>>(std::move(handler));
auto* raw = l.get();
const auto id = my_timer_add_event_listener(ctx_, "on_echo_fired", &my_timer_evt_OnEchoFired, raw);
if (id == 0) return ListenerHandle{0};
listeners_.emplace(id, std::move(l));
return ListenerHandle{id};
}
bool removeEventListener(ListenerHandle handle) {
if (handle.id == 0) return false;
const auto rc = my_timer_remove_event_listener(ctx_, handle.id);
listeners_.erase(handle.id);
return rc == 0;
}
~My_timerNode() { if (ctx_) my_timer_destroy(ctx_); }
My_timerNode(const My_timerNode&) = delete;
My_timerNode& operator=(const My_timerNode&) = delete;
private:
void* ctx_ = nullptr;
std::map<std::uint64_t, std::unique_ptr<detail::ListenerBase>> listeners_;
};
} // namespace my_timer

View File

@ -210,7 +210,10 @@ proc generateCppNativeHeader*(
L.add("")
L.add("#include \"" & libName & ".h\"")
L.add("#include <cstdint>")
L.add("#include <functional>")
L.add("#include <future>")
L.add("#include <map>")
L.add("#include <memory>")
L.add("#include <optional>")
L.add("#include <stdexcept>")
L.add("#include <string>")
@ -238,7 +241,18 @@ proc generateCppNativeHeader*(
L.add("inline std::string rawText(const char* msg, std::size_t len) {")
L.add(" return (msg && len) ? std::string(msg, len) : std::string();")
L.add("}")
if events.len > 0:
L.add("// Event listener storage: a heap handler kept alive by the node so the")
L.add("// native callback's userData stays valid until removed.")
L.add("struct ListenerBase { virtual ~ListenerBase() = default; };")
L.add("template <typename T> struct EventListener : ListenerBase {")
L.add(" std::function<void(const T&)> handler;")
L.add(" explicit EventListener(std::function<void(const T&)> h)")
L.add(" : handler(std::move(h)) {}")
L.add("};")
L.add("} // namespace detail")
if events.len > 0:
L.add("struct ListenerHandle { std::uint64_t id = 0; };")
L.add("")
# Find ctor / dtor.
@ -282,6 +296,17 @@ proc generateCppNativeHeader*(
L.add(" else c->err = detail::rawText(msg, len);")
L.add(" c->done.set_value();")
L.add("}")
# One native event trampoline per event: read the typed POD, call the handler.
for e in events:
if not isStructT(e.payloadTypeName, types):
continue
let pt = e.payloadTypeName
L.add("inline void " & libName & "_evt_" & snakeToPascalCase(e.wireName) &
"(int ret, const char* msg, std::size_t, void* ud) {")
L.add(" auto* l = static_cast<detail::EventListener<" & pt & ">*>(ud);")
L.add(" if (ret == RET_OK && l->handler)")
L.add(" l->handler(fromC(*reinterpret_cast<const ::" & pt & "*>(msg)));")
L.add("}")
L.add("} // extern \"C\"")
L.add("")
@ -353,6 +378,37 @@ proc generateCppNativeHeader*(
L.add(" }")
L.add("")
# Native typed event handlers: On<Event>(handler) registers a native listener
# that delivers the typed payload (read via fromC). The handler is owned by the
# node so its address stays valid until removeEventListener.
for e in events:
if not isStructT(e.payloadTypeName, types):
continue
let pascal = snakeToPascalCase(e.wireName)
let pt = e.payloadTypeName
L.add(" ListenerHandle " & pascal &
"(std::function<void(const " & pt & "&)> handler) {")
L.add(" auto l = std::make_unique<detail::EventListener<" & pt &
">>(std::move(handler));")
L.add(" auto* raw = l.get();")
L.add(" const auto id = " & libName &
"_add_event_listener(ctx_, \"" & e.wireName & "\", &" & libName & "_evt_" &
pascal & ", raw);")
L.add(" if (id == 0) return ListenerHandle{0};")
L.add(" listeners_.emplace(id, std::move(l));")
L.add(" return ListenerHandle{id};")
L.add(" }")
L.add("")
if events.len > 0:
L.add(" bool removeEventListener(ListenerHandle handle) {")
L.add(" if (handle.id == 0) return false;")
L.add(" const auto rc = " & libName &
"_remove_event_listener(ctx_, handle.id);")
L.add(" listeners_.erase(handle.id);")
L.add(" return rc == 0;")
L.add(" }")
L.add("")
if haveDtor:
L.add(" ~" & nodeT & "() { if (ctx_) " & dtor.procName & "(ctx_); }")
L.add(" " & nodeT & "(const " & nodeT & "&) = delete;")
@ -360,6 +416,8 @@ proc generateCppNativeHeader*(
L.add("")
L.add(" private:")
L.add(" void* ctx_ = nullptr;")
if events.len > 0:
L.add(" std::map<std::uint64_t, std::unique_ptr<detail::ListenerBase>> listeners_;")
L.add("};")
L.add("")
L.add("} // namespace " & libName)