remove the wildcard event listener

This commit is contained in:
Ivan FB 2026-06-02 18:00:30 +02:00
parent c43563f82f
commit 8a3a438837
No known key found for this signature in database
GPG Key ID: DF0C67A04C543270
16 changed files with 99 additions and 518 deletions

View File

@ -1,10 +1,8 @@
cmake_minimum_required(VERSION 3.14)
project(echo_cpp_bindings CXX C)
# 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.
# The generated bindings target C++20: designated initializers and other
# C++20 constructs are used throughout the emitted code.
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

View File

@ -1,6 +1,6 @@
#pragma once
// Generated bindings require C++20 — the event-listener API uses
// std::span<const std::uint8_t> for the wildcard callback.
// Generated bindings require C++20 (designated initializers and other
// C++20 constructs are used throughout the emitted code).
// 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).

View File

@ -1,10 +1,8 @@
cmake_minimum_required(VERSION 3.14)
project(my_timer_cpp_bindings CXX C)
# 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.
# The generated bindings target C++20: designated initializers and other
# C++20 constructs are used throughout the emitted code.
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

View File

@ -92,54 +92,25 @@ int main() {
// 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
// Subscribe to each event separately; handlers 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";
<< ", echoCount=" << evt.echoCount << "\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.
// Drop the typed listener — no handler fires for the follow-up echo.
// Sleep briefly to give the lib thread time to settle 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 << "[7] after removeEventListener: typed listener removed\n";
std::cout << "\nDone.\n";
return 0;

View File

@ -1,6 +1,6 @@
#pragma once
// Generated bindings require C++20 — the event-listener API uses
// std::span<const std::uint8_t> for the wildcard callback.
// Generated bindings require C++20 (designated initializers and other
// C++20 constructs are used throughout the emitted code).
// 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).
@ -30,7 +30,6 @@ extern "C" {
}
#include <unordered_map>
#include <span>
// ============================================================
// Result<T> — exception-free error channel
// ============================================================
@ -773,19 +772,6 @@ inline Result<std::vector<std::uint8_t>> ffi_call_(
#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
// ============================================================
@ -853,16 +839,6 @@ public:
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);
@ -945,11 +921,6 @@ private:
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;
@ -965,29 +936,6 @@ private:
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_;

View File

@ -117,48 +117,9 @@ unsafe extern "C" fn on_echo_fired_trampoline(
}
}
struct WildcardHandler {
f: Box<dyn Fn(c_int, &str, &[u8]) + Send + Sync>,
}
unsafe extern "C" fn my_timer_wildcard_trampoline(
ret: c_int, msg: *const c_char, len: usize, ud: *mut c_void,
) {
if ud.is_null() { return; }
let h = &*(ud as *const WildcardHandler);
let bytes = if !msg.is_null() && len > 0 {
slice::from_raw_parts(msg as *const u8, len)
} else { &[] };
let event_id = if ret == 0 && !bytes.is_empty() {
#[derive(serde::Deserialize)]
struct EnvelopeMeta {
#[serde(rename = "eventType")]
event_type: String,
}
ciborium::de::from_reader::<EnvelopeMeta, _>(bytes)
.map(|m| m.event_type).unwrap_or_default()
} else {
String::new()
};
(h.f)(ret, event_id.as_str(), bytes);
}
#[derive(Debug, Clone, Copy)]
pub struct ListenerHandle { pub id: u64 }
/// Decode the `payload` field of a CBOR `EventEnvelope` as `T`.
/// Returns `Err` if the envelope is empty / malformed / the payload
/// cannot be deserialised as `T`.
pub fn decode_event_payload<T: serde::de::DeserializeOwned>(
envelope: &[u8],
) -> Result<T, String> {
#[derive(serde::Deserialize)]
struct Envelope<T> { payload: T }
let env: Envelope<T> = ciborium::de::from_reader(envelope)
.map_err(|e| format!("decode event payload: {e}"))?;
Ok(env.payload)
}
/// High-level context for `MyTimer`.
pub struct MyTimerCtx {
ptr: *mut c_void,
@ -237,19 +198,6 @@ impl MyTimerCtx {
self.add_listener_inner(b"on_echo_fired\0".as_ptr() as *const c_char, on_echo_fired_trampoline, raw, owned)
}
/// Register a catch-all listener that receives every event.
/// The handler arguments are (return_code, event_id, envelope_bytes):
/// `event_id` is the wire `eventType` string extracted from the
/// envelope (empty on error or malformed envelope); `envelope_bytes`
/// is the full CBOR envelope, suitable for `decode_event_payload::<T>`.
pub fn add_event_listener<F>(&self, handler: F) -> ListenerHandle
where F: Fn(c_int, &str, &[u8]) + Send + Sync + 'static,
{
let owned: Box<WildcardHandler> = Box::new(WildcardHandler { f: Box::new(handler) });
let raw = &*owned as *const WildcardHandler as *mut c_void;
self.add_listener_inner(b"\0".as_ptr() as *const c_char, my_timer_wildcard_trampoline, raw, owned)
}
/// Remove a previously-registered listener by handle. Returns true
/// if the listener existed and was removed; false otherwise.
pub fn remove_event_listener(&self, handle: ListenerHandle) -> bool {

View File

@ -145,10 +145,7 @@ proc emitEventDispatcher(
## 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.
## Callers subscribe to each event separately.
## - `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
@ -189,30 +186,6 @@ proc emitEventDispatcher(
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(
" ListenerHandle addEventListener(std::function<void(int, const std::string&, std::span<const std::uint8_t>)> handler) {"
)
lines.add(
" 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;")
@ -231,16 +204,10 @@ proc emitEventTrampoline(
## `emitEventDispatcher`:
##
## - `ListenerBase` is a polymorphic base so the context's
## `listeners_` map can own typed and wildcard listeners under a
## single value type.
## `listeners_` map can own typed 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(" struct ListenerBase {")
@ -253,15 +220,6 @@ proc emitEventTrampoline(
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) {")
@ -284,45 +242,6 @@ proc emitEventTrampoline(
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("")
proc generateCppHeader*(
procs: seq[FFIProcMeta],
@ -335,10 +254,8 @@ proc generateCppHeader*(
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.
# `<unordered_map>` backs the `listeners_` map.
lines.add("#include <unordered_map>")
lines.add("#include <span>")
# Result<T> is the exception-free return channel used by every generated
# entry point. It must precede the CBOR helpers and sync-call helper below,
@ -437,33 +354,6 @@ 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] = @[]
@ -661,13 +551,13 @@ 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.
# Listener machinery (`ListenerBase`, `TypedListener<T>`, 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_;")

View File

@ -449,8 +449,8 @@ proc generateApiRs*(
# ── Per-listener handler boxes + extern "C" trampolines ─────────────────
# Each registered listener owns a `Box<…Handler>` that is kept alive in
# `$1::listeners` (keyed by listener id). The raw pointer to the inner
# handler is handed to the dylib as `user_data` for the per-event or
# wildcard trampoline below.
# handler is handed to the dylib as `user_data` for the per-event
# trampoline below.
if events.len > 0:
for ev in events:
let handlerStruct = capitalizeFirstLetter(ev.nimProcName) & "Handler"
@ -481,73 +481,11 @@ proc generateApiRs*(
lines.add("}")
lines.add("")
# Wildcard handler — receives every event as raw envelope bytes,
# the FFI return code, and the `eventType` string pre-extracted
# from the CBOR envelope. `event_id` is empty when `ret != 0` or
# the envelope is malformed (the bytes are an error string, not a
# CBOR envelope, in that case).
lines.add("struct WildcardHandler {")
lines.add(" f: Box<dyn Fn(c_int, &str, &[u8]) + Send + Sync>,")
lines.add("}")
lines.add("")
lines.add("unsafe extern \"C\" fn $1_wildcard_trampoline(" % [libName])
lines.add(" ret: c_int, msg: *const c_char, len: usize, ud: *mut c_void,")
lines.add(") {")
lines.add(" if ud.is_null() { return; }")
lines.add(" let h = &*(ud as *const WildcardHandler);")
lines.add(" let bytes = if !msg.is_null() && len > 0 {")
lines.add(" slice::from_raw_parts(msg as *const u8, len)")
lines.add(" } else { &[] };")
lines.add(" let event_id = if ret == 0 && !bytes.is_empty() {")
lines.add(" #[derive(serde::Deserialize)]")
lines.add(" struct EnvelopeMeta {")
lines.add(" #[serde(rename = \"eventType\")]")
lines.add(" event_type: String,")
lines.add(" }")
lines.add(
" ciborium::de::from_reader::<EnvelopeMeta, _>(bytes)"
)
lines.add(" .map(|m| m.event_type).unwrap_or_default()")
lines.add(" } else {")
lines.add(" String::new()")
lines.add(" };")
lines.add(" (h.f)(ret, event_id.as_str(), bytes);")
lines.add("}")
lines.add("")
# Public handle returned by every add_…_listener call.
lines.add("#[derive(Debug, Clone, Copy)]")
lines.add("pub struct ListenerHandle { pub id: u64 }")
lines.add("")
# Helper: decode an event envelope's `payload` field into any typed
# `T` that the generated `types.rs` already derives `Deserialize` on.
# Pair with `add_event_listener` to lift raw envelope bytes into a
# typed payload without hand-rolling ciborium calls in each branch.
lines.add(
"/// Decode the `payload` field of a CBOR `EventEnvelope` as `T`."
)
lines.add(
"/// Returns `Err` if the envelope is empty / malformed / the payload"
)
lines.add("/// cannot be deserialised as `T`.")
lines.add(
"pub fn decode_event_payload<T: serde::de::DeserializeOwned>("
)
lines.add(" envelope: &[u8],")
lines.add(") -> Result<T, String> {")
lines.add(" #[derive(serde::Deserialize)]")
lines.add(" struct Envelope<T> { payload: T }")
lines.add(
" let env: Envelope<T> = ciborium::de::from_reader(envelope)"
)
lines.add(
" .map_err(|e| format!(\"decode event payload: {e}\"))?;"
)
lines.add(" Ok(env.payload)")
lines.add("}")
lines.add("")
# ── Context struct ─────────────────────────────────────────────────────────
lines.add("/// High-level context for `$1`." % [libTypeName])
lines.add("pub struct $1 {" % [ctxTypeName])
@ -692,11 +630,11 @@ proc generateApiRs*(
# ── Listener-registration API ─────────────────────────────────────────
if events.len > 0:
# Private helper shared by every public `add_*_listener`: the
# FFI call + map insertion is identical across the typed and
# wildcard variants, so it lives in one place. The caller owns
# the box (typed as the concrete handler struct so the raw
# pointer matches the trampoline's expected type) and only
# erases it to `dyn Any + Send` when handing ownership over.
# FFI call + map insertion is identical across the typed event
# variants, so it lives in one place. The caller owns the box
# (typed as the concrete handler struct so the raw pointer matches
# the trampoline's expected type) and only erases it to
# `dyn Any + Send` when handing ownership over.
lines.add(" fn add_listener_inner(")
lines.add(" &self,")
lines.add(" event_name: *const c_char,")
@ -746,43 +684,6 @@ proc generateApiRs*(
lines.add(" }")
lines.add("")
# Generic wildcard listener — receives every event with the wire
# `eventType` string pre-extracted plus the raw envelope bytes. Pair
# with `decode_event_payload::<T>` to lift the payload into a typed
# value.
lines.add(
" /// Register a catch-all listener that receives every event."
)
lines.add(
" /// The handler arguments are (return_code, event_id, envelope_bytes):"
)
lines.add(
" /// `event_id` is the wire `eventType` string extracted from the"
)
lines.add(
" /// envelope (empty on error or malformed envelope); `envelope_bytes`"
)
lines.add(
" /// is the full CBOR envelope, suitable for `decode_event_payload::<T>`."
)
lines.add(
" pub fn add_event_listener<F>(&self, handler: F) -> ListenerHandle"
)
lines.add(" where F: Fn(c_int, &str, &[u8]) + Send + Sync + 'static,")
lines.add(" {")
lines.add(
" let owned: Box<WildcardHandler> = Box::new(WildcardHandler { f: Box::new(handler) });"
)
lines.add(
" let raw = &*owned as *const WildcardHandler as *mut c_void;"
)
lines.add(
" self.add_listener_inner(b\"\\0\".as_ptr() as *const c_char, $1_wildcard_trampoline, raw, owned)" %
[libName]
)
lines.add(" }")
lines.add("")
# Remove by handle. Drops the Box (and the user's closure) after the
# C ABI confirms the listener has been unregistered.
lines.add(

View File

@ -1,10 +1,8 @@
cmake_minimum_required(VERSION 3.14)
project({{LIB}}_cpp_bindings CXX C)
# 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.
# The generated bindings target C++20: designated initializers and other
# C++20 constructs are used throughout the emitted code.
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

View File

@ -1,6 +1,6 @@
#pragma once
// Generated bindings require C++20 — the event-listener API uses
// std::span<const std::uint8_t> for the wildcard callback.
// Generated bindings require C++20 (designated initializers and other
// C++20 constructs are used throughout the emitted code).
// 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).

View File

@ -107,8 +107,7 @@ proc onNotResponding*(ctx: ptr FFIContext) =
## Mirrors the dispatch templates' lock-during-invocation contract
## (see `ffi_events.nim`).
withLock ctx[].eventRegistry.lock:
let snap = ctx[].eventRegistry.byEvent.getOrDefault("onNotResponding") &
ctx[].eventRegistry.wildcard
let snap = ctx[].eventRegistry.byEvent.getOrDefault("onNotResponding")
if snap.len == 0:
chronicles.debug "onNotResponding - no listener registered"
return

View File

@ -3,9 +3,9 @@
## This module owns two concerns so they can evolve together without dragging
## in the rest of `FFIContext`:
##
## 1. A multi-listener registry. Each event name maps to a `seq` of listeners;
## the empty event name `""` is the wildcard channel and receives every
## dispatched event in addition to its own per-name subscribers.
## 1. A multi-listener registry. Each event name maps to a `seq` of
## listeners; a dispatched event reaches exactly the listeners
## subscribed to its name. Callers subscribe to each event separately.
## 2. The dispatch templates (`dispatchFFIEvent`, `dispatchFFIEventCbor`) used
## by `{.ffiEvent.}`-generated procs. They snapshot the registry under its
## lock, then invoke each listener *outside* the lock so re-entrant
@ -49,10 +49,6 @@ type
lock*: Lock
nextId*: uint64 ## Monotonic id source. 0 is reserved as "invalid"; ids start at 1.
byEvent*: Table[string, seq[FFIEventListener]]
wildcard*: seq[FFIEventListener]
const WildcardEventName* = ""
## Empty string registers a wildcard listener that receives every event.
# ---------------------------------------------------------------------------
# Registry lifecycle and mutation
@ -66,7 +62,6 @@ proc initEventRegistry*(reg: var FFIEventRegistry) =
reg.lock.initLock()
reg.nextId = 0'u64
reg.byEvent = initTable[string, seq[FFIEventListener]]()
reg.wildcard.setLen(0)
proc deinitEventRegistry*(reg: var FFIEventRegistry) =
## Mirror of `initEventRegistry`: must be called exactly once, by the
@ -80,7 +75,6 @@ proc deinitEventRegistry*(reg: var FFIEventRegistry) =
## assignment destructor against this thread's heap allocations.
reg.lock.deinitLock()
reg.byEvent = default(Table[string, seq[FFIEventListener]])
reg.wildcard = @[]
reg.nextId = 0'u64
proc addEventListener*(
@ -90,9 +84,10 @@ proc addEventListener*(
userData: pointer,
): uint64 {.raises: [].} =
## Registers `callback` for `eventName` and returns the listener's stable
## id (always non-zero on success). `eventName == ""` registers a wildcard
## listener that receives every dispatched event. Returns 0 if `callback`
## is nil — the only documented failure mode.
## id (always non-zero on success). A listener only receives events
## dispatched under its own `eventName` — subscribe to each event
## separately. Returns 0 if `callback` is nil — the only documented
## failure mode.
if callback.isNil():
return 0
@ -103,10 +98,7 @@ proc addEventListener*(
assigned = reg.nextId
let listener =
FFIEventListener(id: assigned, callback: callback, userData: userData)
if eventName.len == 0:
reg.wildcard.add(listener)
else:
reg.byEvent.mgetOrPut(eventName, @[]).add(listener)
reg.byEvent.mgetOrPut(eventName, @[]).add(listener)
return assigned
proc removeEventListener*(reg: var FFIEventRegistry, id: uint64): bool {.raises: [].} =
@ -120,54 +112,44 @@ proc removeEventListener*(reg: var FFIEventRegistry, id: uint64): bool {.raises:
var removed = false
withLock reg.lock:
for i in 0 ..< reg.wildcard.len:
if reg.wildcard[i].id == id:
reg.wildcard.delete(i)
removed = true
break
if not removed:
var emptyKey = ""
var prune = false
for key, listeners in reg.byEvent.mpairs:
var idx = -1
for i in 0 ..< listeners.len:
if listeners[i].id == id:
idx = i
break
if idx >= 0:
listeners.delete(idx)
removed = true
if listeners.len == 0:
emptyKey = key
prune = true
var emptyKey = ""
var prune = false
for key, listeners in reg.byEvent.mpairs:
var idx = -1
for i in 0 ..< listeners.len:
if listeners[i].id == id:
idx = i
break
if prune:
reg.byEvent.del(emptyKey)
if idx >= 0:
listeners.delete(idx)
removed = true
if listeners.len == 0:
emptyKey = key
prune = true
break
if prune:
reg.byEvent.del(emptyKey)
return removed
proc removeAllEventListeners*(reg: var FFIEventRegistry) {.raises: [].} =
## Drops every registered listener (per-event and wildcard). Does not
## reset the listener-id counter — subsequent `addEventListener` calls
## still return strictly increasing ids.
## Drops every registered listener. Does not reset the listener-id
## counter — subsequent `addEventListener` calls still return strictly
## increasing ids.
withLock reg.lock:
reg.wildcard.setLen(0)
reg.byEvent.clear()
proc snapshotListeners*(
reg: var FFIEventRegistry, eventName: string
): seq[FFIEventListener] {.raises: [].} =
## Returns a copy of the listener slice for `eventName`, plus every
## wildcard listener. The copy is what makes re-entrant add/remove from
## inside a handler deadlock-free: dispatch holds the lock only for the
## duration of the copy, then iterates the copy outside the lock.
## Returns a copy of the listener slice for `eventName`. The copy is what
## makes re-entrant add/remove from inside a handler deadlock-free:
## dispatch holds the lock only for the duration of the copy, then
## iterates the copy outside the lock.
var snap: seq[FFIEventListener] = @[]
withLock reg.lock:
if eventName.len > 0:
# `getOrDefault` returns an empty seq when the key is absent —
# avoids the raising `[]` operator path.
for l in reg.byEvent.getOrDefault(eventName):
snap.add(l)
for l in reg.wildcard:
# `getOrDefault` returns an empty seq when the key is absent —
# avoids the raising `[]` operator path.
for l in reg.byEvent.getOrDefault(eventName):
snap.add(l)
return snap
@ -192,8 +174,7 @@ template withFFIEventDispatch(
return
withLock regPtr[].lock:
let listeners =
regPtr[].byEvent.getOrDefault(eventName) & regPtr[].wildcard
let listeners = regPtr[].byEvent.getOrDefault(eventName)
if listeners.len == 0:
chronicles.debug eventName & " - no listener registered"
else:
@ -212,8 +193,8 @@ template withFFIEventDispatch(
)
template dispatchFFIEvent*(eventName: string, body: untyped) =
## Dispatches an FFI event to every listener for `eventName` plus every
## wildcard listener. `body` must yield a `string` or `seq[byte]`.
## Dispatches an FFI event to every listener subscribed to `eventName`.
## `body` must yield a `string` or `seq[byte]`.
##
## Valid only on the FFI thread (where `ffiCurrentEventRegistry` is
## set). Holds `reg.lock` for the entire snapshot + invocation so a

View File

@ -112,8 +112,9 @@ macro declareLibrary*(libraryName: static[string], libType: untyped): untyped =
## ABI on its `FFIContext`:
##
## - `{libraryName}_add_event_listener(ctx, event_name, cb, ud) -> uint64`
## — registers `cb` for `event_name` and returns its stable id. An
## empty `event_name` subscribes `cb` to *every* event (catch-all).
## — registers `cb` for `event_name` and returns its stable id. `cb`
## only receives events dispatched under `event_name`; subscribe to
## each event separately.
## - `{libraryName}_remove_event_listener(ctx, id) -> cint` — returns 0 on
## success, non-zero if no listener with that id exists.
##

View File

@ -404,54 +404,3 @@ TEST(TimerE2E, RemoveEventListenerStopsDelivery) {
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);
}

View File

@ -86,8 +86,8 @@ registerReqFFI(EmitRawBytesEventRequest, lib: ptr TestEvtLib):
return ok("emitted")
## Setter-thread worker for the registry race regression test. Each
## iteration adds then immediately removes a wildcard listener so a
## TSan-instrumented build can confirm `FFIEventRegistry.lock`
## iteration adds then immediately removes a listener for the dispatched
## event so a TSan-instrumented build can confirm `FFIEventRegistry.lock`
## serialises the cross-thread mutation against dispatch-time
## `snapshotListeners` reads from the FFI thread.
type SetterArgs = tuple
@ -98,7 +98,7 @@ type SetterArgs = tuple
proc setterThreadBody(args: SetterArgs) {.thread.} =
while not args.stop[].load():
let id = addEventListener(
args.ctx[].eventRegistry, WildcardEventName, captureCb, args.target
args.ctx[].eventRegistry, "message_sent", captureCb, args.target
)
discard removeEventListener(args.ctx[].eventRegistry, id)
@ -116,10 +116,9 @@ suite "dispatchFFIEventCbor":
defer:
deinitCallbackData(evt)
# Register the event callback via the same locked helper that the
# codegen-emitted `{libname}_set_event_callback` uses.
# Subscribe to the specific event the request below dispatches.
discard addEventListener(
ctx[].eventRegistry, WildcardEventName, captureCb, addr evt
ctx[].eventRegistry, "message_sent", captureCb, addr evt
)
# Trigger the dispatch from the FFI thread; the response callback is
@ -159,7 +158,7 @@ suite "dispatchFFIEvent with seq[byte]":
deinitCallbackData(evt)
discard addEventListener(
ctx[].eventRegistry, WildcardEventName, captureCb, addr evt
ctx[].eventRegistry, "raw_bytes", captureCb, addr evt
)
var rsp: CallbackData
@ -178,8 +177,8 @@ suite "dispatchFFIEvent with seq[byte]":
check callbackBytes(evt) == @[byte 0x01, 0x02, 0x03]
when not defined(gcRefc):
## Skipped under `--mm:refc`: each setter thread grows / shrinks
## `reg.wildcard` (a `seq[FFIEventListener]`) via `addEventListener`,
## Skipped under `--mm:refc`: each setter thread grows / shrinks the
## per-event listener `seq[FFIEventListener]` via `addEventListener`,
## and refc's per-thread GC heap ownership makes cross-thread seq
## buffer reallocation unsafe even when the surrounding lock is held.
## ORC + the FFI thread + tsan (the combo this test was written for)
@ -206,7 +205,7 @@ when not defined(gcRefc):
# (callback, userData) pair — what matters is the cross-thread write
# racing the FFI thread's read, not which pair "wins".
discard addEventListener(
ctx[].eventRegistry, WildcardEventName, captureCb, addr evt
ctx[].eventRegistry, "message_sent", captureCb, addr evt
)
const NumSetterThreads = 4

View File

@ -68,7 +68,7 @@ suite "FFIEventRegistry mutation":
let id1 = addEventListener(reg, "evt", tagCb, addr t)
let id2 = addEventListener(reg, "evt", tagCb, addr t)
let id3 = addEventListener(reg, "", tagCb, addr t)
let id3 = addEventListener(reg, "other", tagCb, addr t)
check id1 == 1'u64
check id2 == 2'u64
check id3 == 3'u64
@ -89,7 +89,7 @@ suite "FFIEventRegistry mutation":
check not removeEventListener(reg, 0'u64)
check not removeEventListener(reg, 99'u64)
test "removeEventListener removes from per-event seq and wildcard":
test "removeEventListener removes listeners across distinct events":
var reg: FFIEventRegistry
initEventRegistry(reg)
defer:
@ -101,18 +101,18 @@ suite "FFIEventRegistry mutation":
var t = Tag(name: "a", rec: addr rec)
let id1 = addEventListener(reg, "evt", tagCb, addr t)
let id2 = addEventListener(reg, "", tagCb, addr t)
let id2 = addEventListener(reg, "other", tagCb, addr t)
check removeEventListener(reg, id1)
check removeEventListener(reg, id2)
# Second remove of the same id is a no-op.
check not removeEventListener(reg, id1)
let snap = snapshotListeners(reg, "evt")
check snap.len == 0
check snapshotListeners(reg, "evt").len == 0
check snapshotListeners(reg, "other").len == 0
suite "FFIEventRegistry snapshot semantics":
test "snapshot includes both per-event listeners and wildcards":
test "snapshot returns only the listeners for the requested event":
var reg: FFIEventRegistry
initEventRegistry(reg)
defer:
@ -126,17 +126,17 @@ suite "FFIEventRegistry snapshot semantics":
var c = Tag(name: "c", rec: addr rec)
discard addEventListener(reg, "evt", tagCb, addr a)
discard addEventListener(reg, "other", tagCb, addr b)
discard addEventListener(reg, "", tagCb, addr c)
discard addEventListener(reg, "evt", tagCb, addr b)
discard addEventListener(reg, "other", tagCb, addr c)
let snapEvt = snapshotListeners(reg, "evt")
check snapEvt.len == 2 # listener for "evt" + wildcard
check snapEvt.len == 2 # both listeners for "evt"
let snapOther = snapshotListeners(reg, "other")
check snapOther.len == 2 # listener for "other" + wildcard
check snapOther.len == 1 # only the listener for "other"
let snapUnknown = snapshotListeners(reg, "no-subscriber")
check snapUnknown.len == 1 # only the wildcard
check snapUnknown.len == 0 # no listener for this event
test "snapshot is a copy: post-snapshot mutation does not affect it":
var reg: FFIEventRegistry
@ -161,7 +161,7 @@ suite "FFIEventRegistry snapshot semantics":
check snap[0].id == id1
suite "removeAllEventListeners":
test "drops every listener (per-event and wildcard)":
test "drops every registered listener":
var reg: FFIEventRegistry
initEventRegistry(reg)
defer:
@ -174,8 +174,8 @@ suite "removeAllEventListeners":
var b = Tag(name: "b", rec: addr rec)
discard addEventListener(reg, "evt", tagCb, addr a)
discard addEventListener(reg, WildcardEventName, tagCb, addr b)
discard addEventListener(reg, "other", tagCb, addr b)
removeAllEventListeners(reg)
check snapshotListeners(reg, "evt").len == 0
check snapshotListeners(reg, WildcardEventName).len == 0
check snapshotListeners(reg, "other").len == 0