From e394166c465c01fb232d7a3f406860e3c8192f5a Mon Sep 17 00:00:00 2001 From: Ivan FB <128452529+Ivansete-status@users.noreply.github.com> Date: Thu, 28 May 2026 22:40:33 +0200 Subject: [PATCH] Cpp typed event listeners (#51) --- examples/echo/cpp_bindings/CMakeLists.txt | 13 +- examples/echo/cpp_bindings/echo.hpp | 12 + examples/timer/cpp_bindings/CMakeLists.txt | 13 +- examples/timer/cpp_bindings/main.cpp | 57 +++- examples/timer/cpp_bindings/my_timer.hpp | 146 ++++++--- ffi/codegen/cpp.nim | 281 +++++++++++++----- ffi/codegen/templates/cpp/CMakeLists.txt.tpl | 13 +- .../templates/cpp/header_prelude.hpp.tpl | 12 + tests/e2e/cpp/test_timer_e2e.cpp | 128 +++++++- 9 files changed, 550 insertions(+), 125 deletions(-) diff --git a/examples/echo/cpp_bindings/CMakeLists.txt b/examples/echo/cpp_bindings/CMakeLists.txt index 4f605cf..6e1e25a 100644 --- a/examples/echo/cpp_bindings/CMakeLists.txt +++ b/examples/echo/cpp_bindings/CMakeLists.txt @@ -1,9 +1,20 @@ cmake_minimum_required(VERSION 3.14) project(echo_cpp_bindings CXX C) -set(CMAKE_CXX_STANDARD 17) +# The generated bindings target C++20. The event-listener API uses +# std::span on its wildcard callback to hand the +# CBOR envelope to consumers as a zero-copy view; only became +# part of the standard library in C++20. +set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) +# MSVC defaults __cplusplus to 199711L regardless of the active /std:c++XX +# level — the generated header's C++20 guard would then misfire. /Zc:__cplusplus +# makes MSVC report the actual standard. Harmless on every other compiler. +if(MSVC) + add_compile_options(/Zc:__cplusplus) +endif() + # ── Locate the repository root (contains ffi.nimble) ───────────────────────── set(_search_dir "${CMAKE_CURRENT_SOURCE_DIR}") set(REPO_ROOT "") diff --git a/examples/echo/cpp_bindings/echo.hpp b/examples/echo/cpp_bindings/echo.hpp index 0c4c965..9281f09 100644 --- a/examples/echo/cpp_bindings/echo.hpp +++ b/examples/echo/cpp_bindings/echo.hpp @@ -1,4 +1,16 @@ #pragma once +// Generated bindings require C++20 — the event-listener API uses +// std::span for the wildcard callback. +// MSVC keeps __cplusplus at 199711L unless /Zc:__cplusplus is passed, +// so consult _MSVC_LANG when present (it always reflects the active +// /std:c++XX level). +#if defined(_MSVC_LANG) +# if _MSVC_LANG < 202002L +# error "nim-ffi generated headers require C++20 or later (use /std:c++20)" +# endif +#elif !defined(__cplusplus) || __cplusplus < 202002L +# error "nim-ffi generated headers require C++20 or later" +#endif #include #include #include diff --git a/examples/timer/cpp_bindings/CMakeLists.txt b/examples/timer/cpp_bindings/CMakeLists.txt index 607a2b1..0ead156 100644 --- a/examples/timer/cpp_bindings/CMakeLists.txt +++ b/examples/timer/cpp_bindings/CMakeLists.txt @@ -1,9 +1,20 @@ cmake_minimum_required(VERSION 3.14) project(my_timer_cpp_bindings CXX C) -set(CMAKE_CXX_STANDARD 17) +# The generated bindings target C++20. The event-listener API uses +# std::span on its wildcard callback to hand the +# CBOR envelope to consumers as a zero-copy view; only became +# part of the standard library in C++20. +set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) +# MSVC defaults __cplusplus to 199711L regardless of the active /std:c++XX +# level — the generated header's C++20 guard would then misfire. /Zc:__cplusplus +# makes MSVC report the actual standard. Harmless on every other compiler. +if(MSVC) + add_compile_options(/Zc:__cplusplus) +endif() + # ── Locate the repository root (contains ffi.nimble) ───────────────────────── set(_search_dir "${CMAKE_CURRENT_SOURCE_DIR}") set(REPO_ROOT "") diff --git a/examples/timer/cpp_bindings/main.cpp b/examples/timer/cpp_bindings/main.cpp index 739ad9f..5a9e7ea 100644 --- a/examples/timer/cpp_bindings/main.cpp +++ b/examples/timer/cpp_bindings/main.cpp @@ -1,6 +1,9 @@ #include "my_timer.hpp" -#include +#include +#include #include +#include +#include int main() { try { @@ -35,7 +38,6 @@ int main() { << ", itemCount=" << complex.itemCount << ", hasNote=" << complex.hasNote << "\n"; - // ── 6. Call with three complex parameters ───────────────────── // Each parameter is its own generated C++ struct. The nim-ffi // macro packs all three into one CBOR envelope on the wire — at // the call site, this is just a typed method invocation. @@ -63,6 +65,57 @@ int main() { << ", effectiveBackoffMs=" << scheduleRes.effectiveBackoffMs << "\n"; + // Each `{.ffiEvent.}` declared on the Nim side gets a typed + // registration method — `addOnEchoFiredListener(handler)` here. + // A second `addEventListener` overload registers a catch-all + // wildcard listener that receives every event as raw envelope + // bytes plus the FFI return code. Both fire from the lib's + // dispatch thread, so synchronise via std::promise / atomics. + std::promise echoEvtPromise; + auto echoEvtFuture = echoEvtPromise.get_future(); + const auto typedHandle = ctx->addOnEchoFiredListener( + [&](const EchoEvent& evt) { echoEvtPromise.set_value(evt); }); + + std::atomic wildcardHits{0}; + // Wildcard listener receives every event with the wire `eventId` + // pre-extracted plus a span view over the raw CBOR envelope + // bytes (zero-copy; valid only for the duration of this call). + // Dispatch on `eventId` and use `decodeEventPayload` to lift + // the payload into a typed value without hand-parsing CBOR. + const auto wildcardHandle = ctx->addEventListener( + [&](int retCode, const std::string& eventId, + std::span envelope) { + wildcardHits.fetch_add(1); + std::cout << "[7] wildcard event: retCode=" << retCode + << ", eventId=" << eventId + << ", envelope bytes=" << envelope.size() << "\n"; + if (retCode != 0) return; + if (eventId == "on_echo_fired") { + EchoEvent decoded{}; + if (decodeEventPayload(envelope, decoded)) { + std::cout << " decoded EchoEvent: message=" + << decoded.message + << ", echoCount=" << decoded.echoCount << "\n"; + } + } + }); + + ctx->echo(EchoRequest{"event-demo", 1}); + const auto evt = echoEvtFuture.get(); + std::cout << "[7] typed event onEchoFired: message=" << evt.message + << ", echoCount=" << evt.echoCount + << ", wildcardHits=" << wildcardHits.load() << "\n"; + + // Drop the typed listener — only the wildcard fires for the + // follow-up echo. Sleep briefly to give the lib thread time to + // deliver before we tear the ctx down. + ctx->removeEventListener(typedHandle); + ctx->echo(EchoRequest{"event-demo-after-remove", 1}); + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + std::cout << "[7] after removeEventListener: wildcardHits=" + << wildcardHits.load() << "\n"; + ctx->removeEventListener(wildcardHandle); + std::cout << "\nDone.\n"; } catch (const std::exception& ex) { std::cerr << "Error: " << ex.what() << "\n"; diff --git a/examples/timer/cpp_bindings/my_timer.hpp b/examples/timer/cpp_bindings/my_timer.hpp index dfc0c69..7523504 100644 --- a/examples/timer/cpp_bindings/my_timer.hpp +++ b/examples/timer/cpp_bindings/my_timer.hpp @@ -1,4 +1,16 @@ #pragma once +// Generated bindings require C++20 — the event-listener API uses +// std::span for the wildcard callback. +// MSVC keeps __cplusplus at 199711L unless /Zc:__cplusplus is passed, +// so consult _MSVC_LANG when present (it always reflects the active +// /std:c++XX level). +#if defined(_MSVC_LANG) +# if _MSVC_LANG < 202002L +# error "nim-ffi generated headers require C++20 or later (use /std:c++20)" +# endif +#elif !defined(__cplusplus) || __cplusplus < 202002L +# error "nim-ffi generated headers require C++20 or later" +#endif #include #include #include @@ -16,6 +28,8 @@ extern "C" { #include } +#include +#include // ── encode_cbor overloads (primitives + containers) ───────────────────── // Per-struct encode_cbor / decode_cbor are emitted by cpp.nim next to each // generated struct; these helpers cover the leaf types they defer into. @@ -693,6 +707,19 @@ inline std::vector ffi_call_(std::function +inline bool decodeEventPayload(std::span envelope, T& out) { + if (envelope.empty()) return false; + CborParser parser; CborValue it; + if (cbor_parser_init(envelope.data(), envelope.size(), 0, &parser, &it) != CborNoError) + return false; + if (!cbor_value_is_map(&it)) return false; + CborValue payloadField; + if (cbor_value_map_find_value(&it, "payload", &payloadField) != CborNoError) + return false; + return decode_cbor(payloadField, out) == CborNoError; +} + // ============================================================ // High-level C++ context class // ============================================================ @@ -740,20 +767,34 @@ public: MyTimerCtx(MyTimerCtx&&) = delete; MyTimerCtx& operator=(MyTimerCtx&&) = delete; - // ── Typed event handlers ──────────────────────────────── - struct Events { - std::function on_error; - std::function onEchoFired; - }; + // ── Event listener API ────────────────────────────────── + struct ListenerHandle { std::uint64_t id = 0; }; - void setEventHandlers(Events handlers) { - if (event_listener_id_ != 0) { - my_timer_remove_event_listener(ptr_, event_listener_id_); - event_listener_id_ = 0; - } - events_ = std::make_unique(std::move(handlers)); - event_listener_id_ = my_timer_add_event_listener( - ptr_, "", &MyTimerCtx::eventTrampoline, events_.get()); + ListenerHandle addOnEchoFiredListener(std::function handler) { + auto owned = std::make_unique>(std::move(handler)); + auto* raw = owned.get(); + const auto id = my_timer_add_event_listener( + ptr_, "on_echo_fired", &MyTimerCtx::typedTrampoline, raw); + if (id == 0) return ListenerHandle{0}; + listeners_.emplace(id, std::move(owned)); + return ListenerHandle{id}; + } + + ListenerHandle addEventListener(std::function)> handler) { + auto owned = std::make_unique(std::move(handler)); + auto* raw = owned.get(); + const auto id = my_timer_add_event_listener( + ptr_, "", &MyTimerCtx::wildcardTrampoline, raw); + if (id == 0) return ListenerHandle{0}; + listeners_.emplace(id, std::move(owned)); + return ListenerHandle{id}; + } + + bool removeEventListener(ListenerHandle handle) { + if (handle.id == 0) return false; + const auto rc = my_timer_remove_event_listener(ptr_, handle.id); + listeners_.erase(handle.id); + return rc == 0; } EchoResponse echo(const EchoRequest& req) const { @@ -809,38 +850,61 @@ public: } private: - void* ptr_; - std::chrono::milliseconds timeout_; - std::unique_ptr events_; - uint64_t event_listener_id_ = 0; - explicit MyTimerCtx(void* p, std::chrono::milliseconds t) : ptr_(p), timeout_(t) {} - static void eventTrampoline(int ret, const char* msg, std::size_t len, void* ud) { - if (!ud) return; - auto* events = static_cast(ud); - if (ret != 0) { - if (events->on_error) { - std::string err(msg ? msg : "", len); - events->on_error(err); - } - return; - } - if (!msg || len == 0) return; - std::vector bytes(reinterpret_cast(msg), - reinterpret_cast(msg) + len); + struct ListenerBase { + virtual ~ListenerBase() = default; + }; + + template + struct TypedListener : ListenerBase { + std::function fn; + explicit TypedListener(std::function f) : fn(std::move(f)) {} + }; + + struct WildcardListener : ListenerBase { + std::function)> fn; + explicit WildcardListener(std::function)> f) : fn(std::move(f)) {} + }; + + template + static void typedTrampoline(int ret, const char* msg, std::size_t len, void* ud) { + if (!ud || ret != 0 || !msg || len == 0) return; + auto* listener = static_cast*>(ud); + if (!listener->fn) return; CborParser parser; CborValue it; - if (cbor_parser_init(bytes.data(), bytes.size(), 0, &parser, &it) != CborNoError) return; + if (cbor_parser_init(reinterpret_cast(msg), len, 0, &parser, &it) != CborNoError) return; if (!cbor_value_is_map(&it)) return; - CborValue evtField; - if (cbor_value_map_find_value(&it, "eventType", &evtField) != CborNoError) return; - if (!cbor_value_is_text_string(&evtField)) return; - std::string evtName; if (decode_cbor(evtField, evtName) != CborNoError) return; CborValue payloadField; if (cbor_value_map_find_value(&it, "payload", &payloadField) != CborNoError) return; - if (evtName == "on_echo_fired") { - if (events->onEchoFired) { - EchoEvent payload{}; if (decode_cbor(payloadField, payload) == CborNoError) events->onEchoFired(payload); - } - } + T payload{}; + if (decode_cbor(payloadField, payload) != CborNoError) return; + listener->fn(payload); } + static void wildcardTrampoline(int ret, const char* msg, std::size_t len, void* ud) { + if (!ud) return; + auto* listener = static_cast(ud); + if (!listener->fn) return; + std::span envelope{}; + if (msg && len > 0) { + envelope = std::span(reinterpret_cast(msg), len); + } + std::string eventId; + if (ret == 0 && !envelope.empty()) { + CborParser parser; CborValue it; + if (cbor_parser_init(envelope.data(), envelope.size(), 0, &parser, &it) == CborNoError + && cbor_value_is_map(&it)) { + CborValue evtField; + if (cbor_value_map_find_value(&it, "eventType", &evtField) == CborNoError + && cbor_value_is_text_string(&evtField)) { + (void)decode_cbor(evtField, eventId); + } + } + } + listener->fn(ret, eventId, envelope); + } + + void* ptr_; + std::chrono::milliseconds timeout_; + std::unordered_map> listeners_; + explicit MyTimerCtx(void* p, std::chrono::milliseconds t) : ptr_(p), timeout_(t) {} }; diff --git a/ffi/codegen/cpp.nim b/ffi/codegen/cpp.nim index 30cef9f..32100b2 100644 --- a/ffi/codegen/cpp.nim +++ b/ffi/codegen/cpp.nim @@ -138,88 +138,188 @@ proc cppBracedInit(structName: string, fieldNames: seq[string]): string = proc emitEventDispatcher( lines: var seq[string], ctxTypeName, libName: string, events: seq[FFIEventMeta] ) = - ## Emit the typed-event support inside the C++ context class body: - ## a nested `Events` struct of std::function handlers, plus a - ## `setEventHandlers` method that owns the handlers on the heap and - ## hands the raw pointer to the dylib as `user_data` for the trampoline. + ## Public listener-registration API in the generated context class: ## - ## Storage strategy: `Events` lives on the heap (unique_ptr) so the raw - ## pointer we hand to the C ABI as `user_data` survives the (non-) - ## lifetime of the surrounding context object. The context itself is - ## owned via `std::unique_ptr` returned from `create`, so - ## it's never moved out from under the trampoline. + ## - `addOnListener(std::function) -> ListenerHandle` + ## per declared `{.ffiEvent.}`. Internally registers under the wire + ## event name; the per-listener trampoline decodes the CBOR + ## envelope's `payload` field as `T` and invokes the user handler. + ## - `addEventListener(std::function) -> + ## ListenerHandle` registers a catch-all wildcard listener + ## (event_name == "") that receives every event as raw envelope + ## bytes plus the FFI return code. + ## - `removeEventListener(ListenerHandle) -> bool` drops a listener by + ## handle. After it returns true, no further callbacks for that id + ## are in flight on the FFI side (the Nim-side registry lock plus + ## snapshot copy guarantees this). + ## + ## Ownership: each listener's callable is held by a + ## `std::unique_ptr` in `listeners_`, keyed by id; the + ## raw pointer is handed to the dylib as `user_data`. The map entry + ## (and therefore the callable) survives at a stable heap address + ## until `removeEventListener` removes it. if events.len == 0: return - lines.add(" // ── Typed event handlers ────────────────────────────────") - lines.add(" struct Events {") - lines.add(" std::function on_error;") - for ev in events: - lines.add( - " std::function $2;" % - [ev.payloadTypeName, ev.nimProcName] - ) - lines.add(" };") + lines.add(" // ── Event listener API ──────────────────────────────────") + lines.add(" struct ListenerHandle { std::uint64_t id = 0; };") lines.add("") - lines.add(" void setEventHandlers(Events handlers) {") - # Drop the previously-registered listener so the registry never holds - # a dangling pointer into a freed `Events` heap object. - lines.add(" if (event_listener_id_ != 0) {") + # Per-event typed registration helpers. + for ev in events: + let methodName = + "addOn" & capitalizeFirstLetter(ev.nimProcName).substr(2) & "Listener" + lines.add( + " ListenerHandle $1(std::function handler) {" % + [methodName, ev.payloadTypeName] + ) + lines.add( + " auto owned = std::make_unique>(std::move(handler));" % + [ev.payloadTypeName] + ) + lines.add(" auto* raw = owned.get();") + lines.add( + " const auto id = $1_add_event_listener(" % [libName] + ) + lines.add( + " ptr_, \"$1\", &$2::typedTrampoline<$3>, raw);" % + [ev.wireName, ctxTypeName, ev.payloadTypeName] + ) + lines.add(" if (id == 0) return ListenerHandle{0};") + lines.add(" listeners_.emplace(id, std::move(owned));") + lines.add(" return ListenerHandle{id};") + lines.add(" }") + lines.add("") + # Generic wildcard registration. + # + # The handler receives the FFI return code, the wire `eventType` string + # extracted from the CBOR envelope (empty if the envelope is malformed + # or `ret != 0`), and a `std::span` view over the raw envelope bytes — + # the buffer is owned by the dylib and stays valid for the duration of + # the synchronous callback only. Pair with `decodeEventPayload` to + # lift the payload into a typed value without hand-rolling CBOR parsing. lines.add( - " $1_remove_event_listener(ptr_, event_listener_id_);" % [libName] + " ListenerHandle addEventListener(std::function)> handler) {" ) - lines.add(" event_listener_id_ = 0;") - lines.add(" }") - lines.add(" events_ = std::make_unique(std::move(handlers));") - lines.add(" event_listener_id_ = $1_add_event_listener(" % [libName]) lines.add( - " ptr_, \"\", &$1::eventTrampoline, events_.get());" % [ctxTypeName] + " auto owned = std::make_unique(std::move(handler));" ) + lines.add(" auto* raw = owned.get();") + lines.add(" const auto id = $1_add_event_listener(" % [libName]) + lines.add( + " ptr_, \"\", &$1::wildcardTrampoline, raw);" % [ctxTypeName] + ) + lines.add(" if (id == 0) return ListenerHandle{0};") + lines.add(" listeners_.emplace(id, std::move(owned));") + lines.add(" return ListenerHandle{id};") + lines.add(" }") + lines.add("") + # Remove by handle. + lines.add(" bool removeEventListener(ListenerHandle handle) {") + lines.add(" if (handle.id == 0) return false;") + lines.add( + " const auto rc = $1_remove_event_listener(ptr_, handle.id);" % [libName] + ) + lines.add(" listeners_.erase(handle.id);") + lines.add(" return rc == 0;") lines.add(" }") lines.add("") proc emitEventTrampoline( lines: var seq[string], events: seq[FFIEventMeta] ) = - ## Emit the private static trampoline that backs `setEventHandlers`. The - ## generated function parses the CBOR `EventEnvelope`, picks the matching - ## std::function from the Events struct, decodes the payload as the - ## registered type, and fires the handler. + ## Private listener machinery for the public API emitted by + ## `emitEventDispatcher`: + ## + ## - `ListenerBase` is a polymorphic base so the context's + ## `listeners_` map can own typed and wildcard listeners under a + ## single value type. + ## - `TypedListener` holds the user's `std::function` + ## and is the target of `typedTrampoline`, which CBOR-decodes the + ## envelope's `payload` field as `T` and invokes the handler. + ## - `WildcardListener` holds a `std::function)>` and is the target + ## of `wildcardTrampoline`, which forwards the FFI return code plus + ## a span view over the raw payload bytes (no copy — the dylib owns + ## the buffer for the duration of the synchronous callback). if events.len == 0: return - lines.add(" static void eventTrampoline(int ret, const char* msg, std::size_t len, void* ud) {") - lines.add(" if (!ud) return;") - lines.add(" auto* events = static_cast(ud);") - lines.add(" if (ret != 0) {") - lines.add(" if (events->on_error) {") - lines.add(" std::string err(msg ? msg : \"\", len);") - lines.add(" events->on_error(err);") - lines.add(" }") - lines.add(" return;") - lines.add(" }") - lines.add(" if (!msg || len == 0) return;") - lines.add(" std::vector bytes(reinterpret_cast(msg),") - lines.add(" reinterpret_cast(msg) + len);") + lines.add(" struct ListenerBase {") + lines.add(" virtual ~ListenerBase() = default;") + lines.add(" };") + lines.add("") + lines.add(" template ") + lines.add(" struct TypedListener : ListenerBase {") + lines.add(" std::function fn;") + lines.add(" explicit TypedListener(std::function f) : fn(std::move(f)) {}") + lines.add(" };") + lines.add("") + lines.add(" struct WildcardListener : ListenerBase {") + lines.add( + " std::function)> fn;" + ) + lines.add( + " explicit WildcardListener(std::function)> f) : fn(std::move(f)) {}" + ) + lines.add(" };") + lines.add("") + # Typed trampoline — one instantiation per payload type, all sharing a body. + lines.add(" template ") + lines.add(" static void typedTrampoline(int ret, const char* msg, std::size_t len, void* ud) {") + lines.add(" if (!ud || ret != 0 || !msg || len == 0) return;") + lines.add(" auto* listener = static_cast*>(ud);") + lines.add(" if (!listener->fn) return;") lines.add(" CborParser parser; CborValue it;") - lines.add(" if (cbor_parser_init(bytes.data(), bytes.size(), 0, &parser, &it) != CborNoError) return;") + lines.add( + " if (cbor_parser_init(reinterpret_cast(msg), len, 0, &parser, &it) != CborNoError) return;" + ) lines.add(" if (!cbor_value_is_map(&it)) return;") - lines.add(" CborValue evtField;") - lines.add(" if (cbor_value_map_find_value(&it, \"eventType\", &evtField) != CborNoError) return;") - lines.add(" if (!cbor_value_is_text_string(&evtField)) return;") - lines.add(" std::string evtName; if (decode_cbor(evtField, evtName) != CborNoError) return;") lines.add(" CborValue payloadField;") - lines.add(" if (cbor_value_map_find_value(&it, \"payload\", &payloadField) != CborNoError) return;") - var first = true - for ev in events: - let branchKw = if first: "if" else: "else if" - lines.add(" $1 (evtName == \"$2\") {" % [branchKw, ev.wireName]) - lines.add(" if (events->$1) {" % [ev.nimProcName]) - lines.add( - " $1 payload{}; if (decode_cbor(payloadField, payload) == CborNoError) events->$2(payload);" % - [ev.payloadTypeName, ev.nimProcName] - ) - lines.add(" }") - lines.add(" }") - first = false + lines.add( + " if (cbor_value_map_find_value(&it, \"payload\", &payloadField) != CborNoError) return;" + ) + lines.add(" T payload{};") + lines.add( + " if (decode_cbor(payloadField, payload) != CborNoError) return;" + ) + lines.add(" listener->fn(payload);") + lines.add(" }") + lines.add("") + # Wildcard trampoline — extracts `eventType` from the CBOR envelope so + # the user can match on the event name without hand-parsing. Falls back + # to an empty `eventId` if the envelope is missing, malformed, or the + # FFI return code signals an error (in which case the bytes are an + # error string, not a CBOR envelope). + lines.add( + " static void wildcardTrampoline(int ret, const char* msg, std::size_t len, void* ud) {" + ) + lines.add(" if (!ud) return;") + lines.add(" auto* listener = static_cast(ud);") + lines.add(" if (!listener->fn) return;") + # The buffer pointed to by `msg` is owned by the dylib and stays valid + # for the duration of this synchronous call — safe to hand to the user + # as a span view rather than copying. + lines.add(" std::span envelope{};") + lines.add(" if (msg && len > 0) {") + lines.add( + " envelope = std::span(reinterpret_cast(msg), len);" + ) + lines.add(" }") + lines.add(" std::string eventId;") + lines.add(" if (ret == 0 && !envelope.empty()) {") + lines.add(" CborParser parser; CborValue it;") + lines.add( + " if (cbor_parser_init(envelope.data(), envelope.size(), 0, &parser, &it) == CborNoError" + ) + lines.add(" && cbor_value_is_map(&it)) {") + lines.add(" CborValue evtField;") + lines.add( + " if (cbor_value_map_find_value(&it, \"eventType\", &evtField) == CborNoError" + ) + lines.add(" && cbor_value_is_text_string(&evtField)) {") + lines.add(" (void)decode_cbor(evtField, eventId);") + lines.add(" }") + lines.add(" }") + lines.add(" }") + lines.add(" listener->fn(ret, eventId, envelope);") lines.add(" }") lines.add("") @@ -232,6 +332,12 @@ proc generateCppHeader*( var lines: seq[string] = @[] lines.add(HeaderPreludeTpl) + if events.len > 0: + # Only pulled in when the library declares `{.ffiEvent.}` procs — + # `` backs the `listeners_` map, `` is the + # zero-copy view type handed to wildcard callbacks. + lines.add("#include ") + lines.add("#include ") # CBOR primitive / container helpers must precede the per-struct codecs # below, because each emitted `encode_cbor`/`decode_cbor(T)` calls the @@ -311,8 +417,8 @@ proc generateCppHeader*( ) of FFIKind.DTOR: lines.add("int $1(void* ctx);" % [p.procName]) - # `declareLibrary` always exports the listener-registration ABI; - # declare it here so the typed event-handler wiring below can call in. + # `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);" % [libName] @@ -325,6 +431,33 @@ proc generateCppHeader*( lines.add(SyncCallHelperTpl) + # ── Event-payload decoder helper ────────────────────────────────────────── + # Lets wildcard-listener bodies lift the `payload` field out of a CBOR + # envelope into any registered event type with a single call, e.g. + # EchoEvent evt; + # if (decodeEventPayload(envelope, evt)) { ... } + # Relies on the per-struct `decode_cbor` codec emitted above. + if events.len > 0: + lines.add("template ") + lines.add( + "inline bool decodeEventPayload(std::span envelope, T& out) {" + ) + lines.add(" if (envelope.empty()) return false;") + lines.add(" CborParser parser; CborValue it;") + lines.add( + " if (cbor_parser_init(envelope.data(), envelope.size(), 0, &parser, &it) != CborNoError)" + ) + lines.add(" return false;") + lines.add(" if (!cbor_value_is_map(&it)) return false;") + lines.add(" CborValue payloadField;") + lines.add( + " if (cbor_value_map_find_value(&it, \"payload\", &payloadField) != CborNoError)" + ) + lines.add(" return false;") + lines.add(" return decode_cbor(payloadField, out) == CborNoError;") + lines.add("}") + lines.add("") + # ── High-level C++ context class ────────────────────────────────────────── var ctors: seq[FFIProcMeta] = @[] var methods: seq[FFIProcMeta] = @[] @@ -507,17 +640,27 @@ proc generateCppHeader*( lines.add("") lines.add("private:") + # Listener machinery (`ListenerBase`, `TypedListener`, + # `WildcardListener`, plus the static trampolines) must appear before + # the `listeners_` data member declaration — C++ requires the value + # type of a member to be complete at point of declaration. The public + # add*/remove methods above also reference these types, but member + # function bodies see the full class scope regardless of declaration + # order, so emitting here is sufficient for both. + emitEventTrampoline(lines, events) lines.add(" void* ptr_;") lines.add(" std::chrono::milliseconds timeout_;") if events.len > 0: - lines.add(" std::unique_ptr events_;") - lines.add(" uint64_t event_listener_id_ = 0;") + # One owning entry per live listener, keyed by id. Destroyed after + # the destructor body runs `_destroy(ptr_)`, by which point the + # FFI side has joined its threads so no callback is mid-flight. + lines.add( + " std::unordered_map> listeners_;" + ) lines.add( " explicit $1(void* p, std::chrono::milliseconds t) : ptr_(p), timeout_(t) {}" % [ctxTypeName] ) - # Static trampoline stays private; user only sees Events + setEventHandlers. - emitEventTrampoline(lines, events) lines.add("};") lines.add("") diff --git a/ffi/codegen/templates/cpp/CMakeLists.txt.tpl b/ffi/codegen/templates/cpp/CMakeLists.txt.tpl index dcf0ddf..9310115 100644 --- a/ffi/codegen/templates/cpp/CMakeLists.txt.tpl +++ b/ffi/codegen/templates/cpp/CMakeLists.txt.tpl @@ -1,9 +1,20 @@ cmake_minimum_required(VERSION 3.14) project({{LIB}}_cpp_bindings CXX C) -set(CMAKE_CXX_STANDARD 17) +# The generated bindings target C++20. The event-listener API uses +# std::span on its wildcard callback to hand the +# CBOR envelope to consumers as a zero-copy view; only became +# part of the standard library in C++20. +set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) +# MSVC defaults __cplusplus to 199711L regardless of the active /std:c++XX +# level — the generated header's C++20 guard would then misfire. /Zc:__cplusplus +# makes MSVC report the actual standard. Harmless on every other compiler. +if(MSVC) + add_compile_options(/Zc:__cplusplus) +endif() + # ── Locate the repository root (contains ffi.nimble) ───────────────────────── set(_search_dir "${CMAKE_CURRENT_SOURCE_DIR}") set(REPO_ROOT "") diff --git a/ffi/codegen/templates/cpp/header_prelude.hpp.tpl b/ffi/codegen/templates/cpp/header_prelude.hpp.tpl index 2a835a7..99ff4a9 100644 --- a/ffi/codegen/templates/cpp/header_prelude.hpp.tpl +++ b/ffi/codegen/templates/cpp/header_prelude.hpp.tpl @@ -1,4 +1,16 @@ #pragma once +// Generated bindings require C++20 — the event-listener API uses +// std::span for the wildcard callback. +// MSVC keeps __cplusplus at 199711L unless /Zc:__cplusplus is passed, +// so consult _MSVC_LANG when present (it always reflects the active +// /std:c++XX level). +#if defined(_MSVC_LANG) +# if _MSVC_LANG < 202002L +# error "nim-ffi generated headers require C++20 or later (use /std:c++20)" +# endif +#elif !defined(__cplusplus) || __cplusplus < 202002L +# error "nim-ffi generated headers require C++20 or later" +#endif #include #include #include diff --git a/tests/e2e/cpp/test_timer_e2e.cpp b/tests/e2e/cpp/test_timer_e2e.cpp index 147d600..83eeca9 100644 --- a/tests/e2e/cpp/test_timer_e2e.cpp +++ b/tests/e2e/cpp/test_timer_e2e.cpp @@ -305,22 +305,22 @@ TEST(TimerE2E, ThreadedHammer) { EXPECT_EQ(errors.load(), 0); } -// Library-initiated events flow through the typed `MyTimerCtx::Events` -// dispatcher: setting `onEchoFired` registers a CBOR-decoding trampoline -// inside the context, and every successful `echo()` triggers it. The -// promise here is fulfilled from the FFI thread; we wait synchronously -// for it before destroying the context (the dtor tears down the FFI -// thread and any further events). + +// Library-initiated events flow through `MyTimerCtx::addOnEchoFiredListener`: +// the listener registers a CBOR-decoding trampoline inside the lib's +// registry, and every successful `echo()` triggers it. The promise here +// is fulfilled from the FFI thread; we wait synchronously for it before +// destroying the context (the dtor tears down the FFI thread and any +// further events). TEST(TimerE2E, TypedEventFiresAfterEcho) { auto ctx = makeCtx("events"); std::promise evtPromise; auto evtFuture = evtPromise.get_future(); - ctx->setEventHandlers({ - .on_error = nullptr, - .onEchoFired = [&](const EchoEvent& evt) { evtPromise.set_value(evt); }, - }); + const auto handle = ctx->addOnEchoFiredListener( + [&](const EchoEvent& evt) { evtPromise.set_value(evt); }); + ASSERT_NE(handle.id, 0u) << "addOnEchoFiredListener returned zero id"; const auto resp = ctx->echo(EchoRequest{"event-msg", 1}); EXPECT_EQ(resp.echoed, "event-msg"); @@ -332,3 +332,111 @@ TEST(TimerE2E, TypedEventFiresAfterEcho) { EXPECT_EQ(evt.message, "event-msg"); EXPECT_EQ(evt.echoCount, 1); } + +// Multiple listeners on the same event each fire exactly once per emit. +TEST(TimerE2E, MultipleTypedListenersAllFire) { + auto ctx = makeCtx("multi-listeners"); + + std::promise firstPromise; + std::promise secondPromise; + auto firstFuture = firstPromise.get_future(); + auto secondFuture = secondPromise.get_future(); + + ctx->addOnEchoFiredListener( + [&](const EchoEvent& evt) { firstPromise.set_value(evt); }); + ctx->addOnEchoFiredListener( + [&](const EchoEvent& evt) { secondPromise.set_value(evt); }); + + ctx->echo(EchoRequest{"fan-out", 1}); + + ASSERT_EQ(firstFuture.wait_for(std::chrono::seconds(2)), std::future_status::ready); + ASSERT_EQ(secondFuture.wait_for(std::chrono::seconds(2)), std::future_status::ready); + EXPECT_EQ(firstFuture.get().message, "fan-out"); + EXPECT_EQ(secondFuture.get().message, "fan-out"); +} + +// Removing a listener stops it from firing on subsequent events while the +// other listener keeps receiving them. +TEST(TimerE2E, RemoveEventListenerStopsDelivery) { + auto ctx = makeCtx("remove-listener"); + + std::atomic removedHits{0}; + std::atomic keptHits{0}; + + const auto removedHandle = ctx->addOnEchoFiredListener( + [&](const EchoEvent&) { removedHits.fetch_add(1); }); + ctx->addOnEchoFiredListener( + [&](const EchoEvent&) { keptHits.fetch_add(1); }); + + ctx->echo(EchoRequest{"before-remove", 1}); + + // Give the FFI thread a beat to deliver the first event to both + // listeners before we yank one of them out. + for (int i = 0; i < 200 && (removedHits.load() == 0 || keptHits.load() == 0); ++i) { + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + } + ASSERT_EQ(removedHits.load(), 1); + ASSERT_EQ(keptHits.load(), 1); + + EXPECT_TRUE(ctx->removeEventListener(removedHandle)); + EXPECT_FALSE(ctx->removeEventListener(removedHandle)) << "double remove must report false"; + + ctx->echo(EchoRequest{"after-remove", 1}); + + for (int i = 0; i < 200 && keptHits.load() < 2; ++i) { + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + } + EXPECT_EQ(keptHits.load(), 2); + EXPECT_EQ(removedHits.load(), 1) << "removed listener fired after removeEventListener"; +} + +// The wildcard `addEventListener` overload receives every event with the +// wire `eventId` pre-extracted plus a `std::span` view over the raw +// envelope bytes. The helper `decodeEventPayload` lifts the payload +// into a typed value. +TEST(TimerE2E, WildcardListenerReceivesEventIdAndDecodesPayload) { + auto ctx = makeCtx("wildcard"); + + struct Capture { + int retCode; + std::string eventId; + std::size_t envelopeBytes; + std::optional decoded; + }; + + std::mutex mu; + std::vector captured; + auto handle = ctx->addEventListener( + [&](int retCode, const std::string& eventId, + std::span envelope) { + Capture c{retCode, eventId, envelope.size(), std::nullopt}; + if (retCode == 0 && eventId == "on_echo_fired") { + EchoEvent evt{}; + if (decodeEventPayload(envelope, evt)) { + c.decoded = evt; + } + } + std::lock_guard lock(mu); + captured.push_back(std::move(c)); + }); + ASSERT_NE(handle.id, 0u); + + ctx->echo(EchoRequest{"hello", 1}); + + for (int i = 0; i < 200; ++i) { + { + std::lock_guard lock(mu); + if (!captured.empty()) break; + } + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + } + + std::lock_guard lock(mu); + ASSERT_GE(captured.size(), 1u); + EXPECT_EQ(captured.front().retCode, 0); + EXPECT_EQ(captured.front().eventId, "on_echo_fired"); + EXPECT_GT(captured.front().envelopeBytes, 0u); + ASSERT_TRUE(captured.front().decoded.has_value()); + EXPECT_EQ(captured.front().decoded->message, "hello"); + EXPECT_EQ(captured.front().decoded->echoCount, 1); +}