Cpp typed event listeners (#51)

This commit is contained in:
Ivan FB 2026-05-28 22:40:33 +02:00 committed by GitHub
parent 3f19411684
commit e394166c46
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 550 additions and 125 deletions

View File

@ -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<const std::uint8_t> on its wildcard callback to hand the
# CBOR envelope to consumers as a zero-copy view; <span> 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 "")

View File

@ -1,4 +1,16 @@
#pragma once
// Generated bindings require C++20 — the event-listener API uses
// std::span<const std::uint8_t> 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 <string>
#include <cstdint>
#include <chrono>

View File

@ -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<const std::uint8_t> on its wildcard callback to hand the
# CBOR envelope to consumers as a zero-copy view; <span> 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 "")

View File

@ -1,6 +1,9 @@
#include "my_timer.hpp"
#include <iostream>
#include <atomic>
#include <chrono>
#include <future>
#include <iostream>
#include <thread>
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<EchoEvent> echoEvtPromise;
auto echoEvtFuture = echoEvtPromise.get_future();
const auto typedHandle = ctx->addOnEchoFiredListener(
[&](const EchoEvent& evt) { echoEvtPromise.set_value(evt); });
std::atomic<int> 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<T>` 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<const std::uint8_t> 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";

View File

@ -1,4 +1,16 @@
#pragma once
// Generated bindings require C++20 — the event-listener API uses
// std::span<const std::uint8_t> 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 <string>
#include <cstdint>
#include <chrono>
@ -16,6 +28,8 @@ extern "C" {
#include <tinycbor/cbor.h>
}
#include <unordered_map>
#include <span>
// ── 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<std::uint8_t> ffi_call_(std::function<int(FFICallback, void*)
#endif // NIM_FFI_SYNC_CALL_HELPER_HPP_INCLUDED
template <class T>
inline bool decodeEventPayload(std::span<const std::uint8_t> 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<void(const std::string&)> on_error;
std::function<void(const EchoEvent&)> 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<Events>(std::move(handlers));
event_listener_id_ = my_timer_add_event_listener(
ptr_, "", &MyTimerCtx::eventTrampoline, events_.get());
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(
ptr_, "on_echo_fired", &MyTimerCtx::typedTrampoline<EchoEvent>, raw);
if (id == 0) return ListenerHandle{0};
listeners_.emplace(id, std::move(owned));
return ListenerHandle{id};
}
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(
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> 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<Events*>(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<std::uint8_t> bytes(reinterpret_cast<const std::uint8_t*>(msg),
reinterpret_cast<const std::uint8_t*>(msg) + len);
struct ListenerBase {
virtual ~ListenerBase() = default;
};
template <class T>
struct TypedListener : ListenerBase {
std::function<void(const T&)> fn;
explicit TypedListener(std::function<void(const T&)> f) : fn(std::move(f)) {}
};
struct WildcardListener : ListenerBase {
std::function<void(int, const std::string&, std::span<const std::uint8_t>)> fn;
explicit WildcardListener(std::function<void(int, const std::string&, std::span<const std::uint8_t>)> f) : fn(std::move(f)) {}
};
template <class T>
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<TypedListener<T>*>(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<const std::uint8_t*>(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<WildcardListener*>(ud);
if (!listener->fn) return;
std::span<const std::uint8_t> envelope{};
if (msg && len > 0) {
envelope = std::span<const std::uint8_t>(reinterpret_cast<const std::uint8_t*>(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<std::uint64_t, std::unique_ptr<ListenerBase>> listeners_;
explicit MyTimerCtx(void* p, std::chrono::milliseconds t) : ptr_(p), timeout_(t) {}
};

View File

@ -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<MyTimerCtx>` returned from `create`, so
## it's never moved out from under the trampoline.
## - `addOn<X>Listener(std::function<void(const T&)>) -> 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<void(int, const std::string&)>) ->
## 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<ListenerBase>` 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<void(const std::string&)> on_error;")
for ev in events:
lines.add(
" std::function<void(const $1&)> $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<void(const $2&)> handler) {" %
[methodName, ev.payloadTypeName]
)
lines.add(
" auto owned = std::make_unique<TypedListener<$1>>(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<T>` 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<void(int, const std::string&, std::span<const std::uint8_t>)> handler) {"
)
lines.add(" event_listener_id_ = 0;")
lines.add(" }")
lines.add(" events_ = std::make_unique<Events>(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<WildcardListener>(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<T>` holds the user's `std::function<void(const T&)>`
## and is the target of `typedTrampoline<T>`, which CBOR-decodes the
## envelope's `payload` field as `T` and invokes the handler.
## - `WildcardListener` holds a `std::function<void(int, const
## std::string&, std::span<const std::uint8_t>)>` 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<Events*>(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<std::uint8_t> bytes(reinterpret_cast<const std::uint8_t*>(msg),")
lines.add(" reinterpret_cast<const std::uint8_t*>(msg) + len);")
lines.add(" struct ListenerBase {")
lines.add(" virtual ~ListenerBase() = default;")
lines.add(" };")
lines.add("")
lines.add(" template <class T>")
lines.add(" struct TypedListener : ListenerBase {")
lines.add(" std::function<void(const T&)> fn;")
lines.add(" explicit TypedListener(std::function<void(const T&)> f) : fn(std::move(f)) {}")
lines.add(" };")
lines.add("")
lines.add(" struct WildcardListener : ListenerBase {")
lines.add(
" std::function<void(int, const std::string&, std::span<const std::uint8_t>)> fn;"
)
lines.add(
" explicit WildcardListener(std::function<void(int, const std::string&, std::span<const std::uint8_t>)> f) : fn(std::move(f)) {}"
)
lines.add(" };")
lines.add("")
# Typed trampoline — one instantiation per payload type, all sharing a body.
lines.add(" template <class T>")
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<TypedListener<T>*>(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<const std::uint8_t*>(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<WildcardListener*>(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<const std::uint8_t> envelope{};")
lines.add(" if (msg && len > 0) {")
lines.add(
" envelope = std::span<const std::uint8_t>(reinterpret_cast<const std::uint8_t*>(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 —
# `<unordered_map>` backs the `listeners_` map, `<span>` is the
# zero-copy view type handed to wildcard callbacks.
lines.add("#include <unordered_map>")
lines.add("#include <span>")
# 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 <class T>")
lines.add(
"inline bool decodeEventPayload(std::span<const std::uint8_t> 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<T>`,
# `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> events_;")
lines.add(" uint64_t event_listener_id_ = 0;")
# One owning entry per live listener, keyed by id. Destroyed after
# the destructor body runs `<lib>_destroy(ptr_)`, by which point the
# FFI side has joined its threads so no callback is mid-flight.
lines.add(
" std::unordered_map<std::uint64_t, std::unique_ptr<ListenerBase>> 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("")

View File

@ -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<const std::uint8_t> on its wildcard callback to hand the
# CBOR envelope to consumers as a zero-copy view; <span> 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 "")

View File

@ -1,4 +1,16 @@
#pragma once
// Generated bindings require C++20 — the event-listener API uses
// std::span<const std::uint8_t> 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 <string>
#include <cstdint>
#include <chrono>

View File

@ -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<EchoEvent> 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<EchoEvent> firstPromise;
std::promise<EchoEvent> 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<int> removedHits{0};
std::atomic<int> 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<T>` 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<EchoEvent> decoded;
};
std::mutex mu;
std::vector<Capture> captured;
auto handle = ctx->addEventListener(
[&](int retCode, const std::string& eventId,
std::span<const std::uint8_t> 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<std::mutex> 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<std::mutex> lock(mu);
if (!captured.empty()) break;
}
std::this_thread::sleep_for(std::chrono::milliseconds(5));
}
std::lock_guard<std::mutex> 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);
}