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 c_plugin_path() -> Option<std::path::PathBuf> {
1235        let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1236            .parent()?
1237            .parent()?;
1238        let source = workspace_root.join("examples/c-plugin/plugin.c");
1239        if !source.is_file() {
1240            return None;
1241        }
1242        let extension = if cfg!(target_os = "macos") {
1243            "dylib"
1244        } else if cfg!(target_os = "windows") {
1245            "dll"
1246        } else {
1247            "so"
1248        };
1249        let output = workspace_root
1250            .join("target/c-example")
1251            .join(format!("libnylon_ring_c_example.{extension}"));
1252        std::fs::create_dir_all(output.parent()?).ok()?;
1253        let status = std::process::Command::new("cc")
1254            .args(["-std=c11", "-Wall", "-Wextra", "-Werror", "-shared"])
1255            .args((!cfg!(target_os = "windows")).then_some("-fPIC"))
1256            .arg(&source)
1257            .arg("-o")
1258            .arg(&output)
1259            .status()
1260            .ok()?;
1261        status.success().then_some(output)
1262    }
1263
1264    #[test]
1265    fn dropping_pending_guard_unregisters_unary_request() {
1266        let host = NylonRingHost::new();
1267        let sid = 42;
1268        context::insert_pending(
1269            &host.host_ctx,
1270            sid,
1271            types::Pending::Unary(types::UnaryPending::waiting()),
1272        );
1273        host.host_ctx.set_state(sid, "key".into(), vec![1]);
1274
1275        drop(PendingGuard::new(&host.host_ctx, sid));
1276
1277        assert!(context::remove_pending(&host.host_ctx, sid).is_none());
1278        assert!(!host.host_ctx.state_per_sid.contains_key(&sid));
1279    }
1280
1281    #[test]
1282    fn unary_completion_wakes_latest_waiter_across_threads() {
1283        let host = NylonRingHost::new();
1284        let sid = 46;
1285        context::insert_pending(
1286            &host.host_ctx,
1287            sid,
1288            types::Pending::Unary(types::UnaryPending::waiting()),
1289        );
1290        host.host_ctx.set_state(sid, "key".into(), vec![1]);
1291
1292        let first_woken = Arc::new(std::sync::atomic::AtomicBool::new(false));
1293        let second_woken = Arc::new(std::sync::atomic::AtomicBool::new(false));
1294        let first_waker = std::task::Waker::from(Arc::new(WakeFlag(first_woken.clone())));
1295        let second_waker = std::task::Waker::from(Arc::new(WakeFlag(second_woken.clone())));
1296        let mut future = Box::pin(context::wait_for_unary(&host.host_ctx, sid));
1297
1298        let mut first_context = std::task::Context::from_waker(&first_waker);
1299        assert!(matches!(
1300            std::future::Future::poll(future.as_mut(), &mut first_context),
1301            std::task::Poll::Pending
1302        ));
1303        let mut second_context = std::task::Context::from_waker(&second_waker);
1304        assert!(matches!(
1305            std::future::Future::poll(future.as_mut(), &mut second_context),
1306            std::task::Poll::Pending
1307        ));
1308
1309        let host_ctx = host.host_ctx.clone();
1310        let dispatch_status = std::thread::spawn(move || {
1311            context::dispatch_pending(
1312                &host_ctx,
1313                sid,
1314                NrStatus::Ok,
1315                types::ResponsePayload::Owned(vec![7]),
1316            )
1317        })
1318        .join()
1319        .unwrap();
1320        assert_eq!(dispatch_status, NrStatus::Ok);
1321        assert!(!first_woken.load(std::sync::atomic::Ordering::Acquire));
1322        assert!(second_woken.load(std::sync::atomic::Ordering::Acquire));
1323        assert!(!host.host_ctx.state_per_sid.contains_key(&sid));
1324        assert_eq!(host.metrics().pending_requests, 1);
1325
1326        assert_eq!(
1327            context::dispatch_pending(
1328                &host.host_ctx,
1329                sid,
1330                NrStatus::Ok,
1331                types::ResponsePayload::Owned(vec![8]),
1332            ),
1333            NrStatus::Invalid
1334        );
1335
1336        match std::future::Future::poll(future.as_mut(), &mut second_context) {
1337            std::task::Poll::Ready(Some((status, data))) => {
1338                assert_eq!(status, NrStatus::Ok);
1339                assert_eq!(data.as_slice(), [7]);
1340            }
1341            result => panic!("unexpected unary completion result: {result:?}"),
1342        }
1343        assert_eq!(host.metrics().pending_requests, 0);
1344    }
1345
1346    #[test]
1347    fn unary_waker_callbacks_run_outside_pending_lock() {
1348        let host = NylonRingHost::new();
1349        let sid = 47;
1350        context::insert_pending(
1351            &host.host_ctx,
1352            sid,
1353            types::Pending::Unary(types::UnaryPending::waiting()),
1354        );
1355
1356        let clones = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1357        let drops = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1358        let first_waker = reentrant_waker(host.host_ctx.clone(), clones.clone(), drops.clone());
1359        let mut future = Box::pin(context::wait_for_unary(&host.host_ctx, sid));
1360        {
1361            let mut context = std::task::Context::from_waker(&first_waker);
1362            assert!(matches!(
1363                std::future::Future::poll(future.as_mut(), &mut context),
1364                std::task::Poll::Pending
1365            ));
1366        }
1367        assert_eq!(clones.load(std::sync::atomic::Ordering::Relaxed), 1);
1368
1369        let second_woken = Arc::new(std::sync::atomic::AtomicBool::new(false));
1370        let second_waker = std::task::Waker::from(Arc::new(WakeFlag(second_woken)));
1371        let mut context = std::task::Context::from_waker(&second_waker);
1372        assert!(matches!(
1373            std::future::Future::poll(future.as_mut(), &mut context),
1374            std::task::Poll::Pending
1375        ));
1376        assert_eq!(drops.load(std::sync::atomic::Ordering::Relaxed), 1);
1377
1378        drop(future);
1379        context::cleanup_sid(&host.host_ctx, sid);
1380    }
1381
1382    #[test]
1383    fn dropping_stream_receiver_unregisters_stream() {
1384        let host = NylonRingHost::new();
1385        let sid = 43;
1386        let (tx, rx) = stream_channel::acquire(1);
1387        context::insert_pending(&host.host_ctx, sid, types::Pending::Stream(tx));
1388        host.host_ctx.set_state(sid, "key".into(), vec![1]);
1389
1390        drop(StreamReceiver::new(
1391            rx,
1392            Some(host.host_ctx.clone()),
1393            sid,
1394            None,
1395        ));
1396
1397        assert!(context::remove_pending(&host.host_ctx, sid).is_none());
1398        assert!(!host.host_ctx.state_per_sid.contains_key(&sid));
1399    }
1400
1401    #[test]
1402    fn fast_slot_reentry_is_rejected_and_binding_is_cleared() {
1403        let mut first = types::UnaryResultSlot {
1404            sid: 1,
1405            result: None,
1406            lease: None,
1407        };
1408        let mut second = types::UnaryResultSlot {
1409            sid: 2,
1410            result: None,
1411            lease: None,
1412        };
1413        let binding = FastSlotBinding::bind(&mut first).unwrap();
1414        assert!(matches!(
1415            FastSlotBinding::bind(&mut second),
1416            Err(NylonRingHostError::FastPathReentrant)
1417        ));
1418        drop(binding);
1419        assert!(FastSlotBinding::bind(&mut second).is_ok());
1420    }
1421
1422    #[test]
1423    fn bounded_stream_reports_backpressure_and_removes_terminal_once() {
1424        let host = NylonRingHost::with_stream_capacity(1);
1425        let sid = 44;
1426        let (tx, mut rx) = stream_channel::acquire(1);
1427        context::insert_pending(&host.host_ctx, sid, types::Pending::Stream(tx));
1428
1429        assert_eq!(
1430            context::dispatch_pending(
1431                &host.host_ctx,
1432                sid,
1433                NrStatus::Ok,
1434                types::ResponsePayload::Owned(vec![1]),
1435            ),
1436            NrStatus::Ok
1437        );
1438        assert_eq!(
1439            context::dispatch_pending(
1440                &host.host_ctx,
1441                sid,
1442                NrStatus::Ok,
1443                types::ResponsePayload::Owned(vec![2]),
1444            ),
1445            NrStatus::Backpressure
1446        );
1447        assert_eq!(rx.try_recv().unwrap().data, vec![1]);
1448        assert_eq!(
1449            context::dispatch_pending(
1450                &host.host_ctx,
1451                sid,
1452                NrStatus::StreamEnd,
1453                types::ResponsePayload::Owned(vec![]),
1454            ),
1455            NrStatus::Ok
1456        );
1457        assert_eq!(host.metrics().pending_requests, 0);
1458        assert_eq!(rx.try_recv().unwrap().status, NrStatus::StreamEnd);
1459    }
1460
1461    #[test]
1462    fn concurrent_terminal_frames_complete_stream_once() {
1463        let host = NylonRingHost::with_stream_capacity(2);
1464        let sid = 45;
1465        let (tx, mut rx) = stream_channel::acquire(2);
1466        context::insert_pending(&host.host_ctx, sid, types::Pending::Stream(tx));
1467        let barrier = Arc::new(std::sync::Barrier::new(3));
1468
1469        let results: Vec<_> = std::thread::scope(|scope| {
1470            let handles: Vec<_> = (0..2)
1471                .map(|value| {
1472                    let ctx = host.host_ctx.clone();
1473                    let barrier = barrier.clone();
1474                    scope.spawn(move || {
1475                        barrier.wait();
1476                        context::dispatch_pending(
1477                            &ctx,
1478                            sid,
1479                            NrStatus::StreamEnd,
1480                            types::ResponsePayload::Owned(vec![value]),
1481                        )
1482                    })
1483                })
1484                .collect();
1485            barrier.wait();
1486            handles
1487                .into_iter()
1488                .map(|handle| handle.join().unwrap())
1489                .collect()
1490        });
1491
1492        assert_eq!(
1493            results
1494                .iter()
1495                .filter(|&&status| status == NrStatus::Ok)
1496                .count(),
1497            1
1498        );
1499        assert_eq!(
1500            results
1501                .iter()
1502                .filter(|&&status| status == NrStatus::Invalid)
1503                .count(),
1504            1
1505        );
1506        assert!(rx.try_recv().is_some());
1507        assert!(rx.try_recv().is_none());
1508        assert_eq!(host.metrics().pending_requests, 0);
1509    }
1510
1511    #[test]
1512    fn unload_defers_library_drop_and_rejects_new_calls_on_existing_handle() {
1513        let _plugin_lock = plugin_test_lock();
1514        let Some(path) = example_plugin_path() else {
1515            return;
1516        };
1517        let mut host = NylonRingHost::new();
1518        host.load("example", path.to_str().unwrap()).unwrap();
1519        let handle = host.plugin("example").unwrap();
1520        let runtime = tokio::runtime::Runtime::new().unwrap();
1521        assert!(matches!(
1522            runtime.block_on(handle.call_response_timeout(
1523                "benchmark_without_response",
1524                b"",
1525                Duration::from_millis(1),
1526            )),
1527            Err(NylonRingHostError::Timeout)
1528        ));
1529        assert_eq!(host.metrics().pending_requests, 0);
1530
1531        let guard = handle.plugin.begin_call().unwrap();
1532        assert_eq!(host.metrics().in_flight_calls, 1);
1533
1534        host.unload("example").unwrap();
1535        assert!(matches!(
1536            runtime.block_on(handle.call("benchmark_without_response", b"")),
1537            Err(NylonRingHostError::PluginUnloaded)
1538        ));
1539        drop(guard);
1540        drop(handle);
1541    }
1542
1543    #[test]
1544    fn stream_receiver_outlives_unload_and_drains_frames() {
1545        let _plugin_lock = plugin_test_lock();
1546        let Some(path) = example_plugin_path() else {
1547            return;
1548        };
1549        let mut host = NylonRingHost::new();
1550        host.load("example", path.to_str().unwrap()).unwrap();
1551        let handle = host.plugin("example").unwrap();
1552        let runtime = tokio::runtime::Runtime::new().unwrap();
1553
1554        let (_sid, mut receiver) = runtime
1555            .block_on(handle.call_stream("benchmark_stream", b""))
1556            .unwrap();
1557        host.unload("example").unwrap();
1558        drop(handle);
1559
1560        // The receiver's call guard must keep the retired plugin alive:
1561        // draining all buffered frames exercises the guarded context path.
1562        let frames = runtime.block_on(async {
1563            let mut frames = 0u32;
1564            while receiver.recv().await.is_some() {
1565                frames += 1;
1566            }
1567            frames
1568        });
1569        assert_eq!(frames, 9);
1570        drop(receiver);
1571    }
1572
1573    #[test]
1574    fn stream_receiver_outlives_host_drop() {
1575        let _plugin_lock = plugin_test_lock();
1576        let Some(path) = example_plugin_path() else {
1577            return;
1578        };
1579        let mut host = NylonRingHost::new();
1580        host.load("example", path.to_str().unwrap()).unwrap();
1581        let handle = host.plugin("example").unwrap();
1582        let runtime = tokio::runtime::Runtime::new().unwrap();
1583
1584        let (_sid, mut receiver) = runtime
1585            .block_on(handle.call_stream("benchmark_stream", b""))
1586            .unwrap();
1587        drop(host);
1588        drop(handle);
1589
1590        let frames = runtime.block_on(async {
1591            let mut frames = 0u32;
1592            while receiver.recv().await.is_some() {
1593                frames += 1;
1594            }
1595            frames
1596        });
1597        assert_eq!(frames, 9);
1598    }
1599
1600    #[test]
1601    fn owned_response_round_trips_and_releases_exactly_once() {
1602        let _plugin_lock = plugin_test_lock();
1603        let Some(path) = example_plugin_path() else {
1604            return;
1605        };
1606        let mut host = NylonRingHost::new();
1607        host.load("example", path.to_str().unwrap()).unwrap();
1608        let handle = host.plugin("example").unwrap();
1609        let runtime = tokio::runtime::Runtime::new().unwrap();
1610
1611        async fn release_count(handle: &PluginHandle) -> u64 {
1612            let (status, data) = handle
1613                .call_response("owned_release_count", b"")
1614                .await
1615                .unwrap();
1616            assert_eq!(status, NrStatus::Ok);
1617            u64::from_le_bytes(data.try_into().unwrap())
1618        }
1619
1620        runtime.block_on(async {
1621            // Zero-copy static response: correct bytes, nothing to release.
1622            let (status, data) = handle
1623                .call_response_bytes("benchmark_owned", &[0u8; 128])
1624                .await
1625                .unwrap();
1626            assert_eq!(status, NrStatus::Ok);
1627            assert_eq!(data.len(), 128);
1628            assert!(data.iter().all(|&byte| byte == 42));
1629
1630            let before = release_count(&handle).await;
1631
1632            // Held view: released only when dropped, not on receipt.
1633            let (status, held) = handle
1634                .call_response_bytes("echo_owned", b"held")
1635                .await
1636                .unwrap();
1637            assert_eq!(status, NrStatus::Ok);
1638            assert_eq!(&*held, b"held");
1639            assert_eq!(release_count(&handle).await, before);
1640            drop(held);
1641            assert_eq!(release_count(&handle).await, before + 1);
1642
1643            // Vec conversion path copies and releases immediately.
1644            let (status, data) = handle.call_response("echo_owned", b"copied").await.unwrap();
1645            assert_eq!(status, NrStatus::Ok);
1646            assert_eq!(data, b"copied");
1647            assert_eq!(release_count(&handle).await, before + 2);
1648
1649            // into_vec on the view also releases exactly once.
1650            let (_, view) = handle
1651                .call_response_bytes("echo_owned", b"vec")
1652                .await
1653                .unwrap();
1654            assert_eq!(view.into_vec(), b"vec");
1655            assert_eq!(release_count(&handle).await, before + 3);
1656
1657            // Fast path consumes owned responses through the slot copy.
1658            let (status, data) = handle
1659                .call_response_fast("echo_owned", b"fast")
1660                .await
1661                .unwrap();
1662            assert_eq!(status, NrStatus::Ok);
1663            assert_eq!(data, b"fast");
1664            assert_eq!(release_count(&handle).await, before + 4);
1665        });
1666    }
1667
1668    #[test]
1669    fn owned_response_keeps_plugin_alive_after_unload() {
1670        let _plugin_lock = plugin_test_lock();
1671        let Some(path) = example_plugin_path() else {
1672            return;
1673        };
1674        let mut host = NylonRingHost::new();
1675        host.load("example", path.to_str().unwrap()).unwrap();
1676        let handle = host.plugin("example").unwrap();
1677        let runtime = tokio::runtime::Runtime::new().unwrap();
1678
1679        let (status, response) = runtime
1680            .block_on(handle.call_response_bytes("benchmark_owned", &[0u8; 64]))
1681            .unwrap();
1682        assert_eq!(status, NrStatus::Ok);
1683
1684        // The response borrows memory inside the plugin image; its guard
1685        // must defer the library unload until the view drops.
1686        host.unload("example").unwrap();
1687        drop(handle);
1688        drop(host);
1689        assert_eq!(response.len(), 64);
1690        assert!(response.iter().all(|&byte| byte == 42));
1691        drop(response);
1692    }
1693
1694    #[test]
1695    fn lease_round_trips_and_rejects_misuse() {
1696        let _plugin_lock = plugin_test_lock();
1697        let Some(path) = example_plugin_path() else {
1698            return;
1699        };
1700        let mut host = NylonRingHost::new();
1701        host.load("example", path.to_str().unwrap()).unwrap();
1702        let handle = host.plugin("example").unwrap();
1703        let runtime = tokio::runtime::Runtime::new().unwrap();
1704
1705        runtime.block_on(async {
1706            // Standard unary path: the response Vec is the leased buffer.
1707            let (status, data) = handle
1708                .call_response("echo_lease", b"through the lease")
1709                .await
1710                .unwrap();
1711            assert_eq!(status, NrStatus::Ok);
1712            assert_eq!(data, b"through the lease");
1713
1714            // Empty payloads commit a zero-length lease.
1715            let (status, data) = handle.call_response("echo_lease", b"").await.unwrap();
1716            assert_eq!(status, NrStatus::Ok);
1717            assert!(data.is_empty());
1718
1719            // Fast path: the lease lives in the TLS slot.
1720            let (status, data) = handle
1721                .call_response_fast("echo_lease", b"fast lease")
1722                .await
1723                .unwrap();
1724            assert_eq!(status, NrStatus::Ok);
1725            assert_eq!(data, b"fast lease");
1726
1727            // The misuse handler asserts double acquire, bad token, bad
1728            // length, and double commit all report Invalid; an assertion
1729            // failure would surface here as PluginPanicked.
1730            let (status, data) = handle
1731                .call_response("lease_misuse", b"contract")
1732                .await
1733                .unwrap();
1734            assert_eq!(status, NrStatus::Ok);
1735            assert_eq!(data, b"contract");
1736        });
1737
1738        // Every lease was committed; nothing should be parked or pending.
1739        assert_eq!(host.host_ctx.orphaned_lease_count(), 0);
1740        assert_eq!(host.host_ctx.pending_count(), 0);
1741    }
1742
1743    #[test]
1744    fn abandoned_leases_are_parked_not_freed() {
1745        let _plugin_lock = plugin_test_lock();
1746        let Some(path) = example_plugin_path() else {
1747            return;
1748        };
1749        let mut host = NylonRingHost::new();
1750        host.load("example", path.to_str().unwrap()).unwrap();
1751        let handle = host.plugin("example").unwrap();
1752        let runtime = tokio::runtime::Runtime::new().unwrap();
1753
1754        runtime.block_on(async {
1755            // The plugin acquires a lease and never commits: the call times
1756            // out and cleanup must park the lease (the plugin could still
1757            // hold the pointer), not free it.
1758            let result = handle
1759                .call_response_timeout("lease_abandon", b"", std::time::Duration::from_millis(50))
1760                .await;
1761            assert!(matches!(result, Err(NylonRingHostError::Timeout)));
1762            assert_eq!(host.host_ctx.orphaned_lease_count(), 1);
1763            assert_eq!(host.host_ctx.pending_count(), 0);
1764
1765            // Fast path: an uncommitted lease in the TLS slot is parked too.
1766            let result = handle.call_response_fast("lease_abandon", b"").await;
1767            assert!(matches!(
1768                result,
1769                Err(NylonRingHostError::MissingSynchronousResponse)
1770            ));
1771            assert_eq!(host.host_ctx.orphaned_lease_count(), 2);
1772        });
1773    }
1774
1775    #[test]
1776    fn example_plugin_fire_and_forget_entry_returns_ok() {
1777        let _plugin_lock = plugin_test_lock();
1778        let Some(path) = example_plugin_path() else {
1779            return;
1780        };
1781        let mut host = NylonRingHost::new();
1782        host.load("example", path.to_str().unwrap()).unwrap();
1783        let handle = host.plugin("example").unwrap();
1784        let runtime = tokio::runtime::Runtime::new().unwrap();
1785
1786        assert!(matches!(
1787            runtime.block_on(handle.call("notify", b"fire and forget")),
1788            Ok(NrStatus::Ok)
1789        ));
1790    }
1791
1792    #[test]
1793    fn c_plugin_layout_round_trips_through_host() {
1794        let _plugin_lock = plugin_test_lock();
1795        let Some(path) = c_plugin_path() else {
1796            return;
1797        };
1798        let mut host = NylonRingHost::new();
1799        host.load("c-example", path.to_str().unwrap()).unwrap();
1800        let handle = host.plugin("c-example").unwrap();
1801        let runtime = tokio::runtime::Runtime::new().unwrap();
1802        let (status, response) = runtime
1803            .block_on(handle.call_response("echo", b"from-c"))
1804            .unwrap();
1805        assert_eq!(status, NrStatus::Ok);
1806        assert_eq!(response, b"from-c");
1807
1808        // Integer entry dispatch is optional; this plugin leaves both
1809        // fields NULL, which must surface as a typed error.
1810        assert!(matches!(
1811            handle.entry("echo"),
1812            Err(NylonRingHostError::EntryDispatchUnsupported)
1813        ));
1814    }
1815
1816    #[test]
1817    fn entry_id_dispatch_round_trips_all_call_shapes() {
1818        let _plugin_lock = plugin_test_lock();
1819        let Some(path) = example_plugin_path() else {
1820            return;
1821        };
1822        let mut host = NylonRingHost::new();
1823        host.load("example", path.to_str().unwrap()).unwrap();
1824        let handle = host.plugin("example").unwrap();
1825        let runtime = tokio::runtime::Runtime::new().unwrap();
1826
1827        assert!(matches!(
1828            handle.entry("no_such_entry"),
1829            Err(NylonRingHostError::EntryNotFound(_))
1830        ));
1831
1832        let echo = handle.entry("benchmark").unwrap();
1833        let notify = handle.entry("benchmark_without_response").unwrap();
1834        let stream = handle.entry("benchmark_stream").unwrap();
1835
1836        runtime.block_on(async {
1837            let (status, data) = echo.call_response(b"by-id").await.unwrap();
1838            assert_eq!(status, NrStatus::Ok);
1839            assert_eq!(data, b"by-id");
1840
1841            let (status, data) = echo.call_response_fast(b"fast-by-id").await.unwrap();
1842            assert_eq!(status, NrStatus::Ok);
1843            assert_eq!(data, b"fast-by-id");
1844
1845            assert_eq!(notify.call(b"").await.unwrap(), NrStatus::Ok);
1846
1847            let (_sid, mut receiver) = stream.call_stream(b"").await.unwrap();
1848            let mut frames = 0u32;
1849            while receiver.recv().await.is_some() {
1850                frames += 1;
1851            }
1852            assert_eq!(frames, 9);
1853        });
1854    }
1855
1856    #[test]
1857    fn pinned_plugin_calls_and_refuses_unload() {
1858        let _guard = plugin_test_lock();
1859        let Some(path) = example_plugin_path() else {
1860            return;
1861        };
1862        let runtime = tokio::runtime::Builder::new_current_thread()
1863            .enable_time()
1864            .build()
1865            .unwrap();
1866        let mut host = NylonRingHost::new();
1867        host.load_pinned("pinned", path.to_str().unwrap()).unwrap();
1868        let handle = host.plugin("pinned").unwrap();
1869        runtime.block_on(async {
1870            let (status, data) = handle
1871                .call_response_fast("benchmark", b"pin")
1872                .await
1873                .unwrap();
1874            assert_eq!(status, NrStatus::Ok);
1875            assert_eq!(data, b"pin");
1876            let (status, data) = handle.call_response("benchmark", b"pin2").await.unwrap();
1877            assert_eq!(status, NrStatus::Ok);
1878            assert_eq!(data, b"pin2");
1879            // Streams still take real tracker slots on a pinned plugin.
1880            let (_sid, mut receiver) = handle.call_stream("stream", b"").await.unwrap();
1881            let mut frames = 0u32;
1882            while receiver.recv().await.is_some() {
1883                frames += 1;
1884            }
1885            assert!(frames > 0);
1886        });
1887        assert_eq!(handle.plugin.call_tracker.active_calls(), 0);
1888        assert!(matches!(
1889            host.unload("pinned"),
1890            Err(NylonRingHostError::PluginPinned(_))
1891        ));
1892        assert!(matches!(
1893            host.reload(),
1894            Err(NylonRingHostError::PluginPinned(_))
1895        ));
1896        assert!(matches!(
1897            host.load("pinned", path.to_str().unwrap()),
1898            Err(NylonRingHostError::PluginPinned(_))
1899        ));
1900        runtime.block_on(async {
1901            assert!(matches!(
1902                host.unload_with_grace("pinned", Duration::from_millis(10))
1903                    .await,
1904                Err(NylonRingHostError::PluginPinned(_))
1905            ));
1906            assert!(matches!(
1907                host.reload_with_grace(Duration::from_millis(10)).await,
1908                Err(NylonRingHostError::PluginPinned(_))
1909            ));
1910        });
1911    }
1912
1913    /// Manual cycle-budget probe for the single-worker call paths. Run with:
1914    /// `cargo test --release -p nylon-ring-host --lib cycle_budget_probe -- --ignored --nocapture`
1915    #[test]
1916    #[ignore = "manual probe; needs --release for meaningful numbers"]
1917    fn cycle_budget_probe() {
1918        use std::future::Future;
1919        use std::hint::black_box;
1920        use std::task::{Context, Poll, Waker};
1921
1922        let _guard = plugin_test_lock();
1923        let path = example_plugin_path().expect("example plugin must build");
1924        let mut host = NylonRingHost::new();
1925        host.load("bench", path.to_str().unwrap()).unwrap();
1926        let handle = host.plugin("bench").unwrap();
1927        let fire_entry = handle.entry("benchmark_without_response").unwrap();
1928        let fast_entry = handle.entry("benchmark").unwrap();
1929
1930        const N: u64 = 20_000_000;
1931
1932        fn time_ns(iters: u64, mut f: impl FnMut()) -> f64 {
1933            for _ in 0..iters / 10 {
1934                f();
1935            }
1936            let start = std::time::Instant::now();
1937            for _ in 0..iters {
1938                f();
1939            }
1940            start.elapsed().as_nanos() as f64 / iters as f64
1941        }
1942
1943        // A 16-deep dependent add chain retires at 1/cycle on Apple Silicon:
1944        // measures the actual clock so cycle numbers below don't assume
1945        // nominal boost. The loop lives inside one asm block so the chain
1946        // never round-trips through memory; the loop counter's subs/branch
1947        // overlap with the chain and add no cycles.
1948        let calibrate = |iters: u64| -> f64 {
1949            // Only the aarch64 asm below mutates `x`; other targets compile
1950            // this probe (it is #[ignore]d) without running it.
1951            #[cfg_attr(not(target_arch = "aarch64"), allow(unused_mut))]
1952            let mut x: u64 = black_box(0);
1953            let start = std::time::Instant::now();
1954            #[cfg(target_arch = "aarch64")]
1955            unsafe {
1956                let mut n = iters;
1957                std::arch::asm!(
1958                    "2:",
1959                    "add {x}, {x}, #1", "add {x}, {x}, #1", "add {x}, {x}, #1",
1960                    "add {x}, {x}, #1", "add {x}, {x}, #1", "add {x}, {x}, #1",
1961                    "add {x}, {x}, #1", "add {x}, {x}, #1", "add {x}, {x}, #1",
1962                    "add {x}, {x}, #1", "add {x}, {x}, #1", "add {x}, {x}, #1",
1963                    "add {x}, {x}, #1", "add {x}, {x}, #1", "add {x}, {x}, #1",
1964                    "add {x}, {x}, #1",
1965                    "subs {n}, {n}, #1",
1966                    "b.ne 2b",
1967                    x = inout(reg) x,
1968                    n = inout(reg) n,
1969                );
1970                black_box(n);
1971            }
1972            black_box(x);
1973            start.elapsed().as_nanos() as f64 / iters as f64
1974        };
1975        calibrate(200_000_000); // DVFS ramp warmup
1976        let calib_ns = calibrate(100_000_000).min(calibrate(100_000_000));
1977        let ghz = 16.0 / calib_ns;
1978        println!("clock calibration: {calib_ns:.2} ns / 16 adds -> {ghz:.3} GHz");
1979
1980        let cyc = |ns: f64| ns * ghz;
1981        let report = |name: &str, ns: f64| {
1982            println!("{name:<28} {ns:>7.2} ns  {:>6.1} cycles", cyc(ns));
1983        };
1984
1985        let plugin = &fire_entry.plugin;
1986        let handle_by_id_fn = plugin.dispatch.handle_by_id.unwrap();
1987        let fire_id = fire_entry.id;
1988
1989        report(
1990            "component: tracker begin+fin",
1991            time_ns(N, || {
1992                let shard = plugin.call_tracker.try_begin().unwrap();
1993                let _ = black_box(plugin.call_tracker.finish(shard));
1994            }),
1995        );
1996
1997        // Atomic microbenches mimicking the tracker's shard word, on a
1998        // dedicated 128-byte-aligned line, to separate instruction choice
1999        // (CAS loop vs single fetch_add) from ordering strength.
2000        #[repr(align(128))]
2001        struct PaddedCounter(std::sync::atomic::AtomicUsize);
2002        use std::sync::atomic::Ordering as AtomOrd;
2003        let word = PaddedCounter(std::sync::atomic::AtomicUsize::new(0));
2004        report(
2005            "atomics: casa loop + ldaddl",
2006            time_ns(N, || {
2007                let _ = black_box(
2008                    word.0
2009                        .fetch_update(AtomOrd::Acquire, AtomOrd::Relaxed, |v| Some(v + 1)),
2010                );
2011                black_box(word.0.fetch_sub(1, AtomOrd::Release));
2012            }),
2013        );
2014        report(
2015            "atomics: relaxed cas + sub",
2016            time_ns(N, || {
2017                let _ = black_box(
2018                    word.0
2019                        .fetch_update(AtomOrd::Relaxed, AtomOrd::Relaxed, |v| Some(v + 1)),
2020                );
2021                black_box(word.0.fetch_sub(1, AtomOrd::Relaxed));
2022            }),
2023        );
2024        report(
2025            "atomics: ldadda + ldaddl",
2026            time_ns(N, || {
2027                black_box(word.0.fetch_add(1, AtomOrd::Acquire));
2028                black_box(word.0.fetch_sub(1, AtomOrd::Release));
2029            }),
2030        );
2031        report(
2032            "atomics: single ldadda",
2033            time_ns(N, || {
2034                black_box(word.0.fetch_add(1, AtomOrd::Acquire));
2035            }),
2036        );
2037        report(
2038            "atomics: relaxed cas + ldaddl",
2039            time_ns(N, || {
2040                let _ = black_box(
2041                    word.0
2042                        .fetch_update(AtomOrd::Relaxed, AtomOrd::Relaxed, |v| Some(v + 1)),
2043                );
2044                black_box(word.0.fetch_sub(1, AtomOrd::Release));
2045            }),
2046        );
2047
2048        // B diagnostic: the same begin/finish pair with 8 threads, each on
2049        // its own thread-shard. The tracker is already sharded per thread,
2050        // so per-op cost should stay ~1x the single-thread figure.
2051        {
2052            let tracker = std::sync::Arc::new(crate::call_tracker::CallTracker::new());
2053            let per_thread: u64 = 4_000_000;
2054            let start = std::time::Instant::now();
2055            let threads: Vec<_> = (0..8)
2056                .map(|_| {
2057                    let tracker = tracker.clone();
2058                    std::thread::spawn(move || {
2059                        for _ in 0..per_thread {
2060                            let shard = tracker.try_begin().unwrap();
2061                            let _ = black_box(tracker.finish(shard));
2062                        }
2063                    })
2064                })
2065                .collect();
2066            for t in threads {
2067                t.join().unwrap();
2068            }
2069            let ns = start.elapsed().as_nanos() as f64 / per_thread as f64;
2070            report("tracker pair, 8 threads", ns);
2071        }
2072        report(
2073            "component: next_sid",
2074            time_ns(N, || {
2075                black_box(sid::next_sid());
2076            }),
2077        );
2078        report(
2079            "component: remove_state",
2080            time_ns(N, || {
2081                plugin.host_ctx.remove_state(black_box(1));
2082            }),
2083        );
2084        report(
2085            "raw FFI handle_by_id (fire)",
2086            time_ns(N, || {
2087                let status = unsafe {
2088                    handle_by_id_fn(black_box(fire_id), 1, NrBytes::from_slice(black_box(b"")))
2089                };
2090                assert_eq!(status, NrStatus::Ok);
2091            }),
2092        );
2093
2094        let mut cx = Context::from_waker(Waker::noop());
2095        macro_rules! poll_ready {
2096            ($fut:expr) => {{
2097                let fut = $fut;
2098                let mut fut = std::pin::pin!(fut);
2099                match fut.as_mut().poll(&mut cx) {
2100                    Poll::Ready(result) => result.unwrap(),
2101                    Poll::Pending => panic!("probe future must complete synchronously"),
2102                }
2103            }};
2104        }
2105
2106        report(
2107            "full fire (entry id)",
2108            time_ns(N, || {
2109                poll_ready!(fire_entry.call(black_box(b"")));
2110            }),
2111        );
2112        report(
2113            "full fire (by name)",
2114            time_ns(N, || {
2115                poll_ready!(handle.call("benchmark_without_response", black_box(b"")));
2116            }),
2117        );
2118        report(
2119            "full fast (entry id)",
2120            time_ns(N, || {
2121                black_box(poll_ready!(fast_entry.call_response_fast(black_box(b""))));
2122            }),
2123        );
2124        report(
2125            "full fast (by name)",
2126            time_ns(N, || {
2127                black_box(poll_ready!(
2128                    handle.call_response_fast("benchmark", black_box(b""))
2129                ));
2130            }),
2131        );
2132        report(
2133            "full unary (entry id)",
2134            time_ns(N, || {
2135                black_box(poll_ready!(fast_entry.call_response(black_box(b""))));
2136            }),
2137        );
2138        report(
2139            "full unary (by name)",
2140            time_ns(N, || {
2141                black_box(poll_ready!(
2142                    handle.call_response("benchmark", black_box(b""))
2143                ));
2144            }),
2145        );
2146    }
2147}