Deepen protocol docs and explain runtime flow

This commit is contained in:
Michael Mikovsky
2026-04-26 11:18:49 -06:00
parent 17be0f9daa
commit f332e58e44
13 changed files with 1682 additions and 3 deletions
+272
View File
@@ -10,9 +10,41 @@ use super::types::{
use crate::protocol::{CallMessage, DataMessage, FaultMessage, PacketHeader, PacketType}; use crate::protocol::{CallMessage, DataMessage, FaultMessage, PacketHeader, PacketType};
/// Archived-section alignment guaranteed by the frame format. /// 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; pub const SECTION_ALIGN: usize = 16;
/// Owned framed packet bytes. /// 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<SECTION_ALIGN>; pub type FrameBytes = AlignedVec<SECTION_ALIGN>;
/// Framing or archive failure. /// 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 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. /// 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> { pub struct ParsedFrame<'a> {
header: PacketHeader, header: PacketHeader,
payload_bytes: &'a [u8], payload_bytes: &'a [u8],
@@ -56,39 +111,197 @@ pub struct ParsedFrame<'a> {
impl<'a> ParsedFrame<'a> { impl<'a> ParsedFrame<'a> {
#[must_use] #[must_use]
/// Returns the decoded packet header. /// 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 { pub fn header(&self) -> &PacketHeader {
&self.header &self.header
} }
#[must_use] #[must_use]
/// Returns the packet class from the decoded header. /// 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 { pub fn packet_type(&self) -> PacketType {
self.header.packet_type self.header.packet_type
} }
#[must_use] #[must_use]
/// Returns the borrowed payload section bytes. /// 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] { pub fn payload_bytes(&self) -> &'a [u8] {
self.payload_bytes self.payload_bytes
} }
#[must_use] #[must_use]
/// Splits the parsed frame into its owned header and borrowed payload bytes. /// 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]) { pub fn into_parts(self) -> (PacketHeader, &'a [u8]) {
(self.header, self.payload_bytes) (self.header, self.payload_bytes)
} }
/// Deserializes the payload section as a [`CallMessage`]. /// 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<CallMessage, FrameError> { pub fn deserialize_call(&self) -> Result<CallMessage, FrameError> {
self.deserialize_payload::<ArchivedCallMessage, CallMessage>() self.deserialize_payload::<ArchivedCallMessage, CallMessage>()
} }
/// Deserializes the payload section as a [`DataMessage`]. /// 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<DataMessage, FrameError> { pub fn deserialize_data(&self) -> Result<DataMessage, FrameError> {
self.deserialize_payload::<ArchivedDataMessage, DataMessage>() self.deserialize_payload::<ArchivedDataMessage, DataMessage>()
} }
/// Deserializes the payload section as a [`FaultMessage`]. /// 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<FaultMessage, FrameError> { pub fn deserialize_fault(&self) -> Result<FaultMessage, FrameError> {
self.deserialize_payload::<ArchivedFaultMessage, FaultMessage>() self.deserialize_payload::<ArchivedFaultMessage, FaultMessage>()
} }
@@ -109,6 +322,27 @@ impl<'a> ParsedFrame<'a> {
/// The frame starts with two big-endian `u32` lengths, followed by an aligned archived header /// 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 /// 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. /// 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<P>(header: &PacketHeader, payload: &P) -> Result<FrameBytes, FrameError> pub fn encode_packet<P>(header: &PacketHeader, payload: &P) -> Result<FrameBytes, FrameError>
where where
P: for<'a> Serialize< P: for<'a> Serialize<
@@ -139,6 +373,28 @@ where
/// ///
/// This rejects trailing bytes instead of silently ignoring them, so callers can treat one byte /// This rejects trailing bytes instead of silently ignoring them, so callers can treat one byte
/// slice as exactly one protocol frame. /// 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<ParsedFrame<'_>, FrameError> { pub fn decode_frame(bytes: &[u8]) -> Result<ParsedFrame<'_>, FrameError> {
let (header_bytes, payload_bytes) = split_frame_sections(bytes)?; let (header_bytes, payload_bytes) = split_frame_sections(bytes)?;
let header = deserialize_section::<ArchivedPacketHeader, PacketHeader>( let header = deserialize_section::<ArchivedPacketHeader, PacketHeader>(
@@ -157,6 +413,22 @@ pub fn decode_frame(bytes: &[u8]) -> Result<ParsedFrame<'_>, FrameError> {
/// Payload bytes normally come from [`decode_frame`] or one of [`ParsedFrame`]`'s` /// Payload bytes normally come from [`decode_frame`] or one of [`ParsedFrame`]`'s`
/// `deserialize_*` helpers. This function remains public for callers that archive nested /// `deserialize_*` helpers. This function remains public for callers that archive nested
/// application payloads inside protocol `data` fields. /// 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::<rkyv::rancor::Error>(&Example { value: 7 }).unwrap();
/// let decoded = deserialize_archived_bytes::<<Example as Archive>::Archived, Example>(&bytes)?;
/// assert_eq!(decoded, Example { value: 7 });
/// # Ok::<(), unshell::protocol::FrameError>(())
/// ```
pub fn deserialize_archived_bytes<A, T>(bytes: &[u8]) -> Result<T, FrameError> pub fn deserialize_archived_bytes<A, T>(bytes: &[u8]) -> Result<T, FrameError>
where where
A: rkyv::Portable A: rkyv::Portable
+57
View File
@@ -1,4 +1,19 @@
//! Required introspection payloads for discovery. //! 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 alloc::{string::String, vec::Vec};
use rkyv::{Archive, Deserialize, Serialize}; 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 /// The protocol uses the empty string here so discovery traffic stays outside the normal
/// application procedure namespace. [`crate::protocol::validate_procedure_id`] reserves that /// application procedure namespace. [`crate::protocol::validate_procedure_id`] reserves that
/// value exclusively for introspection. /// value exclusively for introspection.
///
/// # Example
/// ```rust
/// use unshell::protocol::INTROSPECTION_PROCEDURE_ID;
/// assert!(INTROSPECTION_PROCEDURE_ID.is_empty());
/// ```
pub const INTROSPECTION_PROCEDURE_ID: &str = ""; pub const INTROSPECTION_PROCEDURE_ID: &str = "";
/// Endpoint-wide introspection payload. /// 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)] #[derive(Archive, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct EndpointIntrospection { pub struct EndpointIntrospection {
/// Direct child endpoint segment names hosted immediately below this endpoint. /// Direct child endpoint segment names hosted immediately below this endpoint.
@@ -20,6 +54,19 @@ pub struct EndpointIntrospection {
} }
/// Shared per-leaf discovery record. /// 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)] #[derive(Archive, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct LeafIntrospectionSummary { pub struct LeafIntrospectionSummary {
/// Canonical dotted leaf identifier. /// Canonical dotted leaf identifier.
@@ -32,6 +79,16 @@ pub struct LeafIntrospectionSummary {
/// ///
/// This duplicates [`LeafIntrospectionSummary`] intentionally because the leaf-only response is /// This duplicates [`LeafIntrospectionSummary`] intentionally because the leaf-only response is
/// a distinct wire payload from the endpoint-wide discovery response. /// 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)] #[derive(Archive, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct LeafIntrospection { pub struct LeafIntrospection {
/// Canonical dotted leaf identifier. /// Canonical dotted leaf identifier.
+29
View File
@@ -10,6 +10,35 @@
//! //!
//! The concrete wire structs live in the private `types` module and are re-exported here so the //! 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. //! 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 codec;
pub mod introspection; pub mod introspection;
+265 -2
View File
@@ -14,6 +14,22 @@ use super::{
}; };
/// One typed incoming `Call` passed to a leaf procedure. /// 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)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct Call<T> { pub struct Call<T> {
/// Decoded application input payload. /// Decoded application input payload.
@@ -29,6 +45,30 @@ pub struct Call<T> {
} }
/// One incoming local call event that already passed protocol validation. /// 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<T>`].
///
/// # 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)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct IncomingCall { pub struct IncomingCall {
/// Validated protocol header for the call. /// Validated protocol header for the call.
@@ -38,6 +78,31 @@ pub struct IncomingCall {
} }
/// One incoming local data event tied to an active hook. /// 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)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct IncomingData { pub struct IncomingData {
/// Validated protocol header for the data packet. /// 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. /// 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)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct IncomingFault { pub struct IncomingFault {
/// Validated protocol header for the fault packet. /// Validated protocol header for the fault packet.
@@ -60,6 +146,16 @@ pub struct IncomingFault {
} }
/// Outcome of one generated initial call procedure. /// 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<String> = CallResult::Reply("hello".into());
/// assert!(matches!(reply, CallResult::Reply(_)));
/// ```
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum CallResult<T> { pub enum CallResult<T> {
/// Return one reply payload to the caller. /// Return one reply payload to the caller.
@@ -69,6 +165,22 @@ pub enum CallResult<T> {
} }
/// One hook-associated `Data` packet emitted by leaf code. /// 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)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct OutgoingData { pub struct OutgoingData {
/// Destination endpoint path for the hook packet. /// Destination endpoint path for the hook packet.
@@ -84,6 +196,16 @@ pub struct OutgoingData {
} }
/// One runtime-normalized reply produced by generated call dispatch. /// 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)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum CallReply { pub enum CallReply {
/// Serialized reply bytes that should be returned upstream. /// 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. /// 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<core::convert::Infallible> = DispatchError::Decode(FrameError::Truncated);
/// assert!(matches!(error, DispatchError::Decode(_)));
/// ```
#[derive(Debug)] #[derive(Debug)]
pub enum DispatchError<E> { pub enum DispatchError<E> {
/// Failed to decode the typed call input. /// Failed to decode the typed call input.
@@ -119,6 +252,17 @@ where
impl<E> core::error::Error for DispatchError<E> where E: core::error::Error + 'static {} impl<E> core::error::Error for DispatchError<E> where E: core::error::Error + 'static {}
/// Error surfaced by the stateful leaf runtime. /// 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<core::convert::Infallible> = LeafRuntimeError::Dispatch(DispatchError::Decode(FrameError::Truncated));
/// assert!(matches!(error, LeafRuntimeError::Dispatch(_)));
/// ```
#[derive(Debug)] #[derive(Debug)]
pub enum LeafRuntimeError<E> { pub enum LeafRuntimeError<E> {
/// Protocol endpoint routing or framing failed. /// Protocol endpoint routing or framing failed.
@@ -151,6 +295,21 @@ impl<E> From<EndpointError> for LeafRuntimeError<E> {
} }
/// High-level leaf behavior layered on top of validated protocol events. /// 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 { pub trait CallLeaf: ProtocolLeaf {
/// Leaf-specific error surfaced by call, data, or fault handling. /// Leaf-specific error surfaced by call, data, or fault handling.
type Error; type Error;
@@ -172,6 +331,16 @@ pub trait CallLeaf: ProtocolLeaf {
} }
/// Stateful runtime that combines a protocol endpoint with one leaf instance. /// 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::<LeafRuntime<Leaf>>;
/// ```
#[derive(Debug)] #[derive(Debug)]
pub struct LeafRuntime<L> { pub struct LeafRuntime<L> {
endpoint: ProtocolEndpoint, endpoint: ProtocolEndpoint,
@@ -179,6 +348,16 @@ pub struct LeafRuntime<L> {
} }
/// Frames emitted by the runtime after one receive or poll step. /// 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)] #[derive(Debug, Default)]
pub struct RuntimeOutcome { pub struct RuntimeOutcome {
/// Frames emitted while processing the step. /// Frames emitted while processing the step.
@@ -190,28 +369,68 @@ pub struct RuntimeOutcome {
impl<L> LeafRuntime<L> { impl<L> LeafRuntime<L> {
/// Builds a runtime from one endpoint and one leaf instance. /// Builds a runtime from one endpoint and one leaf instance.
#[must_use] #[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 { pub fn new(endpoint: ProtocolEndpoint, leaf: L) -> Self {
Self { endpoint, leaf } Self { endpoint, leaf }
} }
/// Returns the underlying protocol endpoint. /// Returns the underlying protocol endpoint.
#[must_use] #[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 { pub fn endpoint(&self) -> &ProtocolEndpoint {
&self.endpoint &self.endpoint
} }
/// Returns a mutable reference to the underlying 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 { pub fn endpoint_mut(&mut self) -> &mut ProtocolEndpoint {
&mut self.endpoint &mut self.endpoint
} }
/// Returns the hosted leaf instance. /// Returns the hosted leaf instance.
#[must_use] #[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 { pub fn leaf(&self) -> &L {
&self.leaf &self.leaf
} }
/// Returns a mutable reference to the hosted leaf instance. /// 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 { pub fn leaf_mut(&mut self) -> &mut L {
&mut self.leaf &mut self.leaf
} }
@@ -222,6 +441,13 @@ where
L: CallLeaf + super::CallProcedures<Error = <L as CallLeaf>::Error>, L: CallLeaf + super::CallProcedures<Error = <L as CallLeaf>::Error>,
{ {
/// Delivers one inbound frame into the stateful leaf runtime. /// Delivers one inbound frame into the stateful leaf runtime.
///
/// # Example
/// ```rust
/// # use unshell::protocol::tree::{LeafRuntime, ProtocolEndpoint};
/// # struct ExampleLeaf;
/// # let _ = core::marker::PhantomData::<LeafRuntime<ExampleLeaf>>;
/// ```
pub fn receive( pub fn receive(
&mut self, &mut self,
ingress: &Ingress, ingress: &Ingress,
@@ -232,6 +458,13 @@ where
} }
/// Polls the leaf for locally-generated hook traffic and routes any emitted frames. /// 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::<LeafRuntime<ExampleLeaf>>;
/// ```
pub fn poll(&mut self) -> Result<RuntimeOutcome, LeafRuntimeError<<L as CallLeaf>::Error>> { pub fn poll(&mut self) -> Result<RuntimeOutcome, LeafRuntimeError<<L as CallLeaf>::Error>> {
let outgoing = self.leaf.poll().map_err(LeafRuntimeError::Leaf)?; let outgoing = self.leaf.poll().map_err(LeafRuntimeError::Leaf)?;
self.emit_outgoing(outgoing) self.emit_outgoing(outgoing)
@@ -309,8 +542,9 @@ where
} }
Ok(CallReply::NoReply) => Ok(RuntimeOutcome::default()), Ok(CallReply::NoReply) => Ok(RuntimeOutcome::default()),
Err(error) => { Err(error) => {
let frames = self.emit_internal_fault_if_possible(fault_hook)?; // Dispatch failures still emit a protocol fault for the remote caller when a
let _ = frames; // 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)) Err(LeafRuntimeError::Dispatch(error))
} }
} }
@@ -402,6 +636,21 @@ where
} }
/// Decodes one archived call payload into a typed application request. /// 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::<Example>(&bytes)?;
/// assert_eq!(decoded, Example { value: 7 });
/// # Ok::<(), unshell::protocol::FrameError>(())
/// ```
pub fn decode_call_input<T>(bytes: &[u8]) -> Result<T, FrameError> pub fn decode_call_input<T>(bytes: &[u8]) -> Result<T, FrameError>
where where
T: Archive, T: Archive,
@@ -413,6 +662,20 @@ where
} }
/// Encodes one typed application reply into hook `Data` bytes. /// 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<T>(value: &T) -> Result<Vec<u8>, FrameError> pub fn encode_call_reply<T>(value: &T) -> Result<Vec<u8>, FrameError>
where where
T: for<'a> Serialize< T: for<'a> Serialize<
+89
View File
@@ -107,6 +107,21 @@ impl ProtocolEndpoint {
/// ///
/// `parent_path` is currently used only as a presence flag. The endpoint stores its own /// `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. /// 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( pub fn new(
path: Vec<String>, path: Vec<String>,
parent_path: Option<Vec<String>>, parent_path: Option<Vec<String>>,
@@ -133,6 +148,17 @@ impl ProtocolEndpoint {
} }
/// Registers a procedure that is handled directly by the endpoint. /// 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( pub fn add_endpoint_procedure(
&mut self, &mut self,
procedure_id: impl Into<String>, procedure_id: impl Into<String>,
@@ -145,11 +171,37 @@ impl ProtocolEndpoint {
#[must_use] #[must_use]
/// Allocates a hook id scoped to this endpoint path. /// 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 { pub fn allocate_hook_id(&mut self) -> u64 {
self.hooks.allocate_hook_id(&self.path) self.hooks.allocate_hook_id(&self.path)
} }
/// Encodes a call frame without routing it through the local endpoint. /// 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( pub fn make_call(
&mut self, &mut self,
dst_path: Vec<String>, dst_path: Vec<String>,
@@ -165,6 +217,26 @@ impl ProtocolEndpoint {
} }
/// Builds and immediately routes a call, producing either a forward or a local event. /// 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( pub fn send_call(
&mut self, &mut self,
dst_path: Vec<String>, dst_path: Vec<String>,
@@ -191,6 +263,15 @@ impl ProtocolEndpoint {
} }
/// Encodes a data frame without routing it through the local endpoint. /// 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( pub fn make_data(
&self, &self,
dst_path: Vec<String>, dst_path: Vec<String>,
@@ -205,6 +286,14 @@ impl ProtocolEndpoint {
} }
/// Builds and immediately routes a data packet, updating local hook state for end-of-stream. /// 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( pub fn send_data(
&mut self, &mut self,
dst_path: Vec<String>, dst_path: Vec<String>,
+139
View File
@@ -14,6 +14,16 @@ use crate::protocol::{
use super::super::{CompiledRoutes, HookKey, HookTable, RouteDecision}; use super::super::{CompiledRoutes, HookKey, HookTable, RouteDecision};
/// Routing metadata for one direct child endpoint. /// 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)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChildRoute { pub struct ChildRoute {
/// Absolute path for the child endpoint inside the protocol tree. /// Absolute path for the child endpoint inside the protocol tree.
@@ -25,6 +35,13 @@ pub struct ChildRoute {
impl ChildRoute { impl ChildRoute {
#[must_use] #[must_use]
/// Builds one child route that is immediately eligible for routing decisions. /// 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<String>) -> Self { pub fn registered(path: Vec<String>) -> Self {
Self { Self {
path, path,
@@ -34,6 +51,19 @@ impl ChildRoute {
} }
/// Procedures exposed by a named leaf attached to this endpoint. /// 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)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct LeafSpec { pub struct LeafSpec {
/// Leaf identifier used in packet headers. /// Leaf identifier used in packet headers.
@@ -43,6 +73,16 @@ pub struct LeafSpec {
} }
/// Where an inbound frame entered this endpoint. /// 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)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum Ingress { pub enum Ingress {
/// The frame arrived from the parent side of the tree. /// 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. /// 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)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum LocalEvent { pub enum LocalEvent {
/// One opening `Call` packet validated and delivered to local code. /// 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. /// 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)] #[derive(Debug)]
pub enum EndpointOutcome { pub enum EndpointOutcome {
/// Frame to forward, together with the next routing decision. /// Frame to forward, together with the next routing decision.
@@ -98,6 +176,19 @@ pub enum EndpointOutcome {
} }
/// Error surfaced while validating or encoding protocol frames. /// 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)] #[derive(Debug)]
pub enum EndpointError { pub enum EndpointError {
/// Framing, archive decode, or archive encode failed. /// Framing, archive decode, or archive encode failed.
@@ -130,11 +221,49 @@ impl From<ValidationError> for EndpointError {
} }
/// Minimal interface implemented by protocol-tree endpoints. /// 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::<String>::new());
/// let _ = Ingress::Local;
/// ```
pub trait Endpoint { pub trait Endpoint {
/// Returns this endpoint's absolute path. /// 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]; fn path(&self) -> &[String];
/// Processes one inbound frame from the given ingress. /// 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( fn receive(
&mut self, &mut self,
ingress: &Ingress, ingress: &Ingress,
@@ -143,6 +272,16 @@ pub trait Endpoint {
} }
/// Runtime state for one endpoint in the protocol tree. /// 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)] #[derive(Debug, Default)]
pub struct ProtocolEndpoint { pub struct ProtocolEndpoint {
pub(crate) path: Vec<String>, pub(crate) path: Vec<String>,
+271
View File
@@ -12,6 +12,16 @@
use alloc::{collections::BTreeMap, string::String, vec::Vec}; use alloc::{collections::BTreeMap, string::String, vec::Vec};
/// Hook table key scoped to the hook host path. /// 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)] #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct HookKey { pub struct HookKey {
/// Path of the endpoint hosting the hook state. /// Path of the endpoint hosting the hook state.
@@ -22,6 +32,13 @@ pub struct HookKey {
impl HookKey { impl HookKey {
/// Builds the canonical key for a hook hosted at `return_path`. /// 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] #[must_use]
pub fn new(return_path: Vec<String>, hook_id: u64) -> Self { pub fn new(return_path: Vec<String>, hook_id: u64) -> Self {
Self { Self {
@@ -32,6 +49,20 @@ impl HookKey {
} }
/// Pending hook context used only for fault attribution before activation. /// 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)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct PendingHook { pub struct PendingHook {
/// Caller path to promote into `peer_path` once the hook becomes active. /// 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. /// 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)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct ActiveHook { pub struct ActiveHook {
/// Remote endpoint path currently paired with this hook. /// Remote endpoint path currently paired with this hook.
@@ -56,10 +102,34 @@ pub struct ActiveHook {
} }
/// Duplicate hook insertion error. /// 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct HookConflict; pub struct HookConflict;
/// Durable hook state tables. /// 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)] #[derive(Debug, Default)]
pub struct HookTable { pub struct HookTable {
pending: BTreeMap<HookKey, PendingHook>, pending: BTreeMap<HookKey, PendingHook>,
@@ -77,6 +147,14 @@ impl HookTable {
/// The table currently uses one counter shared across all host paths. The `return_path` /// 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 /// parameter remains in the API because hook ids are still interpreted as host-scoped by the
/// rest of the protocol surface. /// 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] #[must_use]
pub fn allocate_hook_id(&mut self, _return_path: &[String]) -> u64 { pub fn allocate_hook_id(&mut self, _return_path: &[String]) -> u64 {
let id = self.next_id.max(1); 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. /// 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( pub fn insert_pending(
&mut self, &mut self,
key: HookKey, key: HookKey,
@@ -101,6 +191,21 @@ impl HookTable {
/// ///
/// Activation intentionally reuses the original hook id and host path, but swaps the /// 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. /// 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<()> { pub fn activate_pending(&mut self, key: &HookKey) -> Option<()> {
let pending = self.pending.remove(key)?; let pending = self.pending.remove(key)?;
self.insert_active( self.insert_active(
@@ -117,7 +222,23 @@ impl HookTable {
} }
/// Inserts a live hook and its peer-path lookup entry. /// 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> { 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) if self.pending.contains_key(&key)
|| self.active.contains_key(&key) || self.active.contains_key(&key)
|| self || self
@@ -136,11 +257,40 @@ impl HookTable {
} }
/// Removes a pending hook without affecting active state. /// 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<PendingHook> { pub fn remove_pending(&mut self, key: &HookKey) -> Option<PendingHook> {
self.pending.remove(key) self.pending.remove(key)
} }
/// Marks the local side finished before the hook becomes active. /// 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) { pub fn mark_pending_local_end(&mut self, key: &HookKey) {
if let Some(pending) = self.pending.get_mut(key) { if let Some(pending) = self.pending.get_mut(key) {
pending.local_ended = true; pending.local_ended = true;
@@ -148,6 +298,21 @@ impl HookTable {
} }
/// Removes an active hook and its secondary peer-path index entry. /// 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<ActiveHook> { pub fn remove_active(&mut self, key: &HookKey) -> Option<ActiveHook> {
let active = self.active.remove(key)?; let active = self.active.remove(key)?;
if let Some(peer_paths) = self.active_by_peer.get_mut(&key.hook_id) { 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. /// 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] #[must_use]
pub fn pending(&self, key: &HookKey) -> Option<&PendingHook> { pub fn pending(&self, key: &HookKey) -> Option<&PendingHook> {
self.pending.get(key) self.pending.get(key)
} }
/// Returns the active hook for `key`, if present. /// 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] #[must_use]
pub fn active(&self, key: &HookKey) -> Option<&ActiveHook> { pub fn active(&self, key: &HookKey) -> Option<&ActiveHook> {
self.active.get(key) self.active.get(key)
} }
/// Returns the mutable active hook for `key`, if present. /// 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> { pub fn active_mut(&mut self, key: &HookKey) -> Option<&mut ActiveHook> {
self.active.get_mut(key) self.active.get_mut(key)
} }
@@ -181,6 +391,21 @@ impl HookTable {
/// The host side addresses hooks directly by `(return_path, hook_id)`. Peer-originated /// 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 /// traffic only has `(hook_id, peer_path)`, so the secondary index maps that back to the
/// canonical host-scoped key. /// 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] #[must_use]
pub fn resolve_active_key( pub fn resolve_active_key(
&self, &self,
@@ -188,6 +413,8 @@ impl HookTable {
hook_id: u64, hook_id: u64,
peer_path: &[String], peer_path: &[String],
) -> Option<HookKey> { ) -> Option<HookKey> {
// 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 if let Some(key) = self
.active_by_peer .active_by_peer
.get(&hook_id) .get(&hook_id)
@@ -204,6 +431,21 @@ impl HookTable {
/// ///
/// This does not remove the hook. Callers use the boolean to decide whether cleanup should /// 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. /// 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 { pub fn mark_local_end(&mut self, key: &HookKey) -> bool {
let Some(active) = self.active_mut(key) else { let Some(active) = self.active_mut(key) else {
return false; return false;
@@ -216,6 +458,21 @@ impl HookTable {
/// ///
/// This mirrors [`mark_local_end`](Self::mark_local_end): it only reports completion, leaving /// 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. /// 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 { pub fn mark_peer_end(&mut self, key: &HookKey) -> bool {
let Some(active) = self.active_mut(key) else { let Some(active) = self.active_mut(key) else {
return false; return false;
@@ -225,12 +482,26 @@ impl HookTable {
} }
/// Returns the number of active hooks. /// 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] #[must_use]
pub fn active_len(&self) -> usize { pub fn active_len(&self) -> usize {
self.active.len() self.active.len()
} }
/// Returns the number of pending hooks. /// 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] #[must_use]
pub fn pending_len(&self) -> usize { pub fn pending_len(&self) -> usize {
self.pending.len() self.pending.len()
+114
View File
@@ -9,20 +9,90 @@ use alloc::{string::String, vec::Vec};
use super::LeafSpec; use super::LeafSpec;
/// Static metadata for one application-defined protocol leaf. /// 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 { pub trait ProtocolLeaf {
/// Returns the canonical dotted leaf name hosted by this type. /// 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; fn leaf_name() -> String;
} }
/// Generated call metadata and initial `Call` dispatch for one leaf. /// 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<unshell::protocol::tree::CallReply, DispatchError<Self::Error>> {
/// Ok(unshell::protocol::tree::CallReply::NoReply)
/// }
/// }
/// assert_eq!(ExampleLeaf::procedure_id("invoke").unwrap(), "org.example.v1.echo.invoke");
/// ```
pub trait CallProcedures: ProtocolLeaf { pub trait CallProcedures: ProtocolLeaf {
/// Leaf-specific error surfaced when generated call dispatch fails. /// Leaf-specific error surfaced when generated call dispatch fails.
type Error; type Error;
/// Returns the local procedure suffixes supported by this leaf. /// 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<unshell::protocol::tree::CallReply, unshell::protocol::tree::DispatchError<Self::Error>> { Ok(unshell::protocol::tree::CallReply::NoReply) }
/// }
/// assert_eq!(ExampleLeaf::procedure_suffixes(), &["invoke", "stream"]);
/// ```
fn procedure_suffixes() -> &'static [&'static str]; fn procedure_suffixes() -> &'static [&'static str];
/// Resolves one local procedure suffix to its full canonical `procedure_id`. /// 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<unshell::protocol::tree::CallReply, unshell::protocol::tree::DispatchError<Self::Error>> { 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<String> { fn procedure_id(suffix: &str) -> Option<String> {
if !Self::procedure_suffixes().contains(&suffix) { if !Self::procedure_suffixes().contains(&suffix) {
return None; return None;
@@ -35,6 +105,19 @@ pub trait CallProcedures: ProtocolLeaf {
} }
/// Returns the full canonical `procedure_id` values supported by this leaf. /// 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<unshell::protocol::tree::CallReply, unshell::protocol::tree::DispatchError<Self::Error>> { Ok(unshell::protocol::tree::CallReply::NoReply) }
/// }
/// assert_eq!(ExampleLeaf::procedure_ids(), vec![String::from("org.example.v1.echo.invoke")]);
/// ```
fn procedure_ids() -> Vec<String> { fn procedure_ids() -> Vec<String> {
Self::procedure_suffixes() Self::procedure_suffixes()
.iter() .iter()
@@ -43,6 +126,20 @@ pub trait CallProcedures: ProtocolLeaf {
} }
/// Materializes the runtime leaf metadata consumed by `ProtocolEndpoint`. /// 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<unshell::protocol::tree::CallReply, unshell::protocol::tree::DispatchError<Self::Error>> { Ok(unshell::protocol::tree::CallReply::NoReply) }
/// }
/// let spec = ExampleLeaf::leaf_spec();
/// assert_eq!(spec.name, "org.example.v1.echo");
/// ```
fn leaf_spec() -> LeafSpec { fn leaf_spec() -> LeafSpec {
LeafSpec { LeafSpec {
name: Self::leaf_name(), name: Self::leaf_name(),
@@ -55,6 +152,21 @@ pub trait CallProcedures: ProtocolLeaf {
/// Implementations may assume the endpoint already proved the call targets this leaf. /// 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 /// They are still responsible for decoding the typed input payload and deciding which local
/// procedure suffix should run. /// 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<unshell::protocol::tree::CallReply, DispatchError<Self::Error>> {
/// Ok(unshell::protocol::tree::CallReply::NoReply)
/// }
/// }
/// # let _ = ExampleLeaf;
/// ```
fn dispatch_call( fn dispatch_call(
&mut self, &mut self,
call: crate::protocol::tree::IncomingCall, 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()) { if let Some(leaf_name) = leaf_name.filter(|value| !value.is_empty()) {
segments.extend(split_leaf_path(leaf_name)); segments.extend(split_leaf_path(leaf_name));
} else { } 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 let mut module_segments = module_path
.split("::") .split("::")
.map(normalize_leaf_segment) .map(normalize_leaf_segment)
+1 -1
View File
@@ -26,7 +26,7 @@ pub use endpoint::{
pub use hook::{ActiveHook, HookConflict, HookKey, HookTable, PendingHook}; pub use hook::{ActiveHook, HookConflict, HookKey, HookTable, PendingHook};
pub use leaf::{CallProcedures, ProtocolLeaf, derive_leaf_name}; pub use leaf::{CallProcedures, ProtocolLeaf, derive_leaf_name};
pub use procedure::{ pub use procedure::{
Procedure, ProcedureEffect, ProcedureRuntime, ProcedureRuntimeError, ProcedureStore, Procedure, ProcedureEffect, ProcedureRuntime, ProcedureRuntimeError, ProcedureRuntimeOutcome, ProcedureStore,
StatefulProcedureMetadata, StatefulProcedureMetadata,
}; };
pub use routing::{ pub use routing::{
+165
View File
@@ -31,6 +31,20 @@ use super::{
/// ///
/// This metadata is intentionally tiny: one procedure suffix plus the derived /// This metadata is intentionally tiny: one procedure suffix plus the derived
/// full `procedure_id`. The leaf still owns all session storage explicitly. /// 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<ExampleLeaf> for Open {
/// fn procedure_suffix() -> &'static str { "open" }
/// }
/// assert_eq!(Open::procedure_id(), "org.example.v1.shell.open");
/// ```
pub trait StatefulProcedureMetadata<L>: Sized pub trait StatefulProcedureMetadata<L>: Sized
where where
L: ProtocolLeaf, L: ProtocolLeaf,
@@ -51,6 +65,19 @@ where
/// ///
/// Rationale: the leaf remains the source of truth for its active sessions. This /// Rationale: the leaf remains the source of truth for its active sessions. This
/// avoids hidden generated enums or side tables and keeps debugging obvious. /// 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<HookKey, Session> }
/// impl ProcedureStore<Session> for Leaf {
/// fn procedure_sessions(&mut self) -> &mut BTreeMap<HookKey, Session> {
/// &mut self.sessions
/// }
/// }
/// ```
pub trait ProcedureStore<P> { pub trait ProcedureStore<P> {
/// Returns the hook-keyed session table for one procedure type. /// Returns the hook-keyed session table for one procedure type.
fn procedure_sessions(&mut self) -> &mut BTreeMap<HookKey, P>; fn procedure_sessions(&mut self) -> &mut BTreeMap<HookKey, P>;
@@ -152,6 +179,16 @@ where
} }
/// Output produced while advancing one session. /// 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)] #[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct ProcedureEffect { pub struct ProcedureEffect {
/// `Data` packets to emit after the session step completes. /// `Data` packets to emit after the session step completes.
@@ -163,6 +200,13 @@ pub struct ProcedureEffect {
impl ProcedureEffect { impl ProcedureEffect {
/// Builds an effect that keeps the session alive after emitting `outgoing`. /// Builds an effect that keeps the session alive after emitting `outgoing`.
#[must_use] #[must_use]
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::ProcedureEffect;
/// let effect = ProcedureEffect::outgoing(Vec::new());
/// assert!(!effect.close_session);
/// ```
pub fn outgoing(outgoing: Vec<OutgoingData>) -> Self { pub fn outgoing(outgoing: Vec<OutgoingData>) -> Self {
Self { Self {
outgoing, outgoing,
@@ -172,6 +216,13 @@ impl ProcedureEffect {
/// Builds an effect that closes the session after emitting `outgoing`. /// Builds an effect that closes the session after emitting `outgoing`.
#[must_use] #[must_use]
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::ProcedureEffect;
/// let effect = ProcedureEffect::close(Vec::new());
/// assert!(effect.close_session);
/// ```
pub fn close(outgoing: Vec<OutgoingData>) -> Self { pub fn close(outgoing: Vec<OutgoingData>) -> Self {
Self { Self {
outgoing, outgoing,
@@ -181,6 +232,18 @@ impl ProcedureEffect {
} }
/// Error surfaced by the procedure runtime. /// 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<core::convert::Infallible> =
/// ProcedureRuntimeError::Decode(DispatchError::Decode(FrameError::Truncated));
/// assert!(matches!(error, ProcedureRuntimeError::Decode(_)));
/// ```
#[derive(Debug)] #[derive(Debug)]
pub enum ProcedureRuntimeError<E> { pub enum ProcedureRuntimeError<E> {
/// Protocol endpoint routing or framing failed. /// Protocol endpoint routing or framing failed.
@@ -213,6 +276,16 @@ impl<E> From<EndpointError> for ProcedureRuntimeError<E> {
} }
/// Frames emitted while advancing one stateful procedure runtime. /// 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)] #[derive(Debug, Default)]
pub struct ProcedureRuntimeOutcome { pub struct ProcedureRuntimeOutcome {
/// Frames emitted while processing the current step. /// 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 /// 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. /// 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`]. /// 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::<ProcedureRuntime<Leaf, Proc>>;
/// ```
#[derive(Debug)] #[derive(Debug)]
pub struct ProcedureRuntime<L, P> { pub struct ProcedureRuntime<L, P> {
endpoint: ProtocolEndpoint, endpoint: ProtocolEndpoint,
@@ -236,6 +317,18 @@ pub struct ProcedureRuntime<L, P> {
impl<L, P> ProcedureRuntime<L, P> { impl<L, P> ProcedureRuntime<L, P> {
/// Builds a procedure runtime from one endpoint and one leaf instance. /// Builds a procedure runtime from one endpoint and one leaf instance.
#[must_use] #[must_use]
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::{ProcedureRuntime, ProtocolEndpoint};
/// struct Leaf;
/// struct Proc;
/// let runtime = ProcedureRuntime::<Leaf, Proc>::new(
/// ProtocolEndpoint::new(Vec::new(), None, Vec::new(), Vec::new()),
/// Leaf,
/// );
/// let _ = runtime;
/// ```
pub fn new(endpoint: ProtocolEndpoint, leaf: L) -> Self { pub fn new(endpoint: ProtocolEndpoint, leaf: L) -> Self {
Self { Self {
endpoint, endpoint,
@@ -246,22 +339,58 @@ impl<L, P> ProcedureRuntime<L, P> {
/// Returns the underlying protocol endpoint. /// Returns the underlying protocol endpoint.
#[must_use] #[must_use]
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::{ProcedureRuntime, ProtocolEndpoint};
/// struct Leaf;
/// struct Proc;
/// let runtime = ProcedureRuntime::<Leaf, Proc>::new(ProtocolEndpoint::new(Vec::new(), None, Vec::new(), Vec::new()), Leaf);
/// let _ = runtime.endpoint();
/// ```
pub fn endpoint(&self) -> &ProtocolEndpoint { pub fn endpoint(&self) -> &ProtocolEndpoint {
&self.endpoint &self.endpoint
} }
/// Returns a mutable reference to the protocol 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::<Leaf, Proc>::new(ProtocolEndpoint::new(Vec::new(), None, Vec::new(), Vec::new()), Leaf);
/// let _ = runtime.endpoint_mut();
/// ```
pub fn endpoint_mut(&mut self) -> &mut ProtocolEndpoint { pub fn endpoint_mut(&mut self) -> &mut ProtocolEndpoint {
&mut self.endpoint &mut self.endpoint
} }
/// Returns the hosted leaf instance. /// Returns the hosted leaf instance.
#[must_use] #[must_use]
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::{ProcedureRuntime, ProtocolEndpoint};
/// struct Leaf;
/// struct Proc;
/// let runtime = ProcedureRuntime::<Leaf, Proc>::new(ProtocolEndpoint::new(Vec::new(), None, Vec::new(), Vec::new()), Leaf);
/// let _ = runtime.leaf();
/// ```
pub fn leaf(&self) -> &L { pub fn leaf(&self) -> &L {
&self.leaf &self.leaf
} }
/// Returns a mutable reference to the hosted leaf instance. /// 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::<Leaf, Proc>::new(ProtocolEndpoint::new(Vec::new(), None, Vec::new(), Vec::new()), Leaf);
/// let _ = runtime.leaf_mut();
/// ```
pub fn leaf_mut(&mut self) -> &mut L { pub fn leaf_mut(&mut self) -> &mut L {
&mut self.leaf &mut self.leaf
} }
@@ -278,6 +407,14 @@ where
P::Error: fmt::Display, P::Error: fmt::Display,
{ {
/// Delivers one framed protocol packet into the runtime. /// Delivers one framed protocol packet into the runtime.
///
/// # Example
/// ```rust
/// # use unshell::protocol::tree::ProcedureRuntime;
/// # struct Leaf;
/// # struct Proc;
/// # let _ = core::marker::PhantomData::<ProcedureRuntime<Leaf, Proc>>;
/// ```
pub fn receive( pub fn receive(
&mut self, &mut self,
ingress: &Ingress, ingress: &Ingress,
@@ -291,6 +428,14 @@ where
/// ///
/// Rationale: many long-lived procedures, including a remote shell, need to /// Rationale: many long-lived procedures, including a remote shell, need to
/// emit output even when no new inbound protocol packet has arrived. /// 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::<ProcedureRuntime<Leaf, Proc>>;
/// ```
pub fn poll(&mut self) -> Result<ProcedureRuntimeOutcome, ProcedureRuntimeError<P::Error>> { pub fn poll(&mut self) -> Result<ProcedureRuntimeOutcome, ProcedureRuntimeError<P::Error>> {
let mut frames = Vec::new(); let mut frames = Vec::new();
let keys = self let keys = self
@@ -304,6 +449,8 @@ where
let Some(session) = self.leaf.procedure_sessions().remove(&key) else { let Some(session) = self.leaf.procedure_sessions().remove(&key) else {
continue; 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)? { match self.poll_session(key, session)? {
Some(session_frames) => frames.extend(session_frames), Some(session_frames) => frames.extend(session_frames),
None => continue, None => continue,
@@ -351,6 +498,8 @@ where
let outgoing = match self.emit_outgoing(effect.outgoing) { let outgoing = match self.emit_outgoing(effect.outgoing) {
Ok(outgoing) => outgoing.frames, Ok(outgoing) => outgoing.frames,
Err(error) => { 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 { if !effect.close_session {
self.leaf.procedure_sessions().insert(key, session); self.leaf.procedure_sessions().insert(key, session);
} else { } else {
@@ -395,6 +544,8 @@ where
) -> Result<ProcedureRuntimeOutcome, ProcedureRuntimeError<P::Error>> { ) -> Result<ProcedureRuntimeOutcome, ProcedureRuntimeError<P::Error>> {
let mut runtime = ProcedureRuntimeOutcome::default(); let mut runtime = ProcedureRuntimeOutcome::default();
if message.procedure_id != P::procedure_id() { 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 runtime
.frames .frames
.extend(self.emit_internal_fault_if_possible(message.response_hook.as_ref())?); .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) { let session = match self.open_session(header, message) {
Ok(session) => session, Ok(session) => session,
Err(error) => { 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 runtime
.frames .frames
.extend(self.emit_internal_fault(Some(hook_key.clone()))?); .extend(self.emit_internal_fault(Some(hook_key.clone()))?);
@@ -489,6 +642,8 @@ where
hook_key: hook_key.clone(), 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); let close_result = P::close(&mut self.leaf, session);
if let Err(error) = on_fault_result { if let Err(error) = on_fault_result {
let _ = close_result; let _ = close_result;
@@ -558,6 +713,14 @@ where
/// Emits an upstream internal fault for the current procedure if the caller /// Emits an upstream internal fault for the current procedure if the caller
/// declared a response hook. /// declared a response hook.
///
/// # Example
/// ```rust
/// # use unshell::protocol::tree::ProcedureRuntime;
/// # struct Leaf;
/// # struct Proc;
/// # let _ = core::marker::PhantomData::<ProcedureRuntime<Leaf, Proc>>;
/// ```
pub fn emit_internal_fault_if_possible( pub fn emit_internal_fault_if_possible(
&mut self, &mut self,
hook: Option<&HookTarget>, hook: Option<&HookTarget>,
@@ -598,6 +761,8 @@ where
// Once a session emits `end_hook`, later packets would violate the protocol, // Once a session emits `end_hook`, later packets would violate the protocol,
// so the runtime keeps only the prefix through that terminal packet. // so the runtime keeps only the prefix through that terminal packet.
if let Some(index) = effect.outgoing.iter().position(|packet| packet.end_hook) { 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); effect.outgoing.truncate(index + 1);
} }
let local_end_already_sent = self let local_end_already_sent = self
+131
View File
@@ -6,6 +6,25 @@
use alloc::{collections::BTreeMap, string::String, vec, vec::Vec}; use alloc::{collections::BTreeMap, string::String, vec, vec::Vec};
/// Explicit tree declaration used for configuration and tests. /// 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)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum TreeNode { pub enum TreeNode {
/// The protocol root. Its path is always empty. /// The protocol root. Its path is always empty.
@@ -25,6 +44,19 @@ pub enum TreeNode {
} }
/// Leaf declaration used inside the explicit tree enum. /// 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)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct LeafNode { pub struct LeafNode {
/// Leaf name local to an endpoint path. /// 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. /// Flattens the explicit tree into the set of endpoint paths it declares.
/// ///
/// The returned list always includes the protocol root as `[]`. /// 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::<String>::new(), vec!["worker".into()]]);
/// ```
pub fn paths(&self) -> Vec<Vec<String>> { pub fn paths(&self) -> Vec<Vec<String>> {
let mut paths = Vec::new(); let mut paths = Vec::new();
self.collect_paths(&[], &mut paths); self.collect_paths(&[], &mut paths);
@@ -67,6 +112,16 @@ impl TreeNode {
} }
/// Longest-prefix route decision. /// 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RouteDecision { pub enum RouteDecision {
/// Forward to the child at the given local child index. /// 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. /// 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)] #[derive(Debug, Clone, Default)]
pub struct CompiledRoutes { pub struct CompiledRoutes {
local_path: Vec<String>, local_path: Vec<String>,
@@ -99,6 +164,20 @@ impl CompiledRoutes {
/// ///
/// Only strict descendants of `local_path` participate in the compiled trie. Paths outside /// Only strict descendants of `local_path` participate in the compiled trie. Paths outside
/// the local subtree, or equal to `local_path` itself, are ignored. /// 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] #[must_use]
pub fn new(local_path: &[String], child_paths: &[Vec<String>], has_parent: bool) -> Self { pub fn new(local_path: &[String], child_paths: &[Vec<String>], has_parent: bool) -> Self {
let mut routes = Self { let mut routes = Self {
@@ -119,6 +198,8 @@ impl CompiledRoutes {
return; 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; let mut node_index = 0usize;
for segment in &child_path[self.local_path.len()..] { for segment in &child_path[self.local_path.len()..] {
let next_index = if let Some(next_index) = self.nodes[node_index].edges.get(segment) { 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. /// 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] #[must_use]
pub fn route(&self, dst_path: &[String]) -> RouteDecision { pub fn route(&self, dst_path: &[String]) -> RouteDecision {
if !is_prefix(&self.local_path, dst_path) { if !is_prefix(&self.local_path, dst_path) {
@@ -173,6 +263,16 @@ impl CompiledRoutes {
} }
/// Returns `true` if `prefix` is a path prefix of `path`. /// 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 { pub fn is_prefix(prefix: &[String], path: &[String]) -> bool {
prefix.len() <= path.len() prefix.len() <= path.len()
&& prefix && 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 /// 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 /// descendant wins for child forwarding, destinations outside the local subtree go to the parent
/// when one exists, and everything else drops. /// 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 { pub trait RouteProvider {
/// Returns the route decision for `dst_path` from the perspective of `local_path`. /// Returns the route decision for `dst_path` from the perspective of `local_path`.
fn route_destination<I>( fn route_destination<I>(
@@ -200,6 +313,17 @@ pub trait RouteProvider {
} }
/// Default routing implementation using the protocol's longest-prefix rule. /// 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; pub struct DefaultRouteProvider;
impl RouteProvider for DefaultRouteProvider { impl RouteProvider for DefaultRouteProvider {
@@ -226,6 +350,13 @@ impl RouteProvider for DefaultRouteProvider {
/// ///
/// Exact matches return [`RouteDecision::Local`]. Destinations outside the local subtree return /// Exact matches return [`RouteDecision::Local`]. Destinations outside the local subtree return
/// [`RouteDecision::Parent`] when `has_parent` is `true`, otherwise [`RouteDecision::Drop`]. /// [`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<I>( pub fn route_destination<I>(
local_path: &[String], local_path: &[String],
child_paths: I, child_paths: I,
+89
View File
@@ -4,6 +4,17 @@ use alloc::{string::String, vec::Vec};
use rkyv::{Archive, Deserialize, Serialize}; use rkyv::{Archive, Deserialize, Serialize};
/// The three protocol packet types. /// 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)] #[repr(u8)]
#[derive(Archive, Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] #[derive(Archive, Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
pub enum PacketType { pub enum PacketType {
@@ -16,6 +27,22 @@ pub enum PacketType {
} }
/// Header fields used for routing and hook attribution. /// 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)] #[derive(Archive, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct PacketHeader { pub struct PacketHeader {
/// Wire-level packet class, which determines which payload type follows. /// Wire-level packet class, which determines which payload type follows.
@@ -35,6 +62,19 @@ pub struct PacketHeader {
} }
/// Hook declaration embedded inside a call. /// 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)] #[derive(Archive, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct HookTarget { pub struct HookTarget {
/// Hook identifier reserved by the caller for returned `Data` or `Fault` traffic. /// Hook identifier reserved by the caller for returned `Data` or `Fault` traffic.
@@ -47,6 +87,23 @@ pub struct HookTarget {
} }
/// Downwards call payload. /// 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)] #[derive(Archive, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct CallMessage { pub struct CallMessage {
/// Canonical procedure identifier chosen by the caller. /// Canonical procedure identifier chosen by the caller.
@@ -58,6 +115,20 @@ pub struct CallMessage {
} }
/// Hook data payload. /// 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)] #[derive(Archive, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct DataMessage { pub struct DataMessage {
/// Canonical procedure identifier that owns the hook stream. /// Canonical procedure identifier that owns the hook stream.
@@ -69,6 +140,17 @@ pub struct DataMessage {
} }
/// Protocol fault payload. /// 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)] #[derive(Archive, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct FaultMessage { pub struct FaultMessage {
/// Stable protocol-level reason code for the failure. /// 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 /// 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 /// without knowing every symbolic constant in advance. Unknown values are allowed so newer
/// peers can extend the set without breaking older runtimes. /// 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)] #[derive(Archive, Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
pub struct ProtocolFault(pub u8); pub struct ProtocolFault(pub u8);
+60
View File
@@ -6,6 +6,22 @@ use crate::protocol::{
use core::fmt; use core::fmt;
/// Validation failures for protocol structures. /// 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)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum ValidationError { pub enum ValidationError {
/// One header field combination is invalid for the chosen packet type. /// 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, /// 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. /// 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> { pub fn validate_header(header: &PacketHeader) -> Result<(), ValidationError> {
match header.packet_type { match header.packet_type {
PacketType::Call => { 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 /// This is intentionally permissive. The protocol reserves only the empty string for
/// introspection; every other non-empty identifier is treated as opaque application data. /// 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> { pub fn validate_procedure_id(procedure_id: &str) -> Result<(), ValidationError> {
if procedure_id == INTROSPECTION_PROCEDURE_ID { if procedure_id == INTROSPECTION_PROCEDURE_ID {
return Ok(()); 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 /// This complements [`validate_header`]. It does not verify destination reachability or leaf
/// support, only consistency between the opening `Call` header and payload. /// 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> { pub fn validate_call(header: &PacketHeader, call: &CallMessage) -> Result<(), ValidationError> {
validate_procedure_id(&call.procedure_id)?; validate_procedure_id(&call.procedure_id)?;