diff --git a/examples/echo/cpp_bindings/CMakeLists.txt b/examples/echo/cpp_bindings/CMakeLists.txt index 6e1e25a..77dd7aa 100644 --- a/examples/echo/cpp_bindings/CMakeLists.txt +++ b/examples/echo/cpp_bindings/CMakeLists.txt @@ -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 on its wildcard callback to hand the -# CBOR envelope to consumers as a zero-copy view; only became -# part of the standard library in C++20. +# 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) diff --git a/examples/echo/cpp_bindings/echo.hpp b/examples/echo/cpp_bindings/echo.hpp index c6f50d0..efcfa2e 100644 --- a/examples/echo/cpp_bindings/echo.hpp +++ b/examples/echo/cpp_bindings/echo.hpp @@ -1,6 +1,6 @@ #pragma once -// Generated bindings require C++20 — the event-listener API uses -// std::span 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). diff --git a/examples/timer/cpp_bindings/CMakeLists.txt b/examples/timer/cpp_bindings/CMakeLists.txt index 0ead156..162d844 100644 --- a/examples/timer/cpp_bindings/CMakeLists.txt +++ b/examples/timer/cpp_bindings/CMakeLists.txt @@ -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 on its wildcard callback to hand the -# CBOR envelope to consumers as a zero-copy view; only became -# part of the standard library in C++20. +# 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) diff --git a/examples/timer/cpp_bindings/main.cpp b/examples/timer/cpp_bindings/main.cpp index 3ab361d..e754a85 100644 --- a/examples/timer/cpp_bindings/main.cpp +++ b/examples/timer/cpp_bindings/main.cpp @@ -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 echoEvtPromise; auto echoEvtFuture = echoEvtPromise.get_future(); const auto typedHandle = ctx->addOnEchoFiredListener( [&](const EchoEvent& evt) { echoEvtPromise.set_value(evt); }); - std::atomic wildcardHits{0}; - // Wildcard listener receives every event with the wire `eventId` - // pre-extracted plus a span view over the raw CBOR envelope - // bytes (zero-copy; valid only for the duration of this call). - // Dispatch on `eventId` and use `decodeEventPayload` to lift - // the payload into a typed value without hand-parsing CBOR. - const auto wildcardHandle = ctx->addEventListener( - [&](int retCode, const std::string& eventId, - std::span envelope) { - wildcardHits.fetch_add(1); - std::cout << "[7] wildcard event: retCode=" << retCode - << ", eventId=" << eventId - << ", envelope bytes=" << envelope.size() << "\n"; - if (retCode != 0) return; - if (eventId == "on_echo_fired") { - EchoEvent decoded{}; - if (decodeEventPayload(envelope, decoded)) { - std::cout << " decoded EchoEvent: message=" - << decoded.message - << ", echoCount=" << decoded.echoCount << "\n"; - } - } - }); - ctx->echo(EchoRequest{"event-demo", 1}); const auto evt = echoEvtFuture.get(); std::cout << "[7] typed event onEchoFired: message=" << evt.message - << ", echoCount=" << evt.echoCount - << ", wildcardHits=" << wildcardHits.load() << "\n"; + << ", 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; diff --git a/examples/timer/cpp_bindings/my_timer.hpp b/examples/timer/cpp_bindings/my_timer.hpp index 8d1fc3d..8fa440b 100644 --- a/examples/timer/cpp_bindings/my_timer.hpp +++ b/examples/timer/cpp_bindings/my_timer.hpp @@ -1,6 +1,6 @@ #pragma once -// Generated bindings require C++20 — the event-listener API uses -// std::span 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 -#include // ============================================================ // Result — exception-free error channel // ============================================================ @@ -773,19 +772,6 @@ inline Result> ffi_call_( #endif // NIM_FFI_SYNC_CALL_HELPER_HPP_INCLUDED -template -inline bool decodeEventPayload(std::span envelope, T& out) { - if (envelope.empty()) return false; - CborParser parser; CborValue it; - if (cbor_parser_init(envelope.data(), envelope.size(), 0, &parser, &it) != CborNoError) - return false; - if (!cbor_value_is_map(&it)) return false; - CborValue payloadField; - if (cbor_value_map_find_value(&it, "payload", &payloadField) != CborNoError) - return false; - return decode_cbor(payloadField, out) == CborNoError; -} - // ============================================================ // High-level C++ context class // ============================================================ @@ -853,16 +839,6 @@ public: return ListenerHandle{id}; } - ListenerHandle addEventListener(std::function)> handler) { - auto owned = std::make_unique(std::move(handler)); - auto* raw = owned.get(); - const auto id = my_timer_add_event_listener( - ptr_, "", &MyTimerCtx::wildcardTrampoline, raw); - if (id == 0) return ListenerHandle{0}; - listeners_.emplace(id, std::move(owned)); - return ListenerHandle{id}; - } - bool removeEventListener(ListenerHandle handle) { if (handle.id == 0) return false; const auto rc = my_timer_remove_event_listener(ptr_, handle.id); @@ -945,11 +921,6 @@ private: explicit TypedListener(std::function f) : fn(std::move(f)) {} }; - struct WildcardListener : ListenerBase { - std::function)> fn; - explicit WildcardListener(std::function)> f) : fn(std::move(f)) {} - }; - template static void typedTrampoline(int ret, const char* msg, std::size_t len, void* ud) { if (!ud || ret != 0 || !msg || len == 0) return; @@ -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(ud); - if (!listener->fn) return; - std::span envelope{}; - if (msg && len > 0) { - envelope = std::span(reinterpret_cast(msg), len); - } - std::string eventId; - if (ret == 0 && !envelope.empty()) { - CborParser parser; CborValue it; - if (cbor_parser_init(envelope.data(), envelope.size(), 0, &parser, &it) == CborNoError - && cbor_value_is_map(&it)) { - CborValue evtField; - if (cbor_value_map_find_value(&it, "eventType", &evtField) == CborNoError - && cbor_value_is_text_string(&evtField)) { - (void)decode_cbor(evtField, eventId); - } - } - } - listener->fn(ret, eventId, envelope); - } - void* ptr_; std::chrono::milliseconds timeout_; std::unordered_map> listeners_; diff --git a/examples/timer/rust_bindings/src/api.rs b/examples/timer/rust_bindings/src/api.rs index d8d9721..709f9c1 100644 --- a/examples/timer/rust_bindings/src/api.rs +++ b/examples/timer/rust_bindings/src/api.rs @@ -117,48 +117,9 @@ unsafe extern "C" fn on_echo_fired_trampoline( } } -struct WildcardHandler { - f: Box, -} - -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::(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( - envelope: &[u8], -) -> Result { - #[derive(serde::Deserialize)] - struct Envelope { payload: T } - let env: Envelope = 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::`. - pub fn add_event_listener(&self, handler: F) -> ListenerHandle - where F: Fn(c_int, &str, &[u8]) + Send + Sync + 'static, - { - let owned: Box = 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 { diff --git a/ffi/codegen/cpp.nim b/ffi/codegen/cpp.nim index 467b26b..3401a83 100644 --- a/ffi/codegen/cpp.nim +++ b/ffi/codegen/cpp.nim @@ -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) -> - ## 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` to - # lift the payload into a typed value without hand-rolling CBOR parsing. - lines.add( - " ListenerHandle addEventListener(std::function)> handler) {" - ) - lines.add( - " auto owned = std::make_unique(std::move(handler));" - ) - lines.add(" auto* raw = owned.get();") - lines.add(" const auto id = $1_add_event_listener(" % [libName]) - lines.add( - " ptr_, \"\", &$1::wildcardTrampoline, raw);" % [ctxTypeName] - ) - lines.add(" if (id == 0) return ListenerHandle{0};") - lines.add(" listeners_.emplace(id, std::move(owned));") - lines.add(" return ListenerHandle{id};") - lines.add(" }") - lines.add("") # Remove by handle. lines.add(" bool removeEventListener(ListenerHandle handle) {") lines.add(" if (handle.id == 0) return false;") @@ -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` holds the user's `std::function` ## and is the target of `typedTrampoline`, which CBOR-decodes the ## envelope's `payload` field as `T` and invokes the handler. - ## - `WildcardListener` holds a `std::function)>` and is the target - ## of `wildcardTrampoline`, which forwards the FFI return code plus - ## a span view over the raw payload bytes (no copy — the dylib owns - ## the buffer for the duration of the synchronous callback). if events.len == 0: return lines.add(" struct ListenerBase {") @@ -253,15 +220,6 @@ proc emitEventTrampoline( lines.add(" explicit TypedListener(std::function f) : fn(std::move(f)) {}") lines.add(" };") lines.add("") - lines.add(" struct WildcardListener : ListenerBase {") - lines.add( - " std::function)> fn;" - ) - lines.add( - " explicit WildcardListener(std::function)> f) : fn(std::move(f)) {}" - ) - lines.add(" };") - lines.add("") # Typed trampoline — one instantiation per payload type, all sharing a body. lines.add(" template ") lines.add(" static void typedTrampoline(int ret, const char* msg, std::size_t len, void* ud) {") @@ -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(ud);") - lines.add(" if (!listener->fn) return;") - # The buffer pointed to by `msg` is owned by the dylib and stays valid - # for the duration of this synchronous call — safe to hand to the user - # as a span view rather than copying. - lines.add(" std::span envelope{};") - lines.add(" if (msg && len > 0) {") - lines.add( - " envelope = std::span(reinterpret_cast(msg), len);" - ) - lines.add(" }") - lines.add(" std::string eventId;") - lines.add(" if (ret == 0 && !envelope.empty()) {") - lines.add(" CborParser parser; CborValue it;") - lines.add( - " if (cbor_parser_init(envelope.data(), envelope.size(), 0, &parser, &it) == CborNoError" - ) - lines.add(" && cbor_value_is_map(&it)) {") - lines.add(" CborValue evtField;") - lines.add( - " if (cbor_value_map_find_value(&it, \"eventType\", &evtField) == CborNoError" - ) - lines.add(" && cbor_value_is_text_string(&evtField)) {") - lines.add(" (void)decode_cbor(evtField, eventId);") - lines.add(" }") - lines.add(" }") - lines.add(" }") - lines.add(" listener->fn(ret, eventId, envelope);") - lines.add(" }") - lines.add("") 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 — - # `` backs the `listeners_` map, `` is the - # zero-copy view type handed to wildcard callbacks. + # `` backs the `listeners_` map. lines.add("#include ") - lines.add("#include ") # Result 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 ") - lines.add( - "inline bool decodeEventPayload(std::span envelope, T& out) {" - ) - lines.add(" if (envelope.empty()) return false;") - lines.add(" CborParser parser; CborValue it;") - lines.add( - " if (cbor_parser_init(envelope.data(), envelope.size(), 0, &parser, &it) != CborNoError)" - ) - lines.add(" return false;") - lines.add(" if (!cbor_value_is_map(&it)) return false;") - lines.add(" CborValue payloadField;") - lines.add( - " if (cbor_value_map_find_value(&it, \"payload\", &payloadField) != CborNoError)" - ) - lines.add(" return false;") - lines.add(" return decode_cbor(payloadField, out) == CborNoError;") - lines.add("}") - lines.add("") - # ── High-level C++ context class ────────────────────────────────────────── var ctors: seq[FFIProcMeta] = @[] var methods: seq[FFIProcMeta] = @[] @@ -661,13 +551,13 @@ proc generateCppHeader*( lines.add("") lines.add("private:") - # Listener machinery (`ListenerBase`, `TypedListener`, - # `WildcardListener`, plus the static trampolines) must appear before - # the `listeners_` data member declaration — C++ requires the value - # type of a member to be complete at point of declaration. The public - # add*/remove methods above also reference these types, but member - # function bodies see the full class scope regardless of declaration - # order, so emitting here is sufficient for both. + # Listener machinery (`ListenerBase`, `TypedListener`, 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_;") diff --git a/ffi/codegen/rust.nim b/ffi/codegen/rust.nim index 709cfe4..6972954 100644 --- a/ffi/codegen/rust.nim +++ b/ffi/codegen/rust.nim @@ -454,8 +454,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" @@ -486,73 +486,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,") - 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::(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(" - ) - lines.add(" envelope: &[u8],") - lines.add(") -> Result {") - lines.add(" #[derive(serde::Deserialize)]") - lines.add(" struct Envelope { payload: T }") - lines.add( - " let env: Envelope = 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]) @@ -697,11 +635,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,") @@ -751,43 +689,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::` 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::`." - ) - lines.add( - " pub fn add_event_listener(&self, handler: F) -> ListenerHandle" - ) - lines.add(" where F: Fn(c_int, &str, &[u8]) + Send + Sync + 'static,") - lines.add(" {") - lines.add( - " let owned: Box = 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( diff --git a/ffi/codegen/templates/cpp/CMakeLists.txt.tpl b/ffi/codegen/templates/cpp/CMakeLists.txt.tpl index 9310115..b204d4e 100644 --- a/ffi/codegen/templates/cpp/CMakeLists.txt.tpl +++ b/ffi/codegen/templates/cpp/CMakeLists.txt.tpl @@ -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 on its wildcard callback to hand the -# CBOR envelope to consumers as a zero-copy view; only became -# part of the standard library in C++20. +# 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) diff --git a/ffi/codegen/templates/cpp/header_prelude.hpp.tpl b/ffi/codegen/templates/cpp/header_prelude.hpp.tpl index 6180dd4..66029b6 100644 --- a/ffi/codegen/templates/cpp/header_prelude.hpp.tpl +++ b/ffi/codegen/templates/cpp/header_prelude.hpp.tpl @@ -1,6 +1,6 @@ #pragma once -// Generated bindings require C++20 — the event-listener API uses -// std::span 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). diff --git a/ffi/ffi_context.nim b/ffi/ffi_context.nim index 7dcb01b..531347c 100644 --- a/ffi/ffi_context.nim +++ b/ffi/ffi_context.nim @@ -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 diff --git a/ffi/ffi_events.nim b/ffi/ffi_events.nim index 09cfa0f..0a33308 100644 --- a/ffi/ffi_events.nim +++ b/ffi/ffi_events.nim @@ -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 @@ -17,7 +17,7 @@ {.pragma: callback, cdecl, raises: [], gcsafe.} -import std/[locks, tables] +import std/[locks, sequtils, tables] import chronicles import ./ffi_types, ./cbor_serial @@ -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,41 @@ 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) + var + pruneKey = "" + prune = false + for key, listeners in reg.byEvent.mpairs: + let before = listeners.len + listeners.keepItIf(it.id != id) + if listeners.len < before: removed = true + if listeners.len == 0: + pruneKey = key + prune = 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 - break - if prune: - reg.byEvent.del(emptyKey) + if prune: + reg.byEvent.del(pruneKey) 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 +171,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 +190,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 diff --git a/ffi/internal/ffi_library.nim b/ffi/internal/ffi_library.nim index 0fcd26c..824f54b 100644 --- a/ffi/internal/ffi_library.nim +++ b/ffi/internal/ffi_library.nim @@ -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. ## diff --git a/tests/e2e/cpp/test_timer_e2e.cpp b/tests/e2e/cpp/test_timer_e2e.cpp index a181d65..1f882a2 100644 --- a/tests/e2e/cpp/test_timer_e2e.cpp +++ b/tests/e2e/cpp/test_timer_e2e.cpp @@ -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` lifts the payload -// into a typed value. -TEST(TimerE2E, WildcardListenerReceivesEventIdAndDecodesPayload) { - auto ctx = makeCtx("wildcard"); - - struct Capture { - int retCode; - std::string eventId; - std::size_t envelopeBytes; - std::optional decoded; - }; - - std::mutex mu; - std::vector captured; - auto handle = ctx->addEventListener( - [&](int retCode, const std::string& eventId, - std::span envelope) { - Capture c{retCode, eventId, envelope.size(), std::nullopt}; - if (retCode == 0 && eventId == "on_echo_fired") { - EchoEvent evt{}; - if (decodeEventPayload(envelope, evt)) { - c.decoded = evt; - } - } - std::lock_guard lock(mu); - captured.push_back(std::move(c)); - }); - ASSERT_NE(handle.id, 0u); - - ctx->echo(EchoRequest{"hello", 1}); - - for (int i = 0; i < 200; ++i) { - { - std::lock_guard lock(mu); - if (!captured.empty()) break; - } - std::this_thread::sleep_for(std::chrono::milliseconds(5)); - } - - std::lock_guard lock(mu); - ASSERT_GE(captured.size(), 1u); - EXPECT_EQ(captured.front().retCode, 0); - EXPECT_EQ(captured.front().eventId, "on_echo_fired"); - EXPECT_GT(captured.front().envelopeBytes, 0u); - ASSERT_TRUE(captured.front().decoded.has_value()); - EXPECT_EQ(captured.front().decoded->message, "hello"); - EXPECT_EQ(captured.front().decoded->echoCount, 1); -} diff --git a/tests/unit/test_event_dispatch.nim b/tests/unit/test_event_dispatch.nim index 8db4ba4..c5bd198 100644 --- a/tests/unit/test_event_dispatch.nim +++ b/tests/unit/test_event_dispatch.nim @@ -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 diff --git a/tests/unit/test_event_listener.nim b/tests/unit/test_event_listener.nim index 432b6ba..620c385 100644 --- a/tests/unit/test_event_listener.nim +++ b/tests/unit/test_event_listener.nim @@ -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