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/// Binds the thread-local stream-frame slot for the duration of a plugin
104/// `handle` call. Binding is best-effort: a reentrant `call_stream` on the
105/// same thread simply falls back to the pending-map frame path.
106struct StreamSlotBinding;
107
108impl StreamSlotBinding {
109    fn bind(slot: &mut context::StreamFrameSlot) -> Option<Self> {
110        let bound = context::CURRENT_STREAM_FRAME.with(|cell| {
111            if !cell.get().is_null() {
112                return false;
113            }
114            cell.set(slot as *mut _);
115            true
116        });
117        bound.then_some(Self)
118    }
119}
120
121impl Drop for StreamSlotBinding {
122    fn drop(&mut self) {
123        context::CURRENT_STREAM_FRAME.with(|cell| cell.set(std::ptr::null_mut()));
124    }
125}
126
127/// The plugin's dispatch surface, copied out of its vtable at load time
128/// (`init` is only used during load and not retained).
129#[derive(Copy, Clone)]
130struct PluginDispatch {
131    handle: Option<unsafe extern "C" fn(NrStr, u64, NrBytes) -> NrStatus>,
132    shutdown: Option<unsafe extern "C" fn()>,
133    stream_data: Option<unsafe extern "C" fn(u64, NrBytes) -> NrStatus>,
134    stream_close: Option<unsafe extern "C" fn(u64) -> NrStatus>,
135    resolve_entry: Option<unsafe extern "C" fn(NrStr) -> u32>,
136    handle_by_id: Option<unsafe extern "C" fn(u32, u64, NrBytes) -> NrStatus>,
137}
138
139impl PluginDispatch {
140    fn from_vtable(vtable: &nylon_ring::NrPluginVTable) -> Self {
141        Self {
142            handle: vtable.handle,
143            shutdown: vtable.shutdown,
144            stream_data: vtable.stream_data,
145            stream_close: vtable.stream_close,
146            resolve_entry: vtable.resolve_entry,
147            handle_by_id: vtable.handle_by_id,
148        }
149    }
150}
151
152/// A loaded plugin instance.
153pub struct LoadedPlugin {
154    _lib: std::mem::ManuallyDrop<Library>,
155    dispatch: PluginDispatch,
156    host_ctx: Arc<HostContext>,
157    path: String,
158    call_tracker: CallTracker,
159    /// Pinned plugins opt out of unload/reload entirely: per-call guards
160    /// skip the tracker RMWs, and the library is leaked on drop instead of
161    /// running shutdown/dlclose that can no longer be proven quiescent.
162    /// Streams still take real tracker slots (their guards hold raw plugin
163    /// pointers whose validity relies on the tracker).
164    pinned: bool,
165}
166
167/// Sentinel shard for calls on pinned plugins: no tracker slot was taken
168/// and `finish` must not run.
169const PINNED_SHARD: usize = usize::MAX;
170
171/// Plugins removed from a host while calls may still be in flight.
172///
173/// Owned call guards do not clone `Arc<LoadedPlugin>` (a per-stream RMW on
174/// one shared cache line capped multi-core streaming); they hold a raw
175/// pointer plus a tracker slot instead. That is sound only if the plugin
176/// allocation outlives every tracker slot, so an Arc leaving a host's map
177/// is never dropped directly: [`retire_plugin`] parks it here and the last
178/// `finish` observing the stopped tracker sweeps it out.
179static RETIRED_PLUGINS: std::sync::Mutex<Vec<Arc<LoadedPlugin>>> =
180    std::sync::Mutex::new(Vec::new());
181
182/// Stop a plugin removed from a host's map and keep it alive until every
183/// tracked call has finished. Every code path where a host-map
184/// `Arc<LoadedPlugin>` leaves the map must go through here (a plain drop
185/// could free the plugin while a call guard still points at it).
186fn retire_plugin(plugin: Arc<LoadedPlugin>) {
187    plugin.stop_accepting_calls();
188    RETIRED_PLUGINS.lock().unwrap().push(plugin);
189    // The plugin may already be idle; free it now instead of waiting for
190    // an unrelated future sweep.
191    sweep_retired_plugins();
192}
193
194/// Drop every retired plugin whose tracked calls have drained.
195///
196/// Memory-ordering argument for "the last finisher always frees": `finish`
197/// is an AcqRel RMW and `stop` is an AcqRel RMW on the same shard counters,
198/// so for each shard either the finish precedes the stop (the stop/retire
199/// thread's sweep then reads the drained value) or it follows it (the
200/// finisher sees CLOSED and sweeps itself, happening-after all finishes the
201/// stop observed). Two concurrent last finishers on different shards are the
202/// store-buffering case; the SeqCst fence below pairs across sweeps so at
203/// least one of them observes every shard drained.
204fn sweep_retired_plugins() {
205    std::sync::atomic::fence(std::sync::atomic::Ordering::SeqCst);
206    let drained: Vec<Arc<LoadedPlugin>> = {
207        let mut retired = RETIRED_PLUGINS.lock().unwrap();
208        let (drained, live) = std::mem::take(&mut *retired)
209            .into_iter()
210            .partition(|plugin| plugin.call_tracker.active_calls() == 0);
211        *retired = live;
212        drained
213    };
214    // Dropping may run the plugin's shutdown callback and unload the
215    // library; keep that outside the registry lock.
216    drop(drained);
217}
218
219impl LoadedPlugin {
220    fn begin_call(&self) -> Result<BorrowedPluginCallGuard<'_>> {
221        if self.pinned {
222            // The borrow ties the guard to a live `Arc<LoadedPlugin>` and a
223            // pinned library is never unloaded, so no tracker slot is needed.
224            return Ok(BorrowedPluginCallGuard {
225                tracker: &self.call_tracker,
226                shard: PINNED_SHARD,
227            });
228        }
229        let shard = self
230            .call_tracker
231            .try_begin()
232            .ok_or(NylonRingHostError::PluginUnloaded)?;
233        Ok(BorrowedPluginCallGuard {
234            tracker: &self.call_tracker,
235            shard,
236        })
237    }
238
239    fn begin_owned_call(self: &Arc<Self>) -> Result<PluginCallGuard> {
240        let shard = self
241            .call_tracker
242            .try_begin()
243            .ok_or(NylonRingHostError::PluginUnloaded)?;
244        Ok(PluginCallGuard {
245            plugin: Arc::as_ptr(self),
246            shard,
247        })
248    }
249
250    fn stop_accepting_calls(&self) {
251        self.call_tracker.stop();
252    }
253}
254
255struct BorrowedPluginCallGuard<'a> {
256    tracker: &'a CallTracker,
257    shard: usize,
258}
259
260impl Drop for BorrowedPluginCallGuard<'_> {
261    fn drop(&mut self) {
262        if self.shard != PINNED_SHARD && self.tracker.finish(self.shard) {
263            sweep_retired_plugins();
264        }
265    }
266}
267
268/// Owned in-flight-call token, held by values that outlive their call
269/// (currently [`StreamReceiver`] via `call_stream`).
270///
271/// Holds a raw plugin pointer instead of an `Arc` clone: every stream
272/// cloning the same Arc made its refcount line the multi-core streaming
273/// bottleneck. Validity: the tracker slot held below is released only in
274/// this guard's `Drop`, and a `LoadedPlugin` is freed only when its host-map
275/// Arc has been retired through [`retire_plugin`] *and* its tracker has
276/// drained — so the pointee outlives the guard.
277pub(crate) struct PluginCallGuard {
278    plugin: *const LoadedPlugin,
279    shard: usize,
280}
281
282// SAFETY: the guard behaves like a `&LoadedPlugin` with liveness (see the
283// type docs); `LoadedPlugin` is Send + Sync (it is shared via Arc across
284// threads today).
285unsafe impl Send for PluginCallGuard {}
286unsafe impl Sync for PluginCallGuard {}
287
288impl PluginCallGuard {
289    pub(crate) fn plugin(&self) -> &LoadedPlugin {
290        // SAFETY: see the type documentation — the held tracker slot keeps
291        // the plugin allocation alive for the guard's lifetime.
292        unsafe { &*self.plugin }
293    }
294}
295
296impl Drop for PluginCallGuard {
297    fn drop(&mut self) {
298        // After this `finish` the plugin pointer must not be dereferenced:
299        // releasing the slot may allow a concurrent sweep to free it.
300        if self.plugin().call_tracker.finish(self.shard) {
301            sweep_retired_plugins();
302        }
303    }
304}
305
306impl Drop for LoadedPlugin {
307    fn drop(&mut self) {
308        if self.pinned {
309            // Untracked borrowed calls may still be executing plugin code;
310            // leak the library and skip shutdown rather than pull code pages
311            // out from under them.
312            return;
313        }
314        if let Some(shutdown_fn) = self.dispatch.shutdown {
315            unsafe {
316                shutdown_fn();
317            }
318        }
319        // SAFETY: dropped exactly once here; the pinned branch above returns
320        // without dropping, which is the intended leak.
321        unsafe { std::mem::ManuallyDrop::drop(&mut self._lib) };
322    }
323}
324
325/// A handle to a specific plugin for making calls.
326#[derive(Clone)]
327pub struct PluginHandle {
328    plugin: Arc<LoadedPlugin>,
329}
330
331impl PluginHandle {
332    fn status_error(status: NrStatus) -> NylonRingHostError {
333        if status == NrStatus::Panic {
334            NylonRingHostError::PluginPanicked
335        } else {
336            NylonRingHostError::PluginHandleFailed(status)
337        }
338    }
339
340    /// Call a plugin entry point with a request-response pattern.
341    pub async fn call_response(&self, entry: &str, payload: &[u8]) -> Result<(NrStatus, Vec<u8>)> {
342        let _call_guard = self.plugin.begin_call()?;
343        let sid = next_sid();
344
345        context::insert_pending(
346            &self.plugin.host_ctx,
347            sid,
348            types::Pending::Unary(types::UnaryPending::waiting()),
349        );
350        let mut pending_guard = PendingGuard::new(&self.plugin.host_ctx, sid);
351
352        // Synchronous responses land in the thread-local slot without touching
353        // the pending map again; the map entry (inserted above, before the
354        // plugin runs) still catches responses from other threads. A reentrant
355        // caller already owns the slot, so binding is best-effort.
356        let mut slot = types::UnaryResultSlot {
357            sid,
358            result: None,
359            lease: None,
360        };
361        let binding = FastSlotBinding::bind(&mut slot).ok();
362
363        let payload_bytes = NrBytes::from_slice(payload);
364        let handle_raw_fn = match self.plugin.dispatch.handle {
365            Some(f) => f,
366            None => return Err(NylonRingHostError::MissingRequiredFunctions),
367        };
368
369        let status = unsafe { handle_raw_fn(NrStr::new(entry), sid, payload_bytes) };
370
371        drop(binding);
372        // A lease the plugin acquired but never committed may still be
373        // written to by misbehaving plugin threads; park it instead of
374        // freeing stack-adjacent heap memory (see HostContext orphans).
375        if let Some(lease) = slot.lease.take() {
376            self.plugin.host_ctx.park_orphan_lease(lease);
377        }
378
379        if status != NrStatus::Ok {
380            return Err(Self::status_error(status));
381        }
382
383        if let Some((status, data)) = slot.result.take() {
384            // The pending guard removes the unused map entry on drop.
385            return Ok((status, data));
386        }
387
388        match context::wait_for_unary(&self.plugin.host_ctx, sid).await {
389            Some((status, payload)) => {
390                pending_guard.disarm();
391                // Conversion happens while the call guard is still held, so
392                // a foreign payload's release cannot outlive the library.
393                Ok((status, payload.into_vec()))
394            }
395            None => Err(NylonRingHostError::PluginUnloaded),
396        }
397    }
398
399    /// Like [`PluginHandle::call_response`], but returns the response as a
400    /// [`ResponseBytes`] view instead of a `Vec<u8>`.
401    ///
402    /// For a plugin that responds with plugin-owned bytes this is
403    /// zero-copy: the buffer is only released back to the plugin when the
404    /// view drops, and the view holds an in-flight call guard so the plugin
405    /// cannot be unloaded from under it.
406    pub async fn call_response_bytes(
407        &self,
408        entry: &str,
409        payload: &[u8],
410    ) -> Result<(NrStatus, ResponseBytes)> {
411        let call_guard = self.plugin.begin_owned_call()?;
412        let sid = next_sid();
413
414        context::insert_pending(
415            &self.plugin.host_ctx,
416            sid,
417            types::Pending::Unary(types::UnaryPending::waiting()),
418        );
419        let mut pending_guard = PendingGuard::new(&self.plugin.host_ctx, sid);
420
421        let payload_bytes = NrBytes::from_slice(payload);
422        let handle_raw_fn = match self.plugin.dispatch.handle {
423            Some(f) => f,
424            None => return Err(NylonRingHostError::MissingRequiredFunctions),
425        };
426
427        let status = unsafe { handle_raw_fn(NrStr::new(entry), sid, payload_bytes) };
428
429        if status != NrStatus::Ok {
430            return Err(Self::status_error(status));
431        }
432
433        match context::wait_for_unary(&self.plugin.host_ctx, sid).await {
434            Some((status, payload)) => {
435                pending_guard.disarm();
436                // Host-owned payloads need no guard; foreign ones keep the
437                // plugin loaded until the view is dropped.
438                let guard =
439                    matches!(payload, types::ResponsePayload::Foreign(_)).then_some(call_guard);
440                Ok((status, ResponseBytes::new(payload, guard)))
441            }
442            None => Err(NylonRingHostError::PluginUnloaded),
443        }
444    }
445
446    /// Like [`PluginHandle::call_response`] but bounded by a timeout.
447    ///
448    /// If the plugin does not deliver a response within `timeout`, the pending
449    /// entry is removed from the host's tracking map and `Err(Timeout)` is
450    /// returned. Use this for any production caller that cannot afford to
451    /// hang indefinitely on a misbehaving plugin.
452    pub async fn call_response_timeout(
453        &self,
454        entry: &str,
455        payload: &[u8],
456        timeout: std::time::Duration,
457    ) -> Result<(NrStatus, Vec<u8>)> {
458        let _call_guard = self.plugin.begin_call()?;
459        let sid = next_sid();
460
461        context::insert_pending(
462            &self.plugin.host_ctx,
463            sid,
464            types::Pending::Unary(types::UnaryPending::waiting()),
465        );
466        let mut pending_guard = PendingGuard::new(&self.plugin.host_ctx, sid);
467
468        // Same synchronous-response slot as `call_response`.
469        let mut slot = types::UnaryResultSlot {
470            sid,
471            result: None,
472            lease: None,
473        };
474        let binding = FastSlotBinding::bind(&mut slot).ok();
475
476        let payload_bytes = NrBytes::from_slice(payload);
477        let handle_raw_fn = match self.plugin.dispatch.handle {
478            Some(f) => f,
479            None => return Err(NylonRingHostError::MissingRequiredFunctions),
480        };
481
482        let status = unsafe { handle_raw_fn(NrStr::new(entry), sid, payload_bytes) };
483
484        drop(binding);
485        if let Some(lease) = slot.lease.take() {
486            self.plugin.host_ctx.park_orphan_lease(lease);
487        }
488
489        if status != NrStatus::Ok {
490            return Err(Self::status_error(status));
491        }
492
493        if let Some((status, data)) = slot.result.take() {
494            return Ok((status, data));
495        }
496
497        match tokio::time::timeout(timeout, context::wait_for_unary(&self.plugin.host_ctx, sid))
498            .await
499        {
500            Ok(Some((status, payload))) => {
501                pending_guard.disarm();
502                Ok((status, payload.into_vec()))
503            }
504            Ok(None) => Err(NylonRingHostError::PluginUnloaded),
505            Err(_) => Err(NylonRingHostError::Timeout),
506        }
507    }
508
509    /// Ultra-fast unary call for synchronous plugins.
510    pub async fn call_response_fast(
511        &self,
512        entry: &str,
513        payload: &[u8],
514    ) -> Result<(NrStatus, Vec<u8>)> {
515        let _call_guard = self.plugin.begin_call()?;
516        let sid = next_sid();
517        let mut slot = types::UnaryResultSlot {
518            sid,
519            result: None,
520            lease: None,
521        };
522
523        let binding = FastSlotBinding::bind(&mut slot)?;
524
525        let payload_bytes = NrBytes::from_slice(payload);
526
527        let handle_raw_fn = match self.plugin.dispatch.handle {
528            Some(f) => f,
529            None => return Err(NylonRingHostError::MissingRequiredFunctions),
530        };
531
532        let status = unsafe { handle_raw_fn(NrStr::new(entry), sid, payload_bytes) };
533
534        drop(binding);
535        // A lease the plugin acquired but never committed may still be
536        // written to by misbehaving plugin threads; park it instead of
537        // freeing stack-adjacent heap memory (see HostContext orphans).
538        if let Some(lease) = slot.lease.take() {
539            self.plugin.host_ctx.park_orphan_lease(lease);
540        }
541        self.plugin.host_ctx.remove_state(sid);
542
543        if status != NrStatus::Ok {
544            return Err(Self::status_error(status));
545        }
546
547        match slot.result {
548            Some((st, data)) => Ok((st, data)),
549            None => Err(NylonRingHostError::MissingSynchronousResponse),
550        }
551    }
552
553    /// Fire-and-forget call to a plugin entry point.
554    pub async fn call(&self, entry: &str, payload: &[u8]) -> Result<NrStatus> {
555        let _call_guard = self.plugin.begin_call()?;
556        // Use Fast SID
557        let sid = next_sid();
558
559        let payload_bytes = NrBytes::from_slice(payload);
560        let handle_raw_fn = match self.plugin.dispatch.handle {
561            Some(f) => f,
562            None => {
563                return Err(NylonRingHostError::MissingRequiredFunctions);
564            }
565        };
566
567        let status = unsafe { handle_raw_fn(NrStr::new(entry), sid, payload_bytes) };
568
569        // Fire-and-forget calls have no later response lifecycle in which to
570        // clean up extension state.
571        self.plugin.host_ctx.remove_state(sid);
572
573        if status != NrStatus::Ok {
574            return Err(Self::status_error(status));
575        }
576        Ok(status)
577    }
578
579    /// Call a plugin entry point with a streaming response pattern.
580    pub async fn call_stream(&self, entry: &str, payload: &[u8]) -> Result<(u64, StreamReceiver)> {
581        let call_guard = self.plugin.begin_owned_call()?;
582        let sid = next_sid();
583
584        let (tx, rx) = stream_channel::acquire(self.plugin.host_ctx.stream_capacity());
585
586        // Frames the plugin sends synchronously inside `handle` land in the
587        // channel through this slot without a per-frame map lookup.
588        let mut frame_slot = context::StreamFrameSlot {
589            sid,
590            chan: tx.channel(),
591            terminal_seen: false,
592        };
593
594        // Register the stream channel (Map)
595        context::insert_pending(&self.plugin.host_ctx, sid, types::Pending::Stream(tx));
596
597        let payload_bytes = NrBytes::from_slice(payload);
598
599        let handle_raw_fn = match self.plugin.dispatch.handle {
600            Some(f) => f,
601            None => {
602                context::cleanup_sid(&self.plugin.host_ctx, sid);
603                return Err(NylonRingHostError::MissingRequiredFunctions);
604            }
605        };
606
607        let binding = StreamSlotBinding::bind(&mut frame_slot);
608        let status = unsafe { handle_raw_fn(NrStr::new(entry), sid, payload_bytes) };
609        drop(binding);
610
611        if frame_slot.terminal_seen {
612            // The stream completed inside `handle`; drop the map entry (and
613            // with it the sender) so the receiver observes end-of-stream.
614            context::cleanup_sid(&self.plugin.host_ctx, sid);
615        }
616
617        if status != NrStatus::Ok {
618            context::cleanup_sid(&self.plugin.host_ctx, sid);
619            return Err(Self::status_error(status));
620        }
621
622        Ok((sid, StreamReceiver::new(rx, None, sid, Some(call_guard))))
623    }
624
625    /// Send data to an active stream.
626    pub fn send_stream_data(&self, sid: u64, data: &[u8]) -> Result<NrStatus> {
627        let stream_data_fn = match self.plugin.dispatch.stream_data {
628            Some(f) => f,
629            None => return Err(NylonRingHostError::MissingRequiredFunctions),
630        };
631        let payload = NrBytes::from_slice(data);
632        Ok(unsafe { stream_data_fn(sid, payload) })
633    }
634
635    /// Close an active stream from the host side.
636    pub fn close_stream(&self, sid: u64) -> Result<NrStatus> {
637        let stream_close_fn = match self.plugin.dispatch.stream_close {
638            Some(f) => f,
639            None => return Err(NylonRingHostError::MissingRequiredFunctions),
640        };
641        Ok(unsafe { stream_close_fn(sid) })
642    }
643
644    /// Resolve an entry name to a [`PluginEntry`] that dispatches by
645    /// integer id, skipping the per-call name comparison.
646    ///
647    /// The id stays valid for this loaded plugin instance; a handle created
648    /// before a reload keeps calling the instance it resolved against, the
649    /// same snapshot semantics as `PluginHandle` itself. Fails with
650    /// [`NylonRingHostError::EntryDispatchUnsupported`] for v1 plugins and
651    /// [`NylonRingHostError::EntryNotFound`] for names the plugin does not
652    /// export.
653    pub fn entry(&self, entry: &str) -> Result<PluginEntry> {
654        let (Some(resolve_fn), Some(_)) = (
655            self.plugin.dispatch.resolve_entry,
656            self.plugin.dispatch.handle_by_id,
657        ) else {
658            return Err(NylonRingHostError::EntryDispatchUnsupported);
659        };
660        // Resolution runs plugin code, so it counts as an in-flight call.
661        let _call_guard = self.plugin.begin_call()?;
662        let id = unsafe { resolve_fn(NrStr::new(entry)) };
663        if id == nylon_ring::NR_ENTRY_UNKNOWN {
664            return Err(NylonRingHostError::EntryNotFound(entry.to_string()));
665        }
666        Ok(PluginEntry {
667            plugin: self.plugin.clone(),
668            id,
669        })
670    }
671}
672
673/// A pre-resolved plugin entry point that dispatches by integer id
674///: the per-call entry-name comparison and `NrStr` construction
675/// are paid once, in [`PluginHandle::entry`], instead of on every call.
676#[derive(Clone)]
677pub struct PluginEntry {
678    plugin: Arc<LoadedPlugin>,
679    id: u32,
680}
681
682impl PluginEntry {
683    fn handle_by_id_fn(&self) -> Result<unsafe extern "C" fn(u32, u64, NrBytes) -> NrStatus> {
684        self.plugin
685            .dispatch
686            .handle_by_id
687            .ok_or(NylonRingHostError::EntryDispatchUnsupported)
688    }
689
690    /// Fire-and-forget call; see [`PluginHandle::call`].
691    pub async fn call(&self, payload: &[u8]) -> Result<NrStatus> {
692        let _call_guard = self.plugin.begin_call()?;
693        let sid = next_sid();
694
695        let handle_by_id_fn = self.handle_by_id_fn()?;
696        let status = unsafe { handle_by_id_fn(self.id, sid, NrBytes::from_slice(payload)) };
697
698        self.plugin.host_ctx.remove_state(sid);
699
700        if status != NrStatus::Ok {
701            return Err(PluginHandle::status_error(status));
702        }
703        Ok(status)
704    }
705
706    /// Request-response call; see [`PluginHandle::call_response`].
707    pub async fn call_response(&self, payload: &[u8]) -> Result<(NrStatus, Vec<u8>)> {
708        let _call_guard = self.plugin.begin_call()?;
709        let sid = next_sid();
710
711        context::insert_pending(
712            &self.plugin.host_ctx,
713            sid,
714            types::Pending::Unary(types::UnaryPending::waiting()),
715        );
716        let mut pending_guard = PendingGuard::new(&self.plugin.host_ctx, sid);
717
718        // Same synchronous-response slot as `PluginHandle::call_response`.
719        let mut slot = types::UnaryResultSlot {
720            sid,
721            result: None,
722            lease: None,
723        };
724        let binding = FastSlotBinding::bind(&mut slot).ok();
725
726        let handle_by_id_fn = self.handle_by_id_fn()?;
727        let status = unsafe { handle_by_id_fn(self.id, sid, NrBytes::from_slice(payload)) };
728
729        drop(binding);
730        if let Some(lease) = slot.lease.take() {
731            self.plugin.host_ctx.park_orphan_lease(lease);
732        }
733
734        if status != NrStatus::Ok {
735            return Err(PluginHandle::status_error(status));
736        }
737
738        if let Some((status, data)) = slot.result.take() {
739            return Ok((status, data));
740        }
741
742        match context::wait_for_unary(&self.plugin.host_ctx, sid).await {
743            Some((status, payload)) => {
744                pending_guard.disarm();
745                Ok((status, payload.into_vec()))
746            }
747            None => Err(NylonRingHostError::PluginUnloaded),
748        }
749    }
750
751    /// Synchronous fast-path call; see [`PluginHandle::call_response_fast`].
752    pub async fn call_response_fast(&self, payload: &[u8]) -> Result<(NrStatus, Vec<u8>)> {
753        let _call_guard = self.plugin.begin_call()?;
754        let sid = next_sid();
755        let mut slot = types::UnaryResultSlot {
756            sid,
757            result: None,
758            lease: None,
759        };
760
761        let binding = FastSlotBinding::bind(&mut slot)?;
762
763        let handle_by_id_fn = self.handle_by_id_fn()?;
764        let status = unsafe { handle_by_id_fn(self.id, sid, NrBytes::from_slice(payload)) };
765
766        drop(binding);
767        if let Some(lease) = slot.lease.take() {
768            self.plugin.host_ctx.park_orphan_lease(lease);
769        }
770        self.plugin.host_ctx.remove_state(sid);
771
772        if status != NrStatus::Ok {
773            return Err(PluginHandle::status_error(status));
774        }
775
776        match slot.result {
777            Some((st, data)) => Ok((st, data)),
778            None => Err(NylonRingHostError::MissingSynchronousResponse),
779        }
780    }
781
782    /// Streaming call; see [`PluginHandle::call_stream`].
783    pub async fn call_stream(&self, payload: &[u8]) -> Result<(u64, StreamReceiver)> {
784        let call_guard = self.plugin.begin_owned_call()?;
785        let sid = next_sid();
786
787        let (tx, rx) = stream_channel::acquire(self.plugin.host_ctx.stream_capacity());
788
789        // Same synchronous-frame slot as `PluginHandle::call_stream`.
790        let mut frame_slot = context::StreamFrameSlot {
791            sid,
792            chan: tx.channel(),
793            terminal_seen: false,
794        };
795
796        context::insert_pending(&self.plugin.host_ctx, sid, types::Pending::Stream(tx));
797
798        let handle_by_id_fn = match self.handle_by_id_fn() {
799            Ok(f) => f,
800            Err(error) => {
801                context::cleanup_sid(&self.plugin.host_ctx, sid);
802                return Err(error);
803            }
804        };
805        let binding = StreamSlotBinding::bind(&mut frame_slot);
806        let status = unsafe { handle_by_id_fn(self.id, sid, NrBytes::from_slice(payload)) };
807        drop(binding);
808
809        if frame_slot.terminal_seen {
810            context::cleanup_sid(&self.plugin.host_ctx, sid);
811        }
812
813        if status != NrStatus::Ok {
814            context::cleanup_sid(&self.plugin.host_ctx, sid);
815            return Err(PluginHandle::status_error(status));
816        }
817
818        Ok((sid, StreamReceiver::new(rx, None, sid, Some(call_guard))))
819    }
820}
821
822/// The main host for loading and managing nylon-ring plugins.
823pub struct NylonRingHost {
824    plugins: HashMap<String, Arc<LoadedPlugin>>,
825    host_ctx: Arc<HostContext>,
826    host_vtable: Box<NrHostVTable>,
827}
828
829impl Default for NylonRingHost {
830    fn default() -> Self {
831        Self::new()
832    }
833}
834
835impl Drop for NylonRingHost {
836    fn drop(&mut self) {
837        // Call guards hold raw plugin pointers, so the map's Arcs must be
838        // retired — never dropped directly — even when the host itself goes
839        // away while streams are still in flight.
840        for (_, plugin) in self.plugins.drain() {
841            retire_plugin(plugin);
842        }
843    }
844}
845
846impl NylonRingHost {
847    /// Create a new empty host.
848    pub fn new() -> Self {
849        Self::with_stream_capacity(DEFAULT_STREAM_CAPACITY)
850    }
851
852    /// Create a host with a bounded per-stream frame capacity.
853    pub fn with_stream_capacity(stream_capacity: usize) -> Self {
854        assert!(
855            stream_capacity > 0,
856            "stream capacity must be greater than zero"
857        );
858        let host_ctx = Arc::new(HostContext::new(
859            NrHostExt {
860                set_state: set_state_callback,
861                get_state: get_state_callback,
862            },
863            stream_capacity,
864        ));
865
866        let host_vtable = Box::new(NrHostVTable {
867            send_result: send_result_vec_callback,
868            send_result_owned: send_result_owned_callback,
869            acquire_result_buffer: acquire_result_buffer_callback,
870            commit_result_buffer: commit_result_buffer_callback,
871        });
872
873        Self {
874            plugins: HashMap::new(),
875            host_ctx,
876            host_vtable,
877        }
878    }
879
880    /// Load a plugin from the specified path with a given name.
881    ///
882    /// The plugin must export `nylon_ring_get_plugin` and match this host's
883    /// `ABI_VERSION` exactly.
884    pub fn load(&mut self, name: &str, path: &str) -> Result<()> {
885        self.load_inner(name, path, false)
886    }
887
888    /// Load a plugin that opts out of unload/reload for the life of the
889    /// process.
890    ///
891    /// Per-call guards on a pinned plugin skip the in-flight tracker RMWs
892    /// (the dominant per-call cost on short paths); in exchange the plugin
893    /// can never be unloaded, reloaded, or replaced, its `shutdown` callback
894    /// never runs, and its library handle is intentionally leaked at host
895    /// drop. Streams still take tracker slots.
896    pub fn load_pinned(&mut self, name: &str, path: &str) -> Result<()> {
897        self.load_inner(name, path, true)
898    }
899
900    fn load_inner(&mut self, name: &str, path: &str, pinned: bool) -> Result<()> {
901        if let Some(existing) = self.plugins.get(name)
902            && existing.pinned
903        {
904            return Err(NylonRingHostError::PluginPinned(name.to_owned()));
905        }
906        unsafe {
907            let lib = Library::new(path).map_err(NylonRingHostError::FailedToLoadLibrary)?;
908
909            let get_plugin: Symbol<extern "C" fn() -> *const NrPluginInfo> =
910                lib.get(b"nylon_ring_get_plugin\0").map_err(|_| {
911                    NylonRingHostError::MissingSymbol("nylon_ring_get_plugin".to_string())
912                })?;
913            let info_ptr = get_plugin();
914            Self::validate_plugin_info::<NrPluginInfo>(info_ptr.cast(), ABI_VERSION)?;
915            let info = &*info_ptr;
916            if info.vtable.is_null() {
917                return Err(NylonRingHostError::NullPluginVTable);
918            }
919            let plugin_vtable = &*info.vtable;
920            if plugin_vtable.init.is_none() || plugin_vtable.handle.is_none() {
921                return Err(NylonRingHostError::MissingRequiredFunctions);
922            }
923            if let Some(init_fn) = plugin_vtable.init {
924                let status = init_fn(
925                    Arc::as_ptr(&self.host_ctx) as *mut c_void,
926                    &*self.host_vtable,
927                );
928                if status != NrStatus::Ok {
929                    return Err(NylonRingHostError::PluginInitFailed(status));
930                }
931            }
932            let dispatch = PluginDispatch::from_vtable(plugin_vtable);
933
934            let loaded = LoadedPlugin {
935                _lib: std::mem::ManuallyDrop::new(lib),
936                dispatch,
937                host_ctx: self.host_ctx.clone(),
938                path: path.to_string(),
939                call_tracker: CallTracker::new(),
940                pinned,
941            };
942
943            if let Some(previous) = self.plugins.insert(name.to_string(), Arc::new(loaded)) {
944                retire_plugin(previous);
945            }
946            Ok(())
947        }
948    }
949
950    /// Validates the version-independent `abi_version`/`struct_size` header
951    /// shared by every plugin-info generation.
952    ///
953    /// # Safety
954    ///
955    /// `info_ptr` must be null or point to a struct starting with two `u32`
956    /// fields (`abi_version`, `struct_size`).
957    unsafe fn validate_plugin_info<T>(info_ptr: *const u32, expected_version: u32) -> Result<()> {
958        if info_ptr.is_null() {
959            return Err(NylonRingHostError::NullPluginInfo);
960        }
961        let abi_version = unsafe { std::ptr::read_unaligned(info_ptr) };
962        let struct_size = unsafe { std::ptr::read_unaligned(info_ptr.add(1)) };
963        if abi_version != expected_version {
964            return Err(NylonRingHostError::IncompatibleAbiVersion {
965                expected: expected_version,
966                actual: abi_version,
967            });
968        }
969        let expected_size = std::mem::size_of::<T>() as u32;
970        if struct_size < expected_size {
971            return Err(NylonRingHostError::IncompatiblePluginInfoSize {
972                expected: expected_size,
973                actual: struct_size,
974            });
975        }
976        Ok(())
977    }
978
979    /// Unload a plugin by name.
980    pub fn unload(&mut self, name: &str) -> Result<()> {
981        self.refuse_pinned(name)?;
982        let plugin = self
983            .plugins
984            .remove(name)
985            .ok_or_else(|| NylonRingHostError::PluginNotFound(name.to_owned()))?;
986        retire_plugin(plugin);
987        Ok(())
988    }
989
990    /// Pinned plugins hold untracked in-flight calls, so retiring them is
991    /// never provably safe; every unload/reload entry point refuses first.
992    fn refuse_pinned(&self, name: &str) -> Result<()> {
993        if let Some(plugin) = self.plugins.get(name)
994            && plugin.pinned
995        {
996            return Err(NylonRingHostError::PluginPinned(name.to_owned()));
997        }
998        Ok(())
999    }
1000
1001    /// Stop accepting new calls, then wait for tracked calls to drain.
1002    pub async fn unload_with_grace(&mut self, name: &str, grace: Duration) -> Result<()> {
1003        self.refuse_pinned(name)?;
1004        let plugin = self
1005            .plugins
1006            .remove(name)
1007            .ok_or_else(|| NylonRingHostError::PluginNotFound(name.to_owned()))?;
1008        // Retire before draining so the plugin stays registered for the
1009        // final sweep even if the drain times out.
1010        retire_plugin(plugin.clone());
1011        Self::wait_for_drain(&plugin, grace).await
1012    }
1013
1014    /// Reload all plugins.
1015    pub fn reload(&mut self) -> Result<()> {
1016        if let Some(name) = self
1017            .plugins
1018            .iter()
1019            .find_map(|(name, plugin)| plugin.pinned.then(|| name.clone()))
1020        {
1021            return Err(NylonRingHostError::PluginPinned(name));
1022        }
1023        let active_calls: usize = self
1024            .plugins
1025            .values()
1026            .map(|plugin| plugin.call_tracker.active_calls())
1027            .sum();
1028        if active_calls != 0 {
1029            return Err(NylonRingHostError::PluginBusy { active_calls });
1030        }
1031        let mut plugins_to_reload = Vec::new();
1032        for (name, plugin) in &self.plugins {
1033            plugins_to_reload.push((name.clone(), plugin.path.clone()));
1034        }
1035
1036        // Load new versions - insert() will atomically replace old ones
1037        // This ensures zero downtime (plugin() always returns a value)
1038        for (name, path) in plugins_to_reload {
1039            self.load(&name, &path)?;
1040        }
1041
1042        Ok(())
1043    }
1044
1045    /// Reload all plugins and wait for calls on replaced instances to drain.
1046    pub async fn reload_with_grace(&mut self, grace: Duration) -> Result<()> {
1047        if let Some(name) = self
1048            .plugins
1049            .iter()
1050            .find_map(|(name, plugin)| plugin.pinned.then(|| name.clone()))
1051        {
1052            return Err(NylonRingHostError::PluginPinned(name));
1053        }
1054        let old_plugins: Vec<_> = self
1055            .plugins
1056            .drain()
1057            .map(|(name, plugin)| {
1058                retire_plugin(plugin.clone());
1059                (name, plugin)
1060            })
1061            .collect();
1062        for (_, plugin) in &old_plugins {
1063            Self::wait_for_drain(plugin, grace).await?;
1064        }
1065        let plugins_to_reload: Vec<_> = old_plugins
1066            .iter()
1067            .map(|(name, plugin)| (name.clone(), plugin.path.clone()))
1068            .collect();
1069        drop(old_plugins);
1070        for (name, path) in plugins_to_reload {
1071            self.load(&name, &path)?;
1072        }
1073        Ok(())
1074    }
1075
1076    async fn wait_for_drain(plugin: &LoadedPlugin, grace: Duration) -> Result<()> {
1077        let deadline = tokio::time::Instant::now() + grace;
1078        while plugin.call_tracker.active_calls() != 0 {
1079            if tokio::time::Instant::now() >= deadline {
1080                return Err(NylonRingHostError::DrainTimeout {
1081                    remaining: plugin.call_tracker.active_calls(),
1082                });
1083            }
1084            tokio::time::sleep(Duration::from_millis(1)).await;
1085        }
1086        Ok(())
1087    }
1088
1089    /// Get a handle to a loaded plugin by name.
1090    pub fn plugin(&self, name: &str) -> Option<PluginHandle> {
1091        self.plugins
1092            .get(name)
1093            .map(|p| PluginHandle { plugin: p.clone() })
1094    }
1095
1096    /// Capture lightweight host metrics without allocating the shard map.
1097    pub fn metrics(&self) -> HostMetrics {
1098        HostMetrics {
1099            loaded_plugins: self.plugins.len(),
1100            pending_requests: self.host_ctx.pending_count(),
1101            state_sessions: self.host_ctx.state_count(),
1102            in_flight_calls: self
1103                .plugins
1104                .values()
1105                .map(|plugin| plugin.call_tracker.active_calls())
1106                .sum(),
1107        }
1108    }
1109
1110    /// Get host extension pointer from host_ctx.
1111    ///
1112    /// # Safety
1113    ///
1114    /// The caller must ensure that `host_ctx` is a valid pointer to a `HostContext`
1115    /// instance that was created by this host, or a null pointer.
1116    pub unsafe fn get_host_ext(host_ctx: *mut c_void) -> *const NrHostExt {
1117        if host_ctx.is_null() {
1118            return std::ptr::null();
1119        }
1120        let ctx = unsafe { &*host_ctx.cast::<HostContext>() };
1121        &ctx.host_ext as *const NrHostExt
1122    }
1123}
1124
1125#[cfg(test)]
1126mod tests {
1127    use super::*;
1128
1129    struct WakeFlag(Arc<std::sync::atomic::AtomicBool>);
1130
1131    impl std::task::Wake for WakeFlag {
1132        fn wake(self: Arc<Self>) {
1133            self.0.store(true, std::sync::atomic::Ordering::Release);
1134        }
1135
1136        fn wake_by_ref(self: &Arc<Self>) {
1137            self.0.store(true, std::sync::atomic::Ordering::Release);
1138        }
1139    }
1140
1141    struct ReentrantWakerState {
1142        host_ctx: Arc<HostContext>,
1143        clones: Arc<std::sync::atomic::AtomicUsize>,
1144        drops: Arc<std::sync::atomic::AtomicUsize>,
1145    }
1146
1147    unsafe fn clone_reentrant_waker(data: *const ()) -> std::task::RawWaker {
1148        let state = unsafe { Arc::<ReentrantWakerState>::from_raw(data.cast()) };
1149        state.host_ctx.pending_count();
1150        state
1151            .clones
1152            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1153        let clone = state.clone();
1154        let _ = Arc::into_raw(state);
1155        std::task::RawWaker::new(Arc::into_raw(clone).cast(), &REENTRANT_WAKER_VTABLE)
1156    }
1157
1158    unsafe fn wake_reentrant_waker(data: *const ()) {
1159        let state = unsafe { Arc::<ReentrantWakerState>::from_raw(data.cast()) };
1160        state.host_ctx.pending_count();
1161    }
1162
1163    unsafe fn wake_reentrant_waker_by_ref(data: *const ()) {
1164        let state = unsafe { Arc::<ReentrantWakerState>::from_raw(data.cast()) };
1165        state.host_ctx.pending_count();
1166        let _ = Arc::into_raw(state);
1167    }
1168
1169    unsafe fn drop_reentrant_waker(data: *const ()) {
1170        let state = unsafe { Arc::<ReentrantWakerState>::from_raw(data.cast()) };
1171        state.host_ctx.pending_count();
1172        state
1173            .drops
1174            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1175    }
1176
1177    static REENTRANT_WAKER_VTABLE: std::task::RawWakerVTable = std::task::RawWakerVTable::new(
1178        clone_reentrant_waker,
1179        wake_reentrant_waker,
1180        wake_reentrant_waker_by_ref,
1181        drop_reentrant_waker,
1182    );
1183
1184    fn reentrant_waker(
1185        host_ctx: Arc<HostContext>,
1186        clones: Arc<std::sync::atomic::AtomicUsize>,
1187        drops: Arc<std::sync::atomic::AtomicUsize>,
1188    ) -> std::task::Waker {
1189        let state = Arc::new(ReentrantWakerState {
1190            host_ctx,
1191            clones,
1192            drops,
1193        });
1194        let raw = std::task::RawWaker::new(Arc::into_raw(state).cast(), &REENTRANT_WAKER_VTABLE);
1195        unsafe { std::task::Waker::from_raw(raw) }
1196    }
1197
1198    /// Tests that load a real plugin library share one dlopen'd image (and
1199    /// therefore its process-global init state) — a concurrent test dropping
1200    /// its `LoadedPlugin` runs `shutdown` under everyone else's feet. Any
1201    /// test that loads a plugin must hold this lock. This was also the root
1202    /// cause of the historical `unload_defers_...` flake.
1203    fn plugin_test_lock() -> std::sync::MutexGuard<'static, ()> {
1204        static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1205        LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
1206    }
1207
1208    fn example_plugin_path() -> Option<std::path::PathBuf> {
1209        let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1210            .parent()?
1211            .parent()?;
1212        let manifest = workspace_root.join("examples/ex-nyring-plugin/Cargo.toml");
1213        if !manifest.is_file() {
1214            return None;
1215        }
1216        let status = std::process::Command::new("cargo")
1217            .args(["build", "--release", "--manifest-path"])
1218            .arg(&manifest)
1219            .status()
1220            .ok()?;
1221        if !status.success() {
1222            return None;
1223        }
1224        let filename = if cfg!(target_os = "macos") {
1225            "libex_nyring_plugin.dylib"
1226        } else if cfg!(target_os = "windows") {
1227            "ex_nyring_plugin.dll"
1228        } else {
1229            "libex_nyring_plugin.so"
1230        };
1231        Some(workspace_root.join("target/release").join(filename))
1232    }
1233
1234    fn workspace_root() -> Option<&'static std::path::Path> {
1235        std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1236            .parent()?
1237            .parent()
1238    }
1239
1240    fn dynlib_extension() -> &'static str {
1241        if cfg!(target_os = "macos") {
1242            "dylib"
1243        } else if cfg!(target_os = "windows") {
1244            "dll"
1245        } else {
1246            "so"
1247        }
1248    }
1249
1250    /// Prepares the output path for a foreign-language example plugin and
1251    /// runs its build command. A missing toolchain (spawn error) skips the
1252    /// test by returning `None`; a failing compile is a real regression and
1253    /// panics.
1254    fn build_foreign_plugin(
1255        out_dir: &str,
1256        lib_stem: &str,
1257        build: impl FnOnce(&std::path::Path, &std::path::Path) -> std::process::Command,
1258    ) -> Option<std::path::PathBuf> {
1259        let workspace_root = workspace_root()?;
1260        let output = workspace_root
1261            .join(out_dir)
1262            .join(format!("lib{lib_stem}.{}", dynlib_extension()));
1263        std::fs::create_dir_all(output.parent()?).ok()?;
1264        let Ok(status) = build(workspace_root, &output).status() else {
1265            return None; // toolchain not installed on this machine
1266        };
1267        assert!(status.success(), "{lib_stem} failed to compile");
1268        Some(output)
1269    }
1270
1271    fn c_plugin_path() -> Option<std::path::PathBuf> {
1272        build_foreign_plugin(
1273            "target/c-example",
1274            "nylon_ring_c_example",
1275            |root, output| {
1276                let mut command = std::process::Command::new("cc");
1277                command
1278                    .args(["-std=c11", "-Wall", "-Wextra", "-Werror", "-shared"])
1279                    .args((!cfg!(target_os = "windows")).then_some("-fPIC"))
1280                    .arg(root.join("examples/c-plugin/plugin.c"))
1281                    .arg("-o")
1282                    .arg(output);
1283                command
1284            },
1285        )
1286    }
1287
1288    fn cpp_plugin_path() -> Option<std::path::PathBuf> {
1289        build_foreign_plugin(
1290            "target/cpp-example",
1291            "nylon_ring_cpp_example",
1292            |root, output| {
1293                let mut command = std::process::Command::new("c++");
1294                command
1295                    .args(["-std=c++17", "-Wall", "-Wextra", "-Werror", "-shared"])
1296                    .args((!cfg!(target_os = "windows")).then_some("-fPIC"))
1297                    .arg(root.join("examples/cpp-plugin/plugin.cpp"))
1298                    .arg("-o")
1299                    .arg(output);
1300                command
1301            },
1302        )
1303    }
1304
1305    fn go_plugin_path() -> Option<std::path::PathBuf> {
1306        build_foreign_plugin(
1307            "target/go-example",
1308            "nylon_ring_go_example",
1309            |root, output| {
1310                let mut command = std::process::Command::new("go");
1311                command
1312                    .args(["build", "-buildmode=c-shared", "-o"])
1313                    .arg(output)
1314                    .arg(".")
1315                    .current_dir(root.join("examples/go-plugin"));
1316                command
1317            },
1318        )
1319    }
1320
1321    fn zig_plugin_path() -> Option<std::path::PathBuf> {
1322        build_foreign_plugin(
1323            "target/zig-example",
1324            "nylon_ring_zig_example",
1325            |root, output| {
1326                let mut command = std::process::Command::new("zig");
1327                command
1328                    .args(["build-lib", "-dynamic", "-O", "ReleaseFast"])
1329                    .arg(format!("-femit-bin={}", output.display()))
1330                    .arg(root.join("examples/zig-plugin/plugin.zig"));
1331                command
1332            },
1333        )
1334    }
1335
1336    fn trait_plugin_path() -> Option<std::path::PathBuf> {
1337        let workspace_root = workspace_root()?;
1338        let manifest = workspace_root.join("examples/ex-nyring-trait-plugin/Cargo.toml");
1339        if !manifest.is_file() {
1340            return None;
1341        }
1342        let status = std::process::Command::new("cargo")
1343            .args(["build", "--release", "--manifest-path"])
1344            .arg(&manifest)
1345            .status()
1346            .ok()?;
1347        assert!(status.success(), "trait example plugin failed to compile");
1348        let filename = if cfg!(target_os = "windows") {
1349            "ex_nyring_trait_plugin.dll".to_string()
1350        } else {
1351            format!("libex_nyring_trait_plugin.{}", dynlib_extension())
1352        };
1353        Some(workspace_root.join("target/release").join(filename))
1354    }
1355
1356    #[test]
1357    fn dropping_pending_guard_unregisters_unary_request() {
1358        let host = NylonRingHost::new();
1359        let sid = 42;
1360        context::insert_pending(
1361            &host.host_ctx,
1362            sid,
1363            types::Pending::Unary(types::UnaryPending::waiting()),
1364        );
1365        host.host_ctx.set_state(sid, "key".into(), vec![1]);
1366
1367        drop(PendingGuard::new(&host.host_ctx, sid));
1368
1369        assert!(context::remove_pending(&host.host_ctx, sid).is_none());
1370        assert!(!host.host_ctx.state_per_sid.contains_key(&sid));
1371    }
1372
1373    #[test]
1374    fn unary_completion_wakes_latest_waiter_across_threads() {
1375        let host = NylonRingHost::new();
1376        let sid = 46;
1377        context::insert_pending(
1378            &host.host_ctx,
1379            sid,
1380            types::Pending::Unary(types::UnaryPending::waiting()),
1381        );
1382        host.host_ctx.set_state(sid, "key".into(), vec![1]);
1383
1384        let first_woken = Arc::new(std::sync::atomic::AtomicBool::new(false));
1385        let second_woken = Arc::new(std::sync::atomic::AtomicBool::new(false));
1386        let first_waker = std::task::Waker::from(Arc::new(WakeFlag(first_woken.clone())));
1387        let second_waker = std::task::Waker::from(Arc::new(WakeFlag(second_woken.clone())));
1388        let mut future = Box::pin(context::wait_for_unary(&host.host_ctx, sid));
1389
1390        let mut first_context = std::task::Context::from_waker(&first_waker);
1391        assert!(matches!(
1392            std::future::Future::poll(future.as_mut(), &mut first_context),
1393            std::task::Poll::Pending
1394        ));
1395        let mut second_context = std::task::Context::from_waker(&second_waker);
1396        assert!(matches!(
1397            std::future::Future::poll(future.as_mut(), &mut second_context),
1398            std::task::Poll::Pending
1399        ));
1400
1401        let host_ctx = host.host_ctx.clone();
1402        let dispatch_status = std::thread::spawn(move || {
1403            context::dispatch_pending(
1404                &host_ctx,
1405                sid,
1406                NrStatus::Ok,
1407                types::ResponsePayload::Owned(vec![7]),
1408            )
1409        })
1410        .join()
1411        .unwrap();
1412        assert_eq!(dispatch_status, NrStatus::Ok);
1413        assert!(!first_woken.load(std::sync::atomic::Ordering::Acquire));
1414        assert!(second_woken.load(std::sync::atomic::Ordering::Acquire));
1415        assert!(!host.host_ctx.state_per_sid.contains_key(&sid));
1416        assert_eq!(host.metrics().pending_requests, 1);
1417
1418        assert_eq!(
1419            context::dispatch_pending(
1420                &host.host_ctx,
1421                sid,
1422                NrStatus::Ok,
1423                types::ResponsePayload::Owned(vec![8]),
1424            ),
1425            NrStatus::Invalid
1426        );
1427
1428        match std::future::Future::poll(future.as_mut(), &mut second_context) {
1429            std::task::Poll::Ready(Some((status, data))) => {
1430                assert_eq!(status, NrStatus::Ok);
1431                assert_eq!(data.as_slice(), [7]);
1432            }
1433            result => panic!("unexpected unary completion result: {result:?}"),
1434        }
1435        assert_eq!(host.metrics().pending_requests, 0);
1436    }
1437
1438    #[test]
1439    fn unary_waker_callbacks_run_outside_pending_lock() {
1440        let host = NylonRingHost::new();
1441        let sid = 47;
1442        context::insert_pending(
1443            &host.host_ctx,
1444            sid,
1445            types::Pending::Unary(types::UnaryPending::waiting()),
1446        );
1447
1448        let clones = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1449        let drops = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1450        let first_waker = reentrant_waker(host.host_ctx.clone(), clones.clone(), drops.clone());
1451        let mut future = Box::pin(context::wait_for_unary(&host.host_ctx, sid));
1452        {
1453            let mut context = std::task::Context::from_waker(&first_waker);
1454            assert!(matches!(
1455                std::future::Future::poll(future.as_mut(), &mut context),
1456                std::task::Poll::Pending
1457            ));
1458        }
1459        assert_eq!(clones.load(std::sync::atomic::Ordering::Relaxed), 1);
1460
1461        let second_woken = Arc::new(std::sync::atomic::AtomicBool::new(false));
1462        let second_waker = std::task::Waker::from(Arc::new(WakeFlag(second_woken)));
1463        let mut context = std::task::Context::from_waker(&second_waker);
1464        assert!(matches!(
1465            std::future::Future::poll(future.as_mut(), &mut context),
1466            std::task::Poll::Pending
1467        ));
1468        assert_eq!(drops.load(std::sync::atomic::Ordering::Relaxed), 1);
1469
1470        drop(future);
1471        context::cleanup_sid(&host.host_ctx, sid);
1472    }
1473
1474    #[test]
1475    fn dropping_stream_receiver_unregisters_stream() {
1476        let host = NylonRingHost::new();
1477        let sid = 43;
1478        let (tx, rx) = stream_channel::acquire(1);
1479        context::insert_pending(&host.host_ctx, sid, types::Pending::Stream(tx));
1480        host.host_ctx.set_state(sid, "key".into(), vec![1]);
1481
1482        drop(StreamReceiver::new(
1483            rx,
1484            Some(host.host_ctx.clone()),
1485            sid,
1486            None,
1487        ));
1488
1489        assert!(context::remove_pending(&host.host_ctx, sid).is_none());
1490        assert!(!host.host_ctx.state_per_sid.contains_key(&sid));
1491    }
1492
1493    #[test]
1494    fn fast_slot_reentry_is_rejected_and_binding_is_cleared() {
1495        let mut first = types::UnaryResultSlot {
1496            sid: 1,
1497            result: None,
1498            lease: None,
1499        };
1500        let mut second = types::UnaryResultSlot {
1501            sid: 2,
1502            result: None,
1503            lease: None,
1504        };
1505        let binding = FastSlotBinding::bind(&mut first).unwrap();
1506        assert!(matches!(
1507            FastSlotBinding::bind(&mut second),
1508            Err(NylonRingHostError::FastPathReentrant)
1509        ));
1510        drop(binding);
1511        assert!(FastSlotBinding::bind(&mut second).is_ok());
1512    }
1513
1514    #[test]
1515    fn bounded_stream_reports_backpressure_and_removes_terminal_once() {
1516        let host = NylonRingHost::with_stream_capacity(1);
1517        let sid = 44;
1518        let (tx, mut rx) = stream_channel::acquire(1);
1519        context::insert_pending(&host.host_ctx, sid, types::Pending::Stream(tx));
1520
1521        assert_eq!(
1522            context::dispatch_pending(
1523                &host.host_ctx,
1524                sid,
1525                NrStatus::Ok,
1526                types::ResponsePayload::Owned(vec![1]),
1527            ),
1528            NrStatus::Ok
1529        );
1530        assert_eq!(
1531            context::dispatch_pending(
1532                &host.host_ctx,
1533                sid,
1534                NrStatus::Ok,
1535                types::ResponsePayload::Owned(vec![2]),
1536            ),
1537            NrStatus::Backpressure
1538        );
1539        assert_eq!(rx.try_recv().unwrap().data, vec![1]);
1540        assert_eq!(
1541            context::dispatch_pending(
1542                &host.host_ctx,
1543                sid,
1544                NrStatus::StreamEnd,
1545                types::ResponsePayload::Owned(vec![]),
1546            ),
1547            NrStatus::Ok
1548        );
1549        assert_eq!(host.metrics().pending_requests, 0);
1550        assert_eq!(rx.try_recv().unwrap().status, NrStatus::StreamEnd);
1551    }
1552
1553    #[test]
1554    fn concurrent_terminal_frames_complete_stream_once() {
1555        let host = NylonRingHost::with_stream_capacity(2);
1556        let sid = 45;
1557        let (tx, mut rx) = stream_channel::acquire(2);
1558        context::insert_pending(&host.host_ctx, sid, types::Pending::Stream(tx));
1559        let barrier = Arc::new(std::sync::Barrier::new(3));
1560
1561        let results: Vec<_> = std::thread::scope(|scope| {
1562            let handles: Vec<_> = (0..2)
1563                .map(|value| {
1564                    let ctx = host.host_ctx.clone();
1565                    let barrier = barrier.clone();
1566                    scope.spawn(move || {
1567                        barrier.wait();
1568                        context::dispatch_pending(
1569                            &ctx,
1570                            sid,
1571                            NrStatus::StreamEnd,
1572                            types::ResponsePayload::Owned(vec![value]),
1573                        )
1574                    })
1575                })
1576                .collect();
1577            barrier.wait();
1578            handles
1579                .into_iter()
1580                .map(|handle| handle.join().unwrap())
1581                .collect()
1582        });
1583
1584        assert_eq!(
1585            results
1586                .iter()
1587                .filter(|&&status| status == NrStatus::Ok)
1588                .count(),
1589            1
1590        );
1591        assert_eq!(
1592            results
1593                .iter()
1594                .filter(|&&status| status == NrStatus::Invalid)
1595                .count(),
1596            1
1597        );
1598        assert!(rx.try_recv().is_some());
1599        assert!(rx.try_recv().is_none());
1600        assert_eq!(host.metrics().pending_requests, 0);
1601    }
1602
1603    #[test]
1604    fn unload_defers_library_drop_and_rejects_new_calls_on_existing_handle() {
1605        let _plugin_lock = plugin_test_lock();
1606        let Some(path) = example_plugin_path() else {
1607            return;
1608        };
1609        let mut host = NylonRingHost::new();
1610        host.load("example", path.to_str().unwrap()).unwrap();
1611        let handle = host.plugin("example").unwrap();
1612        let runtime = tokio::runtime::Runtime::new().unwrap();
1613        assert!(matches!(
1614            runtime.block_on(handle.call_response_timeout(
1615                "benchmark_without_response",
1616                b"",
1617                Duration::from_millis(1),
1618            )),
1619            Err(NylonRingHostError::Timeout)
1620        ));
1621        assert_eq!(host.metrics().pending_requests, 0);
1622
1623        let guard = handle.plugin.begin_call().unwrap();
1624        assert_eq!(host.metrics().in_flight_calls, 1);
1625
1626        host.unload("example").unwrap();
1627        assert!(matches!(
1628            runtime.block_on(handle.call("benchmark_without_response", b"")),
1629            Err(NylonRingHostError::PluginUnloaded)
1630        ));
1631        drop(guard);
1632        drop(handle);
1633    }
1634
1635    #[test]
1636    fn stream_receiver_outlives_unload_and_drains_frames() {
1637        let _plugin_lock = plugin_test_lock();
1638        let Some(path) = example_plugin_path() else {
1639            return;
1640        };
1641        let mut host = NylonRingHost::new();
1642        host.load("example", path.to_str().unwrap()).unwrap();
1643        let handle = host.plugin("example").unwrap();
1644        let runtime = tokio::runtime::Runtime::new().unwrap();
1645
1646        let (_sid, mut receiver) = runtime
1647            .block_on(handle.call_stream("benchmark_stream", b""))
1648            .unwrap();
1649        host.unload("example").unwrap();
1650        drop(handle);
1651
1652        // The receiver's call guard must keep the retired plugin alive:
1653        // draining all buffered frames exercises the guarded context path.
1654        let frames = runtime.block_on(async {
1655            let mut frames = 0u32;
1656            while receiver.recv().await.is_some() {
1657                frames += 1;
1658            }
1659            frames
1660        });
1661        assert_eq!(frames, 9);
1662        drop(receiver);
1663    }
1664
1665    #[test]
1666    fn stream_receiver_outlives_host_drop() {
1667        let _plugin_lock = plugin_test_lock();
1668        let Some(path) = example_plugin_path() else {
1669            return;
1670        };
1671        let mut host = NylonRingHost::new();
1672        host.load("example", path.to_str().unwrap()).unwrap();
1673        let handle = host.plugin("example").unwrap();
1674        let runtime = tokio::runtime::Runtime::new().unwrap();
1675
1676        let (_sid, mut receiver) = runtime
1677            .block_on(handle.call_stream("benchmark_stream", b""))
1678            .unwrap();
1679        drop(host);
1680        drop(handle);
1681
1682        let frames = runtime.block_on(async {
1683            let mut frames = 0u32;
1684            while receiver.recv().await.is_some() {
1685                frames += 1;
1686            }
1687            frames
1688        });
1689        assert_eq!(frames, 9);
1690    }
1691
1692    #[test]
1693    fn owned_response_round_trips_and_releases_exactly_once() {
1694        let _plugin_lock = plugin_test_lock();
1695        let Some(path) = example_plugin_path() else {
1696            return;
1697        };
1698        let mut host = NylonRingHost::new();
1699        host.load("example", path.to_str().unwrap()).unwrap();
1700        let handle = host.plugin("example").unwrap();
1701        let runtime = tokio::runtime::Runtime::new().unwrap();
1702
1703        async fn release_count(handle: &PluginHandle) -> u64 {
1704            let (status, data) = handle
1705                .call_response("owned_release_count", b"")
1706                .await
1707                .unwrap();
1708            assert_eq!(status, NrStatus::Ok);
1709            u64::from_le_bytes(data.try_into().unwrap())
1710        }
1711
1712        runtime.block_on(async {
1713            // Zero-copy static response: correct bytes, nothing to release.
1714            let (status, data) = handle
1715                .call_response_bytes("benchmark_owned", &[0u8; 128])
1716                .await
1717                .unwrap();
1718            assert_eq!(status, NrStatus::Ok);
1719            assert_eq!(data.len(), 128);
1720            assert!(data.iter().all(|&byte| byte == 42));
1721
1722            let before = release_count(&handle).await;
1723
1724            // Held view: released only when dropped, not on receipt.
1725            let (status, held) = handle
1726                .call_response_bytes("echo_owned", b"held")
1727                .await
1728                .unwrap();
1729            assert_eq!(status, NrStatus::Ok);
1730            assert_eq!(&*held, b"held");
1731            assert_eq!(release_count(&handle).await, before);
1732            drop(held);
1733            assert_eq!(release_count(&handle).await, before + 1);
1734
1735            // Vec conversion path copies and releases immediately.
1736            let (status, data) = handle.call_response("echo_owned", b"copied").await.unwrap();
1737            assert_eq!(status, NrStatus::Ok);
1738            assert_eq!(data, b"copied");
1739            assert_eq!(release_count(&handle).await, before + 2);
1740
1741            // into_vec on the view also releases exactly once.
1742            let (_, view) = handle
1743                .call_response_bytes("echo_owned", b"vec")
1744                .await
1745                .unwrap();
1746            assert_eq!(view.into_vec(), b"vec");
1747            assert_eq!(release_count(&handle).await, before + 3);
1748
1749            // Fast path consumes owned responses through the slot copy.
1750            let (status, data) = handle
1751                .call_response_fast("echo_owned", b"fast")
1752                .await
1753                .unwrap();
1754            assert_eq!(status, NrStatus::Ok);
1755            assert_eq!(data, b"fast");
1756            assert_eq!(release_count(&handle).await, before + 4);
1757        });
1758    }
1759
1760    #[test]
1761    fn owned_response_keeps_plugin_alive_after_unload() {
1762        let _plugin_lock = plugin_test_lock();
1763        let Some(path) = example_plugin_path() else {
1764            return;
1765        };
1766        let mut host = NylonRingHost::new();
1767        host.load("example", path.to_str().unwrap()).unwrap();
1768        let handle = host.plugin("example").unwrap();
1769        let runtime = tokio::runtime::Runtime::new().unwrap();
1770
1771        let (status, response) = runtime
1772            .block_on(handle.call_response_bytes("benchmark_owned", &[0u8; 64]))
1773            .unwrap();
1774        assert_eq!(status, NrStatus::Ok);
1775
1776        // The response borrows memory inside the plugin image; its guard
1777        // must defer the library unload until the view drops.
1778        host.unload("example").unwrap();
1779        drop(handle);
1780        drop(host);
1781        assert_eq!(response.len(), 64);
1782        assert!(response.iter().all(|&byte| byte == 42));
1783        drop(response);
1784    }
1785
1786    #[test]
1787    fn lease_round_trips_and_rejects_misuse() {
1788        let _plugin_lock = plugin_test_lock();
1789        let Some(path) = example_plugin_path() else {
1790            return;
1791        };
1792        let mut host = NylonRingHost::new();
1793        host.load("example", path.to_str().unwrap()).unwrap();
1794        let handle = host.plugin("example").unwrap();
1795        let runtime = tokio::runtime::Runtime::new().unwrap();
1796
1797        runtime.block_on(async {
1798            // Standard unary path: the response Vec is the leased buffer.
1799            let (status, data) = handle
1800                .call_response("echo_lease", b"through the lease")
1801                .await
1802                .unwrap();
1803            assert_eq!(status, NrStatus::Ok);
1804            assert_eq!(data, b"through the lease");
1805
1806            // Empty payloads commit a zero-length lease.
1807            let (status, data) = handle.call_response("echo_lease", b"").await.unwrap();
1808            assert_eq!(status, NrStatus::Ok);
1809            assert!(data.is_empty());
1810
1811            // Fast path: the lease lives in the TLS slot.
1812            let (status, data) = handle
1813                .call_response_fast("echo_lease", b"fast lease")
1814                .await
1815                .unwrap();
1816            assert_eq!(status, NrStatus::Ok);
1817            assert_eq!(data, b"fast lease");
1818
1819            // The misuse handler asserts double acquire, bad token, bad
1820            // length, and double commit all report Invalid; an assertion
1821            // failure would surface here as PluginPanicked.
1822            let (status, data) = handle
1823                .call_response("lease_misuse", b"contract")
1824                .await
1825                .unwrap();
1826            assert_eq!(status, NrStatus::Ok);
1827            assert_eq!(data, b"contract");
1828        });
1829
1830        // Every lease was committed; nothing should be parked or pending.
1831        assert_eq!(host.host_ctx.orphaned_lease_count(), 0);
1832        assert_eq!(host.host_ctx.pending_count(), 0);
1833    }
1834
1835    #[test]
1836    fn abandoned_leases_are_parked_not_freed() {
1837        let _plugin_lock = plugin_test_lock();
1838        let Some(path) = example_plugin_path() else {
1839            return;
1840        };
1841        let mut host = NylonRingHost::new();
1842        host.load("example", path.to_str().unwrap()).unwrap();
1843        let handle = host.plugin("example").unwrap();
1844        let runtime = tokio::runtime::Runtime::new().unwrap();
1845
1846        runtime.block_on(async {
1847            // The plugin acquires a lease and never commits: the call times
1848            // out and cleanup must park the lease (the plugin could still
1849            // hold the pointer), not free it.
1850            let result = handle
1851                .call_response_timeout("lease_abandon", b"", std::time::Duration::from_millis(50))
1852                .await;
1853            assert!(matches!(result, Err(NylonRingHostError::Timeout)));
1854            assert_eq!(host.host_ctx.orphaned_lease_count(), 1);
1855            assert_eq!(host.host_ctx.pending_count(), 0);
1856
1857            // Fast path: an uncommitted lease in the TLS slot is parked too.
1858            let result = handle.call_response_fast("lease_abandon", b"").await;
1859            assert!(matches!(
1860                result,
1861                Err(NylonRingHostError::MissingSynchronousResponse)
1862            ));
1863            assert_eq!(host.host_ctx.orphaned_lease_count(), 2);
1864        });
1865    }
1866
1867    #[test]
1868    fn example_plugin_fire_and_forget_entry_returns_ok() {
1869        let _plugin_lock = plugin_test_lock();
1870        let Some(path) = example_plugin_path() else {
1871            return;
1872        };
1873        let mut host = NylonRingHost::new();
1874        host.load("example", path.to_str().unwrap()).unwrap();
1875        let handle = host.plugin("example").unwrap();
1876        let runtime = tokio::runtime::Runtime::new().unwrap();
1877
1878        assert!(matches!(
1879            runtime.block_on(handle.call("notify", b"fire and forget")),
1880            Ok(NrStatus::Ok)
1881        ));
1882    }
1883
1884    #[test]
1885    fn c_plugin_layout_round_trips_through_host() {
1886        let _plugin_lock = plugin_test_lock();
1887        let Some(path) = c_plugin_path() else {
1888            return;
1889        };
1890        let mut host = NylonRingHost::new();
1891        host.load("c-example", path.to_str().unwrap()).unwrap();
1892        let handle = host.plugin("c-example").unwrap();
1893        let runtime = tokio::runtime::Runtime::new().unwrap();
1894        let (status, response) = runtime
1895            .block_on(handle.call_response("echo", b"from-c"))
1896            .unwrap();
1897        assert_eq!(status, NrStatus::Ok);
1898        assert_eq!(response, b"from-c");
1899
1900        // Integer entry dispatch is optional; this plugin leaves both
1901        // fields NULL, which must surface as a typed error.
1902        assert!(matches!(
1903            handle.entry("echo"),
1904            Err(NylonRingHostError::EntryDispatchUnsupported)
1905        ));
1906    }
1907
1908    #[test]
1909    fn cpp_plugin_layout_round_trips_through_host() {
1910        let _plugin_lock = plugin_test_lock();
1911        let Some(path) = cpp_plugin_path() else {
1912            return;
1913        };
1914        let mut host = NylonRingHost::new();
1915        host.load("cpp-example", path.to_str().unwrap()).unwrap();
1916        let handle = host.plugin("cpp-example").unwrap();
1917        let runtime = tokio::runtime::Runtime::new().unwrap();
1918        let (status, response) = runtime
1919            .block_on(handle.call_response("echo", b"from-cpp"))
1920            .unwrap();
1921        assert_eq!(status, NrStatus::Ok);
1922        assert_eq!(response, b"from-cpp");
1923    }
1924
1925    #[test]
1926    fn go_plugin_round_trips_through_pinned_host() {
1927        let _plugin_lock = plugin_test_lock();
1928        let Some(path) = go_plugin_path() else {
1929            return;
1930        };
1931        // The Go runtime cannot be dlclosed, so a regular load would crash
1932        // at unload or host drop; load_pinned never unloads and leaks the
1933        // library handle instead, which is the only safe lifecycle here.
1934        let mut host = NylonRingHost::new();
1935        host.load_pinned("go-example", path.to_str().unwrap())
1936            .unwrap();
1937        let handle = host.plugin("go-example").unwrap();
1938        let runtime = tokio::runtime::Runtime::new().unwrap();
1939        let (status, response) = runtime
1940            .block_on(handle.call_response("echo", b"from-go"))
1941            .unwrap();
1942        assert_eq!(status, NrStatus::Ok);
1943        assert_eq!(response, b"from-go");
1944    }
1945
1946    #[test]
1947    fn zig_plugin_layout_round_trips_through_host() {
1948        let _plugin_lock = plugin_test_lock();
1949        let Some(path) = zig_plugin_path() else {
1950            return;
1951        };
1952        let mut host = NylonRingHost::new();
1953        host.load("zig-example", path.to_str().unwrap()).unwrap();
1954        let handle = host.plugin("zig-example").unwrap();
1955        let runtime = tokio::runtime::Runtime::new().unwrap();
1956        let (status, response) = runtime
1957            .block_on(handle.call_response("echo", b"from-zig"))
1958            .unwrap();
1959        assert_eq!(status, NrStatus::Ok);
1960        assert_eq!(response, b"from-zig");
1961    }
1962
1963    #[test]
1964    fn trait_plugin_round_trips_through_host() {
1965        let _plugin_lock = plugin_test_lock();
1966        let Some(path) = trait_plugin_path() else {
1967            return;
1968        };
1969        let mut host = NylonRingHost::new();
1970        host.load("trait-example", path.to_str().unwrap()).unwrap();
1971        let handle = host.plugin("trait-example").unwrap();
1972        let runtime = tokio::runtime::Runtime::new().unwrap();
1973
1974        // Unary echo through the trait glue.
1975        let (status, response) = runtime
1976            .block_on(handle.call_response("echo", b"hi"))
1977            .unwrap();
1978        assert_eq!(status, NrStatus::Ok);
1979        assert_eq!(response, b"hi");
1980
1981        // Per-call ctx drives the shout transformation.
1982        let (status, response) = runtime
1983            .block_on(handle.call_response("shout", b"hello"))
1984            .unwrap();
1985        assert_eq!(status, NrStatus::Ok);
1986        assert_eq!(response, b"HELLO");
1987
1988        // Fire-and-forget entry answers Ok without a payload.
1989        let status = runtime.block_on(handle.call("notify", b"ping")).unwrap();
1990        assert_eq!(status, NrStatus::Ok);
1991
1992        // Shared state on the plugin struct counts every call so far,
1993        // including this one: echo + shout + notify + count = 4.
1994        let (status, response) = runtime
1995            .block_on(handle.call_response("count", b""))
1996            .unwrap();
1997        assert_eq!(status, NrStatus::Ok);
1998        assert_eq!(response, 4u64.to_le_bytes());
1999
2000        // Integer entry dispatch comes from Plugin::ENTRIES.
2001        let echo = handle.entry("echo").unwrap();
2002        let (status, response) = runtime.block_on(echo.call_response(b"by-id")).unwrap();
2003        assert_eq!(status, NrStatus::Ok);
2004        assert_eq!(response, b"by-id");
2005        assert!(matches!(
2006            handle.entry("missing"),
2007            Err(NylonRingHostError::EntryNotFound(_))
2008        ));
2009
2010        // Async entries defer the reply: handle() returns Ok immediately
2011        // and the future answers through the cross-thread async path.
2012        let (status, response) = runtime
2013            .block_on(handle.call_response("async_echo", b"later"))
2014            .unwrap();
2015        assert_eq!(status, NrStatus::Ok);
2016        assert_eq!(response, b"later");
2017
2018        let (status, response) = runtime
2019            .block_on(handle.call_response("async_delay", b"after-sleep"))
2020            .unwrap();
2021        assert_eq!(status, NrStatus::Ok);
2022        assert_eq!(response, b"after-sleep");
2023
2024        // Async entries resolve to ids after the sync ones.
2025        let async_echo = handle.entry("async_echo").unwrap();
2026        let (status, response) = runtime
2027            .block_on(async_echo.call_response(b"by-id"))
2028            .unwrap();
2029        assert_eq!(status, NrStatus::Ok);
2030        assert_eq!(response, b"by-id");
2031
2032        // Streaming through Session::send_frame / end_stream.
2033        let (_sid, mut rx) = runtime
2034            .block_on(handle.call_stream("stream", b"start"))
2035            .unwrap();
2036        let mut frames = Vec::new();
2037        runtime.block_on(async {
2038            while let Some(frame) = rx.recv().await {
2039                let terminal = frame.status == NrStatus::StreamEnd;
2040                frames.push((frame.status, frame.data));
2041                if terminal {
2042                    break;
2043                }
2044            }
2045        });
2046        assert_eq!(frames.len(), 4);
2047        assert_eq!(frames[0], (NrStatus::Ok, b"frame 1".to_vec()));
2048        assert_eq!(frames[3], (NrStatus::StreamEnd, b"done".to_vec()));
2049    }
2050
2051    #[test]
2052    fn entry_id_dispatch_round_trips_all_call_shapes() {
2053        let _plugin_lock = plugin_test_lock();
2054        let Some(path) = example_plugin_path() else {
2055            return;
2056        };
2057        let mut host = NylonRingHost::new();
2058        host.load("example", path.to_str().unwrap()).unwrap();
2059        let handle = host.plugin("example").unwrap();
2060        let runtime = tokio::runtime::Runtime::new().unwrap();
2061
2062        assert!(matches!(
2063            handle.entry("no_such_entry"),
2064            Err(NylonRingHostError::EntryNotFound(_))
2065        ));
2066
2067        let echo = handle.entry("benchmark").unwrap();
2068        let notify = handle.entry("benchmark_without_response").unwrap();
2069        let stream = handle.entry("benchmark_stream").unwrap();
2070
2071        runtime.block_on(async {
2072            let (status, data) = echo.call_response(b"by-id").await.unwrap();
2073            assert_eq!(status, NrStatus::Ok);
2074            assert_eq!(data, b"by-id");
2075
2076            let (status, data) = echo.call_response_fast(b"fast-by-id").await.unwrap();
2077            assert_eq!(status, NrStatus::Ok);
2078            assert_eq!(data, b"fast-by-id");
2079
2080            assert_eq!(notify.call(b"").await.unwrap(), NrStatus::Ok);
2081
2082            let (_sid, mut receiver) = stream.call_stream(b"").await.unwrap();
2083            let mut frames = 0u32;
2084            while receiver.recv().await.is_some() {
2085                frames += 1;
2086            }
2087            assert_eq!(frames, 9);
2088        });
2089    }
2090
2091    #[test]
2092    fn pinned_plugin_calls_and_refuses_unload() {
2093        let _guard = plugin_test_lock();
2094        let Some(path) = example_plugin_path() else {
2095            return;
2096        };
2097        let runtime = tokio::runtime::Builder::new_current_thread()
2098            .enable_time()
2099            .build()
2100            .unwrap();
2101        let mut host = NylonRingHost::new();
2102        host.load_pinned("pinned", path.to_str().unwrap()).unwrap();
2103        let handle = host.plugin("pinned").unwrap();
2104        runtime.block_on(async {
2105            let (status, data) = handle
2106                .call_response_fast("benchmark", b"pin")
2107                .await
2108                .unwrap();
2109            assert_eq!(status, NrStatus::Ok);
2110            assert_eq!(data, b"pin");
2111            let (status, data) = handle.call_response("benchmark", b"pin2").await.unwrap();
2112            assert_eq!(status, NrStatus::Ok);
2113            assert_eq!(data, b"pin2");
2114            // Streams still take real tracker slots on a pinned plugin.
2115            let (_sid, mut receiver) = handle.call_stream("stream", b"").await.unwrap();
2116            let mut frames = 0u32;
2117            while receiver.recv().await.is_some() {
2118                frames += 1;
2119            }
2120            assert!(frames > 0);
2121        });
2122        assert_eq!(handle.plugin.call_tracker.active_calls(), 0);
2123        assert!(matches!(
2124            host.unload("pinned"),
2125            Err(NylonRingHostError::PluginPinned(_))
2126        ));
2127        assert!(matches!(
2128            host.reload(),
2129            Err(NylonRingHostError::PluginPinned(_))
2130        ));
2131        assert!(matches!(
2132            host.load("pinned", path.to_str().unwrap()),
2133            Err(NylonRingHostError::PluginPinned(_))
2134        ));
2135        runtime.block_on(async {
2136            assert!(matches!(
2137                host.unload_with_grace("pinned", Duration::from_millis(10))
2138                    .await,
2139                Err(NylonRingHostError::PluginPinned(_))
2140            ));
2141            assert!(matches!(
2142                host.reload_with_grace(Duration::from_millis(10)).await,
2143                Err(NylonRingHostError::PluginPinned(_))
2144            ));
2145        });
2146    }
2147
2148    /// Manual cycle-budget probe for the single-worker call paths. Run with:
2149    /// `cargo test --release -p nylon-ring-host --lib cycle_budget_probe -- --ignored --nocapture`
2150    #[test]
2151    #[ignore = "manual probe; needs --release for meaningful numbers"]
2152    fn cycle_budget_probe() {
2153        use std::future::Future;
2154        use std::hint::black_box;
2155        use std::task::{Context, Poll, Waker};
2156
2157        let _guard = plugin_test_lock();
2158        let path = example_plugin_path().expect("example plugin must build");
2159        let mut host = NylonRingHost::new();
2160        host.load("bench", path.to_str().unwrap()).unwrap();
2161        let handle = host.plugin("bench").unwrap();
2162        let fire_entry = handle.entry("benchmark_without_response").unwrap();
2163        let fast_entry = handle.entry("benchmark").unwrap();
2164
2165        const N: u64 = 20_000_000;
2166
2167        fn time_ns(iters: u64, mut f: impl FnMut()) -> f64 {
2168            for _ in 0..iters / 10 {
2169                f();
2170            }
2171            let start = std::time::Instant::now();
2172            for _ in 0..iters {
2173                f();
2174            }
2175            start.elapsed().as_nanos() as f64 / iters as f64
2176        }
2177
2178        // A 16-deep dependent add chain retires at 1/cycle on Apple Silicon:
2179        // measures the actual clock so cycle numbers below don't assume
2180        // nominal boost. The loop lives inside one asm block so the chain
2181        // never round-trips through memory; the loop counter's subs/branch
2182        // overlap with the chain and add no cycles.
2183        let calibrate = |iters: u64| -> f64 {
2184            // Only the aarch64 asm below mutates `x`; other targets compile
2185            // this probe (it is #[ignore]d) without running it.
2186            #[cfg_attr(not(target_arch = "aarch64"), allow(unused_mut))]
2187            let mut x: u64 = black_box(0);
2188            let start = std::time::Instant::now();
2189            #[cfg(target_arch = "aarch64")]
2190            unsafe {
2191                let mut n = iters;
2192                std::arch::asm!(
2193                    "2:",
2194                    "add {x}, {x}, #1", "add {x}, {x}, #1", "add {x}, {x}, #1",
2195                    "add {x}, {x}, #1", "add {x}, {x}, #1", "add {x}, {x}, #1",
2196                    "add {x}, {x}, #1", "add {x}, {x}, #1", "add {x}, {x}, #1",
2197                    "add {x}, {x}, #1", "add {x}, {x}, #1", "add {x}, {x}, #1",
2198                    "add {x}, {x}, #1", "add {x}, {x}, #1", "add {x}, {x}, #1",
2199                    "add {x}, {x}, #1",
2200                    "subs {n}, {n}, #1",
2201                    "b.ne 2b",
2202                    x = inout(reg) x,
2203                    n = inout(reg) n,
2204                );
2205                black_box(n);
2206            }
2207            black_box(x);
2208            start.elapsed().as_nanos() as f64 / iters as f64
2209        };
2210        calibrate(200_000_000); // DVFS ramp warmup
2211        let calib_ns = calibrate(100_000_000).min(calibrate(100_000_000));
2212        let ghz = 16.0 / calib_ns;
2213        println!("clock calibration: {calib_ns:.2} ns / 16 adds -> {ghz:.3} GHz");
2214
2215        let cyc = |ns: f64| ns * ghz;
2216        let report = |name: &str, ns: f64| {
2217            println!("{name:<28} {ns:>7.2} ns  {:>6.1} cycles", cyc(ns));
2218        };
2219
2220        let plugin = &fire_entry.plugin;
2221        let handle_by_id_fn = plugin.dispatch.handle_by_id.unwrap();
2222        let fire_id = fire_entry.id;
2223
2224        report(
2225            "component: tracker begin+fin",
2226            time_ns(N, || {
2227                let shard = plugin.call_tracker.try_begin().unwrap();
2228                let _ = black_box(plugin.call_tracker.finish(shard));
2229            }),
2230        );
2231
2232        // Atomic microbenches mimicking the tracker's shard word, on a
2233        // dedicated 128-byte-aligned line, to separate instruction choice
2234        // (CAS loop vs single fetch_add) from ordering strength.
2235        #[repr(align(128))]
2236        struct PaddedCounter(std::sync::atomic::AtomicUsize);
2237        use std::sync::atomic::Ordering as AtomOrd;
2238        let word = PaddedCounter(std::sync::atomic::AtomicUsize::new(0));
2239        report(
2240            "atomics: casa loop + ldaddl",
2241            time_ns(N, || {
2242                let _ = black_box(
2243                    word.0
2244                        .fetch_update(AtomOrd::Acquire, AtomOrd::Relaxed, |v| Some(v + 1)),
2245                );
2246                black_box(word.0.fetch_sub(1, AtomOrd::Release));
2247            }),
2248        );
2249        report(
2250            "atomics: relaxed cas + sub",
2251            time_ns(N, || {
2252                let _ = black_box(
2253                    word.0
2254                        .fetch_update(AtomOrd::Relaxed, AtomOrd::Relaxed, |v| Some(v + 1)),
2255                );
2256                black_box(word.0.fetch_sub(1, AtomOrd::Relaxed));
2257            }),
2258        );
2259        report(
2260            "atomics: ldadda + ldaddl",
2261            time_ns(N, || {
2262                black_box(word.0.fetch_add(1, AtomOrd::Acquire));
2263                black_box(word.0.fetch_sub(1, AtomOrd::Release));
2264            }),
2265        );
2266        report(
2267            "atomics: single ldadda",
2268            time_ns(N, || {
2269                black_box(word.0.fetch_add(1, AtomOrd::Acquire));
2270            }),
2271        );
2272        report(
2273            "atomics: relaxed cas + ldaddl",
2274            time_ns(N, || {
2275                let _ = black_box(
2276                    word.0
2277                        .fetch_update(AtomOrd::Relaxed, AtomOrd::Relaxed, |v| Some(v + 1)),
2278                );
2279                black_box(word.0.fetch_sub(1, AtomOrd::Release));
2280            }),
2281        );
2282
2283        // B diagnostic: the same begin/finish pair with 8 threads, each on
2284        // its own thread-shard. The tracker is already sharded per thread,
2285        // so per-op cost should stay ~1x the single-thread figure.
2286        {
2287            let tracker = std::sync::Arc::new(crate::call_tracker::CallTracker::new());
2288            let per_thread: u64 = 4_000_000;
2289            let start = std::time::Instant::now();
2290            let threads: Vec<_> = (0..8)
2291                .map(|_| {
2292                    let tracker = tracker.clone();
2293                    std::thread::spawn(move || {
2294                        for _ in 0..per_thread {
2295                            let shard = tracker.try_begin().unwrap();
2296                            let _ = black_box(tracker.finish(shard));
2297                        }
2298                    })
2299                })
2300                .collect();
2301            for t in threads {
2302                t.join().unwrap();
2303            }
2304            let ns = start.elapsed().as_nanos() as f64 / per_thread as f64;
2305            report("tracker pair, 8 threads", ns);
2306        }
2307        report(
2308            "component: next_sid",
2309            time_ns(N, || {
2310                black_box(sid::next_sid());
2311            }),
2312        );
2313        report(
2314            "component: remove_state",
2315            time_ns(N, || {
2316                plugin.host_ctx.remove_state(black_box(1));
2317            }),
2318        );
2319        report(
2320            "raw FFI handle_by_id (fire)",
2321            time_ns(N, || {
2322                let status = unsafe {
2323                    handle_by_id_fn(black_box(fire_id), 1, NrBytes::from_slice(black_box(b"")))
2324                };
2325                assert_eq!(status, NrStatus::Ok);
2326            }),
2327        );
2328
2329        let mut cx = Context::from_waker(Waker::noop());
2330        macro_rules! poll_ready {
2331            ($fut:expr) => {{
2332                let fut = $fut;
2333                let mut fut = std::pin::pin!(fut);
2334                match fut.as_mut().poll(&mut cx) {
2335                    Poll::Ready(result) => result.unwrap(),
2336                    Poll::Pending => panic!("probe future must complete synchronously"),
2337                }
2338            }};
2339        }
2340
2341        report(
2342            "full fire (entry id)",
2343            time_ns(N, || {
2344                poll_ready!(fire_entry.call(black_box(b"")));
2345            }),
2346        );
2347        report(
2348            "full fire (by name)",
2349            time_ns(N, || {
2350                poll_ready!(handle.call("benchmark_without_response", black_box(b"")));
2351            }),
2352        );
2353        report(
2354            "full fast (entry id)",
2355            time_ns(N, || {
2356                black_box(poll_ready!(fast_entry.call_response_fast(black_box(b""))));
2357            }),
2358        );
2359        report(
2360            "full fast (by name)",
2361            time_ns(N, || {
2362                black_box(poll_ready!(
2363                    handle.call_response_fast("benchmark", black_box(b""))
2364                ));
2365            }),
2366        );
2367        report(
2368            "full unary (entry id)",
2369            time_ns(N, || {
2370                black_box(poll_ready!(fast_entry.call_response(black_box(b""))));
2371            }),
2372        );
2373        report(
2374            "full unary (by name)",
2375            time_ns(N, || {
2376                black_box(poll_ready!(
2377                    handle.call_response("benchmark", black_box(b""))
2378                ));
2379            }),
2380        );
2381    }
2382}