mirror of
https://github.com/logos-messaging/nim-ffi.git
synced 2026-06-21 16:59:30 +00:00
Cpp typed event listeners (#51)
This commit is contained in:
parent
3f19411684
commit
e394166c46
@ -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 "")
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 "")
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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) {}
|
||||
};
|
||||
|
||||
@ -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("")
|
||||
|
||||
|
||||
@ -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 "")
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user