mirror of
https://github.com/logos-messaging/nim-ffi.git
synced 2026-06-24 18:30:08 +00:00
feat(host): {.ffiHost.} macro (raw string round-trip)
Increment 3: the {.ffiHost.} pragma. A bodyless
proc fetchToken(key: string): Future[Result[string, string]] {.ffiHost.}
expands into an async proc that resolves the thread-local host registry +
pending table, looks the fn up by snake_case wire name, allocates a token,
invokes the host with the raw request bytes, and awaits the answer.
This is the inverse of {.ffi.} and the first end-to-end use of the registry
(increment 1) + completion bridge (increment 2). First slice is deliberately
narrow — raw ABI, one string param, Future[Result[string, string]] — to prove
the round-trip with zero serialization; struct params/returns and the
{.ffiHost: cbor.} format arg are follow-ups.
The body reads two new threadvars (ffiCurrentHostRegistry / ffiCurrentPendingTable)
set by ffiThreadBody alongside ffiCurrentEventRegistry, so the user's signature
stays ctx-free. The host fn is invoked synchronously before the await, while the
string arg is still alive (honouring the "req valid only for the call" contract).
5 macro tests pass under orc+refc; host + ffi_context suites stay green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
50b56c0cad
commit
556599787c
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 `<lib>_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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
101
tests/unit/test_ffi_host_macro.nim
Normal file
101
tests/unit/test_ffi_host_macro.nim
Normal file
@ -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 <lib>_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
|
||||
Loading…
x
Reference in New Issue
Block a user