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}