nim-ffi/ffi/ffi_context_pool.nim
Ivan FB c77581b694
fix(pool): reuse parked contexts to stop per-cycle fd leak
destroyFFIContext stopped and joined the worker threads on every release,
and createFFIContext rebuilt them on the next acquire. Each cycle therefore
allocated a fresh worker — 4 ThreadSignalPtr socketpairs + the ffi/watchdog
thread chronos dispatcher kqueues — and never reclaimed the old ones (the
pool deliberately skips closing them, relying on slot reuse that never
actually reused the resources). A consumer that creates and destroys
contexts repeatedly (e.g. nim-sds ReliabilityManager) leaked ~10 fds per
cycle, unbounded.

Make the pool genuinely reuse a slot's worker:

- Track per-slot `initialized`; createFFIContext builds the worker once and
  reuses it on every later acquisition of the same slot.
- Add releaseFFIContext: parks a context (returns its slot) WITHOUT stopping
  the threads, so the next acquire reuses the same fds. It also drops the
  stale C event callback so a watchdog tick on a parked slot cannot invoke a
  callback whose user-data the consumer may already have freed. The caller is
  responsible for quiescing its library object (on the FFI thread) first.
- destroyFFIContext keeps full-teardown semantics for error/non-pooling
  paths and now marks the slot uninitialised so a later acquire rebuilds it.

Tests: add a park & reuse suite (same-slot live-worker reuse, callback/lib
pointer dropped on park, and fd usage bounded across 20 park/reuse cycles).
The fd test fails by ~10 fds/cycle against the pre-fix behaviour. Green
under both --mm:refc and --mm:orc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 12:07:17 +02:00

93 lines
4.1 KiB
Nim

import std/atomics
import results
import ./ffi_context
const MaxFFIContexts* = 32
## Maximum number of concurrently live FFI contexts when using FFIContextPool.
## Fds and threads are only consumed for slots that are actually acquired,
## so this value only affects the upfront memory of the pool array.
type FFIContextPool*[T] = object
## Fixed-size pool of FFI contexts. Avoids dynamic heap allocation per context
## and bounds the total number of file descriptors consumed by ThreadSignalPtrs
## to at most MaxFFIContexts * 2.
slots: array[MaxFFIContexts, FFIContext[T]]
inUse: array[MaxFFIContexts, Atomic[bool]]
initialized: array[MaxFFIContexts, Atomic[bool]]
## Whether a slot's worker (threads, chronos dispatcher and ThreadSignalPtrs)
## has been built. Set on first acquisition and kept set across park/reuse,
## so a reacquired slot reuses the same fds instead of allocating a fresh set
## every create/destroy cycle. Cleared only by full teardown.
proc releaseSlot[T](pool: var FFIContextPool[T], ctx: ptr FFIContext[T]) =
for i in 0 ..< MaxFFIContexts:
if pool.slots[i].addr == ctx:
pool.inUse[i].store(false)
return
proc createFFIContext*[T](
pool: var FFIContextPool[T]
): Result[ptr FFIContext[T], string] =
## Acquires a slot from the fixed pool. The slot's worker is built once on
## first use and REUSED on every later acquisition of the same slot (a slot is
## made reacquirable by releaseFFIContext, which parks it without tearing the
## worker down). This is what keeps fd usage bounded: repeated create/destroy
## cycles no longer leak a fresh set of ThreadSignalPtr/dispatcher fds.
for i in 0 ..< MaxFFIContexts:
var expected = false
if not pool.inUse[i].compareExchange(expected, true):
continue
let ctx = pool.slots[i].addr
if pool.initialized[i].load():
## Reused slot: worker threads, dispatcher and signals are already alive.
return ok(ctx)
initContextResources(ctx).isOkOr:
pool.inUse[i].store(false)
return err("createFFIContext: initContextResources failed: " & $error)
pool.initialized[i].store(true)
return ok(ctx)
return err("FFI context pool exhausted (max " & $MaxFFIContexts & " contexts)")
proc releaseFFIContext*[T](
pool: var FFIContextPool[T], ctx: ptr FFIContext[T]
): Result[void, string] =
## Parks a context for reuse: returns its slot to the pool WITHOUT stopping the
## worker threads, so the next createFFIContext reuses the same fds. The caller
## MUST have already quiesced its library object (e.g. cancelled the object's
## async tasks) on the FFI thread before calling this — the worker keeps
## running. The stale C event callback is dropped so a watchdog tick on the
## parked slot cannot invoke a callback whose user-data may already be freed.
ctx.callbackState = default(FFICallbackState)
ctx.myLib = nil
pool.releaseSlot(ctx)
return ok()
proc destroyFFIContext*[T](
pool: var FFIContextPool[T], ctx: ptr FFIContext[T]
): Result[void, string] =
## Full teardown: stops/joins the worker threads and returns the slot to the
## pool, marking it uninitialised so a later createFFIContext rebuilds it. Used
## on creation failure and by non-pooling callers; steady-state cleanup should
## use releaseFFIContext to keep fd usage bounded. If the FFI thread is blocked
## and does not exit in time, the slot is leaked rather than reclaimed —
## closing its resources while the thread is still live would be unsafe.
ctx.stopAndJoinThreads().isOkOr:
return err("destroyFFIContext(pool): " & $error)
for i in 0 ..< MaxFFIContexts:
if pool.slots[i].addr == ctx:
pool.initialized[i].store(false)
break
pool.releaseSlot(ctx)
return ok()
proc isValidCtx*[T](pool: var FFIContextPool[T], ctx: pointer): bool =
## Returns true only if ctx points to one of the pool's slots that is
## currently in use. Rejects nil, offset-invalid, and dangling pointers
## at the API boundary, preventing use-after-free dereferences.
if ctx.isNil():
return false
for i in 0 ..< MaxFFIContexts:
if cast[pointer](pool.slots[i].addr) == ctx:
return pool.inUse[i].load()
return false