diff --git a/ffi/ffi_context.nim b/ffi/ffi_context.nim index ec48c3a..3aa859f 100644 --- a/ffi/ffi_context.nim +++ b/ffi/ffi_context.nim @@ -224,6 +224,8 @@ proc processRequest[T]( proc ffiThreadBody[T](ctx: ptr FFIContext[T]) {.thread.} = ## FFI thread body that attends library user API requests ffiCurrentEventRegistry = addr ctx[].eventRegistry + ffiCurrentHostRegistry = addr ctx[].hostRegistry + ffiCurrentPendingTable = addr ctx[].pendingTable onFFIThread = true logging.setupLog(logging.LogLevel.DEBUG, logging.LogFormat.TEXT) diff --git a/ffi/ffi_host.nim b/ffi/ffi_host.nim index f21756c..c179219 100644 --- a/ffi/ffi_host.nim +++ b/ffi/ffi_host.nim @@ -41,6 +41,14 @@ type HostResult* = object proc okResult*(bytes: seq[byte]): HostResult = return HostResult(ret: RET_OK, bytes: bytes) +proc resultText*(res: HostResult): string = + ## The payload bytes as a string — used by the raw `{.ffiHost.}` path for both + ## the success value (string return) and the error text. + var s = newString(res.bytes.len) + if res.bytes.len > 0: + copyMem(addr s[0], unsafeAddr res.bytes[0], res.bytes.len) + return s + proc errResult*(msg: string): HostResult = var b = newSeq[byte](msg.len) if msg.len > 0: @@ -113,6 +121,12 @@ type FFIPendingTable* = object nextToken: uint64 ## Monotonic; 0 is reserved as "invalid", tokens start at 1. pending: Table[uint64, Future[HostResult]] +# Set by the FFI thread at startup (see ffi_context.ffiThreadBody) so the body a +# `{.ffiHost.}` macro generates can reach its context's host registry + pending +# table without threading a ctx pointer through the user's signature. +var ffiCurrentHostRegistry* {.threadvar.}: ptr FFIHostRegistry +var ffiCurrentPendingTable* {.threadvar.}: ptr FFIPendingTable + proc initPendingTable*(tbl: var FFIPendingTable) = tbl.lock.initLock() tbl.nextToken = 0'u64 diff --git a/ffi/internal/ffi_macro.nim b/ffi/internal/ffi_macro.nim index 5179bf7..ac5b512 100644 --- a/ffi/internal/ffi_macro.nim +++ b/ffi/internal/ffi_macro.nim @@ -1924,6 +1924,108 @@ macro ffiEvent*(wireName: static[string], prc: untyped): untyped = echo withPods.repr return withPods +# --------------------------------------------------------------------------- +# ffiHost — host-provided functions the Nim side can await (roadmap #1) +# --------------------------------------------------------------------------- + +macro ffiHost*(prc: untyped): untyped = + ## Declares a function the *host* implements, which a `{.ffi.}` handler can + ## call and `await` (the inverse of `{.ffi.}`). The annotated proc has an empty + ## body; the macro fills it with the dispatch: look up the host's registered + ## implementation, hand it the marshaled request + a token, and await the + ## answer the host delivers (via `_host_complete`) on the FFI thread. + ## + ## First slice — raw (zero-serialization) ABI, exactly one `string` parameter, + ## returning `Future[Result[string, string]]`: + ## + ## proc fetchToken(key: string): Future[Result[string, string]] {.ffiHost.} + ## + ## # ...then from inside any {.ffi.} handler: + ## let tok = (await fetchToken("session")).valueOr: + ## return err("host lookup failed: " & error) + ## + ## Struct params/returns and the `{.ffiHost: cbor.}` format arg are follow-ups + ## (see docs/design-host-callbacks.md and docs/design-abi-format.md). + + if prc.kind notin {nnkProcDef, nnkFuncDef}: + error("ffiHost must be applied to a proc declaration") + + let procName = prc[0] + let formalParams = prc[3] + + if formalParams.len != 2: + error( + "ffiHost (first pass) supports exactly one `string` parameter; got " & + $(formalParams.len - 1) + ) + + let paramDef = formalParams[1] + let argName = paramDef[0] + if paramDef[1].kind != nnkIdent or $paramDef[1] != "string": + error("ffiHost (first pass) parameter must be `string`, got: " & paramDef[1].repr) + + let retTypeNode = formalParams[0] + if retTypeNode.kind != nnkBracketExpr or $retTypeNode[0] != "Future": + error( + "ffiHost return type must be Future[Result[string, string]], got: " & + retTypeNode.repr + ) + let resultInner = retTypeNode[1] + if resultInner.kind != nnkBracketExpr or $resultInner[0] != "Result" or + $resultInner[1] != "string": + error( + "ffiHost (first pass) return type must be Future[Result[string, string]], got: " & + retTypeNode.repr + ) + + let procNameStr = + block: + let raw = $procName + if raw.endsWith("*"): raw[0 ..^ 2] else: raw + let wireNameLit = newStrLitNode(camelToSnakeCase(procNameStr)) + + # The generated async body: resolve the thread-local host context, look up the + # registered fn, allocate a pending token, invoke the host with the raw request + # bytes, and await the answer. The host fn is called synchronously here (before + # the await) while `argName` is still alive, honouring the "req valid only for + # the call" contract. + let body = quote do: + let ffiReg = ffiCurrentHostRegistry + let ffiTbl = ffiCurrentPendingTable + if ffiReg.isNil() or ffiTbl.isNil(): + return err("ffiHost " & `wireNameLit` & ": no host context on this thread") + let ffiHit = lookupHostFn(ffiReg[], `wireNameLit`) + if not ffiHit.found: + return err("ffiHost: host fn '" & `wireNameLit` & "' not registered") + let (ffiTok, ffiFut) = newPending(ffiTbl[]) + if `argName`.len > 0: + ffiHit.fn( + ffiTok, cast[ptr cchar](unsafeAddr `argName`[0]), csize_t(`argName`.len), + ffiHit.userData, + ) + else: + ffiHit.fn(ffiTok, nil, 0, ffiHit.userData) + let ffiRes = await ffiFut + if ffiRes.ret != RET_OK: + return err(resultText(ffiRes)) + return ok(resultText(ffiRes)) + + var newParams = newSeq[NimNode]() + newParams.add(formalParams[0]) + newParams.add(paramDef) + + let generated = newProc( + name = procName, + params = newParams, + body = body, + procType = prc.kind, + pragmas = newTree(nnkPragma, ident("async")), + ) + + when defined(ffiDumpMacros): + echo generated.repr + return generated + # --------------------------------------------------------------------------- # genBindings — codegen entry point # --------------------------------------------------------------------------- diff --git a/tests/unit/test_ffi_host_macro.nim b/tests/unit/test_ffi_host_macro.nim new file mode 100644 index 0000000..e5360bd --- /dev/null +++ b/tests/unit/test_ffi_host_macro.nim @@ -0,0 +1,101 @@ +## Unit tests for the `{.ffiHost.}` macro (roadmap #1, increment 3) — the +## generated proc that dispatches to a host-registered function and awaits the +## answer over the raw (zero-serialization) ABI. +## +## These drive the generated proc directly with a synchronous "host": the +## registered fn completes the pending future inline (its userData carries the +## pending table), so the await resolves without the full FFI thread. The +## cross-thread bridge is covered separately by the FFIContext wiring. + +import std/strutils +import unittest2 +import chronos +import ffi + +# A {.ffiHost.} declaration: the host implements `echoHost`, Nim awaits it. +proc echoHost(s: string): Future[Result[string, string]] {.ffiHost.} + +# Synchronous host impls. `userData` carries the pending table so the fn can +# resolve the token inline (a real host answers later via _host_complete). +proc echoFn( + token: uint64, req: ptr cchar, reqLen: csize_t, userData: pointer +) {.cdecl, gcsafe, raises: [].} = + let tbl = cast[ptr FFIPendingTable](userData) + var b = newSeq[byte](int(reqLen)) + if reqLen > 0'u: + copyMem(addr b[0], req, int(reqLen)) + discard completePending(tbl[], token, okResult(b)) + +proc failFn( + token: uint64, req: ptr cchar, reqLen: csize_t, userData: pointer +) {.cdecl, gcsafe, raises: [].} = + let tbl = cast[ptr FFIPendingTable](userData) + discard completePending(tbl[], token, errResult("host said no")) + +suite "ffiHost macro": + test "round-trips the value through the registered host fn": + var reg: FFIHostRegistry + var tbl: FFIPendingTable + initHostRegistry(reg) + initPendingTable(tbl) + defer: + deinitHostRegistry(reg) + deinitPendingTable(tbl) + ffiCurrentHostRegistry = addr reg + ffiCurrentPendingTable = addr tbl + check registerHostFn(reg, "echo_host", echoFn, addr tbl) + + let r = waitFor echoHost("hello host") + check r.isOk + check r.get == "hello host" + + test "empty argument is handled": + var reg: FFIHostRegistry + var tbl: FFIPendingTable + initHostRegistry(reg) + initPendingTable(tbl) + defer: + deinitHostRegistry(reg) + deinitPendingTable(tbl) + ffiCurrentHostRegistry = addr reg + ffiCurrentPendingTable = addr tbl + discard registerHostFn(reg, "echo_host", echoFn, addr tbl) + let r = waitFor echoHost("") + check r.isOk + check r.get == "" + + test "unregistered host fn yields an error": + var reg: FFIHostRegistry + var tbl: FFIPendingTable + initHostRegistry(reg) + initPendingTable(tbl) + defer: + deinitHostRegistry(reg) + deinitPendingTable(tbl) + ffiCurrentHostRegistry = addr reg + ffiCurrentPendingTable = addr tbl + let r = waitFor echoHost("x") + check r.isErr + check "not registered" in r.error + + test "host-reported error propagates as the Result error": + var reg: FFIHostRegistry + var tbl: FFIPendingTable + initHostRegistry(reg) + initPendingTable(tbl) + defer: + deinitHostRegistry(reg) + deinitPendingTable(tbl) + ffiCurrentHostRegistry = addr reg + ffiCurrentPendingTable = addr tbl + discard registerHostFn(reg, "echo_host", failFn, addr tbl) + let r = waitFor echoHost("x") + check r.isErr + check r.error == "host said no" + + test "no host context on the thread yields an error": + ffiCurrentHostRegistry = nil + ffiCurrentPendingTable = nil + let r = waitFor echoHost("x") + check r.isErr + check "no host context" in r.error