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