nim-ffi/ffi/ffi_thread.nim
2026-07-15 11:46:05 -03:00

179 lines
6.5 KiB
Nim

## FFI-thread body and request submission API. Included from `ffi_context.nim`.
## Dispatches `FFIThreadRequest`s from `reqQueueBank` and advances
## `ctx.ffiHeartbeat` so the event thread can spot a wedged FFI thread.
proc sendRequestToFFIThread*(
ctx: ptr FFIContext, ffiRequest: ptr FFIThreadRequest
): Result[void, string] =
if ctx.eventQueueStuck.load():
deleteRequest(ffiRequest)
return err("event queue stuck - library cannot accept new requests")
if onFFIThread:
# A handler re-dispatching onto its own FFI thread would deadlock; reject.
deleteRequest(ffiRequest)
return err(
"reentrant ffi call: a handler invoked sendRequestToFFIThread on its own context"
)
# Wake only when the push found the queue empty: waking per submit kills scaling, and a skipped wake just waits the consumer's 100ms poll.
let shouldWake = ctx.reqQueueBank.pushRequest(ffiRequest)
# A failed wake is non-fatal (poll-drain still dispatches); erroring here would double-fire the callback for a request that still completes.
if shouldWake:
ctx.reqSignal.fireSync().isOkOr:
error "failed to wake FFI thread after enqueue (request still queued)",
error = error
ok()
proc awaitWithStaleWarnings(
retFut: Future[Result[seq[byte], string]],
request: ptr FFIThreadRequest,
interval: Duration,
reqId: string,
): Future[Result[seq[byte], string]] {.async.} =
## Pings RET_STALE_WARN every `interval` while the handler runs, then returns
## its real result. Never cancels the handler: a hard-cancel mid-call could
## leave the underlying library partially applied.
let intervalMs = interval.milliseconds
if intervalMs <= 0:
return await retFut
var elapsed = 0'i64
while not retFut.finished():
let timer = sleepAsync(interval)
# `race` doesn't cancel the loser, so the handler keeps running.
discard await race(retFut, timer)
if retFut.finished():
if not timer.finished():
await timer.cancelAndWait()
break
elapsed += intervalMs
warn "ffi request still in flight; caller notified via RET_STALE_WARN",
reqId = reqId, elapsedMs = elapsed
fireStaleWarn(request, elapsed)
return await retFut
proc processRequest[T](
request: ptr FFIThreadRequest, ctx: ptr FFIContext[T]
) {.async.} =
## Processes one request on the FFI thread.
let reqId = $request[].reqId
let reqIdCs = reqId.cstring # keeps reqId alive
let retFut =
if not ctx[].registeredRequests[].contains(reqIdCs):
nilProcess(request[].reqId)
else:
ctx[].registeredRequests[][reqIdCs](cast[pointer](request), ctx)
# One try over warn-loop + handler so a shutdown-drain cancel still reaches the response-and-free below.
let res =
try:
await awaitWithStaleWarnings(retFut, request, ctx.staleWarnInterval, reqId)
except CatchableError as e:
Result[seq[byte], string].err(
"Error in processRequest for " & reqId & ": " & e.msg
)
try:
handleRes(res, request)
except Exception as e:
error "Unexpected exception in handleRes", error = e.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 proveAlive(ctx: ptr FFIContext) =
## Advance the heartbeat the event thread polls; only movement matters, not value.
ctx.ffiHeartbeat.atomicInc()
proc ffiThreadBody[T](ctx: ptr FFIContext[T]) {.thread.} =
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
# Free handle refs on the thread that allocated them (refc heap is thread-local).
ctx[].handles.releaseAll()
# Let the event thread stop draining and exit; wake it so it notices now.
ctx.ffiThreadExited.store(true)
ctx.eventQueueSignal.fireSync().isOkOr:
error "failed to wake event thread on FFI thread exit", err = error
# Unblocks destroyFFIContext's bounded wait.
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 # main library object (Waku, LibP2P, SDS, …)
# Tracked so shutdown can drain them; abandoning a future leaks its request.
var pending: seq[Future[void]] = @[]
proc cleanFinishedRequests() =
var i = 0
while i < pending.len:
if not pending[i].finished():
inc i
continue
pending.del(i)
proc processQueue() =
## Drain fully: one wake can stand for many submits.
while true:
var request = ctx.reqQueueBank.mergeQueues()
if request.isNil():
break
while not request.isNil():
let nextRequest = request[].next # read before processRequest frees it
# Tick per dispatch so a backlog can't flatline the heartbeat mid-drain.
ctx.proveAlive()
if ctx.myLib.isNil():
# Must stay inside the closure: keeps `ffiReqHandler` alive across awaits.
ctx.myLib = addr ffiReqHandler
pending.add processRequest(request, ctx)
request = nextRequest
while ctx.running.load():
ctx.proveAlive()
cleanFinishedRequests()
# Block until a submit signals us, or at most 100ms.
discard await ctx.reqSignal.wait().withTimeout(chronos.milliseconds(100))
processQueue()
# Drain once more for requests enqueued just before `running` flipped.
processQueue()
cleanFinishedRequests()
if pending.len > 0:
try:
await allFutures(pending)
except CatchableError as e:
error "draining pending FFI requests on shutdown raised", error = e.msg
# Run the library's async {.ffiDtor.} shutdown before join if one exists and a request populated `myLib`; exceptions logged, never propagated.
let teardown = ffiTeardownHook[T]()
if not teardown.isNil() and not ctx.myLib.isNil():
try:
await teardown(ctx.myLib)
except CatchableError as e:
error "library teardown raised on shutdown", error = e.msg
waitFor ffiRun(ctx)