mirror of
https://github.com/logos-messaging/nim-ffi.git
synced 2026-07-17 05:19:36 +00:00
263 lines
10 KiB
Nim
263 lines
10 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.
|
|
|
|
const MaxScalarArgs* = 8
|
|
## Inline capacity for the scalar fast path. A `.ffi.` method with more than
|
|
## this many scalar params can't use the fast path (checked at compile time).
|
|
|
|
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.
|
|
dataLen*: int
|
|
isScalar*: bool
|
|
## Set by `initScalar`: the payload rode inline in `scalarArgs` (no CBOR,
|
|
## no `data` buffer). Lets `handleRes` tell a scalar 0-length return (a real
|
|
## empty string) from a CBOR "no value".
|
|
scalarArgs*: array[MaxScalarArgs, uint64]
|
|
## Scalar-fast-path args inlined in the envelope so there's no per-call
|
|
## `c_malloc`. A plain array rather than a `union` with `data`: costs a fixed
|
|
## 64 bytes per request but keeps `deleteRequest` unaliased and branch-free.
|
|
next*: ptr FFIThreadRequest
|
|
## Intrusive ingress-queue link (see `ffi_request_queue.nim`). Touched only
|
|
## under the queue's lock; the request doubles as its own node, so no
|
|
## separate node alloc lands on the per-thread ORC MemRegion.
|
|
responded*: bool
|
|
## De-duplicates the callback across the timeout and completion paths. Both
|
|
## run on the FFI thread, so a plain flag suffices — no cross-thread race.
|
|
|
|
func ffiPackScalar*[T](x: T): uint64 =
|
|
## Bit-cast one scalar into a `uint64` request slot. Signed ints sign-extend
|
|
## to 64 bits; `float32` widens to `float64` (exactly representable, so the
|
|
## value round-trips); `bool` becomes 0/1. Reverse with `ffiUnpackScalar`.
|
|
when T is SomeFloat:
|
|
cast[uint64](float64(x))
|
|
elif T is bool:
|
|
uint64(ord(x))
|
|
elif T is SomeSignedInt:
|
|
cast[uint64](int64(x))
|
|
else:
|
|
uint64(x)
|
|
|
|
func ffiUnpackScalar*[T](u: uint64, _: typedesc[T]): T =
|
|
## Inverse of `ffiPackScalar`: reinterpret a request slot back into `T`.
|
|
when T is SomeFloat:
|
|
T(cast[float64](u))
|
|
elif T is bool:
|
|
u != 0'u64
|
|
elif T is SomeSignedInt:
|
|
T(cast[int64](u))
|
|
else:
|
|
T(u)
|
|
|
|
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[].isScalar = false
|
|
ret[].next = nil
|
|
ret[].responded = false
|
|
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 initScalar*(
|
|
T: typedesc[FFIThreadRequest],
|
|
callback: FFICallBack,
|
|
userData: pointer,
|
|
reqId: cstring,
|
|
args: varargs[uint64],
|
|
): ptr type T =
|
|
## Builds a scalar-fast-path request: the packed scalar args ride inline in
|
|
## `scalarArgs` with no payload `c_malloc`. `args` come from `ffiPackScalar`.
|
|
## Only the routing `reqId` cstring is heap-allocated, same as the CBOR path.
|
|
doAssert args.len <= MaxScalarArgs,
|
|
"initScalar: " & $args.len & " scalar args exceed MaxScalarArgs (" & $MaxScalarArgs &
|
|
")"
|
|
var ret = allocBaseRequest(callback, userData, reqId)
|
|
ret[].isScalar = true
|
|
for i in 0 ..< args.len:
|
|
ret[].scalarArgs[i] = args[i]
|
|
ret
|
|
|
|
func ffiScalarRetBytes*[T](x: T): seq[byte] =
|
|
## Serializes a scalar handler result into the raw response payload — no CBOR
|
|
## envelope. A `string`/`cstring` rides as its own UTF-8 bytes (like the error
|
|
## path); every other scalar rides as the 8-byte native-endian image of
|
|
## `ffiPackScalar(x)`. An empty string yields a 0-length payload (see
|
|
## `handleRes`, which delivers it as `""` rather than the CBOR-null sentinel).
|
|
when T is string:
|
|
var b = newSeq[byte](x.len)
|
|
if x.len > 0:
|
|
copyMem(addr b[0], unsafeAddr x[0], x.len)
|
|
b
|
|
elif T is cstring:
|
|
let n = x.len
|
|
var b = newSeq[byte](n)
|
|
if n > 0:
|
|
copyMem(addr b[0], cast[pointer](x), n)
|
|
b
|
|
else:
|
|
let u = ffiPackScalar(x)
|
|
var b = newSeq[byte](sizeof(uint64))
|
|
copyMem(addr b[0], unsafeAddr u, sizeof(uint64))
|
|
b
|
|
|
|
proc deleteRequest*(request: ptr FFIThreadRequest) =
|
|
if not request[].data.isNil:
|
|
c_free(request[].data)
|
|
if not request[].reqId.isNil:
|
|
c_free(cast[pointer](request[].reqId))
|
|
c_free(request)
|
|
|
|
proc fireCallback*(res: Result[seq[byte], string], request: ptr FFIThreadRequest) =
|
|
## Delivers the response to the foreign callback, at most once per request:
|
|
## the timeout path and the handler-completion path both call it, but the
|
|
## foreign side must be answered exactly once. Both run on the FFI thread, so
|
|
## the plain `responded` flag needs no synchronization. Success payload is the
|
|
## encoded response bytes; error payload is the raw UTF-8 error string. Does
|
|
## NOT free the request; that stays with `handleRes`.
|
|
if request[].responded:
|
|
return
|
|
request[].responded = true
|
|
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[].isScalar:
|
|
# `isScalar` marks a scalar-fast-path request (args rode inline in
|
|
# `scalarArgs`, no CBOR): its result bytes come from `ffiScalarRetBytes`,
|
|
# not a CBOR encoder. So a 0-byte return is a real empty string, not
|
|
# "no value" — hand back a genuine empty buffer, not the CBOR-null sentinel.
|
|
var empty: byte
|
|
request[].callback(
|
|
RET_OK, cast[ptr cchar](addr empty), 0.csize_t, request[].userData
|
|
)
|
|
else:
|
|
# Always 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
|
|
)
|
|
|
|
proc handleRes*(res: Result[seq[byte], string], request: ptr FFIThreadRequest) =
|
|
## Terminal step of every request: delivers the response (unless a timeout
|
|
## already did) and frees the request exactly once.
|
|
defer:
|
|
deleteRequest(request)
|
|
fireCallback(res, request)
|
|
|
|
proc nilProcess*(reqId: cstring): Future[Result[seq[byte], string]] {.async.} =
|
|
return err("This request type is not implemented: " & $reqId)
|