mirror of
https://github.com/logos-messaging/nim-ffi.git
synced 2026-06-26 03:09:31 +00:00
Adjust events to cbor (#39)
This commit is contained in:
parent
31d0ebfa51
commit
6a7e4616fd
47
.github/workflows/ci.yml
vendored
47
.github/workflows/ci.yml
vendored
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 -----------------------------------------------------------
|
||||
|
||||
17
ffi.nimble
17
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"
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
240
tests/unit/test_event_dispatch.nim
Normal file
240
tests/unit/test_event_dispatch.nim
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user