diff --git a/ffi/codegen/meta.nim b/ffi/codegen/meta.nim index d62b14b..eb60784 100644 --- a/ffi/codegen/meta.nim +++ b/ffi/codegen/meta.nim @@ -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" diff --git a/ffi/ffi_thread_request.nim b/ffi/ffi_thread_request.nim index b6b8daf..4cbdc8d 100644 --- a/ffi/ffi_thread_request.nim +++ b/ffi/ffi_thread_request.nim @@ -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) diff --git a/ffi/internal/ffi_macro.nim b/ffi/internal/ffi_macro.nim index 0d3b133..da7defd 100644 --- a/ffi/internal/ffi_macro.nim +++ b/ffi/internal/ffi_macro.nim @@ -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 & diff --git a/ffi/internal/ffi_scalar.nim b/ffi/internal/ffi_scalar.nim new file mode 100644 index 0000000..a5cded5 --- /dev/null +++ b/ffi/internal/ffi_scalar.nim @@ -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) diff --git a/tests/unit/test_scalar_fastpath.nim b/tests/unit/test_scalar_fastpath.nim new file mode 100644 index 0000000..750054b --- /dev/null +++ b/tests/unit/test_scalar_fastpath.nim @@ -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)