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:
Ivan FB 2026-06-13 23:08:18 +02:00
parent 50b56c0cad
commit 556599787c
No known key found for this signature in database
GPG Key ID: DF0C67A04C543270
4 changed files with 219 additions and 0 deletions

View File

@ -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)

View File

@ -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

View File

@ -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
# ---------------------------------------------------------------------------

View 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