nim-ffi/ffi/ffi_thread_request.nim
Ivan FB f3206c30b8
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>
2026-05-31 02:02:39 +02:00

203 lines
7.7 KiB
Nim

## Carries one CBOR-encoded request blob between the main thread and the FFI
## thread. The main thread allocates the request, the FFI thread frees it
## after invoking the user callback.
##
## All three pieces (envelope, reqId copy, payload buffer) are obtained from
## libc `malloc` and released by libc `free`. Nim's `allocShared` under
## `--mm:orc` is backed by a per-thread `MemRegion` stored in TLS; if the
## producer thread (commonly a transient `std::async` worker on the foreign
## side) has exited by the time the FFI thread runs `deleteRequest`, the
## chunk's `owner` pointer dangles into reclaimed TLS and the deallocator
## segfaults. `malloc`/`free` are process-global and immune to that.
import system/ansi_c
import results
import chronos
import ./ffi_types, ./alloc, ./cbor_serial
const EmptyErrorMarker = "unknown error"
## Sent verbatim on RET_ERR when the handler produced no message — keeps
## 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 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
): ptr FFIThreadRequest =
## Allocates the request envelope via `c_malloc` and populates the routing
## fields. Payload setup is delegated to one of the payload helpers below
## depending on whether the bytes need to be copied or adopted.
var ret = cast[ptr FFIThreadRequest](c_malloc(csize_t(sizeof(FFIThreadRequest))))
ret[].callback = callback
ret[].userData = userData
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) =
## Allocates a fresh `c_malloc` buffer and copies `dataLen` bytes from
## `data` into `req`. Empty payloads (non-positive `dataLen` or nil
## `data`) leave the request's payload fields at their zero-initialised
## state.
if dataLen > 0 and not data.isNil():
req[].data = cast[ptr UncheckedArray[byte]](c_malloc(csize_t(dataLen)))
copyMem(req[].data, data, dataLen)
req[].dataLen = dataLen
proc adoptOwnedSharedPayload(
req: ptr FFIThreadRequest, data: ptr UncheckedArray[byte], dataLen: int
) =
## Embeds an already-`c_malloc`'d buffer into `req` without copying.
## `(nil, 0)` is the empty-payload contract; a zero-length-but-non-nil
## buffer is treated as empty and disposed here so it doesn't leak.
if dataLen > 0 and not data.isNil():
req[].data = data
req[].dataLen = dataLen
elif not data.isNil():
c_free(data)
proc initFromPtr*(
T: typedesc[FFIThreadRequest],
callback: FFICallBack,
userData: pointer,
reqId: cstring,
data: ptr byte,
dataLen: int,
): ptr type T =
## Takes a raw ptr+len; the bytes are copied into a fresh shared-memory
## buffer owned by the returned request.
var ret = allocBaseRequest(callback, userData, reqId)
copySharedPayload(ret, data, dataLen)
return ret
proc init*(
T: typedesc[FFIThreadRequest],
callback: FFICallBack,
userData: pointer,
reqId: cstring,
data: openArray[byte],
): ptr type T =
## Same contract as `initFromPtr` but accepts a Nim openArray, copying its
## bytes into a fresh shared-memory buffer owned by the returned request.
let dataPtr =
if data.len > 0:
cast[ptr byte](unsafeAddr data[0])
else:
nil
initFromPtr(T, callback, userData, reqId, dataPtr, data.len)
proc initFromOwnedShared*(
T: typedesc[FFIThreadRequest],
callback: FFICallBack,
userData: pointer,
reqId: cstring,
data: ptr UncheckedArray[byte],
dataLen: int,
): ptr type T =
## Takes ownership of an already-allocated buffer (`data`) and embeds it
## in the request without copying. Pair with `cborEncodeShared` so the
## request payload travels from encoder to FFI thread with a single
## allocation instead of seq → c_malloc + copyMem.
##
## Ownership: `data` must have been allocated via `c_malloc`. After this
## call, `deleteRequest` will `c_free` it. Pass `(nil, 0)` for an empty
## payload.
var ret = allocBaseRequest(callback, userData, reqId)
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():
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)
proc handleRes*(res: Result[seq[byte], string], request: ptr FFIThreadRequest) =
## Fires the registered callback exactly once and frees the request.
## Success payload is CBOR bytes; error payload is the raw UTF-8 error string.
defer:
deleteRequest(request)
if res.isErr():
foreignThreadGc:
let msg = if res.error.len > 0: res.error else: EmptyErrorMarker
request[].callback(
RET_ERR, unsafeAddr msg[0], cast[csize_t](msg.len), request[].userData
)
return
foreignThreadGc:
let bytes = res.get()
if bytes.len > 0:
request[].callback(
RET_OK,
cast[ptr cchar](unsafeAddr bytes[0]),
cast[csize_t](bytes.len),
request[].userData,
)
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)