Skip to content

Commit f3dfe4d

Browse files
committed
feat: move user event code to a dedicated event thread (#69)
1 parent 6b38cde commit f3dfe4d

9 files changed

Lines changed: 801 additions & 769 deletions

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,26 @@
22

33
All notable changes to this project are documented in this file.
44

5+
## [Unreleased]
6+
7+
### Changed
8+
- User event callbacks now run on a dedicated event thread fed by a
9+
bounded SPSC queue (default capacity 1024), so a slow listener can no
10+
longer block the FFI thread or concurrent `add_event_listener` /
11+
`remove_event_listener` calls
12+
([#6](https://github.com/logos-messaging/nim-ffi/issues/6)).
13+
- Replaced the dedicated watchdog thread with a heartbeat check that
14+
runs on the event thread. The FFI thread advances an atomic heartbeat
15+
each loop iteration; if it stalls for more than 1s past the start-up
16+
grace window, the event thread emits the `not_responding` event.
17+
18+
### Added
19+
- Queue-overflow handling: when the bounded event queue is full, the
20+
library sets a sticky "stuck" flag, logs an error, fires
21+
`not_responding` from the event thread, and rejects subsequent
22+
`sendRequestToFFIThread` calls with `event queue stuck - library
23+
cannot accept new requests`.
24+
525
## [0.2.0] - 2026-06-04
626

727
Major release introducing the CBOR-based wire format, CBOR-backed FFI events

ffi/event_thread.nim

Lines changed: 19 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
## so each thread's machinery is readable on its own.
66
##
77
## Responsibilities:
8-
## - Drain queued events into listener callbacks (queue producer lands in PR #69).
8+
## - Drain queued events into listener callbacks.
99
## - Watch `ctx.ffiHeartbeat` and emit `NotRespondingEvent` / `RespondingEvent`
1010
## on FFI-thread stall and recovery transitions.
1111

@@ -37,14 +37,13 @@ proc dispatchToListeners[T](
3737
)
3838

3939
proc emitLivenessEvent[T, P](ctx: ptr FFIContext[T], name: string, payload: P) =
40-
## Encodes a zero-field liveness event (`NotRespondingEvent`,
41-
## `RespondingEvent`) and dispatches it directly to listeners, bypassing
42-
## the event queue (which may itself be wedged). Runs on the event thread.
40+
## Encodes a liveness event and dispatches directly to listeners (bypassing
41+
## the queue, which may be wedged). Runs on the event thread.
4342
let event =
4443
try:
4544
EventEnvelope[P](eventType: name, payload: payload).cborEncode()
46-
except CatchableError as exc:
47-
chronicles.error "liveness event encode failed", name = name, err = exc.msg
45+
except CatchableError as e:
46+
chronicles.error "liveness event encode failed", name = name, err = e.msg
4847
return
4948
let dataPtr: pointer =
5049
if event.len > 0: cast[pointer](unsafeAddr event[0])
@@ -55,18 +54,14 @@ proc onNotResponding*(ctx: ptr FFIContext) =
5554
emitLivenessEvent(ctx, NotRespondingEventName, NotRespondingEvent())
5655

5756
proc onResponding*(ctx: ptr FFIContext) =
58-
## Fired once when the FFI thread's heartbeat starts advancing again
59-
## after a `NotRespondingEvent`. Lets consumers clear any "library
60-
## hung" UI state without polling.
57+
## Fired once when the heartbeat resumes after a NotRespondingEvent.
58+
## Lets consumers clear any "library hung" UI state without polling.
6159
emitLivenessEvent(ctx, RespondingEventName, RespondingEvent())
6260

6361
proc dispatchQueuedEvent[T](ctx: ptr FFIContext[T], qe: QueuedEvent) =
6462
## Frees `qe`'s c_malloc buffers on exit.
6563
defer:
66-
if not qe.name.isNil():
67-
c_free(cast[pointer](qe.name))
68-
if not qe.data.isNil():
69-
c_free(qe.data)
64+
freeEventBuffers(qe.name, qe.data)
7065
ctx.dispatchToListeners($qe.name, qe.data, qe.dataLen)
7166

7267
proc drainEventQueue[T](ctx: ptr FFIContext[T]) =
@@ -92,10 +87,8 @@ proc init(T: type HeartbeatMonitor, ctx: ptr FFIContext): T =
9287
)
9388

9489
proc check[T](hb: var HeartbeatMonitor, ctx: ptr FFIContext[T]) =
95-
## Fires `onNotResponding` once the FFI thread's heartbeat counter stops
96-
## advancing past the stale threshold, and fires `onResponding` once it
97-
## starts advancing again. Both transitions latch so each is emitted at
98-
## most once per stall episode.
90+
## Fires onNotResponding / onResponding on heartbeat stall / recovery.
91+
## Both transitions latch — each fires at most once per stall episode.
9992
if Moment.now() - hb.startedAt <= FFIHeartbeatStartDelay:
10093
return
10194
let cur = ctx.ffiHeartbeat.load()
@@ -112,14 +105,19 @@ proc check[T](hb: var HeartbeatMonitor, ctx: ptr FFIContext[T]) =
112105

113106
proc eventRun[T](ctx: ptr FFIContext[T]) {.async.} =
114107
var hb = HeartbeatMonitor.init(ctx)
108+
var notifiedStuck = false # latched forever — eventQueueStuck is sticky terminal.
115109

116110
while ctx.running.load():
117-
# Wake on enqueue or tick — whichever first. The enqueue path lands in PR #69;
118-
# until then the wait always times out and we fall through to the heartbeat check.
111+
# Wake on enqueue or tick — whichever first.
119112
discard await ctx.eventQueueSignal.wait().withTimeout(EventThreadTickInterval)
120113

121114
ctx.drainEventQueue()
122115

116+
# Fire after drain so reg.lock is free — FFI-thread would deadlock here.
117+
if not notifiedStuck and ctx.eventQueueStuck.load():
118+
onNotResponding(ctx)
119+
notifiedStuck = true
120+
123121
if not ctx.running.load():
124122
break
125123
hb.check(ctx)
@@ -134,5 +132,5 @@ proc eventThreadBody[T](ctx: ptr FFIContext[T]) {.thread.} =
134132

135133
try:
136134
waitFor eventRun(ctx)
137-
except CatchableError as exc:
138-
error "event thread exited with exception", error = exc.msg
135+
except CatchableError as e:
136+
error "event thread exited with exception", error = e.msg

0 commit comments

Comments
 (0)