mirror of
https://github.com/logos-messaging/nim-ffi.git
synced 2026-07-16 13:09:24 +00:00
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:
parent
556599787c
commit
df6dd76311
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
137
tests/unit/test_ffi_host_e2e.nim
Normal file
137
tests/unit/test_ffi_host_e2e.nim
Normal 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()
|
||||
Loading…
x
Reference in New Issue
Block a user