mirror of
https://github.com/logos-messaging/nim-ffi.git
synced 2026-06-22 01:10:03 +00:00
260 lines
8.8 KiB
Nim
260 lines
8.8 KiB
Nim
## Per-context event registry + bounded SPSC queue. FFI thread enqueues,
|
|
## event thread drains; payloads travel via `c_malloc` so they survive
|
|
## pool-slot reuse across thread heaps.
|
|
|
|
{.pragma: callback, cdecl, raises: [], gcsafe.}
|
|
|
|
import system/ansi_c
|
|
import std/[atomics, locks, sequtils, options, tables]
|
|
import chronicles
|
|
import ./ffi_types, ./cbor_serial, ./alloc
|
|
|
|
type EventEnvelope*[T] = object ## CBOR wire shape: { eventType: tstr, payload: <T> }.
|
|
eventType*: string
|
|
payload*: T
|
|
|
|
type
|
|
FFIEventListener* = object
|
|
id*: uint64
|
|
callback*: FFICallBack
|
|
userData*: pointer
|
|
|
|
FFIEventRegistry* = object
|
|
lock*: Lock
|
|
nextId*: uint64 # 0 is reserved as "invalid"; ids start at 1.
|
|
byEvent*: Table[string, seq[FFIEventListener]]
|
|
|
|
proc initEventRegistry*(reg: var FFIEventRegistry) =
|
|
## Must run once on the owning thread before sharing — `initLock` on a
|
|
## live primitive is UB at the OS layer.
|
|
reg.lock.initLock()
|
|
reg.nextId = 0'u64
|
|
reg.byEvent = initTable[string, seq[FFIEventListener]]()
|
|
|
|
proc deinitEventRegistry*(reg: var FFIEventRegistry) =
|
|
## Mirror of `initEventRegistry`; same single-thread constraint. Resets GC
|
|
## fields so pool-slot reuse on another thread sees no hidden dtor.
|
|
reg.lock.deinitLock()
|
|
reg.byEvent = default(Table[string, seq[FFIEventListener]])
|
|
reg.nextId = 0'u64
|
|
|
|
proc addEventListener*(
|
|
reg: var FFIEventRegistry,
|
|
eventName: string,
|
|
callback: FFICallBack,
|
|
userData: pointer,
|
|
): uint64 {.raises: [].} =
|
|
## Returns the listener id (>0), or 0 if `callback` is nil.
|
|
if callback.isNil():
|
|
return 0
|
|
|
|
var assigned: uint64 = 0
|
|
|
|
withLock reg.lock:
|
|
reg.nextId.inc()
|
|
assigned = reg.nextId
|
|
let listener =
|
|
FFIEventListener(id: assigned, callback: callback, userData: userData)
|
|
reg.byEvent.mgetOrPut(eventName, @[]).add(listener)
|
|
assigned
|
|
|
|
proc removeEventListener*(reg: var FFIEventRegistry, id: uint64): bool {.raises: [].} =
|
|
## Safe to call from inside a dispatch — the in-flight snapshot still
|
|
## delivers exactly once to the removed listener.
|
|
if id == 0'u64:
|
|
return false
|
|
|
|
var removed = false
|
|
|
|
withLock reg.lock:
|
|
var
|
|
pruneKey = ""
|
|
prune = false
|
|
for key, listeners in reg.byEvent.mpairs:
|
|
let before = listeners.len
|
|
listeners.keepItIf(it.id != id)
|
|
if listeners.len < before:
|
|
removed = true
|
|
if listeners.len == 0:
|
|
pruneKey = key
|
|
prune = true
|
|
break
|
|
if prune:
|
|
reg.byEvent.del(pruneKey)
|
|
removed
|
|
|
|
proc removeAllEventListeners*(reg: var FFIEventRegistry) {.raises: [].} =
|
|
## Does not reset the id counter.
|
|
withLock reg.lock:
|
|
reg.byEvent.clear()
|
|
|
|
proc snapshotListeners*(
|
|
reg: var FFIEventRegistry, eventName: string
|
|
): seq[FFIEventListener] {.raises: [].} =
|
|
## Lock held only across the copy — keeps re-entrant add/remove
|
|
## from a handler deadlock-free.
|
|
var listeners: seq[FFIEventListener] = @[]
|
|
withLock reg.lock:
|
|
for l in reg.byEvent.getOrDefault(eventName):
|
|
listeners.add(l)
|
|
listeners
|
|
|
|
const EventQueueCapacity* = 1024
|
|
# Sustained backlog at this depth means a listener is wedged.
|
|
|
|
type
|
|
QueuedEvent* = object
|
|
# Raw `c_malloc` pointers so the buffer survives pool-slot reuse
|
|
# across thread heaps without an assignment dtor.
|
|
name*: cstring
|
|
data*: ptr UncheckedArray[byte]
|
|
dataLen*: int
|
|
|
|
EventQueue* = object # SPSC ring; plain lock since ops are short and uncontended.
|
|
lock*: Lock
|
|
head*: int
|
|
tail*: int
|
|
count*: int
|
|
buf*: array[EventQueueCapacity, QueuedEvent]
|
|
|
|
proc initEventQueue*(q: var EventQueue) {.raises: [].} =
|
|
q.lock.initLock()
|
|
q.head = 0
|
|
q.tail = 0
|
|
q.count = 0
|
|
for i in 0 ..< EventQueueCapacity:
|
|
q.buf[i] = QueuedEvent(name: nil, data: nil, dataLen: 0)
|
|
|
|
proc freeEventBuffers*(
|
|
name: cstring, data: ptr UncheckedArray[byte]
|
|
) {.raises: [], gcsafe.} =
|
|
if not name.isNil():
|
|
c_free(cast[pointer](name))
|
|
if not data.isNil():
|
|
c_free(data)
|
|
|
|
proc deinitEventQueue*(q: var EventQueue) {.raises: [].} =
|
|
## Both producer and consumer must have stopped.
|
|
for i in 0 ..< EventQueueCapacity:
|
|
freeEventBuffers(q.buf[i].name, q.buf[i].data)
|
|
q.buf[i] = QueuedEvent(name: nil, data: nil, dataLen: 0)
|
|
q.head = 0
|
|
q.tail = 0
|
|
q.count = 0
|
|
q.lock.deinitLock()
|
|
|
|
proc tryEnqueueEvent*(
|
|
q: var EventQueue, name: cstring, data: ptr UncheckedArray[byte], dataLen: int
|
|
): bool {.raises: [], gcsafe.} =
|
|
## On true the queue owns `name`/`data`; on false the caller still does.
|
|
withLock q.lock:
|
|
if q.count >= EventQueueCapacity:
|
|
return false
|
|
q.buf[q.tail] = QueuedEvent(name: name, data: data, dataLen: dataLen)
|
|
q.tail = (q.tail + 1) mod EventQueueCapacity
|
|
q.count.inc()
|
|
true
|
|
|
|
proc tryDequeueEvent*(q: var EventQueue): Option[QueuedEvent] {.raises: [], gcsafe.} =
|
|
## Caller takes ownership and must `c_free` both buffers.
|
|
withLock q.lock:
|
|
if q.count == 0:
|
|
return none(QueuedEvent)
|
|
let dequeued = q.buf[q.head]
|
|
q.buf[q.head] = QueuedEvent(name: nil, data: nil, dataLen: 0)
|
|
q.head = (q.head + 1) mod EventQueueCapacity
|
|
q.count.dec()
|
|
return some(dequeued)
|
|
|
|
proc eventQueueLen*(q: var EventQueue): int {.raises: [], gcsafe.} =
|
|
withLock q.lock:
|
|
return q.count
|
|
|
|
const emptyListenerPayload*: cstring = ""
|
|
## Non-nil zero-length buffer handed to listeners when the payload is empty
|
|
## (a nil pointer would be UB for consumers doing `memcpy` even at len 0).
|
|
|
|
proc notifyListeners*(
|
|
listeners: seq[FFIEventListener], retCode: cint, data: pointer, dataLen: int
|
|
) =
|
|
## Empty payloads go through `emptyListenerPayload` so consumers doing
|
|
## `std::string(data, len)` / `memcpy` never see a nil pointer.
|
|
let n = max(dataLen, 0)
|
|
let dataPtr =
|
|
if n > 0 and not data.isNil():
|
|
cast[ptr cchar](data)
|
|
else:
|
|
cast[ptr cchar](emptyListenerPayload)
|
|
for listener in listeners:
|
|
listener.callback(retCode, dataPtr, cast[csize_t](n), listener.userData)
|
|
|
|
proc notifyListenersErr*(listeners: seq[FFIEventListener], msg: string) =
|
|
let p =
|
|
if msg.len > 0:
|
|
cast[pointer](unsafeAddr msg[0])
|
|
else:
|
|
cast[pointer](emptyListenerPayload)
|
|
notifyListeners(listeners, RET_ERR, p, msg.len)
|
|
|
|
var ffiCurrentEventRegistry* {.threadvar.}: ptr FFIEventRegistry
|
|
# Kept for tests that drive the registry directly.
|
|
|
|
var ffiCurrentEventQueue* {.threadvar.}: ptr EventQueue
|
|
# Installed by the FFI thread so dispatch templates need no `ctx`.
|
|
|
|
var ffiCurrentEventQueueStuck* {.threadvar.}: ptr Atomic[bool]
|
|
# Sticky overflow flag; FFI request entry point reads it to reject.
|
|
|
|
var ffiCurrentNotifyEventEnqueued* {.threadvar.}: proc() {.gcsafe, raises: [].}
|
|
# Hook so this module doesn't depend on chronos's ThreadSignalPtr.
|
|
# Nil-safe; tick-driven tests leave it unset.
|
|
|
|
template enqueueOrMarkStuck(
|
|
eventName: string, namePtr: cstring, dataPtr: ptr UncheckedArray[byte], dataLen: int
|
|
) =
|
|
## Takes ownership of `namePtr`/`dataPtr`. On queue-full sets the sticky
|
|
## stuck flag and wakes the event thread (firing onNotResponding from here
|
|
## would risk deadlock against a back-pressuring listener).
|
|
block enqueueBlock:
|
|
let q = ffiCurrentEventQueue
|
|
if q.isNil():
|
|
chronicles.error "event queue not set on this thread", event = eventName
|
|
freeEventBuffers(namePtr, dataPtr)
|
|
break enqueueBlock
|
|
if not q[].tryEnqueueEvent(namePtr, dataPtr, dataLen):
|
|
chronicles.error "event queue full; library marked stuck",
|
|
event = eventName, capacity = EventQueueCapacity
|
|
freeEventBuffers(namePtr, dataPtr)
|
|
if not ffiCurrentEventQueueStuck.isNil():
|
|
ffiCurrentEventQueueStuck[].store(true)
|
|
if not ffiCurrentNotifyEventEnqueued.isNil():
|
|
ffiCurrentNotifyEventEnqueued()
|
|
break enqueueBlock
|
|
if not ffiCurrentNotifyEventEnqueued.isNil():
|
|
ffiCurrentNotifyEventEnqueued()
|
|
|
|
template dispatchFFIEvent*(eventName: string, body: untyped) =
|
|
## `body` must yield `string` / `seq[byte]`. FFI thread only: encodes into
|
|
## a `c_malloc` buffer and enqueues; the event thread fans out to listeners.
|
|
block:
|
|
let evtName: string = eventName
|
|
let bodyVal = body
|
|
var dataPtr: ptr UncheckedArray[byte] = nil
|
|
let dataLen = bodyVal.len
|
|
if dataLen > 0:
|
|
dataPtr = cast[ptr UncheckedArray[byte]](c_malloc(csize_t(dataLen)))
|
|
copyMem(dataPtr, unsafeAddr bodyVal[0], dataLen)
|
|
let namePtr = alloc(evtName)
|
|
enqueueOrMarkStuck(evtName, namePtr, dataPtr, dataLen)
|
|
|
|
template dispatchFFIEventCbor*(eventName: string, eventPayload: typed) =
|
|
## Typed CBOR variant of `dispatchFFIEvent`. The param is `eventPayload`
|
|
## (not `payload`) to avoid clobbering `EventEnvelope.payload` substitution.
|
|
block:
|
|
let evtName: string = eventName
|
|
var (dataPtr, dataLen) = cborEncodeShared(
|
|
EventEnvelope[typeof(eventPayload)](eventType: evtName, payload: eventPayload)
|
|
)
|
|
let namePtr = alloc(evtName)
|
|
enqueueOrMarkStuck(evtName, namePtr, dataPtr, dataLen)
|