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}