diff --git a/src/protocol/codec.rs b/src/protocol/codec.rs index b5f7f28..e613efa 100644 --- a/src/protocol/codec.rs +++ b/src/protocol/codec.rs @@ -10,9 +10,41 @@ use super::types::{ use crate::protocol::{CallMessage, DataMessage, FaultMessage, PacketHeader, PacketType}; /// Archived-section alignment guaranteed by the frame format. +/// +/// The protocol aligns both archived sections so `rkyv` can usually validate and deserialize +/// them without first copying into a temporary aligned buffer. +/// +/// # Example +/// ```rust +/// use unshell::protocol::SECTION_ALIGN; +/// assert_eq!(SECTION_ALIGN, 16); +/// ``` pub const SECTION_ALIGN: usize = 16; /// Owned framed packet bytes. +/// +/// This is the concrete buffer type returned by [`encode_packet`]. It keeps archived packet bytes +/// aligned according to [`SECTION_ALIGN`] so decode can often stay zero-copy. +/// +/// # Example +/// ```rust +/// use unshell::protocol::{CallMessage, FrameBytes, PacketHeader, PacketType, encode_packet}; +/// let header = PacketHeader { +/// packet_type: PacketType::Call, +/// src_path: vec!["root".into()], +/// dst_path: vec!["root".into(), "worker".into()], +/// dst_leaf: Some("service".into()), +/// hook_id: None, +/// }; +/// let message = CallMessage { +/// procedure_id: "example.service.v1.invoke".into(), +/// data: vec![], +/// response_hook: None, +/// }; +/// let frame: FrameBytes = encode_packet(&header, &message)?; +/// assert!(!frame.is_empty()); +/// # Ok::<(), unshell::protocol::FrameError>(()) +/// ``` pub type FrameBytes = AlignedVec; /// Framing or archive failure. @@ -48,6 +80,29 @@ impl core::error::Error for FrameError {} /// /// The frame decoder eagerly materializes the routing header into owned Rust values, but keeps /// the payload section borrowed so callers can choose which concrete payload type to decode. +/// +/// # Example +/// ```rust +/// use unshell::protocol::{CallMessage, PacketHeader, PacketType, decode_frame, encode_packet}; +/// let header = PacketHeader { +/// packet_type: PacketType::Call, +/// src_path: vec!["root".into()], +/// dst_path: vec!["root".into(), "worker".into()], +/// dst_leaf: Some("service".into()), +/// hook_id: None, +/// }; +/// let message = CallMessage { +/// procedure_id: "example.service.v1.invoke".into(), +/// data: vec![7; 4], +/// response_hook: None, +/// }; +/// let frame = encode_packet(&header, &message)?; +/// let parsed = decode_frame(&frame)?; +/// assert_eq!(parsed.packet_type(), PacketType::Call); +/// let decoded = parsed.deserialize_call()?; +/// assert_eq!(decoded.data.len(), 4); +/// # Ok::<(), unshell::protocol::FrameError>(()) +/// ``` pub struct ParsedFrame<'a> { header: PacketHeader, payload_bytes: &'a [u8], @@ -56,39 +111,197 @@ pub struct ParsedFrame<'a> { impl<'a> ParsedFrame<'a> { #[must_use] /// Returns the decoded packet header. + /// + /// This exists so callers can inspect routing metadata before deciding which payload schema + /// to decode. + /// + /// # Example + /// ```rust + /// use unshell::protocol::{CallMessage, PacketHeader, PacketType, decode_frame, encode_packet}; + /// let header = PacketHeader { + /// packet_type: PacketType::Call, + /// src_path: vec!["root".into()], + /// dst_path: vec!["worker".into()], + /// dst_leaf: None, + /// hook_id: None, + /// }; + /// let frame = encode_packet(&header, &CallMessage { + /// procedure_id: "example.invoke".into(), + /// data: vec![], + /// response_hook: None, + /// })?; + /// let parsed = decode_frame(&frame)?; + /// assert_eq!(parsed.header().packet_type, PacketType::Call); + /// # Ok::<(), unshell::protocol::FrameError>(()) + /// ``` pub fn header(&self) -> &PacketHeader { &self.header } #[must_use] /// Returns the packet class from the decoded header. + /// + /// This exists as a cheap dispatch helper so callers do not have to reach into the header + /// struct directly when branching on payload type. + /// + /// # Example + /// ```rust + /// use unshell::protocol::{CallMessage, PacketHeader, PacketType, decode_frame, encode_packet}; + /// let header = PacketHeader { + /// packet_type: PacketType::Call, + /// src_path: vec!["root".into()], + /// dst_path: vec!["worker".into()], + /// dst_leaf: None, + /// hook_id: None, + /// }; + /// let frame = encode_packet(&header, &CallMessage { + /// procedure_id: "example.invoke".into(), + /// data: vec![], + /// response_hook: None, + /// })?; + /// let parsed = decode_frame(&frame)?; + /// assert!(matches!(parsed.packet_type(), PacketType::Call)); + /// # Ok::<(), unshell::protocol::FrameError>(()) + /// ``` pub fn packet_type(&self) -> PacketType { self.header.packet_type } #[must_use] /// Returns the borrowed payload section bytes. + /// + /// This exists for callers that embed their own archived application payloads inside protocol + /// `data` fields and want to defer typed decoding. + /// + /// # Example + /// ```rust + /// use unshell::protocol::{CallMessage, PacketHeader, PacketType, decode_frame, encode_packet}; + /// let header = PacketHeader { + /// packet_type: PacketType::Call, + /// src_path: vec!["root".into()], + /// dst_path: vec!["worker".into()], + /// dst_leaf: None, + /// hook_id: None, + /// }; + /// let frame = encode_packet(&header, &CallMessage { + /// procedure_id: "example.invoke".into(), + /// data: vec![1, 2, 3], + /// response_hook: None, + /// })?; + /// let parsed = decode_frame(&frame)?; + /// assert!(!parsed.payload_bytes().is_empty()); + /// # Ok::<(), unshell::protocol::FrameError>(()) + /// ``` pub fn payload_bytes(&self) -> &'a [u8] { self.payload_bytes } #[must_use] /// Splits the parsed frame into its owned header and borrowed payload bytes. + /// + /// This exists when callers want to take ownership of the decoded header while still choosing + /// how and when to interpret the payload bytes. + /// + /// # Example + /// ```rust + /// use unshell::protocol::{CallMessage, PacketHeader, PacketType, decode_frame, encode_packet}; + /// let header = PacketHeader { + /// packet_type: PacketType::Call, + /// src_path: vec!["root".into()], + /// dst_path: vec!["worker".into()], + /// dst_leaf: None, + /// hook_id: None, + /// }; + /// let frame = encode_packet(&header, &CallMessage { + /// procedure_id: "example.invoke".into(), + /// data: vec![], + /// response_hook: None, + /// })?; + /// let parsed = decode_frame(&frame)?; + /// let (owned_header, payload) = parsed.into_parts(); + /// assert_eq!(owned_header.packet_type, PacketType::Call); + /// assert!(!payload.is_empty()); + /// # Ok::<(), unshell::protocol::FrameError>(()) + /// ``` pub fn into_parts(self) -> (PacketHeader, &'a [u8]) { (self.header, self.payload_bytes) } /// Deserializes the payload section as a [`CallMessage`]. + /// + /// This exists so callers can decode a validated `Call` packet payload without spelling the + /// archived-type details themselves. + /// + /// # Example + /// ```rust + /// use unshell::protocol::{CallMessage, PacketHeader, PacketType, decode_frame, encode_packet}; + /// let message = CallMessage { + /// procedure_id: "example.invoke".into(), + /// data: vec![1], + /// response_hook: None, + /// }; + /// let frame = encode_packet(&PacketHeader { + /// packet_type: PacketType::Call, + /// src_path: vec!["root".into()], + /// dst_path: vec!["worker".into()], + /// dst_leaf: None, + /// hook_id: None, + /// }, &message)?; + /// let parsed = decode_frame(&frame)?; + /// assert_eq!(parsed.deserialize_call()?.procedure_id, message.procedure_id); + /// # Ok::<(), unshell::protocol::FrameError>(()) + /// ``` pub fn deserialize_call(&self) -> Result { self.deserialize_payload::() } /// Deserializes the payload section as a [`DataMessage`]. + /// + /// This exists so callers can decode hook `Data` payloads without reaching for the generic + /// archived helper directly. + /// + /// # Example + /// ```rust + /// use unshell::protocol::{DataMessage, PacketHeader, PacketType, decode_frame, encode_packet}; + /// let message = DataMessage { + /// procedure_id: "example.invoke".into(), + /// data: vec![1], + /// end_hook: false, + /// }; + /// let frame = encode_packet(&PacketHeader { + /// packet_type: PacketType::Data, + /// src_path: vec!["worker".into()], + /// dst_path: vec!["root".into()], + /// dst_leaf: None, + /// hook_id: Some(7), + /// }, &message)?; + /// let parsed = decode_frame(&frame)?; + /// assert!(!parsed.deserialize_data()?.end_hook); + /// # Ok::<(), unshell::protocol::FrameError>(()) + /// ``` pub fn deserialize_data(&self) -> Result { self.deserialize_payload::() } /// Deserializes the payload section as a [`FaultMessage`]. + /// + /// This exists so callers can decode protocol faults with the same selective API used for + /// call and data packets. + /// + /// # Example + /// ```rust + /// use unshell::protocol::{FaultMessage, PacketHeader, PacketType, ProtocolFault, decode_frame, encode_packet}; + /// let frame = encode_packet(&PacketHeader { + /// packet_type: PacketType::Fault, + /// src_path: vec!["worker".into()], + /// dst_path: vec!["root".into()], + /// dst_leaf: None, + /// hook_id: Some(7), + /// }, &FaultMessage { fault: ProtocolFault::INTERNAL_ERROR })?; + /// let parsed = decode_frame(&frame)?; + /// assert_eq!(parsed.deserialize_fault()?.fault, ProtocolFault::INTERNAL_ERROR); + /// # Ok::<(), unshell::protocol::FrameError>(()) + /// ``` pub fn deserialize_fault(&self) -> Result { self.deserialize_payload::() } @@ -109,6 +322,27 @@ impl<'a> ParsedFrame<'a> { /// The frame starts with two big-endian `u32` lengths, followed by an aligned archived header /// section and an aligned archived payload section. Both sections use [`SECTION_ALIGN`] so the /// archived bytes can usually be accessed without a fallback copy on decode. +/// +/// # Example +/// ```rust +/// use unshell::protocol::{CallMessage, PacketHeader, PacketType, encode_packet}; +/// let frame = encode_packet( +/// &PacketHeader { +/// packet_type: PacketType::Call, +/// src_path: vec!["root".into()], +/// dst_path: vec!["worker".into()], +/// dst_leaf: Some("service".into()), +/// hook_id: None, +/// }, +/// &CallMessage { +/// procedure_id: "example.invoke".into(), +/// data: vec![1, 2, 3], +/// response_hook: None, +/// }, +/// )?; +/// assert!(frame.len() >= 8); +/// # Ok::<(), unshell::protocol::FrameError>(()) +/// ``` pub fn encode_packet

(header: &PacketHeader, payload: &P) -> Result where P: for<'a> Serialize< @@ -139,6 +373,28 @@ where /// /// This rejects trailing bytes instead of silently ignoring them, so callers can treat one byte /// slice as exactly one protocol frame. +/// +/// # Example +/// ```rust +/// use unshell::protocol::{CallMessage, PacketHeader, PacketType, decode_frame, encode_packet}; +/// let frame = encode_packet( +/// &PacketHeader { +/// packet_type: PacketType::Call, +/// src_path: vec!["root".into()], +/// dst_path: vec!["worker".into()], +/// dst_leaf: Some("service".into()), +/// hook_id: None, +/// }, +/// &CallMessage { +/// procedure_id: "example.invoke".into(), +/// data: vec![1, 2, 3], +/// response_hook: None, +/// }, +/// )?; +/// let parsed = decode_frame(&frame)?; +/// assert_eq!(parsed.packet_type(), PacketType::Call); +/// # Ok::<(), unshell::protocol::FrameError>(()) +/// ``` pub fn decode_frame(bytes: &[u8]) -> Result, FrameError> { let (header_bytes, payload_bytes) = split_frame_sections(bytes)?; let header = deserialize_section::( @@ -157,6 +413,22 @@ pub fn decode_frame(bytes: &[u8]) -> Result, FrameError> { /// Payload bytes normally come from [`decode_frame`] or one of [`ParsedFrame`]`'s` /// `deserialize_*` helpers. This function remains public for callers that archive nested /// application payloads inside protocol `data` fields. +/// +/// # Example +/// ```rust +/// use rkyv::{Archive, Deserialize, Serialize}; +/// use unshell::protocol::deserialize_archived_bytes; +/// +/// #[derive(Archive, Serialize, Deserialize, Debug, PartialEq)] +/// struct Example { +/// value: u32, +/// } +/// +/// let bytes = rkyv::to_bytes::(&Example { value: 7 }).unwrap(); +/// let decoded = deserialize_archived_bytes::<::Archived, Example>(&bytes)?; +/// assert_eq!(decoded, Example { value: 7 }); +/// # Ok::<(), unshell::protocol::FrameError>(()) +/// ``` pub fn deserialize_archived_bytes(bytes: &[u8]) -> Result where A: rkyv::Portable diff --git a/src/protocol/introspection.rs b/src/protocol/introspection.rs index d61363a..8352bda 100644 --- a/src/protocol/introspection.rs +++ b/src/protocol/introspection.rs @@ -1,4 +1,19 @@ //! Required introspection payloads for discovery. +//! +//! These types define the reserved discovery subsystem of the protocol. Endpoints use the +//! reserved empty-string procedure id to request either endpoint-wide discovery or one leaf's +//! exact procedure inventory. +//! +//! # Example +//! ```rust +//! use unshell::protocol::{EndpointIntrospection, INTROSPECTION_PROCEDURE_ID}; +//! let payload = EndpointIntrospection { +//! sub_endpoints: vec!["worker".into()], +//! leaves: vec![], +//! }; +//! assert_eq!(INTROSPECTION_PROCEDURE_ID, ""); +//! assert_eq!(payload.sub_endpoints[0], "worker"); +//! ``` use alloc::{string::String, vec::Vec}; use rkyv::{Archive, Deserialize, Serialize}; @@ -8,9 +23,28 @@ use rkyv::{Archive, Deserialize, Serialize}; /// The protocol uses the empty string here so discovery traffic stays outside the normal /// application procedure namespace. [`crate::protocol::validate_procedure_id`] reserves that /// value exclusively for introspection. +/// +/// # Example +/// ```rust +/// use unshell::protocol::INTROSPECTION_PROCEDURE_ID; +/// assert!(INTROSPECTION_PROCEDURE_ID.is_empty()); +/// ``` pub const INTROSPECTION_PROCEDURE_ID: &str = ""; /// Endpoint-wide introspection payload. +/// +/// This is returned when discovery targets an endpoint path without selecting one specific leaf. +/// It exists so clients can enumerate direct child endpoints and the leaves hosted locally. +/// +/// # Example +/// ```rust +/// use unshell::protocol::EndpointIntrospection; +/// let payload = EndpointIntrospection { +/// sub_endpoints: vec!["worker".into()], +/// leaves: vec![], +/// }; +/// assert_eq!(payload.sub_endpoints.len(), 1); +/// ``` #[derive(Archive, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] pub struct EndpointIntrospection { /// Direct child endpoint segment names hosted immediately below this endpoint. @@ -20,6 +54,19 @@ pub struct EndpointIntrospection { } /// Shared per-leaf discovery record. +/// +/// This compact shape exists so endpoint-wide discovery can advertise each hosted leaf without +/// sending the full endpoint envelope again. +/// +/// # Example +/// ```rust +/// use unshell::protocol::LeafIntrospectionSummary; +/// let summary = LeafIntrospectionSummary { +/// leaf_name: "org.example.v1.echo".into(), +/// procedures: vec!["org.example.v1.echo.invoke".into()], +/// }; +/// assert_eq!(summary.procedures.len(), 1); +/// ``` #[derive(Archive, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] pub struct LeafIntrospectionSummary { /// Canonical dotted leaf identifier. @@ -32,6 +79,16 @@ pub struct LeafIntrospectionSummary { /// /// This duplicates [`LeafIntrospectionSummary`] intentionally because the leaf-only response is /// a distinct wire payload from the endpoint-wide discovery response. +/// +/// # Example +/// ```rust +/// use unshell::protocol::LeafIntrospection; +/// let payload = LeafIntrospection { +/// leaf_name: "org.example.v1.echo".into(), +/// procedures: vec!["org.example.v1.echo.invoke".into()], +/// }; +/// assert_eq!(payload.leaf_name, "org.example.v1.echo"); +/// ``` #[derive(Archive, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] pub struct LeafIntrospection { /// Canonical dotted leaf identifier. diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs index 12a7ed9..6f482c6 100644 --- a/src/protocol/mod.rs +++ b/src/protocol/mod.rs @@ -10,6 +10,35 @@ //! //! The concrete wire structs live in the private `types` module and are re-exported here so the //! public API stays flat while internal archived-type details remain hidden. +//! +//! # Example +//! ```rust +//! use unshell::protocol::{ +//! CallMessage, PacketHeader, PacketType, decode_frame, encode_packet, validate_call, +//! validate_header, +//! }; +//! +//! let header = PacketHeader { +//! packet_type: PacketType::Call, +//! src_path: vec!["root".into()], +//! dst_path: vec!["root".into(), "worker".into()], +//! dst_leaf: Some("service".into()), +//! hook_id: None, +//! }; +//! let call = CallMessage { +//! procedure_id: "example.service.v1.invoke".into(), +//! data: vec![1, 2, 3], +//! response_hook: None, +//! }; +//! +//! validate_header(&header).unwrap(); +//! validate_call(&header, &call).unwrap(); +//! let frame = encode_packet(&header, &call)?; +//! let parsed = decode_frame(&frame)?; +//! let decoded = parsed.deserialize_call()?; +//! assert_eq!(decoded.procedure_id, call.procedure_id); +//! # Ok::<(), unshell::protocol::FrameError>(()) +//! ``` pub mod codec; pub mod introspection; diff --git a/src/protocol/tree/call.rs b/src/protocol/tree/call.rs index 31504ac..47673db 100644 --- a/src/protocol/tree/call.rs +++ b/src/protocol/tree/call.rs @@ -14,6 +14,22 @@ use super::{ }; /// One typed incoming `Call` passed to a leaf procedure. +/// +/// This exists so application code can work with a decoded request type plus the protocol context +/// that matters for authorization, routing, or replies. +/// +/// # Example +/// ```rust +/// use unshell::protocol::tree::{Call, HookKey}; +/// let call = Call { +/// input: String::from("hello"), +/// caller_path: vec!["root".into()], +/// procedure_id: "org.example.v1.echo.invoke".into(), +/// dst_leaf: Some("echo".into()), +/// response_hook: Some(HookKey::new(vec!["root".into()], 7)), +/// }; +/// assert_eq!(call.input, "hello"); +/// ``` #[derive(Debug, Clone, PartialEq, Eq)] pub struct Call { /// Decoded application input payload. @@ -29,6 +45,30 @@ pub struct Call { } /// One incoming local call event that already passed protocol validation. +/// +/// This exists for dispatch layers that still want direct access to the raw protocol payload +/// before converting it into a typed [`Call`]. +/// +/// # Example +/// ```rust +/// use unshell::protocol::{CallMessage, PacketHeader, PacketType}; +/// use unshell::protocol::tree::IncomingCall; +/// let call = IncomingCall { +/// header: PacketHeader { +/// packet_type: PacketType::Call, +/// src_path: vec!["root".into()], +/// dst_path: vec!["worker".into()], +/// dst_leaf: None, +/// hook_id: None, +/// }, +/// message: CallMessage { +/// procedure_id: "example.invoke".into(), +/// data: vec![], +/// response_hook: None, +/// }, +/// }; +/// assert_eq!(call.message.procedure_id, "example.invoke"); +/// ``` #[derive(Debug, Clone, PartialEq, Eq)] pub struct IncomingCall { /// Validated protocol header for the call. @@ -38,6 +78,31 @@ pub struct IncomingCall { } /// One incoming local data event tied to an active hook. +/// +/// This exists so hook-aware leaf code receives both the payload and the resolved hook identity +/// that owns the stream. +/// +/// # Example +/// ```rust +/// use unshell::protocol::{DataMessage, PacketHeader, PacketType}; +/// use unshell::protocol::tree::{HookKey, IncomingData}; +/// let data = IncomingData { +/// header: PacketHeader { +/// packet_type: PacketType::Data, +/// src_path: vec!["worker".into()], +/// dst_path: vec!["root".into()], +/// dst_leaf: None, +/// hook_id: Some(7), +/// }, +/// message: DataMessage { +/// procedure_id: "example.invoke".into(), +/// data: vec![1], +/// end_hook: false, +/// }, +/// hook_key: HookKey::new(vec!["root".into()], 7), +/// }; +/// assert_eq!(data.hook_key.hook_id, 7); +/// ``` #[derive(Debug, Clone, PartialEq, Eq)] pub struct IncomingData { /// Validated protocol header for the data packet. @@ -49,6 +114,27 @@ pub struct IncomingData { } /// One incoming local fault event tied to a pending or active hook. +/// +/// This exists so leaf code can observe upstream protocol termination and release any +/// application-level resources associated with the hook. +/// +/// # Example +/// ```rust +/// use unshell::protocol::{FaultMessage, PacketHeader, PacketType, ProtocolFault}; +/// use unshell::protocol::tree::{HookKey, IncomingFault}; +/// let fault = IncomingFault { +/// header: PacketHeader { +/// packet_type: PacketType::Fault, +/// src_path: vec!["worker".into()], +/// dst_path: vec!["root".into()], +/// dst_leaf: None, +/// hook_id: Some(7), +/// }, +/// fault: FaultMessage { fault: ProtocolFault::INTERNAL_ERROR }, +/// hook_key: HookKey::new(vec!["root".into()], 7), +/// }; +/// assert_eq!(fault.hook_key.hook_id, 7); +/// ``` #[derive(Debug, Clone, PartialEq, Eq)] pub struct IncomingFault { /// Validated protocol header for the fault packet. @@ -60,6 +146,16 @@ pub struct IncomingFault { } /// Outcome of one generated initial call procedure. +/// +/// This exists for generated one-shot leaf procedures that either emit one reply payload or +/// intentionally complete without any returned hook traffic. +/// +/// # Example +/// ```rust +/// use unshell::protocol::tree::CallResult; +/// let reply: CallResult = CallResult::Reply("hello".into()); +/// assert!(matches!(reply, CallResult::Reply(_))); +/// ``` #[derive(Debug, Clone, PartialEq, Eq)] pub enum CallResult { /// Return one reply payload to the caller. @@ -69,6 +165,22 @@ pub enum CallResult { } /// One hook-associated `Data` packet emitted by leaf code. +/// +/// This exists as the normalized outbound unit produced by leaf code before the runtime turns it +/// into framed protocol traffic. +/// +/// # Example +/// ```rust +/// use unshell::protocol::tree::OutgoingData; +/// let packet = OutgoingData { +/// dst_path: vec!["root".into()], +/// hook_id: 7, +/// procedure_id: "example.invoke".into(), +/// data: vec![1, 2, 3], +/// end_hook: true, +/// }; +/// assert!(packet.end_hook); +/// ``` #[derive(Debug, Clone, PartialEq, Eq)] pub struct OutgoingData { /// Destination endpoint path for the hook packet. @@ -84,6 +196,16 @@ pub struct OutgoingData { } /// One runtime-normalized reply produced by generated call dispatch. +/// +/// This exists because generated call dispatch always normalizes leaf return values into either +/// serialized reply bytes or an explicit “no reply” outcome. +/// +/// # Example +/// ```rust +/// use unshell::protocol::tree::CallReply; +/// let reply = CallReply::Reply(vec![1, 2, 3]); +/// assert!(matches!(reply, CallReply::Reply(_))); +/// ``` #[derive(Debug, Clone, PartialEq, Eq)] pub enum CallReply { /// Serialized reply bytes that should be returned upstream. @@ -93,6 +215,17 @@ pub enum CallReply { } /// Error surfaced while decoding one incoming call or encoding one generated reply. +/// +/// This exists so generated dispatch can keep decode, encode, and handler failures distinct while +/// still using one error channel. +/// +/// # Example +/// ```rust +/// use unshell::protocol::{FrameError}; +/// use unshell::protocol::tree::DispatchError; +/// let error: DispatchError = DispatchError::Decode(FrameError::Truncated); +/// assert!(matches!(error, DispatchError::Decode(_))); +/// ``` #[derive(Debug)] pub enum DispatchError { /// Failed to decode the typed call input. @@ -119,6 +252,17 @@ where impl core::error::Error for DispatchError where E: core::error::Error + 'static {} /// Error surfaced by the stateful leaf runtime. +/// +/// This exists so callers can distinguish transport/runtime failures from leaf-local business +/// logic failures. +/// +/// # Example +/// ```rust +/// use unshell::protocol::{FrameError}; +/// use unshell::protocol::tree::{DispatchError, LeafRuntimeError}; +/// let error: LeafRuntimeError = LeafRuntimeError::Dispatch(DispatchError::Decode(FrameError::Truncated)); +/// assert!(matches!(error, LeafRuntimeError::Dispatch(_))); +/// ``` #[derive(Debug)] pub enum LeafRuntimeError { /// Protocol endpoint routing or framing failed. @@ -151,6 +295,21 @@ impl From for LeafRuntimeError { } /// High-level leaf behavior layered on top of validated protocol events. +/// +/// This exists for leaves that want validated call/data/fault delivery without managing endpoint +/// routing details themselves. +/// +/// # Example +/// ```rust +/// use unshell::protocol::tree::CallLeaf; +/// struct ExampleLeaf; +/// impl unshell::protocol::tree::ProtocolLeaf for ExampleLeaf { +/// fn leaf_name() -> String { "org.example.v1.echo".into() } +/// } +/// impl CallLeaf for ExampleLeaf { +/// type Error = core::convert::Infallible; +/// } +/// ``` pub trait CallLeaf: ProtocolLeaf { /// Leaf-specific error surfaced by call, data, or fault handling. type Error; @@ -172,6 +331,16 @@ pub trait CallLeaf: ProtocolLeaf { } /// Stateful runtime that combines a protocol endpoint with one leaf instance. +/// +/// This exists as the high-level runtime for simple one-shot call procedures plus hook data/fault +/// handling. +/// +/// # Example +/// ```rust +/// use unshell::protocol::tree::LeafRuntime; +/// # struct Leaf; +/// # let _ = core::marker::PhantomData::>; +/// ``` #[derive(Debug)] pub struct LeafRuntime { endpoint: ProtocolEndpoint, @@ -179,6 +348,16 @@ pub struct LeafRuntime { } /// Frames emitted by the runtime after one receive or poll step. +/// +/// This exists so callers can flush emitted frames to transport while also learning whether the +/// inbound packet was intentionally dropped. +/// +/// # Example +/// ```rust +/// use unshell::protocol::tree::RuntimeOutcome; +/// let outcome = RuntimeOutcome::default(); +/// assert!(outcome.frames.is_empty()); +/// ``` #[derive(Debug, Default)] pub struct RuntimeOutcome { /// Frames emitted while processing the step. @@ -190,28 +369,68 @@ pub struct RuntimeOutcome { impl LeafRuntime { /// Builds a runtime from one endpoint and one leaf instance. #[must_use] + /// + /// # Example + /// ```rust + /// use unshell::protocol::tree::{LeafRuntime, ProtocolEndpoint}; + /// struct ExampleLeaf; + /// let runtime = LeafRuntime::new(ProtocolEndpoint::new(Vec::new(), None, Vec::new(), Vec::new()), ExampleLeaf); + /// let _ = runtime; + /// ``` pub fn new(endpoint: ProtocolEndpoint, leaf: L) -> Self { Self { endpoint, leaf } } /// Returns the underlying protocol endpoint. #[must_use] + /// + /// # Example + /// ```rust + /// use unshell::protocol::tree::{LeafRuntime, ProtocolEndpoint}; + /// struct ExampleLeaf; + /// let runtime = LeafRuntime::new(ProtocolEndpoint::new(Vec::new(), None, Vec::new(), Vec::new()), ExampleLeaf); + /// let _endpoint = runtime.endpoint(); + /// ``` pub fn endpoint(&self) -> &ProtocolEndpoint { &self.endpoint } /// Returns a mutable reference to the underlying endpoint. + /// + /// # Example + /// ```rust + /// use unshell::protocol::tree::{LeafRuntime, ProtocolEndpoint}; + /// struct ExampleLeaf; + /// let mut runtime = LeafRuntime::new(ProtocolEndpoint::new(Vec::new(), None, Vec::new(), Vec::new()), ExampleLeaf); + /// let _endpoint = runtime.endpoint_mut(); + /// ``` pub fn endpoint_mut(&mut self) -> &mut ProtocolEndpoint { &mut self.endpoint } /// Returns the hosted leaf instance. #[must_use] + /// + /// # Example + /// ```rust + /// use unshell::protocol::tree::{LeafRuntime, ProtocolEndpoint}; + /// struct ExampleLeaf; + /// let runtime = LeafRuntime::new(ProtocolEndpoint::new(Vec::new(), None, Vec::new(), Vec::new()), ExampleLeaf); + /// let _leaf = runtime.leaf(); + /// ``` pub fn leaf(&self) -> &L { &self.leaf } /// Returns a mutable reference to the hosted leaf instance. + /// + /// # Example + /// ```rust + /// use unshell::protocol::tree::{LeafRuntime, ProtocolEndpoint}; + /// struct ExampleLeaf; + /// let mut runtime = LeafRuntime::new(ProtocolEndpoint::new(Vec::new(), None, Vec::new(), Vec::new()), ExampleLeaf); + /// let _leaf = runtime.leaf_mut(); + /// ``` pub fn leaf_mut(&mut self) -> &mut L { &mut self.leaf } @@ -222,6 +441,13 @@ where L: CallLeaf + super::CallProcedures::Error>, { /// Delivers one inbound frame into the stateful leaf runtime. + /// + /// # Example + /// ```rust + /// # use unshell::protocol::tree::{LeafRuntime, ProtocolEndpoint}; + /// # struct ExampleLeaf; + /// # let _ = core::marker::PhantomData::>; + /// ``` pub fn receive( &mut self, ingress: &Ingress, @@ -232,6 +458,13 @@ where } /// Polls the leaf for locally-generated hook traffic and routes any emitted frames. + /// + /// # Example + /// ```rust + /// # use unshell::protocol::tree::{LeafRuntime, ProtocolEndpoint}; + /// # struct ExampleLeaf; + /// # let _ = core::marker::PhantomData::>; + /// ``` pub fn poll(&mut self) -> Result::Error>> { let outgoing = self.leaf.poll().map_err(LeafRuntimeError::Leaf)?; self.emit_outgoing(outgoing) @@ -309,8 +542,9 @@ where } Ok(CallReply::NoReply) => Ok(RuntimeOutcome::default()), Err(error) => { - let frames = self.emit_internal_fault_if_possible(fault_hook)?; - let _ = frames; + // Dispatch failures still emit a protocol fault for the remote caller when a + // response hook exists, even though the local runtime also surfaces the error. + let _ = self.emit_internal_fault_if_possible(fault_hook)?; Err(LeafRuntimeError::Dispatch(error)) } } @@ -402,6 +636,21 @@ where } /// Decodes one archived call payload into a typed application request. +/// +/// This exists for generated and manual leaf code that stores its own typed `rkyv` payload inside +/// protocol `CallMessage::data` bytes. +/// +/// # Example +/// ```rust +/// use rkyv::{Archive, Deserialize, Serialize}; +/// use unshell::protocol::tree::{decode_call_input, encode_call_reply}; +/// #[derive(Archive, Serialize, Deserialize, Debug, PartialEq)] +/// struct Example { value: u32 } +/// let bytes = encode_call_reply(&Example { value: 7 })?; +/// let decoded = decode_call_input::(&bytes)?; +/// assert_eq!(decoded, Example { value: 7 }); +/// # Ok::<(), unshell::protocol::FrameError>(()) +/// ``` pub fn decode_call_input(bytes: &[u8]) -> Result where T: Archive, @@ -413,6 +662,20 @@ where } /// Encodes one typed application reply into hook `Data` bytes. +/// +/// This exists for generated and manual leaf code that wants to place one typed `rkyv` payload in +/// the `data` field of a returned hook packet. +/// +/// # Example +/// ```rust +/// use rkyv::{Archive, Deserialize, Serialize}; +/// use unshell::protocol::tree::encode_call_reply; +/// #[derive(Archive, Serialize, Deserialize, Debug, PartialEq)] +/// struct Example { value: u32 } +/// let bytes = encode_call_reply(&Example { value: 7 })?; +/// assert!(!bytes.is_empty()); +/// # Ok::<(), unshell::protocol::FrameError>(()) +/// ``` pub fn encode_call_reply(value: &T) -> Result, FrameError> where T: for<'a> Serialize< diff --git a/src/protocol/tree/endpoint/builders.rs b/src/protocol/tree/endpoint/builders.rs index 85bdfb3..dc2da34 100644 --- a/src/protocol/tree/endpoint/builders.rs +++ b/src/protocol/tree/endpoint/builders.rs @@ -107,6 +107,21 @@ impl ProtocolEndpoint { /// /// `parent_path` is currently used only as a presence flag. The endpoint stores its own /// absolute `path`, and routing only needs to know whether an upward route exists. + /// + /// # Example + /// ```rust + /// use unshell::protocol::tree::{ChildRoute, LeafSpec, ProtocolEndpoint}; + /// let endpoint = ProtocolEndpoint::new( + /// vec!["worker".into()], + /// Some(Vec::new()), + /// vec![ChildRoute::registered(vec!["worker".into(), "child".into()])], + /// vec![LeafSpec { + /// name: "service".into(), + /// procedures: vec!["example.service.v1.invoke".into()], + /// }], + /// ); + /// let _ = endpoint; + /// ``` pub fn new( path: Vec, parent_path: Option>, @@ -133,6 +148,17 @@ impl ProtocolEndpoint { } /// Registers a procedure that is handled directly by the endpoint. + /// + /// Endpoint-level procedures exist for protocol services that are not attached to one leaf, + /// such as built-in runtime behavior. + /// + /// # Example + /// ```rust + /// use unshell::protocol::tree::ProtocolEndpoint; + /// let mut endpoint = ProtocolEndpoint::new(Vec::new(), None, Vec::new(), Vec::new()); + /// endpoint.add_endpoint_procedure("example.endpoint.v1.health")?; + /// # Ok::<(), unshell::protocol::tree::EndpointError>(()) + /// ``` pub fn add_endpoint_procedure( &mut self, procedure_id: impl Into, @@ -145,11 +171,37 @@ impl ProtocolEndpoint { #[must_use] /// Allocates a hook id scoped to this endpoint path. + /// + /// # Example + /// ```rust + /// use unshell::protocol::tree::ProtocolEndpoint; + /// let mut endpoint = ProtocolEndpoint::new(Vec::new(), None, Vec::new(), Vec::new()); + /// let hook_id = endpoint.allocate_hook_id(); + /// assert_ne!(hook_id, 0); + /// ``` pub fn allocate_hook_id(&mut self) -> u64 { self.hooks.allocate_hook_id(&self.path) } /// Encodes a call frame without routing it through the local endpoint. + /// + /// This exists for callers that want a fully encoded outbound frame while handling transport + /// themselves. + /// + /// # Example + /// ```rust + /// use unshell::protocol::tree::ProtocolEndpoint; + /// let mut endpoint = ProtocolEndpoint::new(Vec::new(), None, Vec::new(), Vec::new()); + /// let frame = endpoint.make_call( + /// vec!["worker".into()], + /// Some("service".into()), + /// "example.service.v1.invoke", + /// None, + /// vec![1, 2, 3], + /// )?; + /// assert!(!frame.is_empty()); + /// # Ok::<(), unshell::protocol::tree::EndpointError>(()) + /// ``` pub fn make_call( &mut self, dst_path: Vec, @@ -165,6 +217,26 @@ impl ProtocolEndpoint { } /// Builds and immediately routes a call, producing either a forward or a local event. + /// + /// # Example + /// ```rust + /// use unshell::protocol::tree::{ChildRoute, EndpointOutcome, ProtocolEndpoint}; + /// let mut endpoint = ProtocolEndpoint::new( + /// Vec::new(), + /// None, + /// vec![ChildRoute::registered(vec!["worker".into()])], + /// Vec::new(), + /// ); + /// let outcome = endpoint.send_call( + /// vec!["worker".into()], + /// Some("service".into()), + /// "example.service.v1.invoke", + /// None, + /// vec![], + /// )?; + /// assert!(matches!(outcome, EndpointOutcome::Forward { .. } | EndpointOutcome::Dropped | EndpointOutcome::Local(_))); + /// # Ok::<(), unshell::protocol::tree::EndpointError>(()) + /// ``` pub fn send_call( &mut self, dst_path: Vec, @@ -191,6 +263,15 @@ impl ProtocolEndpoint { } /// Encodes a data frame without routing it through the local endpoint. + /// + /// # Example + /// ```rust + /// use unshell::protocol::tree::ProtocolEndpoint; + /// let endpoint = ProtocolEndpoint::new(Vec::new(), None, Vec::new(), Vec::new()); + /// let frame = endpoint.make_data(vec!["root".into()], 7, "example.service.v1.invoke", vec![1], false)?; + /// assert!(!frame.is_empty()); + /// # Ok::<(), unshell::protocol::tree::EndpointError>(()) + /// ``` pub fn make_data( &self, dst_path: Vec, @@ -205,6 +286,14 @@ impl ProtocolEndpoint { } /// Builds and immediately routes a data packet, updating local hook state for end-of-stream. + /// + /// # Example + /// ```rust + /// use unshell::protocol::tree::ProtocolEndpoint; + /// let mut endpoint = ProtocolEndpoint::new(Vec::new(), None, Vec::new(), Vec::new()); + /// let _ = endpoint.send_data(vec!["root".into()], 7, "example.service.v1.invoke", vec![], false); + /// # Ok::<(), unshell::protocol::tree::EndpointError>(()) + /// ``` pub fn send_data( &mut self, dst_path: Vec, diff --git a/src/protocol/tree/endpoint/core.rs b/src/protocol/tree/endpoint/core.rs index 3f1bee0..1b540b6 100644 --- a/src/protocol/tree/endpoint/core.rs +++ b/src/protocol/tree/endpoint/core.rs @@ -14,6 +14,16 @@ use crate::protocol::{ use super::super::{CompiledRoutes, HookKey, HookTable, RouteDecision}; /// Routing metadata for one direct child endpoint. +/// +/// This exists so one endpoint can distinguish topology from registration state. A child path may +/// be known structurally while still being excluded from route decisions. +/// +/// # Example +/// ```rust +/// use unshell::protocol::tree::ChildRoute; +/// let route = ChildRoute::registered(vec!["root".into(), "worker".into()]); +/// assert!(route.registered); +/// ``` #[derive(Debug, Clone, PartialEq, Eq)] pub struct ChildRoute { /// Absolute path for the child endpoint inside the protocol tree. @@ -25,6 +35,13 @@ pub struct ChildRoute { impl ChildRoute { #[must_use] /// Builds one child route that is immediately eligible for routing decisions. + /// + /// # Example + /// ```rust + /// use unshell::protocol::tree::ChildRoute; + /// let route = ChildRoute::registered(vec!["worker".into()]); + /// assert!(route.registered); + /// ``` pub fn registered(path: Vec) -> Self { Self { path, @@ -34,6 +51,19 @@ impl ChildRoute { } /// Procedures exposed by a named leaf attached to this endpoint. +/// +/// This exists so endpoint construction can advertise one leaf's callable procedure ids up front, +/// before any runtime packets arrive. +/// +/// # Example +/// ```rust +/// use unshell::protocol::tree::LeafSpec; +/// let leaf = LeafSpec { +/// name: "service".into(), +/// procedures: vec!["example.service.v1.invoke".into()], +/// }; +/// assert_eq!(leaf.procedures.len(), 1); +/// ``` #[derive(Debug, Clone, PartialEq, Eq)] pub struct LeafSpec { /// Leaf identifier used in packet headers. @@ -43,6 +73,16 @@ pub struct LeafSpec { } /// Where an inbound frame entered this endpoint. +/// +/// This exists because protocol validation depends on whether a packet arrived from the parent, +/// one child subtree, or the endpoint itself. +/// +/// # Example +/// ```rust +/// use unshell::protocol::tree::Ingress; +/// let ingress = Ingress::Child(vec!["root".into(), "worker".into()]); +/// assert!(matches!(ingress, Ingress::Child(_))); +/// ``` #[derive(Debug, Clone, PartialEq, Eq)] pub enum Ingress { /// The frame arrived from the parent side of the tree. @@ -54,6 +94,30 @@ pub enum Ingress { } /// Event produced when the endpoint handles a packet locally. +/// +/// This is the validated handoff boundary between transport/routing code and application-facing +/// runtimes layered on top of `ProtocolEndpoint`. +/// +/// # Example +/// ```rust +/// use unshell::protocol::{CallMessage, PacketHeader, PacketType}; +/// use unshell::protocol::tree::LocalEvent; +/// let event = LocalEvent::Call { +/// header: PacketHeader { +/// packet_type: PacketType::Call, +/// src_path: vec!["root".into()], +/// dst_path: vec!["worker".into()], +/// dst_leaf: None, +/// hook_id: None, +/// }, +/// message: CallMessage { +/// procedure_id: "example.invoke".into(), +/// data: vec![], +/// response_hook: None, +/// }, +/// }; +/// assert!(matches!(event, LocalEvent::Call { .. })); +/// ``` #[derive(Debug, Clone, PartialEq, Eq)] pub enum LocalEvent { /// One opening `Call` packet validated and delivered to local code. @@ -84,6 +148,20 @@ pub enum LocalEvent { } /// Result of processing a frame or building a locally-sent packet. +/// +/// This exists so callers can distinguish forwarding, local delivery, and intentional drops +/// without treating normal protocol routing outcomes as errors. +/// +/// # Example +/// ```rust +/// use unshell::protocol::FrameBytes; +/// use unshell::protocol::tree::{EndpointOutcome, RouteDecision}; +/// let outcome = EndpointOutcome::Forward { +/// route: RouteDecision::Parent, +/// frame: FrameBytes::new(), +/// }; +/// assert!(matches!(outcome, EndpointOutcome::Forward { .. })); +/// ``` #[derive(Debug)] pub enum EndpointOutcome { /// Frame to forward, together with the next routing decision. @@ -98,6 +176,19 @@ pub enum EndpointOutcome { } /// Error surfaced while validating or encoding protocol frames. +/// +/// This exists so endpoint callers can preserve the distinction between malformed wire/archive +/// data and semantic protocol invariant failures. +/// +/// # Example +/// ```rust +/// use unshell::protocol::{FrameError, ValidationError}; +/// use unshell::protocol::tree::EndpointError; +/// let error = EndpointError::Frame(FrameError::Truncated); +/// assert!(matches!(error, EndpointError::Frame(_))); +/// let validation = EndpointError::Validation(ValidationError::InvalidHookId); +/// assert!(matches!(validation, EndpointError::Validation(_))); +/// ``` #[derive(Debug)] pub enum EndpointError { /// Framing, archive decode, or archive encode failed. @@ -130,11 +221,49 @@ impl From for EndpointError { } /// Minimal interface implemented by protocol-tree endpoints. +/// +/// This exists so higher-level runtimes can depend on one small receive/path surface instead of a +/// concrete endpoint implementation. +/// +/// # Example +/// ```rust +/// use unshell::protocol::tree::{ChildRoute, Endpoint, Ingress, ProtocolEndpoint}; +/// let endpoint = ProtocolEndpoint::new(Vec::new(), None, vec![ChildRoute::registered(vec!["worker".into()])], Vec::new()); +/// assert_eq!(endpoint.path(), &Vec::::new()); +/// let _ = Ingress::Local; +/// ``` pub trait Endpoint { /// Returns this endpoint's absolute path. + /// + /// # Example + /// ```rust + /// use unshell::protocol::tree::{ChildRoute, Endpoint, ProtocolEndpoint}; + /// let endpoint = ProtocolEndpoint::new(Vec::new(), None, vec![ChildRoute::registered(vec!["worker".into()])], Vec::new()); + /// assert!(endpoint.path().is_empty()); + /// ``` fn path(&self) -> &[String]; /// Processes one inbound frame from the given ingress. + /// + /// # Example + /// ```rust + /// use unshell::protocol::{CallMessage, PacketHeader, PacketType, encode_packet}; + /// use unshell::protocol::tree::{Endpoint, Ingress, ProtocolEndpoint}; + /// let mut endpoint = ProtocolEndpoint::new(vec!["worker".into()], Some(Vec::new()), Vec::new(), Vec::new()); + /// let frame = encode_packet(&PacketHeader { + /// packet_type: PacketType::Call, + /// src_path: Vec::new(), + /// dst_path: vec!["worker".into()], + /// dst_leaf: None, + /// hook_id: None, + /// }, &CallMessage { + /// procedure_id: "example.invoke".into(), + /// data: vec![], + /// response_hook: None, + /// })?; + /// let _outcome = endpoint.receive(&Ingress::Parent, frame); + /// # Ok::<(), unshell::protocol::FrameError>(()) + /// ``` fn receive( &mut self, ingress: &Ingress, @@ -143,6 +272,16 @@ pub trait Endpoint { } /// Runtime state for one endpoint in the protocol tree. +/// +/// This exists as the central protocol node that owns route tables, local leaf metadata, and hook +/// lifecycle state for one endpoint path. +/// +/// # Example +/// ```rust +/// use unshell::protocol::tree::ProtocolEndpoint; +/// let endpoint = ProtocolEndpoint::new(vec!["worker".into()], Some(Vec::new()), Vec::new(), Vec::new()); +/// let _ = endpoint; +/// ``` #[derive(Debug, Default)] pub struct ProtocolEndpoint { pub(crate) path: Vec, diff --git a/src/protocol/tree/hook.rs b/src/protocol/tree/hook.rs index a773aa7..e43d713 100644 --- a/src/protocol/tree/hook.rs +++ b/src/protocol/tree/hook.rs @@ -12,6 +12,16 @@ use alloc::{collections::BTreeMap, string::String, vec::Vec}; /// Hook table key scoped to the hook host path. +/// +/// This exists because hook ids are only unique relative to the endpoint path that hosts the +/// hook state. +/// +/// # Example +/// ```rust +/// use unshell::protocol::tree::HookKey; +/// let key = HookKey::new(vec!["root".into()], 7); +/// assert_eq!(key.hook_id, 7); +/// ``` #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] pub struct HookKey { /// Path of the endpoint hosting the hook state. @@ -22,6 +32,13 @@ pub struct HookKey { impl HookKey { /// Builds the canonical key for a hook hosted at `return_path`. + /// + /// # Example + /// ```rust + /// use unshell::protocol::tree::HookKey; + /// let key = HookKey::new(vec!["root".into()], 42); + /// assert_eq!(key.return_path, vec![String::from("root")]); + /// ``` #[must_use] pub fn new(return_path: Vec, hook_id: u64) -> Self { Self { @@ -32,6 +49,20 @@ impl HookKey { } /// Pending hook context used only for fault attribution before activation. +/// +/// This exists so outbound calls can reserve response-hook ownership before the callee has sent +/// its first valid `Data` packet. +/// +/// # Example +/// ```rust +/// use unshell::protocol::tree::PendingHook; +/// let pending = PendingHook { +/// caller_src_path: vec!["worker".into()], +/// procedure_id: "example.service.v1.invoke".into(), +/// local_ended: false, +/// }; +/// assert!(!pending.local_ended); +/// ``` #[derive(Debug, Clone, PartialEq, Eq)] pub struct PendingHook { /// Caller path to promote into `peer_path` once the hook becomes active. @@ -43,6 +74,21 @@ pub struct PendingHook { } /// Active hook context used for ordinary data traffic. +/// +/// This exists once one peer has proven ownership of the hook stream and ordinary `Data`/`Fault` +/// routing can proceed without the pending reservation state. +/// +/// # Example +/// ```rust +/// use unshell::protocol::tree::ActiveHook; +/// let active = ActiveHook { +/// peer_path: vec!["worker".into()], +/// procedure_id: "example.service.v1.invoke".into(), +/// local_ended: false, +/// peer_ended: false, +/// }; +/// assert_eq!(active.peer_path[0], "worker"); +/// ``` #[derive(Debug, Clone, PartialEq, Eq)] pub struct ActiveHook { /// Remote endpoint path currently paired with this hook. @@ -56,10 +102,34 @@ pub struct ActiveHook { } /// Duplicate hook insertion error. +/// +/// This exists so callers can distinguish “hook id already reserved” from other runtime errors. +/// +/// # Example +/// ```rust +/// use unshell::protocol::tree::HookConflict; +/// let _conflict = HookConflict; +/// ``` #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct HookConflict; /// Durable hook state tables. +/// +/// This owns both pending and active hook lifecycle state plus a peer-path index for resolving +/// inbound hook traffic from either side of the conversation. +/// +/// # Example +/// ```rust +/// use unshell::protocol::tree::{HookKey, HookTable, PendingHook}; +/// let mut hooks = HookTable::default(); +/// let key = HookKey::new(vec!["root".into()], 1); +/// hooks.insert_pending(key.clone(), PendingHook { +/// caller_src_path: vec!["worker".into()], +/// procedure_id: "example.service.v1.invoke".into(), +/// local_ended: false, +/// }).unwrap(); +/// assert_eq!(hooks.pending_len(), 1); +/// ``` #[derive(Debug, Default)] pub struct HookTable { pending: BTreeMap, @@ -77,6 +147,14 @@ impl HookTable { /// The table currently uses one counter shared across all host paths. The `return_path` /// parameter remains in the API because hook ids are still interpreted as host-scoped by the /// rest of the protocol surface. + /// + /// # Example + /// ```rust + /// use unshell::protocol::tree::HookTable; + /// let mut hooks = HookTable::default(); + /// let id = hooks.allocate_hook_id(&[String::from("root")]); + /// assert_ne!(id, 0); + /// ``` #[must_use] pub fn allocate_hook_id(&mut self, _return_path: &[String]) -> u64 { let id = self.next_id.max(1); @@ -85,6 +163,18 @@ impl HookTable { } /// Inserts a hook that has been announced but not yet accepted by the callee. + /// + /// # Example + /// ```rust + /// use unshell::protocol::tree::{HookKey, HookTable, PendingHook}; + /// let mut hooks = HookTable::default(); + /// hooks.insert_pending(HookKey::new(vec!["root".into()], 1), PendingHook { + /// caller_src_path: vec!["worker".into()], + /// procedure_id: "example.service.v1.invoke".into(), + /// local_ended: false, + /// })?; + /// # Ok::<(), unshell::protocol::tree::HookConflict>(()) + /// ``` pub fn insert_pending( &mut self, key: HookKey, @@ -101,6 +191,21 @@ impl HookTable { /// /// Activation intentionally reuses the original hook id and host path, but swaps the /// pending caller attribution into the active peer path used for data routing. + /// + /// # Example + /// ```rust + /// use unshell::protocol::tree::{HookKey, HookTable, PendingHook}; + /// let mut hooks = HookTable::default(); + /// let key = HookKey::new(vec!["root".into()], 1); + /// hooks.insert_pending(key.clone(), PendingHook { + /// caller_src_path: vec!["worker".into()], + /// procedure_id: "example.service.v1.invoke".into(), + /// local_ended: false, + /// })?; + /// hooks.activate_pending(&key); + /// assert_eq!(hooks.active_len(), 1); + /// # Ok::<(), unshell::protocol::tree::HookConflict>(()) + /// ``` pub fn activate_pending(&mut self, key: &HookKey) -> Option<()> { let pending = self.pending.remove(key)?; self.insert_active( @@ -117,7 +222,23 @@ impl HookTable { } /// Inserts a live hook and its peer-path lookup entry. + /// + /// # Example + /// ```rust + /// use unshell::protocol::tree::{ActiveHook, HookKey, HookTable}; + /// let mut hooks = HookTable::default(); + /// hooks.insert_active(HookKey::new(vec!["root".into()], 1), ActiveHook { + /// peer_path: vec!["worker".into()], + /// procedure_id: "example.service.v1.invoke".into(), + /// local_ended: false, + /// peer_ended: false, + /// })?; + /// assert_eq!(hooks.active_len(), 1); + /// # Ok::<(), unshell::protocol::tree::HookConflict>(()) + /// ``` pub fn insert_active(&mut self, key: HookKey, active: ActiveHook) -> Result<(), HookConflict> { + // Reject both duplicate host-scoped keys and duplicate peer ownership claims. Either one + // would make later inbound hook traffic ambiguous. if self.pending.contains_key(&key) || self.active.contains_key(&key) || self @@ -136,11 +257,40 @@ impl HookTable { } /// Removes a pending hook without affecting active state. + /// + /// # Example + /// ```rust + /// use unshell::protocol::tree::{HookKey, HookTable, PendingHook}; + /// let mut hooks = HookTable::default(); + /// let key = HookKey::new(vec!["root".into()], 1); + /// hooks.insert_pending(key.clone(), PendingHook { + /// caller_src_path: vec!["worker".into()], + /// procedure_id: "example.service.v1.invoke".into(), + /// local_ended: false, + /// })?; + /// assert!(hooks.remove_pending(&key).is_some()); + /// # Ok::<(), unshell::protocol::tree::HookConflict>(()) + /// ``` pub fn remove_pending(&mut self, key: &HookKey) -> Option { self.pending.remove(key) } /// Marks the local side finished before the hook becomes active. + /// + /// # Example + /// ```rust + /// use unshell::protocol::tree::{HookKey, HookTable, PendingHook}; + /// let mut hooks = HookTable::default(); + /// let key = HookKey::new(vec!["root".into()], 1); + /// hooks.insert_pending(key.clone(), PendingHook { + /// caller_src_path: vec!["worker".into()], + /// procedure_id: "example.service.v1.invoke".into(), + /// local_ended: false, + /// })?; + /// hooks.mark_pending_local_end(&key); + /// assert!(hooks.pending(&key).unwrap().local_ended); + /// # Ok::<(), unshell::protocol::tree::HookConflict>(()) + /// ``` pub fn mark_pending_local_end(&mut self, key: &HookKey) { if let Some(pending) = self.pending.get_mut(key) { pending.local_ended = true; @@ -148,6 +298,21 @@ impl HookTable { } /// Removes an active hook and its secondary peer-path index entry. + /// + /// # Example + /// ```rust + /// use unshell::protocol::tree::{ActiveHook, HookKey, HookTable}; + /// let mut hooks = HookTable::default(); + /// let key = HookKey::new(vec!["root".into()], 1); + /// hooks.insert_active(key.clone(), ActiveHook { + /// peer_path: vec!["worker".into()], + /// procedure_id: "example.service.v1.invoke".into(), + /// local_ended: false, + /// peer_ended: false, + /// })?; + /// assert!(hooks.remove_active(&key).is_some()); + /// # Ok::<(), unshell::protocol::tree::HookConflict>(()) + /// ``` pub fn remove_active(&mut self, key: &HookKey) -> Option { let active = self.active.remove(key)?; if let Some(peer_paths) = self.active_by_peer.get_mut(&key.hook_id) { @@ -160,18 +325,63 @@ impl HookTable { } /// Returns the pending hook for `key`, if present. + /// + /// # Example + /// ```rust + /// use unshell::protocol::tree::{HookKey, HookTable, PendingHook}; + /// let mut hooks = HookTable::default(); + /// let key = HookKey::new(vec!["root".into()], 1); + /// hooks.insert_pending(key.clone(), PendingHook { + /// caller_src_path: vec!["worker".into()], + /// procedure_id: "example.service.v1.invoke".into(), + /// local_ended: false, + /// })?; + /// assert!(hooks.pending(&key).is_some()); + /// # Ok::<(), unshell::protocol::tree::HookConflict>(()) + /// ``` #[must_use] pub fn pending(&self, key: &HookKey) -> Option<&PendingHook> { self.pending.get(key) } /// Returns the active hook for `key`, if present. + /// + /// # Example + /// ```rust + /// use unshell::protocol::tree::{ActiveHook, HookKey, HookTable}; + /// let mut hooks = HookTable::default(); + /// let key = HookKey::new(vec!["root".into()], 1); + /// hooks.insert_active(key.clone(), ActiveHook { + /// peer_path: vec!["worker".into()], + /// procedure_id: "example.service.v1.invoke".into(), + /// local_ended: false, + /// peer_ended: false, + /// })?; + /// assert!(hooks.active(&key).is_some()); + /// # Ok::<(), unshell::protocol::tree::HookConflict>(()) + /// ``` #[must_use] pub fn active(&self, key: &HookKey) -> Option<&ActiveHook> { self.active.get(key) } /// Returns the mutable active hook for `key`, if present. + /// + /// # Example + /// ```rust + /// use unshell::protocol::tree::{ActiveHook, HookKey, HookTable}; + /// let mut hooks = HookTable::default(); + /// let key = HookKey::new(vec!["root".into()], 1); + /// hooks.insert_active(key.clone(), ActiveHook { + /// peer_path: vec!["worker".into()], + /// procedure_id: "example.service.v1.invoke".into(), + /// local_ended: false, + /// peer_ended: false, + /// })?; + /// hooks.active_mut(&key).unwrap().peer_ended = true; + /// assert!(hooks.active(&key).unwrap().peer_ended); + /// # Ok::<(), unshell::protocol::tree::HookConflict>(()) + /// ``` pub fn active_mut(&mut self, key: &HookKey) -> Option<&mut ActiveHook> { self.active.get_mut(key) } @@ -181,6 +391,21 @@ impl HookTable { /// The host side addresses hooks directly by `(return_path, hook_id)`. Peer-originated /// traffic only has `(hook_id, peer_path)`, so the secondary index maps that back to the /// canonical host-scoped key. + /// + /// # Example + /// ```rust + /// use unshell::protocol::tree::{ActiveHook, HookKey, HookTable}; + /// let mut hooks = HookTable::default(); + /// let key = HookKey::new(vec!["root".into()], 1); + /// hooks.insert_active(key.clone(), ActiveHook { + /// peer_path: vec!["worker".into()], + /// procedure_id: "example.service.v1.invoke".into(), + /// local_ended: false, + /// peer_ended: false, + /// })?; + /// assert_eq!(hooks.resolve_active_key(&["root".into()], 1, &["worker".into()]), Some(key)); + /// # Ok::<(), unshell::protocol::tree::HookConflict>(()) + /// ``` #[must_use] pub fn resolve_active_key( &self, @@ -188,6 +413,8 @@ impl HookTable { hook_id: u64, peer_path: &[String], ) -> Option { + // Prefer peer-originated resolution first because inbound hook traffic normally arrives + // from the far side with only `(hook_id, peer_path)` available. if let Some(key) = self .active_by_peer .get(&hook_id) @@ -204,6 +431,21 @@ impl HookTable { /// /// This does not remove the hook. Callers use the boolean to decide whether cleanup should /// happen immediately or whether the peer side is still expected to send more traffic. + /// + /// # Example + /// ```rust + /// use unshell::protocol::tree::{ActiveHook, HookKey, HookTable}; + /// let mut hooks = HookTable::default(); + /// let key = HookKey::new(vec!["root".into()], 1); + /// hooks.insert_active(key.clone(), ActiveHook { + /// peer_path: vec!["worker".into()], + /// procedure_id: "example.service.v1.invoke".into(), + /// local_ended: false, + /// peer_ended: true, + /// })?; + /// assert!(hooks.mark_local_end(&key)); + /// # Ok::<(), unshell::protocol::tree::HookConflict>(()) + /// ``` pub fn mark_local_end(&mut self, key: &HookKey) -> bool { let Some(active) = self.active_mut(key) else { return false; @@ -216,6 +458,21 @@ impl HookTable { /// /// This mirrors [`mark_local_end`](Self::mark_local_end): it only reports completion, leaving /// final removal to the caller so higher layers can decide when to tear down hook state. + /// + /// # Example + /// ```rust + /// use unshell::protocol::tree::{ActiveHook, HookKey, HookTable}; + /// let mut hooks = HookTable::default(); + /// let key = HookKey::new(vec!["root".into()], 1); + /// hooks.insert_active(key.clone(), ActiveHook { + /// peer_path: vec!["worker".into()], + /// procedure_id: "example.service.v1.invoke".into(), + /// local_ended: true, + /// peer_ended: false, + /// })?; + /// assert!(hooks.mark_peer_end(&key)); + /// # Ok::<(), unshell::protocol::tree::HookConflict>(()) + /// ``` pub fn mark_peer_end(&mut self, key: &HookKey) -> bool { let Some(active) = self.active_mut(key) else { return false; @@ -225,12 +482,26 @@ impl HookTable { } /// Returns the number of active hooks. + /// + /// # Example + /// ```rust + /// use unshell::protocol::tree::HookTable; + /// let hooks = HookTable::default(); + /// assert_eq!(hooks.active_len(), 0); + /// ``` #[must_use] pub fn active_len(&self) -> usize { self.active.len() } /// Returns the number of pending hooks. + /// + /// # Example + /// ```rust + /// use unshell::protocol::tree::HookTable; + /// let hooks = HookTable::default(); + /// assert_eq!(hooks.pending_len(), 0); + /// ``` #[must_use] pub fn pending_len(&self) -> usize { self.pending.len() diff --git a/src/protocol/tree/leaf.rs b/src/protocol/tree/leaf.rs index 3cc57d6..e34fc59 100644 --- a/src/protocol/tree/leaf.rs +++ b/src/protocol/tree/leaf.rs @@ -9,20 +9,90 @@ use alloc::{string::String, vec::Vec}; use super::LeafSpec; /// Static metadata for one application-defined protocol leaf. +/// +/// This exists so runtime code can ask one type for its canonical dotted leaf id without knowing +/// any of that leaf's call-dispatch details. +/// +/// # Example +/// ```rust +/// use unshell::protocol::tree::ProtocolLeaf; +/// struct ExampleLeaf; +/// impl ProtocolLeaf for ExampleLeaf { +/// fn leaf_name() -> String { "org.example.v1.echo".into() } +/// } +/// assert_eq!(ExampleLeaf::leaf_name(), "org.example.v1.echo"); +/// ``` pub trait ProtocolLeaf { /// Returns the canonical dotted leaf name hosted by this type. + /// + /// # Example + /// ```rust + /// use unshell::protocol::tree::ProtocolLeaf; + /// struct ExampleLeaf; + /// impl ProtocolLeaf for ExampleLeaf { + /// fn leaf_name() -> String { "org.example.v1.echo".into() } + /// } + /// assert!(ExampleLeaf::leaf_name().starts_with("org.example")); + /// ``` fn leaf_name() -> String; } /// Generated call metadata and initial `Call` dispatch for one leaf. +/// +/// This exists so one leaf type can advertise which procedure suffixes it serves and convert an +/// opening protocol `Call` into leaf-local behavior. +/// +/// # Example +/// ```rust +/// use unshell::protocol::tree::{CallProcedures, DispatchError, IncomingCall, ProtocolLeaf}; +/// struct ExampleLeaf; +/// impl ProtocolLeaf for ExampleLeaf { +/// fn leaf_name() -> String { "org.example.v1.echo".into() } +/// } +/// impl CallProcedures for ExampleLeaf { +/// type Error = core::convert::Infallible; +/// fn procedure_suffixes() -> &'static [&'static str] { &["invoke"] } +/// fn dispatch_call(&mut self, _call: IncomingCall) -> Result> { +/// Ok(unshell::protocol::tree::CallReply::NoReply) +/// } +/// } +/// assert_eq!(ExampleLeaf::procedure_id("invoke").unwrap(), "org.example.v1.echo.invoke"); +/// ``` pub trait CallProcedures: ProtocolLeaf { /// Leaf-specific error surfaced when generated call dispatch fails. type Error; /// Returns the local procedure suffixes supported by this leaf. + /// + /// # Example + /// ```rust + /// use unshell::protocol::tree::{CallProcedures, ProtocolLeaf}; + /// struct ExampleLeaf; + /// impl ProtocolLeaf for ExampleLeaf { fn leaf_name() -> String { "org.example.v1.echo".into() } } + /// impl CallProcedures for ExampleLeaf { + /// type Error = core::convert::Infallible; + /// fn procedure_suffixes() -> &'static [&'static str] { &["invoke", "stream"] } + /// fn dispatch_call(&mut self, _call: unshell::protocol::tree::IncomingCall) -> Result> { Ok(unshell::protocol::tree::CallReply::NoReply) } + /// } + /// assert_eq!(ExampleLeaf::procedure_suffixes(), &["invoke", "stream"]); + /// ``` fn procedure_suffixes() -> &'static [&'static str]; /// Resolves one local procedure suffix to its full canonical `procedure_id`. + /// + /// # Example + /// ```rust + /// use unshell::protocol::tree::{CallProcedures, ProtocolLeaf}; + /// struct ExampleLeaf; + /// impl ProtocolLeaf for ExampleLeaf { fn leaf_name() -> String { "org.example.v1.echo".into() } } + /// impl CallProcedures for ExampleLeaf { + /// type Error = core::convert::Infallible; + /// fn procedure_suffixes() -> &'static [&'static str] { &["invoke"] } + /// fn dispatch_call(&mut self, _call: unshell::protocol::tree::IncomingCall) -> Result> { Ok(unshell::protocol::tree::CallReply::NoReply) } + /// } + /// assert!(ExampleLeaf::procedure_id("invoke").is_some()); + /// assert!(ExampleLeaf::procedure_id("missing").is_none()); + /// ``` fn procedure_id(suffix: &str) -> Option { if !Self::procedure_suffixes().contains(&suffix) { return None; @@ -35,6 +105,19 @@ pub trait CallProcedures: ProtocolLeaf { } /// Returns the full canonical `procedure_id` values supported by this leaf. + /// + /// # Example + /// ```rust + /// use unshell::protocol::tree::{CallProcedures, ProtocolLeaf}; + /// struct ExampleLeaf; + /// impl ProtocolLeaf for ExampleLeaf { fn leaf_name() -> String { "org.example.v1.echo".into() } } + /// impl CallProcedures for ExampleLeaf { + /// type Error = core::convert::Infallible; + /// fn procedure_suffixes() -> &'static [&'static str] { &["invoke"] } + /// fn dispatch_call(&mut self, _call: unshell::protocol::tree::IncomingCall) -> Result> { Ok(unshell::protocol::tree::CallReply::NoReply) } + /// } + /// assert_eq!(ExampleLeaf::procedure_ids(), vec![String::from("org.example.v1.echo.invoke")]); + /// ``` fn procedure_ids() -> Vec { Self::procedure_suffixes() .iter() @@ -43,6 +126,20 @@ pub trait CallProcedures: ProtocolLeaf { } /// Materializes the runtime leaf metadata consumed by `ProtocolEndpoint`. + /// + /// # Example + /// ```rust + /// use unshell::protocol::tree::{CallProcedures, ProtocolLeaf}; + /// struct ExampleLeaf; + /// impl ProtocolLeaf for ExampleLeaf { fn leaf_name() -> String { "org.example.v1.echo".into() } } + /// impl CallProcedures for ExampleLeaf { + /// type Error = core::convert::Infallible; + /// fn procedure_suffixes() -> &'static [&'static str] { &["invoke"] } + /// fn dispatch_call(&mut self, _call: unshell::protocol::tree::IncomingCall) -> Result> { Ok(unshell::protocol::tree::CallReply::NoReply) } + /// } + /// let spec = ExampleLeaf::leaf_spec(); + /// assert_eq!(spec.name, "org.example.v1.echo"); + /// ``` fn leaf_spec() -> LeafSpec { LeafSpec { name: Self::leaf_name(), @@ -55,6 +152,21 @@ pub trait CallProcedures: ProtocolLeaf { /// Implementations may assume the endpoint already proved the call targets this leaf. /// They are still responsible for decoding the typed input payload and deciding which local /// procedure suffix should run. + /// + /// # Example + /// ```rust + /// use unshell::protocol::tree::{CallProcedures, DispatchError, IncomingCall, ProtocolLeaf}; + /// struct ExampleLeaf; + /// impl ProtocolLeaf for ExampleLeaf { fn leaf_name() -> String { "org.example.v1.echo".into() } } + /// impl CallProcedures for ExampleLeaf { + /// type Error = core::convert::Infallible; + /// fn procedure_suffixes() -> &'static [&'static str] { &["invoke"] } + /// fn dispatch_call(&mut self, _call: IncomingCall) -> Result> { + /// Ok(unshell::protocol::tree::CallReply::NoReply) + /// } + /// } + /// # let _ = ExampleLeaf; + /// ``` fn dispatch_call( &mut self, call: crate::protocol::tree::IncomingCall, @@ -122,6 +234,8 @@ pub fn derive_leaf_name( if let Some(leaf_name) = leaf_name.filter(|value| !value.is_empty()) { segments.extend(split_leaf_path(leaf_name)); } else { + // The package-derived prefix already names the crate/product portion of the identifier, so + // strip the same leading segment from `module_path` when it would otherwise duplicate it. let mut module_segments = module_path .split("::") .map(normalize_leaf_segment) diff --git a/src/protocol/tree/mod.rs b/src/protocol/tree/mod.rs index debb7f0..afdb7cc 100644 --- a/src/protocol/tree/mod.rs +++ b/src/protocol/tree/mod.rs @@ -26,7 +26,7 @@ pub use endpoint::{ pub use hook::{ActiveHook, HookConflict, HookKey, HookTable, PendingHook}; pub use leaf::{CallProcedures, ProtocolLeaf, derive_leaf_name}; pub use procedure::{ - Procedure, ProcedureEffect, ProcedureRuntime, ProcedureRuntimeError, ProcedureStore, + Procedure, ProcedureEffect, ProcedureRuntime, ProcedureRuntimeError, ProcedureRuntimeOutcome, ProcedureStore, StatefulProcedureMetadata, }; pub use routing::{ diff --git a/src/protocol/tree/procedure.rs b/src/protocol/tree/procedure.rs index 14e186b..4e7c062 100644 --- a/src/protocol/tree/procedure.rs +++ b/src/protocol/tree/procedure.rs @@ -31,6 +31,20 @@ use super::{ /// /// This metadata is intentionally tiny: one procedure suffix plus the derived /// full `procedure_id`. The leaf still owns all session storage explicitly. +/// +/// # Example +/// ```rust +/// use unshell::protocol::tree::{ProtocolLeaf, StatefulProcedureMetadata}; +/// struct ExampleLeaf; +/// impl ProtocolLeaf for ExampleLeaf { +/// fn leaf_name() -> String { "org.example.v1.shell".into() } +/// } +/// struct Open; +/// impl StatefulProcedureMetadata for Open { +/// fn procedure_suffix() -> &'static str { "open" } +/// } +/// assert_eq!(Open::procedure_id(), "org.example.v1.shell.open"); +/// ``` pub trait StatefulProcedureMetadata: Sized where L: ProtocolLeaf, @@ -51,6 +65,19 @@ where /// /// Rationale: the leaf remains the source of truth for its active sessions. This /// avoids hidden generated enums or side tables and keeps debugging obvious. +/// +/// # Example +/// ```rust +/// use std::collections::BTreeMap; +/// use unshell::protocol::tree::{HookKey, ProcedureStore}; +/// struct Session; +/// struct Leaf { sessions: BTreeMap } +/// impl ProcedureStore for Leaf { +/// fn procedure_sessions(&mut self) -> &mut BTreeMap { +/// &mut self.sessions +/// } +/// } +/// ``` pub trait ProcedureStore

{ /// Returns the hook-keyed session table for one procedure type. fn procedure_sessions(&mut self) -> &mut BTreeMap; @@ -152,6 +179,16 @@ where } /// Output produced while advancing one session. +/// +/// This exists as the normalized result of one session step: some outgoing hook packets plus an +/// explicit decision about whether the session should stay alive. +/// +/// # Example +/// ```rust +/// use unshell::protocol::tree::ProcedureEffect; +/// let effect = ProcedureEffect::close(Vec::new()); +/// assert!(effect.close_session); +/// ``` #[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct ProcedureEffect { /// `Data` packets to emit after the session step completes. @@ -163,6 +200,13 @@ pub struct ProcedureEffect { impl ProcedureEffect { /// Builds an effect that keeps the session alive after emitting `outgoing`. #[must_use] + /// + /// # Example + /// ```rust + /// use unshell::protocol::tree::ProcedureEffect; + /// let effect = ProcedureEffect::outgoing(Vec::new()); + /// assert!(!effect.close_session); + /// ``` pub fn outgoing(outgoing: Vec) -> Self { Self { outgoing, @@ -172,6 +216,13 @@ impl ProcedureEffect { /// Builds an effect that closes the session after emitting `outgoing`. #[must_use] + /// + /// # Example + /// ```rust + /// use unshell::protocol::tree::ProcedureEffect; + /// let effect = ProcedureEffect::close(Vec::new()); + /// assert!(effect.close_session); + /// ``` pub fn close(outgoing: Vec) -> Self { Self { outgoing, @@ -181,6 +232,18 @@ impl ProcedureEffect { } /// Error surfaced by the procedure runtime. +/// +/// This exists so callers can tell apart transport/runtime failures from an opening call that +/// could not establish a procedure session. +/// +/// # Example +/// ```rust +/// use unshell::protocol::FrameError; +/// use unshell::protocol::tree::{DispatchError, ProcedureRuntimeError}; +/// let error: ProcedureRuntimeError = +/// ProcedureRuntimeError::Decode(DispatchError::Decode(FrameError::Truncated)); +/// assert!(matches!(error, ProcedureRuntimeError::Decode(_))); +/// ``` #[derive(Debug)] pub enum ProcedureRuntimeError { /// Protocol endpoint routing or framing failed. @@ -213,6 +276,16 @@ impl From for ProcedureRuntimeError { } /// Frames emitted while advancing one stateful procedure runtime. +/// +/// This exists so callers can flush emitted frames to transport while also observing whether the +/// inbound packet was intentionally dropped. +/// +/// # Example +/// ```rust +/// use unshell::protocol::tree::ProcedureRuntimeOutcome; +/// let outcome = ProcedureRuntimeOutcome::default(); +/// assert!(outcome.frames.is_empty()); +/// ``` #[derive(Debug, Default)] pub struct ProcedureRuntimeOutcome { /// Frames emitted while processing the current step. @@ -226,6 +299,14 @@ pub struct ProcedureRuntimeOutcome { /// This runtime is deliberately narrow. It is the right tool when one leaf owns /// one hook-backed procedure whose session type is explicit in the leaf's state. /// Simpler one-shot procedures can stay on [`crate::protocol::tree::LeafRuntime`]. +/// +/// # Example +/// ```rust +/// use unshell::protocol::tree::ProcedureRuntime; +/// # struct Leaf; +/// # struct Proc; +/// # let _ = core::marker::PhantomData::>; +/// ``` #[derive(Debug)] pub struct ProcedureRuntime { endpoint: ProtocolEndpoint, @@ -236,6 +317,18 @@ pub struct ProcedureRuntime { impl ProcedureRuntime { /// Builds a procedure runtime from one endpoint and one leaf instance. #[must_use] + /// + /// # Example + /// ```rust + /// use unshell::protocol::tree::{ProcedureRuntime, ProtocolEndpoint}; + /// struct Leaf; + /// struct Proc; + /// let runtime = ProcedureRuntime::::new( + /// ProtocolEndpoint::new(Vec::new(), None, Vec::new(), Vec::new()), + /// Leaf, + /// ); + /// let _ = runtime; + /// ``` pub fn new(endpoint: ProtocolEndpoint, leaf: L) -> Self { Self { endpoint, @@ -246,22 +339,58 @@ impl ProcedureRuntime { /// Returns the underlying protocol endpoint. #[must_use] + /// + /// # Example + /// ```rust + /// use unshell::protocol::tree::{ProcedureRuntime, ProtocolEndpoint}; + /// struct Leaf; + /// struct Proc; + /// let runtime = ProcedureRuntime::::new(ProtocolEndpoint::new(Vec::new(), None, Vec::new(), Vec::new()), Leaf); + /// let _ = runtime.endpoint(); + /// ``` pub fn endpoint(&self) -> &ProtocolEndpoint { &self.endpoint } /// Returns a mutable reference to the protocol endpoint. + /// + /// # Example + /// ```rust + /// use unshell::protocol::tree::{ProcedureRuntime, ProtocolEndpoint}; + /// struct Leaf; + /// struct Proc; + /// let mut runtime = ProcedureRuntime::::new(ProtocolEndpoint::new(Vec::new(), None, Vec::new(), Vec::new()), Leaf); + /// let _ = runtime.endpoint_mut(); + /// ``` pub fn endpoint_mut(&mut self) -> &mut ProtocolEndpoint { &mut self.endpoint } /// Returns the hosted leaf instance. #[must_use] + /// + /// # Example + /// ```rust + /// use unshell::protocol::tree::{ProcedureRuntime, ProtocolEndpoint}; + /// struct Leaf; + /// struct Proc; + /// let runtime = ProcedureRuntime::::new(ProtocolEndpoint::new(Vec::new(), None, Vec::new(), Vec::new()), Leaf); + /// let _ = runtime.leaf(); + /// ``` pub fn leaf(&self) -> &L { &self.leaf } /// Returns a mutable reference to the hosted leaf instance. + /// + /// # Example + /// ```rust + /// use unshell::protocol::tree::{ProcedureRuntime, ProtocolEndpoint}; + /// struct Leaf; + /// struct Proc; + /// let mut runtime = ProcedureRuntime::::new(ProtocolEndpoint::new(Vec::new(), None, Vec::new(), Vec::new()), Leaf); + /// let _ = runtime.leaf_mut(); + /// ``` pub fn leaf_mut(&mut self) -> &mut L { &mut self.leaf } @@ -278,6 +407,14 @@ where P::Error: fmt::Display, { /// Delivers one framed protocol packet into the runtime. + /// + /// # Example + /// ```rust + /// # use unshell::protocol::tree::ProcedureRuntime; + /// # struct Leaf; + /// # struct Proc; + /// # let _ = core::marker::PhantomData::>; + /// ``` pub fn receive( &mut self, ingress: &Ingress, @@ -291,6 +428,14 @@ where /// /// Rationale: many long-lived procedures, including a remote shell, need to /// emit output even when no new inbound protocol packet has arrived. + /// + /// # Example + /// ```rust + /// # use unshell::protocol::tree::ProcedureRuntime; + /// # struct Leaf; + /// # struct Proc; + /// # let _ = core::marker::PhantomData::>; + /// ``` pub fn poll(&mut self) -> Result> { let mut frames = Vec::new(); let keys = self @@ -304,6 +449,8 @@ where let Some(session) = self.leaf.procedure_sessions().remove(&key) else { continue; }; + // Collect keys first and temporarily remove each session so procedure callbacks can + // mutate the leaf without fighting the session-table borrow. match self.poll_session(key, session)? { Some(session_frames) => frames.extend(session_frames), None => continue, @@ -351,6 +498,8 @@ where let outgoing = match self.emit_outgoing(effect.outgoing) { Ok(outgoing) => outgoing.frames, Err(error) => { + // Emit failures are transport/runtime failures, not leaf-procedure failures. Keep + // the session when it asked to stay open so the caller can retry later. if !effect.close_session { self.leaf.procedure_sessions().insert(key, session); } else { @@ -395,6 +544,8 @@ where ) -> Result> { let mut runtime = ProcedureRuntimeOutcome::default(); if message.procedure_id != P::procedure_id() { + // Once this runtime receives a call, a wrong procedure id is a protocol mismatch. + // Fault the caller rather than surfacing a leaf-local error it cannot recover from. runtime .frames .extend(self.emit_internal_fault_if_possible(message.response_hook.as_ref())?); @@ -408,6 +559,8 @@ where let session = match self.open_session(header, message) { Ok(session) => session, Err(error) => { + // Session open failures still fault the caller when a response hook exists, but do + // not leak leaf-local details over the wire. runtime .frames .extend(self.emit_internal_fault(Some(hook_key.clone()))?); @@ -489,6 +642,8 @@ where hook_key: hook_key.clone(), }, ); + // Always attempt both the fault observer and the final close hook so resource cleanup can + // still run even when the leaf reports an error while handling the fault. let close_result = P::close(&mut self.leaf, session); if let Err(error) = on_fault_result { let _ = close_result; @@ -558,6 +713,14 @@ where /// Emits an upstream internal fault for the current procedure if the caller /// declared a response hook. + /// + /// # Example + /// ```rust + /// # use unshell::protocol::tree::ProcedureRuntime; + /// # struct Leaf; + /// # struct Proc; + /// # let _ = core::marker::PhantomData::>; + /// ``` pub fn emit_internal_fault_if_possible( &mut self, hook: Option<&HookTarget>, @@ -598,6 +761,8 @@ where // Once a session emits `end_hook`, later packets would violate the protocol, // so the runtime keeps only the prefix through that terminal packet. if let Some(index) = effect.outgoing.iter().position(|packet| packet.end_hook) { + // The protocol allows only one terminal packet per direction, so ignore anything a + // procedure tried to emit after the first close marker. effect.outgoing.truncate(index + 1); } let local_end_already_sent = self diff --git a/src/protocol/tree/routing.rs b/src/protocol/tree/routing.rs index f4f1a71..6240099 100644 --- a/src/protocol/tree/routing.rs +++ b/src/protocol/tree/routing.rs @@ -6,6 +6,25 @@ use alloc::{collections::BTreeMap, string::String, vec, vec::Vec}; /// Explicit tree declaration used for configuration and tests. +/// +/// This models one protocol tree declaratively so callers can derive endpoint paths, leaf +/// inventory, or test fixtures without first constructing live endpoints. +/// +/// # Example +/// ```rust +/// use unshell::protocol::tree::{LeafNode, TreeNode}; +/// let tree = TreeNode::Root { +/// children: vec![TreeNode::Endpoint { +/// segment: "worker".into(), +/// leaves: vec![LeafNode { +/// name: "service".into(), +/// procedures: vec!["example.service.v1.invoke".into()], +/// }], +/// children: Vec::new(), +/// }], +/// }; +/// assert_eq!(tree.paths().len(), 2); +/// ``` #[derive(Debug, Clone, PartialEq, Eq)] pub enum TreeNode { /// The protocol root. Its path is always empty. @@ -25,6 +44,19 @@ pub enum TreeNode { } /// Leaf declaration used inside the explicit tree enum. +/// +/// This exists so declarative trees can describe the leaves hosted at one endpoint without +/// constructing the full runtime state machine. +/// +/// # Example +/// ```rust +/// use unshell::protocol::tree::LeafNode; +/// let leaf = LeafNode { +/// name: "service".into(), +/// procedures: vec!["example.service.v1.invoke".into()], +/// }; +/// assert_eq!(leaf.name, "service"); +/// ``` #[derive(Debug, Clone, PartialEq, Eq)] pub struct LeafNode { /// Leaf name local to an endpoint path. @@ -37,6 +69,19 @@ impl TreeNode { /// Flattens the explicit tree into the set of endpoint paths it declares. /// /// The returned list always includes the protocol root as `[]`. + /// + /// # Example + /// ```rust + /// use unshell::protocol::tree::TreeNode; + /// let tree = TreeNode::Root { + /// children: vec![TreeNode::Endpoint { + /// segment: "worker".into(), + /// leaves: Vec::new(), + /// children: Vec::new(), + /// }], + /// }; + /// assert_eq!(tree.paths(), vec![Vec::::new(), vec!["worker".into()]]); + /// ``` pub fn paths(&self) -> Vec> { let mut paths = Vec::new(); self.collect_paths(&[], &mut paths); @@ -67,6 +112,16 @@ impl TreeNode { } /// Longest-prefix route decision. +/// +/// Each decision is evaluated from one endpoint's perspective after comparing its own path and +/// compiled child subtree against the destination path. +/// +/// # Example +/// ```rust +/// use unshell::protocol::tree::RouteDecision; +/// let route = RouteDecision::Child(0); +/// assert!(matches!(route, RouteDecision::Child(0))); +/// ``` #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RouteDecision { /// Forward to the child at the given local child index. @@ -80,6 +135,16 @@ pub enum RouteDecision { } /// One compiled routing table for one endpoint boundary. +/// +/// This exists so repeated route lookups can reuse one longest-prefix trie instead of scanning +/// every child path on every packet. +/// +/// # Example +/// ```rust +/// use unshell::protocol::tree::{CompiledRoutes, RouteDecision}; +/// let routes = CompiledRoutes::new(&["root".into()], &[vec!["root".into(), "worker".into()]], true); +/// assert_eq!(routes.route(&["root".into(), "worker".into(), "job".into()]), RouteDecision::Child(0)); +/// ``` #[derive(Debug, Clone, Default)] pub struct CompiledRoutes { local_path: Vec, @@ -99,6 +164,20 @@ impl CompiledRoutes { /// /// Only strict descendants of `local_path` participate in the compiled trie. Paths outside /// the local subtree, or equal to `local_path` itself, are ignored. + /// + /// # Example + /// ```rust + /// use unshell::protocol::tree::CompiledRoutes; + /// let routes = CompiledRoutes::new( + /// &["root".into()], + /// &[ + /// vec!["root".into(), "worker".into()], + /// vec!["other".into()], + /// ], + /// true, + /// ); + /// assert_eq!(routes.route(&["root".into(), "worker".into()]), unshell::protocol::tree::RouteDecision::Child(0)); + /// ``` #[must_use] pub fn new(local_path: &[String], child_paths: &[Vec], has_parent: bool) -> Self { let mut routes = Self { @@ -119,6 +198,8 @@ impl CompiledRoutes { return; } + // Store only strict descendants. The terminal node records which direct child owns that + // descendant boundary so later lookups can recover the longest matching child index. let mut node_index = 0usize; for segment in &child_path[self.local_path.len()..] { let next_index = if let Some(next_index) = self.nodes[node_index].edges.get(segment) { @@ -138,6 +219,15 @@ impl CompiledRoutes { } /// Resolves `dst_path` using the compiled longest-prefix trie. + /// + /// # Example + /// ```rust + /// use unshell::protocol::tree::{CompiledRoutes, RouteDecision}; + /// let routes = CompiledRoutes::new(&["root".into()], &[vec!["root".into(), "worker".into()]], true); + /// assert_eq!(routes.route(&["root".into(), "worker".into()]), RouteDecision::Child(0)); + /// assert_eq!(routes.route(&["root".into()]), RouteDecision::Local); + /// assert_eq!(routes.route(&["elsewhere".into()]), RouteDecision::Parent); + /// ``` #[must_use] pub fn route(&self, dst_path: &[String]) -> RouteDecision { if !is_prefix(&self.local_path, dst_path) { @@ -173,6 +263,16 @@ impl CompiledRoutes { } /// Returns `true` if `prefix` is a path prefix of `path`. +/// +/// This exists as the shared path-comparison primitive for both declarative tree processing and +/// runtime route compilation. +/// +/// # Example +/// ```rust +/// use unshell::protocol::tree::is_prefix; +/// assert!(is_prefix(&["root".into()], &["root".into(), "worker".into()])); +/// assert!(!is_prefix(&["worker".into()], &["root".into(), "worker".into()])); +/// ``` pub fn is_prefix(prefix: &[String], path: &[String]) -> bool { prefix.len() <= path.len() && prefix @@ -185,6 +285,19 @@ pub fn is_prefix(prefix: &[String], path: &[String]) -> bool { /// The default policy is longest-prefix routing: exact matches stay local, the deepest matching /// descendant wins for child forwarding, destinations outside the local subtree go to the parent /// when one exists, and everything else drops. +/// +/// # Example +/// ```rust +/// use unshell::protocol::tree::{DefaultRouteProvider, RouteProvider}; +/// let provider = DefaultRouteProvider; +/// let route = provider.route_destination( +/// &["root".into()], +/// [vec!["root".into(), "worker".into()]], +/// true, +/// &["root".into(), "worker".into()], +/// ); +/// assert!(matches!(route, unshell::protocol::tree::RouteDecision::Child(0))); +/// ``` pub trait RouteProvider { /// Returns the route decision for `dst_path` from the perspective of `local_path`. fn route_destination( @@ -200,6 +313,17 @@ pub trait RouteProvider { } /// Default routing implementation using the protocol's longest-prefix rule. +/// +/// This exists as the stateless policy object behind the free [`route_destination`] helper and +/// as a customization seam for tests or alternate routing strategies. +/// +/// # Example +/// ```rust +/// use unshell::protocol::tree::{DefaultRouteProvider, RouteProvider}; +/// let provider = DefaultRouteProvider; +/// let route = provider.route_destination(&[], [vec!["worker".into()]], false, &["worker".into()]); +/// assert!(matches!(route, unshell::protocol::tree::RouteDecision::Child(0))); +/// ``` pub struct DefaultRouteProvider; impl RouteProvider for DefaultRouteProvider { @@ -226,6 +350,13 @@ impl RouteProvider for DefaultRouteProvider { /// /// Exact matches return [`RouteDecision::Local`]. Destinations outside the local subtree return /// [`RouteDecision::Parent`] when `has_parent` is `true`, otherwise [`RouteDecision::Drop`]. +/// +/// # Example +/// ```rust +/// use unshell::protocol::tree::{RouteDecision, route_destination}; +/// let route = route_destination(&[], [vec!["worker".into()]], false, &["worker".into()]); +/// assert_eq!(route, RouteDecision::Child(0)); +/// ``` pub fn route_destination( local_path: &[String], child_paths: I, diff --git a/src/protocol/types.rs b/src/protocol/types.rs index a85bc40..b7274d8 100644 --- a/src/protocol/types.rs +++ b/src/protocol/types.rs @@ -4,6 +4,17 @@ use alloc::{string::String, vec::Vec}; use rkyv::{Archive, Deserialize, Serialize}; /// The three protocol packet types. +/// +/// This discriminates which payload schema follows the [`PacketHeader`]. Callers normally branch +/// on this before choosing whether to decode a [`CallMessage`], [`DataMessage`], or +/// [`FaultMessage`]. +/// +/// # Example +/// ```rust +/// use unshell::protocol::PacketType; +/// let packet_type = PacketType::Call; +/// assert!(matches!(packet_type, PacketType::Call)); +/// ``` #[repr(u8)] #[derive(Archive, Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] pub enum PacketType { @@ -16,6 +27,22 @@ pub enum PacketType { } /// Header fields used for routing and hook attribution. +/// +/// The protocol keeps routing metadata in the header so endpoints can validate source topology, +/// choose a route, and attribute hook traffic before decoding the payload. +/// +/// # Example +/// ```rust +/// use unshell::protocol::{PacketHeader, PacketType}; +/// let header = PacketHeader { +/// packet_type: PacketType::Call, +/// src_path: vec!["root".into()], +/// dst_path: vec!["root".into(), "worker".into()], +/// dst_leaf: Some("service".into()), +/// hook_id: None, +/// }; +/// assert_eq!(header.src_path[0], "root"); +/// ``` #[derive(Archive, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] pub struct PacketHeader { /// Wire-level packet class, which determines which payload type follows. @@ -35,6 +62,19 @@ pub struct PacketHeader { } /// Hook declaration embedded inside a call. +/// +/// This reserves a response stream before the callee accepts the call so later `Data` or `Fault` +/// traffic can be attributed back to the caller. +/// +/// # Example +/// ```rust +/// use unshell::protocol::HookTarget; +/// let hook = HookTarget { +/// hook_id: 7, +/// return_path: vec!["root".into()], +/// }; +/// assert_eq!(hook.hook_id, 7); +/// ``` #[derive(Archive, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] pub struct HookTarget { /// Hook identifier reserved by the caller for returned `Data` or `Fault` traffic. @@ -47,6 +87,23 @@ pub struct HookTarget { } /// Downwards call payload. +/// +/// This carries one procedure invocation plus the optional declaration that the callee should +/// return hook traffic to a reserved response hook. +/// +/// # Example +/// ```rust +/// use unshell::protocol::{CallMessage, HookTarget}; +/// let call = CallMessage { +/// procedure_id: "example.service.v1.invoke".into(), +/// data: vec![1, 2, 3], +/// response_hook: Some(HookTarget { +/// hook_id: 7, +/// return_path: vec!["root".into()], +/// }), +/// }; +/// assert!(call.response_hook.is_some()); +/// ``` #[derive(Archive, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] pub struct CallMessage { /// Canonical procedure identifier chosen by the caller. @@ -58,6 +115,20 @@ pub struct CallMessage { } /// Hook data payload. +/// +/// This carries one message on an already-established hook stream. `end_hook` closes the sender's +/// side of that stream. +/// +/// # Example +/// ```rust +/// use unshell::protocol::DataMessage; +/// let data = DataMessage { +/// procedure_id: "example.service.v1.invoke".into(), +/// data: vec![9, 8, 7], +/// end_hook: true, +/// }; +/// assert!(data.end_hook); +/// ``` #[derive(Archive, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] pub struct DataMessage { /// Canonical procedure identifier that owns the hook stream. @@ -69,6 +140,17 @@ pub struct DataMessage { } /// Protocol fault payload. +/// +/// This carries one stable protocol error code on an existing hook path. +/// +/// # Example +/// ```rust +/// use unshell::protocol::{FaultMessage, ProtocolFault}; +/// let fault = FaultMessage { +/// fault: ProtocolFault::INTERNAL_ERROR, +/// }; +/// assert_eq!(fault.fault, ProtocolFault::INTERNAL_ERROR); +/// ``` #[derive(Archive, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] pub struct FaultMessage { /// Stable protocol-level reason code for the failure. @@ -80,6 +162,13 @@ pub struct FaultMessage { /// The raw numeric value is public so callers can persist, compare, or forward fault codes /// without knowing every symbolic constant in advance. Unknown values are allowed so newer /// peers can extend the set without breaking older runtimes. +/// +/// # Example +/// ```rust +/// use unshell::protocol::ProtocolFault; +/// let code = ProtocolFault::UNKNOWN_PROCEDURE; +/// assert_eq!(code.0, 0x02); +/// ``` #[derive(Archive, Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] pub struct ProtocolFault(pub u8); diff --git a/src/protocol/validation.rs b/src/protocol/validation.rs index a683149..22cee53 100644 --- a/src/protocol/validation.rs +++ b/src/protocol/validation.rs @@ -6,6 +6,22 @@ use crate::protocol::{ use core::fmt; /// Validation failures for protocol structures. +/// +/// These errors exist so callers can reject malformed outbound packets early, before they are +/// encoded or sent across the tree. +/// +/// # Example +/// ```rust +/// use unshell::protocol::{PacketHeader, PacketType, ValidationError, validate_header}; +/// let invalid = PacketHeader { +/// packet_type: PacketType::Data, +/// src_path: vec!["peer".into()], +/// dst_path: vec!["host".into()], +/// dst_leaf: Some("service".into()), +/// hook_id: None, +/// }; +/// assert!(matches!(validate_header(&invalid), Err(ValidationError::HeaderInvariant(_)))); +/// ``` #[derive(Debug, Clone, PartialEq, Eq)] pub enum ValidationError { /// One header field combination is invalid for the chosen packet type. @@ -38,6 +54,20 @@ impl core::error::Error for ValidationError {} /// /// This checks wire-shape rules only. It does not verify route existence, leaf existence, /// hook ownership, or whether the destination actually supports the requested procedure. +/// +/// # Example +/// ```rust +/// use unshell::protocol::{PacketHeader, PacketType, validate_header}; +/// let header = PacketHeader { +/// packet_type: PacketType::Call, +/// src_path: vec!["root".into()], +/// dst_path: vec!["worker".into()], +/// dst_leaf: Some("service".into()), +/// hook_id: None, +/// }; +/// validate_header(&header)?; +/// # Ok::<(), unshell::protocol::ValidationError>(()) +/// ``` pub fn validate_header(header: &PacketHeader) -> Result<(), ValidationError> { match header.packet_type { PacketType::Call => { @@ -67,6 +97,14 @@ pub fn validate_header(header: &PacketHeader) -> Result<(), ValidationError> { /// /// This is intentionally permissive. The protocol reserves only the empty string for /// introspection; every other non-empty identifier is treated as opaque application data. +/// +/// # Example +/// ```rust +/// use unshell::protocol::{INTROSPECTION_PROCEDURE_ID, validate_procedure_id}; +/// validate_procedure_id(INTROSPECTION_PROCEDURE_ID)?; +/// validate_procedure_id("example.service.v1.invoke")?; +/// # Ok::<(), unshell::protocol::ValidationError>(()) +/// ``` pub fn validate_procedure_id(procedure_id: &str) -> Result<(), ValidationError> { if procedure_id == INTROSPECTION_PROCEDURE_ID { return Ok(()); @@ -83,6 +121,28 @@ pub fn validate_procedure_id(procedure_id: &str) -> Result<(), ValidationError> /// /// This complements [`validate_header`]. It does not verify destination reachability or leaf /// support, only consistency between the opening `Call` header and payload. +/// +/// # Example +/// ```rust +/// use unshell::protocol::{CallMessage, HookTarget, PacketHeader, PacketType, validate_call}; +/// let header = PacketHeader { +/// packet_type: PacketType::Call, +/// src_path: vec!["root".into()], +/// dst_path: vec!["worker".into()], +/// dst_leaf: Some("service".into()), +/// hook_id: None, +/// }; +/// let call = CallMessage { +/// procedure_id: "example.service.v1.invoke".into(), +/// data: vec![], +/// response_hook: Some(HookTarget { +/// hook_id: 7, +/// return_path: vec!["root".into()], +/// }), +/// }; +/// validate_call(&header, &call)?; +/// # Ok::<(), unshell::protocol::ValidationError>(()) +/// ``` pub fn validate_call(header: &PacketHeader, call: &CallMessage) -> Result<(), ValidationError> { validate_procedure_id(&call.procedure_id)?;