Skip to main content

nylon_ring_host/
lib.rs

1//! Nylon Ring Host - A high-performance plugin host for the nylon-ring ABI.
2//!
3//! This crate provides the host-side implementation for loading and managing
4//! plugins that conform to the nylon-ring ABI. It supports multiple execution
5//! modes including fire-and-forget calls, request-response patterns, and
6//! bidirectional streaming.
7
8mod call_tracker;
9mod callbacks;
10mod context;
11mod error;
12mod extensions;
13#[cfg(test)]
14mod loom_tests;
15mod sid;
16mod stream_channel;
17mod types;
18
19use call_tracker::CallTracker;
20use callbacks::{
21    acquire_result_buffer_callback, commit_result_buffer_callback, get_state_callback,
22    send_result_owned_callback, send_result_vec_callback, set_state_callback,
23};
24use context::{CURRENT_UNARY_RESULT, HostContext};
25use libloading::{Library, Symbol};
26use nylon_ring::{ABI_VERSION, NrBytes, NrHostExt, NrHostVTable, NrPluginInfo, NrStr};
27use sid::next_sid;
28use std::collections::HashMap;
29use std::ffi::c_void;
30use std::sync::Arc;
31use std::time::Duration;
32
33pub use error::NylonRingHostError;
34pub use extensions::Extensions;
35pub use nylon_ring::NrStatus;
36pub use types::{ResponseBytes, Result, StreamFrame, StreamReceiver};
37
38/// Default number of frames buffered for each response stream.
39pub const DEFAULT_STREAM_CAPACITY: usize = 64;
40
41/// Point-in-time host metrics suitable for application monitoring hooks.
42#[derive(Debug, Copy, Clone, PartialEq, Eq)]
43pub struct HostMetrics {
44    pub loaded_plugins: usize,
45    pub pending_requests: usize,
46    pub state_sessions: usize,
47    pub in_flight_calls: usize,
48}
49
50struct PendingGuard<'a> {
51    host_ctx: &'a HostContext,
52    sid: u64,
53    armed: bool,
54}
55
56impl<'a> PendingGuard<'a> {
57    fn new(host_ctx: &'a HostContext, sid: u64) -> Self {
58        Self {
59            host_ctx,
60            sid,
61            armed: true,
62        }
63    }
64
65    fn disarm(&mut self) {
66        self.armed = false;
67    }
68}
69
70impl Drop for PendingGuard<'_> {
71    fn drop(&mut self) {
72        if self.armed {
73            context::cleanup_sid(self.host_ctx, self.sid);
74        }
75    }
76}
77
78struct FastSlotBinding;
79
80impl FastSlotBinding {
81    fn bind(slot: &mut types::UnaryResultSlot) -> Result<Self> {
82        let bound = CURRENT_UNARY_RESULT.with(|cell| {
83            if !cell.get().is_null() {
84                return false;
85            }
86            cell.set(slot as *mut _);
87            true
88        });
89        if bound {
90            Ok(Self)
91        } else {
92            Err(NylonRingHostError::FastPathReentrant)
93        }
94    }
95}
96
97impl Drop for FastSlotBinding {
98    fn drop(&mut self) {
99        CURRENT_UNARY_RESULT.with(|cell| cell.set(std::ptr::null_mut()));
100    }
101}
102
103/// The plugin's dispatch surface, copied out of its vtable at load time
104/// (`init` is only used during load and not retained).
105#[derive(Copy, Clone)]
106struct PluginDispatch {
107    handle: Option<unsafe extern "C" fn(NrStr, u64, NrBytes) -> NrStatus>,
108    shutdown: Option<unsafe extern "C" fn()>,
109    stream_data: Option<unsafe extern "C" fn(u64, NrBytes) -> NrStatus>,
110    stream_close: Option<unsafe extern "C" fn(u64) -> NrStatus>,
111    resolve_entry: Option<unsafe extern "C" fn(NrStr) -> u32>,
112    handle_by_id: Option<unsafe extern "C" fn(u32, u64, NrBytes) -> NrStatus>,
113}
114
115impl PluginDispatch {
116    fn from_vtable(vtable: &nylon_ring::NrPluginVTable) -> Self {
117        Self {
118            handle: vtable.handle,
119            shutdown: vtable.shutdown,
120            stream_data: vtable.stream_data,
121            stream_close: vtable.stream_close,
122            resolve_entry: vtable.resolve_entry,
123            handle_by_id: vtable.handle_by_id,
124        }
125    }
126}
127
128/// A loaded plugin instance.
129pub struct LoadedPlugin {
130    _lib: Library,
131    dispatch: PluginDispatch,
132    host_ctx: Arc<HostContext>,
133    path: String,
134    call_tracker: CallTracker,
135}
136
137/// Plugins removed from a host while calls may still be in flight.
138///
139/// Owned call guards do not clone `Arc<LoadedPlugin>` (a per-stream RMW on
140/// one shared cache line capped multi-core streaming); they hold a raw
141/// pointer plus a tracker slot instead. That is sound only if the plugin
142/// allocation outlives every tracker slot, so an Arc leaving a host's map
143/// is never dropped directly: [`retire_plugin`] parks it here and the last
144/// `finish` observing the stopped tracker sweeps it out.
145static RETIRED_PLUGINS: std::sync::Mutex<Vec<Arc<LoadedPlugin>>> =
146    std::sync::Mutex::new(Vec::new());
147
148/// Stop a plugin removed from a host's map and keep it alive until every
149/// tracked call has finished. Every code path where a host-map
150/// `Arc<LoadedPlugin>` leaves the map must go through here (a plain drop
151/// could free the plugin while a call guard still points at it).
152fn retire_plugin(plugin: Arc<LoadedPlugin>) {
153    plugin.stop_accepting_calls();
154    RETIRED_PLUGINS.lock().unwrap().push(plugin);
155    // The plugin may already be idle; free it now instead of waiting for
156    // an unrelated future sweep.
157    sweep_retired_plugins();
158}
159
160/// Drop every retired plugin whose tracked calls have drained.
161///
162/// Memory-ordering argument for "the last finisher always frees": `finish`
163/// is an AcqRel RMW and `stop` is an AcqRel RMW on the same shard counters,
164/// so for each shard either the finish precedes the stop (the stop/retire
165/// thread's sweep then reads the drained value) or it follows it (the
166/// finisher sees CLOSED and sweeps itself, happening-after all finishes the
167/// stop observed). Two concurrent last finishers on different shards are the
168/// store-buffering case; the SeqCst fence below pairs across sweeps so at
169/// least one of them observes every shard drained.
170fn sweep_retired_plugins() {
171    std::sync::atomic::fence(std::sync::atomic::Ordering::SeqCst);
172    let drained: Vec<Arc<LoadedPlugin>> = {
173        let mut retired = RETIRED_PLUGINS.lock().unwrap();
174        let (drained, live) = std::mem::take(&mut *retired)
175            .into_iter()
176            .partition(|plugin| plugin.call_tracker.active_calls() == 0);
177        *retired = live;
178        drained
179    };
180    // Dropping may run the plugin's shutdown callback and unload the
181    // library; keep that outside the registry lock.
182    drop(drained);
183}
184
185impl LoadedPlugin {
186    fn begin_call(&self) -> Result<BorrowedPluginCallGuard<'_>> {
187        let shard = self
188            .call_tracker
189            .try_begin()
190            .ok_or(NylonRingHostError::PluginUnloaded)?;
191        Ok(BorrowedPluginCallGuard {
192            tracker: &self.call_tracker,
193            shard,
194        })
195    }
196
197    fn begin_owned_call(self: &Arc<Self>) -> Result<PluginCallGuard> {
198        let shard = self
199            .call_tracker
200            .try_begin()
201            .ok_or(NylonRingHostError::PluginUnloaded)?;
202        Ok(PluginCallGuard {
203            plugin: Arc::as_ptr(self),
204            shard,
205        })
206    }
207
208    fn stop_accepting_calls(&self) {
209        self.call_tracker.stop();
210    }
211}
212
213struct BorrowedPluginCallGuard<'a> {
214    tracker: &'a CallTracker,
215    shard: usize,
216}
217
218impl Drop for BorrowedPluginCallGuard<'_> {
219    fn drop(&mut self) {
220        if self.tracker.finish(self.shard) {
221            sweep_retired_plugins();
222        }
223    }
224}
225
226/// Owned in-flight-call token, held by values that outlive their call
227/// (currently [`StreamReceiver`] via `call_stream`).
228///
229/// Holds a raw plugin pointer instead of an `Arc` clone: every stream
230/// cloning the same Arc made its refcount line the multi-core streaming
231/// bottleneck. Validity: the tracker slot held below is released only in
232/// this guard's `Drop`, and a `LoadedPlugin` is freed only when its host-map
233/// Arc has been retired through [`retire_plugin`] *and* its tracker has
234/// drained — so the pointee outlives the guard.
235pub(crate) struct PluginCallGuard {
236    plugin: *const LoadedPlugin,
237    shard: usize,
238}
239
240// SAFETY: the guard behaves like a `&LoadedPlugin` with liveness (see the
241// type docs); `LoadedPlugin` is Send + Sync (it is shared via Arc across
242// threads today).
243unsafe impl Send for PluginCallGuard {}
244unsafe impl Sync for PluginCallGuard {}
245
246impl PluginCallGuard {
247    pub(crate) fn plugin(&self) -> &LoadedPlugin {
248        // SAFETY: see the type documentation — the held tracker slot keeps
249        // the plugin allocation alive for the guard's lifetime.
250        unsafe { &*self.plugin }
251    }
252}
253
254impl Drop for PluginCallGuard {
255    fn drop(&mut self) {
256        // After this `finish` the plugin pointer must not be dereferenced:
257        // releasing the slot may allow a concurrent sweep to free it.
258        if self.plugin().call_tracker.finish(self.shard) {
259            sweep_retired_plugins();
260        }
261    }
262}
263
264impl Drop for LoadedPlugin {
265    fn drop(&mut self) {
266        if let Some(shutdown_fn) = self.dispatch.shutdown {
267            unsafe {
268                shutdown_fn();
269            }
270        }
271    }
272}
273
274/// A handle to a specific plugin for making calls.
275#[derive(Clone)]
276pub struct PluginHandle {
277    plugin: Arc<LoadedPlugin>,
278}
279
280impl PluginHandle {
281    fn status_error(status: NrStatus) -> NylonRingHostError {
282        if status == NrStatus::Panic {
283            NylonRingHostError::PluginPanicked
284        } else {
285            NylonRingHostError::PluginHandleFailed(status)
286        }
287    }
288
289    /// Call a plugin entry point with a request-response pattern.
290    pub async fn call_response(&self, entry: &str, payload: &[u8]) -> Result<(NrStatus, Vec<u8>)> {
291        let _call_guard = self.plugin.begin_call()?;
292        let sid = next_sid();
293
294        context::insert_pending(
295            &self.plugin.host_ctx,
296            sid,
297            types::Pending::Unary(types::UnaryPending::waiting()),
298        );
299        let mut pending_guard = PendingGuard::new(&self.plugin.host_ctx, sid);
300
301        let payload_bytes = NrBytes::from_slice(payload);
302        let handle_raw_fn = match self.plugin.dispatch.handle {
303            Some(f) => f,
304            None => return Err(NylonRingHostError::MissingRequiredFunctions),
305        };
306
307        let status = unsafe { handle_raw_fn(NrStr::new(entry), sid, payload_bytes) };
308
309        if status != NrStatus::Ok {
310            return Err(Self::status_error(status));
311        }
312
313        match context::wait_for_unary(&self.plugin.host_ctx, sid).await {
314            Some((status, payload)) => {
315                pending_guard.disarm();
316                // Conversion happens while the call guard is still held, so
317                // a foreign payload's release cannot outlive the library.
318                Ok((status, payload.into_vec()))
319            }
320            None => Err(NylonRingHostError::PluginUnloaded),
321        }
322    }
323
324    /// Like [`PluginHandle::call_response`], but returns the response as a
325    /// [`ResponseBytes`] view instead of a `Vec<u8>`.
326    ///
327    /// For a plugin that responds with plugin-owned bytes this is
328    /// zero-copy: the buffer is only released back to the plugin when the
329    /// view drops, and the view holds an in-flight call guard so the plugin
330    /// cannot be unloaded from under it.
331    pub async fn call_response_bytes(
332        &self,
333        entry: &str,
334        payload: &[u8],
335    ) -> Result<(NrStatus, ResponseBytes)> {
336        let call_guard = self.plugin.begin_owned_call()?;
337        let sid = next_sid();
338
339        context::insert_pending(
340            &self.plugin.host_ctx,
341            sid,
342            types::Pending::Unary(types::UnaryPending::waiting()),
343        );
344        let mut pending_guard = PendingGuard::new(&self.plugin.host_ctx, sid);
345
346        let payload_bytes = NrBytes::from_slice(payload);
347        let handle_raw_fn = match self.plugin.dispatch.handle {
348            Some(f) => f,
349            None => return Err(NylonRingHostError::MissingRequiredFunctions),
350        };
351
352        let status = unsafe { handle_raw_fn(NrStr::new(entry), sid, payload_bytes) };
353
354        if status != NrStatus::Ok {
355            return Err(Self::status_error(status));
356        }
357
358        match context::wait_for_unary(&self.plugin.host_ctx, sid).await {
359            Some((status, payload)) => {
360                pending_guard.disarm();
361                // Host-owned payloads need no guard; foreign ones keep the
362                // plugin loaded until the view is dropped.
363                let guard =
364                    matches!(payload, types::ResponsePayload::Foreign(_)).then_some(call_guard);
365                Ok((status, ResponseBytes::new(payload, guard)))
366            }
367            None => Err(NylonRingHostError::PluginUnloaded),
368        }
369    }
370
371    /// Like [`PluginHandle::call_response`] but bounded by a timeout.
372    ///
373    /// If the plugin does not deliver a response within `timeout`, the pending
374    /// entry is removed from the host's tracking map and `Err(Timeout)` is
375    /// returned. Use this for any production caller that cannot afford to
376    /// hang indefinitely on a misbehaving plugin.
377    pub async fn call_response_timeout(
378        &self,
379        entry: &str,
380        payload: &[u8],
381        timeout: std::time::Duration,
382    ) -> Result<(NrStatus, Vec<u8>)> {
383        let _call_guard = self.plugin.begin_call()?;
384        let sid = next_sid();
385
386        context::insert_pending(
387            &self.plugin.host_ctx,
388            sid,
389            types::Pending::Unary(types::UnaryPending::waiting()),
390        );
391        let mut pending_guard = PendingGuard::new(&self.plugin.host_ctx, sid);
392
393        let payload_bytes = NrBytes::from_slice(payload);
394        let handle_raw_fn = match self.plugin.dispatch.handle {
395            Some(f) => f,
396            None => return Err(NylonRingHostError::MissingRequiredFunctions),
397        };
398
399        let status = unsafe { handle_raw_fn(NrStr::new(entry), sid, payload_bytes) };
400        if status != NrStatus::Ok {
401            return Err(Self::status_error(status));
402        }
403
404        match tokio::time::timeout(timeout, context::wait_for_unary(&self.plugin.host_ctx, sid))
405            .await
406        {
407            Ok(Some((status, payload))) => {
408                pending_guard.disarm();
409                Ok((status, payload.into_vec()))
410            }
411            Ok(None) => Err(NylonRingHostError::PluginUnloaded),
412            Err(_) => Err(NylonRingHostError::Timeout),
413        }
414    }
415
416    /// Ultra-fast unary call for synchronous plugins.
417    pub async fn call_response_fast(
418        &self,
419        entry: &str,
420        payload: &[u8],
421    ) -> Result<(NrStatus, Vec<u8>)> {
422        let _call_guard = self.plugin.begin_call()?;
423        let sid = next_sid();
424        let mut slot = types::UnaryResultSlot {
425            sid,
426            result: None,
427            lease: None,
428        };
429
430        let binding = FastSlotBinding::bind(&mut slot)?;
431
432        let payload_bytes = NrBytes::from_slice(payload);
433
434        let handle_raw_fn = match self.plugin.dispatch.handle {
435            Some(f) => f,
436            None => return Err(NylonRingHostError::MissingRequiredFunctions),
437        };
438
439        let status = unsafe { handle_raw_fn(NrStr::new(entry), sid, payload_bytes) };
440
441        drop(binding);
442        // A lease the plugin acquired but never committed may still be
443        // written to by misbehaving plugin threads; park it instead of
444        // freeing stack-adjacent heap memory (see HostContext orphans).
445        if let Some(lease) = slot.lease.take() {
446            self.plugin.host_ctx.park_orphan_lease(lease);
447        }
448        self.plugin.host_ctx.remove_state(sid);
449
450        if status != NrStatus::Ok {
451            return Err(Self::status_error(status));
452        }
453
454        match slot.result {
455            Some((st, data)) => Ok((st, data)),
456            None => Err(NylonRingHostError::MissingSynchronousResponse),
457        }
458    }
459
460    /// Fire-and-forget call to a plugin entry point.
461    pub async fn call(&self, entry: &str, payload: &[u8]) -> Result<NrStatus> {
462        let _call_guard = self.plugin.begin_call()?;
463        // Use Fast SID
464        let sid = next_sid();
465
466        let payload_bytes = NrBytes::from_slice(payload);
467        let handle_raw_fn = match self.plugin.dispatch.handle {
468            Some(f) => f,
469            None => {
470                return Err(NylonRingHostError::MissingRequiredFunctions);
471            }
472        };
473
474        let status = unsafe { handle_raw_fn(NrStr::new(entry), sid, payload_bytes) };
475
476        // Fire-and-forget calls have no later response lifecycle in which to
477        // clean up extension state.
478        self.plugin.host_ctx.remove_state(sid);
479
480        if status != NrStatus::Ok {
481            return Err(Self::status_error(status));
482        }
483        Ok(status)
484    }
485
486    /// Call a plugin entry point with a streaming response pattern.
487    pub async fn call_stream(&self, entry: &str, payload: &[u8]) -> Result<(u64, StreamReceiver)> {
488        let call_guard = self.plugin.begin_owned_call()?;
489        let sid = next_sid();
490
491        let (tx, rx) = stream_channel::acquire(self.plugin.host_ctx.stream_capacity());
492
493        // Register the stream channel (Map)
494        context::insert_pending(&self.plugin.host_ctx, sid, types::Pending::Stream(tx));
495
496        let payload_bytes = NrBytes::from_slice(payload);
497
498        let handle_raw_fn = match self.plugin.dispatch.handle {
499            Some(f) => f,
500            None => {
501                context::cleanup_sid(&self.plugin.host_ctx, sid);
502                return Err(NylonRingHostError::MissingRequiredFunctions);
503            }
504        };
505
506        let status = unsafe { handle_raw_fn(NrStr::new(entry), sid, payload_bytes) };
507
508        if status != NrStatus::Ok {
509            context::cleanup_sid(&self.plugin.host_ctx, sid);
510            return Err(Self::status_error(status));
511        }
512
513        Ok((sid, StreamReceiver::new(rx, None, sid, Some(call_guard))))
514    }
515
516    /// Send data to an active stream.
517    pub fn send_stream_data(&self, sid: u64, data: &[u8]) -> Result<NrStatus> {
518        let stream_data_fn = match self.plugin.dispatch.stream_data {
519            Some(f) => f,
520            None => return Err(NylonRingHostError::MissingRequiredFunctions),
521        };
522        let payload = NrBytes::from_slice(data);
523        Ok(unsafe { stream_data_fn(sid, payload) })
524    }
525
526    /// Close an active stream from the host side.
527    pub fn close_stream(&self, sid: u64) -> Result<NrStatus> {
528        let stream_close_fn = match self.plugin.dispatch.stream_close {
529            Some(f) => f,
530            None => return Err(NylonRingHostError::MissingRequiredFunctions),
531        };
532        Ok(unsafe { stream_close_fn(sid) })
533    }
534
535    /// Resolve an entry name to a [`PluginEntry`] that dispatches by
536    /// integer id, skipping the per-call name comparison.
537    ///
538    /// The id stays valid for this loaded plugin instance; a handle created
539    /// before a reload keeps calling the instance it resolved against, the
540    /// same snapshot semantics as `PluginHandle` itself. Fails with
541    /// [`NylonRingHostError::EntryDispatchUnsupported`] for v1 plugins and
542    /// [`NylonRingHostError::EntryNotFound`] for names the plugin does not
543    /// export.
544    pub fn entry(&self, entry: &str) -> Result<PluginEntry> {
545        let (Some(resolve_fn), Some(_)) = (
546            self.plugin.dispatch.resolve_entry,
547            self.plugin.dispatch.handle_by_id,
548        ) else {
549            return Err(NylonRingHostError::EntryDispatchUnsupported);
550        };
551        // Resolution runs plugin code, so it counts as an in-flight call.
552        let _call_guard = self.plugin.begin_call()?;
553        let id = unsafe { resolve_fn(NrStr::new(entry)) };
554        if id == nylon_ring::NR_ENTRY_UNKNOWN {
555            return Err(NylonRingHostError::EntryNotFound(entry.to_string()));
556        }
557        Ok(PluginEntry {
558            plugin: self.plugin.clone(),
559            id,
560        })
561    }
562}
563
564/// A pre-resolved plugin entry point that dispatches by integer id
565///: the per-call entry-name comparison and `NrStr` construction
566/// are paid once, in [`PluginHandle::entry`], instead of on every call.
567#[derive(Clone)]
568pub struct PluginEntry {
569    plugin: Arc<LoadedPlugin>,
570    id: u32,
571}
572
573impl PluginEntry {
574    fn handle_by_id_fn(&self) -> Result<unsafe extern "C" fn(u32, u64, NrBytes) -> NrStatus> {
575        self.plugin
576            .dispatch
577            .handle_by_id
578            .ok_or(NylonRingHostError::EntryDispatchUnsupported)
579    }
580
581    /// Fire-and-forget call; see [`PluginHandle::call`].
582    pub async fn call(&self, payload: &[u8]) -> Result<NrStatus> {
583        let _call_guard = self.plugin.begin_call()?;
584        let sid = next_sid();
585
586        let handle_by_id_fn = self.handle_by_id_fn()?;
587        let status = unsafe { handle_by_id_fn(self.id, sid, NrBytes::from_slice(payload)) };
588
589        self.plugin.host_ctx.remove_state(sid);
590
591        if status != NrStatus::Ok {
592            return Err(PluginHandle::status_error(status));
593        }
594        Ok(status)
595    }
596
597    /// Request-response call; see [`PluginHandle::call_response`].
598    pub async fn call_response(&self, payload: &[u8]) -> Result<(NrStatus, Vec<u8>)> {
599        let _call_guard = self.plugin.begin_call()?;
600        let sid = next_sid();
601
602        context::insert_pending(
603            &self.plugin.host_ctx,
604            sid,
605            types::Pending::Unary(types::UnaryPending::waiting()),
606        );
607        let mut pending_guard = PendingGuard::new(&self.plugin.host_ctx, sid);
608
609        let handle_by_id_fn = self.handle_by_id_fn()?;
610        let status = unsafe { handle_by_id_fn(self.id, sid, NrBytes::from_slice(payload)) };
611
612        if status != NrStatus::Ok {
613            return Err(PluginHandle::status_error(status));
614        }
615
616        match context::wait_for_unary(&self.plugin.host_ctx, sid).await {
617            Some((status, payload)) => {
618                pending_guard.disarm();
619                Ok((status, payload.into_vec()))
620            }
621            None => Err(NylonRingHostError::PluginUnloaded),
622        }
623    }
624
625    /// Synchronous fast-path call; see [`PluginHandle::call_response_fast`].
626    pub async fn call_response_fast(&self, payload: &[u8]) -> Result<(NrStatus, Vec<u8>)> {
627        let _call_guard = self.plugin.begin_call()?;
628        let sid = next_sid();
629        let mut slot = types::UnaryResultSlot {
630            sid,
631            result: None,
632            lease: None,
633        };
634
635        let binding = FastSlotBinding::bind(&mut slot)?;
636
637        let handle_by_id_fn = self.handle_by_id_fn()?;
638        let status = unsafe { handle_by_id_fn(self.id, sid, NrBytes::from_slice(payload)) };
639
640        drop(binding);
641        if let Some(lease) = slot.lease.take() {
642            self.plugin.host_ctx.park_orphan_lease(lease);
643        }
644        self.plugin.host_ctx.remove_state(sid);
645
646        if status != NrStatus::Ok {
647            return Err(PluginHandle::status_error(status));
648        }
649
650        match slot.result {
651            Some((st, data)) => Ok((st, data)),
652            None => Err(NylonRingHostError::MissingSynchronousResponse),
653        }
654    }
655
656    /// Streaming call; see [`PluginHandle::call_stream`].
657    pub async fn call_stream(&self, payload: &[u8]) -> Result<(u64, StreamReceiver)> {
658        let call_guard = self.plugin.begin_owned_call()?;
659        let sid = next_sid();
660
661        let (tx, rx) = stream_channel::acquire(self.plugin.host_ctx.stream_capacity());
662        context::insert_pending(&self.plugin.host_ctx, sid, types::Pending::Stream(tx));
663
664        let handle_by_id_fn = match self.handle_by_id_fn() {
665            Ok(f) => f,
666            Err(error) => {
667                context::cleanup_sid(&self.plugin.host_ctx, sid);
668                return Err(error);
669            }
670        };
671        let status = unsafe { handle_by_id_fn(self.id, sid, NrBytes::from_slice(payload)) };
672
673        if status != NrStatus::Ok {
674            context::cleanup_sid(&self.plugin.host_ctx, sid);
675            return Err(PluginHandle::status_error(status));
676        }
677
678        Ok((sid, StreamReceiver::new(rx, None, sid, Some(call_guard))))
679    }
680}
681
682/// The main host for loading and managing nylon-ring plugins.
683pub struct NylonRingHost {
684    plugins: HashMap<String, Arc<LoadedPlugin>>,
685    host_ctx: Arc<HostContext>,
686    host_vtable: Box<NrHostVTable>,
687}
688
689impl Default for NylonRingHost {
690    fn default() -> Self {
691        Self::new()
692    }
693}
694
695impl Drop for NylonRingHost {
696    fn drop(&mut self) {
697        // Call guards hold raw plugin pointers, so the map's Arcs must be
698        // retired — never dropped directly — even when the host itself goes
699        // away while streams are still in flight.
700        for (_, plugin) in self.plugins.drain() {
701            retire_plugin(plugin);
702        }
703    }
704}
705
706impl NylonRingHost {
707    /// Create a new empty host.
708    pub fn new() -> Self {
709        Self::with_stream_capacity(DEFAULT_STREAM_CAPACITY)
710    }
711
712    /// Create a host with a bounded per-stream frame capacity.
713    pub fn with_stream_capacity(stream_capacity: usize) -> Self {
714        assert!(
715            stream_capacity > 0,
716            "stream capacity must be greater than zero"
717        );
718        let host_ctx = Arc::new(HostContext::new(
719            NrHostExt {
720                set_state: set_state_callback,
721                get_state: get_state_callback,
722            },
723            stream_capacity,
724        ));
725
726        let host_vtable = Box::new(NrHostVTable {
727            send_result: send_result_vec_callback,
728            send_result_owned: send_result_owned_callback,
729            acquire_result_buffer: acquire_result_buffer_callback,
730            commit_result_buffer: commit_result_buffer_callback,
731        });
732
733        Self {
734            plugins: HashMap::new(),
735            host_ctx,
736            host_vtable,
737        }
738    }
739
740    /// Load a plugin from the specified path with a given name.
741    ///
742    /// The plugin must export `nylon_ring_get_plugin` and match this host's
743    /// `ABI_VERSION` exactly.
744    pub fn load(&mut self, name: &str, path: &str) -> Result<()> {
745        unsafe {
746            let lib = Library::new(path).map_err(NylonRingHostError::FailedToLoadLibrary)?;
747
748            let get_plugin: Symbol<extern "C" fn() -> *const NrPluginInfo> =
749                lib.get(b"nylon_ring_get_plugin\0").map_err(|_| {
750                    NylonRingHostError::MissingSymbol("nylon_ring_get_plugin".to_string())
751                })?;
752            let info_ptr = get_plugin();
753            Self::validate_plugin_info::<NrPluginInfo>(info_ptr.cast(), ABI_VERSION)?;
754            let info = &*info_ptr;
755            if info.vtable.is_null() {
756                return Err(NylonRingHostError::NullPluginVTable);
757            }
758            let plugin_vtable = &*info.vtable;
759            if plugin_vtable.init.is_none() || plugin_vtable.handle.is_none() {
760                return Err(NylonRingHostError::MissingRequiredFunctions);
761            }
762            if let Some(init_fn) = plugin_vtable.init {
763                let status = init_fn(
764                    Arc::as_ptr(&self.host_ctx) as *mut c_void,
765                    &*self.host_vtable,
766                );
767                if status != NrStatus::Ok {
768                    return Err(NylonRingHostError::PluginInitFailed(status));
769                }
770            }
771            let dispatch = PluginDispatch::from_vtable(plugin_vtable);
772
773            let loaded = LoadedPlugin {
774                _lib: lib,
775                dispatch,
776                host_ctx: self.host_ctx.clone(),
777                path: path.to_string(),
778                call_tracker: CallTracker::new(),
779            };
780
781            if let Some(previous) = self.plugins.insert(name.to_string(), Arc::new(loaded)) {
782                retire_plugin(previous);
783            }
784            Ok(())
785        }
786    }
787
788    /// Validates the version-independent `abi_version`/`struct_size` header
789    /// shared by every plugin-info generation.
790    ///
791    /// # Safety
792    ///
793    /// `info_ptr` must be null or point to a struct starting with two `u32`
794    /// fields (`abi_version`, `struct_size`).
795    unsafe fn validate_plugin_info<T>(info_ptr: *const u32, expected_version: u32) -> Result<()> {
796        if info_ptr.is_null() {
797            return Err(NylonRingHostError::NullPluginInfo);
798        }
799        let abi_version = unsafe { std::ptr::read_unaligned(info_ptr) };
800        let struct_size = unsafe { std::ptr::read_unaligned(info_ptr.add(1)) };
801        if abi_version != expected_version {
802            return Err(NylonRingHostError::IncompatibleAbiVersion {
803                expected: expected_version,
804                actual: abi_version,
805            });
806        }
807        let expected_size = std::mem::size_of::<T>() as u32;
808        if struct_size < expected_size {
809            return Err(NylonRingHostError::IncompatiblePluginInfoSize {
810                expected: expected_size,
811                actual: struct_size,
812            });
813        }
814        Ok(())
815    }
816
817    /// Unload a plugin by name.
818    pub fn unload(&mut self, name: &str) -> Result<()> {
819        let plugin = self
820            .plugins
821            .remove(name)
822            .ok_or_else(|| NylonRingHostError::PluginNotFound(name.to_owned()))?;
823        retire_plugin(plugin);
824        Ok(())
825    }
826
827    /// Stop accepting new calls, then wait for tracked calls to drain.
828    pub async fn unload_with_grace(&mut self, name: &str, grace: Duration) -> Result<()> {
829        let plugin = self
830            .plugins
831            .remove(name)
832            .ok_or_else(|| NylonRingHostError::PluginNotFound(name.to_owned()))?;
833        // Retire before draining so the plugin stays registered for the
834        // final sweep even if the drain times out.
835        retire_plugin(plugin.clone());
836        Self::wait_for_drain(&plugin, grace).await
837    }
838
839    /// Reload all plugins.
840    pub fn reload(&mut self) -> Result<()> {
841        let active_calls: usize = self
842            .plugins
843            .values()
844            .map(|plugin| plugin.call_tracker.active_calls())
845            .sum();
846        if active_calls != 0 {
847            return Err(NylonRingHostError::PluginBusy { active_calls });
848        }
849        let mut plugins_to_reload = Vec::new();
850        for (name, plugin) in &self.plugins {
851            plugins_to_reload.push((name.clone(), plugin.path.clone()));
852        }
853
854        // Load new versions - insert() will atomically replace old ones
855        // This ensures zero downtime (plugin() always returns a value)
856        for (name, path) in plugins_to_reload {
857            self.load(&name, &path)?;
858        }
859
860        Ok(())
861    }
862
863    /// Reload all plugins and wait for calls on replaced instances to drain.
864    pub async fn reload_with_grace(&mut self, grace: Duration) -> Result<()> {
865        let old_plugins: Vec<_> = self
866            .plugins
867            .drain()
868            .map(|(name, plugin)| {
869                retire_plugin(plugin.clone());
870                (name, plugin)
871            })
872            .collect();
873        for (_, plugin) in &old_plugins {
874            Self::wait_for_drain(plugin, grace).await?;
875        }
876        let plugins_to_reload: Vec<_> = old_plugins
877            .iter()
878            .map(|(name, plugin)| (name.clone(), plugin.path.clone()))
879            .collect();
880        drop(old_plugins);
881        for (name, path) in plugins_to_reload {
882            self.load(&name, &path)?;
883        }
884        Ok(())
885    }
886
887    async fn wait_for_drain(plugin: &LoadedPlugin, grace: Duration) -> Result<()> {
888        let deadline = tokio::time::Instant::now() + grace;
889        while plugin.call_tracker.active_calls() != 0 {
890            if tokio::time::Instant::now() >= deadline {
891                return Err(NylonRingHostError::DrainTimeout {
892                    remaining: plugin.call_tracker.active_calls(),
893                });
894            }
895            tokio::time::sleep(Duration::from_millis(1)).await;
896        }
897        Ok(())
898    }
899
900    /// Get a handle to a loaded plugin by name.
901    pub fn plugin(&self, name: &str) -> Option<PluginHandle> {
902        self.plugins
903            .get(name)
904            .map(|p| PluginHandle { plugin: p.clone() })
905    }
906
907    /// Capture lightweight host metrics without allocating the shard map.
908    pub fn metrics(&self) -> HostMetrics {
909        HostMetrics {
910            loaded_plugins: self.plugins.len(),
911            pending_requests: self.host_ctx.pending_count(),
912            state_sessions: self.host_ctx.state_count(),
913            in_flight_calls: self
914                .plugins
915                .values()
916                .map(|plugin| plugin.call_tracker.active_calls())
917                .sum(),
918        }
919    }
920
921    /// Get host extension pointer from host_ctx.
922    ///
923    /// # Safety
924    ///
925    /// The caller must ensure that `host_ctx` is a valid pointer to a `HostContext`
926    /// instance that was created by this host, or a null pointer.
927    pub unsafe fn get_host_ext(host_ctx: *mut c_void) -> *const NrHostExt {
928        if host_ctx.is_null() {
929            return std::ptr::null();
930        }
931        let ctx = unsafe { &*host_ctx.cast::<HostContext>() };
932        &ctx.host_ext as *const NrHostExt
933    }
934}
935
936#[cfg(test)]
937mod tests {
938    use super::*;
939
940    struct WakeFlag(Arc<std::sync::atomic::AtomicBool>);
941
942    impl std::task::Wake for WakeFlag {
943        fn wake(self: Arc<Self>) {
944            self.0.store(true, std::sync::atomic::Ordering::Release);
945        }
946
947        fn wake_by_ref(self: &Arc<Self>) {
948            self.0.store(true, std::sync::atomic::Ordering::Release);
949        }
950    }
951
952    struct ReentrantWakerState {
953        host_ctx: Arc<HostContext>,
954        clones: Arc<std::sync::atomic::AtomicUsize>,
955        drops: Arc<std::sync::atomic::AtomicUsize>,
956    }
957
958    unsafe fn clone_reentrant_waker(data: *const ()) -> std::task::RawWaker {
959        let state = unsafe { Arc::<ReentrantWakerState>::from_raw(data.cast()) };
960        state.host_ctx.pending_count();
961        state
962            .clones
963            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
964        let clone = state.clone();
965        let _ = Arc::into_raw(state);
966        std::task::RawWaker::new(Arc::into_raw(clone).cast(), &REENTRANT_WAKER_VTABLE)
967    }
968
969    unsafe fn wake_reentrant_waker(data: *const ()) {
970        let state = unsafe { Arc::<ReentrantWakerState>::from_raw(data.cast()) };
971        state.host_ctx.pending_count();
972    }
973
974    unsafe fn wake_reentrant_waker_by_ref(data: *const ()) {
975        let state = unsafe { Arc::<ReentrantWakerState>::from_raw(data.cast()) };
976        state.host_ctx.pending_count();
977        let _ = Arc::into_raw(state);
978    }
979
980    unsafe fn drop_reentrant_waker(data: *const ()) {
981        let state = unsafe { Arc::<ReentrantWakerState>::from_raw(data.cast()) };
982        state.host_ctx.pending_count();
983        state
984            .drops
985            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
986    }
987
988    static REENTRANT_WAKER_VTABLE: std::task::RawWakerVTable = std::task::RawWakerVTable::new(
989        clone_reentrant_waker,
990        wake_reentrant_waker,
991        wake_reentrant_waker_by_ref,
992        drop_reentrant_waker,
993    );
994
995    fn reentrant_waker(
996        host_ctx: Arc<HostContext>,
997        clones: Arc<std::sync::atomic::AtomicUsize>,
998        drops: Arc<std::sync::atomic::AtomicUsize>,
999    ) -> std::task::Waker {
1000        let state = Arc::new(ReentrantWakerState {
1001            host_ctx,
1002            clones,
1003            drops,
1004        });
1005        let raw = std::task::RawWaker::new(Arc::into_raw(state).cast(), &REENTRANT_WAKER_VTABLE);
1006        unsafe { std::task::Waker::from_raw(raw) }
1007    }
1008
1009    /// Tests that load a real plugin library share one dlopen'd image (and
1010    /// therefore its process-global init state) — a concurrent test dropping
1011    /// its `LoadedPlugin` runs `shutdown` under everyone else's feet. Any
1012    /// test that loads a plugin must hold this lock. This was also the root
1013    /// cause of the historical `unload_defers_...` flake.
1014    fn plugin_test_lock() -> std::sync::MutexGuard<'static, ()> {
1015        static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1016        LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
1017    }
1018
1019    fn example_plugin_path() -> Option<std::path::PathBuf> {
1020        let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1021            .parent()?
1022            .parent()?;
1023        let manifest = workspace_root.join("examples/ex-nyring-plugin/Cargo.toml");
1024        if !manifest.is_file() {
1025            return None;
1026        }
1027        let status = std::process::Command::new("cargo")
1028            .args(["build", "--release", "--manifest-path"])
1029            .arg(&manifest)
1030            .status()
1031            .ok()?;
1032        if !status.success() {
1033            return None;
1034        }
1035        let filename = if cfg!(target_os = "macos") {
1036            "libex_nyring_plugin.dylib"
1037        } else if cfg!(target_os = "windows") {
1038            "ex_nyring_plugin.dll"
1039        } else {
1040            "libex_nyring_plugin.so"
1041        };
1042        Some(workspace_root.join("target/release").join(filename))
1043    }
1044
1045    fn c_plugin_path() -> Option<std::path::PathBuf> {
1046        let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1047            .parent()?
1048            .parent()?;
1049        let source = workspace_root.join("examples/c-plugin/plugin.c");
1050        if !source.is_file() {
1051            return None;
1052        }
1053        let extension = if cfg!(target_os = "macos") {
1054            "dylib"
1055        } else if cfg!(target_os = "windows") {
1056            "dll"
1057        } else {
1058            "so"
1059        };
1060        let output = workspace_root
1061            .join("target/c-example")
1062            .join(format!("libnylon_ring_c_example.{extension}"));
1063        std::fs::create_dir_all(output.parent()?).ok()?;
1064        let status = std::process::Command::new("cc")
1065            .args(["-std=c11", "-Wall", "-Wextra", "-Werror", "-shared"])
1066            .args((!cfg!(target_os = "windows")).then_some("-fPIC"))
1067            .arg(&source)
1068            .arg("-o")
1069            .arg(&output)
1070            .status()
1071            .ok()?;
1072        status.success().then_some(output)
1073    }
1074
1075    #[test]
1076    fn dropping_pending_guard_unregisters_unary_request() {
1077        let host = NylonRingHost::new();
1078        let sid = 42;
1079        context::insert_pending(
1080            &host.host_ctx,
1081            sid,
1082            types::Pending::Unary(types::UnaryPending::waiting()),
1083        );
1084        host.host_ctx.set_state(sid, "key".into(), vec![1]);
1085
1086        drop(PendingGuard::new(&host.host_ctx, sid));
1087
1088        assert!(context::remove_pending(&host.host_ctx, sid).is_none());
1089        assert!(!host.host_ctx.state_per_sid.contains_key(&sid));
1090    }
1091
1092    #[test]
1093    fn unary_completion_wakes_latest_waiter_across_threads() {
1094        let host = NylonRingHost::new();
1095        let sid = 46;
1096        context::insert_pending(
1097            &host.host_ctx,
1098            sid,
1099            types::Pending::Unary(types::UnaryPending::waiting()),
1100        );
1101        host.host_ctx.set_state(sid, "key".into(), vec![1]);
1102
1103        let first_woken = Arc::new(std::sync::atomic::AtomicBool::new(false));
1104        let second_woken = Arc::new(std::sync::atomic::AtomicBool::new(false));
1105        let first_waker = std::task::Waker::from(Arc::new(WakeFlag(first_woken.clone())));
1106        let second_waker = std::task::Waker::from(Arc::new(WakeFlag(second_woken.clone())));
1107        let mut future = Box::pin(context::wait_for_unary(&host.host_ctx, sid));
1108
1109        let mut first_context = std::task::Context::from_waker(&first_waker);
1110        assert!(matches!(
1111            std::future::Future::poll(future.as_mut(), &mut first_context),
1112            std::task::Poll::Pending
1113        ));
1114        let mut second_context = std::task::Context::from_waker(&second_waker);
1115        assert!(matches!(
1116            std::future::Future::poll(future.as_mut(), &mut second_context),
1117            std::task::Poll::Pending
1118        ));
1119
1120        let host_ctx = host.host_ctx.clone();
1121        let dispatch_status = std::thread::spawn(move || {
1122            context::dispatch_pending(
1123                &host_ctx,
1124                sid,
1125                NrStatus::Ok,
1126                types::ResponsePayload::Owned(vec![7]),
1127            )
1128        })
1129        .join()
1130        .unwrap();
1131        assert_eq!(dispatch_status, NrStatus::Ok);
1132        assert!(!first_woken.load(std::sync::atomic::Ordering::Acquire));
1133        assert!(second_woken.load(std::sync::atomic::Ordering::Acquire));
1134        assert!(!host.host_ctx.state_per_sid.contains_key(&sid));
1135        assert_eq!(host.metrics().pending_requests, 1);
1136
1137        assert_eq!(
1138            context::dispatch_pending(
1139                &host.host_ctx,
1140                sid,
1141                NrStatus::Ok,
1142                types::ResponsePayload::Owned(vec![8]),
1143            ),
1144            NrStatus::Invalid
1145        );
1146
1147        match std::future::Future::poll(future.as_mut(), &mut second_context) {
1148            std::task::Poll::Ready(Some((status, data))) => {
1149                assert_eq!(status, NrStatus::Ok);
1150                assert_eq!(data.as_slice(), [7]);
1151            }
1152            result => panic!("unexpected unary completion result: {result:?}"),
1153        }
1154        assert_eq!(host.metrics().pending_requests, 0);
1155    }
1156
1157    #[test]
1158    fn unary_waker_callbacks_run_outside_pending_lock() {
1159        let host = NylonRingHost::new();
1160        let sid = 47;
1161        context::insert_pending(
1162            &host.host_ctx,
1163            sid,
1164            types::Pending::Unary(types::UnaryPending::waiting()),
1165        );
1166
1167        let clones = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1168        let drops = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1169        let first_waker = reentrant_waker(host.host_ctx.clone(), clones.clone(), drops.clone());
1170        let mut future = Box::pin(context::wait_for_unary(&host.host_ctx, sid));
1171        {
1172            let mut context = std::task::Context::from_waker(&first_waker);
1173            assert!(matches!(
1174                std::future::Future::poll(future.as_mut(), &mut context),
1175                std::task::Poll::Pending
1176            ));
1177        }
1178        assert_eq!(clones.load(std::sync::atomic::Ordering::Relaxed), 1);
1179
1180        let second_woken = Arc::new(std::sync::atomic::AtomicBool::new(false));
1181        let second_waker = std::task::Waker::from(Arc::new(WakeFlag(second_woken)));
1182        let mut context = std::task::Context::from_waker(&second_waker);
1183        assert!(matches!(
1184            std::future::Future::poll(future.as_mut(), &mut context),
1185            std::task::Poll::Pending
1186        ));
1187        assert_eq!(drops.load(std::sync::atomic::Ordering::Relaxed), 1);
1188
1189        drop(future);
1190        context::cleanup_sid(&host.host_ctx, sid);
1191    }
1192
1193    #[test]
1194    fn dropping_stream_receiver_unregisters_stream() {
1195        let host = NylonRingHost::new();
1196        let sid = 43;
1197        let (tx, rx) = stream_channel::acquire(1);
1198        context::insert_pending(&host.host_ctx, sid, types::Pending::Stream(tx));
1199        host.host_ctx.set_state(sid, "key".into(), vec![1]);
1200
1201        drop(StreamReceiver::new(
1202            rx,
1203            Some(host.host_ctx.clone()),
1204            sid,
1205            None,
1206        ));
1207
1208        assert!(context::remove_pending(&host.host_ctx, sid).is_none());
1209        assert!(!host.host_ctx.state_per_sid.contains_key(&sid));
1210    }
1211
1212    #[test]
1213    fn fast_slot_reentry_is_rejected_and_binding_is_cleared() {
1214        let mut first = types::UnaryResultSlot {
1215            sid: 1,
1216            result: None,
1217            lease: None,
1218        };
1219        let mut second = types::UnaryResultSlot {
1220            sid: 2,
1221            result: None,
1222            lease: None,
1223        };
1224        let binding = FastSlotBinding::bind(&mut first).unwrap();
1225        assert!(matches!(
1226            FastSlotBinding::bind(&mut second),
1227            Err(NylonRingHostError::FastPathReentrant)
1228        ));
1229        drop(binding);
1230        assert!(FastSlotBinding::bind(&mut second).is_ok());
1231    }
1232
1233    #[test]
1234    fn bounded_stream_reports_backpressure_and_removes_terminal_once() {
1235        let host = NylonRingHost::with_stream_capacity(1);
1236        let sid = 44;
1237        let (tx, mut rx) = stream_channel::acquire(1);
1238        context::insert_pending(&host.host_ctx, sid, types::Pending::Stream(tx));
1239
1240        assert_eq!(
1241            context::dispatch_pending(
1242                &host.host_ctx,
1243                sid,
1244                NrStatus::Ok,
1245                types::ResponsePayload::Owned(vec![1]),
1246            ),
1247            NrStatus::Ok
1248        );
1249        assert_eq!(
1250            context::dispatch_pending(
1251                &host.host_ctx,
1252                sid,
1253                NrStatus::Ok,
1254                types::ResponsePayload::Owned(vec![2]),
1255            ),
1256            NrStatus::Backpressure
1257        );
1258        assert_eq!(rx.try_recv().unwrap().data, vec![1]);
1259        assert_eq!(
1260            context::dispatch_pending(
1261                &host.host_ctx,
1262                sid,
1263                NrStatus::StreamEnd,
1264                types::ResponsePayload::Owned(vec![]),
1265            ),
1266            NrStatus::Ok
1267        );
1268        assert_eq!(host.metrics().pending_requests, 0);
1269        assert_eq!(rx.try_recv().unwrap().status, NrStatus::StreamEnd);
1270    }
1271
1272    #[test]
1273    fn concurrent_terminal_frames_complete_stream_once() {
1274        let host = NylonRingHost::with_stream_capacity(2);
1275        let sid = 45;
1276        let (tx, mut rx) = stream_channel::acquire(2);
1277        context::insert_pending(&host.host_ctx, sid, types::Pending::Stream(tx));
1278        let barrier = Arc::new(std::sync::Barrier::new(3));
1279
1280        let results: Vec<_> = std::thread::scope(|scope| {
1281            let handles: Vec<_> = (0..2)
1282                .map(|value| {
1283                    let ctx = host.host_ctx.clone();
1284                    let barrier = barrier.clone();
1285                    scope.spawn(move || {
1286                        barrier.wait();
1287                        context::dispatch_pending(
1288                            &ctx,
1289                            sid,
1290                            NrStatus::StreamEnd,
1291                            types::ResponsePayload::Owned(vec![value]),
1292                        )
1293                    })
1294                })
1295                .collect();
1296            barrier.wait();
1297            handles
1298                .into_iter()
1299                .map(|handle| handle.join().unwrap())
1300                .collect()
1301        });
1302
1303        assert_eq!(
1304            results
1305                .iter()
1306                .filter(|&&status| status == NrStatus::Ok)
1307                .count(),
1308            1
1309        );
1310        assert_eq!(
1311            results
1312                .iter()
1313                .filter(|&&status| status == NrStatus::Invalid)
1314                .count(),
1315            1
1316        );
1317        assert!(rx.try_recv().is_some());
1318        assert!(rx.try_recv().is_none());
1319        assert_eq!(host.metrics().pending_requests, 0);
1320    }
1321
1322    #[test]
1323    fn unload_defers_library_drop_and_rejects_new_calls_on_existing_handle() {
1324        let _plugin_lock = plugin_test_lock();
1325        let Some(path) = example_plugin_path() else {
1326            return;
1327        };
1328        let mut host = NylonRingHost::new();
1329        host.load("example", path.to_str().unwrap()).unwrap();
1330        let handle = host.plugin("example").unwrap();
1331        let runtime = tokio::runtime::Runtime::new().unwrap();
1332        assert!(matches!(
1333            runtime.block_on(handle.call_response_timeout(
1334                "benchmark_without_response",
1335                b"",
1336                Duration::from_millis(1),
1337            )),
1338            Err(NylonRingHostError::Timeout)
1339        ));
1340        assert_eq!(host.metrics().pending_requests, 0);
1341
1342        let guard = handle.plugin.begin_call().unwrap();
1343        assert_eq!(host.metrics().in_flight_calls, 1);
1344
1345        host.unload("example").unwrap();
1346        assert!(matches!(
1347            runtime.block_on(handle.call("benchmark_without_response", b"")),
1348            Err(NylonRingHostError::PluginUnloaded)
1349        ));
1350        drop(guard);
1351        drop(handle);
1352    }
1353
1354    #[test]
1355    fn stream_receiver_outlives_unload_and_drains_frames() {
1356        let _plugin_lock = plugin_test_lock();
1357        let Some(path) = example_plugin_path() else {
1358            return;
1359        };
1360        let mut host = NylonRingHost::new();
1361        host.load("example", path.to_str().unwrap()).unwrap();
1362        let handle = host.plugin("example").unwrap();
1363        let runtime = tokio::runtime::Runtime::new().unwrap();
1364
1365        let (_sid, mut receiver) = runtime
1366            .block_on(handle.call_stream("benchmark_stream", b""))
1367            .unwrap();
1368        host.unload("example").unwrap();
1369        drop(handle);
1370
1371        // The receiver's call guard must keep the retired plugin alive:
1372        // draining all buffered frames exercises the guarded context path.
1373        let frames = runtime.block_on(async {
1374            let mut frames = 0u32;
1375            while receiver.recv().await.is_some() {
1376                frames += 1;
1377            }
1378            frames
1379        });
1380        assert_eq!(frames, 9);
1381        drop(receiver);
1382    }
1383
1384    #[test]
1385    fn stream_receiver_outlives_host_drop() {
1386        let _plugin_lock = plugin_test_lock();
1387        let Some(path) = example_plugin_path() else {
1388            return;
1389        };
1390        let mut host = NylonRingHost::new();
1391        host.load("example", path.to_str().unwrap()).unwrap();
1392        let handle = host.plugin("example").unwrap();
1393        let runtime = tokio::runtime::Runtime::new().unwrap();
1394
1395        let (_sid, mut receiver) = runtime
1396            .block_on(handle.call_stream("benchmark_stream", b""))
1397            .unwrap();
1398        drop(host);
1399        drop(handle);
1400
1401        let frames = runtime.block_on(async {
1402            let mut frames = 0u32;
1403            while receiver.recv().await.is_some() {
1404                frames += 1;
1405            }
1406            frames
1407        });
1408        assert_eq!(frames, 9);
1409    }
1410
1411    #[test]
1412    fn owned_response_round_trips_and_releases_exactly_once() {
1413        let _plugin_lock = plugin_test_lock();
1414        let Some(path) = example_plugin_path() else {
1415            return;
1416        };
1417        let mut host = NylonRingHost::new();
1418        host.load("example", path.to_str().unwrap()).unwrap();
1419        let handle = host.plugin("example").unwrap();
1420        let runtime = tokio::runtime::Runtime::new().unwrap();
1421
1422        async fn release_count(handle: &PluginHandle) -> u64 {
1423            let (status, data) = handle
1424                .call_response("owned_release_count", b"")
1425                .await
1426                .unwrap();
1427            assert_eq!(status, NrStatus::Ok);
1428            u64::from_le_bytes(data.try_into().unwrap())
1429        }
1430
1431        runtime.block_on(async {
1432            // Zero-copy static response: correct bytes, nothing to release.
1433            let (status, data) = handle
1434                .call_response_bytes("benchmark_owned", &[0u8; 128])
1435                .await
1436                .unwrap();
1437            assert_eq!(status, NrStatus::Ok);
1438            assert_eq!(data.len(), 128);
1439            assert!(data.iter().all(|&byte| byte == 42));
1440
1441            let before = release_count(&handle).await;
1442
1443            // Held view: released only when dropped, not on receipt.
1444            let (status, held) = handle
1445                .call_response_bytes("echo_owned", b"held")
1446                .await
1447                .unwrap();
1448            assert_eq!(status, NrStatus::Ok);
1449            assert_eq!(&*held, b"held");
1450            assert_eq!(release_count(&handle).await, before);
1451            drop(held);
1452            assert_eq!(release_count(&handle).await, before + 1);
1453
1454            // Vec conversion path copies and releases immediately.
1455            let (status, data) = handle.call_response("echo_owned", b"copied").await.unwrap();
1456            assert_eq!(status, NrStatus::Ok);
1457            assert_eq!(data, b"copied");
1458            assert_eq!(release_count(&handle).await, before + 2);
1459
1460            // into_vec on the view also releases exactly once.
1461            let (_, view) = handle
1462                .call_response_bytes("echo_owned", b"vec")
1463                .await
1464                .unwrap();
1465            assert_eq!(view.into_vec(), b"vec");
1466            assert_eq!(release_count(&handle).await, before + 3);
1467
1468            // Fast path consumes owned responses through the slot copy.
1469            let (status, data) = handle
1470                .call_response_fast("echo_owned", b"fast")
1471                .await
1472                .unwrap();
1473            assert_eq!(status, NrStatus::Ok);
1474            assert_eq!(data, b"fast");
1475            assert_eq!(release_count(&handle).await, before + 4);
1476        });
1477    }
1478
1479    #[test]
1480    fn owned_response_keeps_plugin_alive_after_unload() {
1481        let _plugin_lock = plugin_test_lock();
1482        let Some(path) = example_plugin_path() else {
1483            return;
1484        };
1485        let mut host = NylonRingHost::new();
1486        host.load("example", path.to_str().unwrap()).unwrap();
1487        let handle = host.plugin("example").unwrap();
1488        let runtime = tokio::runtime::Runtime::new().unwrap();
1489
1490        let (status, response) = runtime
1491            .block_on(handle.call_response_bytes("benchmark_owned", &[0u8; 64]))
1492            .unwrap();
1493        assert_eq!(status, NrStatus::Ok);
1494
1495        // The response borrows memory inside the plugin image; its guard
1496        // must defer the library unload until the view drops.
1497        host.unload("example").unwrap();
1498        drop(handle);
1499        drop(host);
1500        assert_eq!(response.len(), 64);
1501        assert!(response.iter().all(|&byte| byte == 42));
1502        drop(response);
1503    }
1504
1505    #[test]
1506    fn lease_round_trips_and_rejects_misuse() {
1507        let _plugin_lock = plugin_test_lock();
1508        let Some(path) = example_plugin_path() else {
1509            return;
1510        };
1511        let mut host = NylonRingHost::new();
1512        host.load("example", path.to_str().unwrap()).unwrap();
1513        let handle = host.plugin("example").unwrap();
1514        let runtime = tokio::runtime::Runtime::new().unwrap();
1515
1516        runtime.block_on(async {
1517            // Standard unary path: the response Vec is the leased buffer.
1518            let (status, data) = handle
1519                .call_response("echo_lease", b"through the lease")
1520                .await
1521                .unwrap();
1522            assert_eq!(status, NrStatus::Ok);
1523            assert_eq!(data, b"through the lease");
1524
1525            // Empty payloads commit a zero-length lease.
1526            let (status, data) = handle.call_response("echo_lease", b"").await.unwrap();
1527            assert_eq!(status, NrStatus::Ok);
1528            assert!(data.is_empty());
1529
1530            // Fast path: the lease lives in the TLS slot.
1531            let (status, data) = handle
1532                .call_response_fast("echo_lease", b"fast lease")
1533                .await
1534                .unwrap();
1535            assert_eq!(status, NrStatus::Ok);
1536            assert_eq!(data, b"fast lease");
1537
1538            // The misuse handler asserts double acquire, bad token, bad
1539            // length, and double commit all report Invalid; an assertion
1540            // failure would surface here as PluginPanicked.
1541            let (status, data) = handle
1542                .call_response("lease_misuse", b"contract")
1543                .await
1544                .unwrap();
1545            assert_eq!(status, NrStatus::Ok);
1546            assert_eq!(data, b"contract");
1547        });
1548
1549        // Every lease was committed; nothing should be parked or pending.
1550        assert_eq!(host.host_ctx.orphaned_lease_count(), 0);
1551        assert_eq!(host.host_ctx.pending_count(), 0);
1552    }
1553
1554    #[test]
1555    fn abandoned_leases_are_parked_not_freed() {
1556        let _plugin_lock = plugin_test_lock();
1557        let Some(path) = example_plugin_path() else {
1558            return;
1559        };
1560        let mut host = NylonRingHost::new();
1561        host.load("example", path.to_str().unwrap()).unwrap();
1562        let handle = host.plugin("example").unwrap();
1563        let runtime = tokio::runtime::Runtime::new().unwrap();
1564
1565        runtime.block_on(async {
1566            // The plugin acquires a lease and never commits: the call times
1567            // out and cleanup must park the lease (the plugin could still
1568            // hold the pointer), not free it.
1569            let result = handle
1570                .call_response_timeout("lease_abandon", b"", std::time::Duration::from_millis(50))
1571                .await;
1572            assert!(matches!(result, Err(NylonRingHostError::Timeout)));
1573            assert_eq!(host.host_ctx.orphaned_lease_count(), 1);
1574            assert_eq!(host.host_ctx.pending_count(), 0);
1575
1576            // Fast path: an uncommitted lease in the TLS slot is parked too.
1577            let result = handle.call_response_fast("lease_abandon", b"").await;
1578            assert!(matches!(
1579                result,
1580                Err(NylonRingHostError::MissingSynchronousResponse)
1581            ));
1582            assert_eq!(host.host_ctx.orphaned_lease_count(), 2);
1583        });
1584    }
1585
1586    #[test]
1587    fn example_plugin_fire_and_forget_entry_returns_ok() {
1588        let _plugin_lock = plugin_test_lock();
1589        let Some(path) = example_plugin_path() else {
1590            return;
1591        };
1592        let mut host = NylonRingHost::new();
1593        host.load("example", path.to_str().unwrap()).unwrap();
1594        let handle = host.plugin("example").unwrap();
1595        let runtime = tokio::runtime::Runtime::new().unwrap();
1596
1597        assert!(matches!(
1598            runtime.block_on(handle.call("notify", b"fire and forget")),
1599            Ok(NrStatus::Ok)
1600        ));
1601    }
1602
1603    #[test]
1604    fn c_plugin_layout_round_trips_through_host() {
1605        let _plugin_lock = plugin_test_lock();
1606        let Some(path) = c_plugin_path() else {
1607            return;
1608        };
1609        let mut host = NylonRingHost::new();
1610        host.load("c-example", path.to_str().unwrap()).unwrap();
1611        let handle = host.plugin("c-example").unwrap();
1612        let runtime = tokio::runtime::Runtime::new().unwrap();
1613        let (status, response) = runtime
1614            .block_on(handle.call_response("echo", b"from-c"))
1615            .unwrap();
1616        assert_eq!(status, NrStatus::Ok);
1617        assert_eq!(response, b"from-c");
1618
1619        // Integer entry dispatch is optional; this plugin leaves both
1620        // fields NULL, which must surface as a typed error.
1621        assert!(matches!(
1622            handle.entry("echo"),
1623            Err(NylonRingHostError::EntryDispatchUnsupported)
1624        ));
1625    }
1626
1627    #[test]
1628    fn entry_id_dispatch_round_trips_all_call_shapes() {
1629        let _plugin_lock = plugin_test_lock();
1630        let Some(path) = example_plugin_path() else {
1631            return;
1632        };
1633        let mut host = NylonRingHost::new();
1634        host.load("example", path.to_str().unwrap()).unwrap();
1635        let handle = host.plugin("example").unwrap();
1636        let runtime = tokio::runtime::Runtime::new().unwrap();
1637
1638        assert!(matches!(
1639            handle.entry("no_such_entry"),
1640            Err(NylonRingHostError::EntryNotFound(_))
1641        ));
1642
1643        let echo = handle.entry("benchmark").unwrap();
1644        let notify = handle.entry("benchmark_without_response").unwrap();
1645        let stream = handle.entry("benchmark_stream").unwrap();
1646
1647        runtime.block_on(async {
1648            let (status, data) = echo.call_response(b"by-id").await.unwrap();
1649            assert_eq!(status, NrStatus::Ok);
1650            assert_eq!(data, b"by-id");
1651
1652            let (status, data) = echo.call_response_fast(b"fast-by-id").await.unwrap();
1653            assert_eq!(status, NrStatus::Ok);
1654            assert_eq!(data, b"fast-by-id");
1655
1656            assert_eq!(notify.call(b"").await.unwrap(), NrStatus::Ok);
1657
1658            let (_sid, mut receiver) = stream.call_stream(b"").await.unwrap();
1659            let mut frames = 0u32;
1660            while receiver.recv().await.is_some() {
1661                frames += 1;
1662            }
1663            assert_eq!(frames, 9);
1664        });
1665    }
1666}