From 56abb5e1e026a20d9c0f9479f6a8fbe03147d666 Mon Sep 17 00:00:00 2001 From: Michael Mikovsky <77305074+Astatin3@users.noreply.github.com> Date: Sat, 16 May 2026 13:10:51 -0600 Subject: [PATCH] Big rewrite. --- Cargo.toml | 4 + examples/protocol/bench/bench.rs | 413 --- examples/protocol/bench/op_decode_call.rs | 10 - examples/protocol/bench/op_encode_call.rs | 10 - .../protocol/bench/op_forward_call_receive.rs | 10 - .../protocol/bench/op_hook_data_receive.rs | 10 - .../protocol/bench/op_local_call_receive.rs | 10 - .../protocol/bench/support/bench_common.rs | 256 -- examples/protocol/crossbeam_channel_leaf.rs | 185 -- examples/protocol/leaf_derive.rs | 142 - examples/protocol/remote_shell_endpoint.rs | 54 - examples/protocol/remote_shell_receive.rs | 86 - .../protocol/remote_shell_single_endpoint.rs | 47 - unshell-leaves/src/crossbeam_channel.rs | 692 ----- unshell-leaves/src/lib.rs | 135 - unshell-leaves/src/remote_shell/endpoint.rs | 65 - .../src/remote_shell/endpoint/errors.rs | 28 - .../src/remote_shell/endpoint/session.rs | 289 -- .../src/remote_shell/endpoint/transport.rs | 93 - unshell-leaves/src/remote_shell/mod.rs | 26 - unshell-leaves/src/remote_shell/tui.rs | 48 - unshell-macros/ABOUT.md | 109 - unshell-macros/Cargo.toml | 13 - unshell-macros/src/leaf_decl.rs | 348 --- unshell-macros/src/lib.rs | 119 - unshell-macros/src/procedure.rs | 108 - unshell-macros/src/procedures.rs | 403 --- unshell-macros/src/utils.rs | 60 - .../src/protocol/PROTOCOL_CHANGES.md | 188 -- unshell-protocol/src/protocol/codec.rs | 516 ---- .../src/protocol/introspection.rs | 98 - unshell-protocol/src/protocol/mod.rs | 62 - unshell-protocol/src/protocol/tests/call.rs | 298 -- .../src/protocol/tests/leaf_decl.rs | 93 - unshell-protocol/src/protocol/tests/mod.rs | 5 - .../src/protocol/tests/procedure.rs | 278 -- .../src/protocol/tests/protocol.rs | 109 - unshell-protocol/src/protocol/tests/tree.rs | 369 --- unshell-protocol/src/protocol/tree/call.rs | 801 ----- .../src/protocol/tree/endpoint/builders.rs | 574 ---- .../src/protocol/tree/endpoint/core.rs | 324 -- .../src/protocol/tree/endpoint/hooks.rs | 197 -- .../protocol/tree/endpoint/introspection.rs | 103 - .../src/protocol/tree/endpoint/mod.rs | 16 - .../src/protocol/tree/endpoint/receive.rs | 171 -- unshell-protocol/src/protocol/tree/hook.rs | 507 ---- unshell-protocol/src/protocol/tree/leaf.rs | 497 --- unshell-protocol/src/protocol/tree/mod.rs | 38 - .../src/protocol/tree/procedure.rs | 823 ----- unshell-protocol/src/protocol/tree/routing.rs | 437 --- unshell-protocol/src/protocol/types.rs | 186 -- unshell-protocol/src/protocol/validation.rs | 168 -- unshell-runtime/Cargo.toml | 21 - unshell-runtime/src/connections.rs | 335 --- unshell-runtime/src/context.rs | 205 -- unshell-runtime/src/effects.rs | 115 - unshell-runtime/src/leaf.rs | 177 -- unshell-runtime/src/lib.rs | 123 - unshell-runtime/src/node/mod.rs | 73 - unshell-runtime/src/node/packet.rs | 174 -- unshell-runtime/src/node/runtime.rs | 2651 ----------------- unshell-runtime/src/node/state.rs | 15 - unshell-runtime/src/transport.rs | 31 - 63 files changed, 4 insertions(+), 14547 deletions(-) delete mode 100644 examples/protocol/bench/bench.rs delete mode 100644 examples/protocol/bench/op_decode_call.rs delete mode 100644 examples/protocol/bench/op_encode_call.rs delete mode 100644 examples/protocol/bench/op_forward_call_receive.rs delete mode 100644 examples/protocol/bench/op_hook_data_receive.rs delete mode 100644 examples/protocol/bench/op_local_call_receive.rs delete mode 100644 examples/protocol/bench/support/bench_common.rs delete mode 100644 examples/protocol/crossbeam_channel_leaf.rs delete mode 100644 examples/protocol/leaf_derive.rs delete mode 100644 examples/protocol/remote_shell_endpoint.rs delete mode 100644 examples/protocol/remote_shell_receive.rs delete mode 100644 examples/protocol/remote_shell_single_endpoint.rs delete mode 100644 unshell-leaves/src/crossbeam_channel.rs delete mode 100644 unshell-leaves/src/remote_shell/endpoint.rs delete mode 100644 unshell-leaves/src/remote_shell/endpoint/errors.rs delete mode 100644 unshell-leaves/src/remote_shell/endpoint/session.rs delete mode 100644 unshell-leaves/src/remote_shell/endpoint/transport.rs delete mode 100644 unshell-leaves/src/remote_shell/mod.rs delete mode 100644 unshell-leaves/src/remote_shell/tui.rs delete mode 100644 unshell-macros/ABOUT.md delete mode 100644 unshell-macros/Cargo.toml delete mode 100644 unshell-macros/src/leaf_decl.rs delete mode 100644 unshell-macros/src/lib.rs delete mode 100644 unshell-macros/src/procedure.rs delete mode 100644 unshell-macros/src/procedures.rs delete mode 100644 unshell-macros/src/utils.rs delete mode 100644 unshell-protocol/src/protocol/PROTOCOL_CHANGES.md delete mode 100644 unshell-protocol/src/protocol/codec.rs delete mode 100644 unshell-protocol/src/protocol/introspection.rs delete mode 100644 unshell-protocol/src/protocol/mod.rs delete mode 100644 unshell-protocol/src/protocol/tests/call.rs delete mode 100644 unshell-protocol/src/protocol/tests/leaf_decl.rs delete mode 100644 unshell-protocol/src/protocol/tests/mod.rs delete mode 100644 unshell-protocol/src/protocol/tests/procedure.rs delete mode 100644 unshell-protocol/src/protocol/tests/protocol.rs delete mode 100644 unshell-protocol/src/protocol/tests/tree.rs delete mode 100644 unshell-protocol/src/protocol/tree/call.rs delete mode 100644 unshell-protocol/src/protocol/tree/endpoint/builders.rs delete mode 100644 unshell-protocol/src/protocol/tree/endpoint/core.rs delete mode 100644 unshell-protocol/src/protocol/tree/endpoint/hooks.rs delete mode 100644 unshell-protocol/src/protocol/tree/endpoint/introspection.rs delete mode 100644 unshell-protocol/src/protocol/tree/endpoint/mod.rs delete mode 100644 unshell-protocol/src/protocol/tree/endpoint/receive.rs delete mode 100644 unshell-protocol/src/protocol/tree/hook.rs delete mode 100644 unshell-protocol/src/protocol/tree/leaf.rs delete mode 100644 unshell-protocol/src/protocol/tree/mod.rs delete mode 100644 unshell-protocol/src/protocol/tree/procedure.rs delete mode 100644 unshell-protocol/src/protocol/tree/routing.rs delete mode 100644 unshell-protocol/src/protocol/types.rs delete mode 100644 unshell-protocol/src/protocol/validation.rs delete mode 100644 unshell-runtime/Cargo.toml delete mode 100644 unshell-runtime/src/connections.rs delete mode 100644 unshell-runtime/src/context.rs delete mode 100644 unshell-runtime/src/effects.rs delete mode 100644 unshell-runtime/src/leaf.rs delete mode 100644 unshell-runtime/src/lib.rs delete mode 100644 unshell-runtime/src/node/mod.rs delete mode 100644 unshell-runtime/src/node/packet.rs delete mode 100644 unshell-runtime/src/node/runtime.rs delete mode 100644 unshell-runtime/src/node/state.rs delete mode 100644 unshell-runtime/src/transport.rs diff --git a/Cargo.toml b/Cargo.toml index aa75033..e6245df 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -79,6 +79,10 @@ path = "examples/protocol/leaf_derive.rs" name = "crossbeam_channel_leaf" path = "examples/protocol/crossbeam_channel_leaf.rs" +[[example]] +name = "runtime_leaf_actions" +path = "examples/protocol/runtime_leaf_actions.rs" + [[example]] name = "remote_shell_endpoint" path = "examples/protocol/remote_shell_endpoint.rs" diff --git a/examples/protocol/bench/bench.rs b/examples/protocol/bench/bench.rs deleted file mode 100644 index a3d58f7..0000000 --- a/examples/protocol/bench/bench.rs +++ /dev/null @@ -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( - name: &'static str, - mut build_cases: F, - mut op: impl FnMut(T), -) -> BenchResult -where - F: FnMut() -> Vec, -{ - 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::() / samples.len() as f64; - let variance = samples - .iter() - .map(|sample| { - let delta = sample - mean; - delta * delta - }) - .sum::() - / samples.len() as f64; - - BenchResult { - name, - mean_ns: mean, - stddev_ns: variance.sqrt(), - samples: samples.len(), - } -} - -fn path(parts: &[&str]) -> Vec { - 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 - ); -} diff --git a/examples/protocol/bench/op_decode_call.rs b/examples/protocol/bench/op_decode_call.rs deleted file mode 100644 index da2d8d4..0000000 --- a/examples/protocol/bench/op_decode_call.rs +++ /dev/null @@ -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}"); -} diff --git a/examples/protocol/bench/op_encode_call.rs b/examples/protocol/bench/op_encode_call.rs deleted file mode 100644 index 60969bc..0000000 --- a/examples/protocol/bench/op_encode_call.rs +++ /dev/null @@ -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}"); -} diff --git a/examples/protocol/bench/op_forward_call_receive.rs b/examples/protocol/bench/op_forward_call_receive.rs deleted file mode 100644 index b20e878..0000000 --- a/examples/protocol/bench/op_forward_call_receive.rs +++ /dev/null @@ -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}"); -} diff --git a/examples/protocol/bench/op_hook_data_receive.rs b/examples/protocol/bench/op_hook_data_receive.rs deleted file mode 100644 index 3e553b7..0000000 --- a/examples/protocol/bench/op_hook_data_receive.rs +++ /dev/null @@ -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}"); -} diff --git a/examples/protocol/bench/op_local_call_receive.rs b/examples/protocol/bench/op_local_call_receive.rs deleted file mode 100644 index 22d58f1..0000000 --- a/examples/protocol/bench/op_local_call_receive.rs +++ /dev/null @@ -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}"); -} diff --git a/examples/protocol/bench/support/bench_common.rs b/examples/protocol/bench/support/bench_common.rs deleted file mode 100644 index e145232..0000000 --- a/examples/protocol/bench/support/bench_common.rs +++ /dev/null @@ -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::() - .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(cases: Vec, 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 { - 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, - } -} diff --git a/examples/protocol/crossbeam_channel_leaf.rs b/examples/protocol/crossbeam_channel_leaf.rs deleted file mode 100644 index 5cf8766..0000000 --- a/examples/protocol/crossbeam_channel_leaf.rs +++ /dev/null @@ -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> { - 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::(reply.as_slice())?; - - println!("child parent: {:?}", snapshot.parent); - println!("child children: {:?}", snapshot.children); - - Ok(()) -} - -struct ChannelNetwork { - root: ProtocolEndpoint, - root_to_agent: Sender, - root_rx: Receiver, - agent: ChannelNode, - child: ChannelNode, -} - -impl ChannelNetwork { - fn new() -> Result> { - 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, - procedure_id: String, - data: Vec, - ) -> Result, Box> { - 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, - rx: Receiver, -} - -impl ChannelNode { - fn new(path: Vec) -> (Self, Sender) { - 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, sender: Sender) { - let _ = self.runtime.leaf_mut().stage_connection(peer_path, sender); - } - - fn connect_staged(&mut self, peer_path: Vec) -> Result<(), Box> { - 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> { - 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 { - parts.iter().map(|part| (*part).to_owned()).collect() -} diff --git a/examples/protocol/leaf_derive.rs b/examples/protocol/leaf_derive.rs deleted file mode 100644 index d6864d8..0000000 --- a/examples/protocol/leaf_derive.rs +++ /dev/null @@ -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, -} - -#[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, - hook_id: u64, - sent_reply: bool, -} - -impl ProcedureStore for EchoLeaf { - fn procedure_sessions(&mut self) -> &mut BTreeMap { - &mut self.sessions - } -} - -impl Procedure for EchoOpen { - type Error = Infallible; - type Input = EchoRequest; - - fn open(_leaf: &mut EchoLeaf, call: Call) -> Result { - 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 { - 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 { - parts.iter().map(|part| (*part).to_owned()).collect() -} - -fn main() -> Result<(), Box> { - let endpoint = ProtocolEndpoint::new( - path(&["agent"]), - Some(Vec::new()), - Vec::new(), - vec![EchoLeaf::protocol_leaf_spec()], - ); - let mut runtime = ProcedureRuntime::::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::( - 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(()) -} diff --git a/examples/protocol/remote_shell_endpoint.rs b/examples/protocol/remote_shell_endpoint.rs deleted file mode 100644 index e57c10a..0000000 --- a/examples/protocol/remote_shell_endpoint.rs +++ /dev/null @@ -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> { - 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 { - vec![String::from("agent")] -} diff --git a/examples/protocol/remote_shell_receive.rs b/examples/protocol/remote_shell_receive.rs deleted file mode 100644 index 1323ac6..0000000 --- a/examples/protocol/remote_shell_receive.rs +++ /dev/null @@ -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> { - 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 { - vec![String::from("agent")] -} diff --git a/examples/protocol/remote_shell_single_endpoint.rs b/examples/protocol/remote_shell_single_endpoint.rs deleted file mode 100644 index 3413360..0000000 --- a/examples/protocol/remote_shell_single_endpoint.rs +++ /dev/null @@ -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> { - 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::(&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(()) -} diff --git a/unshell-leaves/src/crossbeam_channel.rs b/unshell-leaves/src/crossbeam_channel.rs deleted file mode 100644 index 2ad7cfd..0000000 --- a/unshell-leaves/src/crossbeam_channel.rs +++ /dev/null @@ -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, -} - -/// 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>, - /// The currently active direct child paths. - pub children: Vec>, -} - -/// 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, - children: BTreeMap, Sender>, - child_routes: Vec, - staged: BTreeMap, Sender>, -} - -#[derive(Debug, Clone)] -struct ChannelConnection { - path: Vec, - sender: Sender, -} - -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, - sender: Sender, - ) -> Option> { - 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, - ) -> Result { - 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 { - 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 { - self.connect_staged(endpoint, request.peer_path) - } - - #[call] - fn remove_connection( - &mut self, - endpoint: &mut ProtocolEndpoint, - request: ConnectionRequest, - ) -> Result { - 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 { - parts.iter().map(|part| (*part).to_owned()).collect() - } - - struct ChannelNode { - runtime: LeafRuntime, - rx: Receiver, - } - - impl ChannelNode { - fn new(path: Vec) -> (Self, Sender) { - 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, sender: Sender) { - let _ = self.runtime.leaf_mut().stage_connection(peer_path, sender); - } - - fn connect_staged(&mut self, peer_path: Vec) { - 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::(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); - } -} diff --git a/unshell-leaves/src/lib.rs b/unshell-leaves/src/lib.rs index 1578b2e..8b13789 100644 --- a/unshell-leaves/src/lib.rs +++ b/unshell-leaves/src/lib.rs @@ -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::; -/// ``` -#[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) -> 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; diff --git a/unshell-leaves/src/remote_shell/endpoint.rs b/unshell-leaves/src/remote_shell/endpoint.rs deleted file mode 100644 index aee6ba2..0000000 --- a/unshell-leaves/src/remote_shell/endpoint.rs +++ /dev/null @@ -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, -} - -impl ProcedureStore for RemoteShell { - fn procedure_sessions(&mut self) -> &mut BTreeMap { - &mut self.sessions - } -} - -impl Procedure for Open { - type Error = ShellLeafError; - type Input = OpenRequest; - - fn open(_leaf: &mut RemoteShell, call: Call) -> Result { - 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 { - 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 { - session.poll() - } - - fn close(_leaf: &mut RemoteShell, mut session: Self) -> Result<(), Self::Error> { - session.terminate() - } -} diff --git a/unshell-leaves/src/remote_shell/endpoint/errors.rs b/unshell-leaves/src/remote_shell/endpoint/errors.rs deleted file mode 100644 index 7a5b933..0000000 --- a/unshell-leaves/src/remote_shell/endpoint/errors.rs +++ /dev/null @@ -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 for ShellLeafError { - fn from(value: io::Error) -> Self { - Self::Io(value) - } -} diff --git a/unshell-leaves/src/remote_shell/endpoint/session.rs b/unshell-leaves/src/remote_shell/endpoint/session.rs deleted file mode 100644 index cb93077..0000000 --- a/unshell-leaves/src/remote_shell/endpoint/session.rs +++ /dev/null @@ -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, - /// Process-group leader used for Unix hangup and kill signaling. - process_group_leader: Option, - /// Buffered stdin bridge into the shell process. - stdin_tx: Option>>, - /// Buffered output stream read from the PTY. - output_rx: Receiver, - /// Hook return path for packets emitted by this session. - return_path: Vec, - /// 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, - /// 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), - ReaderClosed, -} - -impl Open { - pub(super) fn spawn( - return_path: Vec, - hook_id: u64, - procedure_id: String, - ) -> Result { - 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, 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) { - 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 { - 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 { - 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, rx: Receiver>) { - 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, - stdout: Box, -) -> (SyncSender>, Receiver) { - 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(mut reader: R, tx: mpsc::SyncSender) -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; - } - } - } - }); -} diff --git a/unshell-leaves/src/remote_shell/endpoint/transport.rs b/unshell-leaves/src/remote_shell/endpoint/transport.rs deleted file mode 100644 index 4b23041..0000000 --- a/unshell-leaves/src/remote_shell/endpoint/transport.rs +++ /dev/null @@ -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> { - 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> { - 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> { - 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)) -} diff --git a/unshell-leaves/src/remote_shell/mod.rs b/unshell-leaves/src/remote_shell/mod.rs deleted file mode 100644 index e286675..0000000 --- a/unshell-leaves/src/remote_shell/mod.rs +++ /dev/null @@ -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; diff --git a/unshell-leaves/src/remote_shell/tui.rs b/unshell-leaves/src/remote_shell/tui.rs deleted file mode 100644 index c371c58..0000000 --- a/unshell-leaves/src/remote_shell/tui.rs +++ /dev/null @@ -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, -} - -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 {} diff --git a/unshell-macros/ABOUT.md b/unshell-macros/ABOUT.md deleted file mode 100644 index 0473113..0000000 --- a/unshell-macros/ABOUT.md +++ /dev/null @@ -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. diff --git a/unshell-macros/Cargo.toml b/unshell-macros/Cargo.toml deleted file mode 100644 index 9a7152a..0000000 --- a/unshell-macros/Cargo.toml +++ /dev/null @@ -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 } diff --git a/unshell-macros/src/leaf_decl.rs b/unshell-macros/src/leaf_decl.rs deleted file mode 100644 index 734f534..0000000 --- a/unshell-macros/src/leaf_decl.rs +++ /dev/null @@ -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, - id: Option, - org: Option, - product: Option, - version: Option, - procedures: Vec, - host_bindings: Vec, -} - -impl Parse for LeafDeclarationAttributes { - fn parse(input: ParseStream<'_>) -> Result { - let assignments = Punctuated::::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), - HostBinding(HostBinding), -} - -struct HostBinding { - module_path: Option, - host_path: Option, -} - -enum ProcedureRef { - Symbol(Path), - Suffix(LitStr), -} - -impl Parse for ProcedureRef { - fn parse(input: ParseStream<'_>) -> Result { - if input.peek(LitStr) { - return Ok(Self::Suffix(input.parse()?)); - } - Ok(Self::Symbol(input.parse()?)) - } -} - -impl Parse for LeafAssignment { - fn parse(input: ParseStream<'_>) -> Result { - let name: Path = input.parse()?; - input.parse::()?; - 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::::parse_terminated(&content)? - .into_iter() - .collect::>(); - 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 { - 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::>>()?; - let procedure_type_checks = attr - .host_bindings - .iter() - .map(|binding| procedure_type_check_tokens(binding, &attr.procedures, &declaration_ident)) - .collect::>>()?; - let host_impls = attr - .host_bindings - .iter() - .map(|binding| expand_binding_impl(binding, &declaration_ident)) - .collect::>>()?; - - 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 { - ::leaf_name() - } - - /// Returns the canonical protocol leaf metadata for this surface. - pub fn protocol_leaf_spec() -> ::unshell::protocol::tree::LeafSpec { - ::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> { - ::procedure_id(suffix) - } - } - - const _: fn() = || { - #(#procedure_type_checks)* - }; - - #(#host_impls)* - }) -} - -fn expand_binding_impl(binding: &HostBinding, declaration: &syn::Ident) -> Result { - 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 { - ::leaf_name() - } - - /// Returns the canonical protocol leaf metadata for this host. - pub fn protocol_leaf_spec() -> ::unshell::protocol::tree::LeafSpec { - ::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> { - ::procedure_id(suffix) - } - } - }) -} - -fn host_path_for_binding(binding: &HostBinding, declaration: &syn::Ident) -> Result { - 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 { - 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 { - 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::(quote! { - let _ = ::core::marker::PhantomData::<#path>; - }) - }) - .collect::>>()?; - - let _ = declaration; - Ok(quote! { #(#checks)* }) -} - -fn set_once(target: &mut Option, value: LitStr, label: &str) -> Result<()> { - if target.is_some() { - return Err(Error::new_spanned(value, format!("duplicate {label}"))); - } - *target = Some(value); - Ok(()) -} diff --git a/unshell-macros/src/lib.rs b/unshell-macros/src/lib.rs deleted file mode 100644 index 8dec4f9..0000000 --- a/unshell-macros/src/lib.rs +++ /dev/null @@ -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(), - } -} diff --git a/unshell-macros/src/procedure.rs b/unshell-macros/src/procedure.rs deleted file mode 100644 index 99d01d7..0000000 --- a/unshell-macros/src/procedure.rs +++ /dev/null @@ -1,108 +0,0 @@ -use quote::quote; -use syn::{Attribute, Data, DeriveInput, Error, LitStr, Result, Type}; - -#[derive(Default)] -struct ProcedureAttributes { - leaf: Option, - name: Option, -} - -impl ProcedureAttributes { - fn parse_from(attrs: &[Attribute]) -> Result { - 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 { - 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 { - ::procedure_id() - } - } - }) -} diff --git a/unshell-macros/src/procedures.rs b/unshell-macros/src/procedures.rs deleted file mode 100644 index f40f64a..0000000 --- a/unshell-macros/src/procedures.rs +++ /dev/null @@ -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, -} - -impl Parse for ProceduresAttributes { - fn parse(input: syn::parse::ParseStream<'_>) -> Result { - if input.is_empty() { - return Ok(Self::default()); - } - - let mut parsed = Self::default(); - let assignments = Punctuated::::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 { - Ok(Self { - name: input.parse()?, - value: { - input.parse::()?; - 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 { - 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, - > { - #(#dispatch_checks)* - unreachable!("protocol runtime validated local procedure dispatch") - } - } - - }) -} - -fn expand_call_arm(method: &ImplItemFn) -> Result { - let method_name = &method.sig.ident; - let suffix_literal = call_suffix_literal(method)?; - let call_id_expr = quote! { - { - let mut __unshell_id = ::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::>(); - - 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, - inputs: &[&FnArg], -) -> Result { - 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` 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::>>()?; - let vars = (0..tuple_types.len()) - .map(|index| format_ident!("__unshell_arg_{index}")) - .collect::>(); - - 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, 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> { - 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 { - 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 { - 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 { - 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 { - 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) -} diff --git a/unshell-macros/src/utils.rs b/unshell-macros/src/utils.rs deleted file mode 100644 index 53c47dd..0000000 --- a/unshell-macros/src/utils.rs +++ /dev/null @@ -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) -> bool { - let original_len = attrs.len(); - attrs.retain(|attr| !attr.path().is_ident("call")); - original_len != attrs.len() -} diff --git a/unshell-protocol/src/protocol/PROTOCOL_CHANGES.md b/unshell-protocol/src/protocol/PROTOCOL_CHANGES.md deleted file mode 100644 index 5fead6b..0000000 --- a/unshell-protocol/src/protocol/PROTOCOL_CHANGES.md +++ /dev/null @@ -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` 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` 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. diff --git a/unshell-protocol/src/protocol/codec.rs b/unshell-protocol/src/protocol/codec.rs deleted file mode 100644 index e613efa..0000000 --- a/unshell-protocol/src/protocol/codec.rs +++ /dev/null @@ -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; - -/// 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 { - self.deserialize_payload::() - } - - /// 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 { - self.deserialize_payload::() - } - - /// 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 { - self.deserialize_payload::() - } - - fn deserialize_payload(&self) -> Result - where - A: rkyv::Portable - + for<'b> rkyv::bytecheck::CheckBytes>, - T: rkyv::Archive, - A: rkyv::Deserialize>, - { - deserialize_archived_bytes::(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

(header: &PacketHeader, payload: &P) -> Result -where - P: for<'a> Serialize< - rkyv::api::high::HighSerializer, 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, FrameError> { - let (header_bytes, payload_bytes) = split_frame_sections(bytes)?; - let header = deserialize_section::( - 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::(&Example { value: 7 }).unwrap(); -/// let decoded = deserialize_archived_bytes::<::Archived, Example>(&bytes)?; -/// assert_eq!(decoded, Example { value: 7 }); -/// # Ok::<(), unshell::protocol::FrameError>(()) -/// ``` -pub fn deserialize_archived_bytes(bytes: &[u8]) -> Result -where - A: rkyv::Portable - + for<'b> rkyv::bytecheck::CheckBytes>, - T: rkyv::Archive, - A: rkyv::Deserialize>, -{ - deserialize_section::(bytes, FrameError::InvalidPayload) -} - -fn read_u32(bytes: &[u8], start: usize) -> Result { - 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( - bytes: &[u8], - invalid: fn(Error) -> FrameError, -) -> Result -where - A: rkyv::Portable - + for<'b> rkyv::bytecheck::CheckBytes>, - T: rkyv::Archive, - A: rkyv::Deserialize>, -{ - if is_aligned_for::(bytes) { - let archived = access::(bytes).map_err(invalid)?; - return deserialize::(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::(&aligned).map_err(invalid)?; - deserialize::(archived).map_err(invalid) -} - -fn is_aligned_for(bytes: &[u8]) -> bool { - let alignment = mem::align_of::(); - alignment <= 1 || (bytes.as_ptr() as usize).is_multiple_of(alignment) -} diff --git a/unshell-protocol/src/protocol/introspection.rs b/unshell-protocol/src/protocol/introspection.rs deleted file mode 100644 index 8352bda..0000000 --- a/unshell-protocol/src/protocol/introspection.rs +++ /dev/null @@ -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, - /// Leaf summaries hosted directly at this endpoint. - pub leaves: Vec, -} - -/// 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, -} - -/// 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, -} diff --git a/unshell-protocol/src/protocol/mod.rs b/unshell-protocol/src/protocol/mod.rs deleted file mode 100644 index 6f482c6..0000000 --- a/unshell-protocol/src/protocol/mod.rs +++ /dev/null @@ -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}; diff --git a/unshell-protocol/src/protocol/tests/call.rs b/unshell-protocol/src/protocol/tests/call.rs deleted file mode 100644 index 368a079..0000000 --- a/unshell-protocol/src/protocol/tests/call.rs +++ /dev/null @@ -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 { - 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) -> 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::( - 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, -} - -#[derive(Archive, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] -struct ConnectionsReply { - parent: Option>, - children: Vec>, -} - -#[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::( - 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::( - 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()); -} diff --git a/unshell-protocol/src/protocol/tests/leaf_decl.rs b/unshell-protocol/src/protocol/tests/leaf_decl.rs deleted file mode 100644 index c91b90d..0000000 --- a/unshell-protocol/src/protocol/tests/leaf_decl.rs +++ /dev/null @@ -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!( - ::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!( - ::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!( - ::procedure_id("tail"), - Some(String::from("org.example.v1.transcript.tail")) - ); -} diff --git a/unshell-protocol/src/protocol/tests/mod.rs b/unshell-protocol/src/protocol/tests/mod.rs deleted file mode 100644 index 46d6021..0000000 --- a/unshell-protocol/src/protocol/tests/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -mod call; -mod leaf_decl; -mod procedure; -mod protocol; -mod tree; diff --git a/unshell-protocol/src/protocol/tests/procedure.rs b/unshell-protocol/src/protocol/tests/procedure.rs deleted file mode 100644 index 03241e7..0000000 --- a/unshell-protocol/src/protocol/tests/procedure.rs +++ /dev/null @@ -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 { - parts.iter().map(|part| (*part).to_owned()).collect() -} - -#[derive(Default)] -struct StreamLeaf { - sessions: BTreeMap, -} - -#[leaf(id = "org.example.v1.stream", procedures = [ProcedureOpen], endpoint_struct = StreamLeaf)] -struct Stream; - -impl ProcedureStore for StreamLeaf { - fn procedure_sessions(&mut self) -> &mut BTreeMap { - &mut self.sessions - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Procedure)] -#[procedure(leaf = StreamLeaf, name = "open")] -struct ProcedureOpen { - prefix: String, -} - -impl Procedure for ProcedureOpen { - type Error = Infallible; - type Input = String; - - fn open(_leaf: &mut StreamLeaf, call: Call) -> Result { - Ok(Self { prefix: call.input }) - } - - fn on_data( - _leaf: &mut StreamLeaf, - session: &mut Self, - data: crate::protocol::tree::IncomingData, - ) -> Result { - 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::::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, -} - -#[leaf(id = "org.example.v1.duplex", procedures = [DuplexProcedure], endpoint_struct = DuplexLeaf)] -struct Duplex; - -impl ProcedureStore for DuplexLeaf { - fn procedure_sessions(&mut self) -> &mut BTreeMap { - &mut self.sessions - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Procedure)] -#[procedure(leaf = DuplexLeaf, name = "open")] -struct DuplexProcedure { - saw_peer_close: bool, -} - -impl Procedure for DuplexProcedure { - type Error = Infallible; - type Input = (); - - fn open(_leaf: &mut DuplexLeaf, _call: Call) -> Result { - Ok(Self { - saw_peer_close: false, - }) - } - - fn on_data( - _leaf: &mut DuplexLeaf, - session: &mut Self, - data: crate::protocol::tree::IncomingData, - ) -> Result { - 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::::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()); -} diff --git a/unshell-protocol/src/protocol/tests/protocol.rs b/unshell-protocol/src/protocol/tests/protocol.rs deleted file mode 100644 index febc70c..0000000 --- a/unshell-protocol/src/protocol/tests/protocol.rs +++ /dev/null @@ -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 { - 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) - )); -} diff --git a/unshell-protocol/src/protocol/tests/tree.rs b/unshell-protocol/src/protocol/tests/tree.rs deleted file mode 100644 index d0467d8..0000000 --- a/unshell-protocol/src/protocol/tests/tree.rs +++ /dev/null @@ -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 { - 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::::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()); -} diff --git a/unshell-protocol/src/protocol/tree/call.rs b/unshell-protocol/src/protocol/tree/call.rs deleted file mode 100644 index ca93e5b..0000000 --- a/unshell-protocol/src/protocol/tree/call.rs +++ /dev/null @@ -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 { - /// Decoded application input payload. - pub input: T, - /// Endpoint path of the caller that opened this call. - pub caller_path: Vec, - /// Canonical procedure identifier chosen by the caller. - pub procedure_id: String, - /// Optional destination leaf targeted by the call. - pub dst_leaf: Option, - /// Hook key declared by the caller when it expects a response. - pub response_hook: Option, -} - -/// 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`]. -/// -/// # 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 = CallResult::Reply("hello".into()); -/// assert!(matches!(reply, CallResult::Reply(_))); -/// ``` -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum CallResult { - /// 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, - /// 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, - /// 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), - /// 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 = DispatchError::Decode(FrameError::Truncated); -/// assert!(matches!(error, DispatchError::Decode(_))); -/// ``` -#[derive(Debug)] -pub enum DispatchError { - /// 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 fmt::Display for DispatchError -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 core::error::Error for DispatchError 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 = LeafRuntimeError::Dispatch(DispatchError::Decode(FrameError::Truncated)); -/// assert!(matches!(error, LeafRuntimeError::Dispatch(_))); -/// ``` -#[derive(Debug)] -pub enum LeafRuntimeError { - /// Protocol endpoint routing or framing failed. - Endpoint(EndpointError), - /// Typed call dispatch failed. - Dispatch(DispatchError), - /// Leaf-local data or fault handling failed. - Leaf(E), -} - -impl fmt::Display for LeafRuntimeError -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 core::error::Error for LeafRuntimeError where E: core::error::Error + 'static {} - -impl From for LeafRuntimeError { - 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, 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, 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::>; -/// ``` -#[derive(Debug)] -pub struct LeafRuntime { - 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, - /// 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, - /// Whether the endpoint dropped the incoming packet. - pub dropped: bool, -} - -impl LeafRuntime { - /// 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 LeafRuntime -where - L: CallLeaf + super::CallProcedures::Error>, -{ - /// Delivers one inbound frame into the stateful leaf runtime. - /// - /// # Example - /// ```rust - /// # use unshell::protocol::tree::{LeafRuntime, ProtocolEndpoint}; - /// # struct ExampleLeaf; - /// # let _ = core::marker::PhantomData::>; - /// ``` - pub fn receive( - &mut self, - ingress: &Ingress, - frame: FrameBytes, - ) -> Result::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::>; - /// ``` - pub fn receive_routed( - &mut self, - ingress: &Ingress, - frame: FrameBytes, - ) -> Result::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::>; - /// ``` - pub fn poll(&mut self) -> Result::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::>; - /// ``` - pub fn poll_routed( - &mut self, - ) -> Result::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::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::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::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::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::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, - ) -> Result::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, - end_hook: bool, - ) -> Result::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::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 LeafRuntime -where - L: CallLeaf + super::CallProcedures::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::>; - /// ``` - pub fn route_forwarded( - &mut self, - forwarded: Vec, - ) -> Result<(), ::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::(&bytes)?; -/// assert_eq!(decoded, Example { value: 7 }); -/// # Ok::<(), unshell::protocol::FrameError>(()) -/// ``` -pub fn decode_call_input(bytes: &[u8]) -> Result -where - T: Archive, - ::Archived: rkyv::Portable - + for<'b> rkyv::bytecheck::CheckBytes> - + rkyv::Deserialize>, -{ - crate::protocol::deserialize_archived_bytes::<::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(value: &T) -> Result, FrameError> -where - T: for<'a> Serialize< - rkyv::api::high::HighSerializer, Error>, - >, -{ - let bytes = to_bytes::(value).map_err(FrameError::Serialize)?; - Ok(bytes.as_slice().to_vec()) -} diff --git a/unshell-protocol/src/protocol/tree/endpoint/builders.rs b/unshell-protocol/src/protocol/tree/endpoint/builders.rs deleted file mode 100644 index 7fdcae8..0000000 --- a/unshell-protocol/src/protocol/tree/endpoint/builders.rs +++ /dev/null @@ -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, - dst_leaf: Option, - procedure_id: impl Into, - response_hook_id: Option, - data: Vec, - ) -> 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, - hook_id: u64, - procedure_id: impl Into, - data: Vec, - 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, - parent_path: Option>, - children: Vec, - leaves: Vec, - ) -> Self { - let has_parent = parent_path.is_some(); - let registered_child_paths = children - .iter() - .filter(|child| child.registered) - .map(|child| child.path.clone()) - .collect::>(); - - 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, leaves: Vec) -> 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>, - ) -> 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, - ) -> 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::>(); - 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, - dst_leaf: Option, - procedure_id: impl Into, - response_hook_id: Option, - data: Vec, - ) -> Result { - 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, - dst_leaf: Option, - procedure_id: impl Into, - response_hook_id: Option, - data: Vec, - ) -> Result { - 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, - hook_id: u64, - procedure_id: impl Into, - data: Vec, - end_hook: bool, - ) -> Result { - 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, - hook_id: u64, - procedure_id: impl Into, - data: Vec, - end_hook: bool, - ) -> Result { - 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); - } - } -} diff --git a/unshell-protocol/src/protocol/tree/endpoint/core.rs b/unshell-protocol/src/protocol/tree/endpoint/core.rs deleted file mode 100644 index cacf552..0000000 --- a/unshell-protocol/src/protocol/tree/endpoint/core.rs +++ /dev/null @@ -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, - /// 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) -> 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, -} - -/// 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), - /// 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 for EndpointError { - fn from(value: FrameError) -> Self { - Self::Frame(value) - } -} - -impl From 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::::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; -} - -/// 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, - pub(crate) path: Vec, - pub(crate) parent_path: Option>, - pub(crate) children: Vec, - pub(crate) routing: CompiledRoutes, - pub(crate) leaves: BTreeMap, - pub(crate) endpoint_procedures: BTreeSet, - pub(crate) hooks: HookTable, -} diff --git a/unshell-protocol/src/protocol/tree/endpoint/hooks.rs b/unshell-protocol/src/protocol/tree/endpoint/hooks.rs deleted file mode 100644 index de6353f..0000000 --- a/unshell-protocol/src/protocol/tree/endpoint/hooks.rs +++ /dev/null @@ -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 { - 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 { - 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, - fault: ProtocolFault, - ) -> Result { - 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 { - 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 { - 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, - } - } -} diff --git a/unshell-protocol/src/protocol/tree/endpoint/introspection.rs b/unshell-protocol/src/protocol/tree/endpoint/introspection.rs deleted file mode 100644 index 1554bcf..0000000 --- a/unshell-protocol/src/protocol/tree/endpoint/introspection.rs +++ /dev/null @@ -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, - ) -> Result { - 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 { - 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(&self, value: &T) -> Result, EndpointError> - where - T: for<'a> rkyv::Serialize< - rkyv::api::high::HighSerializer< - rkyv::util::AlignedVec, - rkyv::ser::allocator::ArenaHandle<'a>, - RkyvError, - >, - >, - { - to_bytes::(value) - .map_err(|error| EndpointError::Frame(FrameError::Serialize(error))) - .map(|bytes| bytes.to_vec()) - } -} diff --git a/unshell-protocol/src/protocol/tree/endpoint/mod.rs b/unshell-protocol/src/protocol/tree/endpoint/mod.rs deleted file mode 100644 index b8692c9..0000000 --- a/unshell-protocol/src/protocol/tree/endpoint/mod.rs +++ /dev/null @@ -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, -}; diff --git a/unshell-protocol/src/protocol/tree/endpoint/receive.rs b/unshell-protocol/src/protocol/tree/endpoint/receive.rs deleted file mode 100644 index 3d12275..0000000 --- a/unshell-protocol/src/protocol/tree/endpoint/receive.rs +++ /dev/null @@ -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 { - 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 { - 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 { - // 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::(payload)?; - validate_call(&header, &message)?; - self.handle_local_call(header, message) - } - - fn receive_data( - &mut self, - parsed: crate::protocol::ParsedFrame<'_>, - ) -> Result { - 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 { - 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 { - 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), - } - } -} diff --git a/unshell-protocol/src/protocol/tree/hook.rs b/unshell-protocol/src/protocol/tree/hook.rs deleted file mode 100644 index b099dd2..0000000 --- a/unshell-protocol/src/protocol/tree/hook.rs +++ /dev/null @@ -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, - /// 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, 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, - /// 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, - /// 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, - active: BTreeMap, - active_by_peer: BTreeMap, 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 { - 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 { - 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 { - 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 { - // 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() - } -} diff --git a/unshell-protocol/src/protocol/tree/leaf.rs b/unshell-protocol/src/protocol/tree/leaf.rs deleted file mode 100644 index 90e6324..0000000 --- a/unshell-protocol/src/protocol/tree/leaf.rs +++ /dev/null @@ -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 { - 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 { - 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) -> 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!(::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> { -/// 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> { - /// Ok(unshell::protocol::tree::CallReply::NoReply) - /// } - /// } - /// # let _ = ExampleLeaf; - /// ``` - fn dispatch_call( - &mut self, - endpoint: &mut ProtocolEndpoint, - call: crate::protocol::tree::IncomingCall, - ) -> Result>; -} - -/// 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>, -/// children: Vec, -/// } -/// 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::>(); - 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 { - 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!( - ::Declaration::leaf_spec().name, - "org.example.v1.echo" - ); - } -} diff --git a/unshell-protocol/src/protocol/tree/mod.rs b/unshell-protocol/src/protocol/tree/mod.rs deleted file mode 100644 index 0bd9485..0000000 --- a/unshell-protocol/src/protocol/tree/mod.rs +++ /dev/null @@ -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, -}; diff --git a/unshell-protocol/src/protocol/tree/procedure.rs b/unshell-protocol/src/protocol/tree/procedure.rs deleted file mode 100644 index c67a266..0000000 --- a/unshell-protocol/src/protocol/tree/procedure.rs +++ /dev/null @@ -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 = ::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>() {} -/// _compat::(); -/// assert_eq!(Open::procedure_id(), "org.example.v1.shell.open"); -/// ``` -pub trait StatefulProcedureMetadata: ProcedureMetadata + Sized -where - L: ProtocolLeaf, -{ -} - -impl StatefulProcedureMetadata for T -where - T: ProcedureMetadata, - 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 } -/// impl ProcedureStore for Leaf { -/// fn procedure_sessions(&mut self) -> &mut BTreeMap { -/// &mut self.sessions -/// } -/// } -/// ``` -pub trait ProcedureStore

{ - /// Returns the hook-keyed session table for one procedure type. - fn procedure_sessions(&mut self) -> &mut BTreeMap; -} - -/// 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, -/// } -/// -/// leaf! { -/// id = "org.example.v1.stream", -/// procedures = [OpenProcedure], -/// endpoint_struct = StreamLeaf, -/// } -/// -/// impl ProcedureStore for StreamLeaf { -/// fn procedure_sessions(&mut self) -> &mut BTreeMap { -/// &mut self.sessions -/// } -/// } -/// -/// #[derive(Procedure)] -/// #[procedure(leaf = StreamLeaf, name = "open")] -/// struct OpenProcedure { -/// prefix: String, -/// } -/// -/// impl Procedure for OpenProcedure { -/// type Error = core::convert::Infallible; -/// type Input = String; -/// -/// fn open( -/// _leaf: &mut StreamLeaf, -/// call: Call, -/// ) -> Result { -/// Ok(Self { prefix: call.input }) -/// } -/// -/// fn poll( -/// _leaf: &mut StreamLeaf, -/// _session: &mut Self, -/// ) -> Result { -/// Ok(ProcedureEffect::default()) -/// } -/// } -/// ``` -pub trait Procedure: ProcedureMetadata + 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) -> Result; - - /// Handles one inbound hook `Data` packet for this procedure. - fn on_data( - _leaf: &mut L, - _session: &mut Self, - _data: IncomingData, - ) -> Result { - 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 { - 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, - /// 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) -> 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) -> 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 = -/// ProcedureRuntimeError::Decode(DispatchError::Decode(FrameError::Truncated)); -/// assert!(matches!(error, ProcedureRuntimeError::Decode(_))); -/// ``` -#[derive(Debug)] -pub enum ProcedureRuntimeError { - /// 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), -} - -impl fmt::Display for ProcedureRuntimeError -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 core::error::Error for ProcedureRuntimeError where E: core::error::Error + 'static {} - -impl From for ProcedureRuntimeError { - 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, - /// 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::>; -/// ``` -#[derive(Debug)] -pub struct ProcedureRuntime { - endpoint: ProtocolEndpoint, - leaf: L, - marker: PhantomData

, -} - -impl ProcedureRuntime { - /// 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::::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::::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::::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::::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::::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 ProcedureRuntime -where - L: ProtocolLeaf + ProcedureStore

, - P: Procedure, - P::Input: Archive, - ::Archived: rkyv::Portable - + for<'b> rkyv::bytecheck::CheckBytes> - + rkyv::Deserialize>, - 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::>; - /// ``` - pub fn receive( - &mut self, - ingress: &Ingress, - frame: FrameBytes, - ) -> Result> { - 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::>; - /// ``` - pub fn poll(&mut self) -> Result> { - let mut frames = Vec::new(); - let keys = self - .leaf - .procedure_sessions() - .keys() - .cloned() - .collect::>(); - - 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> { - 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, ProcedureRuntimeError> { - self.advance_session(key, session, P::poll) - } - - fn advance_session( - &mut self, - key: HookKey, - mut session: P, - step: F, - ) -> Result, ProcedureRuntimeError> - where - F: FnOnce(&mut L, &mut P) -> Result, - { - 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> { - 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> { - 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> { - 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> { - 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> { - let CallMessage { - procedure_id, - data, - response_hook, - } = message; - let input = - decode_call_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, - ) -> Result> { - 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::>; - /// ``` - pub fn emit_internal_fault_if_possible( - &mut self, - hook: Option<&HookTarget>, - ) -> Result, ProcedureRuntimeError> { - 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, - ) -> Result, ProcedureRuntimeError> { - 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 - } -} diff --git a/unshell-protocol/src/protocol/tree/routing.rs b/unshell-protocol/src/protocol/tree/routing.rs deleted file mode 100644 index 6240099..0000000 --- a/unshell-protocol/src/protocol/tree/routing.rs +++ /dev/null @@ -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, - }, - /// An addressable endpoint segment in the tree. - Endpoint { - /// Path segment contributed by this endpoint. - segment: String, - /// Leaves hosted directly at this endpoint. - leaves: Vec, - /// Direct child endpoints hosted below this endpoint. - children: Vec, - }, -} - -/// 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, -} - -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::::new(), vec!["worker".into()]]); - /// ``` - pub fn paths(&self) -> Vec> { - let mut paths = Vec::new(); - self.collect_paths(&[], &mut paths); - paths - } - - fn collect_paths(&self, prefix: &[String], paths: &mut Vec>) { - 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, - has_parent: bool, - nodes: Vec, -} - -#[derive(Debug, Clone, Default)] -struct RouteTrieNode { - /// Child selected when traversal stops exactly at this trie node. - best_child: Option, - edges: BTreeMap, -} - -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], 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( - &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( - &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::>(); - 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( - 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::::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::::new(), - vec![String::from("a")], - vec![String::from("a"), String::from("b")], - ] - ); - } -} diff --git a/unshell-protocol/src/protocol/types.rs b/unshell-protocol/src/protocol/types.rs deleted file mode 100644 index b7274d8..0000000 --- a/unshell-protocol/src/protocol/types.rs +++ /dev/null @@ -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, - /// Absolute endpoint path the packet is trying to reach. - pub dst_path: Vec, - /// Optional leaf name inside `dst_path` that should receive a `Call` packet. - /// - /// `Data` and `Fault` packets must leave this unset. - pub dst_leaf: Option, - /// 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, -} - -/// 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, -} - -/// 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, - /// Optional response hook reservation for returned hook traffic. - pub response_hook: Option, -} - -/// 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, - /// 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); -} diff --git a/unshell-protocol/src/protocol/validation.rs b/unshell-protocol/src/protocol/validation.rs deleted file mode 100644 index f36e254..0000000 --- a/unshell-protocol/src/protocol/validation.rs +++ /dev/null @@ -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(()) -} diff --git a/unshell-runtime/Cargo.toml b/unshell-runtime/Cargo.toml deleted file mode 100644 index 2c98898..0000000 --- a/unshell-runtime/Cargo.toml +++ /dev/null @@ -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" diff --git a/unshell-runtime/src/connections.rs b/unshell-runtime/src/connections.rs deleted file mode 100644 index 557ffca..0000000 --- a/unshell-runtime/src/connections.rs +++ /dev/null @@ -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, - generation: ConnectionGeneration, -} - -impl RegisteredConnection { - /// Creates registered routing metadata. - #[must_use] - pub const fn new( - direction: ConnectionDirection, - peer_path: Vec, - 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, - 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, -} - -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) - } -} diff --git a/unshell-runtime/src/context.rs b/unshell-runtime/src/context.rs deleted file mode 100644 index c51b168..0000000 --- a/unshell-runtime/src/context.rs +++ /dev/null @@ -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, -} - -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 { - 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, - /// Optional destination leaf name. - pub dst_leaf: Option, - /// Canonical procedure id. - pub procedure_id: String, - /// Opaque request payload. - pub payload: Vec, - /// 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, - /// 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, - /// 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, - }, - /// 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), -} diff --git a/unshell-runtime/src/effects.rs b/unshell-runtime/src/effects.rs deleted file mode 100644 index cf1d5e0..0000000 --- a/unshell-runtime/src/effects.rs +++ /dev/null @@ -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, -} - -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 + '_ { - self.entries.drain(..) - } - - /// Drains local-dispatch effects in FIFO order, leaving outbound sends queued. - pub fn drain_local(&mut self) -> impl Iterator { - 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 - )); - } -} diff --git a/unshell-runtime/src/leaf.rs b/unshell-runtime/src/leaf.rs deleted file mode 100644 index 49f6dfa..0000000 --- a/unshell-runtime/src/leaf.rs +++ /dev/null @@ -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, - /// 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 { - id: LeafId, - capabilities: LeafCapabilities, - handler: Box>, -} - -impl RegisteredLeaf { - /// Creates a registered leaf from an explicit dispatch id and handler. - #[must_use] - pub fn new(id: LeafId, handler: L) -> Self - where - L: Leaf + '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 { - self.handler.as_ref() - } - - /// Returns mutable access to the hosted leaf. - #[must_use] - pub fn handler_mut(&mut self) -> &mut dyn Leaf { - 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) { - (&self.id, &self.capabilities, self.handler.as_mut()) - } -} - -impl core::fmt::Debug for RegisteredLeaf { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - f.debug_struct("RegisteredLeaf") - .field("id", &self.id) - .finish_non_exhaustive() - } -} diff --git a/unshell-runtime/src/lib.rs b/unshell-runtime/src/lib.rs deleted file mode 100644 index 9c4e381..0000000 --- a/unshell-runtime/src/lib.rs +++ /dev/null @@ -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 - )) - ); - } -} diff --git a/unshell-runtime/src/node/mod.rs b/unshell-runtime/src/node/mod.rs deleted file mode 100644 index b070851..0000000 --- a/unshell-runtime/src/node/mod.rs +++ /dev/null @@ -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; - } -} diff --git a/unshell-runtime/src/node/packet.rs b/unshell-runtime/src/node/packet.rs deleted file mode 100644 index 9502bc8..0000000 --- a/unshell-runtime/src/node/packet.rs +++ /dev/null @@ -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; -} - -/// 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, - leaves: alloc::vec::Vec, - ) -> 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, - hook_id: u64, - procedure_id: alloc::string::String, - data: alloc::vec::Vec, - end_hook: bool, - ) -> Result { - 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 { - 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 { - 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, - dst_leaf: Option, - procedure_id: alloc::string::String, - response_hook_id: Option, - data: alloc::vec::Vec, - ) -> Result { - 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 { - self.endpoint.receive(ingress, frame) - } -} diff --git a/unshell-runtime/src/node/runtime.rs b/unshell-runtime/src/node/runtime.rs deleted file mode 100644 index e6ca326..0000000 --- a/unshell-runtime/src/node/runtime.rs +++ /dev/null @@ -1,2651 +0,0 @@ -//! Single-threaded runtime shell around endpoint packet state. -//! -//! This first slice owns transport and connection metadata, derives ingress from -//! registered connections, delegates packet invariants to [`EndpointState`], and -//! queues concrete runtime effects. Leaf action reduction is intentionally -//! narrow and grows one action family at a time. - -use crate::alloc::{string::String, vec::Vec}; -use crate::connections::{ - Connection, ConnectionDirection, ConnectionGeneration, ConnectionId, ConnectionState, - Connections, RegisteredConnection, -}; -use crate::context::{LeafAction, LeafContext}; -use crate::effects::{EffectQueue, RuntimeEffect}; -use crate::leaf::{Leaf, LeafId, RegisteredLeaf}; -use crate::transport::Transport; -use unshell_protocol::FrameBytes; -use unshell_protocol::tree::ChildRoute; -use unshell_protocol::tree::{ - Endpoint, EndpointError, EndpointOutcome, IncomingCall, IncomingData, IncomingFault, Ingress, - LocalEvent, RouteDecision, -}; - -use super::{EndpointState, PacketProcessor}; - -/// Limits one runtime progress step. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct TickBudget { - /// Maximum inbound frames to poll from the transport. - pub max_inbound_frames: usize, - /// Whether queued outbound frame effects should be flushed through transport. - pub flush_outbound: bool, -} - -impl Default for TickBudget { - fn default() -> Self { - Self { - max_inbound_frames: 16, - flush_outbound: true, - } - } -} - -/// Summary returned after one runtime step. -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -pub struct TickOutcome { - /// Number of inbound frames processed. - pub inbound_frames: usize, - /// Number of outbound frames sent. - pub outbound_frames: usize, - /// Number of frames intentionally dropped. - pub dropped_frames: usize, - /// Number of local endpoint events queued for later leaf dispatch. - pub local_events: usize, -} - -/// Error surfaced by [`NodeRuntime`]. -#[derive(Debug)] -pub enum NodeRuntimeError { - /// The connection is unknown or not registered for protocol routing. - UnregisteredConnection(ConnectionId), - /// The endpoint selected a route with no matching registered connection. - MissingRouteConnection, - /// Packet processing failed inside endpoint state. - Endpoint(EndpointError), - /// Transport send, receive, or flush failed. - Transport(TransportError), - /// A queued leaf action is not implemented by this runtime slice. - UnsupportedLeafAction { - /// Leaf id that requested the action. - leaf_id: LeafId, - /// Stable action name for diagnostics. - action: &'static str, - }, -} - -/// Error returned when a leaf callback rejects a local event. -#[derive(Debug)] -pub struct LeafDispatchError { - /// Leaf id that received the event. - pub leaf_id: LeafId, - /// Callback-specific error returned by the leaf. - pub source: LeafError, -} - -impl core::fmt::Display for LeafDispatchError -where - LeafError: core::fmt::Display, -{ - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - write!( - f, - "leaf {} failed during dispatch: {}", - self.leaf_id.as_str(), - self.source - ) - } -} - -impl core::error::Error for LeafDispatchError where - LeafError: core::error::Error + 'static -{ -} - -impl core::fmt::Display for NodeRuntimeError -where - TransportError: core::fmt::Display, -{ - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - match self { - Self::UnregisteredConnection(connection) => { - write!(f, "connection {} is not registered", connection.get()) - } - Self::MissingRouteConnection => f.write_str("route has no registered connection"), - Self::Endpoint(error) => write!(f, "{error}"), - Self::Transport(error) => write!(f, "{error}"), - Self::UnsupportedLeafAction { leaf_id, action } => { - write!( - f, - "leaf {} requested unsupported action {action}", - leaf_id.as_str() - ) - } - } - } -} - -impl core::error::Error for NodeRuntimeError where - TransportError: core::error::Error + 'static -{ -} - -/// Runtime owner for one endpoint, transport, and connection table. -#[derive(Debug)] -pub struct NodeRuntime { - endpoint: EndpointState, - connections: Connections, - transport: T, - effects: EffectQueue, - leaves: Vec>, - leaf_actions: Vec<(LeafId, LeafAction)>, -} - -impl NodeRuntime { - /// Creates a runtime from endpoint state, registered connection metadata, and - /// one concrete transport. - #[must_use] - pub const fn new(endpoint: EndpointState, connections: Connections, transport: T) -> Self { - Self { - endpoint, - connections, - transport, - effects: EffectQueue::new(), - leaves: Vec::new(), - leaf_actions: Vec::new(), - } - } -} - -impl NodeRuntime { - /// Creates a runtime with an explicit leaf callback error type. - #[must_use] - pub const fn new_with_leaf_error( - endpoint: EndpointState, - connections: Connections, - transport: T, - ) -> Self { - Self { - endpoint, - connections, - transport, - effects: EffectQueue::new(), - leaves: Vec::new(), - leaf_actions: Vec::new(), - } - } - - /// Returns endpoint packet state. - #[must_use] - pub const fn endpoint(&self) -> &EndpointState { - &self.endpoint - } - - /// Returns mutable endpoint packet state. - #[must_use] - pub const fn endpoint_mut(&mut self) -> &mut EndpointState { - &mut self.endpoint - } - - /// Returns connection metadata. - #[must_use] - pub const fn connections(&self) -> &Connections { - &self.connections - } - - /// Returns mutable connection metadata. - #[must_use] - pub const fn connections_mut(&mut self) -> &mut Connections { - &mut self.connections - } - - /// Returns the transport. - #[must_use] - pub const fn transport(&self) -> &T { - &self.transport - } - - /// Returns the mutable transport. - #[must_use] - pub const fn transport_mut(&mut self) -> &mut T { - &mut self.transport - } - - /// Registers or updates the parent connection and endpoint parent route together. - /// - /// Call this instead of mutating [`Connections`] and [`EndpointState`] separately. - /// The endpoint validates that `parent_path` is the direct parent before the - /// connection table is made routable. - pub fn register_parent_connection( - &mut self, - connection: ConnectionId, - parent_path: Vec, - generation: ConnectionGeneration, - ) -> Result<(), EndpointError> { - let previous = self.connections.registered(connection).cloned(); - self.endpoint - .endpoint_mut() - .set_parent_path(Some(parent_path.clone()))?; - - if let Some(previous) = previous - && previous.direction() == ConnectionDirection::Child - { - self.endpoint - .endpoint_mut() - .remove_child_route(previous.peer_path()); - } - - self.upsert_registered_connection( - connection, - ConnectionDirection::Parent, - parent_path.clone(), - generation, - ); - self.connections - .demote_registered_direction_except(ConnectionDirection::Parent, connection); - Ok(()) - } - - /// Registers or updates a child connection and endpoint child route together. - /// - /// Call this instead of mutating [`Connections`] and [`EndpointState`] separately. - /// The endpoint validates that `child_path` is a direct child before the - /// connection table is made routable. - pub fn register_child_connection( - &mut self, - connection: ConnectionId, - child_path: Vec, - generation: ConnectionGeneration, - ) -> Result<(), EndpointError> { - let previous = self.connections.registered(connection).cloned(); - self.endpoint - .endpoint_mut() - .upsert_child_route(ChildRoute::registered(child_path.clone()))?; - - if let Some(previous) = previous { - match previous.direction() { - ConnectionDirection::Parent => { - self.endpoint.endpoint_mut().set_parent_path(None)?; - } - ConnectionDirection::Child if previous.peer_path() != child_path.as_slice() => { - self.endpoint - .endpoint_mut() - .remove_child_route(previous.peer_path()); - } - ConnectionDirection::Child => {} - } - } - - self.upsert_registered_connection( - connection, - ConnectionDirection::Child, - child_path.clone(), - generation, - ); - self.connections.demote_registered_path_except( - ConnectionDirection::Child, - &child_path, - connection, - ); - Ok(()) - } - - fn upsert_registered_connection( - &mut self, - connection: ConnectionId, - direction: ConnectionDirection, - peer_path: Vec, - generation: ConnectionGeneration, - ) { - if let Some(existing) = self.connections.get_mut(connection) { - let state = ConnectionState::Registered(RegisteredConnection::new( - direction, peer_path, generation, - )); - existing.set_state(state); - } else { - self.connections.push(Connection::registered( - connection, direction, peer_path, generation, - )); - } - } - - /// Returns currently queued effects. - #[must_use] - pub fn effects(&self) -> &[RuntimeEffect] { - self.effects.entries() - } - - /// Drains queued local-dispatch effects in FIFO order. - /// - /// Outbound frame effects remain queued for runtime-owned transport flushing. - pub fn drain_local_effects(&mut self) -> impl Iterator { - self.effects.drain_local() - } - - /// Registers a leaf under its declared `leaf_name` dispatch id. - /// - /// If the id already exists, the new handler replaces the previous one. This - /// keeps local dispatch deterministic without adding a broader registry API. - pub fn register_leaf(&mut self, leaf: L) -> LeafId - where - L: Leaf + 'static, - { - let id = LeafId::new(leaf.capabilities().leaf_name.clone()); - self.register_leaf_as(id.clone(), leaf); - id - } - - /// Registers a leaf under an explicit dispatch id. - /// - /// This is useful when tests or adapters already hold the exact `dst_leaf` - /// string from protocol metadata. Duplicate ids are replaced. - pub fn register_leaf_as(&mut self, id: LeafId, leaf: L) - where - L: Leaf + 'static, - { - if let Some(existing) = self.leaves.iter_mut().find(|entry| entry.id() == &id) { - *existing = RegisteredLeaf::new(id, leaf); - } else { - self.leaves.push(RegisteredLeaf::new(id, leaf)); - } - } - - /// Returns registered leaf handlers. - #[must_use] - pub fn leaves(&self) -> &[RegisteredLeaf] { - &self.leaves - } - - /// Returns leaf actions queued by dispatched callbacks. - #[must_use] - pub fn leaf_actions(&self) -> &[(LeafId, LeafAction)] { - &self.leaf_actions - } - - /// Drains leaf actions queued by dispatched callbacks. - pub fn drain_leaf_actions(&mut self) -> impl Iterator { - let actions = core::mem::take(&mut self.leaf_actions); - actions.into_iter() - } - - /// Dispatches currently queued local effects to matching leaf handlers. - /// - /// Local events are attempted in FIFO queue order. A matched event is removed - /// only after the leaf callback succeeds. Unmatched local events, outbound - /// sends, and drop notifications remain queued for future runtime work. - pub fn dispatch_local_effects(&mut self) -> Result> { - let mut retained = EffectQueue::new(); - let mut dispatched = 0usize; - let mut pending = core::mem::take(&mut self.effects); - let mut drained = pending.drain(); - - while let Some(effect) = drained.next() { - match effect { - RuntimeEffect::Local(event) => { - let Some(leaf_index) = self.leaf_index_for_event(&event) else { - retained.push(RuntimeEffect::Local(event)); - continue; - }; - - if let Err(error) = self.dispatch_event_to_leaf(leaf_index, &event) { - retained.push(RuntimeEffect::Local(event)); - for remaining in drained { - retained.push(remaining); - } - self.effects = retained; - return Err(error); - } - dispatched += 1; - } - other => retained.push(other), - } - } - - self.effects = retained; - Ok(dispatched) - } - - fn leaf_index_for_event(&self, event: &LocalEvent) -> Option { - let leaf_name = local_event_leaf_name(event)?; - self.leaves - .iter() - .position(|entry| entry.id().as_str() == leaf_name) - } - - fn dispatch_event_to_leaf( - &mut self, - leaf_index: usize, - event: &LocalEvent, - ) -> Result<(), LeafDispatchError> { - let local_path = self.endpoint.endpoint().path(); - let (leaf_id, actions) = { - let leaf = &mut self.leaves[leaf_index]; - let (leaf_id, capabilities, handler) = leaf.dispatch_parts_mut(); - let mut ctx = LeafContext::new(local_path, leaf_id, capabilities, &self.connections); - - match event { - LocalEvent::Call { header, message } => handler - .on_call( - &mut ctx, - IncomingCall { - header: header.clone(), - message: message.clone(), - }, - ) - .map_err(|source| LeafDispatchError { - leaf_id: leaf_id.clone(), - source, - })?, - LocalEvent::Data { - header, - message, - hook_key, - } => handler - .on_data( - &mut ctx, - IncomingData { - header: header.clone(), - message: message.clone(), - hook_key: hook_key.clone(), - }, - ) - .map_err(|source| LeafDispatchError { - leaf_id: leaf_id.clone(), - source, - })?, - LocalEvent::Fault { - header, - message, - hook_key, - } => handler - .on_fault( - &mut ctx, - IncomingFault { - header: header.clone(), - fault: message.clone(), - hook_key: hook_key.clone(), - }, - ) - .map_err(|source| LeafDispatchError { - leaf_id: leaf_id.clone(), - source, - })?, - } - - (leaf_id.clone(), ctx.into_actions()) - }; - - self.leaf_actions - .extend(actions.into_iter().map(|action| (leaf_id.clone(), action))); - Ok(()) - } -} - -impl NodeRuntime -where - T: Transport, -{ - /// Processes one nonblocking runtime step. - pub fn tick(&mut self, budget: TickBudget) -> Result> { - let mut outcome = TickOutcome::default(); - let effects_start = self.effects.entries().len(); - - for _ in 0..budget.max_inbound_frames { - let Some((connection, frame)) = self - .transport - .poll_recv() - .map_err(NodeRuntimeError::Transport)? - else { - break; - }; - self.receive_frame(connection, frame)?; - outcome.inbound_frames += 1; - } - - outcome.dropped_frames += self - .effects - .entries() - .iter() - .skip(effects_start) - .filter(|effect| matches!(effect, RuntimeEffect::Dropped)) - .count(); - outcome.local_events += self - .effects - .entries() - .iter() - .skip(effects_start) - .filter(|effect| matches!(effect, RuntimeEffect::Local(_))) - .count(); - - if budget.flush_outbound { - outcome.outbound_frames = self.flush_outbound()?; - } - Ok(outcome) - } - - /// Processes one frame from a known transport connection. - pub fn receive_frame( - &mut self, - connection: ConnectionId, - frame: FrameBytes, - ) -> Result<(), NodeRuntimeError> { - let registered = self - .connections - .registered(connection) - .ok_or(NodeRuntimeError::UnregisteredConnection(connection))?; - let ingress = ingress_for(registered); - let outcome = self - .endpoint - .process_frame(&ingress, frame) - .map_err(NodeRuntimeError::Endpoint)?; - self.apply_outcome(outcome) - } - - /// Reduces queued leaf actions through endpoint packet state. - /// - /// [`LeafAction::SendCall`], [`LeafAction::SendHookData`], and - /// [`LeafAction::FailHook`] are implemented in this slice. Unsupported - /// actions stop reduction and remain queued with all later actions so callers - /// can retry after a future runtime gains support. - pub fn reduce_leaf_actions(&mut self) -> Result> { - let mut reduced = 0usize; - let mut retained = Vec::new(); - let mut pending = core::mem::take(&mut self.leaf_actions).into_iter(); - - while let Some((leaf_id, action)) = pending.next() { - match action { - LeafAction::SendCall(call) => { - let original_action = LeafAction::SendCall(call.clone()); - let route = self.endpoint.route_decision(&call.dst_path); - if route_requires_connection(route) - && self.connection_for_route(route).is_none() - { - retained.push((leaf_id, original_action)); - retained.extend(pending); - self.leaf_actions = retained; - return Err(NodeRuntimeError::MissingRouteConnection); - } - - if let Err(error) = self.endpoint.validate_call_request( - &call.dst_path, - call.dst_leaf.as_ref(), - &call.procedure_id, - &call.payload, - call.expects_response, - ) { - retained.push((leaf_id, original_action)); - retained.extend(pending); - self.leaf_actions = retained; - return Err(NodeRuntimeError::Endpoint(error)); - } - - // Allocate only after transport availability is known. A - // failed preflight must leave the queued call retryable - // without consuming a hook id or reserving pending hook state. - let endpoint_checkpoint = self.endpoint.clone(); - let response_hook_id = call - .expects_response - .then(|| self.endpoint.allocate_hook_id()); - let outcome = match self.endpoint.send_call( - call.dst_path, - call.dst_leaf, - call.procedure_id, - response_hook_id, - call.payload, - ) { - Ok(outcome) => outcome, - Err(error) => { - self.endpoint = endpoint_checkpoint; - retained.push((leaf_id, original_action)); - retained.extend(pending); - self.leaf_actions = retained; - return Err(NodeRuntimeError::Endpoint(error)); - } - }; - - if let Err(error) = self.apply_outcome(outcome) { - self.endpoint = endpoint_checkpoint; - retained.push((leaf_id, original_action)); - retained.extend(pending); - self.leaf_actions = retained; - return Err(error); - } - reduced += 1; - } - LeafAction::SendHookData(data) => { - let original_action = LeafAction::SendHookData(data.clone()); - let route = self.endpoint.route_decision(&data.dst_path); - if route_requires_connection(route) - && self.connection_for_route(route).is_none() - { - retained.push((leaf_id, original_action)); - retained.extend(pending); - self.leaf_actions = retained; - return Err(NodeRuntimeError::MissingRouteConnection); - } - - let outcome = match self.endpoint.send_hook_data( - data.dst_path, - data.hook_id, - data.procedure_id, - data.payload, - data.end_hook, - ) { - Ok(outcome) => outcome, - Err(error) => { - retained.push((leaf_id, original_action)); - retained.extend(pending); - self.leaf_actions = retained; - return Err(NodeRuntimeError::Endpoint(error)); - } - }; - - if let Err(error) = self.apply_outcome(outcome) { - retained.push((leaf_id, original_action)); - retained.extend(pending); - self.leaf_actions = retained; - return Err(error); - } - reduced += 1; - } - LeafAction::FailHook { hook_id, fault } => { - let original_action = LeafAction::FailHook { hook_id, fault }; - if let Some(route) = self.endpoint.hook_fault_route(hook_id) - && (matches!(route, RouteDecision::Drop) - || (route_requires_connection(route) - && self.connection_for_route(route).is_none())) - { - retained.push((leaf_id, original_action)); - retained.extend(pending); - self.leaf_actions = retained; - return Err(NodeRuntimeError::MissingRouteConnection); - } - - let endpoint_checkpoint = self.endpoint.clone(); - let outcome = match self.endpoint.fail_hook(hook_id, fault) { - Ok(outcome) => outcome, - Err(error) => { - self.endpoint = endpoint_checkpoint; - retained.push((leaf_id, original_action)); - retained.extend(pending); - self.leaf_actions = retained; - return Err(NodeRuntimeError::Endpoint(error)); - } - }; - - if let Err(error) = self.apply_outcome(outcome) { - self.endpoint = endpoint_checkpoint; - retained.push((leaf_id, original_action)); - retained.extend(pending); - self.leaf_actions = retained; - return Err(error); - } - reduced += 1; - } - unsupported => { - let action_name = leaf_action_name(&unsupported); - retained.push((leaf_id.clone(), unsupported)); - retained.extend(pending); - self.leaf_actions = retained; - return Err(NodeRuntimeError::UnsupportedLeafAction { - leaf_id, - action: action_name, - }); - } - } - } - - self.leaf_actions = retained; - Ok(reduced) - } - - fn connection_for_route( - &self, - route: RouteDecision, - ) -> Option<(ConnectionId, ConnectionGeneration)> { - match route { - RouteDecision::Parent => self - .connections - .registered_by_direction(ConnectionDirection::Parent) - .and_then(|connection| { - connection - .state() - .registered() - .map(|registered| (connection.id(), registered.generation())) - }), - RouteDecision::Child(index) => self - .endpoint - .endpoint() - .child_routes() - .iter() - // RouteDecision indexes are compiled from registered children only. - .filter(|child| child.registered) - .nth(index) - .and_then(|child| { - self.connections - .registered_by_path(ConnectionDirection::Child, &child.path) - }) - .and_then(|connection| { - connection - .state() - .registered() - .map(|registered| (connection.id(), registered.generation())) - }), - RouteDecision::Local | RouteDecision::Drop => None, - } - } - - fn apply_outcome( - &mut self, - outcome: EndpointOutcome, - ) -> Result<(), NodeRuntimeError> { - match outcome { - EndpointOutcome::Forward { route, frame } => self.queue_forward(route, frame), - EndpointOutcome::Local(event) => { - self.effects.push(RuntimeEffect::Local(event)); - Ok(()) - } - EndpointOutcome::Dropped => { - self.effects.push(RuntimeEffect::Dropped); - Ok(()) - } - } - } - - fn queue_forward( - &mut self, - route: RouteDecision, - frame: FrameBytes, - ) -> Result<(), NodeRuntimeError> { - let (connection, generation) = self - .connection_for_route(route) - .ok_or(NodeRuntimeError::MissingRouteConnection)?; - - self.effects.push(RuntimeEffect::SendFrame { - connection, - generation, - frame, - }); - Ok(()) - } - - fn flush_outbound(&mut self) -> Result> { - let mut retained = EffectQueue::new(); - let mut sent = 0usize; - let mut pending = core::mem::take(&mut self.effects); - let mut drained = pending.drain(); - while let Some(effect) = drained.next() { - match effect { - RuntimeEffect::SendFrame { - connection, - generation, - frame, - } if self - .connections - .registered(connection) - .is_some_and(|registered| registered.generation() == generation) => - { - if let Err(error) = self.transport.send_frame(connection, &frame) { - retained.push(RuntimeEffect::SendFrame { - connection, - generation, - frame, - }); - for remaining in drained { - retained.push(remaining); - } - self.effects = retained; - return Err(NodeRuntimeError::Transport(error)); - } - sent += 1; - } - RuntimeEffect::SendFrame { .. } => {} - other => retained.push(other), - } - } - self.effects = retained; - self.transport - .flush() - .map_err(NodeRuntimeError::Transport)?; - Ok(sent) - } -} - -fn ingress_for(registered: &RegisteredConnection) -> Ingress { - match registered.direction() { - ConnectionDirection::Parent => Ingress::Parent, - ConnectionDirection::Child => Ingress::Child(registered.peer_path().to_vec()), - } -} - -fn local_event_leaf_name(event: &LocalEvent) -> Option<&str> { - match event { - LocalEvent::Call { header, .. } - | LocalEvent::Data { header, .. } - | LocalEvent::Fault { header, .. } => header.dst_leaf.as_deref(), - } -} - -fn leaf_action_name(action: &LeafAction) -> &'static str { - match action { - LeafAction::SendCall(_) => "SendCall", - LeafAction::SendHookData(_) => "SendHookData", - LeafAction::FailHook { .. } => "FailHook", - LeafAction::Connection(_) => "Connection", - } -} - -const fn route_requires_connection(route: RouteDecision) -> bool { - matches!(route, RouteDecision::Parent | RouteDecision::Child(_)) -} - -#[cfg(test)] -mod tests { - use core::cell::RefCell; - use core::convert::Infallible; - - use crate::alloc::rc::Rc; - use crate::alloc::string::String; - use crate::alloc::vec; - use crate::alloc::vec::Vec; - use crate::connections::{ - Connection, ConnectionDirection, ConnectionGeneration, ConnectionId, ConnectionState, - Connections, - }; - use crate::context::{ConnectionAction, LeafAction, OutboundCall, OutboundHookData}; - use crate::effects::RuntimeEffect; - use crate::leaf::{Leaf, LeafCapabilities, LeafPermissions}; - use crate::transport::Transport; - use unshell_protocol::tree::{ - ChildRoute, EndpointError, IncomingCall, LeafSpec, LocalEvent, ProtocolEndpoint, - RouteDecision, - }; - use unshell_protocol::{ - CallMessage, FrameBytes, HookTarget, PacketHeader, PacketType, ProtocolFault, decode_frame, - encode_packet, - }; - - use super::{EndpointState, NodeRuntime, NodeRuntimeError, TickBudget}; - - #[derive(Debug, Default)] - struct RecordingTransport { - inbound: Option<(ConnectionId, FrameBytes)>, - sent: Vec<(ConnectionId, FrameBytes)>, - fail_send: bool, - } - - #[derive(Debug, Clone, Copy, Eq, PartialEq)] - struct SendError; - - impl Transport for RecordingTransport { - type Error = SendError; - - fn poll_recv(&mut self) -> Result, Self::Error> { - Ok(self.inbound.take()) - } - - fn send_frame( - &mut self, - connection: ConnectionId, - frame: &FrameBytes, - ) -> Result<(), Self::Error> { - if self.fail_send { - return Err(SendError); - } - self.sent.push((connection, frame.clone())); - Ok(()) - } - } - - struct RecordingLeaf { - capabilities: LeafCapabilities, - calls: Rc>>, - } - - impl RecordingLeaf { - fn new(leaf_name: &str, calls: Rc>>) -> Self { - Self { - capabilities: LeafCapabilities { - leaf_name: String::from(leaf_name), - procedures: vec![String::from("org.example.v1.echo.invoke")], - permissions: LeafPermissions::REPLY_ONLY, - }, - calls, - } - } - } - - impl Leaf for RecordingLeaf { - type Error = Infallible; - - fn capabilities(&self) -> &LeafCapabilities { - &self.capabilities - } - - fn on_call( - &mut self, - ctx: &mut crate::LeafContext<'_>, - call: IncomingCall, - ) -> Result<(), Self::Error> { - self.calls.borrow_mut().push(call.clone()); - ctx.hook_data(OutboundHookData { - dst_path: call.header.src_path, - hook_id: 7, - procedure_id: call.message.procedure_id, - payload: vec![1, 2, 3], - end_hook: true, - }) - .expect("reply-only leaf can queue hook data"); - Ok(()) - } - } - - struct FailingLeaf { - capabilities: LeafCapabilities, - } - - impl FailingLeaf { - fn new(leaf_name: &str) -> Self { - Self { - capabilities: LeafCapabilities { - leaf_name: String::from(leaf_name), - procedures: vec![String::from("org.example.v1.fail.invoke")], - permissions: LeafPermissions::REPLY_ONLY, - }, - } - } - } - - impl Leaf for FailingLeaf { - type Error = &'static str; - - fn capabilities(&self) -> &LeafCapabilities { - &self.capabilities - } - - fn on_call( - &mut self, - _ctx: &mut crate::LeafContext<'_>, - _call: IncomingCall, - ) -> Result<(), Self::Error> { - Err("leaf failed") - } - } - - #[test] - fn tick_derives_ingress_and_sends_forwarded_child_frame() { - let parent = ConnectionId::new(1); - let child = ConnectionId::new(2); - let mut connections = Connections::new(); - connections.push(Connection::registered( - parent, - ConnectionDirection::Parent, - vec![], - ConnectionGeneration::INITIAL, - )); - connections.push(Connection::registered( - child, - ConnectionDirection::Child, - vec![String::from("agent"), String::from("grand")], - ConnectionGeneration::INITIAL, - )); - - let endpoint = ProtocolEndpoint::new( - vec![String::from("agent")], - Some(vec![]), - vec![ChildRoute::registered(vec![ - String::from("agent"), - String::from("grand"), - ])], - vec![], - ); - - let frame = encode_packet( - &PacketHeader { - packet_type: PacketType::Call, - src_path: vec![], - dst_path: vec![String::from("agent"), String::from("grand")], - dst_leaf: None, - hook_id: None, - }, - &CallMessage { - procedure_id: String::from("org.example.v1.echo.invoke"), - data: vec![], - response_hook: None, - }, - ) - .expect("frame encodes"); - - let transport = RecordingTransport { - inbound: Some((parent, frame)), - sent: Vec::new(), - fail_send: false, - }; - let mut runtime = NodeRuntime::new(EndpointState::new(endpoint), connections, transport); - - let outcome = runtime.tick(TickBudget::default()).expect("tick succeeds"); - - assert_eq!(outcome.inbound_frames, 1); - assert_eq!(outcome.outbound_frames, 1); - assert!(runtime.effects().is_empty()); - assert_eq!(runtime.transport().sent[0].0, child); - } - - #[test] - fn runtime_child_registration_updates_connection_and_route_topology() { - let parent = ConnectionId::new(1); - let child = ConnectionId::new(2); - let mut connections = Connections::new(); - connections.push(Connection::connected(parent, ConnectionGeneration::INITIAL)); - connections.push(Connection::connected(child, ConnectionGeneration::INITIAL)); - - let endpoint = - ProtocolEndpoint::new(vec![String::from("agent")], None, Vec::new(), Vec::new()); - let frame = encode_packet( - &PacketHeader { - packet_type: PacketType::Call, - src_path: vec![], - dst_path: vec![String::from("agent"), String::from("grand")], - dst_leaf: None, - hook_id: None, - }, - &CallMessage { - procedure_id: String::from("org.example.v1.echo.invoke"), - data: vec![], - response_hook: None, - }, - ) - .expect("frame encodes"); - let transport = RecordingTransport { - inbound: Some((parent, frame)), - sent: Vec::new(), - fail_send: false, - }; - let mut runtime = NodeRuntime::new(EndpointState::new(endpoint), connections, transport); - - runtime - .register_parent_connection(parent, vec![], ConnectionGeneration::INITIAL) - .expect("parent registers"); - runtime - .register_child_connection( - child, - vec![String::from("agent"), String::from("grand")], - ConnectionGeneration::INITIAL, - ) - .expect("child registers"); - - let outcome = runtime.tick(TickBudget::default()).expect("tick succeeds"); - - assert_eq!(outcome.outbound_frames, 1); - assert_eq!(runtime.transport().sent[0].0, child); - assert_eq!( - runtime.endpoint().endpoint().child_routes(), - [ChildRoute::registered(vec![ - String::from("agent"), - String::from("grand") - ])] - ); - } - - #[test] - fn connected_child_without_runtime_registration_is_unroutable() { - let parent = ConnectionId::new(1); - let child = ConnectionId::new(2); - let mut connections = Connections::new(); - connections.push(Connection::connected(parent, ConnectionGeneration::INITIAL)); - connections.push(Connection::connected(child, ConnectionGeneration::INITIAL)); - - let endpoint = ProtocolEndpoint::new( - vec![String::from("agent")], - None, - vec![ChildRoute::registered(vec![ - String::from("agent"), - String::from("grand"), - ])], - Vec::new(), - ); - let frame = encode_packet( - &PacketHeader { - packet_type: PacketType::Call, - src_path: vec![], - dst_path: vec![String::from("agent"), String::from("grand")], - dst_leaf: None, - hook_id: None, - }, - &CallMessage { - procedure_id: String::from("org.example.v1.echo.invoke"), - data: vec![], - response_hook: None, - }, - ) - .expect("frame encodes"); - let transport = RecordingTransport { - inbound: Some((parent, frame)), - sent: Vec::new(), - fail_send: false, - }; - let mut runtime = NodeRuntime::new(EndpointState::new(endpoint), connections, transport); - runtime - .register_parent_connection(parent, vec![], ConnectionGeneration::INITIAL) - .expect("parent registers"); - - let error = runtime - .tick(TickBudget::default()) - .expect_err("child is not routable"); - - assert!(matches!(error, NodeRuntimeError::MissingRouteConnection)); - assert!(runtime.transport().sent.is_empty()); - assert!(runtime.connections().registered(child).is_none()); - } - - #[test] - fn child_reregistration_removes_old_route() { - let child = ConnectionId::new(1); - let mut connections = Connections::new(); - connections.push(Connection::connected(child, ConnectionGeneration::INITIAL)); - - let endpoint = - ProtocolEndpoint::new(vec![String::from("agent")], None, Vec::new(), Vec::new()); - let transport = RecordingTransport { - inbound: None, - sent: Vec::new(), - fail_send: false, - }; - let mut runtime = NodeRuntime::new(EndpointState::new(endpoint), connections, transport); - - runtime - .register_child_connection( - child, - vec![String::from("agent"), String::from("old")], - ConnectionGeneration::INITIAL, - ) - .expect("old child registers"); - runtime - .register_child_connection( - child, - vec![String::from("agent"), String::from("new")], - ConnectionGeneration::INITIAL, - ) - .expect("new child registers"); - - assert_eq!( - runtime.endpoint().endpoint().child_routes(), - [ChildRoute::registered(vec![ - String::from("agent"), - String::from("new") - ])] - ); - assert!( - runtime - .connections() - .registered_by_path( - ConnectionDirection::Child, - &[String::from("agent"), String::from("old")], - ) - .is_none() - ); - } - - #[test] - fn replacement_child_registration_demotes_old_peer() { - let parent = ConnectionId::new(1); - let old_child = ConnectionId::new(2); - let new_child = ConnectionId::new(3); - let mut connections = Connections::new(); - connections.push(Connection::connected(parent, ConnectionGeneration::INITIAL)); - connections.push(Connection::connected( - old_child, - ConnectionGeneration::INITIAL, - )); - connections.push(Connection::connected( - new_child, - ConnectionGeneration::INITIAL, - )); - - let endpoint = - ProtocolEndpoint::new(vec![String::from("agent")], None, Vec::new(), Vec::new()); - let transport = RecordingTransport { - inbound: None, - sent: Vec::new(), - fail_send: false, - }; - let mut runtime = NodeRuntime::new(EndpointState::new(endpoint), connections, transport); - - runtime - .register_parent_connection(parent, vec![], ConnectionGeneration::INITIAL) - .expect("parent registers"); - runtime - .register_child_connection( - old_child, - vec![String::from("agent"), String::from("grand")], - ConnectionGeneration::INITIAL, - ) - .expect("old child registers"); - runtime - .register_child_connection( - new_child, - vec![String::from("agent"), String::from("grand")], - ConnectionGeneration::INITIAL, - ) - .expect("new child replaces old child"); - - let frame = encode_packet( - &PacketHeader { - packet_type: PacketType::Call, - src_path: vec![], - dst_path: vec![String::from("agent"), String::from("grand")], - dst_leaf: None, - hook_id: None, - }, - &CallMessage { - procedure_id: String::from("org.example.v1.echo.invoke"), - data: vec![], - response_hook: None, - }, - ) - .expect("frame encodes"); - runtime.transport_mut().inbound = Some((parent, frame)); - - let outcome = runtime.tick(TickBudget::default()).expect("tick succeeds"); - - assert_eq!(outcome.outbound_frames, 1); - assert_eq!(runtime.transport().sent[0].0, new_child); - assert!(runtime.connections().registered(old_child).is_none()); - } - - #[test] - fn invalid_child_registration_leaves_connection_unregistered() { - let child = ConnectionId::new(1); - let mut connections = Connections::new(); - connections.push(Connection::connected(child, ConnectionGeneration::INITIAL)); - - let endpoint = - ProtocolEndpoint::new(vec![String::from("agent")], None, Vec::new(), Vec::new()); - let transport = RecordingTransport { - inbound: None, - sent: Vec::new(), - fail_send: false, - }; - let mut runtime = NodeRuntime::new(EndpointState::new(endpoint), connections, transport); - - let error = runtime - .register_child_connection( - child, - vec![String::from("other"), String::from("kid")], - ConnectionGeneration::INITIAL, - ) - .expect_err("invalid child path is rejected"); - - assert!(matches!(error, EndpointError::Validation(_))); - assert!(runtime.connections().registered(child).is_none()); - assert!(runtime.endpoint().endpoint().child_routes().is_empty()); - } - - #[test] - fn invalid_child_reregistration_preserves_existing_registration() { - let child = ConnectionId::new(1); - let mut connections = Connections::new(); - connections.push(Connection::connected(child, ConnectionGeneration::INITIAL)); - - let endpoint = - ProtocolEndpoint::new(vec![String::from("agent")], None, Vec::new(), Vec::new()); - let transport = RecordingTransport { - inbound: None, - sent: Vec::new(), - fail_send: false, - }; - let mut runtime = NodeRuntime::new(EndpointState::new(endpoint), connections, transport); - let valid_path = vec![String::from("agent"), String::from("kid")]; - - runtime - .register_child_connection(child, valid_path.clone(), ConnectionGeneration::INITIAL) - .expect("initial child registers"); - - let error = runtime - .register_child_connection( - child, - vec![String::from("other"), String::from("kid")], - ConnectionGeneration::INITIAL.next(), - ) - .expect_err("invalid replacement path is rejected"); - - assert!(matches!(error, EndpointError::Validation(_))); - let registered = runtime - .connections() - .registered(child) - .expect("original child remains registered"); - assert_eq!(registered.peer_path(), valid_path); - assert_eq!( - runtime.endpoint().endpoint().child_routes(), - [ChildRoute::registered(valid_path)] - ); - } - - #[test] - fn child_route_decision_uses_registered_child_order() { - let parent = ConnectionId::new(1); - let unregistered_child = ConnectionId::new(2); - let registered_child = ConnectionId::new(3); - let mut connections = Connections::new(); - connections.push(Connection::registered( - parent, - ConnectionDirection::Parent, - vec![], - ConnectionGeneration::INITIAL, - )); - connections.push(Connection::registered( - unregistered_child, - ConnectionDirection::Child, - vec![String::from("agent"), String::from("spare")], - ConnectionGeneration::INITIAL, - )); - connections.push(Connection::registered( - registered_child, - ConnectionDirection::Child, - vec![String::from("agent"), String::from("grand")], - ConnectionGeneration::INITIAL, - )); - - let endpoint = ProtocolEndpoint::new( - vec![String::from("agent")], - Some(vec![]), - vec![ - ChildRoute { - path: vec![String::from("agent"), String::from("spare")], - registered: false, - }, - ChildRoute::registered(vec![String::from("agent"), String::from("grand")]), - ], - vec![], - ); - - let frame = encode_packet( - &PacketHeader { - packet_type: PacketType::Call, - src_path: vec![], - dst_path: vec![String::from("agent"), String::from("grand")], - dst_leaf: None, - hook_id: None, - }, - &CallMessage { - procedure_id: String::from("org.example.v1.echo.invoke"), - data: vec![], - response_hook: None, - }, - ) - .expect("frame encodes"); - - let transport = RecordingTransport { - inbound: Some((parent, frame)), - sent: Vec::new(), - fail_send: false, - }; - let mut runtime = NodeRuntime::new(EndpointState::new(endpoint), connections, transport); - - let outcome = runtime.tick(TickBudget::default()).expect("tick succeeds"); - - assert_eq!(outcome.outbound_frames, 1); - assert_eq!(runtime.transport().sent[0].0, registered_child); - } - - #[test] - fn receive_keeps_local_events_queued_for_leaf_dispatch() { - let parent = ConnectionId::new(1); - let mut connections = Connections::new(); - connections.push(Connection::registered( - parent, - ConnectionDirection::Parent, - vec![], - ConnectionGeneration::INITIAL, - )); - - let mut endpoint = - ProtocolEndpoint::new(vec![String::from("agent")], Some(vec![]), vec![], vec![]); - endpoint - .add_endpoint_procedure("org.example.v1.echo.invoke") - .expect("procedure registers"); - let frame = encode_packet( - &PacketHeader { - packet_type: PacketType::Call, - src_path: vec![], - dst_path: vec![String::from("agent")], - dst_leaf: None, - hook_id: None, - }, - &CallMessage { - procedure_id: String::from("org.example.v1.echo.invoke"), - data: vec![], - response_hook: None, - }, - ) - .expect("frame encodes"); - - let mut runtime = NodeRuntime::new( - EndpointState::new(endpoint), - connections, - RecordingTransport::default(), - ); - - runtime - .receive_frame(parent, frame) - .expect("frame processes"); - assert!(matches!(runtime.effects()[0], RuntimeEffect::Local(_))); - } - - #[test] - fn dispatch_local_call_reaches_registered_leaf() { - let parent = ConnectionId::new(1); - let mut connections = Connections::new(); - connections.push(Connection::registered( - parent, - ConnectionDirection::Parent, - vec![], - ConnectionGeneration::INITIAL, - )); - - let leaf_name = "org.example.v1.echo"; - let endpoint = ProtocolEndpoint::new( - vec![String::from("agent")], - Some(vec![]), - vec![], - vec![LeafSpec { - name: String::from(leaf_name), - procedures: vec![String::from("org.example.v1.echo.invoke")], - }], - ); - let frame = encode_packet( - &PacketHeader { - packet_type: PacketType::Call, - src_path: vec![], - dst_path: vec![String::from("agent")], - dst_leaf: Some(String::from(leaf_name)), - hook_id: None, - }, - &CallMessage { - procedure_id: String::from("org.example.v1.echo.invoke"), - data: vec![9], - response_hook: None, - }, - ) - .expect("frame encodes"); - let calls = Rc::new(RefCell::new(Vec::new())); - let mut runtime = NodeRuntime::new( - EndpointState::new(endpoint), - connections, - RecordingTransport::default(), - ); - runtime.register_leaf(RecordingLeaf::new(leaf_name, Rc::clone(&calls))); - - runtime - .receive_frame(parent, frame) - .expect("frame processes"); - let dispatched = runtime.dispatch_local_effects().expect("dispatch succeeds"); - - assert_eq!(dispatched, 1); - assert!(runtime.effects().is_empty()); - assert_eq!(calls.borrow().len(), 1); - assert_eq!(calls.borrow()[0].message.data, [9]); - assert_eq!(runtime.leaf_actions().len(), 1); - let (action_leaf, action) = &runtime.leaf_actions()[0]; - assert_eq!(action_leaf.as_str(), leaf_name); - let LeafAction::SendHookData(data) = action else { - panic!("leaf action should be retained hook data"); - }; - assert_eq!(data.dst_path, Vec::::new()); - assert_eq!(data.hook_id, 7); - assert_eq!(data.procedure_id, "org.example.v1.echo.invoke"); - assert_eq!(data.payload, [1, 2, 3]); - assert!(data.end_hook); - assert!(runtime.transport().sent.is_empty()); - } - - #[test] - fn leaf_hook_data_reduces_to_parent_transport_frame() { - let parent = ConnectionId::new(1); - let mut connections = Connections::new(); - connections.push(Connection::registered( - parent, - ConnectionDirection::Parent, - vec![], - ConnectionGeneration::INITIAL, - )); - - let leaf_name = "org.example.v1.echo"; - let endpoint = ProtocolEndpoint::new( - vec![String::from("agent")], - Some(vec![]), - vec![], - vec![LeafSpec { - name: String::from(leaf_name), - procedures: vec![String::from("org.example.v1.echo.invoke")], - }], - ); - let frame = encode_packet( - &PacketHeader { - packet_type: PacketType::Call, - src_path: vec![], - dst_path: vec![String::from("agent")], - dst_leaf: Some(String::from(leaf_name)), - hook_id: None, - }, - &CallMessage { - procedure_id: String::from("org.example.v1.echo.invoke"), - data: vec![9], - response_hook: Some(HookTarget { - hook_id: 7, - return_path: vec![], - }), - }, - ) - .expect("frame encodes"); - let calls = Rc::new(RefCell::new(Vec::new())); - let mut runtime = NodeRuntime::new( - EndpointState::new(endpoint), - connections, - RecordingTransport::default(), - ); - runtime.register_leaf(RecordingLeaf::new(leaf_name, Rc::clone(&calls))); - - runtime - .receive_frame(parent, frame) - .expect("frame processes"); - runtime.dispatch_local_effects().expect("dispatch succeeds"); - let reduced = runtime.reduce_leaf_actions().expect("hook data reduces"); - let outcome = runtime.tick(TickBudget::default()).expect("tick flushes"); - - assert_eq!(reduced, 1); - assert!(runtime.leaf_actions().is_empty()); - assert_eq!(outcome.outbound_frames, 1); - assert_eq!(runtime.transport().sent.len(), 1); - assert_eq!(runtime.transport().sent[0].0, parent); - let parsed = decode_frame(&runtime.transport().sent[0].1).expect("sent data decodes"); - let header = parsed.header(); - assert_eq!(header.packet_type, PacketType::Data); - assert_eq!(header.src_path, [String::from("agent")]); - assert_eq!(header.dst_path, Vec::::new()); - assert_eq!(header.hook_id, Some(7)); - let data = parsed.deserialize_data().expect("payload is data"); - assert_eq!(data.procedure_id, "org.example.v1.echo.invoke"); - assert_eq!(data.data, [1, 2, 3]); - assert!(data.end_hook); - } - - #[test] - fn leaf_fail_hook_reduces_to_parent_fault_frame() { - let parent = ConnectionId::new(1); - let mut connections = Connections::new(); - connections.push(Connection::registered( - parent, - ConnectionDirection::Parent, - vec![], - ConnectionGeneration::INITIAL, - )); - - let leaf_name = "org.example.v1.echo"; - let endpoint = ProtocolEndpoint::new( - vec![String::from("agent")], - Some(vec![]), - vec![], - vec![LeafSpec { - name: String::from(leaf_name), - procedures: vec![String::from("org.example.v1.echo.invoke")], - }], - ); - let frame = encode_packet( - &PacketHeader { - packet_type: PacketType::Call, - src_path: vec![], - dst_path: vec![String::from("agent")], - dst_leaf: Some(String::from(leaf_name)), - hook_id: None, - }, - &CallMessage { - procedure_id: String::from("org.example.v1.echo.invoke"), - data: vec![9], - response_hook: Some(HookTarget { - hook_id: 7, - return_path: vec![], - }), - }, - ) - .expect("frame encodes"); - let calls = Rc::new(RefCell::new(Vec::new())); - let mut runtime = NodeRuntime::new( - EndpointState::new(endpoint), - connections, - RecordingTransport::default(), - ); - runtime.register_leaf(RecordingLeaf::new(leaf_name, Rc::clone(&calls))); - runtime - .receive_frame(parent, frame) - .expect("call activates hook"); - runtime.dispatch_local_effects().expect("dispatch succeeds"); - runtime.leaf_actions.clear(); - runtime.leaf_actions.push(( - crate::leaf::LeafId::new(String::from(leaf_name)), - LeafAction::FailHook { - hook_id: 7, - fault: ProtocolFault::INTERNAL_ERROR, - }, - )); - - let reduced = runtime.reduce_leaf_actions().expect("fault reduces"); - let outcome = runtime.tick(TickBudget::default()).expect("tick flushes"); - - assert_eq!(reduced, 1); - assert!(runtime.leaf_actions().is_empty()); - assert_eq!(outcome.outbound_frames, 1); - assert_eq!(runtime.transport().sent.len(), 1); - assert_eq!(runtime.transport().sent[0].0, parent); - let parsed = decode_frame(&runtime.transport().sent[0].1).expect("fault decodes"); - assert_eq!(parsed.header().packet_type, PacketType::Fault); - assert_eq!(parsed.header().src_path, [String::from("agent")]); - assert_eq!(parsed.header().dst_path, Vec::::new()); - assert_eq!(parsed.header().hook_id, Some(7)); - let fault = parsed.deserialize_fault().expect("payload is fault"); - assert_eq!(fault.fault, ProtocolFault::INTERNAL_ERROR); - } - - #[test] - fn leaf_send_call_reduces_to_child_transport_frame() { - let child = ConnectionId::new(1); - let mut connections = Connections::new(); - connections.push(Connection::registered( - child, - ConnectionDirection::Child, - vec![String::from("agent"), String::from("worker")], - ConnectionGeneration::INITIAL, - )); - - let leaf_id = crate::leaf::LeafId::new(String::from("org.example.v1.client")); - let endpoint = ProtocolEndpoint::new( - vec![String::from("agent")], - None, - vec![ChildRoute::registered(vec![ - String::from("agent"), - String::from("worker"), - ])], - Vec::new(), - ); - let mut runtime = NodeRuntime::new( - EndpointState::new(endpoint), - connections, - RecordingTransport::default(), - ); - runtime.leaf_actions.push(( - leaf_id, - LeafAction::SendCall(OutboundCall { - dst_path: vec![String::from("agent"), String::from("worker")], - dst_leaf: Some(String::from("org.example.v1.echo")), - procedure_id: String::from("org.example.v1.echo.invoke"), - payload: vec![4, 5, 6], - expects_response: false, - }), - )); - - let reduced = runtime.reduce_leaf_actions().expect("call reduces"); - let outcome = runtime.tick(TickBudget::default()).expect("tick flushes"); - - assert_eq!(reduced, 1); - assert!(runtime.leaf_actions().is_empty()); - assert_eq!(outcome.outbound_frames, 1); - assert_eq!(runtime.transport().sent.len(), 1); - assert_eq!(runtime.transport().sent[0].0, child); - let parsed = decode_frame(&runtime.transport().sent[0].1).expect("sent call decodes"); - let header = parsed.header(); - assert_eq!(header.packet_type, PacketType::Call); - assert_eq!(header.src_path, [String::from("agent")]); - assert_eq!( - header.dst_path, - [String::from("agent"), String::from("worker")] - ); - assert_eq!(header.dst_leaf.as_deref(), Some("org.example.v1.echo")); - let call = parsed.deserialize_call().expect("payload is call"); - assert_eq!(call.procedure_id, "org.example.v1.echo.invoke"); - assert_eq!(call.data, [4, 5, 6]); - assert!(call.response_hook.is_none()); - } - - #[test] - fn expected_response_send_call_preflights_route_and_uses_retry_hook() { - let child = ConnectionId::new(1); - let mut connections = Connections::new(); - connections.push(Connection::connected(child, ConnectionGeneration::INITIAL)); - - let leaf_id = crate::leaf::LeafId::new(String::from("org.example.v1.client")); - let endpoint = ProtocolEndpoint::new( - vec![String::from("agent")], - None, - vec![ChildRoute::registered(vec![ - String::from("agent"), - String::from("worker"), - ])], - Vec::new(), - ); - let mut runtime = NodeRuntime::new( - EndpointState::new(endpoint), - connections, - RecordingTransport::default(), - ); - runtime.leaf_actions.push(( - leaf_id, - LeafAction::SendCall(OutboundCall { - dst_path: vec![String::from("agent"), String::from("worker")], - dst_leaf: Some(String::from("org.example.v1.echo")), - procedure_id: String::from("org.example.v1.echo.invoke"), - payload: vec![], - expects_response: true, - }), - )); - - let error = runtime - .reduce_leaf_actions() - .expect_err("missing child connection is reported"); - - assert!(matches!(error, NodeRuntimeError::MissingRouteConnection)); - assert_eq!(runtime.leaf_actions().len(), 1); - assert!(runtime.effects().is_empty()); - - runtime - .register_child_connection( - child, - vec![String::from("agent"), String::from("worker")], - ConnectionGeneration::INITIAL, - ) - .expect("child route restored"); - let reduced = runtime - .reduce_leaf_actions() - .expect("retry reduces after route exists"); - let outcome = runtime.tick(TickBudget::default()).expect("tick flushes"); - - assert_eq!(reduced, 1); - assert_eq!(outcome.outbound_frames, 1); - let parsed = decode_frame(&runtime.transport().sent[0].1).expect("sent call decodes"); - let call = parsed.deserialize_call().expect("payload is call"); - assert_eq!( - call.response_hook, - Some(HookTarget { - hook_id: 1, - return_path: vec![String::from("agent")], - }) - ); - - let response = encode_packet( - &PacketHeader { - packet_type: PacketType::Data, - src_path: vec![String::from("agent"), String::from("worker")], - dst_path: vec![String::from("agent")], - dst_leaf: None, - hook_id: Some(1), - }, - &unshell_protocol::DataMessage { - procedure_id: String::from("org.example.v1.echo.invoke"), - data: vec![9], - end_hook: true, - }, - ) - .expect("response encodes"); - runtime - .receive_frame(child, response) - .expect("response hook is accepted"); - - assert!( - matches!(runtime.effects()[0], RuntimeEffect::Local(LocalEvent::Data { ref hook_key, .. }) if hook_key.hook_id == 1) - ); - } - - #[test] - fn invalid_send_call_does_not_affect_next_response_hook_id() { - let child = ConnectionId::new(1); - let mut connections = Connections::new(); - connections.push(Connection::registered( - child, - ConnectionDirection::Child, - vec![String::from("agent"), String::from("worker")], - ConnectionGeneration::INITIAL, - )); - - let leaf_id = crate::leaf::LeafId::new(String::from("org.example.v1.client")); - let endpoint = ProtocolEndpoint::new( - vec![String::from("agent")], - None, - vec![ChildRoute::registered(vec![ - String::from("agent"), - String::from("worker"), - ])], - Vec::new(), - ); - let mut runtime = NodeRuntime::new( - EndpointState::new(endpoint), - connections, - RecordingTransport::default(), - ); - runtime.leaf_actions.push(( - leaf_id.clone(), - LeafAction::SendCall(OutboundCall { - dst_path: vec![String::from("agent"), String::from("worker")], - dst_leaf: Some(String::from("org.example.v1.echo")), - procedure_id: String::new(), - payload: vec![], - expects_response: false, - }), - )); - - let error = runtime - .reduce_leaf_actions() - .expect_err("invalid procedure is rejected"); - - assert!(matches!(error, NodeRuntimeError::Endpoint(_))); - assert_eq!(runtime.leaf_actions().len(), 1); - runtime.leaf_actions.clear(); - runtime.leaf_actions.push(( - leaf_id, - LeafAction::SendCall(OutboundCall { - dst_path: vec![String::from("agent"), String::from("worker")], - dst_leaf: Some(String::from("org.example.v1.echo")), - procedure_id: String::from("org.example.v1.echo.invoke"), - payload: vec![], - expects_response: true, - }), - )); - - runtime.reduce_leaf_actions().expect("valid retry reduces"); - runtime.tick(TickBudget::default()).expect("tick flushes"); - - let parsed = decode_frame(&runtime.transport().sent[0].1).expect("sent call decodes"); - let call = parsed.deserialize_call().expect("payload is call"); - assert_eq!( - call.response_hook, - Some(HookTarget { - hook_id: 1, - return_path: vec![String::from("agent")], - }) - ); - } - - #[test] - fn failed_leaf_send_call_routing_retains_failed_and_remaining_actions() { - let child = ConnectionId::new(1); - let mut connections = Connections::new(); - connections.push(Connection::connected(child, ConnectionGeneration::INITIAL)); - - let leaf_id = crate::leaf::LeafId::new(String::from("org.example.v1.client")); - let endpoint = ProtocolEndpoint::new( - vec![String::from("agent")], - None, - vec![ChildRoute::registered(vec![ - String::from("agent"), - String::from("worker"), - ])], - Vec::new(), - ); - let mut runtime = NodeRuntime::new( - EndpointState::new(endpoint), - connections, - RecordingTransport::default(), - ); - runtime.leaf_actions.push(( - leaf_id.clone(), - LeafAction::SendCall(OutboundCall { - dst_path: vec![String::from("agent"), String::from("worker")], - dst_leaf: Some(String::from("org.example.v1.echo")), - procedure_id: String::from("org.example.v1.echo.invoke"), - payload: vec![], - expects_response: true, - }), - )); - runtime.leaf_actions.push(( - leaf_id, - LeafAction::FailHook { - hook_id: 7, - fault: ProtocolFault::INTERNAL_ERROR, - }, - )); - - let error = runtime - .reduce_leaf_actions() - .expect_err("missing child connection is reported"); - - assert!(matches!(error, NodeRuntimeError::MissingRouteConnection)); - assert_eq!(runtime.leaf_actions().len(), 2); - assert!(matches!( - runtime.leaf_actions()[0].1, - LeafAction::SendCall(_) - )); - assert!(matches!( - runtime.leaf_actions()[1].1, - LeafAction::FailHook { .. } - )); - assert!(runtime.effects().is_empty()); - } - - #[test] - fn unsupported_connection_action_is_reported_and_retained() { - let leaf_id = crate::leaf::LeafId::new(String::from("org.example.v1.echo")); - let mut runtime = NodeRuntime::new( - EndpointState::new(ProtocolEndpoint::new( - vec![String::from("agent")], - Some(vec![]), - vec![], - vec![], - )), - Connections::new(), - RecordingTransport::default(), - ); - runtime.leaf_actions.push(( - leaf_id.clone(), - LeafAction::Connection(ConnectionAction::Unregister { - connection: ConnectionId::new(99), - }), - )); - - let error = runtime - .reduce_leaf_actions() - .expect_err("unsupported action is reported"); - - assert!(matches!( - error, - NodeRuntimeError::UnsupportedLeafAction { ref leaf_id, action } - if leaf_id.as_str() == "org.example.v1.echo" && action == "Connection" - )); - assert_eq!(runtime.leaf_actions().len(), 1); - assert!(matches!( - runtime.leaf_actions()[0].1, - LeafAction::Connection(_) - )); - } - - #[test] - fn failed_leaf_hook_data_routing_retains_failed_and_remaining_actions() { - let parent = ConnectionId::new(1); - let mut connections = Connections::new(); - connections.push(Connection::registered( - parent, - ConnectionDirection::Parent, - vec![], - ConnectionGeneration::INITIAL, - )); - - let leaf_name = "org.example.v1.echo"; - let endpoint = ProtocolEndpoint::new( - vec![String::from("agent")], - Some(vec![]), - vec![], - vec![LeafSpec { - name: String::from(leaf_name), - procedures: vec![String::from("org.example.v1.echo.invoke")], - }], - ); - let frame = encode_packet( - &PacketHeader { - packet_type: PacketType::Call, - src_path: vec![], - dst_path: vec![String::from("agent")], - dst_leaf: Some(String::from(leaf_name)), - hook_id: None, - }, - &CallMessage { - procedure_id: String::from("org.example.v1.echo.invoke"), - data: vec![], - response_hook: Some(HookTarget { - hook_id: 7, - return_path: vec![], - }), - }, - ) - .expect("frame encodes"); - let calls = Rc::new(RefCell::new(Vec::new())); - let mut runtime = NodeRuntime::new( - EndpointState::new(endpoint), - connections, - RecordingTransport::default(), - ); - runtime.register_leaf(RecordingLeaf::new(leaf_name, Rc::clone(&calls))); - runtime - .receive_frame(parent, frame) - .expect("frame processes and activates response hook"); - runtime.dispatch_local_effects().expect("dispatch succeeds"); - runtime.leaf_actions.push(( - crate::leaf::LeafId::new(String::from(leaf_name)), - LeafAction::FailHook { - hook_id: 7, - fault: ProtocolFault::INTERNAL_ERROR, - }, - )); - runtime - .connections - .get_mut(parent) - .expect("parent connection exists") - .set_state(ConnectionState::Connected { - generation: ConnectionGeneration::INITIAL, - }); - - let error = runtime - .reduce_leaf_actions() - .expect_err("missing route connection is reported"); - - assert!(matches!(error, NodeRuntimeError::MissingRouteConnection)); - assert_eq!(runtime.leaf_actions().len(), 2); - assert!(matches!( - runtime.leaf_actions()[0].1, - LeafAction::SendHookData(_) - )); - assert!(matches!( - runtime.leaf_actions()[1].1, - LeafAction::FailHook { .. } - )); - - runtime - .register_parent_connection(parent, vec![], ConnectionGeneration::INITIAL) - .expect("parent route restored"); - let reduced = runtime - .reduce_leaf_actions() - .expect("remaining supported actions reduce"); - - assert_eq!(reduced, 2); - assert!(runtime.leaf_actions().is_empty()); - assert!(matches!( - runtime.effects()[0], - RuntimeEffect::SendFrame { connection, .. } if connection == parent - )); - assert!(matches!( - runtime.effects()[1], - RuntimeEffect::SendFrame { connection, .. } if connection == parent - )); - } - - #[test] - fn missing_fail_hook_route_preserves_action_and_hook_for_retry() { - let parent = ConnectionId::new(1); - let mut connections = Connections::new(); - connections.push(Connection::registered( - parent, - ConnectionDirection::Parent, - vec![], - ConnectionGeneration::INITIAL, - )); - - let leaf_name = "org.example.v1.echo"; - let endpoint = ProtocolEndpoint::new( - vec![String::from("agent")], - Some(vec![]), - vec![], - vec![LeafSpec { - name: String::from(leaf_name), - procedures: vec![String::from("org.example.v1.echo.invoke")], - }], - ); - let frame = encode_packet( - &PacketHeader { - packet_type: PacketType::Call, - src_path: vec![], - dst_path: vec![String::from("agent")], - dst_leaf: Some(String::from(leaf_name)), - hook_id: None, - }, - &CallMessage { - procedure_id: String::from("org.example.v1.echo.invoke"), - data: vec![], - response_hook: Some(HookTarget { - hook_id: 7, - return_path: vec![], - }), - }, - ) - .expect("frame encodes"); - let calls = Rc::new(RefCell::new(Vec::new())); - let mut runtime = NodeRuntime::new( - EndpointState::new(endpoint), - connections, - RecordingTransport::default(), - ); - runtime.register_leaf(RecordingLeaf::new(leaf_name, Rc::clone(&calls))); - runtime - .receive_frame(parent, frame) - .expect("call activates hook"); - runtime.dispatch_local_effects().expect("dispatch succeeds"); - runtime.leaf_actions.clear(); - runtime.leaf_actions.push(( - crate::leaf::LeafId::new(String::from(leaf_name)), - LeafAction::FailHook { - hook_id: 7, - fault: ProtocolFault::INTERNAL_ERROR, - }, - )); - runtime.leaf_actions.push(( - crate::leaf::LeafId::new(String::from(leaf_name)), - LeafAction::Connection(ConnectionAction::Unregister { connection: parent }), - )); - runtime - .connections - .get_mut(parent) - .expect("parent connection exists") - .set_state(ConnectionState::Connected { - generation: ConnectionGeneration::INITIAL, - }); - - let error = runtime - .reduce_leaf_actions() - .expect_err("missing route connection is reported"); - - assert!(matches!(error, NodeRuntimeError::MissingRouteConnection)); - assert_eq!(runtime.leaf_actions().len(), 2); - assert!(matches!( - runtime.leaf_actions()[0].1, - LeafAction::FailHook { .. } - )); - assert!(matches!( - runtime.leaf_actions()[1].1, - LeafAction::Connection(_) - )); - assert!(runtime.effects().is_empty()); - - runtime - .register_parent_connection(parent, vec![], ConnectionGeneration::INITIAL) - .expect("parent route restored"); - let error = runtime - .reduce_leaf_actions() - .expect_err("retry faults hook then stops at connection action"); - let outcome = runtime.tick(TickBudget::default()).expect("tick flushes"); - - assert!(matches!( - error, - NodeRuntimeError::UnsupportedLeafAction { - action: "Connection", - .. - } - )); - assert_eq!(runtime.leaf_actions().len(), 1); - assert!(matches!( - runtime.leaf_actions()[0].1, - LeafAction::Connection(_) - )); - assert_eq!(outcome.outbound_frames, 1); - let parsed = decode_frame(&runtime.transport().sent[0].1).expect("fault decodes"); - assert_eq!(parsed.header().packet_type, PacketType::Fault); - assert_eq!(parsed.header().hook_id, Some(7)); - } - - #[test] - fn dropped_fail_hook_route_preserves_action_and_hook_for_retry() { - let parent = ConnectionId::new(1); - let mut connections = Connections::new(); - connections.push(Connection::registered( - parent, - ConnectionDirection::Parent, - vec![], - ConnectionGeneration::INITIAL, - )); - - let leaf_name = "org.example.v1.echo"; - let endpoint = ProtocolEndpoint::new( - vec![String::from("agent")], - Some(vec![]), - vec![], - vec![LeafSpec { - name: String::from(leaf_name), - procedures: vec![String::from("org.example.v1.echo.invoke")], - }], - ); - let frame = encode_packet( - &PacketHeader { - packet_type: PacketType::Call, - src_path: vec![], - dst_path: vec![String::from("agent")], - dst_leaf: Some(String::from(leaf_name)), - hook_id: None, - }, - &CallMessage { - procedure_id: String::from("org.example.v1.echo.invoke"), - data: vec![], - response_hook: Some(HookTarget { - hook_id: 7, - return_path: vec![], - }), - }, - ) - .expect("frame encodes"); - let calls = Rc::new(RefCell::new(Vec::new())); - let mut runtime = NodeRuntime::new( - EndpointState::new(endpoint), - connections, - RecordingTransport::default(), - ); - runtime.register_leaf(RecordingLeaf::new(leaf_name, Rc::clone(&calls))); - runtime - .receive_frame(parent, frame) - .expect("call activates hook with dropped return path"); - assert!(matches!(runtime.effects()[0], RuntimeEffect::Local(_))); - runtime.dispatch_local_effects().expect("dispatch succeeds"); - runtime - .endpoint - .endpoint_mut() - .set_parent_path(None) - .expect("parent route removes"); - assert_eq!( - runtime.endpoint.hook_fault_route(7), - Some(RouteDecision::Drop) - ); - runtime.leaf_actions.clear(); - runtime.leaf_actions.push(( - crate::leaf::LeafId::new(String::from(leaf_name)), - LeafAction::FailHook { - hook_id: 7, - fault: ProtocolFault::INTERNAL_ERROR, - }, - )); - - let error = runtime - .reduce_leaf_actions() - .expect_err("dropped fault route is reported before mutation"); - - assert!(matches!(error, NodeRuntimeError::MissingRouteConnection)); - assert_eq!(runtime.leaf_actions().len(), 1); - assert!(runtime.effects().is_empty()); - - runtime - .register_parent_connection(parent, vec![], ConnectionGeneration::INITIAL) - .expect("parent route restored"); - let reduced = runtime - .reduce_leaf_actions() - .expect("retained fault retries after route is restored"); - let outcome = runtime.tick(TickBudget::default()).expect("tick flushes"); - - assert_eq!(reduced, 1); - assert_eq!(outcome.outbound_frames, 1); - assert_eq!(runtime.transport().sent[0].0, parent); - let parsed = decode_frame(&runtime.transport().sent[0].1).expect("fault decodes"); - assert_eq!(parsed.header().packet_type, PacketType::Fault); - assert_eq!(parsed.header().hook_id, Some(7)); - } - - #[test] - fn unmatched_local_event_remains_queued() { - let mut runtime = NodeRuntime::new( - EndpointState::new(ProtocolEndpoint::new( - vec![String::from("agent")], - Some(vec![]), - vec![], - vec![], - )), - Connections::new(), - RecordingTransport::default(), - ); - runtime.effects.push(RuntimeEffect::Local(LocalEvent::Call { - header: PacketHeader { - packet_type: PacketType::Call, - src_path: vec![], - dst_path: vec![String::from("agent")], - dst_leaf: Some(String::from("org.example.v1.missing")), - hook_id: None, - }, - message: CallMessage { - procedure_id: String::from("org.example.v1.missing.invoke"), - data: vec![], - response_hook: None, - }, - })); - - let dispatched = runtime.dispatch_local_effects().expect("dispatch succeeds"); - - assert_eq!(dispatched, 0); - assert_eq!(runtime.effects().len(), 1); - assert!(matches!(runtime.effects()[0], RuntimeEffect::Local(_))); - } - - #[test] - fn local_dispatch_preserves_send_frame_and_dropped_effects() { - let parent = ConnectionId::new(1); - let frame = FrameBytes::new(); - let mut runtime = NodeRuntime::new( - EndpointState::new(ProtocolEndpoint::new( - vec![String::from("agent")], - Some(vec![]), - vec![], - vec![], - )), - Connections::new(), - RecordingTransport::default(), - ); - runtime.effects.push(RuntimeEffect::SendFrame { - connection: parent, - generation: ConnectionGeneration::INITIAL, - frame, - }); - runtime.effects.push(RuntimeEffect::Dropped); - - let dispatched = runtime.dispatch_local_effects().expect("dispatch succeeds"); - - assert_eq!(dispatched, 0); - assert_eq!(runtime.effects().len(), 2); - assert!(matches!( - runtime.effects()[0], - RuntimeEffect::SendFrame { .. } - )); - assert!(matches!(runtime.effects()[1], RuntimeEffect::Dropped)); - } - - #[test] - fn failed_local_dispatch_preserves_failed_and_remaining_effects() { - let parent = ConnectionId::new(1); - let leaf_name = "org.example.v1.fail"; - let mut runtime = NodeRuntime::<_, &'static str>::new_with_leaf_error( - EndpointState::new(ProtocolEndpoint::new( - vec![String::from("agent")], - Some(vec![]), - vec![], - vec![], - )), - Connections::new(), - RecordingTransport::default(), - ); - runtime.register_leaf(FailingLeaf::new(leaf_name)); - runtime.effects.push(RuntimeEffect::Local(LocalEvent::Call { - header: PacketHeader { - packet_type: PacketType::Call, - src_path: vec![], - dst_path: vec![String::from("agent")], - dst_leaf: Some(String::from(leaf_name)), - hook_id: None, - }, - message: CallMessage { - procedure_id: String::from("org.example.v1.fail.invoke"), - data: vec![], - response_hook: None, - }, - })); - runtime.effects.push(RuntimeEffect::Dropped); - runtime.effects.push(RuntimeEffect::SendFrame { - connection: parent, - generation: ConnectionGeneration::INITIAL, - frame: FrameBytes::new(), - }); - - let error = runtime - .dispatch_local_effects() - .expect_err("leaf callback failure is returned"); - - assert_eq!(error.leaf_id.as_str(), leaf_name); - assert_eq!(error.source, "leaf failed"); - assert!(runtime.leaf_actions().is_empty()); - assert_eq!(runtime.effects().len(), 3); - assert!(matches!(runtime.effects()[0], RuntimeEffect::Local(_))); - assert!(matches!(runtime.effects()[1], RuntimeEffect::Dropped)); - assert!(matches!( - runtime.effects()[2], - RuntimeEffect::SendFrame { .. } - )); - } - - #[test] - fn failed_send_preserves_failed_and_unprocessed_effects() { - let parent = ConnectionId::new(1); - let mut connections = Connections::new(); - connections.push(Connection::registered( - parent, - ConnectionDirection::Parent, - vec![], - ConnectionGeneration::INITIAL, - )); - - let mut endpoint = - ProtocolEndpoint::new(vec![String::from("agent")], Some(vec![]), vec![], vec![]); - endpoint - .add_endpoint_procedure("org.example.v1.echo.invoke") - .expect("procedure registers"); - let frame = encode_packet( - &PacketHeader { - packet_type: PacketType::Call, - src_path: vec![], - dst_path: vec![String::from("agent")], - dst_leaf: None, - hook_id: None, - }, - &CallMessage { - procedure_id: String::from("org.example.v1.echo.invoke"), - data: vec![], - response_hook: None, - }, - ) - .expect("frame encodes"); - - let mut runtime = NodeRuntime::new( - EndpointState::new(endpoint), - connections, - RecordingTransport { - inbound: None, - sent: Vec::new(), - fail_send: true, - }, - ); - - runtime.effects.push(RuntimeEffect::SendFrame { - connection: parent, - generation: ConnectionGeneration::INITIAL, - frame: frame.clone(), - }); - runtime - .receive_frame(parent, frame.clone()) - .expect("local frame processes"); - runtime.effects.push(RuntimeEffect::SendFrame { - connection: parent, - generation: ConnectionGeneration::INITIAL, - frame, - }); - - let error = runtime.flush_outbound().expect_err("send fails"); - - assert!(matches!(error, NodeRuntimeError::Transport(SendError))); - assert!(runtime.transport().sent.is_empty()); - assert_eq!(runtime.effects().len(), 3); - assert!(matches!( - runtime.effects()[0], - RuntimeEffect::SendFrame { .. } - )); - assert!(matches!(runtime.effects()[1], RuntimeEffect::Local(_))); - assert!(matches!( - runtime.effects()[2], - RuntimeEffect::SendFrame { .. } - )); - } - - #[test] - fn tick_counts_only_new_local_events() { - let parent = ConnectionId::new(1); - let mut connections = Connections::new(); - connections.push(Connection::registered( - parent, - ConnectionDirection::Parent, - vec![], - ConnectionGeneration::INITIAL, - )); - - let mut endpoint = - ProtocolEndpoint::new(vec![String::from("agent")], Some(vec![]), vec![], vec![]); - endpoint - .add_endpoint_procedure("org.example.v1.echo.invoke") - .expect("procedure registers"); - let frame = encode_packet( - &PacketHeader { - packet_type: PacketType::Call, - src_path: vec![], - dst_path: vec![String::from("agent")], - dst_leaf: None, - hook_id: None, - }, - &CallMessage { - procedure_id: String::from("org.example.v1.echo.invoke"), - data: vec![], - response_hook: None, - }, - ) - .expect("frame encodes"); - - let transport = RecordingTransport { - inbound: Some((parent, frame)), - sent: Vec::new(), - fail_send: false, - }; - let mut runtime = NodeRuntime::new(EndpointState::new(endpoint), connections, transport); - - let first = runtime.tick(TickBudget::default()).expect("tick succeeds"); - assert_eq!(first.local_events, 1); - assert!(matches!(runtime.effects()[0], RuntimeEffect::Local(_))); - - let second = runtime.tick(TickBudget::default()).expect("tick succeeds"); - assert_eq!(second.local_events, 0); - assert!(matches!(runtime.effects()[0], RuntimeEffect::Local(_))); - } - - #[test] - fn drained_local_event_is_not_peeked_or_recounted() { - let parent = ConnectionId::new(1); - let mut connections = Connections::new(); - connections.push(Connection::registered( - parent, - ConnectionDirection::Parent, - vec![], - ConnectionGeneration::INITIAL, - )); - - let mut endpoint = - ProtocolEndpoint::new(vec![String::from("agent")], Some(vec![]), vec![], vec![]); - endpoint - .add_endpoint_procedure("org.example.v1.echo.invoke") - .expect("procedure registers"); - let frame = encode_packet( - &PacketHeader { - packet_type: PacketType::Call, - src_path: vec![], - dst_path: vec![String::from("agent")], - dst_leaf: None, - hook_id: None, - }, - &CallMessage { - procedure_id: String::from("org.example.v1.echo.invoke"), - data: vec![], - response_hook: None, - }, - ) - .expect("frame encodes"); - - let transport = RecordingTransport { - inbound: Some((parent, frame)), - sent: Vec::new(), - fail_send: false, - }; - let mut runtime = NodeRuntime::new(EndpointState::new(endpoint), connections, transport); - - let first = runtime.tick(TickBudget::default()).expect("tick succeeds"); - assert_eq!(first.local_events, 1); - - let drained: Vec<_> = runtime.drain_local_effects().collect(); - assert_eq!(drained.len(), 1); - assert!(matches!(drained[0], RuntimeEffect::Local(_))); - assert!(runtime.effects().is_empty()); - - let second = runtime.tick(TickBudget::default()).expect("tick succeeds"); - assert_eq!(second.local_events, 0); - assert!(runtime.effects().is_empty()); - } - - #[test] - fn tick_counts_only_new_dropped_frames() { - let child = ConnectionId::new(1); - let mut connections = Connections::new(); - connections.push(Connection::registered( - child, - ConnectionDirection::Child, - vec![String::from("agent"), String::from("kid")], - ConnectionGeneration::INITIAL, - )); - - let mut endpoint = - ProtocolEndpoint::new(vec![String::from("agent")], Some(vec![]), vec![], vec![]); - endpoint - .add_endpoint_procedure("org.example.v1.echo.invoke") - .expect("procedure registers"); - let frame = encode_packet( - &PacketHeader { - packet_type: PacketType::Call, - src_path: vec![String::from("agent"), String::from("kid")], - dst_path: vec![String::from("agent")], - dst_leaf: None, - hook_id: None, - }, - &CallMessage { - procedure_id: String::from("org.example.v1.echo.invoke"), - data: vec![], - response_hook: None, - }, - ) - .expect("frame encodes"); - - let transport = RecordingTransport { - inbound: Some((child, frame)), - sent: Vec::new(), - fail_send: false, - }; - let mut runtime = NodeRuntime::new(EndpointState::new(endpoint), connections, transport); - - let first = runtime.tick(TickBudget::default()).expect("tick succeeds"); - assert_eq!(first.dropped_frames, 1); - assert!(matches!(runtime.effects()[0], RuntimeEffect::Dropped)); - - let second = runtime.tick(TickBudget::default()).expect("tick succeeds"); - assert_eq!(second.dropped_frames, 0); - assert!(matches!(runtime.effects()[0], RuntimeEffect::Dropped)); - } - - #[test] - fn drained_dropped_effect_is_not_peeked_or_recounted() { - let child = ConnectionId::new(1); - let mut connections = Connections::new(); - connections.push(Connection::registered( - child, - ConnectionDirection::Child, - vec![String::from("agent"), String::from("kid")], - ConnectionGeneration::INITIAL, - )); - - let mut endpoint = - ProtocolEndpoint::new(vec![String::from("agent")], Some(vec![]), vec![], vec![]); - endpoint - .add_endpoint_procedure("org.example.v1.echo.invoke") - .expect("procedure registers"); - let frame = encode_packet( - &PacketHeader { - packet_type: PacketType::Call, - src_path: vec![String::from("agent"), String::from("kid")], - dst_path: vec![String::from("agent")], - dst_leaf: None, - hook_id: None, - }, - &CallMessage { - procedure_id: String::from("org.example.v1.echo.invoke"), - data: vec![], - response_hook: None, - }, - ) - .expect("frame encodes"); - - let transport = RecordingTransport { - inbound: Some((child, frame)), - sent: Vec::new(), - fail_send: false, - }; - let mut runtime = NodeRuntime::new(EndpointState::new(endpoint), connections, transport); - - let first = runtime.tick(TickBudget::default()).expect("tick succeeds"); - assert_eq!(first.dropped_frames, 1); - - let drained: Vec<_> = runtime.drain_local_effects().collect(); - assert_eq!(drained.len(), 1); - assert!(matches!(drained[0], RuntimeEffect::Dropped)); - assert!(runtime.effects().is_empty()); - - let second = runtime.tick(TickBudget::default()).expect("tick succeeds"); - assert_eq!(second.dropped_frames, 0); - assert!(runtime.effects().is_empty()); - } -} diff --git a/unshell-runtime/src/node/state.rs b/unshell-runtime/src/node/state.rs deleted file mode 100644 index d15102e..0000000 --- a/unshell-runtime/src/node/state.rs +++ /dev/null @@ -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, -} diff --git a/unshell-runtime/src/transport.rs b/unshell-runtime/src/transport.rs deleted file mode 100644 index 5536664..0000000 --- a/unshell-runtime/src/transport.rs +++ /dev/null @@ -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, 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(()) - } -}