feat(ffi): CBOR-free scalar fast path for all-scalar abi = c procs (#106)

This commit is contained in:
Gabriel Cruz 2026-07-06 11:57:46 -03:00 committed by GitHub
parent 8c1343aaf2
commit fb0a1f1077
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 655 additions and 82 deletions

View File

@ -31,6 +31,11 @@ type
returnIsPtr*: bool # true if return type is ptr T
returnIsHandle*: bool # true if return type is an {.ffiHandle.} type
abiFormat*: ABIFormat # wire format for this interaction (default Cbor)
scalarFastPath*: bool
## True for an `abi = c` proc whose whole signature is scalar (see
## `isScalarOnly`): it dispatches through the CBOR-free scalar fast path
## and is skipped by the foreign-binding generators (no dispatch codegen
## yet — the request rides inline POD args, no `_CWire`, no CBOR).
FFIFieldMeta* = object
name*: string # e.g. "delayMs"

View File

@ -20,17 +20,50 @@ const EmptyErrorMarker = "unknown error"
## 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
scalarArgs*: array[MaxScalarArgs, uint64]
## Scalar-fast-path args inlined in the envelope (one `ffiPackScalar` value
## per slot) 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 the
## `next` link and `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.
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 =
@ -118,6 +151,48 @@ proc initFromOwnedShared*(
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)
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 the
## callback carries — 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)`. Note: an empty string
## yields a 0-length payload, which `handleRes` sends as the CBOR-null
## sentinel — the foreign scalar reader (a follow-up) must special-case it.
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)

View File

@ -1,8 +1,10 @@
import std/[macros, tables, strutils]
import chronos
import ../ffi_types
import ../ffi_thread_request
import ../codegen/[meta, string_helpers]
import ./c_macro_helpers
import ./ffi_scalar
when defined(ffiGenBindings):
import ../codegen/rust
import ../codegen/cpp
@ -762,7 +764,6 @@ macro ffi*(args: varargs[untyped]): untyped =
return registerFFITypeInfo(cleanTypeDef, abiFormat)
requireLibraryDeclared("`.ffi.`")
gateABIFormat(abiFormat, "`.ffi.` proc")
let procName = prc[0]
let formalParams = prc[3]
@ -841,6 +842,97 @@ macro ffi*(args: varargs[untyped]): untyped =
let ctxType =
nnkPtrTy.newTree(nnkBracketExpr.newTree(ident("FFIContext"), libTypeName))
proc wireParamMeta(pname: string, ptype: NimNode): FFIParamMeta =
let isPointer = isPtr(ptype)
let handle = isHandleType(ptype)
let tn =
if isPointer:
nimTypeNameRepr(ptype[0])
else:
nimTypeNameRepr(ptype)
FFIParamMeta(name: pname, typeName: tn, isPtr: isPointer, isHandle: handle)
var wireParamMetas: seq[FFIParamMeta] = @[]
for i in 0 ..< extraParamNames.len:
wireParamMetas.add(wireParamMeta(extraParamNames[i], extraParamTypes[i]))
let retTypeInner = resultInner[1]
let retIsPtr = isPtr(retTypeInner)
let retIsHandle = isHandleType(retTypeInner)
let retTn =
if retIsPtr:
nimTypeNameRepr(retTypeInner[0])
else:
nimTypeNameRepr(retTypeInner)
# Built once, registered by whichever path runs (only `scalarFastPath` differs
# between them) and reused for the fast-path eligibility check below.
let procMeta = FFIProcMeta(
procName: cExportName,
libName: currentLibName,
kind: FFIKind.FFI,
libTypeName: $libTypeName,
extraParams: wireParamMetas,
returnTypeName: retTn,
returnIsPtr: retIsPtr,
returnIsHandle: retIsHandle,
abiFormat: abiFormat,
)
# Does this proc qualify for the CBOR-free scalar fast path? Only `abi = c`
# opts in, and only when every wire param + the return is a plain scalar
# (see `isScalarOnly`) and the args fit the inline slots. An `abi = c` proc
# that isn't scalar-only stays gated — full C-wire dispatch is a follow-up.
let scalarEligible =
abiFormat == ABIFormat.C and isScalarOnly(procMeta) and
extraParamNames.len <= MaxScalarArgs
let poolIdent = ident($libTypeName & "FFIPool")
proc buildCtxGuard(): NimNode =
## Nil-checks the callback and validates `ctx` against the lib's FFI pool,
## replying `RET_ERR` before any request is built. Shared by both wire paths.
quote:
if callback.isNil:
return RET_MISSING_CALLBACK
if not `poolIdent`.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
proc buildSendAndReply(reqPtrIdent: NimNode): NimNode =
## Hands `reqPtrIdent` to the FFI thread and maps the outcome to a C return
## code, reporting any enqueue failure through the callback. Shared by both
## wire paths.
let sendResIdent = genSym(nskLet, "sendRes")
quote:
let `sendResIdent` =
try:
ffi_context.sendRequestToFFIThread(ctx, `reqPtrIdent`)
except Exception as exc:
Result[void, string].err("sendRequestToFFIThread exception: " & exc.msg)
if `sendResIdent`.isErr():
let errStr = "error in sendRequestToFFIThread: " & `sendResIdent`.error
callback(RET_ERR, unsafeAddr errStr[0], cast[csize_t](errStr.len), userData)
return RET_ERR
return RET_OK
proc buildCExportProc(params: seq[NimNode], body: NimNode): NimNode =
## The dynlib/exportc/cdecl C-ABI wrapper both wire paths emit; only the
## params and body differ.
newProc(
name = postfix(cExportProcName, "*"),
params = params,
body = body,
pragmas = newTree(
nnkPragma,
ident("dynlib"),
newTree(nnkExprColonExpr, ident("exportc"), newStrLitNode(cExportName)),
ident("cdecl"),
newTree(nnkExprColonExpr, ident("raises"), newTree(nnkBracket)),
),
)
proc buildAsyncHelperProc(): NimNode =
## Reproduces the user's exact signature so it stays callable from Nim.
var helperParams = newSeq[NimNode]()
@ -904,17 +996,7 @@ macro ffi*(args: varargs[untyped]): untyped =
let exportedParams = cExportedParams(ctxType)
let ffiBody = newStmtList()
ffiBody.add quote do:
if callback.isNil:
return RET_MISSING_CALLBACK
let asyncPoolIdent = ident($libTypeName & "FFIPool")
ffiBody.add quote do:
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
ffiBody.add buildCtxGuard()
# Build the FFIThreadRequest payload directly from the incoming bytes.
let reqPtrIdent = genSym(nskLet, "reqPtr")
@ -923,74 +1005,42 @@ macro ffi*(args: varargs[untyped]): untyped =
let `reqPtrIdent` = FFIThreadRequest.initFromPtr(
callback, userData, typeStr.cstring, reqCbor, int(reqCborLen)
)
ffiBody.add buildSendAndReply(reqPtrIdent)
let sendResIdent = genSym(nskLet, "sendRes")
ffiBody.add quote do:
let `sendResIdent` =
try:
ffi_context.sendRequestToFFIThread(ctx, `reqPtrIdent`)
except Exception as exc:
Result[void, string].err("sendRequestToFFIThread exception: " & exc.msg)
if `sendResIdent`.isErr():
let errStr = "error in sendRequestToFFIThread: " & `sendResIdent`.error
callback(RET_ERR, unsafeAddr errStr[0], cast[csize_t](errStr.len), userData)
return RET_ERR
return RET_OK
let ffiProc = buildCExportProc(exportedParams, ffiBody)
let ffiProc = newProc(
name = postfix(cExportProcName, "*"),
params = exportedParams,
body = ffiBody,
pragmas = newTree(
nnkPragma,
ident("dynlib"),
newTree(nnkExprColonExpr, ident("exportc"), newStrLitNode(cExportName)),
ident("cdecl"),
newTree(nnkExprColonExpr, ident("raises"), newTree(nnkBracket)),
),
)
block:
var ffiExtraParams: seq[FFIParamMeta] = @[]
for i in 0 ..< extraParamNames.len:
let ptype = extraParamTypes[i]
let isPointer = isPtr(ptype)
let handle = isHandleType(ptype)
let tn =
if isPointer:
nimTypeNameRepr(ptype[0])
else:
nimTypeNameRepr(ptype)
ffiExtraParams.add(
FFIParamMeta(
name: extraParamNames[i], typeName: tn, isPtr: isPointer, isHandle: handle
)
)
let retTypeInner = resultInner[1]
let retIsPtr = isPtr(retTypeInner)
let retIsHandle = isHandleType(retTypeInner)
let retTn =
if retIsPtr:
nimTypeNameRepr(retTypeInner[0])
else:
nimTypeNameRepr(retTypeInner)
ffiProcRegistry.add(
FFIProcMeta(
procName: cExportName,
libName: currentLibName,
kind: FFIKind.FFI,
libTypeName: $libTypeName,
extraParams: ffiExtraParams,
returnTypeName: retTn,
returnIsPtr: retIsPtr,
returnIsHandle: retIsHandle,
abiFormat: abiFormat,
)
)
ffiProcRegistry.add(procMeta)
return newStmtList(helperProc, registerReq, ffiProc)
let stmts = asyncPath()
proc scalarPath(): NimNode =
## The scalar fast path lives in `ffi_scalar`; here we only build the shared
## dispatch pieces (same helpers the usual path uses) and hand them over, so
## the base macro carries none of the inline pack/unpack machinery.
let reqPtrIdent = genSym(nskLet, "reqPtr")
buildScalarPath(
helperProc = buildAsyncHelperProc(),
ctxGuard = buildCtxGuard(),
reqPtrIdent = reqPtrIdent,
sendAndReply = buildSendAndReply(reqPtrIdent),
userProcName = userProcName,
cExportProcName = cExportProcName,
cExportName = cExportName,
ctxType = ctxType,
camelName = camelName,
extraParamNames = extraParamNames,
extraParamTypes = extraParamTypes,
procMeta = procMeta,
)
if abiFormat == ABIFormat.C and not scalarEligible:
gateABIFormat(abiFormat, "`.ffi.` proc")
let stmts =
if scalarEligible:
scalarPath()
else:
asyncPath()
when defined(ffiDumpMacros):
echo stmts.repr
@ -1677,16 +1727,18 @@ macro genBindings*(
)
let lang = string_helpers.toLower(targetLang)
let libName = deriveLibName(ffiProcRegistry)
# Scalar-fast-path procs have no foreign-dispatch codegen yet (their C
# export doesn't take the CBOR `(reqCbor, reqCborLen)` shape); drop them so
# the generators don't emit a broken CBOR caller for them.
let genProcs = bindableProcs(ffiProcRegistry)
case lang
of "rust":
generateRustCrate(
ffiProcRegistry, ffiTypeRegistry, libName, outputDir, nimSrcRelPath,
ffiEventRegistry,
genProcs, ffiTypeRegistry, libName, outputDir, nimSrcRelPath, ffiEventRegistry
)
of "cpp", "c++":
generateCppBindings(
ffiProcRegistry, ffiTypeRegistry, libName, outputDir, nimSrcRelPath,
ffiEventRegistry,
genProcs, ffiTypeRegistry, libName, outputDir, nimSrcRelPath, ffiEventRegistry
)
of "c":
generateCBindings(
@ -1694,9 +1746,7 @@ macro genBindings*(
ffiEventRegistry,
)
of "cddl":
generateCddlBindings(
ffiProcRegistry, ffiTypeRegistry, libName, outputDir, nimSrcRelPath
)
generateCddlBindings(genProcs, ffiTypeRegistry, libName, outputDir, nimSrcRelPath)
else:
error(
"genBindings: unknown targetLang '" & lang &

172
ffi/internal/ffi_scalar.nim Normal file
View File

@ -0,0 +1,172 @@
## CBOR-free scalar fast path for all-scalar `{.ffi: "abi = c".}` methods.
##
## Kept out of the base `ffi` macro so the usual CBOR/async dispatch path in
## `ffi_macro.nim` stays simple: the macro only decides eligibility
## (`isScalarOnly`) and, when it applies, hands the whole codegen to
## `buildScalarPath`.
##
## A scalar proc's C export takes its scalar args directly (no
## `reqCbor`/`reqCborLen`), packs them inline into the request (no envelope
## `c_malloc`, no CBOR), and the FFI-thread handler unpacks them, runs the user
## body, and returns the result as raw bytes (`ffiScalarRetBytes`).
import std/macros
import ../codegen/meta
const scalarPodTypeNames = [
"int", "int8", "int16", "int32", "int64", "uint", "uint8", "uint16", "uint32",
"uint64", "byte", "float", "float32", "float64", "bool",
]
## Fixed-width POD scalars that fit a single `uint64` slot and survive the
## async hop by value — the payload the scalar fast path inlines into the
## request (no heap copy). `cstring`/`string` are intentionally absent as
## *params*: they are pointers to caller memory the FFI thread reads later,
## so they'd need a copy, defeating the zero-alloc promise.
func isScalarParamTypeName*(name: string): bool =
## A param type eligible for the CBOR-free scalar fast path.
name in scalarPodTypeNames
func isScalarReturnTypeName*(name: string): bool =
## A return type eligible for the scalar fast path. Unlike params, a
## `string`/`cstring` return is fine: the handler produces the bytes and they
## ride back raw (like the error path), so no caller memory is aliased.
name in scalarPodTypeNames or name == "string" or name == "cstring"
func isScalarOnly*(p: FFIProcMeta): bool =
## True iff `p` is a plain `{.ffi.}` method whose every wire param and return
## is scalar — the whole signature crosses without CBOR or `_CWire`. Handles
## and raw pointers are excluded (a handle needs a ctx-registry round-trip;
## a pointer never crosses). Pure over the compile-time metadata.
if p.kind != FFIKind.FFI:
return false
if p.returnIsPtr or p.returnIsHandle:
return false
if not isScalarReturnTypeName(p.returnTypeName):
return false
for ep in p.extraParams:
if ep.isPtr or ep.isHandle or not isScalarParamTypeName(ep.typeName):
return false
true
func bindableProcs*(procs: seq[FFIProcMeta]): seq[FFIProcMeta] =
## The procs the foreign-binding generators emit for. Scalar-fast-path procs
## are dropped: their C export takes inline scalar args, not the CBOR
## `(reqCbor, reqCborLen)` shape the current codegen assumes, so emitting a
## CBOR caller for them would be wrong. Foreign codegen is a follow-up.
var kept: seq[FFIProcMeta] = @[]
for p in procs:
if not p.scalarFastPath:
kept.add(p)
kept
proc buildScalarPath*(
helperProc, ctxGuard, reqPtrIdent, sendAndReply: NimNode,
userProcName, cExportProcName: NimNode,
cExportName: string,
ctxType: NimNode,
camelName: string,
extraParamNames: seq[string],
extraParamTypes: seq[NimNode],
procMeta: FFIProcMeta,
): NimNode {.compileTime.} =
## Emits the scalar-fast-path codegen for one `.ffi.` proc. The generic
## dispatch pieces (`helperProc`, `ctxGuard`, `sendAndReply`) are built by the
## caller from the same shared helpers the usual path uses, so this only owns
## the scalar-specific inline pack / unpack / raw-bytes wiring.
let scalarReqKey = camelName & "Req"
let reqIdent = genSym(nskLet, "ffiReq")
let ctxHandlerName = genSym(nskLet, "ffiCtxHandler")
let handlerBody = newStmtList()
handlerBody.add quote do:
let `reqIdent` = cast[ptr FFIThreadRequest](request)
let `ctxHandlerName` = cast[`ctxType`](reqHandler)
let helperCall = newTree(nnkCall, userProcName)
let ctxMyLib = newDotExpr(newTree(nnkDerefExpr, ctxHandlerName), ident("myLib"))
helperCall.add(newTree(nnkDerefExpr, ctxMyLib))
for i in 0 ..< extraParamNames.len:
let argIdent = ident(extraParamNames[i])
let slot = nnkBracketExpr.newTree(
newDotExpr(newTree(nnkDerefExpr, reqIdent), ident("scalarArgs")), newLit(i)
)
handlerBody.add(
newLetStmt(argIdent, newCall(ident("ffiUnpackScalar"), slot, extraParamTypes[i]))
)
helperCall.add(argIdent)
let retValIdent = genSym(nskLet, "retVal")
handlerBody.add quote do:
let `retValIdent` = (await `helperCall`).valueOr:
return err($error)
return ok(ffiScalarRetBytes(`retValIdent`))
let seqByteResult = nnkBracketExpr.newTree(
ident("Future"),
nnkBracketExpr.newTree(
ident("Result"),
nnkBracketExpr.newTree(ident("seq"), ident("byte")),
ident("string"),
),
)
let handlerProc = newProc(
name = newEmptyNode(),
params = @[
seqByteResult,
newIdentDefs(ident("request"), ident("pointer")),
newIdentDefs(ident("reqHandler"), ident("pointer")),
],
body = handlerBody,
pragmas = nnkPragma.newTree(ident("async")),
)
let registerAssign = newAssignment(
nnkBracketExpr.newTree(ident("registeredRequests"), newLit(scalarReqKey)),
handlerProc,
)
var scalarParams = @[
ident("cint"),
newIdentDefs(ident("ctx"), ctxType),
newIdentDefs(ident("callback"), ident("FFICallBack")),
newIdentDefs(ident("userData"), ident("pointer")),
]
for i in 0 ..< extraParamNames.len:
scalarParams.add(newIdentDefs(ident(extraParamNames[i]), extraParamTypes[i]))
let ffiBody = newStmtList()
ffiBody.add ctxGuard
let initScalarCall = newTree(
nnkCall,
newDotExpr(ident("FFIThreadRequest"), ident("initScalar")),
ident("callback"),
ident("userData"),
newDotExpr(newLit(scalarReqKey), ident("cstring")),
)
for i in 0 ..< extraParamNames.len:
initScalarCall.add(newCall(ident("ffiPackScalar"), ident(extraParamNames[i])))
ffiBody.add newLetStmt(reqPtrIdent, initScalarCall)
ffiBody.add sendAndReply
let ffiProc = newProc(
name = postfix(cExportProcName, "*"),
params = scalarParams,
body = ffiBody,
pragmas = newTree(
nnkPragma,
ident("dynlib"),
newTree(nnkExprColonExpr, ident("exportc"), newStrLitNode(cExportName)),
ident("cdecl"),
newTree(nnkExprColonExpr, ident("raises"), newTree(nnkBracket)),
),
)
# Registered (not just skipped) so the compile-time metadata stays
# introspectable; `bindableProcs` drops it from foreign codegen.
var scalarMeta = procMeta
scalarMeta.scalarFastPath = true
ffiProcRegistry.add(scalarMeta)
newStmtList(helperProc, registerAssign, ffiProc)

View File

@ -0,0 +1,271 @@
## Exercises the CBOR-free scalar fast path (`{.ffi: "abi = c".}` on an
## all-scalar signature). A scalar proc's C export takes its scalar args
## directly (no `reqCbor`/`reqCborLen`), packs them inline into the request,
## and the response rides back as raw bytes — no CBOR envelope either way.
##
## Two angles are covered: the C-export shape (ctx, callback, userData, args…)
## driven through a real FFI thread, and the Nim-native shape (the user proc
## name still resolves to its declared `Future[Result[T, string]]`).
import std/[locks, strutils, sequtils]
import unittest2
import results
import ffi
import ffi/codegen/meta
import ffi/internal/ffi_scalar
type ScalarLib = object
base: int
# Stub the dylib NimMain importc that declareLibrary emits (this links as an exe).
{.emit: "void libscalarfastNimMain(void) {}".}
declareLibrary("scalarfast", ScalarLib)
type ScalarConfig {.ffi.} = object
base: int
proc scalarfast_create*(
cfg: ScalarConfig
): Future[Result[ScalarLib, string]] {.ffiCtor.} =
return ok(ScalarLib(base: cfg.base))
proc scalarfast_add*(
lib: ScalarLib, a: int, b: int
): Future[Result[int, string]] {.ffi: "abi = c".} =
## Two scalar params, scalar return — the flagship fast-path shape.
return ok(lib.base + a + b)
proc scalarfast_version*(
lib: ScalarLib
): Future[Result[string, string]] {.ffi: "abi = c".} =
## No params, string return (rides back as raw UTF-8, no CBOR).
return ok("scalarfast v1")
proc scalarfast_scale*(
lib: ScalarLib, factor: float
): Future[Result[float, string]] {.ffi: "abi = c".} =
## Float round-trip through the uint64 slot.
return ok(factor * 2.0)
proc scalarfast_positive*(
lib: ScalarLib, n: int
): Future[Result[bool, string]] {.ffi: "abi = c".} =
return ok(n > 0)
proc scalarfast_checked*(
lib: ScalarLib, n: int
): Future[Result[int, string]] {.ffi: "abi = c".} =
## Error path — a scalar proc that can fail surfaces Result.err.
if n < 0:
return err("negative not allowed")
return ok(n * 2)
## --- C-shape callback harness (mirrors test_ffi_context.nim) ----------------
type CallbackData = object
lock: Lock
cond: Cond
called: bool
retCode: cint
msg: array[1024, byte]
msgLen: int
proc initCallbackData(d: var CallbackData) =
d.lock.initLock()
d.cond.initCond()
proc deinitCallbackData(d: var CallbackData) =
d.cond.deinitCond()
d.lock.deinitLock()
proc testCallback(
retCode: cint, msg: ptr cchar, len: csize_t, userData: pointer
) {.cdecl, gcsafe, raises: [].} =
let d = cast[ptr CallbackData](userData)
acquire(d[].lock)
d[].retCode = retCode
let n = min(int(len), d[].msg.len)
if n > 0 and not msg.isNil:
copyMem(addr d[].msg[0], msg, n)
d[].msgLen = n
d[].called = true
signal(d[].cond)
release(d[].lock)
proc waitCallback(d: var CallbackData) =
acquire(d.lock)
while not d.called:
wait(d.cond, d.lock)
release(d.lock)
proc scalarU64(d: CallbackData): uint64 =
## Reads the 8-byte native-endian POD image the fast path returns.
doAssert d.msgLen == sizeof(uint64), "expected 8 scalar bytes, got " & $d.msgLen
var u: uint64
copyMem(addr u, unsafeAddr d.msg[0], sizeof(uint64))
u
proc scalarInt(d: CallbackData): int =
int(cast[int64](scalarU64(d)))
proc scalarFloat(d: CallbackData): float =
cast[float64](scalarU64(d))
proc scalarBool(d: CallbackData): bool =
scalarU64(d) != 0'u64
proc scalarStr(d: CallbackData): string =
var s = newString(d.msgLen)
if d.msgLen > 0:
copyMem(addr s[0], unsafeAddr d.msg[0], d.msgLen)
s
proc callbackErr(d: CallbackData): string =
var msg = newString(d.msgLen)
if d.msgLen > 0:
copyMem(addr msg[0], unsafeAddr d.msg[0], d.msgLen)
msg
proc encodedPtr(bytes: var seq[byte]): ptr byte =
if bytes.len == 0:
nil
else:
cast[ptr byte](addr bytes[0])
proc callbackBytesOf(d: CallbackData): seq[byte] =
var bytes = newSeq[byte](d.msgLen)
if d.msgLen > 0:
copyMem(addr bytes[0], unsafeAddr d.msg[0], d.msgLen)
bytes
proc makeCtx(base: int): ptr FFIContext[ScalarLib] =
var d: CallbackData
initCallbackData(d)
defer:
deinitCallbackData(d)
var cfg = cborEncode(ScalarfastCreateCtorReq(cfg: ScalarConfig(base: base)))
let ret = scalarfast_create(encodedPtr(cfg), cfg.len.csize_t, testCallback, addr d)
doAssert not ret.isNil()
waitCallback(d)
doAssert d.retCode == RET_OK
let addrStr = cborDecode(callbackBytesOf(d), string).value
cast[ptr FFIContext[ScalarLib]](cast[uint](parseBiggestUInt(addrStr)))
suite "scalar fast path — C export shape":
test "two int params + int return, threads through the base state":
let ctx = makeCtx(100)
defer:
check ScalarLibFFIPool.destroyFFIContext(ctx).isOk()
var d: CallbackData
initCallbackData(d)
defer:
deinitCallbackData(d)
check scalarfast_add(ctx, testCallback, addr d, 20, 3) == RET_OK
waitCallback(d)
check d.retCode == RET_OK
check scalarInt(d) == 123
test "no params, string return rides back as raw UTF-8":
let ctx = makeCtx(0)
defer:
check ScalarLibFFIPool.destroyFFIContext(ctx).isOk()
var d: CallbackData
initCallbackData(d)
defer:
deinitCallbackData(d)
check scalarfast_version(ctx, testCallback, addr d) == RET_OK
waitCallback(d)
check d.retCode == RET_OK
check scalarStr(d) == "scalarfast v1"
test "float param round-trips through the uint64 slot":
let ctx = makeCtx(0)
defer:
check ScalarLibFFIPool.destroyFFIContext(ctx).isOk()
var d: CallbackData
initCallbackData(d)
defer:
deinitCallbackData(d)
check scalarfast_scale(ctx, testCallback, addr d, 2.5) == RET_OK
waitCallback(d)
check d.retCode == RET_OK
check scalarFloat(d) == 5.0
test "bool return":
let ctx = makeCtx(0)
defer:
check ScalarLibFFIPool.destroyFFIContext(ctx).isOk()
var d: CallbackData
initCallbackData(d)
defer:
deinitCallbackData(d)
check scalarfast_positive(ctx, testCallback, addr d, -4) == RET_OK
waitCallback(d)
check d.retCode == RET_OK
check scalarBool(d) == false
test "error result surfaces as RET_ERR with a raw UTF-8 message":
let ctx = makeCtx(0)
defer:
check ScalarLibFFIPool.destroyFFIContext(ctx).isOk()
var d: CallbackData
initCallbackData(d)
defer:
deinitCallbackData(d)
check scalarfast_checked(ctx, testCallback, addr d, -1) == RET_OK
waitCallback(d)
check d.retCode == RET_ERR
check callbackErr(d) == "negative not allowed"
test "invalid ctx is rejected before enqueue":
var d: CallbackData
initCallbackData(d)
defer:
deinitCallbackData(d)
let bogus = cast[ptr FFIContext[ScalarLib]](nil)
check scalarfast_add(bogus, testCallback, addr d, 1, 2) == RET_ERR
suite "scalar fast path — Nim-native shape":
test "user proc name keeps its Future[Result[T, string]] signature":
let add = waitFor scalarfast_add(ScalarLib(base: 1), 2, 3)
check add.isOk()
check add.value == 6
let ver = waitFor scalarfast_version(ScalarLib(base: 0))
check ver.isOk()
check ver.value == "scalarfast v1"
let bad = waitFor scalarfast_checked(ScalarLib(base: 0), -5)
check bad.isErr()
check bad.error == "negative not allowed"
# `ffiProcRegistry` is a compile-time var, so its assertions run in a static
# block (mirrors test_abi_format.nim). A scalar-only `abi = c` proc must be
# flagged, recognised by `isScalarOnly`, and dropped from `bindableProcs`.
static:
const scalarNames = [
"scalarfast_add", "scalarfast_version", "scalarfast_scale", "scalarfast_positive",
"scalarfast_checked",
]
var seen = 0
for p in ffiProcRegistry:
if p.procName in scalarNames:
doAssert p.scalarFastPath, p.procName & " should be scalarFastPath"
doAssert p.abiFormat == ABIFormat.C
doAssert isScalarOnly(p), p.procName & " should be isScalarOnly"
inc seen
doAssert seen == scalarNames.len, "saw " & $seen & " scalar procs"
# The ctor stays on the CBOR path and remains bindable.
doAssert bindableProcs(ffiProcRegistry).anyIt(it.procName == "scalarfast_create")
doAssert not bindableProcs(ffiProcRegistry).anyIt(it.scalarFastPath)