From 8f15afce5c377a0e5ee53c35b228025b903604ea Mon Sep 17 00:00:00 2001 From: Ivan FB Date: Thu, 25 Jun 2026 03:05:37 +0200 Subject: [PATCH] feat: multi-parameter {.ffiEvent.} via synthesised envelope `{.ffiEvent.}` previously accepted exactly one parameter, forcing every multi-field event to declare a hand-written {.ffi.} payload type. The macro now bundles two or more parameters into a synthesised, registered envelope object named `Payload`, whose fields are the parameters, and dispatches an instance of it. A single parameter still rides the wire directly (scalar or existing {.ffi.} object), so this is backwards compatible. Because the envelope is registered like any {.ffi.} type, the foreign bindings gain it as a first-class struct plus a typed handler. The timer example gains an `on_job_scheduled(jobId, willRunCount)` event to exercise the path; the C++ and Rust bindings are regenerated accordingly. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 6 ++ examples/timer/cpp_bindings/my_timer.hpp | 37 +++++++ examples/timer/rust_bindings/src/api.rs | 29 ++++++ examples/timer/rust_bindings/src/types.rs | 8 ++ examples/timer/timer.nim | 6 ++ ffi/internal/ffi_macro.nim | 112 ++++++++++++++++------ 6 files changed, 167 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6e3654..67f2948 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,12 @@ All notable changes to this project are documented in this file. where `-install_name` requires `-dynamiclib`. ### 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. - Per-interaction ABI-format annotations: `declareLibrary` now takes an optional `defaultABIFormat` (`"cbor"` default, or `"c"`) that every `{.ffi.}` / `{.ffiCtor.}` / `{.ffiDtor.}` / `{.ffiRaw.}` / `{.ffiEvent.}` diff --git a/examples/timer/cpp_bindings/my_timer.hpp b/examples/timer/cpp_bindings/my_timer.hpp index 8fa440b..49d11d7 100644 --- a/examples/timer/cpp_bindings/my_timer.hpp +++ b/examples/timer/cpp_bindings/my_timer.hpp @@ -443,6 +443,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; @@ -839,6 +866,16 @@ public: return ListenerHandle{id}; } + 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 709f9c1..32eb7be 100644 --- a/examples/timer/rust_bindings/src/api.rs +++ b/examples/timer/rust_bindings/src/api.rs @@ -117,6 +117,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 } @@ -198,6 +217,16 @@ impl MyTimerCtx { self.add_listener_inner(b"on_echo_fired\0".as_ptr() as *const c_char, on_echo_fired_trampoline, raw, owned) } + /// Register a 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 cbb731f..a84322f 100644 --- a/examples/timer/rust_bindings/src/types.rs +++ b/examples/timer/rust_bindings/src/types.rs @@ -43,6 +43,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 0ce0924..b9483de 100644 --- a/examples/timer/timer.nim +++ b/examples/timer/timer.nim @@ -48,6 +48,11 @@ type EchoEvent {.ffi.} = object proc onEchoFired*(evt: EchoEvent) {.ffiEvent: "on_echo_fired".} +# A multi-parameter event: the macro synthesises + registers an envelope object +# (`OnJobScheduledPayload`) from the params, so the foreign side still decodes a +# single typed value — no per-event payload type to hand-write. +proc onJobScheduled*(jobId: string, willRunCount: int) {.ffiEvent: "on_job_scheduled".} + # --- Constructor ----------------------------------------------------------- # Called once from Rust. Creates the FFIContext + MyTimer. # Uses chronos (await sleepAsync) so the body is async. @@ -127,6 +132,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 f106628..eaf18d2 100644 --- a/ffi/internal/ffi_macro.nim +++ b/ffi/internal/ffi_macro.nim @@ -336,8 +336,12 @@ proc buildFFINewReqProc(reqTypeName, body: NimNode): NimNode = `reqObjIdent`.`fieldName` = `fieldName` ) - let reqNameLit = - newLit(if reqTypeName.kind == nnkPostfix: $reqTypeName[1] else: $reqTypeName) + let reqNameLit = newLit( + if reqTypeName.kind == nnkPostfix: + $reqTypeName[1] + else: + $reqTypeName + ) newBody.add( quote do: let (sharedData, sharedLen) = cborEncodeShared(`reqObjIdent`) @@ -919,8 +923,12 @@ macro ffi*(args: varargs[untyped]): untyped = # Build the FFIThreadRequest payload directly from the incoming bytes. let reqPtrIdent = genSym(nskLet, "reqPtr") - let reqNameLit = - newLit(if reqTypeName.kind == nnkPostfix: $reqTypeName[1] else: $reqTypeName) + let reqNameLit = newLit( + if reqTypeName.kind == nnkPostfix: + $reqTypeName[1] + else: + $reqTypeName + ) ffiBody.add quote do: let `reqPtrIdent` = FFIThreadRequest.initFromPtr( callback, userData, cstring(`reqNameLit`), reqCbor, int(reqCborLen) @@ -1042,8 +1050,12 @@ proc buildCtorFFINewReqProc(reqTypeName: NimNode, paramNames: seq[string]): NimN let retType = newTree(nnkPtrTy, ident("FFIThreadRequest")) formalParams = @[retType] & formalParams - let reqNameLit = - newLit(if reqTypeName.kind == nnkPostfix: $reqTypeName[1] else: $reqTypeName) + let reqNameLit = newLit( + if reqTypeName.kind == nnkPostfix: + $reqTypeName[1] + else: + $reqTypeName + ) var newBody = newStmtList() newBody.add quote do: return FFIThreadRequest.initFromPtr( @@ -1142,8 +1154,7 @@ proc buildCtorProcessFFIRequestProc( return err($error) let myLibIdent = newDotExpr(newTree(nnkDerefExpr, ctxIdent), ident("myLib")) - let myLibOwnedIdent = - newDotExpr(newTree(nnkDerefExpr, ctxIdent), ident("myLibOwned")) + let myLibOwnedIdent = newDotExpr(newTree(nnkDerefExpr, ctxIdent), ident("myLibOwned")) let myLibRefdIdent = newDotExpr(newTree(nnkDerefExpr, ctxIdent), ident("myLibRefd")) newBody.add quote do: `myLibIdent` = createShared(`libTypeName`) @@ -1574,8 +1585,11 @@ macro ffiEvent*(args: varargs[untyped]): untyped = ## # ... 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. + ## Parameters: a single parameter rides the wire directly (a scalar, or an + ## existing {.ffi.} object). Two or more parameters are bundled into a + ## synthesised, registered envelope object named `Payload` + ## whose fields are the parameters, so the foreign side still decodes one + ## typed value. requireLibraryDeclared("`.ffiEvent.`") if args.len < 2: @@ -1595,35 +1609,70 @@ macro ffiEvent*(args: varargs[untyped]): untyped = 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 + if formalParams.len < 2: + error("ffiEvent requires at least one parameter") var userProcName = procName if procName.kind == nnkPostfix: userProcName = procName[1] - # The generated body: dispatchFFIEventCbor("wire_name", payload). + # 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]) # return type (typically empty/void) - newParams.add(paramDef) + for i in 1 ..< formalParams.len: + newParams.add(formalParams[i]) let pragmas = if prc.len >= 5 and prc[4].kind != nnkEmpty: @@ -1638,6 +1687,7 @@ macro ffiEvent*(args: varargs[untyped]): untyped = procType = prc.kind, pragmas = pragmas, ) + resultStmts.add(generated) ffiEventRegistry.add( FFIEventMeta( @@ -1650,8 +1700,8 @@ macro ffiEvent*(args: varargs[untyped]): untyped = ) when defined(ffiDumpMacros): - echo generated.repr - return generated + echo resultStmts.repr + return resultStmts macro genBindings*( outputDir: static[string] = ffiOutputDir, nimSrcRelPath: static[string] = ffiSrcPath