lumen_modules/loader.rs
1//! The loading half: for each declared dependency, find what answers to that
2//! name, tell which runtime kind it is, verify it, and bring it into the app.
3//!
4//! A name is answered from the registry first. A module compiled into the
5//! running binary has no file to open, so its constructor put it on
6//! [`lumen_module_registry`] before `main`; a name found there installs
7//! straight away, on every platform, and nothing is resolved or opened.
8//!
9//! Otherwise the name resolves to a library file, and the file itself says
10//! which kind it is:
11//!
12//! - A library exporting `lumen_module_probe_<name>` is an **engine-locked
13//! runtime module** (`lumen-module`): a Rust dylib sharing the running
14//! engine, with full ECS reach. It loads only into a process that links
15//! the engine dynamically, and only when its build id equals the engine's
16//! exactly. The `<name>` half is the declared name, so two modules linked
17//! into one binary never define the same entry.
18//! - A library exporting `lumen_plugin_v1` is a **portable plugin**
19//! (`lumen-plugin`): a C-ABI cdylib exchanging serialized bytes. It loads
20//! into any process, static hosts included, as long as its ABI and wire
21//! versions match.
22//! - A library exporting neither is refused with a banner naming both
23//! symbols; a compiler plugin (`lumenc_plugin_v1`) is named as one.
24//!
25//! See the crate docs for the two hazards the engine-locked arm exists to
26//! close and for the banner-and-continue failure policy, which applies to
27//! both kinds.
28
29use std::ffi::CStr;
30use std::mem::ManuallyDrop;
31use std::os::raw::c_char;
32use std::panic::{AssertUnwindSafe, catch_unwind};
33use std::path::{Path, PathBuf};
34use std::sync::{Arc, OnceLock};
35
36use bevy_ecs::prelude::Res;
37use bevy_ecs::resource::Resource;
38use lumen_core::app::{App, EventLoopWaker};
39use lumen_core::plugin_events::push_plugin_event;
40use lumen_core::tick::TickStage;
41use lumen_module_registry::StaticModule;
42use lumen_plugin::abi::LogLevel;
43use lumen_plugin::{HostHooks, PluginEvent, PluginSet, codec};
44
45use crate::{
46 DepCfg, DependenciesCfg, INSTALL_PREFIX, ModuleSource, PLUGIN_ENTRY_SYMBOL, PROBE_PREFIX,
47 ResolvedModules, entry_symbol, library_spellings,
48};
49
50pub use lumen_plugin::InitEnv;
51
52/// The entry a compiler plugin exports - the wrong kind to declare here.
53const COMPILER_ENTRY_SYMBOL: &str = "lumenc_plugin_v1";
54
55type ProbeFn = unsafe extern "C" fn() -> *const c_char;
56/// Rust ABI on purpose: probe equality has already proven both sides are the
57/// same build, which is what makes `&mut App` / `&str` safe to pass.
58type InstallFn = unsafe fn(&mut App, &str) -> u32;
59
60/// Install returned cleanly.
61const INSTALL_OK: u32 = 0;
62/// The module's constructor or `Plugin::build` panicked (caught on the module
63/// side). Kept in lockstep with `lumen-module`, which cannot be depended on
64/// from here (it sits on the other side of the engine dylib).
65const INSTALL_PANICKED: u32 = 1;
66/// The module rejected its `config` table.
67const INSTALL_BAD_CONFIG: u32 = 2;
68
69/// Every module compiled into this binary, whatever put it there.
70///
71/// A thin wrapper so the one call the loader makes into the registry reads
72/// as an arm of the search rather than as a dependency on any module.
73fn registered(name: &str) -> Option<StaticModule> {
74 lumen_module_registry::registered()
75 .into_iter()
76 .find(|m| m.name == name)
77}
78
79/// What the loader did, queryable for the life of the app.
80///
81/// Holds metadata only, for both kinds. An engine-locked library is
82/// deliberately leaked (load-forever): the schedules hold function pointers
83/// into each one, and components or resources a module registered carry drop
84/// glue that lives in it, so there is no point in the app's life at which
85/// unloading is sound. A portable plugin's open library lives in
86/// [`PortablePlugins`] beside this.
87#[derive(Debug, Default, Resource)]
88pub struct LoadedModules {
89 /// Every dependency that installed, in load (sorted-name) order.
90 pub loaded: Vec<LoadedModule>,
91 /// Every dependency that did not, with the reason its banner carried.
92 pub failed: Vec<ModuleFailure>,
93}
94
95/// Which runtime kind a loaded dependency turned out to be.
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub enum LoadedKind {
98 /// A runtime module compiled into this binary, found on the registry its
99 /// pre-main constructor put it on. No file was opened.
100 Static,
101 /// An engine-locked runtime module (`lumen_module_probe_<name>`).
102 EngineModule,
103 /// A portable C-ABI plugin (`lumen_plugin_v1`).
104 PortablePlugin,
105}
106
107/// One installed dependency.
108#[derive(Debug)]
109pub struct LoadedModule {
110 /// The declared name.
111 pub name: String,
112 /// The library file that was opened, or the running executable for a
113 /// module that was compiled into it.
114 pub path: PathBuf,
115 /// Which kind the search found.
116 pub kind: LoadedKind,
117 /// The build id an engine-locked module reported; equal to the engine's
118 /// own. Empty for the other two kinds: a portable plugin's handshake is
119 /// the ABI and wire versions instead, and a compiled-in module is this
120 /// build.
121 pub build_id: String,
122}
123
124/// One dependency that failed to load. The app keeps running without it.
125#[derive(Debug)]
126pub struct ModuleFailure {
127 /// The declared name.
128 pub name: String,
129 /// The reason, as printed in the banner (or the one-line notice the
130 /// static-host refusal prints instead).
131 pub reason: String,
132}
133
134/// Why one dependency did not load, and how loudly to say so. A genuine
135/// failure gets the unmissable banner; a name that no shape of this build
136/// can answer gets one line, because that is a property of how the binary
137/// was put together rather than a defect in the app, and shouting MODULE
138/// LOAD FAILED on every launch would teach users to ignore the banner.
139struct LoadError {
140 reason: String,
141 banner: bool,
142}
143
144impl LoadError {
145 fn banner(reason: impl Into<String>) -> Self {
146 Self {
147 reason: reason.into(),
148 banner: true,
149 }
150 }
151
152 fn notice(reason: impl Into<String>) -> Self {
153 Self {
154 reason: reason.into(),
155 banner: false,
156 }
157 }
158}
159
160/// The portable plugins of one app: the loaded [`PluginSet`]s, held for the
161/// process lifetime because the app's script registry points into them.
162/// Dropping the resource - the app going down - tells every plugin to shut
163/// down, best effort.
164#[derive(Default, Resource)]
165pub struct PortablePlugins {
166 sets: Vec<PluginSet>,
167}
168
169impl Drop for PortablePlugins {
170 fn drop(&mut self) {
171 for set in &self.sets {
172 set.shutdown();
173 }
174 }
175}
176
177/// Load every declared dependency into `app`, in sorted-name order, and
178/// record the outcome in a [`LoadedModules`] resource. Any failure is a
179/// stderr banner plus a failure entry; the app boots without that module.
180///
181/// `resolved` carries the compiler's `version`-source resolutions (see
182/// [`ResolvedModules`]); pass a default when nothing resolved anything.
183/// `env` is what every portable plugin is told about the app.
184pub fn load_modules(
185 app: &mut App,
186 dir: &Path,
187 deps: &DependenciesCfg,
188 resolved: &ResolvedModules,
189 env: &InitEnv,
190) {
191 let mut state = LoadedModules::default();
192 let mut portable = PortablePlugins::default();
193 if deps.0.is_empty() {
194 app.world.insert_resource(state);
195 app.world.insert_resource(portable);
196 return;
197 }
198 // Read once per process: whether the engine is dynamically linked here.
199 // `None` refuses the engine-locked arm only; a portable plugin loads
200 // into a static host all the same.
201 let engine_id = engine_build_id();
202 // One waker slot and one hook set serve every portable plugin. The waker
203 // is filled by the system below once a backend inserts the resource.
204 let waker: Arc<OnceLock<EventLoopWaker>> = Arc::new(OnceLock::new());
205 let hooks: Arc<dyn HostHooks> = Arc::new(BusHooks {
206 waker: Arc::clone(&waker),
207 });
208 for dep in &deps.0 {
209 match load_one(
210 app,
211 dir,
212 dep,
213 resolved,
214 engine_id.as_deref(),
215 env,
216 &hooks,
217 &mut portable,
218 &state.loaded,
219 ) {
220 Ok(loaded) => state.loaded.push(loaded),
221 Err(err) => {
222 if err.banner {
223 banner(&dep.name, &err.reason);
224 } else {
225 eprintln!(
226 "lumen-runtime: dependency '{}' skipped: this build does not compile \
227 the module in and no engine dylib is present to load it dynamically. \
228 The app runs without it. Package it with `lumenc package --static` to \
229 compile the declared modules into the executable.",
230 dep.name
231 );
232 }
233 state.failed.push(ModuleFailure {
234 name: dep.name.clone(),
235 reason: err.reason,
236 });
237 }
238 }
239 }
240 if !portable.sets.is_empty() {
241 app.add_systems(TickStage::Input, wire_plugin_waker(waker));
242 }
243 app.world.insert_resource(state);
244 app.world.insert_resource(portable);
245}
246
247/// Find, verify, and install one dependency. `Err` carries the reason and
248/// how loudly to report it.
249#[allow(clippy::too_many_arguments)]
250fn load_one(
251 app: &mut App,
252 dir: &Path,
253 dep: &DepCfg,
254 resolved: &ResolvedModules,
255 engine_id: Option<&str>,
256 env: &InitEnv,
257 hooks: &Arc<dyn HostHooks>,
258 portable: &mut PortablePlugins,
259 already: &[LoadedModule],
260) -> Result<LoadedModule, LoadError> {
261 // The registry first, before anything is resolved or opened: a module
262 // compiled into this binary is already here, and a file of the same name
263 // beside the executable would be a second copy of it.
264 if let Some(module) = registered(&dep.name) {
265 return install_static_module(app, dep, module);
266 }
267 // A `bundled` dependency the registry did not answer, in a host that
268 // compiled the engine in, is the same build-shape condition the
269 // engine-locked refusal below notices quietly: the module is neither in
270 // this binary nor loadable beside a shared engine this process does not
271 // have. Every other resolution failure banners.
272 let path = resolve(dir, dep, resolved).map_err(|reason| {
273 if engine_id.is_none() && matches!(dep.source, ModuleSource::Bundled) {
274 LoadError::notice(reason)
275 } else {
276 LoadError::banner(reason)
277 }
278 })?;
279 // Two entries pointing at one file would share the library's statics,
280 // and both kinds hold one instance per process; the second entry would
281 // run against the first one's configuration.
282 if let Some(prior) = already.iter().find(|l| same_file(&l.path, &path)) {
283 return Err(LoadError::banner(format!(
284 "already loaded as module '{}'; declare one library once",
285 prior.name
286 )));
287 }
288 // SAFETY: opening a first-party (or app-declared) library runs its
289 // initializers; a dependency is native code in the app's process by
290 // contract, the same trust model as [[hooks]]. RTLD_NOW is libloading's
291 // default, so a missing symbol fails here rather than mid-frame.
292 let lib = unsafe { libloading::Library::new(&path) }
293 .map_err(|e| LoadError::banner(format!("could not open {}: {e}", path.display())))?;
294
295 // Kind dispatch: the exported entry says what the file is. The
296 // engine-locked entries carry the declared name, so what is probed for
297 // depends on what the app called this dependency.
298 let has = |symbol: &str| {
299 // SAFETY: presence probe only; the symbol is never called through
300 // this lookup.
301 unsafe { lib.get::<ProbeFn>(symbol) }.is_ok()
302 };
303 let probe_symbol = entry_symbol(PROBE_PREFIX, &dep.name);
304 if has(&probe_symbol) {
305 return install_engine_module(app, dep, path, lib, engine_id, &probe_symbol);
306 }
307 if has(PLUGIN_ENTRY_SYMBOL) {
308 return install_portable_plugin(app, dep, path, env, hooks, portable);
309 }
310 if has(COMPILER_ENTRY_SYMBOL) {
311 return Err(LoadError::banner(format!(
312 "{} exports lumenc_plugin_v1: this is a compiler plugin - declare it under \
313 [[plugins]], not [dependencies]",
314 path.display()
315 )));
316 }
317 Err(LoadError::banner(format!(
318 "{} exports neither {probe_symbol} nor lumen_plugin_v1; the library is not a Lumen \
319 runtime module built as '{}', or a portable plugin",
320 path.display(),
321 dep.name
322 )))
323}
324
325/// The compiled-in arm: hand the registered entry the app and the config
326/// table, exactly as the opened arm hands them to the entry it looked up.
327///
328/// No build-id check, and none possible: the module was compiled into this
329/// binary, so it is this build by construction and there is no second engine
330/// instance for it to run against.
331fn install_static_module(
332 app: &mut App,
333 dep: &DepCfg,
334 module: StaticModule,
335) -> Result<LoadedModule, LoadError> {
336 let config_toml = config_toml(dep)?;
337 let status = guarded_install(|| (module.install)(app, &config_toml));
338 read_install_status(status)?;
339 Ok(LoadedModule {
340 name: dep.name.clone(),
341 path: std::env::current_exe().unwrap_or_else(|_| PathBuf::from(dep.name.clone())),
342 kind: LoadedKind::Static,
343 build_id: String::new(),
344 })
345}
346
347/// The engine-locked arm: verify the build id against the running engine and
348/// call the Rust-ABI install entry.
349fn install_engine_module(
350 app: &mut App,
351 dep: &DepCfg,
352 path: PathBuf,
353 lib: libloading::Library,
354 engine_id: Option<&str>,
355 probe_symbol: &str,
356) -> Result<LoadedModule, LoadError> {
357 // Hazard check: a host that compiled the engine in must never install an
358 // engine-locked module (a second engine instance shares no worlds or
359 // statics with the first, and the probe cannot tell). A portable plugin
360 // took the other arm before this check on purpose, and a module compiled
361 // into this binary answered from the registry before either. A notice
362 // rather than the banner: the refusal is a property of the build shape.
363 let Some(engine_id) = engine_id else {
364 return Err(LoadError::notice(
365 "this build does not compile the module in and no engine dylib is present to load \
366 it dynamically.\nThe binary compiles the engine into itself, and a module opened \
367 here would run against a second engine instance.\nA portable plugin \
368 (lumen-plugin) loads here; an engine-locked module (lumen-module) has to be \
369 compiled in.",
370 ));
371 };
372 // SAFETY: the probe is a C-ABI symbol returning a NUL-terminated static;
373 // it is the one symbol safe to call across a build-skewed boundary.
374 let module_id = unsafe {
375 let probe: libloading::Symbol<ProbeFn> = lib
376 .get(probe_symbol)
377 .expect("presence was probed before dispatch");
378 let p = probe();
379 if p.is_null() {
380 return Err(LoadError::banner(format!(
381 "{}: {probe_symbol} returned null",
382 path.display()
383 )));
384 }
385 CStr::from_ptr(p).to_string_lossy().into_owned()
386 };
387 if module_id != engine_id {
388 return Err(LoadError::banner(format!(
389 "{} was built against a different engine build.\n module reports: {module_id}\n \
390 engine is: {engine_id}\nRebuild the module against this exact engine release.",
391 path.display()
392 )));
393 }
394
395 let config_toml = config_toml(dep)?;
396 // Load-forever from here: see [`LoadedModules`] for why unloading is
397 // never sound. Taken before the install runs, so a module that
398 // registered part way and then reported a failure keeps its code mapped
399 // under the schedules and the drop glue it left behind.
400 let lib = ManuallyDrop::new(lib);
401 // SAFETY: exact build-id equality just proved the module and the running
402 // engine are one build, which is the contract that makes the Rust-ABI
403 // signature (`&mut App`, `&str`) sound to call.
404 let install_symbol = entry_symbol(INSTALL_PREFIX, &dep.name);
405 let install: libloading::Symbol<InstallFn> = unsafe { lib.get(install_symbol.as_str()) }
406 .map_err(|_| {
407 LoadError::banner(format!(
408 "{} does not export {install_symbol}",
409 path.display()
410 ))
411 })?;
412 // SAFETY: as above - the probe already proved one build.
413 let status = guarded_install(|| unsafe { install(app, &config_toml) });
414 read_install_status(status)?;
415
416 Ok(LoadedModule {
417 name: dep.name.clone(),
418 path,
419 kind: LoadedKind::EngineModule,
420 build_id: module_id,
421 })
422}
423
424/// The module's `config` table in the wire form every install entry reads.
425/// One copy for both module arms, so a module cannot see a different table
426/// depending on how it reached the app.
427fn config_toml(dep: &DepCfg) -> Result<String, LoadError> {
428 toml::to_string(&dep.config).map_err(|e| {
429 LoadError::banner(format!(
430 "could not serialize the module's config table: {e}"
431 ))
432 })
433}
434
435/// Call an install entry and turn an escaping panic into a status.
436///
437/// The module's own macro catches construction panics; this catch is the belt
438/// for a panic escaping it anyway (one libstd either way, so the unwind
439/// crosses cleanly). The payload is printed here rather than left to the
440/// panic hook: an app's own hook may be silent, and the banner promises the
441/// message was printed.
442fn guarded_install(call: impl FnOnce() -> u32) -> u32 {
443 catch_unwind(AssertUnwindSafe(call)).unwrap_or_else(|payload| {
444 let msg = payload
445 .downcast_ref::<&str>()
446 .copied()
447 .or_else(|| payload.downcast_ref::<String>().map(String::as_str))
448 .unwrap_or("(non-string panic payload)");
449 eprintln!("lumen-runtime: the module's install entry panicked: {msg}");
450 INSTALL_PANICKED
451 })
452}
453
454/// What an install status means, in the words the banner carries. Shared by
455/// both module arms: the codes are one set, so the messages are too.
456fn read_install_status(status: u32) -> Result<(), LoadError> {
457 match status {
458 INSTALL_OK => Ok(()),
459 INSTALL_PANICKED => Err(LoadError::banner(
460 "the module's constructor panicked during install (its message is printed above)",
461 )),
462 INSTALL_BAD_CONFIG => Err(LoadError::banner(
463 "the module rejected its `config` table (its message is printed above)",
464 )),
465 other => Err(LoadError::banner(format!(
466 "the module's install entry failed (code {other})"
467 ))),
468 }
469}
470
471/// The portable arm: hand the file to the C-ABI loader, bind what it
472/// registered onto the app, and keep the set alive for the process.
473fn install_portable_plugin(
474 app: &mut App,
475 dep: &DepCfg,
476 path: PathBuf,
477 env: &InitEnv,
478 hooks: &Arc<dyn HostHooks>,
479 portable: &mut PortablePlugins,
480) -> Result<LoadedModule, LoadError> {
481 let (set, mut failures) = PluginSet::load(
482 &[lumen_plugin::ResolvedModule {
483 name: dep.name.clone(),
484 path: path.clone(),
485 config: dep.config.clone(),
486 }],
487 env,
488 Arc::clone(hooks),
489 );
490 if let Some(failure) = failures.pop() {
491 return Err(LoadError::banner(failure.reason.to_string()));
492 }
493 // Before the script hosts load, like every registration here: the fns go
494 // into the one `ScriptFnRegistry` each host drains as it comes up.
495 set.install(app);
496 portable.sets.push(set);
497 Ok(LoadedModule {
498 name: dep.name.clone(),
499 path,
500 kind: LoadedKind::PortablePlugin,
501 build_id: String::new(),
502 })
503}
504
505/// What the engine offers every portable plugin: events onto the core bus,
506/// logs to stderr, wakes through whatever loop the backend later installs.
507struct BusHooks {
508 waker: Arc<OnceLock<EventLoopWaker>>,
509}
510
511impl HostHooks for BusHooks {
512 fn event(&self, _module: &str, event: PluginEvent) -> bool {
513 match codec::encode(&event) {
514 Ok(bytes) => push_plugin_event(bytes),
515 Err(_) => false,
516 }
517 }
518
519 fn log(&self, module: &str, level: LogLevel, message: &str) {
520 let level = match level {
521 LogLevel::Error => "error",
522 LogLevel::Warn => "warn",
523 LogLevel::Info => "info",
524 LogLevel::Debug => "debug",
525 LogLevel::Trace => "trace",
526 };
527 eprintln!("lumen-runtime: plugin '{module}' {level}: {message}");
528 }
529
530 fn wake(&self) {
531 if let Some(waker) = self.waker.get() {
532 waker.wake();
533 }
534 }
535}
536
537/// The system that closes the ordering gap between the loader (plugin-build
538/// time) and the backend's [`EventLoopWaker`] (event-loop start): copy the
539/// resource into the hooks' slot once it exists. The `OnceLock` write makes
540/// every call after the first a no-op; a headless app that never inserts a
541/// waker keeps hitting the `None` arm, and its driver ticks on
542/// `work_pending` instead.
543fn wire_plugin_waker(
544 slot: Arc<OnceLock<EventLoopWaker>>,
545) -> impl Fn(Option<Res<'_, EventLoopWaker>>) + Send + Sync + 'static {
546 move |waker: Option<Res<EventLoopWaker>>| {
547 if let Some(waker) = waker {
548 let _ = slot.set(waker.clone());
549 }
550 }
551}
552
553/// The running engine's build id, read from the process's own loaded
554/// `liblumen_engine` via its exported C symbol. `None` when the process does
555/// not link the engine dynamically - the hazard the engine-locked arm
556/// refuses on.
557///
558/// Two probes, because the engine reaches a process two ways. A binary that
559/// links it (`lumenc`, a packaged Rust app) has it among its own `NEEDED`
560/// libraries, in the global symbol scope the first probe searches. A host
561/// that dlopens `liblumen` (the launcher, the C++ and Python SDKs) pulls the
562/// engine in as a dependency of that local-scope load, where the global
563/// search cannot see it; `RTLD_NOLOAD` asks the dynamic linker whether the
564/// library is mapped in the process at all, whatever scope it arrived in,
565/// and loads nothing when it is not.
566fn engine_build_id() -> Option<String> {
567 #[cfg(unix)]
568 {
569 use libloading::os::unix::{Library, RTLD_LAZY, Symbol};
570
571 // SAFETY: looking up and calling the engine's own C-ABI export, which
572 // returns a NUL-terminated static valid for the process lifetime.
573 unsafe {
574 let this = Library::this();
575 if let Ok(sym) = this.get::<ProbeFn>(b"lumen_engine_build_id\0") {
576 return read_probe(&sym);
577 }
578 }
579 for name in ["liblumen_engine.so", "liblumen_engine.dylib"] {
580 // SAFETY: RTLD_NOLOAD never runs initializers - it only returns a
581 // handle to a library some earlier load already mapped - and the
582 // symbol contract is the same as above.
583 unsafe {
584 let Ok(lib) = Library::open(Some(name), libc::RTLD_NOLOAD | RTLD_LAZY) else {
585 continue;
586 };
587 let id = lib
588 .get::<ProbeFn>(b"lumen_engine_build_id\0")
589 .ok()
590 .and_then(|sym: Symbol<ProbeFn>| read_probe(&sym));
591 // Balance nothing: the handle came from a library already
592 // held open by whoever loaded it, but dropping would still
593 // dlclose one reference, so keep ours for the process's life
594 // like every other engine mapping.
595 std::mem::forget(lib);
596 if id.is_some() {
597 return id;
598 }
599 }
600 }
601 None
602 }
603 #[cfg(not(unix))]
604 {
605 // Windows has no linkable engine dylib at all (see
606 // public/lumen-dylib/Cargo.toml), so no process qualifies.
607 None
608 }
609}
610
611/// The directory of the shared engine library this process runs on, when it
612/// runs on one: where the toolchain keeps its bundled modules, whichever
613/// executable loaded the engine. `None` for a process that compiled the
614/// engine in, and on Windows, where no engine library exists.
615pub fn engine_dir() -> Option<PathBuf> {
616 #[cfg(unix)]
617 {
618 use libloading::os::unix::{Library, RTLD_LAZY, Symbol};
619
620 // SAFETY: the symbol is only looked up and its address handed to
621 // `dladdr`, which reads the loader's own records; nothing is called.
622 unsafe {
623 let address = |sym: &Symbol<ProbeFn>| **sym as *const std::ffi::c_void;
624 let mut found = Library::this()
625 .get::<ProbeFn>(b"lumen_engine_build_id\0")
626 .ok()
627 .map(|sym| address(&sym));
628 if found.is_none() {
629 for name in ["liblumen_engine.so", "liblumen_engine.dylib"] {
630 let Ok(lib) = Library::open(Some(name), libc::RTLD_NOLOAD | RTLD_LAZY) else {
631 continue;
632 };
633 found = lib
634 .get::<ProbeFn>(b"lumen_engine_build_id\0")
635 .ok()
636 .map(|sym| address(&sym));
637 // Keep the reference the open took, as `engine_build_id`
638 // does: the library stays mapped for the process's life.
639 std::mem::forget(lib);
640 if found.is_some() {
641 break;
642 }
643 }
644 }
645 let mut info: libc::Dl_info = std::mem::zeroed();
646 if libc::dladdr(found?, &mut info) == 0 || info.dli_fname.is_null() {
647 return None;
648 }
649 let file = PathBuf::from(
650 CStr::from_ptr(info.dli_fname)
651 .to_string_lossy()
652 .into_owned(),
653 );
654 // The executable itself defines the symbol when it was linked
655 // against the engine rlib with the export kept; that is not a
656 // shared engine.
657 if std::env::current_exe().is_ok_and(|exe| same_file(&exe, &file)) {
658 return None;
659 }
660 file.parent().map(Path::to_path_buf)
661 }
662 }
663 #[cfg(not(unix))]
664 {
665 None
666 }
667}
668
669/// Call one build-id probe and copy the string out.
670#[cfg(unix)]
671unsafe fn read_probe(probe: &ProbeFn) -> Option<String> {
672 // SAFETY: the caller established the symbol contract (NUL-terminated
673 // static, valid for the process lifetime).
674 unsafe {
675 let p = probe();
676 if p.is_null() {
677 return None;
678 }
679 Some(CStr::from_ptr(p).to_string_lossy().into_owned())
680 }
681}
682
683/// Whether two paths name one file. Canonicalized, so `./lib.so` and
684/// `lib.so` are the same entry; a path that cannot be canonicalized (it was
685/// opened, so this is rare) compares as itself.
686fn same_file(a: &Path, b: &Path) -> bool {
687 let real = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
688 real(a) == real(b)
689}
690
691/// Resolve one dependency to the library file to open. `Err` is the reason,
692/// listing every probed path.
693fn resolve(dir: &Path, dep: &DepCfg, resolved: &ResolvedModules) -> Result<PathBuf, String> {
694 let mut probed: Vec<PathBuf> = Vec::new();
695 match &dep.source {
696 ModuleSource::Path(p) => {
697 let base = {
698 let pp = Path::new(p);
699 if pp.is_absolute() {
700 pp.to_path_buf()
701 } else {
702 dir.join(pp)
703 }
704 };
705 if base.extension().is_some() {
706 if base.is_file() {
707 return Ok(base);
708 }
709 probed.push(base);
710 } else {
711 let stem = base
712 .file_name()
713 .map(|n| n.to_string_lossy().into_owned())
714 .unwrap_or_default();
715 let parent = base.parent().unwrap_or(Path::new(".")).to_path_buf();
716 for f in library_spellings(&stem) {
717 probed.push(parent.join(f));
718 }
719 }
720 push_module_dirs(dir, &dep.name, &mut probed);
721 }
722 ModuleSource::Bundled => {
723 // Beside the running engine: the executable's directory, the
724 // shared engine library's own, then $LUMEN_LIB_DIR - the same
725 // order the liblumen loader probes - then the `modules/`
726 // directories a packaged app stages into. The engine's directory
727 // is what finds the toolchain's modules for an executable that
728 // lives elsewhere, such as an SDK app.
729 if let Ok(exe) = std::env::current_exe()
730 && let Some(exe_dir) = exe.parent()
731 {
732 for f in library_spellings(&dep.name) {
733 probed.push(exe_dir.join(f));
734 }
735 }
736 if let Some(engine_dir) = engine_dir() {
737 for f in library_spellings(&dep.name) {
738 let path = engine_dir.join(f);
739 if !probed.contains(&path) {
740 probed.push(path);
741 }
742 }
743 }
744 if let Some(lib_dir) = std::env::var_os("LUMEN_LIB_DIR") {
745 let lib_dir = PathBuf::from(lib_dir);
746 for f in library_spellings(&dep.name) {
747 probed.push(lib_dir.join(f));
748 }
749 }
750 push_module_dirs(dir, &dep.name, &mut probed);
751 }
752 ModuleSource::Version(v) => {
753 // The runtime never resolves a version. The compiler does, and
754 // hands its answer in; failing that, the probe only finds a
755 // module a build step already put in a modules directory.
756 match resolved.0.get(&dep.name) {
757 Some(Ok(path)) => {
758 if path.is_file() {
759 return Ok(path.clone());
760 }
761 return Err(format!(
762 "the resolved copy of version \"{v}\" is gone: {}",
763 path.display()
764 ));
765 }
766 Some(Err(reason)) => return Err(reason.clone()),
767 None => {}
768 }
769 push_module_dirs(dir, &dep.name, &mut probed);
770 if let Some(hit) = probed.iter().find(|c| c.is_file()) {
771 return Ok(hit.clone());
772 }
773 return Err(format!(
774 "version \"{v}\" is not resolved at runtime; run through `lumenc run` or ship \
775 with `lumenc package` - the runtime does not resolve versions.\nProbed:\n{}",
776 list(&probed)
777 ));
778 }
779 }
780 if let Some(hit) = probed.iter().find(|c| c.is_file()) {
781 return Ok(hit.clone());
782 }
783 // A directory holding a web half and no library is a module for pages
784 // alone, which is worth saying in place of a bare "not found".
785 if let ModuleSource::Path(p) = &dep.source
786 && crate::addon::web_half(&dir.join(p)).is_some()
787 {
788 return Err(format!(
789 "'{}' has a web half and no library, so it runs only in a browser. Declare it \
790 under [target.web.dependencies] to keep it out of desktop builds.\nProbed:\n{}",
791 dep.name,
792 list(&probed)
793 ));
794 }
795 Err(format!(
796 "no module library found.\nProbed:\n{}",
797 list(&probed)
798 ))
799}
800
801/// The `modules/` directories a module may ship in: the app's own, then the
802/// running executable's.
803fn push_module_dirs(dir: &Path, name: &str, probed: &mut Vec<PathBuf>) {
804 for f in library_spellings(name) {
805 probed.push(dir.join("modules").join(f));
806 }
807 if let Ok(exe) = std::env::current_exe()
808 && let Some(exe_dir) = exe.parent()
809 {
810 for f in library_spellings(name) {
811 probed.push(exe_dir.join("modules").join(f));
812 }
813 }
814}
815
816fn list(paths: &[PathBuf]) -> String {
817 paths
818 .iter()
819 .map(|p| format!(" {}", p.display()))
820 .collect::<Vec<_>>()
821 .join("\n")
822}
823
824/// The unmissable stderr banner, in the visual style of the script
825/// load-failure one: a module failing to load must never read as "the app
826/// silently lost a feature".
827fn banner(name: &str, reason: &str) {
828 eprintln!(
829 "\n\
830 ================================================================\n\
831 lumen-runtime: MODULE LOAD FAILED: {name}\n\
832 \n\
833 {reason}\n\
834 \n\
835 The app keeps running without this module.\n\
836 ================================================================\n"
837 );
838}
839
840#[cfg(test)]
841mod tests {
842 use super::*;
843
844 #[test]
845 fn an_entry_symbol_carries_the_declared_name() {
846 assert_eq!(
847 entry_symbol(PROBE_PREFIX, "lumen-audio"),
848 "lumen_module_probe_lumen_audio"
849 );
850 assert_eq!(
851 entry_symbol(INSTALL_PREFIX, "fixture"),
852 "lumen_module_install_fixture"
853 );
854 // Anything a symbol cannot carry becomes an underscore, so a name
855 // the toml table accepts always maps to one that links.
856 assert_eq!(
857 entry_symbol(PROBE_PREFIX, "shape.tools+2"),
858 "lumen_module_probe_shape_tools_2"
859 );
860 }
861
862 #[test]
863 fn a_static_test_binary_has_no_dynamic_engine() {
864 // This test binary compiles its crates in statically, which is
865 // exactly the process shape the engine-locked arm must refuse: no
866 // `lumen_engine_build_id` in the dynamic symbol table, and no
867 // `liblumen_engine` mapped for RTLD_NOLOAD to find.
868 assert_eq!(engine_build_id(), None);
869 }
870
871 #[test]
872 fn a_path_holding_only_a_web_half_says_so() {
873 let dir =
874 std::env::temp_dir().join(format!("lumen-loader-web-half-{}", std::process::id()));
875 let web = dir.join("echo").join(crate::addon::WEB_DIR);
876 std::fs::create_dir_all(&web).unwrap();
877 std::fs::write(web.join(crate::addon::ADDON_MANIFEST), "").unwrap();
878 let dep = |path: &str| DepCfg {
879 name: "echo".to_string(),
880 source: ModuleSource::Path(path.to_string()),
881 config: toml::Table::new(),
882 tags: Vec::new(),
883 };
884 let err = resolve(&dir, &dep("echo"), &ResolvedModules::default()).unwrap_err();
885 assert!(err.contains("runs only in a browser"), "{err}");
886 assert!(err.contains("[target.web.dependencies]"), "{err}");
887 let err = resolve(&dir, &dep("other"), &ResolvedModules::default()).unwrap_err();
888 assert!(err.starts_with("no module library found"), "{err}");
889 let _ = std::fs::remove_dir_all(&dir);
890 }
891}