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 `<WireNamePascalCase>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 <noreply@anthropic.com>
This commit is contained in:
Ivan FB 2026-06-25 03:05:37 +02:00
parent a66c53a34b
commit 8f15afce5c
No known key found for this signature in database
GPG Key ID: DF0C67A04C543270
6 changed files with 167 additions and 31 deletions

View File

@ -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 (`<WireNamePascalCase>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.}`

View File

@ -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<std::string> payload;
@ -839,6 +866,16 @@ public:
return ListenerHandle{id};
}
ListenerHandle addOnJobScheduledListener(std::function<void(const OnJobScheduledPayload&)> handler) {
auto owned = std::make_unique<TypedListener<OnJobScheduledPayload>>(std::move(handler));
auto* raw = owned.get();
const auto id = my_timer_add_event_listener(
ptr_, "on_job_scheduled", &MyTimerCtx::typedTrampoline<OnJobScheduledPayload>, 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);

View File

@ -117,6 +117,25 @@ unsafe extern "C" fn on_echo_fired_trampoline(
}
}
struct OnJobScheduledHandler {
f: Box<dyn Fn(&OnJobScheduledPayload) + Send + Sync>,
}
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::<Envelope, _>(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<F>(&self, handler: F) -> ListenerHandle
where F: Fn(&OnJobScheduledPayload) + Send + Sync + 'static,
{
let owned: Box<OnJobScheduledHandler> = 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 {

View File

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

View File

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

View File

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