2026-05-16 01:08:42 +02:00
|
|
|
|
import std/[locks, options, strutils, os, atomics]
|
2026-04-27 21:22:45 +02:00
|
|
|
|
import unittest2
|
|
|
|
|
|
import results
|
2026-05-19 12:43:34 +02:00
|
|
|
|
import ffi
|
2026-04-27 21:22:45 +02:00
|
|
|
|
|
|
|
|
|
|
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
|
2026-05-16 01:08:42 +02:00
|
|
|
|
msg: array[1024, byte]
|
2026-04-27 21:22:45 +02:00
|
|
|
|
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
|
2026-05-16 01:08:42 +02:00
|
|
|
|
let n = min(int(len), d[].msg.len)
|
2026-04-27 21:22:45 +02:00
|
|
|
|
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)
|
|
|
|
|
|
|
2026-05-16 01:08:42 +02:00
|
|
|
|
proc callbackBytes(d: var CallbackData): seq[byte] =
|
|
|
|
|
|
var bytes = newSeq[byte](d.msgLen)
|
2026-04-27 21:22:45 +02:00
|
|
|
|
if d.msgLen > 0:
|
2026-05-16 01:08:42 +02:00
|
|
|
|
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
|
2026-04-27 21:22:45 +02:00
|
|
|
|
|
|
|
|
|
|
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("")
|
|
|
|
|
|
|
2026-05-11 23:28:17 +02:00
|
|
|
|
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
|
2026-05-16 01:08:42 +02:00
|
|
|
|
node.next = nil
|
2026-05-11 23:28:17 +02:00
|
|
|
|
node = nxt
|
|
|
|
|
|
await sleepAsync(10.milliseconds)
|
|
|
|
|
|
return ok("heavy-done")
|
|
|
|
|
|
|
2026-05-13 00:02:23 +02:00
|
|
|
|
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()
|
2026-05-16 01:08:42 +02:00
|
|
|
|
check ctx1 == ctx2
|
2026-05-13 00:02:23 +02:00
|
|
|
|
|
|
|
|
|
|
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
|
2026-05-16 01:08:42 +02:00
|
|
|
|
check cborDecode(callbackBytes(d), string).value == "pong:pool"
|
2026-05-13 00:02:23 +02:00
|
|
|
|
|
2026-04-27 21:22:45 +02:00
|
|
|
|
suite "createFFIContext / destroyFFIContext":
|
|
|
|
|
|
test "create and destroy succeeds":
|
2026-05-13 00:02:23 +02:00
|
|
|
|
var pool: FFIContextPool[TestLib]
|
|
|
|
|
|
let ctx = pool.createFFIContext().valueOr:
|
2026-04-27 21:22:45 +02:00
|
|
|
|
checkpoint "createFFIContext failed: " & $error
|
|
|
|
|
|
check false
|
|
|
|
|
|
return
|
2026-05-13 00:02:23 +02:00
|
|
|
|
check pool.destroyFFIContext(ctx).isOk()
|
2026-04-27 21:22:45 +02:00
|
|
|
|
|
|
|
|
|
|
test "double destroy is safe via running flag":
|
2026-05-13 00:02:23 +02:00
|
|
|
|
var pool: FFIContextPool[TestLib]
|
|
|
|
|
|
let ctx = pool.createFFIContext().valueOr:
|
2026-04-27 21:22:45 +02:00
|
|
|
|
check false
|
|
|
|
|
|
return
|
2026-05-13 00:02:23 +02:00
|
|
|
|
check pool.destroyFFIContext(ctx).isOk()
|
2026-04-27 21:22:45 +02:00
|
|
|
|
|
2026-05-11 23:28:17 +02:00
|
|
|
|
suite "destroyFFIContext does not hang":
|
|
|
|
|
|
test "destroy while a slow async request is still in-flight":
|
2026-05-13 00:02:23 +02:00
|
|
|
|
var pool: FFIContextPool[TestLib]
|
|
|
|
|
|
let ctx = pool.createFFIContext().valueOr:
|
2026-05-11 23:28:17 +02:00
|
|
|
|
check false
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
var d: CallbackData
|
|
|
|
|
|
initCallbackData(d)
|
2026-05-16 01:08:42 +02:00
|
|
|
|
defer:
|
|
|
|
|
|
deinitCallbackData(d)
|
2026-05-11 23:28:17 +02:00
|
|
|
|
|
2026-05-16 01:08:42 +02:00
|
|
|
|
check sendRequestToFFIThread(ctx, SlowRequest.ffiNewReq(testCallback, addr d)).isOk()
|
2026-05-11 23:28:17 +02:00
|
|
|
|
|
|
|
|
|
|
let t0 = Moment.now()
|
2026-05-13 00:02:23 +02:00
|
|
|
|
check pool.destroyFFIContext(ctx).isOk()
|
2026-05-11 23:28:17 +02:00
|
|
|
|
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":
|
2026-05-13 00:02:23 +02:00
|
|
|
|
var pool: FFIContextPool[TestLib]
|
|
|
|
|
|
let ctx = pool.createFFIContext().valueOr:
|
2026-05-11 23:28:17 +02:00
|
|
|
|
check false
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
let d = createShared(CallbackData)
|
|
|
|
|
|
initCallbackData(d[])
|
|
|
|
|
|
|
2026-05-16 01:08:42 +02:00
|
|
|
|
check sendRequestToFFIThread(ctx, SyncBlockingRequest.ffiNewReq(testCallback, d))
|
|
|
|
|
|
.isOk()
|
2026-05-11 23:28:17 +02:00
|
|
|
|
|
|
|
|
|
|
discard gSyncBlockStarted.recv()
|
|
|
|
|
|
|
|
|
|
|
|
let t0 = Moment.now()
|
2026-05-13 00:02:23 +02:00
|
|
|
|
check pool.destroyFFIContext(ctx).isErr()
|
2026-05-11 23:28:17 +02:00
|
|
|
|
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":
|
2026-05-13 00:02:23 +02:00
|
|
|
|
var pool: FFIContextPool[TestLib]
|
|
|
|
|
|
let ctx = pool.createFFIContext().valueOr:
|
2026-05-11 23:28:17 +02:00
|
|
|
|
check false
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
var d: CallbackData
|
|
|
|
|
|
initCallbackData(d)
|
2026-05-16 01:08:42 +02:00
|
|
|
|
defer:
|
|
|
|
|
|
deinitCallbackData(d)
|
2026-05-11 23:28:17 +02:00
|
|
|
|
|
|
|
|
|
|
check sendRequestToFFIThread(
|
|
|
|
|
|
ctx, HeavyRefAllocRequest.ffiNewReq(testCallback, addr d)
|
2026-05-16 01:08:42 +02:00
|
|
|
|
)
|
|
|
|
|
|
.isOk()
|
2026-05-11 23:28:17 +02:00
|
|
|
|
waitCallback(d)
|
|
|
|
|
|
check d.retCode == RET_OK
|
|
|
|
|
|
|
|
|
|
|
|
let t0 = Moment.now()
|
2026-05-13 00:02:23 +02:00
|
|
|
|
check pool.destroyFFIContext(ctx).isOk()
|
2026-05-11 23:28:17 +02:00
|
|
|
|
check (Moment.now() - t0) < 3.seconds
|
|
|
|
|
|
|
2026-04-27 21:22:45 +02:00
|
|
|
|
suite "sendRequestToFFIThread":
|
|
|
|
|
|
test "successful request triggers RET_OK callback":
|
|
|
|
|
|
var d: CallbackData
|
|
|
|
|
|
initCallbackData(d)
|
2026-05-13 00:02:23 +02:00
|
|
|
|
defer:
|
|
|
|
|
|
deinitCallbackData(d)
|
2026-04-27 21:22:45 +02:00
|
|
|
|
|
2026-05-13 00:02:23 +02:00
|
|
|
|
var pool: FFIContextPool[TestLib]
|
|
|
|
|
|
let ctx = pool.createFFIContext().valueOr:
|
2026-04-27 21:22:45 +02:00
|
|
|
|
check false
|
|
|
|
|
|
return
|
2026-05-13 00:02:23 +02:00
|
|
|
|
defer:
|
|
|
|
|
|
discard pool.destroyFFIContext(ctx)
|
2026-04-27 21:22:45 +02:00
|
|
|
|
|
2026-05-13 00:02:23 +02:00
|
|
|
|
check sendRequestToFFIThread(
|
|
|
|
|
|
ctx, PingRequest.ffiNewReq(testCallback, addr d, "hello".cstring)
|
|
|
|
|
|
)
|
|
|
|
|
|
.isOk()
|
2026-04-27 21:22:45 +02:00
|
|
|
|
waitCallback(d)
|
|
|
|
|
|
check d.retCode == RET_OK
|
2026-05-16 01:08:42 +02:00
|
|
|
|
check cborDecode(callbackBytes(d), string).value == "pong:hello"
|
2026-04-27 21:22:45 +02:00
|
|
|
|
|
|
|
|
|
|
test "failing request triggers RET_ERR callback":
|
|
|
|
|
|
var d: CallbackData
|
|
|
|
|
|
initCallbackData(d)
|
2026-05-13 00:02:23 +02:00
|
|
|
|
defer:
|
|
|
|
|
|
deinitCallbackData(d)
|
2026-04-27 21:22:45 +02:00
|
|
|
|
|
2026-05-13 00:02:23 +02:00
|
|
|
|
var pool: FFIContextPool[TestLib]
|
|
|
|
|
|
let ctx = pool.createFFIContext().valueOr:
|
2026-04-27 21:22:45 +02:00
|
|
|
|
check false
|
|
|
|
|
|
return
|
2026-05-13 00:02:23 +02:00
|
|
|
|
defer:
|
|
|
|
|
|
discard pool.destroyFFIContext(ctx)
|
2026-04-27 21:22:45 +02:00
|
|
|
|
|
|
|
|
|
|
check sendRequestToFFIThread(ctx, FailRequest.ffiNewReq(testCallback, addr d)).isOk()
|
|
|
|
|
|
waitCallback(d)
|
|
|
|
|
|
check d.retCode == RET_ERR
|
2026-05-16 01:08:42 +02:00
|
|
|
|
# Errors are raw UTF-8 — not CBOR.
|
|
|
|
|
|
check callbackErr(d) == "intentional failure"
|
2026-04-27 21:22:45 +02:00
|
|
|
|
|
|
|
|
|
|
test "empty ok response delivers empty message":
|
|
|
|
|
|
var d: CallbackData
|
|
|
|
|
|
initCallbackData(d)
|
2026-05-13 00:02:23 +02:00
|
|
|
|
defer:
|
|
|
|
|
|
deinitCallbackData(d)
|
2026-04-27 21:22:45 +02:00
|
|
|
|
|
2026-05-13 00:02:23 +02:00
|
|
|
|
var pool: FFIContextPool[TestLib]
|
|
|
|
|
|
let ctx = pool.createFFIContext().valueOr:
|
2026-04-27 21:22:45 +02:00
|
|
|
|
check false
|
|
|
|
|
|
return
|
2026-05-13 00:02:23 +02:00
|
|
|
|
defer:
|
|
|
|
|
|
discard pool.destroyFFIContext(ctx)
|
2026-04-27 21:22:45 +02:00
|
|
|
|
|
2026-05-13 00:02:23 +02:00
|
|
|
|
check sendRequestToFFIThread(ctx, EmptyOkRequest.ffiNewReq(testCallback, addr d))
|
|
|
|
|
|
.isOk()
|
2026-04-27 21:22:45 +02:00
|
|
|
|
waitCallback(d)
|
|
|
|
|
|
check d.retCode == RET_OK
|
2026-05-16 01:08:42 +02:00
|
|
|
|
# CBOR-encoded "" is a single byte (text string of length 0): 0x60
|
|
|
|
|
|
check cborDecode(callbackBytes(d), string).value == ""
|
2026-04-27 21:22:45 +02:00
|
|
|
|
|
|
|
|
|
|
test "sequential requests are all processed":
|
2026-05-13 00:02:23 +02:00
|
|
|
|
var pool: FFIContextPool[TestLib]
|
|
|
|
|
|
let ctx = pool.createFFIContext().valueOr:
|
2026-04-27 21:22:45 +02:00
|
|
|
|
check false
|
|
|
|
|
|
return
|
2026-05-13 00:02:23 +02:00
|
|
|
|
defer:
|
|
|
|
|
|
discard pool.destroyFFIContext(ctx)
|
2026-04-27 21:22:45 +02:00
|
|
|
|
|
|
|
|
|
|
for i in 1 .. 5:
|
|
|
|
|
|
var d: CallbackData
|
|
|
|
|
|
initCallbackData(d)
|
|
|
|
|
|
let msg = "msg" & $i
|
2026-05-13 00:02:23 +02:00
|
|
|
|
check sendRequestToFFIThread(
|
|
|
|
|
|
ctx, PingRequest.ffiNewReq(testCallback, addr d, msg.cstring)
|
|
|
|
|
|
)
|
|
|
|
|
|
.isOk()
|
2026-04-27 21:22:45 +02:00
|
|
|
|
waitCallback(d)
|
|
|
|
|
|
deinitCallbackData(d)
|
|
|
|
|
|
check d.retCode == RET_OK
|
2026-05-16 01:08:42 +02:00
|
|
|
|
check cborDecode(callbackBytes(d), string).value == "pong:" & msg
|
2026-05-11 23:28:17 +02:00
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
2026-05-16 01:08:42 +02:00
|
|
|
|
# ffiCtor / .ffi. macros — exercise the full CBOR transport
|
2026-05-11 23:28:17 +02:00
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
type SimpleLib = object
|
|
|
|
|
|
value: int
|
|
|
|
|
|
|
2026-05-13 14:48:54 +02:00
|
|
|
|
type SimpleConfig {.ffi.} = object
|
|
|
|
|
|
initialValue: int
|
2026-05-11 23:28:17 +02:00
|
|
|
|
|
|
|
|
|
|
proc testlib_create*(
|
|
|
|
|
|
config: SimpleConfig
|
|
|
|
|
|
): Future[Result[SimpleLib, string]] {.ffiCtor.} =
|
|
|
|
|
|
return ok(SimpleLib(value: config.initialValue))
|
|
|
|
|
|
|
2026-05-16 01:08:42 +02:00
|
|
|
|
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))
|
|
|
|
|
|
|
2026-05-11 23:28:17 +02:00
|
|
|
|
suite "ffiCtor macro":
|
|
|
|
|
|
test "creates context and returns pointer via callback":
|
|
|
|
|
|
var d: CallbackData
|
|
|
|
|
|
initCallbackData(d)
|
2026-05-16 01:08:42 +02:00
|
|
|
|
defer:
|
|
|
|
|
|
deinitCallbackData(d)
|
2026-05-11 23:28:17 +02:00
|
|
|
|
|
2026-05-16 01:08:42 +02:00
|
|
|
|
var cfg = cborEncode(TestlibCreateCtorReq(config: SimpleConfig(initialValue: 42)))
|
2026-05-31 10:40:54 +02:00
|
|
|
|
let ret = testlib_createCborCtorExport(encodedPtr(cfg), cfg.len.csize_t, testCallback, addr d)
|
2026-05-11 23:28:17 +02:00
|
|
|
|
|
|
|
|
|
|
check not ret.isNil()
|
|
|
|
|
|
|
|
|
|
|
|
waitCallback(d)
|
|
|
|
|
|
check d.retCode == RET_OK
|
|
|
|
|
|
|
2026-05-16 01:08:42 +02:00
|
|
|
|
let ctxAddr = ctorAddrFromCbor(callbackBytes(d))
|
2026-05-11 23:28:17 +02:00
|
|
|
|
check ctxAddr != 0
|
|
|
|
|
|
let ctx = cast[ptr FFIContext[SimpleLib]](ctxAddr)
|
|
|
|
|
|
|
|
|
|
|
|
check not ctx[].myLib.isNil
|
|
|
|
|
|
check ctx[].myLib[].value == 42
|
|
|
|
|
|
|
2026-05-13 00:02:23 +02:00
|
|
|
|
check SimpleLibFFIPool.destroyFFIContext(ctx).isOk()
|
2026-05-11 23:28:17 +02:00
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# Simplified .ffi. macro integration test
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
2026-05-13 14:48:54 +02:00
|
|
|
|
type SendConfig {.ffi.} = object
|
|
|
|
|
|
message: string
|
2026-05-11 23:28:17 +02:00
|
|
|
|
|
|
|
|
|
|
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)
|
2026-05-16 01:08:42 +02:00
|
|
|
|
defer:
|
|
|
|
|
|
deinitCallbackData(ctorD)
|
2026-05-11 23:28:17 +02:00
|
|
|
|
|
2026-05-16 01:08:42 +02:00
|
|
|
|
var cfg = cborEncode(TestlibCreateCtorReq(config: SimpleConfig(initialValue: 7)))
|
|
|
|
|
|
let ctorRet =
|
2026-05-31 10:40:54 +02:00
|
|
|
|
testlib_createCborCtorExport(encodedPtr(cfg), cfg.len.csize_t, testCallback, addr ctorD)
|
2026-05-11 23:28:17 +02:00
|
|
|
|
check not ctorRet.isNil()
|
|
|
|
|
|
|
|
|
|
|
|
waitCallback(ctorD)
|
|
|
|
|
|
check ctorD.retCode == RET_OK
|
|
|
|
|
|
|
2026-05-16 01:08:42 +02:00
|
|
|
|
let ctxAddr = ctorAddrFromCbor(callbackBytes(ctorD))
|
2026-05-11 23:28:17 +02:00
|
|
|
|
check ctxAddr != 0
|
|
|
|
|
|
let ctx = cast[ptr FFIContext[SimpleLib]](ctxAddr)
|
2026-05-16 01:08:42 +02:00
|
|
|
|
defer:
|
|
|
|
|
|
check SimpleLibFFIPool.destroyFFIContext(ctx).isOk()
|
2026-05-11 23:28:17 +02:00
|
|
|
|
|
|
|
|
|
|
var d: CallbackData
|
|
|
|
|
|
initCallbackData(d)
|
2026-05-16 01:08:42 +02:00
|
|
|
|
defer:
|
|
|
|
|
|
deinitCallbackData(d)
|
2026-05-11 23:28:17 +02:00
|
|
|
|
|
2026-05-16 01:08:42 +02:00
|
|
|
|
# The .ffi. macro packs all extra params into one CBOR Req struct.
|
|
|
|
|
|
var reqBytes = cborEncode(TestlibSendReq(cfg: SendConfig(message: "hello")))
|
2026-05-31 10:40:54 +02:00
|
|
|
|
let ret = testlib_sendCborExport(
|
2026-05-16 01:08:42 +02:00
|
|
|
|
ctx, testCallback, addr d, encodedPtr(reqBytes), reqBytes.len.csize_t
|
|
|
|
|
|
)
|
2026-05-11 23:28:17 +02:00
|
|
|
|
check ret == RET_OK
|
|
|
|
|
|
|
|
|
|
|
|
waitCallback(d)
|
|
|
|
|
|
check d.retCode == RET_OK
|
|
|
|
|
|
|
2026-05-16 01:08:42 +02:00
|
|
|
|
check cborDecode(callbackBytes(d), string).value == "echo:hello:7"
|
2026-05-11 23:28:17 +02:00
|
|
|
|
|
2026-05-16 01:08:42 +02:00
|
|
|
|
proc testlib_version*(lib: SimpleLib): Future[Result[string, string]] {.ffi.} =
|
2026-05-11 23:28:17 +02:00
|
|
|
|
return ok("v" & $lib.value)
|
|
|
|
|
|
|
2026-05-16 01:08:42 +02:00
|
|
|
|
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":
|
2026-05-11 23:28:17 +02:00
|
|
|
|
var ctorD: CallbackData
|
|
|
|
|
|
initCallbackData(ctorD)
|
2026-05-16 01:08:42 +02:00
|
|
|
|
defer:
|
|
|
|
|
|
deinitCallbackData(ctorD)
|
2026-05-11 23:28:17 +02:00
|
|
|
|
|
2026-05-16 01:08:42 +02:00
|
|
|
|
var cfg = cborEncode(TestlibCreateCtorReq(config: SimpleConfig(initialValue: 3)))
|
|
|
|
|
|
let ctorRet =
|
2026-05-31 10:40:54 +02:00
|
|
|
|
testlib_createCborCtorExport(encodedPtr(cfg), cfg.len.csize_t, testCallback, addr ctorD)
|
2026-05-11 23:28:17 +02:00
|
|
|
|
check not ctorRet.isNil()
|
|
|
|
|
|
|
|
|
|
|
|
waitCallback(ctorD)
|
|
|
|
|
|
check ctorD.retCode == RET_OK
|
|
|
|
|
|
|
2026-05-16 01:08:42 +02:00
|
|
|
|
let ctxAddr = ctorAddrFromCbor(callbackBytes(ctorD))
|
2026-05-11 23:28:17 +02:00
|
|
|
|
check ctxAddr != 0
|
|
|
|
|
|
let ctx = cast[ptr FFIContext[SimpleLib]](ctxAddr)
|
2026-05-16 01:08:42 +02:00
|
|
|
|
defer:
|
|
|
|
|
|
check SimpleLibFFIPool.destroyFFIContext(ctx).isOk()
|
2026-05-11 23:28:17 +02:00
|
|
|
|
|
|
|
|
|
|
var d2: CallbackData
|
|
|
|
|
|
initCallbackData(d2)
|
2026-05-16 01:08:42 +02:00
|
|
|
|
defer:
|
|
|
|
|
|
deinitCallbackData(d2)
|
2026-05-11 23:28:17 +02:00
|
|
|
|
|
2026-05-16 01:08:42 +02:00
|
|
|
|
# No-extra-param .ffi. proc; pack an empty Req.
|
|
|
|
|
|
var emptyBytes = cborEncode(TestlibVersionReq())
|
2026-05-31 10:40:54 +02:00
|
|
|
|
let ret = testlib_versionCborExport(
|
2026-05-16 01:08:42 +02:00
|
|
|
|
ctx, testCallback, addr d2, encodedPtr(emptyBytes), emptyBytes.len.csize_t
|
|
|
|
|
|
)
|
2026-05-11 23:28:17 +02:00
|
|
|
|
check ret == RET_OK
|
2026-05-16 01:08:42 +02:00
|
|
|
|
waitCallback(d2) # always asynchronous now
|
2026-05-11 23:28:17 +02:00
|
|
|
|
check d2.retCode == RET_OK
|
2026-05-16 01:08:42 +02:00
|
|
|
|
check cborDecode(callbackBytes(d2), string).value == "v3"
|
2026-05-11 23:28:17 +02:00
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
2026-05-16 01:08:42 +02:00
|
|
|
|
# Nim-native API (no callbacks, no CBOR buffers): the original proc name
|
|
|
|
|
|
# resolves to the user's declared async signature and is callable directly.
|
2026-05-11 23:28:17 +02:00
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
2026-05-16 01:08:42 +02:00
|
|
|
|
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
|
2026-05-11 23:28:17 +02:00
|
|
|
|
|
2026-05-16 01:08:42 +02:00
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 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.
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
2026-05-11 23:28:17 +02:00
|
|
|
|
|
2026-05-16 01:08:42 +02:00
|
|
|
|
var gRecordedHandlerTid: Atomic[int]
|
2026-05-11 23:28:17 +02:00
|
|
|
|
|
2026-05-16 01:08:42 +02:00
|
|
|
|
type RecordTidReq {.ffi.} = object
|
|
|
|
|
|
dummy: int
|
2026-05-11 23:28:17 +02:00
|
|
|
|
|
2026-05-16 01:08:42 +02:00
|
|
|
|
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)
|
2026-05-11 23:28:17 +02:00
|
|
|
|
|
2026-05-16 01:08:42 +02:00
|
|
|
|
suite "sync-body .ffi. runs on FFI thread (PR #23 regression)":
|
|
|
|
|
|
test "handler thread id differs from caller's":
|
2026-05-11 23:28:17 +02:00
|
|
|
|
var ctorD: CallbackData
|
|
|
|
|
|
initCallbackData(ctorD)
|
2026-05-16 01:08:42 +02:00
|
|
|
|
defer:
|
|
|
|
|
|
deinitCallbackData(ctorD)
|
2026-05-11 23:28:17 +02:00
|
|
|
|
|
2026-05-16 01:08:42 +02:00
|
|
|
|
var cfg = cborEncode(TestlibCreateCtorReq(config: SimpleConfig(initialValue: 0)))
|
|
|
|
|
|
let ctorRet =
|
2026-05-31 10:40:54 +02:00
|
|
|
|
testlib_createCborCtorExport(encodedPtr(cfg), cfg.len.csize_t, testCallback, addr ctorD)
|
2026-05-11 23:28:17 +02:00
|
|
|
|
check not ctorRet.isNil()
|
|
|
|
|
|
waitCallback(ctorD)
|
|
|
|
|
|
check ctorD.retCode == RET_OK
|
2026-05-16 01:08:42 +02:00
|
|
|
|
let ctxAddr = ctorAddrFromCbor(callbackBytes(ctorD))
|
2026-05-11 23:28:17 +02:00
|
|
|
|
check ctxAddr != 0
|
|
|
|
|
|
let ctx = cast[ptr FFIContext[SimpleLib]](ctxAddr)
|
2026-05-16 01:08:42 +02:00
|
|
|
|
defer:
|
|
|
|
|
|
check SimpleLibFFIPool.destroyFFIContext(ctx).isOk()
|
2026-05-11 23:28:17 +02:00
|
|
|
|
|
2026-05-16 01:08:42 +02:00
|
|
|
|
gRecordedHandlerTid.store(0)
|
|
|
|
|
|
let callerTid = getThreadId()
|
2026-05-11 23:28:17 +02:00
|
|
|
|
|
2026-05-16 01:08:42 +02:00
|
|
|
|
var d: CallbackData
|
|
|
|
|
|
initCallbackData(d)
|
|
|
|
|
|
defer:
|
|
|
|
|
|
deinitCallbackData(d)
|
2026-05-11 23:28:17 +02:00
|
|
|
|
|
2026-05-16 01:08:42 +02:00
|
|
|
|
var reqBytes = cborEncode(TestlibRecordTidReq(req: RecordTidReq(dummy: 1)))
|
2026-05-31 10:40:54 +02:00
|
|
|
|
let ret = testlib_record_tidCborExport(
|
2026-05-16 01:08:42 +02:00
|
|
|
|
ctx, testCallback, addr d, encodedPtr(reqBytes), reqBytes.len.csize_t
|
|
|
|
|
|
)
|
|
|
|
|
|
check ret == RET_OK
|
|
|
|
|
|
waitCallback(d)
|
|
|
|
|
|
check d.retCode == RET_OK
|
2026-05-11 23:28:17 +02:00
|
|
|
|
|
2026-05-16 01:08:42 +02:00
|
|
|
|
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
|
2026-05-11 23:28:17 +02:00
|
|
|
|
|
2026-05-16 01:08:42 +02:00
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 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.
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
2026-05-11 23:28:17 +02:00
|
|
|
|
|
2026-05-16 01:08:42 +02:00
|
|
|
|
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")
|
2026-05-11 23:28:17 +02:00
|
|
|
|
|
2026-05-16 01:08:42 +02:00
|
|
|
|
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:
|
2026-05-11 23:28:17 +02:00
|
|
|
|
check false
|
2026-05-16 01:08:42 +02:00
|
|
|
|
return
|
|
|
|
|
|
defer:
|
|
|
|
|
|
discard pool.destroyFFIContext(ctx)
|
|
|
|
|
|
|
|
|
|
|
|
var d: CallbackData
|
|
|
|
|
|
initCallbackData(d)
|
|
|
|
|
|
defer:
|
|
|
|
|
|
deinitCallbackData(d)
|
2026-05-11 23:28:17 +02:00
|
|
|
|
|
2026-05-16 01:08:42 +02:00
|
|
|
|
let ctxAddrInt = cast[int](cast[uint](ctx))
|
|
|
|
|
|
check sendRequestToFFIThread(
|
|
|
|
|
|
ctx, ReentrantTriggerReq.ffiNewReq(testCallback, addr d, ctxAddrInt)
|
|
|
|
|
|
)
|
|
|
|
|
|
.isOk()
|
2026-05-11 23:28:17 +02:00
|
|
|
|
|
2026-05-16 01:08:42 +02:00
|
|
|
|
# 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"
|
2026-05-11 23:28:17 +02:00
|
|
|
|
|
2026-05-16 01:08:42 +02:00
|
|
|
|
let nestedMsg = gReentrantNestedRes.recv()
|
|
|
|
|
|
check nestedMsg.startsWith("err:")
|
|
|
|
|
|
check "reentrant ffi call" in nestedMsg
|