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