feat(ffi): emit a native (zero-serialization) C ABI alongside CBOR

A single {.ffi.} definition now produces BOTH interfaces, chosen by the
caller at link time rather than by a global compile flag:

- `<name>`      — native typed-arg C export. Args travel to the FFI thread in
                  a c_malloc'd C-POD struct passed by pointer (no CBOR), and the
                  result is delivered to the callback as raw bytes. This is the
                  preferred path for same-process callers: no serialization on
                  either side.
- `<name>_cbor` — the existing CBOR-buffer dispatcher, kept for generic /
                  cross-language callers.

Both share the user's helper proc; they register distinct handlers keyed by
"<Camel>Req" (CBOR) and "<Camel>ReqNative". FFIThreadRequest gains a `cborMode`
flag and a `payloadFree` hook so the native C-POD payload (which owns duplicated
cstring fields) is released correctly and an empty native result is delivered as
a zero-length buffer instead of the CBOR null sentinel. alloc.nim gains
ffiCMalloc/ffiCFree (prefixed to avoid Nim's style-insensitive clash with
ansi_c.c_malloc/c_free).

Verified end-to-end on a scalar-param lib: native calls return raw strings
("calc v1", "sum=42"); the _cbor variant still returns CBOR.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ivan FB 2026-05-31 01:05:12 +02:00
parent 028dbb56e6
commit f3206c30b8
No known key found for this signature in database
GPG Key ID: DF0C67A04C543270
5 changed files with 587 additions and 241 deletions

View File

@ -44,6 +44,21 @@ proc dealloc*(p: cstring) {.inline.} =
if not p.isNil():
c_free(cast[pointer](p))
proc ffiCMalloc*(T: typedesc): ptr T =
## Allocates a zero-initialised `T` via `c_malloc` so the buffer can cross
## threads safely (see the module note). Used to carry a native (non-CBOR)
## request payload by pointer; release with `ffiCFree`. (Named with the `ffi`
## prefix so it doesn't collide with `ansi_c.c_free`/`c_malloc` under Nim's
## style-insensitive identifier rules.)
let p = cast[ptr T](c_malloc(csize_t(sizeof(T))))
zeroMem(p, sizeof(T))
return p
proc ffiCFree*(p: pointer) {.inline.} =
## Frees a buffer obtained from `ffiCMalloc`. Nil-safe.
if not p.isNil():
c_free(p)
proc allocSharedSeq*[T](s: seq[T]): SharedSeq[T] =
if s.len == 0:
return (cast[ptr UncheckedArray[T]](nil), 0)

View File

@ -1,32 +1,34 @@
## C binding generator for the nim-ffi framework.
##
## Emits a single self-contained header `<lib>.h` that hides the CBOR transport
## behind ergonomic, typed C functions. nim-ffi 0.2.0's exported ABI takes the
## request as one CBOR buffer; the generated header restores a natural call
## shape — `<lib>_<proc>(ctx, callback, userData, <typed args...>)` — by
## CBOR-encoding the arguments and forwarding to the real export.
## Each `{.ffi.}` / `{.ffiCtor.}` proc is exported twice — a native typed-arg
## entry point under `<name>` (no CBOR, result delivered raw) and a `<name>_cbor`
## variant (CBOR request/response) — so this generator emits BOTH headers, side
## by side, and the consumer chooses per call site:
##
## The real export and the ergonomic wrapper share the same C name, so the raw
## symbol is reached through an `__asm__` label (with a platform underscore
## shim) and the inline wrapper keeps the public name. This keeps existing C /
## cgo / JNI / Swift consumers calling the same symbols they always did, just
## with typed arguments instead of a hand-built CBOR blob.
## - `<lib>.h` — native ABI: `int <name>(ctx, cb, ud, <typed args...>)`,
## ctor `void *<name>(<typed args...>, cb, ud)`, dtor `int <name>(ctx)`. The
## callback gets the raw result (RET_OK; not NUL-terminated, use len) or raw
## error text (RET_ERR). Best for SAME-process callers (zero serialization).
## - `<lib>_cbor.h` — CBOR ABI: `int <name>_cbor(ctx, cb, ud, reqCbor, len)`,
## ctor `void *<name>_cbor(reqCbor, len, cb, ud)`, plus a small CBOR request
## encoder (FfiCbor) and response decoder (ffi_decode_text). The request is a
## CBOR map keyed by the Nim param names (noted per proc). Best for callers
## that cross a process / machine boundary, where serialization is required
## anyway, or any generic / cross-language caller.
##
## Requests/responses travel as CBOR (RFC 8949), matching the Nim-side
## cbor_serial codec: each request is a map keyed by the Nim parameter names;
## each success response is the CBOR-encoded return value (a text string for
## the common `Result[string, string]` shape).
## Both headers guard their shared definitions (RET codes, FFICallBack) so a
## translation unit may include both. Library-initiated events (registered via
## `<lib>_add_event_listener`) arrive as raw JSON in either case.
import std/[os, strutils]
import ./meta, ./string_helpers
## Fixed 64-bit wire type for any Nim `ptr T` / `pointer`, matching the C++/Rust
## generators so the CBOR payload size is architecture-stable.
## Fixed 64-bit C type for any Nim `ptr T` / `pointer`, matching the other
## generators so the layout is architecture-stable.
const CPtrType = "uint64_t"
proc nimTypeToC(typeName: string): string =
## Maps a Nim parameter/field type to the C type used in the ergonomic
## wrapper signature.
## Maps a Nim parameter type to the C type used in the declaration.
let t = typeName.strip()
if t.startsWith("ptr ") or t == "pointer":
return "void*"
@ -45,47 +47,113 @@ proc nimTypeToC(typeName: string): string =
of "float64": "double"
else: t
type CborKind = enum
ckText
ckUint
ckInt
ckBool
ckUnsupported
proc cParam(ep: FFIParamMeta): string =
(if ep.isPtr: CPtrType else: nimTypeToC(ep.typeName)) & " " & ep.name
proc cborKindOf(typeName: string): CborKind =
## How a parameter of `typeName` is encoded into the request map. Only the
## scalar/string shapes the FFI boundary actually carries are handled; nested
## {.ffi.} struct params would need recursive map emission (not used by the
## libwaku / liblogosdelivery surface and intentionally rejected here).
let t = typeName.strip()
case t
of "string", "cstring":
ckText
of "uint", "uint8", "uint16", "uint32", "uint64", "cuint", "csize_t", "byte":
ckUint
of "int", "int8", "int16", "int32", "int64", "cint", "clong":
ckInt
of "bool":
ckBool
else:
ckUnsupported
proc typedArgs(p: FFIProcMeta): string =
var parts: seq[string] = @[]
for ep in p.extraParams:
parts.add(cParam(ep))
return parts.join(", ")
const HeaderPrelude = """
// Generated by nim-ffi C codegen. Do not edit by hand.
//
// Each function below CBOR-encodes its arguments and forwards to the matching
// nim-ffi export. The asynchronous result is delivered to `callback`:
// - RET_OK : `msg`/`len` is the CBOR-encoded return value. For functions that
// return a string this is a CBOR text string; decode it with
// <LIB>_decode_text(). An empty success encodes as 0xF6 (null) or
// an empty text string.
// - RET_ERR: `msg`/`len` is the raw error text (not CBOR-encoded).
//
// Library-initiated events delivered to a listener registered via
// <LIB>_add_event_listener are raw JSON strings (not CBOR).
// Native (zero-serialization) C ABI. Each call delivers its result to the
// callback: on RET_OK, (msg, len) is the raw return value (for string-returning
// procs, the string bytes — not NUL-terminated; use len); on RET_ERR, (msg, len)
// is the raw error text. A `<name>_cbor` variant of each proc also exists for
// generic/cross-language callers that prefer a CBOR request/response.
#ifndef NIM_FFI_GEN_<GUARD>_H
#define NIM_FFI_GEN_<GUARD>_H
#include <stddef.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
#ifndef NIM_FFI_RET_CODES
#define NIM_FFI_RET_CODES
#define RET_OK 0
#define RET_ERR 1
#define RET_MISSING_CALLBACK 2
#endif
#ifndef NIM_FFI_CALLBACK_T
#define NIM_FFI_CALLBACK_T
typedef void (*FFICallBack)(int callerRet, const char *msg, size_t len, void *userData);
#endif
"""
proc generateCHeader*(
procs: seq[FFIProcMeta],
types: seq[FFITypeMeta],
libName: string,
events: seq[FFIEventMeta] = @[],
): string =
let guard = libName.toUpper()
var lines: seq[string] = @[]
lines.add(HeaderPrelude.replace("<GUARD>", guard))
lines.add("")
for p in procs:
let args = typedArgs(p)
case p.kind
of FFIKind.DTOR:
lines.add("int " & p.procName & "(void *ctx);")
of FFIKind.CTOR:
let lead =
if args.len > 0:
args & ", "
else:
""
lines.add(
"void *" & p.procName & "(" & lead & "FFICallBack callback, void *userData);"
)
of FFIKind.FFI:
let tail =
if args.len > 0:
", " & args
else:
""
lines.add(
"int " & p.procName & "(void *ctx, FFICallBack callback, void *userData" & tail &
");"
)
lines.add("")
# `declareLibrary` always exports the listener-registration ABI.
lines.add(
"uint64_t " & libName &
"_add_event_listener(void *ctx, const char *eventName, FFICallBack callback, void *userData);"
)
lines.add(
"int " & libName & "_remove_event_listener(void *ctx, uint64_t listenerId);"
)
lines.add("")
lines.add("#ifdef __cplusplus")
lines.add("} // extern \"C\"")
lines.add("#endif")
lines.add("")
lines.add("#endif /* NIM_FFI_GEN_" & guard & "_H */")
return lines.join("\n")
const CborHeaderPrelude = """
// Generated by nim-ffi C codegen. Do not edit by hand.
//
// CBOR ABI (`<name>_cbor`). Use this for callers that cross a process or machine
// boundary (the request has to be serialized anyway) or any generic / cross-
// language caller. Build the request with the FfiCbor helpers below — a CBOR map
// whose keys are the Nim parameter names (listed per proc) — call the matching
// `<name>_cbor`, and decode the RET_OK response (a CBOR-encoded value; for
// string-returning procs a CBOR text string) with ffi_decode_text. RET_ERR
// delivers raw error text. For same-process callers, prefer the native `<name>`
// ABI in the companion <lib>.h header.
#ifndef NIM_FFI_GEN_<GUARD>_CBOR_H
#define NIM_FFI_GEN_<GUARD>_CBOR_H
#include <stddef.h>
#include <stdint.h>
#include <stdlib.h>
@ -102,9 +170,14 @@ extern "C" {
#define RET_MISSING_CALLBACK 2
#endif
#ifndef NIM_FFI_CALLBACK_T
#define NIM_FFI_CALLBACK_T
typedef void (*FFICallBack)(int callerRet, const char *msg, size_t len, void *userData);
#endif
// --- minimal growable CBOR encoder ----------------------------------------
#ifndef NIM_FFI_CBOR_HELPERS
#define NIM_FFI_CBOR_HELPERS
// --- minimal growable CBOR request encoder --------------------------------
typedef struct {
uint8_t *buf;
size_t cap;
@ -129,7 +202,6 @@ static inline void ffi_cbor_put(FfiCbor *c, uint8_t b) {
}
c->buf[c->len++] = b;
}
// CBOR head: (major << 5) | smallest-fitting length encoding of `arg`.
static inline void ffi_cbor_head(FfiCbor *c, uint8_t major, uint64_t arg) {
uint8_t mt = (uint8_t)(major << 5);
if (arg < 24) {
@ -173,18 +245,11 @@ static inline void ffi_cbor_kv_int(FfiCbor *c, const char *k, int64_t v) {
else
ffi_cbor_head(c, 1, (uint64_t)(-(v + 1)));
}
static inline void ffi_cbor_kv_bool(FfiCbor *c, const char *k, int v) {
ffi_cbor_text(c, k);
ffi_cbor_put(c, v ? 0xf5 : 0xf4);
}
// --- response decoding -----------------------------------------------------
// Zero-copy view of a top-level CBOR text string (the RET_OK payload of a
// string-returning function). On success sets *out/*outLen to point INTO `data`
// (no allocation) and returns 1; returns 0 for a non-text-string payload (e.g.
// the 0xF6 empty-success sentinel). The view borrows `data`, so it is only valid
// for as long as `data` is (i.e. the duration of the callback) and is NOT
// NUL-terminated — print it with "%.*s", (int)outLen, out.
// Zero-copy view of a top-level CBOR text string (the RET_OK payload). Sets
// *out/*outLen to point INTO `data` (no allocation; valid only while `data` is)
// and returns 1; returns 0 for a non-text-string payload.
static inline int ffi_text_view(const uint8_t *data, size_t len,
const uint8_t **out, size_t *outLen) {
if (len < 1 || (data[0] >> 5) != 3) return 0;
@ -214,10 +279,8 @@ static inline int ffi_text_view(const uint8_t *data, size_t len,
return 1;
}
// Owning variant: decode a top-level CBOR text string into a freshly malloc'd,
// NUL-terminated C string. Returns NULL for a non-text-string payload. The
// caller owns the returned pointer and must free() it. Use this when the value
// must outlive the callback; otherwise prefer the zero-copy ffi_text_view above.
// Owning variant: malloc a NUL-terminated copy. NULL for a non-text payload.
// Caller frees.
static inline char *ffi_decode_text(const uint8_t *data, size_t len) {
const uint8_t *view;
size_t slen;
@ -228,168 +291,49 @@ static inline char *ffi_decode_text(const uint8_t *data, size_t len) {
out[slen] = '\0';
return out;
}
// Reach the real exported symbol (which shares the wrapper's name) through an
// assembler label. macOS prefixes C symbols with an underscore; Linux does not.
#if defined(__APPLE__)
#define NIM_FFI_SYM(name) "_" name
#else
#define NIM_FFI_SYM(name) name
#endif
#endif // NIM_FFI_CBOR_HELPERS
"""
proc emitRequestBuild(lines: var seq[string], p: FFIProcMeta, indent: string): bool =
## Emits the CBOR request-map construction for proc `p` into `ffi_c_`.
## Returns false if any parameter type is unsupported by the C generator.
lines.add(indent & "FfiCbor ffi_c_ = ffi_cbor_new();")
lines.add(indent & "ffi_cbor_map(&ffi_c_, " & $p.extraParams.len & ");")
for ep in p.extraParams:
if ep.isPtr:
return false
case cborKindOf(ep.typeName)
of ckText:
lines.add(
indent & "ffi_cbor_kv_text(&ffi_c_, \"" & ep.name & "\", " & ep.name & ");"
)
of ckUint:
lines.add(
indent & "ffi_cbor_kv_uint(&ffi_c_, \"" & ep.name & "\", (uint64_t)" & ep.name &
");"
)
of ckInt:
lines.add(
indent & "ffi_cbor_kv_int(&ffi_c_, \"" & ep.name & "\", (int64_t)" & ep.name &
");"
)
of ckBool:
lines.add(
indent & "ffi_cbor_kv_bool(&ffi_c_, \"" & ep.name & "\", " & ep.name & ");"
)
of ckUnsupported:
return false
return true
proc paramSig(p: FFIProcMeta): string =
## Comma-separated typed parameter list (no leading comma) for the wrapper.
proc requestFieldsComment(p: FFIProcMeta): string =
## A one-line note of the CBOR request-map keys (the Nim param names) so a
## caller knows what to encode.
if p.extraParams.len == 0:
return "// request: empty CBOR map (0xA0)"
var parts: seq[string] = @[]
for ep in p.extraParams:
let cType =
if ep.isPtr:
CPtrType
else:
nimTypeToC(ep.typeName)
parts.add(cType & " " & ep.name)
return parts.join(", ")
parts.add("\"" & ep.name & "\": " & ep.typeName)
return "// request map keys: {" & parts.join(", ") & "}"
proc allParamsSupported(p: FFIProcMeta): bool =
## The C generator only produces ergonomic wrappers when every parameter is a
## scalar or string. Struct / seq / Option params (which C cannot express
## naturally) fall back to the raw CBOR-buffer ABI declaration instead.
for ep in p.extraParams:
if ep.isPtr or cborKindOf(ep.typeName) == ckUnsupported:
return false
return true
proc generateCHeader*(
proc generateCborCHeader*(
procs: seq[FFIProcMeta],
types: seq[FFITypeMeta],
libName: string,
events: seq[FFIEventMeta] = @[],
): string =
let guard = libName.toUpper()
let upperLib = libName.toUpper()
var lines: seq[string] = @[]
lines.add(HeaderPrelude.replace("<GUARD>", guard).replace("<LIB>", upperLib))
lines.add(CborHeaderPrelude.replace("<GUARD>", guard))
lines.add("")
for p in procs:
case p.kind
of FFIKind.DTOR:
# No request payload — declare the real export directly.
# The destructor takes no request, so it has no CBOR variant.
lines.add("int " & p.procName & "(void *ctx);")
lines.add("")
of FFIKind.CTOR:
if allParamsSupported(p):
let sig = paramSig(p)
# void* <name>(<typed args...>, FFICallBack callback, void *userData)
lines.add(
"extern void *" & p.procName &
"__ffi(const uint8_t *reqCbor, size_t reqCborLen, FFICallBack callback, void *userData) __asm__(NIM_FFI_SYM(\"" &
p.procName & "\"));"
)
let ctorHead =
"void *" & p.procName & "(" & sig & (if sig.len > 0: ", " else: "") &
"FFICallBack callback, void *userData)"
# Rename the wrapper's own symbol so the raw export's asm alias above
# (which targets the unmangled export name) does not bind to this local
# static definition — otherwise the wrapper would call itself.
lines.add(
"static inline " & ctorHead & " __asm__(\"" & p.procName & "__wrap\");"
)
lines.add("static inline " & ctorHead & " {")
discard emitRequestBuild(lines, p, " ")
lines.add(
" void *ffi_r_ = " & p.procName &
"__ffi(ffi_c_.buf, ffi_c_.len, callback, userData);"
)
lines.add(" ffi_cbor_free(&ffi_c_);")
lines.add(" return ffi_r_;")
lines.add("}")
else:
# Non-scalar params: expose the raw CBOR-buffer ABI; the caller builds
# the request map itself (the FfiCbor helpers above are still usable).
lines.add(
"// " & p.procName &
": has struct/seq/Option params; raw CBOR ABI only (build the request map yourself)."
)
lines.add(
"void *" & p.procName &
"(const uint8_t *reqCbor, size_t reqCborLen, FFICallBack callback, void *userData);"
)
lines.add("")
lines.add(requestFieldsComment(p))
lines.add(
"void *" & p.procName &
"_cbor(const uint8_t *reqCbor, size_t reqCborLen, FFICallBack callback, void *userData);"
)
of FFIKind.FFI:
if allParamsSupported(p):
let sig = paramSig(p)
let sigComma =
if sig.len > 0:
", " & sig
else:
""
lines.add(
"extern int " & p.procName &
"__ffi(void *ctx, FFICallBack callback, void *userData, const uint8_t *reqCbor, size_t reqCborLen) __asm__(NIM_FFI_SYM(\"" &
p.procName & "\"));"
)
let ffiHead =
"int " & p.procName & "(void *ctx, FFICallBack callback, void *userData" &
sigComma & ")"
# Rename the wrapper's own symbol so the raw export's asm alias above
# (which targets the unmangled export name) does not bind to this local
# static definition — otherwise the wrapper would call itself.
lines.add(
"static inline " & ffiHead & " __asm__(\"" & p.procName & "__wrap\");"
)
lines.add("static inline " & ffiHead & " {")
discard emitRequestBuild(lines, p, " ")
lines.add(
" int ffi_r_ = " & p.procName &
"__ffi(ctx, callback, userData, ffi_c_.buf, ffi_c_.len);"
)
lines.add(" ffi_cbor_free(&ffi_c_);")
lines.add(" return ffi_r_;")
lines.add("}")
else:
lines.add(
"// " & p.procName &
": has struct/seq/Option params; raw CBOR ABI only (build the request map yourself)."
)
lines.add(
"int " & p.procName &
"(void *ctx, FFICallBack callback, void *userData, const uint8_t *reqCbor, size_t reqCborLen);"
)
lines.add("")
lines.add(requestFieldsComment(p))
lines.add(
"int " & p.procName &
"_cbor(void *ctx, FFICallBack callback, void *userData, const uint8_t *reqCbor, size_t reqCborLen);"
)
lines.add("")
# `declareLibrary` always exports the listener-registration ABI.
lines.add(
"uint64_t " & libName &
"_add_event_listener(void *ctx, const char *eventName, FFICallBack callback, void *userData);"
@ -402,7 +346,7 @@ proc generateCHeader*(
lines.add("} // extern \"C\"")
lines.add("#endif")
lines.add("")
lines.add("#endif /* NIM_FFI_GEN_" & guard & "_H */")
lines.add("#endif /* NIM_FFI_GEN_" & guard & "_CBOR_H */")
return lines.join("\n")
proc generateCBindings*(
@ -413,6 +357,12 @@ proc generateCBindings*(
nimSrcRelPath: string,
events: seq[FFIEventMeta] = @[],
) =
# Emit both ABIs so consumers can choose per call site: the native (zero-copy,
# same-process) one and the CBOR (boundary-crossing / generic) one.
writeFile(
outputDir / (libName & ".h"), generateCHeader(procs, types, libName, events)
)
writeFile(
outputDir / (libName & "_cbor.h"),
generateCborCHeader(procs, types, libName, events),
)

View File

@ -1,10 +1,9 @@
## Go (cgo) binding generator for the nim-ffi framework.
##
## Emits a single `<lib>.go` file that wraps the CBOR ABI behind idiomatic Go.
## It builds on the C codegen's `<lib>.h` (included from the cgo preamble) for
## the ergonomic typed wrappers, the CBOR encoder, and `ffi_decode_text`, and
## adds the piece every async-only consumer needs: a per-call response capture
## that BLOCKS until the FFI callback fires, then decodes the payload.
## Emits a single `<lib>.go` file that wraps the native C ABI behind idiomatic
## Go. It includes the C codegen's `<lib>.h` (the native typed-arg declarations)
## and adds the piece every async-only consumer needs: a per-call response
## capture that BLOCKS until the FFI callback fires, then copies the raw result.
##
## nim-ffi 0.2.0 dispatches every `{.ffi.}` call through the FFI thread (no sync
## fast-path), so a caller cannot read the result immediately after the call —
@ -154,15 +153,12 @@ proc generateGoFile*(
L.add(" " & respT & "* r = (" & respT & "*)ud;")
L.add(" pthread_mutex_lock(&r->mu);")
L.add(" r->ret = ret;")
L.add(" if (ret == RET_OK) {")
L.add(" char* d = ffi_decode_text((const uint8_t*)msg, len);")
L.add(" r->msg = d; r->len = d ? strlen(d) : 0;")
L.add(" } else {")
L.add(" // Native ABI: (msg, len) is the raw result (RET_OK) or error (RET_ERR).")
L.add(" // Copy it so it survives past the callback.")
L.add(
" char* e = (char*)malloc(len + 1); if (e) { memcpy(e, msg, len); e[len] = 0; }"
" char* e = (char*)malloc(len + 1); if (e) { memcpy(e, msg, len); e[len] = 0; }"
)
L.add(" r->msg = e; r->len = len;")
L.add(" }")
L.add(" r->msg = e; r->len = len;")
L.add(" r->done = 1; pthread_cond_signal(&r->cv); pthread_mutex_unlock(&r->mu);")
L.add("}")
L.add("static void " & libName & "RespWait(" & respT & "* r) {")

View File

@ -20,12 +20,29 @@ const EmptyErrorMarker = "unknown error"
## the callback's msg ptr non-nil and gives the foreign side a recognizable
## fallback to log.
type PayloadFreeProc* = proc(p: pointer) {.cdecl, raises: [], gcsafe.}
## Releases a non-CBOR (native C) request payload. The framework's own CBOR
## payload is a single `c_malloc` buffer freed with `c_free`; a native payload
## is a C-POD args struct that may own further allocations (e.g. duplicated
## C strings), so it needs a per-type destructor.
type FFIThreadRequest* = object
callback*: FFICallBack
userData*: pointer
reqId*: cstring ## Per-proc Req type name used to look up the handler.
data*: ptr UncheckedArray[byte] ## Owned CBOR-encoded request payload.
data*: ptr UncheckedArray[byte]
## Owned request payload: CBOR bytes when `cborMode`, otherwise an opaque
## C-POD args struct (see `payloadFree`).
dataLen*: int
cborMode*: bool
## true -> `data` is CBOR; the handler decodes it and the response is
## CBOR-encoded (cross-language / generic dispatch path).
## false -> `data` is a native C-POD args struct passed by pointer with no
## serialization, and the response is delivered as raw bytes
## (the same-process "pure C" path).
payloadFree*: PayloadFreeProc
## When non-nil, `data` is freed by calling this instead of `c_free` — used
## for the native C-POD payload, which owns its duplicated string fields.
proc allocBaseRequest(
callback: FFICallBack, userData: pointer, reqId: cstring
@ -39,6 +56,8 @@ proc allocBaseRequest(
ret[].reqId = reqId.alloc()
ret[].data = nil
ret[].dataLen = 0
ret[].cborMode = true
ret[].payloadFree = nil
return ret
proc copySharedPayload(req: ptr FFIThreadRequest, data: ptr byte, dataLen: int) =
@ -113,10 +132,32 @@ proc initFromOwnedShared*(
adoptOwnedSharedPayload(ret, data, dataLen)
return ret
proc initNative*(
T: typedesc[FFIThreadRequest],
callback: FFICallBack,
userData: pointer,
reqId: cstring,
payload: pointer,
payloadFree: PayloadFreeProc,
): ptr type T =
## Builds a native (no-CBOR) request: `payload` is an opaque, already-allocated
## C-POD args struct passed by pointer (zero serialization). `payloadFree`
## releases it (and any duplicated string fields it owns) after the handler
## runs. The response is delivered as raw bytes rather than CBOR.
var ret = allocBaseRequest(callback, userData, reqId)
ret[].data = cast[ptr UncheckedArray[byte]](payload)
ret[].dataLen = 0
ret[].cborMode = false
ret[].payloadFree = payloadFree
return ret
proc deleteRequest*(request: ptr FFIThreadRequest) =
if not request[].data.isNil:
c_free(request[].data)
if not request[].reqId.isNil:
if not request[].data.isNil():
if not request[].payloadFree.isNil():
request[].payloadFree(cast[pointer](request[].data))
else:
c_free(request[].data)
if not request[].reqId.isNil():
c_free(cast[pointer](request[].reqId))
c_free(request)
@ -143,12 +184,19 @@ proc handleRes*(res: Result[seq[byte], string], request: ptr FFIThreadRequest) =
cast[csize_t](bytes.len),
request[].userData,
)
else:
# Always hand the callback a real buffer; CBOR null marks "no value".
elif request[].cborMode:
# CBOR path: hand the callback a real buffer; CBOR null marks "no value".
var sentinel = CborNullByte
request[].callback(
RET_OK, cast[ptr cchar](addr sentinel), 1.csize_t, request[].userData
)
else:
# Native path: an empty result is just a zero-length payload. Pass a valid
# non-nil pointer with len 0 so the callback never sees a nil msg.
var empty: byte = 0
request[].callback(
RET_OK, cast[ptr cchar](addr empty), 0.csize_t, request[].userData
)
proc nilProcess*(reqId: cstring): Future[Result[seq[byte], string]] {.async.} =
return err("This request type is not implemented: " & $reqId)

View File

@ -602,6 +602,73 @@ macro ffiRaw*(prc: untyped): untyped =
echo stmts.repr
return stmts
# ---------------------------------------------------------------------------
# Native (zero-serialization) C-POD payload helpers, shared by the {.ffi.} and
# {.ffiCtor.} native code paths. The native path passes the typed args to the
# FFI thread inside a c_malloc'd struct (by pointer) instead of CBOR, so the
# struct keeps the user's original param types and owns copies of any cstrings.
# ---------------------------------------------------------------------------
proc isCstringType(t: NimNode): bool =
t.kind == nnkIdent and $t == "cstring"
proc buildCArgsTypeDef(
cargsTypeName: NimNode, paramNames: seq[string], paramTypes: seq[NimNode]
): NimNode =
## `type <cargsTypeName> = object` with one field per param (original types).
## Empty param lists get a `placeholder` field so the object is well-formed.
var fields: seq[NimNode] = @[]
for i in 0 ..< paramNames.len:
fields.add(
newTree(nnkIdentDefs, ident(paramNames[i]), paramTypes[i], newEmptyNode())
)
let recList =
if fields.len > 0:
newTree(nnkRecList, fields)
else:
newTree(
nnkRecList,
newTree(nnkIdentDefs, ident("placeholder"), ident("uint8"), newEmptyNode()),
)
return newNimNode(nnkTypeSection).add(
newTree(
nnkTypeDef,
cargsTypeName,
newEmptyNode(),
newTree(nnkObjectTy, newEmptyNode(), newEmptyNode(), recList),
)
)
proc buildCArgsFreeProc(
cargsTypeName, cargsFreeName: NimNode,
paramNames: seq[string],
paramTypes: seq[NimNode],
): NimNode =
## `proc <cargsFreeName>(p: pointer) {.cdecl, raises:[], gcsafe.}` that frees
## each owned cstring field (with c_free, matching `alloc`) and then the struct.
let freeS = genSym(nskLet, "s")
var freeBody = newStmtList()
freeBody.add quote do:
let `freeS` = cast[ptr `cargsTypeName`](p)
for i in 0 ..< paramNames.len:
if isCstringType(paramTypes[i]):
let f = ident(paramNames[i])
freeBody.add quote do:
ffiCFree(cast[pointer](`freeS`[].`f`))
freeBody.add quote do:
ffiCFree(p)
return newProc(
name = cargsFreeName,
params = @[newEmptyNode(), newIdentDefs(ident("p"), ident("pointer"))],
body = freeBody,
pragmas = newTree(
nnkPragma,
ident("cdecl"),
newTree(nnkExprColonExpr, ident("raises"), newTree(nnkBracket)),
ident("gcsafe"),
),
)
# ---------------------------------------------------------------------------
# ffi macro — primary FFI proc / FFI type registration
# ---------------------------------------------------------------------------
@ -792,10 +859,150 @@ macro ffi*(prc: untyped): untyped =
return RET_ERR
return RET_OK
# The CBOR entry point is the generic / cross-language dispatcher; it keeps
# the per-proc Req type name as its handler key and is exported under the
# `<name>_cbor` symbol. The native typed-arg entry point (below) is the
# primary `<name>` symbol and is preferred for same-process callers.
let cborExportName = ident(procNameStr & "CborExport")
let ffiProc = newProc(
name = postfix(cExportProcName, "*"),
name = cborExportName,
params = exportedParams,
body = ffiBody,
pragmas = newTree(
nnkPragma,
ident("dynlib"),
newTree(
nnkExprColonExpr, ident("exportc"), newStrLitNode(cExportName & "_cbor")
),
ident("cdecl"),
newTree(nnkExprColonExpr, ident("raises"), newTree(nnkBracket)),
),
)
# -------------------------------------------------------------------------
# Native (zero-serialization) path: the typed args travel to the FFI thread
# inside a c_malloc'd C-POD struct passed by pointer — no CBOR — and the
# response is delivered as raw bytes. Registered under a distinct
# "<Camel>ReqNative" key so it dispatches to its own handler.
# -------------------------------------------------------------------------
let cargsTypeName = ident(camelName & "CArgs")
let cargsFreeName = ident(camelName & "CArgsFree")
let nativeReqIdLit = newStrLitNode(camelName & "ReqNative")
let nativeExportName = ident(procNameStr & "NativeExport")
let cargsTypeDef =
buildCArgsTypeDef(cargsTypeName, extraParamNames, extraParamTypes)
let cargsFreeProc =
buildCArgsFreeProc(cargsTypeName, cargsFreeName, extraParamNames, extraParamTypes)
# Native FFI-thread handler: read the C-POD, call the helper, raw-encode.
let ndReq = genSym(nskLet, "ffiReq")
let ndCtx = genSym(nskLet, "nativeCtx")
let ndCargs = genSym(nskLet, "cargs")
let ndRet = genSym(nskLet, "retVal")
var ndBody = newStmtList()
ndBody.add quote do:
let `ndReq` = cast[ptr FFIThreadRequest](request)
let `ndCtx` = cast[ptr FFIContext[`libTypeName`]](reqHandler)
let `ndCargs` = cast[ptr `cargsTypeName`](`ndReq`[].data)
let ndHelperCall = newTree(
nnkCall,
userProcName,
newTree(nnkDerefExpr, newDotExpr(newTree(nnkDerefExpr, ndCtx), ident("myLib"))),
)
for nm in extraParamNames:
let f = ident(nm)
ndBody.add quote do:
let `f` = `ndCargs`[].`f`
ndHelperCall.add(ident(nm))
ndBody.add quote do:
let `ndRet` = (await `ndHelperCall`).valueOr:
return err($error)
when typeof(`ndRet`) is string:
var rb = newSeq[byte](`ndRet`.len)
if `ndRet`.len > 0:
copyMem(addr rb[0], unsafeAddr `ndRet`[0], `ndRet`.len)
return ok(rb)
elif typeof(`ndRet`) is seq[byte]:
return ok(`ndRet`)
else:
return ok(cborEncode(`ndRet`))
let seqByteRet = nnkBracketExpr.newTree(
ident("Future"),
nnkBracketExpr.newTree(
ident("Result"),
nnkBracketExpr.newTree(ident("seq"), ident("byte")),
ident("string"),
),
)
let nativeHandlerProc = newProc(
name = newEmptyNode(),
params = @[
seqByteRet,
newIdentDefs(ident("request"), ident("pointer")),
newIdentDefs(ident("reqHandler"), ident("pointer")),
],
body = ndBody,
pragmas = nnkPragma.newTree(ident("async")),
)
let nativeRegister = newAssignment(
newTree(nnkBracketExpr, ident("registeredRequests"), nativeReqIdLit),
nativeHandlerProc,
)
# Native C export: build the C-POD (duplicating cstrings) and dispatch.
let neCargs = genSym(nskLet, "cargs")
let neReq = genSym(nskLet, "nreq")
let neSend = genSym(nskLet, "sendRes")
var neBody = newStmtList()
neBody.add quote do:
if callback.isNil:
return RET_MISSING_CALLBACK
if not `asyncPoolIdent`.isValidCtx(cast[pointer](ctx)):
let errStr = "ctx is not a valid FFI context"
callback(RET_ERR, unsafeAddr errStr[0], cast[csize_t](errStr.len), userData)
return RET_ERR
let `neCargs` = ffiCMalloc(`cargsTypeName`)
for i in 0 ..< extraParamNames.len:
let f = ident(extraParamNames[i])
if isCstringType(extraParamTypes[i]):
neBody.add quote do:
`neCargs`[].`f` = `f`.alloc()
else:
neBody.add quote do:
`neCargs`[].`f` = `f`
neBody.add quote do:
let `neReq` = FFIThreadRequest.initNative(
callback,
userData,
`nativeReqIdLit`.cstring,
cast[pointer](`neCargs`),
`cargsFreeName`,
)
let `neSend` =
try:
ffi_context.sendRequestToFFIThread(ctx, `neReq`)
except Exception as exc:
Result[void, string].err("sendRequestToFFIThread exception: " & exc.msg)
if `neSend`.isErr():
let errStr = "error in sendRequestToFFIThread: " & `neSend`.error
callback(RET_ERR, unsafeAddr errStr[0], cast[csize_t](errStr.len), userData)
return RET_ERR
return RET_OK
var nativeExportParams = @[
ident("cint"),
newIdentDefs(ident("ctx"), ctxType),
newIdentDefs(ident("callback"), ident("FFICallBack")),
newIdentDefs(ident("userData"), ident("pointer")),
]
for i in 0 ..< extraParamNames.len:
nativeExportParams.add(
newIdentDefs(ident(extraParamNames[i]), extraParamTypes[i])
)
let nativeExportProc = newProc(
name = nativeExportName,
params = nativeExportParams,
body = neBody,
pragmas = newTree(
nnkPragma,
ident("dynlib"),
@ -837,7 +1044,10 @@ macro ffi*(prc: untyped): untyped =
)
)
return newStmtList(helperProc, registerReq, ffiProc)
return newStmtList(
helperProc, registerReq, cargsTypeDef, cargsFreeProc, nativeRegister,
nativeExportProc, ffiProc,
)
let stmts = asyncPath()
@ -1209,14 +1419,17 @@ macro ffiCtor*(prc: untyped): untyped =
ffiBody.add quote do:
return cast[pointer](`ctxSym`)
# CBOR constructor entry point, exported under `<name>_cbor`. The native
# typed-arg constructor below is the primary `<name>` symbol.
let cborCtorExportName = ident(cleanName & "CborCtorExport")
let ffiProc = newProc(
name = postfix(cExportProcName, "*"),
name = cborCtorExportName,
params = exportedParams,
body = ffiBody,
pragmas = newTree(
nnkPragma,
ident("dynlib"),
newTree(nnkExprColonExpr, ident("exportc"), newStrLitNode(cExportName)),
newTree(nnkExprColonExpr, ident("exportc"), newStrLitNode(cExportName & "_cbor")),
ident("cdecl"),
newTree(nnkExprColonExpr, ident("raises"), newTree(nnkBracket)),
),
@ -1247,12 +1460,139 @@ macro ffiCtor*(prc: untyped): untyped =
)
)
# -------------------------------------------------------------------------
# Native (zero-serialization) constructor: typed args -> C-POD by pointer,
# exported as the primary `<name>` symbol. Returns the ctx pointer; the
# callback fires with the ctx address as a raw decimal string.
# -------------------------------------------------------------------------
let ctorCamel = snakeToPascalCase(cleanName)
let cargsTypeName = ident(ctorCamel & "CtorCArgs")
let cargsFreeName = ident(ctorCamel & "CtorCArgsFree")
let nativeCtorReqIdLit = newStrLitNode(ctorCamel & "CtorReqNative")
let nativeCtorExportName = ident(cleanName & "NativeCtorExport")
let ctorCargsTypeDef = buildCArgsTypeDef(cargsTypeName, paramNames, paramTypes)
let ctorCargsFreeProc =
buildCArgsFreeProc(cargsTypeName, cargsFreeName, paramNames, paramTypes)
# Native handler: read the C-POD, run the ctor body, store myLib, raw address.
let ncReq = genSym(nskLet, "ffiReq")
let ncCtx = genSym(nskLet, "nativeCtx")
let ncCargs = genSym(nskLet, "cargs")
let ncLibVal = genSym(nskLet, "libVal")
let ncAddr = genSym(nskLet, "addrStr")
var ncBody = newStmtList()
ncBody.add quote do:
let `ncReq` = cast[ptr FFIThreadRequest](request)
let `ncCtx` = cast[ptr FFIContext[`libTypeName`]](reqHandler)
let `ncCargs` = cast[ptr `cargsTypeName`](`ncReq`[].data)
let ncHelperCall = newTree(nnkCall, userProcName)
for nm in paramNames:
let f = ident(nm)
ncBody.add quote do:
let `f` = `ncCargs`[].`f`
ncHelperCall.add(ident(nm))
let ncMyLib = newDotExpr(newTree(nnkDerefExpr, ncCtx), ident("myLib"))
ncBody.add quote do:
let `ncLibVal` = (await `ncHelperCall`).valueOr:
return err($error)
`ncMyLib` = createShared(`libTypeName`)
`ncMyLib`[] = `ncLibVal`
let `ncAddr` = $cast[uint](`ncCtx`)
var rb = newSeq[byte](`ncAddr`.len)
if `ncAddr`.len > 0:
copyMem(addr rb[0], unsafeAddr `ncAddr`[0], `ncAddr`.len)
return ok(rb)
let ctorSeqByteRet = nnkBracketExpr.newTree(
ident("Future"),
nnkBracketExpr.newTree(
ident("Result"),
nnkBracketExpr.newTree(ident("seq"), ident("byte")),
ident("string"),
),
)
let nativeCtorHandler = newProc(
name = newEmptyNode(),
params = @[
ctorSeqByteRet,
newIdentDefs(ident("request"), ident("pointer")),
newIdentDefs(ident("reqHandler"), ident("pointer")),
],
body = ncBody,
pragmas = nnkPragma.newTree(ident("async")),
)
let nativeCtorRegister = newAssignment(
newTree(nnkBracketExpr, ident("registeredRequests"), nativeCtorReqIdLit),
nativeCtorHandler,
)
# Native C export: create the ctx, build the C-POD (dup cstrings), dispatch.
let necCtx = genSym(nskLet, "ctx")
let necCargs = genSym(nskLet, "cargs")
let necReq = genSym(nskLet, "nreq")
let necSend = genSym(nskLet, "sendRes")
var necBody = newStmtList()
necBody.add quote do:
when declared(initializeLibrary):
initializeLibrary()
let `necCtx` = `poolIdent`.createFFIContext().valueOr:
if not callback.isNil:
let errStr = "ffiCtor: failed to create FFIContext: " & $error
callback(RET_ERR, unsafeAddr errStr[0], cast[csize_t](errStr.len), userData)
return nil
let `necCargs` = ffiCMalloc(`cargsTypeName`)
for i in 0 ..< paramNames.len:
let f = ident(paramNames[i])
if isCstringType(paramTypes[i]):
necBody.add quote do:
`necCargs`[].`f` = `f`.alloc()
else:
necBody.add quote do:
`necCargs`[].`f` = `f`
necBody.add quote do:
let `necReq` = FFIThreadRequest.initNative(
callback,
userData,
`nativeCtorReqIdLit`.cstring,
cast[pointer](`necCargs`),
`cargsFreeName`,
)
let `necSend` =
try:
`necCtx`.sendRequestToFFIThread(`necReq`)
except Exception as exc:
Result[void, string].err("sendRequestToFFIThread exception: " & exc.msg)
if `necSend`.isErr():
if not callback.isNil:
let errStr = "ffiCtor: failed to send request: " & $`necSend`.error
callback(RET_ERR, unsafeAddr errStr[0], cast[csize_t](errStr.len), userData)
return nil
return cast[pointer](`necCtx`)
var nativeCtorParams = @[ident("pointer")]
for i in 0 ..< paramNames.len:
nativeCtorParams.add(newIdentDefs(ident(paramNames[i]), paramTypes[i]))
nativeCtorParams.add(newIdentDefs(ident("callback"), ident("FFICallBack")))
nativeCtorParams.add(newIdentDefs(ident("userData"), ident("pointer")))
let nativeCtorExportProc = newProc(
name = nativeCtorExportName,
params = nativeCtorParams,
body = necBody,
pragmas = newTree(
nnkPragma,
ident("dynlib"),
newTree(nnkExprColonExpr, ident("exportc"), newStrLitNode(cExportName)),
ident("cdecl"),
newTree(nnkExprColonExpr, ident("raises"), newTree(nnkBracket)),
),
)
let poolDecl = quote:
when not declared(`poolIdent`):
var `poolIdent`: FFIContextPool[`libTypeName`]
let stmts = newStmtList(
typeDef, ffiNewReqProc, helperProc, processProc, addToReg, poolDecl, ffiProc
typeDef, ffiNewReqProc, helperProc, processProc, addToReg, poolDecl, ffiProc,
ctorCargsTypeDef, ctorCargsFreeProc, nativeCtorRegister, nativeCtorExportProc,
)
when defined(ffiDumpMacros):
@ -1421,8 +1761,10 @@ macro ffiEvent*(wireName: static[string], prc: untyped): untyped =
let payloadTypeNameStr =
case payloadTypeNode.kind
of nnkIdent: $payloadTypeNode
else: payloadTypeNode.repr
of nnkIdent:
$payloadTypeNode
else:
payloadTypeNode.repr
var userProcName = procName
if procName.kind == nnkPostfix:
@ -1430,13 +1772,8 @@ macro ffiEvent*(wireName: static[string], prc: untyped): untyped =
# The generated body: dispatchFFIEventCbor("wire_name", payload).
let wireNameLit = newStrLitNode(wireName)
let dispatchBody = newStmtList(
newCall(
ident("dispatchFFIEventCbor"),
wireNameLit,
payloadParamName,
)
)
let dispatchBody =
newStmtList(newCall(ident("dispatchFFIEventCbor"), wireNameLit, payloadParamName))
var newParams = newSeq[NimNode]()
newParams.add(formalParams[0]) # return type (typically empty/void)