mirror of
https://github.com/Astatin3/unshell.git
synced 2026-06-08 22:38:01 -06:00
Big rewrite.
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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}");
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
@@ -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")]
|
|
||||||
}
|
|
||||||
@@ -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(())
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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))
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
@@ -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 {}
|
|
||||||
@@ -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.
|
|
||||||
@@ -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 }
|
|
||||||
@@ -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(())
|
|
||||||
}
|
|
||||||
@@ -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(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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.
|
|
||||||
@@ -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>,
|
|
||||||
}
|
|
||||||
@@ -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};
|
|
||||||
@@ -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)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
@@ -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());
|
|
||||||
}
|
|
||||||
@@ -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, ®istered_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,
|
|
||||||
®istered_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),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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")],
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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(())
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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),
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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,
|
|
||||||
}
|
|
||||||
@@ -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(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user