Skip to main content

rpi_extensions/
loader.rs

1//! The `libloading` loader: discover and register cdylib plugins.
2//!
3//! [`load_one`] loads a single cdylib and looks up the unified
4//! `rpi_plugin_register` entrypoint (ABI v4). The entrypoint is called exactly
5//! once; a nonzero return is reported via diagnostics.
6//!
7//! [`load_dir`] walks a directory for `.{dll,so,dylib}` files and loads each.
8//! The returned [`LoadedPlugin`]s hold the `libloading::Library` (dropping them
9//! unloads the cdylib — keep them alive for the session lifetime).
10
11use std::ffi::OsStr;
12use std::path::{Path, PathBuf};
13use std::sync::Arc;
14
15use libloading::Library;
16use thiserror::Error;
17
18use rpi_plugin_sdk::{RpiPluginRegisterUnified, RPI_PLUGIN_ABI_VERSION_UNIFIED};
19
20use crate::registry::{ExtensionRegistry, RegistrySnapshot};
21use crate::{
22    clear_current_api, set_current_api, ActionBridge, HostApi, NullDiagnostics, PluginDiagnostics,
23};
24
25// ---------------------------------------------------------------------------
26// Errors
27// ---------------------------------------------------------------------------
28
29/// Error / skip reason from loading one plugin. `Skip` variants are non-fatal
30/// (logged via diagnostics); `Fatal` means the load itself failed.
31#[derive(Debug, Error)]
32pub enum PluginLoadError {
33    #[error("could not open library {path}: {source}")]
34    Open {
35        path: PathBuf,
36        #[source]
37        source: libloading::Error,
38    },
39    #[error("`rpi_plugin_register` was not found in {path}: {error}")]
40    Symbol { path: PathBuf, error: String },
41    #[error("register returned nonzero code {code} for {path}")]
42    RegisterReturned { path: PathBuf, code: i32 },
43    /// ABI version reported by the plugin mismatches the host's. Skip + diag.
44    #[error(
45        "ABI version mismatch in {path}: plugin built for {plugin_version}, host is {host_version}"
46    )]
47    AbiVersionMismatch {
48        path: PathBuf,
49        plugin_version: u32,
50        host_version: u32,
51    },
52}
53
54// ---------------------------------------------------------------------------
55// LoadedPlugin — the live cdylib handle + where it came from
56// ---------------------------------------------------------------------------
57
58/// A successfully loaded + registered plugin. Holds the `Library` so the cdylib
59/// stays mapped for the session. Dropping this unloads the plugin (do not drop
60/// while any of its tool drivers may still be running).
61pub struct LoadedPlugin {
62    /// The loaded cdylib. Kept alive for the session.
63    pub library: Library,
64    /// Where it was loaded from (for diagnostics).
65    pub path: PathBuf,
66    /// ABI version (always [`RPI_PLUGIN_ABI_VERSION_UNIFIED`] for plugins
67    /// loaded by this version of the loader).
68    pub abi_version: u32,
69    /// The registry snapshot built from this plugin's registrations. The host
70    /// merges snapshots from all loaded plugins into one session registry.
71    pub registry: ExtensionRegistry,
72    /// Retains the plugin-scoped runtime context used by its FFI callbacks.
73    pub action_bridge: Option<Arc<ActionBridge>>,
74}
75
76fn call_register(register: RpiPluginRegisterUnified, host_api: &Arc<HostApi>) -> i32 {
77    // Leak the API struct. The SDK asks plugins to *copy* the function pointers
78    // they need during `register`, but real-world plugins retain the `api`
79    // pointer to call e.g. `runtime_action` later. The host already keeps the
80    // `user_data` (the `ActionBridge`) alive for the whole session, so keeping
81    // the table valid is consistent with that contract and prevents a
82    // use-after-free for plugins that hold the pointer. Bounded: one struct per
83    // plugin load (a leak of a few dozen bytes per load/reload).
84    //
85    // Defense in depth: the SDK's `export_plugin!` (and `register_entrypoint`)
86    // already convert a plugin panic into `REGISTER_PANIC_STATUS` before it can
87    // reach the `extern "C"` boundary. We still wrap the call so a plugin built
88    // against a *newer* `C-unwind` entrypoint, or a panic raised by host vtable
89    // construction, is contained rather than unwinding through the loader.
90    match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
91        let api: &'static rpi_plugin_sdk::PluginApi = Box::leak(Box::new(host_api.build_vtable()));
92        register(api as *const rpi_plugin_sdk::PluginApi)
93    })) {
94        Ok(code) => code,
95        // Reaching here means the panic unwound *into the host* rather than
96        // aborting at the plugin's `extern "C"` frame (e.g. a future
97        // `C-unwind` entrypoint). Report it the same way the SDK does.
98        Err(_) => rpi_plugin_sdk::REGISTER_PANIC_STATUS,
99    }
100}
101
102// ---------------------------------------------------------------------------
103// load_one
104// ---------------------------------------------------------------------------
105
106/// Load and register one cdylib plugin. Returns the live plugin + its
107/// registry, or a [`PluginLoadError`] (skip-fatality distinction is on the
108/// caller; both are logged via `diagnostics`).
109///
110/// `diagnostics` is the host sink for ABI-mismatch/unsupported warnings. The
111/// loader creates a fresh `ExtensionRegistry` for THIS plugin (so a plugin that
112/// fails partway can't pollute others), and the caller merges per-plugin
113/// registries into the session registry in load-order (first-wins on name).
114///
115/// `action_bridge` (B5a): when `Some`, the plugin's vtable wires the real
116/// [`trampoline_runtime_action`] and carries the bridge in `user_data`, so the
117/// plugin can invoke host runtime actions post-register from any thread. `None`
118/// keeps the stub (actions return `-1`). `rpi-cli` builds ONE master
119/// `Arc<ActionBridge>` per session. Each plugin gets a scoped bridge sharing
120/// those facilities, retained alongside its library for the session lifetime.
121pub fn load_one(
122    path: impl AsRef<Path>,
123    diagnostics: Arc<dyn PluginDiagnostics>,
124    action_bridge: Option<Arc<ActionBridge>>,
125) -> Result<LoadedPlugin, PluginLoadError> {
126    let path = path.as_ref().to_path_buf();
127    // 1. Open the cdylib.
128    let library = unsafe { Library::new(&path) }.map_err(|e| PluginLoadError::Open {
129        path: path.clone(),
130        source: e,
131    })?;
132
133    // 2. Look up the unified entrypoint.
134    let register: RpiPluginRegisterUnified = unsafe {
135        library
136            .get::<RpiPluginRegisterUnified>(rpi_plugin_sdk::REGISTER_SYMBOL_UNIFIED)
137            .map(|symbol| *symbol)
138    }
139    .map_err(|e| PluginLoadError::Symbol {
140        path: path.clone(),
141        error: e.to_string(),
142    })?;
143    let abi_version = RPI_PLUGIN_ABI_VERSION_UNIFIED;
144
145    // Plugin display name (file stem) — stamped onto every registration the
146    // plugin makes so host diagnostics (e.g. lifecycle veto messages) can name
147    // the owning extension. Pure host-side; never crosses the ABI.
148    let plugin_name = path
149        .file_stem()
150        .map(|s| s.to_string_lossy().into_owned())
151        .unwrap_or_else(|| "<unknown-plugin>".to_string());
152
153    // 3. Build a fresh registry + HostApi + vtable for this plugin.
154    let registry = ExtensionRegistry::new();
155    let host_api = match action_bridge {
156        Some(bridge) => HostApi::with_action_bridge(
157            plugin_name.clone(),
158            registry,
159            Arc::clone(&diagnostics),
160            bridge,
161        ),
162        None => HostApi::new(plugin_name, registry, Arc::clone(&diagnostics)),
163    };
164    // 4. Set the thread-local current api so the register trampolines can reach
165    //    the registry. Register is synchronous + single-threaded per plugin.
166    // SAFETY: `host_api` is alive for the duration of the register call (held on
167    // this stack); we clear_current_api immediately after.
168    unsafe { set_current_api(&host_api) };
169    let rc = call_register(register, &host_api);
170    clear_current_api();
171
172    if rc != 0 {
173        if let Some(bridge) = &host_api.action_bridge {
174            bridge.release_stdio_claim();
175        }
176        // The plugin refused (its register returned nonzero — e.g. it saw an
177        // ABI version it didn't like), or its body panicked and the SDK
178        // converted that into `REGISTER_PANIC_STATUS`. Skip + diag. The
179        // registry may have partial registrations; we drop it (no harm — the
180        // tools registered so far would reference a plugin that "failed", so
181        // we honor the refusal and discard).
182        if rc == rpi_plugin_sdk::REGISTER_PANIC_STATUS {
183            diagnostics.warn(&format!(
184                "plugin {} panicked during registration — skipped (the host is unaffected)",
185                path.display()
186            ));
187        } else {
188            diagnostics.warn(&format!(
189                "plugin {} ABI v{} register returned code {} — skipped",
190                path.display(),
191                abi_version,
192                rc
193            ));
194        }
195        return Err(PluginLoadError::RegisterReturned { path, code: rc });
196    }
197
198    // 5. Take the registry out of the HostApi. The host keeps the Library alive
199    //    (LoadedPlugin) so the plugin's code + static data remain mapped; the
200    //    registry holds fn pointers into that code.
201    let registry = host_api
202        .take_registry()
203        .ok_or_else(|| PluginLoadError::RegisterReturned {
204            path: path.clone(),
205            code: -2,
206        })?;
207
208    tracing::debug!(path = %path.display(), abi_version, "loaded native plugin");
209
210    Ok(LoadedPlugin {
211        action_bridge: host_api.action_bridge.clone(),
212        library,
213        path,
214        abi_version,
215        registry,
216    })
217}
218
219// ---------------------------------------------------------------------------
220// load_dir
221// ---------------------------------------------------------------------------
222
223/// Platform cdylib extensions.
224const CDYLIB_EXTS: &[&str] = &["dll", "so", "dylib", "pyd"];
225
226/// Load every cdylib in `dir` (non-recursive). Each load failure is logged via
227/// `diagnostics` and skipped (one bad plugin doesn't abort the rest). Returns
228/// the successfully loaded plugins in directory order.
229///
230/// `action_bridge` (B5a) is cloned into each loaded plugin's vtable `user_data`
231/// so post-register `runtime_action` calls recover the bridge on any thread.
232pub fn load_dir(
233    dir: impl AsRef<Path>,
234    diagnostics: Arc<dyn PluginDiagnostics>,
235    action_bridge: Option<Arc<ActionBridge>>,
236) -> Vec<LoadedPlugin> {
237    scan_dir(dir.as_ref(), diagnostics.as_ref())
238        .into_iter()
239        .filter_map(
240            |path| match load_one(&path, Arc::clone(&diagnostics), action_bridge.clone()) {
241                Ok(plugin) => Some(plugin),
242                Err(error) => {
243                    diagnostics.warn(&format!("skipped plugin {}: {error}", path.display()));
244                    None
245                }
246            },
247        )
248        .collect()
249}
250
251fn scan_dir(dir: &Path, diagnostics: &dyn PluginDiagnostics) -> Vec<PathBuf> {
252    match std::fs::read_dir(dir) {
253        Ok(entries) => entries
254            .flatten()
255            .map(|entry| entry.path())
256            .filter(|path| is_cdylib(path))
257            .collect(),
258        Err(error) => {
259            diagnostics.warn(&format!(
260                "extensions dir {} unreadable: {error}",
261                dir.display()
262            ));
263            Vec::new()
264        }
265    }
266}
267
268/// Whether `path`'s extension is a known cdylib extension.
269fn is_cdylib(path: &Path) -> bool {
270    path.extension()
271        .and_then(OsStr::to_str)
272        .map(|ext| CDYLIB_EXTS.iter().any(|e| e.eq_ignore_ascii_case(ext)))
273        .unwrap_or(false)
274}
275
276/// Convenience: merge a slice of per-plugin [`LoadedPlugin`] registries into one
277/// session registry, first-wins on name (mirrors pi's cross-extension
278/// registration order). Consumes the registries (the `LoadedPlugin`s themselves
279/// stay alive — callers keep the `Library` handles).
280pub fn merge_registries(plugins: &mut [LoadedPlugin]) -> ExtensionRegistry {
281    let mut session = ExtensionRegistry::new();
282    for p in plugins.iter_mut() {
283        let taken = std::mem::take(&mut p.registry);
284        session.absorb(taken);
285    }
286    session
287}
288
289// ---------------------------------------------------------------------------
290// PluginKeepalive + ExtensionSession — the host session's plugin lifetime
291// ---------------------------------------------------------------------------
292
293/// Owns the loaded `Library` handles so the cdylibs stay mapped for as long as
294/// any registered tool/handler (whose fn pointers live inside the cdylib) may be
295/// called. Shared via `Arc`: every [`PluginToolAdapter`](crate::PluginToolAdapter)
296/// (and, in B3, the [`ExtensionEmitter`](crate::ExtensionEmitter)) holds a clone,
297/// so the libraries unload only when the last holder drops — which is never
298/// before the harness's tool vec (and thus the last possible tool call) drops.
299///
300/// `libloading::Library` is `Send + Sync` (a handle/HMODULE), so the keepalive is
301/// too — required because `AgentTool: Send + Sync` and the adapter carries it.
302pub struct PluginKeepalive {
303    #[allow(dead_code)]
304    libraries: Vec<Library>,
305    /// B5a: the session's action bridge. Retained here so the raw pointer a
306    /// plugin stored in its vtable `user_data` (`Arc::as_ptr`) stays valid for
307    /// the harness lifetime — every `PluginToolAdapter` + the `ExtensionEmitter`
308    /// clone the keepalive, so the bridge outlives any plugin→host
309    /// `runtime_action` call. `None` under `--no-extensions`, when zero plugins
310    /// loaded, or in tests.
311    #[allow(dead_code)]
312    action_bridge: Option<Arc<ActionBridge>>,
313    plugin_bridges: Vec<Arc<ActionBridge>>,
314}
315
316impl PluginKeepalive {
317    /// Build a keepalive. `pub` so tests + host code can construct an empty
318    /// one (no loaded cdylibs) where the plugin lifecycle is exercised without
319    /// real plugins.
320    pub fn new(libraries: Vec<Library>, action_bridge: Option<Arc<ActionBridge>>) -> Self {
321        Self {
322            plugin_bridges: Vec::new(),
323            libraries,
324            action_bridge,
325        }
326    }
327
328    /// An empty keepalive owning no libraries — for host code that builds an
329    /// adapter outside a real load session (notably in-process tests of the
330    /// adapter against stub fns that live in the test binary, not a cdylib).
331    pub fn empty() -> Arc<Self> {
332        Arc::new(Self::new(Vec::new(), None))
333    }
334}
335
336/// The result of loading a session's worth of extensions: a shared keepalive for
337/// the cdylib handles + a snapshot of the merged registry. Built by
338/// [`load_session`]; the host (pi-cli) stashes one per harness build and hands
339/// clones of the keepalive to each adapter it constructs from the snapshot.
340///
341/// The snapshot is held behind `Arc` so the host can hand a clone to the
342/// [`ExtensionEmitter`](crate::ExtensionEmitter) (installed as the harness's
343/// `agent_emitter`) without borrowing — the emitter must outlive this session
344/// local (it lives for the harness lifetime inside `AgentHarnessOptions`).
345///
346/// `Clone` (B5d): every field is already cheaply clonable (`Arc<PluginKeepalive>`,
347/// `Option<Arc<RegistrySnapshot>>`, `Vec<PathBuf>`, `Option<Arc<ActionBridge>>`),
348/// so the reload routine can clone the live session out of its `Mutex` cell for
349/// local inspection (snapshot/keepalive/loaded_paths) and store a fresh one back
350/// in — without a borrow spanning the store.
351#[derive(Clone)]
352pub struct ExtensionSession {
353    keepalive: Arc<PluginKeepalive>,
354    snapshot: Option<Arc<RegistrySnapshot>>,
355    loaded_paths: Vec<PathBuf>,
356    /// B5a: the session's action bridge (`None` when no plugins / tests / the
357    /// `--no-extensions` path). Kept here so `rpi-cli` can recover it after
358    /// `AgentHarness::create` to call `set_harness` — the bridge's `user_data`
359    /// pointer was already handed out during `register`, so pi-cli must fill the
360    /// host's harness cell immediately after create. Cloning is cheap (an `Arc`
361    /// clone); the keepalive also holds a clone for the lifetime guarantee.
362    action_bridge: Option<Arc<ActionBridge>>,
363}
364
365impl ExtensionSession {
366    /// Assemble a session from already-loaded parts (explicit `--extension`
367    /// files via `load_one` + `merge_registries`). Mirrors `load_session` but
368    /// internal assembly so callers can build a session without a dir scan.
369    pub fn from_parts(
370        snapshot: Arc<RegistrySnapshot>,
371        keepalive: Arc<PluginKeepalive>,
372        loaded_paths: Vec<PathBuf>,
373        action_bridge: Option<Arc<ActionBridge>>,
374    ) -> Self {
375        Self {
376            keepalive,
377            snapshot: Some(snapshot),
378            loaded_paths,
379            action_bridge,
380        }
381    }
382
383    /// An empty session (no plugins loaded — `--no-extensions` or no dirs found).
384    pub fn none() -> Self {
385        Self {
386            keepalive: Arc::new(PluginKeepalive::new(Vec::new(), None)),
387            snapshot: None,
388            loaded_paths: Vec::new(),
389            action_bridge: None,
390        }
391    }
392
393    /// The shared keepalive — clone one per adapter/emitter you build from this
394    /// session so the cdylibs outlive them.
395    pub fn keepalive(&self) -> Arc<PluginKeepalive> {
396        Arc::clone(&self.keepalive)
397    }
398
399    /// The merged registry snapshot (tools/commands/handlers), if any plugin
400    /// loaded. `None` when no plugins loaded successfully. Borrowed view for
401    /// iterating tools/commands; for an owned share (e.g. handing to the
402    /// emitter) use [`snapshot_arc`](Self::snapshot_arc).
403    pub fn snapshot(&self) -> Option<&RegistrySnapshot> {
404        self.snapshot.as_deref()
405    }
406
407    /// A shared (`Arc`) clone of the merged registry snapshot, for host code that
408    /// must keep the snapshot alive beyond this session local — notably the
409    /// [`ExtensionEmitter`](crate::ExtensionEmitter) installed into
410    /// `AgentHarnessOptions.agent_emitter`.
411    pub fn snapshot_arc(&self) -> Option<Arc<RegistrySnapshot>> {
412        self.snapshot.clone()
413    }
414
415    /// Paths of the cdylibs that loaded + registered successfully (diagnostics).
416    pub fn loaded_paths(&self) -> &[PathBuf] {
417        &self.loaded_paths
418    }
419
420    /// Whether any loaded plugin claimed ownership of this host process's stdio
421    /// (`declare {"ownsStdio":true}`). When `true`, the host (pi-cli) must not
422    /// drain stdin before the plugin's `register` has run, and must run the
423    /// headless `Rpc` lifecycle (dispatch BeforeTuiStart + SessionStart, then
424    /// park) instead of Print/TUI so it never writes to the plugin's stdio.
425    /// `false` for `ExtensionSession::none()` and for every non-claiming plugin.
426    pub fn owns_stdio(&self) -> bool {
427        self.snapshot
428            .as_ref()
429            .map(|snapshot| snapshot.owns_stdio())
430            .unwrap_or(false)
431    }
432
433    /// Whether zero plugins loaded.
434    pub fn is_empty(&self) -> bool {
435        self.loaded_paths.is_empty()
436    }
437
438    /// A one-line human summary for `--verbose` startup output, or `None` when
439    /// nothing loaded (so the line is omitted entirely).
440    pub fn summary(&self) -> Option<String> {
441        if self.is_empty() {
442            return None;
443        }
444        let tools = self.snapshot.as_ref().map(|s| s.tools().len()).unwrap_or(0);
445        Some(format!(
446            "loaded {} plugin(s) ({} tool(s))",
447            self.loaded_paths.len(),
448            tools
449        ))
450    }
451
452    /// B5a: the session's action bridge, if one was threaded into `load_*`.
453    /// `rpi-cli` recovers this after `AgentHarness::create` succeeds to call
454    /// `HarnessActionHost::set_harness` (filling the host cell the bridge's
455    /// `user_data`-recovered host reads on the first plugin→host action). The
456    /// bridge pointer was already handed to plugins during `register`, so this
457    /// must happen before any run. `None` when no plugins loaded / tests /
458    /// `--no-extensions`.
459    pub fn action_bridge(&self) -> Option<Arc<ActionBridge>> {
460        self.action_bridge.clone()
461    }
462}
463
464/// Load + register every cdylib in the given dirs (in order, non-recursive),
465/// merge their registries first-wins, and return a session with a shared
466/// keepalive over the `Library` handles + the merged snapshot. Dirs that don't
467/// exist are skipped silently; individual plugin load failures are logged via
468/// `diagnostics` and skipped (one bad plugin doesn't abort the rest).
469///
470/// The order of `dirs` matters: earlier dirs win on tool/command name collision
471/// (pi registration order). Callers pass default dirs first, then `--extensions-dir`
472/// extras, so a same-named tool in a default-dir plugin wins over an extra-dir one.
473///
474/// `action_bridge` (B5a) is cloned into every loaded plugin's vtable so
475/// post-register `runtime_action` calls recover the bridge on any thread.
476/// `rpi-cli` builds one master `Arc<ActionBridge>` per session and passes it
477/// here; `None` keeps the stub (used by tests / `--no-extensions` no-ops).
478pub fn load_session(
479    dirs: &[PathBuf],
480    diagnostics: Arc<dyn PluginDiagnostics>,
481    action_bridge: Option<Arc<ActionBridge>>,
482) -> ExtensionSession {
483    load_session_mixed(dirs, &[], diagnostics, action_bridge)
484}
485
486/// Load plugins from a mix of scanned dirs and explicit cdylib files
487/// (the `--extension`/`-e` CLI paths), assembled into one session. Mirrors
488/// `load_session` but additionally `load_one`s each explicit file.
489pub fn load_session_mixed(
490    dirs: &[PathBuf],
491    files: &[PathBuf],
492    diagnostics: Arc<dyn PluginDiagnostics>,
493    action_bridge: Option<Arc<ActionBridge>>,
494) -> ExtensionSession {
495    let mut loaded: Vec<LoadedPlugin> = Vec::new();
496    let mut seen = std::collections::HashSet::new();
497    let paths = dirs
498        .iter()
499        .flat_map(|dir| scan_dir(dir, diagnostics.as_ref()))
500        .chain(files.iter().cloned());
501    for path in paths {
502        // Repeated configured dirs, explicit files and aliases must register once.
503        let identity = std::fs::canonicalize(&path).unwrap_or_else(|_| path.clone());
504        if !seen.insert(identity) {
505            continue;
506        }
507        match load_one(&path, Arc::clone(&diagnostics), action_bridge.clone()) {
508            Ok(plugin) => loaded.push(plugin),
509            Err(error) => diagnostics.warn(&format!("skipped plugin {}: {error}", path.display())),
510        }
511    }
512    if loaded.is_empty() {
513        return ExtensionSession::none();
514    }
515    let loaded_paths: Vec<PathBuf> = loaded.iter().map(|p| p.path.clone()).collect();
516    let session_registry = merge_registries(&mut loaded);
517    let mut libs: Vec<Library> = Vec::with_capacity(loaded.len());
518    let mut plugin_bridges = Vec::new();
519    for p in loaded {
520        let LoadedPlugin {
521            action_bridge: plugin_bridge,
522            library,
523            registry: _,
524            path: _,
525            abi_version: _,
526        } = p;
527        if let Some(bridge) = plugin_bridge {
528            plugin_bridges.push(bridge);
529        }
530        libs.push(library);
531    }
532    let mut keepalive = PluginKeepalive::new(libs, action_bridge.clone());
533    keepalive.plugin_bridges = plugin_bridges;
534    let snapshot = Arc::new(session_registry.snapshot());
535    ExtensionSession {
536        keepalive: Arc::new(keepalive),
537        snapshot: Some(snapshot),
538        loaded_paths,
539        action_bridge,
540    }
541}
542
543// ---------------------------------------------------------------------------
544// Tests
545// ---------------------------------------------------------------------------
546
547#[cfg(test)]
548mod tests {
549    use super::*;
550    use std::fs;
551    use std::process::Command;
552    use std::sync::atomic::{AtomicI32, AtomicUsize, Ordering};
553    use std::sync::Mutex;
554
555    static UNIFIED_CALLS: AtomicUsize = AtomicUsize::new(0);
556    static UNIFIED_SEEN_VERSION: AtomicUsize = AtomicUsize::new(0);
557    static UNIFIED_RETURN: AtomicI32 = AtomicI32::new(0);
558
559    extern "C" fn test_register_unified(api: *const rpi_plugin_sdk::PluginApi) -> i32 {
560        if api.is_null() {
561            return -99;
562        }
563        UNIFIED_CALLS.fetch_add(1, Ordering::SeqCst);
564        UNIFIED_SEEN_VERSION.store(unsafe { (*api).abi_version as usize }, Ordering::SeqCst);
565        UNIFIED_RETURN.load(Ordering::SeqCst)
566    }
567
568    #[derive(Default)]
569    struct CapturingDiag {
570        warns: Mutex<Vec<String>>,
571    }
572    impl PluginDiagnostics for CapturingDiag {
573        fn warn(&self, msg: &str) {
574            self.warns.lock().unwrap().push(msg.to_string());
575        }
576        fn unsupported(&self, msg: &str) {
577            self.warn(msg);
578        }
579    }
580
581    fn test_host_api() -> Arc<HostApi> {
582        HostApi::new(
583            "test-plugin",
584            ExtensionRegistry::new(),
585            Arc::new(CapturingDiag::default()),
586        )
587    }
588
589    struct CdylibFixture {
590        dir: PathBuf,
591        path: PathBuf,
592    }
593
594    impl Drop for CdylibFixture {
595        fn drop(&mut self) {
596            let _ = fs::remove_dir_all(&self.dir);
597        }
598    }
599
600    fn build_cdylib_fixture(name: &str, source: &str) -> CdylibFixture {
601        static NEXT_FIXTURE: AtomicUsize = AtomicUsize::new(0);
602        let unique = NEXT_FIXTURE.fetch_add(1, Ordering::SeqCst);
603        let dir = std::env::temp_dir().join(format!(
604            "rpi-abi-loader-{}-{name}-{unique}",
605            std::process::id()
606        ));
607        fs::create_dir_all(&dir).expect("create ABI fixture directory");
608        let source_path = dir.join("fixture.rs");
609        fs::write(&source_path, source).expect("write ABI fixture source");
610        let filename = if cfg!(windows) {
611            format!("{name}.dll")
612        } else if cfg!(target_os = "macos") {
613            format!("lib{name}.dylib")
614        } else {
615            format!("lib{name}.so")
616        };
617        let path = dir.join(filename);
618        let output = Command::new(std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into()))
619            .arg("--crate-name")
620            .arg(name)
621            .arg("--crate-type")
622            .arg("cdylib")
623            .arg("--edition")
624            .arg("2021")
625            .arg(&source_path)
626            .arg("-o")
627            .arg(&path)
628            .output()
629            .expect("run rustc for ABI fixture");
630        assert!(
631            output.status.success(),
632            "fixture build failed: {}",
633            String::from_utf8_lossy(&output.stderr)
634        );
635        CdylibFixture { dir, path }
636    }
637
638    #[test]
639    fn loads_unified_plugin() {
640        const PREFIX: &str = "use std::ffi::c_void;\n";
641        let diag: Arc<dyn PluginDiagnostics> = Arc::new(CapturingDiag::default());
642
643        let unified = build_cdylib_fixture(
644            "abi_unified_only",
645            &format!(
646                "{PREFIX}#[no_mangle]\npub extern \"C\" fn rpi_plugin_register(api: *const c_void) -> i32 {{ if !api.is_null() {{ 0 }} else {{ 91 }} }}\n"
647            ),
648        );
649        let loaded = load_one(&unified.path, Arc::clone(&diag), None).expect("load unified plugin");
650        assert_eq!(loaded.abi_version, RPI_PLUGIN_ABI_VERSION_UNIFIED);
651        drop(loaded);
652        drop(unified);
653    }
654
655    #[test]
656    fn unified_registration_failure_is_reported() {
657        const PREFIX: &str = "use std::ffi::c_void;\n";
658        let diag: Arc<dyn PluginDiagnostics> = Arc::new(CapturingDiag::default());
659
660        let failed = build_cdylib_fixture(
661            "abi_unified_failure",
662            &format!(
663                "{PREFIX}#[no_mangle]\npub extern \"C\" fn rpi_plugin_register(_: *const c_void) -> i32 {{ 73 }}\n"
664            ),
665        );
666        let error = match load_one(&failed.path, diag, None) {
667            Ok(_) => panic!("failed registration must be reported as error"),
668            Err(error) => error,
669        };
670        assert!(matches!(
671            error,
672            PluginLoadError::RegisterReturned { code: 73, .. }
673        ));
674        drop(failed);
675    }
676
677    /// A plugin whose `extern "C"` register body panics must be skipped, not
678    /// crash the host. This mirrors what the SDK's `export_plugin!` /
679    /// `register_entrypoint` do: catch the panic *inside* the plugin and return
680    /// `REGISTER_PANIC_STATUS` so the unwind never reaches the `extern "C"`
681    /// boundary (which would abort the process).
682    #[test]
683    fn panicking_register_body_is_contained_and_skipped() {
684        let diag = Arc::new(CapturingDiag::default());
685        let diag_dyn: Arc<dyn PluginDiagnostics> = diag.clone();
686        let panic_status = rpi_plugin_sdk::REGISTER_PANIC_STATUS;
687        const PREFIX: &str = "use std::ffi::c_void;\n";
688        let source = format!(
689            r#"{PREFIX}
690fn body() -> i32 {{ panic!("register body exploded"); }}
691#[no_mangle]
692pub extern "C" fn rpi_plugin_register(_: *const c_void) -> i32 {{
693    match std::panic::catch_unwind(std::panic::AssertUnwindSafe(body)) {{
694        Ok(code) => code,
695        Err(_) => {panic_status},
696    }}
697}}
698"#
699        );
700        let fixture = build_cdylib_fixture("abi_unified_panic", &source);
701        let prev_hook = std::panic::take_hook();
702        std::panic::set_hook(Box::new(|_| {}));
703        let result = load_one(&fixture.path, diag_dyn, None);
704        std::panic::set_hook(prev_hook);
705
706        match result {
707            Ok(_) => panic!("a panicking register must be skipped, not loaded"),
708            Err(PluginLoadError::RegisterReturned { code, .. }) => {
709                assert_eq!(code, panic_status);
710            }
711            Err(other) => panic!("expected RegisterReturned, got {other:?}"),
712        }
713        let warns = diag.warns.lock().unwrap().clone();
714        assert!(
715            warns
716                .iter()
717                .any(|w| w.contains("panicked during registration")),
718            "diagnostic should mention the panic: {warns:?}"
719        );
720        drop(fixture);
721    }
722
723    #[test]
724    fn repeated_discovery_and_explicit_alias_register_the_library_once() {
725        let fixture = build_cdylib_fixture(
726            "register_once",
727            r#"
728use std::sync::atomic::{AtomicUsize, Ordering};
729static COUNT: AtomicUsize = AtomicUsize::new(0);
730#[no_mangle]
731pub extern "C" fn rpi_plugin_register(_: *const std::ffi::c_void) -> i32 {
732    if COUNT.fetch_add(1, Ordering::SeqCst) == 0 { 0 } else { 73 }
733}
734"#,
735        );
736        let diag = Arc::new(CapturingDiag::default());
737        let alias = fixture
738            .dir
739            .join(".")
740            .join(fixture.path.file_name().unwrap());
741        let session = load_session_mixed(
742            &[fixture.dir.clone(), fixture.dir.clone()],
743            &[alias],
744            diag.clone(),
745            None,
746        );
747        assert_eq!(session.loaded_paths().len(), 1);
748        assert!(diag.warns.lock().unwrap().is_empty());
749        drop(session);
750    }
751
752    #[test]
753    fn load_one_missing_file_reports_open_error() {
754        let diag: Arc<dyn PluginDiagnostics> = Arc::new(CapturingDiag::default());
755        let res = load_one("definitely_not_a_plugin.dll", diag, None);
756        assert!(matches!(res, Err(PluginLoadError::Open { .. })));
757    }
758
759    #[test]
760    fn load_dir_missing_dir_returns_empty_and_warns() {
761        let empty = load_dir(
762            "no_such_dir_xyz",
763            Arc::new(CapturingDiag::default()) as Arc<dyn PluginDiagnostics>,
764            None,
765        );
766        assert!(empty.is_empty());
767    }
768
769    #[test]
770    fn is_cdylib_recognizes_extensions() {
771        assert!(is_cdylib(Path::new("foo.dll")));
772        assert!(is_cdylib(Path::new("foo.so")));
773        assert!(is_cdylib(Path::new("foo.dylib")));
774        assert!(is_cdylib(Path::new("FOO.DLL")));
775        assert!(!is_cdylib(Path::new("foo.md")));
776        assert!(!is_cdylib(Path::new("foo")));
777    }
778}
779
780// Silence the unused-default-import warning for NullDiagnostics re-exported by the crate.
781#[allow(dead_code)]
782fn _ensure_nulldiagnostics_referenced() -> Arc<dyn PluginDiagnostics> {
783    Arc::new(NullDiagnostics)
784}