Split protocol and leaf surfaces into crates

Move the protocol runtime into unshell-protocol and remote shell leaf code into unshell-leaves so endpoint and TUI roles can compile independently without circular dependencies.
This commit is contained in:
Michael Mikovsky
2026-04-26 12:39:06 -06:00
parent 74f08333ae
commit d4100d0604
41 changed files with 435 additions and 195 deletions
+687
View File
@@ -0,0 +1,687 @@
//! Stateful application-layer call runtime built on top of `ProtocolEndpoint`.
use alloc::{string::String, vec, vec::Vec};
use core::fmt;
use rkyv::{Archive, Serialize, rancor::Error, to_bytes, util::AlignedVec};
use crate::protocol::{
CallMessage, DataMessage, FrameBytes, FrameError, HookTarget, PacketHeader, ProtocolFault,
};
use super::{
Endpoint, EndpointError, HookKey, Ingress, LocalEvent, ProtocolEndpoint, ProtocolLeaf,
};
/// One typed incoming `Call` passed to a leaf procedure.
///
/// This exists so application code can work with a decoded request type plus the protocol context
/// that matters for authorization, routing, or replies.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::{Call, HookKey};
/// let call = Call {
/// input: String::from("hello"),
/// caller_path: vec!["root".into()],
/// procedure_id: "org.example.v1.echo.invoke".into(),
/// dst_leaf: Some("echo".into()),
/// response_hook: Some(HookKey::new(vec!["root".into()], 7)),
/// };
/// assert_eq!(call.input, "hello");
/// ```
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Call<T> {
/// Decoded application input payload.
pub input: T,
/// Endpoint path of the caller that opened this call.
pub caller_path: Vec<String>,
/// Canonical procedure identifier chosen by the caller.
pub procedure_id: String,
/// Optional destination leaf targeted by the call.
pub dst_leaf: Option<String>,
/// Hook key declared by the caller when it expects a response.
pub response_hook: Option<HookKey>,
}
/// 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)]
pub struct IncomingCall {
/// Validated protocol header for the call.
pub header: PacketHeader,
/// Application payload for the call.
pub message: CallMessage,
}
/// One incoming local data event tied to an active hook.
///
/// This exists so hook-aware leaf code receives both the payload and the resolved hook identity
/// that owns the stream.
///
/// # Example
/// ```rust
/// use unshell::protocol::{DataMessage, PacketHeader, PacketType};
/// use unshell::protocol::tree::{HookKey, IncomingData};
/// let data = IncomingData {
/// header: PacketHeader {
/// packet_type: PacketType::Data,
/// src_path: vec!["worker".into()],
/// dst_path: vec!["root".into()],
/// dst_leaf: None,
/// hook_id: Some(7),
/// },
/// message: DataMessage {
/// procedure_id: "example.invoke".into(),
/// data: vec![1],
/// end_hook: false,
/// },
/// hook_key: HookKey::new(vec!["root".into()], 7),
/// };
/// assert_eq!(data.hook_key.hook_id, 7);
/// ```
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IncomingData {
/// Validated protocol header for the data packet.
pub header: PacketHeader,
/// Hook-associated data payload.
pub message: DataMessage,
/// Resolved hook key for the active session.
pub hook_key: HookKey,
}
/// One incoming local fault event tied to a pending or active hook.
///
/// This exists so leaf code can observe upstream protocol termination and release any
/// application-level resources associated with the hook.
///
/// # Example
/// ```rust
/// use unshell::protocol::{FaultMessage, PacketHeader, PacketType, ProtocolFault};
/// use unshell::protocol::tree::{HookKey, IncomingFault};
/// let fault = IncomingFault {
/// header: PacketHeader {
/// packet_type: PacketType::Fault,
/// src_path: vec!["worker".into()],
/// dst_path: vec!["root".into()],
/// dst_leaf: None,
/// hook_id: Some(7),
/// },
/// fault: FaultMessage { fault: ProtocolFault::INTERNAL_ERROR },
/// hook_key: HookKey::new(vec!["root".into()], 7),
/// };
/// assert_eq!(fault.hook_key.hook_id, 7);
/// ```
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IncomingFault {
/// Validated protocol header for the fault packet.
pub header: PacketHeader,
/// Fault payload emitted by the peer.
pub fault: crate::protocol::FaultMessage,
/// Hook key for the pending or active session that faulted.
pub hook_key: HookKey,
}
/// 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)]
pub enum CallResult<T> {
/// Return one reply payload to the caller.
Reply(T),
/// Complete the call without any response data.
NoReply,
}
/// One hook-associated `Data` packet emitted by leaf code.
///
/// This exists as the normalized outbound unit produced by leaf code before the runtime turns it
/// into framed protocol traffic.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::OutgoingData;
/// let packet = OutgoingData {
/// dst_path: vec!["root".into()],
/// hook_id: 7,
/// procedure_id: "example.invoke".into(),
/// data: vec![1, 2, 3],
/// end_hook: true,
/// };
/// assert!(packet.end_hook);
/// ```
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OutgoingData {
/// Destination endpoint path for the hook packet.
pub dst_path: Vec<String>,
/// Hook identifier scoped to the receiving endpoint.
pub hook_id: u64,
/// Procedure identifier that owns this hook stream.
pub procedure_id: String,
/// Serialized application data to send.
pub data: Vec<u8>,
/// Whether this packet closes the local side of the hook.
pub end_hook: bool,
}
/// One runtime-normalized reply produced by generated call dispatch.
///
/// This exists because generated call dispatch always normalizes leaf return values into either
/// serialized reply bytes or an explicit “no reply” outcome.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::CallReply;
/// let reply = CallReply::Reply(vec![1, 2, 3]);
/// assert!(matches!(reply, CallReply::Reply(_)));
/// ```
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CallReply {
/// Serialized reply bytes that should be returned upstream.
Reply(Vec<u8>),
/// Complete without emitting any reply packet.
NoReply,
}
/// 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)]
pub enum DispatchError<E> {
/// Failed to decode the typed call input.
Decode(FrameError),
/// Failed to encode the typed call output.
Encode(FrameError),
/// The leaf-specific call handler returned an error.
Handler(E),
}
impl<E> fmt::Display for DispatchError<E>
where
E: fmt::Display,
{
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Decode(error) => write!(f, "call decode failed: {error}"),
Self::Encode(error) => write!(f, "call reply encode failed: {error}"),
Self::Handler(error) => write!(f, "call handler failed: {error}"),
}
}
}
impl<E> core::error::Error for DispatchError<E> where E: core::error::Error + 'static {}
/// Error surfaced by the stateful leaf runtime.
///
/// This exists so callers can distinguish transport/runtime failures from leaf-local business
/// logic failures.
///
/// # Example
/// ```rust
/// use unshell::protocol::{FrameError};
/// use unshell::protocol::tree::{DispatchError, LeafRuntimeError};
/// let error: LeafRuntimeError<core::convert::Infallible> = LeafRuntimeError::Dispatch(DispatchError::Decode(FrameError::Truncated));
/// assert!(matches!(error, LeafRuntimeError::Dispatch(_)));
/// ```
#[derive(Debug)]
pub enum LeafRuntimeError<E> {
/// Protocol endpoint routing or framing failed.
Endpoint(EndpointError),
/// Typed call dispatch failed.
Dispatch(DispatchError<E>),
/// Leaf-local data or fault handling failed.
Leaf(E),
}
impl<E> fmt::Display for LeafRuntimeError<E>
where
E: fmt::Display,
{
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Endpoint(error) => write!(f, "{error}"),
Self::Dispatch(error) => write!(f, "{error}"),
Self::Leaf(error) => write!(f, "{error}"),
}
}
}
impl<E> core::error::Error for LeafRuntimeError<E> where E: core::error::Error + 'static {}
impl<E> From<EndpointError> for LeafRuntimeError<E> {
fn from(value: EndpointError) -> Self {
Self::Endpoint(value)
}
}
/// High-level leaf behavior layered on top of validated protocol events.
///
/// This exists for leaves that want validated call/data/fault delivery without managing endpoint
/// routing details themselves.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::CallLeaf;
/// struct ExampleLeaf;
/// impl unshell::protocol::tree::ProtocolLeaf for ExampleLeaf {
/// fn leaf_name() -> String { "org.example.v1.echo".into() }
/// }
/// impl CallLeaf for ExampleLeaf {
/// type Error = core::convert::Infallible;
/// }
/// ```
pub trait CallLeaf: ProtocolLeaf {
/// Leaf-specific error surfaced by call, data, or fault handling.
type Error;
/// Handles hook-associated inbound `Data` after protocol validation.
fn on_data(&mut self, _data: IncomingData) -> Result<Vec<OutgoingData>, Self::Error> {
Ok(Vec::new())
}
/// Observes one inbound `Fault` after protocol validation.
fn on_fault(&mut self, _fault: IncomingFault) -> Result<(), Self::Error> {
Ok(())
}
/// Polls the leaf for locally-generated hook traffic.
fn poll(&mut self) -> Result<Vec<OutgoingData>, Self::Error> {
Ok(Vec::new())
}
}
/// 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)]
pub struct LeafRuntime<L> {
endpoint: ProtocolEndpoint,
leaf: L,
}
/// Frames emitted by the runtime after one receive or poll step.
///
/// This exists so callers can flush emitted frames to transport while also learning whether the
/// inbound packet was intentionally dropped.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::RuntimeOutcome;
/// let outcome = RuntimeOutcome::default();
/// assert!(outcome.frames.is_empty());
/// ```
#[derive(Debug, Default)]
pub struct RuntimeOutcome {
/// Frames emitted while processing the step.
pub frames: Vec<FrameBytes>,
/// Whether the endpoint dropped the incoming packet.
pub dropped: bool,
}
impl<L> LeafRuntime<L> {
/// Builds a runtime from one endpoint and one leaf instance.
#[must_use]
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::{LeafRuntime, ProtocolEndpoint};
/// struct ExampleLeaf;
/// let runtime = LeafRuntime::new(ProtocolEndpoint::new(Vec::new(), None, Vec::new(), Vec::new()), ExampleLeaf);
/// let _ = runtime;
/// ```
pub fn new(endpoint: ProtocolEndpoint, leaf: L) -> Self {
Self { endpoint, leaf }
}
/// Returns the underlying protocol endpoint.
#[must_use]
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::{LeafRuntime, ProtocolEndpoint};
/// struct ExampleLeaf;
/// let runtime = LeafRuntime::new(ProtocolEndpoint::new(Vec::new(), None, Vec::new(), Vec::new()), ExampleLeaf);
/// let _endpoint = runtime.endpoint();
/// ```
pub fn endpoint(&self) -> &ProtocolEndpoint {
&self.endpoint
}
/// Returns a mutable reference to the underlying endpoint.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::{LeafRuntime, ProtocolEndpoint};
/// struct ExampleLeaf;
/// let mut runtime = LeafRuntime::new(ProtocolEndpoint::new(Vec::new(), None, Vec::new(), Vec::new()), ExampleLeaf);
/// let _endpoint = runtime.endpoint_mut();
/// ```
pub fn endpoint_mut(&mut self) -> &mut ProtocolEndpoint {
&mut self.endpoint
}
/// Returns the hosted leaf instance.
#[must_use]
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::{LeafRuntime, ProtocolEndpoint};
/// struct ExampleLeaf;
/// let runtime = LeafRuntime::new(ProtocolEndpoint::new(Vec::new(), None, Vec::new(), Vec::new()), ExampleLeaf);
/// let _leaf = runtime.leaf();
/// ```
pub fn leaf(&self) -> &L {
&self.leaf
}
/// Returns a mutable reference to the hosted leaf instance.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::{LeafRuntime, ProtocolEndpoint};
/// struct ExampleLeaf;
/// let mut runtime = LeafRuntime::new(ProtocolEndpoint::new(Vec::new(), None, Vec::new(), Vec::new()), ExampleLeaf);
/// let _leaf = runtime.leaf_mut();
/// ```
pub fn leaf_mut(&mut self) -> &mut L {
&mut self.leaf
}
}
impl<L> LeafRuntime<L>
where
L: CallLeaf + super::CallProcedures<Error = <L as CallLeaf>::Error>,
{
/// 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(
&mut self,
ingress: &Ingress,
frame: FrameBytes,
) -> Result<RuntimeOutcome, LeafRuntimeError<<L as CallLeaf>::Error>> {
let outcome = self.endpoint.receive(ingress, frame)?;
self.process_endpoint_outcome(outcome)
}
/// 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>> {
let outgoing = self.leaf.poll().map_err(LeafRuntimeError::Leaf)?;
self.emit_outgoing(outgoing)
}
fn process_endpoint_outcome(
&mut self,
outcome: crate::protocol::tree::EndpointOutcome,
) -> Result<RuntimeOutcome, LeafRuntimeError<<L as CallLeaf>::Error>> {
match outcome {
crate::protocol::tree::EndpointOutcome::Forward { frame, .. } => Ok(RuntimeOutcome {
frames: vec![frame],
dropped: false,
}),
crate::protocol::tree::EndpointOutcome::Dropped => Ok(RuntimeOutcome {
frames: Vec::new(),
dropped: true,
}),
crate::protocol::tree::EndpointOutcome::Local(event) => self.process_local_event(event),
}
}
fn process_local_event(
&mut self,
event: LocalEvent,
) -> Result<RuntimeOutcome, LeafRuntimeError<<L as CallLeaf>::Error>> {
match event {
LocalEvent::Call { header, message } => self.process_local_call(header, message),
LocalEvent::Data {
header,
message,
hook_key,
} => self.process_local_data(header, message, hook_key),
LocalEvent::Fault {
header,
message,
hook_key,
} => self.process_local_fault(header, message, hook_key),
}
}
fn process_local_call(
&mut self,
header: PacketHeader,
message: CallMessage,
) -> Result<RuntimeOutcome, LeafRuntimeError<<L as CallLeaf>::Error>> {
let CallMessage {
procedure_id,
data,
response_hook,
} = message;
let fault_hook = response_hook.as_ref();
let incoming = IncomingCall {
header,
// Split the payload apart so the reply path can reuse the owned procedure id and
// response hook without re-decoding the incoming bytes.
message: CallMessage {
procedure_id: procedure_id.clone(),
data,
response_hook: response_hook.clone(),
},
};
match self.leaf.dispatch_call(incoming) {
Ok(CallReply::Reply(bytes)) => {
let frames = if let Some(hook) = response_hook {
self.send_reply_data(hook, procedure_id, bytes, true)?
} else {
Vec::new()
};
Ok(RuntimeOutcome {
frames,
dropped: false,
})
}
Ok(CallReply::NoReply) => Ok(RuntimeOutcome::default()),
Err(error) => {
// Dispatch failures still emit a protocol fault for the remote caller when a
// response hook exists, even though the local runtime also surfaces the error.
let _ = self.emit_internal_fault_if_possible(fault_hook)?;
Err(LeafRuntimeError::Dispatch(error))
}
}
}
fn process_local_data(
&mut self,
header: PacketHeader,
message: DataMessage,
hook_key: HookKey,
) -> Result<RuntimeOutcome, LeafRuntimeError<<L as CallLeaf>::Error>> {
let outgoing = self
.leaf
.on_data(IncomingData {
header,
message,
hook_key,
})
.map_err(LeafRuntimeError::Leaf)?;
self.emit_outgoing(outgoing)
}
fn process_local_fault(
&mut self,
header: PacketHeader,
message: crate::protocol::FaultMessage,
hook_key: HookKey,
) -> Result<RuntimeOutcome, LeafRuntimeError<<L as CallLeaf>::Error>> {
self.leaf
.on_fault(IncomingFault {
header,
fault: message,
hook_key,
})
.map_err(LeafRuntimeError::Leaf)?;
Ok(RuntimeOutcome::default())
}
fn emit_outgoing(
&mut self,
outgoing: Vec<OutgoingData>,
) -> Result<RuntimeOutcome, LeafRuntimeError<<L as CallLeaf>::Error>> {
let mut runtime = RuntimeOutcome::default();
for packet in outgoing {
let endpoint_outcome = self.endpoint.send_data(
packet.dst_path,
packet.hook_id,
packet.procedure_id,
packet.data,
packet.end_hook,
)?;
runtime
.frames
.extend(self.process_endpoint_outcome(endpoint_outcome)?.frames);
}
Ok(runtime)
}
fn send_reply_data(
&mut self,
hook: HookTarget,
procedure_id: String,
bytes: Vec<u8>,
end_hook: bool,
) -> Result<Vec<FrameBytes>, LeafRuntimeError<<L as CallLeaf>::Error>> {
let endpoint_outcome = self.endpoint.send_data(
hook.return_path,
hook.hook_id,
procedure_id,
bytes,
end_hook,
)?;
Ok(self.process_endpoint_outcome(endpoint_outcome)?.frames)
}
fn emit_internal_fault_if_possible(
&mut self,
hook: Option<&HookTarget>,
) -> Result<Vec<FrameBytes>, LeafRuntimeError<<L as CallLeaf>::Error>> {
let Some(hook) = hook else {
return Ok(Vec::new());
};
let key = HookKey::new(hook.return_path.clone(), hook.hook_id);
let outcome = self
.endpoint
.emit_fault_if_possible(Some(key), ProtocolFault::INTERNAL_ERROR)?;
Ok(self.process_endpoint_outcome(outcome)?.frames)
}
}
/// 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>
where
T: Archive,
<T as Archive>::Archived: rkyv::Portable
+ for<'b> rkyv::bytecheck::CheckBytes<rkyv::api::high::HighValidator<'b, Error>>
+ rkyv::Deserialize<T, rkyv::api::high::HighDeserializer<Error>>,
{
crate::protocol::deserialize_archived_bytes::<<T as Archive>::Archived, T>(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>
where
T: for<'a> Serialize<
rkyv::api::high::HighSerializer<AlignedVec, rkyv::ser::allocator::ArenaHandle<'a>, Error>,
>,
{
let bytes = to_bytes::<Error>(value).map_err(FrameError::Serialize)?;
Ok(bytes.as_slice().to_vec())
}
@@ -0,0 +1,357 @@
//! Packet builders and endpoint construction.
use alloc::{collections::BTreeSet, string::String, vec::Vec};
use crate::protocol::tree::{HookKey, PendingHook};
use crate::protocol::{
CallMessage, DataMessage, FrameBytes, HookTarget, PacketHeader, PacketType, ValidationError,
encode_packet, validate_call, validate_header, validate_procedure_id,
};
use super::super::{CompiledRoutes, RouteDecision};
use super::core::{ChildRoute, EndpointError, EndpointOutcome, ProtocolEndpoint};
use crate::protocol::tree::LeafSpec;
impl ProtocolEndpoint {
fn prepare_call(
&self,
dst_path: Vec<String>,
dst_leaf: Option<String>,
procedure_id: impl Into<String>,
response_hook_id: Option<u64>,
data: Vec<u8>,
) -> Result<(PacketHeader, CallMessage), EndpointError> {
let procedure_id = procedure_id.into();
validate_procedure_id(&procedure_id)?;
let response_hook = response_hook_id.map(|hook_id| HookTarget {
hook_id,
return_path: self.path.clone(),
});
let header = PacketHeader {
packet_type: PacketType::Call,
src_path: self.path.clone(),
dst_path,
dst_leaf,
hook_id: None,
};
let call = CallMessage {
procedure_id,
data,
response_hook,
};
validate_header(&header)?;
validate_call(&header, &call)?;
Ok((header, call))
}
fn prepare_data(
&self,
dst_path: Vec<String>,
hook_id: u64,
procedure_id: impl Into<String>,
data: Vec<u8>,
end_hook: bool,
) -> Result<(PacketHeader, DataMessage), EndpointError> {
let procedure_id = procedure_id.into();
validate_procedure_id(&procedure_id)?;
let header = PacketHeader {
packet_type: PacketType::Data,
src_path: self.path.clone(),
dst_path,
dst_leaf: None,
hook_id: Some(hook_id),
};
let message = DataMessage {
procedure_id,
data,
end_hook,
};
validate_header(&header)?;
Ok((header, message))
}
fn register_outbound_call_hook(
&mut self,
header: &PacketHeader,
call: &CallMessage,
) -> Result<(), EndpointError> {
// Outbound calls reserve their response hook before the frame is emitted so
// the endpoint can attribute returned Fault packets even before the callee
// accepts the call. The hook only becomes active once valid hook traffic
// comes back from the expected peer.
if let Some(hook) = &call.response_hook
&& let key = HookKey::new(hook.return_path.clone(), hook.hook_id)
&& self
.hooks
.insert_pending(
key,
PendingHook {
caller_src_path: header.dst_path.clone(),
procedure_id: call.procedure_id.clone(),
local_ended: false,
},
)
.is_err()
{
return Err(EndpointError::Validation(ValidationError::InvalidHookId));
}
Ok(())
}
#[must_use]
/// Creates an endpoint with compiled routing tables for its current topology.
///
/// `parent_path` is currently used only as a presence flag. The endpoint stores its own
/// absolute `path`, and routing only needs to know whether an upward route exists.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::{ChildRoute, LeafSpec, ProtocolEndpoint};
/// let endpoint = ProtocolEndpoint::new(
/// vec!["worker".into()],
/// Some(Vec::new()),
/// vec![ChildRoute::registered(vec!["worker".into(), "child".into()])],
/// vec![LeafSpec {
/// name: "service".into(),
/// procedures: vec!["example.service.v1.invoke".into()],
/// }],
/// );
/// let _ = endpoint;
/// ```
pub fn new(
path: Vec<String>,
parent_path: Option<Vec<String>>,
children: Vec<ChildRoute>,
leaves: Vec<LeafSpec>,
) -> Self {
let registered_child_paths = children
.iter()
.filter(|child| child.registered)
.map(|child| child.path.clone())
.collect::<Vec<_>>();
Self {
routing: CompiledRoutes::new(&path, &registered_child_paths, parent_path.is_some()),
path,
children,
leaves: leaves
.into_iter()
.map(|leaf| (leaf.name.clone(), leaf))
.collect(),
endpoint_procedures: BTreeSet::new(),
hooks: Default::default(),
}
}
/// Registers a procedure that is handled directly by the endpoint.
///
/// Endpoint-level procedures exist for protocol services that are not attached to one leaf,
/// such as built-in runtime behavior.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::ProtocolEndpoint;
/// let mut endpoint = ProtocolEndpoint::new(Vec::new(), None, Vec::new(), Vec::new());
/// endpoint.add_endpoint_procedure("example.endpoint.v1.health")?;
/// # Ok::<(), unshell::protocol::tree::EndpointError>(())
/// ```
pub fn add_endpoint_procedure(
&mut self,
procedure_id: impl Into<String>,
) -> Result<(), EndpointError> {
let procedure_id = procedure_id.into();
validate_procedure_id(&procedure_id)?;
self.endpoint_procedures.insert(procedure_id);
Ok(())
}
#[must_use]
/// Allocates a hook id scoped to this endpoint path.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::ProtocolEndpoint;
/// let mut endpoint = ProtocolEndpoint::new(Vec::new(), None, Vec::new(), Vec::new());
/// let hook_id = endpoint.allocate_hook_id();
/// assert_ne!(hook_id, 0);
/// ```
pub fn allocate_hook_id(&mut self) -> u64 {
self.hooks.allocate_hook_id(&self.path)
}
/// Encodes a call frame without routing it through the local endpoint.
///
/// This exists for callers that want a fully encoded outbound frame while handling transport
/// themselves.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::ProtocolEndpoint;
/// let mut endpoint = ProtocolEndpoint::new(Vec::new(), None, Vec::new(), Vec::new());
/// let frame = endpoint.make_call(
/// vec!["worker".into()],
/// Some("service".into()),
/// "example.service.v1.invoke",
/// None,
/// vec![1, 2, 3],
/// )?;
/// assert!(!frame.is_empty());
/// # Ok::<(), unshell::protocol::tree::EndpointError>(())
/// ```
pub fn make_call(
&mut self,
dst_path: Vec<String>,
dst_leaf: Option<String>,
procedure_id: impl Into<String>,
response_hook_id: Option<u64>,
data: Vec<u8>,
) -> Result<FrameBytes, EndpointError> {
let (header, call) =
self.prepare_call(dst_path, dst_leaf, procedure_id, response_hook_id, data)?;
self.register_outbound_call_hook(&header, &call)?;
Ok(encode_packet(&header, &call)?)
}
/// Builds and immediately routes a call, producing either a forward or a local event.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::{ChildRoute, EndpointOutcome, ProtocolEndpoint};
/// let mut endpoint = ProtocolEndpoint::new(
/// Vec::new(),
/// None,
/// vec![ChildRoute::registered(vec!["worker".into()])],
/// Vec::new(),
/// );
/// let outcome = endpoint.send_call(
/// vec!["worker".into()],
/// Some("service".into()),
/// "example.service.v1.invoke",
/// None,
/// vec![],
/// )?;
/// assert!(matches!(outcome, EndpointOutcome::Forward { .. } | EndpointOutcome::Dropped | EndpointOutcome::Local(_)));
/// # Ok::<(), unshell::protocol::tree::EndpointError>(())
/// ```
pub fn send_call(
&mut self,
dst_path: Vec<String>,
dst_leaf: Option<String>,
procedure_id: impl Into<String>,
response_hook_id: Option<u64>,
data: Vec<u8>,
) -> Result<EndpointOutcome, EndpointError> {
let (header, call) =
self.prepare_call(dst_path, dst_leaf, procedure_id, response_hook_id, data)?;
self.register_outbound_call_hook(&header, &call)?;
match self.decide_route(&header.dst_path) {
RouteDecision::Local => self.handle_local_call(header, call),
RouteDecision::Drop => {
self.rollback_pending_call_hook(&call);
Ok(EndpointOutcome::Dropped)
}
route => Ok(EndpointOutcome::Forward {
route,
frame: encode_packet(&header, &call)?,
}),
}
}
/// Encodes a data frame without routing it through the local endpoint.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::ProtocolEndpoint;
/// let endpoint = ProtocolEndpoint::new(Vec::new(), None, Vec::new(), Vec::new());
/// let frame = endpoint.make_data(vec!["root".into()], 7, "example.service.v1.invoke", vec![1], false)?;
/// assert!(!frame.is_empty());
/// # Ok::<(), unshell::protocol::tree::EndpointError>(())
/// ```
pub fn make_data(
&self,
dst_path: Vec<String>,
hook_id: u64,
procedure_id: impl Into<String>,
data: Vec<u8>,
end_hook: bool,
) -> Result<FrameBytes, EndpointError> {
let (header, message) =
self.prepare_data(dst_path, hook_id, procedure_id, data, end_hook)?;
Ok(encode_packet(&header, &message)?)
}
/// Builds and immediately routes a data packet, updating local hook state for end-of-stream.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::ProtocolEndpoint;
/// let mut endpoint = ProtocolEndpoint::new(Vec::new(), None, Vec::new(), Vec::new());
/// let _ = endpoint.send_data(vec!["root".into()], 7, "example.service.v1.invoke", vec![], false);
/// # Ok::<(), unshell::protocol::tree::EndpointError>(())
/// ```
pub fn send_data(
&mut self,
dst_path: Vec<String>,
hook_id: u64,
procedure_id: impl Into<String>,
data: Vec<u8>,
end_hook: bool,
) -> Result<EndpointOutcome, EndpointError> {
if let Some(active_key) = self
.hooks
.resolve_active_key(&dst_path, hook_id, &self.path)
&& self
.hooks
.active(&active_key)
.is_some_and(|active| active.local_ended)
{
return Err(EndpointError::Validation(ValidationError::HookInvariant(
"local side already closed this hook",
)));
}
let local_end_dst_path = dst_path.clone();
let host_key = HookKey::new(self.path.clone(), hook_id);
let (header, message) =
self.prepare_data(dst_path, hook_id, procedure_id, data, end_hook)?;
if end_hook {
self.mark_local_stream_end(&local_end_dst_path, hook_id, &host_key);
}
match self.decide_route(&header.dst_path) {
RouteDecision::Local => self.handle_local_data(header, message),
RouteDecision::Drop => Ok(EndpointOutcome::Dropped),
route => Ok(EndpointOutcome::Forward {
route,
frame: encode_packet(&header, &message)?,
}),
}
}
fn rollback_pending_call_hook(&mut self, call: &CallMessage) {
if let Some(hook) = &call.response_hook {
self.hooks
.remove_pending(&HookKey::new(hook.return_path.clone(), hook.hook_id));
}
}
fn mark_local_stream_end(&mut self, dst_path: &[String], hook_id: u64, host_key: &HookKey) {
// Locally-originated streams may not have been resolved against a peer yet, so fall
// back to the endpoint's own hook key shape when closing them.
let local_hook_key = self
.hooks
.resolve_active_key(dst_path, hook_id, &self.path)
.unwrap_or_else(|| host_key.clone());
if self.hooks.pending(host_key).is_some() {
self.hooks.mark_pending_local_end(host_key);
} else if self.hooks.mark_local_end(&local_hook_key) {
self.hooks.remove_active(&local_hook_key);
}
}
}
@@ -0,0 +1,295 @@
//! Core endpoint state and externally visible types.
use alloc::{
collections::{BTreeMap, BTreeSet},
string::String,
vec::Vec,
};
use core::fmt;
use crate::protocol::{
CallMessage, DataMessage, FaultMessage, FrameBytes, FrameError, PacketHeader, ValidationError,
};
use super::super::{CompiledRoutes, HookKey, HookTable, RouteDecision};
/// Routing metadata for one direct child endpoint.
///
/// This exists so one endpoint can distinguish topology from registration state. A child path may
/// be known structurally while still being excluded from route decisions.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::ChildRoute;
/// let route = ChildRoute::registered(vec!["root".into(), "worker".into()]);
/// assert!(route.registered);
/// ```
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChildRoute {
/// Absolute path for the child endpoint inside the protocol tree.
pub path: Vec<String>,
/// Whether this child currently participates in routing decisions.
pub registered: bool,
}
impl ChildRoute {
#[must_use]
/// Builds one child route that is immediately eligible for routing decisions.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::ChildRoute;
/// let route = ChildRoute::registered(vec!["worker".into()]);
/// assert!(route.registered);
/// ```
pub fn registered(path: Vec<String>) -> Self {
Self {
path,
registered: true,
}
}
}
/// Procedures exposed by a named leaf attached to this endpoint.
///
/// This exists so endpoint construction can advertise one leaf's callable procedure ids up front,
/// before any runtime packets arrive.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::LeafSpec;
/// let leaf = LeafSpec {
/// name: "service".into(),
/// procedures: vec!["example.service.v1.invoke".into()],
/// };
/// assert_eq!(leaf.procedures.len(), 1);
/// ```
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LeafSpec {
/// Leaf identifier used in packet headers.
pub name: String,
/// Procedures this leaf accepts.
pub procedures: Vec<String>,
}
/// Where an inbound frame entered this endpoint.
///
/// This exists because protocol validation depends on whether a packet arrived from the parent,
/// one child subtree, or the endpoint itself.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::Ingress;
/// let ingress = Ingress::Child(vec!["root".into(), "worker".into()]);
/// assert!(matches!(ingress, Ingress::Child(_)));
/// ```
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Ingress {
/// The frame arrived from the parent side of the tree.
Parent,
/// The frame arrived from one direct child, identified by that child's absolute path.
Child(Vec<String>),
/// The frame originated locally at this endpoint.
Local,
}
/// Event produced when the endpoint handles a packet locally.
///
/// This is the validated handoff boundary between transport/routing code and application-facing
/// runtimes layered on top of `ProtocolEndpoint`.
///
/// # Example
/// ```rust
/// use unshell::protocol::{CallMessage, PacketHeader, PacketType};
/// use unshell::protocol::tree::LocalEvent;
/// let event = LocalEvent::Call {
/// header: PacketHeader {
/// packet_type: PacketType::Call,
/// src_path: vec!["root".into()],
/// dst_path: vec!["worker".into()],
/// dst_leaf: None,
/// hook_id: None,
/// },
/// message: CallMessage {
/// procedure_id: "example.invoke".into(),
/// data: vec![],
/// response_hook: None,
/// },
/// };
/// assert!(matches!(event, LocalEvent::Call { .. }));
/// ```
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LocalEvent {
/// One opening `Call` packet validated and delivered to local code.
Call {
/// Validated protocol header for the packet.
header: PacketHeader,
/// Deserialized call payload.
message: CallMessage,
},
/// One hook-associated `Data` packet validated and delivered locally.
Data {
/// Validated protocol header for the packet.
header: PacketHeader,
/// Deserialized data payload.
message: DataMessage,
/// Canonical host-scoped hook key resolved for this hook stream.
hook_key: HookKey,
},
/// One hook-associated `Fault` packet validated and delivered locally.
Fault {
/// Validated protocol header for the packet.
header: PacketHeader,
/// Deserialized fault payload.
message: FaultMessage,
/// Canonical host-scoped hook key resolved for this hook stream.
hook_key: HookKey,
},
}
/// Result of processing a frame or building a locally-sent packet.
///
/// This exists so callers can distinguish forwarding, local delivery, and intentional drops
/// without treating normal protocol routing outcomes as errors.
///
/// # Example
/// ```rust
/// use unshell::protocol::FrameBytes;
/// use unshell::protocol::tree::{EndpointOutcome, RouteDecision};
/// let outcome = EndpointOutcome::Forward {
/// route: RouteDecision::Parent,
/// frame: FrameBytes::new(),
/// };
/// assert!(matches!(outcome, EndpointOutcome::Forward { .. }));
/// ```
#[derive(Debug)]
pub enum EndpointOutcome {
/// Frame to forward, together with the next routing decision.
Forward {
/// The next routing decision chosen for the forwarded frame.
route: RouteDecision,
/// The encoded frame bytes to send along that route.
frame: FrameBytes,
},
/// Locally-delivered protocol event.
Local(LocalEvent),
/// Packet intentionally discarded.
Dropped,
}
/// Error surfaced while validating or encoding protocol frames.
///
/// This exists so endpoint callers can preserve the distinction between malformed wire/archive
/// data and semantic protocol invariant failures.
///
/// # Example
/// ```rust
/// use unshell::protocol::{FrameError, ValidationError};
/// use unshell::protocol::tree::EndpointError;
/// let error = EndpointError::Frame(FrameError::Truncated);
/// assert!(matches!(error, EndpointError::Frame(_)));
/// let validation = EndpointError::Validation(ValidationError::InvalidHookId);
/// assert!(matches!(validation, EndpointError::Validation(_)));
/// ```
#[derive(Debug)]
pub enum EndpointError {
/// Framing, archive decode, or archive encode failed.
Frame(FrameError),
/// One protocol invariant failed validation.
Validation(ValidationError),
}
impl fmt::Display for EndpointError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Frame(error) => write!(f, "{error}"),
Self::Validation(error) => write!(f, "{error}"),
}
}
}
impl core::error::Error for EndpointError {}
impl From<FrameError> for EndpointError {
fn from(value: FrameError) -> Self {
Self::Frame(value)
}
}
impl From<ValidationError> for EndpointError {
fn from(value: ValidationError) -> Self {
Self::Validation(value)
}
}
/// 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 {
/// Returns this endpoint's absolute path.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::{ChildRoute, Endpoint, ProtocolEndpoint};
/// let endpoint = ProtocolEndpoint::new(Vec::new(), None, vec![ChildRoute::registered(vec!["worker".into()])], Vec::new());
/// assert!(endpoint.path().is_empty());
/// ```
fn path(&self) -> &[String];
/// Processes one inbound frame from the given ingress.
///
/// # Example
/// ```rust
/// use unshell::protocol::{CallMessage, PacketHeader, PacketType, encode_packet};
/// use unshell::protocol::tree::{Endpoint, Ingress, ProtocolEndpoint};
/// let mut endpoint = ProtocolEndpoint::new(vec!["worker".into()], Some(Vec::new()), Vec::new(), Vec::new());
/// let frame = encode_packet(&PacketHeader {
/// packet_type: PacketType::Call,
/// src_path: Vec::new(),
/// dst_path: vec!["worker".into()],
/// dst_leaf: None,
/// hook_id: None,
/// }, &CallMessage {
/// procedure_id: "example.invoke".into(),
/// data: vec![],
/// response_hook: None,
/// })?;
/// let _outcome = endpoint.receive(&Ingress::Parent, frame);
/// # Ok::<(), unshell::protocol::FrameError>(())
/// ```
fn receive(
&mut self,
ingress: &Ingress,
frame: FrameBytes,
) -> Result<EndpointOutcome, EndpointError>;
}
/// Runtime state for one endpoint in the protocol tree.
///
/// This exists as the central protocol node that owns route tables, local leaf metadata, and hook
/// lifecycle state for one endpoint path.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::ProtocolEndpoint;
/// let endpoint = ProtocolEndpoint::new(vec!["worker".into()], Some(Vec::new()), Vec::new(), Vec::new());
/// let _ = endpoint;
/// ```
#[derive(Debug, Default)]
pub struct ProtocolEndpoint {
pub(crate) path: Vec<String>,
pub(crate) children: Vec<ChildRoute>,
pub(crate) routing: CompiledRoutes,
pub(crate) leaves: BTreeMap<String, LeafSpec>,
pub(crate) endpoint_procedures: BTreeSet<String>,
pub(crate) hooks: HookTable,
}
@@ -0,0 +1,163 @@
//! Hook-state transitions and route helpers.
use alloc::string::String;
use crate::protocol::{
DataMessage, FaultMessage, PacketHeader, PacketType, ProtocolFault, encode_packet,
};
use super::super::{HookKey, RouteDecision};
use super::core::{EndpointError, EndpointOutcome, Ingress, LocalEvent, ProtocolEndpoint};
impl ProtocolEndpoint {
pub(crate) fn emit_fault_if_possible(
&mut self,
key: Option<HookKey>,
fault: ProtocolFault,
) -> Result<EndpointOutcome, EndpointError> {
let Some(key) = key else {
return Ok(EndpointOutcome::Dropped);
};
self.hooks.remove_pending(&key);
self.hooks.remove_active(&key);
let header = PacketHeader {
packet_type: PacketType::Fault,
src_path: self.path.clone(),
dst_path: key.return_path.clone(),
dst_leaf: None,
hook_id: Some(key.hook_id),
};
let message = FaultMessage { fault };
match self.decide_route(&key.return_path) {
RouteDecision::Local => Ok(EndpointOutcome::Local(LocalEvent::Fault {
header,
message,
hook_key: key,
})),
route => Ok(EndpointOutcome::Forward {
route,
frame: encode_packet(&header, &message)?,
}),
}
}
pub(crate) fn handle_local_data(
&mut self,
header: PacketHeader,
message: DataMessage,
) -> Result<EndpointOutcome, EndpointError> {
let hook_id = header.hook_id.expect("validated");
let key = if let Some(key) =
self.hooks
.resolve_active_key(&self.path, hook_id, &header.src_path)
{
key
} else {
let pending_key = HookKey::new(self.path.clone(), hook_id);
if self.hooks.pending(&pending_key).is_some_and(|pending| {
pending.caller_src_path == header.src_path
&& pending.procedure_id == message.procedure_id
}) {
self.hooks.activate_pending(&pending_key);
pending_key
} else {
return Ok(EndpointOutcome::Dropped);
}
};
let Some(active) = self.hooks.active(&key) else {
return Ok(EndpointOutcome::Dropped);
};
if active.peer_path != header.src_path {
// A reused hook id from the wrong peer is treated as terminal for this hook,
// because the endpoint can no longer trust future traffic on it.
self.hooks.remove_active(&key);
return self.emit_fault_if_possible(Some(key), ProtocolFault::INVALID_HOOK_PEER);
}
if active.procedure_id != message.procedure_id {
// Data frames stay bound to the procedure chosen by the original call.
// A procedure mismatch is dropped rather than faulted because the wrong peer may be
// replaying stale traffic, and converting that into a terminal hook fault would let a
// stray packet tear down an otherwise valid stream.
return Ok(EndpointOutcome::Dropped);
}
if message.end_hook && self.hooks.mark_peer_end(&key) {
self.hooks.remove_active(&key);
}
Ok(EndpointOutcome::Local(LocalEvent::Data {
header,
message,
hook_key: key,
}))
}
pub(crate) fn handle_local_fault(
&mut self,
header: PacketHeader,
message: FaultMessage,
) -> Result<EndpointOutcome, EndpointError> {
let hook_id = header.hook_id.expect("validated");
if let Some(key) = self
.hooks
.resolve_active_key(&self.path, hook_id, &header.src_path)
{
self.hooks.remove_active(&key);
return Ok(EndpointOutcome::Local(LocalEvent::Fault {
header,
message,
hook_key: key,
}));
}
let pending_key = HookKey::new(self.path.clone(), hook_id);
if self
.hooks
.pending(&pending_key)
.is_some_and(|pending| pending.caller_src_path == header.src_path)
{
self.hooks.remove_pending(&pending_key);
return Ok(EndpointOutcome::Local(LocalEvent::Fault {
header,
message,
hook_key: pending_key,
}));
}
Ok(EndpointOutcome::Dropped)
}
pub(crate) fn decide_route(&self, dst_path: &[String]) -> RouteDecision {
self.routing.route(dst_path)
}
/// Returns whether one `src_path` is topologically valid for the ingress side that delivered
/// the frame.
///
/// Parent ingress may carry packets from ancestors, siblings, or the endpoint itself, but not
/// from descendants pretending to be upstream. Child ingress may only carry packets from that
/// child subtree, and local ingress must exactly match the endpoint path.
pub(crate) fn valid_source_for_ingress(&self, ingress: &Ingress, src_path: &[String]) -> bool {
match ingress {
Ingress::Parent => {
// Parent ingress may carry packets from ancestors, siblings, or the endpoint
// itself, but not from descendants pretending to be upstream.
if src_path.len() < self.path.len() {
return true;
}
if src_path.len() == self.path.len() {
return src_path == self.path;
}
!src_path.starts_with(&self.path)
}
Ingress::Child(child_path) => src_path.starts_with(child_path),
Ingress::Local => src_path == self.path,
}
}
}
@@ -0,0 +1,103 @@
//! Introspection response generation.
use alloc::{string::String, vec::Vec};
use rkyv::{rancor::Error as RkyvError, to_bytes};
use crate::protocol::{
DataMessage, EndpointIntrospection, FrameError, LeafIntrospection, LeafIntrospectionSummary,
PacketHeader, PacketType, ProtocolFault, encode_packet,
};
use super::super::HookKey;
use super::core::{EndpointError, EndpointOutcome, ProtocolEndpoint};
impl ProtocolEndpoint {
pub(crate) fn handle_introspection(
&mut self,
header: &PacketHeader,
key: Option<HookKey>,
) -> Result<EndpointOutcome, EndpointError> {
let Some(key) = key else {
return Ok(EndpointOutcome::Dropped);
};
let response_payload = if let Some(leaf_name) = &header.dst_leaf {
let Some(leaf) = self.leaves.get(leaf_name) else {
return self.emit_fault_if_possible(Some(key), ProtocolFault::UNKNOWN_LEAF);
};
self.serialize_introspection(&LeafIntrospection {
leaf_name: leaf_name.clone(),
procedures: leaf.procedures.clone(),
})?
} else {
self.serialize_introspection(&EndpointIntrospection {
sub_endpoints: self.direct_registered_child_names(),
leaves: self
.leaves
.values()
.map(|leaf| LeafIntrospectionSummary {
leaf_name: leaf.name.clone(),
procedures: leaf.procedures.clone(),
})
.collect(),
})?
};
let response_header = PacketHeader {
packet_type: PacketType::Data,
src_path: self.path.clone(),
dst_path: key.return_path.clone(),
dst_leaf: None,
hook_id: Some(key.hook_id),
};
let response = DataMessage {
procedure_id: String::new(),
data: response_payload,
end_hook: true,
};
// Introspection always completes in a single response frame.
if self.hooks.mark_local_end(&key) {
self.hooks.remove_active(&key);
}
match self.decide_route(&key.return_path) {
super::super::RouteDecision::Local => {
Ok(EndpointOutcome::Local(super::core::LocalEvent::Data {
header: response_header,
message: response,
hook_key: key,
}))
}
route => Ok(EndpointOutcome::Forward {
route,
frame: encode_packet(&response_header, &response)?,
}),
}
}
fn direct_registered_child_names(&self) -> Vec<String> {
self.children
.iter()
.filter(|child| child.registered)
// Child routes store absolute endpoint paths. Index the first segment below the
// current endpoint so discovery only reports direct descendants.
.filter_map(|child| child.path.get(self.path.len()).cloned())
.collect()
}
fn serialize_introspection<T>(&self, value: &T) -> Result<Vec<u8>, EndpointError>
where
T: for<'a> rkyv::Serialize<
rkyv::api::high::HighSerializer<
rkyv::util::AlignedVec,
rkyv::ser::allocator::ArenaHandle<'a>,
RkyvError,
>,
>,
{
to_bytes::<RkyvError>(value)
.map_err(|error| EndpointError::Frame(FrameError::Serialize(error)))
.map(|bytes| bytes.to_vec())
}
}
@@ -0,0 +1,16 @@
//! Protocol-tree endpoint runtime.
//!
//! This module holds the state machine that validates ingress, decides whether a
//! packet should be handled locally or forwarded, and manages hook lifetimes for
//! call/data/fault exchanges.
mod builders;
mod core;
mod hooks;
mod introspection;
mod receive;
pub use core::{
ChildRoute, Endpoint, EndpointError, EndpointOutcome, Ingress, LeafSpec, LocalEvent,
ProtocolEndpoint,
};
@@ -0,0 +1,171 @@
//! Packet ingress and local call dispatch.
use crate::protocol::types::{ArchivedCallMessage, ArchivedDataMessage, ArchivedFaultMessage};
use crate::protocol::{
CallMessage, ProtocolFault, decode_frame, deserialize_archived_bytes,
introspection::INTROSPECTION_PROCEDURE_ID, validate_call, validate_header,
};
use super::super::{ActiveHook, HookKey, RouteDecision};
use super::core::{
Endpoint, EndpointError, EndpointOutcome, Ingress, LocalEvent, ProtocolEndpoint,
};
impl ProtocolEndpoint {
fn local_procedure_fault(
&self,
dst_leaf: Option<&str>,
procedure_id: &str,
) -> Option<ProtocolFault> {
match dst_leaf {
Some(leaf_name) => match self.leaves.get(leaf_name) {
Some(leaf) => (!leaf
.procedures
.iter()
.any(|procedure| procedure == procedure_id))
.then_some(ProtocolFault::UNKNOWN_PROCEDURE),
None => Some(ProtocolFault::UNKNOWN_LEAF),
},
None => (!self.endpoint_procedures.contains(procedure_id))
.then_some(ProtocolFault::UNKNOWN_PROCEDURE),
}
}
pub(crate) fn handle_local_call(
&mut self,
header: crate::protocol::PacketHeader,
message: CallMessage,
) -> Result<EndpointOutcome, EndpointError> {
let key = message
.response_hook
.as_ref()
.map(|hook| HookKey::new(hook.return_path.clone(), hook.hook_id));
if message.procedure_id == INTROSPECTION_PROCEDURE_ID {
return self.handle_introspection(&header, key);
}
if let Some(fault) =
self.local_procedure_fault(header.dst_leaf.as_deref(), &message.procedure_id)
{
return self.emit_fault_if_possible(key, fault);
}
if let Some(hook) = &message.response_hook
&& hook.return_path != self.path
{
// Calls targeting this endpoint may still ask another endpoint to host the response
// hook. Only register a local active hook when the response path escapes this node.
let Some(key) = key.clone() else {
unreachable!("response_hook checked above");
};
if self
.hooks
.insert_active(
key.clone(),
ActiveHook {
peer_path: header.src_path.clone(),
procedure_id: message.procedure_id.clone(),
local_ended: false,
peer_ended: false,
},
)
.is_err()
{
return self.emit_fault_if_possible(Some(key), ProtocolFault::INTERNAL_ERROR);
}
}
Ok(EndpointOutcome::Local(LocalEvent::Call { header, message }))
}
fn receive_call(
&mut self,
ingress: &Ingress,
parsed: crate::protocol::ParsedFrame<'_>,
) -> Result<EndpointOutcome, EndpointError> {
// Calls only enter from the parent side of the tree or from the endpoint itself.
// Children can return data/faults, but they do not initiate new calls through this node.
if !matches!(ingress, Ingress::Parent | Ingress::Local) {
return Ok(EndpointOutcome::Dropped);
}
let (header, payload) = parsed.into_parts();
let message = deserialize_archived_bytes::<ArchivedCallMessage, CallMessage>(payload)?;
validate_call(&header, &message)?;
self.handle_local_call(header, message)
}
fn receive_data(
&mut self,
parsed: crate::protocol::ParsedFrame<'_>,
) -> Result<EndpointOutcome, EndpointError> {
let (header, payload) = parsed.into_parts();
let message = deserialize_archived_bytes::<
ArchivedDataMessage,
crate::protocol::DataMessage,
>(payload)?;
self.handle_local_data(header, message)
}
fn receive_fault(
&mut self,
parsed: crate::protocol::ParsedFrame<'_>,
) -> Result<EndpointOutcome, EndpointError> {
let (header, payload) = parsed.into_parts();
let message = deserialize_archived_bytes::<
ArchivedFaultMessage,
crate::protocol::FaultMessage,
>(payload)?;
self.handle_local_fault(header, message)
}
fn forward_or_drop(
route: RouteDecision,
frame: crate::protocol::FrameBytes,
) -> EndpointOutcome {
match route {
RouteDecision::Child(index) => EndpointOutcome::Forward {
route: RouteDecision::Child(index),
frame,
},
RouteDecision::Parent => EndpointOutcome::Forward {
route: RouteDecision::Parent,
frame,
},
RouteDecision::Drop => EndpointOutcome::Dropped,
RouteDecision::Local => unreachable!("local routes are handled before forwarding"),
}
}
}
impl Endpoint for ProtocolEndpoint {
fn path(&self) -> &[alloc::string::String] {
&self.path
}
fn receive(
&mut self,
ingress: &Ingress,
frame: crate::protocol::FrameBytes,
) -> Result<EndpointOutcome, EndpointError> {
let parsed = decode_frame(&frame)?;
let header = parsed.header();
validate_header(header)?;
if !self.valid_source_for_ingress(ingress, &header.src_path) {
return Ok(EndpointOutcome::Dropped);
}
let route = self.decide_route(&header.dst_path);
if route != RouteDecision::Local {
return Ok(Self::forward_or_drop(route, frame));
}
match header.packet_type {
crate::protocol::PacketType::Call => self.receive_call(ingress, parsed),
crate::protocol::PacketType::Data => self.receive_data(parsed),
crate::protocol::PacketType::Fault => self.receive_fault(parsed),
}
}
}
+509
View File
@@ -0,0 +1,509 @@
//! Hook state for pending and active protocol flows.
//!
//! Hooks move through two phases:
//! - `PendingHook` tracks enough context to attribute faults before the callee accepts.
//! - `ActiveHook` tracks the live bidirectional flow after activation.
//!
//! The table indexes active hooks both by their host-side return path and by the remote
//! peer path so routing code can resolve whichever side of the relationship it currently has.
//! The `HookKey` already carries the host path and hook id, so the pending/active records only
//! store the extra state that actually changes across the hook lifecycle.
use alloc::{collections::BTreeMap, string::String, vec::Vec};
/// Hook table key scoped to the hook host path.
///
/// This exists because hook ids are only unique relative to the endpoint path that hosts the
/// hook state.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::HookKey;
/// let key = HookKey::new(vec!["root".into()], 7);
/// assert_eq!(key.hook_id, 7);
/// ```
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct HookKey {
/// Path of the endpoint hosting the hook state.
pub return_path: Vec<String>,
/// Per-host hook identifier.
pub hook_id: u64,
}
impl HookKey {
/// Builds the canonical key for a hook hosted at `return_path`.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::HookKey;
/// let key = HookKey::new(vec!["root".into()], 42);
/// assert_eq!(key.return_path, vec![String::from("root")]);
/// ```
#[must_use]
pub fn new(return_path: Vec<String>, hook_id: u64) -> Self {
Self {
return_path,
hook_id,
}
}
}
/// Pending hook context used only for fault attribution before activation.
///
/// This exists so outbound calls can reserve response-hook ownership before the callee has sent
/// its first valid `Data` packet.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::PendingHook;
/// let pending = PendingHook {
/// caller_src_path: vec!["worker".into()],
/// procedure_id: "example.service.v1.invoke".into(),
/// local_ended: false,
/// };
/// assert!(!pending.local_ended);
/// ```
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PendingHook {
/// Caller path to promote into `peer_path` once the hook becomes active.
pub caller_src_path: Vec<String>,
/// Procedure that created the hook.
pub procedure_id: String,
/// Set once the local side has already emitted its terminal message before activation.
pub local_ended: bool,
}
/// Active hook context used for ordinary data traffic.
///
/// This exists once one peer has proven ownership of the hook stream and ordinary `Data`/`Fault`
/// routing can proceed without the pending reservation state.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::ActiveHook;
/// let active = ActiveHook {
/// peer_path: vec!["worker".into()],
/// procedure_id: "example.service.v1.invoke".into(),
/// local_ended: false,
/// peer_ended: false,
/// };
/// assert_eq!(active.peer_path[0], "worker");
/// ```
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ActiveHook {
/// Remote endpoint path currently paired with this hook.
pub peer_path: Vec<String>,
/// Procedure that owns the hook conversation.
pub procedure_id: String,
/// Set once the local side has emitted its terminal message.
pub local_ended: bool,
/// Set once the peer side has emitted its terminal message.
pub peer_ended: bool,
}
/// Duplicate hook insertion error.
///
/// This exists so callers can distinguish “hook id already reserved” from other runtime errors.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::HookConflict;
/// let _conflict = HookConflict;
/// ```
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct HookConflict;
/// Durable hook state tables.
///
/// This owns both pending and active hook lifecycle state plus a peer-path index for resolving
/// inbound hook traffic from either side of the conversation.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::{HookKey, HookTable, PendingHook};
/// let mut hooks = HookTable::default();
/// let key = HookKey::new(vec!["root".into()], 1);
/// hooks.insert_pending(key.clone(), PendingHook {
/// caller_src_path: vec!["worker".into()],
/// procedure_id: "example.service.v1.invoke".into(),
/// local_ended: false,
/// }).unwrap();
/// assert_eq!(hooks.pending_len(), 1);
/// ```
#[derive(Debug, Default)]
pub struct HookTable {
pending: BTreeMap<HookKey, PendingHook>,
active: BTreeMap<HookKey, ActiveHook>,
active_by_peer: BTreeMap<u64, BTreeMap<Vec<String>, HookKey>>,
next_id: u64,
}
impl HookTable {
/// Allocates a non-zero hook id for a hook hosted at `return_path`.
///
/// Hook ids are scoped by host path, so this only needs to guarantee uniqueness within the
/// local table. The wrapped increment keeps allocation infallible for long-lived runtimes.
///
/// The table currently uses one counter shared across all host paths. The `return_path`
/// parameter remains in the API because hook ids are still interpreted as host-scoped by the
/// rest of the protocol surface.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::HookTable;
/// let mut hooks = HookTable::default();
/// let id = hooks.allocate_hook_id(&[String::from("root")]);
/// assert_ne!(id, 0);
/// ```
#[must_use]
pub fn allocate_hook_id(&mut self, _return_path: &[String]) -> u64 {
let id = self.next_id.max(1);
self.next_id = id.wrapping_add(1);
id
}
/// Inserts a hook that has been announced but not yet accepted by the callee.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::{HookKey, HookTable, PendingHook};
/// let mut hooks = HookTable::default();
/// hooks.insert_pending(HookKey::new(vec!["root".into()], 1), PendingHook {
/// caller_src_path: vec!["worker".into()],
/// procedure_id: "example.service.v1.invoke".into(),
/// local_ended: false,
/// })?;
/// # Ok::<(), unshell::protocol::tree::HookConflict>(())
/// ```
pub fn insert_pending(
&mut self,
key: HookKey,
pending: PendingHook,
) -> Result<(), HookConflict> {
if self.pending.contains_key(&key) || self.active.contains_key(&key) {
return Err(HookConflict);
}
self.pending.insert(key, pending);
Ok(())
}
/// Promotes a pending hook into the active table.
///
/// Activation intentionally reuses the original hook id and host path, but swaps the
/// pending caller attribution into the active peer path used for data routing.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::{HookKey, HookTable, PendingHook};
/// let mut hooks = HookTable::default();
/// let key = HookKey::new(vec!["root".into()], 1);
/// hooks.insert_pending(key.clone(), PendingHook {
/// caller_src_path: vec!["worker".into()],
/// procedure_id: "example.service.v1.invoke".into(),
/// local_ended: false,
/// })?;
/// hooks.activate_pending(&key);
/// assert_eq!(hooks.active_len(), 1);
/// # Ok::<(), unshell::protocol::tree::HookConflict>(())
/// ```
pub fn activate_pending(&mut self, key: &HookKey) -> Option<()> {
let pending = self.pending.remove(key)?;
self.insert_active(
key.clone(),
ActiveHook {
peer_path: pending.caller_src_path,
procedure_id: pending.procedure_id,
local_ended: pending.local_ended,
peer_ended: false,
},
)
.ok()?;
Some(())
}
/// Inserts a live hook and its peer-path lookup entry.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::{ActiveHook, HookKey, HookTable};
/// let mut hooks = HookTable::default();
/// hooks.insert_active(HookKey::new(vec!["root".into()], 1), ActiveHook {
/// peer_path: vec!["worker".into()],
/// procedure_id: "example.service.v1.invoke".into(),
/// local_ended: false,
/// peer_ended: false,
/// })?;
/// assert_eq!(hooks.active_len(), 1);
/// # Ok::<(), unshell::protocol::tree::HookConflict>(())
/// ```
pub fn insert_active(&mut self, key: HookKey, active: ActiveHook) -> Result<(), HookConflict> {
// Reject both duplicate host-scoped keys and duplicate peer ownership claims. Either one
// would make later inbound hook traffic ambiguous.
if self.pending.contains_key(&key)
|| self.active.contains_key(&key)
|| self
.active_by_peer
.get(&key.hook_id)
.is_some_and(|peer_paths| peer_paths.contains_key(active.peer_path.as_slice()))
{
return Err(HookConflict);
}
self.active_by_peer
.entry(key.hook_id)
.or_default()
.insert(active.peer_path.clone(), key.clone());
self.active.insert(key, active);
Ok(())
}
/// 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> {
self.pending.remove(key)
}
/// Marks the local side finished before the hook becomes active.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::{HookKey, HookTable, PendingHook};
/// let mut hooks = HookTable::default();
/// let key = HookKey::new(vec!["root".into()], 1);
/// hooks.insert_pending(key.clone(), PendingHook {
/// caller_src_path: vec!["worker".into()],
/// procedure_id: "example.service.v1.invoke".into(),
/// local_ended: false,
/// })?;
/// hooks.mark_pending_local_end(&key);
/// assert!(hooks.pending(&key).unwrap().local_ended);
/// # Ok::<(), unshell::protocol::tree::HookConflict>(())
/// ```
pub fn mark_pending_local_end(&mut self, key: &HookKey) {
if let Some(pending) = self.pending.get_mut(key) {
pending.local_ended = true;
}
}
/// 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> {
let active = self.active.remove(key)?;
if let Some(peer_paths) = self.active_by_peer.get_mut(&key.hook_id) {
peer_paths.remove(active.peer_path.as_slice());
if peer_paths.is_empty() {
self.active_by_peer.remove(&key.hook_id);
}
}
Some(active)
}
/// Returns the pending hook for `key`, if present.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::{HookKey, HookTable, PendingHook};
/// let mut hooks = HookTable::default();
/// let key = HookKey::new(vec!["root".into()], 1);
/// hooks.insert_pending(key.clone(), PendingHook {
/// caller_src_path: vec!["worker".into()],
/// procedure_id: "example.service.v1.invoke".into(),
/// local_ended: false,
/// })?;
/// assert!(hooks.pending(&key).is_some());
/// # Ok::<(), unshell::protocol::tree::HookConflict>(())
/// ```
#[must_use]
pub fn pending(&self, key: &HookKey) -> Option<&PendingHook> {
self.pending.get(key)
}
/// Returns the active hook for `key`, if present.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::{ActiveHook, HookKey, HookTable};
/// let mut hooks = HookTable::default();
/// let key = HookKey::new(vec!["root".into()], 1);
/// hooks.insert_active(key.clone(), ActiveHook {
/// peer_path: vec!["worker".into()],
/// procedure_id: "example.service.v1.invoke".into(),
/// local_ended: false,
/// peer_ended: false,
/// })?;
/// assert!(hooks.active(&key).is_some());
/// # Ok::<(), unshell::protocol::tree::HookConflict>(())
/// ```
#[must_use]
pub fn active(&self, key: &HookKey) -> Option<&ActiveHook> {
self.active.get(key)
}
/// Returns the mutable active hook for `key`, if present.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::{ActiveHook, HookKey, HookTable};
/// let mut hooks = HookTable::default();
/// let key = HookKey::new(vec!["root".into()], 1);
/// hooks.insert_active(key.clone(), ActiveHook {
/// peer_path: vec!["worker".into()],
/// procedure_id: "example.service.v1.invoke".into(),
/// local_ended: false,
/// peer_ended: false,
/// })?;
/// hooks.active_mut(&key).unwrap().peer_ended = true;
/// assert!(hooks.active(&key).unwrap().peer_ended);
/// # Ok::<(), unshell::protocol::tree::HookConflict>(())
/// ```
pub fn active_mut(&mut self, key: &HookKey) -> Option<&mut ActiveHook> {
self.active.get_mut(key)
}
/// Resolves an active hook from either side of the conversation.
///
/// The host side addresses hooks directly by `(return_path, hook_id)`. Peer-originated
/// traffic only has `(hook_id, peer_path)`, so the secondary index maps that back to the
/// canonical host-scoped key.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::{ActiveHook, HookKey, HookTable};
/// let mut hooks = HookTable::default();
/// let key = HookKey::new(vec!["root".into()], 1);
/// hooks.insert_active(key.clone(), ActiveHook {
/// peer_path: vec!["worker".into()],
/// procedure_id: "example.service.v1.invoke".into(),
/// local_ended: false,
/// peer_ended: false,
/// })?;
/// assert_eq!(hooks.resolve_active_key(&["root".into()], 1, &["worker".into()]), Some(key));
/// # Ok::<(), unshell::protocol::tree::HookConflict>(())
/// ```
#[must_use]
pub fn resolve_active_key(
&self,
return_path: &[String],
hook_id: u64,
peer_path: &[String],
) -> 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
.active_by_peer
.get(&hook_id)
.and_then(|peer_paths| peer_paths.get(peer_path))
{
return Some(key.clone());
}
let host_key = HookKey::new(return_path.to_vec(), hook_id);
self.active.contains_key(&host_key).then_some(host_key)
}
/// Marks the local side finished and returns `true` once both sides are finished.
///
/// This does not remove the hook. Callers use the boolean to decide whether cleanup should
/// happen immediately or whether the peer side is still expected to send more traffic.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::{ActiveHook, HookKey, HookTable};
/// let mut hooks = HookTable::default();
/// let key = HookKey::new(vec!["root".into()], 1);
/// hooks.insert_active(key.clone(), ActiveHook {
/// peer_path: vec!["worker".into()],
/// procedure_id: "example.service.v1.invoke".into(),
/// local_ended: false,
/// peer_ended: true,
/// })?;
/// assert!(hooks.mark_local_end(&key));
/// # Ok::<(), unshell::protocol::tree::HookConflict>(())
/// ```
pub fn mark_local_end(&mut self, key: &HookKey) -> bool {
let Some(active) = self.active_mut(key) else {
return false;
};
active.local_ended = true;
active.peer_ended
}
/// Marks the peer side finished and returns `true` once both sides are finished.
///
/// This mirrors [`mark_local_end`](Self::mark_local_end): it only reports completion, leaving
/// final removal to the caller so higher layers can decide when to tear down hook state.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::{ActiveHook, HookKey, HookTable};
/// let mut hooks = HookTable::default();
/// let key = HookKey::new(vec!["root".into()], 1);
/// hooks.insert_active(key.clone(), ActiveHook {
/// peer_path: vec!["worker".into()],
/// procedure_id: "example.service.v1.invoke".into(),
/// local_ended: true,
/// peer_ended: false,
/// })?;
/// assert!(hooks.mark_peer_end(&key));
/// # Ok::<(), unshell::protocol::tree::HookConflict>(())
/// ```
pub fn mark_peer_end(&mut self, key: &HookKey) -> bool {
let Some(active) = self.active_mut(key) else {
return false;
};
active.peer_ended = true;
active.local_ended
}
/// Returns the number of active hooks.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::HookTable;
/// let hooks = HookTable::default();
/// assert_eq!(hooks.active_len(), 0);
/// ```
#[must_use]
pub fn active_len(&self) -> usize {
self.active.len()
}
/// Returns the number of pending hooks.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::HookTable;
/// let hooks = HookTable::default();
/// assert_eq!(hooks.pending_len(), 0);
/// ```
#[must_use]
pub fn pending_len(&self) -> usize {
self.pending.len()
}
}
+377
View File
@@ -0,0 +1,377 @@
//! Application-facing leaf metadata helpers.
//!
//! The protocol runtime itself only knows about `LeafSpec` metadata and validated
//! `LocalEvent` delivery. `ProtocolLeaf` owns the canonical dotted leaf id, while
//! `CallProcedures` owns generated procedure ids and initial call dispatch.
use alloc::{string::String, vec::Vec};
use super::LeafSpec;
/// Static metadata for one application-defined protocol leaf.
///
/// This exists so runtime code can ask one type for its canonical dotted leaf id without knowing
/// any of that leaf's call-dispatch details.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::ProtocolLeaf;
/// struct ExampleLeaf;
/// impl ProtocolLeaf for ExampleLeaf {
/// fn leaf_name() -> String { "org.example.v1.echo".into() }
/// }
/// assert_eq!(ExampleLeaf::leaf_name(), "org.example.v1.echo");
/// ```
pub trait ProtocolLeaf {
/// Returns the canonical dotted leaf name hosted by this type.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::ProtocolLeaf;
/// struct ExampleLeaf;
/// impl ProtocolLeaf for ExampleLeaf {
/// fn leaf_name() -> String { "org.example.v1.echo".into() }
/// }
/// assert!(ExampleLeaf::leaf_name().starts_with("org.example"));
/// ```
fn leaf_name() -> String;
}
/// Generated call metadata and initial `Call` dispatch for one leaf.
///
/// This exists so one leaf type can advertise which procedure suffixes it serves and convert an
/// opening protocol `Call` into leaf-local behavior.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::{CallProcedures, DispatchError, IncomingCall, ProtocolLeaf};
/// struct ExampleLeaf;
/// impl ProtocolLeaf for ExampleLeaf {
/// fn leaf_name() -> String { "org.example.v1.echo".into() }
/// }
/// impl CallProcedures for ExampleLeaf {
/// type Error = core::convert::Infallible;
/// fn procedure_suffixes() -> &'static [&'static str] { &["invoke"] }
/// fn dispatch_call(&mut self, _call: IncomingCall) -> Result<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 {
/// Leaf-specific error surfaced when generated call dispatch fails.
type Error;
/// Returns the local procedure suffixes supported by this leaf.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::{CallProcedures, ProtocolLeaf};
/// struct ExampleLeaf;
/// impl ProtocolLeaf for ExampleLeaf { fn leaf_name() -> String { "org.example.v1.echo".into() } }
/// impl CallProcedures for ExampleLeaf {
/// type Error = core::convert::Infallible;
/// fn procedure_suffixes() -> &'static [&'static str] { &["invoke", "stream"] }
/// fn dispatch_call(&mut self, _call: unshell::protocol::tree::IncomingCall) -> Result<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];
/// 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> {
if !Self::procedure_suffixes().contains(&suffix) {
return None;
}
let mut procedure_id = Self::leaf_name();
procedure_id.push('.');
procedure_id.push_str(suffix);
Some(procedure_id)
}
/// 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> {
Self::procedure_suffixes()
.iter()
.filter_map(|suffix| Self::procedure_id(suffix))
.collect()
}
/// 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 {
LeafSpec {
name: Self::leaf_name(),
procedures: Self::procedure_ids(),
}
}
/// Dispatches one initial `Call` that targeted 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
/// 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(
&mut self,
call: crate::protocol::tree::IncomingCall,
) -> Result<crate::protocol::tree::CallReply, crate::protocol::tree::DispatchError<Self::Error>>;
}
/// Builds one canonical dotted leaf id from crate-local metadata plus optional
/// user overrides.
///
/// Rationale: derive macros cannot reliably inspect Cargo workspace metadata, but
/// they can always access the current package name, module path, crate version,
/// and Rust type name at the expansion site. This helper normalizes those inputs
/// into one deterministic dotted identifier without leaking Rust separators or
/// casing into protocol-visible names. Deterministic is not the same as stable
/// across refactors, so shipped protocol surfaces should prefer explicit `id`
/// overrides.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::derive_leaf_name;
///
/// let leaf = derive_leaf_name(
/// "unshell-core",
/// "0",
/// "1",
/// "0",
/// "unshell_core::examples::demo_shell",
/// "ShellLeaf",
/// None,
/// None,
/// None,
/// None,
/// None,
/// );
/// assert_eq!(leaf, "unshell_core.unshell_core.v0_1_0.examples.demo_shell.shell_leaf");
/// ```
#[allow(clippy::too_many_arguments)]
// This helper mirrors derive-macro inputs directly so callers do not have to allocate an
// intermediate metadata struct just to compute one deterministic protocol identifier.
pub fn derive_leaf_name(
package_name: &str,
version_major: &str,
version_minor: &str,
version_patch: &str,
module_path: &str,
type_name: &str,
org: Option<&str>,
product: Option<&str>,
version: Option<&str>,
leaf_name: Option<&str>,
id: Option<&str>,
) -> String {
if let Some(id) = id.filter(|value| !value.is_empty()) {
return String::from(id);
}
let package_segment = normalize_leaf_segment(package_name);
let mut segments = Vec::new();
segments.push(normalize_leaf_segment(org.unwrap_or(package_name)));
segments.push(normalize_leaf_segment(product.unwrap_or(package_name)));
segments.push(normalize_version_segment(version.unwrap_or(
&alloc::format!("v{}_{}_{}", version_major, version_minor, version_patch),
)));
if let Some(leaf_name) = leaf_name.filter(|value| !value.is_empty()) {
segments.extend(split_leaf_path(leaf_name));
} else {
// The package-derived prefix already names the crate/product portion of the identifier, so
// strip the same leading segment from `module_path` when it would otherwise duplicate it.
let mut module_segments = module_path
.split("::")
.map(normalize_leaf_segment)
.filter(|segment| !segment.is_empty())
.collect::<Vec<_>>();
if module_segments
.first()
.is_some_and(|segment| segment == &package_segment)
{
module_segments.remove(0);
}
segments.extend(module_segments);
segments.push(normalize_leaf_segment(type_name));
}
segments.join(".")
}
fn split_leaf_path(value: &str) -> Vec<String> {
value
.split('.')
.map(normalize_leaf_segment)
.filter(|segment| !segment.is_empty())
.collect()
}
fn normalize_version_segment(value: &str) -> String {
let normalized = normalize_leaf_segment(value);
if normalized.starts_with('v') && normalized.len() > 1 {
normalized
} else {
alloc::format!("v{}", normalized)
}
}
fn normalize_leaf_segment(value: &str) -> String {
let mut normalized = String::with_capacity(value.len());
let mut previous_was_separator = false;
for character in value.chars() {
if character.is_ascii_uppercase() {
// Preserve CamelCase word boundaries in a snake_case protocol identifier.
if !normalized.is_empty() && !previous_was_separator {
normalized.push('_');
}
normalized.push(character.to_ascii_lowercase());
previous_was_separator = false;
continue;
}
if character.is_ascii_lowercase() || character.is_ascii_digit() {
normalized.push(character);
previous_was_separator = false;
continue;
}
if !normalized.is_empty() && !previous_was_separator {
normalized.push('_');
previous_was_separator = true;
}
}
while normalized.ends_with('_') {
normalized.pop();
}
if normalized.is_empty() {
// Protocol identifiers still need a stable non-empty placeholder when user input is all
// punctuation or whitespace.
String::from("leaf")
} else {
normalized
}
}
#[cfg(test)]
mod tests {
use super::derive_leaf_name;
#[test]
fn derive_leaf_name_normalizes_inputs_into_dotted_segments() {
assert_eq!(
derive_leaf_name(
"unshell-core",
"0",
"1",
"0",
"unshell_core::examples::demo_shell",
"ShellLeaf",
None,
None,
None,
None,
None,
),
"unshell_core.unshell_core.v0_1_0.examples.demo_shell.shell_leaf"
);
}
#[test]
fn derive_leaf_name_applies_partial_overrides() {
assert_eq!(
derive_leaf_name(
"unshell-core",
"0",
"1",
"0",
"unshell_core::examples::demo_shell",
"ShellLeaf",
Some("org"),
Some("product"),
Some("v1.2.3.4"),
Some("echo.shell"),
None,
),
"org.product.v1_2_3_4.echo.shell"
);
}
#[test]
fn derive_leaf_name_id_override_wins() {
assert_eq!(
derive_leaf_name(
"unshell-core",
"0",
"1",
"0",
"unshell_core::examples::demo_shell",
"ShellLeaf",
Some("org"),
Some("product"),
Some("v1"),
Some("echo"),
Some("org.example.v1.echo.abc"),
),
"org.example.v1.echo.abc"
);
}
}
+35
View File
@@ -0,0 +1,35 @@
//! Explicit tree declaration, routing, and a small endpoint runtime.
//!
//! This module keeps the protocol tree machinery split by concern:
//! - `routing` contains static path declarations and longest-prefix routing helpers.
//! - `hook` contains the pending/active hook lifecycle tables used by endpoint runtime code.
//! - `endpoint` ties those pieces together into the runtime-facing protocol endpoint API.
//! - `leaf` defines application-facing metadata and generated call-dispatch traits.
//! - `call` and `procedure` layer higher-level runtimes on top of validated endpoint events.
mod call;
mod endpoint;
mod hook;
mod leaf;
mod procedure;
mod routing;
pub use call::{
Call, CallLeaf, CallReply, CallResult, DispatchError, IncomingCall, IncomingData,
IncomingFault, LeafRuntime, LeafRuntimeError, OutgoingData, RuntimeOutcome, decode_call_input,
encode_call_reply,
};
pub use endpoint::{
ChildRoute, Endpoint, EndpointError, EndpointOutcome, Ingress, LeafSpec, LocalEvent,
ProtocolEndpoint,
};
pub use hook::{ActiveHook, HookConflict, HookKey, HookTable, PendingHook};
pub use leaf::{CallProcedures, ProtocolLeaf, derive_leaf_name};
pub use procedure::{
Procedure, ProcedureEffect, ProcedureRuntime, ProcedureRuntimeError, ProcedureRuntimeOutcome,
ProcedureStore, StatefulProcedureMetadata,
};
pub use routing::{
CompiledRoutes, DefaultRouteProvider, LeafNode, RouteDecision, RouteProvider, TreeNode,
is_prefix, route_destination,
};
@@ -0,0 +1,790 @@
//! Procedure-scoped session runtime for complex hook-backed leaves.
//!
//! This layer exists for procedures that need long-lived per-hook state, such as
//! a remote shell. The leaf owns the session table explicitly, while the runtime
//! handles the protocol bookkeeping around initial `Call`, follow-on `Data`, and
//! upstream `Fault` traffic.
//!
//! # Model
//!
//! - One opening `Call` targets one procedure suffix such as `open`.
//! - If that procedure succeeds, it returns one session value.
//! - The runtime stores that session under the hook key declared by the caller.
//! - Later hook traffic is routed back to that same session automatically.
//!
//! The protocol still owns transport truth such as half-close state and fault
//! routing. Procedure sessions only own application resources and behavior.
use alloc::{collections::BTreeMap, string::String, vec, vec::Vec};
use core::{fmt, marker::PhantomData};
use rkyv::{Archive, rancor::Error};
use crate::protocol::{CallMessage, FrameBytes, HookTarget, ProtocolFault};
use super::{
DispatchError, Endpoint, EndpointError, HookKey, IncomingData, IncomingFault, Ingress,
LocalEvent, OutgoingData, ProtocolEndpoint, ProtocolLeaf, decode_call_input,
};
/// Generated metadata for one stateful procedure bound to one leaf type.
///
/// This metadata is intentionally tiny: one procedure suffix plus the derived
/// full `procedure_id`. The leaf still owns all session storage explicitly.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::{ProtocolLeaf, StatefulProcedureMetadata};
/// struct ExampleLeaf;
/// impl ProtocolLeaf for ExampleLeaf {
/// fn leaf_name() -> String { "org.example.v1.shell".into() }
/// }
/// struct Open;
/// impl StatefulProcedureMetadata<ExampleLeaf> for Open {
/// fn procedure_suffix() -> &'static str { "open" }
/// }
/// assert_eq!(Open::procedure_id(), "org.example.v1.shell.open");
/// ```
pub trait StatefulProcedureMetadata<L>: Sized
where
L: ProtocolLeaf,
{
/// Returns the local suffix used to derive the full canonical `procedure_id`.
fn procedure_suffix() -> &'static str;
/// Returns the canonical `procedure_id` for this procedure.
fn procedure_id() -> String {
let mut procedure_id = L::leaf_name();
procedure_id.push('.');
procedure_id.push_str(Self::procedure_suffix());
procedure_id
}
}
/// Explicit storage access for one procedure session map inside the leaf.
///
/// Rationale: the leaf remains the source of truth for its active sessions. This
/// avoids hidden generated enums or side tables and keeps debugging obvious.
///
/// # Example
/// ```rust
/// use std::collections::BTreeMap;
/// use unshell::protocol::tree::{HookKey, ProcedureStore};
/// struct Session;
/// struct Leaf { sessions: BTreeMap<HookKey, Session> }
/// impl ProcedureStore<Session> for Leaf {
/// fn procedure_sessions(&mut self) -> &mut BTreeMap<HookKey, Session> {
/// &mut self.sessions
/// }
/// }
/// ```
pub trait ProcedureStore<P> {
/// Returns the hook-keyed session table for one procedure type.
fn procedure_sessions(&mut self) -> &mut BTreeMap<HookKey, P>;
}
/// One procedure that owns per-hook session state.
///
/// The opening `Call` constructs one session value. The runtime then hands later
/// `Data`, `Fault`, and `poll()` ticks back to that stored session until the
/// session requests removal or the protocol faults it out.
///
/// # Example
/// ```rust
/// use std::collections::BTreeMap;
/// use std::string::String;
/// use unshell::{Leaf, Procedure};
/// use unshell::protocol::tree::{Call, HookKey, Procedure, ProcedureEffect, ProcedureStore};
///
/// #[derive(Default, Leaf)]
/// #[leaf(id = "org.example.v1.stream")]
/// struct StreamLeaf {
/// sessions: BTreeMap<HookKey, OpenProcedure>,
/// }
///
/// impl ProcedureStore<OpenProcedure> for StreamLeaf {
/// fn procedure_sessions(&mut self) -> &mut BTreeMap<HookKey, OpenProcedure> {
/// &mut self.sessions
/// }
/// }
///
/// #[derive(Procedure)]
/// #[procedure(leaf = StreamLeaf, name = "open")]
/// struct OpenProcedure {
/// prefix: String,
/// }
///
/// impl Procedure<StreamLeaf> for OpenProcedure {
/// type Error = core::convert::Infallible;
/// type Input = String;
///
/// fn open(
/// _leaf: &mut StreamLeaf,
/// call: Call<Self::Input>,
/// ) -> Result<Self, Self::Error> {
/// Ok(Self { prefix: call.input })
/// }
///
/// fn poll(
/// _leaf: &mut StreamLeaf,
/// _session: &mut Self,
/// ) -> Result<ProcedureEffect, Self::Error> {
/// Ok(ProcedureEffect::default())
/// }
/// }
/// ```
pub trait Procedure<L>: StatefulProcedureMetadata<L> + Sized
where
L: ProtocolLeaf,
{
/// Leaf-specific error surfaced while opening or advancing the session.
type Error;
/// Typed input payload decoded from the opening call.
type Input;
/// Creates one session from the opening `Call`.
fn open(leaf: &mut L, call: super::Call<Self::Input>) -> Result<Self, Self::Error>;
/// Handles one inbound hook `Data` packet for this procedure.
fn on_data(
_leaf: &mut L,
_session: &mut Self,
_data: IncomingData,
) -> Result<ProcedureEffect, Self::Error> {
Ok(ProcedureEffect::default())
}
/// Handles one inbound hook `Fault` packet for this procedure.
fn on_fault(
_leaf: &mut L,
_session: &mut Self,
_fault: IncomingFault,
) -> Result<(), Self::Error> {
Ok(())
}
/// Polls one live session for locally-generated hook traffic.
fn poll(_leaf: &mut L, _session: &mut Self) -> Result<ProcedureEffect, Self::Error> {
Ok(ProcedureEffect::default())
}
/// Releases application resources when the runtime discards one session.
///
/// This hook exists because a runtime error may force the session to be
/// dropped before the normal protocol close path completes. Simple state
/// objects can keep the default no-op implementation.
fn close(_leaf: &mut L, _session: Self) -> Result<(), Self::Error> {
Ok(())
}
}
/// Output produced while advancing one session.
///
/// This exists as the normalized result of one session step: some outgoing hook packets plus an
/// explicit decision about whether the session should stay alive.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::ProcedureEffect;
/// let effect = ProcedureEffect::close(Vec::new());
/// assert!(effect.close_session);
/// ```
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct ProcedureEffect {
/// `Data` packets to emit after the session step completes.
pub outgoing: Vec<OutgoingData>,
/// Whether the runtime should remove the session after sending `outgoing`.
pub close_session: bool,
}
impl ProcedureEffect {
/// Builds an effect that keeps the session alive after emitting `outgoing`.
#[must_use]
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::ProcedureEffect;
/// let effect = ProcedureEffect::outgoing(Vec::new());
/// assert!(!effect.close_session);
/// ```
pub fn outgoing(outgoing: Vec<OutgoingData>) -> Self {
Self {
outgoing,
close_session: false,
}
}
/// Builds an effect that closes the session after emitting `outgoing`.
#[must_use]
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::ProcedureEffect;
/// let effect = ProcedureEffect::close(Vec::new());
/// assert!(effect.close_session);
/// ```
pub fn close(outgoing: Vec<OutgoingData>) -> Self {
Self {
outgoing,
close_session: true,
}
}
}
/// 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)]
pub enum ProcedureRuntimeError<E> {
/// Protocol endpoint routing or framing failed.
Endpoint(EndpointError),
/// The opening call failed to decode or open cleanly before a session existed.
///
/// Once a session is already live, runtime failures prefer emitting protocol faults and
/// tearing down that session rather than surfacing leaf errors directly.
Decode(super::DispatchError<E>),
}
impl<E> fmt::Display for ProcedureRuntimeError<E>
where
E: fmt::Display,
{
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Endpoint(error) => write!(f, "{error}"),
Self::Decode(error) => write!(f, "{error}"),
}
}
}
impl<E> core::error::Error for ProcedureRuntimeError<E> where E: core::error::Error + 'static {}
impl<E> From<EndpointError> for ProcedureRuntimeError<E> {
fn from(value: EndpointError) -> Self {
Self::Endpoint(value)
}
}
/// Frames emitted while advancing one stateful procedure runtime.
///
/// This exists so callers can flush emitted frames to transport while also observing whether the
/// inbound packet was intentionally dropped.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::ProcedureRuntimeOutcome;
/// let outcome = ProcedureRuntimeOutcome::default();
/// assert!(outcome.frames.is_empty());
/// ```
#[derive(Debug, Default)]
pub struct ProcedureRuntimeOutcome {
/// Frames emitted while processing the current step.
pub frames: Vec<FrameBytes>,
/// Whether the endpoint dropped the incoming packet.
pub dropped: bool,
}
/// Runtime for one leaf paired with one procedure-owned session type.
///
/// This runtime is deliberately narrow. It is the right tool when one leaf owns
/// one hook-backed procedure whose session type is explicit in the leaf's state.
/// Simpler one-shot procedures can stay on [`crate::protocol::tree::LeafRuntime`].
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::ProcedureRuntime;
/// # struct Leaf;
/// # struct Proc;
/// # let _ = core::marker::PhantomData::<ProcedureRuntime<Leaf, Proc>>;
/// ```
#[derive(Debug)]
pub struct ProcedureRuntime<L, P> {
endpoint: ProtocolEndpoint,
leaf: L,
marker: PhantomData<P>,
}
impl<L, P> ProcedureRuntime<L, P> {
/// Builds a procedure runtime from one endpoint and one leaf instance.
#[must_use]
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::{ProcedureRuntime, ProtocolEndpoint};
/// struct Leaf;
/// struct Proc;
/// let runtime = ProcedureRuntime::<Leaf, Proc>::new(
/// ProtocolEndpoint::new(Vec::new(), None, Vec::new(), Vec::new()),
/// Leaf,
/// );
/// let _ = runtime;
/// ```
pub fn new(endpoint: ProtocolEndpoint, leaf: L) -> Self {
Self {
endpoint,
leaf,
marker: PhantomData,
}
}
/// Returns the underlying protocol endpoint.
#[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 {
&self.endpoint
}
/// Returns a mutable reference to the protocol endpoint.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::{ProcedureRuntime, ProtocolEndpoint};
/// struct Leaf;
/// struct Proc;
/// let mut runtime = ProcedureRuntime::<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 {
&mut self.endpoint
}
/// Returns the hosted leaf instance.
#[must_use]
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::{ProcedureRuntime, ProtocolEndpoint};
/// struct Leaf;
/// struct Proc;
/// let runtime = ProcedureRuntime::<Leaf, Proc>::new(ProtocolEndpoint::new(Vec::new(), None, Vec::new(), Vec::new()), Leaf);
/// let _ = runtime.leaf();
/// ```
pub fn leaf(&self) -> &L {
&self.leaf
}
/// Returns a mutable reference to the hosted leaf instance.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::{ProcedureRuntime, ProtocolEndpoint};
/// struct Leaf;
/// struct Proc;
/// let mut runtime = ProcedureRuntime::<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 {
&mut self.leaf
}
}
impl<L, P> ProcedureRuntime<L, P>
where
L: ProtocolLeaf + ProcedureStore<P>,
P: Procedure<L>,
P::Input: Archive,
<P::Input as Archive>::Archived: rkyv::Portable
+ for<'b> rkyv::bytecheck::CheckBytes<rkyv::api::high::HighValidator<'b, Error>>
+ rkyv::Deserialize<P::Input, rkyv::api::high::HighDeserializer<Error>>,
P::Error: fmt::Display,
{
/// Delivers one framed protocol packet into the runtime.
///
/// # Example
/// ```rust
/// # use unshell::protocol::tree::ProcedureRuntime;
/// # struct Leaf;
/// # struct Proc;
/// # let _ = core::marker::PhantomData::<ProcedureRuntime<Leaf, Proc>>;
/// ```
pub fn receive(
&mut self,
ingress: &Ingress,
frame: FrameBytes,
) -> Result<ProcedureRuntimeOutcome, ProcedureRuntimeError<P::Error>> {
let outcome = self.endpoint.receive(ingress, frame)?;
self.process_endpoint_outcome(outcome)
}
/// Polls all live sessions for locally-generated hook traffic.
///
/// Rationale: many long-lived procedures, including a remote shell, need to
/// emit output even when no new inbound protocol packet has arrived.
///
/// # Example
/// ```rust
/// # use unshell::protocol::tree::ProcedureRuntime;
/// # struct Leaf;
/// # struct Proc;
/// # let _ = core::marker::PhantomData::<ProcedureRuntime<Leaf, Proc>>;
/// ```
pub fn poll(&mut self) -> Result<ProcedureRuntimeOutcome, ProcedureRuntimeError<P::Error>> {
let mut frames = Vec::new();
let keys = self
.leaf
.procedure_sessions()
.keys()
.cloned()
.collect::<Vec<_>>();
for key in keys {
let Some(session) = self.leaf.procedure_sessions().remove(&key) else {
continue;
};
// Collect keys first and temporarily remove each session so procedure callbacks can
// mutate the leaf without fighting the session-table borrow.
match self.poll_session(key, session)? {
Some(session_frames) => frames.extend(session_frames),
None => continue,
}
}
Ok(ProcedureRuntimeOutcome {
frames,
dropped: false,
})
}
fn process_endpoint_outcome(
&mut self,
outcome: super::EndpointOutcome,
) -> Result<ProcedureRuntimeOutcome, ProcedureRuntimeError<P::Error>> {
match outcome {
super::EndpointOutcome::Forward { frame, .. } => Ok(ProcedureRuntimeOutcome {
frames: vec![frame],
dropped: false,
}),
super::EndpointOutcome::Dropped => Ok(ProcedureRuntimeOutcome {
frames: Vec::new(),
dropped: true,
}),
super::EndpointOutcome::Local(event) => self.process_local_event(event),
}
}
fn poll_session(
&mut self,
key: HookKey,
mut session: P,
) -> Result<Option<Vec<FrameBytes>>, ProcedureRuntimeError<P::Error>> {
let effect = match P::poll(&mut self.leaf, &mut session) {
Ok(effect) => self.ensure_terminal_packet(&key, effect),
Err(error) => {
let _ = P::close(&mut self.leaf, session);
let frames = self.emit_internal_fault(Some(key.clone()))?;
let _ = error;
return Ok(Some(frames));
}
};
let outgoing = match self.emit_outgoing(effect.outgoing) {
Ok(outgoing) => outgoing.frames,
Err(error) => {
// Emit failures are transport/runtime failures, not leaf-procedure failures. Keep
// the session when it asked to stay open so the caller can retry later.
if !effect.close_session {
self.leaf.procedure_sessions().insert(key, session);
} else {
let _ = P::close(&mut self.leaf, session);
}
return Err(error);
}
};
if !effect.close_session {
self.leaf.procedure_sessions().insert(key, session);
} else {
let _ = P::close(&mut self.leaf, session);
}
Ok(Some(outgoing))
}
fn process_local_event(
&mut self,
event: LocalEvent,
) -> Result<ProcedureRuntimeOutcome, ProcedureRuntimeError<P::Error>> {
match event {
LocalEvent::Call { header, message } => self.process_local_call(header, message),
LocalEvent::Data {
header,
message,
hook_key,
} => self.process_local_data(header, message, hook_key),
LocalEvent::Fault {
header,
message,
hook_key,
} => self.process_local_fault(header, message, hook_key),
}
}
fn process_local_call(
&mut self,
header: crate::protocol::PacketHeader,
message: CallMessage,
) -> Result<ProcedureRuntimeOutcome, ProcedureRuntimeError<P::Error>> {
let mut runtime = ProcedureRuntimeOutcome::default();
if message.procedure_id != P::procedure_id() {
// Once this runtime receives a call, a wrong procedure id is a protocol mismatch.
// Fault the caller rather than surfacing a leaf-local error it cannot recover from.
runtime
.frames
.extend(self.emit_internal_fault_if_possible(message.response_hook.as_ref())?);
return Ok(runtime);
}
let Some(hook) = message.response_hook.as_ref() else {
return Ok(runtime);
};
let hook_key = HookKey::new(hook.return_path.clone(), hook.hook_id);
let session = match self.open_session(header, message) {
Ok(session) => session,
Err(error) => {
// Session open failures still fault the caller when a response hook exists, but do
// not leak leaf-local details over the wire.
runtime
.frames
.extend(self.emit_internal_fault(Some(hook_key.clone()))?);
let _ = error;
return Ok(runtime);
}
};
self.leaf.procedure_sessions().insert(hook_key, session);
Ok(runtime)
}
fn process_local_data(
&mut self,
header: crate::protocol::PacketHeader,
message: crate::protocol::DataMessage,
hook_key: HookKey,
) -> Result<ProcedureRuntimeOutcome, ProcedureRuntimeError<P::Error>> {
let Some(mut session) = self.leaf.procedure_sessions().remove(&hook_key) else {
return Ok(ProcedureRuntimeOutcome::default());
};
let effect = match P::on_data(
&mut self.leaf,
&mut session,
IncomingData {
header,
message,
hook_key: hook_key.clone(),
},
) {
Ok(effect) => self.ensure_terminal_packet(&hook_key, effect),
Err(error) => {
let _ = P::close(&mut self.leaf, session);
let frames = self.emit_internal_fault(Some(hook_key.clone()))?;
let _ = error;
return Ok(ProcedureRuntimeOutcome {
frames,
dropped: false,
});
}
};
let outgoing = match self.emit_outgoing(effect.outgoing) {
Ok(outgoing) => outgoing.frames,
Err(error) => {
if !effect.close_session {
self.leaf.procedure_sessions().insert(hook_key, session);
} else {
let _ = P::close(&mut self.leaf, session);
}
return Err(error);
}
};
if !effect.close_session {
self.leaf.procedure_sessions().insert(hook_key, session);
} else {
let _ = P::close(&mut self.leaf, session);
}
Ok(ProcedureRuntimeOutcome {
frames: outgoing,
dropped: false,
})
}
fn process_local_fault(
&mut self,
header: crate::protocol::PacketHeader,
message: crate::protocol::FaultMessage,
hook_key: HookKey,
) -> Result<ProcedureRuntimeOutcome, ProcedureRuntimeError<P::Error>> {
let Some(mut session) = self.leaf.procedure_sessions().remove(&hook_key) else {
return Ok(ProcedureRuntimeOutcome::default());
};
let on_fault_result = P::on_fault(
&mut self.leaf,
&mut session,
IncomingFault {
header,
fault: message,
hook_key: hook_key.clone(),
},
);
// Always attempt both the fault observer and the final close hook so resource cleanup can
// still run even when the leaf reports an error while handling the fault.
let close_result = P::close(&mut self.leaf, session);
if let Err(error) = on_fault_result {
let _ = close_result;
let frames = self.emit_internal_fault(Some(hook_key.clone()))?;
let _ = error;
return Ok(ProcedureRuntimeOutcome {
frames,
dropped: false,
});
}
if let Err(error) = close_result {
let frames = self.emit_internal_fault(Some(hook_key))?;
let _ = error;
return Ok(ProcedureRuntimeOutcome {
frames,
dropped: false,
});
}
Ok(ProcedureRuntimeOutcome::default())
}
fn open_session(
&mut self,
header: crate::protocol::PacketHeader,
message: CallMessage,
) -> Result<P, DispatchError<P::Error>> {
let CallMessage {
procedure_id,
data,
response_hook,
} = message;
let input =
decode_call_input::<P::Input>(data.as_slice()).map_err(DispatchError::Decode)?;
P::open(
&mut self.leaf,
super::Call {
input,
caller_path: header.src_path,
procedure_id,
dst_leaf: header.dst_leaf,
response_hook: response_hook
.map(|hook| HookKey::new(hook.return_path, hook.hook_id)),
},
)
.map_err(DispatchError::Handler)
}
fn emit_outgoing(
&mut self,
outgoing: Vec<OutgoingData>,
) -> Result<ProcedureRuntimeOutcome, ProcedureRuntimeError<P::Error>> {
let mut runtime = ProcedureRuntimeOutcome::default();
for packet in outgoing {
let endpoint_outcome = self.endpoint.send_data(
packet.dst_path,
packet.hook_id,
packet.procedure_id,
packet.data,
packet.end_hook,
)?;
runtime
.frames
.extend(self.process_endpoint_outcome(endpoint_outcome)?.frames);
}
Ok(runtime)
}
/// Emits an upstream internal fault for the current procedure if the caller
/// declared a response hook.
///
/// # Example
/// ```rust
/// # use unshell::protocol::tree::ProcedureRuntime;
/// # struct Leaf;
/// # struct Proc;
/// # let _ = core::marker::PhantomData::<ProcedureRuntime<Leaf, Proc>>;
/// ```
pub fn emit_internal_fault_if_possible(
&mut self,
hook: Option<&HookTarget>,
) -> Result<Vec<FrameBytes>, ProcedureRuntimeError<P::Error>> {
let Some(HookTarget {
return_path,
hook_id,
}) = hook
else {
return Ok(Vec::new());
};
let outcome = self.endpoint.emit_fault_if_possible(
Some(HookKey::new(return_path.clone(), *hook_id)),
ProtocolFault::INTERNAL_ERROR,
)?;
Ok(self.process_endpoint_outcome(outcome)?.frames)
}
fn emit_internal_fault(
&mut self,
hook_key: Option<HookKey>,
) -> Result<Vec<FrameBytes>, ProcedureRuntimeError<P::Error>> {
let outcome = self
.endpoint
.emit_fault_if_possible(hook_key, ProtocolFault::INTERNAL_ERROR)?;
Ok(self.process_endpoint_outcome(outcome)?.frames)
}
/// Ensures a closing session leaves the protocol hook in a fully terminated state.
///
/// If leaf code requests `close_session` without emitting an explicit terminal packet, the
/// runtime synthesizes an empty final `Data` frame so the hook closes cleanly on the wire.
fn ensure_terminal_packet(
&self,
hook_key: &HookKey,
mut effect: ProcedureEffect,
) -> ProcedureEffect {
// Once a session emits `end_hook`, later packets would violate the protocol,
// so the runtime keeps only the prefix through that terminal packet.
if let Some(index) = effect.outgoing.iter().position(|packet| packet.end_hook) {
// The protocol allows only one terminal packet per direction, so ignore anything a
// procedure tried to emit after the first close marker.
effect.outgoing.truncate(index + 1);
}
let local_end_already_sent = self
.endpoint
.hooks
.active(hook_key)
.is_none_or(|active| active.local_ended);
if effect.close_session
&& !effect.outgoing.iter().any(|packet| packet.end_hook)
&& !local_end_already_sent
{
// Closing a session without an explicit terminal packet would leave the
// protocol hook half-open, so emit an empty terminal frame on behalf of
// the procedure unless the local side already ended earlier.
effect.outgoing.push(OutgoingData {
dst_path: hook_key.return_path.clone(),
hook_id: hook_key.hook_id,
procedure_id: P::procedure_id(),
data: Vec::new(),
end_hook: true,
});
}
effect
}
}
@@ -0,0 +1,437 @@
//! Path routing helpers and explicit enum tree declarations.
//!
//! Routing follows a longest-prefix rule over endpoint paths. Each endpoint boundary can compile
//! its children into a small trie so repeated route decisions do not need to scan every child.
use alloc::{collections::BTreeMap, string::String, vec, vec::Vec};
/// Explicit tree declaration used for configuration and tests.
///
/// This models one protocol tree declaratively so callers can derive endpoint paths, leaf
/// inventory, or test fixtures without first constructing live endpoints.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::{LeafNode, TreeNode};
/// let tree = TreeNode::Root {
/// children: vec![TreeNode::Endpoint {
/// segment: "worker".into(),
/// leaves: vec![LeafNode {
/// name: "service".into(),
/// procedures: vec!["example.service.v1.invoke".into()],
/// }],
/// children: Vec::new(),
/// }],
/// };
/// assert_eq!(tree.paths().len(), 2);
/// ```
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TreeNode {
/// The protocol root. Its path is always empty.
Root {
/// Direct child endpoints hosted below the root.
children: Vec<Self>,
},
/// An addressable endpoint segment in the tree.
Endpoint {
/// Path segment contributed by this endpoint.
segment: String,
/// Leaves hosted directly at this endpoint.
leaves: Vec<LeafNode>,
/// Direct child endpoints hosted below this endpoint.
children: Vec<Self>,
},
}
/// Leaf declaration used inside the explicit tree enum.
///
/// This exists so declarative trees can describe the leaves hosted at one endpoint without
/// constructing the full runtime state machine.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::LeafNode;
/// let leaf = LeafNode {
/// name: "service".into(),
/// procedures: vec!["example.service.v1.invoke".into()],
/// };
/// assert_eq!(leaf.name, "service");
/// ```
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LeafNode {
/// Leaf name local to an endpoint path.
pub name: String,
/// Procedures served by this leaf.
pub procedures: Vec<String>,
}
impl TreeNode {
/// Flattens the explicit tree into the set of endpoint paths it declares.
///
/// The returned list always includes the protocol root as `[]`.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::TreeNode;
/// let tree = TreeNode::Root {
/// children: vec![TreeNode::Endpoint {
/// segment: "worker".into(),
/// leaves: Vec::new(),
/// children: Vec::new(),
/// }],
/// };
/// assert_eq!(tree.paths(), vec![Vec::<String>::new(), vec!["worker".into()]]);
/// ```
pub fn paths(&self) -> Vec<Vec<String>> {
let mut paths = Vec::new();
self.collect_paths(&[], &mut paths);
paths
}
fn collect_paths(&self, prefix: &[String], paths: &mut Vec<Vec<String>>) {
match self {
Self::Root { children } => {
paths.push(Vec::new());
for child in children {
// Root always restarts collection from the empty path.
child.collect_paths(&[], paths);
}
}
Self::Endpoint {
segment, children, ..
} => {
let mut next = prefix.to_vec();
next.push(segment.clone());
paths.push(next.clone());
for child in children {
child.collect_paths(&next, paths);
}
}
}
}
}
/// Longest-prefix route decision.
///
/// Each decision is evaluated from one endpoint's perspective after comparing its own path and
/// compiled child subtree against the destination path.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::RouteDecision;
/// let route = RouteDecision::Child(0);
/// assert!(matches!(route, RouteDecision::Child(0)));
/// ```
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RouteDecision {
/// Forward to the child at the given local child index.
Child(usize),
/// Deliver locally at this endpoint.
Local,
/// Forward upward because the destination is outside the local subtree.
Parent,
/// Drop because no local, child, or parent route applies.
Drop,
}
/// One compiled routing table for one endpoint boundary.
///
/// This exists so repeated route lookups can reuse one longest-prefix trie instead of scanning
/// every child path on every packet.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::{CompiledRoutes, RouteDecision};
/// let routes = CompiledRoutes::new(&["root".into()], &[vec!["root".into(), "worker".into()]], true);
/// assert_eq!(routes.route(&["root".into(), "worker".into(), "job".into()]), RouteDecision::Child(0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct CompiledRoutes {
local_path: Vec<String>,
has_parent: bool,
nodes: Vec<RouteTrieNode>,
}
#[derive(Debug, Clone, Default)]
struct RouteTrieNode {
/// Child selected when traversal stops exactly at this trie node.
best_child: Option<usize>,
edges: BTreeMap<String, usize>,
}
impl CompiledRoutes {
/// Compiles child endpoint paths into a trie rooted at `local_path`.
///
/// Only strict descendants of `local_path` participate in the compiled trie. Paths outside
/// the local subtree, or equal to `local_path` itself, are ignored.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::CompiledRoutes;
/// let routes = CompiledRoutes::new(
/// &["root".into()],
/// &[
/// vec!["root".into(), "worker".into()],
/// vec!["other".into()],
/// ],
/// true,
/// );
/// assert_eq!(routes.route(&["root".into(), "worker".into()]), unshell::protocol::tree::RouteDecision::Child(0));
/// ```
#[must_use]
pub fn new(local_path: &[String], child_paths: &[Vec<String>], has_parent: bool) -> Self {
let mut routes = Self {
local_path: local_path.to_vec(),
has_parent,
nodes: vec![RouteTrieNode::default()],
};
for (index, child_path) in child_paths.iter().enumerate() {
routes.insert_child(index, child_path);
}
routes
}
fn insert_child(&mut self, index: usize, child_path: &[String]) {
if !is_prefix(&self.local_path, child_path) || child_path.len() <= self.local_path.len() {
return;
}
// Store only strict descendants. The terminal node records which direct child owns that
// descendant boundary so later lookups can recover the longest matching child index.
let mut node_index = 0usize;
for segment in &child_path[self.local_path.len()..] {
let next_index = if let Some(next_index) = self.nodes[node_index].edges.get(segment) {
*next_index
} else {
let next_index = self.nodes.len();
self.nodes.push(RouteTrieNode::default());
self.nodes[node_index]
.edges
.insert(segment.clone(), next_index);
next_index
};
node_index = next_index;
}
self.nodes[node_index].best_child = Some(index);
}
/// Resolves `dst_path` using the compiled longest-prefix trie.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::{CompiledRoutes, RouteDecision};
/// let routes = CompiledRoutes::new(&["root".into()], &[vec!["root".into(), "worker".into()]], true);
/// assert_eq!(routes.route(&["root".into(), "worker".into()]), RouteDecision::Child(0));
/// assert_eq!(routes.route(&["root".into()]), RouteDecision::Local);
/// assert_eq!(routes.route(&["elsewhere".into()]), RouteDecision::Parent);
/// ```
#[must_use]
pub fn route(&self, dst_path: &[String]) -> RouteDecision {
if !is_prefix(&self.local_path, dst_path) {
return if self.has_parent {
RouteDecision::Parent
} else {
RouteDecision::Drop
};
}
let mut best_child = None;
let mut node_index = 0usize;
for segment in &dst_path[self.local_path.len()..] {
let Some(next_index) = self.nodes[node_index].edges.get(segment) else {
break;
};
node_index = *next_index;
if let Some(index) = self.nodes[node_index].best_child {
// Keep the deepest matching child seen so far; if traversal breaks later, the
// protocol still routes to the longest matching descendant boundary.
best_child = Some(index);
}
}
if let Some(index) = best_child {
return RouteDecision::Child(index);
}
if self.local_path == dst_path {
return RouteDecision::Local;
}
RouteDecision::Drop
}
}
/// Returns `true` if `prefix` is a path prefix of `path`.
///
/// This exists as the shared path-comparison primitive for both declarative tree processing and
/// runtime route compilation.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::is_prefix;
/// assert!(is_prefix(&["root".into()], &["root".into(), "worker".into()]));
/// assert!(!is_prefix(&["worker".into()], &["root".into(), "worker".into()]));
/// ```
pub fn is_prefix(prefix: &[String], path: &[String]) -> bool {
prefix.len() <= path.len()
&& prefix
.iter()
.zip(path.iter())
.all(|(left, right)| left == right)
}
/// Trait for resolving a destination path to a routing decision.
///
/// The default policy is longest-prefix routing: exact matches stay local, the deepest matching
/// descendant wins for child forwarding, destinations outside the local subtree go to the parent
/// when one exists, and everything else drops.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::{DefaultRouteProvider, RouteProvider};
/// let provider = DefaultRouteProvider;
/// let route = provider.route_destination(
/// &["root".into()],
/// [vec!["root".into(), "worker".into()]],
/// true,
/// &["root".into(), "worker".into()],
/// );
/// assert!(matches!(route, unshell::protocol::tree::RouteDecision::Child(0)));
/// ```
pub trait RouteProvider {
/// Returns the route decision for `dst_path` from the perspective of `local_path`.
fn route_destination<I>(
&self,
local_path: &[String],
child_paths: I,
has_parent: bool,
dst_path: &[String],
) -> RouteDecision
where
I: IntoIterator,
I::Item: AsRef<[String]>;
}
/// Default routing implementation using the protocol's longest-prefix rule.
///
/// This exists as the stateless policy object behind the free [`route_destination`] helper and
/// as a customization seam for tests or alternate routing strategies.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::{DefaultRouteProvider, RouteProvider};
/// let provider = DefaultRouteProvider;
/// let route = provider.route_destination(&[], [vec!["worker".into()]], false, &["worker".into()]);
/// assert!(matches!(route, unshell::protocol::tree::RouteDecision::Child(0)));
/// ```
pub struct DefaultRouteProvider;
impl RouteProvider for DefaultRouteProvider {
fn route_destination<I>(
&self,
local_path: &[String],
child_paths: I,
has_parent: bool,
dst_path: &[String],
) -> RouteDecision
where
I: IntoIterator,
I::Item: AsRef<[String]>,
{
let child_paths = child_paths
.into_iter()
.map(|child| child.as_ref().to_vec())
.collect::<Vec<_>>();
CompiledRoutes::new(local_path, &child_paths, has_parent).route(dst_path)
}
}
/// Resolves `dst_path` with the default longest-prefix route provider.
///
/// Exact matches return [`RouteDecision::Local`]. Destinations outside the local subtree return
/// [`RouteDecision::Parent`] when `has_parent` is `true`, otherwise [`RouteDecision::Drop`].
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::{RouteDecision, route_destination};
/// let route = route_destination(&[], [vec!["worker".into()]], false, &["worker".into()]);
/// assert_eq!(route, RouteDecision::Child(0));
/// ```
pub fn route_destination<I>(
local_path: &[String],
child_paths: I,
has_parent: bool,
dst_path: &[String],
) -> RouteDecision
where
I: IntoIterator,
I::Item: AsRef<[String]>,
{
DefaultRouteProvider.route_destination(local_path, child_paths, has_parent, dst_path)
}
#[cfg(test)]
mod tests {
use super::*;
use alloc::{string::String, vec};
#[test]
fn longest_prefix_wins() {
let provider = DefaultRouteProvider;
let children = vec![
vec![String::from("a")],
vec![String::from("a"), String::from("b")],
];
assert_eq!(
provider.route_destination(
&Vec::<String>::new(),
children,
false,
&[String::from("a"), String::from("b"), String::from("c")]
),
RouteDecision::Child(1)
);
}
#[test]
fn compiled_routes_choose_longest_prefix_without_child_scan() {
let table = CompiledRoutes::new(
&[String::from("a")],
&[
vec![String::from("a"), String::from("b")],
vec![String::from("a"), String::from("x")],
],
true,
);
assert_eq!(
table.route(&[String::from("a"), String::from("b"), String::from("c")]),
RouteDecision::Child(0)
);
assert_eq!(table.route(&[String::from("z")]), RouteDecision::Parent);
}
#[test]
fn tree_enum_flattens_paths() {
let tree = TreeNode::Root {
children: vec![TreeNode::Endpoint {
segment: String::from("a"),
leaves: Vec::new(),
children: vec![TreeNode::Endpoint {
segment: String::from("b"),
leaves: Vec::new(),
children: Vec::new(),
}],
}],
};
assert_eq!(
tree.paths(),
vec![
Vec::<String>::new(),
vec![String::from("a")],
vec![String::from("a"), String::from("b")],
]
);
}
}