nim-ffi/tests/unit/test_host_callbacks.nim
Ivan FB c90200de2b
feat(host): FFIContext completion bridge for {.ffiHost.}
Increment 2: wires the host-call machinery into the running FFI thread so a
host answer (delivered from any thread) resolves the chronos Future an awaiting
handler is blocked on.

- FFICompletionQueue (ffi_host.nim): a GC-free intrusive queue. host_complete
  pushes c_malloc'd nodes from any thread; the FFI thread drains, copies the
  payload into GC memory, completes the future by token, and frees the node.
- FFIContext gains hostRegistry / pendingTable / completionQueue, init'd and
  deinit'd alongside the event registry.
- completeHostCall parks the answer and fires the EXISTING reqSignal — no second
  ThreadSignalPtr needed; the loop drains completions every iteration, on the
  loop thread (chronos single-thread invariant).
- On shutdown the loop failAllPendings first, so a handler awaiting a host
  answer that never arrives can't hang the allFutures(pending) drain.

4 new queue unit tests (10 total) pass under orc+refc; the 19 ffi_context
integration tests stay green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 22:28:58 +02:00

174 lines
5.6 KiB
Nim

## Unit tests for the host-callback primitives (`FFIHostRegistry` /
## `FFIPendingTable`) that back `{.ffiHost.}` — roadmap item #1, increment 1
## (see docs/design-host-callbacks.md).
##
## These exercise the data structures directly: no FFI thread, no macro, no
## completion bridge. They pin down registration, lookup, token allocation, and
## future completion semantics in isolation.
import std/locks
import unittest2
import chronos
import ffi
# A host fn does nothing here — we only assert it round-trips through the
# registry. `userData` carries a tag we read back to prove identity.
proc noopHostFn(
token: uint64, req: ptr cchar, reqLen: csize_t, userData: pointer
) {.cdecl, gcsafe, raises: [].} =
discard
proc bytesToStr(b: seq[byte]): string =
var s = newString(b.len)
if b.len > 0:
copyMem(addr s[0], unsafeAddr b[0], b.len)
return s
suite "FFIHostRegistry":
test "register, lookup, replace, and remove":
var reg: FFIHostRegistry
initHostRegistry(reg)
defer:
deinitHostRegistry(reg)
var tag = 42
check registerHostFn(reg, "fetch_profile", noopHostFn, addr tag)
let hit = lookupHostFn(reg, "fetch_profile")
check hit.found
check hit.userData == addr tag
check not hit.fn.isNil()
# missing name -> found == false (never a crash)
check not lookupHostFn(reg, "does_not_exist").found
# nil fn unregisters and reports false
check not registerHostFn(reg, "fetch_profile", nil, nil)
check not lookupHostFn(reg, "fetch_profile").found
test "clear drops every registration":
var reg: FFIHostRegistry
initHostRegistry(reg)
defer:
deinitHostRegistry(reg)
check registerHostFn(reg, "a", noopHostFn, nil)
check registerHostFn(reg, "b", noopHostFn, nil)
clearHostFns(reg)
check not lookupHostFn(reg, "a").found
check not lookupHostFn(reg, "b").found
suite "FFIPendingTable":
test "tokens are monotonic and start at 1":
var tbl: FFIPendingTable
initPendingTable(tbl)
defer:
deinitPendingTable(tbl)
let a = newPending(tbl)
let b = newPending(tbl)
check a.token == 1'u64
check b.token == 2'u64
check tbl.pendingCount == 2
test "completePending resolves the awaiting future and removes it":
var tbl: FFIPendingTable
initPendingTable(tbl)
defer:
deinitPendingTable(tbl)
let p = newPending(tbl)
check completePending(tbl, p.token, okResult(@[byte 1, 2, 3]))
check p.fut.finished()
check waitFor(p.fut).ret == RET_OK
check waitFor(p.fut).bytes == @[byte 1, 2, 3]
check tbl.pendingCount == 0
test "unknown or double completion is dropped, not fatal":
var tbl: FFIPendingTable
initPendingTable(tbl)
defer:
deinitPendingTable(tbl)
check not completePending(tbl, 999'u64, okResult(@[]))
let p = newPending(tbl)
check completePending(tbl, p.token, okResult(@[]))
check not completePending(tbl, p.token, okResult(@[])) # second time: dropped
test "failAllPending errors every outstanding future":
var tbl: FFIPendingTable
initPendingTable(tbl)
defer:
deinitPendingTable(tbl)
let p1 = newPending(tbl)
let p2 = newPending(tbl)
failAllPending(tbl, "context shutting down")
check p1.fut.finished()
check p2.fut.finished()
let r = waitFor(p1.fut)
check r.ret == RET_ERR
check bytesToStr(r.bytes) == "context shutting down"
check tbl.pendingCount == 0
# `pushCompletion` takes the raw (msg, len) a host hands across the C ABI.
proc pushStr(q: var FFICompletionQueue, token: uint64, ret: cint, s: string) =
if s.len == 0:
pushCompletion(q, token, ret, nil, 0)
else:
pushCompletion(q, token, ret, cast[ptr cchar](unsafeAddr s[0]), csize_t(s.len))
suite "FFICompletionQueue":
test "drain resolves pending futures by token, in FIFO order":
var tbl: FFIPendingTable
var q: FFICompletionQueue
initPendingTable(tbl)
initCompletionQueue(q)
defer:
deinitPendingTable(tbl)
deinitCompletionQueue(q)
let a = newPending(tbl) # token 1
let b = newPending(tbl) # token 2
pushStr(q, a.token, RET_OK, "alpha")
pushStr(q, b.token, RET_ERR, "boom")
check drainCompletions(q, tbl) == 2
check bytesToStr(waitFor(a.fut).bytes) == "alpha"
check waitFor(a.fut).ret == RET_OK
check bytesToStr(waitFor(b.fut).bytes) == "boom"
check waitFor(b.fut).ret == RET_ERR
check tbl.pendingCount == 0
test "empty payload and empty queue drain cleanly":
var tbl: FFIPendingTable
var q: FFICompletionQueue
initPendingTable(tbl)
initCompletionQueue(q)
defer:
deinitPendingTable(tbl)
deinitCompletionQueue(q)
check drainCompletions(q, tbl) == 0 # nothing queued
let p = newPending(tbl)
pushStr(q, p.token, RET_OK, "") # empty (nil buf) payload
check drainCompletions(q, tbl) == 1
check waitFor(p.fut).bytes.len == 0
test "completion for an unknown token is drained and dropped":
var tbl: FFIPendingTable
var q: FFICompletionQueue
initPendingTable(tbl)
initCompletionQueue(q)
defer:
deinitPendingTable(tbl)
deinitCompletionQueue(q)
pushStr(q, 999'u64, RET_OK, "orphan") # no pending future for this token
check drainCompletions(q, tbl) == 1 # drained (and its buffer freed)
test "deinit frees still-queued nodes without draining":
var tbl: FFIPendingTable
var q: FFICompletionQueue
initPendingTable(tbl)
initCompletionQueue(q)
let p = newPending(tbl)
pushStr(q, p.token, RET_OK, "leftover")
failAllPending(tbl, "shutdown") # the future is settled separately
deinitPendingTable(tbl)
deinitCompletionQueue(q) # must free the queued node, no leak/crash
check waitFor(p.fut).ret == RET_ERR