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}