Skip to main content

rpi_extensions/
loader.rs

1//! The `libloading` loader: discover and register cdylib plugins.
2//!
3//! [`load_one`] loads a single cdylib and prefers `rpi_plugin_register_v2`.
4//! Only when that symbol is absent does it use legacy `rpi_plugin_register`.
5//! The selected entrypoint is called exactly once; a nonzero return never
6//! triggers fallback to the other ABI.
7//!
8//! [`load_dir`] walks a directory for `.{dll,so,dylib}` files and loads each.
9//! The returned [`LoadedPlugin`]s hold the `libloading::Library` (dropping them
10//! unloads the cdylib — keep them alive for the session lifetime).
11
12use std::ffi::OsStr;
13use std::path::{Path, PathBuf};
14use std::sync::Arc;
15
16use libloading::Library;
17use thiserror::Error;
18
19use rpi_plugin_sdk::{
20    LegacyPluginApiV1, LegacyRpiPluginRegister, PluginApiVt, RpiPluginRegister,
21    LEGACY_PLUGIN_ABI_VERSION, RPI_PLUGIN_ABI_VERSION,
22};
23
24use crate::registry::{ExtensionRegistry, RegistrySnapshot};
25use crate::{
26    clear_current_api, set_current_api, ActionBridge, HostApi, NullDiagnostics, PluginDiagnostics,
27};
28
29// ---------------------------------------------------------------------------
30// Errors
31// ---------------------------------------------------------------------------
32
33/// Error / skip reason from loading one plugin. `Skip` variants are non-fatal
34/// (logged via diagnostics); `Fatal` means the load itself failed.
35#[derive(Debug, Error)]
36pub enum PluginLoadError {
37    #[error("could not open library {path}: {source}")]
38    Open {
39        path: PathBuf,
40        #[source]
41        source: libloading::Error,
42    },
43    #[error(
44        "neither `rpi_plugin_register_v2` nor legacy `rpi_plugin_register` was found in {path} (v2: {v2_error}; v1: {legacy_error})"
45    )]
46    Symbol {
47        path: PathBuf,
48        v2_error: String,
49        legacy_error: String,
50    },
51    #[error("register returned nonzero code {code} for {path}")]
52    RegisterReturned { path: PathBuf, code: i32 },
53    /// ABI version reported by the plugin mismatches the host's. Skip + diag.
54    #[error(
55        "ABI version mismatch in {path}: plugin built for {plugin_version}, host is {host_version}"
56    )]
57    AbiVersionMismatch {
58        path: PathBuf,
59        plugin_version: u32,
60        host_version: u32,
61    },
62}
63
64// ---------------------------------------------------------------------------
65// LoadedPlugin — the live cdylib handle + where it came from
66// ---------------------------------------------------------------------------
67
68/// A successfully loaded + registered plugin. Holds the `Library` so the cdylib
69/// stays mapped for the session. Dropping this unloads the plugin (do not drop
70/// while any of its tool drivers may still be running).
71pub struct LoadedPlugin {
72    /// The loaded cdylib. Kept alive for the session.
73    pub library: Library,
74    /// Where it was loaded from (for diagnostics).
75    pub path: PathBuf,
76    /// ABI selected from the exported register symbol (`1` or `2`).
77    pub abi_version: u32,
78    /// The registry snapshot built from this plugin's registrations. The host
79    /// merges snapshots from all loaded plugins into one session registry.
80    pub registry: ExtensionRegistry,
81}
82
83#[derive(Clone, Copy)]
84enum RegisterEntrypoint {
85    V2(RpiPluginRegister),
86    V1(LegacyRpiPluginRegister),
87}
88
89impl RegisterEntrypoint {
90    fn abi_version(self) -> u32 {
91        match self {
92            Self::V2(_) => RPI_PLUGIN_ABI_VERSION,
93            Self::V1(_) => LEGACY_PLUGIN_ABI_VERSION,
94        }
95    }
96}
97
98fn select_register<V, L, E>(
99    v2: Result<V, E>,
100    legacy: impl FnOnce() -> Result<L, E>,
101) -> Result<Result<V, L>, (E, E)> {
102    match v2 {
103        Ok(register) => Ok(Ok(register)),
104        Err(v2_error) => match legacy() {
105            Ok(register) => Ok(Err(register)),
106            Err(legacy_error) => Err((v2_error, legacy_error)),
107        },
108    }
109}
110
111fn call_register(entrypoint: RegisterEntrypoint, host_api: &Arc<HostApi>) -> i32 {
112    match entrypoint {
113        RegisterEntrypoint::V2(register) => {
114            let vtable = host_api.build_vtable();
115            let vt_ref: &PluginApiVt = &vtable;
116            register(vt_ref as *const PluginApiVt, RPI_PLUGIN_ABI_VERSION)
117        }
118        RegisterEntrypoint::V1(register) => {
119            let vtable = host_api.build_legacy_vtable();
120            let vt_ref: &LegacyPluginApiV1 = &vtable;
121            register(
122                vt_ref as *const LegacyPluginApiV1,
123                LEGACY_PLUGIN_ABI_VERSION,
124            )
125        }
126    }
127}
128
129// ---------------------------------------------------------------------------
130// load_one
131// ---------------------------------------------------------------------------
132
133/// Load and register one cdylib plugin. Returns the live plugin + its
134/// registry, or a [`PluginLoadError`] (skip-fatality distinction is on the
135/// caller; both are logged via `diagnostics`).
136///
137/// `diagnostics` is the host sink for ABI-mismatch/unsupported warnings. The
138/// loader creates a fresh `ExtensionRegistry` for THIS plugin (so a plugin that
139/// fails partway can't pollute others), and the caller merges per-plugin
140/// registries into the session registry in load-order (first-wins on name).
141///
142/// `action_bridge` (B5a): when `Some`, the plugin's vtable wires the real
143/// [`trampoline_runtime_action`] and carries the bridge in `user_data`, so the
144/// plugin can invoke host runtime actions post-register from any thread. `None`
145/// keeps the v1 stub (actions return `-1`). `rpi-cli` builds ONE master
146/// `Arc<ActionBridge>` per session and clones it into every `load_one` — every
147/// plugin's `user_data` points at the same bridge (Arc-ptr-stable, kept alive
148/// by `rpi-cli` for the harness lifetime).
149pub fn load_one(
150    path: impl AsRef<Path>,
151    diagnostics: Arc<dyn PluginDiagnostics>,
152    action_bridge: Option<Arc<ActionBridge>>,
153) -> Result<LoadedPlugin, PluginLoadError> {
154    let path = path.as_ref().to_path_buf();
155    // 1. Open the cdylib.
156    let library = unsafe { Library::new(&path) }.map_err(|e| PluginLoadError::Open {
157        path: path.clone(),
158        source: e,
159    })?;
160
161    // 2. Prefer ABI v2. The legacy lookup is lazy, so a plugin exporting both
162    // symbols is unambiguously v2 and the legacy path is not even consulted.
163    let entrypoint = unsafe {
164        select_register(
165            library
166                .get::<RpiPluginRegister>(rpi_plugin_sdk::REGISTER_SYMBOL_V2)
167                .map(|symbol| *symbol),
168            || {
169                library
170                    .get::<LegacyRpiPluginRegister>(rpi_plugin_sdk::LEGACY_REGISTER_SYMBOL)
171                    .map(|symbol| *symbol)
172            },
173        )
174    }
175    .map(|selected| match selected {
176        Ok(register) => RegisterEntrypoint::V2(register),
177        Err(register) => RegisterEntrypoint::V1(register),
178    })
179    .map_err(|(v2_error, legacy_error)| PluginLoadError::Symbol {
180        path: path.clone(),
181        v2_error: v2_error.to_string(),
182        legacy_error: legacy_error.to_string(),
183    })?;
184    let abi_version = entrypoint.abi_version();
185
186    // 3. Build a fresh registry + HostApi + vtable for this plugin.
187    let registry = ExtensionRegistry::new();
188    let host_api = match action_bridge {
189        Some(bridge) => HostApi::with_action_bridge(registry, Arc::clone(&diagnostics), bridge),
190        None => HostApi::new(registry, Arc::clone(&diagnostics)),
191    };
192    // 4. Set the thread-local current api so the register trampolines can reach
193    //    the registry. Register is synchronous + single-threaded per plugin.
194    // SAFETY: `host_api` is alive for the duration of the register call (held on
195    // this stack); we clear_current_api immediately after.
196    unsafe { set_current_api(&host_api) };
197    // The selected symbol is invoked exactly once. In particular, a nonzero v2
198    // result does not fall back to v1, because registration may have produced
199    // side effects before returning. A panic from an `extern "C"` plugin is not
200    // recoverable in general and is deliberately not advertised as contained.
201    let rc = call_register(entrypoint, &host_api);
202    clear_current_api();
203
204    if rc != 0 {
205        // The plugin refused (its register returned nonzero — e.g. it saw an
206        // ABI version it didn't like). Skip + diag. The registry may have
207        // partial registrations; we drop it (no harm — the tools registered so
208        // far would reference a plugin that "failed", so we honor the plugin's
209        // refusal and discard).
210        diagnostics.warn(&format!(
211            "plugin {} ABI v{} register returned code {} — skipped",
212            path.display(),
213            abi_version,
214            rc
215        ));
216        return Err(PluginLoadError::RegisterReturned { path, code: rc });
217    }
218
219    // 5. Take the registry out of the HostApi. The host keeps the Library alive
220    //    (LoadedPlugin) so the plugin's code + static data remain mapped; the
221    //    registry holds fn pointers into that code.
222    let registry = host_api
223        .take_registry()
224        .ok_or_else(|| PluginLoadError::RegisterReturned {
225            path: path.clone(),
226            code: -2,
227        })?;
228
229    tracing::debug!(path = %path.display(), abi_version, "loaded native plugin");
230
231    Ok(LoadedPlugin {
232        library,
233        path,
234        abi_version,
235        registry,
236    })
237}
238
239// ---------------------------------------------------------------------------
240// load_dir
241// ---------------------------------------------------------------------------
242
243/// Platform cdylib extensions.
244const CDYLIB_EXTS: &[&str] = &["dll", "so", "dylib", "pyd"];
245
246/// Load every cdylib in `dir` (non-recursive). Each load failure is logged via
247/// `diagnostics` and skipped (one bad plugin doesn't abort the rest). Returns
248/// the successfully loaded plugins in directory order.
249///
250/// `action_bridge` (B5a) is cloned into each loaded plugin's vtable `user_data`
251/// so post-register `runtime_action` calls recover the bridge on any thread.
252pub fn load_dir(
253    dir: impl AsRef<Path>,
254    diagnostics: Arc<dyn PluginDiagnostics>,
255    action_bridge: Option<Arc<ActionBridge>>,
256) -> Vec<LoadedPlugin> {
257    let dir = dir.as_ref();
258    let mut out = Vec::new();
259    let read = match std::fs::read_dir(dir) {
260        Ok(r) => r,
261        Err(e) => {
262            diagnostics.warn(&format!(
263                "extensions dir {} unreadable: {}",
264                dir.display(),
265                e
266            ));
267            return out;
268        }
269    };
270    for entry in read.flatten() {
271        let path = entry.path();
272        if !is_cdylib(&path) {
273            continue;
274        }
275        match load_one(&path, Arc::clone(&diagnostics), action_bridge.clone()) {
276            Ok(p) => out.push(p),
277            Err(e) => diagnostics.warn(&format!("skipped plugin {}: {e}", path.display())),
278        }
279    }
280    out
281}
282
283/// Whether `path`'s extension is a known cdylib extension.
284fn is_cdylib(path: &Path) -> bool {
285    path.extension()
286        .and_then(OsStr::to_str)
287        .map(|ext| CDYLIB_EXTS.iter().any(|e| e.eq_ignore_ascii_case(ext)))
288        .unwrap_or(false)
289}
290
291/// Convenience: merge a slice of per-plugin [`LoadedPlugin`] registries into one
292/// session registry, first-wins on name (mirrors pi's cross-extension
293/// registration order). Consumes the registries (the `LoadedPlugin`s themselves
294/// stay alive — callers keep the `Library` handles).
295pub fn merge_registries(plugins: &mut [LoadedPlugin]) -> ExtensionRegistry {
296    let mut session = ExtensionRegistry::new();
297    // We can't move registries out of LoadedPlugin without taking them; borrow
298    // mutably and drain into the session. Since ExtensionRegistry's registrars
299    // consume by value, we rebuild from the snapshot instead.
300    // Simpler: snapshot each, then re-register by iteration. But ExtensionRegistry
301    // has no public "absorb another registry" — so we drain tools/commands/handlers
302    // via internal access. For v1 we expose the typed fields via crate-internal
303    // methods on ExtensionRegistry used only here.
304    for p in plugins.iter_mut() {
305        // Take the plugin's registry out (LoadedPlugin keeps the Library).
306        let taken = std::mem::take(&mut p.registry);
307        session.absorb(taken);
308    }
309    session
310}
311
312// ---------------------------------------------------------------------------
313// PluginKeepalive + ExtensionSession — the host session's plugin lifetime
314// ---------------------------------------------------------------------------
315
316/// Owns the loaded `Library` handles so the cdylibs stay mapped for as long as
317/// any registered tool/handler (whose fn pointers live inside the cdylib) may be
318/// called. Shared via `Arc`: every [`PluginToolAdapter`](crate::PluginToolAdapter)
319/// (and, in B3, the [`ExtensionEmitter`](crate::ExtensionEmitter)) holds a clone,
320/// so the libraries unload only when the last holder drops — which is never
321/// before the harness's tool vec (and thus the last possible tool call) drops.
322///
323/// `libloading::Library` is `Send + Sync` (a handle/HMODULE), so the keepalive is
324/// too — required because `AgentTool: Send + Sync` and the adapter carries it.
325pub struct PluginKeepalive {
326    #[allow(dead_code)]
327    libraries: Vec<Library>,
328    /// B5a: the session's action bridge. Retained here so the raw pointer a
329    /// plugin stored in its vtable `user_data` (`Arc::as_ptr`) stays valid for
330    /// the harness lifetime — every `PluginToolAdapter` + the `ExtensionEmitter`
331    /// clone the keepalive, so the bridge outlives any plugin→host
332    /// `runtime_action` call. `None` under `--no-extensions`, when zero plugins
333    /// loaded, or in tests.
334    #[allow(dead_code)]
335    action_bridge: Option<Arc<ActionBridge>>,
336}
337
338impl PluginKeepalive {
339    /// Build a keepalive. `pub` so tests + host code can construct an empty
340    /// one (no loaded cdylibs) where the plugin lifecycle is exercised without
341    /// real plugins.
342    pub fn new(libraries: Vec<Library>, action_bridge: Option<Arc<ActionBridge>>) -> Self {
343        Self {
344            libraries,
345            action_bridge,
346        }
347    }
348
349    /// An empty keepalive owning no libraries — for host code that builds an
350    /// adapter outside a real load session (notably in-process tests of the
351    /// adapter against stub fns that live in the test binary, not a cdylib).
352    pub fn empty() -> Arc<Self> {
353        Arc::new(Self::new(Vec::new(), None))
354    }
355}
356
357/// The result of loading a session's worth of extensions: a shared keepalive for
358/// the cdylib handles + a snapshot of the merged registry. Built by
359/// [`load_session`]; the host (pi-cli) stashes one per harness build and hands
360/// clones of the keepalive to each adapter it constructs from the snapshot.
361///
362/// The snapshot is held behind `Arc` so the host can hand a clone to the
363/// [`ExtensionEmitter`](crate::ExtensionEmitter) (installed as the harness's
364/// `agent_emitter`) without borrowing — the emitter must outlive this session
365/// local (it lives for the harness lifetime inside `AgentHarnessOptions`).
366///
367/// `Clone` (B5d): every field is already cheaply clonable (`Arc<PluginKeepalive>`,
368/// `Option<Arc<RegistrySnapshot>>`, `Vec<PathBuf>`, `Option<Arc<ActionBridge>>`),
369/// so the reload routine can clone the live session out of its `Mutex` cell for
370/// local inspection (snapshot/keepalive/loaded_paths) and store a fresh one back
371/// in — without a borrow spanning the store.
372#[derive(Clone)]
373pub struct ExtensionSession {
374    keepalive: Arc<PluginKeepalive>,
375    snapshot: Option<Arc<RegistrySnapshot>>,
376    loaded_paths: Vec<PathBuf>,
377    /// B5a: the session's action bridge (`None` when no plugins / tests / the
378    /// `--no-extensions` path). Kept here so `rpi-cli` can recover it after
379    /// `AgentHarness::create` to call `set_harness` — the bridge's `user_data`
380    /// pointer was already handed out during `register`, so pi-cli must fill the
381    /// host's harness cell immediately after create. Cloning is cheap (an `Arc`
382    /// clone); the keepalive also holds a clone for the lifetime guarantee.
383    action_bridge: Option<Arc<ActionBridge>>,
384}
385
386impl ExtensionSession {
387    /// Assemble a session from already-loaded parts (explicit `--extension`
388    /// files via `load_one` + `merge_registries`). Mirrors `load_session`'s
389    /// internal assembly so callers can build a session without a dir scan.
390    pub fn from_parts(
391        snapshot: Arc<RegistrySnapshot>,
392        keepalive: Arc<PluginKeepalive>,
393        loaded_paths: Vec<PathBuf>,
394        action_bridge: Option<Arc<ActionBridge>>,
395    ) -> Self {
396        Self {
397            keepalive,
398            snapshot: Some(snapshot),
399            loaded_paths,
400            action_bridge,
401        }
402    }
403
404    /// An empty session (no plugins loaded — `--no-extensions` or no dirs found).
405    pub fn none() -> Self {
406        Self {
407            keepalive: Arc::new(PluginKeepalive::new(Vec::new(), None)),
408            snapshot: None,
409            loaded_paths: Vec::new(),
410            action_bridge: None,
411        }
412    }
413
414    /// The shared keepalive — clone one per adapter/emitter you build from this
415    /// session so the cdylibs outlive them.
416    pub fn keepalive(&self) -> Arc<PluginKeepalive> {
417        Arc::clone(&self.keepalive)
418    }
419
420    /// The merged registry snapshot (tools/commands/handlers), if any plugin
421    /// loaded. `None` when no plugins loaded successfully. Borrowed view for
422    /// iterating tools/commands; for an owned share (e.g. handing to the
423    /// emitter) use [`snapshot_arc`](Self::snapshot_arc).
424    pub fn snapshot(&self) -> Option<&RegistrySnapshot> {
425        self.snapshot.as_deref()
426    }
427
428    /// A shared (`Arc`) clone of the merged registry snapshot, for host code that
429    /// must keep the snapshot alive beyond this session local — notably the
430    /// [`ExtensionEmitter`](crate::ExtensionEmitter) installed into
431    /// `AgentHarnessOptions.agent_emitter`.
432    pub fn snapshot_arc(&self) -> Option<Arc<RegistrySnapshot>> {
433        self.snapshot.clone()
434    }
435
436    /// Paths of the cdylibs that loaded + registered successfully (diagnostics).
437    pub fn loaded_paths(&self) -> &[PathBuf] {
438        &self.loaded_paths
439    }
440
441    /// Whether zero plugins loaded.
442    pub fn is_empty(&self) -> bool {
443        self.loaded_paths.is_empty()
444    }
445
446    /// A one-line human summary for `--verbose` startup output, or `None` when
447    /// nothing loaded (so the line is omitted entirely).
448    pub fn summary(&self) -> Option<String> {
449        if self.is_empty() {
450            return None;
451        }
452        let tools = self.snapshot.as_ref().map(|s| s.tools().len()).unwrap_or(0);
453        Some(format!(
454            "loaded {} plugin(s) ({} tool(s))",
455            self.loaded_paths.len(),
456            tools
457        ))
458    }
459
460    /// B5a: the session's action bridge, if one was threaded into `load_*`.
461    /// `rpi-cli` recovers this after `AgentHarness::create` succeeds to call
462    /// `HarnessActionHost::set_harness` (filling the host cell the bridge's
463    /// `user_data`-recovered host reads on the first plugin→host action). The
464    /// bridge pointer was already handed to plugins during `register`, so this
465    /// must happen before any run. `None` when no plugins loaded / tests /
466    /// `--no-extensions`.
467    pub fn action_bridge(&self) -> Option<Arc<ActionBridge>> {
468        self.action_bridge.clone()
469    }
470}
471
472/// Load + register every cdylib in the given dirs (in order, non-recursive),
473/// merge their registries first-wins, and return a session with a shared
474/// keepalive over the `Library` handles + the merged snapshot. Dirs that don't
475/// exist are skipped silently; individual plugin load failures are logged via
476/// `diagnostics` and skipped (one bad plugin doesn't abort the rest).
477///
478/// The order of `dirs` matters: earlier dirs win on tool/command name collision
479/// (pi registration order). Callers pass default dirs first, then `--extensions-dir`
480/// extras, so a same-named tool in a default-dir plugin wins over an extra-dir one.
481///
482/// `action_bridge` (B5a) is cloned into every loaded plugin's vtable so
483/// post-register `runtime_action` calls recover the bridge on any thread.
484/// `rpi-cli` builds one master `Arc<ActionBridge>` per session and passes it
485/// here; `None` keeps the v1 stub (used by tests / `--no-extensions` no-ops).
486pub fn load_session(
487    dirs: &[PathBuf],
488    diagnostics: Arc<dyn PluginDiagnostics>,
489    action_bridge: Option<Arc<ActionBridge>>,
490) -> ExtensionSession {
491    load_session_mixed(dirs, &[], diagnostics, action_bridge)
492}
493
494/// Load plugins from a mix of scanned dirs and explicit cdylib files
495/// (the `--extension`/`-e` CLI paths), assembled into one session. Mirrors
496/// `load_session` but additionally `load_one`s each explicit file.
497pub fn load_session_mixed(
498    dirs: &[PathBuf],
499    files: &[PathBuf],
500    diagnostics: Arc<dyn PluginDiagnostics>,
501    action_bridge: Option<Arc<ActionBridge>>,
502) -> ExtensionSession {
503    let mut loaded: Vec<LoadedPlugin> = Vec::new();
504    for dir in dirs {
505        loaded.extend(load_dir(
506            dir,
507            Arc::clone(&diagnostics),
508            action_bridge.clone(),
509        ));
510    }
511    for f in files {
512        if let Ok(plugin) = load_one(f, Arc::clone(&diagnostics), action_bridge.clone()) {
513            loaded.push(plugin);
514        }
515    }
516    if loaded.is_empty() {
517        return ExtensionSession::none();
518    }
519    let loaded_paths: Vec<PathBuf> = loaded.iter().map(|p| p.path.clone()).collect();
520    // Merge the per-plugin registries first-wins. This drains each `registry`
521    // field (via mem::take inside `absorb`) but leaves `library` intact, so we
522    // can then destructure-own each Library into the keepalive below.
523    let session_registry = merge_registries(&mut loaded);
524    // Now move each Library out of its (registry-hollowed) LoadedPlugin by struct
525    // destructuring, collecting them into the keepalive. `registry`/`path` were
526    // left valid-but-empty / cloned already, and `library` is a move into `libs`.
527    let mut libs: Vec<Library> = Vec::with_capacity(loaded.len());
528    for p in loaded {
529        let LoadedPlugin {
530            library,
531            registry: _,
532            path: _,
533            abi_version: _,
534        } = p;
535        libs.push(library);
536    }
537    let snapshot = Arc::new(session_registry.snapshot());
538    ExtensionSession {
539        keepalive: Arc::new(PluginKeepalive::new(libs, action_bridge.clone())),
540        snapshot: Some(snapshot),
541        loaded_paths,
542        action_bridge,
543    }
544}
545
546// ---------------------------------------------------------------------------
547// Tests
548// ---------------------------------------------------------------------------
549
550#[cfg(test)]
551mod tests {
552    use super::*;
553    use std::fs;
554    use std::process::Command;
555    use std::sync::atomic::{AtomicI32, AtomicU32, AtomicUsize, Ordering};
556    use std::sync::Mutex;
557
558    static V2_CALLS: AtomicUsize = AtomicUsize::new(0);
559    static V1_CALLS: AtomicUsize = AtomicUsize::new(0);
560    static V1_LOOKUPS: AtomicUsize = AtomicUsize::new(0);
561    static V2_SEEN_VERSION: AtomicU32 = AtomicU32::new(0);
562    static V1_SEEN_VERSION: AtomicU32 = AtomicU32::new(0);
563    static V2_RETURN: AtomicI32 = AtomicI32::new(0);
564
565    extern "C" fn test_register_v2(api: *const PluginApiVt, abi_version: u32) -> i32 {
566        if api.is_null() {
567            return -99;
568        }
569        V2_CALLS.fetch_add(1, Ordering::SeqCst);
570        V2_SEEN_VERSION.store(abi_version, Ordering::SeqCst);
571        V2_RETURN.load(Ordering::SeqCst)
572    }
573
574    extern "C" fn test_register_v1(api: *const LegacyPluginApiV1, abi_version: u32) -> i32 {
575        if api.is_null() {
576            return -99;
577        }
578        V1_CALLS.fetch_add(1, Ordering::SeqCst);
579        V1_SEEN_VERSION.store(abi_version, Ordering::SeqCst);
580        0
581    }
582
583    #[derive(Default)]
584    struct CapturingDiag {
585        warns: Mutex<Vec<String>>,
586    }
587    impl PluginDiagnostics for CapturingDiag {
588        fn warn(&self, msg: &str) {
589            self.warns.lock().unwrap().push(msg.to_string());
590        }
591        fn unsupported(&self, msg: &str) {
592            self.warn(msg);
593        }
594    }
595
596    fn test_host_api() -> Arc<HostApi> {
597        HostApi::new(ExtensionRegistry::new(), Arc::new(CapturingDiag::default()))
598    }
599
600    struct CdylibFixture {
601        dir: PathBuf,
602        path: PathBuf,
603    }
604
605    impl Drop for CdylibFixture {
606        fn drop(&mut self) {
607            let _ = fs::remove_dir_all(&self.dir);
608        }
609    }
610
611    fn build_cdylib_fixture(name: &str, source: &str) -> CdylibFixture {
612        static NEXT_FIXTURE: AtomicUsize = AtomicUsize::new(0);
613        let unique = NEXT_FIXTURE.fetch_add(1, Ordering::SeqCst);
614        let dir = std::env::temp_dir().join(format!(
615            "rpi-abi-loader-{}-{name}-{unique}",
616            std::process::id()
617        ));
618        fs::create_dir_all(&dir).expect("create ABI fixture directory");
619        let source_path = dir.join("fixture.rs");
620        fs::write(&source_path, source).expect("write ABI fixture source");
621        let filename = if cfg!(windows) {
622            format!("{name}.dll")
623        } else if cfg!(target_os = "macos") {
624            format!("lib{name}.dylib")
625        } else {
626            format!("lib{name}.so")
627        };
628        let path = dir.join(filename);
629        let output = Command::new(std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into()))
630            .arg("--crate-name")
631            .arg(name)
632            .arg("--crate-type")
633            .arg("cdylib")
634            .arg("--edition")
635            .arg("2021")
636            .arg(&source_path)
637            .arg("-o")
638            .arg(&path)
639            .output()
640            .expect("run rustc for ABI fixture");
641        assert!(
642            output.status.success(),
643            "fixture build failed: {}",
644            String::from_utf8_lossy(&output.stderr)
645        );
646        CdylibFixture { dir, path }
647    }
648
649    // Self-contained copy of the minimum ABI surface emitted by
650    // rpi-plugin-sdk v0.1.11. It intentionally does not import this workspace's
651    // LegacyPluginApiV1. Unused registrar callback signatures are represented
652    // as opaque C function pointers: they have the same pointer layout and are
653    // never invoked. The exercised free_string and runtime_action slots retain
654    // their exact old signatures, including the RuntimeActionId enum boundary.
655    const V011_PLUGIN_SOURCE: &str = r#"
656use std::ffi::c_void;
657
658#[repr(C)]
659#[derive(Clone, Copy)]
660pub struct StbString {
661    pub ptr: *mut u8,
662    pub len: usize,
663}
664
665#[repr(C)]
666#[derive(Clone, Copy)]
667pub struct StbStringRef {
668    pub ptr: *const u8,
669    pub len: usize,
670}
671
672#[repr(u32)]
673#[derive(Clone, Copy)]
674pub enum RuntimeActionId {
675    SendMessage = 0,
676    SendUserMessage = 1,
677    AppendEntry = 2,
678    SetSessionName = 3,
679    GetActiveTools = 4,
680    SetActiveTools = 5,
681    SetModel = 6,
682    GetThinkingLevel = 7,
683    SetThinkingLevel = 8,
684    Compact = 9,
685    GetSystemPrompt = 10,
686    NewSession = 11,
687    Fork = 12,
688    NavigateTree = 13,
689    SwitchSession = 14,
690    Reload = 15,
691}
692
693pub type FreeStringFn = extern "C" fn(StbString);
694pub type OpaqueRegistrarFn = extern "C" fn();
695pub type RuntimeActionFn = extern "C" fn(
696    action: RuntimeActionId,
697    args_json: StbStringRef,
698    out: *mut StbString,
699    user_data: *mut c_void,
700) -> i32;
701
702#[repr(C)]
703pub struct PluginApiVt {
704    pub free_string: FreeStringFn,
705    pub register_tool: Option<OpaqueRegistrarFn>,
706    pub register_command: Option<OpaqueRegistrarFn>,
707    pub register_shortcut: Option<OpaqueRegistrarFn>,
708    pub register_flag: Option<OpaqueRegistrarFn>,
709    pub register_provider: Option<OpaqueRegistrarFn>,
710    pub register_message_renderer: Option<OpaqueRegistrarFn>,
711    pub register_markdown_transformer: Option<OpaqueRegistrarFn>,
712    pub register_entry_renderer: Option<OpaqueRegistrarFn>,
713    pub register_event_handler: Option<OpaqueRegistrarFn>,
714    pub register_resources_discover: Option<OpaqueRegistrarFn>,
715    pub runtime_action: RuntimeActionFn,
716    pub dispatch_event: Option<OpaqueRegistrarFn>,
717    pub user_data: *mut c_void,
718}
719
720#[no_mangle]
721pub extern "C" fn rpi_plugin_register(api: *const PluginApiVt, abi_version: u32) -> i32 {
722    if abi_version != 1 {
723        return 91;
724    }
725    if api.is_null() {
726        return 92;
727    }
728
729    let api = unsafe { &*api };
730    let args = b"{}";
731    let args_ref = StbStringRef {
732        ptr: args.as_ptr(),
733        len: args.len(),
734    };
735    let mut out = StbString {
736        ptr: std::ptr::null_mut(),
737        len: 0,
738    };
739    let rc = (api.runtime_action)(
740        RuntimeActionId::Reload,
741        args_ref,
742        &mut out,
743        api.user_data,
744    );
745    if !out.ptr.is_null() {
746        (api.free_string)(out);
747    }
748    rc
749}
750"#;
751
752    struct LegacyReloadHost {
753        reloads: Arc<AtomicUsize>,
754    }
755
756    fn unexpected_legacy_action() -> Result<serde_json::Value, String> {
757        Err("unexpected action from ABI v1 fixture".to_string())
758    }
759
760    #[async_trait::async_trait]
761    impl crate::RuntimeActionHost for LegacyReloadHost {
762        async fn send_message(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
763            unexpected_legacy_action()
764        }
765
766        async fn send_user_message(
767            &self,
768            _: serde_json::Value,
769        ) -> Result<serde_json::Value, String> {
770            unexpected_legacy_action()
771        }
772
773        async fn append_entry(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
774            unexpected_legacy_action()
775        }
776
777        async fn set_session_name(
778            &self,
779            _: serde_json::Value,
780        ) -> Result<serde_json::Value, String> {
781            unexpected_legacy_action()
782        }
783
784        async fn get_active_tools(
785            &self,
786            _: serde_json::Value,
787        ) -> Result<serde_json::Value, String> {
788            unexpected_legacy_action()
789        }
790
791        async fn set_active_tools(
792            &self,
793            _: serde_json::Value,
794        ) -> Result<serde_json::Value, String> {
795            unexpected_legacy_action()
796        }
797
798        async fn set_model(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
799            unexpected_legacy_action()
800        }
801
802        async fn get_thinking_level(
803            &self,
804            _: serde_json::Value,
805        ) -> Result<serde_json::Value, String> {
806            unexpected_legacy_action()
807        }
808
809        async fn set_thinking_level(
810            &self,
811            _: serde_json::Value,
812        ) -> Result<serde_json::Value, String> {
813            unexpected_legacy_action()
814        }
815
816        async fn compact(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
817            unexpected_legacy_action()
818        }
819
820        async fn get_system_prompt(
821            &self,
822            _: serde_json::Value,
823        ) -> Result<serde_json::Value, String> {
824            unexpected_legacy_action()
825        }
826
827        async fn new_session(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
828            unexpected_legacy_action()
829        }
830
831        async fn fork(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
832            unexpected_legacy_action()
833        }
834
835        async fn navigate_tree(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
836            unexpected_legacy_action()
837        }
838
839        async fn switch_session(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
840            unexpected_legacy_action()
841        }
842
843        async fn reload(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
844            self.reloads.fetch_add(1, Ordering::SeqCst);
845            Ok(serde_json::Value::Null)
846        }
847    }
848
849    #[test]
850    fn entrypoint_selection_supports_v1_v2_and_never_falls_back_after_call() {
851        V2_CALLS.store(0, Ordering::SeqCst);
852        V1_CALLS.store(0, Ordering::SeqCst);
853        V1_LOOKUPS.store(0, Ordering::SeqCst);
854        V2_RETURN.store(0, Ordering::SeqCst);
855
856        let selected = select_register::<RpiPluginRegister, LegacyRpiPluginRegister, &str>(
857            Ok(test_register_v2),
858            || {
859                V1_LOOKUPS.fetch_add(1, Ordering::SeqCst);
860                Ok(test_register_v1)
861            },
862        )
863        .expect("v2 selected");
864        let entrypoint = match selected {
865            Ok(register) => RegisterEntrypoint::V2(register),
866            Err(register) => RegisterEntrypoint::V1(register),
867        };
868        assert_eq!(call_register(entrypoint, &test_host_api()), 0);
869        assert_eq!(V2_CALLS.load(Ordering::SeqCst), 1);
870        assert_eq!(V1_CALLS.load(Ordering::SeqCst), 0);
871        assert_eq!(V1_LOOKUPS.load(Ordering::SeqCst), 0);
872        assert_eq!(V2_SEEN_VERSION.load(Ordering::SeqCst), 2);
873
874        let selected = select_register::<RpiPluginRegister, LegacyRpiPluginRegister, &str>(
875            Err("v2 missing"),
876            || {
877                V1_LOOKUPS.fetch_add(1, Ordering::SeqCst);
878                Ok(test_register_v1)
879            },
880        )
881        .expect("legacy selected");
882        let entrypoint = match selected {
883            Ok(register) => RegisterEntrypoint::V2(register),
884            Err(register) => RegisterEntrypoint::V1(register),
885        };
886        assert_eq!(call_register(entrypoint, &test_host_api()), 0);
887        assert_eq!(V1_CALLS.load(Ordering::SeqCst), 1);
888        assert_eq!(V1_LOOKUPS.load(Ordering::SeqCst), 1);
889        assert_eq!(V1_SEEN_VERSION.load(Ordering::SeqCst), 1);
890
891        // A selected v2 entrypoint that fails is still called once and is not
892        // followed by a legacy call.
893        V2_RETURN.store(73, Ordering::SeqCst);
894        let selected = select_register::<RpiPluginRegister, LegacyRpiPluginRegister, &str>(
895            Ok(test_register_v2),
896            || {
897                V1_LOOKUPS.fetch_add(1, Ordering::SeqCst);
898                Ok(test_register_v1)
899            },
900        )
901        .expect("v2 selected even though its later call will fail");
902        let entrypoint = match selected {
903            Ok(register) => RegisterEntrypoint::V2(register),
904            Err(register) => RegisterEntrypoint::V1(register),
905        };
906        assert_eq!(call_register(entrypoint, &test_host_api()), 73);
907        assert_eq!(V2_CALLS.load(Ordering::SeqCst), 2);
908        assert_eq!(V1_CALLS.load(Ordering::SeqCst), 1);
909        assert_eq!(V1_LOOKUPS.load(Ordering::SeqCst), 1);
910    }
911
912    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
913    async fn loads_real_v011_plugin_and_dispatches_old_enum_reload() {
914        let fixture = build_cdylib_fixture("abi_v011_real", V011_PLUGIN_SOURCE);
915        let reloads = Arc::new(AtomicUsize::new(0));
916        let host: Arc<dyn crate::RuntimeActionHost> = Arc::new(LegacyReloadHost {
917            reloads: Arc::clone(&reloads),
918        });
919        let bridge = ActionBridge::new(tokio::runtime::Handle::current(), host);
920        let diagnostics = Arc::new(CapturingDiag::default());
921
922        let loaded = load_one(
923            &fixture.path,
924            Arc::clone(&diagnostics) as Arc<dyn PluginDiagnostics>,
925            Some(bridge),
926        )
927        .expect("load plugin built against the vendored v0.1.11 ABI");
928
929        assert_eq!(loaded.abi_version, LEGACY_PLUGIN_ABI_VERSION);
930        assert_eq!(reloads.load(Ordering::SeqCst), 1);
931        assert!(diagnostics.warns.lock().unwrap().is_empty());
932        drop(loaded);
933        drop(fixture);
934    }
935
936    #[test]
937    fn load_one_supports_both_abis_prefers_v2_and_never_retries_failed_v2() {
938        const PREFIX: &str = "use std::ffi::c_void;\n";
939        let diag: Arc<dyn PluginDiagnostics> = Arc::new(CapturingDiag::default());
940
941        let v1 = build_cdylib_fixture(
942            "abi_v1_only",
943            &format!(
944                "{PREFIX}#[no_mangle]\npub extern \"C\" fn rpi_plugin_register(api: *const c_void, abi: u32) -> i32 {{ if !api.is_null() && abi == 1 {{ 0 }} else {{ 91 }} }}\n"
945            ),
946        );
947        let loaded_v1 = load_one(&v1.path, Arc::clone(&diag), None).expect("load ABI v1 plugin");
948        assert_eq!(loaded_v1.abi_version, 1);
949        drop(loaded_v1);
950        drop(v1);
951
952        let v2 = build_cdylib_fixture(
953            "abi_v2_only",
954            &format!(
955                "{PREFIX}#[no_mangle]\npub extern \"C\" fn rpi_plugin_register_v2(api: *const c_void, abi: u32) -> i32 {{ if !api.is_null() && abi == 2 {{ 0 }} else {{ 92 }} }}\n"
956            ),
957        );
958        let loaded_v2 = load_one(&v2.path, Arc::clone(&diag), None).expect("load ABI v2 plugin");
959        assert_eq!(loaded_v2.abi_version, 2);
960        drop(loaded_v2);
961        drop(v2);
962
963        let dual = build_cdylib_fixture(
964            "abi_dual",
965            &format!(
966                "{PREFIX}#[no_mangle]\npub extern \"C\" fn rpi_plugin_register(_: *const c_void, _: u32) -> i32 {{ 93 }}\n#[no_mangle]\npub extern \"C\" fn rpi_plugin_register_v2(api: *const c_void, abi: u32) -> i32 {{ if !api.is_null() && abi == 2 {{ 0 }} else {{ 94 }} }}\n"
967            ),
968        );
969        let loaded_dual =
970            load_one(&dual.path, Arc::clone(&diag), None).expect("dual-symbol plugin uses v2");
971        assert_eq!(loaded_dual.abi_version, 2);
972        drop(loaded_dual);
973        drop(dual);
974
975        let failed_v2 = build_cdylib_fixture(
976            "abi_v2_failure",
977            &format!(
978                "{PREFIX}#[no_mangle]\npub extern \"C\" fn rpi_plugin_register(_: *const c_void, _: u32) -> i32 {{ 0 }}\n#[no_mangle]\npub extern \"C\" fn rpi_plugin_register_v2(_: *const c_void, _: u32) -> i32 {{ 73 }}\n"
979            ),
980        );
981        let error = match load_one(&failed_v2.path, diag, None) {
982            Ok(_) => panic!("failed v2 registration must not fall back to v1"),
983            Err(error) => error,
984        };
985        assert!(matches!(
986            error,
987            PluginLoadError::RegisterReturned { code: 73, .. }
988        ));
989        drop(failed_v2);
990    }
991
992    #[test]
993    fn load_one_missing_file_reports_open_error() {
994        let diag: Arc<dyn PluginDiagnostics> = Arc::new(CapturingDiag::default());
995        let res = load_one("definitely_not_a_plugin.dll", diag, None);
996        assert!(matches!(res, Err(PluginLoadError::Open { .. })));
997    }
998
999    #[test]
1000    fn load_dir_missing_dir_returns_empty_and_warns() {
1001        let empty = load_dir(
1002            "no_such_dir_xyz",
1003            Arc::new(CapturingDiag::default()) as Arc<dyn PluginDiagnostics>,
1004            None,
1005        );
1006        assert!(empty.is_empty());
1007    }
1008
1009    #[test]
1010    fn is_cdylib_recognizes_extensions() {
1011        assert!(is_cdylib(Path::new("foo.dll")));
1012        assert!(is_cdylib(Path::new("foo.so")));
1013        assert!(is_cdylib(Path::new("foo.dylib")));
1014        assert!(is_cdylib(Path::new("FOO.DLL")));
1015        assert!(!is_cdylib(Path::new("foo.md")));
1016        assert!(!is_cdylib(Path::new("foo")));
1017    }
1018}
1019
1020// Silence the unused-default-import warning for NullDiagnostics re-exported by the crate.
1021#[allow(dead_code)]
1022fn _ensure_nulldiagnostics_referenced() -> Arc<dyn PluginDiagnostics> {
1023    Arc::new(NullDiagnostics)
1024}