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
-1
View File
@@ -1 +0,0 @@
pub mod remote_shell;
-25
View File
@@ -1,25 +0,0 @@
use std::fmt;
use std::io;
#[derive(Debug)]
pub enum ShellLeafError {
Io(io::Error),
MissingHook,
}
impl fmt::Display for ShellLeafError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Io(error) => write!(f, "{error}"),
Self::MissingHook => f.write_str("shell open requires a response hook"),
}
}
}
impl std::error::Error for ShellLeafError {}
impl From<io::Error> for ShellLeafError {
fn from(value: io::Error) -> Self {
Self::Io(value)
}
}
-159
View File
@@ -1,159 +0,0 @@
//! Stateful remote shell leaf used by the protocol examples.
//!
//! # Design
//!
//! The leaf owns all live hook sessions explicitly in `sessions`. Each entry in
//! that map is one `ProcedureOpen`, keyed by the caller-owned hook identity.
//! The protocol runtime still owns packet validation and transport close state,
//! while the procedure session owns application resources such as the spawned
//! shell process.
//!
//! This keeps the storage obvious:
//! - the leaf owns session maps
//! - the procedure type owns one hook conversation
//! - the runtime routes later `Data` and `Fault` packets automatically
mod errors;
mod session;
mod transport;
use std::collections::BTreeMap;
use unshell::Leaf;
use unshell::protocol::tree::{
Call, HookKey, Procedure, ProcedureEffect, ProcedureRuntime, ProcedureStore, ProtocolEndpoint,
};
pub use errors::ShellLeafError;
pub use session::ProcedureOpen;
pub use transport::LISTEN_ADDR;
/// Leaf state for the remote shell example.
///
/// The map is explicit on purpose. Stateful procedures are easier to debug when
/// the leaf clearly owns its live sessions instead of relying on generated hidden
/// enums or side tables.
#[derive(Default, Leaf)]
#[leaf(leaf_name = "remote_shell")]
pub struct RemoteShellLeaf {
sessions: BTreeMap<HookKey, ProcedureOpen>,
}
impl ProcedureStore<ProcedureOpen> for RemoteShellLeaf {
fn procedure_sessions(&mut self) -> &mut BTreeMap<HookKey, ProcedureOpen> {
&mut self.sessions
}
}
impl Procedure<RemoteShellLeaf> for ProcedureOpen {
type Error = ShellLeafError;
type Input = ();
fn open(_leaf: &mut RemoteShellLeaf, call: Call<Self::Input>) -> Result<Self, Self::Error> {
let hook_key = call.response_hook.ok_or(ShellLeafError::MissingHook)?;
ProcedureOpen::spawn(hook_key.return_path, hook_key.hook_id, call.procedure_id)
}
fn on_data(
_leaf: &mut RemoteShellLeaf,
session: &mut Self,
data: unshell::protocol::tree::IncomingData,
) -> Result<ProcedureEffect, Self::Error> {
session.on_data(data)
}
fn on_fault(
_leaf: &mut RemoteShellLeaf,
_session: &mut Self,
_fault: unshell::protocol::tree::IncomingFault,
) -> Result<(), Self::Error> {
Ok(())
}
fn poll(
_leaf: &mut RemoteShellLeaf,
session: &mut Self,
) -> Result<ProcedureEffect, Self::Error> {
session.poll()
}
fn close(_leaf: &mut RemoteShellLeaf, mut session: Self) -> Result<(), Self::Error> {
session.terminate()
}
}
/// Returns the example endpoint path used by both shell binaries.
pub fn agent_path() -> Vec<String> {
path(&["agent"])
}
/// Builds the controller endpoint used by the receiver example.
#[allow(dead_code)]
pub fn build_controller_endpoint() -> ProtocolEndpoint {
ProtocolEndpoint::new(
Vec::new(),
None,
vec![unshell::protocol::tree::ChildRoute::registered(agent_path())],
Vec::new(),
)
}
/// Builds the stateful shell runtime used by the endpoint example.
#[allow(dead_code)]
pub fn build_agent_runtime() -> ProcedureRuntime<RemoteShellLeaf, ProcedureOpen> {
let endpoint = ProtocolEndpoint::new(
agent_path(),
Some(Vec::new()),
Vec::new(),
vec![unshell::protocol::tree::LeafSpec {
name: RemoteShellLeaf::protocol_leaf_name(),
procedures: vec![ProcedureOpen::protocol_procedure_id()],
}],
);
ProcedureRuntime::new(endpoint, RemoteShellLeaf::default())
}
/// Returns the canonical leaf id used by the receiver example.
#[allow(dead_code)]
pub fn shell_leaf_name() -> String {
RemoteShellLeaf::protocol_leaf_name()
}
/// Returns the opening `procedure_id` used to create one shell session.
#[allow(dead_code)]
pub fn shell_open_procedure() -> String {
ProcedureOpen::protocol_procedure_id()
}
/// Encodes the empty opening payload used by the shell example.
#[allow(dead_code)]
pub fn shell_open_payload() -> Vec<u8> {
unshell::protocol::tree::encode_call_reply(&()).expect("unit shell open payload should encode")
}
#[allow(dead_code)]
pub fn send_forward(
stream: &mut std::net::TcpStream,
outcome: unshell::protocol::tree::EndpointOutcome,
) -> std::io::Result<()> {
transport::send_forward(stream, outcome)
}
#[allow(dead_code)]
pub fn write_frames(
stream: &mut std::net::TcpStream,
frames: &[unshell::protocol::FrameBytes],
) -> std::io::Result<()> {
transport::write_frames(stream, frames)
}
#[allow(dead_code)]
pub fn spawn_frame_reader(
stream: std::net::TcpStream,
) -> std::sync::mpsc::Receiver<std::io::Result<unshell::protocol::FrameBytes>> {
transport::spawn_frame_reader(stream)
}
fn path(parts: &[&str]) -> Vec<String> {
parts.iter().map(|part| (*part).to_owned()).collect()
}
-297
View File
@@ -1,297 +0,0 @@
//! Per-hook remote shell session lifecycle.
//!
//! A session opens one PTY-backed shell process and then translates protocol hook
//! traffic into stdin writes and stdout/stderr chunks. The close model is
//! intentionally two-sided:
//! - peer end: the caller sets `end_hook`, so no more stdin is accepted
//! - local end: the shell process exits and the PTY reader drains completely
//!
//! Only after both conditions are observed does the session emit its final empty
//! `end_hook` packet back through the protocol runtime.
use std::io::{self, Read, Write};
use std::process::Command;
use std::sync::mpsc::{self, Receiver, SyncSender, TryRecvError};
use std::thread;
use portable_pty::{CommandBuilder, ExitStatus, PtySize, native_pty_system};
use unshell::protocol::tree::{IncomingData, OutgoingData, ProcedureEffect};
use unshell::Procedure;
use super::errors::ShellLeafError;
/// Per-hook shell session created by the `open` procedure.
///
/// The procedure type is also the stored session type. This keeps the mapping
/// between protocol procedure and hook state direct and easy to inspect.
#[derive(Procedure)]
#[procedure(leaf = RemoteShellLeaf, name = "open")]
pub struct ProcedureOpen {
/// Spawned PTY child process.
pub(super) child: Box<dyn portable_pty::Child + Send>,
/// Process-group leader used for Unix hangup and kill signaling.
process_group_leader: Option<u32>,
/// Buffered stdin bridge into the shell process.
stdin_tx: Option<SyncSender<Vec<u8>>>,
/// Buffered output stream read from the PTY.
output_rx: Receiver<OutputEvent>,
/// Hook return path for packets emitted by this session.
return_path: Vec<String>,
/// Hook identifier allocated by the caller.
hook_id: u64,
/// Procedure id bound to this shell hook.
procedure_id: String,
/// Whether the PTY reader has closed and drained.
output_closed: bool,
/// Observed child exit status, once known.
pub(super) exit_status: Option<ExitStatus>,
/// Whether this session already emitted its terminal local packet.
pub(super) local_end_sent: bool,
}
/// One event forwarded from the PTY reader thread.
enum OutputEvent {
Chunk(Vec<u8>),
ReaderClosed,
}
use super::RemoteShellLeaf;
impl ProcedureOpen {
pub(super) fn spawn(
return_path: Vec<String>,
hook_id: u64,
procedure_id: String,
) -> Result<Self, ShellLeafError> {
let command = build_shell_command();
let pty_system = native_pty_system();
let pair = pty_system
.openpty(PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
})
.map_err(|error| io::Error::other(error.to_string()))?;
let child = pair
.slave
.spawn_command(command)
.map_err(|error| io::Error::other(error.to_string()))?;
let process_group_leader = child.process_id();
let stdin = pair
.master
.take_writer()
.map_err(|error| io::Error::other(error.to_string()))?;
let stdout = pair
.master
.try_clone_reader()
.map_err(|error| io::Error::other(error.to_string()))?;
let (stdin_tx, rx) = spawn_io_threads(stdin, stdout);
Ok(Self {
child,
process_group_leader,
stdin_tx: Some(stdin_tx),
output_rx: rx,
return_path,
hook_id,
procedure_id,
output_closed: false,
exit_status: None,
local_end_sent: false,
})
}
/// Builds one outgoing hook packet owned by this session.
pub(super) fn packet(&self, data: Vec<u8>, end_hook: bool) -> OutgoingData {
OutgoingData {
dst_path: self.return_path.clone(),
hook_id: self.hook_id,
procedure_id: self.procedure_id.clone(),
data,
end_hook,
}
}
/// Forces the underlying shell process to stop and records its exit status.
pub(super) fn terminate(&mut self) -> Result<(), ShellLeafError> {
self.stdin_tx.take();
match self.child.try_wait()? {
Some(status) => {
self.exit_status = Some(status);
Ok(())
}
None => {
self.signal_process_group("-KILL");
self.child
.kill()
.map_err(|error| io::Error::other(error.to_string()))?;
self.exit_status = Some(
self.child
.wait()
.map_err(|error| io::Error::other(error.to_string()))?,
);
Ok(())
}
}
}
/// Drains any currently buffered PTY output into protocol packets.
pub(super) fn drain_output(&mut self, outgoing: &mut Vec<OutgoingData>) {
loop {
match self.output_rx.try_recv() {
Ok(OutputEvent::Chunk(bytes)) => outgoing.push(self.packet(bytes, false)),
Ok(OutputEvent::ReaderClosed) => self.output_closed = true,
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => {
self.output_closed = true;
break;
}
}
}
}
/// Applies one inbound hook payload to the shell process.
pub(super) fn on_data(
&mut self,
data: IncomingData,
) -> Result<ProcedureEffect, ShellLeafError> {
if !data.message.data.is_empty() {
let Some(stdin_tx) = self.stdin_tx.as_ref() else {
return Ok(ProcedureEffect::default());
};
stdin_tx.try_send(data.message.data).map_err(|_| {
io::Error::new(io::ErrorKind::WouldBlock, "shell stdin channel full")
})?;
}
if !data.message.end_hook {
return Ok(ProcedureEffect::default());
}
// Peer end means no more stdin from the caller. Keep the process alive so
// any buffered PTY output can drain through the normal poll path. On Unix
// we also send SIGHUP so an interactive shell treats this like terminal
// hangup instead of waiting forever on the still-open PTY master.
self.stdin_tx.take();
self.signal_process_group("-HUP");
Ok(ProcedureEffect::default())
}
/// Polls the shell for locally-generated output.
pub(super) fn poll(&mut self) -> Result<ProcedureEffect, ShellLeafError> {
let mut outgoing = Vec::new();
self.drain_output(&mut outgoing);
if self.local_end_sent {
return Ok(ProcedureEffect::outgoing(outgoing));
}
if self.exit_status.is_none() {
self.exit_status = self
.child
.try_wait()
.map_err(|error| io::Error::other(error.to_string()))?;
}
if self.exit_status.is_some() && !self.output_closed {
self.signal_process_group("-KILL");
}
if self.exit_status.is_some() && self.output_closed {
outgoing.push(self.packet(Vec::new(), true));
self.local_end_sent = true;
return Ok(ProcedureEffect::close(outgoing));
}
Ok(ProcedureEffect::outgoing(outgoing))
}
fn signal_process_group(&self, signal: &str) {
#[cfg(unix)]
if let Some(process_group_leader) = self.process_group_leader {
let _ = Command::new("kill")
.arg(signal)
.arg(format!("-{}", process_group_leader))
.status();
}
}
}
impl Drop for ProcedureOpen {
fn drop(&mut self) {
let _ = self.terminate();
}
}
fn spawn_pipe_writer(mut stdin: Box<dyn Write + Send>, rx: Receiver<Vec<u8>>) {
thread::spawn(move || {
for bytes in rx {
if stdin.write_all(&bytes).is_err() {
break;
}
if stdin.flush().is_err() {
break;
}
}
});
}
fn build_shell_command() -> CommandBuilder {
if cfg!(windows) {
let mut command = CommandBuilder::new("cmd.exe");
command.arg("/Q");
command
} else {
let mut command = CommandBuilder::new("/bin/sh");
command.arg("-i");
command
}
}
fn spawn_io_threads(
stdin: Box<dyn Write + Send>,
stdout: Box<dyn Read + Send>,
) -> (SyncSender<Vec<u8>>, Receiver<OutputEvent>) {
let (stdin_tx, stdin_rx) = mpsc::sync_channel(64);
let (tx, rx) = mpsc::sync_channel(64);
spawn_pipe_writer(stdin, stdin_rx);
spawn_pipe_reader(stdout, tx);
(stdin_tx, rx)
}
fn spawn_pipe_reader<R>(mut reader: R, tx: mpsc::SyncSender<OutputEvent>)
where
R: Read + Send + 'static,
{
thread::spawn(move || {
loop {
let mut buffer = [0u8; 1024];
match reader.read(&mut buffer) {
Ok(0) => {
let _ = tx.send(OutputEvent::ReaderClosed);
break;
}
Ok(read_len) => {
if tx
.send(OutputEvent::Chunk(buffer[..read_len].to_vec()))
.is_err()
{
break;
}
}
Err(error) if error.kind() == io::ErrorKind::Interrupted => {}
Err(error) => {
let _ = tx.send(OutputEvent::Chunk(
format!("shell pipe read error: {error}\n").into_bytes(),
));
let _ = tx.send(OutputEvent::ReaderClosed);
break;
}
}
}
});
}
-93
View File
@@ -1,93 +0,0 @@
use std::io::{self, ErrorKind, Read, Write};
use std::net::TcpStream;
use std::sync::mpsc::{self, Receiver};
use std::thread;
use unshell::protocol::FrameBytes;
use unshell::protocol::tree::EndpointOutcome;
pub const LISTEN_ADDR: &str = "127.0.0.1:4444";
const MAX_FRAME_BYTES: usize = 1024 * 1024;
#[allow(dead_code)]
pub fn send_forward(stream: &mut TcpStream, outcome: EndpointOutcome) -> io::Result<()> {
match outcome {
EndpointOutcome::Forward { frame, .. } => write_frames(stream, &[frame]),
EndpointOutcome::Local(_) | EndpointOutcome::Dropped => write_frames(stream, &[]),
}
}
pub fn write_frames(stream: &mut TcpStream, frames: &[FrameBytes]) -> io::Result<()> {
for frame in frames {
let frame_len = u32::try_from(frame.len()).map_err(|_| {
io::Error::new(ErrorKind::InvalidData, "frame exceeds u32 transport size")
})?;
stream.write_all(&frame_len.to_be_bytes())?;
stream.write_all(frame)?;
}
stream.flush()?;
Ok(())
}
pub fn spawn_frame_reader(mut stream: TcpStream) -> Receiver<io::Result<FrameBytes>> {
let (tx, rx) = mpsc::sync_channel(64);
thread::spawn(move || {
loop {
match read_frame(&mut stream) {
Ok(Some(frame)) => {
if tx.send(Ok(frame)).is_err() {
break;
}
}
Ok(None) => break,
Err(error) => {
let _ = tx.send(Err(error));
break;
}
}
}
});
rx
}
fn read_frame(stream: &mut TcpStream) -> io::Result<Option<FrameBytes>> {
let Some(len_bytes) = read_prefix(stream)? else {
return Ok(None);
};
let frame_len = u32::from_be_bytes(len_bytes) as usize;
if frame_len > MAX_FRAME_BYTES {
return Err(io::Error::new(
ErrorKind::InvalidData,
"frame exceeds remote shell example transport limit",
));
}
let mut bytes = vec![0u8; frame_len];
match stream.read_exact(&mut bytes) {
Ok(()) => {}
Err(error) => return Err(error),
}
let mut frame = FrameBytes::with_capacity(bytes.len());
frame.extend_from_slice(&bytes);
Ok(Some(frame))
}
fn read_prefix(stream: &mut TcpStream) -> io::Result<Option<[u8; 4]>> {
let mut len_bytes = [0u8; 4];
let mut filled = 0usize;
while filled < len_bytes.len() {
match stream.read(&mut len_bytes[filled..]) {
Ok(0) if filled == 0 => return Ok(None),
Ok(0) => return Err(io::Error::from(ErrorKind::UnexpectedEof)),
Ok(read_len) => filled += read_len,
Err(error) if error.kind() == ErrorKind::Interrupted => {}
Err(error) => return Err(error),
}
}
Ok(Some(len_bytes))
}
+4 -1
View File
@@ -18,7 +18,10 @@ pub extern crate alloc;
extern crate self as unshell;
pub mod logger;
pub mod protocol;
/// Re-export the protocol crate behind the historical `unshell::protocol` path so
/// proc-macro output and downstream code do not need a second migration.
pub use unshell_protocol as protocol;
pub use unshell_macros::{Leaf, Procedure, procedures};
-188
View File
@@ -1,188 +0,0 @@
# Protocol Change Pressure
This document records protocol-spec changes that are worth considering after the
runtime rewrite in `src/protocol`.
The current rewrite intentionally keeps the existing wire model from
`/home/astatin3/Documents/GitHub/unshell/PROTOCOL.md` wherever possible. The main
goal was to remove avoidable runtime work without silently drifting the protocol.
The implementation now does the following:
- compiles child routing prefixes once instead of scanning child paths on every packet
- routes from the header first, then decodes payloads only on local delivery
- keeps pending hook state minimal and active hook state directly indexed
- separates local typed send paths from framed transport-facing send paths
Those are implementation changes. They do not require a protocol update.
## Implemented Deviation
The current scratch rewrite **does** deviate from the frame format described in
`PROTOCOL.md` Section 8.
The old format used one `u32` length prefix immediately before each archived
section. The new implementation uses one aligned two-section frame:
- `u32 header_len`
- `u32 payload_len`
- aligned archived header bytes
- aligned archived payload bytes
The payload start is padded up to the canonical archive alignment boundary.
This deviation was made explicitly because the prior layout baked in alignment
repair complexity and extra decode copies even in an otherwise clean runtime.
## No Immediate Semantic Change Required
Aside from the framing change above, the current runtime rewrite does **not**
require a semantic protocol break.
The following parts of `PROTOCOL.md` remain worth keeping as-is:
- path-based routing remains the canonical behavior
- pending call context remains distinct from active hook state
- `Fault` remains upstream-only
- unknown or expired `hook_id` still drops returned traffic
- hook closure still requires both sides to send `end_hook = true`, or one `Fault`
Those rules keep the protocol boring and interoperable.
## Change 1: Framing That Guarantees Archive Alignment
### Current problem
`PROTOCOL.md` Section 8 fixes a framed format with a 4-byte big-endian length
prefix before each archived section.
That is simple, but it has one hard performance downside in the current Rust
implementation:
- the start of the archived section is not guaranteed to satisfy `rkyv` alignment
- the decoder therefore has to copy header bytes into an `AlignedVec` before safe access
- local payload decode also copies the payload bytes into another `AlignedVec`
This means the runtime still performs unavoidable memory copies during decode even
after the architectural cleanup.
### Recommended protocol change
Revise the framing rules so each archived section begins at a guaranteed aligned
offset.
Two viable options:
1. Add explicit padding after each length field so the archived section begins at
the required alignment boundary.
2. Replace the current two-section frame with one canonical aligned envelope type
whose internal layout already satisfies the archive alignment rules.
### Why this is objectively better
- removes the forced alignment-copy step on decode
- makes zero-copy or near-zero-copy archived access actually achievable
- reduces local delivery latency for all packet types
- reduces transient allocation pressure in the decoder
### Tradeoff
This is a wire-format change. Every compliant implementation would need to adopt
the new framing.
### Status
Implemented in the current rewrite.
## Change 2: Compact Path Representation for a Future v2
### Current problem
`PROTOCOL.md` Sections 5, 6, 10, 11, and 13 make paths canonical on the wire as
`Vec<String>` values.
That is easy to understand and debug, but it imposes real cost:
- path routing requires segment-wise string comparison
- hook state keys carry owned path vectors
- packets repeat full path strings over and over
- the runtime must repeatedly compare or clone path structures at boundaries
The new implementation minimizes those costs internally, but it cannot eliminate
them while the wire format remains path-string based.
### Recommended protocol change
For a future protocol version, consider separating:
- the canonical human-readable control/discovery layer
- the compact transport/runtime layer
The compact transport/runtime layer would use stable numeric endpoint IDs instead
of repeated `Vec<String>` path payloads.
### Why this is objectively better
- routing becomes integer-based instead of string-prefix based
- hook keys become compact and cheap to index
- packets shrink
- path comparisons and many path clones disappear from the hot path
### Tradeoff
This is a full protocol-versioning decision, not a local cleanup.
It adds coordination costs:
- peers must agree on endpoint IDs
- topology updates become more structured
- the protocol becomes less self-describing on the wire
### Recommendation
Do **not** make this change as a silent update to the current protocol.
If pursued, it should be introduced explicitly as a `v2` protocol, because it is
no longer behaviorally equivalent to the current path-based wire model.
## Change 3: Clarify Caller-Side Hook Activation Semantics
### Current problem
`PROTOCOL.md` Section 13 is explicit about callee-side pending call context, but
it leaves more room for interpretation on the caller side after a `Call` is sent.
The current runtime keeps caller-side hook state available immediately after send
so it can validate returned traffic efficiently.
That is practical, but the spec could be clearer about whether the caller's local
hook record is considered active immediately, or merely reserved until the callee
accepts.
### Recommended protocol change
Clarify caller-side wording in Section 13 so implementations know whether the
caller may allocate directly into active host state after sending a `Call`, as
long as early returned `Data` for an actually inactive hook is still discarded per
Section 14.1.
### Why this is objectively better
- removes ambiguity for optimized runtimes
- makes caller-side hook bookkeeping more consistent across implementations
- avoids accidental spec drift through inference
### Tradeoff
This is a clarification change, not necessarily a wire-format change.
## Summary
The runtime rewrite shows that most of the original performance problems were
architectural, not inherent to the protocol.
The current protocol can support a much lower-loop implementation than before.
The main remaining protocol-level blocker is the framing/alignment rule. That is
the one change most worth making if the next goal is to reduce unavoidable memory
copies further.
-516
View File
@@ -1,516 +0,0 @@
//! Framed packet encoding and decoding.
use core::{fmt, mem};
use rkyv::{
Serialize, access, api::high::to_bytes_in, deserialize, rancor::Error, util::AlignedVec,
};
use super::types::{
ArchivedCallMessage, ArchivedDataMessage, ArchivedFaultMessage, ArchivedPacketHeader,
};
use crate::protocol::{CallMessage, DataMessage, FaultMessage, PacketHeader, PacketType};
/// Archived-section alignment guaranteed by the frame format.
///
/// The protocol aligns both archived sections so `rkyv` can usually validate and deserialize
/// them without first copying into a temporary aligned buffer.
///
/// # Example
/// ```rust
/// use unshell::protocol::SECTION_ALIGN;
/// assert_eq!(SECTION_ALIGN, 16);
/// ```
pub const SECTION_ALIGN: usize = 16;
/// Owned framed packet bytes.
///
/// This is the concrete buffer type returned by [`encode_packet`]. It keeps archived packet bytes
/// aligned according to [`SECTION_ALIGN`] so decode can often stay zero-copy.
///
/// # Example
/// ```rust
/// use unshell::protocol::{CallMessage, FrameBytes, PacketHeader, PacketType, encode_packet};
/// let header = PacketHeader {
/// packet_type: PacketType::Call,
/// src_path: vec!["root".into()],
/// dst_path: vec!["root".into(), "worker".into()],
/// dst_leaf: Some("service".into()),
/// hook_id: None,
/// };
/// let message = CallMessage {
/// procedure_id: "example.service.v1.invoke".into(),
/// data: vec![],
/// response_hook: None,
/// };
/// let frame: FrameBytes = encode_packet(&header, &message)?;
/// assert!(!frame.is_empty());
/// # Ok::<(), unshell::protocol::FrameError>(())
/// ```
pub type FrameBytes = AlignedVec<SECTION_ALIGN>;
/// Framing or archive failure.
#[derive(Debug)]
pub enum FrameError {
/// The byte slice ended before a full frame could be decoded.
Truncated,
/// The archived header bytes failed validation or deserialization.
InvalidHeader(Error),
/// The archived payload bytes failed validation or deserialization.
InvalidPayload(Error),
/// Serializing one header or payload section failed.
Serialize(Error),
/// One archived section grew beyond the `u32` length prefix supported by the format.
LengthOverflow,
}
impl fmt::Display for FrameError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Truncated => f.write_str("truncated frame"),
Self::InvalidHeader(error) => write!(f, "invalid archived header: {error}"),
Self::InvalidPayload(error) => write!(f, "invalid archived payload: {error}"),
Self::Serialize(error) => write!(f, "serialization failed: {error}"),
Self::LengthOverflow => f.write_str("framed section exceeds u32 length"),
}
}
}
impl core::error::Error for FrameError {}
/// Parsed frame with one owned header and a borrowed payload section.
///
/// The frame decoder eagerly materializes the routing header into owned Rust values, but keeps
/// the payload section borrowed so callers can choose which concrete payload type to decode.
///
/// # Example
/// ```rust
/// use unshell::protocol::{CallMessage, PacketHeader, PacketType, decode_frame, encode_packet};
/// let header = PacketHeader {
/// packet_type: PacketType::Call,
/// src_path: vec!["root".into()],
/// dst_path: vec!["root".into(), "worker".into()],
/// dst_leaf: Some("service".into()),
/// hook_id: None,
/// };
/// let message = CallMessage {
/// procedure_id: "example.service.v1.invoke".into(),
/// data: vec![7; 4],
/// response_hook: None,
/// };
/// let frame = encode_packet(&header, &message)?;
/// let parsed = decode_frame(&frame)?;
/// assert_eq!(parsed.packet_type(), PacketType::Call);
/// let decoded = parsed.deserialize_call()?;
/// assert_eq!(decoded.data.len(), 4);
/// # Ok::<(), unshell::protocol::FrameError>(())
/// ```
pub struct ParsedFrame<'a> {
header: PacketHeader,
payload_bytes: &'a [u8],
}
impl<'a> ParsedFrame<'a> {
#[must_use]
/// Returns the decoded packet header.
///
/// This exists so callers can inspect routing metadata before deciding which payload schema
/// to decode.
///
/// # Example
/// ```rust
/// use unshell::protocol::{CallMessage, PacketHeader, PacketType, decode_frame, encode_packet};
/// let header = PacketHeader {
/// packet_type: PacketType::Call,
/// src_path: vec!["root".into()],
/// dst_path: vec!["worker".into()],
/// dst_leaf: None,
/// hook_id: None,
/// };
/// let frame = encode_packet(&header, &CallMessage {
/// procedure_id: "example.invoke".into(),
/// data: vec![],
/// response_hook: None,
/// })?;
/// let parsed = decode_frame(&frame)?;
/// assert_eq!(parsed.header().packet_type, PacketType::Call);
/// # Ok::<(), unshell::protocol::FrameError>(())
/// ```
pub fn header(&self) -> &PacketHeader {
&self.header
}
#[must_use]
/// Returns the packet class from the decoded header.
///
/// This exists as a cheap dispatch helper so callers do not have to reach into the header
/// struct directly when branching on payload type.
///
/// # Example
/// ```rust
/// use unshell::protocol::{CallMessage, PacketHeader, PacketType, decode_frame, encode_packet};
/// let header = PacketHeader {
/// packet_type: PacketType::Call,
/// src_path: vec!["root".into()],
/// dst_path: vec!["worker".into()],
/// dst_leaf: None,
/// hook_id: None,
/// };
/// let frame = encode_packet(&header, &CallMessage {
/// procedure_id: "example.invoke".into(),
/// data: vec![],
/// response_hook: None,
/// })?;
/// let parsed = decode_frame(&frame)?;
/// assert!(matches!(parsed.packet_type(), PacketType::Call));
/// # Ok::<(), unshell::protocol::FrameError>(())
/// ```
pub fn packet_type(&self) -> PacketType {
self.header.packet_type
}
#[must_use]
/// Returns the borrowed payload section bytes.
///
/// This exists for callers that embed their own archived application payloads inside protocol
/// `data` fields and want to defer typed decoding.
///
/// # Example
/// ```rust
/// use unshell::protocol::{CallMessage, PacketHeader, PacketType, decode_frame, encode_packet};
/// let header = PacketHeader {
/// packet_type: PacketType::Call,
/// src_path: vec!["root".into()],
/// dst_path: vec!["worker".into()],
/// dst_leaf: None,
/// hook_id: None,
/// };
/// let frame = encode_packet(&header, &CallMessage {
/// procedure_id: "example.invoke".into(),
/// data: vec![1, 2, 3],
/// response_hook: None,
/// })?;
/// let parsed = decode_frame(&frame)?;
/// assert!(!parsed.payload_bytes().is_empty());
/// # Ok::<(), unshell::protocol::FrameError>(())
/// ```
pub fn payload_bytes(&self) -> &'a [u8] {
self.payload_bytes
}
#[must_use]
/// Splits the parsed frame into its owned header and borrowed payload bytes.
///
/// This exists when callers want to take ownership of the decoded header while still choosing
/// how and when to interpret the payload bytes.
///
/// # Example
/// ```rust
/// use unshell::protocol::{CallMessage, PacketHeader, PacketType, decode_frame, encode_packet};
/// let header = PacketHeader {
/// packet_type: PacketType::Call,
/// src_path: vec!["root".into()],
/// dst_path: vec!["worker".into()],
/// dst_leaf: None,
/// hook_id: None,
/// };
/// let frame = encode_packet(&header, &CallMessage {
/// procedure_id: "example.invoke".into(),
/// data: vec![],
/// response_hook: None,
/// })?;
/// let parsed = decode_frame(&frame)?;
/// let (owned_header, payload) = parsed.into_parts();
/// assert_eq!(owned_header.packet_type, PacketType::Call);
/// assert!(!payload.is_empty());
/// # Ok::<(), unshell::protocol::FrameError>(())
/// ```
pub fn into_parts(self) -> (PacketHeader, &'a [u8]) {
(self.header, self.payload_bytes)
}
/// Deserializes the payload section as a [`CallMessage`].
///
/// This exists so callers can decode a validated `Call` packet payload without spelling the
/// archived-type details themselves.
///
/// # Example
/// ```rust
/// use unshell::protocol::{CallMessage, PacketHeader, PacketType, decode_frame, encode_packet};
/// let message = CallMessage {
/// procedure_id: "example.invoke".into(),
/// data: vec![1],
/// response_hook: None,
/// };
/// let frame = encode_packet(&PacketHeader {
/// packet_type: PacketType::Call,
/// src_path: vec!["root".into()],
/// dst_path: vec!["worker".into()],
/// dst_leaf: None,
/// hook_id: None,
/// }, &message)?;
/// let parsed = decode_frame(&frame)?;
/// assert_eq!(parsed.deserialize_call()?.procedure_id, message.procedure_id);
/// # Ok::<(), unshell::protocol::FrameError>(())
/// ```
pub fn deserialize_call(&self) -> Result<CallMessage, FrameError> {
self.deserialize_payload::<ArchivedCallMessage, CallMessage>()
}
/// Deserializes the payload section as a [`DataMessage`].
///
/// This exists so callers can decode hook `Data` payloads without reaching for the generic
/// archived helper directly.
///
/// # Example
/// ```rust
/// use unshell::protocol::{DataMessage, PacketHeader, PacketType, decode_frame, encode_packet};
/// let message = DataMessage {
/// procedure_id: "example.invoke".into(),
/// data: vec![1],
/// end_hook: false,
/// };
/// let frame = encode_packet(&PacketHeader {
/// packet_type: PacketType::Data,
/// src_path: vec!["worker".into()],
/// dst_path: vec!["root".into()],
/// dst_leaf: None,
/// hook_id: Some(7),
/// }, &message)?;
/// let parsed = decode_frame(&frame)?;
/// assert!(!parsed.deserialize_data()?.end_hook);
/// # Ok::<(), unshell::protocol::FrameError>(())
/// ```
pub fn deserialize_data(&self) -> Result<DataMessage, FrameError> {
self.deserialize_payload::<ArchivedDataMessage, DataMessage>()
}
/// Deserializes the payload section as a [`FaultMessage`].
///
/// This exists so callers can decode protocol faults with the same selective API used for
/// call and data packets.
///
/// # Example
/// ```rust
/// use unshell::protocol::{FaultMessage, PacketHeader, PacketType, ProtocolFault, decode_frame, encode_packet};
/// let frame = encode_packet(&PacketHeader {
/// packet_type: PacketType::Fault,
/// src_path: vec!["worker".into()],
/// dst_path: vec!["root".into()],
/// dst_leaf: None,
/// hook_id: Some(7),
/// }, &FaultMessage { fault: ProtocolFault::INTERNAL_ERROR })?;
/// let parsed = decode_frame(&frame)?;
/// assert_eq!(parsed.deserialize_fault()?.fault, ProtocolFault::INTERNAL_ERROR);
/// # Ok::<(), unshell::protocol::FrameError>(())
/// ```
pub fn deserialize_fault(&self) -> Result<FaultMessage, FrameError> {
self.deserialize_payload::<ArchivedFaultMessage, FaultMessage>()
}
fn deserialize_payload<A, T>(&self) -> Result<T, FrameError>
where
A: rkyv::Portable
+ for<'b> rkyv::bytecheck::CheckBytes<rkyv::api::high::HighValidator<'b, Error>>,
T: rkyv::Archive,
A: rkyv::Deserialize<T, rkyv::api::high::HighDeserializer<Error>>,
{
deserialize_archived_bytes::<A, T>(self.payload_bytes)
}
}
/// Encodes a packet header and payload using the aligned two-section frame format.
///
/// The frame starts with two big-endian `u32` lengths, followed by an aligned archived header
/// section and an aligned archived payload section. Both sections use [`SECTION_ALIGN`] so the
/// archived bytes can usually be accessed without a fallback copy on decode.
///
/// # Example
/// ```rust
/// use unshell::protocol::{CallMessage, PacketHeader, PacketType, encode_packet};
/// let frame = encode_packet(
/// &PacketHeader {
/// packet_type: PacketType::Call,
/// src_path: vec!["root".into()],
/// dst_path: vec!["worker".into()],
/// dst_leaf: Some("service".into()),
/// hook_id: None,
/// },
/// &CallMessage {
/// procedure_id: "example.invoke".into(),
/// data: vec![1, 2, 3],
/// response_hook: None,
/// },
/// )?;
/// assert!(frame.len() >= 8);
/// # Ok::<(), unshell::protocol::FrameError>(())
/// ```
pub fn encode_packet<P>(header: &PacketHeader, payload: &P) -> Result<FrameBytes, FrameError>
where
P: for<'a> Serialize<
rkyv::api::high::HighSerializer<AlignedVec, rkyv::ser::allocator::ArenaHandle<'a>, Error>,
>,
{
let header_start = align_up(8usize, SECTION_ALIGN);
// Reserve enough space for the framing prefix plus a typical header/payload pair so the
// common encode path avoids early growth reallocations inside `to_bytes_in`.
let mut frame = FrameBytes::with_capacity(header_start + 256);
frame.resize(header_start, 0);
frame = to_bytes_in::<_, Error>(header, frame).map_err(FrameError::Serialize)?;
let header_len =
u32::try_from(frame.len() - header_start).map_err(|_| FrameError::LengthOverflow)?;
let payload_start = align_up(frame.len(), SECTION_ALIGN);
frame.resize(payload_start, 0);
frame = to_bytes_in::<_, Error>(payload, frame).map_err(FrameError::Serialize)?;
let payload_len =
u32::try_from(frame.len() - payload_start).map_err(|_| FrameError::LengthOverflow)?;
frame[0..4].copy_from_slice(&header_len.to_be_bytes());
frame[4..8].copy_from_slice(&payload_len.to_be_bytes());
Ok(frame)
}
/// Decodes one aligned two-section frame.
///
/// This rejects trailing bytes instead of silently ignoring them, so callers can treat one byte
/// slice as exactly one protocol frame.
///
/// # Example
/// ```rust
/// use unshell::protocol::{CallMessage, PacketHeader, PacketType, decode_frame, encode_packet};
/// let frame = encode_packet(
/// &PacketHeader {
/// packet_type: PacketType::Call,
/// src_path: vec!["root".into()],
/// dst_path: vec!["worker".into()],
/// dst_leaf: Some("service".into()),
/// hook_id: None,
/// },
/// &CallMessage {
/// procedure_id: "example.invoke".into(),
/// data: vec![1, 2, 3],
/// response_hook: None,
/// },
/// )?;
/// let parsed = decode_frame(&frame)?;
/// assert_eq!(parsed.packet_type(), PacketType::Call);
/// # Ok::<(), unshell::protocol::FrameError>(())
/// ```
pub fn decode_frame(bytes: &[u8]) -> Result<ParsedFrame<'_>, FrameError> {
let (header_bytes, payload_bytes) = split_frame_sections(bytes)?;
let header = deserialize_section::<ArchivedPacketHeader, PacketHeader>(
header_bytes,
FrameError::InvalidHeader,
)?;
Ok(ParsedFrame {
header,
payload_bytes,
})
}
/// Deserializes one archived byte section.
///
/// Payload bytes normally come from [`decode_frame`] or one of [`ParsedFrame`]`'s`
/// `deserialize_*` helpers. This function remains public for callers that archive nested
/// application payloads inside protocol `data` fields.
///
/// # Example
/// ```rust
/// use rkyv::{Archive, Deserialize, Serialize};
/// use unshell::protocol::deserialize_archived_bytes;
///
/// #[derive(Archive, Serialize, Deserialize, Debug, PartialEq)]
/// struct Example {
/// value: u32,
/// }
///
/// let bytes = rkyv::to_bytes::<rkyv::rancor::Error>(&Example { value: 7 }).unwrap();
/// let decoded = deserialize_archived_bytes::<<Example as Archive>::Archived, Example>(&bytes)?;
/// assert_eq!(decoded, Example { value: 7 });
/// # Ok::<(), unshell::protocol::FrameError>(())
/// ```
pub fn deserialize_archived_bytes<A, T>(bytes: &[u8]) -> Result<T, FrameError>
where
A: rkyv::Portable
+ for<'b> rkyv::bytecheck::CheckBytes<rkyv::api::high::HighValidator<'b, Error>>,
T: rkyv::Archive,
A: rkyv::Deserialize<T, rkyv::api::high::HighDeserializer<Error>>,
{
deserialize_section::<A, T>(bytes, FrameError::InvalidPayload)
}
fn read_u32(bytes: &[u8], start: usize) -> Result<u32, FrameError> {
let end = start + 4;
Ok(u32::from_be_bytes(
bytes
.get(start..end)
.ok_or(FrameError::Truncated)?
.try_into()
.expect("slice width checked"),
))
}
fn split_frame_sections(bytes: &[u8]) -> Result<(&[u8], &[u8]), FrameError> {
if bytes.len() < 8 {
return Err(FrameError::Truncated);
}
let header_len = read_u32(bytes, 0)? as usize;
let payload_len = read_u32(bytes, 4)? as usize;
let header_start = align_up(8usize, SECTION_ALIGN);
let header_end = header_start + header_len;
if header_end > bytes.len() {
return Err(FrameError::Truncated);
}
let payload_start = align_up(header_end, SECTION_ALIGN);
let payload_end = payload_start + payload_len;
if payload_end != bytes.len() {
// Framed packets do not permit trailing bytes. Treating the slice as exactly one frame
// keeps stream framing bugs visible instead of silently accepting concatenated payloads.
return Err(FrameError::Truncated);
}
Ok((
bytes
.get(header_start..header_end)
.ok_or(FrameError::Truncated)?,
bytes
.get(payload_start..payload_end)
.ok_or(FrameError::Truncated)?,
))
}
fn align_up(offset: usize, alignment: usize) -> usize {
let mask = alignment - 1;
(offset + mask) & !mask
}
fn deserialize_section<A, T>(
bytes: &[u8],
invalid: fn(Error) -> FrameError,
) -> Result<T, FrameError>
where
A: rkyv::Portable
+ for<'b> rkyv::bytecheck::CheckBytes<rkyv::api::high::HighValidator<'b, Error>>,
T: rkyv::Archive,
A: rkyv::Deserialize<T, rkyv::api::high::HighDeserializer<Error>>,
{
if is_aligned_for::<A>(bytes) {
let archived = access::<A, Error>(bytes).map_err(invalid)?;
return deserialize::<T, Error>(archived).map_err(invalid);
}
// Archived types may require stronger alignment than a borrowed byte slice can guarantee.
// Copy into an aligned buffer so callers can still decode valid frames from arbitrary input
// sources instead of rejecting them purely for allocation layout reasons.
let mut aligned: FrameBytes = FrameBytes::with_capacity(bytes.len());
aligned.extend_from_slice(bytes);
let archived = access::<A, Error>(&aligned).map_err(invalid)?;
deserialize::<T, Error>(archived).map_err(invalid)
}
fn is_aligned_for<A>(bytes: &[u8]) -> bool {
let alignment = mem::align_of::<A>();
alignment <= 1 || (bytes.as_ptr() as usize).is_multiple_of(alignment)
}
-98
View File
@@ -1,98 +0,0 @@
//! Required introspection payloads for discovery.
//!
//! These types define the reserved discovery subsystem of the protocol. Endpoints use the
//! reserved empty-string procedure id to request either endpoint-wide discovery or one leaf's
//! exact procedure inventory.
//!
//! # Example
//! ```rust
//! use unshell::protocol::{EndpointIntrospection, INTROSPECTION_PROCEDURE_ID};
//! let payload = EndpointIntrospection {
//! sub_endpoints: vec!["worker".into()],
//! leaves: vec![],
//! };
//! assert_eq!(INTROSPECTION_PROCEDURE_ID, "");
//! assert_eq!(payload.sub_endpoints[0], "worker");
//! ```
use alloc::{string::String, vec::Vec};
use rkyv::{Archive, Deserialize, Serialize};
/// Reserved procedure id for protocol introspection.
///
/// The protocol uses the empty string here so discovery traffic stays outside the normal
/// application procedure namespace. [`crate::protocol::validate_procedure_id`] reserves that
/// value exclusively for introspection.
///
/// # Example
/// ```rust
/// use unshell::protocol::INTROSPECTION_PROCEDURE_ID;
/// assert!(INTROSPECTION_PROCEDURE_ID.is_empty());
/// ```
pub const INTROSPECTION_PROCEDURE_ID: &str = "";
/// Endpoint-wide introspection payload.
///
/// This is returned when discovery targets an endpoint path without selecting one specific leaf.
/// It exists so clients can enumerate direct child endpoints and the leaves hosted locally.
///
/// # Example
/// ```rust
/// use unshell::protocol::EndpointIntrospection;
/// let payload = EndpointIntrospection {
/// sub_endpoints: vec!["worker".into()],
/// leaves: vec![],
/// };
/// assert_eq!(payload.sub_endpoints.len(), 1);
/// ```
#[derive(Archive, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct EndpointIntrospection {
/// Direct child endpoint segment names hosted immediately below this endpoint.
pub sub_endpoints: Vec<String>,
/// Leaf summaries hosted directly at this endpoint.
pub leaves: Vec<LeafIntrospectionSummary>,
}
/// Shared per-leaf discovery record.
///
/// This compact shape exists so endpoint-wide discovery can advertise each hosted leaf without
/// sending the full endpoint envelope again.
///
/// # Example
/// ```rust
/// use unshell::protocol::LeafIntrospectionSummary;
/// let summary = LeafIntrospectionSummary {
/// leaf_name: "org.example.v1.echo".into(),
/// procedures: vec!["org.example.v1.echo.invoke".into()],
/// };
/// assert_eq!(summary.procedures.len(), 1);
/// ```
#[derive(Archive, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct LeafIntrospectionSummary {
/// Canonical dotted leaf identifier.
pub leaf_name: String,
/// Exhaustive canonical procedure ids currently exposed by the leaf.
pub procedures: Vec<String>,
}
/// Leaf-specific introspection payload.
///
/// This duplicates [`LeafIntrospectionSummary`] intentionally because the leaf-only response is
/// a distinct wire payload from the endpoint-wide discovery response.
///
/// # Example
/// ```rust
/// use unshell::protocol::LeafIntrospection;
/// let payload = LeafIntrospection {
/// leaf_name: "org.example.v1.echo".into(),
/// procedures: vec!["org.example.v1.echo.invoke".into()],
/// };
/// assert_eq!(payload.leaf_name, "org.example.v1.echo");
/// ```
#[derive(Archive, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct LeafIntrospection {
/// Canonical dotted leaf identifier.
pub leaf_name: String,
/// Exhaustive canonical procedure ids currently exposed by the leaf.
pub procedures: Vec<String>,
}
-62
View File
@@ -1,62 +0,0 @@
//! Canonical UnShell protocol surface.
//!
//! This module is the stable facade for wire-level protocol types, framing, and
//! stateless validation helpers. Callers normally:
//! - build one [`PacketHeader`] plus payload type from this module,
//! - encode it with [`encode_packet`],
//! - decode inbound bytes with [`decode_frame`], and
//! - validate message/header shape with [`validate_header`], [`validate_call`], and
//! [`validate_procedure_id`].
//!
//! The concrete wire structs live in the private `types` module and are re-exported here so the
//! public API stays flat while internal archived-type details remain hidden.
//!
//! # Example
//! ```rust
//! use unshell::protocol::{
//! CallMessage, PacketHeader, PacketType, decode_frame, encode_packet, validate_call,
//! validate_header,
//! };
//!
//! let header = PacketHeader {
//! packet_type: PacketType::Call,
//! src_path: vec!["root".into()],
//! dst_path: vec!["root".into(), "worker".into()],
//! dst_leaf: Some("service".into()),
//! hook_id: None,
//! };
//! let call = CallMessage {
//! procedure_id: "example.service.v1.invoke".into(),
//! data: vec![1, 2, 3],
//! response_hook: None,
//! };
//!
//! validate_header(&header).unwrap();
//! validate_call(&header, &call).unwrap();
//! let frame = encode_packet(&header, &call)?;
//! let parsed = decode_frame(&frame)?;
//! let decoded = parsed.deserialize_call()?;
//! assert_eq!(decoded.procedure_id, call.procedure_id);
//! # Ok::<(), unshell::protocol::FrameError>(())
//! ```
pub mod codec;
pub mod introspection;
pub mod tree;
mod types;
pub mod validation;
#[cfg(test)]
mod tests;
pub use codec::{
FrameBytes, FrameError, ParsedFrame, SECTION_ALIGN, decode_frame, deserialize_archived_bytes,
encode_packet,
};
pub use introspection::{
EndpointIntrospection, INTROSPECTION_PROCEDURE_ID, LeafIntrospection, LeafIntrospectionSummary,
};
pub use types::{
CallMessage, DataMessage, FaultMessage, HookTarget, PacketHeader, PacketType, ProtocolFault,
};
pub use validation::{ValidationError, validate_call, validate_header, validate_procedure_id};
-106
View File
@@ -1,106 +0,0 @@
use alloc::{borrow::ToOwned, format, string::String, vec, vec::Vec};
use core::convert::Infallible;
use rkyv::{Archive, Deserialize, Serialize};
use crate::protocol::tree::{
Call, CallLeaf, ChildRoute, EndpointOutcome, Ingress, LeafRuntime, ProtocolEndpoint,
decode_call_input, encode_call_reply,
};
use crate::protocol::{PacketType, decode_frame};
use crate::{Leaf, procedures};
fn path(parts: &[&str]) -> Vec<String> {
parts.iter().map(|part| (*part).to_owned()).collect()
}
#[derive(Leaf)]
#[leaf(id = "org.example.v1.echo")]
struct EchoLeaf {
prefix: String,
}
#[derive(Archive, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
struct EchoRequest {
text: String,
}
#[derive(Archive, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
struct EchoResponse {
text: String,
}
#[procedures(error = Infallible)]
impl EchoLeaf {
#[call]
fn echo(&mut self, request: Call<EchoRequest>) -> EchoResponse {
EchoResponse {
text: format!("{}{}", self.prefix, request.input.text),
}
}
}
impl CallLeaf for EchoLeaf {
type Error = Infallible;
}
#[test]
fn leaf_runtime_dispatches_generated_call_procedure() {
let endpoint = ProtocolEndpoint::new(
path(&["agent"]),
Some(Vec::new()),
Vec::new(),
vec![EchoLeaf::protocol_leaf_spec()],
);
let mut runtime = LeafRuntime::new(
endpoint,
EchoLeaf {
prefix: String::from("echo: "),
},
);
let mut controller = ProtocolEndpoint::new(
Vec::new(),
None,
vec![ChildRoute {
path: path(&["agent"]),
registered: true,
}],
Vec::new(),
);
let hook_id = controller.allocate_hook_id();
let controller_outcome = controller
.send_call(
path(&["agent"]),
Some(EchoLeaf::protocol_leaf_name()),
EchoLeaf::protocol_procedure_id("echo").expect("generated suffix should resolve"),
Some(hook_id),
encode_call_reply(&EchoRequest {
text: String::from("hello"),
})
.expect("request should encode"),
)
.expect("call should encode");
let EndpointOutcome::Forward { frame, .. } = controller_outcome else {
panic!("controller should forward call to child");
};
let outcome = runtime
.receive(&Ingress::Parent, frame)
.expect("runtime should handle call");
let [response_frame] = outcome.frames.as_slice() else {
panic!("expected one response frame");
};
let parsed = decode_frame(response_frame.as_slice()).expect("response frame should decode");
assert_eq!(parsed.packet_type(), PacketType::Data);
let response = decode_call_input::<EchoResponse>(
parsed
.deserialize_data()
.expect("data payload should deserialize")
.data
.as_slice(),
)
.expect("typed response should decode");
assert_eq!(response.text, "echo: hello");
}
-4
View File
@@ -1,4 +0,0 @@
mod call;
mod procedure;
mod protocol;
mod tree;
-280
View File
@@ -1,280 +0,0 @@
use alloc::{borrow::ToOwned, collections::BTreeMap, format, string::String, vec, vec::Vec};
use core::convert::Infallible;
use crate::protocol::tree::{
Call, ChildRoute, Endpoint, EndpointOutcome, HookKey, Ingress, OutgoingData, Procedure,
ProcedureEffect, ProcedureRuntime, ProcedureStore, ProtocolEndpoint, encode_call_reply,
};
use crate::protocol::{PacketType, decode_frame};
use crate::{Leaf, Procedure};
fn path(parts: &[&str]) -> Vec<String> {
parts.iter().map(|part| (*part).to_owned()).collect()
}
#[derive(Default, Leaf)]
#[leaf(id = "org.example.v1.stream")]
struct StreamLeaf {
sessions: BTreeMap<HookKey, ProcedureOpen>,
}
impl ProcedureStore<ProcedureOpen> for StreamLeaf {
fn procedure_sessions(&mut self) -> &mut BTreeMap<HookKey, ProcedureOpen> {
&mut self.sessions
}
}
#[derive(Debug, Clone, PartialEq, Eq, Procedure)]
#[procedure(leaf = StreamLeaf, name = "open")]
struct ProcedureOpen {
prefix: String,
}
impl Procedure<StreamLeaf> for ProcedureOpen {
type Error = Infallible;
type Input = String;
fn open(_leaf: &mut StreamLeaf, call: Call<Self::Input>) -> Result<Self, Self::Error> {
Ok(Self { prefix: call.input })
}
fn on_data(
_leaf: &mut StreamLeaf,
session: &mut Self,
data: crate::protocol::tree::IncomingData,
) -> Result<ProcedureEffect, Self::Error> {
Ok(ProcedureEffect {
outgoing: vec![OutgoingData {
dst_path: data.hook_key.return_path,
hook_id: data.hook_key.hook_id,
procedure_id: ProcedureOpen::protocol_procedure_id(),
data: format!(
"{}{}",
session.prefix,
String::from_utf8_lossy(&data.message.data)
)
.into_bytes(),
end_hook: data.message.end_hook,
}],
close_session: data.message.end_hook,
})
}
}
#[test]
fn procedure_runtime_routes_data_to_stored_session() {
let endpoint = ProtocolEndpoint::new(
path(&["agent"]),
Some(Vec::new()),
Vec::new(),
vec![crate::protocol::tree::LeafSpec {
name: StreamLeaf::protocol_leaf_name(),
procedures: vec![ProcedureOpen::protocol_procedure_id()],
}],
);
let mut runtime =
ProcedureRuntime::<StreamLeaf, ProcedureOpen>::new(endpoint, StreamLeaf::default());
let mut controller = ProtocolEndpoint::new(
Vec::new(),
None,
vec![ChildRoute {
path: path(&["agent"]),
registered: true,
}],
Vec::new(),
);
let hook_id = controller.allocate_hook_id();
let open = controller
.send_call(
path(&["agent"]),
Some(StreamLeaf::protocol_leaf_name()),
ProcedureOpen::protocol_procedure_id(),
Some(hook_id),
encode_call_reply(&String::from("prefix:")).expect("procedure input should encode"),
)
.expect("open call should encode");
let EndpointOutcome::Forward {
frame: open_frame, ..
} = open
else {
panic!("controller should forward opening call");
};
runtime
.receive(&Ingress::Parent, open_frame)
.expect("runtime should open a session");
let data = controller
.send_data(
path(&["agent"]),
hook_id,
ProcedureOpen::protocol_procedure_id(),
b"hello".to_vec(),
true,
)
.expect("data should encode");
let EndpointOutcome::Forward {
frame: data_frame, ..
} = data
else {
panic!("controller should forward data frame");
};
let outcome = runtime
.receive(&Ingress::Parent, data_frame)
.expect("runtime should route data to session");
let [response_frame] = outcome.frames.as_slice() else {
panic!("expected one response frame");
};
let parsed = decode_frame(response_frame.as_slice()).expect("response frame should decode");
assert_eq!(parsed.packet_type(), PacketType::Data);
let message = parsed.deserialize_data().expect("data should deserialize");
assert!(message.end_hook);
assert_eq!(String::from_utf8_lossy(&message.data), "prefix:hello");
let forwarded = controller
.receive(&Ingress::Child(path(&["agent"])), response_frame.clone())
.expect("controller should receive session response");
assert!(matches!(forwarded, EndpointOutcome::Local(_)));
assert!(runtime.leaf_mut().procedure_sessions().is_empty());
}
#[derive(Default, Leaf)]
#[leaf(id = "org.example.v1.duplex")]
struct DuplexLeaf {
sessions: BTreeMap<HookKey, DuplexProcedure>,
}
impl ProcedureStore<DuplexProcedure> for DuplexLeaf {
fn procedure_sessions(&mut self) -> &mut BTreeMap<HookKey, DuplexProcedure> {
&mut self.sessions
}
}
#[derive(Debug, Clone, PartialEq, Eq, Procedure)]
#[procedure(leaf = DuplexLeaf, name = "open")]
struct DuplexProcedure {
saw_peer_close: bool,
}
impl Procedure<DuplexLeaf> for DuplexProcedure {
type Error = Infallible;
type Input = ();
fn open(_leaf: &mut DuplexLeaf, _call: Call<Self::Input>) -> Result<Self, Self::Error> {
Ok(Self {
saw_peer_close: false,
})
}
fn on_data(
_leaf: &mut DuplexLeaf,
session: &mut Self,
data: crate::protocol::tree::IncomingData,
) -> Result<ProcedureEffect, Self::Error> {
if data.message.data == b"local-end" {
return Ok(ProcedureEffect::outgoing(vec![OutgoingData {
dst_path: data.hook_key.return_path,
hook_id: data.hook_key.hook_id,
procedure_id: DuplexProcedure::protocol_procedure_id(),
data: Vec::new(),
end_hook: true,
}]));
}
if data.message.end_hook {
session.saw_peer_close = true;
return Ok(ProcedureEffect::close(Vec::new()));
}
Ok(ProcedureEffect::default())
}
}
#[test]
fn procedure_runtime_keeps_session_after_local_end_until_explicit_close() {
let endpoint = ProtocolEndpoint::new(
path(&["agent"]),
Some(Vec::new()),
Vec::new(),
vec![crate::protocol::tree::LeafSpec {
name: DuplexLeaf::protocol_leaf_name(),
procedures: vec![DuplexProcedure::protocol_procedure_id()],
}],
);
let mut runtime =
ProcedureRuntime::<DuplexLeaf, DuplexProcedure>::new(endpoint, DuplexLeaf::default());
let mut controller = ProtocolEndpoint::new(
Vec::new(),
None,
vec![ChildRoute {
path: path(&["agent"]),
registered: true,
}],
Vec::new(),
);
let hook_id = controller.allocate_hook_id();
let open = controller
.send_call(
path(&["agent"]),
Some(DuplexLeaf::protocol_leaf_name()),
DuplexProcedure::protocol_procedure_id(),
Some(hook_id),
encode_call_reply(&()).expect("unit call should encode"),
)
.expect("open call should encode");
let EndpointOutcome::Forward {
frame: open_frame, ..
} = open
else {
panic!("controller should forward opening call");
};
runtime
.receive(&Ingress::Parent, open_frame)
.expect("runtime should open duplex session");
let local_end = controller
.send_data(
path(&["agent"]),
hook_id,
DuplexProcedure::protocol_procedure_id(),
b"local-end".to_vec(),
false,
)
.expect("local end trigger should encode");
let EndpointOutcome::Forward {
frame: local_end_frame,
..
} = local_end
else {
panic!("controller should forward local end trigger");
};
let outcome = runtime
.receive(&Ingress::Parent, local_end_frame)
.expect("runtime should emit a local end packet");
assert_eq!(outcome.frames.len(), 1);
assert_eq!(runtime.leaf_mut().procedure_sessions().len(), 1);
let peer_end = encode_call_reply(&()).expect("unit value is just a placeholder");
let peer_end = crate::protocol::encode_packet(
&crate::protocol::PacketHeader {
packet_type: PacketType::Data,
src_path: Vec::new(),
dst_path: path(&["agent"]),
dst_leaf: None,
hook_id: Some(hook_id),
},
&crate::protocol::DataMessage {
procedure_id: DuplexProcedure::protocol_procedure_id(),
data: peer_end,
end_hook: true,
},
)
.expect("peer end frame should encode");
let peer_end_outcome = runtime
.receive(&Ingress::Parent, peer_end)
.expect("runtime should accept peer end after local end");
assert!(peer_end_outcome.frames.is_empty());
assert!(runtime.leaf_mut().procedure_sessions().is_empty());
}
-109
View File
@@ -1,109 +0,0 @@
use alloc::{borrow::ToOwned, string::String, vec, vec::Vec};
use crate::protocol::{
CallMessage, FaultMessage, FrameError, HookTarget, PacketHeader, PacketType, ProtocolFault,
SECTION_ALIGN, ValidationError, decode_frame, encode_packet, validate_call, validate_header,
validate_procedure_id,
};
fn path(parts: &[&str]) -> Vec<String> {
parts.iter().map(|part| (*part).to_owned()).collect()
}
#[test]
fn packet_framing_roundtrip_preserves_header_and_payload() {
let header = PacketHeader {
packet_type: PacketType::Call,
src_path: path(&["root", "caller"]),
dst_path: path(&["root", "callee"]),
dst_leaf: Some("service".to_owned()),
hook_id: None,
};
let call = CallMessage {
procedure_id: "example.service.v1.invoke".to_owned(),
data: vec![1, 2, 3, 4],
response_hook: Some(HookTarget {
hook_id: 7,
return_path: path(&["root", "caller"]),
}),
};
let frame = encode_packet(&header, &call).expect("frame should encode");
assert_eq!(frame.as_ptr() as usize % SECTION_ALIGN, 0);
let parsed = decode_frame(&frame).expect("frame should decode");
assert_eq!(parsed.header(), &header);
assert_eq!(parsed.packet_type(), PacketType::Call);
assert_eq!(
parsed.deserialize_call().expect("call should deserialize"),
call
);
}
#[test]
fn header_and_call_validation_reject_invalid_combinations() {
let invalid_header = PacketHeader {
packet_type: PacketType::Data,
src_path: path(&["peer"]),
dst_path: path(&["host"]),
dst_leaf: Some("service".to_owned()),
hook_id: None,
};
assert_eq!(
validate_header(&invalid_header),
Err(ValidationError::HeaderInvariant(
"Data and Fault packets must not carry dst_leaf"
))
);
let header = PacketHeader {
packet_type: PacketType::Call,
src_path: path(&["caller"]),
dst_path: path(&["callee"]),
dst_leaf: Some("service".to_owned()),
hook_id: None,
};
let invalid_call = CallMessage {
procedure_id: "example.service.v1.invoke".to_owned(),
data: Vec::new(),
response_hook: Some(HookTarget {
hook_id: 5,
return_path: path(&["elsewhere"]),
}),
};
assert_eq!(
validate_call(&header, &invalid_call),
Err(ValidationError::CallInvariant(
"response_hook.return_path must equal header.src_path"
))
);
}
#[test]
fn procedure_validation_accepts_introspection_and_non_empty_opaque_ids() {
assert_eq!(validate_procedure_id(""), Ok(()));
assert_eq!(validate_procedure_id("example.service.v01.invoke"), Ok(()));
assert_eq!(validate_procedure_id("contains spaces"), Ok(()));
}
#[test]
fn truncated_frames_are_rejected() {
let header = PacketHeader {
packet_type: PacketType::Fault,
src_path: path(&["src"]),
dst_path: path(&["dst"]),
dst_leaf: None,
hook_id: Some(9),
};
let message = FaultMessage {
fault: ProtocolFault::INTERNAL_ERROR,
};
let frame = encode_packet(&header, &message).expect("frame should encode");
let truncated = &frame[..frame.len() - 1];
assert!(matches!(
decode_frame(truncated),
Err(FrameError::Truncated)
));
}
-369
View File
@@ -1,369 +0,0 @@
use alloc::{borrow::ToOwned, string::String, vec, vec::Vec};
use crate::protocol::tree::{
ChildRoute, DefaultRouteProvider, Endpoint, EndpointOutcome, Ingress, LeafNode, LeafSpec,
LocalEvent, ProtocolEndpoint, RouteDecision, RouteProvider, TreeNode,
};
use crate::protocol::{
DataMessage, EndpointIntrospection, FaultMessage, PacketHeader, PacketType, ProtocolFault,
deserialize_archived_bytes, encode_packet,
};
fn path(parts: &[&str]) -> Vec<String> {
parts.iter().map(|part| (*part).to_owned()).collect()
}
#[test]
fn tree_node_paths_flatten_explicitly() {
let tree = TreeNode::Root {
children: vec![TreeNode::Endpoint {
segment: "branch".to_owned(),
leaves: vec![LeafNode {
name: "service".to_owned(),
procedures: vec!["example.service.v1.invoke".to_owned()],
}],
children: vec![TreeNode::Endpoint {
segment: "leaf".to_owned(),
leaves: Vec::new(),
children: Vec::new(),
}],
}],
};
assert_eq!(
tree.paths(),
vec![
Vec::<String>::new(),
path(&["branch"]),
path(&["branch", "leaf"])
]
);
}
#[test]
fn longest_prefix_routing_prefers_most_specific_child() {
let provider = DefaultRouteProvider;
let child_paths = vec![path(&["a"]), path(&["a", "b"]), path(&["x"])];
assert_eq!(
provider.route_destination(&Vec::new(), &child_paths, true, &path(&["a", "b", "c"])),
RouteDecision::Child(1)
);
assert_eq!(
provider.route_destination(&path(&["a"]), &child_paths, true, &path(&["z"])),
RouteDecision::Parent
);
}
#[test]
fn protocol_endpoint_introspection_returns_leaf_summary() {
let mut endpoint = ProtocolEndpoint::new(
path(&["root"]),
Some(Vec::new()),
vec![ChildRoute::registered(path(&["root", "child"]))],
vec![LeafSpec {
name: "service".to_owned(),
procedures: vec!["example.service.v1.invoke".to_owned()],
}],
);
let hook_id = endpoint.allocate_hook_id();
let frame = endpoint
.make_call(path(&["root"]), None, "", Some(hook_id), Vec::new())
.expect("introspection call should encode");
let outcome = endpoint
.receive(&Ingress::Local, frame)
.expect("endpoint should handle introspection");
let EndpointOutcome::Local(LocalEvent::Data {
header,
message: response,
..
}) = &outcome
else {
panic!("expected local data event");
};
assert_eq!(header.packet_type, PacketType::Data);
assert_eq!(header.dst_path, path(&["root"]));
let introspection = deserialize_archived_bytes::<
crate::protocol::introspection::ArchivedEndpointIntrospection,
EndpointIntrospection,
>(&response.data)
.expect("introspection payload should deserialize");
assert!(response.end_hook);
assert_eq!(introspection.sub_endpoints, vec!["child".to_owned()]);
assert_eq!(introspection.leaves.len(), 1);
assert_eq!(introspection.leaves[0].leaf_name, "service");
assert_eq!(
introspection.leaves[0].procedures,
vec!["example.service.v1.invoke".to_owned()]
);
}
#[test]
fn invalid_hook_peer_emits_local_fault_event() {
let mut endpoint = ProtocolEndpoint::new(
Vec::new(),
None,
vec![
ChildRoute::registered(path(&["server"])),
ChildRoute::registered(path(&["intruder"])),
],
Vec::new(),
);
let hook_id = endpoint.allocate_hook_id();
endpoint
.make_call(
path(&["server"]),
None,
"example.service.v1.invoke",
Some(hook_id),
vec![1, 2, 3],
)
.expect("call should establish an active hook");
let valid_frame = encode_packet(
&PacketHeader {
packet_type: PacketType::Data,
src_path: path(&["server"]),
dst_path: Vec::new(),
dst_leaf: None,
hook_id: Some(hook_id),
},
&DataMessage {
procedure_id: "example.service.v1.invoke".to_owned(),
data: vec![8],
end_hook: false,
},
)
.expect("valid server data should encode");
endpoint
.receive(&Ingress::Child(path(&["server"])), valid_frame)
.expect("first server data should activate the hook");
let frame = encode_packet(
&PacketHeader {
packet_type: PacketType::Data,
src_path: path(&["intruder"]),
dst_path: Vec::new(),
dst_leaf: None,
hook_id: Some(hook_id),
},
&DataMessage {
procedure_id: "example.service.v1.invoke".to_owned(),
data: vec![9],
end_hook: false,
},
)
.expect("data frame should encode");
let outcome = endpoint
.receive(&Ingress::Child(path(&["intruder"])), frame)
.expect("invalid peer should be handled");
match &outcome {
EndpointOutcome::Local(event) => match event {
LocalEvent::Fault {
header, message, ..
} => {
assert_eq!(header.packet_type, PacketType::Fault);
assert_eq!(header.hook_id, Some(hook_id));
assert_eq!(
message,
&FaultMessage {
fault: ProtocolFault::INVALID_HOOK_PEER,
}
);
}
other => panic!("expected fault event, got {other:?}"),
},
other => panic!("expected local fault event, got {other:?}"),
}
}
#[test]
fn hook_closes_only_after_both_sides_end() {
let mut endpoint = ProtocolEndpoint::new(
Vec::new(),
None,
vec![ChildRoute::registered(path(&["server"]))],
Vec::new(),
);
let hook_id = endpoint.allocate_hook_id();
endpoint
.make_call(
path(&["server"]),
None,
"example.service.v1.invoke",
Some(hook_id),
vec![1],
)
.expect("call should establish an active hook");
let host_key = crate::protocol::tree::HookKey::new(Vec::new(), hook_id);
assert!(endpoint.hooks.pending(&host_key).is_some());
let activation_frame = encode_packet(
&PacketHeader {
packet_type: PacketType::Data,
src_path: path(&["server"]),
dst_path: Vec::new(),
dst_leaf: None,
hook_id: Some(hook_id),
},
&DataMessage {
procedure_id: "example.service.v1.invoke".to_owned(),
data: vec![9],
end_hook: false,
},
)
.expect("activation data should encode");
endpoint
.receive(&Ingress::Child(path(&["server"])), activation_frame)
.expect("first server data should activate the hook");
assert!(endpoint.hooks.active(&host_key).is_some());
endpoint
.send_data(
path(&["server"]),
hook_id,
"example.service.v1.invoke",
vec![2],
true,
)
.expect("local end should succeed");
assert!(endpoint.hooks.active(&host_key).is_some());
let frame = encode_packet(
&PacketHeader {
packet_type: PacketType::Data,
src_path: path(&["server"]),
dst_path: Vec::new(),
dst_leaf: None,
hook_id: Some(hook_id),
},
&DataMessage {
procedure_id: "example.service.v1.invoke".to_owned(),
data: vec![3],
end_hook: true,
},
)
.expect("peer final data should encode");
endpoint
.receive(&Ingress::Child(path(&["server"])), frame)
.expect("peer final data should be handled");
assert!(endpoint.hooks.active(&host_key).is_none());
}
#[test]
fn pending_hook_fault_is_delivered_before_activation() {
let mut endpoint = ProtocolEndpoint::new(path(&["server"]), None, Vec::new(), Vec::new());
let header = PacketHeader {
packet_type: PacketType::Call,
src_path: path(&["client"]),
dst_path: path(&["server"]),
dst_leaf: None,
hook_id: None,
};
let call = crate::protocol::CallMessage {
procedure_id: crate::protocol::INTROSPECTION_PROCEDURE_ID.to_owned(),
data: Vec::new(),
response_hook: Some(crate::protocol::HookTarget {
hook_id: 11,
return_path: path(&["client"]),
}),
};
endpoint
.hooks
.insert_pending(
crate::protocol::tree::HookKey::new(path(&["client"]), 11),
crate::protocol::tree::PendingHook {
caller_src_path: path(&["client"]),
procedure_id: call.procedure_id.clone(),
local_ended: false,
},
)
.expect("pending hook should insert");
let outcome = endpoint
.handle_introspection(
&header,
Some(crate::protocol::tree::HookKey::new(path(&["client"]), 11)),
)
.expect("introspection should handle pending hook");
assert!(!matches!(outcome, EndpointOutcome::Dropped));
}
#[test]
fn callee_side_end_hook_marks_local_end_before_peer_close() {
let mut endpoint = ProtocolEndpoint::new(path(&["server"]), None, Vec::new(), Vec::new());
endpoint
.add_endpoint_procedure("example.service.v1.invoke")
.expect("procedure registration should succeed");
let frame = encode_packet(
&PacketHeader {
packet_type: PacketType::Call,
src_path: Vec::new(),
dst_path: path(&["server"]),
dst_leaf: None,
hook_id: None,
},
&crate::protocol::CallMessage {
procedure_id: "example.service.v1.invoke".to_owned(),
data: vec![1],
response_hook: Some(crate::protocol::HookTarget {
hook_id: 21,
return_path: Vec::new(),
}),
},
)
.expect("call should encode");
endpoint
.receive(&Ingress::Parent, frame)
.expect("callee should accept call");
let key = crate::protocol::tree::HookKey::new(Vec::new(), 21);
assert!(endpoint.hooks.active(&key).is_some());
endpoint
.send_data(
Vec::new(),
21,
"example.service.v1.invoke",
Vec::new(),
true,
)
.expect("callee local end should succeed");
assert!(endpoint.hooks.active(&key).is_some());
let peer_final = encode_packet(
&PacketHeader {
packet_type: PacketType::Data,
src_path: Vec::new(),
dst_path: path(&["server"]),
dst_leaf: None,
hook_id: Some(21),
},
&DataMessage {
procedure_id: "example.service.v1.invoke".to_owned(),
data: Vec::new(),
end_hook: true,
},
)
.expect("peer final data should encode");
endpoint
.receive(&Ingress::Parent, peer_final)
.expect("callee should accept peer close");
assert!(endpoint.hooks.active(&key).is_none());
}
-687
View File
@@ -1,687 +0,0 @@
//! 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())
}
-357
View File
@@ -1,357 +0,0 @@
//! 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);
}
}
}
-295
View File
@@ -1,295 +0,0 @@
//! 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,
}
-163
View File
@@ -1,163 +0,0 @@
//! 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,
}
}
}
-103
View File
@@ -1,103 +0,0 @@
//! 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())
}
}
-16
View File
@@ -1,16 +0,0 @@
//! 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,
};
-171
View File
@@ -1,171 +0,0 @@
//! 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
@@ -1,509 +0,0 @@
//! 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
@@ -1,377 +0,0 @@
//! 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
@@ -1,35 +0,0 @@
//! 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,
};
-790
View File
@@ -1,790 +0,0 @@
//! 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
}
}
-437
View File
@@ -1,437 +0,0 @@
//! 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")],
]
);
}
}
-186
View File
@@ -1,186 +0,0 @@
//! Canonical UnShell protocol message types.
use alloc::{string::String, vec::Vec};
use rkyv::{Archive, Deserialize, Serialize};
/// The three protocol packet types.
///
/// This discriminates which payload schema follows the [`PacketHeader`]. Callers normally branch
/// on this before choosing whether to decode a [`CallMessage`], [`DataMessage`], or
/// [`FaultMessage`].
///
/// # Example
/// ```rust
/// use unshell::protocol::PacketType;
/// let packet_type = PacketType::Call;
/// assert!(matches!(packet_type, PacketType::Call));
/// ```
#[repr(u8)]
#[derive(Archive, Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
pub enum PacketType {
/// Downwards procedure invocation.
Call = 0x01,
/// Returned or continuing hook traffic.
Data = 0x02,
/// Upstream protocol failure tied to a hook.
Fault = 0xFF,
}
/// Header fields used for routing and hook attribution.
///
/// The protocol keeps routing metadata in the header so endpoints can validate source topology,
/// choose a route, and attribute hook traffic before decoding the payload.
///
/// # Example
/// ```rust
/// use unshell::protocol::{PacketHeader, PacketType};
/// let header = PacketHeader {
/// packet_type: PacketType::Call,
/// src_path: vec!["root".into()],
/// dst_path: vec!["root".into(), "worker".into()],
/// dst_leaf: Some("service".into()),
/// hook_id: None,
/// };
/// assert_eq!(header.src_path[0], "root");
/// ```
#[derive(Archive, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct PacketHeader {
/// Wire-level packet class, which determines which payload type follows.
pub packet_type: PacketType,
/// Absolute endpoint path that sent the packet.
pub src_path: Vec<String>,
/// Absolute endpoint path the packet is trying to reach.
pub dst_path: Vec<String>,
/// Optional leaf name inside `dst_path` that should receive a `Call` packet.
///
/// `Data` and `Fault` packets must leave this unset.
pub dst_leaf: Option<String>,
/// Hook identifier scoped to the receiving endpoint.
///
/// `Call` packets must leave this unset. `Data` and `Fault` packets must fill it in.
pub hook_id: Option<u64>,
}
/// Hook declaration embedded inside a call.
///
/// This reserves a response stream before the callee accepts the call so later `Data` or `Fault`
/// traffic can be attributed back to the caller.
///
/// # Example
/// ```rust
/// use unshell::protocol::HookTarget;
/// let hook = HookTarget {
/// hook_id: 7,
/// return_path: vec!["root".into()],
/// };
/// assert_eq!(hook.hook_id, 7);
/// ```
#[derive(Archive, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct HookTarget {
/// Hook identifier reserved by the caller for returned `Data` or `Fault` traffic.
pub hook_id: u64,
/// Absolute endpoint path that should receive the response stream.
///
/// Protocol validation requires this to exactly match the enclosing call header's
/// `src_path`.
pub return_path: Vec<String>,
}
/// Downwards call payload.
///
/// This carries one procedure invocation plus the optional declaration that the callee should
/// return hook traffic to a reserved response hook.
///
/// # Example
/// ```rust
/// use unshell::protocol::{CallMessage, HookTarget};
/// let call = CallMessage {
/// procedure_id: "example.service.v1.invoke".into(),
/// data: vec![1, 2, 3],
/// response_hook: Some(HookTarget {
/// hook_id: 7,
/// return_path: vec!["root".into()],
/// }),
/// };
/// assert!(call.response_hook.is_some());
/// ```
#[derive(Archive, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct CallMessage {
/// Canonical procedure identifier chosen by the caller.
pub procedure_id: String,
/// Opaque application payload for the target procedure.
pub data: Vec<u8>,
/// Optional response hook reservation for returned hook traffic.
pub response_hook: Option<HookTarget>,
}
/// Hook data payload.
///
/// This carries one message on an already-established hook stream. `end_hook` closes the sender's
/// side of that stream.
///
/// # Example
/// ```rust
/// use unshell::protocol::DataMessage;
/// let data = DataMessage {
/// procedure_id: "example.service.v1.invoke".into(),
/// data: vec![9, 8, 7],
/// end_hook: true,
/// };
/// assert!(data.end_hook);
/// ```
#[derive(Archive, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct DataMessage {
/// Canonical procedure identifier that owns the hook stream.
pub procedure_id: String,
/// Opaque application payload for the hook message.
pub data: Vec<u8>,
/// Whether this packet closes the peer side of the hook stream.
pub end_hook: bool,
}
/// Protocol fault payload.
///
/// This carries one stable protocol error code on an existing hook path.
///
/// # Example
/// ```rust
/// use unshell::protocol::{FaultMessage, ProtocolFault};
/// let fault = FaultMessage {
/// fault: ProtocolFault::INTERNAL_ERROR,
/// };
/// assert_eq!(fault.fault, ProtocolFault::INTERNAL_ERROR);
/// ```
#[derive(Archive, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct FaultMessage {
/// Stable protocol-level reason code for the failure.
pub fault: ProtocolFault,
}
/// Stable protocol fault code.
///
/// The raw numeric value is public so callers can persist, compare, or forward fault codes
/// without knowing every symbolic constant in advance. Unknown values are allowed so newer
/// peers can extend the set without breaking older runtimes.
///
/// # Example
/// ```rust
/// use unshell::protocol::ProtocolFault;
/// let code = ProtocolFault::UNKNOWN_PROCEDURE;
/// assert_eq!(code.0, 0x02);
/// ```
#[derive(Archive, Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
pub struct ProtocolFault(pub u8);
impl ProtocolFault {
/// The addressed leaf name does not exist at the destination endpoint.
pub const UNKNOWN_LEAF: Self = Self(0x01);
/// The destination exists, but it does not expose the requested procedure id.
pub const UNKNOWN_PROCEDURE: Self = Self(0x02);
/// The packet source path is not valid for the ingress side where it arrived.
pub const INVALID_SOURCE_PATH: Self = Self(0x03);
/// Hook traffic arrived from a peer that does not own the active hook relationship.
pub const INVALID_HOOK_PEER: Self = Self(0x04);
/// The runtime hit an internal protocol failure and could only surface a generic fault.
pub const INTERNAL_ERROR: Self = Self(0x05);
}
-165
View File
@@ -1,165 +0,0 @@
//! Stateless protocol validation.
use crate::protocol::{
CallMessage, PacketHeader, PacketType, introspection::INTROSPECTION_PROCEDURE_ID,
};
use core::fmt;
/// Validation failures for protocol structures.
///
/// These errors exist so callers can reject malformed outbound packets early, before they are
/// encoded or sent across the tree.
///
/// # Example
/// ```rust
/// use unshell::protocol::{PacketHeader, PacketType, ValidationError, validate_header};
/// let invalid = PacketHeader {
/// packet_type: PacketType::Data,
/// src_path: vec!["peer".into()],
/// dst_path: vec!["host".into()],
/// dst_leaf: Some("service".into()),
/// hook_id: None,
/// };
/// assert!(matches!(validate_header(&invalid), Err(ValidationError::HeaderInvariant(_))));
/// ```
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ValidationError {
/// One header field combination is invalid for the chosen packet type.
HeaderInvariant(&'static str),
/// The procedure identifier violates the protocol's minimal reserved-id rules.
ProcedureId(&'static str),
/// The call payload contradicts the surrounding packet header.
CallInvariant(&'static str),
/// A hook lifecycle transition would break protocol state invariants.
HookInvariant(&'static str),
/// A hook id collided with existing endpoint-local state.
InvalidHookId,
}
impl fmt::Display for ValidationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::HeaderInvariant(message) => write!(f, "invalid header: {message}"),
Self::ProcedureId(message) => write!(f, "invalid procedure id: {message}"),
Self::CallInvariant(message) => write!(f, "invalid call: {message}"),
Self::HookInvariant(message) => write!(f, "invalid hook state: {message}"),
Self::InvalidHookId => f.write_str("invalid hook identifier"),
}
}
}
impl core::error::Error for ValidationError {}
/// Validates stateless packet-header invariants.
///
/// This checks wire-shape rules only. It does not verify route existence, leaf existence,
/// hook ownership, or whether the destination actually supports the requested procedure.
///
/// # Example
/// ```rust
/// use unshell::protocol::{PacketHeader, PacketType, validate_header};
/// let header = PacketHeader {
/// packet_type: PacketType::Call,
/// src_path: vec!["root".into()],
/// dst_path: vec!["worker".into()],
/// dst_leaf: Some("service".into()),
/// hook_id: None,
/// };
/// validate_header(&header)?;
/// # Ok::<(), unshell::protocol::ValidationError>(())
/// ```
pub fn validate_header(header: &PacketHeader) -> Result<(), ValidationError> {
match header.packet_type {
PacketType::Call => {
if header.hook_id.is_some() {
return Err(ValidationError::HeaderInvariant(
"Call packets must not carry hook_id",
));
}
}
PacketType::Data | PacketType::Fault => {
if header.dst_leaf.is_some() {
return Err(ValidationError::HeaderInvariant(
"Data and Fault packets must not carry dst_leaf",
));
}
if header.hook_id.is_none() {
return Err(ValidationError::HeaderInvariant(
"Data and Fault packets must carry hook_id",
));
}
}
}
Ok(())
}
/// Validates the protocol-level `procedure_id` invariant.
///
/// This is intentionally permissive. The protocol reserves only the empty string for
/// introspection; every other non-empty identifier is treated as opaque application data.
///
/// # Example
/// ```rust
/// use unshell::protocol::{INTROSPECTION_PROCEDURE_ID, validate_procedure_id};
/// validate_procedure_id(INTROSPECTION_PROCEDURE_ID)?;
/// validate_procedure_id("example.service.v1.invoke")?;
/// # Ok::<(), unshell::protocol::ValidationError>(())
/// ```
pub fn validate_procedure_id(procedure_id: &str) -> Result<(), ValidationError> {
if procedure_id == INTROSPECTION_PROCEDURE_ID {
return Ok(());
}
if procedure_id.is_empty() {
return Err(ValidationError::ProcedureId(
"procedure identifier cannot be empty except for introspection",
));
}
Ok(())
}
/// Validates call-specific invariants that depend on both header and payload.
///
/// This complements [`validate_header`]. It does not verify destination reachability or leaf
/// support, only consistency between the opening `Call` header and payload.
///
/// # Example
/// ```rust
/// use unshell::protocol::{CallMessage, HookTarget, PacketHeader, PacketType, validate_call};
/// let header = PacketHeader {
/// packet_type: PacketType::Call,
/// src_path: vec!["root".into()],
/// dst_path: vec!["worker".into()],
/// dst_leaf: Some("service".into()),
/// hook_id: None,
/// };
/// let call = CallMessage {
/// procedure_id: "example.service.v1.invoke".into(),
/// data: vec![],
/// response_hook: Some(HookTarget {
/// hook_id: 7,
/// return_path: vec!["root".into()],
/// }),
/// };
/// validate_call(&header, &call)?;
/// # Ok::<(), unshell::protocol::ValidationError>(())
/// ```
pub fn validate_call(header: &PacketHeader, call: &CallMessage) -> Result<(), ValidationError> {
validate_procedure_id(&call.procedure_id)?;
if let Some(hook) = &call.response_hook
&& hook.return_path != header.src_path
{
return Err(ValidationError::CallInvariant(
"response_hook.return_path must equal header.src_path",
));
}
if call.procedure_id == INTROSPECTION_PROCEDURE_ID && call.response_hook.is_none() {
// Introspection is defined as a request/response exchange, never a fire-and-forget call.
return Err(ValidationError::CallInvariant(
"introspection requires a response hook",
));
}
Ok(())
}