mirror of
https://github.com/logos-messaging/nim-ffi.git
synced 2026-07-16 13:09:24 +00:00
179 lines
6.5 KiB
Nim
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)
|