mirror of
https://github.com/logos-messaging/nim-ffi.git
synced 2026-08-05 22:43:17 +00:00
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:
parent
dda79fab34
commit
80d3f58702
10
CHANGELOG.md
10
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 (`<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.
|
||||
|
||||
## [0.3.0] - 2026-07-24
|
||||
|
||||
[Full changelog](https://github.com/logos-messaging/nim-ffi/compare/v0.2.0...v0.3.0)
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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] }
|
||||
|
||||
@ -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<std::string> 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<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);
|
||||
|
||||
@ -127,6 +127,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 }
|
||||
|
||||
@ -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<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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 `<WireNamePascalCase>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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user