mirror of
https://github.com/logos-messaging/nim-ffi.git
synced 2026-07-08 00:59:47 +00:00
Two foreign-host concurrency fixes for refc, both needed so a Go host can hammer the FFI under load without corrupting Nim's per-thread GC. 1. Compile-time request ids. ffiNewReq / the method + ctor wrappers built the request id with `$T` at runtime, allocating a Nim GC string on the foreign caller's (often transient) thread. Emit a `cstring` literal of the type name instead — no allocation on the caller thread. 2. Recycle pooled contexts instead of destroy/recreate. Restores the release/v0.1 model that v0.2 dropped: a pool slot's worker + event threads and signal fds are built once and reused. The ffiDtor now requests a synchronous recycle (drain in-flight handlers, free the lib, clear listeners, release the slot) on the FFI thread, keeping the threads alive; createFFIContext reuses an initialised slot. Without this every create/destroy churned ~6 signal fds, so fd numbers climbed past FD_SETSIZE (1024) and ThreadSignalPtr.waitSync's select() failed with EINVAL under create/destroy load. Adds CtxLifecycle (Active/RecyclePending/Recycling), ctx-level inUse/tryClaim/release/markAsActive, requestRecycle (waits on a new recycleDoneSignal), freeLib (refc GC_unref / orc =destroy of ctor-owned libs), recycleContext, FFIEventRegistry.clearListeners, and roots the ctor-stored ref lib under refc (GC_ref, balanced in freeLib). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
267 lines
10 KiB
Nim
267 lines
10 KiB
Nim
## FFIContext type plus lifecycle (init / signal-stop / join / destroy).
|
|
##
|
|
## The per-thread bodies live in `ffi_thread.nim` and `event_thread.nim`,
|
|
## included below so the thread code can access the private FFIContext
|
|
## fields without forcing them through a public surface.
|
|
|
|
{.passc: "-fPIC".}
|
|
|
|
import std/[atomics, locks, options, sequtils, tables]
|
|
import chronicles, chronos, chronos/threadsync, taskpools/channels_spsc_single, results
|
|
import
|
|
./ffi_types,
|
|
./ffi_events,
|
|
./ffi_handles,
|
|
./ffi_thread_request,
|
|
./logging,
|
|
./cbor_serial
|
|
|
|
export ffi_events, ffi_handles
|
|
|
|
type CtxLifecycle* {.pure.} = enum
|
|
## State machine guarding a pooled FFI context (Atomic on FFIContext).
|
|
## Active -> RecyclePending when the ffiDtor requests recycle
|
|
## RecyclePending -> Recycling FFI loop claimed it, draining handlers
|
|
## Recycling -> Active createFFIContext reuses the slot
|
|
Active
|
|
RecyclePending
|
|
Recycling
|
|
|
|
type FFIContext*[T] = object
|
|
myLib*: ptr T # main library object (Waku, LibP2P, SDS, …)
|
|
myLibRefd*: bool
|
|
# refc only: true once myLib[] (a ref) has been GC_ref'd to root it against
|
|
# the cycle collector. Balanced by GC_unref in freeLib.
|
|
myLibOwned*: bool
|
|
# true once a ctor stored a createShared'd lib into myLib (vs the worker's
|
|
# stack fallback). freeLib only frees/destroys owned libs.
|
|
inUse*: Atomic[bool]
|
|
# Whether this pooled context is claimed. The recycle handler clears it on
|
|
# 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.
|
|
ffiThread: Thread[(ptr FFIContext[T])]
|
|
eventThread: Thread[(ptr FFIContext[T])]
|
|
lock: Lock
|
|
reqChannel: ChannelSPSCSingle[ptr FFIThreadRequest]
|
|
reqSignal: ThreadSignalPtr
|
|
reqReceivedSignal: ThreadSignalPtr
|
|
stopSignal: ThreadSignalPtr
|
|
threadExitSignal: ThreadSignalPtr
|
|
# bounds destroyFFIContext's wait so a blocked loop cannot hang the caller
|
|
eventQueueSignal: ThreadSignalPtr # wakes the event thread on enqueue
|
|
eventThreadExitSignal: ThreadSignalPtr # mirrors threadExitSignal for the event thread
|
|
userData*: pointer
|
|
eventRegistry*: FFIEventRegistry
|
|
handles*: FFIHandleRegistry # live {.ffiHandle.} objects, keyed by uint64 id
|
|
eventQueue*: EventQueue
|
|
ffiHeartbeat*: Atomic[int64]
|
|
# advanced each FFI-thread loop; event thread reads for liveness
|
|
eventQueueStuck*: Atomic[bool] # sticky overflow flag
|
|
running: Atomic[bool] # To control when the threads are running
|
|
registeredRequests: ptr Table[cstring, FFIRequestProc]
|
|
|
|
var onFFIThread* {.threadvar.}: bool
|
|
# Re-entrant dispatch guard for `sendRequestToFFIThread`.
|
|
|
|
const git_version* {.strdefine.} = "n/a"
|
|
|
|
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.
|
|
EventThreadTickInterval* = 1.seconds
|
|
FFIHeartbeatStartDelay* = 10.seconds # grace window for library startup
|
|
FFIHeartbeatStaleThreshold* = 1.seconds
|
|
|
|
include ./event_thread
|
|
include ./ffi_thread
|
|
|
|
template closeAndNil(field: untyped) =
|
|
if not field.isNil():
|
|
?field.close()
|
|
field = nil
|
|
|
|
proc deinitContextResources*[T](ctx: ptr FFIContext[T]): Result[void, string] =
|
|
## Mirror of `initContextResources`. Threads MUST be joined first;
|
|
## fields are nil'd after close so re-init on the same slot is safe.
|
|
ctx.lock.deinitLock()
|
|
deinitEventRegistry(ctx[].eventRegistry)
|
|
deinitHandleRegistry(ctx[].handles)
|
|
deinitEventQueue(ctx[].eventQueue)
|
|
when defined(gcRefc):
|
|
# ThreadSignalPtr.close() under refc traps in safeUnregisterAndCloseFd
|
|
# → newDispatcher → rawNewObj → signal-handler re-entry (process hangs).
|
|
# See tests/test_ffi_context.nim "destroyFFIContext refc workaround".
|
|
# Fd leak is bounded — with the recycle pool, full destroy is rare.
|
|
discard
|
|
else:
|
|
closeAndNil(ctx.reqSignal)
|
|
closeAndNil(ctx.reqReceivedSignal)
|
|
closeAndNil(ctx.stopSignal)
|
|
closeAndNil(ctx.threadExitSignal)
|
|
closeAndNil(ctx.eventQueueSignal)
|
|
closeAndNil(ctx.eventThreadExitSignal)
|
|
closeAndNil(ctx.recycleDoneSignal)
|
|
ok()
|
|
|
|
proc cleanUpResources[T](ctx: ptr FFIContext[T]): Result[void, string] =
|
|
## Deinit + free for heap-allocated contexts.
|
|
defer:
|
|
freeShared(ctx)
|
|
ctx.deinitContextResources()
|
|
|
|
template newSignalOrErr(field: untyped, name: string) =
|
|
field = ThreadSignalPtr.new().valueOr:
|
|
return err("couldn't create ThreadSignalPtr: " & name & ": " & $error)
|
|
|
|
proc initContextResources*[T](ctx: ptr FFIContext[T]): Result[void, string] =
|
|
## On failure, the deferred cleanup closes partial state; caller releases
|
|
## the slot (freeShared or pool.releaseSlot).
|
|
# Nil first so deferred cleanup can't double-close a reused pool slot.
|
|
ctx.reqSignal = nil
|
|
ctx.reqReceivedSignal = nil
|
|
ctx.stopSignal = nil
|
|
ctx.threadExitSignal = nil
|
|
ctx.eventQueueSignal = nil
|
|
ctx.eventThreadExitSignal = nil
|
|
ctx.recycleDoneSignal = nil
|
|
ctx.myLibOwned = false
|
|
ctx.myLibRefd = false
|
|
ctx.lifecycle.store(CtxLifecycle.Active)
|
|
ctx.lock.initLock()
|
|
initEventRegistry(ctx[].eventRegistry)
|
|
initHandleRegistry(ctx[].handles)
|
|
initEventQueue(ctx[].eventQueue)
|
|
ctx.ffiHeartbeat.store(0)
|
|
ctx.eventQueueStuck.store(false)
|
|
|
|
var success = false
|
|
defer:
|
|
if not success:
|
|
ctx.cleanUpResources().isOkOr:
|
|
error "failed to clean up resources after createFFIContext failure",
|
|
error = error
|
|
|
|
newSignalOrErr(ctx.reqSignal, "reqSignal")
|
|
newSignalOrErr(ctx.reqReceivedSignal, "reqReceivedSignal")
|
|
newSignalOrErr(ctx.stopSignal, "stopSignal")
|
|
newSignalOrErr(ctx.threadExitSignal, "threadExitSignal")
|
|
newSignalOrErr(ctx.eventQueueSignal, "eventQueueSignal")
|
|
newSignalOrErr(ctx.eventThreadExitSignal, "eventThreadExitSignal")
|
|
newSignalOrErr(ctx.recycleDoneSignal, "recycleDoneSignal")
|
|
|
|
ctx.registeredRequests = addr ffi_types.registeredRequests
|
|
|
|
ctx.running.store(true)
|
|
|
|
try:
|
|
createThread(ctx.ffiThread, ffiThreadBody[T], ctx)
|
|
except ValueError, ResourceExhaustedError:
|
|
return err("failed to create the FFI thread: " & getCurrentExceptionMsg())
|
|
|
|
try:
|
|
createThread(ctx.eventThread, eventThreadBody[T], ctx)
|
|
except ValueError, ResourceExhaustedError:
|
|
# Join ffiThread before deferred cleanup closes signals it's waiting on.
|
|
ctx.running.store(false)
|
|
let fireRes = ctx.reqSignal.fireSync()
|
|
if fireRes.isErr():
|
|
error "failed to signal ffiThread during event-thread cleanup",
|
|
error = fireRes.error
|
|
joinThread(ctx.ffiThread)
|
|
return err("failed to create the event thread: " & getCurrentExceptionMsg())
|
|
|
|
success = true
|
|
ok()
|
|
|
|
proc fireOrErr(sig: ThreadSignalPtr, name: string): Result[void, string] =
|
|
let fired = sig.fireSync().valueOr:
|
|
return err("error signaling: " & name & ": " & $error)
|
|
if not fired:
|
|
return err("failed to signal: " & name & " on time")
|
|
ok()
|
|
|
|
proc waitExitOrErr(
|
|
sig: ThreadSignalPtr, name: string, timeout: Duration
|
|
): Result[void, string] =
|
|
let exited = sig.waitSync(timeout).valueOr:
|
|
return err("error waiting for exit: " & name & ": " & $error)
|
|
if not exited:
|
|
return err("did not exit in time: " & name & " (leaking ctx to avoid hang)")
|
|
ok()
|
|
|
|
proc signalStop*[T](ctx: ptr FFIContext[T]): Result[void, string] =
|
|
# Skip onNotResponding on error: it takes reg.lock, which a back-pressuring
|
|
# listener may hold — would deepen the stuck state into a deadlock.
|
|
ctx.running.store(false)
|
|
?ctx.reqSignal.fireOrErr("reqSignal")
|
|
?ctx.stopSignal.fireOrErr("stopSignal")
|
|
# Non-fatal: event thread sees running==false on the next tick anyway.
|
|
ctx.eventQueueSignal.fireOrErr("eventQueueSignal").isOkOr:
|
|
error "failed to signal eventQueueSignal in signalStop", error = error
|
|
ok()
|
|
|
|
proc tryClaim*[T](ctx: ptr FFIContext[T]): bool =
|
|
## Atomically claim a free pooled context (false -> true).
|
|
var expected = false
|
|
ctx.inUse.compareExchange(expected, true)
|
|
|
|
proc release*[T](ctx: ptr FFIContext[T]) =
|
|
ctx.inUse.store(false)
|
|
|
|
proc isInUse*[T](ctx: ptr FFIContext[T]): bool =
|
|
ctx.inUse.load()
|
|
|
|
proc markAsActive*[T](ctx: ptr FFIContext[T]) =
|
|
## Reused context: its worker threads are still alive; re-arm for requests.
|
|
ctx.lifecycle.store(CtxLifecycle.Active)
|
|
|
|
proc requestRecycle*[T](ctx: ptr FFIContext[T]): Result[void, string] =
|
|
## Ask the FFI thread to drain, free the lib and release the slot, WITHOUT
|
|
## stopping its worker/event threads, so the next createFFIContext reuses them.
|
|
## Synchronous: waits on recycleDoneSignal. No fd churn -> no select() limit.
|
|
ctx.lock.acquire()
|
|
if ctx.lifecycle.load() != CtxLifecycle.Active:
|
|
ctx.lock.release()
|
|
return err("requestRecycle: context is not Active (already recycling)")
|
|
ctx.lifecycle.store(CtxLifecycle.RecyclePending)
|
|
ctx.lock.release()
|
|
|
|
let fired = ctx.reqSignal.fireSync().valueOr:
|
|
return err("requestRecycle: failed to signal the FFI thread: " & $error)
|
|
if not fired:
|
|
return err("requestRecycle: failed to signal the FFI thread in time")
|
|
|
|
let done = ctx.recycleDoneSignal.waitSync(RecycleWaitTimeout).valueOr:
|
|
return err("requestRecycle: failed waiting for recycle: " & $error)
|
|
if not done:
|
|
return err("requestRecycle: recycle did not complete in time")
|
|
ok()
|
|
|
|
## Bound on how long clearContext waits for the FFI thread to exit before
|
|
## leaking ctx rather than hanging the caller.
|
|
const ThreadExitTimeout* = 1500.milliseconds
|
|
|
|
proc stopAndJoinThreads*[T](ctx: ptr FFIContext[T]): Result[void, string] =
|
|
## On timeout, returns err and skips remaining joins (leaves threads live).
|
|
## Caller owns resource cleanup. Skips onNotResponding (same reason as signalStop).
|
|
ctx.signalStop().isOkOr:
|
|
return err("signalStop failed: " & $error)
|
|
|
|
?ctx.threadExitSignal.waitExitOrErr("FFI thread", ThreadExitTimeout)
|
|
joinThread(ctx.ffiThread)
|
|
?ctx.eventThreadExitSignal.waitExitOrErr("event thread", ThreadExitTimeout)
|
|
joinThread(ctx.eventThread)
|
|
ok()
|
|
|
|
proc clearContext[T](ctx: ptr FFIContext[T]): Result[void, string] =
|
|
## Stops a heap-allocated FFI context.
|
|
ctx.stopAndJoinThreads().isOkOr:
|
|
return err("clearContext: " & $error)
|
|
ctx.cleanUpResources().isOkOr:
|
|
return err("cleanUpResources failed: " & $error)
|
|
ok()
|