You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Rework the event delivery queue so that no event can ever be dropped, while keeping the steady-state memory footprint flat (near-zero allocation on the hot path). The proposal is to replace the current fixed-capacity ring (introduced in #107, closing #92) with a single unbounded intrusive SPSC queue backed by a node freelist — reusing the mechanism already proven for FFI requests in #102, minus the sharding.
Motivation — the current design drops events, terminally
Event delivery today is a bounded ring (EventQueue, EventQueueCapacity = 1024). The FFI thread is the sole producer; the event thread is the sole consumer that invokes listener callbacks. When the ring is full, the overflowing event is discarded, and the failure is not transient:
ffi/ffi_events.nim — enqueueOrMarkStuck: on a full ring it logs "event queue full; library marked stuck", sets the sticky eventQueueStuck flag, and breaks — the event is never enqueued, never retried, never delivered.
ffi/event_thread.nim:117 — notifiedStuck is documented as "latched forever — eventQueueStuck is sticky terminal." Nothing clears it.
ffi/ffi_thread.nim:16 — sendRequestToFFIThread starts with if ctx.eventQueueStuck.load(): return err(...). So once the ring overflows once, the context (a) loses that event and (b) rejects all future FFI requests — the library is effectively bricked.
A single slow/wedged listener that lets the ring fill is enough to trigger this. Losing events is unacceptable for our use case, and the terminal-stuck cascade makes it worse. This issue's primary goal is to make event delivery lossless.
Non-negotiables
No event is ever dropped. This is the top priority. Under sustained consumer backlog the queue must grow rather than discard. (We explicitly accept unbounded growth under a wedged listener — see "Backpressure / observability" — because dropping is not an option.)
Unbounded intrusive linked list, c_malloc-backed, with a next link where the node is its own payload carrier — survives the FFI→event-thread hop (same TLS/MemRegion hazard already documented in alloc.nim / ffi_events.nim).
Lock-guarded O(1) push that always succeeds → producer never blocks, events never dropped.
Wake-on-empty + short poll signaling (already present).
What does NOT transfer: the sharding (bank of queues)
The #102RequestQueueBank shards into 16 queues to spread multi-producer contention. Events have a single producer, so:
there is no producer-producer contention to spread — a bank would be pure overhead (N locks/heads/tails), and
sharding only guarantees per-shard order; a single queue gives strict global FIFO for free.
So the event queue is effectively one shard of the request bank — SPSC, one lock, one head/tail.
The piece #102 lacks: a node freelist (keeps the footprint flat)
The request queue mallocs a node per request and frees it after dispatch. To hold requirement #2, add recycling:
Commit (event thread): after the callback returns, return the node to the freelist instead of freeing it.
Steady state → the freelist recycles a small working set → zero allocation. Backlog → freelist drains, we c_malloc more → grows, never drops. No fixed upfront 576 KB slab; memory tracks the high-water mark and recycles from there.
Invariant to carry over from #107 (peek → dispatch → commit)
pop-under-lock → release lock → run callback → re-acquire lock → return node to freelist.
The callback MUST run outside the queue lock — holding the lock across a slow callback would silently re-couple the producer and consumer (the exact bug to avoid).
While the callback runs, the node is off both the ready-list and the freelist (owned by the consumer), so the producer cannot grab and overwrite a buffer still being read. This is feat(ffi): slab-allocate event payloads instead of c_malloc per dispatch #107's slot-pinning contract, unchanged — only "clear the ring slot" becomes "return node to freelist."
Backpressure / observability (replacing the drop)
Dropping was doing crude backpressure; removing it means a wedged listener grows memory unbounded → eventual OOM. Since we will not drop, make the condition loud and visible instead:
Track and expose the queue depth / high-water mark.
Log a warning when depth crosses a threshold and when it recovers.
The existing heartbeat watchdog (onNotResponding / onResponding) already surfaces a wedged consumer — keep it.
If a hard ceiling is ever needed, apply backpressure at the event source (slow emission), never by discarding at the queue.
Remove the eventQueueStuck sticky-terminal machinery and the request-intake gate that depends on it (ffi/ffi_thread.nim:16), since overflow-to-drop no longer exists.
Acceptance criteria
No code path discards an event; a full/backlogged consumer causes the queue to grow, not drop.
New tests: sustained backlog grows and fully drains with zero loss and no leak; oversize name/payload heap-fallback still works; freelist reuse is exercised (steady state does not grow RSS).
Notes / future
Long term, the FFI request bank could adopt the same node freelist to eliminate its per-request c_malloc too — the two subsystems would then share one primitive (unbounded intrusive queue), parameterized only by shard count (N for requests, 1 for events) and whether recycling is enabled.
Summary
Rework the event delivery queue so that no event can ever be dropped, while keeping the steady-state memory footprint flat (near-zero allocation on the hot path). The proposal is to replace the current fixed-capacity ring (introduced in #107, closing #92) with a single unbounded intrusive SPSC queue backed by a node freelist — reusing the mechanism already proven for FFI requests in #102, minus the sharding.
Motivation — the current design drops events, terminally
Event delivery today is a bounded ring (
EventQueue,EventQueueCapacity = 1024). The FFI thread is the sole producer; the event thread is the sole consumer that invokes listener callbacks. When the ring is full, the overflowing event is discarded, and the failure is not transient:ffi/ffi_events.nim—enqueueOrMarkStuck: on a full ring it logs"event queue full; library marked stuck", sets the stickyeventQueueStuckflag, andbreaks — the event is never enqueued, never retried, never delivered.ffi/event_thread.nim:117—notifiedStuckis documented as "latched forever — eventQueueStuck is sticky terminal." Nothing clears it.ffi/ffi_thread.nim:16—sendRequestToFFIThreadstarts withif ctx.eventQueueStuck.load(): return err(...). So once the ring overflows once, the context (a) loses that event and (b) rejects all future FFI requests — the library is effectively bricked.A single slow/wedged listener that lets the ring fill is enough to trigger this. Losing events is unacceptable for our use case, and the terminal-stuck cascade makes it worse. This issue's primary goal is to make event delivery lossless.
Non-negotiables
startedis always delivered before itsstopped).Proposed design
Reuse the request-queue mechanism from #102 (
ffi/ffi_request_queue.nim), adapted to the events' single-producer/single-consumer shape:What transfers from #102
c_malloc-backed, with anextlink where the node is its own payload carrier — survives the FFI→event-thread hop (same TLS/MemRegionhazard already documented inalloc.nim/ffi_events.nim).What does NOT transfer: the sharding (bank of queues)
The #102
RequestQueueBankshards into 16 queues to spread multi-producer contention. Events have a single producer, so:So the event queue is effectively one shard of the request bank — SPSC, one lock, one head/tail.
The piece #102 lacks: a node freelist (keeps the footprint flat)
The request queue mallocs a node per request and frees it after dispatch. To hold requirement #2, add recycling:
c_mallocif empty), copy name + payload into it (reuse feat(ffi): slab-allocate event payloads instead of c_malloc per dispatch #107's inline-slot-vs-heap-fallback for oversize values viaMaxEventPayloadBytes/MaxEventNameBytes), link into the tail.Steady state → the freelist recycles a small working set → zero allocation. Backlog → freelist drains, we
c_mallocmore → grows, never drops. No fixed upfront 576 KB slab; memory tracks the high-water mark and recycles from there.Invariant to carry over from #107 (peek → dispatch → commit)
Backpressure / observability (replacing the drop)
Dropping was doing crude backpressure; removing it means a wedged listener grows memory unbounded → eventual OOM. Since we will not drop, make the condition loud and visible instead:
onNotResponding/onResponding) already surfaces a wedged consumer — keep it.Remove the
eventQueueStucksticky-terminal machinery and the request-intake gate that depends on it (ffi/ffi_thread.nim:16), since overflow-to-drop no longer exists.Acceptance criteria
detect_leaks=1) and TSan, matching feat(ffi): slab-allocate event payloads instead of c_malloc per dispatch #107's bar.eventQueueStuckand its request-intake gate are removed; a wedged listener is surfaced via watchdog + depth logging, not a terminal stuck state.test_event_dispatch.nim,test_event_thread.nim,test_event_listener.nim.Notes / future
c_malloctoo — the two subsystems would then share one primitive (unbounded intrusive queue), parameterized only by shard count (N for requests, 1 for events) and whether recycling is enabled.References
ffi/ffi_request_queue.nim).ffi/ffi_events.nimenqueueOrMarkStuck/tryEnqueueEvent.ffi/event_thread.nim:117,:126.ffi/ffi_thread.nim:16.