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}