From f3206c30b844ae344bebab37df162b5e1e49386c Mon Sep 17 00:00:00 2001 From: Ivan FB Date: Sun, 31 May 2026 01:05:12 +0200 Subject: [PATCH] feat(ffi): emit a native (zero-serialization) C ABI alongside CBOR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single {.ffi.} definition now produces BOTH interfaces, chosen by the caller at link time rather than by a global compile flag: - `` — 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. - `_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 "Req" (CBOR) and "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 --- ffi/alloc.nim | 15 ++ ffi/codegen/c.nim | 368 ++++++++++++++++--------------------- ffi/codegen/go.nim | 20 +- ffi/ffi_thread_request.nim | 60 +++++- ffi/internal/ffi_macro.nim | 365 ++++++++++++++++++++++++++++++++++-- 5 files changed, 587 insertions(+), 241 deletions(-) diff --git a/ffi/alloc.nim b/ffi/alloc.nim index 66e688e..4577313 100644 --- a/ffi/alloc.nim +++ b/ffi/alloc.nim @@ -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) diff --git a/ffi/codegen/c.nim b/ffi/codegen/c.nim index 92c08d5..db935d2 100644 --- a/ffi/codegen/c.nim +++ b/ffi/codegen/c.nim @@ -1,32 +1,34 @@ ## C binding generator for the nim-ffi framework. ## -## Emits a single self-contained header `.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 — `_(ctx, callback, userData, )` — 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 `` (no CBOR, result delivered raw) and a `_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. +## - `.h` — native ABI: `int (ctx, cb, ud, )`, +## ctor `void *(, cb, ud)`, dtor `int (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). +## - `_cbor.h` — CBOR ABI: `int _cbor(ctx, cb, ud, reqCbor, len)`, +## ctor `void *_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 +## `_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 -// _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 -// _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 `_cbor` variant of each proc also exists for +// generic/cross-language callers that prefer a CBOR request/response. #ifndef NIM_FFI_GEN__H #define NIM_FFI_GEN__H +#include +#include + +#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)) + 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 (`_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 +// `_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 `` +// ABI in the companion .h header. +#ifndef NIM_FFI_GEN__CBOR_H +#define NIM_FFI_GEN__CBOR_H + #include #include #include @@ -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).replace("", upperLib)) + lines.add(CborHeaderPrelude.replace("", 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* (, 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), + ) diff --git a/ffi/codegen/go.nim b/ffi/codegen/go.nim index f4c003d..d808ae8 100644 --- a/ffi/codegen/go.nim +++ b/ffi/codegen/go.nim @@ -1,10 +1,9 @@ ## Go (cgo) binding generator for the nim-ffi framework. ## -## Emits a single `.go` file that wraps the CBOR ABI behind idiomatic Go. -## It builds on the C codegen's `.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 `.go` file that wraps the native C ABI behind idiomatic +## Go. It includes the C codegen's `.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) {") diff --git a/ffi/ffi_thread_request.nim b/ffi/ffi_thread_request.nim index 25a9632..d609dba 100644 --- a/ffi/ffi_thread_request.nim +++ b/ffi/ffi_thread_request.nim @@ -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) diff --git a/ffi/internal/ffi_macro.nim b/ffi/internal/ffi_macro.nim index a1f54fb..17e4f9c 100644 --- a/ffi/internal/ffi_macro.nim +++ b/ffi/internal/ffi_macro.nim @@ -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 = 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 (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 + # `_cbor` symbol. The native typed-arg entry point (below) is the + # primary `` 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 + # "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 `_cbor`. The native + # typed-arg constructor below is the primary `` 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 `` 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)