mirror of
https://github.com/logos-messaging/nim-ffi.git
synced 2026-06-21 00:40:16 +00:00
184 lines
6.8 KiB
Nim
184 lines
6.8 KiB
Nim
## FFI-thread body and request submission API.
|
|
##
|
|
## Included from `ffi_context.nim` — inherits its imports, FFIContext type,
|
|
## and the `onFFIThread` threadvar. Companion to `event_thread.nim`.
|
|
##
|
|
## Responsibilities:
|
|
## - Receive `FFIThreadRequest`s from foreign threads via `reqChannel` and
|
|
## dispatch them through the user-registered handler table.
|
|
## - Advance `ctx.ffiHeartbeat` each loop iteration so the event thread can
|
|
## detect a wedged FFI thread.
|
|
|
|
proc sendRequestToFFIThread*(
|
|
ctx: ptr FFIContext, ffiRequest: ptr FFIThreadRequest, timeout = InfiniteDuration
|
|
): Result[void, string] =
|
|
if ctx.eventQueueStuck.load():
|
|
deleteRequest(ffiRequest)
|
|
return err("event queue stuck - library cannot accept new requests")
|
|
|
|
if onFFIThread:
|
|
# Re-entrant dispatch from a handler would self-deadlock on `reqReceivedSignal`.
|
|
deleteRequest(ffiRequest)
|
|
return err(
|
|
"reentrant ffi call: a handler invoked sendRequestToFFIThread on its own context"
|
|
)
|
|
|
|
# All async submissions serialise on `ctx.lock` for the full
|
|
# trySend + fireSync + waitSync sequence because `reqChannel` is
|
|
# single-producer and `reqReceivedSignal` is shared across callers.
|
|
# Multi-producer redesign is tracked as PR #23 review item 7.
|
|
ctx.lock.acquire()
|
|
defer:
|
|
ctx.lock.release()
|
|
|
|
## Sending the request
|
|
let sentOk = ctx.reqChannel.trySend(ffiRequest)
|
|
if not sentOk:
|
|
deleteRequest(ffiRequest)
|
|
return err("Couldn't send a request to the ffi thread")
|
|
|
|
let fireSyncRes = ctx.reqSignal.fireSync()
|
|
if fireSyncRes.isErr():
|
|
deleteRequest(ffiRequest)
|
|
return err("failed fireSync: " & $fireSyncRes.error)
|
|
|
|
if fireSyncRes.get() == false:
|
|
deleteRequest(ffiRequest)
|
|
return err("Couldn't fireSync in time")
|
|
|
|
## wait until the FFI working thread properly received the request
|
|
let res = ctx.reqReceivedSignal.waitSync(timeout)
|
|
if res.isErr():
|
|
## Do not free ffiRequest here: the FFI thread was already signaled and
|
|
## will process (and free) it.
|
|
return err("Couldn't receive reqReceivedSignal signal")
|
|
|
|
## Notice that in case of "ok", the deallocShared(req) is performed by the FFI Thread in the
|
|
## process proc.
|
|
ok()
|
|
|
|
proc processRequest[T](
|
|
request: ptr FFIThreadRequest, ctx: ptr FFIContext[T]
|
|
) {.async.} =
|
|
## Invoked within the FFI thread to process a request coming from the FFI API consumer thread.
|
|
|
|
let reqId = $request[].reqId
|
|
## The reqId determines which proc will handle the request.
|
|
## The registeredRequests represents a table defined at compile time.
|
|
## Then, registeredRequests == Table[reqId, proc-handling-the-request-asynchronously]
|
|
|
|
## Explicit conversion keeps `reqId` alive as the backing string,
|
|
## avoiding the implicit string→cstring warning that will become an error.
|
|
let reqIdCs = reqId.cstring
|
|
|
|
let retFut =
|
|
if not ctx[].registeredRequests[].contains(reqIdCs):
|
|
## That shouldn't happen because only registered requests should be sent to the FFI thread.
|
|
nilProcess(request[].reqId)
|
|
else:
|
|
ctx[].registeredRequests[][reqIdCs](cast[pointer](request), ctx)
|
|
|
|
## Catch every catchable exception (including CancelledError raised by
|
|
## the shutdown drain in ffiRun) so handleRes — and its `deleteRequest`
|
|
## defer — always runs. Otherwise an abandoned in-flight handler would
|
|
## leak its request envelope, reqId copy, and CBOR payload.
|
|
let res =
|
|
try:
|
|
await retFut
|
|
except CatchableError as exc:
|
|
Result[seq[byte], string].err(
|
|
"Error in processRequest for " & reqId & ": " & exc.msg
|
|
)
|
|
|
|
## handleRes may raise (OOM, GC setup) even though it is rare. Catching here
|
|
## keeps the async proc raises:[] compatible. The defer inside handleRes
|
|
## guarantees request is freed before the exception propagates.
|
|
try:
|
|
handleRes(res, request)
|
|
except Exception as exc:
|
|
error "Unexpected exception in handleRes", error = exc.msg
|
|
|
|
var ffiEventQueueSignalPtr {.threadvar.}: ThreadSignalPtr
|
|
# Stashed so the hook has no closure env.
|
|
|
|
proc ffiNotifyEventEnqueuedHook() {.gcsafe, raises: [].} =
|
|
if not ffiEventQueueSignalPtr.isNil():
|
|
let res = ffiEventQueueSignalPtr.fireSync()
|
|
if res.isErr():
|
|
error "failed to fire eventQueueSignal after enqueue", err = res.error
|
|
|
|
proc ffiThreadBody[T](ctx: ptr FFIContext[T]) {.thread.} =
|
|
## FFI thread body that attends library user API requests
|
|
ffiCurrentEventRegistry = addr ctx[].eventRegistry
|
|
ffiCurrentEventQueue = addr ctx[].eventQueue
|
|
ffiCurrentEventQueueStuck = addr ctx[].eventQueueStuck
|
|
ffiEventQueueSignalPtr = ctx.eventQueueSignal
|
|
ffiCurrentNotifyEventEnqueued = ffiNotifyEventEnqueuedHook
|
|
onFFIThread = true
|
|
|
|
logging.setupLog(logging.LogLevel.DEBUG, logging.LogFormat.TEXT)
|
|
|
|
defer:
|
|
onFFIThread = false
|
|
# Unblocks destroyFFIContext's bounded wait so cleanup can proceed.
|
|
let fireRes = ctx.threadExitSignal.fireSync()
|
|
if fireRes.isErr():
|
|
error "failed to fire threadExitSignal on FFI thread exit", err = fireRes.error
|
|
|
|
let ffiRun = proc(ctx: ptr FFIContext[T]) {.async.} =
|
|
var ffiReqHandler: T
|
|
## Holds the main library object, i.e., in charge of handling the ffi requests.
|
|
## e.g., Waku, LibP2P, SDS, etc.
|
|
|
|
## In-flight processRequest futures. Tracked so they can be drained on
|
|
## shutdown — otherwise destroying the context while a handler is
|
|
## awaiting (e.g. sleepAsync) abandons the future and leaks the
|
|
## request's envelope/reqId/payload allocations.
|
|
var pending: seq[Future[void]] = @[]
|
|
|
|
proc reapCompleted() =
|
|
var i = 0
|
|
while i < pending.len:
|
|
if pending[i].finished():
|
|
pending.del(i)
|
|
else:
|
|
inc i
|
|
|
|
while ctx.running.load():
|
|
# Freezes if a sync handler blocks the dispatcher; event thread reads to detect wedged FFI thread.
|
|
discard ctx.ffiHeartbeat.fetchAdd(1)
|
|
|
|
reapCompleted()
|
|
|
|
let gotSignal = await ctx.reqSignal.wait().withTimeout(100.milliseconds)
|
|
if not gotSignal:
|
|
continue
|
|
|
|
## Wait for a request from the ffi consumer thread
|
|
var request: ptr FFIThreadRequest
|
|
if not ctx.reqChannel.tryRecv(request):
|
|
continue
|
|
|
|
if ctx.myLib.isNil():
|
|
ctx.myLib = addr ffiReqHandler
|
|
|
|
## Handle the request
|
|
pending.add processRequest(request, ctx)
|
|
|
|
let fireRes = ctx.reqReceivedSignal.fireSync()
|
|
if fireRes.isErr():
|
|
error "could not fireSync back to requester thread", error = fireRes.error
|
|
|
|
## Drain in-flight handlers so each request's `deleteRequest` runs
|
|
## before we exit. Without this, abandoning a future mid-await would
|
|
## leak the request allocations (visible to LSan; previously hidden
|
|
## because Nim's pool allocator kept the chunks alive in the process).
|
|
reapCompleted()
|
|
if pending.len > 0:
|
|
try:
|
|
await allFutures(pending)
|
|
except CatchableError as exc:
|
|
error "draining pending FFI requests on shutdown raised", error = exc.msg
|
|
|
|
waitFor ffiRun(ctx)
|