diff --git a/CHANGELOG.md b/CHANGELOG.md index e4c763e..ae76697 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ All notable changes to this project are documented in this file. +## [Unreleased] + +### Added +- `{.ffiEvent.}` now accepts multiple parameters. The macro synthesises and + registers an envelope object (`Payload`) whose fields are + the parameters and dispatches an instance of it, so multi-field events no + longer need a hand-written payload type. A single parameter still rides the + wire directly (a scalar, or an existing `{.ffi.}` object). The foreign + bindings gain the envelope as a first-class struct plus a typed handler. + ## [0.3.0] - 2026-07-24 [Full changelog](https://github.com/logos-messaging/nim-ffi/compare/v0.2.0...v0.3.0) diff --git a/examples/timer/c_bindings/my_timer.h b/examples/timer/c_bindings/my_timer.h index 45e453a..9a0795a 100644 --- a/examples/timer/c_bindings/my_timer.h +++ b/examples/timer/c_bindings/my_timer.h @@ -56,6 +56,10 @@ typedef struct { NimFfiStr message; int64_t echoCount; } EchoEvent; +typedef struct { + NimFfiStr jobId; + int64_t willRunCount; +} OnJobScheduledPayload; typedef enum { JOB_PRIORITY_JP_LOW = 0, JOB_PRIORITY_JP_NORMAL = 1, @@ -448,6 +452,42 @@ static inline void my_timer_free_EchoEvent(EchoEvent* v) { if (!v) return; nimffi_free_str(&v->message); } +static inline CborError my_timer_enc_OnJobScheduledPayload( + CborEncoder* e, const OnJobScheduledPayload* v) { + CborEncoder m; + CborError err = cbor_encoder_create_map(e, &m, 2); + if (err) return err; + err = cbor_encode_text_stringz(&m, "jobId"); + if (err) return err; + err = nimffi_enc_str(&m, &v->jobId); + if (err) return err; + err = cbor_encode_text_stringz(&m, "willRunCount"); + if (err) return err; + err = nimffi_enc_i64(&m, &v->willRunCount); + if (err) return err; + return cbor_encoder_close_container(e, &m); +} +static inline CborError my_timer_dec_OnJobScheduledPayload( + CborValue* it, OnJobScheduledPayload* out) { + if (!cbor_value_is_map(it)) return CborErrorImproperValue; + CborValue field; + CborError err; + err = cbor_value_map_find_value(it, "jobId", &field); + if (err) return err; + if (!cbor_value_is_valid(&field)) return CborErrorImproperValue; + err = nimffi_dec_str(&field, &out->jobId); + if (err) return err; + err = cbor_value_map_find_value(it, "willRunCount", &field); + if (err) return err; + if (!cbor_value_is_valid(&field)) return CborErrorImproperValue; + err = nimffi_dec_i64(&field, &out->willRunCount); + if (err) return err; + return cbor_value_advance(it); +} +static inline void my_timer_free_OnJobScheduledPayload(OnJobScheduledPayload* v) { + if (!v) return; + nimffi_free_str(&v->jobId); +} static inline CborError my_timer_enc_JobPriority( CborEncoder* e, const JobPriority* v) { switch (*v) { @@ -883,6 +923,25 @@ static void my_timer_on_echo_fired_trampoline(int ret, const char* msg, size_t l my_timer_free_EchoEvent(&payload); } +typedef void (*MyTimerOnJobScheduledFn)(const OnJobScheduledPayload* evt, void* user_data); +typedef struct { MyTimerOnJobScheduledFn fn; void* user_data; } MyTimerOnJobScheduledBox; +static void my_timer_on_job_scheduled_trampoline(int ret, const char* msg, size_t len, void* ud) { + if (!ud || ret != 0 || !msg || len == 0) return; + MyTimerOnJobScheduledBox* box = (MyTimerOnJobScheduledBox*)ud; + if (!box->fn) return; + CborParser parser; + CborValue it; + if (cbor_parser_init((const uint8_t*)msg, len, 0, &parser, &it) != CborNoError) return; + if (!cbor_value_is_map(&it)) return; + CborValue payloadField; + if (cbor_value_map_find_value(&it, "payload", &payloadField) != CborNoError) return; + OnJobScheduledPayload payload; + memset(&payload, 0, sizeof(payload)); + if (my_timer_dec_OnJobScheduledPayload(&payloadField, &payload) != CborNoError) return; + box->fn(&payload, box->user_data); + my_timer_free_OnJobScheduledPayload(&payload); +} + /* ============================================================ */ /* High-level context wrapper */ /* ============================================================ */ @@ -1004,6 +1063,30 @@ static inline uint64_t my_timer_ctx_add_on_echo_fired_listener(MyTimerCtx* ctx, return id; } +/** + * Fired by `myTimerSchedule`. Its two params ride the wire as a synthesised + * `OnJobScheduledPayload` envelope, so the foreign side decodes one typed value. + */ +static inline uint64_t my_timer_ctx_add_on_job_scheduled_listener(MyTimerCtx* ctx, MyTimerOnJobScheduledFn fn, void* user_data) { + MyTimerOnJobScheduledBox* box = (MyTimerOnJobScheduledBox*)malloc(sizeof(MyTimerOnJobScheduledBox)); + if (!box) return 0; + box->fn = fn; + box->user_data = user_data; + uint64_t id = my_timer_add_event_listener(ctx->ptr, "on_job_scheduled", my_timer_on_job_scheduled_trampoline, box); + if (id == 0) { free(box); return 0; } + if (ctx->listeners_len == ctx->listeners_cap) { + size_t ncap = ctx->listeners_cap ? ctx->listeners_cap * 2 : 4; + MyTimerCtxListener* grown = (MyTimerCtxListener*)realloc(ctx->listeners, ncap * sizeof(MyTimerCtxListener)); + if (!grown) { my_timer_remove_event_listener(ctx->ptr, id); free(box); return 0; } + ctx->listeners = grown; + ctx->listeners_cap = ncap; + } + ctx->listeners[ctx->listeners_len].id = id; + ctx->listeners[ctx->listeners_len].box = box; + ctx->listeners_len++; + return id; +} + static inline bool my_timer_ctx_remove_event_listener(MyTimerCtx* ctx, uint64_t id) { if (id == 0) return false; int rc = my_timer_remove_event_listener(ctx->ptr, id); diff --git a/examples/timer/cddl_bindings/my_timer.cddl b/examples/timer/cddl_bindings/my_timer.cddl index 516384c..d0eb417 100644 --- a/examples/timer/cddl_bindings/my_timer.cddl +++ b/examples/timer/cddl_bindings/my_timer.cddl @@ -9,6 +9,7 @@ EchoResponse = { echoed: tstr, timerName: tstr } ComplexRequest = { messages: [* EchoRequest], tags: [* tstr], note: tstr / nil, retries: int / nil } ComplexResponse = { summary: tstr, itemCount: int, hasNote: bool } EchoEvent = { message: tstr, echoCount: int } +OnJobScheduledPayload = { jobId: tstr, willRunCount: int } JobPriority = "low" / "normal" / "high" JobSpec = { name: tstr, payload: [* tstr], priority: JobPriority } RetryPolicy = { maxAttempts: int, backoffMs: int, retryOn: [* tstr] } diff --git a/examples/timer/cpp_bindings/my_timer.hpp b/examples/timer/cpp_bindings/my_timer.hpp index 57b71b4..fe57a33 100644 --- a/examples/timer/cpp_bindings/my_timer.hpp +++ b/examples/timer/cpp_bindings/my_timer.hpp @@ -502,6 +502,33 @@ inline CborError decode_cbor(CborValue& it, EchoEvent& v) { return cbor_value_advance(&it); } +struct OnJobScheduledPayload { + std::string jobId; + int64_t willRunCount; +}; +inline CborError encode_cbor(CborEncoder& e, const OnJobScheduledPayload& v) { + CborEncoder m; + CborError err = cbor_encoder_create_map(&e, &m, 2); + if (err) return err; + err = cbor_encode_text_stringz(&m, "jobId"); if (err) return err; + err = encode_cbor(m, v.jobId); if (err) return err; + err = cbor_encode_text_stringz(&m, "willRunCount"); if (err) return err; + err = encode_cbor(m, v.willRunCount); if (err) return err; + return cbor_encoder_close_container(&e, &m); +} +inline CborError decode_cbor(CborValue& it, OnJobScheduledPayload& v) { + if (!cbor_value_is_map(&it)) return CborErrorImproperValue; + CborValue field; + CborError err; + err = cbor_value_map_find_value(&it, "jobId", &field); if (err) return err; + if (!cbor_value_is_valid(&field)) return CborErrorImproperValue; + err = decode_cbor(field, v.jobId); if (err) return err; + err = cbor_value_map_find_value(&it, "willRunCount", &field); if (err) return err; + if (!cbor_value_is_valid(&field)) return CborErrorImproperValue; + err = decode_cbor(field, v.willRunCount); if (err) return err; + return cbor_value_advance(&it); +} + struct JobSpec { std::string name; std::vector payload; @@ -932,6 +959,18 @@ public: return ListenerHandle{id}; } + /// Fired by `myTimerSchedule`. Its two params ride the wire as a synthesised + /// `OnJobScheduledPayload` envelope, so the foreign side decodes one typed value. + ListenerHandle addOnJobScheduledListener(std::function handler) { + auto owned = std::make_unique>(std::move(handler)); + auto* raw = owned.get(); + const auto id = my_timer_add_event_listener( + ptr_, "on_job_scheduled", &MyTimerCtx::typedTrampoline, raw); + if (id == 0) return ListenerHandle{0}; + listeners_.emplace(id, std::move(owned)); + return ListenerHandle{id}; + } + bool removeEventListener(ListenerHandle handle) { if (handle.id == 0) return false; const auto rc = my_timer_remove_event_listener(ptr_, handle.id); diff --git a/examples/timer/rust_bindings/src/api.rs b/examples/timer/rust_bindings/src/api.rs index cf02916..54b49b0 100644 --- a/examples/timer/rust_bindings/src/api.rs +++ b/examples/timer/rust_bindings/src/api.rs @@ -127,6 +127,25 @@ unsafe extern "C" fn on_echo_fired_trampoline( } } +struct OnJobScheduledHandler { + f: Box, +} + +unsafe extern "C" fn on_job_scheduled_trampoline( + ret: c_int, msg: *const c_char, len: usize, ud: *mut c_void, +) { + if ud.is_null() || ret != 0 || msg.is_null() || len == 0 { + return; + } + let h = &*(ud as *const OnJobScheduledHandler); + let bytes = slice::from_raw_parts(msg as *const u8, len); + #[derive(serde::Deserialize)] + struct Envelope { payload: OnJobScheduledPayload } + if let Ok(env) = ciborium::de::from_reader::(bytes) { + (h.f)(&env.payload); + } +} + #[derive(Debug, Clone, Copy)] pub struct ListenerHandle { pub id: u64 } @@ -212,6 +231,18 @@ impl MyTimerCtx { self.add_listener_inner(b"on_echo_fired\0".as_ptr() as *const c_char, on_echo_fired_trampoline, raw, owned) } + /// Fired by `myTimerSchedule`. Its two params ride the wire as a synthesised + /// `OnJobScheduledPayload` envelope, so the foreign side decodes one typed value. + /// Register a typed listener for `on_job_scheduled`. The returned handle can be + /// passed to `remove_event_listener` to unregister. + pub fn add_on_job_scheduled_listener(&self, handler: F) -> ListenerHandle + where F: Fn(&OnJobScheduledPayload) + Send + Sync + 'static, + { + let owned: Box = Box::new(OnJobScheduledHandler { f: Box::new(handler) }); + let raw = &*owned as *const OnJobScheduledHandler as *mut c_void; + self.add_listener_inner(b"on_job_scheduled\0".as_ptr() as *const c_char, on_job_scheduled_trampoline, raw, owned) + } + /// Remove a previously-registered listener by handle. Returns true /// if the listener existed and was removed; false otherwise. pub fn remove_event_listener(&self, handle: ListenerHandle) -> bool { diff --git a/examples/timer/rust_bindings/src/types.rs b/examples/timer/rust_bindings/src/types.rs index 21c2a5a..c38f455 100644 --- a/examples/timer/rust_bindings/src/types.rs +++ b/examples/timer/rust_bindings/src/types.rs @@ -57,6 +57,14 @@ pub struct EchoEvent { pub echo_count: i64, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OnJobScheduledPayload { + #[serde(rename = "jobId")] + pub job_id: String, + #[serde(rename = "willRunCount")] + pub will_run_count: i64, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct JobSpec { pub name: String, diff --git a/examples/timer/timer.nim b/examples/timer/timer.nim index c405952..0e49090 100644 --- a/examples/timer/timer.nim +++ b/examples/timer/timer.nim @@ -45,6 +45,12 @@ type EchoEvent {.ffi.} = object proc onEchoFired*(evt: EchoEvent) {.ffiEvent: "on_echo_fired".} = ## Fired by `myTimerEcho` once the reply is ready. +proc onJobScheduled*( + jobId: string, willRunCount: int +) {.ffiEvent: "on_job_scheduled".} = + ## Fired by `myTimerSchedule`. Its two params ride the wire as a synthesised + ## `OnJobScheduledPayload` envelope, so the foreign side decodes one typed value. + proc myTimerCreate*(config: TimerConfig): Future[Result[MyTimer, string]] {.ffiCtor.} = ## Creates the FFIContext + MyTimer; async via chronos. await sleepAsync(1.milliseconds) # proves chronos is live on the FFI thread @@ -134,6 +140,7 @@ proc myTimerSchedule*( else: 1 let jitter = if schedule.jitter.isSome: schedule.jitter.get else: 0 + onJobScheduled(timer.name & ":" & job.name, willRunCount) return ok( ScheduleResult( jobId: timer.name & ":" & job.name, diff --git a/ffi/internal/ffi_macro.nim b/ffi/internal/ffi_macro.nim index d2d87e5..361b293 100644 --- a/ffi/internal/ffi_macro.nim +++ b/ffi/internal/ffi_macro.nim @@ -1721,6 +1721,11 @@ macro ffiEvent*(args: varargs[untyped]): untyped = ## Declares a library-initiated event: the empty-bodied proc is filled with a ## `dispatchFFIEventCbor` call. Wire name defaults to `camelToSnakeCase` of the ## proc name (a string literal overrides it) and is the cross-binding source of truth. + ## + ## One parameter rides the wire directly (a scalar, or an existing `{.ffi.}` + ## object). Two or more are bundled into a synthesised, registered envelope + ## object named `Payload` whose fields are the parameters, + ## so the foreign side still decodes one typed value. requireBeforeGenBindings("`.ffiEvent.`") requireLibraryDeclared("`.ffiEvent.`") if args.len < 1: @@ -1747,30 +1752,66 @@ macro ffiEvent*(args: varargs[untyped]): untyped = let formalParams = prc[3] - if formalParams.len != 2: - error( - "ffiEvent (first pass) supports exactly one parameter; got " & - $(formalParams.len - 1) - ) + if formalParams.len < 2: + error("ffiEvent requires at least one parameter") - let paramDef = formalParams[1] - let payloadParamName = paramDef[0] - let payloadTypeNode = paramDef[1] - - let payloadTypeNameStr = - case payloadTypeNode.kind - of nnkIdent: - $payloadTypeNode - else: - payloadTypeNode.repr + # Flatten the parameter list (a grouped `a, b: T` expands to one entry each). + var paramNames: seq[NimNode] = @[] + var paramTypes: seq[NimNode] = @[] + for i in 1 ..< formalParams.len: + let p = formalParams[i] + for j in 0 ..< p.len - 2: + rejectRawPtrType( + p[^2], "`.ffiEvent.` proc " & $userProcName & " parameter " & $p[j] + ) + paramNames.add(p[j]) + paramTypes.add(p[^2]) let wireNameLit = newStrLitNode(wireName) + let resultStmts = newStmtList() + + var payloadTypeNameStr: string + var dispatchPayload: NimNode + + if paramNames.len == 1: + let payloadTypeNode = paramTypes[0] + payloadTypeNameStr = + if payloadTypeNode.kind == nnkIdent: + $payloadTypeNode + else: + payloadTypeNode.repr + dispatchPayload = paramNames[0] + else: + # Synthesise + register an envelope object, then dispatch an instance built + # from the parameters. + let payloadType = ident(snakeToPascalCase(wireName) & "Payload") + payloadTypeNameStr = $payloadType + + var paramNameStrs: seq[string] = @[] + for n in paramNames: + paramNameStrs.add($n) + let typeSection = buildCtorRequestType(payloadType, paramNameStrs, paramTypes) + discard registerFFITypeInfo(typeSection[0], abiFormat) + resultStmts.add(typeSection) + + let envelope = nnkObjConstr.newTree(payloadType) + for i in 0 ..< paramNames.len: + # `cstring` rides as `string` in the envelope (per storageType). + let value = + if paramTypes[i].kind == nnkIdent and $paramTypes[i] == "cstring": + newCall(ident("$"), paramNames[i]) + else: + paramNames[i] + envelope.add(nnkExprColonExpr.newTree(paramNames[i], value)) + dispatchPayload = envelope + let dispatchBody = - newStmtList(newCall(ident("dispatchFFIEventCbor"), wireNameLit, payloadParamName)) + newStmtList(newCall(ident("dispatchFFIEventCbor"), wireNameLit, dispatchPayload)) var newParams = newSeq[NimNode]() - newParams.add(formalParams[0]) - newParams.add(paramDef) + newParams.add(formalParams[0]) # return type (typically empty/void) + for i in 1 ..< formalParams.len: + newParams.add(formalParams[i]) let pragmas = if prc.len >= 5 and prc[4].kind != nnkEmpty: @@ -1785,6 +1826,7 @@ macro ffiEvent*(args: varargs[untyped]): untyped = procType = prc.kind, pragmas = pragmas, ) + resultStmts.add(generated) ffiEventRegistry.add( FFIEventMeta( @@ -1798,8 +1840,8 @@ macro ffiEvent*(args: varargs[untyped]): untyped = ) when defined(ffiDumpMacros): - echo generated.repr - return generated + echo resultStmts.repr + return resultStmts proc reportScalarFastPathDrops(procs: seq[FFIProcMeta]) {.compileTime.} = ## Fail loudly on scalar-fast-path procs a target can't bind, unless