Skip to main content

razeri_plugin_api/
process.rs

1// Copyright (C) 2026 Gabriel Shea
2// Licensed under either of the Apache License, Version 2.0 or the MIT
3// license, at your option.
4
5//! One dialect registry per process.
6//!
7//! Razeri resolves platforms by name in hundreds of places — every renderer,
8//! every face, every binding — and until now each of those calls built a fresh
9//! registry from the dialects linked into the binary. That was correct and it
10//! was also a closed door: a dialect loaded from a shared library at run time
11//! landed in whichever builder loaded it, and nothing that resolved by name
12//! ever looked there.
13//!
14//! This module holds the one registry those calls now read. It is built once,
15//! from the linked dialects plus whatever a plugin directory contributes, and
16//! it never changes afterwards. Two consequences follow, and both are the
17//! point:
18//!
19//! - **A plugin loaded here is visible everywhere.** The command line's
20//!   `--target`, the materialization core three calls deeper, and a Python
21//!   caller in another process entirely all resolve through the same registry.
22//! - **The directory is chosen before the first resolution, or not at all.**
23//!   A registry that could change under a running process would let two
24//!   halves of one render disagree about what a name means. So the directory
25//!   is installed first — explicitly by a host that has one, or from
26//!   `RAZERI_PLUGIN_DIR` when the first resolution happens — and a later
27//!   attempt to install a different one is refused rather than honoured.
28//!
29//! Only dialects live here. A plugin may also register storage providers,
30//! language renderers and model adapters, and those still reach only the
31//! builder that loaded them; the process-wide registry grows to hold them when
32//! something resolves them by name the way dialects are resolved.
33
34use crate::builder::PluginRegistryBuilder;
35use crate::dialect::PlatformRegistry;
36use std::path::{Path, PathBuf};
37use std::sync::OnceLock;
38
39/// The environment variable that names a directory of plugin shared libraries.
40///
41/// Read when the process-wide registry is first built and no host has
42/// installed a directory explicitly. Unset means no directory; a value that
43/// names nothing readable contributes nothing and is noted in the log.
44pub const PLUGIN_DIR_ENV: &str = "RAZERI_PLUGIN_DIR";
45
46/// What loading a plugin directory produced, kept for the life of the process
47/// so a face can report it — which library loaded, which was refused, and why.
48#[derive(Debug)]
49pub struct PluginDirReport {
50    /// The directory that was scanned.
51    pub dir: PathBuf,
52    /// One entry per shared library found in it, in directory order.
53    pub loaded: Vec<crate::dynamic::PluginLoadResult>,
54}
55
56/// Why an explicit installation was refused.
57#[derive(Debug, thiserror::Error)]
58pub enum InstallError {
59    /// The registry was already built — from a different directory, or from
60    /// none — before this directory was offered. The order is the rule: a
61    /// host installs its plugin directory before anything resolves a name.
62    #[error(
63        "the plugin directory {dir} was offered after this process had already built its \
64         dialect registry{earlier}; a plugin directory is installed before the first \
65         platform name is resolved, never after"
66    )]
67    AlreadyBuilt {
68        /// The directory that was refused.
69        dir: PathBuf,
70        /// `" from <dir>"` when a directory had been installed, else empty.
71        earlier: String,
72    },
73    /// The directory cannot be read at all — it is missing, or not a
74    /// directory, or not readable by this process.
75    #[error("the plugin directory {dir} cannot be read: {source}")]
76    Unreadable {
77        /// The directory that was offered.
78        dir: PathBuf,
79        /// What the filesystem said.
80        #[source]
81        source: std::io::Error,
82    },
83}
84
85struct Installed {
86    dialects: PlatformRegistry,
87    report: Option<PluginDirReport>,
88}
89
90static INSTALLED: OnceLock<Installed> = OnceLock::new();
91
92/// The registry every by-name resolution in this process reads.
93///
94/// Built on first use from the dialects linked into the binary, plus the
95/// shared libraries in the directory named by [`PLUGIN_DIR_ENV`] if that is
96/// set and no host installed a directory first. The result never changes for
97/// the life of the process.
98pub fn process_registry() -> &'static PlatformRegistry {
99    &INSTALLED.get_or_init(|| build(plugin_dir_from_environment())).dialects
100}
101
102/// Install a plugin directory as the one this process loads from, and build
103/// the registry now.
104///
105/// This is the call a host makes when it has a directory from its own
106/// configuration — a command-line flag, say — and wants failures reported
107/// rather than logged. It must come before the first call to
108/// [`process_registry`] or to any by-name resolver, directly or indirectly.
109/// Offering the same directory twice is fine and returns the same report.
110///
111/// # Errors
112///
113/// [`InstallError::Unreadable`] when `dir` cannot be read;
114/// [`InstallError::AlreadyBuilt`] when the registry already exists and was not
115/// built from this same directory. Individual libraries that fail to load are
116/// not errors here — they are entries in the returned report, because a host
117/// decides whether a broken plugin is fatal.
118pub fn install_plugin_dir(dir: &Path) -> Result<&'static PluginDirReport, InstallError> {
119    std::fs::read_dir(dir)
120        .map_err(|source| InstallError::Unreadable { dir: dir.to_path_buf(), source })?;
121
122    let mut built_now = false;
123    let installed = INSTALLED.get_or_init(|| {
124        built_now = true;
125        build(Some(dir.to_path_buf()))
126    });
127
128    match &installed.report {
129        Some(report) if built_now || same_dir(&report.dir, dir) => Ok(report),
130        Some(report) => Err(InstallError::AlreadyBuilt {
131            dir: dir.to_path_buf(),
132            earlier: format!(" from {}", report.dir.display()),
133        }),
134        None => Err(InstallError::AlreadyBuilt { dir: dir.to_path_buf(), earlier: String::new() }),
135    }
136}
137
138/// The report from whichever directory this process loaded, if any.
139///
140/// `None` until the registry is built, and `None` afterwards if no directory
141/// was installed and [`PLUGIN_DIR_ENV`] was unset.
142pub fn plugin_dir_report() -> Option<&'static PluginDirReport> {
143    INSTALLED.get().and_then(|installed| installed.report.as_ref())
144}
145
146/// The directory [`PLUGIN_DIR_ENV`] names, if it is set to anything.
147#[must_use]
148pub fn plugin_dir_from_environment() -> Option<PathBuf> {
149    std::env::var_os(PLUGIN_DIR_ENV).filter(|value| !value.is_empty()).map(PathBuf::from)
150}
151
152fn build(dir: Option<PathBuf>) -> Installed {
153    let mut builder = PluginRegistryBuilder::new().with_auto_discovered();
154    let report = dir.map(|dir| {
155        let loaded = load_into(&dir, &mut builder);
156        PluginDirReport { dir, loaded }
157    });
158    Installed { dialects: builder.build().dialects, report }
159}
160
161#[cfg(all(feature = "dynamic", not(target_arch = "wasm32")))]
162fn load_into(
163    dir: &Path,
164    builder: &mut PluginRegistryBuilder,
165) -> Vec<crate::dynamic::PluginLoadResult> {
166    crate::dynamic::load_plugins_from_dir(dir, builder)
167}
168
169#[cfg(not(all(feature = "dynamic", not(target_arch = "wasm32"))))]
170fn load_into(
171    dir: &Path,
172    _builder: &mut PluginRegistryBuilder,
173) -> Vec<crate::dynamic::PluginLoadResult> {
174    tracing::warn!(
175        "a plugin directory was named ({}) but this build cannot load shared libraries",
176        dir.display()
177    );
178    Vec::new()
179}
180
181fn same_dir(a: &Path, b: &Path) -> bool {
182    match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
183        (Ok(a), Ok(b)) => a == b,
184        _ => a == b,
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    // The registry is process-wide, so these tests share it and are written to
193    // agree with each other rather than to reset it: nothing here depends on
194    // the registry being empty, only on it being the same one every time.
195
196    #[test]
197    fn the_registry_is_built_once_and_handed_back_every_time() {
198        let first: *const PlatformRegistry = process_registry();
199        let second: *const PlatformRegistry = process_registry();
200        assert!(std::ptr::eq(first, second), "two calls returned two registries");
201    }
202
203    #[test]
204    fn a_directory_offered_after_the_build_is_refused_unless_it_is_the_same_one() {
205        let _ = process_registry();
206        let elsewhere = tempfile::tempdir().unwrap();
207        let err = install_plugin_dir(elsewhere.path()).unwrap_err();
208        assert!(matches!(err, InstallError::AlreadyBuilt { .. }), "{err}");
209        assert!(err.to_string().contains("before the first platform name is resolved"), "{err}");
210    }
211
212    #[test]
213    fn an_unreadable_directory_is_named_in_the_error() {
214        let missing = std::env::temp_dir().join("razeri-no-such-plugin-dir-ever");
215        let err = install_plugin_dir(&missing).unwrap_err();
216        assert!(matches!(err, InstallError::Unreadable { .. }), "{err}");
217        assert!(err.to_string().contains("razeri-no-such-plugin-dir-ever"), "{err}");
218    }
219
220    #[test]
221    fn the_environment_variable_is_read_as_a_path_and_empty_means_unset() {
222        // Read-only: the variable's value in this process is whatever the
223        // harness set, and the function must agree with it.
224        let expected =
225            std::env::var_os(PLUGIN_DIR_ENV).filter(|v| !v.is_empty()).map(PathBuf::from);
226        assert_eq!(plugin_dir_from_environment(), expected);
227    }
228}