Big rewrite.

This commit is contained in:
Michael Mikovsky
2026-05-16 13:10:51 -06:00
parent da9166daf0
commit 56abb5e1e0
63 changed files with 4 additions and 14547 deletions
+4
View File
@@ -79,6 +79,10 @@ path = "examples/protocol/leaf_derive.rs"
name = "crossbeam_channel_leaf" name = "crossbeam_channel_leaf"
path = "examples/protocol/crossbeam_channel_leaf.rs" path = "examples/protocol/crossbeam_channel_leaf.rs"
[[example]]
name = "runtime_leaf_actions"
path = "examples/protocol/runtime_leaf_actions.rs"
[[example]] [[example]]
name = "remote_shell_endpoint" name = "remote_shell_endpoint"
path = "examples/protocol/remote_shell_endpoint.rs" path = "examples/protocol/remote_shell_endpoint.rs"
-413
View File
@@ -1,413 +0,0 @@
//! Protocol benchmark driver.
//!
//! Running the example normally prints the in-process benchmark table. Running it with `tools`
//! builds the standalone operation binaries and feeds them to external profiling tools.
use std::hint::black_box;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Instant;
use unshell::protocol::tree::{
ChildRoute, Endpoint, EndpointOutcome, Ingress, LeafSpec, LocalEvent, ProtocolEndpoint,
};
use unshell::protocol::{CallMessage, PacketHeader, PacketType, decode_frame, encode_packet};
const SAMPLES: usize = 500;
const ITERS: usize = 10_000;
const TOOL_ITERS: usize = 10_000;
fn main() {
if std::env::args().nth(1).as_deref() == Some("tools") {
run_external_tools();
return;
}
println!("protocol benchmark");
println!("samples: {SAMPLES}");
println!("iterations/sample: {ITERS}");
println!();
let benches = [
bench_encode_call(),
bench_decode_call(),
bench_forward_call_receive(),
bench_local_call_receive(),
bench_hook_data_receive(),
];
println!(
"{:32} {:>14} {:>14} {:>14}",
"benchmark", "mean ns/op", "stddev", "samples"
);
for bench in benches {
println!(
"{:32} {:>14.2} {:>14.2} {:>14}",
bench.name, bench.mean_ns, bench.stddev_ns, bench.samples
);
}
println!();
println!("Run `cargo run --example bench -- tools` to build and execute");
println!("the standalone operation binaries under strace, perf, and heaptrack.");
}
struct BenchResult {
name: &'static str,
mean_ns: f64,
stddev_ns: f64,
samples: usize,
}
fn bench_encode_call() -> BenchResult {
let header = PacketHeader {
packet_type: PacketType::Call,
src_path: path(&["root"]),
dst_path: path(&["root", "worker"]),
dst_leaf: Some(String::from("service")),
hook_id: None,
};
let message = CallMessage {
procedure_id: String::from("example.service.v1.invoke"),
data: vec![7; 64],
response_hook: None,
};
run_bench("encode_call", || {
let frame =
encode_packet(black_box(&header), black_box(&message)).expect("encode should work");
black_box(frame.len());
})
}
fn bench_decode_call() -> BenchResult {
let header = PacketHeader {
packet_type: PacketType::Call,
src_path: path(&["root"]),
dst_path: path(&["root", "worker"]),
dst_leaf: Some(String::from("service")),
hook_id: None,
};
let message = CallMessage {
procedure_id: String::from("example.service.v1.invoke"),
data: vec![9; 64],
response_hook: None,
};
let frame = encode_packet(&header, &message).expect("seed frame should encode");
run_bench("decode_call", || {
let parsed = decode_frame(black_box(frame.as_slice())).expect("decode should work");
let call = parsed.deserialize_call().expect("call should deserialize");
black_box(call.data.len());
})
}
fn bench_forward_call_receive() -> BenchResult {
run_prebuilt_bench(
"forward_call_receive",
build_forward_call_cases,
|(mut root, frame)| {
let outcome = root
.receive(&Ingress::Local, frame)
.expect("forward receive should work");
black_box(matches!(outcome, EndpointOutcome::Forward { .. }));
},
)
}
fn bench_local_call_receive() -> BenchResult {
run_prebuilt_bench(
"local_call_receive",
build_local_call_cases,
|(mut endpoint, frame)| {
let outcome = endpoint
.receive(&Ingress::Parent, frame)
.expect("local call should work");
match black_box(outcome) {
EndpointOutcome::Local(LocalEvent::Call { .. }) => {}
other => panic!("expected local call event, got {other:?}"),
}
},
)
}
fn bench_hook_data_receive() -> BenchResult {
run_prebuilt_bench(
"hook_data_receive",
build_hook_data_cases,
|(mut host, frame)| {
let outcome = host
.receive(&Ingress::Child(path(&["worker"])), frame)
.expect("hook data should work");
match black_box(outcome) {
EndpointOutcome::Local(LocalEvent::Data { .. }) => {}
other => panic!("expected local data event, got {other:?}"),
}
},
)
}
fn run_bench(name: &'static str, mut op: impl FnMut()) -> BenchResult {
let mut samples = Vec::with_capacity(SAMPLES);
for _ in 0..SAMPLES {
let start = Instant::now();
for _ in 0..ITERS {
op();
}
let elapsed = start.elapsed().as_nanos() as f64 / ITERS as f64;
samples.push(elapsed);
}
summarize(name, &samples)
}
fn run_prebuilt_bench<T, F>(
name: &'static str,
mut build_cases: F,
mut op: impl FnMut(T),
) -> BenchResult
where
F: FnMut() -> Vec<T>,
{
let mut repeated = Vec::with_capacity(SAMPLES);
for _ in 0..SAMPLES {
let mut cases = build_cases();
assert_eq!(cases.len(), ITERS);
let start = Instant::now();
for case in cases.drain(..) {
op(case);
}
let elapsed = start.elapsed().as_nanos() as f64 / ITERS as f64;
repeated.push(elapsed);
}
summarize(name, &repeated)
}
fn build_forward_call_cases() -> Vec<(ProtocolEndpoint, unshell::protocol::FrameBytes)> {
(0..ITERS)
.map(|_| {
let mut root = ProtocolEndpoint::new(
Vec::new(),
None,
vec![ChildRoute::registered(path(&["edge"]))],
Vec::new(),
);
let hook_id = root.allocate_hook_id();
let frame = root
.make_call(
path(&["edge", "worker"]),
Some(String::from("service")),
String::from("example.service.v1.invoke"),
Some(hook_id),
vec![1; 32],
)
.expect("seed call should encode");
(root, frame)
})
.collect()
}
fn build_local_call_cases() -> Vec<(ProtocolEndpoint, unshell::protocol::FrameBytes)> {
(0..ITERS)
.map(|_| {
let endpoint = ProtocolEndpoint::new(
path(&["worker"]),
Some(Vec::new()),
Vec::new(),
vec![LeafSpec {
name: String::from("service"),
procedures: vec![String::from("example.service.v1.invoke")],
}],
);
let frame = encode_packet(
&PacketHeader {
packet_type: PacketType::Call,
src_path: Vec::new(),
dst_path: path(&["worker"]),
dst_leaf: Some(String::from("service")),
hook_id: None,
},
&CallMessage {
procedure_id: String::from("example.service.v1.invoke"),
data: vec![2; 32],
response_hook: Some(unshell::protocol::HookTarget {
hook_id: 42,
return_path: Vec::new(),
}),
},
)
.expect("seed local call should encode");
(endpoint, frame)
})
.collect()
}
fn build_hook_data_cases() -> Vec<(ProtocolEndpoint, unshell::protocol::FrameBytes)> {
(0..ITERS)
.map(|_| {
let mut host = ProtocolEndpoint::new(
Vec::new(),
None,
vec![ChildRoute::registered(path(&["worker"]))],
Vec::new(),
);
let hook_id = host.allocate_hook_id();
host.make_call(
path(&["worker"]),
None,
String::from("example.service.v1.invoke"),
Some(hook_id),
vec![3; 8],
)
.expect("seed active hook should encode");
let frame = encode_packet(
&PacketHeader {
packet_type: PacketType::Data,
src_path: path(&["worker"]),
dst_path: Vec::new(),
dst_leaf: None,
hook_id: Some(hook_id),
},
&unshell::protocol::DataMessage {
procedure_id: String::from("example.service.v1.invoke"),
data: vec![4; 16],
end_hook: false,
},
)
.expect("seed data should encode");
(host, frame)
})
.collect()
}
fn summarize(name: &'static str, samples: &[f64]) -> BenchResult {
let mean = samples.iter().sum::<f64>() / samples.len() as f64;
let variance = samples
.iter()
.map(|sample| {
let delta = sample - mean;
delta * delta
})
.sum::<f64>()
/ samples.len() as f64;
BenchResult {
name,
mean_ns: mean,
stddev_ns: variance.sqrt(),
samples: samples.len(),
}
}
fn path(parts: &[&str]) -> Vec<String> {
parts.iter().map(|part| String::from(*part)).collect()
}
fn run_external_tools() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
build_examples(root);
let ops = [
("encode_call", "op_encode_call"),
("decode_call", "op_decode_call"),
("forward_call_receive", "op_forward_call_receive"),
("local_call_receive", "op_local_call_receive"),
("hook_data_receive", "op_hook_data_receive"),
];
let heap_dir = root.join("heaptrack-cli");
std::fs::create_dir_all(&heap_dir).expect("heaptrack-cli directory should be creatable");
for (name, binary) in ops {
let binary_path = root.join("target/debug/examples").join(binary);
println!();
println!("=== {name} ===");
run_binary(&binary_path, TOOL_ITERS, "direct run");
run_strace(&binary_path, TOOL_ITERS);
run_perf(&binary_path, TOOL_ITERS);
run_heaptrack(root, &heap_dir, name, &binary_path, TOOL_ITERS);
}
}
fn build_examples(root: &Path) {
run_command(
"cargo build --examples",
Command::new("cargo")
.arg("build")
.arg("--examples")
.current_dir(root),
);
}
fn run_binary(binary: &Path, iterations: usize, label: &str) {
run_command(label, Command::new(binary).arg(iterations.to_string()));
}
fn run_strace(binary: &Path, iterations: usize) {
run_command(
"strace -c memory syscalls",
Command::new("strace")
.arg("-qq")
.arg("-c")
.arg("-e")
.arg("trace=brk,mmap,mremap,munmap,mprotect,madvise")
.arg(binary)
.arg(iterations.to_string()),
);
}
fn run_perf(binary: &Path, iterations: usize) {
run_command(
"perf stat",
Command::new("perf")
.arg("stat")
.arg("-e")
.arg("task-clock,cycles,instructions,branches,branch-misses,cache-references,cache-misses")
.arg(binary)
.arg(iterations.to_string()),
);
}
fn run_heaptrack(root: &Path, heap_dir: &Path, name: &str, binary: &Path, iterations: usize) {
let prefix = heap_dir.join(format!("{name}.zst"));
run_command(
"heaptrack --record-only",
Command::new("heaptrack")
.arg("--record-only")
.arg("-o")
.arg(&prefix)
.arg(binary)
.arg(iterations.to_string())
.current_dir(root),
);
let recorded = PathBuf::from(format!("{}.zst", prefix.display()));
run_command(
"heaptrack_print summary",
Command::new("heaptrack_print")
.arg("-f")
.arg(recorded)
.arg("-n")
.arg("4")
.arg("-s")
.arg("2")
.current_dir(root),
);
}
fn run_command(label: &str, command: &mut Command) {
println!("--- {label} ---");
let output = command
.output()
.unwrap_or_else(|error| panic!("{label} failed to launch: {error}"));
if !output.stdout.is_empty() {
print!("{}", String::from_utf8_lossy(&output.stdout));
}
if !output.stderr.is_empty() {
print!("{}", String::from_utf8_lossy(&output.stderr));
}
assert!(
output.status.success(),
"{label} failed with status {}",
output.status
);
}
-10
View File
@@ -1,10 +0,0 @@
//! Standalone benchmark binary for `decode_call`.
#[path = "support/bench_common.rs"]
mod common;
fn main() {
let iterations = common::iterations_from_args(1_000);
let checksum = common::run_decode_call(iterations);
println!("decode_call iterations={iterations} checksum={checksum}");
}
-10
View File
@@ -1,10 +0,0 @@
//! Standalone benchmark binary for `encode_call`.
#[path = "support/bench_common.rs"]
mod common;
fn main() {
let iterations = common::iterations_from_args(1_000);
let checksum = common::run_encode_call(iterations);
println!("encode_call iterations={iterations} checksum={checksum}");
}
@@ -1,10 +0,0 @@
//! Standalone benchmark binary for `forward_call_receive`.
#[path = "support/bench_common.rs"]
mod common;
fn main() {
let iterations = common::iterations_from_args(1_000);
let checksum = common::run_forward_call_receive(iterations);
println!("forward_call_receive iterations={iterations} checksum={checksum}");
}
@@ -1,10 +0,0 @@
//! Standalone benchmark binary for `hook_data_receive`.
#[path = "support/bench_common.rs"]
mod common;
fn main() {
let iterations = common::iterations_from_args(1_000);
let checksum = common::run_hook_data_receive(iterations);
println!("hook_data_receive iterations={iterations} checksum={checksum}");
}
@@ -1,10 +0,0 @@
//! Standalone benchmark binary for `local_call_receive`.
#[path = "support/bench_common.rs"]
mod common;
fn main() {
let iterations = common::iterations_from_args(1_000);
let checksum = common::run_local_call_receive(iterations);
println!("local_call_receive iterations={iterations} checksum={checksum}");
}
@@ -1,256 +0,0 @@
//! Shared helpers for the standalone benchmark operation binaries.
//!
//! These helpers keep each operation binary tiny while still exposing the same setup and checksum
//! logic to strace, perf, and heaptrack.
#![allow(dead_code)]
use std::hint::black_box;
use unshell::protocol::tree::{
ChildRoute, Endpoint, EndpointOutcome, Ingress, LeafSpec, LocalEvent, ProtocolEndpoint,
};
use unshell::protocol::{CallMessage, PacketHeader, PacketType, decode_frame, encode_packet};
pub fn iterations_from_args(default: usize) -> usize {
std::env::args()
.nth(1)
.map(|value| {
value
.parse::<usize>()
.expect("iterations must be a positive integer")
})
.unwrap_or(default)
}
#[inline(never)]
pub fn run_encode_call(iterations: usize) -> usize {
let header = PacketHeader {
packet_type: PacketType::Call,
src_path: path(&["root"]),
dst_path: path(&["root", "worker"]),
dst_leaf: Some(String::from("service")),
hook_id: None,
};
let message = CallMessage {
procedure_id: String::from("example.service.v1.invoke"),
data: vec![7; 64],
response_hook: None,
};
let mut checksum = 0usize;
for _ in 0..iterations {
let frame =
encode_packet(black_box(&header), black_box(&message)).expect("encode should work");
checksum = checksum.wrapping_add(frame.len());
}
black_box(checksum)
}
#[inline(never)]
pub fn run_decode_call(iterations: usize) -> usize {
let header = PacketHeader {
packet_type: PacketType::Call,
src_path: path(&["root"]),
dst_path: path(&["root", "worker"]),
dst_leaf: Some(String::from("service")),
hook_id: None,
};
let message = CallMessage {
procedure_id: String::from("example.service.v1.invoke"),
data: vec![9; 64],
response_hook: None,
};
let frame = encode_packet(&header, &message).expect("seed frame should encode");
let mut checksum = 0usize;
for _ in 0..iterations {
let parsed = decode_frame(black_box(frame.as_slice())).expect("decode should work");
let call = parsed.deserialize_call().expect("call should deserialize");
checksum = checksum
.wrapping_add(call.data.len())
.wrapping_add(call.procedure_id.len())
.wrapping_add(call.response_hook.is_some() as usize);
}
black_box(checksum)
}
#[inline(never)]
pub fn run_forward_call_receive(iterations: usize) -> usize {
let cases = build_forward_call_cases(iterations);
run_cases(cases, |(mut root, frame)| {
let outcome = root
.receive(&Ingress::Local, frame)
.expect("forward receive should work");
match outcome {
EndpointOutcome::Forward { route, frame } => {
route_value(route).wrapping_add(frame.len())
}
EndpointOutcome::Local(_) => 0,
EndpointOutcome::Dropped => usize::from(true),
}
})
}
#[inline(never)]
pub fn run_local_call_receive(iterations: usize) -> usize {
let cases = build_local_call_cases(iterations);
run_cases(cases, |(mut endpoint, frame)| {
let outcome = endpoint
.receive(&Ingress::Parent, frame)
.expect("local call should work");
match outcome {
EndpointOutcome::Local(LocalEvent::Call { header, message }) => header
.dst_path
.len()
.wrapping_add(header.src_path.len())
.wrapping_add(header.dst_leaf.as_ref().map_or(0, String::len))
.wrapping_add(message.data.len())
.wrapping_add(message.procedure_id.len()),
other => panic!("expected local call event, got {other:?}"),
}
})
}
#[inline(never)]
pub fn run_hook_data_receive(iterations: usize) -> usize {
let cases = build_hook_data_cases(iterations);
run_cases(cases, |(mut host, frame)| {
let outcome = host
.receive(&Ingress::Child(path(&["worker"])), frame)
.expect("hook data should work");
match outcome {
EndpointOutcome::Local(LocalEvent::Data {
header, message, ..
}) => (header.hook_id.unwrap_or_default() as usize)
.wrapping_add(message.data.len())
.wrapping_add(message.procedure_id.len())
.wrapping_add(message.end_hook as usize),
other => panic!("expected local data event, got {other:?}"),
}
})
}
fn run_cases<T>(cases: Vec<T>, mut op: impl FnMut(T) -> usize) -> usize {
let mut checksum = 0usize;
for case in cases {
checksum = checksum.wrapping_add(op(case));
}
black_box(checksum)
}
fn build_forward_call_cases(
iterations: usize,
) -> Vec<(ProtocolEndpoint, unshell::protocol::FrameBytes)> {
(0..iterations)
.map(|_| {
let mut root = ProtocolEndpoint::new(
Vec::new(),
None,
vec![ChildRoute::registered(path(&["edge"]))],
Vec::new(),
);
let hook_id = root.allocate_hook_id();
let frame = root
.make_call(
path(&["edge", "worker"]),
Some(String::from("service")),
String::from("example.service.v1.invoke"),
Some(hook_id),
vec![1; 32],
)
.expect("seed call should encode");
(root, frame)
})
.collect()
}
fn build_local_call_cases(
iterations: usize,
) -> Vec<(ProtocolEndpoint, unshell::protocol::FrameBytes)> {
(0..iterations)
.map(|_| {
let endpoint = ProtocolEndpoint::new(
path(&["worker"]),
Some(Vec::new()),
Vec::new(),
vec![LeafSpec {
name: String::from("service"),
procedures: vec![String::from("example.service.v1.invoke")],
}],
);
let frame = encode_packet(
&PacketHeader {
packet_type: PacketType::Call,
src_path: Vec::new(),
dst_path: path(&["worker"]),
dst_leaf: Some(String::from("service")),
hook_id: None,
},
&CallMessage {
procedure_id: String::from("example.service.v1.invoke"),
data: vec![2; 32],
response_hook: Some(unshell::protocol::HookTarget {
hook_id: 42,
return_path: Vec::new(),
}),
},
)
.expect("seed local call should encode");
(endpoint, frame)
})
.collect()
}
fn build_hook_data_cases(
iterations: usize,
) -> Vec<(ProtocolEndpoint, unshell::protocol::FrameBytes)> {
(0..iterations)
.map(|_| {
let mut host = ProtocolEndpoint::new(
Vec::new(),
None,
vec![ChildRoute::registered(path(&["worker"]))],
Vec::new(),
);
let hook_id = host.allocate_hook_id();
host.make_call(
path(&["worker"]),
None,
String::from("example.service.v1.invoke"),
Some(hook_id),
vec![3; 8],
)
.expect("seed active hook should encode");
let frame = encode_packet(
&PacketHeader {
packet_type: PacketType::Data,
src_path: path(&["worker"]),
dst_path: Vec::new(),
dst_leaf: None,
hook_id: Some(hook_id),
},
&unshell::protocol::DataMessage {
procedure_id: String::from("example.service.v1.invoke"),
data: vec![4; 16],
end_hook: false,
},
)
.expect("seed data should encode");
(host, frame)
})
.collect()
}
pub fn path(parts: &[&str]) -> Vec<String> {
parts.iter().map(|part| String::from(*part)).collect()
}
fn route_value(route: unshell::protocol::tree::RouteDecision) -> usize {
match route {
unshell::protocol::tree::RouteDecision::Child(index) => index,
unshell::protocol::tree::RouteDecision::Local => usize::MAX - 2,
unshell::protocol::tree::RouteDecision::Parent => usize::MAX - 1,
unshell::protocol::tree::RouteDecision::Drop => usize::MAX,
}
}
-185
View File
@@ -1,185 +0,0 @@
//! Crossbeam-channel router leaf example.
//!
//! This example wires a root controller to an `agent` node, promotes a staged
//! child connection on that agent via the `add_connection` procedure, and then
//! queries the grandchild's connection snapshot through a fully routed call/reply
//! exchange.
use std::error::Error;
use crossbeam_channel::{Receiver, Sender, unbounded};
use unshell::leaves::crossbeam_channel::{
ConnectionRequest, ConnectionSnapshot, CrossbeamChannelLeaf, CrossbeamEnvelope,
};
use unshell::protocol::tree::ProtocolEndpoint;
use unshell::protocol::tree::{
ChildRoute, Endpoint, EndpointOutcome, Ingress, LeafRuntime, decode_call_input,
encode_call_reply,
};
fn main() -> Result<(), Box<dyn Error>> {
let mut network = ChannelNetwork::new()?;
network.call_root(
path(&["agent"]),
CrossbeamChannelLeaf::protocol_procedure_id("add_connection").expect("procedure exists"),
encode_call_reply(&ConnectionRequest {
peer_path: path(&["agent", "child"]),
})?,
)?;
let reply = network.call_root(
path(&["agent", "child"]),
CrossbeamChannelLeaf::protocol_procedure_id("get_connections").expect("procedure exists"),
encode_call_reply(&())?,
)?;
let snapshot = decode_call_input::<ConnectionSnapshot>(reply.as_slice())?;
println!("child parent: {:?}", snapshot.parent);
println!("child children: {:?}", snapshot.children);
Ok(())
}
struct ChannelNetwork {
root: ProtocolEndpoint,
root_to_agent: Sender<CrossbeamEnvelope>,
root_rx: Receiver<CrossbeamEnvelope>,
agent: ChannelNode,
child: ChannelNode,
}
impl ChannelNetwork {
fn new() -> Result<Self, Box<dyn Error>> {
let (mut agent, root_to_agent) = ChannelNode::new(path(&["agent"]));
let (mut child, agent_to_child) = ChannelNode::new(path(&["agent", "child"]));
let (agent_to_root, root_rx) = unbounded();
let root = ProtocolEndpoint::new(
Vec::new(),
None,
vec![ChildRoute::registered(path(&["agent"]))],
Vec::new(),
);
agent.stage_connection(Vec::new(), agent_to_root);
agent.connect_staged(Vec::new())?;
child.stage_connection(path(&["agent"]), root_to_agent.clone());
child.connect_staged(path(&["agent"]))?;
agent.stage_connection(path(&["agent", "child"]), agent_to_child);
Ok(Self {
root,
root_to_agent,
root_rx,
agent,
child,
})
}
fn call_root(
&mut self,
dst_path: Vec<String>,
procedure_id: String,
data: Vec<u8>,
) -> Result<Vec<u8>, Box<dyn Error>> {
let hook_id = self.root.allocate_hook_id();
let outcome = self.root.send_call(
dst_path,
Some(CrossbeamChannelLeaf::protocol_leaf_name()),
procedure_id,
Some(hook_id),
data,
)?;
let EndpointOutcome::Forward { frame, .. } = outcome else {
return Err("root call did not forward".into());
};
self.root_to_agent.send(CrossbeamEnvelope {
ingress: Ingress::Parent,
frame,
})?;
for _ in 0..16 {
let mut progress = 0usize;
progress += self.agent.drain()?;
progress += self.child.drain()?;
while let Ok(envelope) = self.root_rx.try_recv() {
progress += 1;
let outcome = self.root.receive(&envelope.ingress, envelope.frame)?;
if let EndpointOutcome::Local(event) = outcome {
match event {
unshell::protocol::tree::LocalEvent::Data { message, .. } => {
return Ok(message.data);
}
unshell::protocol::tree::LocalEvent::Fault { message, .. } => {
return Err(format!("routed call faulted: {:?}", message.fault).into());
}
unshell::protocol::tree::LocalEvent::Call { .. } => {}
}
}
}
if progress == 0 {
break;
}
}
Err("timed out waiting for routed reply".into())
}
}
struct ChannelNode {
runtime: LeafRuntime<CrossbeamChannelLeaf>,
rx: Receiver<CrossbeamEnvelope>,
}
impl ChannelNode {
fn new(path: Vec<String>) -> (Self, Sender<CrossbeamEnvelope>) {
let (tx, rx) = unbounded();
let endpoint = ProtocolEndpoint::new(
path,
None,
Vec::new(),
vec![CrossbeamChannelLeaf::protocol_leaf_spec()],
);
(
Self {
runtime: LeafRuntime::new(endpoint, CrossbeamChannelLeaf::default()),
rx,
},
tx,
)
}
fn stage_connection(&mut self, peer_path: Vec<String>, sender: Sender<CrossbeamEnvelope>) {
let _ = self.runtime.leaf_mut().stage_connection(peer_path, sender);
}
fn connect_staged(&mut self, peer_path: Vec<String>) -> Result<(), Box<dyn Error>> {
let runtime = &mut self.runtime;
let mut leaf = core::mem::take(runtime.leaf_mut());
let result = leaf.connect_staged(runtime.endpoint_mut(), peer_path);
*runtime.leaf_mut() = leaf;
result?;
Ok(())
}
fn drain(&mut self) -> Result<usize, Box<dyn Error>> {
let mut processed = 0usize;
while let Ok(envelope) = self.rx.try_recv() {
let outcome = self
.runtime
.receive_routed(&envelope.ingress, envelope.frame)?;
self.runtime.route_forwarded(outcome.forwarded)?;
processed += 1;
}
Ok(processed)
}
}
fn path(parts: &[&str]) -> Vec<String> {
parts.iter().map(|part| (*part).to_owned()).collect()
}
-142
View File
@@ -1,142 +0,0 @@
//! Small end-to-end example for the `leaf!` and `Procedure` macros.
//!
//! This stays entirely local. A controller endpoint opens one hook-backed procedure against a
//! single in-process leaf runtime, and the example decodes the returned reply payload.
use std::error::Error;
use std::{collections::BTreeMap, convert::Infallible, string::String};
use rkyv::{Archive, Deserialize, Serialize};
use unshell::protocol::tree::{
Call, ChildRoute, EndpointOutcome, HookKey, Ingress, OutgoingData, Procedure, ProcedureEffect,
ProcedureRuntime, ProcedureStore, ProtocolEndpoint,
};
use unshell::protocol::{PacketType, decode_frame};
use unshell::{Procedure, leaf};
#[derive(Default)]
struct EchoLeaf {
sessions: BTreeMap<HookKey, EchoOpen>,
}
#[leaf(id = "org.example.v1.echo", procedures = [EchoOpen], endpoint_struct = EchoLeaf)]
struct Echo;
#[derive(Archive, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
struct EchoRequest {
text: String,
}
#[derive(Archive, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
struct EchoResponse {
text: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Procedure)]
#[procedure(leaf = EchoLeaf, name = "echo")]
struct EchoOpen {
prefix: String,
return_path: Vec<String>,
hook_id: u64,
sent_reply: bool,
}
impl ProcedureStore<EchoOpen> for EchoLeaf {
fn procedure_sessions(&mut self) -> &mut BTreeMap<HookKey, EchoOpen> {
&mut self.sessions
}
}
impl Procedure<EchoLeaf> for EchoOpen {
type Error = Infallible;
type Input = EchoRequest;
fn open(_leaf: &mut EchoLeaf, call: Call<Self::Input>) -> Result<Self, Self::Error> {
let response_hook = call
.response_hook
.expect("example call declares a response hook");
Ok(Self {
prefix: call.input.text,
return_path: response_hook.return_path,
hook_id: response_hook.hook_id,
sent_reply: false,
})
}
fn poll(_leaf: &mut EchoLeaf, session: &mut Self) -> Result<ProcedureEffect, Self::Error> {
if session.sent_reply {
return Ok(ProcedureEffect::default());
}
session.sent_reply = true;
Ok(ProcedureEffect::close(vec![OutgoingData {
dst_path: session.return_path.clone(),
hook_id: session.hook_id,
procedure_id: EchoOpen::protocol_procedure_id(),
data: unshell::protocol::tree::encode_call_reply(&EchoResponse {
text: format!("echo: {}", session.prefix),
})
.expect("response should encode"),
end_hook: true,
}]))
}
}
fn path(parts: &[&str]) -> Vec<String> {
parts.iter().map(|part| (*part).to_owned()).collect()
}
fn main() -> Result<(), Box<dyn Error>> {
let endpoint = ProtocolEndpoint::new(
path(&["agent"]),
Some(Vec::new()),
Vec::new(),
vec![EchoLeaf::protocol_leaf_spec()],
);
let mut runtime = ProcedureRuntime::<EchoLeaf, EchoOpen>::new(endpoint, EchoLeaf::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 controller_outcome = controller.send_call(
path(&["agent"]),
Some(EchoLeaf::protocol_leaf_name()),
EchoOpen::protocol_procedure_id(),
Some(hook_id),
unshell::protocol::tree::encode_call_reply(&EchoRequest {
text: String::from("hello leaf"),
})?,
)?;
let EndpointOutcome::Forward { frame, .. } = controller_outcome else {
return Err("expected controller to forward call".into());
};
let receive_outcome = runtime.receive(&Ingress::Parent, frame)?;
assert!(receive_outcome.frames.is_empty());
let outcome = runtime.poll()?;
let [response_frame] = outcome.frames.as_slice() else {
return Err("expected one response frame".into());
};
let parsed = decode_frame(response_frame.as_slice())?;
assert_eq!(parsed.packet_type(), PacketType::Data);
let response = unshell::protocol::tree::decode_call_input::<EchoResponse>(
parsed.deserialize_data()?.data.as_slice(),
)?;
assert_eq!(EchoLeaf::protocol_leaf_name(), "org.example.v1.echo");
assert_eq!(response.text, "echo: hello leaf");
println!(
"leaf={} procedure={} response={}",
EchoLeaf::protocol_leaf_name(),
EchoOpen::protocol_procedure_id(),
response.text,
);
Ok(())
}
@@ -1,54 +0,0 @@
//! Remote shell endpoint example.
//!
//! This binary acts as the single remote-shell endpoint process. It connects to the controller
//! example over TCP, feeds inbound frames into the `ProcedureRuntime`, and flushes any resulting
//! protocol frames back to the controller.
use std::error::Error;
use std::net::TcpStream;
use std::sync::mpsc::RecvTimeoutError;
use std::time::Duration;
use unshell::leaves::remote_shell;
use unshell::protocol::tree::{Ingress, ProcedureRuntime, ProtocolEndpoint};
fn main() -> Result<(), Box<dyn Error>> {
let mut stream = TcpStream::connect(remote_shell::endpoint::LISTEN_ADDR)?;
let frame_rx = remote_shell::endpoint::spawn_frame_reader(stream.try_clone()?);
let endpoint = ProtocolEndpoint::new(
agent_path(),
Some(Vec::new()),
Vec::new(),
vec![remote_shell::endpoint::RemoteShell::protocol_leaf_spec()],
);
let mut runtime = ProcedureRuntime::<
remote_shell::endpoint::RemoteShell,
remote_shell::endpoint::Open,
>::new(endpoint, remote_shell::endpoint::RemoteShell::default());
println!(
"connected to controller at {}",
remote_shell::endpoint::LISTEN_ADDR
);
loop {
match frame_rx.recv_timeout(Duration::from_millis(25)) {
Ok(result) => {
let frame = result?;
let outcome = runtime.receive(&Ingress::Parent, frame)?;
remote_shell::endpoint::write_frames(&mut stream, &outcome.frames)?;
}
Err(RecvTimeoutError::Timeout) => {}
Err(RecvTimeoutError::Disconnected) => break,
}
let outcome = runtime.poll()?;
remote_shell::endpoint::write_frames(&mut stream, &outcome.frames)?;
}
Ok(())
}
fn agent_path() -> Vec<String> {
vec![String::from("agent")]
}
-86
View File
@@ -1,86 +0,0 @@
//! Remote shell controller example.
//!
//! This binary listens for the endpoint example, opens one remote shell session, sends a few
//! commands, and prints returned hook data until the shell closes.
use std::error::Error;
use std::net::TcpListener;
use unshell::leaves::remote_shell;
use unshell::leaves::remote_shell::OpenRequest;
use unshell::protocol::tree::encode_call_reply;
use unshell::protocol::tree::{
ChildRoute, Endpoint, EndpointOutcome, Ingress, LocalEvent, ProtocolEndpoint,
};
fn main() -> Result<(), Box<dyn Error>> {
let listener = TcpListener::bind(remote_shell::endpoint::LISTEN_ADDR)?;
println!("listening on {}", remote_shell::endpoint::LISTEN_ADDR);
let (mut stream, peer_addr) = listener.accept()?;
println!("accepted endpoint connection from {peer_addr}");
let frame_rx = remote_shell::endpoint::spawn_frame_reader(stream.try_clone()?);
let mut endpoint = ProtocolEndpoint::new(
Vec::new(),
None,
vec![ChildRoute::registered(agent_path())],
Vec::new(),
);
let hook_id = endpoint.allocate_hook_id();
let shell_leaf_name = remote_shell::endpoint::RemoteShell::protocol_leaf_name();
let open_procedure = remote_shell::endpoint::Open::protocol_procedure_id();
remote_shell::endpoint::send_forward(
&mut stream,
endpoint.send_call(
agent_path(),
Some(shell_leaf_name),
open_procedure.clone(),
Some(hook_id),
encode_call_reply(&OpenRequest).expect("remote shell open payload should encode"),
)?,
)?;
for (index, command) in ["pwd\n", "whoami\n", "exit\n"].iter().enumerate() {
remote_shell::endpoint::send_forward(
&mut stream,
endpoint.send_data(
agent_path(),
hook_id,
open_procedure.clone(),
command.as_bytes().to_vec(),
index == 2,
)?,
)?;
}
for result in frame_rx {
let frame = result?;
let outcome = endpoint.receive(&Ingress::Child(agent_path()), frame)?;
let EndpointOutcome::Local(event) = outcome else {
continue;
};
match event {
LocalEvent::Data { message, .. } => {
print!("{}", String::from_utf8_lossy(&message.data));
if message.end_hook {
break;
}
}
LocalEvent::Fault { message, .. } => {
eprintln!("received protocol fault: 0x{:02X}", message.fault.0);
break;
}
LocalEvent::Call { .. } => {}
}
}
Ok(())
}
fn agent_path() -> Vec<String> {
vec![String::from("agent")]
}
@@ -1,47 +0,0 @@
//! Smallest in-process `remote_shell` declaration example.
//!
//! This example hosts exactly one protocol endpoint with exactly one leaf and performs a local
//! introspection request against that leaf. The important detail is that the endpoint metadata is
//! taken from `remote_shell::endpoint::RemoteShellEndpoint::protocol_leaf_spec()`, which is
//! generated by the `leaf!` declaration in `unshell-leaves/src/remote_shell/mod.rs`.
//!
//! It does not open any sockets or spawn a shell process, so it is the easiest place to verify
//! that the shared compile-time leaf declaration and the generated endpoint host metadata line up.
use std::error::Error;
use unshell::create_endpoint;
use unshell::leaves::remote_shell;
use unshell::protocol::tree::{Endpoint, EndpointOutcome, LocalEvent, ProtocolEndpoint};
use unshell::protocol::{INTROSPECTION_PROCEDURE_ID, LeafIntrospection};
fn main() -> Result<(), Box<dyn Error>> {
let mut endpoint: ProtocolEndpoint =
create_endpoint!("agent", remote_shell::endpoint::RemoteShell::default());
let leaf_spec = remote_shell::endpoint::RemoteShell::protocol_leaf_spec();
let hook_id = endpoint.allocate_hook_id();
let outcome = endpoint.send_call(
Vec::new(),
Some(remote_shell::endpoint::RemoteShell::protocol_leaf_name()),
INTROSPECTION_PROCEDURE_ID,
Some(hook_id),
Vec::new(),
)?;
let EndpointOutcome::Local(LocalEvent::Data { message, .. }) = outcome else {
return Err("expected one local introspection response".into());
};
let payload = unshell::protocol::tree::decode_call_input::<LeafIntrospection>(&message.data)?;
println!(
"remote-shell examples normally listen on {}",
remote_shell::endpoint::LISTEN_ADDR
);
println!("endpoint id: {:?}", endpoint.local_id());
println!("endpoint path: {:?}", endpoint.path());
println!("declared leaf: {}", leaf_spec.name);
println!("leaf: {}", payload.leaf_name);
println!("procedures: {:?}", payload.procedures);
Ok(())
}
-692
View File
@@ -1,692 +0,0 @@
//! Crossbeam-channel-backed router leaf for in-process protocol simulations.
//!
//! This leaf owns parent/child transport links backed by `crossbeam_channel`, so
//! tests and examples can exercise full packet routing without opening real
//! sockets.
use std::collections::BTreeMap;
use crossbeam_channel::Sender;
use rkyv::{Archive, Deserialize, Serialize};
use unshell_protocol::FrameBytes;
use unshell_protocol::tree::{
CallLeaf, ChildRoute, Endpoint, Ingress, ProtocolEndpoint, RouterLeaf,
};
use crate::{leaf, procedures};
/// One inbound frame delivered across a simulated channel hop.
///
/// What it is: the transport envelope sent between in-process nodes when this
/// leaf forwards protocol traffic over `crossbeam_channel`.
///
/// Why it exists: routing needs both the encoded frame bytes and the ingress side
/// that the receiver should apply when validating source paths.
///
/// # Example
/// ```rust
/// use unshell_leaves::crossbeam_channel::CrossbeamEnvelope;
/// use unshell_leaves::protocol::{FrameBytes, tree::Ingress};
/// let envelope = CrossbeamEnvelope {
/// ingress: Ingress::Parent,
/// frame: FrameBytes::new(),
/// };
/// assert!(matches!(envelope.ingress, Ingress::Parent));
/// ```
#[derive(Debug, Clone)]
pub struct CrossbeamEnvelope {
/// Which side of the tree the receiving endpoint should treat this frame as coming from.
pub ingress: Ingress,
/// Encoded protocol frame bytes.
pub frame: FrameBytes,
}
/// Request payload for promoting or pruning one simulated connection.
///
/// What it is: the protocol payload shared by the `add_connection` and
/// `remove_connection` procedures.
///
/// Why it exists: the leaf only needs the peer endpoint path to decide whether the
/// connection is a direct parent edge or a direct child edge.
///
/// # Example
/// ```rust
/// use unshell_leaves::crossbeam_channel::ConnectionRequest;
/// let request = ConnectionRequest {
/// peer_path: vec!["agent".into(), "child".into()],
/// };
/// assert_eq!(request.peer_path.len(), 2);
/// ```
#[derive(Archive, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct ConnectionRequest {
/// Absolute endpoint path of the peer connection being managed.
pub peer_path: Vec<String>,
}
/// Machine-readable snapshot of the leaf's active simulated connections.
///
/// What it is: the reply payload returned by `get_connections`, `add_connection`,
/// and `remove_connection`.
///
/// Why it exists: connection-management procedures should return the resulting
/// topology immediately so tests and tooling can confirm what changed.
///
/// # Example
/// ```rust
/// use unshell_leaves::crossbeam_channel::ConnectionSnapshot;
/// let snapshot = ConnectionSnapshot {
/// parent: Some(vec!["agent".into()]),
/// children: vec![vec!["agent".into(), "child".into()]],
/// };
/// assert_eq!(snapshot.children.len(), 1);
/// ```
#[derive(Archive, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct ConnectionSnapshot {
/// The direct parent path, if this endpoint currently has one.
pub parent: Option<Vec<String>>,
/// The currently active direct child paths.
pub children: Vec<Vec<String>>,
}
/// Errors surfaced by the channel-backed router leaf.
///
/// What it is: the small, deterministic error set used by both the management
/// procedures and the transport forwarding hooks.
///
/// Why it exists: tests and examples need structured failures when a staged link is
/// missing, a path is not a direct neighbor, or a channel is already closed.
///
/// # Example
/// ```rust
/// use unshell_leaves::crossbeam_channel::CrossbeamChannelError;
/// let error = CrossbeamChannelError::MissingStagedConnection;
/// assert_eq!(error.to_string(), "missing staged connection");
/// ```
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CrossbeamChannelError {
/// The requested peer path does not have a staged sender ready to activate.
MissingStagedConnection,
/// The requested peer path is neither the direct parent nor a direct child.
InvalidPeerPath,
/// No active parent link exists for upstream forwarding.
MissingParentConnection,
/// No active child link exists for the requested child path.
MissingChildConnection,
/// The receiving side of the channel is already disconnected.
ChannelClosed,
}
impl core::fmt::Display for CrossbeamChannelError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::MissingStagedConnection => f.write_str("missing staged connection"),
Self::InvalidPeerPath => f.write_str("peer path is not a direct parent or child"),
Self::MissingParentConnection => f.write_str("missing parent connection"),
Self::MissingChildConnection => f.write_str("missing child connection"),
Self::ChannelClosed => f.write_str("channel receiver is disconnected"),
}
}
}
impl core::error::Error for CrossbeamChannelError {}
/// Shared compile-time declaration for the crossbeam-channel router leaf.
///
/// What it is: the public leaf declaration that owns the canonical leaf name and
/// exported management procedure ids for [`CrossbeamChannelLeaf`].
///
/// Why it exists: endpoint code, examples, and tests should all derive the same
/// protocol-facing metadata from one source of truth instead of hand-assembling
/// the leaf id and procedure inventory.
///
/// # Example
/// ```rust
/// use unshell_leaves::crossbeam_channel::CrossbeamChannel;
/// assert!(CrossbeamChannel::protocol_leaf_name().contains("crossbeam_channel"));
/// ```
#[leaf(
id = "org.unshell.v1.crossbeam_channel",
endpoint_struct = CrossbeamChannelLeaf,
procedures = ["add_connection", "remove_connection", "get_connections"]
)]
pub struct CrossbeamChannel;
/// In-process router leaf backed by `crossbeam_channel` senders.
///
/// What it is: a leaf host that stores one optional parent sender, any number of
/// child senders, and a staging area for connections that should only become live
/// after an explicit procedure call.
///
/// Why it exists: protocol tests need a realistic forwarding surface with parent
/// and child links, but opening TCP sockets would make those tests slower and more
/// brittle than necessary.
///
/// # Example
/// ```rust
/// use crossbeam_channel::unbounded;
/// use unshell_leaves::crossbeam_channel::CrossbeamChannelLeaf;
/// let (tx, _rx) = unbounded();
/// let mut leaf = CrossbeamChannelLeaf::default();
/// let previous = leaf.stage_connection(vec!["agent".into()], tx);
/// assert!(previous.is_none());
/// ```
#[derive(Default)]
pub struct CrossbeamChannelLeaf {
parent: Option<ChannelConnection>,
children: BTreeMap<Vec<String>, Sender<CrossbeamEnvelope>>,
child_routes: Vec<ChildRoute>,
staged: BTreeMap<Vec<String>, Sender<CrossbeamEnvelope>>,
}
#[derive(Debug, Clone)]
struct ChannelConnection {
path: Vec<String>,
sender: Sender<CrossbeamEnvelope>,
}
impl CrossbeamChannelLeaf {
/// Stages one channel sender so a later protocol procedure can activate it.
///
/// What it is: a bootstrap helper that prepares the transport handle before the
/// leaf promotes it into active routing state.
///
/// Why it exists: the sender itself is not a serializable protocol payload, so
/// tests and examples need a local way to install it before calling
/// `add_connection`.
pub fn stage_connection(
&mut self,
peer_path: Vec<String>,
sender: Sender<CrossbeamEnvelope>,
) -> Option<Sender<CrossbeamEnvelope>> {
self.staged.insert(peer_path, sender)
}
/// Promotes one staged connection into the active topology.
///
/// This is the same operation used by the public `add_connection` procedure,
/// but it is also useful for local bootstrap code that has not yet wired the
/// control plane needed to issue that call remotely.
pub fn connect_staged(
&mut self,
endpoint: &mut ProtocolEndpoint,
peer_path: Vec<String>,
) -> Result<ConnectionSnapshot, CrossbeamChannelError> {
if !is_direct_parent(endpoint.path(), &peer_path)
&& !is_direct_child(endpoint.path(), &peer_path)
{
return Err(CrossbeamChannelError::InvalidPeerPath);
}
let Some(sender) = self.staged.remove(&peer_path) else {
return Err(CrossbeamChannelError::MissingStagedConnection);
};
if is_direct_parent(endpoint.path(), &peer_path) {
self.parent = Some(ChannelConnection {
path: peer_path.clone(),
sender,
});
endpoint
.set_parent_path(Some(peer_path))
.map_err(|_| CrossbeamChannelError::InvalidPeerPath)?;
return Ok(ConnectionSnapshot::from_endpoint(endpoint));
}
if is_direct_child(endpoint.path(), &peer_path) {
self.children.insert(peer_path.clone(), sender);
self.sync_child_routes();
endpoint
.upsert_child_route(ChildRoute::registered(peer_path))
.map_err(|_| CrossbeamChannelError::InvalidPeerPath)?;
return Ok(ConnectionSnapshot::from_endpoint(endpoint));
}
unreachable!("direct-neighbor validation returned early above")
}
/// Removes one active connection and returns it to the staged set.
pub fn disconnect(
&mut self,
endpoint: &mut ProtocolEndpoint,
peer_path: &[String],
) -> Result<ConnectionSnapshot, CrossbeamChannelError> {
if !is_direct_parent(endpoint.path(), peer_path)
&& !is_direct_child(endpoint.path(), peer_path)
{
return Err(CrossbeamChannelError::InvalidPeerPath);
}
if self
.parent
.as_ref()
.is_some_and(|parent| parent.path == peer_path)
{
let Some(parent) = self.parent.take() else {
return Err(CrossbeamChannelError::MissingParentConnection);
};
self.staged.insert(parent.path, parent.sender);
endpoint
.set_parent_path(None)
.map_err(|_| CrossbeamChannelError::InvalidPeerPath)?;
return Ok(ConnectionSnapshot::from_endpoint(endpoint));
}
let Some(sender) = self.children.remove(peer_path) else {
return Err(CrossbeamChannelError::MissingChildConnection);
};
self.staged.insert(peer_path.to_vec(), sender);
self.sync_child_routes();
endpoint.remove_child_route(peer_path);
Ok(ConnectionSnapshot::from_endpoint(endpoint))
}
fn sync_child_routes(&mut self) {
self.child_routes = self
.children
.keys()
.cloned()
.map(ChildRoute::registered)
.collect();
}
}
impl ConnectionSnapshot {
fn from_endpoint(endpoint: &ProtocolEndpoint) -> Self {
Self {
parent: endpoint.parent_path().map(<[String]>::to_vec),
children: endpoint
.child_routes()
.iter()
.map(|child| child.path.clone())
.collect(),
}
}
}
#[procedures(error = CrossbeamChannelError)]
impl CrossbeamChannelLeaf {
#[call]
fn add_connection(
&mut self,
endpoint: &mut ProtocolEndpoint,
request: ConnectionRequest,
) -> Result<ConnectionSnapshot, CrossbeamChannelError> {
self.connect_staged(endpoint, request.peer_path)
}
#[call]
fn remove_connection(
&mut self,
endpoint: &mut ProtocolEndpoint,
request: ConnectionRequest,
) -> Result<ConnectionSnapshot, CrossbeamChannelError> {
self.disconnect(endpoint, &request.peer_path)
}
#[call]
fn get_connections(&mut self, endpoint: &ProtocolEndpoint) -> ConnectionSnapshot {
ConnectionSnapshot::from_endpoint(endpoint)
}
}
impl CallLeaf for CrossbeamChannelLeaf {
type Error = CrossbeamChannelError;
}
impl RouterLeaf for CrossbeamChannelLeaf {
type RouteError = CrossbeamChannelError;
fn parent_path(&self) -> Option<&[String]> {
self.parent.as_ref().map(|parent| parent.path.as_slice())
}
fn child_routes(&self) -> &[ChildRoute] {
&self.child_routes
}
fn route_to_parent(
&mut self,
local_path: &[String],
frame: FrameBytes,
) -> Result<(), Self::RouteError> {
let Some(parent) = &self.parent else {
return Err(CrossbeamChannelError::MissingParentConnection);
};
parent
.sender
.send(CrossbeamEnvelope {
ingress: Ingress::Child(local_path.to_vec()),
frame,
})
.map_err(|_| CrossbeamChannelError::ChannelClosed)
}
fn route_to_child(
&mut self,
child_path: &[String],
frame: FrameBytes,
) -> Result<(), Self::RouteError> {
let Some(sender) = self.children.get(child_path) else {
return Err(CrossbeamChannelError::MissingChildConnection);
};
sender
.send(CrossbeamEnvelope {
ingress: Ingress::Parent,
frame,
})
.map_err(|_| CrossbeamChannelError::ChannelClosed)
}
}
fn is_direct_parent(local_path: &[String], peer_path: &[String]) -> bool {
local_path
.split_last()
.is_some_and(|(_, parent_path)| parent_path == peer_path)
}
fn is_direct_child(local_path: &[String], peer_path: &[String]) -> bool {
peer_path.len() == local_path.len() + 1 && peer_path.starts_with(local_path)
}
#[cfg(test)]
mod tests {
use crossbeam_channel::{Receiver, unbounded};
use unshell_protocol::decode_frame;
use unshell_protocol::tree::{
Endpoint, EndpointOutcome, LeafRuntime, decode_call_input, encode_call_reply,
};
use super::*;
fn path(parts: &[&str]) -> Vec<String> {
parts.iter().map(|part| (*part).to_owned()).collect()
}
struct ChannelNode {
runtime: LeafRuntime<CrossbeamChannelLeaf>,
rx: Receiver<CrossbeamEnvelope>,
}
impl ChannelNode {
fn new(path: Vec<String>) -> (Self, Sender<CrossbeamEnvelope>) {
let (tx, rx) = unbounded();
let endpoint = ProtocolEndpoint::new(
path,
None,
Vec::new(),
vec![CrossbeamChannelLeaf::protocol_leaf_spec()],
);
(
Self {
runtime: LeafRuntime::new(endpoint, CrossbeamChannelLeaf::default()),
rx,
},
tx,
)
}
fn drain(&mut self) -> usize {
let mut processed = 0usize;
while let Ok(envelope) = self.rx.try_recv() {
let outcome = self
.runtime
.receive_routed(&envelope.ingress, envelope.frame)
.expect("node should process routed frame");
self.runtime
.route_forwarded(outcome.forwarded)
.expect("router leaf should forward emitted frames");
processed += 1;
}
processed
}
fn stage_connection(&mut self, peer_path: Vec<String>, sender: Sender<CrossbeamEnvelope>) {
let _ = self.runtime.leaf_mut().stage_connection(peer_path, sender);
}
fn connect_staged(&mut self, peer_path: Vec<String>) {
let snapshot = {
let runtime = &mut self.runtime;
let mut leaf = core::mem::take(runtime.leaf_mut());
let result = leaf.connect_staged(runtime.endpoint_mut(), peer_path);
*runtime.leaf_mut() = leaf;
result
};
snapshot.expect("staged connection should activate");
}
}
#[test]
fn crossbeam_channel_leaf_routes_calls_and_replies_across_parent_and_child_links() {
let (mut agent, root_to_agent) = ChannelNode::new(path(&["agent"]));
let (mut child, agent_to_child) = ChannelNode::new(path(&["agent", "child"]));
let (agent_to_root, root_rx) = unbounded();
let mut root = ProtocolEndpoint::new(
Vec::new(),
None,
vec![ChildRoute::registered(path(&["agent"]))],
Vec::new(),
);
agent.stage_connection(Vec::new(), agent_to_root);
agent.connect_staged(Vec::new());
child.stage_connection(path(&["agent"]), root_to_agent.clone());
child.connect_staged(path(&["agent"]));
agent.stage_connection(path(&["agent", "child"]), agent_to_child);
let hook_id = root.allocate_hook_id();
let add_connection = root
.send_call(
path(&["agent"]),
Some(CrossbeamChannelLeaf::protocol_leaf_name()),
CrossbeamChannelLeaf::protocol_procedure_id("add_connection")
.expect("procedure should exist"),
Some(hook_id),
encode_call_reply(&ConnectionRequest {
peer_path: path(&["agent", "child"]),
})
.expect("request should encode"),
)
.expect("root should build add-connection call");
let EndpointOutcome::Forward { frame, .. } = add_connection else {
panic!("root should forward add-connection call");
};
root_to_agent
.send(CrossbeamEnvelope {
ingress: Ingress::Parent,
frame,
})
.expect("root should deliver frame to agent");
for _ in 0..8 {
let mut progress = 0usize;
progress += agent.drain();
progress += child.drain();
while let Ok(envelope) = root_rx.try_recv() {
let outcome = root
.receive(&envelope.ingress, envelope.frame)
.expect("root should accept reply frame");
if let EndpointOutcome::Local(local) = outcome {
match local {
unshell_protocol::tree::LocalEvent::Data { .. }
| unshell_protocol::tree::LocalEvent::Fault { .. } => {}
unshell_protocol::tree::LocalEvent::Call { .. } => {}
}
}
progress += 1;
}
if progress == 0 {
break;
}
}
assert_eq!(agent.runtime.endpoint().child_routes().len(), 1);
let query_hook = root.allocate_hook_id();
let query = root
.send_call(
path(&["agent", "child"]),
Some(CrossbeamChannelLeaf::protocol_leaf_name()),
CrossbeamChannelLeaf::protocol_procedure_id("get_connections")
.expect("procedure should exist"),
Some(query_hook),
encode_call_reply(&()).expect("unit request should encode"),
)
.expect("root should build query call");
let EndpointOutcome::Forward { frame, .. } = query else {
panic!("root should forward query call");
};
root_to_agent
.send(CrossbeamEnvelope {
ingress: Ingress::Parent,
frame,
})
.expect("root should deliver query to agent");
let mut reply = None;
for _ in 0..12 {
let mut progress = 0usize;
progress += agent.drain();
progress += child.drain();
while let Ok(envelope) = root_rx.try_recv() {
let outcome = root
.receive(&envelope.ingress, envelope.frame)
.expect("root should accept routed reply");
if let EndpointOutcome::Local(unshell_protocol::tree::LocalEvent::Data {
message,
..
}) = outcome
{
reply = Some(
decode_call_input::<ConnectionSnapshot>(message.data.as_slice())
.expect("reply payload should decode"),
);
}
progress += 1;
}
if reply.is_some() || progress == 0 {
break;
}
}
let reply = reply.expect("root should receive child connection snapshot");
assert_eq!(reply.parent, Some(path(&["agent"])));
assert!(reply.children.is_empty());
let remove_hook = root.allocate_hook_id();
let remove = root
.send_call(
path(&["agent"]),
Some(CrossbeamChannelLeaf::protocol_leaf_name()),
CrossbeamChannelLeaf::protocol_procedure_id("remove_connection")
.expect("procedure should exist"),
Some(remove_hook),
encode_call_reply(&ConnectionRequest {
peer_path: path(&["agent", "child"]),
})
.expect("request should encode"),
)
.expect("root should build remove-connection call");
let EndpointOutcome::Forward { frame, .. } = remove else {
panic!("root should forward remove-connection call");
};
root_to_agent
.send(CrossbeamEnvelope {
ingress: Ingress::Parent,
frame,
})
.expect("root should deliver removal call to agent");
for _ in 0..8 {
let mut progress = 0usize;
progress += agent.drain();
progress += child.drain();
while let Ok(envelope) = root_rx.try_recv() {
let _ = root
.receive(&envelope.ingress, envelope.frame)
.expect("root should process removal reply");
progress += 1;
}
if progress == 0 {
break;
}
}
assert!(agent.runtime.endpoint().child_routes().is_empty());
let final_hook = root.allocate_hook_id();
let dropped = root
.send_call(
path(&["agent", "child"]),
Some(CrossbeamChannelLeaf::protocol_leaf_name()),
CrossbeamChannelLeaf::protocol_procedure_id("get_connections")
.expect("procedure should exist"),
Some(final_hook),
encode_call_reply(&()).expect("unit request should encode"),
)
.expect("query call should encode after removal");
assert!(matches!(dropped, EndpointOutcome::Forward { .. }));
if let EndpointOutcome::Forward { frame, .. } = dropped {
root_to_agent
.send(CrossbeamEnvelope {
ingress: Ingress::Parent,
frame,
})
.expect("root should still reach the agent");
}
let mut saw_reply = false;
for _ in 0..8 {
let mut progress = 0usize;
progress += agent.drain();
progress += child.drain();
while let Ok(envelope) = root_rx.try_recv() {
progress += 1;
if let EndpointOutcome::Local(unshell_protocol::tree::LocalEvent::Data {
message,
..
}) = root
.receive(&envelope.ingress, envelope.frame)
.expect("root should process any late reply")
{
let _ = decode_frame(message.data.as_slice());
saw_reply = true;
}
}
if progress == 0 {
break;
}
}
assert!(
!saw_reply,
"removed child route should stop forwarded replies"
);
}
#[test]
fn invalid_add_connection_keeps_staged_sender_available_for_retry() {
let (tx, _rx) = unbounded();
let mut leaf = CrossbeamChannelLeaf::default();
let mut endpoint = ProtocolEndpoint::new(path(&["agent"]), None, Vec::new(), Vec::new());
leaf.stage_connection(path(&["elsewhere"]), tx);
let error = leaf
.connect_staged(&mut endpoint, path(&["elsewhere"]))
.expect_err("non-neighbor path should fail");
assert_eq!(error, CrossbeamChannelError::InvalidPeerPath);
assert!(leaf.staged.contains_key(&path(&["elsewhere"])));
}
#[test]
fn invalid_remove_connection_reports_invalid_peer_path() {
let mut leaf = CrossbeamChannelLeaf::default();
let mut endpoint = ProtocolEndpoint::new(path(&["agent"]), None, Vec::new(), Vec::new());
let error = leaf
.disconnect(&mut endpoint, &path(&["not", "a", "neighbor"]))
.expect_err("non-neighbor removal should fail");
assert_eq!(error, CrossbeamChannelError::InvalidPeerPath);
}
}
-135
View File
@@ -1,136 +1 @@
//! Application-layer leaves and user-facing surfaces built on top of the UnShell
//! protocol runtime.
//!
//! Each leaf module always exports its shared protocol-facing types. Role-specific
//! implementations are selected with the crate-wide `leaf_endpoint` and `leaf_tui`
//! features, and can optionally be re-exported behind one stable alias.
#[allow(unused_extern_crates)]
extern crate self as unshell;
pub extern crate alloc;
use unshell_protocol::DataMessage;
pub use unshell_macros::{Procedure, leaf, procedures};
pub use unshell_protocol as protocol;
/// Re-exports one role-specific type behind a stable public alias.
///
/// What it is: a small macro that binds one public type alias to either an
/// endpoint-facing leaf host or a TUI-facing leaf host based on active features.
///
/// Why it exists: downstream code should be able to import one stable name such as
/// `RemoteShell` without caring which concrete role implementation was compiled for
/// the current binary.
///
/// # Example
/// ```rust
/// use unshell_leaves::role_leaf;
/// mod endpoint { pub struct DemoEndpoint; }
/// mod tui { pub struct DemoTui; }
/// # #[cfg(not(all(feature = "leaf_endpoint", feature = "leaf_tui")))]
/// role_leaf! {
/// pub type DemoLeaf {
/// endpoint => endpoint::DemoEndpoint,
/// tui => tui::DemoTui,
/// }
/// }
/// # #[cfg(all(feature = "leaf_endpoint", not(feature = "leaf_tui")))]
/// # let _ = core::marker::PhantomData::<DemoLeaf>;
/// ```
#[macro_export]
macro_rules! role_leaf {
(
$(#[$meta:meta])*
$vis:vis type $alias:ident {
endpoint => $endpoint:path,
tui => $tui:path $(,)?
}
) => {
#[cfg(all(feature = "leaf_endpoint", feature = "leaf_tui"))]
compile_error!(concat!(
"`",
stringify!($alias),
"` can only alias one concrete role at a time; enable either `leaf_endpoint` or `leaf_tui`, not both"
));
#[cfg(feature = "leaf_endpoint")]
$(#[$meta])*
$vis type $alias = $endpoint;
#[cfg(all(not(feature = "leaf_endpoint"), feature = "leaf_tui"))]
$(#[$meta])*
$vis type $alias = $tui;
};
}
/// Minimal leaf-specific TUI contract.
///
/// What it is: the smallest public trait a leaf-specific user interface needs in
/// order to consume protocol `DataMessage` values and render a textual frame.
///
/// Why it exists: leaf UIs should remain transport-agnostic and renderer-agnostic,
/// so callers can experiment with CLIs and TUIs without coupling the core leaf API
/// to any one terminal framework.
///
/// # Example
/// ```rust
/// use unshell_leaves::{LeafTui, TuiError};
/// use unshell_leaves::protocol::DataMessage;
/// struct DemoTui;
/// impl LeafTui for DemoTui {
/// fn leaf_name(&self) -> String { "org.example.v1.demo".into() }
/// fn handle_data(&mut self, _message: &DataMessage) -> Result<(), TuiError> { Ok(()) }
/// fn render(&self) -> String { String::from("demo") }
/// }
/// assert_eq!(DemoTui.render(), "demo");
/// ```
pub trait LeafTui {
/// Returns the canonical protocol leaf name this UI understands.
fn leaf_name(&self) -> String;
/// Applies one inbound hook payload to the local UI state.
fn handle_data(&mut self, message: &DataMessage) -> Result<(), TuiError>;
/// Produces the current textual frame for the leaf.
fn render(&self) -> String;
}
/// Lightweight error used by the leaf TUI surface.
///
/// What it is: a small owned-string error for UI adapters built on [`LeafTui`].
///
/// Why it exists: the TUI surface should not force downstream UIs into a heavier
/// error dependency just to report leaf-local rendering or decoding failures.
///
/// # Example
/// ```rust
/// use unshell_leaves::TuiError;
/// let error = TuiError::new("invalid frame");
/// assert_eq!(error.to_string(), "invalid frame");
/// ```
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TuiError {
message: String,
}
impl TuiError {
/// Creates one UI-surface error from owned text.
pub fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
}
impl core::fmt::Display for TuiError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str(&self.message)
}
}
impl core::error::Error for TuiError {}
pub mod crossbeam_channel;
pub mod remote_shell;
@@ -1,65 +0,0 @@
//! PTY-backed endpoint implementation for the remote shell leaf.
mod errors;
mod session;
mod transport;
use std::collections::BTreeMap;
use unshell::protocol::tree::{Call, HookKey, Procedure, ProcedureEffect, ProcedureStore};
pub use errors::ShellLeafError;
pub use session::Open;
pub use transport::{LISTEN_ADDR, send_forward, spawn_frame_reader, write_frames};
use super::OpenRequest;
/// Leaf state for the remote shell endpoint runtime.
///
/// The endpoint keeps each live shell session in an explicit map keyed by the
/// caller-owned hook identity. That makes ownership and cleanup of hook-backed
/// shell processes easy to inspect during debugging.
#[derive(Default)]
pub struct RemoteShell {
sessions: BTreeMap<HookKey, Open>,
}
impl ProcedureStore<Open> for RemoteShell {
fn procedure_sessions(&mut self) -> &mut BTreeMap<HookKey, Open> {
&mut self.sessions
}
}
impl Procedure<RemoteShell> for Open {
type Error = ShellLeafError;
type Input = OpenRequest;
fn open(_leaf: &mut RemoteShell, call: Call<Self::Input>) -> Result<Self, Self::Error> {
let hook_key = call.response_hook.ok_or(ShellLeafError::MissingHook)?;
Open::spawn(hook_key.return_path, hook_key.hook_id, call.procedure_id)
}
fn on_data(
_leaf: &mut RemoteShell,
session: &mut Self,
data: unshell::protocol::tree::IncomingData,
) -> Result<ProcedureEffect, Self::Error> {
session.on_data(data)
}
fn on_fault(
_leaf: &mut RemoteShell,
_session: &mut Self,
_fault: unshell::protocol::tree::IncomingFault,
) -> Result<(), Self::Error> {
Ok(())
}
fn poll(_leaf: &mut RemoteShell, session: &mut Self) -> Result<ProcedureEffect, Self::Error> {
session.poll()
}
fn close(_leaf: &mut RemoteShell, mut session: Self) -> Result<(), Self::Error> {
session.terminate()
}
}
@@ -1,28 +0,0 @@
use std::fmt;
use std::io;
/// Error produced by the remote shell endpoint implementation.
#[derive(Debug)]
pub enum ShellLeafError {
/// Underlying PTY or I/O failure.
Io(io::Error),
/// Shell open requires a response hook so the session can stream bytes back.
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)
}
}
@@ -1,289 +0,0 @@
//! Per-hook remote shell session lifecycle.
//!
//! A session opens one PTY-backed shell process and translates protocol hook
//! traffic into stdin writes and stdout or stderr chunks. Close is intentionally
//! two-sided: the peer signals input completion with `end_hook`, while the local
//! side closes only after the child exits and the PTY reader drains.
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::Procedure;
use unshell::protocol::tree::{IncomingData, OutgoingData, ProcedureEffect};
use super::RemoteShell;
use super::errors::ShellLeafError;
/// Per-hook shell session created by the `open` procedure.
///
/// The procedure type is also the stored session type so the mapping between
/// one opening procedure and one live hook remains direct and visible.
#[derive(Procedure)]
#[procedure(leaf = RemoteShell, name = "open")]
pub struct Open {
/// 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,
}
impl Open {
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
// buffered PTY output can drain through the normal poll path.
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 Open {
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;
}
}
}
});
}
@@ -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;
/// TCP listen address used by the remote shell examples.
pub const LISTEN_ADDR: &str = "127.0.0.1:4444";
const MAX_FRAME_BYTES: usize = 1024 * 1024;
/// Writes the forwarded frame produced by one endpoint outcome.
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, &[]),
}
}
/// Writes one or more framed packets onto the example TCP 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(())
}
/// Spawns the example frame reader that lifts prefixed frames off the TCP stream.
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];
stream.read_exact(&mut bytes)?;
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))
}
-26
View File
@@ -1,26 +0,0 @@
//! Remote shell leaf and its user-facing surfaces.
//!
//! The module always exports the protocol contract for the leaf together with the
//! endpoint and TUI host implementations.
use rkyv::{Archive, Deserialize, Serialize};
use unshell_macros::leaf;
pub mod endpoint;
pub mod tui;
/// Open-request payload for the remote shell leaf.
///
/// The shell currently needs no structured arguments, but a named payload type is
/// easier for downstream code to discover than a bare `()`.
#[derive(Archive, Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
pub struct OpenRequest;
#[leaf(
name = "remote_shell",
procedures = [Open],
endpoint = endpoint,
tui = tui,
)]
/// Shared compile-time declaration for the `remote_shell` leaf surface.
pub struct RemoteShell;
-48
View File
@@ -1,48 +0,0 @@
//! Placeholder client-side TUI surface for the remote shell leaf.
//!
//! The first application-layer consumer will be a CLI and later a full GUI. This
//! stub keeps the leaf-specific interpretation point in place without forcing a
//! rendering-library decision yet.
use std::string::String;
use std::vec::Vec;
use unshell::protocol::DataMessage;
use unshell_macros::Procedure;
use crate::{LeafTui, TuiError};
/// Stub TUI surface for the remote shell leaf.
#[derive(Default)]
pub struct RemoteShell {
transcript: Vec<u8>,
}
impl RemoteShell {
/// Returns a short explanation of the current stub status.
pub fn status_line(&self) -> &'static str {
"remote shell TUI stub: rendering is placeholder-only for now"
}
}
impl LeafTui for RemoteShell {
fn leaf_name(&self) -> String {
Self::protocol_leaf_name()
}
fn handle_data(&mut self, message: &DataMessage) -> Result<(), TuiError> {
self.transcript.extend_from_slice(&message.data);
Ok(())
}
fn render(&self) -> String {
let body = String::from_utf8_lossy(&self.transcript);
format!("{}\n\n{}", self.status_line(), body)
}
}
/// TUI-side placeholder procedure symbol for the shared `remote_shell` leaf
/// declaration.
#[derive(Procedure)]
#[procedure(leaf = RemoteShell, name = "open")]
pub struct Open {}
-109
View File
@@ -1,109 +0,0 @@
# UnShell Macros
This crate owns the compile-time declaration layer for UnShell application-facing
leaves.
## Purpose
The protocol crate intentionally stays generic: it knows how to route packets,
validate framing, and deliver local events, but it should not need handwritten
registration code for every leaf.
The macro layer exists to move as much of that registration work as possible to
compile time.
In practical terms, the macro system is responsible for:
- deriving canonical procedure identifiers
- generating compile-time procedure inventories for leaves
- binding one leaf declaration to separate endpoint and TUI host modules without
repeating the metadata on each host
- generating dispatch glue for simple call-driven leaves
## Model
There are three layers in the intended design.
### 1. Leaf declaration
One declaration is the source of truth for one protocol leaf.
The declaration answers:
- what is this leaf called on the wire?
- which procedure suffixes belong to it?
- which host modules implement its endpoint and TUI roles?
The goal is that this information is written once and reused everywhere.
### 2. Host structs
One leaf can have multiple host structs with different responsibilities.
- the endpoint host owns runtime state and protocol-side behavior
- the TUI host owns user-interface state and interpretation behavior
Those hosts should not each have to repeat the leaf name or procedure inventory.
They bind to the declaration instead.
The current convention is module-based. A declaration such as:
```rust
#[leaf(
name = "remote_shell",
procedures = [Open],
endpoint = endpoint,
tui = tui,
)]
pub struct RemoteShell;
```
means:
- the endpoint host type is inferred as `endpoint::RemoteShell`
- the TUI host type is inferred as `tui::RemoteShell`
- type-based procedure metadata is resolved from the endpoint module as
`endpoint::Open`
This convention removes repeated host type paths from the declaration while still
keeping the generated code deterministic and inspectable.
### 3. Procedure and method metadata
Procedures and future typed remote methods need stable canonical identifiers.
The macro layer generates those identifiers from the leaf declaration and the
local suffix for each procedure or method. That lets the runtime consume a
compile-time inventory instead of handwritten lists.
## Current direction
The public declaration model is now centered on `#[leaf(...)]`.
- `#[leaf(...)]` declares the canonical protocol surface once
- `#[derive(Procedure)]` derives stateful procedure metadata
- `#[procedures]` derives one-shot call dispatch for simple leaves
The next evolution from here is typed remote-method metadata on top of the same
declaration model.
## Design constraints
The system is optimized for a few constraints that matter to this repository.
- compile-time declaration should replace handwritten runtime registration where
possible
- protocol-visible names should remain deterministic and canonical
- generated code should stay explicit enough to debug
- endpoint and TUI roles should share metadata but not be forced into the same
runtime trait when their behavior differs
- host inference should stay convention-based instead of discovery-based so a
declaration can be understood from its source without macro expansion tools
- migration should be low-breakage for the existing examples and tests
## Non-goals
This crate does not own transport, connection management, or packet execution.
Those remain in `unshell-protocol` and higher application layers.
The macro crate should generate metadata and glue, not hide the runtime model.
-13
View File
@@ -1,13 +0,0 @@
[package]
name = "unshell-macros"
version.workspace = true
edition.workspace = true
description = "Proc macros for unshell leaf declarations"
[lib]
proc-macro = true
[dependencies]
syn = { workspace = true, features = ["full"] }
quote = { workspace = true }
proc-macro2 = { workspace = true }
-348
View File
@@ -1,348 +0,0 @@
use proc_macro2::TokenStream;
use quote::{format_ident, quote};
use syn::{
Error, ItemStruct, LitStr, Path, Result, Token,
parse::{Parse, ParseStream},
punctuated::Punctuated,
};
use crate::utils::option_litstr_tokens;
pub(crate) struct LeafDeclarationAttributes {
name: Option<LitStr>,
id: Option<LitStr>,
org: Option<LitStr>,
product: Option<LitStr>,
version: Option<LitStr>,
procedures: Vec<ProcedureRef>,
host_bindings: Vec<HostBinding>,
}
impl Parse for LeafDeclarationAttributes {
fn parse(input: ParseStream<'_>) -> Result<Self> {
let assignments = Punctuated::<LeafAssignment, Token![,]>::parse_terminated(input)?;
let mut parsed = Self {
name: None,
id: None,
org: None,
product: None,
version: None,
procedures: Vec::new(),
host_bindings: Vec::new(),
};
for assignment in assignments {
match assignment {
LeafAssignment::Name(value) => set_once(&mut parsed.name, value, "leaf name")?,
LeafAssignment::Id(value) => set_once(&mut parsed.id, value, "leaf id")?,
LeafAssignment::Org(value) => set_once(&mut parsed.org, value, "leaf org")?,
LeafAssignment::Product(value) => {
set_once(&mut parsed.product, value, "leaf product")?
}
LeafAssignment::Version(value) => {
set_once(&mut parsed.version, value, "leaf version")?
}
LeafAssignment::Procedures(values) => {
if !parsed.procedures.is_empty() {
return Err(Error::new(input.span(), "duplicate procedures list"));
}
parsed.procedures = values;
}
LeafAssignment::HostBinding(binding) => parsed.host_bindings.push(binding),
}
}
if parsed.name.is_none() && parsed.id.is_none() {
return Err(Error::new(
input.span(),
"#[leaf(...)] requires either `name = \"...\"` or `id = \"...\"`",
));
}
if parsed.host_bindings.is_empty() {
return Err(Error::new(
input.span(),
"#[leaf(...)] requires at least one host binding",
));
}
Ok(parsed)
}
}
enum LeafAssignment {
Name(LitStr),
Id(LitStr),
Org(LitStr),
Product(LitStr),
Version(LitStr),
Procedures(Vec<ProcedureRef>),
HostBinding(HostBinding),
}
struct HostBinding {
module_path: Option<Path>,
host_path: Option<Path>,
}
enum ProcedureRef {
Symbol(Path),
Suffix(LitStr),
}
impl Parse for ProcedureRef {
fn parse(input: ParseStream<'_>) -> Result<Self> {
if input.peek(LitStr) {
return Ok(Self::Suffix(input.parse()?));
}
Ok(Self::Symbol(input.parse()?))
}
}
impl Parse for LeafAssignment {
fn parse(input: ParseStream<'_>) -> Result<Self> {
let name: Path = input.parse()?;
input.parse::<Token![=]>()?;
let key = name
.get_ident()
.ok_or_else(|| Error::new_spanned(&name, "leaf keys must be identifiers"))?
.to_string();
match key.as_str() {
"name" => Ok(Self::Name(input.parse()?)),
"id" => Ok(Self::Id(input.parse()?)),
"org" => Ok(Self::Org(input.parse()?)),
"product" => Ok(Self::Product(input.parse()?)),
"version" => Ok(Self::Version(input.parse()?)),
"endpoint_struct" | "tui_struct" => Ok(Self::HostBinding(HostBinding {
module_path: None,
host_path: Some(input.parse()?),
})),
"endpoint" | "tui" => Ok(Self::HostBinding(HostBinding {
module_path: Some(input.parse()?),
host_path: None,
})),
"procedures" => {
let content;
syn::bracketed!(content in input);
let values = Punctuated::<ProcedureRef, Token![,]>::parse_terminated(&content)?
.into_iter()
.collect::<Vec<_>>();
Ok(Self::Procedures(values))
}
_ => Err(Error::new_spanned(
name,
"unsupported #[leaf(...)] key; expected one of name, id, org, product, version, procedures, endpoint, tui, endpoint_struct, or tui_struct",
)),
}
}
}
pub(crate) fn expand_leaf_declaration(
attr: LeafDeclarationAttributes,
item: ItemStruct,
) -> Result<TokenStream> {
let declaration_ident = item.ident.clone();
let id = option_litstr_tokens(attr.id.as_ref());
let org = option_litstr_tokens(attr.org.as_ref());
let product = option_litstr_tokens(attr.product.as_ref());
let version = option_litstr_tokens(attr.version.as_ref());
let leaf_name = option_litstr_tokens(attr.name.as_ref());
let canonical_procedure_module = attr
.host_bindings
.iter()
.find_map(|binding| binding.module_path.as_ref())
.cloned();
let procedure_suffixes = attr
.procedures
.iter()
.map(|procedure| procedure_suffix_tokens(procedure, canonical_procedure_module.as_ref()))
.collect::<Result<Vec<_>>>()?;
let procedure_type_checks = attr
.host_bindings
.iter()
.map(|binding| procedure_type_check_tokens(binding, &attr.procedures, &declaration_ident))
.collect::<Result<Vec<_>>>()?;
let host_impls = attr
.host_bindings
.iter()
.map(|binding| expand_binding_impl(binding, &declaration_ident))
.collect::<Result<Vec<_>>>()?;
Ok(quote! {
#item
impl ::unshell::protocol::tree::ProtocolLeaf for #declaration_ident {
fn leaf_name() -> ::unshell::alloc::string::String {
::unshell::protocol::tree::derive_leaf_name(
::core::env!("CARGO_PKG_NAME"),
::core::env!("CARGO_PKG_VERSION_MAJOR"),
::core::env!("CARGO_PKG_VERSION_MINOR"),
::core::env!("CARGO_PKG_VERSION_PATCH"),
::core::module_path!(),
::core::stringify!(#declaration_ident),
#org,
#product,
#version,
#leaf_name,
#id,
)
}
}
impl ::unshell::protocol::tree::LeafDeclaration for #declaration_ident {
fn procedure_suffixes() -> &'static [&'static str] {
&[#(#procedure_suffixes),*]
}
}
impl #declaration_ident {
/// Returns the canonical dotted leaf name declared for this surface.
pub fn protocol_leaf_name() -> ::unshell::alloc::string::String {
<Self as ::unshell::protocol::tree::ProtocolLeaf>::leaf_name()
}
/// Returns the canonical protocol leaf metadata for this surface.
pub fn protocol_leaf_spec() -> ::unshell::protocol::tree::LeafSpec {
<Self as ::unshell::protocol::tree::LeafDeclaration>::leaf_spec()
}
/// Resolves one local procedure suffix to its full canonical `procedure_id`.
pub fn protocol_procedure_id(
suffix: &str,
) -> ::core::option::Option<::unshell::alloc::string::String> {
<Self as ::unshell::protocol::tree::LeafDeclaration>::procedure_id(suffix)
}
}
const _: fn() = || {
#(#procedure_type_checks)*
};
#(#host_impls)*
})
}
fn expand_binding_impl(binding: &HostBinding, declaration: &syn::Ident) -> Result<TokenStream> {
let host = host_path_for_binding(binding, declaration)?;
Ok(quote! {
impl ::unshell::protocol::tree::ProtocolLeaf for #host {
fn leaf_name() -> ::unshell::alloc::string::String {
<#declaration as ::unshell::protocol::tree::ProtocolLeaf>::leaf_name()
}
}
impl ::unshell::protocol::tree::LeafBinding for #host {
type Declaration = #declaration;
}
impl ::unshell::protocol::tree::LeafDeclaration for #host {
fn procedure_suffixes() -> &'static [&'static str] {
<#declaration as ::unshell::protocol::tree::LeafDeclaration>::procedure_suffixes()
}
}
impl #host {
/// Returns the canonical dotted leaf name declared for this host.
pub fn protocol_leaf_name() -> ::unshell::alloc::string::String {
<Self as ::unshell::protocol::tree::ProtocolLeaf>::leaf_name()
}
/// Returns the canonical protocol leaf metadata for this host.
pub fn protocol_leaf_spec() -> ::unshell::protocol::tree::LeafSpec {
<Self as ::unshell::protocol::tree::LeafDeclaration>::leaf_spec()
}
/// Resolves one local procedure suffix to its full canonical `procedure_id`.
pub fn protocol_procedure_id(
suffix: &str,
) -> ::core::option::Option<::unshell::alloc::string::String> {
<Self as ::unshell::protocol::tree::LeafDeclaration>::procedure_id(suffix)
}
}
})
}
fn host_path_for_binding(binding: &HostBinding, declaration: &syn::Ident) -> Result<Path> {
if let Some(path) = &binding.host_path {
return Ok(path.clone());
}
let Some(module_path) = &binding.module_path else {
return Err(Error::new(
declaration.span(),
"leaf binding is missing a host path",
));
};
let mut path = module_path.clone();
path.segments.push(format_ident!("{declaration}").into());
Ok(path)
}
fn procedure_suffix_tokens(
procedure: &ProcedureRef,
canonical_module: Option<&Path>,
) -> Result<TokenStream> {
match procedure {
ProcedureRef::Symbol(procedure) => {
let procedure_path = if let Some(module_path) = canonical_module {
let mut path = module_path.clone();
let ident = procedure.get_ident().ok_or_else(|| {
Error::new_spanned(
procedure,
"procedure names must be bare identifiers when inferred from a module",
)
})?;
path.segments.push(ident.clone().into());
path
} else {
procedure.clone()
};
Ok(
quote! { <#procedure_path as ::unshell::protocol::tree::ProcedureMetadata>::PROCEDURE_SUFFIX },
)
}
ProcedureRef::Suffix(suffix) => Ok(quote! { #suffix }),
}
}
fn procedure_type_check_tokens(
binding: &HostBinding,
procedures: &[ProcedureRef],
declaration: &syn::Ident,
) -> Result<TokenStream> {
let Some(module_path) = &binding.module_path else {
return Ok(quote! {});
};
let checks = procedures
.iter()
.filter_map(|procedure| match procedure {
ProcedureRef::Symbol(procedure) => Some(procedure),
ProcedureRef::Suffix(_) => None,
})
.map(|procedure| {
let mut path = module_path.clone();
let ident = procedure.get_ident().ok_or_else(|| {
Error::new_spanned(
procedure,
"procedure names must be bare identifiers when inferred from a module",
)
})?;
path.segments.push(ident.clone().into());
Ok::<TokenStream, Error>(quote! {
let _ = ::core::marker::PhantomData::<#path>;
})
})
.collect::<Result<Vec<_>>>()?;
let _ = declaration;
Ok(quote! { #(#checks)* })
}
fn set_once(target: &mut Option<LitStr>, value: LitStr, label: &str) -> Result<()> {
if target.is_some() {
return Err(Error::new_spanned(value, format!("duplicate {label}")));
}
*target = Some(value);
Ok(())
}
-119
View File
@@ -1,119 +0,0 @@
//! Proc macros for `unshell` application-layer leaf declarations.
mod leaf_decl;
mod procedure;
mod procedures;
mod utils;
use proc_macro::TokenStream;
use syn::{DeriveInput, ItemImpl, ItemStruct, parse_macro_input};
/// Declares one compile-time leaf surface and binds it to endpoint and/or TUI
/// host structs.
///
/// What it is: an attribute macro placed on a marker struct that generates the
/// shared protocol-visible metadata for one leaf and applies that metadata to the
/// listed host structs.
///
/// Why it exists: endpoint and TUI hosts should not each have to repeat the leaf
/// name and procedure inventory, and endpoint construction should not need a
/// handwritten list of procedure ids.
///
/// # Example
/// ```ignore
/// #[unshell::leaf(
/// name = "remote_shell",
/// procedures = [Open],
/// leaf_endpoint = endpoint::RemoteShellEndpoint,
/// leaf_tui = tui::RemoteShellTui,
/// )]
/// pub struct RemoteShell;
/// ```
#[proc_macro_attribute]
pub fn leaf(attr: TokenStream, item: TokenStream) -> TokenStream {
match leaf_decl::expand_leaf_declaration(
parse_macro_input!(attr as leaf_decl::LeafDeclarationAttributes),
parse_macro_input!(item as ItemStruct),
) {
Ok(tokens) => tokens.into(),
Err(error) => error.to_compile_error().into(),
}
}
/// Derives canonical stateful-procedure metadata for one procedure type.
///
/// What it is: a derive macro that records one procedure suffix and generates
/// the canonical `protocol_procedure_id()` helper for that procedure.
///
/// Why it exists: hook-backed procedures need one stable `procedure_id`, but the
/// runtime should not require each procedure to handwrite the identifier logic.
///
/// # Example
/// ```ignore
/// use unshell::{Procedure, leaf};
///
/// #[leaf(
/// name = "shell",
/// procedures = [OpenSession],
/// endpoint_struct = ShellLeaf,
/// )]
/// struct Shell;
///
/// struct ShellLeaf;
///
/// #[derive(Procedure)]
/// #[procedure(leaf = ShellLeaf, name = "open")]
/// struct OpenSession;
///
/// assert!(OpenSession::protocol_procedure_id().ends_with(".open"));
/// ```
#[proc_macro_derive(Procedure, attributes(procedure))]
pub fn derive_procedure(input: TokenStream) -> TokenStream {
match procedure::expand_procedure(parse_macro_input!(input as DeriveInput)) {
Ok(tokens) => tokens.into(),
Err(error) => error.to_compile_error().into(),
}
}
/// Generates dispatch glue for a simple call-driven leaf impl block.
///
/// What it is: an attribute macro placed on one `impl` block whose `#[call]`
/// methods define the callable surface for that leaf.
///
/// Why it exists: one-shot leaves should be able to declare a small RPC-like API
/// on ordinary Rust methods while still producing the canonical procedure list
/// and dispatch logic expected by the protocol runtime.
///
/// # Example
/// ```ignore
/// use unshell::{leaf, procedures};
///
/// #[leaf(
/// id = "org.example.v1.echo",
/// procedures = ["echo"],
/// endpoint_struct = EchoLeaf,
/// )]
/// struct Echo;
///
/// struct EchoLeaf;
///
/// #[procedures(error = core::convert::Infallible)]
/// impl EchoLeaf {
/// #[call]
/// fn echo(&mut self, input: String) -> String {
/// input
/// }
/// }
///
/// assert!(EchoLeaf::protocol_procedure_id("echo").is_some());
/// ```
#[proc_macro_attribute]
pub fn procedures(attr: TokenStream, item: TokenStream) -> TokenStream {
match procedures::expand_procedures(
parse_macro_input!(attr as procedures::ProceduresAttributes),
parse_macro_input!(item as ItemImpl),
) {
Ok(tokens) => tokens.into(),
Err(error) => error.to_compile_error().into(),
}
}
-108
View File
@@ -1,108 +0,0 @@
use quote::quote;
use syn::{Attribute, Data, DeriveInput, Error, LitStr, Result, Type};
#[derive(Default)]
struct ProcedureAttributes {
leaf: Option<Type>,
name: Option<LitStr>,
}
impl ProcedureAttributes {
fn parse_from(attrs: &[Attribute]) -> Result<Self> {
let mut parsed = Self::default();
for attr in attrs {
if !attr.path().is_ident("procedure") {
continue;
}
attr.parse_nested_meta(|meta| {
if meta.path.is_ident("leaf") {
if parsed.leaf.is_some() {
return Err(meta.error("duplicate procedure leaf attribute"));
}
parsed.leaf = Some(meta.value()?.parse()?);
return Ok(());
}
if meta.path.is_ident("name") {
if parsed.name.is_some() {
return Err(meta.error("duplicate procedure name attribute"));
}
parsed.name = Some(meta.value()?.parse()?);
return Ok(());
}
Err(meta.error("unsupported #[procedure(...)] attribute"))
})?;
}
Ok(parsed)
}
}
pub(crate) fn expand_procedure(input: DeriveInput) -> Result<proc_macro2::TokenStream> {
let procedure_name = input.ident;
match input.data {
Data::Struct(_) => {}
_ => {
return Err(Error::new_spanned(
procedure_name,
"Procedure can only be derived for structs",
));
}
};
let parsed = ProcedureAttributes::parse_from(&input.attrs)?;
let leaf_ty = parsed.leaf.ok_or_else(|| {
Error::new_spanned(
&procedure_name,
"missing #[procedure(leaf = LeafType, name = \"...\")] attribute",
)
})?;
let suffix = parsed.name.ok_or_else(|| {
Error::new_spanned(
&procedure_name,
"missing #[procedure(leaf = LeafType, name = \"...\")] attribute",
)
})?;
if suffix.value().is_empty() {
return Err(Error::new_spanned(
&suffix,
"procedure name must not be empty",
));
}
if suffix.value().contains('.') {
return Err(Error::new_spanned(
&suffix,
"procedure name must be one local suffix without dots",
));
}
if suffix.value().chars().any(char::is_whitespace) {
return Err(Error::new_spanned(
&suffix,
"procedure name must not contain whitespace",
));
}
let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
Ok(quote! {
impl #impl_generics ::unshell::protocol::tree::ProcedureMetadata
for #procedure_name #ty_generics #where_clause
where
#leaf_ty: ::unshell::protocol::tree::ProtocolLeaf,
{
type Leaf = #leaf_ty;
const PROCEDURE_SUFFIX: &'static str = #suffix;
}
impl #impl_generics #procedure_name #ty_generics #where_clause {
/// Returns the full canonical `procedure_id` for this stateful procedure.
pub fn protocol_procedure_id() -> ::unshell::alloc::string::String {
<Self as ::unshell::protocol::tree::ProcedureMetadata>::procedure_id()
}
}
})
}
-403
View File
@@ -1,403 +0,0 @@
use proc_macro2::TokenStream;
use quote::{format_ident, quote};
use syn::{
Error, FnArg, Ident, ImplItem, ImplItemFn, ItemImpl, LitStr, PatType, Result, ReturnType,
Token, Type, parse::Parse, punctuated::Punctuated,
};
use crate::utils::{
extract_outer_type_argument, extract_result_type_arguments, is_unit_type, take_call_attr,
};
#[derive(Default)]
pub(crate) struct ProceduresAttributes {
error: Option<Type>,
}
impl Parse for ProceduresAttributes {
fn parse(input: syn::parse::ParseStream<'_>) -> Result<Self> {
if input.is_empty() {
return Ok(Self::default());
}
let mut parsed = Self::default();
let assignments = Punctuated::<Assignment, Token![,]>::parse_terminated(input)?;
for assignment in assignments {
if assignment.name == "error" {
if parsed.error.is_some() {
return Err(Error::new_spanned(
assignment.name,
"duplicate procedures error attribute",
));
}
parsed.error = Some(assignment.value);
continue;
}
return Err(Error::new_spanned(
assignment.name,
"unsupported #[procedures(...)] attribute",
));
}
Ok(parsed)
}
}
struct Assignment {
name: Ident,
value: Type,
}
impl Parse for Assignment {
fn parse(input: syn::parse::ParseStream<'_>) -> Result<Self> {
Ok(Self {
name: input.parse()?,
value: {
input.parse::<Token![=]>()?;
input.parse()?
},
})
}
}
struct CallArm {
suffix_literal: LitStr,
dispatch_tokens: TokenStream,
}
#[derive(Clone, Copy)]
enum EndpointArgKind {
Shared,
Mutable,
}
pub(crate) fn expand_procedures(
attr: ProceduresAttributes,
mut item: ItemImpl,
) -> Result<TokenStream> {
let self_ty = item.self_ty.clone();
let impl_generics = item.generics.clone();
let (impl_generics_tokens, _ty_generics, where_clause) = impl_generics.split_for_impl();
let error_ty = attr.error.ok_or_else(|| {
Error::new_spanned(
&item.self_ty,
"missing #[procedures(error = MyError)] attribute",
)
})?;
let mut dispatch_arms = Vec::new();
let mut seen_suffixes = std::collections::BTreeSet::new();
for impl_item in &mut item.items {
let ImplItem::Fn(method) = impl_item else {
continue;
};
let has_call_attr = method.attrs.iter().any(|attr| attr.path().is_ident("call"));
if !has_call_attr {
continue;
}
let arm = expand_call_arm(method)?;
take_call_attr(&mut method.attrs);
if !seen_suffixes.insert(arm.suffix_literal.value()) {
return Err(Error::new_spanned(
method,
"duplicate #[call] procedure suffix in this impl block",
));
}
dispatch_arms.push(arm);
}
if dispatch_arms.is_empty() {
return Err(Error::new_spanned(
&item.self_ty,
"#[procedures] requires at least one #[call] method",
));
}
let dispatch_checks = dispatch_arms.iter().map(|arm| arm.dispatch_tokens.clone());
Ok(quote! {
#item
impl #impl_generics_tokens ::unshell::protocol::tree::CallProcedures for #self_ty #where_clause {
type Error = #error_ty;
fn dispatch_call(
&mut self,
endpoint: &mut ::unshell::protocol::tree::ProtocolEndpoint,
call: ::unshell::protocol::tree::IncomingCall,
) -> ::core::result::Result<
::unshell::protocol::tree::CallReply,
::unshell::protocol::tree::DispatchError<Self::Error>,
> {
#(#dispatch_checks)*
unreachable!("protocol runtime validated local procedure dispatch")
}
}
})
}
fn expand_call_arm(method: &ImplItemFn) -> Result<CallArm> {
let method_name = &method.sig.ident;
let suffix_literal = call_suffix_literal(method)?;
let call_id_expr = quote! {
{
let mut __unshell_id = <Self as ::unshell::protocol::tree::ProtocolLeaf>::leaf_name();
__unshell_id.push('.');
__unshell_id.push_str(#suffix_literal);
__unshell_id
}
};
let inputs = method
.sig
.inputs
.iter()
.filter(|input| !matches!(input, FnArg::Receiver(_)))
.collect::<Vec<_>>();
let (endpoint_arg, inputs) = split_endpoint_arg(&inputs)?;
let invocation = expand_invocation(method_name, endpoint_arg, &inputs)?;
let return_value = expand_return_conversion(&method.sig.output, quote! { __unshell_result })?;
Ok(CallArm {
suffix_literal: suffix_literal.clone(),
dispatch_tokens: quote! {
if call.message.procedure_id == #call_id_expr {
let __unshell_result = #invocation;
return { #return_value };
}
},
})
}
fn expand_invocation(
method_name: &Ident,
endpoint_arg: Option<EndpointArgKind>,
inputs: &[&FnArg],
) -> Result<TokenStream> {
let endpoint_prefix = endpoint_arg.map(endpoint_arg_tokens);
if inputs.is_empty() {
return Ok(if let Some(prefix) = endpoint_prefix {
quote! { self.#method_name(#prefix) }
} else {
quote! { self.#method_name() }
});
}
if inputs.len() == 1 {
let FnArg::Typed(PatType { ty, .. }) = inputs[0] else {
return Err(Error::new_spanned(
inputs[0],
"unsupported receiver in procedure signature",
));
};
if let Some(inner) = extract_call_inner_type(ty) {
return Ok(quote! {{
let __unshell_input = ::unshell::protocol::tree::decode_call_input::<#inner>(
call.message.data.as_slice(),
)
.map_err(::unshell::protocol::tree::DispatchError::Decode)?;
// Rebuild the normalized `Call<T>` value expected by generated handlers from the
// validated protocol envelope plus the typed payload we just decoded.
let __unshell_call = ::unshell::protocol::tree::Call {
input: __unshell_input,
caller_path: call.header.src_path.clone(),
procedure_id: call.message.procedure_id.clone(),
dst_leaf: call.header.dst_leaf.clone(),
response_hook: call
.message
.response_hook
.as_ref()
.map(|hook| ::unshell::protocol::tree::HookKey::new(
hook.return_path.clone(),
hook.hook_id,
)),
};
self.#method_name(#endpoint_prefix __unshell_call)
}});
}
return Ok(quote! {{
let __unshell_input = ::unshell::protocol::tree::decode_call_input::<#ty>(
call.message.data.as_slice(),
)
.map_err(::unshell::protocol::tree::DispatchError::Decode)?;
self.#method_name(#endpoint_prefix __unshell_input)
}});
}
let tuple_types = inputs
.iter()
.map(|input| match input {
FnArg::Typed(PatType { ty, .. }) => Ok(ty.clone()),
other => Err(Error::new_spanned(
other,
"unsupported receiver in procedure signature",
)),
})
.collect::<Result<Vec<_>>>()?;
let vars = (0..tuple_types.len())
.map(|index| format_ident!("__unshell_arg_{index}"))
.collect::<Vec<_>>();
Ok(quote! {{
let (#(#vars),*) = ::unshell::protocol::tree::decode_call_input::<(#(#tuple_types),*)>(
call.message.data.as_slice(),
)
.map_err(::unshell::protocol::tree::DispatchError::Decode)?;
self.#method_name(#endpoint_prefix #(#vars),*)
}})
}
fn split_endpoint_arg<'a>(
inputs: &[&'a FnArg],
) -> Result<(Option<EndpointArgKind>, Vec<&'a FnArg>)> {
let Some(first) = inputs.first() else {
return Ok((None, Vec::new()));
};
let Some(kind) = endpoint_arg_kind(first)? else {
return Ok((None, inputs.to_vec()));
};
Ok((Some(kind), inputs[1..].to_vec()))
}
fn endpoint_arg_kind(arg: &FnArg) -> Result<Option<EndpointArgKind>> {
let FnArg::Typed(PatType { ty, .. }) = arg else {
return Ok(None);
};
let Type::Reference(reference) = ty.as_ref() else {
return Ok(None);
};
let Type::Path(type_path) = reference.elem.as_ref() else {
return Ok(None);
};
let Some(segment) = type_path.path.segments.last() else {
return Ok(None);
};
if segment.ident != "ProtocolEndpoint" {
return Ok(None);
}
Ok(Some(if reference.mutability.is_some() {
EndpointArgKind::Mutable
} else {
EndpointArgKind::Shared
}))
}
fn endpoint_arg_tokens(kind: EndpointArgKind) -> TokenStream {
match kind {
EndpointArgKind::Shared => quote! { &*endpoint, },
EndpointArgKind::Mutable => quote! { endpoint, },
}
}
fn expand_return_conversion(return_type: &ReturnType, value: TokenStream) -> Result<TokenStream> {
match return_type {
ReturnType::Default => Ok(quote! {
let _ = #value;
::core::result::Result::Ok(::unshell::protocol::tree::CallReply::NoReply)
}),
ReturnType::Type(_, ty) => normalize_output_type(ty, value),
}
}
fn normalize_output_type(ty: &Type, value: TokenStream) -> Result<TokenStream> {
if is_unit_type(ty) {
return Ok(quote! {
let _ = #value;
::core::result::Result::Ok(::unshell::protocol::tree::CallReply::NoReply)
});
}
if let Some(inner) = extract_outer_type_argument(ty, "CallResult") {
let inner_conversion = normalize_reply_value(inner, quote! { __unshell_value })?;
return Ok(quote! {
match #value {
::unshell::protocol::tree::CallResult::Reply(__unshell_value) => {
#inner_conversion
}
::unshell::protocol::tree::CallResult::NoReply => {
::core::result::Result::Ok(::unshell::protocol::tree::CallReply::NoReply)
}
}
});
}
if let Some((ok_ty, _error_ty)) = extract_result_type_arguments(ty) {
let ok_conversion = normalize_output_type(ok_ty, quote! { __unshell_value })?;
return Ok(quote! {
match #value {
::core::result::Result::Ok(__unshell_value) => { #ok_conversion }
::core::result::Result::Err(__unshell_error) => {
::core::result::Result::Err(
::unshell::protocol::tree::DispatchError::Handler(__unshell_error)
)
}
}
});
}
normalize_reply_value(ty, value)
}
fn normalize_reply_value(_ty: &Type, value: TokenStream) -> Result<TokenStream> {
Ok(quote! {
::core::result::Result::Ok(::unshell::protocol::tree::CallReply::Reply(
::unshell::protocol::tree::encode_call_reply(&#value)
.map_err(::unshell::protocol::tree::DispatchError::Encode)?
))
})
}
fn extract_call_inner_type(ty: &Type) -> Option<&Type> {
extract_outer_type_argument(ty, "Call")
}
fn call_suffix_literal(method: &ImplItemFn) -> Result<LitStr> {
let mut suffix = None;
for attr in &method.attrs {
if !attr.path().is_ident("call") {
continue;
}
if matches!(attr.meta, syn::Meta::Path(_)) {
continue;
}
attr.parse_nested_meta(|meta| {
if meta.path.is_ident("name") {
if suffix.is_some() {
return Err(meta.error("duplicate call name attribute"));
}
suffix = Some(meta.value()?.parse()?);
return Ok(());
}
Err(meta.error("unsupported #[call(...)] attribute"))
})?;
}
let suffix = suffix
.unwrap_or_else(|| LitStr::new(&method.sig.ident.to_string(), method.sig.ident.span()));
if suffix.value().is_empty() {
return Err(Error::new_spanned(&suffix, "call name must not be empty"));
}
if suffix.value().contains('.') {
return Err(Error::new_spanned(
&suffix,
"call name must be one local suffix without dots",
));
}
if suffix.value().chars().any(char::is_whitespace) {
return Err(Error::new_spanned(
&suffix,
"call name must not contain whitespace",
));
}
Ok(suffix)
}
-60
View File
@@ -1,60 +0,0 @@
use proc_macro2::TokenStream;
use quote::quote;
use syn::{Attribute, GenericArgument, LitStr, Type, TypePath};
pub(crate) fn option_litstr_tokens(value: Option<&LitStr>) -> TokenStream {
match value {
Some(value) => quote! { ::core::option::Option::Some(#value) },
None => quote! { ::core::option::Option::None },
}
}
pub(crate) fn extract_outer_type_argument<'a>(ty: &'a Type, expected: &str) -> Option<&'a Type> {
let Type::Path(TypePath { path, .. }) = ty else {
return None;
};
let segment = path.segments.last()?;
if segment.ident != expected {
return None;
}
let syn::PathArguments::AngleBracketed(arguments) = &segment.arguments else {
return None;
};
match arguments.args.first()? {
GenericArgument::Type(inner) => Some(inner),
_ => None,
}
}
pub(crate) fn extract_result_type_arguments(ty: &Type) -> Option<(&Type, &Type)> {
let Type::Path(TypePath { path, .. }) = ty else {
return None;
};
let segment = path.segments.last()?;
if segment.ident != "Result" {
return None;
}
let syn::PathArguments::AngleBracketed(arguments) = &segment.arguments else {
return None;
};
let mut args = arguments.args.iter();
let ok = match args.next()? {
GenericArgument::Type(value) => value,
_ => return None,
};
let err = match args.next()? {
GenericArgument::Type(value) => value,
_ => return None,
};
Some((ok, err))
}
pub(crate) fn is_unit_type(ty: &Type) -> bool {
matches!(ty, Type::Tuple(tuple) if tuple.elems.is_empty())
}
pub(crate) fn take_call_attr(attrs: &mut Vec<Attribute>) -> bool {
let original_len = attrs.len();
attrs.retain(|attr| !attr.path().is_ident("call"));
original_len != attrs.len()
}
@@ -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)
}
@@ -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};
-298
View File
@@ -1,298 +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()
}
struct EchoLeaf {
prefix: String,
}
#[leaf(id = "org.example.v1.echo", endpoint_struct = EchoLeaf, procedures = ["echo"])]
struct Echo;
#[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");
}
#[derive(Default)]
struct TopologyLeaf;
#[leaf(
id = "org.example.v1.topology",
endpoint_struct = TopologyLeaf,
procedures = ["add_child", "remove_child", "connections"]
)]
struct Topology;
#[derive(Archive, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
struct ChildRequest {
child_path: Vec<String>,
}
#[derive(Archive, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
struct ConnectionsReply {
parent: Option<Vec<String>>,
children: Vec<Vec<String>>,
}
#[procedures(error = Infallible)]
impl TopologyLeaf {
#[call]
fn add_child(
&mut self,
endpoint: &mut ProtocolEndpoint,
request: ChildRequest,
) -> ConnectionsReply {
endpoint
.upsert_child_route(ChildRoute::registered(request.child_path))
.expect("topology mutation should satisfy direct-child invariants");
ConnectionsReply {
parent: endpoint.parent_path().map(<[String]>::to_vec),
children: endpoint
.child_routes()
.iter()
.map(|child| child.path.clone())
.collect(),
}
}
#[call]
fn remove_child(
&mut self,
endpoint: &mut ProtocolEndpoint,
request: ChildRequest,
) -> ConnectionsReply {
endpoint.remove_child_route(&request.child_path);
ConnectionsReply {
parent: endpoint.parent_path().map(<[String]>::to_vec),
children: endpoint
.child_routes()
.iter()
.map(|child| child.path.clone())
.collect(),
}
}
#[call]
fn connections(&mut self, endpoint: &ProtocolEndpoint) -> ConnectionsReply {
ConnectionsReply {
parent: endpoint.parent_path().map(<[String]>::to_vec),
children: endpoint
.child_routes()
.iter()
.map(|child| child.path.clone())
.collect(),
}
}
}
impl CallLeaf for TopologyLeaf {
type Error = Infallible;
}
#[test]
fn generated_call_procedure_can_query_and_mutate_endpoint_topology() {
let endpoint = ProtocolEndpoint::new(
path(&["agent"]),
Some(Vec::new()),
Vec::new(),
vec![TopologyLeaf::protocol_leaf_spec()],
);
let mut runtime = LeafRuntime::new(endpoint, TopologyLeaf);
let mut controller = ProtocolEndpoint::new(
Vec::new(),
None,
vec![ChildRoute::registered(path(&["agent"]))],
Vec::new(),
);
let add_hook = controller.allocate_hook_id();
let add_child = controller
.send_call(
path(&["agent"]),
Some(TopologyLeaf::protocol_leaf_name()),
TopologyLeaf::protocol_procedure_id("add_child").expect("suffix should resolve"),
Some(add_hook),
encode_call_reply(&ChildRequest {
child_path: path(&["agent", "child"]),
})
.expect("request should encode"),
)
.expect("call should encode");
let EndpointOutcome::Forward {
frame: add_child_frame,
..
} = add_child
else {
panic!("controller should forward add-child call");
};
let add_outcome = runtime
.receive(&Ingress::Parent, add_child_frame)
.expect("runtime should mutate topology");
let [response] = add_outcome.frames.as_slice() else {
panic!("expected add-child response frame");
};
let parsed = decode_frame(response).expect("response should decode");
let reply = decode_call_input::<ConnectionsReply>(
parsed
.deserialize_data()
.expect("reply data should decode")
.data
.as_slice(),
)
.expect("typed reply should decode");
assert_eq!(reply.parent, Some(Vec::new()));
assert_eq!(reply.children, vec![path(&["agent", "child"])]);
assert_eq!(runtime.endpoint().child_routes().len(), 1);
let list_hook = controller.allocate_hook_id();
let list = controller
.send_call(
path(&["agent"]),
Some(TopologyLeaf::protocol_leaf_name()),
TopologyLeaf::protocol_procedure_id("connections").expect("suffix should resolve"),
Some(list_hook),
encode_call_reply(&()).expect("unit request should encode"),
)
.expect("list call should encode");
let EndpointOutcome::Forward {
frame: list_frame, ..
} = list
else {
panic!("controller should forward connections call");
};
let list_outcome = runtime
.receive(&Ingress::Parent, list_frame)
.expect("runtime should return topology snapshot");
let [list_response] = list_outcome.frames.as_slice() else {
panic!("expected connections response frame");
};
let list_reply = decode_call_input::<ConnectionsReply>(
decode_frame(list_response)
.expect("response should decode")
.deserialize_data()
.expect("data should deserialize")
.data
.as_slice(),
)
.expect("typed reply should decode");
assert_eq!(list_reply.children, vec![path(&["agent", "child"])]);
let remove_hook = controller.allocate_hook_id();
let remove = controller
.send_call(
path(&["agent"]),
Some(TopologyLeaf::protocol_leaf_name()),
TopologyLeaf::protocol_procedure_id("remove_child").expect("suffix should resolve"),
Some(remove_hook),
encode_call_reply(&ChildRequest {
child_path: path(&["agent", "child"]),
})
.expect("request should encode"),
)
.expect("remove call should encode");
let EndpointOutcome::Forward {
frame: remove_frame,
..
} = remove
else {
panic!("controller should forward remove-child call");
};
runtime
.receive(&Ingress::Parent, remove_frame)
.expect("runtime should prune topology");
assert!(runtime.endpoint().child_routes().is_empty());
}
@@ -1,93 +0,0 @@
use alloc::{string::String, vec};
use crate::leaf;
use crate::protocol::tree::{LeafBinding, LeafDeclaration, ProcedureMetadata, ProtocolLeaf};
struct EndpointHost;
struct Open;
struct Reset;
impl ProcedureMetadata for Open {
type Leaf = EndpointHost;
const PROCEDURE_SUFFIX: &'static str = "open";
}
impl ProcedureMetadata for Reset {
type Leaf = EndpointHost;
const PROCEDURE_SUFFIX: &'static str = "reset";
}
#[leaf(id = "org.example.v1.demo", procedures = [Open, Reset], endpoint_struct = EndpointHost)]
struct Demo;
struct EndpointHalf;
struct TuiHalf;
struct Connect;
impl ProcedureMetadata for Connect {
type Leaf = EndpointHalf;
const PROCEDURE_SUFFIX: &'static str = "connect";
}
#[leaf(
name = "chat",
org = "org",
product = "example",
version = "v2",
procedures = [Connect],
endpoint_struct = EndpointHalf,
tui_struct = TuiHalf,
)]
struct Chat;
struct TuiOnly;
struct Tail;
impl ProcedureMetadata for Tail {
type Leaf = TuiOnly;
const PROCEDURE_SUFFIX: &'static str = "tail";
}
#[leaf(id = "org.example.v1.transcript", procedures = [Tail], tui_struct = TuiOnly)]
struct Transcript;
#[test]
fn leaf_declaration_generates_endpoint_host_metadata() {
assert_eq!(EndpointHost::protocol_leaf_name(), "org.example.v1.demo");
assert_eq!(
EndpointHost::protocol_leaf_spec().procedures,
vec![
String::from("org.example.v1.demo.open"),
String::from("org.example.v1.demo.reset"),
]
);
assert_eq!(
<EndpointHost as LeafBinding>::Declaration::leaf_name(),
"org.example.v1.demo"
);
}
#[test]
fn leaf_declaration_shares_metadata_between_endpoint_and_tui_hosts() {
assert_eq!(
EndpointHalf::protocol_leaf_name(),
TuiHalf::protocol_leaf_name()
);
assert_eq!(
EndpointHalf::protocol_leaf_spec().procedures,
TuiHalf::protocol_leaf_spec().procedures
);
assert_eq!(
<EndpointHalf as LeafBinding>::Declaration::procedure_id("connect"),
Some(String::from("org.example.v2.chat.connect"))
);
}
#[test]
fn leaf_declaration_supports_tui_only_hosts() {
assert_eq!(TuiOnly::protocol_leaf_name(), "org.example.v1.transcript");
assert_eq!(
<TuiOnly as LeafDeclaration>::procedure_id("tail"),
Some(String::from("org.example.v1.transcript.tail"))
);
}
@@ -1,5 +0,0 @@
mod call;
mod leaf_decl;
mod procedure;
mod protocol;
mod tree;
@@ -1,278 +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::{Procedure, leaf};
fn path(parts: &[&str]) -> Vec<String> {
parts.iter().map(|part| (*part).to_owned()).collect()
}
#[derive(Default)]
struct StreamLeaf {
sessions: BTreeMap<HookKey, ProcedureOpen>,
}
#[leaf(id = "org.example.v1.stream", procedures = [ProcedureOpen], endpoint_struct = StreamLeaf)]
struct Stream;
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![StreamLeaf::protocol_leaf_spec()],
);
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)]
struct DuplexLeaf {
sessions: BTreeMap<HookKey, DuplexProcedure>,
}
#[leaf(id = "org.example.v1.duplex", procedures = [DuplexProcedure], endpoint_struct = DuplexLeaf)]
struct Duplex;
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![DuplexLeaf::protocol_leaf_spec()],
);
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());
}
@@ -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());
}
-801
View File
@@ -1,801 +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::ForwardedFrame;
use super::{
Endpoint, EndpointError, HookKey, Ingress, LocalEvent, ProtocolEndpoint, ProtocolLeaf,
RouteDecision, RouterLeaf,
};
/// 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,
}
/// Frames emitted by the runtime together with their chosen next hops.
///
/// What it is: the router-oriented variant of [`RuntimeOutcome`], preserving the
/// `RouteDecision` for every emitted frame.
///
/// Why it exists: transport-owning leaves need to know whether each frame should
/// go to the parent or to a specific child, not just the encoded bytes.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::RoutedRuntimeOutcome;
/// let outcome = RoutedRuntimeOutcome::default();
/// assert!(outcome.forwarded.is_empty());
/// ```
#[derive(Debug, Default, Clone)]
pub struct RoutedRuntimeOutcome {
/// Forwarded frames paired with the route chosen by the endpoint runtime.
pub forwarded: Vec<ForwardedFrame>,
/// 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 routed = self.receive_routed(ingress, frame)?;
Ok(RuntimeOutcome {
frames: routed
.forwarded
.into_iter()
.map(|forwarded| forwarded.frame)
.collect(),
dropped: routed.dropped,
})
}
/// Delivers one inbound frame while preserving route decisions for emitted traffic.
///
/// # Example
/// ```rust
/// # use unshell::protocol::tree::{LeafRuntime, ProtocolEndpoint};
/// # struct ExampleLeaf;
/// # let _ = core::marker::PhantomData::<LeafRuntime<ExampleLeaf>>;
/// ```
pub fn receive_routed(
&mut self,
ingress: &Ingress,
frame: FrameBytes,
) -> Result<RoutedRuntimeOutcome, LeafRuntimeError<<L as CallLeaf>::Error>> {
let outcome = self.endpoint.receive(ingress, frame)?;
self.process_endpoint_outcome_routed(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 routed = self.poll_routed()?;
Ok(RuntimeOutcome {
frames: routed
.forwarded
.into_iter()
.map(|forwarded| forwarded.frame)
.collect(),
dropped: routed.dropped,
})
}
/// Polls the leaf while preserving route decisions for emitted traffic.
///
/// # Example
/// ```rust
/// # use unshell::protocol::tree::{LeafRuntime, ProtocolEndpoint};
/// # struct ExampleLeaf;
/// # let _ = core::marker::PhantomData::<LeafRuntime<ExampleLeaf>>;
/// ```
pub fn poll_routed(
&mut self,
) -> Result<RoutedRuntimeOutcome, LeafRuntimeError<<L as CallLeaf>::Error>> {
let outgoing = self.leaf.poll().map_err(LeafRuntimeError::Leaf)?;
self.emit_outgoing_routed(outgoing)
}
fn process_endpoint_outcome_routed(
&mut self,
outcome: crate::protocol::tree::EndpointOutcome,
) -> Result<RoutedRuntimeOutcome, LeafRuntimeError<<L as CallLeaf>::Error>> {
match outcome {
crate::protocol::tree::EndpointOutcome::Forward { route, frame } => {
Ok(RoutedRuntimeOutcome {
forwarded: vec![ForwardedFrame { route, frame }],
dropped: false,
})
}
crate::protocol::tree::EndpointOutcome::Dropped => Ok(RoutedRuntimeOutcome {
forwarded: Vec::new(),
dropped: true,
}),
crate::protocol::tree::EndpointOutcome::Local(event) => self.process_local_event(event),
}
}
fn process_local_event(
&mut self,
event: LocalEvent,
) -> Result<RoutedRuntimeOutcome, 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<RoutedRuntimeOutcome, 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(&mut self.endpoint, incoming) {
Ok(CallReply::Reply(bytes)) => {
let frames = if let Some(hook) = response_hook {
self.send_reply_data(hook, procedure_id, bytes, true)?
} else {
RoutedRuntimeOutcome::default()
};
Ok(frames)
}
Ok(CallReply::NoReply) => Ok(RoutedRuntimeOutcome::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<RoutedRuntimeOutcome, LeafRuntimeError<<L as CallLeaf>::Error>> {
let outgoing = self
.leaf
.on_data(IncomingData {
header,
message,
hook_key,
})
.map_err(LeafRuntimeError::Leaf)?;
self.emit_outgoing_routed(outgoing)
}
fn process_local_fault(
&mut self,
header: PacketHeader,
message: crate::protocol::FaultMessage,
hook_key: HookKey,
) -> Result<RoutedRuntimeOutcome, LeafRuntimeError<<L as CallLeaf>::Error>> {
self.leaf
.on_fault(IncomingFault {
header,
fault: message,
hook_key,
})
.map_err(LeafRuntimeError::Leaf)?;
Ok(RoutedRuntimeOutcome::default())
}
fn emit_outgoing_routed(
&mut self,
outgoing: Vec<OutgoingData>,
) -> Result<RoutedRuntimeOutcome, LeafRuntimeError<<L as CallLeaf>::Error>> {
let mut runtime = RoutedRuntimeOutcome::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.forwarded.extend(
self.process_endpoint_outcome_routed(endpoint_outcome)?
.forwarded,
);
}
Ok(runtime)
}
fn send_reply_data(
&mut self,
hook: HookTarget,
procedure_id: String,
bytes: Vec<u8>,
end_hook: bool,
) -> Result<RoutedRuntimeOutcome, LeafRuntimeError<<L as CallLeaf>::Error>> {
let endpoint_outcome = self.endpoint.send_data(
hook.return_path,
hook.hook_id,
procedure_id,
bytes,
end_hook,
)?;
self.process_endpoint_outcome_routed(endpoint_outcome)
}
fn emit_internal_fault_if_possible(
&mut self,
hook: Option<&HookTarget>,
) -> Result<RoutedRuntimeOutcome, LeafRuntimeError<<L as CallLeaf>::Error>> {
let Some(hook) = hook else {
return Ok(RoutedRuntimeOutcome::default());
};
let key = HookKey::new(hook.return_path.clone(), hook.hook_id);
let outcome = self
.endpoint
.emit_fault_if_possible(Some(key), ProtocolFault::INTERNAL_ERROR)?;
self.process_endpoint_outcome_routed(outcome)
}
}
impl<L> LeafRuntime<L>
where
L: CallLeaf + super::CallProcedures<Error = <L as CallLeaf>::Error> + RouterLeaf,
{
/// Sends previously forwarded frames through the router leaf's parent/child links.
///
/// What it is: a small transport bridge from endpoint route decisions to the
/// leaf-owned connections.
///
/// Why it exists: router leaves should be able to reuse the normal protocol
/// runtime and still own the concrete forwarding mechanism.
///
/// # Example
/// ```rust
/// # use unshell::protocol::tree::{LeafRuntime, ProtocolEndpoint};
/// # struct ExampleLeaf;
/// # let _ = core::marker::PhantomData::<LeafRuntime<ExampleLeaf>>;
/// ```
pub fn route_forwarded(
&mut self,
forwarded: Vec<ForwardedFrame>,
) -> Result<(), <L as RouterLeaf>::RouteError> {
for forwarded in forwarded {
match forwarded.route {
RouteDecision::Parent => {
self.leaf
.route_to_parent(self.endpoint.path(), forwarded.frame)?;
}
RouteDecision::Child(index) => {
let child_path = self
.endpoint
.child_routes()
.get(index)
.map(|child| child.path.clone())
.unwrap_or_default();
self.leaf.route_to_child(&child_path, forwarded.frame)?;
}
RouteDecision::Local | RouteDecision::Drop => {}
}
}
Ok(())
}
}
/// 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())
}
@@ -1,574 +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 has_parent = parent_path.is_some();
let registered_child_paths = children
.iter()
.filter(|child| child.registered)
.map(|child| child.path.clone())
.collect::<Vec<_>>();
Self {
local_id: None,
parent_path,
routing: CompiledRoutes::new(&path, &registered_child_paths, has_parent),
path,
children,
leaves: leaves
.into_iter()
.map(|leaf| (leaf.name.clone(), leaf))
.collect(),
endpoint_procedures: BTreeSet::new(),
hooks: Default::default(),
}
}
#[must_use]
/// Creates a root-assumed endpoint with one local identifier and predeclared leaves.
///
/// What it is: a convenience constructor for the common bootstrap state where an endpoint has
/// one local name but has not yet been assigned a non-root path by a parent connection.
///
/// Why it exists: endpoint creation should not require every caller to manually pass an empty
/// path, no parent, and no children just to host one or more known leaves.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::{LeafSpec, ProtocolEndpoint};
/// let endpoint = ProtocolEndpoint::root(
/// "worker",
/// vec![LeafSpec {
/// name: "service".into(),
/// procedures: vec!["example.service.v1.invoke".into()],
/// }],
/// );
/// assert!(endpoint.path().is_empty());
/// assert_eq!(endpoint.local_id(), Some("worker"));
/// ```
pub fn root(local_id: impl Into<String>, leaves: Vec<LeafSpec>) -> Self {
let mut endpoint = Self::new(Vec::new(), None, Vec::new(), leaves);
endpoint.local_id = Some(local_id.into());
endpoint
}
#[must_use]
/// Returns the endpoint's local bootstrap identifier, if one was assigned.
///
/// What it is: a lightweight label separate from the protocol path.
///
/// Why it exists: a freshly created endpoint may know its own local identity before a parent
/// connection assigns its final tree path.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::ProtocolEndpoint;
/// let endpoint = ProtocolEndpoint::root("worker", Vec::new());
/// assert_eq!(endpoint.local_id(), Some("worker"));
/// ```
pub fn local_id(&self) -> Option<&str> {
self.local_id.as_deref()
}
/// Returns the absolute path of this endpoint's direct parent, if one exists.
///
/// What it is: the currently configured one-hop parent boundary for this
/// endpoint.
///
/// Why it exists: router-style leaves need to expose and inspect the tree edge
/// they use for upstream traffic.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::ProtocolEndpoint;
/// let endpoint = ProtocolEndpoint::new(vec!["worker".into()], Some(Vec::new()), Vec::new(), Vec::new());
/// assert_eq!(endpoint.parent_path(), Some([].as_slice()));
/// ```
pub fn parent_path(&self) -> Option<&[String]> {
self.parent_path.as_deref()
}
/// Returns the direct child routes currently known to this endpoint.
///
/// What it is: the local routing-table inputs for direct descendants.
///
/// Why it exists: management leaves often need to inspect or mirror the child
/// topology they are controlling.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::{ChildRoute, ProtocolEndpoint};
/// let endpoint = ProtocolEndpoint::new(
/// vec!["root".into()],
/// None,
/// vec![ChildRoute::registered(vec!["root".into(), "child".into()])],
/// Vec::new(),
/// );
/// assert_eq!(endpoint.child_routes().len(), 1);
/// ```
pub fn child_routes(&self) -> &[ChildRoute] {
&self.children
}
/// Replaces the configured direct parent path and recompiles local routing.
///
/// What it is: the supported way to attach or detach this endpoint from its
/// upstream boundary.
///
/// Why it exists: a router leaf should be able to promote or remove its parent
/// connection without rebuilding the entire endpoint.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::ProtocolEndpoint;
/// let mut endpoint = ProtocolEndpoint::new(vec!["root".into(), "worker".into()], Some(vec!["root".into()]), Vec::new(), Vec::new());
/// endpoint.set_parent_path(None)?;
/// assert!(endpoint.parent_path().is_none());
/// # Ok::<(), unshell::protocol::tree::EndpointError>(())
/// ```
pub fn set_parent_path(
&mut self,
parent_path: Option<Vec<String>>,
) -> Result<(), EndpointError> {
if let Some(path) = parent_path.as_deref() {
self.validate_direct_parent_path(path)?;
}
self.parent_path = parent_path;
self.rebuild_routing();
Ok(())
}
/// Inserts or updates one direct child route and recompiles local routing.
///
/// What it is: the supported mutation API for the endpoint's child list.
///
/// Why it exists: router-management leaves need one invariant-preserving way to
/// reflect child connection changes into path routing.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::{ChildRoute, ProtocolEndpoint};
/// let mut endpoint = ProtocolEndpoint::new(vec!["root".into()], None, Vec::new(), Vec::new());
/// endpoint.upsert_child_route(ChildRoute::registered(vec!["root".into(), "child".into()]))?;
/// assert_eq!(endpoint.child_routes().len(), 1);
/// # Ok::<(), unshell::protocol::tree::EndpointError>(())
/// ```
pub fn upsert_child_route(&mut self, route: ChildRoute) -> Result<(), EndpointError> {
self.validate_direct_child_path(&route.path)?;
if let Some(existing) = self
.children
.iter_mut()
.find(|child| child.path == route.path)
{
*existing = route;
} else {
self.children.push(route);
}
self.rebuild_routing();
Ok(())
}
/// Removes one direct child route by absolute path and recompiles local routing.
///
/// What it is: the supported mutation API for pruning a direct descendant.
///
/// Why it exists: connection-management leaves need to tear down routes without
/// mutating the endpoint internals directly.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::{ChildRoute, ProtocolEndpoint};
/// let mut endpoint = ProtocolEndpoint::new(
/// vec!["root".into()],
/// None,
/// vec![ChildRoute::registered(vec!["root".into(), "child".into()])],
/// Vec::new(),
/// );
/// assert!(endpoint.remove_child_route(&[String::from("root"), String::from("child")]));
/// assert!(endpoint.child_routes().is_empty());
/// ```
pub fn remove_child_route(&mut self, path: &[String]) -> bool {
let original_len = self.children.len();
self.children.retain(|child| child.path != path);
let removed = self.children.len() != original_len;
if removed {
self.rebuild_routing();
}
removed
}
/// 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)
}
fn rebuild_routing(&mut self) {
let registered_child_paths = self
.children
.iter()
.filter(|child| child.registered)
.map(|child| child.path.clone())
.collect::<Vec<_>>();
self.routing = CompiledRoutes::new(
&self.path,
&registered_child_paths,
self.parent_path.is_some(),
);
}
fn validate_direct_parent_path(&self, parent_path: &[String]) -> Result<(), EndpointError> {
let Some((_, expected_parent)) = self.path.split_last() else {
return Err(EndpointError::Validation(
ValidationError::TopologyInvariant("root endpoints cannot declare a parent path"),
));
};
if parent_path != expected_parent {
return Err(EndpointError::Validation(
ValidationError::TopologyInvariant(
"parent path must equal the direct path prefix of this endpoint",
),
));
}
Ok(())
}
fn validate_direct_child_path(&self, child_path: &[String]) -> Result<(), EndpointError> {
if child_path.len() != self.path.len() + 1 || !child_path.starts_with(&self.path) {
return Err(EndpointError::Validation(
ValidationError::TopologyInvariant(
"child path must be one direct descendant of this endpoint",
),
));
}
Ok(())
}
/// 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);
}
}
}
@@ -1,324 +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,
}
/// One framed packet together with the next hop selected by endpoint routing.
///
/// What it is: a transport-ready frame paired with the resolved direction the
/// endpoint chose for it.
///
/// Why it exists: high-level runtimes often flatten forwarded traffic down to raw
/// bytes, but router-host leaves need the route decision so they can choose the
/// correct parent or child connection.
///
/// # Example
/// ```rust
/// use unshell::protocol::FrameBytes;
/// use unshell::protocol::tree::{ForwardedFrame, RouteDecision};
/// let forwarded = ForwardedFrame {
/// route: RouteDecision::Parent,
/// frame: FrameBytes::new(),
/// };
/// assert!(matches!(forwarded.route, RouteDecision::Parent));
/// ```
#[derive(Debug, Clone)]
pub struct ForwardedFrame {
/// The next hop selected by the endpoint runtime.
pub route: RouteDecision,
/// The encoded protocol frame to send over that hop.
pub frame: FrameBytes,
}
/// 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, Clone, Default)]
pub struct ProtocolEndpoint {
pub(crate) local_id: Option<String>,
pub(crate) path: Vec<String>,
pub(crate) parent_path: Option<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,
}
@@ -1,197 +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 {
/// Returns the route that would carry a locally generated hook fault for `hook_id`.
///
/// The method does not mutate hook state. Runtime owners use it to preflight transport
/// availability before calling [`fail_hook`](Self::fail_hook), which removes hook state when
/// the fault is emitted.
#[must_use]
pub fn hook_fault_route(&self, hook_id: u64) -> Option<RouteDecision> {
self.hooks
.key_for_hook_id(hook_id)
.map(|key| self.decide_route(&key.return_path))
}
/// Terminates a locally known hook with a protocol fault.
///
/// Unknown hooks are treated as an intentional drop. Known hooks are removed before the fault
/// is routed so no further local data can be emitted after the terminal fault.
pub fn fail_hook(
&mut self,
hook_id: u64,
fault: ProtocolFault,
) -> Result<EndpointOutcome, EndpointError> {
let key = self.hooks.key_for_hook_id(hook_id);
self.emit_fault_if_possible(key, fault)
}
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)
}
/// Returns the current route decision for an absolute destination path.
///
/// Runtime owners use this to validate transport availability before invoking
/// endpoint operations that also mutate hook state.
#[must_use]
pub fn route_decision(&self, dst_path: &[String]) -> RouteDecision {
self.routing.route(dst_path)
}
pub(crate) fn decide_route(&self, dst_path: &[String]) -> RouteDecision {
self.route_decision(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,
}
}
}
@@ -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())
}
}
@@ -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, ForwardedFrame, Ingress, LeafSpec,
LocalEvent, ProtocolEndpoint,
};
@@ -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),
}
}
}
-507
View File
@@ -1,507 +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, Clone, 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 a hook key matching `hook_id`, preferring active hooks over pending hooks.
///
/// This is intentionally a narrow bridge for current leaf APIs that identify a hook only by
/// id. Hook ids are protocol-scoped by host path, so future APIs should pass the full
/// [`HookKey`] when leaf dispatch exposes it.
#[must_use]
pub fn key_for_hook_id(&self, hook_id: u64) -> Option<HookKey> {
self.active
.keys()
.find(|key| key.hook_id == hook_id)
.cloned()
.or_else(|| {
self.pending
.keys()
.find(|key| key.hook_id == hook_id)
.cloned()
})
}
/// 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)
}
/// 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.get_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.get_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()
}
}
-497
View File
@@ -1,497 +0,0 @@
//! Application-facing leaf metadata helpers.
//!
//! The protocol runtime itself only knows about `LeafSpec` metadata and validated
//! `LocalEvent` delivery. `ProtocolLeaf` owns canonical identity, `LeafDeclaration`
//! owns the compile-time procedure inventory for one leaf surface, and
//! `CallProcedures` adds local call dispatch on top of that inventory.
use alloc::{string::String, vec::Vec};
use crate::protocol::FrameBytes;
use super::{ChildRoute, LeafSpec, ProtocolEndpoint};
/// 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;
}
/// Compile-time declaration metadata for one leaf surface.
///
/// What it is: a trait for types that can describe the complete protocol-visible
/// surface of one leaf at compile time.
///
/// Why it exists: endpoint construction should not need handwritten procedure
/// lists. A leaf declaration can generate the canonical suffix inventory once and
/// let both endpoint and TUI host types reuse it.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::{LeafDeclaration, ProtocolLeaf};
/// struct ExampleLeaf;
/// impl ProtocolLeaf for ExampleLeaf {
/// fn leaf_name() -> String { "org.example.v1.echo".into() }
/// }
/// impl LeafDeclaration for ExampleLeaf {
/// fn procedure_suffixes() -> &'static [&'static str] { &["invoke"] }
/// }
/// assert_eq!(ExampleLeaf::leaf_spec().procedures, vec![String::from("org.example.v1.echo.invoke")]);
/// ```
pub trait LeafDeclaration: ProtocolLeaf {
/// Returns the local procedure suffixes supported by this leaf.
fn procedure_suffixes() -> &'static [&'static str];
/// Resolves one local procedure suffix to its full canonical `procedure_id`.
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.
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`.
fn leaf_spec() -> LeafSpec {
LeafSpec {
name: Self::leaf_name(),
procedures: Self::procedure_ids(),
}
}
}
/// Returns the canonical `LeafSpec` for one concrete leaf host value.
///
/// What it is: a tiny typed helper that uses a host value only for type inference.
///
/// Why it exists: endpoint-construction macros can accept ordinary host expressions like
/// `RemoteShell::default()` and still derive the compile-time `LeafSpec` without the caller
/// spelling the leaf type twice.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::{LeafDeclaration, ProtocolLeaf, leaf_spec_of};
/// struct ExampleLeaf;
/// impl ProtocolLeaf for ExampleLeaf {
/// fn leaf_name() -> String { "org.example.v1.echo".into() }
/// }
/// impl LeafDeclaration for ExampleLeaf {
/// fn procedure_suffixes() -> &'static [&'static str] { &["invoke"] }
/// }
/// let spec = leaf_spec_of(&ExampleLeaf);
/// assert_eq!(spec.name, "org.example.v1.echo");
/// ```
pub fn leaf_spec_of<L>(_: &L) -> LeafSpec
where
L: LeafDeclaration,
{
L::leaf_spec()
}
/// Declares that one host struct is bound to one compile-time leaf declaration.
///
/// What it is: a trait that links a concrete host type, such as an endpoint or
/// TUI struct, back to the declaration that owns its shared protocol metadata.
///
/// Why it exists: endpoint and TUI hosts often need different state and behavior,
/// but they should still share one canonical leaf identity and procedure list.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::{LeafBinding, LeafDeclaration, ProtocolLeaf};
/// struct ExampleDecl;
/// impl ProtocolLeaf for ExampleDecl {
/// fn leaf_name() -> String { "org.example.v1.echo".into() }
/// }
/// impl LeafDeclaration for ExampleDecl {
/// fn procedure_suffixes() -> &'static [&'static str] { &["invoke"] }
/// }
/// struct ExampleHost;
/// impl ProtocolLeaf for ExampleHost {
/// fn leaf_name() -> String { ExampleDecl::leaf_name() }
/// }
/// impl LeafBinding for ExampleHost {
/// type Declaration = ExampleDecl;
/// }
/// assert_eq!(<ExampleHost as LeafBinding>::Declaration::leaf_name(), "org.example.v1.echo");
/// ```
pub trait LeafBinding: ProtocolLeaf {
/// Shared declaration that owns the canonical metadata for this host type.
type Declaration: ProtocolLeaf;
}
/// 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, _endpoint: &mut unshell::protocol::tree::ProtocolEndpoint, _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: LeafDeclaration {
/// Leaf-specific error surfaced when generated call dispatch fails.
type Error;
/// 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, _endpoint: &mut unshell::protocol::tree::ProtocolEndpoint, _call: IncomingCall) -> Result<unshell::protocol::tree::CallReply, DispatchError<Self::Error>> {
/// Ok(unshell::protocol::tree::CallReply::NoReply)
/// }
/// }
/// # let _ = ExampleLeaf;
/// ```
fn dispatch_call(
&mut self,
endpoint: &mut ProtocolEndpoint,
call: crate::protocol::tree::IncomingCall,
) -> Result<crate::protocol::tree::CallReply, crate::protocol::tree::DispatchError<Self::Error>>;
}
/// Router-facing transport hooks for leaves that own parent/child connections.
///
/// What it is: an opt-in trait for leaves that want to act as the transport layer
/// for one endpoint's forwarded traffic.
///
/// Why it exists: ordinary leaves only need validated local events, but a router
/// leaf also needs to know its active parent/children and where to physically send
/// frames chosen by the endpoint's routing logic.
///
/// # Example
/// ```rust
/// use unshell::protocol::FrameBytes;
/// use unshell::protocol::tree::{ChildRoute, RouterLeaf};
/// #[derive(Default)]
/// struct DemoRouter {
/// parent: Option<Vec<String>>,
/// children: Vec<ChildRoute>,
/// }
/// impl unshell::protocol::tree::ProtocolLeaf for DemoRouter {
/// fn leaf_name() -> String { "org.example.v1.router".into() }
/// }
/// impl RouterLeaf for DemoRouter {
/// type RouteError = core::convert::Infallible;
///
/// fn parent_path(&self) -> Option<&[String]> { self.parent.as_deref() }
/// fn child_routes(&self) -> &[ChildRoute] { &self.children }
/// fn route_to_parent(&mut self, _local_path: &[String], _frame: FrameBytes) -> Result<(), Self::RouteError> { Ok(()) }
/// fn route_to_child(&mut self, _child_path: &[String], _frame: FrameBytes) -> Result<(), Self::RouteError> { Ok(()) }
/// }
/// ```
pub trait RouterLeaf: ProtocolLeaf {
/// Transport-specific error surfaced while handing a frame to the chosen link.
type RouteError;
/// Returns the currently connected direct parent path, if any.
fn parent_path(&self) -> Option<&[String]>;
/// Returns the currently connected direct child routes.
fn child_routes(&self) -> &[ChildRoute];
/// Sends one routed frame toward the direct parent connection.
fn route_to_parent(
&mut self,
local_path: &[String],
frame: FrameBytes,
) -> Result<(), Self::RouteError>;
/// Sends one routed frame toward the chosen direct child connection.
fn route_to_child(
&mut self,
child_path: &[String],
frame: FrameBytes,
) -> Result<(), Self::RouteError>;
}
/// 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 alloc::string::String;
use super::{LeafBinding, LeafDeclaration, ProtocolLeaf, 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"
);
}
#[test]
fn bound_hosts_can_share_one_declaration() {
struct SharedDecl;
impl ProtocolLeaf for SharedDecl {
fn leaf_name() -> String {
String::from("org.example.v1.echo")
}
}
impl LeafDeclaration for SharedDecl {
fn procedure_suffixes() -> &'static [&'static str] {
&["invoke"]
}
}
struct Host;
impl ProtocolLeaf for Host {
fn leaf_name() -> String {
SharedDecl::leaf_name()
}
}
impl LeafBinding for Host {
type Declaration = SharedDecl;
}
assert_eq!(
<Host as LeafBinding>::Declaration::leaf_spec().name,
"org.example.v1.echo"
);
}
}
-38
View File
@@ -1,38 +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, RoutedRuntimeOutcome,
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, LeafBinding, LeafDeclaration, ProtocolLeaf, RouterLeaf, derive_leaf_name,
leaf_spec_of,
};
pub use procedure::{
Procedure, ProcedureEffect, ProcedureMetadata, ProcedureRuntime, ProcedureRuntimeError,
ProcedureRuntimeOutcome, ProcedureStore, StatefulProcedureMetadata,
};
pub use routing::{
CompiledRoutes, DefaultRouteProvider, LeafNode, RouteDecision, RouteProvider, TreeNode,
is_prefix, route_destination,
};
@@ -1,823 +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,
};
/// Canonical compile-time metadata for one procedure surface.
///
/// What it is: a trait that defines the leaf type and local suffix used to derive
/// one stable protocol `procedure_id`.
///
/// Why it exists: compile-time leaf declarations and future typed remote methods
/// need to talk about procedures without hand-assembling identifiers at each use
/// site.
///
/// # Example
/// ```rust
/// use unshell::protocol::tree::{ProcedureMetadata, ProtocolLeaf};
/// struct ExampleLeaf;
/// impl ProtocolLeaf for ExampleLeaf {
/// fn leaf_name() -> String { "org.example.v1.shell".into() }
/// }
/// struct Open;
/// impl ProcedureMetadata for Open {
/// type Leaf = ExampleLeaf;
/// const PROCEDURE_SUFFIX: &'static str = "open";
/// }
/// assert_eq!(Open::procedure_id(), "org.example.v1.shell.open");
/// ```
pub trait ProcedureMetadata: Sized {
/// Leaf surface this procedure belongs to.
type Leaf: ProtocolLeaf;
/// Returns the local suffix used to derive the full canonical `procedure_id`.
const PROCEDURE_SUFFIX: &'static str;
/// Returns the local suffix used to derive the full canonical `procedure_id`.
fn procedure_suffix() -> &'static str {
Self::PROCEDURE_SUFFIX
}
/// Returns the canonical `procedure_id` for this procedure.
fn procedure_id() -> String {
let mut procedure_id = <Self::Leaf as ProtocolLeaf>::leaf_name();
procedure_id.push('.');
procedure_id.push_str(Self::procedure_suffix());
procedure_id
}
}
/// 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::{ProcedureMetadata, ProtocolLeaf, StatefulProcedureMetadata};
/// struct ExampleLeaf;
/// impl ProtocolLeaf for ExampleLeaf {
/// fn leaf_name() -> String { "org.example.v1.shell".into() }
/// }
/// struct Open;
/// impl ProcedureMetadata for Open {
/// type Leaf = ExampleLeaf;
/// const PROCEDURE_SUFFIX: &'static str = "open";
/// }
/// fn _compat<T: StatefulProcedureMetadata<ExampleLeaf>>() {}
/// _compat::<Open>();
/// assert_eq!(Open::procedure_id(), "org.example.v1.shell.open");
/// ```
pub trait StatefulProcedureMetadata<L>: ProcedureMetadata<Leaf = L> + Sized
where
L: ProtocolLeaf,
{
}
impl<T, L> StatefulProcedureMetadata<L> for T
where
T: ProcedureMetadata<Leaf = L>,
L: ProtocolLeaf,
{
}
/// 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::{Procedure, leaf};
/// use unshell::protocol::tree::{Call, HookKey, Procedure, ProcedureEffect, ProcedureStore};
///
/// #[derive(Default)]
/// struct StreamLeaf {
/// sessions: BTreeMap<HookKey, OpenProcedure>,
/// }
///
/// leaf! {
/// id = "org.example.v1.stream",
/// procedures = [OpenProcedure],
/// endpoint_struct = StreamLeaf,
/// }
///
/// 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>: ProcedureMetadata<Leaf = 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.
frames.extend(self.poll_session(key, session)?);
}
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,
session: P,
) -> Result<Vec<FrameBytes>, ProcedureRuntimeError<P::Error>> {
self.advance_session(key, session, P::poll)
}
fn advance_session<F>(
&mut self,
key: HookKey,
mut session: P,
step: F,
) -> Result<Vec<FrameBytes>, ProcedureRuntimeError<P::Error>>
where
F: FnOnce(&mut L, &mut P) -> Result<ProcedureEffect, P::Error>,
{
let effect = match step(&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(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(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(session) = self.leaf.procedure_sessions().remove(&hook_key) else {
return Ok(ProcedureRuntimeOutcome::default());
};
let outgoing = self.advance_session(hook_key.clone(), session, |leaf, session| {
P::on_data(
leaf,
session,
IncomingData {
header,
message,
hook_key,
},
)
})?;
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
}
}
@@ -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);
}
-168
View File
@@ -1,168 +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),
/// One endpoint-topology update would break local tree invariants.
TopologyInvariant(&'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::TopologyInvariant(message) => write!(f, "invalid topology: {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(())
}
-21
View File
@@ -1,21 +0,0 @@
[package]
name = "unshell-runtime"
version.workspace = true
edition.workspace = true
description = "Transport-neutral runtime API types for UnShell"
[dependencies]
unshell-protocol = { workspace = true }
[lints.rust]
elided_lifetimes_in_paths = "warn"
future_incompatible = { level = "warn", priority = -1 }
nonstandard_style = { level = "warn", priority = -1 }
rust_2018_idioms = { level = "warn", priority = -1 }
rust_2021_prelude_collisions = "warn"
semicolon_in_expressions_from_macros = "warn"
unsafe_op_in_unsafe_fn = "warn"
unused_import_braces = "warn"
unused_lifetimes = "warn"
trivial_casts = "allow"
missing_docs = "warn"
-335
View File
@@ -1,335 +0,0 @@
//! Runtime connection admission and routing metadata.
//!
//! A connection is not routable just because a transport exists. Only
//! [`ConnectionState::Registered`] connections are allowed to produce protocol
//! ingress or receive forwarded frames.
use crate::alloc::string::String;
use crate::alloc::vec::Vec;
/// Stable runtime handle for one transport connection slot.
#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct ConnectionId(u64);
impl ConnectionId {
/// Creates a connection identifier from a raw value.
#[must_use]
pub const fn new(value: u64) -> Self {
Self(value)
}
/// Returns the raw identifier value.
#[must_use]
pub const fn get(self) -> u64 {
self.0
}
}
/// Monotonic incarnation number for one connection slot.
#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct ConnectionGeneration(u64);
impl ConnectionGeneration {
/// First generation assigned to a new connection slot.
pub const INITIAL: Self = Self(0);
/// Creates a generation from a raw value.
#[must_use]
pub const fn new(value: u64) -> Self {
Self(value)
}
/// Returns the raw generation value.
#[must_use]
pub const fn get(self) -> u64 {
self.0
}
/// Returns the next generation, saturating at `u64::MAX`.
#[must_use]
pub const fn next(self) -> Self {
Self(self.0.saturating_add(1))
}
}
/// Local tree relationship for a registered connection.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum ConnectionDirection {
/// The peer is the direct parent of this endpoint.
Parent,
/// The peer is a direct child of this endpoint.
Child,
}
/// Metadata that makes a connection routable.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RegisteredConnection {
direction: ConnectionDirection,
peer_path: Vec<String>,
generation: ConnectionGeneration,
}
impl RegisteredConnection {
/// Creates registered routing metadata.
#[must_use]
pub const fn new(
direction: ConnectionDirection,
peer_path: Vec<String>,
generation: ConnectionGeneration,
) -> Self {
Self {
direction,
peer_path,
generation,
}
}
/// Returns the local tree relationship.
#[must_use]
pub const fn direction(&self) -> ConnectionDirection {
self.direction
}
/// Returns the registered peer path.
#[must_use]
pub fn peer_path(&self) -> &[String] {
&self.peer_path
}
/// Returns the connection generation.
#[must_use]
pub const fn generation(&self) -> ConnectionGeneration {
self.generation
}
}
/// Runtime lifecycle state for one connection slot.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ConnectionState {
/// The transport exists but has not started or completed admission.
Connected {
/// Connection generation for this transport incarnation.
generation: ConnectionGeneration,
},
/// The runtime is evaluating whether this peer should become routable.
Authenticating {
/// Connection generation for this transport incarnation.
generation: ConnectionGeneration,
},
/// The peer is admitted into protocol routing.
Registered(RegisteredConnection),
/// The runtime is tearing this connection down and should reject new work.
Draining {
/// Connection generation for this transport incarnation.
generation: ConnectionGeneration,
},
/// The connection is closed and retained only as historical metadata.
Closed {
/// Connection generation for this transport incarnation.
generation: ConnectionGeneration,
},
}
impl ConnectionState {
/// Returns the generation associated with this state.
#[must_use]
pub const fn generation(&self) -> ConnectionGeneration {
match self {
Self::Connected { generation }
| Self::Authenticating { generation }
| Self::Draining { generation }
| Self::Closed { generation } => *generation,
Self::Registered(registered) => registered.generation(),
}
}
/// Returns registered metadata when this connection is routable.
#[must_use]
pub const fn registered(&self) -> Option<&RegisteredConnection> {
match self {
Self::Registered(registered) => Some(registered),
Self::Connected { .. }
| Self::Authenticating { .. }
| Self::Draining { .. }
| Self::Closed { .. } => None,
}
}
}
/// One runtime connection slot.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Connection {
id: ConnectionId,
state: ConnectionState,
}
impl Connection {
/// Creates a connected but unroutable connection slot.
#[must_use]
pub const fn connected(id: ConnectionId, generation: ConnectionGeneration) -> Self {
Self {
id,
state: ConnectionState::Connected { generation },
}
}
/// Creates a registered connection slot.
#[must_use]
pub const fn registered(
id: ConnectionId,
direction: ConnectionDirection,
peer_path: Vec<String>,
generation: ConnectionGeneration,
) -> Self {
Self {
id,
state: ConnectionState::Registered(RegisteredConnection::new(
direction, peer_path, generation,
)),
}
}
/// Returns the connection id.
#[must_use]
pub const fn id(&self) -> ConnectionId {
self.id
}
/// Returns the current connection state.
#[must_use]
pub const fn state(&self) -> &ConnectionState {
&self.state
}
/// Replaces the current connection state.
pub fn set_state(&mut self, state: ConnectionState) {
self.state = state;
}
}
/// Connection metadata table owned by the runtime.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct Connections {
entries: Vec<Connection>,
}
impl Connections {
/// Creates an empty table.
#[must_use]
pub const fn new() -> Self {
Self {
entries: Vec::new(),
}
}
/// Inserts a connection descriptor.
pub fn push(&mut self, connection: Connection) {
self.entries.push(connection);
}
/// Returns all connection descriptors.
#[must_use]
pub fn entries(&self) -> &[Connection] {
&self.entries
}
/// Finds a connection by id.
#[must_use]
pub fn get(&self, id: ConnectionId) -> Option<&Connection> {
self.entries.iter().find(|entry| entry.id() == id)
}
/// Finds a mutable connection by id.
#[must_use]
pub fn get_mut(&mut self, id: ConnectionId) -> Option<&mut Connection> {
self.entries.iter_mut().find(|entry| entry.id() == id)
}
/// Returns registered metadata for a routable connection.
#[must_use]
pub fn registered(&self, id: ConnectionId) -> Option<&RegisteredConnection> {
self.get(id)
.and_then(|connection| connection.state().registered())
}
/// Finds a registered connection by direction.
#[must_use]
pub fn registered_by_direction(&self, direction: ConnectionDirection) -> Option<&Connection> {
self.entries.iter().find(|entry| {
entry
.state()
.registered()
.is_some_and(|registered| registered.direction() == direction)
})
}
/// Finds a registered connection by direction and peer path.
#[must_use]
pub fn registered_by_path(
&self,
direction: ConnectionDirection,
peer_path: &[String],
) -> Option<&Connection> {
self.entries.iter().find(|entry| {
entry.state().registered().is_some_and(|registered| {
registered.direction() == direction && registered.peer_path() == peer_path
})
})
}
/// Makes every matching registered connection except `except` unroutable.
pub(crate) fn demote_registered_direction_except(
&mut self,
direction: ConnectionDirection,
except: ConnectionId,
) {
for entry in &mut self.entries {
let Some(registered) = entry.state().registered() else {
continue;
};
if entry.id() == except || registered.direction() != direction {
continue;
}
entry.set_state(ConnectionState::Connected {
generation: registered.generation(),
});
}
}
/// Makes every matching registered peer path except `except` unroutable.
pub(crate) fn demote_registered_path_except(
&mut self,
direction: ConnectionDirection,
peer_path: &[String],
except: ConnectionId,
) {
for entry in &mut self.entries {
let Some(registered) = entry.state().registered() else {
continue;
};
if entry.id() == except
|| registered.direction() != direction
|| registered.peer_path() != peer_path
{
continue;
}
entry.set_state(ConnectionState::Connected {
generation: registered.generation(),
});
}
}
}
/// Read-only connection table view exposed to leaf contexts.
pub trait ConnectionTable {
/// Returns registered metadata for a routable connection.
fn registered(&self, id: ConnectionId) -> Option<&RegisteredConnection>;
}
impl ConnectionTable for Connections {
fn registered(&self, id: ConnectionId) -> Option<&RegisteredConnection> {
Self::registered(self, id)
}
}
-205
View File
@@ -1,205 +0,0 @@
//! Request-only context exposed to leaf callbacks.
//!
//! Leaf code never receives direct access to route tables, hook state, endpoint
//! internals, or transport handles. It can only enqueue [`LeafAction`] values.
//! The runtime validates and applies those actions later.
use crate::alloc::string::String;
use crate::alloc::vec::Vec;
use crate::connections::{ConnectionDirection, ConnectionId, Connections};
use crate::leaf::{LeafCapabilities, LeafId};
use unshell_protocol::ProtocolFault;
/// Context handed to one leaf callback.
#[derive(Debug)]
pub struct LeafContext<'a> {
local_path: &'a [String],
leaf_id: &'a LeafId,
capabilities: &'a LeafCapabilities,
connections: &'a Connections,
actions: Vec<LeafAction>,
}
impl<'a> LeafContext<'a> {
/// Creates a context for one leaf callback.
#[must_use]
pub const fn new(
local_path: &'a [String],
leaf_id: &'a LeafId,
capabilities: &'a LeafCapabilities,
connections: &'a Connections,
) -> Self {
Self {
local_path,
leaf_id,
capabilities,
connections,
actions: Vec::new(),
}
}
/// Returns this endpoint's absolute path.
#[must_use]
pub const fn local_path(&self) -> &[String] {
self.local_path
}
/// Returns the leaf currently using this context.
#[must_use]
pub const fn leaf_id(&self) -> &LeafId {
self.leaf_id
}
/// Returns the permissions granted to this leaf.
#[must_use]
pub const fn capabilities(&self) -> &LeafCapabilities {
self.capabilities
}
/// Returns read-only connection metadata.
#[must_use]
pub const fn connections(&self) -> &Connections {
self.connections
}
/// Returns queued leaf actions.
#[must_use]
pub fn actions(&self) -> &[LeafAction] {
&self.actions
}
/// Consumes the context and returns queued actions.
#[must_use]
pub fn into_actions(self) -> Vec<LeafAction> {
self.actions
}
/// Requests an outbound call.
pub fn call(&mut self, call: OutboundCall) -> Result<(), RequestDenied> {
if !self.capabilities.permissions.send_calls {
return Err(RequestDenied::MissingCapability(
RuntimeCapability::SendCalls,
));
}
self.actions.push(LeafAction::SendCall(call));
Ok(())
}
/// Requests data on an existing hook.
pub fn hook_data(&mut self, data: OutboundHookData) -> Result<(), RequestDenied> {
if !self.capabilities.permissions.send_hook_data {
return Err(RequestDenied::MissingCapability(
RuntimeCapability::SendHookData,
));
}
self.actions.push(LeafAction::SendHookData(data));
Ok(())
}
/// Requests hook termination with a protocol fault.
pub fn fail_hook(&mut self, hook_id: u64, fault: ProtocolFault) -> Result<(), RequestDenied> {
if !self.capabilities.permissions.send_hook_data {
return Err(RequestDenied::MissingCapability(
RuntimeCapability::SendHookData,
));
}
self.actions.push(LeafAction::FailHook { hook_id, fault });
Ok(())
}
/// Requests a connection admission or teardown action.
pub fn connection(&mut self, request: ConnectionAction) -> Result<(), RequestDenied> {
if !self.capabilities.permissions.manage_connections {
return Err(RequestDenied::MissingCapability(
RuntimeCapability::ManageConnections,
));
}
self.actions.push(LeafAction::Connection(request));
Ok(())
}
}
/// Runtime action requested by leaf code.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum LeafAction {
/// Build and send one outbound call.
SendCall(OutboundCall),
/// Build and send one hook data packet.
SendHookData(OutboundHookData),
/// Terminate a hook with a protocol fault.
FailHook {
/// Hook identifier scoped by the hook host.
hook_id: u64,
/// Stable protocol fault code.
fault: ProtocolFault,
},
/// Request a connection state change.
Connection(ConnectionAction),
}
/// Outbound call request before packet construction.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct OutboundCall {
/// Destination endpoint path.
pub dst_path: Vec<String>,
/// Optional destination leaf name.
pub dst_leaf: Option<String>,
/// Canonical procedure id.
pub procedure_id: String,
/// Opaque request payload.
pub payload: Vec<u8>,
/// Whether the runtime should allocate a response hook.
pub expects_response: bool,
}
/// Hook data request before packet construction.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct OutboundHookData {
/// Destination endpoint path for the hook packet.
pub dst_path: Vec<String>,
/// Hook identifier scoped by the receiving endpoint.
pub hook_id: u64,
/// Canonical procedure id associated with the hook stream.
pub procedure_id: String,
/// Opaque payload bytes.
pub payload: Vec<u8>,
/// Whether this packet closes the local side of the hook.
pub end_hook: bool,
}
/// Requested connection state change.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ConnectionAction {
/// Register an existing connection as a direct parent or child.
Register {
/// Runtime transport connection id.
connection: ConnectionId,
/// Requested tree direction.
direction: ConnectionDirection,
/// Peer path to register.
peer_path: Vec<String>,
},
/// Remove a connection from runtime routing.
Unregister {
/// Runtime transport connection id.
connection: ConnectionId,
},
}
/// Capability checked by [`LeafContext`] helpers.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RuntimeCapability {
/// Permission to request outbound calls.
SendCalls,
/// Permission to request hook data or hook faults.
SendHookData,
/// Permission to request connection state changes.
ManageConnections,
}
/// Rejection reason for a leaf action request.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RequestDenied {
/// The leaf does not have the required capability.
MissingCapability(RuntimeCapability),
}
-115
View File
@@ -1,115 +0,0 @@
//! Runtime effects produced by packet processing.
use crate::alloc::vec::Vec;
use crate::connections::{ConnectionGeneration, ConnectionId};
use unshell_protocol::FrameBytes;
use unshell_protocol::tree::LocalEvent;
/// Side effect selected by endpoint packet processing.
#[derive(Clone, Debug)]
pub enum RuntimeEffect {
/// Send a frame to a registered connection.
SendFrame {
/// Destination connection id.
connection: ConnectionId,
/// Generation observed when the effect was queued.
generation: ConnectionGeneration,
/// Encoded protocol frame.
frame: FrameBytes,
},
/// Deliver a local protocol event to the future leaf/session dispatcher.
Local(LocalEvent),
/// The frame was intentionally dropped by protocol state.
Dropped,
}
/// FIFO queue of runtime effects.
#[derive(Clone, Debug, Default)]
pub struct EffectQueue {
entries: Vec<RuntimeEffect>,
}
impl EffectQueue {
/// Creates an empty effect queue.
#[must_use]
pub const fn new() -> Self {
Self {
entries: Vec::new(),
}
}
/// Queues an effect.
pub fn push(&mut self, effect: RuntimeEffect) {
self.entries.push(effect);
}
/// Returns queued effects.
#[must_use]
pub fn entries(&self) -> &[RuntimeEffect] {
&self.entries
}
/// Drains queued effects in FIFO order.
pub fn drain(&mut self) -> impl Iterator<Item = RuntimeEffect> + '_ {
self.entries.drain(..)
}
/// Drains local-dispatch effects in FIFO order, leaving outbound sends queued.
pub fn drain_local(&mut self) -> impl Iterator<Item = RuntimeEffect> {
let mut drained = Vec::new();
let mut retained = Vec::with_capacity(self.entries.len());
for effect in self.entries.drain(..) {
match effect {
RuntimeEffect::Local(_) | RuntimeEffect::Dropped => drained.push(effect),
RuntimeEffect::SendFrame { .. } => retained.push(effect),
}
}
self.entries = retained;
drained.into_iter()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn drain_local_leaves_outbound_sends_queued() {
let first = ConnectionId::new(1);
let second = ConnectionId::new(2);
let mut queue = EffectQueue::new();
queue.push(RuntimeEffect::SendFrame {
connection: first,
generation: ConnectionGeneration::INITIAL,
frame: FrameBytes::new(),
});
queue.push(RuntimeEffect::Dropped);
queue.push(RuntimeEffect::SendFrame {
connection: second,
generation: ConnectionGeneration::INITIAL,
frame: FrameBytes::new(),
});
queue.push(RuntimeEffect::Dropped);
let drained: Vec<_> = queue.drain_local().collect();
assert_eq!(drained.len(), 2);
assert!(
drained
.iter()
.all(|effect| matches!(effect, RuntimeEffect::Dropped))
);
assert_eq!(queue.entries().len(), 2);
assert!(matches!(
queue.entries()[0],
RuntimeEffect::SendFrame { connection, .. } if connection == first
));
assert!(matches!(
queue.entries()[1],
RuntimeEffect::SendFrame { connection, .. } if connection == second
));
}
}
-177
View File
@@ -1,177 +0,0 @@
//! Leaf-facing runtime types.
use crate::alloc::boxed::Box;
use crate::alloc::string::String;
use crate::alloc::vec::Vec;
use crate::context::LeafContext;
use unshell_protocol::tree::{IncomingCall, IncomingData, IncomingFault};
/// Stable identifier for a locally hosted leaf binding.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct LeafId(String);
impl LeafId {
/// Creates a leaf id from an owned string.
#[must_use]
pub const fn new(value: String) -> Self {
Self(value)
}
/// Returns the leaf id as a string slice.
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
/// Runtime permissions granted to one leaf binding.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct LeafPermissions {
/// The leaf may request new outbound calls.
pub send_calls: bool,
/// The leaf may request data or faults on hook streams.
pub send_hook_data: bool,
/// The leaf may request connection registration or removal.
pub manage_connections: bool,
}
impl LeafPermissions {
/// Grants no runtime-side effects.
pub const NONE: Self = Self {
send_calls: false,
send_hook_data: false,
manage_connections: false,
};
/// Grants the common permission set for a passive responder leaf.
pub const REPLY_ONLY: Self = Self {
send_calls: false,
send_hook_data: true,
manage_connections: false,
};
/// Grants all current permissions. Use sparingly.
pub const ALL: Self = Self {
send_calls: true,
send_hook_data: true,
manage_connections: true,
};
}
/// Protocol surface and runtime permissions for one leaf.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct LeafCapabilities {
/// Canonical dotted leaf name.
pub leaf_name: String,
/// Canonical procedure ids supported by the leaf.
pub procedures: Vec<String>,
/// Runtime permissions granted to this leaf binding.
pub permissions: LeafPermissions,
}
/// One hosted leaf implementation.
pub trait Leaf {
/// Leaf-specific error type.
type Error;
/// Returns static protocol and runtime capabilities.
fn capabilities(&self) -> &LeafCapabilities;
/// Handles one opening call routed to this leaf.
fn on_call(
&mut self,
_ctx: &mut LeafContext<'_>,
_call: IncomingCall,
) -> Result<(), Self::Error> {
Ok(())
}
/// Handles hook data routed to this leaf or its session adapter.
fn on_data(
&mut self,
_ctx: &mut LeafContext<'_>,
_data: IncomingData,
) -> Result<(), Self::Error> {
Ok(())
}
/// Handles hook fault routed to this leaf or its session adapter.
fn on_fault(
&mut self,
_ctx: &mut LeafContext<'_>,
_fault: IncomingFault,
) -> Result<(), Self::Error> {
Ok(())
}
/// Gives the leaf one bounded opportunity to request local work.
fn poll(&mut self, _ctx: &mut LeafContext<'_>) -> Result<(), Self::Error> {
Ok(())
}
}
/// One leaf handler registered with a runtime-local dispatch key.
///
/// The id is the packet `dst_leaf` name used by [`unshell_protocol::tree::LocalEvent`]
/// call headers. The runtime keeps this intentionally small: it only finds the
/// target callback and records requested [`crate::context::LeafAction`] values.
pub struct RegisteredLeaf<Error> {
id: LeafId,
capabilities: LeafCapabilities,
handler: Box<dyn Leaf<Error = Error>>,
}
impl<Error> RegisteredLeaf<Error> {
/// Creates a registered leaf from an explicit dispatch id and handler.
#[must_use]
pub fn new<L>(id: LeafId, handler: L) -> Self
where
L: Leaf<Error = Error> + 'static,
{
let capabilities = handler.capabilities().clone();
Self {
id,
capabilities,
handler: Box::new(handler),
}
}
/// Returns the dispatch id used for local packet matching.
#[must_use]
pub const fn id(&self) -> &LeafId {
&self.id
}
/// Returns the capabilities cached at registration time.
#[must_use]
pub const fn capabilities(&self) -> &LeafCapabilities {
&self.capabilities
}
/// Returns immutable access to the hosted leaf.
#[must_use]
pub fn handler(&self) -> &dyn Leaf<Error = Error> {
self.handler.as_ref()
}
/// Returns mutable access to the hosted leaf.
#[must_use]
pub fn handler_mut(&mut self) -> &mut dyn Leaf<Error = Error> {
self.handler.as_mut()
}
/// Returns all fields needed to invoke a leaf without cloning metadata.
pub(crate) fn dispatch_parts_mut(
&mut self,
) -> (&LeafId, &LeafCapabilities, &mut dyn Leaf<Error = Error>) {
(&self.id, &self.capabilities, self.handler.as_mut())
}
}
impl<Error> core::fmt::Debug for RegisteredLeaf<Error> {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("RegisteredLeaf")
.field("id", &self.id)
.finish_non_exhaustive()
}
}
-123
View File
@@ -1,123 +0,0 @@
//! # UnShell Runtime
//!
//! Single-threaded runtime scaffolding for hosting UnShell protocol nodes. This
//! crate currently bridges the existing protocol endpoint state while defining
//! the concrete transport, connection, and leaf-action APIs the redesign will use.
#![no_std]
pub extern crate alloc;
pub mod connections;
pub mod context;
pub mod effects;
pub mod leaf;
pub mod node;
pub mod transport;
pub use connections::{
Connection, ConnectionDirection, ConnectionGeneration, ConnectionId, ConnectionState,
ConnectionTable, Connections, RegisteredConnection,
};
pub use context::{
ConnectionAction, LeafAction, LeafContext, OutboundCall, OutboundHookData, RequestDenied,
RuntimeCapability,
};
pub use effects::{EffectQueue, RuntimeEffect};
pub use leaf::{Leaf, LeafCapabilities, LeafId, LeafPermissions, RegisteredLeaf};
pub use node::{
EndpointState, LeafDispatchError, Node, NodeId, NodeRuntime, NodeRuntimeError, NodeState,
TickBudget, TickOutcome,
};
pub use transport::Transport;
#[cfg(test)]
mod tests {
use crate::alloc::string::String;
use crate::alloc::vec;
use crate::alloc::vec::Vec;
use super::{
Connection, ConnectionDirection, ConnectionGeneration, ConnectionId, ConnectionState,
Connections, LeafAction, LeafCapabilities, LeafContext, LeafId, LeafPermissions,
OutboundCall, OutboundHookData, RequestDenied, RuntimeCapability,
};
#[test]
fn connection_generation_advances_without_wrapping() {
assert_eq!(ConnectionGeneration::INITIAL.get(), 0);
assert_eq!(ConnectionGeneration::new(41).next().get(), 42);
assert_eq!(ConnectionGeneration::new(u64::MAX).next().get(), u64::MAX);
}
#[test]
fn connection_table_reports_registered_connection_metadata() {
let id = ConnectionId::new(7);
let mut connections = Connections::new();
connections.push(Connection::registered(
id,
ConnectionDirection::Child,
vec![String::from("root"), String::from("child")],
ConnectionGeneration::new(3),
));
let registered = connections
.registered(id)
.expect("connection is registered");
assert_eq!(registered.direction(), ConnectionDirection::Child);
assert_eq!(registered.generation().get(), 3);
assert_eq!(registered.peer_path(), ["root", "child"]);
}
#[test]
fn connected_connections_are_not_routable() {
let id = ConnectionId::new(9);
let mut connections = Connections::new();
connections.push(Connection::connected(id, ConnectionGeneration::INITIAL));
assert!(connections.registered(id).is_none());
assert!(matches!(
connections.get(id).unwrap().state(),
ConnectionState::Connected { .. }
));
}
#[test]
fn leaf_context_queues_only_capability_checked_actions() {
let id = LeafId::new(String::from("org.example.v1.echo"));
let capabilities = LeafCapabilities {
leaf_name: String::from("org.example.v1.echo"),
procedures: vec![String::from("org.example.v1.echo.invoke")],
permissions: LeafPermissions::REPLY_ONLY,
};
let connections = Connections::new();
let local_path = vec![String::from("root")];
let mut ctx = LeafContext::new(&local_path, &id, &capabilities, &connections);
ctx.hook_data(OutboundHookData {
dst_path: vec![String::from("root")],
hook_id: 7,
procedure_id: String::from("org.example.v1.echo.invoke"),
payload: vec![1, 2, 3],
end_hook: true,
})
.expect("reply-only leaf can send hook data");
let denied = ctx.call(OutboundCall {
dst_path: vec![String::from("root"), String::from("child")],
dst_leaf: None,
procedure_id: String::from("org.example.v1.echo.invoke"),
payload: Vec::new(),
expects_response: false,
});
assert_eq!(ctx.local_path(), ["root"]);
assert!(matches!(ctx.actions()[0], LeafAction::SendHookData(_)));
assert_eq!(
denied,
Err(RequestDenied::MissingCapability(
RuntimeCapability::SendCalls
))
);
}
}
-73
View File
@@ -1,73 +0,0 @@
//! Node-level runtime identity types.
//!
//! A node is the local runtime owner for protocol state, leaf bindings, and
//! transport connections. This module only models identity and lifecycle state.
pub mod packet;
pub mod runtime;
pub mod state;
pub use packet::{EndpointState, PacketProcessor};
pub use runtime::{LeafDispatchError, NodeRuntime, NodeRuntimeError, TickBudget, TickOutcome};
pub use state::NodeState;
use crate::alloc::string::String;
/// Stable identifier for a runtime node.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct NodeId(String);
impl NodeId {
/// Creates a node identifier from an owned string.
#[must_use]
pub const fn new(value: String) -> Self {
Self(value)
}
/// Returns the identifier as a string slice.
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
/// Consumes the identifier and returns the owned string.
#[must_use]
pub fn into_string(self) -> String {
self.0
}
}
/// Minimal runtime node descriptor.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Node {
id: NodeId,
state: NodeState,
}
impl Node {
/// Creates a new node descriptor in the default [`NodeState::Created`] state.
#[must_use]
pub const fn new(id: NodeId) -> Self {
Self {
id,
state: NodeState::Created,
}
}
/// Returns the node identifier.
#[must_use]
pub const fn id(&self) -> &NodeId {
&self.id
}
/// Returns the current node lifecycle state.
#[must_use]
pub const fn state(&self) -> NodeState {
self.state
}
/// Updates the current node lifecycle state.
pub const fn set_state(&mut self, state: NodeState) {
self.state = state;
}
}
-174
View File
@@ -1,174 +0,0 @@
//! Transitional packet-processing wrapper around the current protocol endpoint.
//!
//! This module is intentionally small. It gives the new runtime crate a concrete
//! bridge to the existing packet state machine while the protocol crate is split
//! into packet-only and runtime-owned layers. The wrapper does not own transport
//! handles, does not dispatch leaves, and does not make admission decisions.
use unshell_protocol::{
CallMessage, FrameBytes, PacketHeader, PacketType, ProtocolFault,
tree::Endpoint as ProtocolEndpointTrait, validate_call, validate_header, validate_procedure_id,
};
pub use unshell_protocol::tree::{
ChildRoute, EndpointError, EndpointOutcome, HookKey, Ingress, LeafSpec, LocalEvent,
ProtocolEndpoint, RouteDecision,
};
/// Minimal packet processor used by future single-threaded runtimes.
///
/// The processor receives one frame with an already-derived ingress side and
/// returns the existing endpoint outcome. A full `NodeRuntime` should derive the
/// ingress from registered connection metadata before calling this trait.
pub trait PacketProcessor {
/// Processes one serialized frame through protocol validation, routing, and
/// hook-state transitions.
fn process_frame(
&mut self,
ingress: &Ingress,
frame: FrameBytes,
) -> Result<EndpointOutcome, EndpointError>;
}
/// Runtime-owned endpoint packet state.
///
/// This is a compatibility shell around [`ProtocolEndpoint`]. It exists so new
/// runtime code can depend on `unshell_runtime::node::EndpointState` while the
/// old protocol-tree endpoint remains the source of truth for packet invariants.
#[derive(Clone, Debug, Default)]
pub struct EndpointState {
endpoint: ProtocolEndpoint,
}
impl EndpointState {
/// Creates a packet state wrapper from an existing protocol endpoint.
#[must_use]
pub const fn new(endpoint: ProtocolEndpoint) -> Self {
Self { endpoint }
}
/// Creates packet state for a root-assumed endpoint.
#[must_use]
pub fn root(
local_id: impl Into<alloc::string::String>,
leaves: alloc::vec::Vec<LeafSpec>,
) -> Self {
Self::new(ProtocolEndpoint::root(local_id, leaves))
}
/// Returns the wrapped protocol endpoint.
#[must_use]
pub const fn endpoint(&self) -> &ProtocolEndpoint {
&self.endpoint
}
/// Returns mutable access to the wrapped protocol endpoint.
///
/// This is intentionally exposed only on the transitional wrapper. New runtime
/// code should prefer smaller methods as the endpoint state is split apart.
#[must_use]
pub const fn endpoint_mut(&mut self) -> &mut ProtocolEndpoint {
&mut self.endpoint
}
/// Returns the endpoint's current route decision for an absolute path.
#[must_use]
pub fn route_decision(&self, dst_path: &[alloc::string::String]) -> RouteDecision {
self.endpoint.route_decision(dst_path)
}
/// Builds and routes one hook-data packet through the wrapped endpoint state.
pub fn send_hook_data(
&mut self,
dst_path: alloc::vec::Vec<alloc::string::String>,
hook_id: u64,
procedure_id: alloc::string::String,
data: alloc::vec::Vec<u8>,
end_hook: bool,
) -> Result<EndpointOutcome, EndpointError> {
self.endpoint
.send_data(dst_path, hook_id, procedure_id, data, end_hook)
}
/// Returns the route that would carry a terminal hook fault, if the hook is known.
#[must_use]
pub fn hook_fault_route(&self, hook_id: u64) -> Option<RouteDecision> {
self.endpoint.hook_fault_route(hook_id)
}
/// Terminates a known hook with a protocol fault, or drops unknown hook ids.
pub fn fail_hook(
&mut self,
hook_id: u64,
fault: ProtocolFault,
) -> Result<EndpointOutcome, EndpointError> {
self.endpoint.fail_hook(hook_id, fault)
}
/// Builds and routes one call packet through the wrapped endpoint state.
pub fn send_call(
&mut self,
dst_path: alloc::vec::Vec<alloc::string::String>,
dst_leaf: Option<alloc::string::String>,
procedure_id: alloc::string::String,
response_hook_id: Option<u64>,
data: alloc::vec::Vec<u8>,
) -> Result<EndpointOutcome, EndpointError> {
self.endpoint
.send_call(dst_path, dst_leaf, procedure_id, response_hook_id, data)
}
/// Validates an outbound call request before allocating response hook state.
pub fn validate_call_request(
&self,
dst_path: &[alloc::string::String],
dst_leaf: Option<&alloc::string::String>,
procedure_id: &str,
data: &[u8],
expects_response: bool,
) -> Result<(), EndpointError> {
validate_procedure_id(procedure_id)?;
let header = PacketHeader {
packet_type: PacketType::Call,
src_path: self.endpoint.path().to_vec(),
dst_path: dst_path.to_vec(),
dst_leaf: dst_leaf.cloned(),
hook_id: None,
};
let call = CallMessage {
procedure_id: procedure_id.into(),
data: data.to_vec(),
response_hook: expects_response.then(|| unshell_protocol::HookTarget {
hook_id: 1,
return_path: self.endpoint.path().to_vec(),
}),
};
validate_header(&header)?;
validate_call(&header, &call)?;
Ok(())
}
/// Allocates a response hook id scoped to this endpoint path.
#[must_use]
pub fn allocate_hook_id(&mut self) -> u64 {
self.endpoint.allocate_hook_id()
}
/// Consumes the wrapper and returns the underlying protocol endpoint.
#[must_use]
pub fn into_endpoint(self) -> ProtocolEndpoint {
self.endpoint
}
}
impl PacketProcessor for EndpointState {
fn process_frame(
&mut self,
ingress: &Ingress,
frame: FrameBytes,
) -> Result<EndpointOutcome, EndpointError> {
self.endpoint.receive(ingress, frame)
}
}
File diff suppressed because it is too large Load Diff
-15
View File
@@ -1,15 +0,0 @@
//! Node lifecycle state.
/// Lifecycle state for a runtime node.
#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum NodeState {
/// The node has been constructed but has not started transport activity.
#[default]
Created,
/// The node is accepting local work and transport events.
Running,
/// The node is draining work before shutdown.
Stopping,
/// The node has stopped and should not accept new work.
Stopped,
}
-31
View File
@@ -1,31 +0,0 @@
//! Nonblocking transport contract for the single-threaded runtime.
//!
//! Transports move already-framed protocol packets. They do not know tree paths,
//! leaf names, hook state, admission policy, or route decisions.
use crate::connections::ConnectionId;
use unshell_protocol::FrameBytes;
/// Nonblocking frame transport used by [`crate::node::NodeRuntime`].
pub trait Transport {
/// Transport-specific error.
type Error;
/// Polls for one inbound frame.
///
/// `Ok(None)` means no frame is currently ready. Implementations must not
/// block inside this method; callers drive progress by calling `tick` again.
fn poll_recv(&mut self) -> Result<Option<(ConnectionId, FrameBytes)>, Self::Error>;
/// Sends one framed packet on a registered connection.
fn send_frame(
&mut self,
connection: ConnectionId,
frame: &FrameBytes,
) -> Result<(), Self::Error>;
/// Flushes buffered outbound transport data, if the transport has any.
fn flush(&mut self) -> Result<(), Self::Error> {
Ok(())
}
}