Skip to main content

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}