diff --git a/ffi/ffi_context.nim b/ffi/ffi_context.nim index 643395b..0912b0b 100644 --- a/ffi/ffi_context.nim +++ b/ffi/ffi_context.nim @@ -19,38 +19,25 @@ type FFIContext*[T] = object ffiThread: Thread[(ptr FFIContext[T])] # represents the main FFI thread in charge of attending API consumer actions eventThread: Thread[(ptr FFIContext[T])] - # drains the bounded event queue and runs the heartbeat health check; - # replaces the previous standalone watchdog thread + # drains the event queue and runs the FFI-thread heartbeat check lock: Lock reqChannel: ChannelSPSCSingle[ptr FFIThreadRequest] reqSignal: ThreadSignalPtr # to notify the FFI Thread that a new request is sent reqReceivedSignal: ThreadSignalPtr # to signal main thread, interfacing with the FFI thread, that FFI thread received the request stopSignal: ThreadSignalPtr - # fired by destroyFFIContext so both ffiThread and eventThread can exit promptly threadExitSignal: ThreadSignalPtr - # fired by ffiThread just before it exits; destroyFFIContext waits on - # this with a bounded timeout instead of joining unconditionally, so a - # blocked event loop cannot hang the caller forever - eventQueueSignal: ThreadSignalPtr - # fired by the FFI thread (via the dispatch templates) when it enqueues - # an event, so the event thread wakes promptly instead of waiting out - # the tick interval - eventThreadExitSignal: ThreadSignalPtr - # fired by the event thread just before it exits; mirrors threadExitSignal - # so destroyFFIContext can do a bounded wait on the event thread too + # fired by ffiThread before exit; bounds destroyFFIContext's wait so + # a blocked event 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 eventQueue*: EventQueue - # bounded SPSC ring; the FFI thread is the only producer, the event - # thread the only consumer ffiHeartbeat*: Atomic[int64] - # advanced by the FFI thread on every iteration of its main loop; the - # event thread reads it to detect a wedged FFI thread (>1s without an - # advance after the start-grace window) and fires onNotResponding - eventQueueStuck*: Atomic[bool] - # sticky overflow flag — once the queue saturates, sendRequestToFFIThread - # rejects further calls so the library can't dig itself deeper + # advanced by the FFI thread each loop iteration; event thread reads it + # for liveness + eventQueueStuck*: Atomic[bool] # sticky overflow flag; recovery is destroy+recreate running: Atomic[bool] # To control when the threads are running registeredRequests: ptr Table[cstring, FFIRequestProc] # Pointer to with the registered requests at compile time @@ -63,49 +50,18 @@ var onFFIThread* {.threadvar.}: bool const git_version* {.strdefine.} = "n/a" const - EventThreadTickInterval* = 1.seconds - ## How often the event thread wakes to do a heartbeat check when no - ## events are pending. The dispatch templates also fire - ## `eventQueueSignal` on enqueue, so this only bounds the *idle* - ## latency between consecutive heartbeat checks. - FFIHeartbeatStartDelay* = 10.seconds - ## Grace window after thread startup during which heartbeat stalls - ## are ignored — gives the host library (waku / libp2p / …) time to - ## come up before we start measuring liveness. Same value the old - ## standalone watchdog used. + EventThreadTickInterval* = 1.seconds # bounds idle heartbeat check latency + FFIHeartbeatStartDelay* = 10.seconds # grace window for library startup FFIHeartbeatStaleThreshold* = 1.seconds - ## Once past the start delay, the FFI thread must advance its - ## heartbeat at least once per this interval or it is considered - ## blocked and onNotResponding fires. type NotRespondingEvent* = object - ## Empty CBOR payload — the event itself is the signal. Consumers - ## discriminate on the surrounding `EventEnvelope.eventType` - ## (`NotRespondingEventName`); this struct exists so the wire shape - ## matches the typed-event contract every other `{.ffiEvent.}` - ## produces (`{ eventType, payload }`). const NotRespondingEventName* = "not_responding" - ## Registry key and CBOR `eventType` for `onNotResponding`. Exposed so - ## typed listeners can register against the same name the dispatch - ## path uses, without depending on a string literal. proc onNotResponding*(ctx: ptr FFIContext) = - ## Fans out the "library is unhealthy" event to every listener - ## registered for `not_responding` plus every wildcard listener. The - ## payload is `EventEnvelope[NotRespondingEvent]`, CBOR-encoded once - ## and reused across listeners — same wire shape as - ## `dispatchFFIEventCbor`. - ## - ## Synchronous, lock-during-invocation by design: this is the global - ## "library is unhealthy" notification path and bypasses the event - ## queue (which may itself be the thing that's stuck). The dispatch - ## templates' lock-during-invocation contract is mirrored here. - ## - ## Cannot reuse `dispatchFFIEventCbor`: that template resolves the - ## registry via the `ffiCurrentEventRegistry` threadvar, which is only - ## set on the FFI thread. `onNotResponding` runs on the event thread, - ## so it goes through `ctx[].eventRegistry` directly. + ## Bypasses the event queue (which may itself be wedged). Cannot reuse + ## `dispatchFFIEventCbor`: that template reads `ffiCurrentEventRegistry`, + ## a threadvar only set on the FFI thread, but this runs on the event thread. withLock ctx[].eventRegistry.lock: let snap = ctx[].eventRegistry.byEvent.getOrDefault(NotRespondingEventName) & @@ -142,24 +98,15 @@ proc onNotResponding*(ctx: ptr FFIContext) = proc sendRequestToFFIThread*( ctx: ptr FFIContext, ffiRequest: ptr FFIThreadRequest, timeout = InfiniteDuration ): Result[void, string] = - # Issue #6: once the event queue has overflowed we stop accepting new - # requests entirely, on the assumption that any further work will just - # produce more events the listener side already can't keep up with. - # Stuck flag is sticky for the context lifetime — recovery requires - # destroy + recreate, matching the issue's "expected malfunctioning". - # NB: we deliberately do NOT call onNotResponding here. The event - # thread fires it once when it observes the stuck flag (its loop is - # the only place where reg.lock is guaranteed not to be held by an - # in-flight listener); calling it from a foreign thread would - # deadlock against a back-pressuring listener mid-invocation. + # Once the event queue overflows, refuse further requests. onNotResponding + # is fired by the event thread (not here) to avoid deadlocking against a + # back-pressuring listener that holds reg.lock. if ctx.eventQueueStuck.load(): deleteRequest(ffiRequest) return err("event queue stuck - library cannot accept new requests") - # Reentrancy guard (PR #23 review, item 6): if a handler running on the FFI - # thread tries to dispatch back through this proc, it would wait forever on - # `reqReceivedSignal` — which only this thread can fire — and self-deadlock. - # Return an error instead so the caller can surface it. + # Reentrancy guard: a handler dispatching back through this proc would + # wait forever on `reqReceivedSignal` — only this thread can fire it. if onFFIThread: deleteRequest(ffiRequest) return err( @@ -241,22 +188,13 @@ proc processRequest[T]( except Exception as exc: error "Unexpected exception in handleRes", error = exc.msg -# --------------------------------------------------------------------------- -# Heartbeat-aware closure capturing for the dispatch threadvar hook -# --------------------------------------------------------------------------- - var ffiEventQueueSignalPtr {.threadvar.}: ThreadSignalPtr - ## Stash for the event-thread wakeup signal. Captured here so the - ## notify-enqueued hook below has no closure environment. + ## Stashed so the hook below has no closure environment. proc ffiNotifyEventEnqueuedHook() {.gcsafe, raises: [].} = - ## Wakes the event thread immediately after a successful enqueue so - ## the listener fan-out latency isn't bounded by the tick interval. if not ffiEventQueueSignalPtr.isNil(): let res = ffiEventQueueSignalPtr.fireSync() if res.isErr(): - # The event thread will still see the queue depth on the next - # tick; logging is enough to flag a misconfigured signal fd. error "failed to fire eventQueueSignal after enqueue", err = res.error proc ffiThreadBody[T](ctx: ptr FFIContext[T]) {.thread.} = @@ -298,11 +236,8 @@ proc ffiThreadBody[T](ctx: ptr FFIContext[T]) {.thread.} = inc i while ctx.running.load(): - # Heartbeat: the event thread reads this to confirm the FFI thread - # isn't wedged. The 100 ms `reqSignal.wait` below means we advance - # at least ~10x per second under any normal load; a sync handler - # that blocks the dispatcher will freeze the counter, which is - # exactly the failure mode the watchdog used to detect. + # A sync handler blocking the dispatcher freezes this counter; the + # event thread reads it to detect a wedged FFI thread. discard ctx.ffiHeartbeat.fetchAdd(1) reapCompleted() @@ -340,20 +275,10 @@ proc ffiThreadBody[T](ctx: ptr FFIContext[T]) {.thread.} = waitFor ffiRun(ctx) proc eventThreadBody[T](ctx: ptr FFIContext[T]) {.thread.} = - ## Drains the bounded event queue and runs the FFI-thread heartbeat - ## health check. Replaces the standalone watchdog thread: the event - ## thread checks liveness in-band (no probe request round-trip), and - ## the dispatch templates' queue-overflow path is the second trigger - ## for `onNotResponding`. - ## - ## The queue stores raw `c_malloc` payloads + names; this thread owns - ## them for the duration of dispatch and frees them after the listener - ## fan-out returns. + ## Drains the event queue and runs the FFI-thread heartbeat check. + ## Owns the queued `c_malloc` payloads until dispatch returns. defer: - # Best-effort: tell stopAndJoinThreads we've exited so its bounded - # wait unblocks. If this fails we still exit; the caller's timeout - # path will take over. let fireRes = ctx.eventThreadExitSignal.fireSync() if fireRes.isErr(): error "failed to fire eventThreadExitSignal", err = fireRes.error @@ -366,19 +291,11 @@ proc eventThreadBody[T](ctx: ptr FFIContext[T]) {.thread.} = var notifiedStuck = false while ctx.running.load(): - # Wake on either an enqueue (eventQueueSignal) or the tick interval — - # whichever comes first. The signal path keeps dispatch latency low - # under load; the timeout path bounds idle latency for the - # heartbeat check. + # Wake on enqueue or tick — whichever first. discard await ctx.eventQueueSignal.wait().withTimeout(EventThreadTickInterval) - # Drain whatever is currently in the queue. Each iteration: - # 1. Pop one event (queue lock). - # 2. Snapshot listeners + invoke them under reg.lock — the - # lock-during-invocation contract from PR #39 / issue #40 is - # preserved here: a foreign `removeEventListener` blocks - # until the in-flight callback fan-out returns. - # 3. Free the payload. + # Listener fan-out runs under reg.lock — preserves the + # lock-during-invocation contract from PR #39 / issue #40. while true: let opt = ctx.eventQueue.tryDequeueEvent() if opt.isNone: @@ -417,20 +334,13 @@ proc eventThreadBody[T](ctx: ptr FFIContext[T]) {.thread.} = listener.userData, ) - # Queue-overflow notification: the FFI thread can only set the - # sticky flag (firing onNotResponding from there would deadlock - # against a back-pressuring listener that's holding reg.lock on - # this thread). We fire it once from here, after the drain loop, - # so the slow listener has already released the lock. + # Overflow notification fires here (after drain releases reg.lock) + # rather than from the FFI thread, which would deadlock against an + # in-flight back-pressuring listener. if not notifiedStuck and ctx.eventQueueStuck.load(): onNotResponding(ctx) notifiedStuck = true - # Heartbeat staleness check. Skipped during the start-delay grace - # window so a slow library bring-up doesn't fire a spurious - # not_responding. Once we've fired, latch `notifiedStale` until - # the FFI thread proves it's alive again — avoids spamming the - # listener while the FFI thread is still stuck. if not ctx.running.load(): break if Moment.now() - startedAt <= FFIHeartbeatStartDelay: @@ -443,6 +353,7 @@ proc eventThreadBody[T](ctx: ptr FFIContext[T]) {.thread.} = notifiedStale = false elif not notifiedStale and Moment.now() - lastHeartbeatChange > FFIHeartbeatStaleThreshold: + # Latch until the FFI thread proves it's alive again. onNotResponding(ctx) notifiedStale = true @@ -452,14 +363,10 @@ proc eventThreadBody[T](ctx: ptr FFIContext[T]) {.thread.} = error "event thread exited with exception", error = exc.msg proc deinitContextResources*[T](ctx: ptr FFIContext[T]): Result[void, string] = - ## Mirror of `initContextResources`: tears down the lock, registry, - ## queue, and signal fds in place. The caller is responsible for the - ## memory holding `ctx` (free it for heap allocations, return it to the - ## pool for slot-allocated contexts). Threads MUST already be joined. - ## - ## Each field is nil'd after close so a subsequent re-init on the same - ## storage (pool slot reuse) doesn't double-close a stale pointer if - ## init's deferred cleanup runs. + ## Mirror of `initContextResources`: tears down lock, registry, queue, + ## and signal fds in place. Threads MUST already be joined. Caller owns + ## the memory holding `ctx`. Fields are nil'd after close so a re-init + ## on the same slot doesn't double-close. ctx.lock.deinitLock() deinitEventRegistry(ctx[].eventRegistry) deinitEventQueue(ctx[].eventQueue) @@ -515,11 +422,8 @@ proc initContextResources*[T](ctx: ptr FFIContext[T]): Result[void, string] = ## Initialises all resources inside an already-allocated FFIContext slot. ## On failure every partially-initialised resource is closed; the caller ## is responsible for releasing the slot (freeShared or pool.releaseSlot). - ## - ## Defensive: a reused pool slot still holds the previous lifetime's - ## signal pointers (set to nil by `deinitContextResources` on destroy, - ## but explicit here in case a future path forgets). Nil before - ## allocating so the deferred cleanup never double-closes. + # Defensive nil so the deferred cleanup never double-closes if a future + # path forgets to clear stale pointers on a reused pool slot. ctx.reqSignal = nil ctx.reqReceivedSignal = nil ctx.stopSignal = nil @@ -583,11 +487,10 @@ proc initContextResources*[T](ctx: ptr FFIContext[T]): Result[void, string] = return ok() proc signalStop*[T](ctx: ptr FFIContext[T]): Result[void, string] = + # Error paths intentionally skip onNotResponding: a back-pressuring + # listener may hold reg.lock, and onNotResponding takes it — would + # amplify the stuck state into a deadlock instead of escaping it. ctx.running.store(false) - # We deliberately do NOT call onNotResponding from these error paths: - # the event thread may be stuck mid-callback holding `eventRegistry.lock`, - # and onNotResponding takes that same lock — exactly the scenario the - # bounded-timeout caller needs to escape from, not amplify into a deadlock. let reqSignaled = ctx.reqSignal.fireSync().valueOr: return err("error signaling reqSignal in signalStop: " & $error) if not reqSignaled: @@ -596,10 +499,7 @@ proc signalStop*[T](ctx: ptr FFIContext[T]): Result[void, string] = return err("error signaling stopSignal in signalStop: " & $error) if not stopSignaled: return err("failed to signal stopSignal on time in signalStop") - # Wake the event thread so it observes `running == false` immediately - # instead of waiting out the tick interval. fireSync failing here is - # not fatal — the event thread will still notice on the next tick — so - # we only log and continue. + # Non-fatal: event thread will see running==false on the next tick. let evtSignaled = ctx.eventQueueSignal.fireSync() if evtSignaled.isErr(): error "failed to signal eventQueueSignal in signalStop", error = evtSignaled.error @@ -615,18 +515,15 @@ proc signalStop*[T](ctx: ptr FFIContext[T]): Result[void, string] = const ThreadExitTimeout* = 1500.milliseconds proc stopAndJoinThreads*[T](ctx: ptr FFIContext[T]): Result[void, string] = - ## Signals the FFI and event threads to stop, waits up to ThreadExitTimeout - ## for each to exit, and joins them. On timeout returns err and skips the - ## remaining joinThread (leaving the threads live) rather than hanging the - ## caller. Resource cleanup (signal fds, lock) is the caller's - ## responsibility. + ## Signals both threads to stop, waits up to ThreadExitTimeout per thread, + ## and joins them. On timeout returns err and skips remaining joins + ## (leaving the threads live) rather than hanging the caller. Resource + ## cleanup is the caller's responsibility. + ## + ## Timeout paths skip onNotResponding for the same reason signalStop does. ctx.signalStop().isOkOr: return err("signalStop failed: " & $error) - # We deliberately do NOT call onNotResponding from the timeout paths: - # the event thread may be stuck mid-callback holding `eventRegistry.lock`, - # and onNotResponding takes that same lock — exactly the scenario the - # bounded-timeout caller needs to escape from, not amplify into a deadlock. let ffiExitedOnTime = ctx.threadExitSignal.waitSync(ThreadExitTimeout).valueOr: return err("error waiting for FFI thread exit: " & $error) diff --git a/ffi/ffi_context_pool.nim b/ffi/ffi_context_pool.nim index f5b8c56..a92ef6e 100644 --- a/ffi/ffi_context_pool.nim +++ b/ffi/ffi_context_pool.nim @@ -48,11 +48,8 @@ proc destroyFFIContext*[T]( ## unsafe. ctx.stopAndJoinThreads().isOkOr: return err("destroyFFIContext(pool): " & $error) - # Mirror initContextResources: tear down the lock, registry, queue, - # and signal fds in place. Without this the next slot acquisition would - # re-init an already-initialised lock (UB at the pthread layer) and - # overwrite the existing ThreadSignalPtr fields without closing the - # underlying fds (unbounded fd leak across create/destroy cycles). + # Without this, the next acquisition would re-init an already-initialised + # lock (UB) and leak the previous signal fds. let deinitRes = ctx.deinitContextResources() pool.releaseSlot(ctx) deinitRes.isOkOr: diff --git a/ffi/ffi_events.nim b/ffi/ffi_events.nim index 1e8390e..a9a4b07 100644 --- a/ffi/ffi_events.nim +++ b/ffi/ffi_events.nim @@ -1,22 +1,8 @@ -## Event registry, bounded event queue, and dispatch primitives for FFI -## library-initiated events. -## -## This module owns three concerns so they can evolve together without -## dragging in the rest of `FFIContext`: -## -## 1. A multi-listener registry. Each event name maps to a `seq` of -## listeners; the empty event name `""` is the wildcard channel and -## receives every dispatched event in addition to its own per-name -## subscribers. -## 2. A bounded SPSC event queue. Infrastructure for the dedicated event -## thread (owned by `FFIContext`) to drain encoded events; payloads -## travel via `c_malloc` so transfer across Nim heaps is safe under -## both `--mm:orc` and `--mm:refc`. The dispatch templates do not yet -## enqueue — that rewiring lands alongside the dispatch overhaul. -## 3. The dispatch templates (`dispatchFFIEvent`, `dispatchFFIEventCbor`) -## used by `{.ffiEvent.}`-generated procs. They snapshot the registry -## under its lock, then invoke each listener *outside* the lock so -## re-entrant add/remove from within a handler cannot self-deadlock. +## Event registry, bounded SPSC event queue, and dispatch templates for +## FFI library-initiated events. Empty event name `""` registers a +## wildcard listener that receives every dispatched event. Queue +## payloads travel via `c_malloc` so transfer across Nim heaps is safe +## under both `--mm:orc` and `--mm:refc`. {.pragma: callback, cdecl, raises: [], gcsafe.} @@ -180,36 +166,28 @@ proc snapshotListeners*( # --------------------------------------------------------------------------- const EventQueueCapacity* = 1024 - ## Maximum number of events that can sit in the queue at once. Sized - ## generously — a sustained backlog at this depth almost certainly - ## means a user listener is wedged, which is exactly what the stuck - ## flag is meant to surface. Each `QueuedEvent` is two pointers plus - ## an int (24 B on 64-bit), so the ring is ~24 KiB per context. + ## ~24 KiB per context. Sustained backlog at this depth means a + ## listener is wedged — what the stuck flag exists to surface. type QueuedEvent* = object - ## A single event sitting in the bounded queue. All fields are - ## raw `c_malloc` pointers — no GC-managed storage — so the queue - ## can be a plain `array` without an assignment destructor running - ## across thread heaps when an `FFIContextPool` slot is reused. - name*: cstring ## c_malloc'd copy of the event name. - data*: ptr UncheckedArray[byte] ## c_malloc'd CBOR-encoded payload (may be nil). + ## All fields are 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. Only the FFI thread enqueues; only the event thread - ## dequeues. `lock` is sufficient — no need for atomic indices — - ## because every operation is short and uncontended. + ## SPSC ring: FFI thread enqueues, event thread dequeues. Plain lock + ## (no atomic indices) — operations are short and uncontended. lock*: Lock - head*: int ## Next slot the consumer will read. - tail*: int ## Next slot the producer will write. - count*: int ## Current depth, in [0, EventQueueCapacity]. + head*: int + tail*: int + count*: int buf*: array[EventQueueCapacity, QueuedEvent] proc initEventQueue*(q: var EventQueue) {.raises: [].} = - ## Initialises the queue's lock and zeroes the ring. Must be called - ## exactly once on the owning thread before any other thread uses it - ## (same constraint as `initEventRegistry`). + ## Same single-owning-thread constraint as `initEventRegistry`. q.lock.initLock() q.head = 0 q.tail = 0 @@ -218,10 +196,7 @@ proc initEventQueue*(q: var EventQueue) {.raises: [].} = q.buf[i] = QueuedEvent(name: nil, data: nil, dataLen: 0) proc deinitEventQueue*(q: var EventQueue) {.raises: [].} = - ## Frees any pending entries with `c_free` and tears down the lock. - ## Called on shutdown (after both producer and consumer threads have - ## stopped) and on pool-slot reuse so the next thread to grab the - ## slot starts from a clean state. + ## Both producer and consumer must have stopped before calling. for i in 0 ..< EventQueueCapacity: let e = q.buf[i] if not e.name.isNil: @@ -237,10 +212,8 @@ proc deinitEventQueue*(q: var EventQueue) {.raises: [].} = proc tryEnqueueEvent*( q: var EventQueue, name: cstring, data: ptr UncheckedArray[byte], dataLen: int ): bool {.raises: [], gcsafe.} = - ## Pushes `(name, data, dataLen)` onto the queue. The queue takes - ## ownership of both `name` and `data` (both must be `c_malloc`'d by - ## the caller). Returns false if the queue is full — in that case the - ## caller still owns the buffers and must free them. + ## Both `name` and `data` must be `c_malloc`'d; on success the queue + ## takes ownership. On false the caller still owns and must free them. withLock q.lock: if q.count >= EventQueueCapacity: return false @@ -250,9 +223,7 @@ proc tryEnqueueEvent*( return true proc tryDequeueEvent*(q: var EventQueue): Option[QueuedEvent] {.raises: [], gcsafe.} = - ## Pops the next entry off the queue and transfers ownership of its - ## buffers to the caller (who must `c_free(name)` and `c_free(data)`). - ## Returns `none` when the queue is empty. + ## Transfers buffer ownership to the caller, who must `c_free` both. withLock q.lock: if q.count == 0: return none(QueuedEvent) @@ -263,7 +234,6 @@ proc tryDequeueEvent*(q: var EventQueue): Option[QueuedEvent] {.raises: [], gcsa return some(e) proc eventQueueLen*(q: var EventQueue): int {.raises: [], gcsafe.} = - ## Snapshot depth, mainly useful from tests. withLock q.lock: return q.count @@ -272,28 +242,15 @@ proc eventQueueLen*(q: var EventQueue): int {.raises: [], gcsafe.} = # --------------------------------------------------------------------------- var ffiCurrentEventRegistry* {.threadvar.}: ptr FFIEventRegistry - ## Set by the FFI thread at startup so dispatchFFIEvent / dispatchFFIEventCbor - ## can find their registry without taking a context pointer per call site. - var ffiCurrentEventQueue* {.threadvar.}: ptr EventQueue - ## Bounded queue handle for the dedicated event thread to drain. The - ## dispatch templates do not enqueue yet; this is set up so the event - ## thread infrastructure has somewhere to read from. - var ffiCurrentEventQueueStuck* {.threadvar.}: ptr Atomic[bool] - ## Sticky overflow flag belonging to the owning `FFIContext`. Reserved - ## for the dispatch-overhaul follow-up; not consulted yet. - var ffiCurrentNotifyEventEnqueued* {.threadvar.}: proc() {.gcsafe, raises: [].} - ## Wakes the event thread after a successful enqueue. Kept as a - ## threadvar hook (rather than a queue field) so `ffi_events.nim` - ## doesn't have to depend on chronos's `ThreadSignalPtr`. Nil-safe. + ## Hook (not a queue field) so this module doesn't depend on chronos's + ## ThreadSignalPtr. Nil-safe. template withFFIEventDispatch(eventName: string, listeners, body: untyped) = - ## Shared scaffold for `dispatchFFIEvent` / `dispatchFFIEventCbor`: - ## resolves the thread-local registry, snapshots listeners under - ## `reg.lock` into the caller-named `listeners` binding, then runs - ## `body` inside `foreignThreadGc` + try/except. + ## Resolves the thread-local registry, snapshots listeners under + ## `reg.lock`, then runs `body` inside `foreignThreadGc` + try/except. let regPtr = ffiCurrentEventRegistry if regPtr.isNil(): chronicles.error eventName & " - event registry not set on this thread"