From 2947813df410d1cdade6562b6be5a51546aefbe1 Mon Sep 17 00:00:00 2001 From: Gabriel Cruz Date: Fri, 12 Jun 2026 12:04:20 -0300 Subject: [PATCH] feat: ffiHandle (cherry picked from commit 498416b5ea06e2bb130d6feedd53a28aa96a476a) --- ffi.nim | 8 +- ffi/codegen/cddl.nim | 4 +- ffi/codegen/cpp.nim | 10 +- ffi/codegen/meta.nim | 20 ++++ ffi/codegen/rust.nim | 9 +- ffi/ffi_context.nim | 13 ++- ffi/ffi_handles.nim | 55 +++++++++++ ffi/ffi_thread.nim | 2 + ffi/internal/ffi_library.nim | 8 ++ ffi/internal/ffi_macro.nim | 127 +++++++++++++++++++++---- tests/unit/test_ffi_handle.nim | 164 +++++++++++++++++++++++++++++++++ 11 files changed, 385 insertions(+), 35 deletions(-) create mode 100644 ffi/ffi_handles.nim create mode 100644 tests/unit/test_ffi_handle.nim diff --git a/ffi.nim b/ffi.nim index f399e3b..20f766c 100644 --- a/ffi.nim +++ b/ffi.nim @@ -3,12 +3,12 @@ import chronos, chronicles import ffi/internal/[ffi_library, ffi_macro], ffi/[ - alloc, ffi_types, ffi_events, ffi_context, ffi_context_pool, ffi_thread_request, - cbor_serial, + alloc, ffi_types, ffi_events, ffi_handles, ffi_context, ffi_context_pool, + ffi_thread_request, cbor_serial, ] export atomics, tables export chronos, chronicles export - atomics, alloc, ffi_library, ffi_macro, ffi_types, ffi_events, ffi_context, - ffi_context_pool, ffi_thread_request, cbor_serial + atomics, alloc, ffi_library, ffi_macro, ffi_types, ffi_events, ffi_handles, + ffi_context, ffi_context_pool, ffi_thread_request, cbor_serial diff --git a/ffi/codegen/cddl.nim b/ffi/codegen/cddl.nim index d583a1e..dece15e 100644 --- a/ffi/codegen/cddl.nim +++ b/ffi/codegen/cddl.nim @@ -92,7 +92,7 @@ proc emitObjectFields(t: FFITypeMeta): string = proc emitReqFields(p: FFIProcMeta): string = var fields: seq[tuple[name: string, typeName: string, isPtr: bool]] = @[] for ep in p.extraParams: - fields.add((name: ep.name, typeName: ep.typeName, isPtr: ep.isPtr)) + fields.add((name: ep.name, typeName: ep.typeName, isPtr: ep.ridesAsPtr())) emitMap(fields) proc responseRule(p: FFIProcMeta): string = @@ -106,7 +106,7 @@ proc responseRule(p: FFIProcMeta): string = # The dtor has no meaningful payload — handleRes sends a CBOR null sentinel. "nil" of FFIKind.FFI: - if p.returnIsPtr: + if p.returnRidesAsPtr(): "uint" else: nimTypeToCddl(p.returnTypeName) diff --git a/ffi/codegen/cpp.nim b/ffi/codegen/cpp.nim index 0ae4bff..d0454c2 100644 --- a/ffi/codegen/cpp.nim +++ b/ffi/codegen/cpp.nim @@ -299,7 +299,7 @@ proc generateCppHeader*( lines.add("struct $1 {" % [reqName]) for ep in p.extraParams: let cppType = - if ep.isPtr: + if ep.ridesAsPtr(): CppPtrType else: nimTypeToCpp(ep.typeName) @@ -308,7 +308,7 @@ proc generateCppHeader*( var fields: seq[(string, string)] = @[] for ep in p.extraParams: let cppType = - if ep.isPtr: + if ep.ridesAsPtr(): CppPtrType else: nimTypeToCpp(ep.typeName) @@ -388,7 +388,7 @@ proc generateCppHeader*( var epNames: seq[string] = @[] for ep in ctor.extraParams: let cppType = - if ep.isPtr: + if ep.ridesAsPtr(): CppPtrType else: nimTypeToCpp(ep.typeName) @@ -492,7 +492,7 @@ proc generateCppHeader*( for m in methods: let methodName = stripLibPrefixCpp(m.procName, libName) let retCppType = - if m.returnIsPtr: + if m.returnRidesAsPtr(): CppPtrType else: nimTypeToCpp(m.returnTypeName) @@ -502,7 +502,7 @@ proc generateCppHeader*( var methParamNames: seq[string] = @[] for ep in m.extraParams: let cppType = - if ep.isPtr: + if ep.ridesAsPtr(): CppPtrType else: nimTypeToCpp(ep.typeName) diff --git a/ffi/codegen/meta.nim b/ffi/codegen/meta.nim index 230919a..c3844e6 100644 --- a/ffi/codegen/meta.nim +++ b/ffi/codegen/meta.nim @@ -6,6 +6,7 @@ type name*: string # Nim param name, e.g. "req" typeName*: string # Nim type name, e.g. "EchoRequest" isPtr*: bool # true if the type is `ptr T` + isHandle*: bool # true if the type is an {.ffiHandle.} type (wire form uint64) FFIKind* {.pure.} = enum FFI @@ -20,6 +21,7 @@ type extraParams*: seq[FFIParamMeta] # all params except the lib param returnTypeName*: string # e.g. "EchoResponse", "string", "pointer" returnIsPtr*: bool # true if return type is ptr T + returnIsHandle*: bool # true if return type is an {.ffiHandle.} type FFIFieldMeta* = object name*: string # e.g. "delayMs" @@ -46,6 +48,24 @@ var ffiTypeRegistry* {.compileTime.}: seq[FFITypeMeta] var ffiEventRegistry* {.compileTime.}: seq[FFIEventMeta] var currentLibName* {.compileTime.}: string +# Lib type name (set by declareLibrary) so handle-receiver procs resolve the pool. +var currentLibType* {.compileTime.}: string + +# Names of types marked `{.ffiHandle.}` (wire form uint64). +var ffiHandleTypeNames* {.compileTime.}: seq[string] + +proc isFFIHandleTypeName*(name: string): bool {.compileTime.} = + name in ffiHandleTypeNames + +proc ridesAsPtr*(ep: FFIParamMeta): bool = + ## True if the param crosses the wire as an opaque uint64 — a raw `ptr` or an + ## `{.ffiHandle.}` id. Both share the codegen pointer type. + ep.isPtr or ep.isHandle + +proc returnRidesAsPtr*(p: FFIProcMeta): bool = + ## True if the return crosses the wire as an opaque uint64 (raw `ptr` or handle). + p.returnIsPtr or p.returnIsHandle + # Target language for binding generation; override with -d:targetLang=cpp const targetLang* {.strdefine.} = "rust" diff --git a/ffi/codegen/rust.nim b/ffi/codegen/rust.nim index a2acb17..c28efc5 100644 --- a/ffi/codegen/rust.nim +++ b/ffi/codegen/rust.nim @@ -256,7 +256,7 @@ proc generateTypesRs*(types: seq[FFITypeMeta], procs: seq[FFIProcMeta]): string for ep in p.extraParams: let snake = camelToSnakeCase(ep.name) let rustType = - if ep.isPtr: + if ep.ridesAsPtr(): RustPtrType else: nimTypeToRust(ep.typeName) @@ -550,7 +550,7 @@ proc generateApiRs*( for ep in ctor.extraParams: let snake = camelToSnakeCase(ep.name) let rustType = - if ep.isPtr: + if ep.ridesAsPtr(): RustPtrType else: nimTypeToRust(ep.typeName) @@ -711,7 +711,7 @@ proc generateApiRs*( for ep in m.extraParams: let snake = camelToSnakeCase(ep.name) let rustType = - if ep.isPtr: + if ep.ridesAsPtr(): RustPtrType else: nimTypeToRust(ep.typeName) @@ -729,7 +729,8 @@ proc generateApiRs*( else: reqName & " {}" - let retTypeForApi = if m.returnIsPtr: RustPtrType else: retRustType + let retTypeForApi = + if m.returnRidesAsPtr(): RustPtrType else: retRustType # -- blocking method -- lines.add( diff --git a/ffi/ffi_context.nim b/ffi/ffi_context.nim index 3fa50cf..ef1b6f5 100644 --- a/ffi/ffi_context.nim +++ b/ffi/ffi_context.nim @@ -8,9 +8,15 @@ import std/[atomics, locks, options, tables] import chronicles, chronos, chronos/threadsync, taskpools/channels_spsc_single, results -import ./ffi_types, ./ffi_events, ./ffi_thread_request, ./logging, ./cbor_serial +import + ./ffi_types, + ./ffi_events, + ./ffi_handles, + ./ffi_thread_request, + ./logging, + ./cbor_serial -export ffi_events +export ffi_events, ffi_handles type FFIContext*[T] = object myLib*: ptr T # main library object (Waku, LibP2P, SDS, …) @@ -27,6 +33,7 @@ type FFIContext*[T] = object eventThreadExitSignal: ThreadSignalPtr # mirrors threadExitSignal for the event thread userData*: pointer eventRegistry*: FFIEventRegistry + handles*: FFIHandleRegistry # live {.ffiHandle.} objects, keyed by uint64 id eventQueue*: EventQueue ffiHeartbeat*: Atomic[int64] # advanced each FFI-thread loop; event thread reads for liveness @@ -57,6 +64,7 @@ proc deinitContextResources*[T](ctx: ptr FFIContext[T]): Result[void, string] = ## fields are nil'd after close so re-init on the same slot is safe. ctx.lock.deinitLock() deinitEventRegistry(ctx[].eventRegistry) + deinitHandleRegistry(ctx[].handles) deinitEventQueue(ctx[].eventQueue) when defined(gcRefc): # ThreadSignalPtr.close() under refc traps in safeUnregisterAndCloseFd @@ -95,6 +103,7 @@ proc initContextResources*[T](ctx: ptr FFIContext[T]): Result[void, string] = ctx.eventThreadExitSignal = nil ctx.lock.initLock() initEventRegistry(ctx[].eventRegistry) + initHandleRegistry(ctx[].handles) initEventQueue(ctx[].eventQueue) ctx.ffiHeartbeat.store(0) ctx.eventQueueStuck.store(false) diff --git a/ffi/ffi_handles.nim b/ffi/ffi_handles.nim new file mode 100644 index 0000000..c3e94e1 --- /dev/null +++ b/ffi/ffi_handles.nim @@ -0,0 +1,55 @@ +## Per-context registry of live `{.ffiHandle.}` objects. The object stays here, +## in `FFIContext.handles`; only its `uint64` id crosses the boundary. Ids are +## monotonic and never recycled (0 = null), so a stale/forged id misses cleanly. +## FFI-thread-only access, so no locking. + +import std/tables + +type + FFIHandleRoot* = ref object of RootObj + ## Base every `{.ffiHandle.}` type inherits from, so handle refs are storable + ## under one static type. + + FFIHandleEntry = object + obj: FFIHandleRoot + typeName: string + + FFIHandleRegistry* = object + nextId*: uint64 + byHandle*: Table[uint64, FFIHandleEntry] + +proc initHandleRegistry*(reg: var FFIHandleRegistry) = + reg.nextId = 0'u64 + reg.byHandle = initTable[uint64, FFIHandleEntry]() + +proc deinitHandleRegistry*(reg: var FFIHandleRegistry) = + reg.byHandle = default(Table[uint64, FFIHandleEntry]) + reg.nextId = 0'u64 + +proc register*( + reg: var FFIHandleRegistry, obj: FFIHandleRoot, typeName: string +): uint64 = + ## Stores `obj`, returns its fresh handle id (>0). + reg.nextId.inc() + reg.byHandle[reg.nextId] = FFIHandleEntry(obj: obj, typeName: typeName) + reg.nextId + +proc lookup*( + reg: var FFIHandleRegistry, handle: uint64, typeName: string +): FFIHandleRoot = + ## Live ref for `handle`, or nil if absent or registered under another type. + let entry = reg.byHandle.getOrDefault(handle) + if entry.obj.isNil() or entry.typeName != typeName: + return nil + entry.obj + +proc release*(reg: var FFIHandleRegistry, handle: uint64): bool {.discardable.} = + ## Drops the entry; true iff it existed. + if not reg.byHandle.hasKey(handle): + return false + reg.byHandle.del(handle) + true + +proc releaseAll*(reg: var FFIHandleRegistry) = + ## Drops every entry. Must run on the FFI thread that allocated the refs. + reg.byHandle.clear() diff --git a/ffi/ffi_thread.nim b/ffi/ffi_thread.nim index 9685a45..ae5b6d8 100644 --- a/ffi/ffi_thread.nim +++ b/ffi/ffi_thread.nim @@ -101,6 +101,8 @@ proc ffiThreadBody[T](ctx: ptr FFIContext[T]) {.thread.} = defer: onFFIThread = false + # Free handle refs on the FFI thread that allocated them (refc heap is thread-local). + ctx[].handles.releaseAll() # Unblocks destroyFFIContext's bounded wait so cleanup can proceed. let fireRes = ctx.threadExitSignal.fireSync() if fireRes.isErr(): diff --git a/ffi/internal/ffi_library.nim b/ffi/internal/ffi_library.nim index ed04d9e..e0e00e8 100644 --- a/ffi/internal/ffi_library.nim +++ b/ffi/internal/ffi_library.nim @@ -121,11 +121,19 @@ macro declareLibrary*(libraryName: static[string], libType: untyped): untyped = ## `libType` is the Nim type of the main library object, used to type ## the `ctx: ptr FFIContext[libType]` parameter. See ## `examples/timer/timer.nim` for a working call site. + currentLibType = $libType # so handle-receiver `.ffi.` procs can resolve the pool + var stmts = newStmtList() # Emit the base bootstrap (pragmas, linker flags, NimMain, initializeLibrary) stmts.add(newCall(ident("declareLibraryBase"), newStrLitNode(libraryName))) + # The pool the generated wrappers validate against; ffiCtor/ffiDtor guard alike. + let poolIdent = ident($libType & "FFIPool") + stmts.add quote do: + when not declared(`poolIdent`): + var `poolIdent`*: FFIContextPool[`libType`] + let ctxType = nnkPtrTy.newTree(nnkBracketExpr.newTree(ident("FFIContext"), libType)) let cdeclExportPragma = newTree( nnkPragma, diff --git a/ffi/internal/ffi_macro.nim b/ffi/internal/ffi_macro.nim index b8e7be4..80f5197 100644 --- a/ffi/internal/ffi_macro.nim +++ b/ffi/internal/ffi_macro.nim @@ -82,13 +82,18 @@ proc nimTypeNameRepr(typ: NimNode): string = else: typ.repr +proc isHandleType(typ: NimNode): bool = + ## True iff `typ` is an `{.ffiHandle.}` type — its wire form is `uint64`. + typ.kind == nnkIdent and isFFIHandleTypeName($typ) + proc storageType(typ: NimNode): NimNode = - ## Returns the in-Req-struct storage type for a user-declared param type. - ## `cstring` is stored as `string` for trivial CBOR transport; everything - ## else is stored as the user typed it. + ## In-Req-struct storage type. `cstring` rides as `string`; an {.ffiHandle.} + ## type rides as its `uint64` id; everything else as-is. if typ.kind == nnkIdent and $typ == "cstring": return ident("string") - return typ + if isHandleType(typ): + return ident("uint64") + typ proc unpackReqField*(fieldIdent, userType, decodedIdent: NimNode): NimNode = ## Emits AST for unpacking one field from a CBOR-decoded Req struct into a @@ -113,6 +118,19 @@ proc unpackReqField*(fieldIdent, userType, decodedIdent: NimNode): NimNode = return nnkLetSection.newTree(nnkIdentDefs.newTree(fieldIdent, ident("cstring"), castExpr)) +proc unpackHandleField*( + fieldIdent, userType, ctxIdent, decodedIdent: NimNode +): NimNode = + ## Reconstitutes a handle param from its wire `uint64` via the ctx registry, + ## returning `RET_ERR` (Result.err) on a stale/forged/wrong-type id. + let errMsg = "invalid or stale ffiHandle for parameter '" & $fieldIdent & "'" + quote: + let `fieldIdent` = block: + let ffiH = `ctxIdent`[].handles.lookup(`decodedIdent`.`fieldIdent`, $`userType`) + if ffiH.isNil(): + return err(`errMsg`) + cast[`userType`](ffiH) + proc cExportedParams(ctxType: NimNode): seq[NimNode] = ## Standard parameter list for the C-exported wrapper of a .ffi. proc: ## (returns cint; ctx, callback, userData, reqCbor, reqCborLen) @@ -239,9 +257,12 @@ proc buildFFINewReqProc(reqTypeName, body: NimNode): NimNode = formalParams.add(newIdentDefs(ident("callback"), ident("FFICallBack"))) formalParams.add(newIdentDefs(ident("userData"), ident("pointer"))) - # User-typed lambda params (kept as-is so callers see their original signature) + # Handle params travel as their uint64 id; others keep the user's type. let procParams = procNode[3] for p in procParams[1 .. ^1]: + if isHandleType(p[1]): + formalParams.add(newIdentDefs(p[0], ident("uint64"))) + continue formalParams.add(p) let retType = newNimNode(nnkPtrTy) @@ -345,6 +366,9 @@ proc buildProcessFFIRequestProc(reqTypeName, reqHandler, body: NimNode): NimNode # Unpack each field as a local typed as the user's original param type. for p in procParams[1 ..^ 1]: + if isHandleType(p[1]): + newBody.add unpackHandleField(p[0], p[1], reqHandler[0], decodedIdent) + continue newBody.add unpackReqField(p[0], p[1], decodedIdent) newBody.add(bodyNode) @@ -385,16 +409,19 @@ proc addNewRequestToRegistry(reqTypeName, reqHandler: NimNode): NimNode = else: error "Second argument must be a typed parameter, e.g. waku: ptr Waku" - let castedHandler = newTree(nnkCast, rhsType, ident("reqHandler")) + let handlerCtxIdent = genSym(nskLet, "handlerCtx") let callExpr = newCall( - newDotExpr(reqTypeName, ident("processFFIRequest")), ident("request"), castedHandler + newDotExpr(reqTypeName, ident("processFFIRequest")), + ident("request"), + handlerCtxIdent, ) let typedResIdent = genSym(nskLet, "typedRes") var newBody = newStmtList() newBody.add quote do: + let `handlerCtxIdent` = cast[`rhsType`](reqHandler) let `typedResIdent` = await `callExpr` if `typedResIdent`.isErr: return err(`typedResIdent`.error) @@ -402,6 +429,14 @@ proc addNewRequestToRegistry(reqTypeName, reqHandler: NimNode): NimNode = return ok(`typedResIdent`.value) elif typeof(`typedResIdent`.value) is void: return ok(newSeq[byte]()) + elif typeof(`typedResIdent`.value) is FFIHandleRoot: + return ok( + cborEncode( + `handlerCtxIdent`[].handles.register( + `typedResIdent`.value, $typeof(`typedResIdent`.value) + ) + ) + ) else: return ok(cborEncode(`typedResIdent`.value)) @@ -596,6 +631,40 @@ macro ffiRaw*(prc: untyped): untyped = echo stmts.repr return stmts +macro ffiHandle*(prc: untyped): untyped = + ## Marks a `ref object` as an opaque FFI handle. Its wire form is a `uint64` + ## id; the live object stays in the per-ctx handle registry and never crosses. + ## + ## type Kernel {.ffiHandle.} = ref object + ## ... + if prc.kind != nnkTypeDef: + error("`.ffiHandle.` must be applied to a type definition") + + var clean = prc.copyNimTree() + if clean[0].kind == nnkPragmaExpr: + clean[0] = clean[0][0] + + let typeName = + if clean[0].kind == nnkPostfix: + clean[0][1] + else: + clean[0] + + let refTy = clean[2] + if refTy.kind != nnkRefTy or refTy[0].kind != nnkObjectTy: + error("`.ffiHandle.` type " & $typeName & " must be a `ref object`") + let objTy = refTy[0] + if objTy[1].kind != nnkEmpty: + error("`.ffiHandle.` type " & $typeName & " must not already inherit a base") + # Inherit the registry's storable base so handle refs share one static type. + objTy[1] = nnkOfInherit.newTree(ident("FFIHandleRoot")) + + ffiHandleTypeNames.add($typeName) + + when defined(ffiDumpMacros): + echo clean.repr + return clean + macro ffi*(prc: untyped): untyped = ## Simplified FFI macro — applies to procs or types. ## @@ -631,8 +700,20 @@ macro ffi*(prc: untyped): untyped = error("`.ffi.` procs require at least 1 parameter (the library type)") let firstParam = formalParams[1] - let libParamName = firstParam[0] - let libTypeName = firstParam[1] + let recvName = firstParam[0] + let recvType = firstParam[1] + let firstIsHandle = isHandleType(recvType) + if firstIsHandle and currentLibType.len == 0: + error( + "`.ffi.` proc " & $procName & " has an {.ffiHandle.} receiver but no " & + "library is declared; call declareLibrary(name, LibType) first" + ) + # A handle receiver carries no library type, so fall back to the declared one. + let libTypeName = + if firstIsHandle: + ident(currentLibType) + else: + recvType let retTypeNode = formalParams[0] if retTypeNode.kind == nnkEmpty: @@ -654,9 +735,11 @@ macro ffi*(prc: untyped): untyped = let resultRetType = resultInner[1] rejectRawPtrType(resultRetType, "`.ffi.` proc " & $procName & " return type") + # A handle receiver rides the wire; a value-type lib receiver binds to ctx.myLib. var extraParamNames: seq[string] = @[] var extraParamTypes: seq[NimNode] = @[] - for i in 2 ..< formalParams.len: + let wireStart = if firstIsHandle: 1 else: 2 + for i in wireStart ..< formalParams.len: let p = formalParams[i] for j in 0 ..< p.len - 2: rejectRawPtrType(p[^2], "`.ffi.` proc " & $procName & " parameter " & $p[j]) @@ -687,12 +770,14 @@ macro ffi*(prc: untyped): untyped = nnkPtrTy.newTree(nnkBracketExpr.newTree(ident("FFIContext"), libTypeName)) proc buildAsyncHelperProc(): NimNode = - ## proc *(lib: LibType, extras...): Future[Result[T, string]] {.async.} = + ## Reproduces the user's exact signature so it stays callable from Nim. var helperParams = newSeq[NimNode]() helperParams.add(retTypeNode) - helperParams.add(newIdentDefs(libParamName, libTypeName)) - for i in 0 ..< extraParamNames.len: - helperParams.add(newIdentDefs(ident(extraParamNames[i]), extraParamTypes[i])) + helperParams.add(newIdentDefs(recvName, recvType)) + for i in 2 ..< formalParams.len: + let p = formalParams[i] + for j in 0 ..< p.len - 2: + helperParams.add(newIdentDefs(p[j], p[^2])) newProc( name = postfix(userProcName, "*"), params = helperParams, @@ -719,9 +804,10 @@ macro ffi*(prc: untyped): untyped = for i in 0 ..< extraParamNames.len: lambdaParams.add(newIdentDefs(ident(extraParamNames[i]), extraParamTypes[i])) - let ctxMyLib = newDotExpr(newTree(nnkDerefExpr, ctxHandlerName), ident("myLib")) - let libValDeref = newTree(nnkDerefExpr, ctxMyLib) - let helperCall = newTree(nnkCall, userProcName, libValDeref) + let helperCall = newTree(nnkCall, userProcName) + if not firstIsHandle: + let ctxMyLib = newDotExpr(newTree(nnkDerefExpr, ctxHandlerName), ident("myLib")) + helperCall.add(newTree(nnkDerefExpr, ctxMyLib)) for name in extraParamNames: helperCall.add(ident(name)) @@ -800,16 +886,20 @@ macro ffi*(prc: untyped): untyped = 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) + 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]) @@ -824,6 +914,7 @@ macro ffi*(prc: untyped): untyped = extraParams: ffiExtraParams, returnTypeName: retTn, returnIsPtr: retIsPtr, + returnIsHandle: retIsHandle, ) ) diff --git a/tests/unit/test_ffi_handle.nim b/tests/unit/test_ffi_handle.nim new file mode 100644 index 0000000..372731e --- /dev/null +++ b/tests/unit/test_ffi_handle.nim @@ -0,0 +1,164 @@ +## {.ffiHandle.} round-trip through the full C-shape dispatch path: a handle +## returned by one `.ffi.` proc crosses as a uint64 and is reconstituted as the +## live object by another; stale/forged/null ids miss cleanly with RET_ERR. + +import std/[locks, strutils] +import unittest2 +import results +import ffi + +type HandleLib = object + base: int + +# Stub the dylib NimMain importc that declareLibrary emits (this links as a plain exe). +{.emit: "void libhandletestNimMain(void) {}".} + +declareLibrary("handletest", HandleLib) + +type Session {.ffiHandle.} = ref object + token: string + hits: int + +type OpenReq {.ffi.} = object + name: string + +proc handletest_open*( + lib: HandleLib, req: OpenReq +): Future[Result[Session, string]] {.ffi.} = + return ok(Session(token: req.name & ":" & $lib.base, hits: 0)) + +proc handletest_token*( + lib: HandleLib, s: Session +): Future[Result[string, string]] {.ffi.} = + s.hits.inc() + return ok(s.token & "#" & $s.hits) + +# Handle as the receiver (first param) — lib type comes from declareLibrary. +proc handletest_session_bump*(s: Session): Future[Result[int, string]] {.ffi.} = + s.hits.inc() + return ok(s.hits) + +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 payload(d: var CallbackData): seq[byte] = + var b = newSeq[byte](d.msgLen) + if d.msgLen > 0: + copyMem(addr b[0], addr d.msg[0], d.msgLen) + b + +proc text(d: var CallbackData): string = + var s = newString(d.msgLen) + if d.msgLen > 0: + copyMem(addr s[0], addr d.msg[0], d.msgLen) + s + +proc encodedPtr(b: var seq[byte]): ptr byte = + if b.len == 0: + nil + else: + cast[ptr byte](addr b[0]) + +template runCall(d, ctx, reqBytes, exportProc) = + ## Fires `exportProc` with `reqBytes` and blocks until the callback lands in `d`. + initCallbackData(d) + var rb = reqBytes + check exportProc(ctx, testCallback, addr d, encodedPtr(rb), rb.len.csize_t) == RET_OK + waitCallback(d) + +suite "{.ffiHandle.} round-trip": + setup: + let ctx {.inject.} = HandleLibFFIPool.createFFIContext().get() + + teardown: + discard HandleLibFFIPool.destroyFFIContext(ctx) + + test "handle returned as uint64, reconstituted on the next call": + var od: CallbackData + runCall( + od, + ctx, + cborEncode(HandletestOpenReq(req: OpenReq(name: "alpha"))), + handletest_open, + ) + defer: + deinitCallbackData(od) + check od.retCode == RET_OK + let handle = cborDecode(payload(od), uint64).value + check handle == 1'u64 # ids start at 1 + + var td: CallbackData + runCall(td, ctx, cborEncode(HandletestTokenReq(s: handle)), handletest_token) + defer: + deinitCallbackData(td) + check td.retCode == RET_OK + check cborDecode(payload(td), string).value == "alpha:0#1" + + test "handle as receiver (first param)": + var od: CallbackData + runCall( + od, + ctx, + cborEncode(HandletestOpenReq(req: OpenReq(name: "beta"))), + handletest_open, + ) + defer: + deinitCallbackData(od) + let handle = cborDecode(payload(od), uint64).value + + var bd: CallbackData + runCall( + bd, ctx, cborEncode(HandletestSessionBumpReq(s: handle)), handletest_session_bump + ) + defer: + deinitCallbackData(bd) + check bd.retCode == RET_OK + check cborDecode(payload(bd), int).value == 1 + + test "forged handle misses cleanly with RET_ERR": + var td: CallbackData + runCall(td, ctx, cborEncode(HandletestTokenReq(s: 9999'u64)), handletest_token) + defer: + deinitCallbackData(td) + check td.retCode == RET_ERR + check "ffiHandle" in text(td) + + test "null handle (0) misses with RET_ERR": + var td: CallbackData + runCall(td, ctx, cborEncode(HandletestTokenReq(s: 0'u64)), handletest_token) + defer: + deinitCallbackData(td) + check td.retCode == RET_ERR