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}