Adjust events to cbor (#39)

This commit is contained in:
Ivan FB 2026-05-25 15:51:56 +02:00 committed by GitHub
parent 31d0ebfa51
commit 6a7e4616fd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 947 additions and 36 deletions

View File

@ -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

View File

@ -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<std::string> 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<void(const std::string&)> on_error;
std::function<void(const EchoEvent&)> onEchoFired;
};
void setEventHandlers(Events handlers) {
events_ = std::make_unique<Events>(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> 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<Events*>(ud);
if (ret != 0) {
if (events->on_error) {
std::string err(msg ? msg : "", len);
events->on_error(err);
}
return;
}
if (!msg || len == 0) return;
std::vector<std::uint8_t> bytes(reinterpret_cast<const std::uint8_t*>(msg),
reinterpret_cast<const std::uint8_t*>(msg) + len);
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);
}
}
}
};

View File

@ -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<Box<dyn Fn(&str) + Send + Sync>>,
pub onEchoFired: Option<Box<dyn Fn(&EchoEvent) + Send + Sync>>,
}
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::<Envelope, _>(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<Self, String> {
@ -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<EchoResponse, String> {

View File

@ -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);
}

View File

@ -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,

View File

@ -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 -----------------------------------------------------------

View File

@ -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"

View File

@ -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.

View File

@ -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<MyTimerCtx>` 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<void(const std::string&)> on_error;")
for ev in events:
lines.add(
" std::function<void(const $1&)> $2;" %
[ev.payloadTypeName, ev.nimProcName]
)
lines.add(" };")
lines.add("")
lines.add(" void setEventHandlers(Events handlers) {")
lines.add(" events_ = std::make_unique<Events>(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<Events*>(ud);")
lines.add(" if (ret != 0) {")
lines.add(" if (events->on_error) {")
lines.add(" std::string err(msg ? msg : \"\", len);")
lines.add(" events->on_error(err);")
lines.add(" }")
lines.add(" return;")
lines.add(" }")
lines.add(" if (!msg || len == 0) return;")
lines.add(" std::vector<std::uint8_t> bytes(reinterpret_cast<const std::uint8_t*>(msg),")
lines.add(" reinterpret_cast<const std::uint8_t*>(msg) + len);")
lines.add(" 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> 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))

View File

@ -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

View File

@ -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<Box<dyn Fn(&str) + Send + Sync>>,")
for ev in events:
lines.add(
" pub $1: Option<Box<dyn Fn(&$2) + Send + Sync>>," %
[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::<Envelope, _>(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))

View File

@ -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: <T> }
## 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:

View File

@ -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,

View File

@ -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(

View File

@ -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

View File

@ -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<EchoEvent> 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);
}

View File

@ -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