diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5042d12d..91ca2b7e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,11 +9,15 @@ env: CARGO_TERM_COLOR: always RUSTFLAGS: -D warnings +permissions: {} + jobs: check: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + persist-credentials: false - uses: dtolnay/rust-toolchain@stable with: components: rustfmt, clippy @@ -34,14 +38,17 @@ jobs: - name: Slim build run: cargo check --locked --no-default-features + - name: Module loader without sockets + run: cargo check --locked --no-default-features --features modules + - name: Docs run: cargo doc --locked --all-features --no-deps env: RUSTDOCFLAGS: -D warnings # The bus is meant to be embeddable on every platform OpenHuman ships on. The - # Unix transport is Unix-only by construction, so Windows builds the slim - # configuration until the named-pipe backend lands (ROADMAP.md, M6). + # socket adapter remains Unix-only, while the in-process module transport + # and dynamic loader are portable and run on both non-Linux targets. portability: strategy: fail-fast: false @@ -50,9 +57,81 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 + with: + persist-credentials: false - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - name: Build run: cargo check --locked --no-default-features --features macros - name: Test run: cargo test --locked --no-default-features --features macros + - name: Test modules without sockets + run: cargo test --locked --no-default-features --features modules,macros + + modules: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Build example module + run: cargo build --locked --example module_clock --example module_clock_two --example module_wrong_target --no-default-features --features modules,macros + - name: Exercise the real loader (Unix) + if: runner.os != 'Windows' + shell: bash + run: | + set -euo pipefail + module=$(find target/debug/examples -maxdepth 1 -type f \( -name 'libmodule_clock.so' -o -name 'libmodule_clock.dylib' \) -print -quit) + module_two=$(find target/debug/examples -maxdepth 1 -type f \( -name 'libmodule_clock_two.so' -o -name 'libmodule_clock_two.dylib' \) -print -quit) + wrong_target=$(find target/debug/examples -maxdepth 1 -type f \( -name 'libmodule_wrong_target.so' -o -name 'libmodule_wrong_target.dylib' \) -print -quit) + for artifact in "$module" "$module_two" "$wrong_target"; do + if [ -z "$artifact" ] || [ ! -f "$artifact" ]; then + echo "a module example artifact is missing; the loader tests would not exercise it" >&2 + exit 1 + fi + done + TINYBUS_TEST_MODULE="$PWD/$module" TINYBUS_TEST_MODULE_TWO="$PWD/$module_two" TINYBUS_TEST_WRONG_TARGET="$PWD/$wrong_target" cargo test --locked --no-default-features --features modules,macros -- --ignored + - name: Exercise the real loader (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + $private = Join-Path $PWD target/private-module-tests + New-Item -ItemType Directory -Force $private | Out-Null + $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent() + $acl = [System.Security.AccessControl.DirectorySecurity]::new() + $acl.SetOwner($identity.User) + $acl.SetAccessRuleProtection($true, $false) + $inherit = [System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit' + $rule = [System.Security.AccessControl.FileSystemAccessRule]::new( + $identity.User, + [System.Security.AccessControl.FileSystemRights]::FullControl, + $inherit, + [System.Security.AccessControl.PropagationFlags]::None, + [System.Security.AccessControl.AccessControlType]::Allow + ) + $acl.AddAccessRule($rule) + Set-Acl -LiteralPath $private -AclObject $acl + Copy-Item target/debug/examples/module_clock.dll $private + Copy-Item target/debug/examples/module_clock_two.dll $private + Copy-Item target/debug/examples/module_wrong_target.dll $private + $mixed = Join-Path $private mixed + New-Item -ItemType Directory -Force $mixed | Out-Null + Set-Acl -LiteralPath $mixed -AclObject $acl + Copy-Item target/debug/examples/module_clock.dll $mixed + Copy-Item target/debug/examples/module_wrong_target.dll $mixed + $module = (Get-ChildItem "$private/module_clock.dll" | Select-Object -First 1).FullName + $moduleTwo = (Get-ChildItem "$private/module_clock_two.dll" | Select-Object -First 1).FullName + $wrongTarget = (Get-ChildItem "$private/module_wrong_target.dll" | Select-Object -First 1).FullName + $env:TEMP = $private + $env:TMP = $private + $env:TINYBUS_TEST_MODULE = $module + $env:TINYBUS_TEST_MODULE_TWO = $moduleTwo + $env:TINYBUS_TEST_WRONG_TARGET = $wrongTarget + $env:TINYBUS_TEST_MODULE_DIRECTORY = $mixed + cargo test --locked --no-default-features --features modules,macros -- --ignored diff --git a/AGENTS.md b/AGENTS.md index fcc5d423..f9f2bfb4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,3 +107,10 @@ discussion in the pull request: best-effort signal delivery, and an accept loop that survives a bad client are all this invariant. Any change that lets one peer's slowness reach another's latency is a bug, not a tuning question. +- **In-process modules are inside the trust boundary, not outside it.** The + misbehaving-peer invariant applies to peers reached across a real transport + boundary. A module loaded with `dlopen` shares the host address space and is + trusted exactly as much as the host's own code. Bounded queues, deadlines and + caught panics bound misbehaviour; they cannot contain a segfault, abort, heap + corruption, OOM, or deliberate memory access. An integration whose crash or + compromise must not reach the kernel belongs in a separate process. diff --git a/Cargo.lock b/Cargo.lock index fe404990..d9a5ab02 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -471,6 +471,7 @@ dependencies = [ "tempfile", "thiserror", "tinybus-macros", + "tinybus-module", "tokio", "tracing", "tracing-subscriber", @@ -485,6 +486,18 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "tinybus-module" +version = "0.1.0" +dependencies = [ + "async-trait", + "serde", + "serde_json", + "tinybus", + "tokio", + "tracing", +] + [[package]] name = "tokio" version = "1.53.1" diff --git a/Cargo.toml b/Cargo.toml index 07cac301..8241affe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,13 +1,12 @@ [workspace] -# Two crates, and there is a reason it is not one. `tinybus-macros` is a +# Three crates, and there is a reason they are not one. `tinybus-macros` is a # proc-macro crate, which means it compiles for the *host* even when the bus # itself is cross-compiled; keeping it separate is the only way to have # `#[interface]` without forcing that split on every downstream target. -# Integrations themselves are never workspace members — the whole point is that -# an integration is a separate process (or a separate crate in its own repo) -# that speaks the wire protocol. Adding an integration here would re-create the -# dependency blob we are trying to delete from the kernel. -members = ["crates/tinybus", "crates/tinybus-macros"] +# `tinybus-module` is the module-side half of the C ABI. It is versioned here in +# lockstep with the host ABI so the two definitions cannot drift. Integrations +# themselves remain separate repositories and are never workspace members. +members = ["crates/tinybus", "crates/tinybus-macros", "crates/tinybus-module"] resolver = "3" [workspace.package] @@ -17,8 +16,10 @@ license = "GPL-3.0-only" repository = "https://github.com/tinyhumansai/tinybus" [workspace.dependencies] -tinybus = { path = "crates/tinybus", version = "0.1.0" } +tinybus = { path = "crates/tinybus", version = "0.1.0", default-features = false } tinybus-macros = { path = "crates/tinybus-macros", version = "0.1.0" } +tinybus-module = { path = "crates/tinybus-module", version = "0.1.0" } +tokio = { version = "1", features = ["rt-multi-thread", "sync", "time"] } async-trait = "0.1" serde = { version = "1", features = ["derive"] } diff --git a/README.md b/README.md index 90e04a12..193d18f0 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,11 @@ crates/tinybus/ the bus: protocol, broker, connection, proxy src/name/ validated addresses (bus name, path, interface, member) src/ports/ the two seams: Transport and Listener src/transport/ in-memory (always) and Unix socket (feature `uds`) + src/module/ stable module ABI and optional dynamic loader + src/events/ bounded in-process domain event fan-out + src/global.rs process-wide one-time bus installation + src/native.rs typed in-process request registry + src/version.rs peer and interface compatibility declarations src/router.rs the routing table and match rules src/broker.rs the daemon src/connection.rs a peer's link: calls out, dispatch in @@ -97,6 +102,7 @@ crates/tinybus/ the bus: protocol, broker, connection, proxy src/service/ the Interface trait and the object tree src/bin/tinybus.rs the CLI crates/tinybus-macros/ #[interface] +crates/tinybus-module/ module-side runtime and export macro docs/modules/ one document per module ``` @@ -107,6 +113,7 @@ docs/modules/ one document per module | `uds` | yes | Unix-socket transport — the production one | | `macros` | yes | `#[tinybus::interface]` | | `cli` | yes | the `tinybus` binary | +| `modules` | no | trusted in-process `cdylib` discovery and loading | `--no-default-features` leaves the protocol, the router, the broker and the in-memory transport: no sockets, no proc-macro build step, no CLI. That is the @@ -114,10 +121,12 @@ configuration a slim kernel build embeds. ## Status -Milestone 1. The core protocol, broker, connection, proxy, macro, Unix -transport and CLI are implemented and tested. Service activation, file-descriptor -passing, per-peer authorisation policy and the Windows named-pipe backend are -not — see `ROADMAP.md`. +The core protocol, broker, connection, proxy, macro, Unix transport and CLI are +implemented and tested. Trusted in-process modules support lazy activation +behind the non-default `modules` feature; read the module trust-boundary notes +before enabling it. External service-process or bus-driven activation remains +unimplemented. File-descriptor passing, per-peer authorisation policy and the +Windows named-pipe backend are also not implemented — see `ROADMAP.md`. ## Licence diff --git a/ROADMAP.md b/ROADMAP.md index 13f21ab5..bcc534f4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -47,21 +47,23 @@ Deliberately **not** on this list: `tinyagents`, `tinycortex`, `tinyflows`, crates. Moving them behind a bus would add latency and a serialisation boundary to the hot path and remove nothing. -## M3 — activation +## M3 — trusted in-process modules -Right now a service must already be running. Activation makes the broker start -one on demand: a call to a name nobody owns consults a service file, launches -the process, waits for it to claim the name, and then delivers the call. +OpenHuman may embed the broker and load selected integrations as `cdylib` +modules. Each module is an ordinary peer connected at the `Transport` seam, +with its own runtime and bounded queues. The ABI gate rejects incompatible +artifacts before calling their initialization entrypoint. -This is what turns "the integration is not installed" into a non-error for the -common case, and it is what makes lazily-paid dependencies real — an -integration that is never used is a process that never starts. +This is deployment convenience, not process isolation. Loading a library runs +arbitrary code with the host's privileges and puts it inside the host's memory +and crash boundary. Integrations that hold especially sensitive material or +need fault containment remain separate processes. -- [ ] Service files: name, exec line, environment, timeout -- [ ] Launch, wait for `RequestName`, deliver the queued call -- [ ] Refuse to relaunch a service that is crash-looping, with a clear error - rather than a fork bomb -- [ ] `tinybus list --activatable` +- [x] Versioned, C-shaped descriptor and manifest ABI +- [x] Module-side runtime and export macro +- [x] Host transport bridge, admission gate, dependency ordering and loader +- [x] Linux, macOS and Windows loader implementations +- [x] Bus and CLI module inspection/control surface ## M4 — the security boundary @@ -96,6 +98,14 @@ sender is responsible for the file's lifetime. - [ ] A TypeScript client, because some integrations genuinely want the Node ecosystem and shelling out to it is worse than speaking the protocol +## M7 — external service activation + +The trusted-module loader is not a replacement for activating isolated service +processes. A later milestone must define service-file discovery, process +launching, delivery of calls queued during startup, crash-loop prevention, and +how activatable names appear in inspection APIs before bus-driven activation is +safe to claim. + ## Not planned - **A network transport.** The bus is local by construction, and every diff --git a/crates/tinybus-module/Cargo.toml b/crates/tinybus-module/Cargo.toml new file mode 100644 index 00000000..3cd43917 --- /dev/null +++ b/crates/tinybus-module/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "tinybus-module" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Module-side runtime and export macro for trusted tinybus cdylibs." + +[dependencies] +async-trait.workspace = true +serde_json.workspace = true +serde.workspace = true +tinybus.workspace = true +tokio.workspace = true +tracing.workspace = true diff --git a/crates/tinybus-module/src/lib.rs b/crates/tinybus-module/src/lib.rs new file mode 100644 index 00000000..8f30e9c8 --- /dev/null +++ b/crates/tinybus-module/src/lib.rs @@ -0,0 +1,698 @@ +//! Module-side runtime for trusted tinybus `cdylib` integrations. +//! +//! Each module owns a Tokio runtime. A statically linked `cdylib` has its own +//! Tokio thread-locals, so attempting to borrow the host runtime is both +//! incorrect and capable of silently blocking a host worker thread. + +use std::ffi::c_void; +use std::future::Future; +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Mutex as StdMutex, OnceLock}; +use std::time::Duration; + +use async_trait::async_trait; +use tinybus::message::Message; +use tinybus::message::codec::MAX_FRAME_LEN; +use tinybus::module::abi::{ + TB_BACKPRESSURE, TB_BAD_ARGUMENT, TB_CLOSED, TB_OK, TB_PANICKED, TbHostVtable, TbModuleVtable, +}; +use tinybus::{Connection, Error, Result, Transport}; +use tokio::sync::{Mutex, mpsc}; + +const MODULE_QUEUE_CAPACITY: usize = 256; +const MODULE_PANIC_ERROR: &str = "ai.tinyhumans.tinybus.Error.ModulePanicked"; +static MANIFEST_BYTES: OnceLock> = OnceLock::new(); + +/// Build and retain the exported manifest bytes for the process lifetime. +#[doc(hidden)] +pub struct ManifestDeclaration<'a> { + pub name: &'a str, + pub version: &'a str, + pub provides: &'a [&'a str], + pub methods: &'a [&'a str], + pub signals: &'a [&'a str], + pub requires: &'a [&'a str], + pub optional: &'a [&'a str], + pub lazy: bool, + pub worker_threads: u32, +} + +/// Build and retain the exported manifest bytes for the process lifetime. +#[doc(hidden)] +pub fn manifest_slice(declaration: ManifestDeclaration<'_>) -> tinybus::module::abi::TbSlice { + catch_unwind(AssertUnwindSafe(|| build_manifest_slice(declaration))).unwrap_or( + tinybus::module::abi::TbSlice { + ptr: std::ptr::null(), + len: 0, + }, + ) +} + +fn build_manifest_slice(declaration: ManifestDeclaration<'_>) -> tinybus::module::abi::TbSlice { + use tinybus::module::manifest::{ + Dependency, MANIFEST_SCHEMA, ModuleIdentity, ModuleManifest, PanicPolicy, ProvidedInterface, + }; + use tinybus::{BusName, InterfaceName, InterfaceVersion, ObjectPath, Version}; + + let bytes = MANIFEST_BYTES.get_or_init(|| { + let package_version = + Version::parse(declaration.version).expect("package version is semver"); + let provided = |(index, interface): (usize, &&str)| ProvidedInterface { + version: InterfaceVersion::provided( + InterfaceName::new(*interface).expect("provided interface is valid"), + package_version.clone(), + ), + methods: if index == 0 { + declaration + .methods + .iter() + .map(|member| tinybus::MemberName::new(*member).expect("method is valid")) + .collect() + } else { + Vec::new() + }, + signals: if index == 0 { + declaration + .signals + .iter() + .map(|member| tinybus::MemberName::new(*member).expect("signal is valid")) + .collect() + } else { + Vec::new() + }, + }; + let dependency = |interface: &&str, optional| Dependency { + interface: InterfaceVersion::consumed( + InterfaceName::new(*interface).expect("dependency interface is valid"), + package_version.clone(), + ), + optional, + reason: String::new(), + }; + let bus_name = declaration + .provides + .first() + .copied() + .unwrap_or("ai.tinyhumans.module.Empty"); + let object_path = format!("/{}", bus_name.replace('.', "/")); + serde_json::to_vec(&ModuleManifest { + schema: MANIFEST_SCHEMA, + module: ModuleIdentity { + name: declaration.name.to_string(), + version: package_version.clone(), + description: String::new(), + homepage: None, + license: String::new(), + }, + bus_name: BusName::new(bus_name).expect("provided interface is a bus name"), + object_path: ObjectPath::new(object_path).expect("derived object path is valid"), + provides: declaration + .provides + .iter() + .enumerate() + .map(provided) + .collect(), + requires: declaration + .requires + .iter() + .map(|interface| dependency(interface, false)) + .chain( + declaration + .optional + .iter() + .map(|interface| dependency(interface, true)), + ) + .collect(), + environment: Vec::new(), + capabilities: Vec::new(), + lazy_init: declaration.lazy, + worker_threads: declaration.worker_threads, + on_panic: PanicPolicy::Detach, + }) + .expect("module manifest is serializable") + }); + tinybus::module::abi::TbSlice { + ptr: bytes.as_ptr(), + len: bytes.len(), + } +} + +#[derive(Clone, Copy)] +struct HostCalls(TbHostVtable); + +// The opaque pointer belongs to the host and is explicitly valid for the +// process lifetime. Calls are required to be thread-safe by the ABI contract. +unsafe impl Send for HostCalls {} +unsafe impl Sync for HostCalls {} + +impl HostCalls { + fn send(&self, bytes: &[u8]) -> i32 { + unsafe { (self.0.send)(self.0.host_ctx, bytes.as_ptr(), bytes.len()) } + } + + fn wake(&self) { + unsafe { (self.0.wake)(self.0.host_ctx) } + } + + fn fault(&self) { + unsafe { (self.0.fault)(self.0.host_ctx, std::ptr::null(), 0) } + } + + fn log(&self, level: u32, message: &[u8]) { + unsafe { (self.0.log)(self.0.host_ctx, level, message.as_ptr(), message.len()) } + } + + fn ready(&self) { + unsafe { (self.0.ready)(self.0.host_ctx) } + } +} + +struct HostSubscriber { + host: HostCalls, + next_span: AtomicU64, + max_level: tracing::level_filters::LevelFilter, +} + +impl tracing::Subscriber for HostSubscriber { + fn enabled(&self, metadata: &tracing::Metadata<'_>) -> bool { + self.max_level >= *metadata.level() + } + + fn max_level_hint(&self) -> Option { + Some(self.max_level) + } + + fn new_span(&self, _: &tracing::span::Attributes<'_>) -> tracing::span::Id { + tracing::span::Id::from_u64(self.next_span.fetch_add(1, Ordering::Relaxed)) + } + + fn record(&self, _: &tracing::span::Id, _: &tracing::span::Record<'_>) {} + + fn record_follows_from(&self, _: &tracing::span::Id, _: &tracing::span::Id) {} + + fn event(&self, event: &tracing::Event<'_>) { + use std::fmt::Write as _; + + let metadata = event.metadata(); + let mut visitor = LogVisitor(String::new()); + event.record(&mut visitor); + let mut line = String::new(); + let _ = write!(line, "{} {}", metadata.target(), visitor.0); + let level = match *metadata.level() { + tracing::Level::ERROR => 1, + tracing::Level::WARN => 2, + tracing::Level::INFO => 3, + tracing::Level::DEBUG => 4, + tracing::Level::TRACE => 5, + }; + self.host.log(level, line.as_bytes()); + } + + fn enter(&self, _: &tracing::span::Id) {} + + fn exit(&self, _: &tracing::span::Id) {} +} + +struct LogVisitor(String); + +impl tracing::field::Visit for LogVisitor { + fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) { + use std::fmt::Write as _; + + if !self.0.is_empty() { + self.0.push(' '); + } + let _ = write!(self.0, "{}={value:?}", field.name()); + } +} + +struct ModuleTransport { + host: HostCalls, + inbound: Mutex>>, + detach_on_panic: bool, +} + +#[async_trait] +impl Transport for ModuleTransport { + async fn send(&self, message: Message) -> Result<()> { + let panicked = message.header.error_name.as_deref() == Some(MODULE_PANIC_ERROR); + let bytes = serde_json::to_vec(&message)?; + if bytes.len() > MAX_FRAME_LEN { + return Err(Error::protocol("module frame exceeds the size cap")); + } + match self.host.send(&bytes) { + TB_OK => { + if panicked && self.detach_on_panic { + self.host.fault(); + } + Ok(()) + } + TB_BACKPRESSURE => Err(Error::Backpressure), + TB_CLOSED => Err(Error::ConnectionClosed), + _ => Err(Error::transport("module host refused a frame")), + } + } + + async fn recv(&self) -> Result> { + let bytes = self.inbound.lock().await.recv().await; + let Some(bytes) = bytes else { + return Ok(None); + }; + self.host.wake(); + Ok(Some(serde_json::from_slice(&bytes)?)) + } + + async fn close(&self) -> Result<()> { + self.inbound.lock().await.close(); + Ok(()) + } + + fn describe(&self) -> String { + "module".to_string() + } +} + +struct RuntimeState { + inbound: StdMutex>>>, + runtime: StdMutex>, +} + +unsafe extern "C" fn deliver(ctx: *mut c_void, ptr: *const u8, len: usize) -> i32 { + match catch_unwind(AssertUnwindSafe(|| { + if ctx.is_null() || ptr.is_null() || len > MAX_FRAME_LEN { + return TB_BAD_ARGUMENT; + } + let state = unsafe { &*(ctx.cast::()) }; + let bytes = unsafe { std::slice::from_raw_parts(ptr, len) }.to_vec(); + let sender = state.inbound.lock().expect("module inbound lock").clone(); + match sender { + Some(sender) => match sender.try_send(bytes) { + Ok(()) => TB_OK, + Err(mpsc::error::TrySendError::Full(_)) => TB_BACKPRESSURE, + Err(mpsc::error::TrySendError::Closed(_)) => TB_CLOSED, + }, + None => TB_CLOSED, + } + })) { + Ok(code) => code, + Err(_) => TB_PANICKED, + } +} + +unsafe extern "C" fn shutdown(ctx: *mut c_void, deadline_ms: u64) -> i32 { + match catch_unwind(AssertUnwindSafe(|| { + if ctx.is_null() { + return TB_BAD_ARGUMENT; + } + let state = unsafe { &*(ctx.cast::()) }; + state.inbound.lock().expect("module inbound lock").take(); + match state.runtime.lock().expect("module runtime lock").take() { + Some(runtime) => { + runtime.shutdown_timeout(Duration::from_millis(deadline_ms)); + TB_OK + } + None => TB_CLOSED, + } + })) { + Ok(code) => code, + Err(_) => TB_PANICKED, + } +} + +/// Initialize the module runtime and start its async setup function. +/// +/// This is public only for [`module_export!`] expansions. Module authors call +/// the macro, not this function directly. +#[doc(hidden)] +pub unsafe fn start_module( + host: *const TbHostVtable, + out: *mut TbModuleVtable, + worker_threads: usize, + detach_on_panic: bool, + setup: F, +) -> i32 +where + F: FnOnce(Connection) -> Fut + Send + 'static, + Fut: Future> + Send + 'static, +{ + match catch_unwind(AssertUnwindSafe(|| { + if host.is_null() || out.is_null() || worker_threads == 0 { + return TB_BAD_ARGUMENT; + } + // `size` is the frozen prefix field; do not copy the full vtable until + // the host has proved that all v1 fields are present. + if unsafe { host.cast::().read() } < size_of::() as u32 { + return TB_BAD_ARGUMENT; + } + let host = HostCalls(unsafe { *host }); + + // A cdylib carries its own statically linked `tracing` and `std` state; + // this registration is global to the module's copy, not the embedding + // host's. Failure therefore means this module runtime was initialized + // more than once and cannot safely replace the existing subscriber. + if tracing::subscriber::set_global_default(HostSubscriber { + host, + next_span: AtomicU64::new(1), + max_level: tracing::level_filters::LevelFilter::TRACE, + }) + .is_err() + { + return TB_CLOSED; + } + + let panic_host = host; + let panic_location = std::sync::Arc::new(StdMutex::new(None::)); + let hook_location = panic_location.clone(); + std::panic::set_hook(Box::new(move |panic| { + let location = panic.location().map_or_else( + || "module panicked at an unknown location".to_string(), + |location| { + let file = std::path::Path::new(location.file()) + .file_name() + .and_then(|file| file.to_str()) + .unwrap_or("module"); + format!( + "module panicked at {}:{}:{}", + file, + location.line(), + location.column() + ) + }, + ); + // The payload is intentionally neither formatted nor forwarded: + // it may contain arguments, credentials, or recovery material. + *hook_location.lock().expect("panic location lock") = Some(location.clone()); + panic_host.log(1, location.as_bytes()); + })); + + let runtime = match tokio::runtime::Builder::new_multi_thread() + .worker_threads(worker_threads) + .enable_all() + .build() + { + Ok(runtime) => runtime, + Err(_) => return TB_CLOSED, + }; + let (inbound_tx, inbound_rx) = mpsc::channel(MODULE_QUEUE_CAPACITY); + let transport = Box::new(ModuleTransport { + host, + inbound: Mutex::new(inbound_rx), + detach_on_panic, + }); + + runtime.spawn(async move { + let outcome = match Connection::connect(transport).await { + Ok(connection) => { + connection.__set_panic_handler(std::sync::Arc::new(move || { + let location = panic_location + .lock() + .expect("panic location lock") + .take() + .unwrap_or_else(|| "an unknown location".to_string()); + Error::MethodFailed { + name: MODULE_PANIC_ERROR.to_string(), + message: format!("a module method panicked at {location}"), + } + })); + match setup(connection.clone()).await { + Ok(()) => { + host.ready(); + // Keep the connection (and therefore the served + // object tree and transport) alive until shutdown + // stops this runtime. Setup returning means ready, + // not that the module has finished serving. + std::future::pending::<()>().await; + Ok(()) + } + Err(error) => Err(error), + } + } + Err(error) => Err(error), + }; + if let Err(error) = outcome { + tracing::error!(error = %error, "tinybus module failed"); + host.fault(); + } + }); + + let state = Box::new(RuntimeState { + inbound: StdMutex::new(Some(inbound_tx)), + runtime: StdMutex::new(Some(runtime)), + }); + let state = Box::into_raw(state).cast::(); + unsafe { + out.write(TbModuleVtable { + size: size_of::() as u32, + _reserved: 0, + module_ctx: state, + deliver, + shutdown, + }); + } + TB_OK + })) { + Ok(code) => code, + Err(_) => TB_PANICKED, + } +} + +/// Initialize a module whose setup function accepts typed JSON configuration. +#[doc(hidden)] +pub unsafe fn start_module_with_config( + host: *const TbHostVtable, + out: *mut TbModuleVtable, + worker_threads: usize, + detach_on_panic: bool, + setup: F, +) -> i32 +where + C: serde::de::DeserializeOwned + Send + 'static, + F: FnOnce(Connection, C) -> Fut + Send + 'static, + Fut: Future> + Send + 'static, +{ + let parsed = catch_unwind(AssertUnwindSafe(|| { + if host.is_null() { + return Err(TB_BAD_ARGUMENT); + } + if unsafe { host.cast::().read() } < size_of::() as u32 { + return Err(TB_BAD_ARGUMENT); + } + let host_ref = unsafe { &*host }; + if host_ref.config.len > 1024 * 1024 + || (host_ref.config.ptr.is_null() && host_ref.config.len != 0) + { + return Err(TB_BAD_ARGUMENT); + } + let bytes = if host_ref.config.len == 0 { + b"{}".as_slice() + } else { + unsafe { std::slice::from_raw_parts(host_ref.config.ptr, host_ref.config.len) } + }; + serde_json::from_slice::(bytes).map_err(|_| TB_BAD_ARGUMENT) + })); + let config = match parsed { + Ok(Ok(config)) => config, + Ok(Err(code)) => return code, + Err(_) => return TB_PANICKED, + }; + unsafe { + start_module( + host, + out, + worker_threads, + detach_on_panic, + move |connection| setup(connection, config), + ) + } +} + +/// Export a tinybus module's descriptor, manifest and initialization entrypoint. +/// +/// ```ignore +/// tinybus_module::module_export! { setup = setup, worker_threads = 1 } +/// ``` +#[macro_export] +macro_rules! module_export { + (@common + worker_threads = $threads:expr, + provides = [$($provides:literal),* $(,)?], + methods = [$($methods:literal),* $(,)?], + signals = [$($signals:literal),* $(,)?], + requires = [$($requires:literal),* $(,)?], + optional = [$($optional:literal),* $(,)?], + lazy = $lazy:expr $(,)? + ) => { + #[unsafe(no_mangle)] + pub static TINYBUS_MODULE_ABI_V1: ::tinybus::module::abi::TbAbiDescriptor = + ::tinybus::module::abi::TbAbiDescriptor::current( + env!("CARGO_PKG_NAME"), + env!("CARGO_PKG_VERSION"), + ); + + #[unsafe(no_mangle)] + pub extern "C" fn tinybus_module_manifest_v1() -> ::tinybus::module::abi::TbSlice { + $crate::manifest_slice($crate::ManifestDeclaration { + name: env!("CARGO_PKG_NAME"), + version: env!("CARGO_PKG_VERSION"), + provides: &[$($provides),*], + methods: &[$($methods),*], + signals: &[$($signals),*], + requires: &[$($requires),*], + optional: &[$($optional),*], + lazy: $lazy, + worker_threads: $threads as u32, + }) + } + }; + ( + setup = $setup:path, + config = $config:ty, + worker_threads = $threads:expr, + provides = [$($provides:literal),* $(,)?], + methods = [$($methods:literal),* $(,)?], + signals = [$($signals:literal),* $(,)?], + requires = [$($requires:literal),* $(,)?], + optional = [$($optional:literal),* $(,)?], + lazy = $lazy:expr $(,)? + ) => { + $crate::module_export! { + @common + worker_threads = $threads, + provides = [$($provides),*], + methods = [$($methods),*], + signals = [$($signals),*], + requires = [$($requires),*], + optional = [$($optional),*], + lazy = $lazy, + } + + #[unsafe(no_mangle)] + pub unsafe extern "C" fn tinybus_module_init_v1( + host: *const ::tinybus::module::abi::TbHostVtable, + out: *mut ::tinybus::module::abi::TbModuleVtable, + ) -> i32 { + unsafe { + $crate::start_module_with_config::<$config, _, _>( + host, + out, + $threads, + true, + $setup, + ) + } + } + }; + (setup = $setup:path, worker_threads = $threads:expr $(,)?) => { + $crate::module_export! { + setup = $setup, + worker_threads = $threads, + provides = [], + methods = [], + signals = [], + requires = [], + optional = [], + lazy = false, + } + }; + ( + setup = $setup:path, + worker_threads = $threads:expr, + provides = [$($provides:literal),* $(,)?], + methods = [$($methods:literal),* $(,)?], + signals = [$($signals:literal),* $(,)?], + requires = [$($requires:literal),* $(,)?], + optional = [$($optional:literal),* $(,)?], + lazy = $lazy:expr $(,)? + ) => { + $crate::module_export! { + @common + worker_threads = $threads, + provides = [$($provides),*], + methods = [$($methods),*], + signals = [$($signals),*], + requires = [$($requires),*], + optional = [$($optional),*], + lazy = $lazy, + } + + #[unsafe(no_mangle)] + pub unsafe extern "C" fn tinybus_module_init_v1( + host: *const ::tinybus::module::abi::TbHostVtable, + out: *mut ::tinybus::module::abi::TbModuleVtable, + ) -> i32 { + unsafe { $crate::start_module(host, out, $threads, true, $setup) } + } + }; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_module_whose_queue_is_full_reports_backpressure_rather_than_blocking_the_broker() { + let (sender, _receiver) = mpsc::channel(1); + sender.try_send(vec![1]).unwrap(); + let state = RuntimeState { + inbound: StdMutex::new(Some(sender)), + runtime: StdMutex::new(None), + }; + let bytes = b"{}"; + let code = unsafe { + deliver( + std::ptr::from_ref(&state).cast_mut().cast(), + bytes.as_ptr(), + bytes.len(), + ) + }; + assert_eq!(code, TB_BACKPRESSURE); + } + + #[test] + fn a_frame_over_the_size_cap_is_rejected_rather_than_truncated() { + let state = RuntimeState { + inbound: StdMutex::new(None), + runtime: StdMutex::new(None), + }; + let code = unsafe { + deliver( + std::ptr::from_ref(&state).cast_mut().cast(), + std::ptr::NonNull::::dangling().as_ptr(), + MAX_FRAME_LEN + 1, + ) + }; + assert_eq!(code, TB_BAD_ARGUMENT); + } + + #[test] + fn an_invalid_manifest_declaration_returns_a_null_slice_instead_of_unwinding() { + let slice = manifest_slice(ManifestDeclaration { + name: "invalid", + version: "not-semver", + provides: &[], + methods: &[], + signals: &[], + requires: &[], + optional: &[], + lazy: false, + worker_threads: 1, + }); + assert!(slice.ptr.is_null()); + assert_eq!(slice.len, 0); + } + + #[test] + fn a_panicking_shutdown_callback_reports_panicked_not_timed_out() { + let state = RuntimeState { + inbound: StdMutex::new(None), + runtime: StdMutex::new(None), + }; + let _ = catch_unwind(AssertUnwindSafe(|| { + let _guard = state.runtime.lock().unwrap(); + panic!("poison runtime lock"); + })); + let code = unsafe { shutdown(std::ptr::from_ref(&state).cast_mut().cast(), 1) }; + assert_eq!(code, TB_PANICKED); + } +} diff --git a/crates/tinybus/Cargo.toml b/crates/tinybus/Cargo.toml index 7d57afce..50418615 100644 --- a/crates/tinybus/Cargo.toml +++ b/crates/tinybus/Cargo.toml @@ -4,6 +4,7 @@ version.workspace = true edition.workspace = true license.workspace = true repository.workspace = true +build = "build.rs" description = "A zbus-style message bus that keeps external integrations out of the OpenHuman kernel's dependency graph." readme = "README.md" keywords = ["bus", "ipc", "rpc", "integrations", "openhuman"] @@ -59,6 +60,10 @@ cli = [ "uds", ] +# Load trusted, in-process cdylib modules. Off by default because a loaded +# module shares the host's address space and full privileges. +modules = [] + [dev-dependencies] tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time", "io-util"] } tempfile = "3" @@ -75,3 +80,21 @@ required-features = ["uds", "macros", "cli"] [[example]] name = "kernel_client" required-features = ["uds", "macros"] + +[[example]] +name = "module_clock" +crate-type = ["cdylib"] +required-features = ["modules", "macros"] + +[[example]] +name = "module_clock_two" +crate-type = ["cdylib"] +required-features = ["modules", "macros"] + +[[example]] +name = "module_wrong_target" +crate-type = ["cdylib"] +required-features = ["modules"] + +[dev-dependencies.tinybus-module] +workspace = true diff --git a/crates/tinybus/build.rs b/crates/tinybus/build.rs new file mode 100644 index 00000000..805aef46 --- /dev/null +++ b/crates/tinybus/build.rs @@ -0,0 +1,25 @@ +//! Records build facts that participate in the module ABI gate. + +use std::process::Command; + +fn main() { + println!("cargo::rerun-if-env-changed=TARGET"); + let target = std::env::var("TARGET").expect("Cargo always sets TARGET"); + println!("cargo::rustc-env=TINYBUS_TARGET={target}"); + + let output = Command::new(std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into())) + .arg("-vV") + .output() + .expect("rustc -vV must run while building tinybus"); + assert!( + output.status.success(), + "rustc -vV failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let text = String::from_utf8(output.stdout).expect("rustc -vV is UTF-8"); + let release = text + .lines() + .find_map(|line| line.strip_prefix("release: ")) + .expect("rustc -vV contains a release line"); + println!("cargo::rustc-env=TINYBUS_RUSTC_VERSION={release}"); +} diff --git a/crates/tinybus/examples/module_clock.rs b/crates/tinybus/examples/module_clock.rs new file mode 100644 index 00000000..2683cc71 --- /dev/null +++ b/crates/tinybus/examples/module_clock.rs @@ -0,0 +1,71 @@ +//! A complete dynamically loaded module used by documentation and loader CI. + +use tinybus::{Connection, Result}; + +#[derive(serde::Deserialize)] +struct ClockConfig { + #[serde(default)] + prefix: String, +} + +struct Clock { + prefix: String, +} + +#[tinybus::interface(name = "ai.tinyhumans.openhuman.Clock")] +impl Clock { + async fn now(&self) -> Result { + Ok(format!("{}{:?}", self.prefix, std::time::SystemTime::now())) + } + + async fn panic(&self) -> Result<()> { + panic!("panic payload must never cross the module boundary: secret-token") + } +} + +async fn setup(connection: Connection, config: ClockConfig) -> Result<()> { + connection + .serve_at( + "/ai/tinyhumans/openhuman/Clock".try_into()?, + Clock { + prefix: config.prefix, + }, + ) + .await?; + connection + .request_name("ai.tinyhumans.openhuman.Clock") + .await?; + Ok(()) +} + +tinybus_module::module_export! { + setup = setup, + config = ClockConfig, + worker_threads = 1, + provides = ["ai.tinyhumans.openhuman.Clock"], + methods = ["Now", "Panic"], + signals = [], + requires = [], + optional = [], + lazy = false, +} + +#[cfg(test)] +mod tests { + use super::*; + use tinybus::Interface; + + #[test] + fn a_declared_method_list_matches_the_interfaces_dispatch_table() { + let slice = tinybus_module_manifest_v1(); + let bytes = unsafe { std::slice::from_raw_parts(slice.ptr, slice.len) }; + let manifest: tinybus::module::manifest::ModuleManifest = + serde_json::from_slice(bytes).unwrap(); + let declared = &manifest.provides[0].methods; + let dispatched = Clock { + prefix: String::new(), + } + .members(); + assert_eq!(declared, &dispatched); + } +} diff --git a/crates/tinybus/examples/module_clock_two.rs b/crates/tinybus/examples/module_clock_two.rs new file mode 100644 index 00000000..3f38d5ac --- /dev/null +++ b/crates/tinybus/examples/module_clock_two.rs @@ -0,0 +1,59 @@ +//! Second cdylib proving identical exported symbol names stay handle-local. + +use tinybus::{Connection, Result}; + +struct SecondClock; + +#[tinybus::interface(name = "ai.tinyhumans.openhuman.SecondClock")] +impl SecondClock { + async fn identify(&self) -> Result { + Ok("second-clock".to_string()) + } +} + +async fn setup(connection: Connection) -> Result<()> { + connection + .serve_at( + "/ai/tinyhumans/openhuman/SecondClock".try_into()?, + SecondClock, + ) + .await?; + connection + .request_name("ai.tinyhumans.openhuman.SecondClock") + .await?; + Ok(()) +} + +#[unsafe(no_mangle)] +/// ABI descriptor inspected by the host before loading this fixture. +pub static TINYBUS_MODULE_ABI_V1: tinybus::module::abi::TbAbiDescriptor = + tinybus::module::abi::TbAbiDescriptor::current("module-clock-two", env!("CARGO_PKG_VERSION")); + +#[unsafe(no_mangle)] +/// Return the fixture's v1 manifest as process-lifetime bytes. +pub extern "C" fn tinybus_module_manifest_v1() -> tinybus::module::abi::TbSlice { + tinybus_module::manifest_slice(tinybus_module::ManifestDeclaration { + name: "module-clock-two", + version: env!("CARGO_PKG_VERSION"), + provides: &["ai.tinyhumans.openhuman.SecondClock"], + methods: &["Identify"], + signals: &[], + requires: &[], + optional: &[], + lazy: false, + worker_threads: 1, + }) +} + +#[unsafe(no_mangle)] +/// Initialize through the v1 ABI after the host has admitted the descriptor. +/// +/// # Safety +/// +/// The host must supply valid v1 vtable pointers for the process lifetime. +pub unsafe extern "C" fn tinybus_module_init_v1( + host: *const tinybus::module::abi::TbHostVtable, + out: *mut tinybus::module::abi::TbModuleVtable, +) -> i32 { + unsafe { tinybus_module::start_module(host, out, 1, true, setup) } +} diff --git a/crates/tinybus/examples/module_wrong_target.rs b/crates/tinybus/examples/module_wrong_target.rs new file mode 100644 index 00000000..226cd829 --- /dev/null +++ b/crates/tinybus/examples/module_wrong_target.rs @@ -0,0 +1,43 @@ +//! Loadable cdylib with a deliberately incompatible target descriptor. + +const fn wrong_descriptor() -> tinybus::module::abi::TbAbiDescriptor { + let mut descriptor = + tinybus::module::abi::TbAbiDescriptor::current("wrong-target", env!("CARGO_PKG_VERSION")); + descriptor.target_triple[0] = b'!'; + descriptor +} + +#[unsafe(no_mangle)] +/// Deliberately incompatible descriptor used to verify pre-init rejection. +pub static TINYBUS_MODULE_ABI_V1: tinybus::module::abi::TbAbiDescriptor = wrong_descriptor(); + +#[unsafe(no_mangle)] +/// Return the fixture's v1 manifest as process-lifetime bytes. +pub extern "C" fn tinybus_module_manifest_v1() -> tinybus::module::abi::TbSlice { + tinybus_module::manifest_slice(tinybus_module::ManifestDeclaration { + name: "wrong-target", + version: env!("CARGO_PKG_VERSION"), + provides: &["ai.tinyhumans.openhuman.WrongTarget"], + methods: &[], + signals: &[], + requires: &[], + optional: &[], + lazy: false, + worker_threads: 1, + }) +} + +#[unsafe(no_mangle)] +/// Entry point that the gate must refuse to call. +/// +/// # Safety +/// +/// This function is an ABI fixture and accepts only valid v1 pointers, though +/// its deliberately incompatible descriptor means a correct host never calls +/// it. +pub unsafe extern "C" fn tinybus_module_init_v1( + _: *const tinybus::module::abi::TbHostVtable, + _: *mut tinybus::module::abi::TbModuleVtable, +) -> i32 { + tinybus::module::abi::TB_CLOSED +} diff --git a/crates/tinybus/src/bin/tinybus.rs b/crates/tinybus/src/bin/tinybus.rs index 6fb07b1f..1eb2a748 100644 --- a/crates/tinybus/src/bin/tinybus.rs +++ b/crates/tinybus/src/bin/tinybus.rs @@ -9,7 +9,7 @@ //! bus you debug by adding logging to two processes and restarting both; with //! it, "did the kernel actually call the wallet" is one command. -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::time::Duration; use clap::{Parser, Subcommand}; @@ -80,6 +80,70 @@ enum Command { /// Check that the bus is reachable and report what is on it. Doctor, + + /// Inspect and control trusted in-process modules. + Modules { + #[command(subcommand)] + command: ModulesCommand, + }, +} + +#[derive(Subcommand)] +enum ModulesCommand { + /// List discovered modules. + List { + /// Keep only modules in this state. + #[arg(long)] + state: Option, + /// Emit machine-readable JSON. + #[arg(long)] + json: bool, + }, + /// Show one module and its manifest. + Show { + /// Stable module name. + name: String, + /// Emit machine-readable JSON. + #[arg(long)] + json: bool, + }, + /// Ask the running host to scan module directories. + Scan { + /// Directory to inspect. Repeat to scan several paths. + #[arg(long = "path")] + paths: Vec, + /// Report admissions without initializing or attaching modules. + #[arg(long)] + dry_run: bool, + }, + /// Load one newly installed dynamic library. + Load { + /// Path to the `.so`, `.dylib`, or `.dll`. + path: PathBuf, + /// JSON object passed to the module's setup function. + #[arg(long, default_value = "{}")] + config: String, + }, + /// Stop a loaded module without unloading its library. + Stop { + /// Stable module name. + name: String, + /// Milliseconds the host waits for the module to stop. + #[arg(long, default_value_t = 5_000)] + deadline_ms: u64, + }, + /// Enable a known module for future scans. + Enable { + /// Stable module name. + name: String, + }, + /// Disable a known module for future scans. + Disable { + /// Stable module name. + name: String, + }, + /// Report stopped modules and toolchain mismatches. + Doctor, } fn main() -> std::process::ExitCode { @@ -214,10 +278,116 @@ async fn run(cli: Cli) -> Result<()> { } Ok(()) } + + Command::Modules { command } => run_modules(&address, timeout, command).await, + } +} + +async fn run_modules(address: &Path, timeout: Duration, command: ModulesCommand) -> Result<()> { + let connection = connect(address).await?; + let bus = connection + .proxy(tinybus::BUS_NAME, tinybus::BUS_PATH, tinybus::BUS_INTERFACE)? + .with_timeout(timeout); + + match command { + ModulesCommand::List { state, json } => { + let mut modules: Vec = bus.call("ListModules", ()).await?; + if let Some(state) = state { + modules.retain(|module| module["state"].as_str() == Some(&state)); + } + if json { + println!("{}", serde_json::to_string_pretty(&modules)?); + } else { + for module in modules { + println!( + "{:<24} {:<10} {}", + module["name"].as_str().unwrap_or("?"), + module["state"].as_str().unwrap_or("?"), + module["version"].as_str().unwrap_or("?") + ); + } + } + Ok(()) + } + ModulesCommand::Show { name, json } => { + let module: Option = bus.call("GetModule", (name,)).await?; + let module = module.ok_or_else(|| Error::failed("module is not known"))?; + if json { + println!("{}", serde_json::to_string_pretty(&module)?); + } else { + println!("name {}", module["name"].as_str().unwrap_or("?")); + println!("version {}", module["version"].as_str().unwrap_or("?")); + println!("state {}", module["state"].as_str().unwrap_or("?")); + println!("artifact {}", module["file"].as_str().unwrap_or("?")); + println!( + "rustc {}", + module["rustc_version"].as_str().unwrap_or("?") + ); + println!("manifest {}", serde_json::to_string(&module["manifest"])?); + } + Ok(()) + } + ModulesCommand::Scan { paths, dry_run } => { + let paths = paths + .into_iter() + .map(|path| path.to_string_lossy().into_owned()) + .collect::>(); + let modules: Vec = + bus.call("RescanModules", (paths, dry_run)).await?; + println!("{}", serde_json::to_string_pretty(&modules)?); + Ok(()) + } + ModulesCommand::Load { path, config } => { + let config: serde_json::Value = serde_json::from_str(&config)?; + let module: serde_json::Value = bus + .call("LoadModule", (path.to_string_lossy().to_string(), config)) + .await?; + println!("{}", serde_json::to_string_pretty(&module)?); + Ok(()) + } + ModulesCommand::Stop { name, deadline_ms } => { + if Duration::from_millis(deadline_ms) >= timeout { + return Err(Error::failed( + "module stop deadline must be shorter than the RPC timeout", + )); + } + let module: serde_json::Value = bus.call("StopModule", (name, deadline_ms)).await?; + println!("{}", serde_json::to_string_pretty(&module)?); + Ok(()) + } + ModulesCommand::Enable { name } => { + let module: serde_json::Value = bus.call("EnableModule", (name, true)).await?; + println!("{}", serde_json::to_string_pretty(&module)?); + Ok(()) + } + ModulesCommand::Disable { name } => { + let module: serde_json::Value = bus.call("EnableModule", (name, false)).await?; + println!("{}", serde_json::to_string_pretty(&module)?); + Ok(()) + } + ModulesCommand::Doctor => { + let modules: Vec = bus.call("ListModules", ()).await?; + let mut problems = 0; + for module in modules { + let state = module["state"].as_str().unwrap_or("unknown"); + let mismatch = module["rustc_mismatch"].as_bool().unwrap_or(false); + if !matches!(state, "ready" | "serving") || mismatch { + problems += 1; + println!( + "{}: state={state}, rustc_mismatch={mismatch}", + module["name"].as_str().unwrap_or("?") + ); + } + } + if problems == 0 { + println!("modules are healthy"); + } + Ok(()) + } } } -async fn connect(address: &PathBuf) -> Result { +async fn connect(address: &Path) -> Result { Connection::connect(Box::new(UnixTransport::connect(address).await?)).await } diff --git a/crates/tinybus/src/broker.rs b/crates/tinybus/src/broker.rs index 98238b34..dbae0073 100644 --- a/crates/tinybus/src/broker.rs +++ b/crates/tinybus/src/broker.rs @@ -21,6 +21,8 @@ //! service pass credentials over the bus without them being logged, cached, or //! parsed by a process that has no business seeing them. +#[cfg(feature = "modules")] +use std::sync::Weak; use std::sync::{Arc, Mutex}; use serde_json::Value; @@ -43,6 +45,11 @@ pub const PEER_QUEUE_CAPACITY: usize = 256; pub struct Broker { router: Arc>, id: String, + // `Weak`, not `Arc`: the module host owns this broker, so a strong + // reference back would form a cycle and leak both. Callers tolerate a + // failed upgrade by falling back to the ordinary routing error. + #[cfg(feature = "modules")] + modules: Arc>>>, } impl Broker { @@ -50,6 +57,8 @@ impl Broker { pub fn new() -> Self { Self { router: Arc::new(Mutex::new(Router::default())), + #[cfg(feature = "modules")] + modules: Arc::new(Mutex::new(None)), // The id changes per broker *process*, so a peer that reconnects // can tell "the bus restarted" (every name is gone, re-register) // from "my socket blipped" (state is intact). @@ -62,6 +71,11 @@ impl Broker { &self.id } + #[cfg(feature = "modules")] + pub(crate) fn set_module_control(&self, control: Weak) { + *self.modules.lock().expect("module control lock") = Some(control); + } + /// Serve until the listener stops accepting. /// /// A per-peer failure never ends this loop; only the listener itself @@ -110,6 +124,18 @@ impl Broker { unique } + #[cfg(feature = "modules")] + pub(crate) fn reserve_module_name( + &self, + unique: &BusName, + name: BusName, + ) -> Result { + self.router + .lock() + .expect("router lock") + .request_name_for_unique(unique, name) + } + /// Route one inbound message from peer `id`. async fn route(&self, from: u64, from_name: &BusName, mut message: Message) -> Result<()> { message.validate()?; @@ -147,7 +173,20 @@ impl Broker { .router .lock() .expect("router lock") - .resolve(&destination)?; + .resolve(&destination); + #[cfg(feature = "modules")] + let target = target.map_err(|error| { + let control = self + .modules + .lock() + .expect("module control lock") + .as_ref() + .and_then(Weak::upgrade); + control + .and_then(|control| control.unavailable_for(&destination)) + .unwrap_or(error) + }); + let target = target?; target .send(message) .await @@ -169,7 +208,9 @@ impl Broker { .clone() .ok_or_else(|| Error::protocol("bus call has no member"))?; - let (result, changes) = self.bus_method(from, from_name, &member, message.body); + let (result, changes, module_states) = self + .bus_method(from, from_name, &member, message.body) + .await; let reply = match result { Ok(value) => Message::method_return(&header, value), @@ -186,20 +227,33 @@ impl Broker { let _ = outbox.send(reply).await; for change in changes { - self.announce_name_change(change).await; + if change.old_owner != change.new_owner { + self.announce_name_change(change).await; + } + } + #[cfg(feature = "modules")] + for module_state in module_states { + self.announce_module_state(module_state).await; } + #[cfg(not(feature = "modules"))] + let _ = module_states; Ok(()) } - /// The bus's own interface. Synchronous: it only touches the routing table, - /// and holding the lock across an await is exactly what this design avoids. - fn bus_method( + /// The bus's own interface. Module stop may await a blocking callback; the + /// ordinary table still holds no lock across an await. + async fn bus_method( &self, from: u64, from_name: &BusName, member: &MemberName, body: Value, - ) -> (Result, Vec) { + ) -> (Result, Vec, Vec) { + #[cfg(feature = "modules")] + if let Some((result, module_states)) = self.module_method(member, body.clone()).await { + return (result, Vec::new(), module_states); + } + let mut changes = Vec::new(); let mut router = self.router.lock().expect("router lock"); @@ -257,7 +311,158 @@ impl Broker { } })(); - (result, changes) + (result, changes, Vec::new()) + } + + #[cfg(feature = "modules")] + async fn module_method( + &self, + member: &MemberName, + body: Value, + ) -> Option<(Result, Vec)> { + use std::path::PathBuf; + use std::time::Duration; + + enum ModuleMember { + List, + Get, + GetManifest, + Load, + Stop, + Enable, + Rescan, + } + let operation = match member.as_str() { + "ListModules" => ModuleMember::List, + "GetModule" => ModuleMember::Get, + "GetModuleManifest" => ModuleMember::GetManifest, + "LoadModule" => ModuleMember::Load, + "StopModule" => ModuleMember::Stop, + "EnableModule" => ModuleMember::Enable, + "RescanModules" => ModuleMember::Rescan, + _ => return None, + }; + let control = self + .modules + .lock() + .expect("module control lock") + .as_ref() + .and_then(Weak::upgrade) + .ok_or_else(|| Error::failed("module host is not installed")); + let control = match control { + Ok(control) => control, + Err(error) => return Some((Err(error), Vec::new())), + }; + if matches!(operation, ModuleMember::Stop) { + let parsed = parse_args::<(String, u64)>(member, body); + let result = match parsed { + Ok((name, deadline_ms)) => control + .stop(&name, Duration::from_millis(deadline_ms)) + .await + .and_then(|info| serde_json::to_value(info).map_err(Error::from)), + Err(error) => Err(error), + }; + return Some((result, Vec::new())); + } + + let outcome = (|| -> Result<(Value, Vec)> { + match operation { + ModuleMember::List => Ok((serde_json::to_value(control.list())?, Vec::new())), + ModuleMember::Get => { + let (name,): (String,) = parse_args(member, body)?; + Ok(( + serde_json::to_value( + control + .list() + .into_iter() + .find(|module| module.name == name), + )?, + Vec::new(), + )) + } + ModuleMember::GetManifest => { + let (name,): (String,) = parse_args(member, body)?; + Ok(( + serde_json::to_value( + control + .list() + .into_iter() + .find(|module| module.name == name) + .map(|module| module.manifest), + )?, + Vec::new(), + )) + } + ModuleMember::Load => { + let arguments = body.as_array().ok_or_else(|| { + Error::bad_arguments(member.clone(), "expected a positional array") + })?; + let path: String = arguments + .first() + .cloned() + .ok_or_else(|| Error::bad_arguments(member.clone(), "missing path")) + .and_then(|value| { + serde_json::from_value(value) + .map_err(|error| Error::bad_arguments(member.clone(), error)) + })?; + let config = arguments + .get(1) + .cloned() + .unwrap_or_else(|| serde_json::json!({})); + if arguments.len() > 2 { + return Err(Error::bad_arguments( + member.clone(), + "expected path and optional configuration", + )); + } + let (info, transition) = control.load(PathBuf::from(path), config)?; + Ok(( + serde_json::to_value(info)?, + module_state_body(transition).into_iter().collect(), + )) + } + ModuleMember::Stop => Err(Error::failed("module stop dispatch failed")), + ModuleMember::Enable => { + let (name, enabled): (String, bool) = parse_args(member, body)?; + let (info, transition) = control.enable(&name, enabled)?; + Ok(( + serde_json::to_value(info)?, + module_state_body(transition).into_iter().collect(), + )) + } + ModuleMember::Rescan => { + let arguments = body.as_array().ok_or_else(|| { + Error::bad_arguments(member.clone(), "expected a positional array") + })?; + let paths = arguments + .first() + .cloned() + .map(serde_json::from_value::>) + .transpose() + .map_err(|error| Error::bad_arguments(member.clone(), error))? + .unwrap_or_default(); + let dry_run = arguments.get(1).and_then(Value::as_bool).unwrap_or(false); + if arguments.len() > 2 { + return Err(Error::bad_arguments( + member.clone(), + "expected optional paths and dry-run flag", + )); + } + let (infos, transitions) = control.rescan(paths, dry_run)?; + Ok(( + serde_json::to_value(infos)?, + transitions + .into_iter() + .filter_map(|transition| module_state_body(Some(transition))) + .collect(), + )) + } + } + })(); + Some(match outcome { + Ok((value, states)) => (Ok(value), states), + Err(error) => (Err(error), Vec::new()), + }) } /// Broadcast `NameOwnerChanged`. @@ -266,7 +471,20 @@ impl Broker { /// signal would be a call timing out thirty seconds later, by which point a /// user has been staring at a spinner. Subscribers still have to have asked /// for it; the broker does not push it at peers that did not. - async fn announce_name_change(&self, change: NameChange) { + pub(crate) async fn announce_name_change(&self, change: NameChange) { + self.broadcast_bus_signal( + "NameOwnerChanged", + serde_json::json!([change.name, change.old_owner, change.new_owner]), + ) + .await; + } + + #[cfg(feature = "modules")] + pub(crate) async fn announce_module_state(&self, body: Value) { + self.broadcast_bus_signal("ModuleStateChanged", body).await; + } + + async fn broadcast_bus_signal(&self, member: &str, body: Value) { let signal = Message { header: crate::message::Header { kind: MessageKind::Signal, @@ -283,14 +501,11 @@ impl Broker { InterfaceName::new(crate::BUS_INTERFACE) .expect("the bus interface constant is valid"), ), - member: Some( - MemberName::new("NameOwnerChanged").expect("literal is a valid member"), - ), + member: Some(MemberName::new(member).expect("literal is a valid member")), error_name: None, }, - body: serde_json::json!([change.name, change.old_owner, change.new_owner]), + body, }; - let targets = self .router .lock() @@ -302,6 +517,20 @@ impl Broker { } } +#[cfg(feature = "modules")] +fn module_state_body(transition: Option) -> Option { + use crate::module::host::{state_detail, state_name}; + + transition.map(|(module, old, new)| { + serde_json::json!([ + module, + state_name(&old), + state_name(&new), + state_detail(&new) + ]) + }) +} + impl Default for Broker { fn default() -> Self { Self::new() @@ -355,6 +584,25 @@ async fn reader_task(broker: Broker, transport: Arc, id: u64, nam for change in changes { broker.announce_name_change(change).await; } + #[cfg(feature = "modules")] + { + let control = broker + .modules + .lock() + .expect("module control lock") + .as_ref() + .and_then(Weak::upgrade); + if let Some((module, old, new)) = control.and_then(|control| control.peer_detached(&name)) { + broker + .announce_module_state(serde_json::json!([ + module, + crate::module::host::state_name(&old), + crate::module::host::state_name(&new), + crate::module::host::state_detail(&new) + ])) + .await; + } + } } #[cfg(test)] diff --git a/crates/tinybus/src/build_info.rs b/crates/tinybus/src/build_info.rs new file mode 100644 index 00000000..1b04fd64 --- /dev/null +++ b/crates/tinybus/src/build_info.rs @@ -0,0 +1,46 @@ +//! Compile-time facts used to admit dynamically loaded modules. + +/// The target triple Cargo built this copy of tinybus for. +pub const TARGET: &str = env!("TINYBUS_TARGET"); + +/// The rustc release that built this copy of tinybus. +pub const RUSTC_VERSION: &str = env!("TINYBUS_RUSTC_VERSION"); + +/// Feature bit for the Unix-domain-socket adapter. +pub const FEATURE_UDS: u64 = 1 << 0; +/// Feature bit for the interface proc macro. +pub const FEATURE_MACROS: u64 = 1 << 1; +/// Feature bit for the CLI. +pub const FEATURE_CLI: u64 = 1 << 2; +/// Feature bit for the module loader. +pub const FEATURE_MODULES: u64 = 1 << 3; + +/// Features compiled into this copy of tinybus. +pub const FEATURE_BITS: u64 = (if cfg!(feature = "uds") { + FEATURE_UDS +} else { + 0 +}) | (if cfg!(feature = "macros") { + FEATURE_MACROS +} else { + 0 +}) | (if cfg!(feature = "cli") { + FEATURE_CLI +} else { + 0 +}) | (if cfg!(feature = "modules") { + FEATURE_MODULES +} else { + 0 +}); + +/// Render a known feature bit for admission diagnostics. +pub const fn feature_name(bit: u64) -> &'static str { + match bit { + FEATURE_UDS => "uds", + FEATURE_MACROS => "macros", + FEATURE_CLI => "cli", + FEATURE_MODULES => "modules", + _ => "unknown", + } +} diff --git a/crates/tinybus/src/connection.rs b/crates/tinybus/src/connection.rs index c6587097..5fbfc7b9 100644 --- a/crates/tinybus/src/connection.rs +++ b/crates/tinybus/src/connection.rs @@ -23,8 +23,11 @@ //! remote work — tinybus cannot — it stops waiting and frees the caller. use std::collections::HashMap; +use std::future::Future; +use std::pin::Pin; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; +use std::task::{Context, Poll}; use std::time::Duration; use serde::Serialize; @@ -77,6 +80,7 @@ struct Inner { /// the sender on a locally looped-back signal without an await. unique_name: std::sync::RwLock>, signals: broadcast::Sender, + panic_handler: std::sync::RwLock Error + Send + Sync>>>, } /// Closes the transport when the last [`Connection`] handle goes away. @@ -168,6 +172,7 @@ impl Connection { objects: RwLock::new(ObjectTree::new()), unique_name: std::sync::RwLock::new(None), signals, + panic_handler: std::sync::RwLock::new(None), }); tokio::spawn(writer_loop(inner.transport.clone(), outbound)); tokio::spawn(dispatch_loop(inner.clone())); @@ -283,6 +288,104 @@ impl Connection { Ok(serde_json::from_value(value)?) } + /// Every module known to the embedded host. + #[cfg(feature = "modules")] + pub async fn list_modules(&self) -> Result> { + let value = self.call_bus("ListModules", serde_json::json!([])).await?; + Ok(serde_json::from_value(value)?) + } + + /// Inspect one module by stable module name. + #[cfg(feature = "modules")] + pub async fn module(&self, name: impl AsRef) -> Result> { + let value = self + .call_bus("GetModule", serde_json::json!([name.as_ref()])) + .await?; + Ok(serde_json::from_value(value)?) + } + + /// Read one module's declared manifest without initializing it. + #[cfg(feature = "modules")] + pub async fn module_manifest( + &self, + name: impl AsRef, + ) -> Result> { + let value = self + .call_bus("GetModuleManifest", serde_json::json!([name.as_ref()])) + .await?; + Ok(serde_json::from_value(value)?) + } + + /// Dynamically install one module, passing JSON setup configuration. + #[cfg(feature = "modules")] + pub async fn load_module( + &self, + path: impl AsRef, + config: serde_json::Value, + ) -> Result { + let value = self + .call_bus( + "LoadModule", + serde_json::json!([path.as_ref().to_string_lossy(), config]), + ) + .await?; + Ok(serde_json::from_value(value)?) + } + + /// Stop one module. Its library remains mapped until process exit. + #[cfg(feature = "modules")] + pub async fn stop_module( + &self, + name: impl AsRef, + deadline: Duration, + ) -> Result { + let value = self + .call_bus( + "StopModule", + serde_json::json!([name.as_ref(), deadline.as_millis() as u64]), + ) + .await?; + Ok(serde_json::from_value(value)?) + } + + /// Enable or disable a known module for subsequent scans. + #[cfg(feature = "modules")] + pub async fn enable_module( + &self, + name: impl AsRef, + enabled: bool, + ) -> Result { + let value = self + .call_bus("EnableModule", serde_json::json!([name.as_ref(), enabled])) + .await?; + Ok(serde_json::from_value(value)?) + } + + /// Rescan the host's configured module directories. + #[cfg(feature = "modules")] + pub async fn rescan_modules(&self) -> Result> { + self.scan_modules(std::iter::empty::<&std::path::Path>(), false) + .await + } + + /// Scan explicit module directories, optionally without initializing any + /// admitted artifact. + #[cfg(feature = "modules")] + pub async fn scan_modules>( + &self, + paths: impl IntoIterator, + dry_run: bool, + ) -> Result> { + let paths = paths + .into_iter() + .map(|path| path.as_ref().to_string_lossy().into_owned()) + .collect::>(); + let value = self + .call_bus("RescanModules", serde_json::json!([paths, dry_run])) + .await?; + Ok(serde_json::from_value(value)?) + } + /// Check whether this peer can call `interface` on `destination`. /// /// `local` is this peer's own manifest. Returns the verdict rather than an @@ -511,6 +614,21 @@ impl Connection { self.inner.transport.close().await } + /// Install module-boundary panic conversion for dispatched methods. + /// + /// Hidden because ordinary process peers intentionally keep the existing + /// policy that a panic is fatal to the service. The module SDK uses this to + /// turn an unwind into a redacted error reply before applying its manifest + /// panic policy. + #[doc(hidden)] + pub fn __set_panic_handler(&self, handler: Arc Error + Send + Sync>) { + *self + .inner + .panic_handler + .write() + .expect("panic handler lock") = Some(handler); + } + /// Call a method on the broker's own interface. async fn call_bus(&self, member: &str, args: Value) -> Result { let message = Message::method_call( @@ -629,7 +747,19 @@ async fn dispatch_loop(inner: Arc) { /// Run one inbound method call and send its reply. async fn handle_call(inner: Arc, message: Message) { let header = message.header.clone(); - let result = dispatch(&inner, &header, message.body).await; + let panic_handler = inner + .panic_handler + .read() + .expect("panic handler lock") + .clone(); + let result = if let Some(panic_handler) = panic_handler { + match CatchUnwind::new(dispatch(&inner, &header, message.body)).await { + Ok(result) => result, + Err(()) => Err(panic_handler()), + } + } else { + dispatch(&inner, &header, message.body).await + }; let mut reply = match result { Ok(value) => Message::method_return(&header, value), @@ -641,6 +771,31 @@ async fn handle_call(inner: Arc, message: Message) { } } +struct CatchUnwind { + future: Pin>, +} + +impl CatchUnwind { + fn new(future: F) -> Self { + Self { + future: Box::pin(future), + } + } +} + +impl Future for CatchUnwind { + type Output = std::result::Result; + + fn poll(self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll { + let future = self.get_mut().future.as_mut(); + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| future.poll(context))) { + Ok(Poll::Ready(value)) => Poll::Ready(Ok(value)), + Ok(Poll::Pending) => Poll::Pending, + Err(_) => Poll::Ready(Err(())), + } + } +} + async fn dispatch(inner: &Inner, header: &Header, body: Value) -> Result { let (Some(path), Some(interface), Some(member)) = (&header.path, &header.interface, &header.member) @@ -670,6 +825,7 @@ mod tests { vec![ MemberName::new("Echo").unwrap(), MemberName::new("Boom").unwrap(), + MemberName::new("Panic").unwrap(), MemberName::new("Hang").unwrap(), ] } @@ -678,6 +834,7 @@ mod tests { match member.as_str() { "Echo" => Ok(args), "Boom" => Err(Error::failed("as requested")), + "Panic" => panic!("secret panic payload"), "Hang" => { tokio::time::sleep(Duration::from_secs(3600)).await; Ok(Value::Null) @@ -732,6 +889,43 @@ mod tests { assert!(err.to_string().contains("as requested"), "{err}"); } + #[tokio::test] + async fn a_panicking_module_method_becomes_an_error_reply_rather_than_an_abort() { + let (client, service) = pair().await; + service.__set_panic_handler(Arc::new(|| Error::MethodFailed { + name: "ai.tinyhumans.tinybus.Error.ModulePanicked".to_string(), + message: "module panicked at fixture.rs:12:3".to_string(), + })); + let error = client + .call_raw(call("Panic", serde_json::json!([])), DEFAULT_TIMEOUT) + .await + .unwrap_err(); + assert_eq!( + error.wire_name(), + "ai.tinyhumans.tinybus.Error.ModulePanicked" + ); + let reply = client + .call_raw(call("Echo", serde_json::json!(["alive"])), DEFAULT_TIMEOUT) + .await + .unwrap(); + assert_eq!(reply, serde_json::json!(["alive"])); + } + + #[tokio::test] + async fn a_panic_reply_carries_the_location_but_never_the_payload() { + let (client, service) = pair().await; + service.__set_panic_handler(Arc::new(|| Error::MethodFailed { + name: "ai.tinyhumans.tinybus.Error.ModulePanicked".to_string(), + message: "module panicked at fixture.rs:12:3".to_string(), + })); + let error = client + .call_raw(call("Panic", serde_json::json!([])), DEFAULT_TIMEOUT) + .await + .unwrap_err(); + assert!(error.to_string().contains("fixture.rs:12:3")); + assert!(!error.to_string().contains("secret panic payload")); + } + #[tokio::test] async fn an_unknown_member_is_reported_as_such_rather_than_hanging() { let (client, _service) = pair().await; diff --git a/crates/tinybus/src/error.rs b/crates/tinybus/src/error.rs index 5d90aa7d..9d2cdaa2 100644 --- a/crates/tinybus/src/error.rs +++ b/crates/tinybus/src/error.rs @@ -178,6 +178,29 @@ pub enum Error { #[error("{0} requires the `{1}` feature; rebuild with --features {1}")] FeatureDisabled(&'static str, &'static str), + /// A dynamic module failed a fixed admission rule. + /// + /// `file` is a basename only and `reason` is selected by the host. Neither + /// field may contain a path or attacker-controlled descriptor bytes. + #[error("module `{file}` refused: {reason}")] + ModuleRefused { + /// Sanitized artifact basename. + file: String, + /// Fixed admission failure phrase. + reason: String, + }, + + /// A known module cannot serve calls in its terminal/current state. + #[error("module `{module}` is unavailable ({state}): {detail}")] + ModuleUnavailable { + /// Stable module identity. + module: String, + /// Closed lifecycle state name. + state: String, + /// Safe state detail, never a body or environment value. + detail: String, + }, + /// Filesystem or socket I/O failed. #[error("io: {0}")] Io(#[from] std::io::Error), @@ -211,6 +234,24 @@ impl Error { } } + /// Build a redacted module refusal from an artifact path. + pub fn module_refused(path: &std::path::Path, reason: impl Into) -> Self { + let file = path + .file_name() + .and_then(|name| name.to_str()) + .map(sanitize_untrusted) + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| "module".to_string()); + let reason = reason.into(); + let reason = reason + .split_whitespace() + .map(sanitize_untrusted) + .filter(|word| !word.is_empty()) + .collect::>() + .join(" "); + Self::ModuleRefused { file, reason } + } + /// Build an [`Error::BadArguments`] from a serde failure, with the /// offending *values* stripped out. /// @@ -278,6 +319,8 @@ impl Error { Self::InvalidDomain { .. } => "ai.tinyhumans.tinybus.Error.InvalidDomain", Self::Timeout { .. } => "ai.tinyhumans.tinybus.Error.Timeout", Self::IncompatibleVersion { .. } => "ai.tinyhumans.tinybus.Error.IncompatibleVersion", + Self::ModuleRefused { .. } => "ai.tinyhumans.tinybus.Error.ModuleRefused", + Self::ModuleUnavailable { .. } => "ai.tinyhumans.tinybus.Error.ModuleUnavailable", Self::Path { .. } => "ai.tinyhumans.tinybus.Error.Path", Self::FeatureDisabled(_, _) => "ai.tinyhumans.tinybus.Error.FeatureDisabled", Self::Json(_) => "ai.tinyhumans.tinybus.Error.Json", @@ -313,6 +356,15 @@ pub fn redact_values(message: &str) -> String { out } +/// Keep only log-safe descriptor characters and cap their length. +pub(crate) fn sanitize_untrusted(value: &str) -> String { + value + .chars() + .filter(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '+' | '-')) + .take(32) + .collect() +} + /// The crate-wide result alias. pub type Result = std::result::Result; @@ -375,4 +427,16 @@ mod tests { "no peer owns the name `ai.tinyhumans.openhuman.Voice`" ); } + + #[test] + fn module_refusal_sanitizes_an_untrusted_reason() { + let error = Error::module_refused( + std::path::Path::new("module.so"), + "loader exposed /secret/path and spaces", + ); + let Error::ModuleRefused { reason, .. } = error else { + panic!("expected module refusal"); + }; + assert_eq!(reason, "loader exposed secretpath and spaces"); + } } diff --git a/crates/tinybus/src/global.rs b/crates/tinybus/src/global.rs index 2eac9c5f..956f005b 100644 --- a/crates/tinybus/src/global.rs +++ b/crates/tinybus/src/global.rs @@ -18,24 +18,6 @@ //! …and gets the whole surface off it. `OnceBus::new` is `const`, so this costs //! nothing until something initialises it. //! -//! # Runtime ownership -//! -//! [`OnceBus::init_in_process`] and [`OnceBus::init_over`] put the bus's tasks -//! — the broker, and this peer's reader/writer loops — on a **dedicated -//! runtime that the singleton owns**, rather than on whichever runtime happened -//! to call `init` first. -//! -//! That is not a detail. A process-wide bus outlives any one runtime: under -//! `cargo test` every `#[tokio::test]` builds and tears down its own, so the -//! first test to win the `OnceLock` would otherwise leave every later test -//! holding a bus attached to a dead reactor — publishes going nowhere, -//! subscribers never waking, and no error anywhere to say why. Owning the -//! runtime makes the bus's lifetime match the `static`'s, which is what -//! everything reaching for a global bus already assumes. -//! -//! [`OnceBus::init_with`] is the exception: the caller supplied that connection -//! and owns its tasks, so it is left where it was built. -//! //! # Before initialisation //! //! Every accessor is safe to call before `init`. Publishing goes nowhere and @@ -50,78 +32,17 @@ use std::sync::OnceLock; use crate::broker::Broker; use crate::connection::Connection; -use crate::error::{Error, Result}; +use crate::error::Result; use crate::events::{Event, EventBus, EventBusConfig, EventHandler, SubscriptionHandle}; use crate::native::NativeRegistry; use crate::ports::Transport; use crate::transport::memory::MemoryBus; use crate::version::PeerManifest; -/// A tokio runtime living on a thread of its own. -/// -/// A **current-thread** runtime driven by a dedicated OS thread, rather than a -/// multi-threaded one, for a specific reason: `new_multi_thread` needs tokio's -/// `rt-multi-thread` feature, and the slim `--no-default-features` build — the -/// one an embedded host links — does not have it. A bus that could not be a -/// singleton in the slim build would defeat the purpose of the slim build. -/// -/// One thread is also simply enough. The broker routes messages and the -/// connection loops shuffle frames; none of it is CPU-bound. -struct BusRuntime { - handle: tokio::runtime::Handle, - /// Dropping this ends the thread's `block_on`, which drops the runtime on - /// that thread — where blocking is allowed. That is why there is no `Drop` - /// impl here: a plain in-place `Runtime` drop panics inside an async - /// context, and this shape cannot. - _shutdown: tokio::sync::oneshot::Sender<()>, -} - -impl BusRuntime { - fn start() -> Result { - let (handle_tx, handle_rx) = std::sync::mpsc::channel(); - let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); - - std::thread::Builder::new() - .name("tinybus".to_string()) - .spawn(move || { - let runtime = match tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - { - Ok(runtime) => runtime, - Err(e) => { - let _ = handle_tx.send(Err(e.to_string())); - return; - } - }; - if handle_tx.send(Ok(runtime.handle().clone())).is_err() { - return; - } - // Drive the runtime until the owning `BusRuntime` goes away. - runtime.block_on(async move { - let _ = shutdown_rx.await; - }); - }) - .map_err(|e| Error::transport(format!("could not start the bus thread: {e}")))?; - - let handle = handle_rx - .recv() - .map_err(|_| Error::transport("the bus thread died before reporting its runtime"))? - .map_err(|e| Error::transport(format!("could not build the bus runtime: {e}")))?; - - Ok(Self { - handle, - _shutdown: shutdown_tx, - }) - } -} - /// A lazily-initialised, process-wide [`EventBus`]. pub struct OnceBus { bus: OnceLock>, native: OnceLock, - /// Owns the bus's tasks so they outlive the caller's runtime. - runtime: OnceLock, } impl Default for OnceBus { @@ -136,17 +57,7 @@ impl OnceBus { Self { bus: OnceLock::new(), native: OnceLock::new(), - runtime: OnceLock::new(), - } - } - - /// The runtime this bus's tasks live on, started on first use. - fn runtime(&self) -> Result<&BusRuntime> { - if let Some(existing) = self.runtime.get() { - return Ok(existing); } - let started = BusRuntime::start()?; - Ok(self.runtime.get_or_init(|| started)) } /// Initialise with an in-process broker: no sockets, no external services. @@ -157,22 +68,9 @@ impl OnceBus { /// production — moving an integration out of the process later is then a /// deployment change rather than a different code path that has never run. pub async fn init_in_process(&self, config: EventBusConfig) -> Result<&EventBus> { - if let Some(existing) = self.bus.get() { - return Ok(existing); - } let transport = MemoryBus::new(); - let peer = transport.connect().await?; - - // Everything that spawns happens under the guard; nothing is awaited - // under it, because an `EnterGuard` is `!Send` and holding one across - // an await would make this future `!Send` for every caller. - let connection = { - let _guard = self.runtime()?.handle.enter(); - Broker::new().spawn(transport); - Connection::attach(peer.into()) - }; - connection.handshake().await?; - + Broker::new().spawn(transport.clone()); + let connection = Connection::connect(transport.connect().await?).await?; self.init_with(connection, config).await } @@ -182,16 +80,7 @@ impl OnceBus { transport: Box, config: EventBusConfig, ) -> Result<&EventBus> { - if let Some(existing) = self.bus.get() { - return Ok(existing); - } - // Same runtime ownership as `init_in_process`: only the broker differs, - // and it is somebody else's process here. - let connection = { - let _guard = self.runtime()?.handle.enter(); - Connection::attach(transport.into()) - }; - connection.handshake().await?; + let connection = Connection::connect(transport).await?; self.init_with(connection, config).await } @@ -337,76 +226,6 @@ mod tests { assert!(!bus.is_initialised()); } - /// Subscribe first, so a publish that follows cannot be broadcast into an - /// empty room. Returns the handle, which must be held for delivery to - /// continue. - fn watch(bus: &OnceBus) -> (SubscriptionHandle, Arc>>) { - let seen = Arc::new(Mutex::new(Vec::new())); - let handle = bus - .subscribe(Arc::new(Capture(seen.clone()))) - .expect("the bus is initialised"); - (handle, seen) - } - - /// Wait for `n` events to land, or fail on a deadline. - async fn wait_for(seen: &Arc>>, n: usize) -> Vec { - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5); - loop { - { - let guard = seen.lock().await; - if guard.len() >= n { - return guard.clone(); - } - } - assert!( - tokio::time::Instant::now() < deadline, - "the bus delivered nothing" - ); - tokio::task::yield_now().await; - } - } - - #[test] - fn a_bus_outlives_the_runtime_that_initialised_it() { - // The exact shape of the bug this owns a runtime to prevent: under - // `cargo test` the first `#[tokio::test]` to touch a global bus - // initialises it and then tears its own runtime down. If the bus's - // tasks lived on that runtime, every later test would inherit a bus - // attached to a dead reactor — publishing into silence, with nothing - // anywhere reporting an error. - static BUS: OnceBus = OnceBus::new(); - - let first = tokio::runtime::Runtime::new().unwrap(); - first.block_on(async { - BUS.init_in_process(config()).await.unwrap(); - // Delivery works while the initialising runtime is still alive. - let (_handle, seen) = watch(&BUS); - BUS.publish(Tick(1)); - assert_eq!(wait_for(&seen, 1).await, vec![Tick(1)]); - }); - drop(first); - - // A later, unrelated runtime — a second test, in the real case. - let second = tokio::runtime::Runtime::new().unwrap(); - second.block_on(async { - let (_handle, seen) = watch(&BUS); - BUS.publish(Tick(2)); - assert_eq!(wait_for(&seen, 1).await, vec![Tick(2)]); - }); - } - - #[test] - fn the_bus_runtime_is_built_once_and_shared() { - static BUS: OnceBus = OnceBus::new(); - let runtime = tokio::runtime::Runtime::new().unwrap(); - runtime.block_on(async { - BUS.init_in_process(config()).await.unwrap(); - BUS.init_in_process(config()).await.unwrap(); - }); - // One runtime, however many times `init` is called. - assert!(BUS.runtime.get().is_some()); - } - #[test] fn a_once_bus_can_be_a_static() { // `new()` being `const` is what lets the host declare the singleton. diff --git a/crates/tinybus/src/lib.rs b/crates/tinybus/src/lib.rs index 282a65e5..38f79560 100644 --- a/crates/tinybus/src/lib.rs +++ b/crates/tinybus/src/lib.rs @@ -66,11 +66,13 @@ extern crate self as tinybus; pub mod broker; +pub mod build_info; pub mod connection; pub mod error; pub mod events; pub mod global; pub mod message; +pub mod module; pub mod name; pub mod native; pub mod ports; @@ -108,11 +110,10 @@ pub use tinybus_macros::interface; /// `GetId` method so a peer can tell which broker it is attached to. pub const VERSION: &str = env!("CARGO_PKG_VERSION"); -/// The wire-protocol version. +/// The wire-format revision, reserved for a change an older peer cannot parse. /// -/// Bumped only for a change that an older peer cannot parse. The broker -/// refuses a peer whose `Hello` carries a different major, because half-parsed -/// routing is worse than a clear rejection at connect time. +/// Interface additions do not bump this value. It is published for manifests +/// and diagnostics; v1 does not put it in the `Hello` handshake. pub const PROTOCOL_VERSION: u32 = 1; /// The well-known name of the broker's own service. diff --git a/crates/tinybus/src/module/abi.rs b/crates/tinybus/src/module/abi.rs new file mode 100644 index 00000000..acf04450 --- /dev/null +++ b/crates/tinybus/src/module/abi.rs @@ -0,0 +1,199 @@ +//! Stable, C-shaped module ABI. No Rust-layout value crosses this boundary. + +use std::ffi::c_void; + +/// Current ABI revision. It is also part of every exported symbol name. +pub const ABI_REVISION: u32 = 1; +/// `TBUSMOD` followed by the ABI revision byte. +pub const ABI_MAGIC: u64 = u64::from_le_bytes(*b"TBUSMOD\x01"); +/// Frozen descriptor prefix size. +pub const DESCRIPTOR_PREFIX_SIZE: u32 = 16; +/// Defensive maximum before reading descriptor bytes controlled by a module. +pub const MAX_DESCRIPTOR_SIZE: u32 = 4096; + +/// The module was accepted and the operation completed. +pub const TB_OK: i32 = 0; +/// The receiving queue has no room right now. +pub const TB_BACKPRESSURE: i32 = -1; +/// The other side has closed. +pub const TB_CLOSED: i32 = -2; +/// Module code panicked at the FFI boundary. +pub const TB_PANICKED: i32 = -3; +/// Shutdown did not finish before its deadline. +pub const TB_TIMEOUT: i32 = -4; +/// A pointer, length, or frame was invalid. +pub const TB_BAD_ARGUMENT: i32 = -5; + +/// Revision-one module initialization entrypoint. +pub type TbModuleInit = unsafe extern "C" fn(*const TbHostVtable, *mut TbModuleVtable) -> i32; + +/// A borrowed byte slice returned across the ABI. +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct TbSlice { + /// First byte, valid for `len` bytes and only for the duration documented + /// by the function returning this value. + pub ptr: *const u8, + /// Number of readable bytes. + pub len: usize, +} + +/// Facts that must match before module initialization is allowed. +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TbAbiDescriptor { + /// [`ABI_MAGIC`]. + pub magic: u64, + /// [`ABI_REVISION`]. + pub abi_revision: u32, + /// Size of the full descriptor supplied by the module. + pub descriptor_size: u32, + /// Bit 0: unwind panics, bit 1: debug assertions, bit 2: little endian. + pub flags: u32, + /// Target pointer width in bits. + pub pointer_width: u32, + /// tinybus major version. + pub tinybus_major: u32, + /// tinybus minor version. + pub tinybus_minor: u32, + /// tinybus patch version. + pub tinybus_patch: u32, + /// Additive tinybus Cargo feature bits. + pub tinybus_feature_bits: u64, + /// NUL-padded target triple. + pub target_triple: [u8; 64], + /// NUL-padded rustc release. + pub rustc_version: [u8; 48], + /// NUL-padded module package name. + pub module_name: [u8; 64], + /// NUL-padded module package version. + pub module_version: [u8; 32], +} + +impl TbAbiDescriptor { + /// Construct the descriptor for the current build and module identity. + pub const fn current(module_name: &str, module_version: &str) -> Self { + let version = crate_version(); + Self { + magic: ABI_MAGIC, + abi_revision: ABI_REVISION, + descriptor_size: size_of::() as u32, + flags: (if cfg!(panic = "unwind") { 1 } else { 0 }) + | (if cfg!(debug_assertions) { 1 << 1 } else { 0 }) + | (if cfg!(target_endian = "little") { + 1 << 2 + } else { + 0 + }), + pointer_width: usize::BITS, + tinybus_major: version[0], + tinybus_minor: version[1], + tinybus_patch: version[2], + tinybus_feature_bits: crate::build_info::FEATURE_BITS, + target_triple: fixed(crate::build_info::TARGET), + rustc_version: fixed(crate::build_info::RUSTC_VERSION), + module_name: fixed(module_name), + module_version: fixed(module_version), + } + } +} + +/// Calls from a module into its host. +#[repr(C)] +#[derive(Clone, Copy)] +pub struct TbHostVtable { + /// Size of this vtable, allowing additive growth. + pub size: u32, + /// Reserved and zero in v1. + pub _reserved: u32, + /// Opaque host context, never dereferenced by the module. + pub host_ctx: *mut c_void, + /// Send one complete JSON message frame to the host. + pub send: unsafe extern "C" fn(*mut c_void, *const u8, usize) -> i32, + /// Tell the host the module's inbound queue has room again. + pub wake: unsafe extern "C" fn(*mut c_void), + /// Forward one log record to the host. + pub log: unsafe extern "C" fn(*mut c_void, u32, *const u8, usize), + /// Mark the module failed and detach it. + pub fault: unsafe extern "C" fn(*mut c_void, *const u8, usize), + /// Borrowed JSON configuration. The module must copy it during init and + /// must not retain this pointer. + pub config: TbSlice, + /// Module setup completed and its declared bus surface is ready. + pub ready: unsafe extern "C" fn(*mut c_void), +} + +/// Calls from a host into one initialized module. +#[repr(C)] +#[derive(Clone, Copy)] +pub struct TbModuleVtable { + /// Size of this vtable, allowing additive growth. + pub size: u32, + /// Reserved and zero in v1. + pub _reserved: u32, + /// Opaque module context, never dereferenced by the host. + pub module_ctx: *mut c_void, + /// Deliver one complete JSON message frame without blocking. + pub deliver: unsafe extern "C" fn(*mut c_void, *const u8, usize) -> i32, + /// Stop module tasks, bounded by `deadline_ms`. + pub shutdown: unsafe extern "C" fn(*mut c_void, u64) -> i32, +} + +impl Default for TbModuleVtable { + fn default() -> Self { + Self { + size: size_of::() as u32, + _reserved: 0, + module_ctx: std::ptr::null_mut(), + deliver: invalid_deliver, + shutdown: invalid_shutdown, + } + } +} + +unsafe extern "C" fn invalid_deliver(_: *mut c_void, _: *const u8, _: usize) -> i32 { + TB_CLOSED +} + +unsafe extern "C" fn invalid_shutdown(_: *mut c_void, _: u64) -> i32 { + TB_CLOSED +} + +// Oversized values are deliberately truncated. Host comparisons use the full +// expected value, so truncation fails closed at admission instead of matching +// a different target or toolchain accidentally. +const fn fixed(value: &str) -> [u8; N] { + let bytes = value.as_bytes(); + let mut out = [0; N]; + let mut index = 0; + while index < bytes.len() && index < N { + out[index] = bytes[index]; + index += 1; + } + out +} + +const fn crate_version() -> [u32; 3] { + [ + parse_component(env!("CARGO_PKG_VERSION_MAJOR")), + parse_component(env!("CARGO_PKG_VERSION_MINOR")), + parse_component(env!("CARGO_PKG_VERSION_PATCH")), + ] +} + +const fn parse_component(value: &str) -> u32 { + let bytes = value.as_bytes(); + let mut out = 0u32; + let mut index = 0; + while index < bytes.len() { + out = out * 10 + (bytes[index] - b'0') as u32; + index += 1; + } + out +} + +/// Read a NUL-padded field without trusting it as UTF-8. +pub fn field_bytes(field: &[u8; N]) -> &[u8] { + let end = field.iter().position(|byte| *byte == 0).unwrap_or(N); + &field[..end] +} diff --git a/crates/tinybus/src/module/hash.rs b/crates/tinybus/src/module/hash.rs new file mode 100644 index 00000000..8f5bd54f --- /dev/null +++ b/crates/tinybus/src/module/hash.rs @@ -0,0 +1,120 @@ +//! Dependency-free SHA-256 used by the optional module allowlist. + +use std::io::{self, Read}; + +const INITIAL: [u32; 8] = [ + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19, +]; + +const K: [u32; 64] = [ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, +]; + +pub(crate) fn file_hex(mut reader: impl Read) -> io::Result { + let mut state = INITIAL; + let mut block = [0u8; 64]; + let mut used = 0usize; + let mut byte_len = 0u64; + loop { + let read = reader.read(&mut block[used..])?; + if read == 0 { + break; + } + used += read; + byte_len = byte_len.wrapping_add(read as u64); + if used == block.len() { + compress(&mut state, &block); + used = 0; + } + } + block[used] = 0x80; + used += 1; + if used > 56 { + block[used..].fill(0); + compress(&mut state, &block); + block.fill(0); + } else { + block[used..56].fill(0); + } + block[56..].copy_from_slice(&byte_len.wrapping_mul(8).to_be_bytes()); + compress(&mut state, &block); + Ok(state.iter().map(|word| format!("{word:08x}")).collect()) +} + +fn compress(state: &mut [u32; 8], block: &[u8]) { + let mut words = [0u32; 64]; + for (index, chunk) in block.chunks_exact(4).enumerate() { + words[index] = u32::from_be_bytes(chunk.try_into().expect("four-byte chunk")); + } + for index in 16..64 { + let s0 = words[index - 15].rotate_right(7) + ^ words[index - 15].rotate_right(18) + ^ (words[index - 15] >> 3); + let s1 = words[index - 2].rotate_right(17) + ^ words[index - 2].rotate_right(19) + ^ (words[index - 2] >> 10); + words[index] = words[index - 16] + .wrapping_add(s0) + .wrapping_add(words[index - 7]) + .wrapping_add(s1); + } + + let [mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut h] = *state; + for index in 0..64 { + let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25); + let choice = (e & f) ^ ((!e) & g); + let t1 = h + .wrapping_add(s1) + .wrapping_add(choice) + .wrapping_add(K[index]) + .wrapping_add(words[index]); + let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22); + let majority = (a & b) ^ (a & c) ^ (b & c); + let t2 = s0.wrapping_add(majority); + h = g; + g = f; + f = e; + e = d.wrapping_add(t1); + d = c; + c = b; + b = a; + a = t1.wrapping_add(t2); + } + for (slot, value) in state.iter_mut().zip([a, b, c, d, e, f, g, h]) { + *slot = slot.wrapping_add(value); + } +} + +#[cfg(test)] +mod tests { + #[test] + fn sha256_matches_the_published_empty_input_vector() { + assert_eq!( + super::file_hex(&b""[..]).unwrap(), + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + } + + #[test] + fn sha256_matches_the_published_56_byte_input_vector() { + assert_eq!( + super::file_hex(&[b'a'; 56][..]).unwrap(), + "b35439a4ac6f0948b6d6f9e3c6af0f5f590ce20f1bde7090ef7970686ec6738a" + ); + } + + #[test] + fn sha256_matches_a_published_multi_block_input_vector() { + assert_eq!( + super::file_hex(&[b'a'; 1000][..]).unwrap(), + "41edece42d63e8d9bf515a9ba6932e1c20cbc9f5a5d134645adb5db1b9737ea3" + ); + } +} diff --git a/crates/tinybus/src/module/host.rs b/crates/tinybus/src/module/host.rs new file mode 100644 index 00000000..aac59a86 --- /dev/null +++ b/crates/tinybus/src/module/host.rs @@ -0,0 +1,1383 @@ +//! Module admission, dependency ordering, attachment, and lifecycle. + +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +use crate::broker::Broker; +use crate::build_info; +use crate::error::{Error, Result, sanitize_untrusted}; +use crate::module::abi::{TB_OK, TbAbiDescriptor, TbModuleInit, TbModuleVtable, field_bytes}; +use crate::module::loader::{self, LoadedArtifact}; +use crate::module::manifest::{MANIFEST_SCHEMA, ModuleIdentity, ModuleManifest, PanicPolicy}; +use crate::module::transport::ModuleTransport; +use crate::name::{BusName, ObjectPath}; +use crate::ports::Transport; +use crate::version::Version; + +/// Current lifecycle state of a discovered module. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "state", content = "detail", rename_all = "snake_case")] +pub enum ModuleState { + /// File passed every check that can run before the platform loader. + Discovered, + /// ABI or manifest admission failed. Terminal. + Rejected { + /// Safe refusal reason. + reason: String, + }, + /// Dependencies or bus-name collisions prevent initialization. + Unresolved { + /// Safe resolution reason. + reason: String, + }, + /// Gated and ordered, waiting for eager or lazy initialization. + Resolved, + /// Initialization is running exactly once. + Initializing, + /// Owns its name and is waiting for calls. + Ready, + /// At least one method call is in flight. + Serving, + /// A panic or explicit fault detached the module. Terminal. + Faulted { + /// Safe fault reason. + reason: String, + }, + /// Initialization returned failure. Terminal. + Failed { + /// Safe initialization reason. + reason: String, + }, + /// Explicitly stopped. Its library remains mapped until process exit. + Stopped, + /// Operator disabled the module before initialization. + Disabled, +} + +/// Safe, serializable facts about one module. No absolute artifact path leaks. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ModuleInfo { + /// Descriptor/manifest module name after sanitization. + pub name: String, + /// Declared module version after sanitization. + pub version: String, + /// Artifact basename only. + pub file: String, + /// Current lifecycle state and safe detail, flattened for CLI JSON. + #[serde(flatten)] + pub state: ModuleState, + /// Module dependency and surface declaration. + pub manifest: ModuleManifest, + /// Toolchain recorded by the descriptor, sanitized for display. + pub rustc_version: String, + /// Whether this module's toolchain differs from the host. + pub rustc_mismatch: bool, + /// Whether discovery should admit this module on future scans. + pub enabled: bool, +} + +struct LoadedModule { + info: ModuleInfo, + transport: Arc, + unique_name: BusName, + transition_from: Option, +} + +#[derive(Clone, Copy)] +enum RefusalClass { + Rejected, + Unresolved, + Failed, +} + +impl LoadedModule { + fn snapshot(&self) -> ModuleInfo { + let mut info = self.info.clone(); + if self.transport.init_failed() + && !matches!(info.state, ModuleState::Stopped | ModuleState::Disabled) + { + info.state = ModuleState::Failed { + reason: "module initialization failed".to_string(), + }; + } else if self.transport.is_faulted() + && !matches!(info.state, ModuleState::Stopped | ModuleState::Disabled) + { + info.state = ModuleState::Faulted { + reason: "module reported an unrecoverable fault".to_string(), + }; + } else if (self.transport.is_ready() + && matches!( + info.state, + ModuleState::Resolved | ModuleState::Initializing + )) + || matches!(info.state, ModuleState::Ready | ModuleState::Serving) + { + info.state = if self.transport.inflight() == 0 { + ModuleState::Ready + } else { + ModuleState::Serving + }; + } else if self.transport.init_started() && matches!(info.state, ModuleState::Resolved) { + info.state = ModuleState::Initializing; + } + info + } +} + +/// Loads trusted cdylib modules into one embedded broker. +pub struct ModuleHost { + inner: Arc, +} + +struct ModuleHostInner { + broker: Broker, + strict: AtomicBool, + // Admission spans duplicate validation through final insertion; a narrower + // lock lets two concurrent loads both pass the name check. + admission: Mutex<()>, + loaded: Mutex>, + rejected: Mutex>, + directories: Mutex>, + configs: Mutex>, + warned: AtomicBool, +} + +/// The broker's private control hook. Kept behind a weak pointer so an unused +/// broker does not keep a module host alive. +#[async_trait::async_trait] +pub(crate) trait ModuleControl: Send + Sync { + fn list(&self) -> Vec; + fn load( + self: Arc, + path: PathBuf, + config: serde_json::Value, + ) -> Result<(ModuleInfo, Option)>; + async fn stop(&self, name: &str, deadline: Duration) -> Result; + fn enable(&self, name: &str, enabled: bool) -> Result<(ModuleInfo, Option)>; + fn rescan( + self: Arc, + paths: Vec, + dry_run: bool, + ) -> Result<(Vec, Vec)>; + fn peer_detached(&self, unique_name: &BusName) -> Option; + fn unavailable_for(&self, bus_name: &BusName) -> Option; +} + +pub(crate) type ModuleTransition = (String, ModuleState, ModuleState); + +impl ModuleHost { + /// Create a host. Loading is permissive about rustc drift by default. + pub fn new(broker: Broker) -> Self { + let inner = Arc::new(ModuleHostInner { + broker: broker.clone(), + strict: AtomicBool::new(false), + admission: Mutex::new(()), + loaded: Mutex::new(Vec::new()), + rejected: Mutex::new(Vec::new()), + directories: Mutex::new(Vec::new()), + configs: Mutex::new(HashMap::new()), + warned: AtomicBool::new(false), + }); + let control: Arc = inner.clone(); + broker.set_module_control(Arc::downgrade(&control)); + Self { inner } + } + + /// Refuse modules built by a different rustc release. + #[must_use] + pub fn strict(self, strict: bool) -> Self { + self.inner.strict.store(strict, Ordering::Release); + self + } + + /// Set JSON configuration used when `load_dir` initializes this module. + pub fn set_config(&self, module: impl Into, config: serde_json::Value) { + self.inner + .configs + .lock() + .expect("module config lock") + .insert(module.into(), config); + } + + /// Builder form of [`ModuleHost::set_config`]. + #[must_use] + pub fn with_config(self, module: impl Into, config: serde_json::Value) -> Self { + self.set_config(module, config); + self + } + + /// Snapshot all admitted modules. + pub fn list(&self) -> Vec { + let mut modules = self + .inner + .loaded + .lock() + .expect("module list lock") + .iter() + .map(LoadedModule::snapshot) + .collect::>(); + modules.extend( + self.inner + .rejected + .lock() + .expect("rejected module list lock") + .iter() + .cloned(), + ); + modules + } + + /// Load one newly installed module. + pub fn load_file(&self, path: impl AsRef) -> Result { + self.load_file_with_config(path, serde_json::json!({})) + } + + /// Admit and initialize an already-resolved module without calling the + /// platform loader. + /// + /// This is the testable seam beneath `dlopen`: callers must ensure `init` + /// and every pointer reachable through it remain valid for the process + /// lifetime, exactly as the real loader does by leaking its handle. + /// + /// # Safety + /// + /// `init` and every function pointer or context it returns must remain + /// valid and thread-safe until process exit. + pub unsafe fn attach_raw( + &self, + file: impl AsRef, + descriptor: TbAbiDescriptor, + manifest: ModuleManifest, + init: TbModuleInit, + ) -> Result { + unsafe { + self.attach_raw_with_config(file, descriptor, manifest, init, serde_json::json!({})) + } + } + + /// Configured form of [`ModuleHost::attach_raw`]. + /// + /// # Safety + /// + /// The caller must uphold the lifetime and thread-safety contract stated + /// on [`ModuleHost::attach_raw`]. + pub unsafe fn attach_raw_with_config( + &self, + file: impl AsRef, + descriptor: TbAbiDescriptor, + manifest: ModuleManifest, + init: TbModuleInit, + config: serde_json::Value, + ) -> Result { + let artifact = LoadedArtifact { + descriptor, + manifest, + init, + }; + self.activate(file.as_ref(), artifact, config) + } + + /// Load one module and pass JSON configuration to its setup function. + /// + /// The bytes are copied by the module during initialization and are never + /// retained as a host allocation across the ABI boundary. + pub fn load_file_with_config( + &self, + path: impl AsRef, + config: serde_json::Value, + ) -> Result { + let path = path.as_ref(); + let result = (|| { + if let Some(parent) = path.parent() { + check_directory(if parent.as_os_str().is_empty() { + Path::new(".") + } else { + parent + })?; + } + check_file(path)?; + 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) { + self.record_manifest_rejection(&error, rejected_manifest, RefusalClass::Unresolved); + return Err(error); + } + self.activate(path, artifact, config) + })(); + if let Err(error) = &result { + self.record_rejection(error); + } + result + } + + /// Discover and load every platform library in a private directory. + /// + /// Refusals are returned per artifact so one bad file cannot prevent the + /// remaining modules from loading. + pub fn load_dir(&self, directory: impl AsRef) -> Result>> { + let directory = directory.as_ref(); + check_directory(directory)?; + let mut directories = self + .inner + .directories + .lock() + .expect("module directory lock"); + if !directories.iter().any(|known| known == directory) { + directories.push(directory.to_path_buf()); + } + drop(directories); + let mut paths = std::fs::read_dir(directory)? + .filter_map(|entry| entry.ok().map(|entry| entry.path())) + .filter(|path| has_library_extension(path)) + .collect::>(); + paths.sort(); + let loaded_files = self + .inner + .loaded + .lock() + .expect("module list lock") + .iter() + .map(|module| module.info.file.clone()) + .collect::>(); + paths.retain(|path| !loaded_files.contains(&safe_file_name(path))); + + 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)), + Err(error) => outcomes.push(Err(error)), + } + } + + let manifests = pending + .iter() + .map(|(_, artifact)| artifact.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 error = Error::module_refused(&path, reason); + self.record_manifest_rejection(&error, artifact.manifest, RefusalClass::Unresolved); + outcomes.push(Err(error)); + } + for index in resolution.order { + let (path, artifact) = pending[index].take().expect("resolver index is valid"); + let config = self + .inner + .configs + .lock() + .expect("module config lock") + .get(&artifact.manifest.module.name) + .cloned() + .unwrap_or_else(|| serde_json::json!({})); + let result = self + .ensure_dependencies(&artifact.manifest, &path) + .inspect_err(|error| { + self.record_manifest_rejection( + error, + artifact.manifest.clone(), + RefusalClass::Unresolved, + ); + }) + .and_then(|()| self.activate(&path, artifact, config)); + outcomes.push(result); + } + for error in outcomes.iter().filter_map(|outcome| outcome.as_ref().err()) { + self.record_rejection(error); + } + Ok(outcomes) + } + + /// Search paths in precedence order for the current platform. + pub fn search_paths() -> Vec { + let mut paths = std::env::var_os("OPENHUMAN_MODULE_PATH") + .map(|value| std::env::split_paths(&value).collect::>()) + .unwrap_or_default(); + #[cfg(target_os = "linux")] + { + if let Some(path) = std::env::var_os("XDG_DATA_HOME") { + paths.push(PathBuf::from(path).join("openhuman/modules")); + } else if let Some(path) = std::env::var_os("HOME") { + paths.push(PathBuf::from(path).join(".local/share/openhuman/modules")); + } + paths.push(PathBuf::from("/usr/lib/openhuman/modules")); + } + #[cfg(target_os = "macos")] + paths.push(PathBuf::from("/usr/local/lib/openhuman/modules")); + #[cfg(windows)] + if let Some(path) = std::env::var_os("LOCALAPPDATA") { + paths.push(PathBuf::from(path).join("openhuman/modules")); + } + paths + } + + /// Load every existing directory in [`ModuleHost::search_paths`] order. + pub fn load_search_paths(&self) -> Vec> { + let mut outcomes = Vec::new(); + for path in Self::search_paths() + .into_iter() + .filter(|path| path.is_dir()) + { + match self.load_dir(path) { + Ok(results) => outcomes.extend(results), + Err(error) => outcomes.push(Err(error)), + } + } + outcomes + } + + /// Inspect a directory without initializing or attaching any module. + pub fn scan_dir(&self, directory: impl AsRef) -> Result> { + let directory = directory.as_ref(); + check_directory(directory)?; + let mut paths = std::fs::read_dir(directory)? + .filter_map(|entry| entry.ok().map(|entry| entry.path())) + .filter(|path| has_library_extension(path)) + .collect::>(); + paths.sort(); + 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 info = self.validate(&path, &artifact.descriptor, &artifact.manifest)?; + Ok((artifact, info)) + }); + match inspected { + Ok((artifact, info)) => admitted.push((artifact, info)), + Err(error) => results.push(rejection_info(&error)), + } + } + let manifests = admitted + .iter() + .map(|(artifact, _)| artifact.manifest.clone()) + .collect::>(); + let resolution = crate::module::resolve::resolve(&manifests, &self.provided_interfaces()); + let mut admitted = admitted.into_iter().map(Some).collect::>(); + for (index, reason) in resolution.unresolved { + let (_, mut info) = admitted[index].take().expect("resolver index is valid"); + info.state = ModuleState::Unresolved { reason }; + results.push(info); + } + for index in resolution.order { + let (_, info) = admitted[index].take().expect("resolver index is valid"); + results.push(info); + } + Ok(results) + } + + /// Stop every module within the supplied deadline per module. + pub async fn shutdown(&self, deadline: Duration) { + let transports = self + .inner + .loaded + .lock() + .expect("module list lock") + .iter() + .map(|module| module.transport.clone()) + .collect::>(); + for transport in transports { + let _ = tokio::task::spawn_blocking(move || transport.stop_sync(deadline)).await; + } + for module in self + .inner + .loaded + .lock() + .expect("module list lock") + .iter_mut() + { + module.transition_from = Some(module.snapshot().state); + module.info.state = ModuleState::Stopped; + } + } + + fn activate( + &self, + path: &Path, + artifact: LoadedArtifact, + config: serde_json::Value, + ) -> Result { + let _admission = self.inner.admission.lock().expect("module admission lock"); + let manifest = artifact.manifest.clone(); + let mut admitted = self + .validate(path, &artifact.descriptor, &artifact.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, 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); + if artifact.manifest.lazy_init { + admitted.state = ModuleState::Resolved; + transport.defer_initialize(artifact.init, host_vtable); + } else { + admitted.state = ModuleState::Initializing; + let mut module_vtable = TbModuleVtable::default(); + let code = unsafe { (artifact.init)(&host_vtable, &mut module_vtable) }; + transport.clear_config(); + if code != TB_OK { + let error = Error::module_refused(path, "module initialization failed"); + self.record_manifest_rejection(&error, manifest, RefusalClass::Failed); + return Err(error); + } + if transport.initialize(module_vtable).is_err() { + let error = Error::module_refused(path, "module returned an invalid vtable"); + self.record_manifest_rejection(&error, manifest, RefusalClass::Failed); + return Err(error); + } + } + + let transport_for_broker: Arc = transport.clone(); + let unique = self.inner.broker.attach(transport_for_broker); + let reserved_change = match self + .inner + .broker + .reserve_module_name(&unique, admitted.manifest.bus_name.clone()) + { + Ok(change) => change, + Err(_) => { + let _ = transport.stop_sync(Duration::from_millis(0)); + let error = Error::module_refused(path, "module bus name is already owned"); + self.record_manifest_rejection(&error, manifest, RefusalClass::Unresolved); + return Err(error); + } + }; + let broker = self.inner.broker.clone(); + 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; + tokio::spawn(async move { + if lazy_init { + ready_transport.wait_initializing().await; + broker + .announce_module_state(serde_json::json!([ + module_name.clone(), + "resolved", + "initializing", + null + ])) + .await; + } + ready_transport.wait_ready().await; + if ready_transport.is_ready() { + broker.announce_name_change(reserved_change).await; + broker + .announce_module_state(serde_json::json!([ + module_name, + previous_state, + "ready", + null + ])) + .await; + } + }); + if !self.inner.warned.swap(true, Ordering::AcqRel) { + tracing::warn!( + modules = 1, + "in-process modules are inside the host trust boundary" + ); + } + tracing::info!(module = %admitted.name, "module loaded"); + self.inner + .loaded + .lock() + .expect("module list lock") + .push(LoadedModule { + info: admitted.clone(), + transport, + unique_name: unique, + transition_from: None, + }); + Ok(admitted) + } + + fn validate( + &self, + path: &Path, + descriptor: &TbAbiDescriptor, + manifest: &ModuleManifest, + ) -> Result { + let refuse = |reason| Error::module_refused(path, reason); + loader::gate_descriptor(path, descriptor, self.inner.strict.load(Ordering::Acquire))?; + + let rustc = sanitized_field(&descriptor.rustc_version) + .ok_or_else(|| refuse("descriptor identity is invalid"))?; + let rustc_mismatch = + field_bytes(&descriptor.rustc_version) != build_info::RUSTC_VERSION.as_bytes(); + if rustc_mismatch { + tracing::warn!(module = %sanitize_untrusted(&manifest.module.name), "module rustc differs from host"); + } + + let name = sanitized_field(&descriptor.module_name) + .ok_or_else(|| refuse("descriptor identity is invalid"))?; + let version = sanitized_field(&descriptor.module_version) + .ok_or_else(|| refuse("descriptor identity is invalid"))?; + if 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 + { + return Err(refuse("manifest identity does not match descriptor")); + } + + Ok(ModuleInfo { + name, + version, + file: safe_file_name(path), + state: ModuleState::Resolved, + manifest: manifest.clone(), + rustc_version: rustc, + rustc_mismatch, + enabled: true, + }) + } + + fn ensure_dependencies(&self, manifest: &ModuleManifest, path: &Path) -> Result<()> { + let available = self.provided_interfaces(); + if manifest + .requires + .iter() + .filter(|dependency| !dependency.optional) + .any(|dependency| !available.contains(dependency.interface.interface.as_str())) + { + return Err(Error::module_refused( + path, + "a required interface has no provider", + )); + } + Ok(()) + } + + fn provided_interfaces(&self) -> HashSet { + self.inner + .loaded + .lock() + .expect("module list lock") + .iter() + .flat_map(|module| { + module + .info + .manifest + .provides + .iter() + .map(|provided| provided.version.interface.to_string()) + .collect::>() + }) + .collect() + } + + fn record_rejection(&self, error: &Error) { + let Error::ModuleRefused { .. } = error else { + return; + }; + let info = rejection_info(error); + let mut rejected = self + .inner + .rejected + .lock() + .expect("rejected module list lock"); + if !rejected.iter().any(|known| known.file == info.file) { + rejected.push(info); + } + } + + fn record_manifest_rejection( + &self, + error: &Error, + manifest: ModuleManifest, + class: RefusalClass, + ) { + let Error::ModuleRefused { file, reason } = error else { + return; + }; + let state = match class { + RefusalClass::Rejected => ModuleState::Rejected { + reason: reason.clone(), + }, + RefusalClass::Unresolved => ModuleState::Unresolved { + reason: reason.clone(), + }, + RefusalClass::Failed => ModuleState::Failed { + reason: reason.clone(), + }, + }; + let info = ModuleInfo { + name: sanitize_untrusted(&manifest.module.name), + version: sanitize_untrusted(&manifest.module.version.to_string()), + file: file.clone(), + state, + manifest, + rustc_version: String::new(), + rustc_mismatch: false, + enabled: false, + }; + let mut rejected = self + .inner + .rejected + .lock() + .expect("rejected module list lock"); + if let Some(existing) = rejected.iter_mut().find(|known| known.file == info.file) { + *existing = info; + } else { + rejected.push(info); + } + } +} + +#[async_trait::async_trait] +impl ModuleControl for ModuleHostInner { + fn list(&self) -> Vec { + let mut modules = self + .loaded + .lock() + .expect("module list lock") + .iter() + .map(LoadedModule::snapshot) + .collect::>(); + modules.extend( + self.rejected + .lock() + .expect("rejected module list lock") + .iter() + .cloned(), + ); + modules + } + + fn load( + self: Arc, + path: PathBuf, + config: serde_json::Value, + ) -> Result<(ModuleInfo, Option)> { + let info = ModuleHost { inner: self }.load_file_with_config(path, config)?; + let transition = Some(( + info.name.clone(), + ModuleState::Discovered, + info.state.clone(), + )); + Ok((info, transition)) + } + + async fn stop(&self, name: &str, deadline: Duration) -> Result { + let transport = { + let mut loaded = self.loaded.lock().expect("module list lock"); + let module = loaded + .iter_mut() + .find(|module| module.info.name == name) + .ok_or_else(|| Error::failed("module is not loaded"))?; + let old = module.snapshot().state; + if matches!( + &old, + ModuleState::Stopped | ModuleState::Faulted { .. } | ModuleState::Failed { .. } + ) { + return Err(Error::ModuleUnavailable { + module: module.info.name.clone(), + state: state_name(&old).to_string(), + detail: state_detail(&old) + .unwrap_or("module is terminal") + .to_string(), + }); + } + module.transition_from = Some(old); + module.transport.clone() + }; + tokio::task::spawn_blocking(move || transport.stop_sync(deadline)) + .await + .map_err(|_| Error::failed("module stop task was cancelled"))?; + let mut loaded = self.loaded.lock().expect("module list lock"); + let module = loaded + .iter_mut() + .find(|module| module.info.name == name) + .ok_or_else(|| Error::failed("module is not loaded"))?; + module.info.state = ModuleState::Stopped; + Ok(module.info.clone()) + } + + fn enable(&self, name: &str, enabled: bool) -> Result<(ModuleInfo, Option)> { + let mut loaded = self.loaded.lock().expect("module list lock"); + let module = loaded + .iter_mut() + .find(|module| module.info.name == name) + .ok_or_else(|| Error::failed("module is not known"))?; + let old = module.snapshot().state; + if matches!( + &old, + ModuleState::Stopped | ModuleState::Failed { .. } | ModuleState::Faulted { .. } + ) { + return Err(Error::ModuleUnavailable { + module: module.info.name.clone(), + state: state_name(&old).to_string(), + detail: state_detail(&old) + .unwrap_or("module is terminal") + .to_string(), + }); + } + module.info.enabled = enabled; + module.info.state = if enabled { + if module.transport.is_ready() { + ModuleState::Ready + } else { + ModuleState::Resolved + } + } else { + ModuleState::Disabled + }; + let info = module.snapshot(); + let transition = (old != info.state).then(|| (info.name.clone(), old, info.state.clone())); + Ok((info, transition)) + } + + fn rescan( + self: Arc, + paths: Vec, + dry_run: bool, + ) -> Result<(Vec, Vec)> { + let directories = if paths.is_empty() { + let configured = self + .directories + .lock() + .expect("module directory lock") + .clone(); + if configured.is_empty() { + ModuleHost::search_paths() + .into_iter() + .filter(|path| path.is_dir()) + .collect() + } else { + configured + } + } else { + paths + }; + let host = ModuleHost { inner: self }; + let mut loaded = Vec::new(); + for directory in directories { + if dry_run { + loaded.extend(host.scan_dir(directory)?); + continue; + } + for outcome in host.load_dir(directory)? { + match outcome { + Ok(info) => loaded.push(info), + Err(error) => tracing::warn!(error = %error, "module refused during rescan"), + } + } + } + let transitions = if dry_run { + Vec::new() + } else { + loaded + .iter() + .map(|info| { + ( + info.name.clone(), + ModuleState::Discovered, + info.state.clone(), + ) + }) + .collect() + }; + Ok((loaded, transitions)) + } + + fn peer_detached(&self, unique_name: &BusName) -> Option<(String, ModuleState, ModuleState)> { + let mut loaded = self.loaded.lock().expect("module list lock"); + let module = loaded + .iter_mut() + .find(|module| &module.unique_name == unique_name)?; + if let Some(old) = module.transition_from.take() { + module.info.state = ModuleState::Stopped; + return Some((module.info.name.clone(), old, ModuleState::Stopped)); + } + if !module.transport.is_faulted() + || matches!( + module.info.state, + ModuleState::Stopped | ModuleState::Disabled + ) + { + return None; + } + let old = if module.transport.is_ready() { + if module.transport.inflight() == 0 { + ModuleState::Ready + } else { + ModuleState::Serving + } + } else if module.transport.init_started() + && matches!(module.info.state, ModuleState::Resolved) + { + ModuleState::Initializing + } else { + module.info.state.clone() + }; + let new = if module.transport.init_failed() { + ModuleState::Failed { + reason: "module initialization failed".to_string(), + } + } else { + ModuleState::Faulted { + reason: "module reported an unrecoverable fault".to_string(), + } + }; + module.info.state = new.clone(); + Some((module.info.name.clone(), old, new)) + } + + fn unavailable_for(&self, bus_name: &BusName) -> Option { + let loaded = self.loaded.lock().expect("module list lock"); + let loaded_info = loaded + .iter() + .find(|module| &module.info.manifest.bus_name == bus_name) + .map(LoadedModule::snapshot); + drop(loaded); + let info = loaded_info.or_else(|| { + self.rejected + .lock() + .expect("rejected module list lock") + .iter() + .find(|module| &module.manifest.bus_name == bus_name) + .cloned() + })?; + let detail = state_detail(&info.state) + .unwrap_or("module is not accepting calls") + .to_string(); + matches!( + info.state, + ModuleState::Rejected { .. } + | ModuleState::Faulted { .. } + | ModuleState::Failed { .. } + | ModuleState::Stopped + | ModuleState::Disabled + ) + .then(|| Error::ModuleUnavailable { + module: info.name, + state: state_name(&info.state).to_string(), + detail, + }) + } +} + +fn rejection_info(error: &Error) -> ModuleInfo { + let (file, reason) = match error { + Error::ModuleRefused { file, reason } => (file.clone(), reason.clone()), + _ => ("module".to_string(), "module inspection failed".to_string()), + }; + ModuleInfo { + name: file.clone(), + version: String::new(), + file: file.clone(), + state: ModuleState::Rejected { reason }, + manifest: ModuleManifest { + schema: MANIFEST_SCHEMA, + module: ModuleIdentity { + name: file, + version: Version::new(0, 0, 0), + description: String::new(), + homepage: None, + license: String::new(), + }, + bus_name: BusName::new("ai.tinyhumans.module.Rejected").expect("literal bus name"), + object_path: ObjectPath::new("/ai/tinyhumans/module/Rejected") + .expect("literal object path"), + provides: Vec::new(), + requires: Vec::new(), + environment: Vec::new(), + capabilities: Vec::new(), + lazy_init: false, + worker_threads: 1, + on_panic: PanicPolicy::Detach, + }, + rustc_version: String::new(), + rustc_mismatch: false, + enabled: false, + } +} + +pub(crate) fn state_name(state: &ModuleState) -> &'static str { + match state { + ModuleState::Discovered => "discovered", + ModuleState::Rejected { .. } => "rejected", + ModuleState::Unresolved { .. } => "unresolved", + ModuleState::Resolved => "resolved", + ModuleState::Initializing => "initializing", + ModuleState::Ready => "ready", + ModuleState::Serving => "serving", + ModuleState::Faulted { .. } => "faulted", + ModuleState::Failed { .. } => "failed", + ModuleState::Stopped => "stopped", + ModuleState::Disabled => "disabled", + } +} + +pub(crate) fn state_detail(state: &ModuleState) -> Option<&str> { + match state { + ModuleState::Rejected { reason } + | ModuleState::Unresolved { reason } + | ModuleState::Faulted { reason } + | ModuleState::Failed { reason } => Some(reason), + _ => None, + } +} + +fn sanitized_field(field: &[u8; N]) -> Option { + let raw = std::str::from_utf8(field_bytes(field)).ok()?; + let sanitized = sanitize_untrusted(raw); + (!sanitized.is_empty() && sanitized == raw).then_some(sanitized) +} + +fn safe_file_name(path: &Path) -> String { + path.file_name() + .and_then(|name| name.to_str()) + .map(sanitize_untrusted) + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| "module".to_string()) +} + +fn check_file(path: &Path) -> Result<()> { + #[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; + // There is no portable fd-based dlopen. O_NOFOLLOW closes the obvious + // symlink path, while the checked parent permissions are what prevent + // a hostile swap in the unavoidable check-to-dlopen interval. + std::fs::OpenOptions::new() + .read(true) + .custom_flags(O_NOFOLLOW) + .open(path) + .map_err(|_| Error::module_refused(path, "artifact metadata is unavailable"))? + }; + #[cfg(unix)] + let metadata = file + .metadata() + .map_err(|_| Error::module_refused(path, "artifact metadata is unavailable"))?; + #[cfg(windows)] + let metadata = std::fs::symlink_metadata(path) + .map_err(|_| Error::module_refused(path, "artifact metadata is unavailable"))?; + if !metadata.file_type().is_file() { + return Err(Error::module_refused( + path, + "artifact is not a regular file", + )); + } + if metadata.len() > 512 * 1024 * 1024 { + return Err(Error::module_refused( + path, + "artifact exceeds the 512 MiB size cap", + )); + } + if !has_library_extension(path) { + return Err(Error::module_refused( + path, + "artifact extension is not loadable", + )); + } + #[cfg(windows)] + let file = std::fs::File::open(path) + .map_err(|_| Error::module_refused(path, "artifact is unreadable"))?; + check_allowlist(path, file) +} + +fn check_allowlist(path: &Path, file: std::fs::File) -> Result<()> { + let Some(directory) = path.parent() else { + return Ok(()); + }; + let allowlist = directory.join("modules.toml"); + if !allowlist.exists() { + return Ok(()); + } + let source = std::fs::read_to_string(&allowlist) + .map_err(|_| Error::module_refused(path, "module allowlist is unreadable"))?; + let file_name = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or(""); + let file_stem = path + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or(""); + let expected = source.lines().find_map(|line| { + let line = line.split('#').next()?.trim(); + if line.is_empty() || line.starts_with('[') { + return None; + } + let (key, value) = line.split_once('=')?; + let key = key.trim().trim_matches(['"', '\'']); + (key == file_name || key == file_stem) + .then(|| value.trim().trim_matches(['"', '\'']).to_ascii_lowercase()) + }); + let Some(expected) = expected else { + return Err(Error::module_refused( + path, + "artifact is absent from the module allowlist", + )); + }; + if expected.len() != 64 || !expected.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(Error::module_refused( + path, + "module allowlist contains an invalid hash", + )); + } + let actual = crate::module::hash::file_hex(file) + .map_err(|_| Error::module_refused(path, "artifact hash could not be read"))?; + if actual != expected { + return Err(Error::module_refused( + path, + "artifact hash does not match the module allowlist", + )); + } + Ok(()) +} + +fn has_library_extension(path: &Path) -> bool { + let expected = if cfg!(windows) { + "dll" + } else if cfg!(target_os = "macos") { + "dylib" + } else { + "so" + }; + path.extension().and_then(|value| value.to_str()) == Some(expected) +} + +#[cfg(unix)] +fn check_directory(path: &Path) -> Result<()> { + use std::os::unix::fs::MetadataExt; + + unsafe extern "C" { + fn getuid() -> u32; + } + + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir() + .map_err(|_| Error::module_refused(path, "module directory is unavailable"))? + .join(path) + }; + let uid = unsafe { getuid() }; + for component in absolute.ancestors() { + let metadata = std::fs::symlink_metadata(component) + .map_err(|_| Error::module_refused(path, "module directory is unavailable"))?; + if !metadata.file_type().is_dir() { + return Err(Error::module_refused( + path, + "module search path contains a non-directory component", + )); + } + if let Some(reason) = unix_directory_refusal(metadata.uid(), metadata.mode(), uid) { + return Err(Error::module_refused(path, reason)); + } + } + Ok(()) +} + +#[cfg(unix)] +fn unix_directory_refusal(owner: u32, mode: u32, current_uid: u32) -> Option<&'static str> { + if owner != current_uid && owner != 0 { + Some("module directory is owned by another user") + } else if mode & 0o022 != 0 { + Some("module directory is writable by another user") + } else { + None + } +} + +#[cfg(windows)] +fn check_directory(path: &Path) -> Result<()> { + let metadata = std::fs::symlink_metadata(path) + .map_err(|_| Error::module_refused(path, "module directory is unavailable"))?; + if !metadata.file_type().is_dir() { + return Err(Error::module_refused( + path, + "module search path is not a directory", + )); + } + if windows_directory_grants_untrusted_write(path)? { + return Err(Error::module_refused( + path, + "module directory is writable by another user", + )); + } + Ok(()) +} + +#[cfg(windows)] +fn windows_directory_grants_untrusted_write(path: &Path) -> Result { + use std::ffi::c_void; + use std::os::windows::ffi::OsStrExt; + + #[repr(C)] + struct Acl { + revision: u8, + sbz1: u8, + size: u16, + ace_count: u16, + sbz2: u16, + } + #[repr(C)] + struct AceHeader { + ace_type: u8, + ace_flags: u8, + ace_size: u16, + } + #[repr(C)] + struct AccessAllowedAce { + header: AceHeader, + mask: u32, + sid_start: u32, + } + + #[link(name = "advapi32")] + unsafe extern "system" { + fn GetNamedSecurityInfoW( + name: *mut u16, + object_type: u32, + security_info: u32, + owner: *mut *mut c_void, + group: *mut *mut c_void, + dacl: *mut *mut Acl, + sacl: *mut *mut Acl, + descriptor: *mut *mut c_void, + ) -> u32; + fn GetAce(acl: *const Acl, index: u32, ace: *mut *mut c_void) -> i32; + fn EqualSid(first: *const c_void, second: *const c_void) -> i32; + fn CreateWellKnownSid( + kind: u32, + domain: *const c_void, + sid: *mut c_void, + size: *mut u32, + ) -> i32; + } + #[link(name = "kernel32")] + unsafe extern "system" { + fn LocalFree(memory: *mut c_void) -> *mut c_void; + } + + const SE_FILE_OBJECT: u32 = 1; + const OWNER_SECURITY_INFORMATION: u32 = 0x1; + const DACL_SECURITY_INFORMATION: u32 = 0x4; + const ACCESS_ALLOWED_ACE_TYPE: u8 = 0; + const WIN_LOCAL_SYSTEM_SID: u32 = 22; + const WIN_BUILTIN_ADMINISTRATORS_SID: u32 = 26; + const WRITE_MASK: u32 = + 0x2 | 0x4 | 0x10 | 0x100 | 0x1_0000 | 0x4_0000 | 0x8_0000 | 0x1000_0000 | 0x4000_0000; + + let mut wide = path + .as_os_str() + .encode_wide() + .chain([0]) + .collect::>(); + let mut owner = std::ptr::null_mut(); + let mut dacl = std::ptr::null_mut(); + let mut descriptor = std::ptr::null_mut(); + let status = unsafe { + GetNamedSecurityInfoW( + wide.as_mut_ptr(), + SE_FILE_OBJECT, + OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, + &mut owner, + std::ptr::null_mut(), + &mut dacl, + std::ptr::null_mut(), + &mut descriptor, + ) + }; + if status != 0 || descriptor.is_null() || owner.is_null() { + if !descriptor.is_null() { + unsafe { LocalFree(descriptor) }; + } + return Err(Error::module_refused( + path, + "module directory ACL is unavailable", + )); + } + + let result = (|| { + if dacl.is_null() { + return true; + } + let mut admin_sid = [0u32; 17]; + let mut admin_len = size_of_val(&admin_sid) as u32; + let mut system_sid = [0u32; 17]; + let mut system_len = size_of_val(&system_sid) as u32; + if unsafe { + CreateWellKnownSid( + WIN_BUILTIN_ADMINISTRATORS_SID, + std::ptr::null(), + admin_sid.as_mut_ptr().cast(), + &mut admin_len, + ) + } == 0 + || unsafe { + CreateWellKnownSid( + WIN_LOCAL_SYSTEM_SID, + std::ptr::null(), + system_sid.as_mut_ptr().cast(), + &mut system_len, + ) + } == 0 + { + return true; + } + let ace_count = unsafe { (*dacl).ace_count }; + for index in 0..u32::from(ace_count) { + let mut ace = std::ptr::null_mut(); + if unsafe { GetAce(dacl, index, &mut ace) } == 0 || ace.is_null() { + return true; + } + let ace = ace.cast::(); + if unsafe { (*ace).header.ace_type } != ACCESS_ALLOWED_ACE_TYPE + || unsafe { (*ace).mask } & WRITE_MASK == 0 + { + continue; + } + let sid = unsafe { std::ptr::addr_of!((*ace).sid_start) }.cast(); + let trusted = unsafe { EqualSid(sid, owner) } != 0 + || unsafe { EqualSid(sid, admin_sid.as_ptr().cast()) } != 0 + || unsafe { EqualSid(sid, system_sid.as_ptr().cast()) } != 0; + if !trusted { + return true; + } + } + false + })(); + unsafe { LocalFree(descriptor) }; + Ok(result) +} + +#[cfg(test)] +#[path = "host_test.rs"] +mod tests; diff --git a/crates/tinybus/src/module/host_test.rs b/crates/tinybus/src/module/host_test.rs new file mode 100644 index 00000000..a0780518 --- /dev/null +++ b/crates/tinybus/src/module/host_test.rs @@ -0,0 +1,845 @@ +//! Host module tests, including raw-vtable fixtures and real-loader cases. + +use super::*; +use crate::Connection; +use crate::module::abi::TbAbiDescriptor; +use crate::transport::memory::MemoryBus; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + +static INIT_RAN: AtomicBool = AtomicBool::new(false); +static LAZY_INIT_COUNT: AtomicUsize = AtomicUsize::new(0); +static FAILED_INIT_COUNT: AtomicUsize = AtomicUsize::new(0); +static FAKE_MODULE_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + +struct FakeModule { + tx: std::sync::mpsc::SyncSender>, +} + +unsafe extern "C" fn fake_deliver(ctx: *mut std::ffi::c_void, ptr: *const u8, len: usize) -> i32 { + if ctx.is_null() || ptr.is_null() { + return crate::module::abi::TB_BAD_ARGUMENT; + } + let module = unsafe { &*ctx.cast::() }; + let bytes = unsafe { std::slice::from_raw_parts(ptr, len) }.to_vec(); + match module.tx.try_send(bytes) { + Ok(()) => TB_OK, + Err(std::sync::mpsc::TrySendError::Full(_)) => crate::module::abi::TB_BACKPRESSURE, + Err(std::sync::mpsc::TrySendError::Disconnected(_)) => crate::module::abi::TB_CLOSED, + } +} + +unsafe extern "C" fn fake_shutdown(_: *mut std::ffi::c_void, _: u64) -> i32 { + TB_OK +} + +unsafe extern "C" fn lazy_echo_init( + host: *const crate::module::abi::TbHostVtable, + out: *mut TbModuleVtable, +) -> i32 { + LAZY_INIT_COUNT.fetch_add(1, Ordering::AcqRel); + let host = unsafe { *host }; + let host_ctx = host.host_ctx as usize; + let send = host.send; + let ready = host.ready; + let (tx, rx) = std::sync::mpsc::sync_channel::>(8); + std::thread::spawn(move || { + unsafe { ready(host_ctx as *mut std::ffi::c_void) }; + while let Ok(bytes) = rx.recv() { + let Ok(message) = serde_json::from_slice::(&bytes) else { + continue; + }; + if message.header.kind == crate::message::MessageKind::MethodCall { + if message.header.member.as_ref().map(|member| member.as_str()) == Some("Hang") { + continue; + } + let body = if message.header.member.as_ref().map(|member| member.as_str()) + == Some("Sender") + { + serde_json::to_value(&message.header.sender).expect("fake sender serializes") + } else { + message + .body + .as_array() + .and_then(|values| values.first()) + .cloned() + .unwrap_or(serde_json::Value::Null) + }; + let reply = crate::Message::method_return(&message.header, body); + let bytes = serde_json::to_vec(&reply).expect("fake reply serializes"); + let _ = unsafe { + send( + host_ctx as *mut std::ffi::c_void, + bytes.as_ptr(), + bytes.len(), + ) + }; + } + } + }); + let module = Box::into_raw(Box::new(FakeModule { tx })); + unsafe { + *out = TbModuleVtable { + size: size_of::() as u32, + _reserved: 0, + module_ctx: module.cast(), + deliver: fake_deliver, + shutdown: fake_shutdown, + }; + } + TB_OK +} + +unsafe extern "C" fn failing_init( + _: *const crate::module::abi::TbHostVtable, + _: *mut TbModuleVtable, +) -> i32 { + FAILED_INIT_COUNT.fetch_add(1, Ordering::AcqRel); + crate::module::abi::TB_BAD_ARGUMENT +} + +unsafe extern "C" fn init_that_must_not_run( + _: *const crate::module::abi::TbHostVtable, + _: *mut TbModuleVtable, +) -> i32 { + INIT_RAN.store(true, Ordering::Release); + TB_OK +} + +fn manifest() -> ModuleManifest { + named_manifest("clock", "Clock") +} + +fn named_manifest(module_name: &str, surface_name: &str) -> ModuleManifest { + ModuleManifest { + schema: MANIFEST_SCHEMA, + module: ModuleIdentity { + name: module_name.to_string(), + version: Version::new(0, 1, 0), + description: String::new(), + homepage: None, + license: String::new(), + }, + bus_name: BusName::new(format!("ai.tinyhumans.module.{surface_name}")).unwrap(), + object_path: ObjectPath::new(format!("/ai/tinyhumans/module/{surface_name}")).unwrap(), + provides: vec![], + requires: vec![], + environment: vec![], + capabilities: vec![], + lazy_init: false, + worker_threads: 1, + on_panic: PanicPolicy::Detach, + } +} + +#[tokio::test] +async fn a_lazy_module_initializes_on_the_first_call_and_two_racing_callers_initialize_it_once() { + let _test_guard = FAKE_MODULE_TEST_LOCK.lock().await; + LAZY_INIT_COUNT.store(0, Ordering::Release); + let bus = MemoryBus::new(); + let broker = Broker::new(); + let broker_task = broker.spawn(bus.clone()); + let host = ModuleHost::new(broker); + let mut lazy_manifest = manifest(); + lazy_manifest.lazy_init = true; + let info = unsafe { + host.attach_raw( + "clock.so", + TbAbiDescriptor::current("clock", "0.1.0"), + lazy_manifest, + lazy_echo_init, + ) + } + .unwrap(); + assert_eq!(info.state, ModuleState::Resolved); + assert_eq!(LAZY_INIT_COUNT.load(Ordering::Acquire), 0); + + 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 first = proxy.call::("Echo", ("first",)); + let second = proxy.call::("Echo", ("second",)); + let (first, second) = tokio::join!(first, second); + assert_eq!(first.unwrap(), "first"); + assert_eq!(second.unwrap(), "second"); + assert_eq!(LAZY_INIT_COUNT.load(Ordering::Acquire), 1); + assert_eq!(host.list()[0].state, ModuleState::Ready); + 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); + let bus = MemoryBus::new(); + let broker = Broker::new(); + let broker_task = broker.spawn(bus.clone()); + let host = ModuleHost::new(broker); + let mut lazy_manifest = manifest(); + lazy_manifest.lazy_init = true; + unsafe { + host.attach_raw( + "clock.so", + TbAbiDescriptor::current("clock", "0.1.0"), + lazy_manifest, + failing_init, + ) + } + .unwrap(); + 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 first = proxy.call::<()>("Call", ()).await.unwrap_err(); + assert_eq!( + first.wire_name(), + "ai.tinyhumans.tinybus.Error.ModuleUnavailable" + ); + tokio::time::timeout(Duration::from_secs(1), async { + while !matches!(host.list()[0].state, ModuleState::Failed { .. }) { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + let second = proxy.call::<()>("Call", ()).await.unwrap_err(); + assert_eq!( + second.wire_name(), + "ai.tinyhumans.tinybus.Error.ModuleUnavailable" + ); + assert_eq!(FAILED_INIT_COUNT.load(Ordering::Acquire), 1); + broker_task.abort(); +} + +#[tokio::test] +async fn a_call_to_a_rejected_module_names_the_state_rather_than_timing_out() { + let bus = MemoryBus::new(); + let broker = Broker::new(); + let broker_task = broker.spawn(bus.clone()); + let host = ModuleHost::new(broker); + let mut descriptor = TbAbiDescriptor::current("clock", "0.1.0"); + descriptor.magic = 0; + unsafe { host.attach_raw("clock.so", descriptor, manifest(), init_that_must_not_run) } + .unwrap_err(); + + 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::<()>("Call", ()).await.unwrap_err(); + assert_eq!( + error.wire_name(), + "ai.tinyhumans.tinybus.Error.ModuleUnavailable" + ); + assert!(error.to_string().contains("rejected"), "{error}"); + broker_task.abort(); +} + +#[tokio::test] +async fn a_match_rule_on_a_lazy_modules_signal_does_not_initialize_it() { + let _test_guard = FAKE_MODULE_TEST_LOCK.lock().await; + LAZY_INIT_COUNT.store(0, Ordering::Release); + let bus = MemoryBus::new(); + let broker = Broker::new(); + let broker_task = broker.spawn(bus.clone()); + let host = ModuleHost::new(broker); + let mut lazy_manifest = manifest(); + lazy_manifest.lazy_init = true; + unsafe { + host.attach_raw( + "clock.so", + TbAbiDescriptor::current("clock", "0.1.0"), + lazy_manifest, + lazy_echo_init, + ) + } + .unwrap(); + let connection = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let _signals = connection + .add_match( + crate::router::MatchRule::parse("type=signal,sender=ai.tinyhumans.module.Clock") + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(LAZY_INIT_COUNT.load(Ordering::Acquire), 0); + broker_task.abort(); +} + +#[tokio::test] +async fn stopping_one_module_leaves_the_other_serving() { + let _test_guard = FAKE_MODULE_TEST_LOCK.lock().await; + LAZY_INIT_COUNT.store(0, Ordering::Release); + let bus = MemoryBus::new(); + let broker = Broker::new(); + let broker_task = broker.spawn(bus.clone()); + let host = ModuleHost::new(broker); + for (module_name, surface_name) in [("one", "One"), ("two", "Two")] { + let mut module_manifest = named_manifest(module_name, surface_name); + module_manifest.lazy_init = true; + unsafe { + host.attach_raw( + format!("{module_name}.so"), + TbAbiDescriptor::current(module_name, "0.1.0"), + module_manifest, + lazy_echo_init, + ) + } + .unwrap(); + } + let connection = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let proxy = |surface_name: &str| { + connection + .proxy( + format!("ai.tinyhumans.module.{surface_name}"), + format!("/ai/tinyhumans/module/{surface_name}"), + format!("ai.tinyhumans.module.{surface_name}"), + ) + .unwrap() + }; + assert_eq!( + proxy("One").call::("Echo", ("one",)).await.unwrap(), + "one" + ); + assert_eq!( + proxy("Two").call::("Echo", ("two",)).await.unwrap(), + "two" + ); + connection + .stop_module("one", Duration::from_secs(1)) + .await + .unwrap(); + let enable_error = connection.enable_module("one", true).await.unwrap_err(); + assert_eq!( + enable_error.wire_name(), + "ai.tinyhumans.tinybus.Error.ModuleUnavailable" + ); + assert_eq!(host.list()[0].state, ModuleState::Stopped); + assert_eq!( + proxy("Two") + .call::("Echo", ("still serving",)) + .await + .unwrap(), + "still serving" + ); + let stopped = proxy("One") + .call::<()>("Echo", ("stopped",)) + .await + .unwrap_err(); + assert_eq!( + stopped.wire_name(), + "ai.tinyhumans.tinybus.Error.ModuleUnavailable" + ); + broker_task.abort(); +} + +#[tokio::test] +async fn a_module_that_never_replies_times_out_the_caller_and_leaves_the_bus_usable() { + let _test_guard = FAKE_MODULE_TEST_LOCK.lock().await; + let bus = MemoryBus::new(); + let broker = Broker::new(); + let broker_task = broker.spawn(bus.clone()); + let host = ModuleHost::new(broker); + let mut lazy_manifest = manifest(); + lazy_manifest.lazy_init = true; + unsafe { + host.attach_raw( + "clock.so", + TbAbiDescriptor::current("clock", "0.1.0"), + lazy_manifest, + lazy_echo_init, + ) + } + .unwrap(); + 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() + .with_timeout(Duration::from_millis(20)); + let error = proxy.call::<()>("Hang", ()).await.unwrap_err(); + assert!(matches!(error, Error::Timeout { .. }), "{error}"); + assert_eq!( + proxy.call::("Echo", ("usable",)).await.unwrap(), + "usable" + ); + assert_eq!(host.list()[0].state, ModuleState::Serving); + broker_task.abort(); +} + +#[tokio::test] +async fn the_sender_on_a_module_frame_is_stamped_by_the_broker_like_any_other_peers() { + let _test_guard = FAKE_MODULE_TEST_LOCK.lock().await; + let bus = MemoryBus::new(); + let broker = Broker::new(); + let broker_task = broker.spawn(bus.clone()); + let host = ModuleHost::new(broker); + let mut lazy_manifest = manifest(); + lazy_manifest.lazy_init = true; + unsafe { + host.attach_raw( + "clock.so", + TbAbiDescriptor::current("clock", "0.1.0"), + lazy_manifest, + lazy_echo_init, + ) + } + .unwrap(); + 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 sender: Option = proxy.call("Sender", ()).await.unwrap(); + assert_eq!(sender, connection.unique_name()); + broker_task.abort(); +} + +#[tokio::test] +async fn one_wedged_module_does_not_stall_another_modules_traffic() { + let _test_guard = FAKE_MODULE_TEST_LOCK.lock().await; + let bus = MemoryBus::new(); + let broker = Broker::new(); + let broker_task = broker.spawn(bus.clone()); + let host = ModuleHost::new(broker); + for (module_name, surface_name) in [("one", "One"), ("two", "Two")] { + let mut module_manifest = named_manifest(module_name, surface_name); + module_manifest.lazy_init = true; + unsafe { + host.attach_raw( + format!("{module_name}.so"), + TbAbiDescriptor::current(module_name, "0.1.0"), + module_manifest, + lazy_echo_init, + ) + } + .unwrap(); + } + let connection = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let proxy = |surface_name: &str| { + connection + .proxy( + format!("ai.tinyhumans.module.{surface_name}"), + format!("/ai/tinyhumans/module/{surface_name}"), + format!("ai.tinyhumans.module.{surface_name}"), + ) + .unwrap() + }; + let wedged_proxy = proxy("One").with_timeout(Duration::from_millis(30)); + let healthy_proxy = proxy("Two"); + let wedged = wedged_proxy.call::<()>("Hang", ()); + let healthy = healthy_proxy.call::("Echo", ("healthy",)); + let (wedged, healthy) = tokio::join!(wedged, healthy); + assert!(matches!(wedged.unwrap_err(), Error::Timeout { .. })); + assert_eq!(healthy.unwrap(), "healthy"); + broker_task.abort(); +} + +#[test] +fn a_module_compiled_with_panic_abort_is_refused_because_a_panic_would_kill_the_host() { + let broker = Broker::new(); + let host = ModuleHost::new(broker); + let mut descriptor = TbAbiDescriptor::current("clock", "0.1.0"); + descriptor.flags &= !1; + let error = host + .validate(Path::new("clock.so"), &descriptor, &manifest()) + .unwrap_err(); + assert!(error.to_string().contains("panic abort"), "{error}"); +} + +#[test] +fn a_descriptor_with_a_bad_magic_is_refused_without_reading_past_the_prefix() { + let host = ModuleHost::new(Broker::new()); + let mut descriptor = TbAbiDescriptor::current("clock", "0.1.0"); + descriptor.magic = 0; + let error = host + .validate(Path::new("clock.so"), &descriptor, &manifest()) + .unwrap_err(); + assert!(error.to_string().contains("magic"), "{error}"); +} + +#[test] +fn a_module_built_for_an_older_abi_revision_is_refused_before_its_init_runs() { + INIT_RAN.store(false, Ordering::Release); + let host = ModuleHost::new(Broker::new()); + let mut descriptor = TbAbiDescriptor::current("clock", "0.1.0"); + descriptor.abi_revision = 0; + let error = + unsafe { host.attach_raw("clock.so", descriptor, manifest(), init_that_must_not_run) } + .unwrap_err(); + assert!(error.to_string().contains("revision"), "{error}"); + assert!(!INIT_RAN.load(Ordering::Acquire)); +} + +#[test] +fn a_descriptor_smaller_than_the_host_expects_is_refused_and_a_larger_one_is_accepted() { + let host = ModuleHost::new(Broker::new()); + let mut descriptor = TbAbiDescriptor::current("clock", "0.1.0"); + descriptor.descriptor_size = size_of::() as u32 - 1; + assert!( + host.validate(Path::new("clock.so"), &descriptor, &manifest()) + .is_err() + ); + descriptor.descriptor_size = size_of::() as u32 + 64; + assert!( + host.validate(Path::new("clock.so"), &descriptor, &manifest()) + .is_ok() + ); +} + +#[test] +fn a_module_built_for_a_different_target_triple_is_refused() { + let host = ModuleHost::new(Broker::new()); + let mut descriptor = TbAbiDescriptor::current("clock", "0.1.0"); + descriptor.target_triple = [0; 64]; + descriptor.target_triple[..13].copy_from_slice(b"other-unknown"); + let error = host + .validate(Path::new("clock.so"), &descriptor, &manifest()) + .unwrap_err(); + assert!(error.to_string().contains("target triple"), "{error}"); +} + +#[test] +fn a_module_needing_a_feature_the_host_lacks_is_refused_and_names_the_feature() { + let host = ModuleHost::new(Broker::new()); + let mut descriptor = TbAbiDescriptor::current("clock", "0.1.0"); + descriptor.tinybus_feature_bits |= 1 << 63; + let error = host + .validate(Path::new("clock.so"), &descriptor, &manifest()) + .unwrap_err(); + assert!( + error.to_string().contains("unavailable tinybus feature"), + "{error}" + ); +} + +#[test] +fn a_module_built_against_an_incompatible_tinybus_is_refused_and_names_both_versions() { + let host = ModuleHost::new(Broker::new()); + let mut descriptor = TbAbiDescriptor::current("clock", "0.1.0"); + descriptor.tinybus_major = 99; + let error = host + .validate(Path::new("clock.so"), &descriptor, &manifest()) + .unwrap_err(); + let text = error.to_string(); + assert!(text.contains(crate::VERSION), "{text}"); + assert!(text.contains("99.1.0"), "{text}"); +} + +#[test] +fn a_module_that_never_had_its_init_called_is_the_refusal_path() { + INIT_RAN.store(false, Ordering::Release); + let host = ModuleHost::new(Broker::new()); + let mut descriptor = TbAbiDescriptor::current("clock", "0.1.0"); + descriptor.pointer_width = if usize::BITS == 64 { 32 } else { 64 }; + let _ = unsafe { host.attach_raw("clock.so", descriptor, manifest(), init_that_must_not_run) }; + assert!(!INIT_RAN.load(Ordering::Acquire)); +} + +#[test] +fn a_module_built_by_a_different_rustc_is_refused_in_strict_mode_and_only_warned_about_otherwise() { + let broker = Broker::new(); + let permissive = ModuleHost::new(broker.clone()); + let strict = ModuleHost::new(broker).strict(true); + let mut descriptor = TbAbiDescriptor::current("clock", "0.1.0"); + descriptor.rustc_version = [0; 48]; + descriptor.rustc_version[..5].copy_from_slice(b"0.0.0"); + assert!( + permissive + .validate(Path::new("clock.so"), &descriptor, &manifest()) + .is_ok() + ); + assert!( + strict + .validate(Path::new("clock.so"), &descriptor, &manifest()) + .unwrap_err() + .to_string() + .contains("strict mode") + ); +} + +#[test] +fn a_refusal_names_the_file_but_never_the_path_or_the_descriptor_bytes() { + let directory = tempfile::tempdir().unwrap(); + let extension = if cfg!(windows) { + "dll" + } else if cfg!(target_os = "macos") { + "dylib" + } else { + "so" + }; + let path = directory.path().join(format!("broken.{extension}")); + std::fs::write(&path, b"not a dynamic library").unwrap(); + let host = ModuleHost::new(Broker::new()); + let error = host.load_file(&path).unwrap_err(); + assert!( + !error + .to_string() + .contains(&directory.path().display().to_string()) + ); + let listed = host.list(); + assert_eq!(listed.len(), 1); + assert!(matches!( + listed[0].state, + ModuleState::Rejected { ref reason } if !reason.is_empty() + )); +} + +#[cfg(unix)] +#[test] +fn a_world_writable_module_directory_is_refused_before_any_dlopen() { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + + let directory = tempfile::tempdir().unwrap(); + std::fs::set_permissions(directory.path(), std::fs::Permissions::from_mode(0o777)).unwrap(); + let metadata = std::fs::metadata(directory.path()).unwrap(); + assert_eq!( + unix_directory_refusal( + metadata.uid(), + metadata.permissions().mode(), + metadata.uid() + ), + Some("module directory is writable by another user") + ); +} + +#[cfg(unix)] +#[test] +fn a_module_directory_owned_by_another_user_is_refused() { + assert_eq!( + unix_directory_refusal(1_001, 0o755, 1_000), + Some("module directory is owned by another user") + ); + assert_eq!(unix_directory_refusal(0, 0o755, 1_000), None); +} + +#[test] +fn a_file_that_is_not_a_regular_file_is_skipped() { + let directory = tempfile::tempdir().unwrap(); + let error = check_file(directory.path()).unwrap_err(); + assert!(error.to_string().contains("not a regular file")); +} + +#[test] +fn an_allowlist_with_a_mismatched_hash_refuses_the_file() { + 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 path = directory.path().join(format!("clock.{extension}")); + std::fs::write(&path, b"not a module").unwrap(); + std::fs::write( + directory.path().join("modules.toml"), + format!("clock.{extension} = \"{}\"\n", "0".repeat(64)), + ) + .unwrap(); + let error = check_file(&path).unwrap_err(); + assert!(error.to_string().contains("hash does not match"), "{error}"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires TINYBUS_TEST_MODULE to point at the built cdylib"] +async fn a_real_cdylib_loads_and_serves_a_call() { + let path = std::env::var_os("TINYBUS_TEST_MODULE").expect("TINYBUS_TEST_MODULE"); + let bus = MemoryBus::new(); + let broker = Broker::new(); + let task = broker.spawn(bus.clone()); + let modules = ModuleHost::new(broker); + modules + .load_file_with_config(path, serde_json::json!({ "prefix": "configured:" })) + .unwrap(); + + let client = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(2), async { + loop { + if client + .list_names() + .await + .unwrap() + .iter() + .any(|name| name.as_str() == "ai.tinyhumans.openhuman.Clock") + { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + let clock = client + .proxy( + "ai.tinyhumans.openhuman.Clock", + "/ai/tinyhumans/openhuman/Clock", + "ai.tinyhumans.openhuman.Clock", + ) + .unwrap(); + let value: String = clock.call("Now", ()).await.unwrap(); + assert!(value.starts_with("configured:"), "{value}"); + let control = client + .proxy(crate::BUS_NAME, crate::BUS_PATH, crate::BUS_INTERFACE) + .unwrap(); + let listed: Vec = control.call("ListModules", ()).await.unwrap(); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].name, "tinybus"); + let mut state_changes = client + .add_match( + crate::router::MatchRule::parse( + "type=signal,interface=ai.tinyhumans.tinybus.Bus,member=ModuleStateChanged", + ) + .unwrap(), + ) + .await + .unwrap(); + let mut name_changes = client + .add_match( + crate::router::MatchRule::parse( + "type=signal,interface=ai.tinyhumans.tinybus.Bus,member=NameOwnerChanged", + ) + .unwrap(), + ) + .await + .unwrap(); + + let panic_error = clock.call::<()>("Panic", ()).await.unwrap_err(); + let panic_text = panic_error.to_string(); + assert!(panic_text.contains("ModulePanicked"), "{panic_text}"); + assert!(panic_text.contains("module_clock.rs"), "{panic_text}"); + assert!(!panic_text.contains("secret-token"), "{panic_text}"); + let name_change = tokio::time::timeout(Duration::from_secs(2), name_changes.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!(name_change.body[0], "ai.tinyhumans.openhuman.Clock"); + assert!(name_change.body[2].is_null()); + + let state_change = tokio::time::timeout(Duration::from_secs(2), async { + loop { + let message = state_changes.recv().await.unwrap(); + if message.header.member.as_ref().map(|member| member.as_str()) + == Some("ModuleStateChanged") + && message.body.get(2).and_then(serde_json::Value::as_str) == Some("faulted") + { + break message; + } + } + }) + .await + .unwrap(); + assert_eq!(state_change.body[0], "tinybus"); + assert_eq!(state_change.body[2], "faulted"); + tokio::time::timeout(Duration::from_secs(2), async { + loop { + if !client + .list_names() + .await + .unwrap() + .iter() + .any(|name| name.as_str() == "ai.tinyhumans.openhuman.Clock") + { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + task.abort(); +} + +#[test] +#[ignore = "requires TINYBUS_TEST_MODULE and TINYBUS_TEST_MODULE_TWO"] +fn two_modules_exporting_the_same_symbol_name_each_resolve_to_their_own() { + let first = + PathBuf::from(std::env::var_os("TINYBUS_TEST_MODULE").expect("TINYBUS_TEST_MODULE")); + let second = PathBuf::from( + std::env::var_os("TINYBUS_TEST_MODULE_TWO").expect("TINYBUS_TEST_MODULE_TWO"), + ); + let first = loader::load(&first, false).unwrap(); + let second = loader::load(&second, false).unwrap(); + assert_eq!(first.manifest.module.name, "tinybus"); + assert_eq!(second.manifest.module.name, "module-clock-two"); + assert_ne!(first.descriptor.module_name, second.descriptor.module_name); +} + +#[test] +#[ignore = "requires TINYBUS_TEST_WRONG_TARGET"] +fn a_cdylib_built_for_a_different_target_is_refused_at_the_gate() { + let path = PathBuf::from( + std::env::var_os("TINYBUS_TEST_WRONG_TARGET").expect("TINYBUS_TEST_WRONG_TARGET"), + ); + let host = ModuleHost::new(Broker::new()); + let error = host.load_file(path).unwrap_err(); + assert!(error.to_string().contains("target triple"), "{error}"); +} + +#[tokio::test] +#[ignore = "requires TINYBUS_TEST_MODULE and TINYBUS_TEST_WRONG_TARGET"] +async fn one_refused_module_does_not_stop_the_others_in_the_directory_from_loading() { + let valid = + PathBuf::from(std::env::var_os("TINYBUS_TEST_MODULE").expect("TINYBUS_TEST_MODULE")); + let invalid = PathBuf::from( + std::env::var_os("TINYBUS_TEST_WRONG_TARGET").expect("TINYBUS_TEST_WRONG_TARGET"), + ); + let prepared_directory = std::env::var_os("TINYBUS_TEST_MODULE_DIRECTORY").map(PathBuf::from); + let temporary_directory = prepared_directory.is_none().then(|| { + tempfile::tempdir_in(valid.parent().expect("module artifact has a parent")).unwrap() + }); + let directory = prepared_directory.as_deref().unwrap_or_else(|| { + let directory = temporary_directory.as_ref().unwrap().path(); + std::fs::copy(&valid, directory.join(valid.file_name().unwrap())).unwrap(); + std::fs::copy(&invalid, directory.join(invalid.file_name().unwrap())).unwrap(); + directory + }); + + let host = ModuleHost::new(Broker::new()); + let outcomes = host.load_dir(directory).unwrap(); + assert_eq!(outcomes.len(), 2); + assert_eq!( + outcomes.iter().filter(|outcome| outcome.is_ok()).count(), + 1, + "{outcomes:?}" + ); + assert_eq!( + outcomes.iter().filter(|outcome| outcome.is_err()).count(), + 1 + ); +} diff --git a/crates/tinybus/src/module/loader.rs b/crates/tinybus/src/module/loader.rs new file mode 100644 index 00000000..ada4a323 --- /dev/null +++ b/crates/tinybus/src/module/loader.rs @@ -0,0 +1,252 @@ +//! Platform loader: resolve the three v1 symbols and deliberately never unload. + +use std::path::Path; + +use crate::error::{Error, Result}; +use crate::module::abi::{ + ABI_MAGIC, ABI_REVISION, DESCRIPTOR_PREFIX_SIZE, MAX_DESCRIPTOR_SIZE, TbAbiDescriptor, + TbModuleInit, TbSlice, field_bytes, +}; +use crate::module::manifest::ModuleManifest; +use crate::{build_info, version::Version}; + +type ManifestFn = unsafe extern "C" fn() -> TbSlice; + +#[derive(Clone)] +pub(crate) struct LoadedArtifact { + pub(crate) descriptor: TbAbiDescriptor, + pub(crate) manifest: ModuleManifest, + pub(crate) init: TbModuleInit, +} + +#[repr(C)] +#[derive(Clone, Copy)] +struct DescriptorPrefix { + magic: u64, + abi_revision: u32, + descriptor_size: u32, +} + +pub(crate) fn load(path: &Path, strict: bool) -> Result { + let handle = platform::open(path)?; + let descriptor_ptr = platform::symbol(handle, b"TINYBUS_MODULE_ABI_V1\0", path)?; + + let prefix = unsafe { descriptor_ptr.cast::().read_unaligned() }; + if prefix.magic != ABI_MAGIC { + return Err(Error::module_refused(path, "ABI magic does not match")); + } + if prefix.abi_revision != ABI_REVISION { + return Err(Error::module_refused(path, "ABI revision does not match")); + } + if !(DESCRIPTOR_PREFIX_SIZE..=MAX_DESCRIPTOR_SIZE).contains(&prefix.descriptor_size) { + return Err(Error::module_refused(path, "descriptor size is invalid")); + } + if prefix.descriptor_size < size_of::() as u32 { + return Err(Error::module_refused(path, "descriptor is too small")); + } + let descriptor = unsafe { descriptor_ptr.cast::().read_unaligned() }; + gate_descriptor(path, &descriptor, strict)?; + + let manifest_fn: ManifestFn = unsafe { + std::mem::transmute(platform::symbol( + handle, + b"tinybus_module_manifest_v1\0", + path, + )?) + }; + let slice = unsafe { manifest_fn() }; + if slice.ptr.is_null() || slice.len > 1024 * 1024 { + return Err(Error::module_refused(path, "manifest bytes are invalid")); + } + let bytes = unsafe { std::slice::from_raw_parts(slice.ptr, slice.len) }; + let manifest = serde_json::from_slice(bytes) + .map_err(|_| Error::module_refused(path, "manifest is not valid JSON"))?; + + let init: TbModuleInit = unsafe { + std::mem::transmute(platform::symbol(handle, b"tinybus_module_init_v1\0", path)?) + }; + Ok(LoadedArtifact { + descriptor, + manifest, + init, + }) +} + +pub(crate) fn gate_descriptor( + path: &Path, + descriptor: &TbAbiDescriptor, + strict: bool, +) -> Result<()> { + let refuse = |reason| Error::module_refused(path, reason); + if descriptor.magic != ABI_MAGIC { + return Err(refuse("ABI magic does not match")); + } + if descriptor.abi_revision != ABI_REVISION { + return Err(refuse("ABI revision does not match")); + } + if !(DESCRIPTOR_PREFIX_SIZE..=MAX_DESCRIPTOR_SIZE).contains(&descriptor.descriptor_size) { + return Err(refuse("descriptor size is invalid")); + } + if descriptor.descriptor_size < size_of::() as u32 { + return Err(refuse("descriptor is too small")); + } + if descriptor.pointer_width != usize::BITS { + return Err(refuse("pointer width does not match")); + } + if (descriptor.flags & (1 << 2) != 0) != cfg!(target_endian = "little") { + return Err(refuse("target endianness does not match")); + } + if field_bytes(&descriptor.target_triple) != build_info::TARGET.as_bytes() { + return Err(refuse("target triple does not match")); + } + if descriptor.flags & 1 == 0 { + return Err(refuse("module was built with panic abort")); + } + let host_version = Version::parse(crate::VERSION).expect("crate version is semver"); + let module_version = Version::new( + descriptor.tinybus_major.into(), + descriptor.tinybus_minor.into(), + descriptor.tinybus_patch.into(), + ); + if !host_version.compatible_series().accepts(&module_version) { + return Err(Error::module_refused( + path, + format!( + "tinybus version is incompatible: host {host_version}, module {module_version}" + ), + )); + } + let missing_features = descriptor.tinybus_feature_bits & !build_info::FEATURE_BITS; + if missing_features != 0 { + let bit = 1u64 << missing_features.trailing_zeros(); + return Err(Error::module_refused( + path, + format!( + "module requires unavailable tinybus feature {}", + build_info::feature_name(bit) + ), + )); + } + if strict && field_bytes(&descriptor.rustc_version) != build_info::RUSTC_VERSION.as_bytes() { + return Err(refuse("rustc version does not match in strict mode")); + } + Ok(()) +} + +#[cfg(unix)] +mod platform { + use std::ffi::{CString, c_char, c_int, c_void}; + use std::os::unix::ffi::OsStrExt; + use std::path::Path; + + use crate::error::{Error, Result}; + + #[cfg_attr(all(target_os = "linux", target_env = "gnu"), link(name = "dl"))] + unsafe extern "C" { + fn dlopen(filename: *const c_char, flags: c_int) -> *mut c_void; + fn dlsym(handle: *mut c_void, symbol: *const c_char) -> *mut c_void; + fn dlerror() -> *mut c_char; + } + + const RTLD_NOW: c_int = 2; + #[cfg(target_os = "macos")] + const RTLD_LOCAL: c_int = 4; + #[cfg(not(target_os = "macos"))] + const RTLD_LOCAL: c_int = 0; + + pub(super) type Handle = *mut c_void; + + pub(super) fn open(path: &Path) -> Result { + let path_bytes = CString::new(path.as_os_str().as_bytes()) + .map_err(|_| Error::module_refused(path, "artifact path is invalid"))?; + let handle = unsafe { dlopen(path_bytes.as_ptr(), RTLD_NOW | RTLD_LOCAL) }; + if handle.is_null() { + log_last_error(); + return Err(Error::module_refused( + path, + "dynamic loader rejected the artifact", + )); + } + // No Drop wrapper on purpose. Calling dlclose would invalidate code, + // TLS, panic metadata, and callbacks that may still be reachable. + Ok(handle) + } + + pub(super) fn symbol(handle: Handle, name: &[u8], path: &Path) -> Result<*mut c_void> { + let pointer = unsafe { dlsym(handle, name.as_ptr().cast()) }; + if pointer.is_null() { + log_last_error(); + return Err(Error::module_refused( + path, + "required ABI symbol is missing", + )); + } + Ok(pointer) + } + + fn log_last_error() { + let pointer = unsafe { dlerror() }; + if !pointer.is_null() { + let message = unsafe { std::ffi::CStr::from_ptr(pointer) }.to_string_lossy(); + tracing::debug!(loader_error = %message, "module loader detail"); + } + } +} + +#[cfg(windows)] +mod platform { + use std::ffi::{c_char, c_void}; + use std::os::windows::ffi::OsStrExt; + use std::path::Path; + + use crate::error::{Error, Result}; + + #[link(name = "kernel32")] + unsafe extern "system" { + fn LoadLibraryExW(path: *const u16, file: *mut c_void, flags: u32) -> *mut c_void; + fn GetProcAddress(handle: *mut c_void, name: *const c_char) -> *mut c_void; + fn GetLastError() -> u32; + } + + const LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR: u32 = 0x0000_0100; + pub(super) type Handle = *mut c_void; + + pub(super) fn open(path: &Path) -> Result { + let canonical = std::fs::canonicalize(path) + .map_err(|_| Error::module_refused(path, "module artifact path is invalid"))?; + let wide: Vec = canonical.as_os_str().encode_wide().chain([0]).collect(); + let handle = unsafe { + LoadLibraryExW( + wide.as_ptr(), + std::ptr::null_mut(), + LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR, + ) + }; + if handle.is_null() { + tracing::debug!( + loader_error = unsafe { GetLastError() }, + "module loader detail" + ); + return Err(Error::module_refused( + path, + "dynamic loader rejected the artifact", + )); + } + Ok(handle) + } + + pub(super) fn symbol(handle: Handle, name: &[u8], path: &Path) -> Result<*mut c_void> { + let pointer = unsafe { GetProcAddress(handle, name.as_ptr().cast()) }; + if pointer.is_null() { + tracing::debug!( + loader_error = unsafe { GetLastError() }, + "module loader detail" + ); + return Err(Error::module_refused( + path, + "required ABI symbol is missing", + )); + } + Ok(pointer) + } +} diff --git a/crates/tinybus/src/module/manifest.rs b/crates/tinybus/src/module/manifest.rs new file mode 100644 index 00000000..7b69f82d --- /dev/null +++ b/crates/tinybus/src/module/manifest.rs @@ -0,0 +1,154 @@ +//! Declarative module identity, bus surface, dependencies, and trust metadata. + +use serde::{Deserialize, Serialize}; + +use crate::name::{BusName, InterfaceName, MemberName, ObjectPath}; +use crate::version::{InterfaceVersion, PeerManifest, Version}; + +/// Current JSON manifest schema. +pub const MANIFEST_SCHEMA: u32 = 1; + +/// Package identity shown to operators and used for diagnostics. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ModuleIdentity { + /// Stable package name. + pub name: String, + /// Package release. + pub version: Version, + /// Human-readable purpose. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub description: String, + /// Project homepage. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub homepage: Option, + /// SPDX license expression or package license string. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub license: String, +} + +/// One provided interface plus its introspectable surface. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProvidedInterface { + /// Interface version declaration shared with peer negotiation. + #[serde(flatten)] + pub version: InterfaceVersion, + /// Methods dispatched by the served object. + #[serde(default)] + pub methods: Vec, + /// Signals the module may emit. + #[serde(default)] + pub signals: Vec, +} + +/// One interface dependency used for admission and load ordering. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Dependency { + /// Required interface and compatible version range. + pub interface: InterfaceVersion, + /// Missing optional dependencies do not prevent resolution. + #[serde(default)] + pub optional: bool, + /// Operator-facing explanation when this dependency is absent. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub reason: String, +} + +/// Environment input declared by a module. Values never appear here. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EnvSpec { + /// Environment variable name. + pub name: String, + /// Whether setup cannot proceed without it. + #[serde(default)] + pub required: bool, + /// Human-readable purpose, never its value. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub description: String, +} + +/// A declared privilege or sensitive operation for operator inspection. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Capability { + /// Stable capability name. + pub name: String, + /// Human-readable scope. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub description: String, +} + +/// What to do after a module method panics. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PanicPolicy { + /// Reply with a redacted panic error, then detach the module. + #[default] + Detach, + /// Reply with the redacted panic error and keep serving. + Reply, +} + +/// A module's declared bus surface and load behavior. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ModuleManifest { + /// [`MANIFEST_SCHEMA`]. + #[serde(rename = "tinybus_manifest")] + pub schema: u32, + /// Package identity. + pub module: ModuleIdentity, + /// Single well-known name this module claims. + pub bus_name: BusName, + /// Object path hosting its interfaces. + pub object_path: ObjectPath, + /// Interfaces implemented by the module. + #[serde(default)] + pub provides: Vec, + /// Interface dependencies, optional or required. + #[serde(default)] + pub requires: Vec, + /// Declared environment inputs. + #[serde(default)] + pub environment: Vec, + /// Declared privileges for operator inspection. + #[serde(default)] + pub capabilities: Vec, + /// Defer setup until the first method call. + #[serde(default)] + pub lazy_init: bool, + /// Tokio worker threads owned by this module. + #[serde(default = "default_worker_threads")] + pub worker_threads: u32, + /// Panic behavior; detach is the safe default. + #[serde(default)] + pub on_panic: PanicPolicy, +} + +impl ModuleManifest { + /// Derive the runtime peer declaration from this single source of truth. + pub fn peer_manifest(&self) -> PeerManifest { + PeerManifest { + name: self.module.name.clone(), + version: Some(self.module.version.clone()), + provides: self + .provides + .iter() + .map(|provided| provided.version.clone()) + .collect(), + consumes: self + .requires + .iter() + .map(|dependency| dependency.interface.clone()) + .collect(), + } + } + + /// Find the declaration for `interface`. + pub fn provided(&self, interface: &InterfaceName) -> Option<&ProvidedInterface> { + self.provides + .iter() + .find(|provided| &provided.version.interface == interface) + } +} + +const fn default_worker_threads() -> u32 { + 1 +} diff --git a/crates/tinybus/src/module/mod.rs b/crates/tinybus/src/module/mod.rs new file mode 100644 index 00000000..a5ece551 --- /dev/null +++ b/crates/tinybus/src/module/mod.rs @@ -0,0 +1,22 @@ +//! ABI shared by module authors and hosts, plus the optional host loader. +//! +//! The ABI and manifest types are always compiled so a module can depend on +//! tinybus with no default features. Loading is behind `modules`: in-process +//! modules are trusted code with the host's full address-space privileges. + +pub mod abi; +#[cfg(feature = "modules")] +mod hash; +pub mod manifest; + +#[cfg(feature = "modules")] +pub(crate) mod host; +#[cfg(feature = "modules")] +mod loader; +#[cfg(feature = "modules")] +mod resolve; +#[cfg(feature = "modules")] +mod transport; + +#[cfg(feature = "modules")] +pub use host::{ModuleHost, ModuleInfo, ModuleState}; diff --git a/crates/tinybus/src/module/resolve.rs b/crates/tinybus/src/module/resolve.rs new file mode 100644 index 00000000..c7d1f47f --- /dev/null +++ b/crates/tinybus/src/module/resolve.rs @@ -0,0 +1,254 @@ +//! Pure manifest dependency resolution and deterministic load ordering. + +use std::collections::{HashMap, HashSet}; + +use crate::module::manifest::ModuleManifest; + +/// Result indexes refer to the input manifest slice. +pub(crate) struct Resolution { + pub(crate) order: Vec, + pub(crate) unresolved: Vec<(usize, String)>, +} + +/// Reject collisions, ignore absent optional dependencies, and topologically +/// order every remaining manifest. +pub(crate) fn resolve( + manifests: &[ModuleManifest], + initially_available: &HashSet, +) -> Resolution { + let duplicate_modules = duplicates(manifests.iter().map(|manifest| &manifest.module.name)); + let duplicate_bus_names = duplicates(manifests.iter().map(|manifest| &manifest.bus_name)); + let mut unresolved = Vec::new(); + let mut pending = Vec::new(); + for (index, manifest) in manifests.iter().enumerate() { + if duplicate_modules.contains(&manifest.module.name) { + unresolved.push(( + index, + "two artifacts declare the same module name".to_string(), + )); + } else if duplicate_bus_names.contains(&manifest.bus_name) { + unresolved.push((index, "two modules claim the same bus name".to_string())); + } else { + pending.push(index); + } + } + + let mut available = initially_available.clone(); + let mut order = Vec::new(); + while !pending.is_empty() { + let ready = pending.iter().position(|index| { + manifests[*index] + .requires + .iter() + .filter(|dependency| !dependency.optional) + .all(|dependency| available.contains(dependency.interface.interface.as_str())) + }); + if let Some(position) = ready { + let index = pending.remove(position); + available.extend( + manifests[index] + .provides + .iter() + .map(|provided| provided.version.interface.to_string()), + ); + order.push(index); + continue; + } + + // Remove genuinely blocked providers first. Their interfaces then stop + // masking missing dependencies in consumers behind them; only a fixed + // point with no absent provider is a real cycle. + let declared = pending + .iter() + .flat_map(|index| manifests[*index].provides.iter()) + .map(|provided| provided.version.interface.to_string()) + .collect::>(); + let blocked = pending.iter().position(|index| { + manifests[*index] + .requires + .iter() + .filter(|dependency| !dependency.optional) + .any(|dependency| { + !available.contains(dependency.interface.interface.as_str()) + && !declared.contains(dependency.interface.interface.as_str()) + }) + }); + if let Some(position) = blocked { + let index = pending.remove(position); + let missing = manifests[index] + .requires + .iter() + .filter(|dependency| !dependency.optional) + .find(|dependency| { + !available.contains(dependency.interface.interface.as_str()) + && !declared.contains(dependency.interface.interface.as_str()) + }) + .expect("blocked module has a missing dependency"); + unresolved.push(( + index, + format!( + "required interface {} has no provider", + missing.interface.interface + ), + )); + continue; + } + for index in pending.drain(..) { + unresolved.push((index, "module dependency cycle detected".to_string())); + } + } + Resolution { order, unresolved } +} + +fn duplicates(values: impl Iterator) -> HashSet { + let mut counts = HashMap::new(); + for value in values { + *counts.entry(value).or_insert(0usize) += 1; + } + counts + .into_iter() + .filter_map(|(value, count)| (count > 1).then_some(value)) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::module::manifest::{ + Dependency, MANIFEST_SCHEMA, ModuleIdentity, PanicPolicy, ProvidedInterface, + }; + use crate::{BusName, InterfaceName, InterfaceVersion, ObjectPath, Version}; + + fn module(name: &str, provides: &[&str], requires: &[(&str, bool)]) -> ModuleManifest { + let version = Version::new(1, 0, 0); + ModuleManifest { + schema: MANIFEST_SCHEMA, + module: ModuleIdentity { + name: name.to_string(), + version: version.clone(), + description: String::new(), + homepage: None, + license: String::new(), + }, + bus_name: BusName::new(format!("ai.tinyhumans.module.{name}")).unwrap(), + object_path: ObjectPath::new(format!("/ai/tinyhumans/module/{name}")).unwrap(), + provides: provides + .iter() + .map(|name| ProvidedInterface { + version: InterfaceVersion::provided( + InterfaceName::new(*name).unwrap(), + version.clone(), + ), + methods: Vec::new(), + signals: Vec::new(), + }) + .collect(), + requires: requires + .iter() + .map(|(name, optional)| Dependency { + interface: InterfaceVersion::consumed( + InterfaceName::new(*name).unwrap(), + version.clone(), + ), + optional: *optional, + reason: String::new(), + }) + .collect(), + environment: Vec::new(), + capabilities: Vec::new(), + lazy_init: false, + worker_threads: 1, + on_panic: PanicPolicy::Detach, + } + } + + #[test] + fn a_required_dependency_no_module_provides_leaves_the_module_unresolved_and_names_the_interface() + { + let manifests = [module( + "Consumer", + &[], + &[("ai.tinyhumans.module.Missing", false)], + )]; + let result = resolve(&manifests, &HashSet::new()); + assert!(result.order.is_empty()); + assert!( + result.unresolved[0] + .1 + .contains("ai.tinyhumans.module.Missing") + ); + } + + #[test] + fn an_optional_dependency_that_is_missing_still_resolves() { + let manifests = [module( + "Consumer", + &[], + &[("ai.tinyhumans.module.Missing", true)], + )]; + assert_eq!(resolve(&manifests, &HashSet::new()).order, [0]); + } + + #[test] + fn a_dependency_cycle_is_reported_rather_than_loaded() { + let manifests = [ + module( + "One", + &["ai.tinyhumans.module.One"], + &[("ai.tinyhumans.module.Two", false)], + ), + module( + "Two", + &["ai.tinyhumans.module.Two"], + &[("ai.tinyhumans.module.One", false)], + ), + ]; + let result = resolve(&manifests, &HashSet::new()); + assert!(result.order.is_empty()); + assert!( + result + .unresolved + .iter() + .all(|(_, reason)| reason == "module dependency cycle detected") + ); + } + + #[test] + fn a_module_blocked_behind_an_unresolved_provider_is_not_reported_as_a_cycle() { + let manifests = [ + module("Consumer", &[], &[("ai.tinyhumans.module.Provider", false)]), + module( + "Provider", + &["ai.tinyhumans.module.Provider"], + &[("ai.tinyhumans.module.Missing", false)], + ), + ]; + let result = resolve(&manifests, &HashSet::new()); + assert!(result.order.is_empty()); + assert!( + result + .unresolved + .iter() + .all(|(_, reason)| reason.contains("has no provider")) + ); + } + + #[test] + fn a_module_is_initialized_after_every_module_it_depends_on() { + let manifests = [ + module("Consumer", &[], &[("ai.tinyhumans.module.Provider", false)]), + module("Provider", &["ai.tinyhumans.module.Provider"], &[]), + ]; + assert_eq!(resolve(&manifests, &HashSet::new()).order, [1, 0]); + } + + #[test] + fn two_modules_claiming_one_bus_name_are_both_reported_and_neither_is_initialized() { + let first = module("One", &[], &[]); + let mut second = module("Two", &[], &[]); + second.bus_name = first.bus_name.clone(); + let result = resolve(&[first, second], &HashSet::new()); + assert!(result.order.is_empty()); + assert_eq!(result.unresolved.len(), 2); + } +} diff --git a/crates/tinybus/src/module/transport.rs b/crates/tinybus/src/module/transport.rs new file mode 100644 index 00000000..a68efb1e --- /dev/null +++ b/crates/tinybus/src/module/transport.rs @@ -0,0 +1,577 @@ +//! Host-side transport bridge over the module C vtables. + +use std::collections::VecDeque; +use std::ffi::c_void; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex as StdMutex, Weak}; +use std::time::Duration; + +use async_trait::async_trait; +use tokio::sync::{Mutex, Notify, OnceCell, mpsc}; + +use crate::error::{Error, Result}; +use crate::message::codec::MAX_FRAME_LEN; +use crate::message::{Message, MessageKind}; +use crate::module::abi::{ + TB_BACKPRESSURE, TB_BAD_ARGUMENT, TB_CLOSED, TB_OK, TbHostVtable, TbModuleVtable, +}; +use crate::ports::Transport; + +const HOST_QUEUE_CAPACITY: usize = 256; +const MODULE_INIT_DEADLINE: Duration = Duration::from_secs(5); + +/// How long the host waits for a module to drain its queue before declaring it +/// faulted. Bounded so a module that stops calling `wake` cannot park the +/// delivery task for the process lifetime; the security boundary applies to +/// in-process modules too ("every call has a deadline"). +const BACKPRESSURE_DEADLINE: Duration = Duration::from_secs(5); + +struct HostContext { + inbound: StdMutex>>>, + wake: Arc, + config: StdMutex>, + faulted: AtomicBool, + init_failed: AtomicBool, + init_started: AtomicBool, + init_notify: Notify, + ready: AtomicBool, + ready_notify: Notify, + inflight: AtomicUsize, +} + +/// The broker-facing side of one loaded module. +pub(crate) struct ModuleTransport { + self_ref: Weak, + module: StdMutex>, + inbound: Mutex>>, + context: &'static HostContext, + label: String, + initializer: StdMutex>, + init_result: OnceCell>, + pending: Mutex>, + drain_started: AtomicBool, +} + +// `module_ctx` is opaque and all access to it goes through callbacks whose ABI +// contract requires thread safety. The vtable itself is immutable after init. +unsafe impl Send for ModuleTransport {} +unsafe impl Sync for ModuleTransport {} + +struct SendHostVtable(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 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 ModuleTransport { + pub(crate) fn new(label: String, config: Vec) -> (Arc, TbHostVtable) { + let (inbound_tx, inbound_rx) = mpsc::channel(HOST_QUEUE_CAPACITY); + let context = Box::leak(Box::new(HostContext { + inbound: StdMutex::new(Some(inbound_tx)), + wake: Arc::new(Notify::new()), + config: StdMutex::new(config), + faulted: AtomicBool::new(false), + init_failed: AtomicBool::new(false), + init_started: AtomicBool::new(false), + init_notify: Notify::new(), + ready: AtomicBool::new(false), + ready_notify: Notify::new(), + inflight: AtomicUsize::new(0), + })); + let transport = Arc::new_cyclic(|self_ref| Self { + self_ref: self_ref.clone(), + module: StdMutex::new(None), + inbound: Mutex::new(inbound_rx), + context, + label, + initializer: StdMutex::new(None), + init_result: OnceCell::new(), + pending: Mutex::new(VecDeque::new()), + drain_started: AtomicBool::new(false), + }); + let config = context.config.lock().expect("module config lock"); + let config_slice = crate::module::abi::TbSlice { + ptr: config.as_ptr(), + len: config.len(), + }; + drop(config); + let vtable = TbHostVtable { + size: size_of::() as u32, + _reserved: 0, + host_ctx: std::ptr::from_ref(context).cast_mut().cast(), + send: host_send, + wake: host_wake, + log: host_log, + fault: host_fault, + config: config_slice, + ready: host_ready, + }; + (transport, vtable) + } + + pub(crate) fn initialize(&self, module: TbModuleVtable) -> Result<()> { + if module.size < size_of::() as u32 || module.module_ctx.is_null() { + return Err(Error::transport("module returned an incomplete vtable")); + } + *self.module.lock().expect("module vtable lock") = Some(module); + Ok(()) + } + + pub(crate) fn defer_initialize( + &self, + init: crate::module::abi::TbModuleInit, + host: TbHostVtable, + ) { + *self.initializer.lock().expect("module initializer lock") = Some((init, host)); + } + + async fn ensure_initialized(&self) -> Result<()> { + let result = self + .init_result + .get_or_init(|| async { + self.context.init_started.store(true, Ordering::Release); + self.context.init_notify.notify_waiters(); + let Some((init, host)) = self + .initializer + .lock() + .expect("module initializer lock") + .take() + else { + return if self.module.lock().expect("module vtable lock").is_some() { + Ok(()) + } else { + Err("module has no initializer".to_string()) + }; + }; + // Module init is opaque synchronous code. Keep it off Tokio's + // 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)), + ) + .await; + let (code, module) = match initialized { + Ok(Ok(initialized)) => initialized, + 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(()) + }) + .await; + result.clone().map_err(Error::transport) + } + + pub(crate) async fn wait_ready(&self) { + loop { + let notified = self.context.ready_notify.notified(); + if self.context.ready.load(Ordering::Acquire) + || self.context.faulted.load(Ordering::Acquire) + { + return; + } + notified.await; + } + } + + pub(crate) fn is_ready(&self) -> bool { + self.context.ready.load(Ordering::Acquire) + } + + pub(crate) fn clear_config(&self) { + let mut config = self.context.config.lock().expect("module config lock"); + config.fill(0); + config.clear(); + config.shrink_to_fit(); + } + + pub(crate) fn is_faulted(&self) -> bool { + self.context.faulted.load(Ordering::Acquire) + } + + pub(crate) fn init_failed(&self) -> bool { + self.context.init_failed.load(Ordering::Acquire) + } + + pub(crate) fn init_started(&self) -> bool { + self.context.init_started.load(Ordering::Acquire) + } + + pub(crate) fn inflight(&self) -> usize { + self.context.inflight.load(Ordering::Acquire) + } + + pub(crate) async fn wait_initializing(&self) { + loop { + let notified = self.context.init_notify.notified(); + if self.init_started() { + return; + } + notified.await; + } + } + + async fn deliver_now(&self, message: Message) -> Result<()> { + if self.context.faulted.load(Ordering::Acquire) { + return Err(Error::ConnectionClosed); + } + let bytes = serde_json::to_vec(&message)?; + if bytes.len() > MAX_FRAME_LEN { + return Err(Error::protocol("module frame exceeds the size cap")); + } + + loop { + let notified = self.context.wake.notified(); + let module = *self.module.lock().expect("module vtable lock"); + let Some(module) = module else { + return Err(Error::ConnectionClosed); + }; + let code = unsafe { (module.deliver)(module.module_ctx, bytes.as_ptr(), bytes.len()) }; + match code { + TB_OK => return Ok(()), + TB_BACKPRESSURE => { + // A module that stops draining its queue and never calls + // `wake` must not park this delivery task — or the caller + // behind it — for the process lifetime. Expiry faults the + // module downstream so later callers get `ModuleUnavailable` + // instead of waiting out their own deadline. + if tokio::time::timeout(BACKPRESSURE_DEADLINE, notified) + .await + .is_err() + { + self.context.faulted.store(true, Ordering::Release); + self.context.ready_notify.notify_waiters(); + self.context + .inbound + .lock() + .expect("host inbound lock") + .take(); + return Err(Error::transport( + "module stopped draining its queue within the deadline", + )); + } + } + TB_CLOSED => return Err(Error::ConnectionClosed), + TB_BAD_ARGUMENT => return Err(Error::protocol("module refused a valid frame")), + _ => return Err(Error::transport("module delivery callback failed")), + } + } + } + + async fn drain_pending(self: Arc) { + self.wait_ready().await; + loop { + while !self.is_faulted() { + let message = self.pending.lock().await.pop_front(); + let Some(message) = message else { + break; + }; + if self.deliver_now(message.clone()).await.is_err() { + self.fault_pending(message).await; + return; + } + } + if self.is_faulted() { + self.drain_started.store(false, Ordering::Release); + return; + } + // A `send` that pushed while the loop observed an empty queue saw + // `drain_started == true` and skipped spawning its own drainer, so + // it is this loop's job to pick the message up. Clear the flag only + // while holding the queue lock, and return only once the queue is + // genuinely empty; the lock makes the check-and-clear atomic with a + // concurrent push. + let restart = { + let pending = self.pending.lock().await; + let empty = pending.is_empty(); + if empty { + self.drain_started.store(false, Ordering::Release); + } + !empty + }; + if !restart { + return; + } + // Fall back into the drain loop for the newly queued messages. + } + } + + /// Answer every still-queued call — and the one that just failed to + /// deliver — with `ModuleUnavailable`, then fault the module, so callers + /// learn the delivery failed instead of waiting out their deadline. + async fn fault_pending(&self, failed: Message) { + let cleared = { + let mut pending = self.pending.lock().await; + std::mem::take(&mut *pending) + }; + // Each of these calls incremented `inflight` when it was queued. The + // synthesized error replies below travel back through `recv`, which + // refunds one slot per reply, so no manual adjustment is needed. + let sender = self + .context + .inbound + .lock() + .expect("host inbound lock") + .clone(); + if let Some(sender) = sender { + for queued in cleared.iter().chain([&failed]) { + if queued.header.kind == MessageKind::MethodCall { + let error = Error::ModuleUnavailable { + module: self.label.clone(), + state: "faulted".to_string(), + detail: "module stopped accepting calls".to_string(), + }; + let reply = Message::error_reply(&queued.header, &error); + if let Ok(bytes) = serde_json::to_vec(&reply) { + let _ = sender.try_send(bytes); + } + } + } + } + self.context.faulted.store(true, Ordering::Release); + self.context.ready_notify.notify_waiters(); + self.context + .inbound + .lock() + .expect("host inbound lock") + .take(); + } + + pub(crate) fn shutdown_sync(&self, deadline: Duration) -> i32 { + let module = *self.module.lock().expect("module vtable lock"); + match module { + Some(module) => unsafe { + (module.shutdown)(module.module_ctx, deadline.as_millis() as u64) + }, + None => TB_CLOSED, + } + } + + pub(crate) fn stop_sync(&self, deadline: Duration) -> i32 { + let code = self.shutdown_sync(deadline); + self.context + .inbound + .lock() + .expect("host inbound lock") + .take(); + code + } +} + +#[async_trait] +impl Transport for ModuleTransport { + async fn send(&self, message: Message) -> Result<()> { + if self.ensure_initialized().await.is_err() { + self.context.init_failed.store(true, Ordering::Release); + self.context.faulted.store(true, Ordering::Release); + self.context.ready_notify.notify_waiters(); + if message.header.kind == MessageKind::MethodCall { + let error = Error::ModuleUnavailable { + module: self.label.clone(), + state: "failed".to_string(), + detail: "module initialization failed".to_string(), + }; + let reply = Message::error_reply(&message.header, &error); + if let Ok(bytes) = serde_json::to_vec(&reply) { + let sender = self + .context + .inbound + .lock() + .expect("host inbound lock") + .take(); + if let Some(sender) = sender { + let _ = sender.try_send(bytes); + } + } + return Ok(()); + } + return Err(Error::ConnectionClosed); + } + let is_call = message.header.kind == MessageKind::MethodCall; + if is_call { + self.context.inflight.fetch_add(1, Ordering::AcqRel); + } + if message.header.kind == MessageKind::MethodCall && !self.is_ready() { + self.pending.lock().await.push_back(message); + if !self.drain_started.swap(true, Ordering::AcqRel) { + let transport = self.self_ref.upgrade().ok_or(Error::ConnectionClosed)?; + tokio::spawn(transport.drain_pending()); + } + return Ok(()); + } + let result = self.deliver_now(message).await; + if result.is_err() && is_call { + self.context.inflight.fetch_sub(1, Ordering::AcqRel); + } + result + } + + async fn recv(&self) -> Result> { + let bytes = self.inbound.lock().await.recv().await; + let Some(bytes) = bytes else { + return Ok(None); + }; + let message: Message = serde_json::from_slice(&bytes)?; + if matches!( + message.header.kind, + MessageKind::MethodReturn | MessageKind::Error + ) { + let _ = + self.context + .inflight + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |count| { + count.checked_sub(1) + }); + } + Ok(Some(message)) + } + + async fn close(&self) -> Result<()> { + // The module's `shutdown` callback may block up to its deadline. Run it + // on a blocking thread so a wedged shutdown cannot stall the broker + // task that is closing this transport. + let transport = self.self_ref.upgrade().ok_or(Error::ConnectionClosed)?; + tokio::task::spawn_blocking(move || transport.stop_sync(Duration::from_secs(5))) + .await + .map_err(|_| Error::transport("module shutdown task was cancelled"))?; + Ok(()) + } + + fn describe(&self) -> String { + format!("module:{}", self.label) + } +} + +unsafe extern "C" fn host_send(ctx: *mut c_void, ptr: *const u8, len: usize) -> i32 { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + if ctx.is_null() || ptr.is_null() || len > MAX_FRAME_LEN { + return TB_BAD_ARGUMENT; + } + let context = unsafe { &*(ctx.cast::()) }; + let bytes = unsafe { std::slice::from_raw_parts(ptr, len) }.to_vec(); + let sender = context.inbound.lock().expect("host inbound lock").clone(); + match sender { + // This callback runs on a module-owned runtime thread. Blocking it + // applies bounded backpressure only to that module and gives the + // async SDK a reliable completion without adding a fifth callback + // to the frozen v1 ABI. + Some(sender) => match sender.blocking_send(bytes) { + Ok(()) => TB_OK, + Err(_) => TB_CLOSED, + }, + None => TB_CLOSED, + } + })) + .unwrap_or(TB_BACKPRESSURE) +} + +unsafe extern "C" fn host_wake(ctx: *mut c_void) { + // A panic escaping a plain `extern "C"` frame aborts the process, so each + // host callback that a module can reach must contain its own unwinds. + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + if let Some(context) = unsafe { ctx.cast::().as_ref() } { + context.wake.notify_one(); + } + })); +} + +unsafe extern "C" fn host_log(ctx: *mut c_void, level: u32, ptr: *const u8, len: usize) { + // `host_log` is the most exposed callback: the `tracing` macros run + // arbitrary subscriber code chosen by the embedding host, so a panicking + // subscriber must not be able to abort the process through the module. + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + if ctx.is_null() || ptr.is_null() { + return; + } + let len = len.min(4096); + let message = String::from_utf8_lossy(unsafe { std::slice::from_raw_parts(ptr, len) }); + match level { + 1 => tracing::error!(target: "tinybus_module", message = %message), + 2 => tracing::warn!(target: "tinybus_module", message = %message), + 3 => tracing::info!(target: "tinybus_module", message = %message), + 4 => tracing::debug!(target: "tinybus_module", message = %message), + _ => tracing::trace!(target: "tinybus_module", message = %message), + } + })); +} + +unsafe extern "C" fn host_fault(ctx: *mut c_void, _: *const u8, _: usize) { + // `host_fault` exists so a misbehaving module is detached instead of + // taking the host down; an abort inside it would defeat that goal. + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + if let Some(context) = unsafe { ctx.cast::().as_ref() } { + context.faulted.store(true, Ordering::Release); + context.ready_notify.notify_waiters(); + context.inbound.lock().expect("host inbound lock").take(); + } + })); +} + +unsafe extern "C" fn host_ready(ctx: *mut c_void) { + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + if let Some(context) = unsafe { ctx.cast::().as_ref() } { + context.ready.store(true, Ordering::Release); + context.ready_notify.notify_waiters(); + } + })); +} + +#[cfg(test)] +mod tests { + use super::*; + + struct CaptureLevel(Arc); + + impl tracing::Subscriber for CaptureLevel { + fn enabled(&self, _: &tracing::Metadata<'_>) -> bool { + true + } + + fn new_span(&self, _: &tracing::span::Attributes<'_>) -> tracing::span::Id { + tracing::span::Id::from_u64(1) + } + + fn record(&self, _: &tracing::span::Id, _: &tracing::span::Record<'_>) {} + + fn record_follows_from(&self, _: &tracing::span::Id, _: &tracing::span::Id) {} + + fn event(&self, event: &tracing::Event<'_>) { + if *event.metadata().level() == tracing::Level::ERROR { + self.0.store(true, Ordering::Release); + } + } + + fn enter(&self, _: &tracing::span::Id) {} + + fn exit(&self, _: &tracing::span::Id) {} + } + + #[test] + fn a_module_log_line_reaches_the_hosts_subscriber_with_its_level() { + let (_transport, host) = ModuleTransport::new("logger".to_string(), Vec::new()); + let observed = Arc::new(AtomicBool::new(false)); + let bytes = b"module log line"; + tracing::subscriber::with_default(CaptureLevel(observed.clone()), || unsafe { + (host.log)(host.host_ctx, 1, bytes.as_ptr(), bytes.len()); + }); + assert!(observed.load(Ordering::Acquire)); + } +} diff --git a/crates/tinybus/src/native.rs b/crates/tinybus/src/native.rs index d1ace7aa..9a53de4e 100644 --- a/crates/tinybus/src/native.rs +++ b/crates/tinybus/src/native.rs @@ -41,6 +41,12 @@ //! without a runtime. Dispatch is **async**, and takes care to clone the //! handler's `Arc` and drop the lock *before* awaiting, so a slow handler never //! blocks an unrelated dispatch. +//! +//! A dynamically loaded module cannot use this registry to cross the `dlopen` +//! boundary. It links its own copy of this static and Rust does not promise +//! `TypeId` identity across separately loaded artifacts. Module-to-host and +//! module-to-module calls therefore go through the framed module transport; +//! no `Any`, `TypeId`, or native-registry pointer appears in the module ABI. use std::any::{Any, TypeId}; use std::collections::HashMap; diff --git a/crates/tinybus/src/router.rs b/crates/tinybus/src/router.rs index ac8668c6..c56ac483 100644 --- a/crates/tinybus/src/router.rs +++ b/crates/tinybus/src/router.rs @@ -280,6 +280,22 @@ impl Router { } } + /// Claim a well-known name for an attached peer identified by its unique + /// name. Module activation uses this to make a lazy module routable before + /// its SDK handshake has run. + #[cfg(feature = "modules")] + pub(crate) fn request_name_for_unique( + &mut self, + unique: &BusName, + name: BusName, + ) -> Result { + let id = *self + .names + .get(unique) + .ok_or_else(|| Error::NameHasNoOwner(unique.clone()))?; + self.request_name(id, name) + } + /// Give up a well-known name. Releasing a name you do not own is an error, /// not a no-op — it always means the caller's model of the bus is wrong. pub fn release_name(&mut self, id: u64, name: &BusName) -> Result { diff --git a/docs/modules/README.md b/docs/modules/README.md index ec0da746..99333d4f 100644 --- a/docs/modules/README.md +++ b/docs/modules/README.md @@ -1,8 +1,9 @@ # Module documentation -One document per module in `crates/tinybus/src/`. Each answers the questions a -rustdoc page cannot: why the module exists, what it deliberately does not do, -and which of its behaviours other modules are relying on. +One design reference per module in `crates/tinybus/src/`: a focused README when +the rationale exceeds source rustdoc, otherwise the module-level rustdoc itself. +Each records why the module exists, what it deliberately does not do, and which +behaviours other modules rely on. | Module | Document | | --- | --- | @@ -15,6 +16,11 @@ and which of its behaviours other modules are relying on. | `connection` | [connection/README.md](connection/README.md) | | `proxy` | [proxy/README.md](proxy/README.md) | | `service` | [service/README.md](service/README.md) | +| `events` | source rustdoc (bounded domain-event fan-out) | +| `global` | source rustdoc (one-time process-wide bus) | +| `native` | source rustdoc (typed in-process request registry) | +| `version` | source rustdoc (interface compatibility records) | +| `module` | [module/README.md](module/README.md) | -See also [protocol.md](protocol.md) for the wire format as a specification +See also [protocol.md](../protocol.md) for the wire format as a specification rather than as Rust. diff --git a/docs/modules/module/README.md b/docs/modules/module/README.md new file mode 100644 index 00000000..044bf071 --- /dev/null +++ b/docs/modules/module/README.md @@ -0,0 +1,72 @@ +# Dynamic modules + +The `module` package is the host half of tinybus's trusted in-process module +boundary. A module is a Rust `cdylib` loaded at runtime and attached to the +broker as an ordinary peer. The broker still stamps senders, routes only on +headers, applies per-peer queue bounds, and releases names when the bridge +closes. + +Loading is behind the non-default `modules` feature. The ABI types and manifest +are always available so a module SDK can compile with no socket or loader. + +## Trust model + +`dlopen`, `LoadLibraryExW`, and their platform equivalents execute code before +the host can inspect an exported symbol. The ABI gate prevents incompatible +modules from receiving the host vtable; it does not make an artifact safe. +Every loaded module can read and write the host address space and a native +fault terminates the process. Install directories must therefore be private, +and modules must be treated as first-party host code. + +The loader never unloads a library. A stopped module releases its bus transport +and names, but code, TLS, panic metadata and callback addresses remain mapped +until process exit. Replacing or removing an already loaded artifact requires a +restart. + +## Loading sequence + +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). +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. +6. Resolve dependencies and reject missing providers, cycles and name clashes. +7. Call `tinybus_module_init_v1`, receive its vtable, and attach the transport. + A manifest with `lazy_init = true` defers this step until its first method + call; racing first calls share one initialization and retain their order. + +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 +function shaped `setup(Connection, MyConfig)`, while the original +`setup(Connection)` form ignores configuration. Operators can pass the value +with `tinybus modules load --config '{...}'`. +For directory discovery, embedding hosts call `ModuleHost::set_config(name, +value)` (or the builder-form `with_config`) before `load_dir`; the value is +selected by the admitted manifest name. + +The optional `modules.toml` file is authoritative for its directory. Keys are +artifact file names (or stems) and values are lowercase SHA-256 hashes. An +artifact absent from the file or with a mismatched hash is refused. Search +precedence is `OPENHUMAN_MODULE_PATH`, the platform user data directory, then +the platform system directory. `tinybus modules scan --path --dry-run` +performs admission and dependency checks without initializing or attaching. + +Refusing one artifact does not prevent the host from admitting other artifacts +in the same directory. The refused artifact's error contains only a sanitized +basename and fixed reason. + +Lifecycle states are `discovered`, `rejected`, `unresolved`, `resolved`, +`initializing`, `ready`, `serving`, `faulted`, `failed`, `stopped`, and +`disabled`. Rejected, faulted, failed, stopped, and disabled modules answer a +call immediately with `ModuleUnavailable`; they are never retried in the same +process. `ready` and `serving` reflect whether calls are in flight and do not +emit per-call state signals. `StopModule` emits no signal through its bus-call +path; the stopped transition is announced when the module peer subsequently +detaches. Other lifecycle edges emit `ModuleStateChanged` after any +corresponding `NameOwnerChanged` announcement. + +See [abi.md](abi.md) for the binary contract and +[the protocol](../../protocol.md) for the bus control members. diff --git a/docs/modules/module/abi.md b/docs/modules/module/abi.md new file mode 100644 index 00000000..da0a187f --- /dev/null +++ b/docs/modules/module/abi.md @@ -0,0 +1,63 @@ +# Module ABI v1 + +Rust has no stable ABI. The module boundary therefore carries only `repr(C)` +plain data, opaque pointers, borrowed byte slices and `extern "C"` callbacks. +No Rust allocation, trait object, future, Tokio type, panic payload or +`repr(Rust)` value crosses it. + +The tail of the host vtable contains a borrowed JSON configuration slice. It is +valid for the init call; the SDK deserializes it into module-owned memory before +returning. Configuration decode failures return a numeric status and never +include attacker-controlled values in host logs. +The host zeroes and releases its serialized copy immediately after init +returns; modules must not retain the borrowed pointer. +The final host callback, `ready`, marks completion of asynchronous setup. This +keeps a lazy module's reserved name routable during initialization without +announcing ownership before its objects are serving. + +## Symbols + +Every module exports exactly these revisioned symbols: + +- `TINYBUS_MODULE_ABI_V1`: a data descriptor; +- `tinybus_module_manifest_v1()`: borrowed JSON manifest bytes; +- `tinybus_module_init_v1(host, out)`: initialization after admission. + +Putting the revision in each name makes an old module fail symbol lookup before +the host interprets a changed layout. + +## Descriptor gate + +The first 16 bytes are frozen as four 32-bit fields: magic, ABI revision, +descriptor size, and flags. The host rejects a wrong prefix, sizes outside +16–4096 bytes, and descriptors smaller +than the v1 structure before reading more. Larger descriptors are accepted and +their tail ignored. + +Admission then requires matching pointer width, endianness, target triple, a +compatible tinybus series, and module feature bits that are a subset of the +host's. Modules built with `panic=abort` are refused. A rustc mismatch warns in +permissive mode and refuses in strict mode. + +Fixed byte arrays hold target, toolchain and identity fields. Displayed values +keep only ASCII letters, digits, `.`, `_`, `+`, and `-`, capped at 32 +characters. + +## Frames and runtimes + +Both directions pass one borrowed JSON `Message` document without the socket +transport's four-byte length prefix. The 16 MiB frame cap applies at both ends. +The receiver copies the bytes before returning from the callback. + +Each module creates its own Tokio runtime. A statically linked library has a +separate copy of Tokio's thread-locals, so it cannot borrow the host runtime. +The host-to-module `deliver` callback only performs a bounded `try_send` and +never blocks a host thread. Queue capacity is reported as backpressure and the +host retries after the module's `wake` callback. +The symmetric module-to-host `send` callback applies bounded backpressure by +blocking the calling module thread until host queue capacity is available. +Modules must not call it from a thread that has to remain responsive. + +The SDK installs a panic hook that forwards only the source location, never the +payload, and faults the transport so the broker releases the module's names. +Native faults remain outside what an in-process boundary can contain. diff --git a/docs/protocol.md b/docs/protocol.md index d7b13f6b..67ceb69c 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -98,16 +98,36 @@ interface `ai.tinyhumans.tinybus.Bus`. | `RequestName` | `[name]` | `true`, or an error if taken | | `ReleaseName` | `[name]` | `true` | | `ListNames` | `[]` | every owned name, unique names included | +| `Announce` | `[manifest]` | `true`; records this peer's interface versions | +| `GetManifest` | `[name]` | that peer's manifest, or `null` | +| `ListPeers` | `[]` | unique names, owned names, and peer manifests | | `GetNameOwner` | `[name]` | the owner's unique name, or `null` | | `AddMatch` | `[rule]` | `null` | | `RemoveMatch` | `[rule]` | `null` | +| `ListModules` | `[]` | every module known to the embedded host | +| `GetModule` | `[name]` | module identity, ABI facts and state, or `null` | +| `GetModuleManifest` | `[name]` | the declared module manifest, or `null` | +| `LoadModule` | `[path, config?]` | the newly loaded module record; config is JSON | +| `StopModule` | `[name, deadline_ms]` | the stopped module record | +| `EnableModule` | `[name, on]` | the updated module record | +| `RescanModules` | `[]`, `[paths]`, or `[paths, dry_run]` | modules loaded or inspected from configured/explicit search paths; `dry_run` defaults to `false` | `ai.tinyhumans.tinybus.Bus` is reserved; `RequestName` for it always fails. So does `RequestName` for a unique name. -The bus emits one signal, `NameOwnerChanged`, with body -`[name, old_owner, new_owner]`, either owner being `null`. This is how a peer -learns a service died without polling it. +When ownership changes, the bus emits `NameOwnerChanged`, with body +`[name, old_owner, new_owner]`, either owner being `null`. Unchanged ownership +emits no signal. This is how a peer learns a service died without polling it. + +An embedded module host also exposes the additive module members above. A +broker built without the `modules` feature returns `UnknownMethod`; a +feature-enabled broker with no registered host returns `Failed` with "module +host is not installed". The wire protocol version remains 1 because old peers +can still parse every message. Module state changes are +announced as `ModuleStateChanged` with body +`[module, old_state, new_state, detail]`; `detail` is `null` unless the new +state has a safe refusal or fault reason. +Name ownership changes still announce when a module attaches or stops. ## Match rules