chore: tidying up

This commit is contained in:
Gabriel Cruz 2026-07-29 13:44:41 -03:00
parent 41053a50a4
commit 4d0e566cdf
No known key found for this signature in database
GPG Key ID: 3C6977037D5A1EF5
10 changed files with 232 additions and 102 deletions

View File

@ -11,7 +11,19 @@ All notable changes to this project are documented in this file.
longer need a hand-written payload type. A single parameter still rides the
wire directly (a scalar, or an existing `{.ffi.}` object). The foreign
bindings gain the envelope as a first-class struct plus a typed handler.
- `{.ffiExport.}`, for simple synchronous C exports, from the 0.2 line.
- `{.ffiExport.}`, for simple synchronous C exports, from the 0.2 line. Each
wrapper carries `raises: []` and catches every exception of the body, because
an exception must not cross the C ABI. A return type with no C ABI mapping is
a compile error. `int` maps to `long long`, so a 64-bit value keeps its full
width. A `string` return rides in a buffer that belongs to the calling thread
and stays valid until that thread calls another string export.
- Pooled FFI contexts are recycled instead of destroyed. Each slot builds its
worker thread, its event thread and its signal fds once, then reuses them, so
repeated create/destroy no longer churns fds past `FD_SETSIZE`. The `ffiDtor`
asks the FFI thread to drain the in-flight handlers, free the library and
return the slot, while the threads stay alive. A recycle also fails every
request still queued for that slot, because such a request carries the
`userData` of a host that is gone.
- `{.ffi.}` now picks the path from the shape of the signature. One pragma
covers the context method, the static call, the synchronous export, the
destructor and the event. `ffi/internal/ffi_route.nim` holds the rules:

View File

@ -37,8 +37,8 @@ type FFIContext*[T] = object
# the FFI thread so the slot returns to the pool without recreating threads.
lifecycle*: Atomic[CtxLifecycle]
recycleDoneSignal: ThreadSignalPtr
# fired by the recycle handler once the lib is freed and the slot released;
# the synchronous recycleFFIContext caller waits on it.
# fired by the recycle handler once the lib is freed, just before it releases
# the slot; the synchronous recycleFFIContext caller waits on it.
libReady*: Atomic[bool]
# False until a {.ffiCtor.} stores the library. Before that, `myLib` points
# at the default fallback of the FFI thread. For a `ref` type that fallback
@ -67,10 +67,16 @@ var onFFIThread* {.threadvar.}: bool
const git_version* {.strdefine.} = "n/a"
const RecycleTimeoutMs* {.intdefine: "ffiRecycleTimeoutMs".} = 1500
## Bounds one drain round of the recycle handler. The handler runs at most two
## rounds: it waits for the in-flight handlers, then cancels them and waits
## again. Override with `-d:ffiRecycleTimeoutMs=<ms>`.
const RecycleTimeout* = RecycleTimeoutMs.milliseconds
const
RecycleWaitTimeout* = 5.seconds
## Caller-side bound for synchronous recycle; the FFI-thread drain itself is
## bounded by RecycleTimeout, so this only guards against a wedged worker.
RecycleWaitTimeout* = 2 * RecycleTimeout + 2.seconds
## Caller-side bound for synchronous recycle. It covers both drain rounds
## plus slack, so it only fires when the worker itself is wedged.
EventThreadTickInterval* = 1.seconds
FFIHeartbeatStartDelay* = 10.seconds
FFIHeartbeatStaleThreshold* = 1.seconds
@ -209,7 +215,7 @@ proc tryClaim*[T](ctx: ptr FFIContext[T]): bool =
var expected = false
ctx.inUse.compareExchange(expected, true)
proc release*[T](ctx: ptr FFIContext[T]) =
proc releaseClaim*[T](ctx: ptr FFIContext[T]) =
ctx.inUse.store(false)
proc isInUse*[T](ctx: ptr FFIContext[T]): bool =
@ -227,6 +233,10 @@ proc requestRecycle*[T](ctx: ptr FFIContext[T]): Result[void, string] =
if not ctx.lifecycle.compareExchange(expected, CtxLifecycle.RecyclePending):
return err("requestRecycle: context is not Active (already recycling)")
# A recycle that timed out can fire late. The CAS makes this the only recycle
# in flight, so drop that stale fire before the wait below can answer to it.
discard ctx.recycleDoneSignal.waitSync(ZeroDuration)
let fired = ctx.reqSignal.fireSync().valueOr:
return err("requestRecycle: failed to signal the FFI thread: " & $error)
if not fired:

View File

@ -29,7 +29,7 @@ proc releaseSlot[T](pool: var FFIContextPool[T], ctx: ptr FFIContext[T]) =
if pool.contexts[i].addr == ctx:
pool.initialized[i].store(false)
break
ctx.release()
ctx.releaseClaim()
proc createFFIContext*[T](
pool: var FFIContextPool[T]
@ -45,7 +45,7 @@ proc createFFIContext*[T](
ctx.markAsActive()
return ok(ctx)
initContextResources(ctx).isOkOr:
ctx.release()
ctx.releaseClaim()
return err("createFFIContext: initContextResources failed: " & $error)
pool.initialized[i].store(true)
return ok(ctx)
@ -138,5 +138,5 @@ proc isValidCtx*[T](pool: var FFIContextPool[T], ctx: pointer): bool =
return false
for i in 0 ..< MaxFFIContexts:
if cast[pointer](pool.contexts[i].addr) == ctx:
return cast[ptr FFIContext[T]](ctx).isInUse()
return pool.contexts[i].addr.isInUse()
false

View File

@ -86,10 +86,6 @@ proc processRequest[T](
except Exception as e:
error "Unexpected exception in handleRes", error = e.msg
const RecycleTimeout = 1500.milliseconds
## Bounds how long the recycle handler waits for in-flight handlers before it
## cancels them, so a wedged handler cannot block reuse forever.
proc freeLib[T](ctx: ptr FFIContext[T]) {.gcsafe.} =
## Releases the library object the ctor stored in ctx.myLib. Only owned libs
## (createShared'd by a ctor) are freed; the worker's stack fallback is not.
@ -102,8 +98,8 @@ proc freeLib[T](ctx: ptr FFIContext[T]) {.gcsafe.} =
try:
{.cast(gcsafe).}:
`=destroy`(ctx.myLib[])
except Exception:
discard
except Exception as e:
error "destroying the library on recycle raised; freeing it anyway", error = e.msg
else:
when T is ref:
if ctx.myLibRefd:
@ -113,6 +109,23 @@ proc freeLib[T](ctx: ptr FFIContext[T]) {.gcsafe.} =
ctx.myLib = nil
ctx.myLibOwned = false
const RecycledReason =
"FFI context was recycled before this request ran; the caller is gone"
proc rejectQueuedRequests[T](ctx: ptr FFIContext[T]) =
## Fails every queued request instead of dispatching it. A request that a
## destroyed context left behind still carries that host's `userData`, which
## the host has freed; running it would answer a dead callback, and running it
## after the slot is reused would run it against the library of the next owner.
var request = ctx.reqQueueBank.mergeQueues()
while not request.isNil():
let nextRequest = request[].next # read before handleRes frees it
try:
handleRes(Result[seq[byte], string].err(RecycledReason), request)
except Exception as e:
error "rejecting a queued request raised", error = e.msg
request = nextRequest
proc recycleContext[T](
ctx: ptr FFIContext[T], ongoing: ptr seq[Future[void]]
) {.async.} =
@ -125,18 +138,20 @@ proc recycleContext[T](
drained = await allFutures(ongoing[]).withTimeout(RecycleTimeout)
if not drained:
for fut in ongoing[]:
if not fut.finished():
fut.cancelSoon()
fut.cancelSoon()
drained = await allFutures(ongoing[]).withTimeout(RecycleTimeout)
freeLib(ctx)
clearListeners(ctx[].eventRegistry)
rejectQueuedRequests(ctx)
ongoing[].setLen(0)
ctx.release()
# Fire before the release: a thread that claims the freed slot first would
# otherwise take this fire as the answer to its own recycle.
let fireRes = ctx.recycleDoneSignal.fireSync()
if fireRes.isErr():
error "failed to fire recycleDoneSignal", err = fireRes.error
ctx.releaseClaim()
var ffiEventQueueSignalPtr {.threadvar.}: ThreadSignalPtr
# Stashed so the hook has no closure env.
@ -215,6 +230,13 @@ proc ffiThreadBody[T](ctx: ptr FFIContext[T]) {.thread.} =
await recycleContext(ctx, addr pending)
continue
# A submit that read `Active` just before the recycle can still land here.
# Fail it rather than run it against the library of the next owner.
if ctx.lifecycle.load() != CtxLifecycle.Active:
rejectQueuedRequests(ctx)
discard await ctx.reqSignal.wait().withTimeout(chronos.milliseconds(100))
continue
cleanFinishedRequests()
# Block until a submit signals us, or at most 100ms.

View File

@ -0,0 +1,29 @@
## Compile-time pieces that more than one `{.ffi.}` codegen path shares.
import std/macros
func unwrapPostfix*(n: NimNode): NimNode =
## Strips the `*` of an exported name, so a name node reads the same whether
## the writer exported it or not.
return
if n.kind == nnkPostfix:
n[1]
else:
n
func procIdent*(prc: NimNode): NimNode =
return unwrapPostfix(prc[0])
proc buildLibReadyGuard*(
ctxHandlerName, libTypeName: NimNode
): NimNode {.compileTime.} =
## Rejects a request that reaches the FFI thread before the ctor stores a
## library. The guard applies only to a `ref` type. For an `object` type the
## fallback is a usable zero value, but for a `ref` type it is nil. The guard
## runs in the handler, behind the ctor in the queue. Thus a host can send a
## call before it waits for the create callback.
quote:
when `libTypeName` is ref:
if not `ctxHandlerName`[].libReady.load():
return
err("library is not initialized: the constructor failed or has not run yet")

View File

@ -8,15 +8,22 @@
## return value of the function crosses the ABI directly.
##
## Write native Nim types. `ffiExport` maps them to the C ABI:
## int / bool -> C int
## uint64 -> C unsigned long long
## string -> C const char* (stays alive in shared memory until the next call)
## (no return) -> C void
## int / int64 -> C long long
## int32 / bool -> C int
## uint / uint64 -> C unsigned long long
## float -> C double
## string -> C const char* (valid until the same thread calls again)
## (no return) -> C void
## A return type with no mapping is a compile error.
## The `const char*` buffer belongs to the calling thread. Copy the bytes before
## that thread calls another `{.ffiExport.}` proc that returns a string.
## `ffiExport` also injects the `initializeLibrary()` call of the library. The Nim
## runtime therefore starts on the first call, and the host never calls NimMain.
## The wrapper catches every exception of the body, prints it and returns the
## zero value, because an exception must not cross the C ABI.
##
## declareLibraryBase("myLib") # emits initializeLibrary()
## proc my_start(): int {.ffiExport.} = 0 # -> int my_start(void)
## proc my_start(): int {.ffiExport.} = 0 # -> long long my_start(void)
## proc my_alive(): uint64 {.ffiExport.} = beats # -> unsigned long long my_alive(void)
## proc my_error(): string {.ffiExport.} = lastErr # -> const char* my_error(void)
##
@ -25,22 +32,65 @@
import std/macros
import ./ffi_route
import ./ffi_codegen_common
proc cReturnType(t: NimNode): NimNode =
const passthroughCTypes = [
"cint", "cuint", "clong", "culong", "clonglong", "culonglong", "cfloat", "cdouble",
"cstring", "pointer",
]
proc cReturnType(t: NimNode, exportName: string): NimNode =
## Maps the native Nim return type to the C ABI type that crosses the boundary.
## A type with no mapping is an error: emitting the Nim type as-is would export
## a symbol whose ABI no host can call.
if t.kind == nnkEmpty:
return t # void
return t
if t.kind == nnkIdent:
case $t
of "int", "int32", "bool":
of "int", "int64":
# `int` is pointer-wide, so `cint` would truncate it on a 64-bit host.
return ident("clonglong")
of "int8", "int16", "int32", "bool":
return ident("cint")
of "uint", "uint64":
return ident("culonglong")
of "uint8", "uint16", "uint32":
return ident("cuint")
of "float", "float64":
return ident("cdouble")
of "float32":
return ident("cfloat")
of "string":
return ident("cstring")
else:
discard
return t # already a C-compatible type
if $t in passthroughCTypes:
return t
error(
"`.ffiExport.` proc " & exportName & " returns " & t.repr &
", which has no C ABI mapping. Return a scalar, a bool, a string, a C type, " &
"or nothing. For a richer return type, use `{.ffi.}`."
)
proc withoutFFIPragmas(pragmas: NimNode): NimNode =
## Drops only the pragma that routed the proc here, so a `raises` or `gcsafe`
## the writer asked for still applies to the body.
if pragmas.kind != nnkPragma:
return newEmptyNode()
var kept = nnkPragma.newTree()
for p in pragmas:
let name =
if p.kind in {nnkExprColonExpr, nnkCall}:
p[0]
else:
p
if name.kind == nnkIdent and $name in ["ffi", "ffiExport"]:
continue
kept.add(p)
return
if kept.len == 0:
newEmptyNode()
else:
kept
proc buildFFIExportProc*(prc: NimNode): NimNode {.compileTime.} =
## Emits the synchronous C export. `{.ffi.}` and `{.ffiExport.}` share it.
@ -48,50 +98,64 @@ proc buildFFIExportProc*(prc: NimNode): NimNode {.compileTime.} =
let exportName = $procIdent(prc)
let params = prc.params
let nativeRet = params[0]
let cRet = cReturnType(nativeRet)
let cRet = cReturnType(nativeRet, exportName)
# The user body becomes a private impl proc. The exported wrapper converts the
# result.
# The user body becomes a private impl proc that the exported wrapper calls.
let implName = genSym(nskProc, exportName & "Impl")
var impl = copyNimTree(prc)
impl[0] = implName # rename
impl[4] = newEmptyNode() # remove the pragmas: this proc stays internal
impl[0] = implName
impl[4] = withoutFFIPragmas(prc[4])
let wrapName = ident(exportName)
let boot = quote:
when declared(initializeLibrary):
initializeLibrary()
# A Nim exception that unwinds through a cdecl frame into the host is
# undefined behaviour, so every wrapper catches and reports instead.
let raiseNote = newLit("error: " & exportName & " raised: ")
var res = newStmtList(impl)
if nativeRet.kind == nnkIdent and $nativeRet == "string":
# string -> const char*: keep the bytes alive in shared memory across the call.
# One buffer per calling thread: a process-wide buffer would let one thread
# free the bytes another thread is still reading.
let buf = genSym(nskVar, exportName & "Buf")
res.add quote do:
var `buf` {.global.}: pointer = nil
proc `wrapName`(): cstring {.exportc: `exportName`, cdecl, dynlib.} =
var `buf` {.threadvar.}: pointer
proc `wrapName`(): cstring {.exportc: `exportName`, cdecl, dynlib, raises: [].} =
`boot`
let s = `implName`()
if `buf` != nil:
var s = ""
try:
s = `implName`()
except CatchableError as e:
echo `raiseNote`, e.msg
if not `buf`.isNil():
deallocShared(`buf`)
`buf` = allocShared(s.len + 1)
if s.len > 0:
copyMem(`buf`, unsafeAddr s[0], s.len)
cast[ptr char](cast[uint](`buf`) + uint(s.len))[] = '\0'
cast[ptr UncheckedArray[char]](`buf`)[s.len] = '\0'
return cast[cstring](`buf`)
elif nativeRet.kind == nnkEmpty:
res.add quote do:
proc `wrapName`() {.exportc: `exportName`, cdecl, dynlib.} =
proc `wrapName`() {.exportc: `exportName`, cdecl, dynlib, raises: [].} =
`boot`
`implName`()
try:
`implName`()
except CatchableError as e:
echo `raiseNote`, e.msg
else:
# scalar: convert the native result to the C return type (cint / culonglong / …).
res.add quote do:
proc `wrapName`(): `cRet` {.exportc: `exportName`, cdecl, dynlib.} =
proc `wrapName`(): `cRet` {.exportc: `exportName`, cdecl, dynlib, raises: [].} =
`boot`
return `cRet`(`implName`())
try:
return `cRet`(`implName`())
except CatchableError as e:
echo `raiseNote`, e.msg
return `cRet`(0)
return res

View File

@ -9,6 +9,7 @@ import ./c_macro_helpers
import ./ffi_scalar
import ./ffi_route
import ./ffi_export
import ./ffi_codegen_common
when defined(ffiGenBindings):
import ../codegen/rust
import ../codegen/cpp
@ -394,12 +395,7 @@ proc buildFFINewReqProc(reqTypeName, body: NimNode): NimNode =
`reqObjIdent`.`fieldName` = `fieldName`
)
let reqNameLit = newLit(
if reqTypeName.kind == nnkPostfix:
$reqTypeName[1]
else:
$reqTypeName
)
let reqNameLit = newLit($unwrapPostfix(reqTypeName))
newBody.add(
quote do:
# Encode into shared memory, avoiding a second seq[byte] copy.
@ -1087,12 +1083,7 @@ proc buildFFIProc(
ffiBody.add(stmt)
let reqPtrIdent = genSym(nskLet, "reqPtr")
let reqNameLit = newLit(
if reqTypeName.kind == nnkPostfix:
$reqTypeName[1]
else:
$reqTypeName
)
let reqNameLit = newLit($unwrapPostfix(reqTypeName))
ffiBody.add quote do:
let `reqPtrIdent` = FFIThreadRequest.initFromPtr(
callback, userData, cstring(`reqNameLit`), reqCbor, int(reqCborLen)
@ -1173,15 +1164,16 @@ macro ffi*(args: varargs[untyped]): untyped =
error("`.ffi.` must be applied to a type or a proc definition")
requireLibraryDeclared("`.ffi.`")
let path = routeFFIProc(prc)
proc gatedABIFormat(what: string): ABIFormat =
let abiFormat = resolveFFISpecs(leading)
gateABIFormat(abiFormat, what)
return abiFormat
# An event may lead with a wire-name literal, which the ABI parser rejects, so
# it resolves its own specs.
if path == fpEvent:
case routeFFIProc(prc)
of fpEvent:
# An event may lead with a wire-name literal, which the ABI parser rejects,
# so it resolves its own specs.
return buildFFIEventProc(prc, leading)
let abiFormat = resolveFFISpecs(leading)
case path
of fpExport:
# The export crosses the ABI with its own return value, so no ABI applies.
if leading.len > 0:
@ -1191,15 +1183,11 @@ macro ffi*(args: varargs[untyped]): untyped =
)
return buildFFIExportProc(prc)
of fpDtor:
gateABIFormat(abiFormat, "`.ffi.` destructor")
return buildFFIDtorProc(prc, abiFormat)
return buildFFIDtorProc(prc, gatedABIFormat("`.ffi.` destructor"))
of fpStatic:
gateABIFormat(abiFormat, "`.ffi.` static proc")
return buildFFIProc(prc, abiFormat, isStatic = true)
return buildFFIProc(prc, gatedABIFormat("`.ffi.` static proc"), isStatic = true)
of fpMethod:
return buildFFIProc(prc, abiFormat, isStatic = false)
of fpEvent:
error("unreachable: the event path returns above")
return buildFFIProc(prc, resolveFFISpecs(leading), isStatic = false)
macro ffiStatic*(args: varargs[untyped]): untyped =
## Context-independent `{.ffi.}`: no library receiver, and no `ctx` in the C
@ -1259,12 +1247,7 @@ proc buildCtorFFINewReqProc(reqTypeName: NimNode, paramNames: seq[string]): NimN
let retType = newTree(nnkPtrTy, ident("FFIThreadRequest"))
formalParams = @[retType] & formalParams
let reqNameLit = newLit(
if reqTypeName.kind == nnkPostfix:
$reqTypeName[1]
else:
$reqTypeName
)
let reqNameLit = newLit($unwrapPostfix(reqTypeName))
var newBody = newStmtList()
newBody.add quote do:
return FFIThreadRequest.initFromPtr(
@ -1846,7 +1829,7 @@ proc buildFFIEventProc(prc: NimNode, leading: seq[NimNode]): NimNode {.compileTi
newStmtList(newCall(ident("dispatchFFIEventCbor"), wireNameLit, dispatchPayload))
var newParams = newSeq[NimNode]()
newParams.add(formalParams[0]) # return type (typically empty/void)
newParams.add(formalParams[0])
for i in 1 ..< formalParams.len:
newParams.add(formalParams[i])

View File

@ -12,6 +12,7 @@
import std/macros
import ../codegen/meta
import ./ffi_codegen_common
type FFIPath* = enum
fpMethod ## A library or handle receiver, and an async result.
@ -56,16 +57,9 @@ proc isLibReceiver(t: NimNode): bool {.compileTime.} =
return false
return ($t == currentLibType and currentLibType.len > 0) or isFFIHandleTypeName($t)
func procIdent*(prc: NimNode): NimNode =
return
if prc[0].kind == nnkPostfix:
prc[0][1]
else:
prc[0]
proc routeFFIProc*(prc: NimNode): FFIPath {.compileTime.} =
## Reads the receiver and the return type, then names the path.
let params = prc[3]
let params = prc.params
let ret = params[0]
let hasReceiver = params.len > 1 and isLibReceiver(params[1][1])
@ -88,7 +82,7 @@ proc assertFFIPath*(prc: NimNode, want: FFIPath) {.compileTime.} =
# A receiver is the one mismatch a caller can read straight off the signature.
if want == fpStatic and got == fpMethod:
error(
"`.ffiStatic.` proc " & name & " takes " & prc[3][1][1].repr &
"`.ffiStatic.` proc " & name & " takes " & prc.params[1][1].repr &
" as its first parameter, which is the library type or an {.ffiHandle.} type. " &
"A receiver belongs to a context. Make it an `{.ffi.}` method instead."
)

View File

@ -2,20 +2,7 @@
import std/macros
import ../codegen/meta
proc buildLibReadyGuard*(
ctxHandlerName, libTypeName: NimNode
): NimNode {.compileTime.} =
## Rejects a request that reaches the FFI thread before the ctor stores a
## library. The guard applies only to a `ref` type. For an `object` type the
## fallback is a usable zero value, but for a `ref` type it is nil. The guard
## runs in the handler, behind the ctor in the queue. Thus a host can send a
## call before it waits for the create callback.
quote:
when `libTypeName` is ref:
if not `ctxHandlerName`[].libReady.load():
return
err("library is not initialized: the constructor failed or has not run yet")
import ./ffi_codegen_common
const scalarPodTypeNames = [
"int", "int8", "int16", "int32", "int64", "uint", "uint8", "uint16", "uint32",

View File

@ -34,6 +34,21 @@ proc router_version*(): Future[Result[string, string]] {.ffi.} =
proc router_alive*(): int {.ffi.} =
7
# `int` is pointer-wide, so the export must not narrow it to a C `int`.
proc router_big*(): int {.ffi.} =
int(high(int32)) + 1
proc router_banner*(): string {.ffi.} =
"router banner"
var routerBeats = 0
proc router_beat*() {.ffi.} =
inc routerBeats
proc router_raises*(): int {.ffi.} =
raise newException(ValueError, "boom")
# A library receiver and no result, so the router picks the destructor.
proc router_destroy*(lib: RouterLib) {.ffi.} =
discard
@ -121,7 +136,21 @@ suite "{.ffi.} routes on the shape of the signature":
test "no arguments and a plain return type route to the synchronous export":
# The export returns its value directly, with no context and no callback.
check router_alive() == cint(7)
check router_alive() == clonglong(7)
test "an int export keeps its full width across the C ABI":
check router_big() == clonglong(int32.high) + 1
test "a string export returns bytes the caller can read":
check $router_banner() == "router banner"
test "a no-return export routes to a void C symbol":
let before = routerBeats
router_beat()
check routerBeats == before + 1
test "an exception in the body never crosses the C ABI":
check router_raises() == clonglong(0)
test "a payload parameter and no result route to the event":
var s: CallbackState