feat(host): C ABI for {.ffiHost.} + cross-thread e2e

Increment 4: the exported C surface for host callbacks, plus an end-to-end
test that the host can answer from a different thread than the FFI loop.

- declareLibrary now emits two exportc/cdecl procs on every library's
  FFIContext (like the event ABI):
    <lib>_register_host_fn(ctx, name, fn, userData)
    <lib>_host_complete(ctx, token, ret, msg, len)
  (the `name` param is spelled `hostFnName` to dodge the macros.name capture
  under quote, same class as the existing id/ret collisions.)
- c.nim emits the FFIHostFn typedef + both declarations into <lib>.h
  (guarded, format-agnostic), and the timer header is regenerated.
- Verified: the built timer lib exports both symbols.

The e2e (test_ffi_host_e2e) drives the real bridge: a {.ffi.} handler awaits a
{.ffiHost.} call; the host fn (invoked on the FFI thread, non-blocking) hands
the work to a worker thread, which answers via the completion path. The result
resolves on the loop thread and round-trips correctly (orc+refc). It calls the
underlying registerHostFn/completeHostCall directly, since the exported shims
need an --app:lib build; those shims are verified by the symbol check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ivan FB 2026-06-13 23:32:38 +02:00
parent 556599787c
commit df6dd76311
No known key found for this signature in database
GPG Key ID: DF0C67A04C543270
4 changed files with 227 additions and 0 deletions

View File

@ -114,6 +114,14 @@ int my_timer_destroy(void *ctx);
uint64_t my_timer_add_event_listener(void *ctx, const char *eventName, FFICallBack callback, void *userData);
int my_timer_remove_event_listener(void *ctx, uint64_t listenerId);
// --- host callbacks ({.ffiHost.}) — host-implemented functions --------
#ifndef NIM_FFI_HOST_FN_T
#define NIM_FFI_HOST_FN_T
typedef void (*FFIHostFn)(uint64_t token, const char *req, size_t reqLen, void *userData);
#endif
int my_timer_register_host_fn(void *ctx, const char *name, FFIHostFn fn, void *userData);
int my_timer_host_complete(void *ctx, uint64_t token, int ret, const char *msg, size_t len);
#ifdef __cplusplus
} // extern "C"
#endif

View File

@ -175,6 +175,28 @@ proc generateCHeader*(
"int " & libName & "_remove_event_listener(void *ctx, uint64_t listenerId);"
)
lines.add("")
# Host callbacks ({.ffiHost.}): the host registers an implementation, the
# library invokes it with a token + raw request, and the host answers by token
# (from any thread) via host_complete. Always exported, like the event ABI.
lines.add(
"// --- host callbacks ({.ffiHost.}) — host-implemented functions --------"
)
lines.add("#ifndef NIM_FFI_HOST_FN_T")
lines.add("#define NIM_FFI_HOST_FN_T")
lines.add(
"typedef void (*FFIHostFn)(uint64_t token, const char *req, size_t reqLen, void *userData);"
)
lines.add("#endif")
lines.add(
"int " & libName &
"_register_host_fn(void *ctx, const char *name, FFIHostFn fn, void *userData);"
)
lines.add(
"int " & libName &
"_host_complete(void *ctx, uint64_t token, int ret, const char *msg, size_t len);"
)
lines.add("")
lines.add("#ifdef __cplusplus")
lines.add("} // extern \"C\"")
lines.add("#endif")

View File

@ -189,4 +189,64 @@ macro declareLibrary*(libraryName: static[string], libType: untyped): untyped =
)
)
# --- {libraryName}_register_host_fn -------------------------------------
# Registers the host's implementation of a {.ffiHost.} proc, keyed by its
# snake_case wire name. Returns 0 on success, non-zero on a nil ctx / nil fn.
let registerName = libraryName & "_register_host_fn"
let registerErr = "error: invalid context in " & registerName
let registerBody = quote:
var ret: cint = 1
if isNil(ctx):
echo `registerErr`
return ret
let resolvedName = if hostFnName.isNil(): "" else: $hostFnName
if registerHostFn(ctx[].hostRegistry, resolvedName, fn, userData):
ret = 0
return ret
stmts.add(
newProc(
name = ident(registerName),
params = @[
ident("cint"),
newIdentDefs(ident("ctx"), ctxType),
newIdentDefs(ident("hostFnName"), ident("cstring")),
newIdentDefs(ident("fn"), ident("FFIHostFn")),
newIdentDefs(ident("userData"), ident("pointer")),
],
body = registerBody,
pragmas = cdeclExportPragma,
)
)
# --- {libraryName}_host_complete ----------------------------------------
# The host delivers a {.ffiHost.} answer by token. Callable from ANY thread —
# it parks the result and wakes the FFI loop, which completes the awaited
# future. `retCode` (not `ret`) avoids colliding with chronos templates under
# quote injection, like `listenerId` above.
let completeName = libraryName & "_host_complete"
let completeErr = "error: invalid context in " & completeName
let completeBody = quote:
if isNil(ctx):
echo `completeErr`
return cint(1)
completeHostCall(ctx, token, retCode, msg, msgLen)
return cint(0)
stmts.add(
newProc(
name = ident(completeName),
params = @[
ident("cint"),
newIdentDefs(ident("ctx"), ctxType),
newIdentDefs(ident("token"), ident("uint64")),
newIdentDefs(ident("retCode"), ident("cint")),
newIdentDefs(ident("msg"), nnkPtrTy.newTree(ident("cchar"))),
newIdentDefs(ident("msgLen"), ident("csize_t")),
],
body = completeBody,
pragmas = cdeclExportPragma,
)
)
return stmts

View File

@ -0,0 +1,137 @@
## End-to-end cross-thread test for {.ffiHost.} (roadmap #1, increment 4).
##
## Proves the full bridge under the real FFI thread + the *exported* C ABI:
## request -> {.ffi.} handler awaits a {.ffiHost.} call -> library invokes the
## host fn ON the FFI thread -> host hands the work to a SEPARATE worker thread
## (non-blocking) -> worker answers via the exported <lib>_host_complete ->
## reqSignal wakes the loop -> drain completes the future on the loop thread ->
## handler resumes -> callback fires.
##
## The host answering from a different thread than the FFI loop is the property
## the in-thread macro test can't cover.
import std/[locks, atomics]
import unittest2
import results
import ffi
type TestLib = object
# NB: this drives the runtime bridge directly (registerHostFn / completeHostCall),
# not the exported C shims `<lib>_register_host_fn` / `<lib>_host_complete` — those
# need an --app:lib build (declareLibrary emits an importc NimMain) and are verified
# separately by the symbol check on the timer library. The shims are thin wrappers
# over exactly the two procs used here.
# The host implements this; a {.ffi.} handler awaits it.
proc lookupHost(key: string): Future[Result[string, string]] {.ffiHost.}
# A {.ffi.}-style request whose handler depends on the host's answer.
registerReqFFI(HostCallRequest, lib: ptr TestLib):
proc(key: cstring): Future[Result[string, string]] {.async.} =
let v = (await lookupHost($key)).valueOr:
return err("host failed: " & error)
return ok("got:" & v)
# --- the host, answering on a worker thread --------------------------------
# The host fn runs on the FFI thread, so it must NOT block: it copies the
# request and hands (token, key) to a worker via a channel, then returns. The
# worker answers later through the exported <lib>_host_complete.
var gHostJobs: Channel[tuple[token: uint64, key: string]]
var gCtx: Atomic[pointer]
proc lookupHostFnImpl(
token: uint64, req: ptr cchar, reqLen: csize_t, userData: pointer
) {.cdecl, gcsafe, raises: [].} =
var key = newString(int(reqLen))
if reqLen > 0'u:
copyMem(addr key[0], req, int(reqLen))
try:
gHostJobs.send((token: token, key: key))
except Exception:
discard
proc hostWorker(_: pointer) {.thread.} =
while true:
let job = gHostJobs.recv()
if job.token == 0'u64: # sentinel: shut down
break
let answer = "reply:" & job.key
completeHostCall(
cast[ptr FFIContext[TestLib]](gCtx.load()),
job.token,
RET_OK,
cast[ptr cchar](unsafeAddr answer[0]),
csize_t(answer.len),
)
# --- blocking callback capture (same shape as test_ffi_context) -------------
type CallbackData = object
lock: Lock
cond: Cond
called: bool
retCode: cint
msg: array[1024, byte]
msgLen: int
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 callbackBytes(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)
return b
suite "ffiHost end-to-end (cross-thread)":
test "handler awaits a host fn answered from another thread":
gHostJobs.open()
var pool: FFIContextPool[TestLib]
let ctx = pool.createFFIContext().valueOr:
assert false, "createFFIContext failed: " & $error
return
gCtx.store(ctx)
check registerHostFn(ctx[].hostRegistry, "lookup_host", lookupHostFnImpl, nil)
var worker: Thread[pointer]
createThread(worker, hostWorker, nil)
var d = CallbackData()
d.lock.initLock()
d.cond.initCond()
check sendRequestToFFIThread(
ctx, HostCallRequest.ffiNewReq(testCallback, addr d, "session".cstring)
)
.isOk()
waitCallback(d)
check d.retCode == RET_OK
# The {.ffi.} OK payload is CBOR-encoded (registerReqFFI returns seq[byte]).
check cborDecode(callbackBytes(d), string).value == "got:reply:session"
# Shut the worker down, then tear the context down.
gHostJobs.send((token: 0'u64, key: ""))
joinThread(worker)
d.cond.deinitCond()
d.lock.deinitLock()
gHostJobs.close()
check pool.destroyFFIContext(ctx).isOk()