diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3aa6db2..49de017 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -143,6 +143,53 @@ jobs: fi nimble test_cpp_e2e -y + check-bindings: + name: Check generated bindings + needs: versions + # Codegen output is platform-independent — single OS is enough. Matrix + # over Nim versions to catch any version-sensitive output. Catches the + # class of drift surfaced in PR #39 (C++ regen committed, Rust + # overlooked); see `nimble check_bindings` in ffi.nimble. + strategy: + fail-fast: false + matrix: + nim-version: ${{ fromJSON(needs.versions.outputs.nim-versions) }} + runs-on: ubuntu-22.04 + env: + NIMBLE_VERSION: ${{ needs.versions.outputs.nimble }} + steps: + - uses: actions/checkout@v4 + + - name: Setup Nim + uses: jiro4989/setup-nim-action@v2 + with: + nim-version: ${{ matrix.nim-version }} + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Install Nimble ${{ env.NIMBLE_VERSION }} + run: | + cd /tmp && nimble install "nimble@${{ env.NIMBLE_VERSION }}" -y + echo "$HOME/.nimble/bin" >> $GITHUB_PATH + + - name: Cache nimble deps + id: cache-nimbledeps + uses: actions/cache@v4 + with: + path: | + nimbledeps/ + nimble.paths + key: ${{ runner.os }}-nimbledeps-${{ matrix.nim-version }}-${{ hashFiles('*.nimble') }} + restore-keys: | + ${{ runner.os }}-nimbledeps-${{ matrix.nim-version }}- + ${{ runner.os }}-nimbledeps- + + - name: Install nimble deps + if: steps.cache-nimbledeps.outputs.cache-hit != 'true' + run: nimble setup --localdeps -y + + - name: Verify checked-in bindings match generator output + run: nimble check_bindings -y + tests-asan-ubsan: name: Tests · ASan+UBSan+LSan needs: versions diff --git a/examples/timer/cpp_bindings/my_timer.hpp b/examples/timer/cpp_bindings/my_timer.hpp index e403760..f707626 100644 --- a/examples/timer/cpp_bindings/my_timer.hpp +++ b/examples/timer/cpp_bindings/my_timer.hpp @@ -336,6 +336,33 @@ inline CborError decode_cbor(CborValue& it, ComplexResponse& v) { return cbor_value_advance(&it); } +struct EchoEvent { + std::string message; + int64_t echoCount; +}; +inline CborError encode_cbor(CborEncoder& e, const EchoEvent& v) { + CborEncoder m; + CborError err = cbor_encoder_create_map(&e, &m, 2); + if (err) return err; + err = cbor_encode_text_stringz(&m, "message"); if (err) return err; + err = encode_cbor(m, v.message); if (err) return err; + err = cbor_encode_text_stringz(&m, "echoCount"); if (err) return err; + err = encode_cbor(m, v.echoCount); if (err) return err; + return cbor_encoder_close_container(&e, &m); +} +inline CborError decode_cbor(CborValue& it, EchoEvent& v) { + if (!cbor_value_is_map(&it)) return CborErrorImproperValue; + CborValue field; + CborError err; + err = cbor_value_map_find_value(&it, "message", &field); if (err) return err; + if (!cbor_value_is_valid(&field)) return CborErrorImproperValue; + err = decode_cbor(field, v.message); if (err) return err; + err = cbor_value_map_find_value(&it, "echoCount", &field); if (err) return err; + if (!cbor_value_is_valid(&field)) return CborErrorImproperValue; + err = decode_cbor(field, v.echoCount); if (err) return err; + return cbor_value_advance(&it); +} + struct JobSpec { std::string name; std::vector payload; @@ -600,6 +627,7 @@ int my_timer_version(void* ctx, FFICallback callback, void* user_data, const uin int my_timer_complex(void* ctx, FFICallback callback, void* user_data, const uint8_t* req_cbor, size_t req_cbor_len); int my_timer_schedule(void* ctx, FFICallback callback, void* user_data, const uint8_t* req_cbor, size_t req_cbor_len); int my_timer_destroy(void* ctx); +void my_timer_set_event_callback(void* ctx, FFICallback callback, void* user_data); } // extern "C" // ============================================================ @@ -702,6 +730,17 @@ public: MyTimerCtx(MyTimerCtx&&) = delete; MyTimerCtx& operator=(MyTimerCtx&&) = delete; + // ── Typed event handlers ──────────────────────────────── + struct Events { + std::function on_error; + std::function onEchoFired; + }; + + void setEventHandlers(Events handlers) { + events_ = std::make_unique(std::move(handlers)); + my_timer_set_event_callback(ptr_, &MyTimerCtx::eventTrampoline, events_.get()); + } + EchoResponse echo(const EchoRequest& req) const { const auto ffi_req_ = MyTimerEchoReq{req}; const auto ffi_req_bytes_ = encodeCborFFI(ffi_req_); @@ -757,5 +796,35 @@ public: private: void* ptr_; std::chrono::milliseconds timeout_; + std::unique_ptr events_; explicit MyTimerCtx(void* p, std::chrono::milliseconds t) : ptr_(p), timeout_(t) {} + static void eventTrampoline(int ret, const char* msg, std::size_t len, void* ud) { + if (!ud) return; + auto* events = static_cast(ud); + if (ret != 0) { + if (events->on_error) { + std::string err(msg ? msg : "", len); + events->on_error(err); + } + return; + } + if (!msg || len == 0) return; + std::vector bytes(reinterpret_cast(msg), + reinterpret_cast(msg) + len); + CborParser parser; CborValue it; + if (cbor_parser_init(bytes.data(), bytes.size(), 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); + } + } + } + }; diff --git a/examples/timer/rust_bindings/src/api.rs b/examples/timer/rust_bindings/src/api.rs index ba1a9a6..deb1fe2 100644 --- a/examples/timer/rust_bindings/src/api.rs +++ b/examples/timer/rust_bindings/src/api.rs @@ -98,10 +98,62 @@ where } } +/// Typed event handlers for `MyTimerCtx`. Each field is `None` by +/// default; set the ones you care about and pass to +/// `MyTimerCtx::set_event_handlers`. +#[allow(non_snake_case)] +pub struct Events { + pub on_error: Option>, + pub onEchoFired: Option>, +} + +impl Default for Events { + fn default() -> Self { + Self { on_error: None, onEchoFired: None } + } +} + +unsafe extern "C" fn my_timer_event_trampoline( + ret: c_int, msg: *const c_char, len: usize, ud: *mut c_void, +) { + if ud.is_null() { return; } + let events = &*(ud as *const Events); + if ret != 0 { + if let Some(ref on_err) = events.on_error { + let bytes = if !msg.is_null() && len > 0 { + slice::from_raw_parts(msg as *const u8, len) + } else { &[] }; + let s = String::from_utf8_lossy(bytes); + on_err(&s); + } + return; + } + if msg.is_null() || len == 0 { return; } + let bytes = slice::from_raw_parts(msg as *const u8, len); + #[derive(serde::Deserialize)] + struct EnvelopeMeta { + #[serde(rename = "eventType")] + event_type: String, + } + let meta: EnvelopeMeta = match ciborium::de::from_reader(bytes) { + Ok(m) => m, + Err(_) => return, + }; + if meta.event_type == "on_echo_fired" { + #[derive(serde::Deserialize)] + struct Envelope { payload: EchoEvent } + if let Ok(env) = ciborium::de::from_reader::(bytes) { + if let Some(ref h) = events.onEchoFired { h(&env.payload); } + } + return; + } +} + /// High-level context for `MyTimer`. pub struct MyTimerCtx { ptr: *mut c_void, timeout: Duration, + events: *mut Events, } // SAFETY: The `ptr` field points to an FFIContext owned by the Nim runtime. @@ -121,6 +173,10 @@ impl Drop for MyTimerCtx { unsafe { ffi::my_timer_destroy(self.ptr); } self.ptr = std::ptr::null_mut(); } + if !self.events.is_null() { + unsafe { drop(Box::from_raw(self.events)); } + self.events = std::ptr::null_mut(); + } } } @@ -134,7 +190,7 @@ impl MyTimerCtx { })?; let addr_str: String = decode_cbor(&raw_bytes)?; let addr: usize = addr_str.parse().map_err(|e: std::num::ParseIntError| e.to_string())?; - Ok(Self { ptr: addr as *mut c_void, timeout }) + Ok(Self { ptr: addr as *mut c_void, timeout, events: std::ptr::null_mut() }) } pub async fn new_async(config: TimerConfig, timeout: Duration) -> Result { @@ -146,7 +202,23 @@ impl MyTimerCtx { }).await?; let addr_str: String = decode_cbor(&raw_bytes)?; let addr: usize = addr_str.parse().map_err(|e: std::num::ParseIntError| e.to_string())?; - Ok(Self { ptr: addr as *mut c_void, timeout }) + Ok(Self { ptr: addr as *mut c_void, timeout, events: std::ptr::null_mut() }) + } + + /// Attach typed event handlers. Replacing handlers calls the + /// dylib's set_event_callback with a fresh trampoline target. + /// The previously-installed Events box (if any) is dropped here, + /// so callbacks in flight on the FFI thread must already be done. + pub fn set_event_handlers(&mut self, handlers: Events) { + if !self.events.is_null() { + unsafe { drop(Box::from_raw(self.events)); } + self.events = std::ptr::null_mut(); + } + let raw = Box::into_raw(Box::new(handlers)); + self.events = raw; + unsafe { + ffi::my_timer_set_event_callback(self.ptr, my_timer_event_trampoline, raw as *mut c_void); + } } pub fn echo(&self, req: EchoRequest) -> Result { diff --git a/examples/timer/rust_bindings/src/ffi.rs b/examples/timer/rust_bindings/src/ffi.rs index 4d11d0d..40613ee 100644 --- a/examples/timer/rust_bindings/src/ffi.rs +++ b/examples/timer/rust_bindings/src/ffi.rs @@ -15,4 +15,5 @@ extern "C" { pub fn my_timer_complex(ctx: *mut c_void, callback: FFICallback, user_data: *mut c_void, req_cbor: *const u8, req_cbor_len: usize) -> c_int; pub fn my_timer_schedule(ctx: *mut c_void, callback: FFICallback, user_data: *mut c_void, req_cbor: *const u8, req_cbor_len: usize) -> c_int; pub fn my_timer_destroy(ctx: *mut c_void) -> c_int; + pub fn my_timer_set_event_callback(ctx: *mut c_void, callback: FFICallback, user_data: *mut c_void); } diff --git a/examples/timer/rust_bindings/src/types.rs b/examples/timer/rust_bindings/src/types.rs index dac8cbe..cbb731f 100644 --- a/examples/timer/rust_bindings/src/types.rs +++ b/examples/timer/rust_bindings/src/types.rs @@ -36,6 +36,13 @@ pub struct ComplexResponse { pub has_note: bool, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EchoEvent { + pub message: String, + #[serde(rename = "echoCount")] + pub echo_count: i64, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct JobSpec { pub name: String, diff --git a/examples/timer/timer.nim b/examples/timer/timer.nim index 098fd1a..5b119de 100644 --- a/examples/timer/timer.nim +++ b/examples/timer/timer.nim @@ -34,6 +34,17 @@ type ComplexResponse {.ffi.} = object itemCount: int hasNote: bool +# --- Library-initiated event ---------------------------------------------- +# Demonstrates the {.ffiEvent.} macro: a typed event the library can fire +# from any {.ffi.} handler, dispatched to the foreign side's registered +# callback as CBOR. Per-target codegens emit a typed handler-struct + +# dispatcher so the foreign caller decodes nothing by hand. +type EchoEvent {.ffi.} = object + message: string + echoCount: int + +proc onEchoFired*(evt: EchoEvent) {.ffiEvent: "on_echo_fired".} + # --- Constructor ----------------------------------------------------------- # Called once from Rust. Creates the FFIContext + MyTimer. # Uses chronos (await sleepAsync) so the body is async. @@ -48,6 +59,7 @@ proc myTimerEcho*( timer: MyTimer, req: EchoRequest ): Future[Result[EchoResponse, string]] {.ffi.} = await sleepAsync(req.delayMs.milliseconds) + onEchoFired(EchoEvent(message: req.message, echoCount: 1)) return ok(EchoResponse(echoed: req.message, timerName: timer.name)) # --- Sync method ----------------------------------------------------------- diff --git a/ffi.nimble b/ffi.nimble index c3ad2e8..b1dffea 100644 --- a/ffi.nimble +++ b/ffi.nimble @@ -165,3 +165,20 @@ task genbindings_cpp, "Generate C++ bindings for the timer example": " -d:ffiOutputDir=examples/timer/cpp_bindings" & " -d:ffiSrcPath=../timer.nim" & " -o:/dev/null examples/timer/timer.nim" + +task check_bindings_rust, "Verify checked-in Rust bindings match Nim source": + exec "nimble genbindings_rust" + exec "git diff --exit-code --" & + " examples/timer/rust_bindings/Cargo.toml" & + " examples/timer/rust_bindings/build.rs" & + " examples/timer/rust_bindings/src" + +task check_bindings_cpp, "Verify checked-in C++ bindings match Nim source": + exec "nimble genbindings_cpp" + exec "git diff --exit-code --" & + " examples/timer/cpp_bindings/my_timer.hpp" & + " examples/timer/cpp_bindings/CMakeLists.txt" + +task check_bindings, "Verify all checked-in example bindings match Nim source": + exec "nimble check_bindings_rust" + exec "nimble check_bindings_cpp" diff --git a/ffi/cbor_serial.nim b/ffi/cbor_serial.nim index 2651631..5c1ae8f 100644 --- a/ffi/cbor_serial.nim +++ b/ffi/cbor_serial.nim @@ -48,8 +48,9 @@ proc cborEncodeShared*[T](x: T): tuple[data: ptr UncheckedArray[byte], len: int] ## Encodes `x` into a `c_malloc` buffer. ## ## The returned `data` is owned by the caller and must be freed exactly - ## once via `c_free` (the FFIThreadRequest `deleteRequest` path does this - ## automatically). Empty payloads return `(nil, 0)` without allocating. + ## once via `cborFreeShared`. The + ## `FFIThreadRequest deleteRequest` path frees adopted buffers + ## automatically. Empty payloads return `(nil, 0)` without allocating. let bytes = Cbor.encode(x) if bytes.len == 0: return (nil, 0) @@ -57,6 +58,14 @@ proc cborEncodeShared*[T](x: T): tuple[data: ptr UncheckedArray[byte], len: int] copyMem(buf, unsafeAddr bytes[0], bytes.len) return (buf, bytes.len) +proc cborFreeShared*(data: var ptr UncheckedArray[byte]) = + ## Releases a buffer previously returned by `cborEncodeShared` and nils + ## the caller's pointer so a stale reference can't be reused after free. + ## Safe to call with `nil` (the `(nil, 0)` empty-payload contract). + if not data.isNil(): + c_free(data) + data = nil + proc cborDecode*[T](data: openArray[byte], _: typedesc[T]): Result[T, string] = ## Decode `data` into a `T`, converting any cbor_serialization exception ## into a `Result.err` carrying the exception message. diff --git a/ffi/codegen/cpp.nim b/ffi/codegen/cpp.nim index e6b4d7b..9c5d3fd 100644 --- a/ffi/codegen/cpp.nim +++ b/ffi/codegen/cpp.nim @@ -135,8 +135,91 @@ proc cppBracedInit(structName: string, fieldNames: seq[string]): string = ## returns "", so the result is the well-formed empty-init `Name{}`. return structName & "{" & fieldNames.join(", ") & "}" +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. + ## + ## Storage strategy: `Events` lives on the heap (unique_ptr) so the raw + ## pointer we hand to the C ABI as `user_data` survives the (non-) + ## lifetime of the surrounding context object. The context itself is + ## owned via `std::unique_ptr` returned from `create`, so + ## it's never moved out from under the trampoline. + if events.len == 0: + return + lines.add(" // ── Typed event handlers ────────────────────────────────") + lines.add(" struct Events {") + lines.add(" std::function on_error;") + for ev in events: + lines.add( + " std::function $2;" % + [ev.payloadTypeName, ev.nimProcName] + ) + lines.add(" };") + lines.add("") + lines.add(" void setEventHandlers(Events handlers) {") + lines.add(" events_ = std::make_unique(std::move(handlers));") + lines.add( + " $1_set_event_callback(ptr_, &$2::eventTrampoline, events_.get());" % + [libName, ctxTypeName] + ) + 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. + if events.len == 0: + return + lines.add(" static void eventTrampoline(int ret, const char* msg, std::size_t len, void* ud) {") + lines.add(" if (!ud) return;") + lines.add(" auto* events = static_cast(ud);") + lines.add(" if (ret != 0) {") + lines.add(" if (events->on_error) {") + lines.add(" std::string err(msg ? msg : \"\", len);") + lines.add(" events->on_error(err);") + lines.add(" }") + lines.add(" return;") + lines.add(" }") + lines.add(" if (!msg || len == 0) return;") + lines.add(" std::vector bytes(reinterpret_cast(msg),") + lines.add(" reinterpret_cast(msg) + len);") + lines.add(" CborParser parser; CborValue it;") + lines.add(" if (cbor_parser_init(bytes.data(), bytes.size(), 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(" }") + lines.add("") + proc generateCppHeader*( - procs: seq[FFIProcMeta], types: seq[FFITypeMeta], libName: string + procs: seq[FFIProcMeta], + types: seq[FFITypeMeta], + libName: string, + events: seq[FFIEventMeta] = @[], ): string = var lines: seq[string] = @[] @@ -220,6 +303,13 @@ proc generateCppHeader*( ) of FFIKind.DTOR: lines.add("int $1(void* ctx);" % [p.procName]) + # The event-callback setter is always exported by the dylib (via + # declareLibrary). Declare it here so the typed event-handler wiring + # below can call into it. + lines.add( + "void $1_set_event_callback(void* ctx, FFICallback callback, void* user_data);" % + [libName] + ) lines.add("} // extern \"C\"") lines.add("") @@ -341,6 +431,9 @@ proc generateCppHeader*( ContextRuleOf5Tpl.multiReplace(("{{CTX}}", ctxTypeName), ("{{LIB}}", libName)) ) + # ── Typed event handlers (public section) ─────────────────────────────── + emitEventDispatcher(lines, ctxTypeName, libName, events) + # ── Instance methods ──────────────────────────────────────────────────── for m in methods: let methodName = stripLibPrefixCpp(m.procName, libName) @@ -406,10 +499,14 @@ proc generateCppHeader*( lines.add("private:") lines.add(" void* ptr_;") lines.add(" std::chrono::milliseconds timeout_;") + if events.len > 0: + lines.add(" std::unique_ptr events_;") 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("") @@ -425,7 +522,11 @@ proc generateCppBindings*( libName: string, outputDir: string, nimSrcRelPath: string, + events: seq[FFIEventMeta] = @[], ) = createDir(outputDir) - writeFile(outputDir / (libName & ".hpp"), generateCppHeader(procs, types, libName)) + writeFile( + outputDir / (libName & ".hpp"), + generateCppHeader(procs, types, libName, events), + ) writeFile(outputDir / "CMakeLists.txt", generateCppCMakeLists(libName, nimSrcRelPath)) diff --git a/ffi/codegen/meta.nim b/ffi/codegen/meta.nim index 736af55..230919a 100644 --- a/ffi/codegen/meta.nim +++ b/ffi/codegen/meta.nim @@ -29,9 +29,21 @@ type name*: string fields*: seq[FFIFieldMeta] + FFIEventMeta* = object + ## Library-initiated event declared with `{.ffiEvent: "wire_name".}`. + ## `wireName` is the literal string the foreign side dispatches on + ## (it appears in the CBOR `eventType` field, verbatim — no case + ## conversion). `payloadTypeName` is the Nim type of the single + ## payload parameter. + wireName*: string + nimProcName*: string + libName*: string + payloadTypeName*: string + # Compile-time registries populated by the macros var ffiProcRegistry* {.compileTime.}: seq[FFIProcMeta] var ffiTypeRegistry* {.compileTime.}: seq[FFITypeMeta] +var ffiEventRegistry* {.compileTime.}: seq[FFIEventMeta] var currentLibName* {.compileTime.}: string # Target language for binding generation; override with -d:targetLang=cpp diff --git a/ffi/codegen/rust.nim b/ffi/codegen/rust.nim index 07c93d9..9ce0b5c 100644 --- a/ffi/codegen/rust.nim +++ b/ffi/codegen/rust.nim @@ -1,7 +1,7 @@ ## Rust binding generator for the nim-ffi framework. ## Generates a complete Rust crate that uses CBOR (ciborium) on the wire. -import std/[os, strutils] +import std/[os, strutils, sequtils] import ./meta, ./string_helpers ## Wire-format Rust type used for any Nim `ptr T` / `pointer`. Fixed 64-bit so @@ -207,6 +207,13 @@ proc generateFFIRs*(procs: seq[FFIProcMeta]): string = params.add("ctx: *mut c_void") lines.add(" pub fn $1($2) -> c_int;" % [p.procName, params.join(", ")]) + # Event-callback setter — emitted on the Nim side by `declareLibrary`, + # always present in the dylib. + lines.add( + " pub fn $1_set_event_callback(ctx: *mut c_void, callback: FFICallback, user_data: *mut c_void);" % + [linkLibName] + ) + lines.add("}") return lines.join("\n") & "\n" @@ -256,7 +263,9 @@ proc generateTypesRs*(types: seq[FFITypeMeta], procs: seq[FFIProcMeta]): string return lines.join("\n") -proc generateApiRs*(procs: seq[FFIProcMeta], libName: string): string = +proc generateApiRs*( + procs: seq[FFIProcMeta], libName: string, events: seq[FFIEventMeta] = @[] +): string = ## Generates api.rs with both a blocking and a tokio-async high-level API. ## ## Blocking: ctx.echo(req) — thread-blocks via Condvar @@ -433,11 +442,87 @@ proc generateApiRs*(procs: seq[FFIProcMeta], libName: string): string = lines.add("}") lines.add("") + # ── Typed event handler struct + trampoline (only if events declared) ──── + # The Events struct holds optional boxed closures, one per registered + # `{.ffiEvent.}`. The struct lives on the heap (Box::into_raw); its raw + # pointer is handed to the dylib as `user_data` for the event callback. + # The trampoline parses the CBOR `EventEnvelope`, picks the matching + # field on Events, decodes the payload as the registered type, and + # invokes the closure. + if events.len > 0: + lines.add("/// Typed event handlers for `$1`. Each field is `None` by" % [ctxTypeName]) + lines.add("/// default; set the ones you care about and pass to") + lines.add("/// `$1::set_event_handlers`." % [ctxTypeName]) + lines.add("#[allow(non_snake_case)]") + lines.add("pub struct Events {") + lines.add(" pub on_error: Option>,") + for ev in events: + lines.add( + " pub $1: Option>," % + [ev.nimProcName, ev.payloadTypeName] + ) + lines.add("}") + lines.add("") + lines.add("impl Default for Events {") + lines.add(" fn default() -> Self {") + lines.add(" Self { on_error: None, " & + events.mapIt(it.nimProcName & ": None").join(", ") & " }") + lines.add(" }") + lines.add("}") + lines.add("") + + # Trampoline — `extern "C"` free function. Deserialises the envelope + # twice: once for `eventType`, then with the typed payload via serde. + lines.add("unsafe extern \"C\" fn $1_event_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 events = &*(ud as *const Events);") + lines.add(" if ret != 0 {") + lines.add(" if let Some(ref on_err) = events.on_error {") + 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 s = String::from_utf8_lossy(bytes);") + lines.add(" on_err(&s);") + lines.add(" }") + lines.add(" return;") + lines.add(" }") + lines.add(" if msg.is_null() || len == 0 { return; }") + lines.add(" let bytes = slice::from_raw_parts(msg as *const u8, len);") + lines.add(" #[derive(serde::Deserialize)]") + lines.add(" struct EnvelopeMeta {") + lines.add(" #[serde(rename = \"eventType\")]") + lines.add(" event_type: String,") + lines.add(" }") + lines.add(" let meta: EnvelopeMeta = match ciborium::de::from_reader(bytes) {") + lines.add(" Ok(m) => m,") + lines.add(" Err(_) => return,") + lines.add(" };") + for ev in events: + lines.add(" if meta.event_type == \"$1\" {" % [ev.wireName]) + lines.add(" #[derive(serde::Deserialize)]") + lines.add(" struct Envelope { payload: $1 }" % [ev.payloadTypeName]) + lines.add( + " if let Ok(env) = ciborium::de::from_reader::(bytes) {" + ) + lines.add( + " if let Some(ref h) = events.$1 { h(&env.payload); }" % + [ev.nimProcName] + ) + lines.add(" }") + lines.add(" return;") + lines.add(" }") + lines.add("}") + lines.add("") + # ── Context struct ───────────────────────────────────────────────────────── lines.add("/// High-level context for `$1`." % [libTypeName]) lines.add("pub struct $1 {" % [ctxTypeName]) lines.add(" ptr: *mut c_void,") lines.add(" timeout: Duration,") + if events.len > 0: + lines.add(" events: *mut Events,") lines.add("}") lines.add("") # SAFETY block applies to both impls below (PR #23 Rust review, item 7). @@ -474,6 +559,13 @@ proc generateApiRs*(procs: seq[FFIProcMeta], libName: string): string = lines.add(" unsafe { ffi::$1(self.ptr); }" % [dtorProcName]) lines.add(" self.ptr = std::ptr::null_mut();") lines.add(" }") + # Reclaim the Events box after the dylib's destroy has torn down the + # FFI thread (no more events will fire by this point). + if events.len > 0: + lines.add(" if !self.events.is_null() {") + lines.add(" unsafe { drop(Box::from_raw(self.events)); }") + lines.add(" self.events = std::ptr::null_mut();") + lines.add(" }") lines.add(" }") lines.add("}") lines.add("") @@ -529,7 +621,10 @@ proc generateApiRs*(procs: seq[FFIProcMeta], libName: string): string = lines.add( " let addr: usize = addr_str.parse().map_err(|e: std::num::ParseIntError| e.to_string())?;" ) - lines.add(" Ok(Self { ptr: addr as *mut c_void, timeout })") + if events.len > 0: + lines.add(" Ok(Self { ptr: addr as *mut c_void, timeout, events: std::ptr::null_mut() })") + else: + lines.add(" Ok(Self { ptr: addr as *mut c_void, timeout })") lines.add(" }") lines.add("") @@ -552,7 +647,32 @@ proc generateApiRs*(procs: seq[FFIProcMeta], libName: string): string = lines.add( " let addr: usize = addr_str.parse().map_err(|e: std::num::ParseIntError| e.to_string())?;" ) - lines.add(" Ok(Self { ptr: addr as *mut c_void, timeout })") + if events.len > 0: + lines.add(" Ok(Self { ptr: addr as *mut c_void, timeout, events: std::ptr::null_mut() })") + else: + lines.add(" Ok(Self { ptr: addr as *mut c_void, timeout })") + lines.add(" }") + lines.add("") + + # ── Typed event registration ─────────────────────────────────────────── + if events.len > 0: + lines.add(" /// Attach typed event handlers. Replacing handlers calls the") + lines.add(" /// dylib's set_event_callback with a fresh trampoline target.") + lines.add(" /// The previously-installed Events box (if any) is dropped here,") + lines.add(" /// so callbacks in flight on the FFI thread must already be done.") + lines.add(" pub fn set_event_handlers(&mut self, handlers: Events) {") + lines.add(" if !self.events.is_null() {") + lines.add(" unsafe { drop(Box::from_raw(self.events)); }") + lines.add(" self.events = std::ptr::null_mut();") + lines.add(" }") + lines.add(" let raw = Box::into_raw(Box::new(handlers));") + lines.add(" self.events = raw;") + lines.add(" unsafe {") + lines.add( + " ffi::$1_set_event_callback(self.ptr, $1_event_trampoline, raw as *mut c_void);" % + [libName] + ) + lines.add(" }") lines.add(" }") lines.add("") @@ -635,6 +755,7 @@ proc generateRustCrate*( libName: string, outputDir: string, nimSrcRelPath: string, + events: seq[FFIEventMeta] = @[], ) = ## Generates a complete Rust crate in outputDir. createDir(outputDir) @@ -645,4 +766,4 @@ proc generateRustCrate*( writeFile(outputDir / "src" / "lib.rs", generateLibRs()) writeFile(outputDir / "src" / "ffi.rs", generateFFIRs(procs)) writeFile(outputDir / "src" / "types.rs", generateTypesRs(types, procs)) - writeFile(outputDir / "src" / "api.rs", generateApiRs(procs, libName)) + writeFile(outputDir / "src" / "api.rs", generateApiRs(procs, libName, events)) diff --git a/ffi/ffi_context.nim b/ffi/ffi_context.nim index b99777a..5ddd5a9 100644 --- a/ffi/ffi_context.nim +++ b/ffi/ffi_context.nim @@ -8,9 +8,38 @@ import ./ffi_types, ./ffi_thread_request, ./internal/ffi_macro, ./logging, ./cbo type FFICallbackState* = object ## Holds the C event callback and its associated user-data pointer. - ## Embedded in FFIContext and referenced from the FFI thread via a thread-local. + ## Embedded in FFIContext and referenced from the FFI thread via a + ## thread-local. `callback` / `userData` are written by + ## `{libName}_set_event_callback` from arbitrary caller threads and read + ## by every event dispatch on the FFI thread — `lock` exists so the + ## reader observes the pair atomically (and so the writes are visible + ## across threads at all under Nim's memory model). callback*: pointer userData*: pointer + lock*: Lock + +proc initCallbackState*(state: var FFICallbackState) = + state.lock.initLock() + +proc deinitCallbackState*(state: var FFICallbackState) = + state.lock.deinitLock() + +proc setCallback*( + state: var FFICallbackState, callback: pointer, userData: pointer +) = + ## Locked writer used by the generated `_set_event_callback` proc. + withLock state.lock: + state.callback = callback + state.userData = userData + +proc snapshotCallback*( + state: var FFICallbackState +): tuple[callback, userData: pointer] = + ## Locked reader: copies the (callback, userData) pair out as a single + ## consistent snapshot so dispatch code can invoke the callback without + ## holding the lock across user code. + withLock state.lock: + return (state.callback, state.userData) type FFIContext*[T] = object myLib*: ptr T @@ -47,48 +76,93 @@ var onFFIThread* {.threadvar.}: bool const git_version* {.strdefine.} = "n/a" template callEventCallback*(ctx: ptr FFIContext, eventName: string, body: untyped) = - if isNil(ctx[].callbackState.callback): + ## `body` may evaluate to a `string` or a `seq[byte]` — the cast to + ## `ptr cchar` accepts both `ptr char` and `ptr byte` source pointers. + let (cbPtr, ud) = snapshotCallback(ctx[].callbackState) + if isNil(cbPtr): chronicles.error eventName & " - eventCallback is nil" return foreignThreadGc: + let cb = cast[FFICallBack](cbPtr) try: let event = body - cast[FFICallBack](ctx[].callbackState.callback)( - RET_OK, - unsafeAddr event[0], - cast[csize_t](len(event)), - ctx[].callbackState.userData, - ) + cb(RET_OK, cast[ptr cchar](unsafeAddr event[0]), cast[csize_t](len(event)), ud) except Exception, CatchableError: let msg = "Exception " & eventName & " when calling 'eventCallBack': " & getCurrentExceptionMsg() - cast[FFICallBack](ctx[].callbackState.callback)( - RET_ERR, - unsafeAddr msg[0], - cast[csize_t](len(msg)), - ctx[].callbackState.userData, - ) + cb(RET_ERR, cast[ptr cchar](unsafeAddr msg[0]), cast[csize_t](len(msg)), ud) template dispatchFFIEvent*(eventName: string, body: untyped) = ## Dispatches an FFI event to the callback registered via `{libName}_set_event_callback`. ## `body` is evaluated lazily — only when a callback is registered. + ## `body` may produce a `string` (legacy JSON style) or a `seq[byte]` (CBOR). ## Valid only on the FFI thread (i.e., inside {.ffi.} proc bodies and their async closures). let ffiState = ffiCurrentCallbackState - if isNil(ffiState) or isNil(ffiState[].callback): + if isNil(ffiState): + chronicles.error eventName & " - event callback not set" + return + let (cbPtr, ud) = snapshotCallback(ffiState[]) + if isNil(cbPtr): chronicles.error eventName & " - event callback not set" return foreignThreadGc: + let cb = cast[FFICallBack](cbPtr) try: let event = body - cast[FFICallBack](ffiState[].callback)( - RET_OK, unsafeAddr event[0], cast[csize_t](len(event)), ffiState[].userData - ) + cb(RET_OK, cast[ptr cchar](unsafeAddr event[0]), cast[csize_t](len(event)), ud) except Exception, CatchableError: let msg = "Exception dispatching " & eventName & ": " & getCurrentExceptionMsg() - cast[FFICallBack](ffiState[].callback)( - RET_ERR, unsafeAddr msg[0], cast[csize_t](len(msg)), ffiState[].userData + cb(RET_ERR, cast[ptr cchar](unsafeAddr msg[0]), cast[csize_t](len(msg)), ud) + +type EventEnvelope*[T] = object + ## Standard wire shape for CBOR-encoded FFI events: + ## { eventType: tstr, payload: } + ## Pair with `dispatchFFIEventCbor` (or call `cborEncode` directly). + eventType*: string + payload*: T + +template dispatchFFIEventCbor*(eventName: string, eventPayload: typed) = + ## Typed CBOR variant of `dispatchFFIEvent`. Wraps `eventPayload` in an + ## `EventEnvelope`, CBOR-encodes it into a `c_malloc` buffer, dispatches + ## the callback, then frees the buffer. The payload type should be a + ## `{.ffi.}`-annotated object so the binding generator emits a matching + ## codec. + ## + ## The buffer is `c_malloc`-backed (not Nim GC-owned) so the pointer + ## handed to the callback does not depend on the FFI thread's GC heap + ## remaining valid — matches the ownership story used by request payloads + ## (see `ffi_thread_request.nim`). + ## + ## NB: the template parameter is intentionally named `eventPayload` + ## rather than `payload` — Nim's template substitution would otherwise + ## also replace the `payload:` field name inside `EventEnvelope`. + let ffiState = ffiCurrentCallbackState + if ffiState.isNil(): + chronicles.error eventName & " - event callback not set" + return + let (cbPtr, ud) = snapshotCallback(ffiState[]) + if cbPtr.isNil(): + chronicles.error eventName & " - event callback not set" + return + foreignThreadGc: + let cb = cast[FFICallBack](cbPtr) + try: + var (data, dataLen) = cborEncodeShared( + EventEnvelope[typeof(eventPayload)](eventType: eventName, payload: eventPayload) + ) + defer: + cborFreeShared(data) + cb(RET_OK, cast[ptr cchar](data), cast[csize_t](dataLen), ud) + except Exception, CatchableError: + # Catching `Exception` also catches Defects (OOM, overflow, ...) so + # the C caller always gets RET_OK/RET_ERR. Requires `--panics:off` + # (Nim's default; don't enable `--panics:on` for this lib). + + let msg = "Exception dispatching " & eventName & ": " & getCurrentExceptionMsg() + cb( + RET_ERR, cast[ptr cchar](unsafeAddr msg[0]), cast[csize_t](len(msg)), ud ) proc sendRequestToFFIThread*( @@ -313,6 +387,7 @@ proc cleanUpResources[T](ctx: ptr FFIContext[T]): Result[void, string] = defer: freeShared(ctx) ctx.lock.deinitLock() + deinitCallbackState(ctx[].callbackState) when defined(gcRefc): ## ThreadSignalPtr.close() is intentionally skipped under --mm:refc. ## @@ -350,6 +425,7 @@ proc initContextResources*[T](ctx: ptr FFIContext[T]): Result[void, string] = ## On failure every partially-initialised resource is closed; the caller ## is responsible for releasing the slot (freeShared or pool.releaseSlot). ctx.lock.initLock() + initCallbackState(ctx[].callbackState) var success = false defer: diff --git a/ffi/internal/ffi_library.nim b/ffi/internal/ffi_library.nim index 46b9b22..63faf18 100644 --- a/ffi/internal/ffi_library.nim +++ b/ffi/internal/ffi_library.nim @@ -114,8 +114,7 @@ macro declareLibrary*(libraryName: static[string], libType: untyped): untyped = if isNil(ctx): echo `errorMsg` return - ctx[].callbackState.callback = cast[pointer](callback) - ctx[].callbackState.userData = userData + setCallback(ctx[].callbackState, cast[pointer](callback), userData) let procNode = newProc( name = funcIdent, diff --git a/ffi/internal/ffi_macro.nim b/ffi/internal/ffi_macro.nim index 2d22422..4f9ffc5 100644 --- a/ffi/internal/ffi_macro.nim +++ b/ffi/internal/ffi_macro.nim @@ -1373,6 +1373,100 @@ macro ffiDtor*(prc: untyped): untyped = echo stmts.repr return stmts +# --------------------------------------------------------------------------- +# ffiEvent — library-initiated typed event +# --------------------------------------------------------------------------- + +macro ffiEvent*(wireName: static[string], prc: untyped): untyped = + ## Declares a library-initiated event. The annotated proc has an empty + ## body — the macro fills it with a `dispatchFFIEventCbor` call so the + ## Nim author dispatches the event by calling the proc with a typed + ## payload, and the per-target codegens emit a typed handler dispatcher + ## on the foreign side. + ## + ## The pragma takes the wire-format event name verbatim (no case + ## conversion). That string appears in the CBOR `eventType` field and is + ## the single source of truth across Nim / C++ / Rust bindings. + ## + ## Example: + ## type PeerInfo {.ffi.} = object + ## id: string + ## address: string + ## + ## proc onPeerConnected*(peer: PeerInfo) {.ffiEvent: "on_peer_connected".} + ## + ## # ... then from inside any {.ffi.} handler: + ## onPeerConnected(PeerInfo(id: "p-1", address: "127.0.0.1")) + ## + ## Restriction (first pass): exactly one parameter. Multi-param events + ## need a synthesised envelope struct; planned for a follow-up. + + if prc.kind notin {nnkProcDef, nnkFuncDef}: + error("ffiEvent must be applied to a proc declaration") + + let procName = prc[0] + let formalParams = prc[3] + + if formalParams.len != 2: + error( + "ffiEvent (first pass) supports exactly one parameter; got " & + $(formalParams.len - 1) + ) + + let paramDef = formalParams[1] + let payloadParamName = paramDef[0] + let payloadTypeNode = paramDef[1] + + let payloadTypeNameStr = + case payloadTypeNode.kind + of nnkIdent: $payloadTypeNode + else: payloadTypeNode.repr + + var userProcName = procName + if procName.kind == nnkPostfix: + userProcName = procName[1] + + # The generated body: dispatchFFIEventCbor("wire_name", payload). + let wireNameLit = newStrLitNode(wireName) + let dispatchBody = newStmtList( + newCall( + ident("dispatchFFIEventCbor"), + wireNameLit, + payloadParamName, + ) + ) + + var newParams = newSeq[NimNode]() + newParams.add(formalParams[0]) # return type (typically empty/void) + newParams.add(paramDef) + + let pragmas = + if prc.len >= 5 and prc[4].kind != nnkEmpty: + prc[4] + else: + newEmptyNode() + + let generated = newProc( + name = procName, + params = newParams, + body = dispatchBody, + procType = prc.kind, + pragmas = pragmas, + ) + + ffiEventRegistry.add( + FFIEventMeta( + wireName: wireName, + nimProcName: $userProcName, + libName: currentLibName, + payloadTypeName: payloadTypeNameStr, + ) + ) + + when defined(ffiDumpMacros): + echo generated.repr + return generated + # --------------------------------------------------------------------------- # genBindings — codegen entry point # --------------------------------------------------------------------------- @@ -1415,11 +1509,13 @@ macro genBindings*( case lang of "rust": generateRustCrate( - ffiProcRegistry, ffiTypeRegistry, libName, outputDir, nimSrcRelPath + ffiProcRegistry, ffiTypeRegistry, libName, outputDir, nimSrcRelPath, + ffiEventRegistry, ) of "cpp", "c++": generateCppBindings( - ffiProcRegistry, ffiTypeRegistry, libName, outputDir, nimSrcRelPath + ffiProcRegistry, ffiTypeRegistry, libName, outputDir, nimSrcRelPath, + ffiEventRegistry, ) of "cddl": generateCddlBindings( diff --git a/tests/e2e/cpp/CMakeLists.txt b/tests/e2e/cpp/CMakeLists.txt index 3c9e731..9742a87 100644 --- a/tests/e2e/cpp/CMakeLists.txt +++ b/tests/e2e/cpp/CMakeLists.txt @@ -1,7 +1,11 @@ cmake_minimum_required(VERSION 3.14) project(nim_ffi_cpp_e2e CXX) -set(CMAKE_CXX_STANDARD 17) +# Test harness compiles at C++20 so we can use designated initializers +# (`.field = …`) in test bodies; MSVC rejects them under /std:c++17. +# The generated example bindings and the CMake template they ship with +# stay at C++17, so library consumers aren't forced onto a newer std. +set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) # ── Reuse the timer cpp_bindings (compiles libmy_timer + exposes the diff --git a/tests/e2e/cpp/test_timer_e2e.cpp b/tests/e2e/cpp/test_timer_e2e.cpp index d223ea7..8119175 100644 --- a/tests/e2e/cpp/test_timer_e2e.cpp +++ b/tests/e2e/cpp/test_timer_e2e.cpp @@ -164,3 +164,31 @@ TEST(TimerE2E, ThreadedHammer) { for (auto& w : workers) w.join(); 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). +TEST(TimerE2E, TypedEventFiresAfterEcho) { + auto ctx = makeCtx("events"); + + std::promise evtPromise; + auto evtFuture = evtPromise.get_future(); + + ctx->setEventHandlers({ + .on_error = nullptr, + .onEchoFired = [&](const EchoEvent& evt) { evtPromise.set_value(evt); }, + }); + + const auto resp = ctx->echo(EchoRequest{"event-msg", 1}); + EXPECT_EQ(resp.echoed, "event-msg"); + + const auto status = evtFuture.wait_for(std::chrono::seconds(2)); + ASSERT_EQ(status, std::future_status::ready) << "event never arrived"; + + const auto evt = evtFuture.get(); + EXPECT_EQ(evt.message, "event-msg"); + EXPECT_EQ(evt.echoCount, 1); +} diff --git a/tests/unit/test_event_dispatch.nim b/tests/unit/test_event_dispatch.nim new file mode 100644 index 0000000..a12fa91 --- /dev/null +++ b/tests/unit/test_event_dispatch.nim @@ -0,0 +1,240 @@ +## Tests for the CBOR-style FFI event dispatch path: +## - `dispatchFFIEvent` accepts both `string` and `seq[byte]` bodies +## - `dispatchFFIEventCbor` wraps a typed payload in `EventEnvelope[T]`, +## CBOR-encodes it, and dispatches via the event callback +## +## Tests run end-to-end against a real FFI thread (via FFIContextPool + +## sendRequestToFFIThread) so we exercise the threadvar-backed +## ffiCurrentCallbackState wiring, not just the templates in isolation. + +import std/[locks] +import unittest2 +import results +import ffi + +type TestEvtLib = object + +## Event payload type (would be `{.ffi.}` in production so the codec gen +## emits a matching struct on the foreign side; the test only needs CBOR +## round-trip, which `cborEncode`/`cborDecode` provide via cbor_serial's +## generic overloads). +type MessageSentBody* {.ffi.} = object + requestId*: string + messageHash*: string + +## Same callback-state helper as test_ffi_context.nim, duplicated here so +## this file stays a self-contained test binary. +type CallbackData = object + lock: Lock + cond: Cond + called: bool + retCode: cint + msg: array[1024, byte] + msgLen: int + +proc initCallbackData(d: var CallbackData) = + d.lock.initLock() + d.cond.initCond() + +proc deinitCallbackData(d: var CallbackData) = + d.cond.deinitCond() + d.lock.deinitLock() + +proc captureCb( + retCode: cint, msg: ptr cchar, len: csize_t, userData: pointer +) {.cdecl, gcsafe, raises: [].} = + let d = cast[ptr CallbackData](userData) + acquire(d[].lock) + d[].retCode = retCode + let n = min(int(len), d[].msg.len) + if n > 0 and not msg.isNil: + copyMem(addr d[].msg[0], msg, n) + d[].msgLen = n + d[].called = true + signal(d[].cond) + release(d[].lock) + +proc waitCallback(d: var CallbackData) = + acquire(d.lock) + while not d.called: + wait(d.cond, d.lock) + release(d.lock) + +proc callbackBytes(d: var CallbackData): seq[byte] = + var bytes = newSeq[byte](d.msgLen) + if d.msgLen > 0: + copyMem(addr bytes[0], addr d.msg[0], d.msgLen) + return bytes + +## A request that dispatches a typed CBOR event from inside the FFI +## thread and then returns ok — so the response callback can be used to +## synchronize the test. +registerReqFFI(EmitCborEventRequest, lib: ptr TestEvtLib): + proc(): Future[Result[string, string]] {.async.} = + dispatchFFIEventCbor( + "message_sent", + MessageSentBody(requestId: "req-1", messageHash: "0xdeadbeef"), + ) + return ok("emitted") + +## A request that uses the lower-level `dispatchFFIEvent` with a raw +## `seq[byte]` body — the path that previously rejected non-string bodies. +registerReqFFI(EmitRawBytesEventRequest, lib: ptr TestEvtLib): + proc(): Future[Result[string, string]] {.async.} = + dispatchFFIEvent("raw_bytes"): + @[byte 0x01, 0x02, 0x03] + return ok("emitted") + +## Setter-thread worker for the FFICallbackState race regression test. +## Hammers `setCallback` so a TSan-instrumented build can confirm +## `FFICallbackState.lock` actually serialises the cross-thread mutation +## against `snapshotCallback` reads from the FFI thread. +type SetterArgs = tuple + ctx: ptr FFIContext[TestEvtLib] + stop: ptr Atomic[bool] + target: ptr CallbackData + +proc setterThreadBody(args: SetterArgs) {.thread.} = + while not args.stop[].load(): + setCallback(args.ctx[].callbackState, cast[pointer](captureCb), args.target) + +suite "dispatchFFIEventCbor": + test "delivers EventEnvelope-shaped CBOR payload to event callback": + var pool: FFIContextPool[TestEvtLib] + let ctx = pool.createFFIContext().valueOr: + check false + return + defer: + discard pool.destroyFFIContext(ctx) + + var evt: CallbackData + initCallbackData(evt) + defer: + deinitCallbackData(evt) + + # Register the event callback via the same locked helper that the + # codegen-emitted `{libname}_set_event_callback` uses. + setCallback(ctx[].callbackState, cast[pointer](captureCb), addr evt) + + # Trigger the dispatch from the FFI thread; the response callback is + # ignored (we only care that the request completed so we know the event + # has fired). + var rsp: CallbackData + initCallbackData(rsp) + defer: + deinitCallbackData(rsp) + + check sendRequestToFFIThread( + ctx, EmitCborEventRequest.ffiNewReq(captureCb, addr rsp) + ) + .isOk() + waitCallback(rsp) + waitCallback(evt) + + check evt.retCode == RET_OK + let decoded = cborDecode(callbackBytes(evt), EventEnvelope[MessageSentBody]) + check decoded.isOk() + check decoded.value.eventType == "message_sent" + check decoded.value.payload.requestId == "req-1" + check decoded.value.payload.messageHash == "0xdeadbeef" + +suite "dispatchFFIEvent with seq[byte]": + test "accepts a raw seq[byte] body": + var pool: FFIContextPool[TestEvtLib] + let ctx = pool.createFFIContext().valueOr: + check false + return + defer: + discard pool.destroyFFIContext(ctx) + + var evt: CallbackData + initCallbackData(evt) + defer: + deinitCallbackData(evt) + + setCallback(ctx[].callbackState, cast[pointer](captureCb), addr evt) + + var rsp: CallbackData + initCallbackData(rsp) + defer: + deinitCallbackData(rsp) + + check sendRequestToFFIThread( + ctx, EmitRawBytesEventRequest.ffiNewReq(captureCb, addr rsp) + ) + .isOk() + waitCallback(rsp) + waitCallback(evt) + + check evt.retCode == RET_OK + check callbackBytes(evt) == @[byte 0x01, 0x02, 0x03] + +suite "FFICallbackState concurrent access": + ## Regression test for PR #39 review comment r3288220895 / r3289285387: + ## `{lib}_set_event_callback` writes `callbackState.callback / .userData` + ## from foreign caller threads while the FFI thread reads them on every + ## dispatch. Without `FFICallbackState.lock` this is a data race. + ## + ## IMPORTANT: run this under ThreadSanitizer to actually validate the + ## fix: + ## + ## NIM_FFI_SAN=tsan NIM_FFI_MM=orc nimble test_sanitized + ## + ## A regular build will silently pass either way — the racing reads and + ## writes are pointer-aligned, so on x86/arm64 they don't tear visibly + ## and the loop completes. Only tsan inspects the memory model and + ## flags the missing happens-before edge if the lock is removed. + ## Treat a clean tsan run on this test as the load-bearing signal. + test "concurrent setCallback writers vs dispatch reads stay race-free": + var pool: FFIContextPool[TestEvtLib] + let ctx = pool.createFFIContext().valueOr: + check false + return + defer: + discard pool.destroyFFIContext(ctx) + + var evt: CallbackData + initCallbackData(evt) + defer: + deinitCallbackData(evt) + + # Seed an initial callback so the FFI thread's first dispatch has a + # target. The setter threads will then repeatedly re-install the same + # (callback, userData) pair — what matters is the cross-thread write + # racing the FFI thread's read, not which pair "wins". + setCallback(ctx[].callbackState, cast[pointer](captureCb), addr evt) + + const NumSetterThreads = 4 + const NumDispatchIters = 200 + + var stop: Atomic[bool] + stop.store(false) + var setters: array[NumSetterThreads, Thread[SetterArgs]] + for i in 0 ..< NumSetterThreads: + createThread(setters[i], setterThreadBody, (ctx, addr stop, addr evt)) + + var rsp: CallbackData + initCallbackData(rsp) + defer: + deinitCallbackData(rsp) + + for _ in 0 ..< NumDispatchIters: + # Reset rsp so each iteration's `waitCallback` blocks until the + # FFI thread fires the response — keeps the loop synchronous. + acquire(rsp.lock) + rsp.called = false + release(rsp.lock) + + check sendRequestToFFIThread( + ctx, EmitCborEventRequest.ffiNewReq(captureCb, addr rsp) + ) + .isOk() + waitCallback(rsp) + + stop.store(true) + for i in 0 ..< NumSetterThreads: + joinThread(setters[i]) + + # `evt` got hit by every dispatch above; just confirm at least one + # actually landed so a silently-broken dispatch loop is caught. + check evt.called