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, looks up `rpi_plugin_register`, builds a
4//! fresh [`HostApi`](crate::HostApi) + [`PluginApiVt`](rpi_plugin_sdk::PluginApiVt),
5//! sets the thread-local current api, calls `register` with
6//! [`RPI_PLUGIN_ABI_VERSION`](rpi_plugin_sdk::RPI_PLUGIN_ABI_VERSION), clears
7//! the api, and takes the registry out. ABI mismatch or a nonzero register
8//! return ⇒ the plugin is skipped with a diagnostic (never crashes).
9//!
10//! [`load_dir`] walks a directory for `.{dll,so,dylib}` files and loads each.
11//! The returned [`LoadedPlugin`]s hold the `libloading::Library` (dropping them
12//! unloads the cdylib — keep them alive for the session lifetime).
13
14use std::ffi::OsStr;
15use std::path::{Path, PathBuf};
16use std::sync::Arc;
17
18use libloading::{Library, Symbol};
19use thiserror::Error;
20
21use rpi_plugin_sdk::{PluginApiVt, RpiPluginRegister, RPI_PLUGIN_ABI_VERSION};
22
23use crate::registry::{ExtensionRegistry, RegistrySnapshot};
24use crate::{
25    clear_current_api, set_current_api, ActionBridge, HostApi, NullDiagnostics, PluginDiagnostics,
26};
27
28// ---------------------------------------------------------------------------
29// Errors
30// ---------------------------------------------------------------------------
31
32/// Error / skip reason from loading one plugin. `Skip` variants are non-fatal
33/// (logged via diagnostics); `Fatal` means the load itself failed.
34#[derive(Debug, Error)]
35pub enum PluginLoadError {
36    #[error("could not open library {path}: {source}")]
37    Open {
38        path: PathBuf,
39        #[source]
40        source: libloading::Error,
41    },
42    #[error("symbol `rpi_plugin_register` not found in {path}: {source}")]
43    Symbol {
44        path: PathBuf,
45        #[source]
46        source: libloading::Error,
47    },
48    #[error("register returned nonzero code {code} for {path}")]
49    RegisterReturned { path: PathBuf, code: i32 },
50    /// ABI version reported by the plugin mismatches the host's. Skip + diag.
51    #[error(
52        "ABI version mismatch in {path}: plugin built for {plugin_version}, host is {host_version}"
53    )]
54    AbiVersionMismatch {
55        path: PathBuf,
56        plugin_version: u32,
57        host_version: u32,
58    },
59}
60
61// ---------------------------------------------------------------------------
62// LoadedPlugin — the live cdylib handle + where it came from
63// ---------------------------------------------------------------------------
64
65/// A successfully loaded + registered plugin. Holds the `Library` so the cdylib
66/// stays mapped for the session. Dropping this unloads the plugin (do not drop
67/// while any of its tool drivers may still be running).
68pub struct LoadedPlugin {
69    /// The loaded cdylib. Kept alive for the session.
70    pub library: Library,
71    /// Where it was loaded from (for diagnostics).
72    pub path: PathBuf,
73    /// The registry snapshot built from this plugin's registrations. The host
74    /// merges snapshots from all loaded plugins into one session registry.
75    pub registry: ExtensionRegistry,
76}
77
78// ---------------------------------------------------------------------------
79// load_one
80// ---------------------------------------------------------------------------
81
82/// Load and register one cdylib plugin. Returns the live plugin + its
83/// registry, or a [`PluginLoadError`] (skip-fatality distinction is on the
84/// caller; both are logged via `diagnostics`).
85///
86/// `diagnostics` is the host sink for ABI-mismatch/unsupported warnings. The
87/// loader creates a fresh `ExtensionRegistry` for THIS plugin (so a plugin that
88/// fails partway can't pollute others), and the caller merges per-plugin
89/// registries into the session registry in load-order (first-wins on name).
90///
91/// `action_bridge` (B5a): when `Some`, the plugin's vtable wires the real
92/// [`trampoline_runtime_action`] and carries the bridge in `user_data`, so the
93/// plugin can invoke host runtime actions post-register from any thread. `None`
94/// keeps the v1 stub (actions return `-1`). `rpi-cli` builds ONE master
95/// `Arc<ActionBridge>` per session and clones it into every `load_one` — every
96/// plugin's `user_data` points at the same bridge (Arc-ptr-stable, kept alive
97/// by `rpi-cli` for the harness lifetime).
98pub fn load_one(
99    path: impl AsRef<Path>,
100    diagnostics: Arc<dyn PluginDiagnostics>,
101    action_bridge: Option<Arc<ActionBridge>>,
102) -> Result<LoadedPlugin, PluginLoadError> {
103    let path = path.as_ref().to_path_buf();
104    // 1. Open the cdylib.
105    let library = unsafe { Library::new(&path) }.map_err(|e| PluginLoadError::Open {
106        path: path.clone(),
107        source: e,
108    })?;
109
110    // 2. Look up `rpi_plugin_register`.
111    let register: Symbol<RpiPluginRegister> = unsafe {
112        library.get(rpi_plugin_sdk::REGISTER_SYMBOL)
113    }
114    .map_err(|e| PluginLoadError::Symbol {
115        path: path.clone(),
116        source: e,
117    })?;
118
119    // 3. Build a fresh registry + HostApi + vtable for this plugin.
120    let registry = ExtensionRegistry::new();
121    let host_api = match action_bridge {
122        Some(bridge) => HostApi::with_action_bridge(registry, Arc::clone(&diagnostics), bridge),
123        None => HostApi::new(registry, Arc::clone(&diagnostics)),
124    };
125    let vtable = host_api.build_vtable();
126
127    // 4. Set the thread-local current api so the register trampolines can reach
128    //    the registry. Register is synchronous + single-threaded per plugin.
129    // SAFETY: `host_api` is alive for the duration of the register call (held on
130    // this stack); we clear_current_api immediately after.
131    unsafe { set_current_api(&host_api) };
132    // Keep the vtable reference alive across the call (the plugin borrows it).
133    let vt_ref: &PluginApiVt = &vtable;
134    // Call register; a panic inside the plugin's extern "C" fn would unwind
135    // across FFI — catch_unwind contains that (register runs on the loader
136    // thread, not a blocking-driver thread, so recovery is safe here; we log
137    // + treat as skip). The vtable pointer is valid (vt_ref lives on this stack).
138    let register_outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
139        register(vt_ref as *const PluginApiVt, RPI_PLUGIN_ABI_VERSION)
140    }));
141    clear_current_api();
142
143    let rc = match register_outcome {
144        Ok(rc) => rc,
145        Err(_) => {
146            diagnostics.warn(&format!(
147                "plugin {} register panicked — skipped (unwind contained)",
148                path.display()
149            ));
150            return Err(PluginLoadError::RegisterReturned { path, code: -1 });
151        }
152    };
153
154    if rc != 0 {
155        // The plugin refused (its register returned nonzero — e.g. it saw an
156        // ABI version it didn't like). Skip + diag. The registry may have
157        // partial registrations; we drop it (no harm — the tools registered so
158        // far would reference a plugin that "failed", so we honor the plugin's
159        // refusal and discard).
160        diagnostics.warn(&format!(
161            "plugin {} register returned code {} — skipped",
162            path.display(),
163            rc
164        ));
165        return Err(PluginLoadError::RegisterReturned { path, code: rc });
166    }
167
168    // 5. Take the registry out of the HostApi. The host keeps the Library alive
169    //    (LoadedPlugin) so the plugin's code + static data remain mapped; the
170    //    registry holds fn pointers into that code.
171    let registry = host_api
172        .take_registry()
173        .ok_or_else(|| PluginLoadError::RegisterReturned {
174            path: path.clone(),
175            code: -2,
176        })?;
177
178    Ok(LoadedPlugin {
179        library,
180        path,
181        registry,
182    })
183}
184
185// ---------------------------------------------------------------------------
186// load_dir
187// ---------------------------------------------------------------------------
188
189/// Platform cdylib extensions.
190const CDYLIB_EXTS: &[&str] = &["dll", "so", "dylib", "pyd"];
191
192/// Load every cdylib in `dir` (non-recursive). Each load failure is logged via
193/// `diagnostics` and skipped (one bad plugin doesn't abort the rest). Returns
194/// the successfully loaded plugins in directory order.
195///
196/// `action_bridge` (B5a) is cloned into each loaded plugin's vtable `user_data`
197/// so post-register `runtime_action` calls recover the bridge on any thread.
198pub fn load_dir(
199    dir: impl AsRef<Path>,
200    diagnostics: Arc<dyn PluginDiagnostics>,
201    action_bridge: Option<Arc<ActionBridge>>,
202) -> Vec<LoadedPlugin> {
203    let dir = dir.as_ref();
204    let mut out = Vec::new();
205    let read = match std::fs::read_dir(dir) {
206        Ok(r) => r,
207        Err(e) => {
208            diagnostics.warn(&format!(
209                "extensions dir {} unreadable: {}",
210                dir.display(),
211                e
212            ));
213            return out;
214        }
215    };
216    for entry in read.flatten() {
217        let path = entry.path();
218        if !is_cdylib(&path) {
219            continue;
220        }
221        match load_one(&path, Arc::clone(&diagnostics), action_bridge.clone()) {
222            Ok(p) => out.push(p),
223            Err(e) => diagnostics.warn(&format!("skipped plugin {}: {e}", path.display())),
224        }
225    }
226    out
227}
228
229/// Whether `path`'s extension is a known cdylib extension.
230fn is_cdylib(path: &Path) -> bool {
231    path.extension()
232        .and_then(OsStr::to_str)
233        .map(|ext| CDYLIB_EXTS.iter().any(|e| e.eq_ignore_ascii_case(ext)))
234        .unwrap_or(false)
235}
236
237/// Convenience: merge a slice of per-plugin [`LoadedPlugin`] registries into one
238/// session registry, first-wins on name (mirrors pi's cross-extension
239/// registration order). Consumes the registries (the `LoadedPlugin`s themselves
240/// stay alive — callers keep the `Library` handles).
241pub fn merge_registries(plugins: &mut [LoadedPlugin]) -> ExtensionRegistry {
242    let mut session = ExtensionRegistry::new();
243    // We can't move registries out of LoadedPlugin without taking them; borrow
244    // mutably and drain into the session. Since ExtensionRegistry's registrars
245    // consume by value, we rebuild from the snapshot instead.
246    // Simpler: snapshot each, then re-register by iteration. But ExtensionRegistry
247    // has no public "absorb another registry" — so we drain tools/commands/handlers
248    // via internal access. For v1 we expose the typed fields via crate-internal
249    // methods on ExtensionRegistry used only here.
250    for p in plugins.iter_mut() {
251        // Take the plugin's registry out (LoadedPlugin keeps the Library).
252        let taken = std::mem::take(&mut p.registry);
253        session.absorb(taken);
254    }
255    session
256}
257
258// ---------------------------------------------------------------------------
259// PluginKeepalive + ExtensionSession — the host session's plugin lifetime
260// ---------------------------------------------------------------------------
261
262/// Owns the loaded `Library` handles so the cdylibs stay mapped for as long as
263/// any registered tool/handler (whose fn pointers live inside the cdylib) may be
264/// called. Shared via `Arc`: every [`PluginToolAdapter`](crate::PluginToolAdapter)
265/// (and, in B3, the [`ExtensionEmitter`](crate::ExtensionEmitter)) holds a clone,
266/// so the libraries unload only when the last holder drops — which is never
267/// before the harness's tool vec (and thus the last possible tool call) drops.
268///
269/// `libloading::Library` is `Send + Sync` (a handle/HMODULE), so the keepalive is
270/// too — required because `AgentTool: Send + Sync` and the adapter carries it.
271pub struct PluginKeepalive {
272    #[allow(dead_code)]
273    libraries: Vec<Library>,
274    /// B5a: the session's action bridge. Retained here so the raw pointer a
275    /// plugin stored in its vtable `user_data` (`Arc::as_ptr`) stays valid for
276    /// the harness lifetime — every `PluginToolAdapter` + the `ExtensionEmitter`
277    /// clone the keepalive, so the bridge outlives any plugin→host
278    /// `runtime_action` call. `None` under `--no-extensions`, when zero plugins
279    /// loaded, or in tests.
280    #[allow(dead_code)]
281    action_bridge: Option<Arc<ActionBridge>>,
282}
283
284impl PluginKeepalive {
285    /// Build a keepalive. `pub` so tests + host code can construct an empty
286    /// one (no loaded cdylibs) where the plugin lifecycle is exercised without
287    /// real plugins.
288    pub fn new(libraries: Vec<Library>, action_bridge: Option<Arc<ActionBridge>>) -> Self {
289        Self {
290            libraries,
291            action_bridge,
292        }
293    }
294
295    /// An empty keepalive owning no libraries — for host code that builds an
296    /// adapter outside a real load session (notably in-process tests of the
297    /// adapter against stub fns that live in the test binary, not a cdylib).
298    pub fn empty() -> Arc<Self> {
299        Arc::new(Self::new(Vec::new(), None))
300    }
301}
302
303/// The result of loading a session's worth of extensions: a shared keepalive for
304/// the cdylib handles + a snapshot of the merged registry. Built by
305/// [`load_session`]; the host (pi-cli) stashes one per harness build and hands
306/// clones of the keepalive to each adapter it constructs from the snapshot.
307///
308/// The snapshot is held behind `Arc` so the host can hand a clone to the
309/// [`ExtensionEmitter`](crate::ExtensionEmitter) (installed as the harness's
310/// `agent_emitter`) without borrowing — the emitter must outlive this session
311/// local (it lives for the harness lifetime inside `AgentHarnessOptions`).
312///
313/// `Clone` (B5d): every field is already cheaply clonable (`Arc<PluginKeepalive>`,
314/// `Option<Arc<RegistrySnapshot>>`, `Vec<PathBuf>`, `Option<Arc<ActionBridge>>`),
315/// so the reload routine can clone the live session out of its `Mutex` cell for
316/// local inspection (snapshot/keepalive/loaded_paths) and store a fresh one back
317/// in — without a borrow spanning the store.
318#[derive(Clone)]
319pub struct ExtensionSession {
320    keepalive: Arc<PluginKeepalive>,
321    snapshot: Option<Arc<RegistrySnapshot>>,
322    loaded_paths: Vec<PathBuf>,
323    /// B5a: the session's action bridge (`None` when no plugins / tests / the
324    /// `--no-extensions` path). Kept here so `rpi-cli` can recover it after
325    /// `AgentHarness::create` to call `set_harness` — the bridge's `user_data`
326    /// pointer was already handed out during `register`, so pi-cli must fill the
327    /// host's harness cell immediately after create. Cloning is cheap (an `Arc`
328    /// clone); the keepalive also holds a clone for the lifetime guarantee.
329    action_bridge: Option<Arc<ActionBridge>>,
330}
331
332impl ExtensionSession {
333    /// Assemble a session from already-loaded parts (explicit `--extension`
334    /// files via `load_one` + `merge_registries`). Mirrors `load_session`'s
335    /// internal assembly so callers can build a session without a dir scan.
336    pub fn from_parts(
337        snapshot: Arc<RegistrySnapshot>,
338        keepalive: Arc<PluginKeepalive>,
339        loaded_paths: Vec<PathBuf>,
340        action_bridge: Option<Arc<ActionBridge>>,
341    ) -> Self {
342        Self {
343            keepalive,
344            snapshot: Some(snapshot),
345            loaded_paths,
346            action_bridge,
347        }
348    }
349
350    /// An empty session (no plugins loaded — `--no-extensions` or no dirs found).
351    pub fn none() -> Self {
352        Self {
353            keepalive: Arc::new(PluginKeepalive::new(Vec::new(), None)),
354            snapshot: None,
355            loaded_paths: Vec::new(),
356            action_bridge: None,
357        }
358    }
359
360    /// The shared keepalive — clone one per adapter/emitter you build from this
361    /// session so the cdylibs outlive them.
362    pub fn keepalive(&self) -> Arc<PluginKeepalive> {
363        Arc::clone(&self.keepalive)
364    }
365
366    /// The merged registry snapshot (tools/commands/handlers), if any plugin
367    /// loaded. `None` when no plugins loaded successfully. Borrowed view for
368    /// iterating tools/commands; for an owned share (e.g. handing to the
369    /// emitter) use [`snapshot_arc`](Self::snapshot_arc).
370    pub fn snapshot(&self) -> Option<&RegistrySnapshot> {
371        self.snapshot.as_deref()
372    }
373
374    /// A shared (`Arc`) clone of the merged registry snapshot, for host code that
375    /// must keep the snapshot alive beyond this session local — notably the
376    /// [`ExtensionEmitter`](crate::ExtensionEmitter) installed into
377    /// `AgentHarnessOptions.agent_emitter`.
378    pub fn snapshot_arc(&self) -> Option<Arc<RegistrySnapshot>> {
379        self.snapshot.clone()
380    }
381
382    /// Paths of the cdylibs that loaded + registered successfully (diagnostics).
383    pub fn loaded_paths(&self) -> &[PathBuf] {
384        &self.loaded_paths
385    }
386
387    /// Whether zero plugins loaded.
388    pub fn is_empty(&self) -> bool {
389        self.loaded_paths.is_empty()
390    }
391
392    /// A one-line human summary for `--verbose` startup output, or `None` when
393    /// nothing loaded (so the line is omitted entirely).
394    pub fn summary(&self) -> Option<String> {
395        if self.is_empty() {
396            return None;
397        }
398        let tools = self.snapshot.as_ref().map(|s| s.tools().len()).unwrap_or(0);
399        Some(format!(
400            "loaded {} plugin(s) ({} tool(s))",
401            self.loaded_paths.len(),
402            tools
403        ))
404    }
405
406    /// B5a: the session's action bridge, if one was threaded into `load_*`.
407    /// `rpi-cli` recovers this after `AgentHarness::create` succeeds to call
408    /// `HarnessActionHost::set_harness` (filling the host cell the bridge's
409    /// `user_data`-recovered host reads on the first plugin→host action). The
410    /// bridge pointer was already handed to plugins during `register`, so this
411    /// must happen before any run. `None` when no plugins loaded / tests /
412    /// `--no-extensions`.
413    pub fn action_bridge(&self) -> Option<Arc<ActionBridge>> {
414        self.action_bridge.clone()
415    }
416}
417
418/// Load + register every cdylib in the given dirs (in order, non-recursive),
419/// merge their registries first-wins, and return a session with a shared
420/// keepalive over the `Library` handles + the merged snapshot. Dirs that don't
421/// exist are skipped silently; individual plugin load failures are logged via
422/// `diagnostics` and skipped (one bad plugin doesn't abort the rest).
423///
424/// The order of `dirs` matters: earlier dirs win on tool/command name collision
425/// (pi registration order). Callers pass default dirs first, then `--extensions-dir`
426/// extras, so a same-named tool in a default-dir plugin wins over an extra-dir one.
427///
428/// `action_bridge` (B5a) is cloned into every loaded plugin's vtable so
429/// post-register `runtime_action` calls recover the bridge on any thread.
430/// `rpi-cli` builds one master `Arc<ActionBridge>` per session and passes it
431/// here; `None` keeps the v1 stub (used by tests / `--no-extensions` no-ops).
432pub fn load_session(
433    dirs: &[PathBuf],
434    diagnostics: Arc<dyn PluginDiagnostics>,
435    action_bridge: Option<Arc<ActionBridge>>,
436) -> ExtensionSession {
437    load_session_mixed(dirs, &[], diagnostics, action_bridge)
438}
439
440/// Load plugins from a mix of scanned dirs and explicit cdylib files
441/// (the `--extension`/`-e` CLI paths), assembled into one session. Mirrors
442/// `load_session` but additionally `load_one`s each explicit file.
443pub fn load_session_mixed(
444    dirs: &[PathBuf],
445    files: &[PathBuf],
446    diagnostics: Arc<dyn PluginDiagnostics>,
447    action_bridge: Option<Arc<ActionBridge>>,
448) -> ExtensionSession {
449    let mut loaded: Vec<LoadedPlugin> = Vec::new();
450    for dir in dirs {
451        loaded.extend(load_dir(
452            dir,
453            Arc::clone(&diagnostics),
454            action_bridge.clone(),
455        ));
456    }
457    for f in files {
458        if let Ok(plugin) = load_one(f, Arc::clone(&diagnostics), action_bridge.clone()) {
459            loaded.push(plugin);
460        }
461    }
462    if loaded.is_empty() {
463        return ExtensionSession::none();
464    }
465    let loaded_paths: Vec<PathBuf> = loaded.iter().map(|p| p.path.clone()).collect();
466    // Merge the per-plugin registries first-wins. This drains each `registry`
467    // field (via mem::take inside `absorb`) but leaves `library` intact, so we
468    // can then destructure-own each Library into the keepalive below.
469    let session_registry = merge_registries(&mut loaded);
470    // Now move each Library out of its (registry-hollowed) LoadedPlugin by struct
471    // destructuring, collecting them into the keepalive. `registry`/`path` were
472    // left valid-but-empty / cloned already, and `library` is a move into `libs`.
473    let mut libs: Vec<Library> = Vec::with_capacity(loaded.len());
474    for p in loaded {
475        let LoadedPlugin {
476            library,
477            registry: _,
478            path: _,
479        } = p;
480        libs.push(library);
481    }
482    let snapshot = Arc::new(session_registry.snapshot());
483    ExtensionSession {
484        keepalive: Arc::new(PluginKeepalive::new(libs, action_bridge.clone())),
485        snapshot: Some(snapshot),
486        loaded_paths,
487        action_bridge,
488    }
489}
490
491// ---------------------------------------------------------------------------
492// Tests
493// ---------------------------------------------------------------------------
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498    use std::sync::Mutex;
499
500    #[derive(Default)]
501    struct CapturingDiag {
502        warns: Mutex<Vec<String>>,
503    }
504    impl PluginDiagnostics for CapturingDiag {
505        fn warn(&self, msg: &str) {
506            self.warns.lock().unwrap().push(msg.to_string());
507        }
508        fn unsupported(&self, msg: &str) {
509            self.warn(msg);
510        }
511    }
512
513    #[test]
514    fn load_one_missing_file_reports_open_error() {
515        let diag: Arc<dyn PluginDiagnostics> = Arc::new(CapturingDiag::default());
516        let res = load_one("definitely_not_a_plugin.dll", diag, None);
517        assert!(matches!(res, Err(PluginLoadError::Open { .. })));
518    }
519
520    #[test]
521    fn load_dir_missing_dir_returns_empty_and_warns() {
522        let empty = load_dir(
523            "no_such_dir_xyz",
524            Arc::new(CapturingDiag::default()) as Arc<dyn PluginDiagnostics>,
525            None,
526        );
527        assert!(empty.is_empty());
528    }
529
530    #[test]
531    fn is_cdylib_recognizes_extensions() {
532        assert!(is_cdylib(Path::new("foo.dll")));
533        assert!(is_cdylib(Path::new("foo.so")));
534        assert!(is_cdylib(Path::new("foo.dylib")));
535        assert!(is_cdylib(Path::new("FOO.DLL")));
536        assert!(!is_cdylib(Path::new("foo.md")));
537        assert!(!is_cdylib(Path::new("foo")));
538    }
539}
540
541// Silence the unused-default-import warning for NullDiagnostics re-exported by the crate.
542#[allow(dead_code)]
543fn _ensure_nulldiagnostics_referenced() -> Arc<dyn PluginDiagnostics> {
544    Arc::new(NullDiagnostics)
545}