diff --git a/examples/timer/c_bindings/my_timer.h b/examples/timer/c_bindings/my_timer.h index cd50e82..c00d236 100644 --- a/examples/timer/c_bindings/my_timer.h +++ b/examples/timer/c_bindings/my_timer.h @@ -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 diff --git a/ffi/codegen/c.nim b/ffi/codegen/c.nim index f4010c8..bab5f70 100644 --- a/ffi/codegen/c.nim +++ b/ffi/codegen/c.nim @@ -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") diff --git a/ffi/internal/ffi_library.nim b/ffi/internal/ffi_library.nim index 0fcd26c..1c3af53 100644 --- a/ffi/internal/ffi_library.nim +++ b/ffi/internal/ffi_library.nim @@ -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 diff --git a/tests/unit/test_ffi_host_e2e.nim b/tests/unit/test_ffi_host_e2e.nim new file mode 100644 index 0000000..49e9281 --- /dev/null +++ b/tests/unit/test_ffi_host_e2e.nim @@ -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 _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 `_register_host_fn` / `_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 _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()