//! RFC 010 c8 — exposure registry + type hashing. Purely local, no network. //! //! Payload types are std types (`String`, `u64`) because the crate's serde is //! deliberately derive-less (`default-features = false`) — user crates bring //! their own derive; the contract here is `DeserializeOwned`. //! //! The hash-stability test re-execs the current binary (the c4 harness): the //! guarantee under test is "stable across runs in the SAME binary" — exactly //! what the build-hash handshake reduces the mesh to — not stability across //! builds, which the scope guard explicitly rejects. #![cfg(feature = "cluster")] mod common; use common::{maybe_child, spawn_node}; use smarm::cluster::envelope::encode_payload; use smarm::cluster::expose::{ decode_deliver, decoder_registered, expose, expose_type, exposed_hash, exposed_names, type_hash, DeliverError, }; use smarm::monitor::{monitor, terminal_reason, DownReason}; use smarm::{channel, register, run, spawn, Name}; const ROLES: &[(&str, fn())] = &[("hasher", role_hasher)]; /// Print the hashes this process computes; the parent (a different run of /// the same binary) compares against its own. fn role_hasher() { println!("HASH-STRING {}", type_hash::()); println!("HASH-U64 {}", type_hash::()); } const GREETER: Name = Name::new("expose-test.greeter"); /// Exposed and unexposed lookup, the returned hash, and the audit listing. #[test] fn exposed_and_unexposed_lookup() { maybe_child(ROLES); run(|| { let h = expose(GREETER); assert_eq!(h, type_hash::()); assert_eq!(exposed_hash("expose-test.greeter"), Some(h)); assert_eq!(exposed_hash("never-exposed"), None); assert!(exposed_names().contains(&("expose-test.greeter", h))); }); } /// Distinct types land on distinct hashes (FNV over distinct TypeIds — a /// smoke assertion; a collision would degrade to a decode error, never a /// misroute, per RFC §3). #[test] fn distinct_types_distinct_hashes() { maybe_child(ROLES); run(|| { assert_ne!(type_hash::(), type_hash::()); assert_ne!(type_hash::(), type_hash::>()); }); } /// The decode-and-deliver contract: a registered hash decodes into the /// target's typed channel; an unknown hash, corrupt bytes, and a missing /// channel each fail without delivering — `WrongChannel`, never a misroute. #[test] fn decoder_registration_and_delivery() { maybe_child(ROLES); run(|| { let h_string = expose_type::(); let h_u64 = expose_type::(); assert!(decoder_registered(h_string)); assert!(!decoder_registered(h_string.wrapping_add(1))); // A live actor with a String channel (registered from its own body, // announced via a ready signal — the tests/registry.rs idiom). let (ready_tx, ready_rx) = channel::<()>(); let (stop_tx, stop_rx) = channel::<()>(); let (msg_tx, msg_rx) = channel::(); let pid = spawn(move || { register(Name::::new("expose-test.sink"), msg_tx).unwrap(); ready_tx.send(()).unwrap(); let _ = stop_rx.recv(); }) .pid(); ready_rx.recv().unwrap(); // Happy path: decode + deliver through the published channel. let bytes = encode_payload("hello across the seam").unwrap(); decode_deliver(h_string, pid, &bytes).unwrap(); assert_eq!(msg_rx.recv().unwrap(), "hello across the seam"); // Unknown hash: nothing was registered under it. assert!(matches!( decode_deliver(h_string.wrapping_add(1), pid, &bytes), Err(DeliverError::UnknownType) )); // Corrupt bytes: the decoder fails before any send. assert!(matches!( decode_deliver(h_string, pid, &[0xff; 3]), Err(DeliverError::Decode(_)) )); // Right decoder, wrong channel: the actor has no u64 channel, so the // decoded value is refused — the NoChannel guarantee. let u64_bytes = encode_payload(&7u64).unwrap(); assert!(matches!( decode_deliver(h_u64, pid, &u64_bytes), Err(DeliverError::WrongChannel) )); stop_tx.send(()).unwrap(); }); } /// `expose` and the bridge crossing agree on the resulting set: both funnel /// the pid-boundary mark through the watchable machinery, so an exposed /// name's holder dies with a terminal record — the exact observable /// `mark_watchable` guarantees the membrane. (For named holders the mark is /// already stamped by `register` itself; this pins the shared contract.) #[test] fn expose_and_bridge_crossing_agree_on_the_set() { maybe_child(ROLES); run(|| { let (ready_tx, ready_rx) = channel::<()>(); let (stop_tx, stop_rx) = channel::<()>(); let (msg_tx, _msg_rx) = channel::(); let pid = spawn(move || { register(GREETER, msg_tx).unwrap(); ready_tx.send(()).unwrap(); let _ = stop_rx.recv(); }) .pid(); ready_rx.recv().unwrap(); expose(GREETER); let m = monitor(pid); stop_tx.send(()).unwrap(); assert_eq!(m.rx.recv().unwrap().reason, DownReason::Exit); assert_eq!(terminal_reason(pid), Some(DownReason::Exit)); }); } /// Hash stability across runs in the same binary: a re-exec of this binary /// computes the same hashes this process does. #[test] fn hash_stable_across_runs_in_same_binary() { maybe_child(ROLES); let (mine_string, mine_u64) = { // Computing a TypeId hash needs no runtime, but keep the contract // uniform with real call sites. (type_hash::(), type_hash::()) }; let mut child = spawn_node("hasher", &[]); let line = child.wait_line("HASH-STRING", |l| l.starts_with("HASH-STRING ")); assert_eq!( line["HASH-STRING ".len()..].parse::().unwrap(), mine_string ); let line = child.wait_line("HASH-U64", |l| l.starts_with("HASH-U64 ")); assert_eq!(line["HASH-U64 ".len()..].parse::().unwrap(), mine_u64); child.wait_exit(); }