From 95ffa754ea69d0f3a8a90614e5907734c86e2288 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 14 Aug 2026 08:57:19 +0300 Subject: [PATCH 1/2] feat(module): lazily map modules on first call Co-authored-by: Medulla --- crates/tinybus/examples/module_clock.rs | 2 +- crates/tinybus/src/module/host.rs | 351 ++++++++++++++++++++++-- crates/tinybus/src/module/host_test.rs | 49 ++++ crates/tinybus/src/module/manifest.rs | 4 +- crates/tinybus/src/module/transport.rs | 54 ++-- docs/modules/module/README.md | 25 +- 6 files changed, 443 insertions(+), 42 deletions(-) diff --git a/crates/tinybus/examples/module_clock.rs b/crates/tinybus/examples/module_clock.rs index 2683cc7..0dfa336 100644 --- a/crates/tinybus/examples/module_clock.rs +++ b/crates/tinybus/examples/module_clock.rs @@ -47,7 +47,7 @@ tinybus_module::module_export! { signals = [], requires = [], optional = [], - lazy = false, + lazy = true, } #[cfg(test)] diff --git a/crates/tinybus/src/module/host.rs b/crates/tinybus/src/module/host.rs index aac59a8..3f64548 100644 --- a/crates/tinybus/src/module/host.rs +++ b/crates/tinybus/src/module/host.rs @@ -1,6 +1,7 @@ //! Module admission, dependency ordering, attachment, and lifecycle. use std::collections::{HashMap, HashSet}; +use std::io::Read; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; @@ -19,6 +20,8 @@ use crate::name::{BusName, ObjectPath}; use crate::ports::Transport; use crate::version::Version; +const LAZY_MANIFEST_SUFFIX: &str = ".manifest.json"; + /// Current lifecycle state of a discovered module. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "state", content = "detail", rename_all = "snake_case")] @@ -73,9 +76,11 @@ pub struct ModuleInfo { pub state: ModuleState, /// Module dependency and surface declaration. pub manifest: ModuleManifest, - /// Toolchain recorded by the descriptor, sanitized for display. + /// Toolchain recorded by the descriptor, sanitized for display. Empty + /// until a truly lazy module is first loaded. pub rustc_version: String, - /// Whether this module's toolchain differs from the host. + /// Whether this module's toolchain differs from the host. This becomes + /// meaningful once a truly lazy module has been loaded. pub rustc_mismatch: bool, /// Whether discovery should admit this module on future scans. pub enabled: bool, @@ -86,6 +91,35 @@ struct LoadedModule { transport: Arc, unique_name: BusName, transition_from: Option, + descriptor_info: Option, +} + +enum PendingModule { + Loaded(Box), + Lazy(Box), +} + +type DescriptorInfo = Arc>>; + +#[derive(Clone)] +struct DescriptorMetadata { + rustc_version: String, + rustc_mismatch: bool, +} + +struct Activation { + lazy_init: bool, + lazy_load: bool, + descriptor_info: Option, +} + +impl PendingModule { + fn manifest(&self) -> &ModuleManifest { + match self { + Self::Loaded(artifact) => &artifact.manifest, + Self::Lazy(manifest) => manifest, + } + } } #[derive(Clone, Copy)] @@ -98,6 +132,14 @@ enum RefusalClass { impl LoadedModule { fn snapshot(&self) -> ModuleInfo { let mut info = self.info.clone(); + if let Some(metadata) = self + .descriptor_info + .as_ref() + .and_then(|value| value.lock().expect("module descriptor lock").clone()) + { + info.rustc_version = metadata.rustc_version; + info.rustc_mismatch = metadata.rustc_mismatch; + } if self.transport.init_failed() && !matches!(info.state, ModuleState::Stopped | ModuleState::Disabled) { @@ -301,6 +343,10 @@ impl ModuleHost { })?; } check_file(path)?; + if let Some(manifest) = read_lazy_manifest(path)? { + self.ensure_dependencies(&manifest, path)?; + return self.register_lazy(path, manifest, config); + } let artifact = loader::load(path, self.inner.strict.load(Ordering::Acquire))?; let rejected_manifest = artifact.manifest.clone(); if let Err(error) = self.ensure_dependencies(&artifact.manifest, path) { @@ -315,6 +361,39 @@ impl ModuleHost { result } + /// Register a module without mapping its library into this process. + /// + /// `manifest` must be the same manifest embedded in the library and must + /// set `lazy_init`. The first method call loads the artifact, verifies the + /// embedded declaration against this copy, and initializes it exactly once. + pub fn register_lazy_file( + &self, + path: impl AsRef, + manifest: ModuleManifest, + ) -> Result { + self.register_lazy_file_with_config(path, manifest, serde_json::json!({})) + } + + /// Configured form of [`ModuleHost::register_lazy_file`]. + pub fn register_lazy_file_with_config( + &self, + path: impl AsRef, + manifest: ModuleManifest, + config: serde_json::Value, + ) -> Result { + let path = path.as_ref(); + if let Some(parent) = path.parent() { + check_directory(if parent.as_os_str().is_empty() { + Path::new(".") + } else { + parent + })?; + } + check_file(path)?; + self.ensure_dependencies(&manifest, path)?; + self.register_lazy(path, manifest, config) + } + /// Discover and load every platform library in a private directory. /// /// Refusals are returned per artifact so one bad file cannot prevent the @@ -349,46 +428,59 @@ impl ModuleHost { let mut outcomes = Vec::new(); let mut pending = Vec::new(); for path in paths { - match check_file(&path) - .and_then(|()| loader::load(&path, self.inner.strict.load(Ordering::Acquire))) - { - Ok(artifact) => pending.push((path, artifact)), + let inspected = check_file(&path).and_then(|()| { + if let Some(manifest) = read_lazy_manifest(&path)? { + Ok(PendingModule::Lazy(Box::new(manifest))) + } else { + loader::load(&path, self.inner.strict.load(Ordering::Acquire)) + .map(|artifact| PendingModule::Loaded(Box::new(artifact))) + } + }); + match inspected { + Ok(module) => pending.push((path, module)), Err(error) => outcomes.push(Err(error)), } } let manifests = pending .iter() - .map(|(_, artifact)| artifact.manifest.clone()) + .map(|(_, module)| module.manifest().clone()) .collect::>(); let resolution = crate::module::resolve::resolve(&manifests, &self.provided_interfaces()); let mut pending = pending.into_iter().map(Some).collect::>(); for (index, reason) in resolution.unresolved { - let (path, artifact) = pending[index].take().expect("resolver index is valid"); + let (path, module) = pending[index].take().expect("resolver index is valid"); let error = Error::module_refused(&path, reason); - self.record_manifest_rejection(&error, artifact.manifest, RefusalClass::Unresolved); + self.record_manifest_rejection( + &error, + module.manifest().clone(), + RefusalClass::Unresolved, + ); outcomes.push(Err(error)); } for index in resolution.order { - let (path, artifact) = pending[index].take().expect("resolver index is valid"); + let (path, module) = pending[index].take().expect("resolver index is valid"); let config = self .inner .configs .lock() .expect("module config lock") - .get(&artifact.manifest.module.name) + .get(&module.manifest().module.name) .cloned() .unwrap_or_else(|| serde_json::json!({})); let result = self - .ensure_dependencies(&artifact.manifest, &path) + .ensure_dependencies(module.manifest(), &path) .inspect_err(|error| { self.record_manifest_rejection( error, - artifact.manifest.clone(), + module.manifest().clone(), RefusalClass::Unresolved, ); }) - .and_then(|()| self.activate(&path, artifact, config)); + .and_then(|()| match module { + PendingModule::Loaded(artifact) => self.activate(&path, *artifact, config), + PendingModule::Lazy(manifest) => self.register_lazy(&path, *manifest, config), + }); outcomes.push(result); } for error in outcomes.iter().filter_map(|outcome| outcome.as_ref().err()) { @@ -447,12 +539,16 @@ impl ModuleHost { let mut results = Vec::new(); let mut admitted = Vec::new(); for path in paths { - let inspected = check_file(&path) - .and_then(|()| loader::load(&path, self.inner.strict.load(Ordering::Acquire))) - .and_then(|artifact| { + let inspected = check_file(&path).and_then(|()| { + if let Some(manifest) = read_lazy_manifest(&path)? { + let info = provisional_info(&path, &manifest)?; + Ok((PendingModule::Lazy(Box::new(manifest)), info)) + } else { + let artifact = loader::load(&path, self.inner.strict.load(Ordering::Acquire))?; let info = self.validate(&path, &artifact.descriptor, &artifact.manifest)?; - Ok((artifact, info)) - }); + Ok((PendingModule::Loaded(Box::new(artifact)), info)) + } + }); match inspected { Ok((artifact, info)) => admitted.push((artifact, info)), Err(error) => results.push(rejection_info(&error)), @@ -460,7 +556,7 @@ impl ModuleHost { } let manifests = admitted .iter() - .map(|(artifact, _)| artifact.manifest.clone()) + .map(|(module, _)| module.manifest().clone()) .collect::>(); let resolution = crate::module::resolve::resolve(&manifests, &self.provided_interfaces()); let mut admitted = admitted.into_iter().map(Some).collect::>(); @@ -553,6 +649,98 @@ impl ModuleHost { } } + self.attach_transport( + path, + admitted, + manifest, + transport, + Activation { + lazy_init: artifact.manifest.lazy_init, + lazy_load: false, + descriptor_info: None, + }, + ) + } + + fn register_lazy( + &self, + path: &Path, + manifest: ModuleManifest, + config: serde_json::Value, + ) -> Result { + let _admission = self.inner.admission.lock().expect("module admission lock"); + let admitted = provisional_info(path, &manifest).inspect_err(|error| { + self.record_manifest_rejection(error, manifest.clone(), RefusalClass::Rejected); + })?; + if self + .inner + .loaded + .lock() + .expect("module list lock") + .iter() + .any(|loaded| loaded.info.name == admitted.name) + { + let error = Error::module_refused(path, "module name is already loaded"); + self.record_manifest_rejection(&error, manifest.clone(), RefusalClass::Unresolved); + return Err(error); + } + let config = serde_json::to_vec(&config).map_err(|_| { + let error = Error::module_refused(path, "module configuration is invalid"); + self.record_manifest_rejection(&error, manifest.clone(), RefusalClass::Rejected); + error + })?; + let (transport, host_vtable) = ModuleTransport::new(admitted.name.clone(), config); + let expected = manifest.clone(); + let artifact_path = path.to_path_buf(); + let strict = self.inner.strict.load(Ordering::Acquire); + let descriptor_info = Arc::new(Mutex::new(None)); + let loaded_descriptor_info = descriptor_info.clone(); + transport.defer_initializer(host_vtable, move |host| { + let artifact = loader::load(&artifact_path, strict) + .map_err(|_| "module library could not be loaded".to_string())?; + let rustc = sanitized_field(&artifact.descriptor.rustc_version); + if artifact.manifest != expected + || loaded_identity(&artifact.descriptor, &artifact.manifest).is_none() + || rustc.is_none() + { + return Err("loaded module does not match its lazy manifest".to_string()); + } + *loaded_descriptor_info + .lock() + .expect("module descriptor lock") = Some(DescriptorMetadata { + rustc_version: rustc.expect("checked above"), + rustc_mismatch: field_bytes(&artifact.descriptor.rustc_version) + != build_info::RUSTC_VERSION.as_bytes(), + }); + let mut module = TbModuleVtable::default(); + let code = unsafe { (artifact.init)(&host, &mut module) }; + if code == TB_OK { + Ok(module) + } else { + Err("module initialization failed".to_string()) + } + }); + self.attach_transport( + path, + admitted, + manifest, + transport, + Activation { + lazy_init: true, + lazy_load: true, + descriptor_info: Some(descriptor_info), + }, + ) + } + + fn attach_transport( + &self, + path: &Path, + admitted: ModuleInfo, + manifest: ModuleManifest, + transport: Arc, + activation: Activation, + ) -> Result { let transport_for_broker: Arc = transport.clone(); let unique = self.inner.broker.attach(transport_for_broker); let reserved_change = match self @@ -572,7 +760,9 @@ impl ModuleHost { let ready_transport = transport.clone(); let module_name = admitted.name.clone(); let previous_state = state_name(&admitted.state); - let lazy_init = artifact.manifest.lazy_init; + let lazy_init = activation.lazy_init; + let lazy_load = activation.lazy_load; + let descriptor_info = activation.descriptor_info; tokio::spawn(async move { if lazy_init { ready_transport.wait_initializing().await; @@ -604,7 +794,11 @@ impl ModuleHost { "in-process modules are inside the host trust boundary" ); } - tracing::info!(module = %admitted.name, "module loaded"); + if lazy_load { + tracing::info!(module = %admitted.name, "module registered for lazy loading"); + } else { + tracing::info!(module = %admitted.name, "module loaded"); + } self.inner .loaded .lock() @@ -614,6 +808,7 @@ impl ModuleHost { transport, unique_name: unique, transition_from: None, + descriptor_info, }); Ok(admitted) } @@ -985,6 +1180,118 @@ impl ModuleControl for ModuleHostInner { } } +fn lazy_manifest_path(path: &Path) -> PathBuf { + let name = path + .file_name() + .map(|name| name.to_string_lossy()) + .unwrap_or_default(); + path.with_file_name(format!("{name}{LAZY_MANIFEST_SUFFIX}")) +} + +fn read_lazy_manifest(path: &Path) -> Result> { + let sidecar = lazy_manifest_path(path); + let sidecar_metadata = match std::fs::symlink_metadata(&sidecar) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(_) => return Err(Error::module_refused(path, "lazy manifest is unreadable")), + }; + if !sidecar_metadata.file_type().is_file() || sidecar_metadata.len() > 1024 * 1024 { + return Err(Error::module_refused( + path, + "lazy manifest is not a regular file below the 1 MiB limit", + )); + } + #[cfg(unix)] + let file = { + use std::os::unix::fs::OpenOptionsExt; + + #[cfg(target_os = "macos")] + const O_NOFOLLOW: i32 = 0x100; + #[cfg(not(target_os = "macos"))] + const O_NOFOLLOW: i32 = 0x2_0000; + std::fs::OpenOptions::new() + .read(true) + .custom_flags(O_NOFOLLOW) + .open(&sidecar) + .map_err(|_| Error::module_refused(path, "lazy manifest is unreadable"))? + }; + #[cfg(windows)] + let file = std::fs::File::open(&sidecar) + .map_err(|_| Error::module_refused(path, "lazy manifest is unreadable"))?; + let metadata = file + .metadata() + .map_err(|_| Error::module_refused(path, "lazy manifest metadata is unavailable"))?; + if !metadata.file_type().is_file() || metadata.len() > 1024 * 1024 { + return Err(Error::module_refused( + path, + "lazy manifest is not a regular file below the 1 MiB limit", + )); + } + let mut bytes = Vec::with_capacity(metadata.len() as usize); + file.take(1024 * 1024 + 1) + .read_to_end(&mut bytes) + .map_err(|_| Error::module_refused(path, "lazy manifest is unreadable"))?; + if bytes.len() > 1024 * 1024 { + return Err(Error::module_refused( + path, + "lazy manifest is not a regular file below the 1 MiB limit", + )); + } + let manifest: ModuleManifest = serde_json::from_slice(&bytes) + .map_err(|_| Error::module_refused(path, "lazy manifest is not valid JSON"))?; + if !manifest.lazy_init { + return Err(Error::module_refused( + path, + "lazy manifest must set lazy_init", + )); + } + Ok(Some(manifest)) +} + +fn provisional_info(path: &Path, manifest: &ModuleManifest) -> Result { + let name = sanitize_untrusted(&manifest.module.name); + let version = sanitize_untrusted(&manifest.module.version.to_string()); + if !manifest.lazy_init + || manifest.schema != MANIFEST_SCHEMA + || name.is_empty() + || name != manifest.module.name + || version != manifest.module.version.to_string() + || manifest.module.name.len() > 64 + || manifest.module.version.to_string().len() > 32 + { + return Err(Error::module_refused( + path, + "lazy manifest identity is invalid", + )); + } + Ok(ModuleInfo { + name, + version, + file: safe_file_name(path), + state: ModuleState::Resolved, + manifest: manifest.clone(), + // These descriptor facts are unavailable until the first call maps the + // library. They remain empty/false rather than pretending to be known. + rustc_version: String::new(), + rustc_mismatch: false, + enabled: true, + }) +} + +fn loaded_identity( + descriptor: &TbAbiDescriptor, + manifest: &ModuleManifest, +) -> Option<(String, String)> { + let name = sanitized_field(&descriptor.module_name)?; + let version = sanitized_field(&descriptor.module_version)?; + (manifest.schema == MANIFEST_SCHEMA + && sanitize_untrusted(&manifest.module.name) == name + && sanitize_untrusted(&manifest.module.version.to_string()) == version + && manifest.module.name.len() <= 64 + && manifest.module.version.to_string().len() <= 32) + .then_some((name, version)) +} + fn rejection_info(error: &Error) -> ModuleInfo { let (file, reason) = match error { Error::ModuleRefused { file, reason } => (file.clone(), reason.clone()), diff --git a/crates/tinybus/src/module/host_test.rs b/crates/tinybus/src/module/host_test.rs index da257de..de7afa9 100644 --- a/crates/tinybus/src/module/host_test.rs +++ b/crates/tinybus/src/module/host_test.rs @@ -180,6 +180,55 @@ async fn a_lazy_module_initializes_on_the_first_call_and_two_racing_callers_init broker_task.abort(); } +#[tokio::test] +async fn a_lazy_manifest_registers_an_unmapped_library_and_the_first_call_loads_it() { + let directory = tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap(); + let extension = if cfg!(windows) { + "dll" + } else if cfg!(target_os = "macos") { + "dylib" + } else { + "so" + }; + let artifact = directory.path().join(format!("clock.{extension}")); + // These are deliberately not a dynamic library. Registration succeeding + // proves discovery did not ask the platform loader to map the artifact. + std::fs::write(&artifact, b"not loaded until the first call").unwrap(); + let mut lazy_manifest = manifest(); + lazy_manifest.lazy_init = true; + std::fs::write( + lazy_manifest_path(&artifact), + serde_json::to_vec(&lazy_manifest).unwrap(), + ) + .unwrap(); + + let bus = MemoryBus::new(); + let broker = Broker::new(); + let broker_task = broker.spawn(bus.clone()); + let host = ModuleHost::new(broker); + let loaded = host.load_dir(directory.path()).unwrap(); + assert_eq!(loaded.len(), 1); + assert_eq!(loaded[0].as_ref().unwrap().state, ModuleState::Resolved); + + let connection = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let proxy = connection + .proxy( + "ai.tinyhumans.module.Clock", + "/ai/tinyhumans/module/Clock", + "ai.tinyhumans.module.Clock", + ) + .unwrap(); + let error = proxy.call::<()>("Now", ()).await.unwrap_err(); + assert_eq!( + error.wire_name(), + "ai.tinyhumans.tinybus.Error.ModuleUnavailable" + ); + assert!(matches!(host.list()[0].state, ModuleState::Failed { .. })); + broker_task.abort(); +} + #[tokio::test] async fn a_module_whose_init_fails_is_terminal_and_is_never_initialized_again() { FAILED_INIT_COUNT.store(0, Ordering::Release); diff --git a/crates/tinybus/src/module/manifest.rs b/crates/tinybus/src/module/manifest.rs index de38f39..5105f1d 100644 --- a/crates/tinybus/src/module/manifest.rs +++ b/crates/tinybus/src/module/manifest.rs @@ -111,7 +111,9 @@ pub struct ModuleManifest { /// Declared privileges for operator inspection. #[serde(default)] pub capabilities: Vec, - /// Defer setup until the first method call. + /// Defer setup until the first method call. When this manifest is installed + /// as the artifact's `.manifest.json` sidecar, library mapping is deferred + /// as well. #[serde(default)] pub lazy_init: bool, /// Tokio worker threads owned by this module. diff --git a/crates/tinybus/src/module/transport.rs b/crates/tinybus/src/module/transport.rs index 05dcad6..19cbfe4 100644 --- a/crates/tinybus/src/module/transport.rs +++ b/crates/tinybus/src/module/transport.rs @@ -46,7 +46,7 @@ pub(crate) struct ModuleTransport { inbound: Mutex>>, context: &'static HostContext, label: String, - initializer: StdMutex>, + initializer: StdMutex>, init_result: OnceCell>, pending: Mutex>, drain_started: AtomicBool, @@ -57,19 +57,24 @@ pub(crate) struct ModuleTransport { unsafe impl Send for ModuleTransport {} unsafe impl Sync for ModuleTransport {} -struct SendHostVtable(TbHostVtable); +type Initializer = + Box std::result::Result + Send + 'static>; + +struct DeferredInitializer { + initialize: Initializer, + host: TbHostVtable, +} + struct SendModuleVtable(TbModuleVtable); // The opaque host context is process-lifetime state and every callback is // required by the ABI to be thread-safe. -unsafe impl Send for SendHostVtable {} +unsafe impl Send for DeferredInitializer {} unsafe impl Send for SendModuleVtable {} -impl SendHostVtable { - fn initialize(self, init: crate::module::abi::TbModuleInit) -> (i32, SendModuleVtable) { - let mut module = TbModuleVtable::default(); - let code = unsafe { init(&self.0, &mut module) }; - (code, SendModuleVtable(module)) +impl DeferredInitializer { + fn run(self) -> std::result::Result { + (self.initialize)(self.host).map(SendModuleVtable) } } @@ -132,7 +137,25 @@ impl ModuleTransport { init: crate::module::abi::TbModuleInit, host: TbHostVtable, ) { - *self.initializer.lock().expect("module initializer lock") = Some((init, host)); + self.defer_initializer(host, move |host| { + let mut module = TbModuleVtable::default(); + let code = unsafe { init(&host, &mut module) }; + if code == TB_OK { + Ok(module) + } else { + Err("module initialization failed".to_string()) + } + }); + } + + pub(crate) fn defer_initializer(&self, host: TbHostVtable, initialize: F) + where + F: FnOnce(TbHostVtable) -> std::result::Result + Send + 'static, + { + *self.initializer.lock().expect("module initializer lock") = Some(DeferredInitializer { + initialize: Box::new(initialize), + host, + }); } async fn ensure_initialized(&self) -> Result<()> { @@ -141,7 +164,7 @@ impl ModuleTransport { .get_or_init(|| async { self.context.init_started.store(true, Ordering::Release); self.context.init_notify.notify_waiters(); - let Some((init, host)) = self + let Some(initializer) = self .initializer .lock() .expect("module initializer lock") @@ -157,21 +180,18 @@ impl ModuleTransport { // worker threads and bound how long the broker waits; a timed // out blocking task may remain wedged, so its borrowed config // remains allocated rather than being invalidated underneath it. - let host = SendHostVtable(host); let initialized = tokio::time::timeout( MODULE_INIT_DEADLINE, - tokio::task::spawn_blocking(move || host.initialize(init)), + tokio::task::spawn_blocking(move || initializer.run()), ) .await; - let (code, module) = match initialized { - Ok(Ok(initialized)) => initialized, + let module = match initialized { + Ok(Ok(Ok(initialized))) => initialized, + Ok(Ok(Err(reason))) => return Err(reason), Ok(Err(_)) => return Err("module initialization panicked".to_string()), Err(_) => return Err("module initialization exceeded its deadline".to_string()), }; self.clear_config(); - if code != TB_OK { - return Err("module initialization failed".to_string()); - } self.initialize(module.0) .map_err(|_| "module returned an invalid vtable".to_string())?; Ok(()) diff --git a/docs/modules/module/README.md b/docs/modules/module/README.md index 044bf07..17a55af 100644 --- a/docs/modules/module/README.md +++ b/docs/modules/module/README.md @@ -28,7 +28,8 @@ restart. 1. Check every directory component's ownership/mode, require a regular platform library file no larger than 512 MiB, and enforce `modules.toml` when present. -2. Load eagerly and locally (`RTLD_NOW | RTLD_LOCAL` on Unix). +2. Read an adjacent lazy manifest when one is present; otherwise load eagerly + and locally (`RTLD_NOW | RTLD_LOCAL` on Unix). 3. Resolve `TINYBUS_MODULE_ABI_V1` against that specific handle. 4. Read and validate only the frozen 16-byte descriptor prefix. 5. Validate the full descriptor, then parse the manifest. @@ -37,6 +38,28 @@ restart. A manifest with `lazy_init = true` defers this step until its first method call; racing first calls share one initialization and retain their order. +## Lazy loading + +To keep a library entirely out of the host address space until it is called, +install a JSON copy of its embedded manifest next to the artifact. For an +artifact named `wallet.so`, the sidecar is `wallet.so.manifest.json` (and the +same suffix rule applies to `.dylib` and `.dll`). The manifest must set +`lazy_init` to `true`. + +At discovery, TinyBus validates the artifact and the sidecar, resolves +dependencies, reserves the declared bus name, and attaches a dormant bounded +transport without calling `dlopen` or `LoadLibraryExW`. The first method call +loads the library on a blocking worker, applies the normal ABI gate, requires +the embedded manifest to exactly match the sidecar, and runs setup. Concurrent +first calls share that one attempt and remain queued in arrival order. A load +or setup failure is terminal for the process and all callers receive +`ModuleUnavailable` rather than waiting for their individual deadlines. + +`ModuleHost::register_lazy_file` provides the same behavior when an embedding +host already has the trusted manifest in memory. Modules without a sidecar keep +the existing behavior: their library is mapped during discovery, while +`lazy_init = true` still defers setup. + The host vtable also carries borrowed JSON configuration. The SDK copies and deserializes it during initialization; the module never retains a pointer into host memory. `module_export!` accepts `config = MyConfig` for an async setup From 35fb0ff20f25b664d69a54991813a06ff8b20ed2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 14 Aug 2026 09:08:38 +0300 Subject: [PATCH 2/2] test(module): respect Windows directory admission Co-authored-by: Medulla --- crates/tinybus/src/module/host_test.rs | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/crates/tinybus/src/module/host_test.rs b/crates/tinybus/src/module/host_test.rs index bd90ce4..25f3f83 100644 --- a/crates/tinybus/src/module/host_test.rs +++ b/crates/tinybus/src/module/host_test.rs @@ -206,9 +206,23 @@ async fn a_lazy_manifest_registers_an_unmapped_library_and_the_first_call_loads_ let broker = Broker::new(); let broker_task = broker.spawn(bus.clone()); let host = ModuleHost::new(broker); - let loaded = host.load_dir(directory.path()).unwrap(); - assert_eq!(loaded.len(), 1); - assert_eq!(loaded[0].as_ref().unwrap().state, ModuleState::Resolved); + #[cfg(not(windows))] + let info = { + let loaded = host.load_dir(directory.path()).unwrap(); + assert_eq!(loaded.len(), 1); + loaded.into_iter().next().unwrap().unwrap() + }; + #[cfg(windows)] + let info = { + // Windows module directories admit only their owner, LocalSystem, and + // Administrators. TempDir inherits a CI-runner ACE that is deliberately + // rejected before discovery, so exercise the same sidecar seam directly; + // the dedicated loader job covers a CI-provisioned private directory. + let discovered = read_lazy_manifest(&artifact).unwrap().unwrap(); + host.register_lazy(&artifact, discovered, serde_json::json!({})) + .unwrap() + }; + assert_eq!(info.state, ModuleState::Resolved); let connection = Connection::connect(bus.connect().await.unwrap()) .await