import std/[locks, options, strutils, os, atomics] import unittest2 import results import ffi type TestLib = object ## Per-request callback state. The test thread blocks on `cond` until the ## FFI thread signals it — no polling, no CPU waste. type CallbackData = object lock: Lock cond: Cond called: bool retCode: cint msg: array[1024, byte] msgLen: int proc initCallbackData(d: var CallbackData) = d.lock.initLock() d.cond.initCond() proc deinitCallbackData(d: var CallbackData) = d.cond.deinitCond() d.lock.deinitLock() 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 bytes = newSeq[byte](d.msgLen) if d.msgLen > 0: copyMem(addr bytes[0], addr d.msg[0], d.msgLen) return bytes proc callbackErr(d: var CallbackData): string = ## Reads the error payload (sent as raw UTF-8 bytes on RET_ERR). var msg = newString(d.msgLen) if d.msgLen > 0: copyMem(addr msg[0], addr d.msg[0], d.msgLen) return msg registerReqFFI(PingRequest, lib: ptr TestLib): proc(message: cstring): Future[Result[string, string]] {.async.} = return ok("pong:" & $message) registerReqFFI(FailRequest, lib: ptr TestLib): proc(): Future[Result[string, string]] {.async.} = return err("intentional failure") registerReqFFI(EmptyOkRequest, lib: ptr TestLib): proc(): Future[Result[string, string]] {.async.} = return ok("") registerReqFFI(SlowRequest, lib: ptr TestLib): proc(): Future[Result[string, string]] {.async.} = await sleepAsync(500.milliseconds) return ok("slow-done") var gSyncBlockStarted: Channel[bool] gSyncBlockStarted.open() registerReqFFI(SyncBlockingRequest, lib: ptr TestLib): proc(): Future[Result[string, string]] {.async.} = await sleepAsync(0.milliseconds) try: gSyncBlockStarted.send(true) except Exception as exc: return err("gSyncBlockStarted.send raised: " & exc.msg) os.sleep(5_000) return ok("sync-blocking-done") type RefCell = ref object next: RefCell payload: array[64, byte] registerReqFFI(HeavyRefAllocRequest, lib: ptr TestLib): proc(): Future[Result[string, string]] {.async.} = var head: RefCell for i in 0 ..< 50_000: let n = RefCell(next: head) head = n if i mod 1000 == 0: await sleepAsync(0.milliseconds) var node = head head = nil while not node.isNil(): let nxt = node.next node.next = nil node = nxt await sleepAsync(10.milliseconds) return ok("heavy-done") suite "FFIContextPool": test "create and destroy via pool succeeds": var pool: FFIContextPool[TestLib] let ctx = pool.createFFIContext().valueOr: assert false, "createFFIContext(pool) failed: " & $error return check pool.destroyFFIContext(ctx).isOk() test "slot is reused after destroy": var pool: FFIContextPool[TestLib] let ctx1 = pool.createFFIContext().valueOr: assert false, "createFFIContext(pool) failed: " & $error return check pool.destroyFFIContext(ctx1).isOk() let ctx2 = pool.createFFIContext().valueOr: assert false, "createFFIContext(pool) failed after slot release: " & $error return check pool.destroyFFIContext(ctx2).isOk() check ctx1 == ctx2 test "pool exhaustion returns error": var pool: FFIContextPool[TestLib] var ctxs: array[MaxFFIContexts, ptr FFIContext[TestLib]] for i in 0 ..< MaxFFIContexts: ctxs[i] = pool.createFFIContext().valueOr: for j in 0 ..< i: discard pool.destroyFFIContext(ctxs[j]) assert false, "createFFIContext(pool) failed at slot " & $i & ": " & $error return check pool.createFFIContext().isErr() for i in 0 ..< MaxFFIContexts: discard pool.destroyFFIContext(ctxs[i]) test "requests are processed via pool context": var pool: FFIContextPool[TestLib] var d: CallbackData initCallbackData(d) defer: deinitCallbackData(d) let ctx = pool.createFFIContext().valueOr: assert false, "createFFIContext(pool) failed: " & $error return defer: discard pool.destroyFFIContext(ctx) check sendRequestToFFIThread( ctx, PingRequest.ffiNewReq(testCallback, addr d, "pool".cstring) ) .isOk() waitCallback(d) check d.retCode == RET_OK check cborDecode(callbackBytes(d), string).value == "pong:pool" suite "createFFIContext / destroyFFIContext": test "create and destroy succeeds": var pool: FFIContextPool[TestLib] let ctx = pool.createFFIContext().valueOr: checkpoint "createFFIContext failed: " & $error check false return check pool.destroyFFIContext(ctx).isOk() test "double destroy is safe via running flag": var pool: FFIContextPool[TestLib] let ctx = pool.createFFIContext().valueOr: check false return check pool.destroyFFIContext(ctx).isOk() suite "destroyFFIContext does not hang": test "destroy while a slow async request is still in-flight": var pool: FFIContextPool[TestLib] let ctx = pool.createFFIContext().valueOr: check false return var d: CallbackData initCallbackData(d) defer: deinitCallbackData(d) check sendRequestToFFIThread(ctx, SlowRequest.ffiNewReq(testCallback, addr d)).isOk() let t0 = Moment.now() check pool.destroyFFIContext(ctx).isOk() check (Moment.now() - t0) < 2.seconds suite "destroyFFIContext does not hang when event loop is blocked": test "destroy while sync-blocking request is in-flight": var pool: FFIContextPool[TestLib] let ctx = pool.createFFIContext().valueOr: check false return let d = createShared(CallbackData) initCallbackData(d[]) check sendRequestToFFIThread(ctx, SyncBlockingRequest.ffiNewReq(testCallback, d)) .isOk() discard gSyncBlockStarted.recv() let t0 = Moment.now() check pool.destroyFFIContext(ctx).isErr() check (Moment.now() - t0) < 3.seconds waitCallback(d[]) os.sleep(200) deinitCallbackData(d[]) freeShared(d) suite "destroyFFIContext refc workaround": test "destroy after heavy ref-allocation workload returns promptly": var pool: FFIContextPool[TestLib] let ctx = pool.createFFIContext().valueOr: check false return var d: CallbackData initCallbackData(d) defer: deinitCallbackData(d) check sendRequestToFFIThread( ctx, HeavyRefAllocRequest.ffiNewReq(testCallback, addr d) ) .isOk() waitCallback(d) check d.retCode == RET_OK let t0 = Moment.now() check pool.destroyFFIContext(ctx).isOk() check (Moment.now() - t0) < 3.seconds suite "sendRequestToFFIThread": test "successful request triggers RET_OK callback": var d: CallbackData initCallbackData(d) defer: deinitCallbackData(d) var pool: FFIContextPool[TestLib] let ctx = pool.createFFIContext().valueOr: check false return defer: discard pool.destroyFFIContext(ctx) check sendRequestToFFIThread( ctx, PingRequest.ffiNewReq(testCallback, addr d, "hello".cstring) ) .isOk() waitCallback(d) check d.retCode == RET_OK check cborDecode(callbackBytes(d), string).value == "pong:hello" test "failing request triggers RET_ERR callback": var d: CallbackData initCallbackData(d) defer: deinitCallbackData(d) var pool: FFIContextPool[TestLib] let ctx = pool.createFFIContext().valueOr: check false return defer: discard pool.destroyFFIContext(ctx) check sendRequestToFFIThread(ctx, FailRequest.ffiNewReq(testCallback, addr d)).isOk() waitCallback(d) check d.retCode == RET_ERR # Errors are raw UTF-8 — not CBOR. check callbackErr(d) == "intentional failure" test "empty ok response delivers empty message": var d: CallbackData initCallbackData(d) defer: deinitCallbackData(d) var pool: FFIContextPool[TestLib] let ctx = pool.createFFIContext().valueOr: check false return defer: discard pool.destroyFFIContext(ctx) check sendRequestToFFIThread(ctx, EmptyOkRequest.ffiNewReq(testCallback, addr d)) .isOk() waitCallback(d) check d.retCode == RET_OK # CBOR-encoded "" is a single byte (text string of length 0): 0x60 check cborDecode(callbackBytes(d), string).value == "" test "sequential requests are all processed": var pool: FFIContextPool[TestLib] let ctx = pool.createFFIContext().valueOr: check false return defer: discard pool.destroyFFIContext(ctx) for i in 1 .. 5: var d: CallbackData initCallbackData(d) let msg = "msg" & $i check sendRequestToFFIThread( ctx, PingRequest.ffiNewReq(testCallback, addr d, msg.cstring) ) .isOk() waitCallback(d) deinitCallbackData(d) check d.retCode == RET_OK check cborDecode(callbackBytes(d), string).value == "pong:" & msg # --------------------------------------------------------------------------- # ffiCtor / .ffi. macros — exercise the full CBOR transport # --------------------------------------------------------------------------- type SimpleLib = object value: int type SimpleConfig {.ffi.} = object initialValue: int proc testlib_create*( config: SimpleConfig ): Future[Result[SimpleLib, string]] {.ffiCtor.} = return ok(SimpleLib(value: config.initialValue)) proc encodedPtr(bytes: var seq[byte]): ptr byte = if bytes.len == 0: nil else: cast[ptr byte](addr bytes[0]) proc ctorAddrFromCbor(bytes: seq[byte]): uint = ## The ctor success payload is a CBOR text string of the decimal address. let addrStr = cborDecode(bytes, string).valueOr: return 0 cast[uint](parseBiggestUInt(addrStr)) suite "ffiCtor macro": test "creates context and returns pointer via callback": var d: CallbackData initCallbackData(d) defer: deinitCallbackData(d) var cfg = cborEncode(TestlibCreateCtorReq(config: SimpleConfig(initialValue: 42))) let ret = testlib_createCborCtorExport(encodedPtr(cfg), cfg.len.csize_t, testCallback, addr d) check not ret.isNil() waitCallback(d) check d.retCode == RET_OK let ctxAddr = ctorAddrFromCbor(callbackBytes(d)) check ctxAddr != 0 let ctx = cast[ptr FFIContext[SimpleLib]](ctxAddr) check not ctx[].myLib.isNil check ctx[].myLib[].value == 42 check SimpleLibFFIPool.destroyFFIContext(ctx).isOk() # --------------------------------------------------------------------------- # Simplified .ffi. macro integration test # --------------------------------------------------------------------------- type SendConfig {.ffi.} = object message: string proc testlib_send*( lib: SimpleLib, cfg: SendConfig ): Future[Result[string, string]] {.ffi.} = return ok("echo:" & cfg.message & ":" & $lib.value) suite "simplified .ffi. macro": test "sends request and gets serialized response via callback": var ctorD: CallbackData initCallbackData(ctorD) defer: deinitCallbackData(ctorD) var cfg = cborEncode(TestlibCreateCtorReq(config: SimpleConfig(initialValue: 7))) let ctorRet = testlib_createCborCtorExport(encodedPtr(cfg), cfg.len.csize_t, testCallback, addr ctorD) check not ctorRet.isNil() waitCallback(ctorD) check ctorD.retCode == RET_OK let ctxAddr = ctorAddrFromCbor(callbackBytes(ctorD)) check ctxAddr != 0 let ctx = cast[ptr FFIContext[SimpleLib]](ctxAddr) defer: check SimpleLibFFIPool.destroyFFIContext(ctx).isOk() var d: CallbackData initCallbackData(d) defer: deinitCallbackData(d) # The .ffi. macro packs all extra params into one CBOR Req struct. var reqBytes = cborEncode(TestlibSendReq(cfg: SendConfig(message: "hello"))) let ret = testlib_sendCborExport( ctx, testCallback, addr d, encodedPtr(reqBytes), reqBytes.len.csize_t ) check ret == RET_OK waitCallback(d) check d.retCode == RET_OK check cborDecode(callbackBytes(d), string).value == "echo:hello:7" proc testlib_version*(lib: SimpleLib): Future[Result[string, string]] {.ffi.} = return ok("v" & $lib.value) suite "sync-body .ffi. is dispatched on FFI thread": ## Before PR #23 (items 1–5), a `.ffi.` body without `await` was emitted as ## an inline-on-foreign-thread fast path. That was deleted; all `.ffi.` ## procs now go through the FFI thread. This test asserts the round-trip ## still produces the expected payload via the callback. test "sync body still produces correct payload via callback": var ctorD: CallbackData initCallbackData(ctorD) defer: deinitCallbackData(ctorD) var cfg = cborEncode(TestlibCreateCtorReq(config: SimpleConfig(initialValue: 3))) let ctorRet = testlib_createCborCtorExport(encodedPtr(cfg), cfg.len.csize_t, testCallback, addr ctorD) check not ctorRet.isNil() waitCallback(ctorD) check ctorD.retCode == RET_OK let ctxAddr = ctorAddrFromCbor(callbackBytes(ctorD)) check ctxAddr != 0 let ctx = cast[ptr FFIContext[SimpleLib]](ctxAddr) defer: check SimpleLibFFIPool.destroyFFIContext(ctx).isOk() var d2: CallbackData initCallbackData(d2) defer: deinitCallbackData(d2) # No-extra-param .ffi. proc; pack an empty Req. var emptyBytes = cborEncode(TestlibVersionReq()) let ret = testlib_versionCborExport( ctx, testCallback, addr d2, encodedPtr(emptyBytes), emptyBytes.len.csize_t ) check ret == RET_OK waitCallback(d2) # always asynchronous now check d2.retCode == RET_OK check cborDecode(callbackBytes(d2), string).value == "v3" # --------------------------------------------------------------------------- # Nim-native API (no callbacks, no CBOR buffers): the original proc name # resolves to the user's declared async signature and is callable directly. # --------------------------------------------------------------------------- suite "Nim-native .ffi. / .ffiCtor. API": test "user proc names retain their declared Future[Result[T,string]] shape": let lib = SimpleLib(value: 9) # Async {.ffi.} proc — call directly without ctx/callback dance. let echoed = waitFor testlib_send(lib, SendConfig(message: "direct")) check echoed.isOk check echoed.value == "echo:direct:9" # Sync {.ffi.} body — still typed as Future[Result[T,string]] per the # user's source-level declaration (b): completed-future wrapper. let v = waitFor testlib_version(lib) check v.isOk check v.value == "v9" # The ctor body is similarly callable from Nim with its declared signature. let ctorRes = waitFor testlib_create(SimpleConfig(initialValue: 21)) check ctorRes.isOk check ctorRes.value.value == 21 # --------------------------------------------------------------------------- # Regression for PR #23 review items 1–5: a `.ffi.` body without `await` # used to be emitted as an inline-on-foreign-thread fast path, which bypassed # `foreignThreadGc`, `ctx.lock`, and chronos's single-thread invariant. The # sync fast-path was deleted; this test records `getThreadId()` inside a # sync body and asserts the handler runs on the FFI thread, not on the # caller's thread. # --------------------------------------------------------------------------- var gRecordedHandlerTid: Atomic[int] type RecordTidReq {.ffi.} = object dummy: int proc testlib_record_tid*( lib: SimpleLib, req: RecordTidReq ): Future[Result[int, string]] {.ffi.} = ## Sync body — used to live on the inline fast-path; must now run on the ## FFI thread. let tid = getThreadId() gRecordedHandlerTid.store(tid) return ok(tid) suite "sync-body .ffi. runs on FFI thread (PR #23 regression)": test "handler thread id differs from caller's": var ctorD: CallbackData initCallbackData(ctorD) defer: deinitCallbackData(ctorD) var cfg = cborEncode(TestlibCreateCtorReq(config: SimpleConfig(initialValue: 0))) let ctorRet = testlib_createCborCtorExport(encodedPtr(cfg), cfg.len.csize_t, testCallback, addr ctorD) check not ctorRet.isNil() waitCallback(ctorD) check ctorD.retCode == RET_OK let ctxAddr = ctorAddrFromCbor(callbackBytes(ctorD)) check ctxAddr != 0 let ctx = cast[ptr FFIContext[SimpleLib]](ctxAddr) defer: check SimpleLibFFIPool.destroyFFIContext(ctx).isOk() gRecordedHandlerTid.store(0) let callerTid = getThreadId() var d: CallbackData initCallbackData(d) defer: deinitCallbackData(d) var reqBytes = cborEncode(TestlibRecordTidReq(req: RecordTidReq(dummy: 1))) let ret = testlib_record_tidCborExport( ctx, testCallback, addr d, encodedPtr(reqBytes), reqBytes.len.csize_t ) check ret == RET_OK waitCallback(d) check d.retCode == RET_OK let handlerTid = gRecordedHandlerTid.load() check handlerTid != 0 # The whole point of the fix: even a sync-body handler is dispatched off # the caller thread. If this fails the inline fast-path is back. check handlerTid != callerTid # And the callback payload (the recorded tid) matches what the handler stored. check cborDecode(callbackBytes(d), int).value == handlerTid # --------------------------------------------------------------------------- # Regression for PR #23 review item 6: reentrancy guard on # sendRequestToFFIThread. A handler running on the FFI thread that tries to # dispatch back through sendRequestToFFIThread used to self-deadlock waiting # on `reqReceivedSignal` (which only the FFI thread can fire). The guard now # returns an Err immediately. # --------------------------------------------------------------------------- var gReentrantNestedRes: Channel[string] gReentrantNestedRes.open() # Handler runs on the FFI thread; it nests a send back into the same ctx and # reports the outcome through gReentrantNestedRes. Carrying the ctx address # via the request payload sidesteps the cross-thread visibility issue of # thread-local pointers. registerReqFFI(ReentrantTriggerReq, lib: ptr TestLib): proc(ctxAddr: int): Future[Result[string, string]] {.async.} = let ctx = cast[ptr FFIContext[TestLib]](cast[uint](ctxAddr)) var nestedD: CallbackData initCallbackData(nestedD) defer: deinitCallbackData(nestedD) let res = sendRequestToFFIThread( ctx, PingRequest.ffiNewReq(testCallback, addr nestedD, "x".cstring) ) if res.isErr(): try: gReentrantNestedRes.send("err:" & res.error) except Exception as exc: return err("channel.send raised: " & exc.msg) return ok("guard-fired") try: gReentrantNestedRes.send("ok-unexpected") except Exception as exc: return err("channel.send raised: " & exc.msg) return ok("ok-unexpected") suite "reentrancy guard (PR #23 review, item 6)": test "send from inside an FFI handler returns Err instead of deadlocking": var pool: FFIContextPool[TestLib] let ctx = pool.createFFIContext().valueOr: check false return defer: discard pool.destroyFFIContext(ctx) var d: CallbackData initCallbackData(d) defer: deinitCallbackData(d) let ctxAddrInt = cast[int](cast[uint](ctx)) check sendRequestToFFIThread( ctx, ReentrantTriggerReq.ffiNewReq(testCallback, addr d, ctxAddrInt) ) .isOk() # The outer callback only fires once the handler — including its nested # send attempt — has finished. No polling/sleep needed. waitCallback(d) check d.retCode == RET_OK check cborDecode(callbackBytes(d), string).value == "guard-fired" let nestedMsg = gReentrantNestedRes.recv() check nestedMsg.startsWith("err:") check "reentrant ffi call" in nestedMsg