nim-ffi/ffi/ffi_thread_request.nim
Ivan FB 9cf4bf0127
feat(ffi): native typed struct returns for the C ABI
A `{.ffi.}` proc that returns a registered struct now delivers it natively
instead of CBOR-encoding it. The FFI-thread handler builds the return's
`<T>Pod` mirror on the heap (`nimToPod`) and stashes it on the request; the
callback receives it as a typed `const <T>*` (msg = pointer, len = sizeof), and
handleRes deep-frees it the instant the callback returns — callback-lifetime
ownership, the caller frees nothing.

Mechanics: FFIThreadRequest gains respPod/respPodLen/respPodFree fields that
handleRes honors ahead of the byte payload; the macro emits a per-proc
cdecl freer (`freePod` + `ffiCFree`) for the response POD. String and
seq[byte] returns still travel as raw bytes; the CBOR path (`<name>_cbor`) is a
separate handler and is unchanged. The C header documents the new return shape.

Validated end-to-end from C (EchoResponse, ComplexResponse with nested
seq/option graphs) including under ASAN — no UAF or double-free.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 18:37:18 +02:00

230 lines
8.8 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.
respPod*: pointer
## Native typed response: when non-nil, the handler produced a heap C-POD
## struct (the `nimToPod` of a `{.ffi.}`-typed return) to hand to the
## callback *instead of* the `res` bytes. The callback receives it as its
## `msg` pointer (cast to `const <Type>*`) with `len = respPodLen`; it is
## valid only for the callback's lifetime — `handleRes` deep-frees it via
## `respPodFree` immediately after the callback returns.
respPodLen*: int
respPodFree*: PayloadFreeProc
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
ret[].respPod = nil
ret[].respPodLen = 0
ret[].respPodFree = 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
# Native typed return: deliver the heap C-POD to the callback, then deep-free
# it (caller frees nothing). Takes precedence over the byte payload, which the
# handler leaves empty in this case.
if not request[].respPod.isNil():
foreignThreadGc:
request[].callback(
RET_OK,
cast[ptr cchar](request[].respPod),
cast[csize_t](request[].respPodLen),
request[].userData,
)
if request[].respPodFree != nil:
request[].respPodFree(request[].respPod)
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)