Skip to content

Commit 6b38cde

Browse files
committed
refactor(ffi): event thread scaffolding + FFIContext lifecycle split (#71)
1 parent 55371e6 commit 6b38cde

14 files changed

Lines changed: 648 additions & 605 deletions

File tree

Lines changed: 3 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
1-
//! Synchronous example: exercises the library-event listener API
2-
//! (typed + wildcard + remove).
1+
//! Synchronous example: exercises the typed per-event listener API.
32
//!
43
//! Run with: `cargo run --example main`
54
6-
use my_timer::{decode_event_payload, EchoEvent, EchoRequest, MyTimerCtx, TimerConfig};
7-
use std::os::raw::c_int;
5+
use my_timer::{EchoEvent, EchoRequest, MyTimerCtx, TimerConfig};
86
use std::sync::mpsc;
97
use std::time::Duration;
108

@@ -14,32 +12,12 @@ fn main() -> Result<(), String> {
1412
Duration::from_secs(5),
1513
)?;
1614

17-
// Typed listener: the closure is invoked on the lib's dispatch
18-
// thread, so forward the payload to `main` via std mpsc and block
19-
// on `recv_timeout` below. `add_on_echo_fired_listener` is generated
20-
// per `{.ffiEvent.}`-declared proc and takes a typed `&EchoEvent`.
15+
// Closure runs on the lib's dispatch thread; forward to `main` via mpsc and recv_timeout below.
2116
let (tx, rx) = mpsc::channel::<EchoEvent>();
2217
let typed_handle = ctx.add_on_echo_fired_listener(move |evt: &EchoEvent| {
2318
let _ = tx.send(evt.clone());
2419
});
2520

26-
// Wildcard listener: receives every event with the FFI return code,
27-
// the wire `event_id` pre-extracted from the CBOR envelope, and the
28-
// raw envelope bytes. Lift to a typed payload via
29-
// `decode_event_payload::<T>` when the event_id matches one you
30-
// care about — this avoids hand-rolling ciborium calls per branch.
31-
let wildcard_handle = ctx.add_event_listener(|ret: c_int, event_id: &str, envelope: &[u8]| {
32-
println!("wildcard: ret={}, event_id={}, bytes={}", ret, event_id, envelope.len());
33-
if ret == 0 && event_id == "on_echo_fired" {
34-
match decode_event_payload::<EchoEvent>(envelope) {
35-
Ok(evt) => println!(" decoded: message={}, echo_count={}", evt.message, evt.echo_count),
36-
Err(e) => println!(" decode failed: {}", e),
37-
}
38-
}
39-
});
40-
41-
// Trigger the event — fires `on_echo_fired` once, which the
42-
// dispatch thread delivers to both listeners above.
4321
ctx.echo(EchoRequest { message: "sync-event-demo".into(), delay_ms: 1 })?;
4422

4523
match rx.recv_timeout(Duration::from_secs(2)) {
@@ -48,6 +26,5 @@ fn main() -> Result<(), String> {
4826
}
4927

5028
ctx.remove_event_listener(typed_handle);
51-
ctx.remove_event_listener(wildcard_handle);
5229
Ok(())
5330
}
Lines changed: 4 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
1-
//! Tokio (async) example: same shape as `main.rs` but exercises the
2-
//! async `_async` API and bridges library events into a tokio-aware
3-
//! channel for async consumption.
1+
//! Tokio (async) example: same shape as `main.rs` but exercises the async `_async` API
2+
//! and bridges library events into a tokio mpsc for async consumption.
43
//!
54
//! Run with: `cargo run --example tokio_main`
65
7-
use my_timer::{decode_event_payload, EchoEvent, EchoRequest, MyTimerCtx, TimerConfig};
8-
use std::os::raw::c_int;
6+
use my_timer::{EchoEvent, EchoRequest, MyTimerCtx, TimerConfig};
97
use std::time::Duration;
108
use tokio::sync::mpsc;
119

@@ -17,44 +15,21 @@ async fn main() -> Result<(), String> {
1715
)
1816
.await?;
1917

20-
// Typed listener: the handler fires on the lib's dispatch thread,
21-
// which is *outside* the tokio runtime. Forwarding through a tokio
22-
// `unbounded_channel` (Sender is Send + Sync, non-blocking) hands
23-
// the event over to the runtime so we can `.await` it below.
18+
// Handler fires on the lib's dispatch thread (outside the tokio runtime); forward via tokio mpsc to await it below.
2419
let (typed_tx, mut typed_rx) = mpsc::unbounded_channel::<EchoEvent>();
2520
let typed_handle = ctx.add_on_echo_fired_listener(move |evt: &EchoEvent| {
2621
let _ = typed_tx.send(evt.clone());
2722
});
2823

29-
// Wildcard listener: receives every event with the FFI return code,
30-
// the wire `event_id` pre-extracted from the CBOR envelope, and the
31-
// raw envelope bytes. Lift to a typed payload via
32-
// `decode_event_payload::<T>` when the event_id matches one you
33-
// care about — this avoids hand-rolling ciborium calls per branch.
34-
let wildcard_handle = ctx.add_event_listener(|ret: c_int, event_id: &str, envelope: &[u8]| {
35-
println!("wildcard: ret={}, event_id={}, bytes={}", ret, event_id, envelope.len());
36-
if ret == 0 && event_id == "on_echo_fired" {
37-
match decode_event_payload::<EchoEvent>(envelope) {
38-
Ok(evt) => println!(" decoded: message={}, echo_count={}", evt.message, evt.echo_count),
39-
Err(e) => println!(" decode failed: {}", e),
40-
}
41-
}
42-
});
43-
44-
// Trigger an echo via the async API — fires `on_echo_fired` once,
45-
// which the dispatch thread delivers to both listeners above.
4624
ctx.echo_async(EchoRequest { message: "async-event-demo".into(), delay_ms: 1 })
4725
.await?;
4826

49-
// Await the typed event with a bounded timeout so a missing event
50-
// surfaces as an error instead of hanging the example forever.
5127
let evt = tokio::time::timeout(Duration::from_secs(2), typed_rx.recv())
5228
.await
5329
.map_err(|_| "event never arrived".to_string())?
5430
.ok_or_else(|| "typed channel closed".to_string())?;
5531
println!("typed onEchoFired: message={}, echo_count={}", evt.message, evt.echo_count);
5632

5733
ctx.remove_event_listener(typed_handle);
58-
ctx.remove_event_listener(wildcard_handle);
5934
Ok(())
6035
}

ffi/cbor_serial.nim

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,6 @@ export cbor_serialization, options, results
3535
const CborNullByte*: byte = 0xf6'u8
3636
## CBOR encoding of `null` — used as the wire sentinel for empty OK payloads.
3737

38-
# ---------------------------------------------------------------------------
39-
# Public API
40-
# ---------------------------------------------------------------------------
4138

4239
proc cborEncode*[T](x: T): seq[byte] =
4340
## CBOR-encode any cbor_serialization-supported type (plus `pointer` / `ptr T`

ffi/codegen/rust.nim

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,6 @@ proc reqStructName(p: FFIProcMeta): string =
6363
else:
6464
camel & "Req"
6565

66-
# ---------------------------------------------------------------------------
67-
# File generators
68-
# ---------------------------------------------------------------------------
6966

7067
proc generateCargoToml*(libName: string): string =
7168
# `flume` is the unified callback channel (PR #23 Rust review, item 8): one

ffi/event_thread.nim

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
## Event-thread body and FFI-thread liveness monitoring.
2+
##
3+
## Included from `ffi_context.nim` — inherits its imports, FFIContext type,
4+
## and the heartbeat-timing constants. Lives alongside `ffi_thread.nim`
5+
## so each thread's machinery is readable on its own.
6+
##
7+
## Responsibilities:
8+
## - Drain queued events into listener callbacks (queue producer lands in PR #69).
9+
## - Watch `ctx.ffiHeartbeat` and emit `NotRespondingEvent` / `RespondingEvent`
10+
## on FFI-thread stall and recovery transitions.
11+
12+
type
13+
NotRespondingEvent* = object
14+
RespondingEvent* = object
15+
16+
const
17+
NotRespondingEventName* = "not_responding"
18+
RespondingEventName* = "responding"
19+
20+
proc dispatchToListeners[T](
21+
ctx: ptr FFIContext[T], eventName: string, data: pointer, dataLen: int
22+
) =
23+
## Holds reg.lock for the entire snapshot + invocation so concurrent
24+
## add/remove on this registry blocks until dispatch returns.
25+
withLock ctx[].eventRegistry.lock:
26+
let listeners = ctx[].eventRegistry.byEvent.getOrDefault(eventName)
27+
if listeners.len == 0:
28+
chronicles.debug "no listener registered", event = eventName
29+
return
30+
foreignThreadGc:
31+
try:
32+
notifyListeners(listeners, RET_OK, data, dataLen)
33+
except Exception, CatchableError:
34+
notifyListenersErr(
35+
listeners,
36+
"Exception dispatching " & eventName & ": " & getCurrentExceptionMsg(),
37+
)
38+
39+
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.
43+
let event =
44+
try:
45+
EventEnvelope[P](eventType: name, payload: payload).cborEncode()
46+
except CatchableError as exc:
47+
chronicles.error "liveness event encode failed", name = name, err = exc.msg
48+
return
49+
let dataPtr: pointer =
50+
if event.len > 0: cast[pointer](unsafeAddr event[0])
51+
else: cast[pointer](emptyListenerPayload)
52+
ctx.dispatchToListeners(name, dataPtr, event.len)
53+
54+
proc onNotResponding*(ctx: ptr FFIContext) =
55+
emitLivenessEvent(ctx, NotRespondingEventName, NotRespondingEvent())
56+
57+
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.
61+
emitLivenessEvent(ctx, RespondingEventName, RespondingEvent())
62+
63+
proc dispatchQueuedEvent[T](ctx: ptr FFIContext[T], qe: QueuedEvent) =
64+
## Frees `qe`'s c_malloc buffers on exit.
65+
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)
70+
ctx.dispatchToListeners($qe.name, qe.data, qe.dataLen)
71+
72+
proc drainEventQueue[T](ctx: ptr FFIContext[T]) =
73+
while true:
74+
let opt = ctx.eventQueue.tryDequeueEvent()
75+
if opt.isNone():
76+
break
77+
ctx.dispatchQueuedEvent(opt.get())
78+
79+
type HeartbeatMonitor = object
80+
startedAt: Moment
81+
lastChange: Moment
82+
lastValue: int64
83+
notifiedStale: bool
84+
85+
proc init(T: type HeartbeatMonitor, ctx: ptr FFIContext): T =
86+
let now = Moment.now()
87+
T(
88+
startedAt: now,
89+
lastChange: now,
90+
lastValue: ctx.ffiHeartbeat.load(),
91+
notifiedStale: false,
92+
)
93+
94+
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.
99+
if Moment.now() - hb.startedAt <= FFIHeartbeatStartDelay:
100+
return
101+
let cur = ctx.ffiHeartbeat.load()
102+
if cur != hb.lastValue:
103+
if hb.notifiedStale:
104+
onResponding(ctx)
105+
hb.lastValue = cur
106+
hb.lastChange = Moment.now()
107+
hb.notifiedStale = false
108+
elif not hb.notifiedStale and
109+
Moment.now() - hb.lastChange > FFIHeartbeatStaleThreshold:
110+
onNotResponding(ctx)
111+
hb.notifiedStale = true
112+
113+
proc eventRun[T](ctx: ptr FFIContext[T]) {.async.} =
114+
var hb = HeartbeatMonitor.init(ctx)
115+
116+
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.
119+
discard await ctx.eventQueueSignal.wait().withTimeout(EventThreadTickInterval)
120+
121+
ctx.drainEventQueue()
122+
123+
if not ctx.running.load():
124+
break
125+
hb.check(ctx)
126+
127+
proc eventThreadBody[T](ctx: ptr FFIContext[T]) {.thread.} =
128+
## Drains the event queue and runs the FFI-thread heartbeat check.
129+
## Owns the queued `c_malloc` payloads until dispatch returns.
130+
defer:
131+
let fireRes = ctx.eventThreadExitSignal.fireSync()
132+
if fireRes.isErr():
133+
error "failed to fire eventThreadExitSignal", err = fireRes.error
134+
135+
try:
136+
waitFor eventRun(ctx)
137+
except CatchableError as exc:
138+
error "event thread exited with exception", error = exc.msg

0 commit comments

Comments
 (0)