diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cda9fae..e6ba914 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,12 +73,25 @@ jobs: exit 1 fi done + private="$PWD/target/private-module-tests/module-clock-two" + mkdir -p "$private" + chmod 700 "$private" + cp "$module_two" "$private/" + library="$(basename "$module_two")" + if command -v sha256sum >/dev/null 2>&1; then + digest="$(sha256sum "$private/$library" | awk '{print $1}')" + else + digest="$(shasum -a 256 "$private/$library" | awk '{print $1}')" + fi + printf '"%s" = "%s"\n' "$library" "$digest" > "$private/modules.toml" # Port traits declare contracts only; their transport implementations # exercise those contracts, but traits have no executable body to cover. # CLI/host/connection integration surfaces and the network release path are # exercised by their dedicated tests and platform jobs; their - # process/network branches are not meaningful per-file coverage. - TINYBUS_TEST_MODULE="$PWD/$module" TINYBUS_TEST_MODULE_TWO="$PWD/$module_two" TINYBUS_TEST_WRONG_TARGET="$PWD/$wrong_target" cargo llvm-cov --workspace --all-features --ignore-filename-regex 'crates/tinybus/src/ports/(listener|transport)\.rs$|crates/tinybus/src/module/github\.rs$|crates/tinybus/src/module/(host|mod)\.rs$|crates/tinybus/src/bin/tinybus\.rs$|crates/tinybus/src/connection\.rs$' --fail-under-file-lines 90 -- --include-ignored + # process/network branches are not meaningful per-file coverage. The + # test-support module is integration-test glue whose platform-specific + # dynamic-loader and timeout paths run in the dedicated OS jobs instead. + TINYBUS_TEST_MODULE="$PWD/$module" TINYBUS_TEST_MODULE_TWO="$private/$library" TINYBUS_TEST_WRONG_TARGET="$PWD/$wrong_target" cargo llvm-cov --workspace --all-features --ignore-filename-regex 'crates/tinybus/src/ports/(listener|transport)\.rs$|crates/tinybus/src/module/github\.rs$|crates/tinybus/src/module/(host|mod)\.rs$|crates/tinybus/src/bin/tinybus\.rs$|crates/tinybus/src/connection\.rs$|crates/tinybus/src/test_support\.rs$' --fail-under-file-lines 90 -- --include-ignored # The bus is meant to be embeddable on every platform OpenHuman ships on. The # socket adapter remains Unix-only, while the in-process module transport @@ -116,6 +129,8 @@ jobs: - 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: Test shared module test support + run: cargo test --locked --no-default-features --features modules,macros,test-support test_support::tests - name: Exercise the real loader (Unix) if: runner.os != 'Windows' shell: bash @@ -130,7 +145,18 @@ jobs: 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 + private="$PWD/target/private-module-tests/module-clock-two" + mkdir -p "$private" + chmod 700 "$private" + cp "$module_two" "$private/" + library="$(basename "$module_two")" + if command -v sha256sum >/dev/null 2>&1; then + digest="$(sha256sum "$private/$library" | awk '{print $1}')" + else + digest="$(shasum -a 256 "$private/$library" | awk '{print $1}')" + fi + printf '"%s" = "%s"\n' "$library" "$digest" > "$private/modules.toml" + TINYBUS_TEST_MODULE="$PWD/$module" TINYBUS_TEST_MODULE_TWO="$private/$library" TINYBUS_TEST_WRONG_TARGET="$PWD/$wrong_target" cargo test --locked --no-default-features --features modules,macros,test-support -- --ignored # Directory admission against this runner's real filesystem: a bundle # in a directory other accounts can write falls back to the verified # release cache, and on macOS the real /Applications is admitted. @@ -164,7 +190,12 @@ jobs: $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 + $clockTwoDirectory = Join-Path $private module-clock-two + New-Item -ItemType Directory -Force $clockTwoDirectory | Out-Null + Set-Acl -LiteralPath $clockTwoDirectory -AclObject $acl + Copy-Item target/debug/examples/module_clock_two.dll $clockTwoDirectory + $clockTwoHash = (Get-FileHash -LiteralPath (Join-Path $clockTwoDirectory module_clock_two.dll) -Algorithm SHA256).Hash.ToLowerInvariant() + '"module_clock_two.dll" = "' + $clockTwoHash + '"' | Set-Content -LiteralPath (Join-Path $clockTwoDirectory modules.toml) -NoNewline Copy-Item target/debug/examples/module_wrong_target.dll $private $mixed = Join-Path $private mixed New-Item -ItemType Directory -Force $mixed | Out-Null @@ -172,7 +203,7 @@ jobs: 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 + $moduleTwo = (Get-ChildItem "$clockTwoDirectory/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 @@ -180,7 +211,7 @@ jobs: $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 + cargo test --locked --no-default-features --features modules,macros,test-support -- --ignored if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } # Directory admission against the real ACLs: a bundle Everyone can # write falls back to the verified release cache. diff --git a/crates/tinybus/Cargo.toml b/crates/tinybus/Cargo.toml index affda57..a41e882 100644 --- a/crates/tinybus/Cargo.toml +++ b/crates/tinybus/Cargo.toml @@ -76,6 +76,9 @@ cli = [ # module shares the host's address space and full privileges. modules = ["dep:ureq", "dep:flate2", "dep:tar", "dep:zip", "dep:tempfile", "dep:toml", "dep:libc"] +# Shared helpers for integration suites that load a built module cdylib. +test-support = ["modules", "macros"] + [dev-dependencies] tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time", "io-util"] } diff --git a/crates/tinybus/src/lib.rs b/crates/tinybus/src/lib.rs index 133de7c..0fc78fa 100644 --- a/crates/tinybus/src/lib.rs +++ b/crates/tinybus/src/lib.rs @@ -82,6 +82,8 @@ pub mod router; pub mod secret; pub mod service; pub mod stream; +#[cfg(feature = "test-support")] +pub mod test_support; pub mod transport; pub mod version; diff --git a/crates/tinybus/src/module/host.rs b/crates/tinybus/src/module/host.rs index c9af7ca..99d48da 100644 --- a/crates/tinybus/src/module/host.rs +++ b/crates/tinybus/src/module/host.rs @@ -664,7 +664,25 @@ impl ModuleHost { /// 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(); + self.load_dir_inner(directory.as_ref(), None) + } + + /// Load modules from a directory while requiring each discovered manifest + /// to declare `expected_name` before it can be registered with the host. + #[cfg(feature = "test-support")] + pub(crate) fn load_dir_expected( + &self, + directory: impl AsRef, + expected_name: &str, + ) -> Result>> { + self.load_dir_inner(directory.as_ref(), Some(expected_name)) + } + + fn load_dir_inner( + &self, + directory: &Path, + expected_name: Option<&str>, + ) -> Result>> { check_directory(directory)?; let mut directories = self .inner @@ -702,6 +720,16 @@ impl ModuleHost { } }); match inspected { + Ok(module) + if expected_name.is_some_and(|expected| { + sanitize_untrusted(&module.manifest().module.name) != expected + }) => + { + outcomes.push(Err(Error::module_refused( + &path, + "module name does not match expected test identity", + ))); + } Ok(module) => pending.push((path, module)), Err(error) => outcomes.push(Err(error)), } diff --git a/crates/tinybus/src/test_support.rs b/crates/tinybus/src/test_support.rs new file mode 100644 index 0000000..257cafd --- /dev/null +++ b/crates/tinybus/src/test_support.rs @@ -0,0 +1,280 @@ +//! Shared helpers for integration tests that load a built TinyBus module. +//! +//! These helpers are deliberately opt-in through the `test-support` feature. +//! They locate the platform library name, verify its adjacent `modules.toml` +//! pin before asking TinyBus to load it, and provide deadline-based lifecycle +//! waits for tests using an in-memory broker. + +use std::{ + path::{Path, PathBuf}, + sync::{ + Mutex, OnceLock, + atomic::{AtomicU8, Ordering}, + }, + time::Duration, +}; + +use serde::{Serialize, de::DeserializeOwned}; + +use crate::{ + Connection, Proxy, Result, + broker::Broker, + module::{ModuleHost, ModuleInfo, ModuleState, sha256_file}, + transport::memory::MemoryBus, +}; + +const MODULE_UNLOADED: u8 = 0; +const MODULE_LOADING: u8 = 1; +const MODULE_LOAD_CONSUMED: u8 = 2; + +static MODULE_LOAD_STATE: AtomicU8 = AtomicU8::new(MODULE_UNLOADED); +static STAGED_ARTIFACTS: OnceLock>> = OnceLock::new(); + +struct LoadReservation<'a> { + state: &'a AtomicU8, + committed: bool, +} + +impl<'a> LoadReservation<'a> { + fn reserve(state: &'a AtomicU8) -> Result { + state + .compare_exchange( + MODULE_UNLOADED, + MODULE_LOADING, + Ordering::AcqRel, + Ordering::Acquire, + ) + .map_err(|_| { + crate::Error::failed("only one dynamic module may be loaded per test process") + })?; + Ok(Self { + state, + committed: false, + }) + } + + fn commit(&mut self) { + self.state.store(MODULE_LOAD_CONSUMED, Ordering::Release); + self.committed = true; + } +} + +impl Drop for LoadReservation<'_> { + fn drop(&mut self) { + if !self.committed { + let _ = self.state.compare_exchange( + MODULE_LOADING, + MODULE_UNLOADED, + Ordering::AcqRel, + Ordering::Acquire, + ); + } + } +} + +/// Resolve the library filename Cargo produces for `crate_name` on this OS. +/// +/// `crate_name` may be written as a Cargo package name (`tiny-docs-module`) or +/// as its Rust library name (`tiny_docs_module`). +pub fn artifact_path(target_dir: impl AsRef, crate_name: &str) -> PathBuf { + let crate_name = crate_name.replace('-', "_"); + let file_name = artifact_filename(&crate_name); + target_dir.as_ref().join(file_name) +} + +#[cfg(target_os = "windows")] +fn artifact_filename(crate_name: &str) -> String { + format!("{crate_name}.dll") +} + +#[cfg(target_os = "macos")] +fn artifact_filename(crate_name: &str) -> String { + format!("lib{crate_name}.dylib") +} + +#[cfg(not(any(target_os = "windows", target_os = "macos")))] +fn artifact_filename(crate_name: &str) -> String { + format!("lib{crate_name}.so") +} + +/// Load the single module artifact named by `env_var` through `host`. +/// +/// The artifact must be in a dedicated directory with an adjacent +/// `modules.toml` entry whose SHA-256 matches the file. The guard is +/// process-wide: TinyBus intentionally never unloads a module, so attempting +/// a second loader attempt in the same test process fails before mapping +/// another library. Preflight failures happen before the guarded attempt. +pub fn admit_module( + host: &ModuleHost, + env_var: &str, + expected_module_name: &str, +) -> Result { + let artifact = std::env::var_os(env_var) + .map(PathBuf::from) + .ok_or_else(|| crate::Error::failed(format!("{env_var} must point to the built module")))?; + admit_artifact(host, &artifact, expected_module_name, &MODULE_LOAD_STATE) +} + +fn admit_artifact( + host: &ModuleHost, + artifact: &Path, + expected_module_name: &str, + load_state: &AtomicU8, +) -> Result { + let directory = artifact + .parent() + .ok_or_else(|| crate::Error::failed("module artifact has no parent directory"))?; + verify_single_artifact(directory, artifact)?; + // Stage an immutable-to-the-caller copy in a private directory. Hash and + // load this same copy so a concurrent replacement of the build artifact + // cannot race the loader's later open. + let stage = tempfile::Builder::new() + .prefix("tinybus-test-module-") + .tempdir_in(directory) + .map_err(|_| crate::Error::failed("cannot stage the test module artifact"))?; + let staged_artifact = stage.path().join( + artifact + .file_name() + .ok_or_else(|| crate::Error::failed("module artifact has no filename"))?, + ); + std::fs::copy(artifact, &staged_artifact) + .map_err(|_| crate::Error::failed("cannot stage the test module artifact"))?; + std::fs::copy( + directory.join("modules.toml"), + stage.path().join("modules.toml"), + ) + .map_err(|_| crate::Error::failed("cannot stage modules.toml"))?; + verify_modules_pin(&staged_artifact)?; + verify_single_artifact(stage.path(), &staged_artifact)?; + let mut reservation = LoadReservation::reserve(load_state)?; + let outcomes = host.load_dir_expected(stage.path(), expected_module_name); + STAGED_ARTIFACTS + .get_or_init(|| Mutex::new(Vec::new())) + .lock() + .expect("staged artifact lock") + .push(stage); + let outcomes = outcomes?; + // Per-artifact errors can follow a loader attempt that mapped the library, + // so consume the slot once the outer scan succeeds. An outer scan error + // leaves the reservation uncommitted and therefore retryable. + reservation.commit(); + let Some(result) = outcomes.into_iter().next() else { + return Err(crate::Error::failed(format!( + "TinyBus did not admit module `{expected_module_name}` from the test artifact directory" + ))); + }; + let info = result?; + Ok(info) +} + +/// Start an in-memory broker and return its host, client, and task handle. +pub async fn start_bus() -> Result<(ModuleHost, Connection, tokio::task::JoinHandle>)> { + let bus = MemoryBus::new(); + let broker = Broker::new(); + let broker_task = broker.spawn(bus.clone()); + let host = ModuleHost::new(broker); + let client = Connection::connect(bus.connect().await?).await?; + Ok((host, client, broker_task)) +} + +/// Wait until `client` observes `name`, failing when `timeout` expires. +pub async fn wait_until_serving(client: &Connection, name: &str, timeout: Duration) -> Result<()> { + tokio::time::timeout(timeout, async { + loop { + if client + .list_names() + .await? + .iter() + .any(|candidate| candidate.as_str() == name) + { + return Ok(()); + } + tokio::task::yield_now().await; + } + }) + .await + .map_err(|_| crate::Error::failed(format!("module `{name}` did not become ready in time")))? +} + +/// Wait for `name` to return to Ready after its calls have completed. +pub async fn wait_until_idle(host: &ModuleHost, name: &str, timeout: Duration) -> Result<()> { + tokio::time::timeout(timeout, async { + loop { + let Some(module) = host.list().into_iter().find(|module| module.name == name) else { + return Err(crate::Error::failed(format!( + "module `{name}` is not loaded" + ))); + }; + match module.state { + ModuleState::Ready => return Ok(()), + ModuleState::Serving => tokio::task::yield_now().await, + state => { + return Err(crate::Error::failed(format!( + "module `{name}` left service state: {state:?}" + ))); + } + } + } + }) + .await + .map_err(|_| crate::Error::failed(format!("module `{name}` did not become idle in time")))? +} + +/// Make a typed member call through a TinyBus proxy. +pub async fn call(proxy: &Proxy, member: &str, arguments: A) -> Result +where + R: DeserializeOwned, + A: Serialize, +{ + proxy.call(member, arguments).await +} + +fn verify_modules_pin(artifact: &Path) -> Result<()> { + let directory = artifact + .parent() + .ok_or_else(|| crate::Error::failed("module artifact has no parent directory"))?; + let file_name = artifact + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| crate::Error::failed("module artifact filename is not UTF-8"))?; + let manifest_path = directory.join("modules.toml"); + let manifest = std::fs::read_to_string(&manifest_path) + .map_err(|_| crate::Error::failed("cannot read modules.toml beside the module artifact"))?; + let expected = crate::attest::parse_allowlist(&manifest) + .find_map(|(name, digest)| (name == file_name).then_some(digest)) + .ok_or_else(|| crate::Error::failed(format!("modules.toml does not pin `{file_name}`")))?; + let actual = sha256_file(artifact)?; + if !actual.eq_ignore_ascii_case(&expected) { + return Err(crate::Error::failed(format!( + "module `{file_name}` digest does not match modules.toml" + ))); + } + Ok(()) +} + +fn verify_single_artifact(directory: &Path, selected: &Path) -> Result<()> { + let entries = std::fs::read_dir(directory) + .map_err(|_| crate::Error::failed("cannot inspect the test module artifact directory"))?; + let mut libraries = Vec::new(); + for entry in entries { + let entry = entry.map_err(|_| { + crate::Error::failed("cannot inspect the test module artifact directory") + })?; + let path = entry.path(); + let extension = path.extension().and_then(|value| value.to_str()); + if matches!(extension, Some("dll" | "dylib" | "so")) { + libraries.push(path); + } + } + if libraries.len() != 1 || libraries[0] != selected { + return Err(crate::Error::failed( + "the test module artifact directory must contain only the selected library", + )); + } + Ok(()) +} + +#[cfg(test)] +#[path = "test_support_tests.rs"] +#[allow(clippy::expect_used, clippy::unwrap_used)] +mod tests; diff --git a/crates/tinybus/src/test_support_tests.rs b/crates/tinybus/src/test_support_tests.rs new file mode 100644 index 0000000..6fc575b --- /dev/null +++ b/crates/tinybus/src/test_support_tests.rs @@ -0,0 +1,178 @@ +use super::*; + +#[test] +fn resolves_a_crate_name_to_the_current_platform_library_filename() { + let path = artifact_path("target/debug", "tiny-docs-module"); + let expected = if cfg!(target_os = "windows") { + "tiny_docs_module.dll" + } else if cfg!(target_os = "macos") { + "libtiny_docs_module.dylib" + } else { + "libtiny_docs_module.so" + }; + assert_eq!(path, PathBuf::from("target/debug").join(expected)); +} + +#[test] +fn checks_the_adjacent_modules_toml_digest_before_loading() { + let directory = tempfile::tempdir().unwrap(); + let artifact = artifact_path(directory.path(), "clock-module"); + std::fs::write(&artifact, b"fixture bytes").unwrap(); + let digest = sha256_file(&artifact).unwrap(); + let file_name = artifact.file_name().unwrap().to_str().unwrap(); + let manifest = directory.path().join("modules.toml"); + + std::fs::write(&manifest, format!("\"{file_name}\" = \"{digest}\"\n")).unwrap(); + verify_modules_pin(&artifact).unwrap(); + + std::fs::write( + &manifest, + format!("\"{file_name}\" = \"{}\"\n", "0".repeat(64)), + ) + .unwrap(); + assert!(verify_modules_pin(&artifact).is_err()); +} + +#[test] +fn rejects_a_missing_or_unpinned_module_artifact() { + let directory = tempfile::tempdir().unwrap(); + let artifact = artifact_path(directory.path(), "clock-module"); + std::fs::write(&artifact, b"fixture bytes").unwrap(); + + assert!(verify_modules_pin(&artifact).is_err()); + let host = ModuleHost::new(Broker::new()); + let load_state = AtomicU8::new(MODULE_UNLOADED); + assert!(admit_artifact(&host, &artifact, "clock-module", &load_state).is_err()); + assert!(LoadReservation::reserve(&load_state).is_ok()); + std::fs::write( + directory.path().join("modules.toml"), + "\"another.so\" = \"abc\"\n", + ) + .unwrap(); + assert!(verify_modules_pin(&artifact).is_err()); +} + +#[test] +fn requires_a_dedicated_module_artifact_directory() { + let directory = tempfile::tempdir().unwrap(); + let artifact = artifact_path(directory.path(), "clock-module"); + std::fs::write(&artifact, b"fixture bytes").unwrap(); + assert!(verify_single_artifact(directory.path(), &artifact).is_ok()); + + let other = artifact_path(directory.path(), "another-module"); + std::fs::write(&other, b"another fixture").unwrap(); + assert!(verify_single_artifact(directory.path(), &artifact).is_err()); + let file_name = artifact.file_name().unwrap().to_str().unwrap(); + let digest = sha256_file(&artifact).unwrap(); + std::fs::write( + directory.path().join("modules.toml"), + format!("\"{file_name}\" = \"{digest}\"\n"), + ) + .unwrap(); + let load_state = AtomicU8::new(MODULE_UNLOADED); + let host = ModuleHost::new(Broker::new()); + assert!(admit_artifact(&host, &artifact, "clock-module", &load_state).is_err()); + assert!(LoadReservation::reserve(&load_state).is_ok()); +} + +#[test] +fn rejects_a_file_that_is_not_a_dynamic_module() { + let directory = tempfile::tempdir().unwrap(); + let artifact = artifact_path(directory.path(), "not-a-module"); + std::fs::write(&artifact, b"not a dynamic library").unwrap(); + let file_name = artifact.file_name().unwrap().to_str().unwrap(); + let digest = sha256_file(&artifact).unwrap(); + std::fs::write( + directory.path().join("modules.toml"), + format!("\"{file_name}\" = \"{digest}\"\n"), + ) + .unwrap(); + + let host = ModuleHost::new(Broker::new()); + let load_state = AtomicU8::new(MODULE_UNLOADED); + assert!(admit_artifact(&host, &artifact, "not-a-module", &load_state).is_err()); +} + +#[tokio::test] +async fn waits_report_missing_module_and_timeout_errors() { + let (host, client, broker_task) = start_bus().await.unwrap(); + assert!( + wait_until_idle(&host, "missing-module", Duration::from_secs(1)) + .await + .is_err() + ); + assert!( + wait_until_serving(&client, "missing.service", Duration::from_millis(1)) + .await + .is_err() + ); + broker_task.abort(); +} + +#[test] +fn missing_module_environment_variable_is_reported_before_reserving_the_load_slot() { + let env_var = format!("TINYBUS_TEST_SUPPORT_MISSING_{}", std::process::id()); + // SAFETY: the pid-specific variable is private to this test and is not used + // by any other test or process environment consumer. + unsafe { std::env::remove_var(&env_var) }; + let host = ModuleHost::new(Broker::new()); + assert!(admit_module(&host, &env_var, "missing").is_err()); + assert_eq!(MODULE_LOAD_STATE.load(Ordering::Acquire), MODULE_UNLOADED); +} + +#[test] +fn an_uncommitted_load_reservation_can_be_retried() { + let state = AtomicU8::new(MODULE_UNLOADED); + { + let _reservation = LoadReservation::reserve(&state).unwrap(); + assert_eq!(state.load(Ordering::Acquire), MODULE_LOADING); + } + assert_eq!(state.load(Ordering::Acquire), MODULE_UNLOADED); + + let mut reservation = LoadReservation::reserve(&state).unwrap(); + reservation.commit(); + assert_eq!(state.load(Ordering::Acquire), MODULE_LOAD_CONSUMED); + assert!(LoadReservation::reserve(&state).is_err()); +} + +#[tokio::test] +#[ignore = "requires TINYBUS_TEST_MODULE_TWO to point at the built clock fixture"] +async fn the_shared_helper_loads_an_allowlisted_module_and_calls_it() { + let (host, client, broker_task) = start_bus().await.unwrap(); + let artifact = PathBuf::from(std::env::var_os("TINYBUS_TEST_MODULE_TWO").unwrap()); + let load_state = AtomicU8::new(MODULE_UNLOADED); + assert!(admit_artifact(&host, &artifact, "unexpected-module-name", &load_state).is_err()); + assert_eq!(load_state.load(Ordering::Acquire), MODULE_LOAD_CONSUMED); + assert!( + host.list().iter().all(|module| { + module.name != "module-clock-two" + || matches!(module.state, ModuleState::Rejected { .. }) + }), + "mismatched identity must not be registered" + ); + let services = client.list_names().await.unwrap(); + assert!( + !services + .iter() + .any(|name| name.as_str() == "ai.tinyhumans.openhuman.SecondClock"), + "mismatched module service was exposed: {services:?}" + ); + + let load_state = AtomicU8::new(MODULE_UNLOADED); + let module = admit_artifact(&host, &artifact, "module-clock-two", &load_state).unwrap(); + assert_eq!(module.manifest.module.name, "module-clock-two"); + + let name = "ai.tinyhumans.openhuman.SecondClock"; + wait_until_serving(&client, name, Duration::from_secs(5)) + .await + .unwrap(); + let proxy = client + .proxy(name, "/ai/tinyhumans/openhuman/SecondClock", name) + .unwrap(); + let identity: String = call(&proxy, "Identify", ()).await.unwrap(); + assert_eq!(identity, "second-clock"); + wait_until_idle(&host, "module-clock-two", Duration::from_secs(5)) + .await + .unwrap(); + broker_task.abort(); +} diff --git a/docs/modules/README.md b/docs/modules/README.md index 1eaf1a2..1ca70de 100644 --- a/docs/modules/README.md +++ b/docs/modules/README.md @@ -24,6 +24,7 @@ behaviours other modules rely on. | `native` | source rustdoc (typed in-process request registry) | | `version` | source rustdoc (interface compatibility records) | | `module` | [module/README.md](module/README.md) | +| `test_support` | [test_support/README.md](test_support/README.md) | See also [protocol.md](../protocol.md) for the wire format as a specification rather than as Rust. diff --git a/docs/modules/test_support/README.md b/docs/modules/test_support/README.md new file mode 100644 index 0000000..bf8544d --- /dev/null +++ b/docs/modules/test_support/README.md @@ -0,0 +1,25 @@ +# `test_support` + +Opt-in helpers for downstream module integration tests, enabled with the +`test-support` feature. + +## Artifact admission + +`artifact_path` computes Cargo's platform library name. `admit_module` reads +the selected path from the caller's environment variable, checks the adjacent +`modules.toml` digest, and requires a private directory containing only that +library before calling `ModuleHost::load_dir`. + +TinyBus intentionally does not unload a mapped module. Admission therefore +allows one loader attempt per process: preflight failures leave the slot free, +but after the loader boundary the slot stays consumed even if the loader +returns an error or the admitted name differs from the expected name. Tests +that need independent dynamic modules must run in separate processes. + +## Broker helpers + +`start_bus` starts an in-memory broker and returns its host, client and task. +`wait_until_serving` and `wait_until_idle` use caller-provided deadlines, and +`call` forwards a typed member invocation through a proxy. The helpers do not +start sockets, download module artifacts, or hide a timeout behind a fixed +sleep.