// message_bus.rs --- T M3.2 typed in-process message bus with `MessagePack` // serialization, a schema registry, and structured schema-mismatch errors. //! Message bus core (T M3.2). //! //! Spec contract: a single typed protocol used uniformly for every //! producer the editor talks to ([spec §5.2]). T M3.2 implements only //! the in-process transport --- two crossbeam channels wired into a //! pair of [`BusEnd`]s --- but the wire format (`MessagePack`) is the //! same one out-of-process workers will eventually use, so a future //! migration of one topic from in-process to subprocess is a transport //! swap, not a serialisation rewrite. //! //! # Topology //! //! [`MessageBus::pair`] returns two [`BusEnd`]s. Each end owns a //! [`Sender`] aimed at the *other* end and a [`Receiver`] reading //! messages aimed at *itself*. Cloning a [`BusEnd`] yields another //! handle that shares the same channels --- typical use is for the //! main thread to keep one end and hand a clone of the other end to //! every worker via the closure captured by [`crate::worker::WorkerPool::dispatch`]. //! //! # Schema registry //! //! Topics are `&'static str` keys. Each topic is bound (at runtime, //! once) to a Rust type via [`SchemaRegistry::register`]. Sending //! validates the payload type against the registry; decoding //! validates the requested type against the registry. Both produce //! [`BusError::SchemaMismatch`] with `&'static str` topic and type //! names so the error payload itself does not allocate. //! //! # Hot-path allocation //! //! The send path performs exactly one allocation, the //! `MessagePack`-encoded payload buffer. Topic strings are //! `&'static str`, registry lookups use the borrowed key, and the //! envelope construction moves the payload buffer rather than copying //! it. Internal `crossbeam_channel` node allocation is implementation //! detail of the channel and is acceptable per the spec acceptance //! ("beyond payload buffer"). use std::any::{TypeId, type_name}; use std::collections::HashMap; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, RwLock}; use std::time::Duration; use crossbeam::channel::{self, Receiver, Sender}; use serde::Serialize; use serde::de::DeserializeOwned; /// Stable identifier for an envelope, monotonically increasing per /// [`MessageBus`] from `0`. pub type MessageId = u64; // --------------------------------------------------------------------------- // Errors // --------------------------------------------------------------------------- /// Structured failures from the bus. All variants use `&'static str` /// for topic and type names so the error itself does not allocate --- /// codec errors are wrapped owned because the underlying /// `rmp_serde` types are owned. #[derive(Debug)] pub enum BusError { /// A topic was used for send/decode but not previously /// [`SchemaRegistry::register`]ed. UnregisteredTopic { /// The topic that was not registered. topic: &'static str, }, /// The Rust type used for send/decode does not match the type the /// topic was registered with. SchemaMismatch { /// The topic involved in the mismatch. topic: &'static str, /// The fully-qualified type name supplied by the caller. expected: &'static str, /// The fully-qualified type name the registry was bound to. registered: &'static str, }, /// A topic was registered twice with two different types. /// Re-registering with the *same* type is a no-op and not an error. DuplicateRegistration { /// The contested topic. topic: &'static str, /// The type name from the prior registration. existing: &'static str, /// The type name attempted in the second registration. attempted: &'static str, }, /// `MessagePack` encoding failed. Encode(rmp_serde::encode::Error), /// `MessagePack` decoding failed. Decode(rmp_serde::decode::Error), /// The peer end of the channel has been dropped; no further /// sends or receives will succeed. Disconnected, /// Non-blocking receive found nothing. Empty, } impl std::fmt::Display for BusError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::UnregisteredTopic { topic } => { write!(f, "unregistered topic {topic:?}") } Self::SchemaMismatch { topic, expected, registered, } => { write!( f, "schema mismatch on topic {topic:?}: caller used {expected}, \ registry has {registered}" ) } Self::DuplicateRegistration { topic, existing, attempted, } => { write!( f, "topic {topic:?} already registered as {existing}; cannot \ re-register as {attempted}" ) } Self::Encode(e) => write!(f, "MessagePack encode failed: {e}"), Self::Decode(e) => write!(f, "MessagePack decode failed: {e}"), Self::Disconnected => write!(f, "bus peer is gone"), Self::Empty => write!(f, "no message available"), } } } impl std::error::Error for BusError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { Self::Encode(e) => Some(e), Self::Decode(e) => Some(e), _ => None, } } } // --------------------------------------------------------------------------- // SchemaRegistry // --------------------------------------------------------------------------- #[derive(Clone, Copy, Debug)] struct TopicEntry { type_id: TypeId, type_name: &'static str, } /// Maps topic names to the Rust types their payloads serialize to. /// /// Senders register each topic up front (typically at editor startup /// or at package load); send and decode operations validate against /// the registry. A registry is cheap to share via [`Arc`] --- all /// runtime methods take `&self` and use a single internal `RwLock`. #[derive(Debug, Default)] pub struct SchemaRegistry { topics: RwLock>, } impl SchemaRegistry { /// Build an empty registry. #[must_use] pub fn new() -> Self { Self::default() } /// Bind `topic` to type `T`. Re-registering with the same `T` /// is a no-op; re-registering with a different type returns /// [`BusError::DuplicateRegistration`]. pub fn register(&self, topic: &'static str) -> Result<(), BusError> { let new = TopicEntry { type_id: TypeId::of::(), type_name: type_name::(), }; let mut guard = self.topics.write().expect("schema registry poisoned"); if let Some(existing) = guard.get(topic) { if existing.type_id == new.type_id { return Ok(()); } return Err(BusError::DuplicateRegistration { topic, existing: existing.type_name, attempted: new.type_name, }); } guard.insert(topic, new); Ok(()) } /// Validate that `topic` is registered and bound to type `T`. /// Used internally on the send and decode hot paths. fn check(&self, topic: &'static str) -> Result<(), BusError> { let guard = self.topics.read().expect("schema registry poisoned"); let Some(entry) = guard.get(topic) else { return Err(BusError::UnregisteredTopic { topic }); }; if entry.type_id != TypeId::of::() { return Err(BusError::SchemaMismatch { topic, expected: type_name::(), registered: entry.type_name, }); } Ok(()) } /// Number of topics currently registered. Useful in tests. #[must_use] pub fn len(&self) -> usize { self.topics.read().expect("schema registry poisoned").len() } /// Whether the registry is empty. #[must_use] pub fn is_empty(&self) -> bool { self.len() == 0 } } // --------------------------------------------------------------------------- // Envelope // --------------------------------------------------------------------------- /// A typed message in transit. The bus moves envelopes between /// [`BusEnd`]s; the payload is `MessagePack`-encoded bytes whose Rust /// type is determined by `topic` via the [`SchemaRegistry`]. #[derive(Clone, Debug)] pub struct Envelope { /// Per-bus monotonic id. pub id: MessageId, /// Schema key naming the payload type. pub topic: &'static str, /// `MessagePack`-encoded payload bytes. pub payload: Vec, } // --------------------------------------------------------------------------- // BusEnd // --------------------------------------------------------------------------- /// One end of a [`MessageBus`]. Send goes to the *other* end; recv /// reads messages aimed at *this* end. Cloning yields another handle /// that shares the same underlying channels and id counter. /// /// `BusEnd` is `Send + Sync`; clones can move to other threads and /// each one can send concurrently. Recv calls from multiple clones /// race --- exactly one clone will see any given envelope. #[derive(Clone)] pub struct BusEnd { out: Sender, inbox: Receiver, registry: Arc, next_id: Arc, } impl std::fmt::Debug for BusEnd { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("BusEnd") .field("inbox_len", &self.inbox.len()) .field("registered_topics", &self.registry.len()) .finish_non_exhaustive() } } impl BusEnd { /// Encode `payload` as `MessagePack` and send to the peer end. /// Validates `topic` against the schema registry first; an /// unregistered or mismatched topic returns a structured /// [`BusError`] without touching the channel. pub fn send(&self, topic: &'static str, payload: &T) -> Result where T: Serialize + 'static, { self.registry.check::(topic)?; let bytes = rmp_serde::to_vec(payload).map_err(BusError::Encode)?; let id = self.next_id.fetch_add(1, Ordering::Relaxed); let env = Envelope { id, topic, payload: bytes, }; self.out.send(env).map_err(|_| BusError::Disconnected)?; Ok(id) } /// Block until an envelope arrives at this end. Returns /// [`BusError::Disconnected`] if the peer has dropped its sender /// without queuing further messages. pub fn recv(&self) -> Result { self.inbox.recv().map_err(|_| BusError::Disconnected) } /// Non-blocking receive: returns [`BusError::Empty`] if no /// envelope is queued, or [`BusError::Disconnected`] if the peer /// is gone and the queue is drained. pub fn try_recv(&self) -> Result { match self.inbox.try_recv() { Ok(env) => Ok(env), Err(channel::TryRecvError::Empty) => Err(BusError::Empty), Err(channel::TryRecvError::Disconnected) => Err(BusError::Disconnected), } } /// Block up to `timeout` waiting for the next envelope. pub fn recv_timeout(&self, timeout: Duration) -> Result { match self.inbox.recv_timeout(timeout) { Ok(env) => Ok(env), Err(channel::RecvTimeoutError::Timeout) => Err(BusError::Empty), Err(channel::RecvTimeoutError::Disconnected) => Err(BusError::Disconnected), } } /// Decode an envelope's payload as type `T`. Validates the /// envelope's topic against the schema registry: if the /// registered type for `env.topic` is not `T`, returns /// [`BusError::SchemaMismatch`] without invoking the codec. pub fn decode(&self, env: &Envelope) -> Result where T: DeserializeOwned + 'static, { self.registry.check::(env.topic)?; rmp_serde::from_slice(&env.payload).map_err(BusError::Decode) } /// Borrow the schema registry. Useful for callers that hold the /// pair but want to register topics through the same `Arc` the /// bus is using. #[must_use] pub fn registry(&self) -> &Arc { &self.registry } } // --------------------------------------------------------------------------- // MessageBus // --------------------------------------------------------------------------- /// Factory for paired [`BusEnd`]s. The bus type itself holds no /// state; it exists as a namespace for [`Self::pair`]. pub struct MessageBus; impl MessageBus { /// Build a pair of bidirectional [`BusEnd`]s sharing `registry` /// and a single per-bus id counter. Either end can send and /// receive; messages are routed point-to-point only (each end /// has one peer). #[must_use] pub fn pair(registry: Arc) -> (BusEnd, BusEnd) { let (a_to_b_tx, a_to_b_rx) = channel::unbounded::(); let (b_to_a_tx, b_to_a_rx) = channel::unbounded::(); let next_id = Arc::new(AtomicU64::new(0)); let a = BusEnd { out: a_to_b_tx, inbox: b_to_a_rx, registry: Arc::clone(®istry), next_id: Arc::clone(&next_id), }; let b = BusEnd { out: b_to_a_tx, inbox: a_to_b_rx, registry, next_id, }; (a, b) } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use crate::worker::WorkerPool; use serde::Deserialize; use std::time::Instant; #[derive(Debug, PartialEq, Serialize, Deserialize)] struct Hit { line: u32, col: u32, snippet: String, } fn registry_with(topic: &'static str) -> Arc { let r = Arc::new(SchemaRegistry::new()); r.register::(topic).expect("register"); r } /// Schema registration is idempotent for the same type; a /// conflicting registration is a structured error. #[test] fn schema_register_idempotent_for_same_type() { let reg = SchemaRegistry::new(); reg.register::("ping").unwrap(); reg.register::("ping").unwrap(); assert_eq!(reg.len(), 1); } #[test] fn schema_register_conflicting_type_is_structured_error() { let reg = SchemaRegistry::new(); reg.register::("ping").unwrap(); match reg.register::("ping") { Err(BusError::DuplicateRegistration { topic, existing, attempted, }) => { assert_eq!(topic, "ping"); assert!(existing.contains("u32"), "existing was {existing}"); assert!(attempted.contains("String"), "attempted was {attempted}"); } other => panic!("expected DuplicateRegistration, got {other:?}"), } } /// Acceptance bullet 1: messages can be sent between the two /// ends. Smoke test on a paired bus, no workers involved yet. #[test] fn round_trip_preserves_value_in_each_direction() { let reg = registry_with::("search.hit"); let (a, b) = MessageBus::pair(reg); let original = Hit { line: 7, col: 3, snippet: "fn main".into(), }; a.send("search.hit", &original).unwrap(); let env = b.recv().unwrap(); let got: Hit = b.decode(&env).unwrap(); assert_eq!(got, original); let echo = Hit { line: 8, col: 4, snippet: "}".into(), }; b.send("search.hit", &echo).unwrap(); let env = a.recv().unwrap(); let got: Hit = a.decode(&env).unwrap(); assert_eq!(got, echo); } /// Per-bus id counter is monotonic across both directions. #[test] fn message_ids_are_monotonic_across_directions() { let reg = registry_with::("n"); let (a, b) = MessageBus::pair(reg); let id1 = a.send("n", &1u32).unwrap(); let id2 = b.send("n", &2u32).unwrap(); let id3 = a.send("n", &3u32).unwrap(); assert!(id1 < id2 && id2 < id3); } /// Acceptance bullet 2 (send side): unregistered topic is a /// structured error and never reaches the channel. #[test] fn send_unregistered_topic_returns_structured_error() { let reg = Arc::new(SchemaRegistry::new()); let (a, _b) = MessageBus::pair(reg); match a.send("nope", &1u32) { Err(BusError::UnregisteredTopic { topic }) => assert_eq!(topic, "nope"), other => panic!("expected UnregisteredTopic, got {other:?}"), } } /// Acceptance bullet 2 (send side): mismatched type is a /// structured error and never reaches the channel. #[test] fn send_with_wrong_type_returns_schema_mismatch() { let reg = registry_with::("n"); let (a, _b) = MessageBus::pair(reg); match a.send("n", &"hi".to_string()) { Err(BusError::SchemaMismatch { topic, expected, registered, }) => { assert_eq!(topic, "n"); assert!(expected.contains("String"), "expected = {expected}"); assert!(registered.contains("u32"), "registered = {registered}"); } other => panic!("expected SchemaMismatch, got {other:?}"), } } /// Acceptance bullet 2 (decode side): pulling an envelope and /// decoding it at the wrong type fails before the codec runs. #[test] fn decode_with_wrong_type_returns_schema_mismatch() { let reg = registry_with::("n"); let (a, b) = MessageBus::pair(reg); a.send("n", &42u32).unwrap(); let env = b.recv().unwrap(); let result: Result = b.decode(&env); match result { Err(BusError::SchemaMismatch { topic, .. }) => assert_eq!(topic, "n"), other => panic!("expected SchemaMismatch on decode, got {other:?}"), } } /// Acceptance bullet 1, integrated path: a worker pool job sends /// a result through the bus; the main thread receives and /// decodes it. This is the canonical M3.2 use case --- T M3.3 /// will wrap this in a Lua coroutine API. #[test] fn worker_dispatched_job_sends_through_bus() { let reg = registry_with::("worker.result"); let (main_end, worker_end) = MessageBus::pair(reg); let pool = WorkerPool::new(2); let _ = pool.dispatch(move |_| { // A real job would fetch a snapshot, compute, and send. let answer: u64 = (1..=10).sum(); worker_end.send("worker.result", &answer).unwrap(); }); let env = main_end .recv_timeout(Duration::from_secs(2)) .expect("worker reply"); let got: u64 = main_end.decode(&env).unwrap(); assert_eq!(got, 55); } /// Multiple workers each holding a clone of the `BusEnd` should /// all be able to send concurrently; the main end collects every /// envelope. #[test] fn many_workers_share_one_bus_end_clone() { const N: usize = 50; let reg = registry_with::("worker.tick"); let (main_end, worker_end) = MessageBus::pair(reg); let pool = WorkerPool::new(4); for i in 0..N { let bus = worker_end.clone(); let _ = pool.dispatch(move |_| { #[allow(clippy::cast_possible_truncation)] bus.send("worker.tick", &(i as u32)).unwrap(); }); } let mut collected = Vec::with_capacity(N); for _ in 0..N { let env = main_end .recv_timeout(Duration::from_secs(2)) .expect("worker tick"); let v: u32 = main_end.decode(&env).unwrap(); collected.push(v); } collected.sort_unstable(); let expected: Vec = (0..N).map(|i| u32::try_from(i).unwrap()).collect(); assert_eq!(collected, expected); } /// `try_recv` distinguishes empty from disconnected. #[test] fn try_recv_reports_empty_then_disconnected() { let reg = registry_with::("n"); let (a, b) = MessageBus::pair(reg); assert!(matches!(a.try_recv(), Err(BusError::Empty))); drop(b); // After the peer drops, try_recv reports Disconnected (queue // was empty when the peer left). assert!(matches!(a.try_recv(), Err(BusError::Disconnected))); } /// Acceptance bullet 3: round-trip latency under a microsecond /// budget. The spec target is 50 microseconds in release; we /// assert a generous upper bound here so debug-mode CI stays /// green, and rely on the loop average rather than any single /// iteration to absorb scheduler noise. The point is to fail /// loudly if anyone introduces a per-message allocation storm /// (a String topic, a clone of the registry `HashMap`, a Box /// per send, etc). #[test] fn round_trip_latency_is_within_budget() { const ITERS: usize = 5_000; let reg = registry_with::("ping"); let (a, b) = MessageBus::pair(reg); // Warm-up to amortise channel bootstrap. for _ in 0..256 { a.send("ping", &1u32).unwrap(); let _ = b.recv().unwrap(); } let start = Instant::now(); for i in 0..ITERS { #[allow(clippy::cast_possible_truncation)] a.send("ping", &(i as u32)).unwrap(); let env = b.recv().unwrap(); let _: u32 = b.decode(&env).unwrap(); } let elapsed = start.elapsed(); #[allow(clippy::cast_possible_truncation)] let per_op = elapsed / ITERS as u32; // Spec target: 50 microseconds in release. Debug builds run // serde without optimisations, so we assert a 500-microsecond // ceiling that still catches order-of-magnitude regressions. let ceiling = if cfg!(debug_assertions) { Duration::from_micros(500) } else { Duration::from_micros(50) }; assert!( per_op < ceiling, "round-trip averaged {per_op:?} (target {ceiling:?})" ); } /// Acceptance bullet 4 (sentinel): the send hot path produces /// exactly one `Vec` allocation --- the payload buffer --- /// per call. We test this indirectly by verifying that the /// envelope's payload buffer is the *only* heap-bound piece of /// state on the wire (topic is `&'static str`, ids are `u64`). /// Compile-time evidence: changing `topic` to `String` would /// fail this test by adding String allocations per send. #[test] fn envelope_topic_is_borrowed_static_str() { let reg = registry_with::("ping"); let (a, b) = MessageBus::pair(reg); a.send("ping", &1u32).unwrap(); let env = b.recv().unwrap(); // Static-str equality is by pointer, not by content, when // both literals are deduplicated by the linker. Confirm the // envelope's topic *is* the same static slot as the literal. assert!(std::ptr::eq(env.topic, "ping")); // Payload buffer length matches a single u32 in `MessagePack` // (positive fixint or u8 form, ≤ 2 bytes). assert!( env.payload.len() <= 2, "payload was {} bytes, expected ≤2 for a small u32", env.payload.len() ); } /// Disconnect detection: after the peer drops, send returns /// Disconnected without panicking. #[test] fn send_after_peer_drop_returns_disconnected() { let reg = registry_with::("n"); let (a, b) = MessageBus::pair(reg); drop(b); assert!(matches!(a.send("n", &1u32), Err(BusError::Disconnected))); } }