Update ffi/codegen/meta.nim

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Gabriel Cruz 2026-07-23 15:36:19 -03:00 committed by Gabriel Cruz
parent 4bee89567c
commit 4510109817
No known key found for this signature in database
GPG Key ID: 2E467754A6BA9BA5
19 changed files with 156 additions and 199 deletions

View File

@ -46,19 +46,19 @@ All notable changes to this project are documented in this file.
where `-install_name` requires `-dynamiclib`.
### Added
- **`{.ffiStatic.}`**: exports a context-independent proc. It takes no library
param and its wrapper takes no `ctx`, so a host can call a stateless utility
(key generation, parsing, a version string) without constructing the library
- **`{.ffiStatic.}`**: exports a context-independent proc — no library param, and
no `ctx` in its wrapper, so a host can call a stateless utility (key generation,
parsing, a version string) without constructing the library
([#134](https://github.com/logos-messaging/nim-ffi/issues/134)). Wired for both
the `cbor` and `c` ABIs across all four backends: the C header emits
`<lib>_static_<proc>(...)`, while C++ and Rust emit an associated function on
the ctx type taking the `timeout` a method reads from its ctx. Handlers run on
the library's *static context*, created on the first such call and alive for the
rest of the process, so that call starts a thread pair that is never torn down —
`destroyFFIContext` refuses it rather than releasing its slot; `destroyStaticFFIContext`
is the explicit teardown counterpart (stops the thread pair and frees the slot) for
process shutdown and tests. An `{.ffiHandle.}` parameter or return is rejected at macro
time: a handle belongs to the context that created it, which a static proc cannot reach.
`<lib>_static_<proc>(...)`, C++ and Rust an associated function on the ctx type
taking the `timeout` a method reads from its ctx. Handlers run on the library's
*static context*, created on the first such call and held for the rest of the
process, so that call starts a thread pair nothing tears down —
`destroyFFIContext` refuses it; `destroyStaticFFIContext` is the Nim-side
teardown for process shutdown and tests, with no foreign equivalent. An
`{.ffiHandle.}` parameter or return is rejected at macro time: a handle belongs
to the context that created it, which a static proc cannot reach.
- `{.ffi.}` now accepts an `enum` type, emitting a native enum in every target
(C `enum`, C++ `enum class`, Rust enum, CDDL string choice). Values cross the
wire as the text `$value` yields — the associated string if declared, else the

View File

@ -200,8 +200,13 @@ exports. In C++ and Rust a static is an associated function on the ctx type
The handler still needs an FFI thread, so it runs on the library's **static
context**: created on the first `{.ffiStatic.}` call, then alive for the rest of
the process — no ctx owns it, so nothing tears its thread pair down. It has no
`myLib`, which is why a static proc cannot take the library value.
the process — no ctx owns it, so nothing tears its thread pair down, and it holds
one of the pool's slots. It has no `myLib`, which is why a static proc cannot
take the library value.
There is no foreign teardown for it. From Nim, `destroyStaticFFIContext(pool)`
stops the thread pair and frees the slot; it is only sound once nothing will call
a `{.ffiStatic.}` proc again, so it is meant for process shutdown and tests.
The macro rejects an `{.ffiHandle.}` parameter or return: a handle is registered
in the context that created it, which a static proc cannot reach. Under

View File

@ -44,7 +44,7 @@ proc echoVersion*(e: Echo): Future[Result[string, string]] {.ffi.} =
## Returns the library's version string.
return ok("nim-echo v0.1.0")
# The two below need no `Echo`, so their wrappers take no ctx.
# No `Echo` param, so the wrappers take no ctx.
proc echoLibVersion*(): Future[Result[string, string]] {.ffiStatic.} =
return ok("nim-echo v0.1.0")

View File

@ -683,8 +683,8 @@ proc emitProcWrapper(
ctxType, libType, libName: string,
m: FFIProcMeta,
) =
## Reply trampoline + CBOR-encoding wrapper: `<lib>_ctx_<name>` for a method,
## `<lib>_static_<name>` for a static. `<lib>_<name>` is the raw dylib symbol.
## Reply trampoline + wrapper: `<lib>_ctx_<name>`, or `<lib>_static_<name>` for a
## static; `<lib>_<name>` itself is the raw symbol the dylib exports.
let isStatic = m.isStatic()
let stripped = stripLibPrefix(m.procName, libName)
let reqName = reqStructName(m)
@ -809,8 +809,8 @@ proc monomorphiseAll(
discard ensureCType(reg, n)
reqTypes.add(n)
var respTypes: seq[string] = @[]
for m in replyProcs:
respTypes.add(cReturnType(reg, m))
for p in replyProcs:
respTypes.add(cReturnType(reg, p))
for ev in events:
discard ensureCType(reg, ev.payloadTypeName)
return (reqTypes, respTypes)
@ -1329,7 +1329,6 @@ proc emitAbiProcWrapper(
ctxType, libName, libType: string,
m: FFIProcMeta,
) =
## See `emitProcWrapper` for why a static's wrapper is `<lib>_static_<name>`.
let isStatic = m.isStatic()
let stripped = stripLibPrefix(m.procName, m.libName)
let reqStruct = reqStructName(m)

View File

@ -1,6 +1,6 @@
## Helpers shared by the C/C++ binding generators (cpp.nim, c.nim).
import std/[strutils, options]
import std/strutils
import ./meta, ./string_helpers
proc stripLibPrefix*(procName, libName: string): string =

View File

@ -506,8 +506,8 @@ proc generateCppHeader*(
lines.add(" }")
lines.add("")
# A static forwards its own `timeout`; a method captures `this` and calls
# `this->methodName(...)` so a same-named param can't shadow the call target.
# A method calls `this->methodName(...)` so a same-named param can't shadow
# the call target; a static has no `this` and forwards its own `timeout`.
let staticArgs =
if methParamNames.len > 0:
methParamNamesStr & ", timeout"

View File

@ -148,7 +148,6 @@ proc isFFIEnumTypeName*(name: string): bool {.compileTime.} =
name in ffiEnumTypeNames
func isStatic*(p: FFIProcMeta): bool =
## True for a `{.ffiStatic.}` proc: no library receiver, no ctx in its wrapper.
p.kind == FFIKind.STATIC
type ClassifiedProcs* = object
@ -175,7 +174,10 @@ func classifyProcs*(procs: seq[FFIProcMeta]): ClassifiedProcs =
func dtorProcName*(c: ClassifiedProcs): string =
## The destructor's proc name, or "" when the library has no destructor.
if c.dtor.isSome(): c.dtor.get().procName else: ""
if c.dtor.isSome():
c.dtor.get().procName
else:
""
func replyProcs*(c: ClassifiedProcs): seq[FFIProcMeta] =
## Procs that reply with a decoded value: methods and statics.

View File

@ -186,7 +186,6 @@ proc generateFFIRs*(procs: seq[FFIProcMeta]): string =
lines.add(renderMemberDocComment(p.doc))
case p.kind
of FFIKind.FFI, FFIKind.STATIC:
# Method-style: ctx first. A static is the same shape, minus the ctx.
if not p.isStatic():
params.add("ctx: *mut c_void")
params.add("callback: FFICallback")

View File

@ -9,12 +9,14 @@ type
## Lifecycle of the pool's `{.ffiStatic.}` context; see `staticFFIContext`.
StaticCtxNone
StaticCtxCreating
StaticCtxDestroying
StaticCtxReady
FFIContextPool*[T] = object
## Fixed pool. Each live context holds 5 ThreadSignalPtrs — one fd each on
## Linux, two (a socketpair) elsewhere. Under refc a destroyed context cannot
## close them (see `deinitContextResources`), so churn leaks fds unbounded.
## Fixed pool of FFI contexts, plus the one `{.ffiStatic.}` context.
# Each live context holds 5 ThreadSignalPtrs — one fd each on Linux, two (a
# socketpair) elsewhere. Under refc a destroyed context cannot close them
# (see `deinitContextResources`), so churn leaks fds unbounded.
slots: array[MaxFFIContexts, FFIContext[T]]
inUse: array[MaxFFIContexts, Atomic[bool]]
staticCtx: Atomic[pointer]
@ -44,9 +46,10 @@ proc createFFIContext*[T](
ok(ctx)
proc isStaticCtx[T](pool: var FFIContextPool[T], ctx: ptr FFIContext[T]): bool =
## `staticCtx` is published before the state flips, so `Ready` implies it is readable.
pool.staticState.load() == StaticCtxReady and
pool.staticCtx.load() == cast[pointer](ctx)
## True while `ctx` is the pool's static context, including mid-teardown.
# `staticCtx` is cleared only once the slot is released, so matching on the
# pointer covers `Destroying` too.
pool.staticCtx.load() == cast[pointer](ctx)
proc destroyFFIContext*[T](
pool: var FFIContextPool[T], ctx: ptr FFIContext[T]
@ -67,17 +70,16 @@ proc destroyFFIContext*[T](
proc staticFFIContext*[T](
pool: var FFIContextPool[T]
): Result[ptr FFIContext[T], string] =
## The pool's `{.ffiStatic.}` context: a static proc has no ctx of its own, but
## its handler still needs an FFI thread. Created on first use and never
## destroyed, so it holds a slot for good and `pool` must outlive its threads —
## only ever call this on the global `declareLibrary` emits. `myLib` stays the
## zero value; a static handler must not touch it. A failed create resets to
## `StaticCtxNone` so the spinning losers retry instead of hanging.
## The pool's `{.ffiStatic.}` context, created on first use: a static proc has
## no ctx of its own, but its handler still needs an FFI thread.
# Holds its slot until `destroyStaticFFIContext`, so `pool` must outlive its
# threads: only call this on the global `declareLibrary` emits. `myLib` stays
# the zero value. A failed create resets to `StaticCtxNone` so waiters retry.
while true:
case pool.staticState.load()
of StaticCtxReady:
return ok(cast[ptr FFIContext[T]](pool.staticCtx.load()))
of StaticCtxCreating:
of StaticCtxCreating, StaticCtxDestroying:
cpuRelax()
of StaticCtxNone:
var expected = StaticCtxNone
@ -92,14 +94,16 @@ proc staticFFIContext*[T](
proc destroyStaticFFIContext*[T](pool: var FFIContextPool[T]): Result[void, string] =
## Teardown counterpart to `staticFFIContext`: stops the static context's
## threads and frees its slot. The static context is meant to live for the
## whole process, so only call this once nothing will call `staticFFIContext`
## again (e.g. test teardown) — a lingering static context otherwise keeps its
## FFI/event threads running for the process lifetime.
if pool.staticState.load() != StaticCtxReady:
## threads and frees its slot. A no-op when there is no static context.
# Claiming `Ready -> Destroying` serialises concurrent teardowns; it does not
# make teardown safe against a static call already in flight.
var expected = StaticCtxReady
if not pool.staticState.compareExchange(expected, StaticCtxDestroying):
return ok()
let ctx = cast[ptr FFIContext[T]](pool.staticCtx.load())
ctx.stopAndJoinThreads().isOkOr:
# Threads are still live: leak the slot rather than free resources under them.
pool.staticState.store(StaticCtxReady)
return err("destroyStaticFFIContext: " & $error)
let deinitRes = ctx.deinitContextResources()
pool.releaseSlot(ctx)
@ -107,7 +111,7 @@ proc destroyStaticFFIContext*[T](pool: var FFIContextPool[T]): Result[void, stri
pool.staticState.store(StaticCtxNone)
deinitRes.isOkOr:
return err("destroyStaticFFIContext: " & $error)
return ok()
ok()
proc isValidCtx*[T](pool: var FFIContextPool[T], ctx: pointer): bool =
## Rejects nil / dangling pointers at the API boundary.

View File

@ -706,13 +706,48 @@ proc stringTrampBody(boxName: NimNode): NimNode =
except CatchableError as e:
box.fn(RET_ERR, "".cstring, e.msg.cstring, box.ud)
proc exportedMethodProc(
spec: CAbiSpec, boxName, envWire, trampName, poolIdent, cbType: NimNode
proc ctxBindingGuard(
poolIdent, emptyReply, ctxIdent: NimNode, isStatic: bool
): NimNode {.compileTime.} =
## Prologue that binds `ctxIdent`: a method validates the ctx it was handed, a
## static resolves the library's shared one.
if not isStatic:
return quote:
if onReply.isNil():
return RET_MISSING_CALLBACK
if not `poolIdent`.isValidCtx(cast[pointer](`ctxIdent`)):
onReply(
RET_ERR, `emptyReply`, "ctx is not a valid FFI context".cstring, userData
)
return RET_ERR
let guard = quote:
if onReply.isNil():
return RET_MISSING_CALLBACK
let `ctxIdent` = `poolIdent`.staticFFIContext().valueOr:
let errStr = "ffiStatic: " & error
onReply(RET_ERR, `emptyReply`, errStr.cstring, userData)
return RET_ERR
# A static call may be the host's first entry into the library. Raw AST, not
# `quote`: `when declared` over an undeclared symbol inside `quote` ICEs.
guard.insert(
0,
nnkWhenStmt.newTree(
nnkElifBranch.newTree(
newCall(ident("declared"), ident("initializeLibrary")),
newStmtList(newCall(ident("initializeLibrary"))),
)
),
)
guard
proc exportedProc(
spec: CAbiSpec,
boxName, envWire, trampName, poolIdent, cbType: NimNode,
isStatic: bool,
): NimNode =
# No `foreignThreadGc`: `cwireUnpack`/`cwirePack` alloc on the calling thread (already GC-registered); wrapping would free its live ORC heap.
let envName = spec.envelope
let libFFICtx =
nnkPtrTy.newTree(nnkBracketExpr.newTree(ident("FFIContext"), spec.libType))
let ctxIdent = ident("ctx")
# String reply: empty non-nil cstring on error; object reply: nil ptr gated by err_code.
let emptyReply =
if isStringType(spec.respType):
@ -720,11 +755,6 @@ proc exportedMethodProc(
else:
newNilLit()
let body = quote:
if onReply.isNil():
return RET_MISSING_CALLBACK
if not `poolIdent`.isValidCtx(cast[pointer](ctx)):
onReply(RET_ERR, `emptyReply`, "ctx is not a valid FFI context".cstring, userData)
return RET_ERR
var ownedWire: `envWire`
cwirePack(ownedWire, cwireUnpack(req[]))
let ownedCopy = cwireOwnedCopy(ownedWire)
@ -742,7 +772,7 @@ proc exportedMethodProc(
)
let sendRes =
try:
ffi_context.sendRequestToFFIThread(ctx, reqPtr)
ffi_context.sendRequestToFFIThread(`ctxIdent`, reqPtr)
except Exception as e:
Result[void, string].err("sendRequestToFFIThread exception: " & e.msg)
if sendRes.isErr():
@ -754,86 +784,25 @@ proc exportedMethodProc(
return RET_ERR
return RET_OK
newProc(
name = ident($envName & "CAbiExport"),
params = @[
ident("cint"),
newIdentDefs(ident("ctx"), libFFICtx),
newIdentDefs(ident("onReply"), cbType),
newIdentDefs(ident("userData"), ident("pointer")),
newIdentDefs(ident("req"), nnkPtrTy.newTree(envWire)),
],
body = body,
pragmas = nnkPragma.newTree(
ident("dynlib"),
nnkExprColonExpr.newTree(ident("exportc"), newStrLitNode(spec.exportName)),
ident("cdecl"),
nnkExprColonExpr.newTree(ident("raises"), nnkBracket.newTree()),
),
)
let fullBody = ctxBindingGuard(poolIdent, emptyReply, ctxIdent, isStatic)
for stmt in body:
fullBody.add(stmt)
var params = @[
ident("cint"),
newIdentDefs(ident("onReply"), cbType),
newIdentDefs(ident("userData"), ident("pointer")),
newIdentDefs(ident("req"), nnkPtrTy.newTree(envWire)),
]
if not isStatic:
let libFFICtx =
nnkPtrTy.newTree(nnkBracketExpr.newTree(ident("FFIContext"), spec.libType))
params.insert(newIdentDefs(ctxIdent, libFFICtx), 1)
proc exportedStaticProc(
spec: CAbiSpec, boxName, envWire, trampName, poolIdent, cbType: NimNode
): NimNode =
## Ctx-less twin of `exportedMethodProc`: binds the library's static context
## instead of taking one. `initGuard` is raw AST because a `when declared` over
## an undeclared symbol inside `quote` ICEs (see `exportedCtorProc`).
let envName = spec.envelope
let emptyReply =
if isStringType(spec.respType):
newDotExpr(newLit(""), ident("cstring"))
else:
newNilLit()
let initGuard = nnkWhenStmt.newTree(
nnkElifBranch.newTree(
newCall(ident("declared"), ident("initializeLibrary")),
newStmtList(newCall(ident("initializeLibrary"))),
)
)
let body = quote:
if onReply.isNil():
return RET_MISSING_CALLBACK
let ctx = `poolIdent`.staticFFIContext().valueOr:
let errStr = "ffiStatic: " & error
onReply(RET_ERR, `emptyReply`, errStr.cstring, userData)
return RET_ERR
var ownedWire: `envWire`
cwirePack(ownedWire, cwireUnpack(req[]))
let ownedCopy = cwireOwnedCopy(ownedWire)
if ownedCopy.isNil():
cwireFree(ownedWire)
onReply(RET_ERR, `emptyReply`, "out of memory".cstring, userData)
return RET_ERR
let reqBuf = cast[ptr UncheckedArray[byte]](ownedCopy)
let box = cast[ptr `boxName`](allocBox(sizeof(`boxName`)))
box.fn = onReply
box.ud = userData
let typeStr = $`envName`
let reqPtr = FFIThreadRequest.initFromOwnedShared(
`trampName`, box, typeStr.cstring, reqBuf, sizeof(`envWire`), rawReply = true
)
let sendRes =
try:
ffi_context.sendRequestToFFIThread(ctx, reqPtr)
except Exception as e:
Result[void, string].err("sendRequestToFFIThread exception: " & e.msg)
if sendRes.isErr():
# See exportedMethodProc: the rejected send freed the struct copy, not the
# field buffers `ownedWire` still aliases.
cwireFree(ownedWire)
onReply(RET_ERR, `emptyReply`, sendRes.error.cstring, userData)
return RET_ERR
return RET_OK
body.insert(0, initGuard)
newProc(
name = ident($envName & "CAbiExport"),
params = @[
ident("cint"),
newIdentDefs(ident("onReply"), cbType),
newIdentDefs(ident("userData"), ident("pointer")),
newIdentDefs(ident("req"), nnkPtrTy.newTree(envWire)),
],
body = body,
params = params,
body = fullBody,
pragmas = nnkPragma.newTree(
ident("dynlib"),
nnkExprColonExpr.newTree(ident("exportc"), newStrLitNode(spec.exportName)),
@ -949,14 +918,15 @@ proc flushCAbiDispatch*(): NimNode {.compileTime.} =
sink.add(replyTrampProc(trampName, stringTrampBody(boxName)))
sink.add(exportedCtorProc(spec, boxName, envWire, trampName, poolIdent, cbType))
of cakMethod, cakStatic:
let emitExport =
if spec.kind == cakStatic: exportedStaticProc else: exportedMethodProc
let isStatic = spec.kind == cakStatic
let rt = spec.respType
if isStringType(rt):
let cbType = cAbiCbType(ident("cstring"))
sink.add(boxTypeDef(boxName, cbType))
sink.add(replyTrampProc(trampName, stringTrampBody(boxName)))
sink.add(emitExport(spec, boxName, envWire, trampName, poolIdent, cbType))
sink.add(
exportedProc(spec, boxName, envWire, trampName, poolIdent, cbType, isStatic)
)
# `isKnownFFIType`, not just `nnkIdent`: a bare `int` is an ident too, and
# would otherwise reach for a `int_CWire` companion that is never emitted.
elif rt.kind == nnkIdent and isKnownFFIType($rt):
@ -964,7 +934,9 @@ proc flushCAbiDispatch*(): NimNode {.compileTime.} =
let cbType = cAbiCbType(nnkPtrTy.newTree(respWire))
sink.add(boxTypeDef(boxName, cbType))
sink.add(replyTrampProc(trampName, objectTrampBody(boxName, respWire)))
sink.add(emitExport(spec, boxName, envWire, trampName, poolIdent, cbType))
sink.add(
exportedProc(spec, boxName, envWire, trampName, poolIdent, cbType, isStatic)
)
else:
error(
"abi = c: unsupported response type for proc '" & spec.exportName & "': " &

View File

@ -831,11 +831,13 @@ proc buildFFIProc(
recvType = firstParam[1]
firstIsHandle = isHandleType(recvType)
if (firstIsHandle or isStatic) and currentLibType.len == 0:
let why =
if isStatic: " takes no library param" else: " has an {.ffiHandle.} receiver"
error(
where & " proc " & $procName & " carries no library type but no library is " &
"declared; call declareLibrary(name, LibType) first"
where & " proc " & $procName & why & " but no library is declared; " &
"call declareLibrary(name, LibType) first"
)
# A static proc and a handle receiver both carry no library type, so fall back to the declared one.
# Neither carries a library type, so fall back to the declared one.
let libTypeName =
if firstIsHandle or isStatic:
ident(currentLibType)
@ -960,9 +962,9 @@ proc buildFFIProc(
return RET_ERR
proc buildStaticCtxGuard(): NimNode =
## Binds the library's static context: a static wrapper takes no `ctx`, and a
## static call may be the host's first entry (hence `initializeLibrary`).
## `ctxIdent` is substituted so the send below sees it (`quote` gensyms).
## Binds the library's static context; a static call may be the host's first
## entry, hence `initializeLibrary`.
# `ctxIdent` is substituted so the send below sees it (`quote` gensyms).
let ctxIdent = ident("ctx")
quote:
initializeLibrary()
@ -1064,8 +1066,7 @@ proc buildFFIProc(
let exportedParams = cExportedParams(ctxType, withCtx = not isStatic)
let ffiBody = newStmtList()
# Flattened, not nested: the static guard's `let ctx` has to be a sibling of
# the send below for it to be in scope.
# Flattened: the guard's `let ctx` must be a sibling of the send to be in scope.
let guard =
if isStatic:
buildStaticCtxGuard()
@ -1151,10 +1152,8 @@ macro ffi*(args: varargs[untyped]): untyped =
return buildFFIProc(prc, abiFormat, isStatic = false)
macro ffiStatic*(args: varargs[untyped]): untyped =
## Context-independent twin of `{.ffi.}`: the proc takes no library receiver and
## its C wrapper takes no `ctx`, so a host can call it without constructing the
## library. Handlers still run on an FFI thread — the library's static context,
## created on the first such call and alive for the rest of the process.
## Context-independent `{.ffi.}`: no library receiver, and no `ctx` in the C
## wrapper, so a host calls it without constructing the library.
requireBeforeGenBindings("`.ffiStatic.`")
requireLibraryDeclared("`.ffiStatic.`")
let prc = args[^1]

View File

@ -118,9 +118,8 @@ static void test_version(EchoCtx* ctx) {
assert(strcmp(w.text_a, "nim-echo v0.1.0") == 0);
}
/* {.ffiStatic.}: no EchoCtx, and the library's static context is created by
* this very call. Runs before make_ctx() so nothing else has initialised the
* Nim runtime first. */
/* No EchoCtx: this call creates the library's static context. Runs before
* make_ctx() so nothing else has initialised the Nim runtime first. */
static void test_static_no_ctx(void) {
ReplyWaiter v;
memset(&v, 0, sizeof(v));

View File

@ -247,7 +247,7 @@ TEST(TimerE2E, CrossLibrary) {
EXPECT_EQ(e.shouted, "X-ECHO: ASYNC-E");
}
// The whole point of {.ffiStatic.}: no EchoCtx is ever constructed here.
// No EchoCtx is constructed anywhere in this test.
TEST(TimerE2E, StaticProcNeedsNoContext) {
EXPECT_EQ(mustOk(EchoCtx::lib_version()), "nim-echo v0.1.0");

View File

@ -1,6 +1,5 @@
## Must fail: `{.ffiStatic.}` never rides the ctx-bound all-scalar fast path, so a
## scalar return has no `abi = c` reply shape. The error must say so, not die on an
## undeclared `int_CWire` (see tests/unit/test_ffistatic_reject.nim).
## Must fail: no `abi = c` reply shape for a static's scalar return, and the error
## must say so rather than die on an undeclared `int_CWire`.
import ffi, chronos

View File

@ -1,4 +1,4 @@
## Must compile: proves the rejections are about handles, not the static shape.
## Must compile: the same shapes, with no handle crossing a static's boundary.
import ffi, chronos

View File

@ -109,15 +109,11 @@ registerReqFFI(HeavyRefAllocRequest, lib: ptr TestLib):
await sleepAsync(10.milliseconds)
return ok("heavy-done")
# Globals, as declareLibrary emits them: a static ctx is never destroyed, so its
# threads outlive any scope and the pool must outlive them.
#
# One pool, filled once, for every slot-accounting case below. Under refc
# `deinitContextResources` cannot close a context's five ThreadSignalPtrs (see
# there), so every context ever created leaks its fds — and macOS spends two per
# signal. A second 32-slot fill puts the suite over the 1024-fd limit.
# Global, as declareLibrary emits it: a static ctx's threads may outlive any scope.
# One pool for every slot-accounting case below — under refc a destroyed context
# can't close its five ThreadSignalPtrs, so a second 32-slot fill would put the
# suite over the 1024-fd limit.
var staticPool: FFIContextPool[TestLib]
var filler: seq[ptr FFIContext[TestLib]]
suite "FFIContextPool":
test "create and destroy via pool succeeds":
@ -139,28 +135,39 @@ suite "FFIContextPool":
check pool.destroyFFIContext(ctx2).isOk()
check ctx1 == ctx2
# Each static case tears its pool back down on every exit path: left running
# under refc the threads race later suites' allocation and GC (macOS SIGSEGV).
test "staticFFIContext returns one shared context and refuses destruction":
defer:
check staticPool.destroyStaticFFIContext().isOk()
let first = staticPool.staticFFIContext().valueOr:
assert false, "staticFFIContext failed: " & $error
return
check staticPool.staticFFIContext().tryGet() == first
# Owns a real slot, not a context handed out on the side.
# Occupies a pool slot like any other context.
check staticPool.isValidCtx(first)
check staticPool.destroyFFIContext(first).isErr()
# Still live and still the same context, not a released slot.
# Still live, and still the same context.
check staticPool.staticFFIContext().tryGet() == first
test "pool exhaustion errors and leaves staticFFIContext retryable":
var filler: seq[ptr FFIContext[TestLib]]
defer:
check staticPool.destroyStaticFFIContext().isOk()
for c in filler:
check staticPool.destroyFFIContext(c).isOk()
check staticPool.staticFFIContext().isOk()
var c = staticPool.createFFIContext()
while c.isOk():
filler.add(c.tryGet())
c = staticPool.createFFIContext()
# The static ctx holds a slot for good, so only MaxFFIContexts-1 were left.
# The static ctx holds a slot, so only MaxFFIContexts-1 were left.
check filler.len == MaxFFIContexts - 1
check staticPool.createFFIContext().isErr()
# Drop the static ctx and hand its slot straight to a plain one, so the
# retry below has to fail on a genuinely full pool.
# Hand the static ctx's slot straight to a plain one, so the retry below has
# to fail on a genuinely full pool.
check staticPool.destroyStaticFFIContext().isOk()
let reclaimed = staticPool.createFFIContext().valueOr:
assert false, "createFFIContext(pool) failed on the freed static slot: " & $error
@ -171,15 +178,6 @@ suite "FFIContextPool":
check staticPool.destroyFFIContext(filler.pop()).isOk()
check staticPool.staticFFIContext().isOk()
# Static contexts hold FFI/event threads for the whole process. Left running
# under refc they race with later suites' allocation and GC (macOS SIGSEGV).
# No later case uses this pool, so stop its threads here.
test "static contexts tear down without outliving the suite":
check staticPool.destroyStaticFFIContext().isOk()
for c in filler:
check staticPool.destroyFFIContext(c).isOk()
filler.setLen(0)
test "requests are processed via pool context":
var pool: FFIContextPool[TestLib]
var d: CallbackData

View File

@ -1,10 +1,6 @@
## Asserts `{.ffiStatic.}` rejects what cannot cross a context-independent proc:
## an {.ffiHandle.} parameter or return (both are resolved against the context
## that owns them), and an `abi = c` scalar return (no reply shape without the
## ctx-bound fast path).
##
## Each fixture compiles in a child `nim check` so its expected failure is a test
## assertion, not this file's own compile error.
## Asserts `{.ffiStatic.}` rejects an {.ffiHandle.} param/return and an `abi = c`
## scalar return. Each fixture compiles in a child `nim check` so its expected
## failure is an assertion rather than this file's own compile error.
import std/[os, osproc, strutils, compilesettings]
import unittest2

View File

@ -1,11 +0,0 @@
import results
import ffi
type TestLib = object
var sharedPool: FFIContextPool[TestLib]
when isMainModule:
let s = sharedPool.staticFFIContext().valueOr:
quit("static failed: " & error)
echo "static ctx: ", cast[uint](s)
# Simulate the rest of the suite doing light work, then exit with the
# static thread still running.
echo "exiting with static thread alive"

View File

@ -1,4 +0,0 @@
import ffi
type TestLib = object
echo "sizeof(FFIContext[TestLib]) = ", sizeof(FFIContext[TestLib])
echo "sizeof(FFIContextPool[TestLib]) = ", sizeof(FFIContextPool[TestLib])