From 6bdf59c5c963af35e87cd77d7d62064a4e273b56 Mon Sep 17 00:00:00 2001 From: Michael Mikovsky <77305074+Astatin3@users.noreply.github.com> Date: Sat, 25 Apr 2026 11:27:29 -0600 Subject: [PATCH] Align protocol runtime with spec boundaries Move demo leaf echo behavior out of the core protocol runtime, treat procedure IDs as opaque protocol fields, and return direct registered children in endpoint introspection to match the spec. --- src/protocol/introspection.rs | 2 + src/protocol/tests/protocol.rs | 24 +++------ src/protocol/tests/tree.rs | 22 ++++---- src/protocol/tree/endpoint/core.rs | 9 ---- src/protocol/tree/endpoint/introspection.rs | 6 +++ src/protocol/tree/endpoint/mod.rs | 4 +- src/protocol/tree/endpoint/receive.rs | 53 +++---------------- src/protocol/tree/mod.rs | 4 +- src/protocol/validation.rs | 11 +--- treetest/src/model.rs | 2 +- treetest/src/scenarios/simple.rs | 2 +- treetest/src/sim/build.rs | 7 +-- .../src/sim/runtime/events/application.rs | 23 +++++++- 13 files changed, 63 insertions(+), 106 deletions(-) diff --git a/src/protocol/introspection.rs b/src/protocol/introspection.rs index 1f046e1..be3f423 100644 --- a/src/protocol/introspection.rs +++ b/src/protocol/introspection.rs @@ -9,6 +9,8 @@ pub const INTROSPECTION_PROCEDURE_ID: &str = ""; /// Endpoint-wide introspection payload. #[derive(Archive, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] pub struct EndpointIntrospection { + /// Direct child path segments currently registered under this endpoint. + pub sub_endpoints: Vec, /// Hosted leaves and their supported procedures. pub leaves: Vec, } diff --git a/src/protocol/tests/protocol.rs b/src/protocol/tests/protocol.rs index 50698df..fe26281 100644 --- a/src/protocol/tests/protocol.rs +++ b/src/protocol/tests/protocol.rs @@ -16,11 +16,11 @@ fn packet_framing_roundtrip_preserves_header_and_payload() { packet_type: PacketType::Call, src_path: path(&["root", "caller"]), dst_path: path(&["root", "callee"]), - dst_leaf: Some("echo".to_owned()), + dst_leaf: Some("service".to_owned()), hook_id: None, }; let call = CallMessage { - procedure_id: "unshell.echo.v1.alpha.invoke".to_owned(), + procedure_id: "example.service.v1.invoke".to_owned(), data: vec![1, 2, 3, 4], response_hook: Some(HookTarget { hook_id: 7, @@ -45,7 +45,7 @@ fn header_and_call_validation_reject_invalid_combinations() { packet_type: PacketType::Data, src_path: path(&["peer"]), dst_path: path(&["host"]), - dst_leaf: Some("echo".to_owned()), + dst_leaf: Some("service".to_owned()), hook_id: None, }; assert_eq!( @@ -59,11 +59,11 @@ fn header_and_call_validation_reject_invalid_combinations() { packet_type: PacketType::Call, src_path: path(&["caller"]), dst_path: path(&["callee"]), - dst_leaf: Some("echo".to_owned()), + dst_leaf: Some("service".to_owned()), hook_id: None, }; let invalid_call = CallMessage { - procedure_id: "unshell.echo.v1.alpha.invoke".to_owned(), + procedure_id: "example.service.v1.invoke".to_owned(), data: Vec::new(), response_hook: Some(HookTarget { hook_id: 5, @@ -79,18 +79,10 @@ fn header_and_call_validation_reject_invalid_combinations() { } #[test] -fn procedure_validation_accepts_introspection_and_rejects_bad_shapes() { +fn procedure_validation_accepts_introspection_and_non_empty_opaque_ids() { assert_eq!(validate_procedure_id(""), Ok(())); - assert_eq!( - validate_procedure_id("unshell.echo.v01.alpha.invoke"), - Ok(()) - ); - assert_eq!( - validate_procedure_id("contains spaces"), - Err(ValidationError::ProcedureId( - "procedure identifier should use alphanumeric characters, dots, and underscores" - )) - ); + assert_eq!(validate_procedure_id("example.service.v01.invoke"), Ok(())); + assert_eq!(validate_procedure_id("contains spaces"), Ok(())); } #[test] diff --git a/src/protocol/tests/tree.rs b/src/protocol/tests/tree.rs index abffde9..4293c4d 100644 --- a/src/protocol/tests/tree.rs +++ b/src/protocol/tests/tree.rs @@ -1,7 +1,7 @@ use alloc::{borrow::ToOwned, string::String, vec, vec::Vec}; use crate::protocol::tree::{ - DefaultRouteProvider, Endpoint, Ingress, LeafBehavior, LeafNode, LeafSpec, LocalEvent, + ChildRoute, DefaultRouteProvider, Endpoint, Ingress, LeafNode, LeafSpec, LocalEvent, ProtocolEndpoint, RouteDecision, RouteProvider, TreeNode, }; use crate::protocol::{ @@ -19,8 +19,8 @@ fn tree_node_paths_flatten_explicitly() { children: vec![TreeNode::Endpoint { segment: "branch".to_owned(), leaves: vec![LeafNode { - name: "echo".to_owned(), - procedures: vec!["unshell.echo.v1.alpha.invoke".to_owned()], + name: "service".to_owned(), + procedures: vec!["example.service.v1.invoke".to_owned()], }], children: vec![TreeNode::Endpoint { segment: "leaf".to_owned(), @@ -60,11 +60,10 @@ fn protocol_endpoint_introspection_returns_leaf_summary() { let mut endpoint = ProtocolEndpoint::new( path(&["root"]), Some(Vec::new()), - Vec::new(), + vec![ChildRoute::registered(path(&["root", "child"]))], vec![LeafSpec { - name: "echo".to_owned(), - procedures: vec!["unshell.echo.v1.alpha.invoke".to_owned()], - behavior: LeafBehavior::Echo, + name: "service".to_owned(), + procedures: vec!["example.service.v1.invoke".to_owned()], }], ); @@ -96,11 +95,12 @@ fn protocol_endpoint_introspection_returns_leaf_summary() { .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, "echo"); + assert_eq!(introspection.leaves[0].leaf_name, "service"); assert_eq!( introspection.leaves[0].procedures, - vec!["unshell.echo.v1.alpha.invoke".to_owned()] + vec!["example.service.v1.invoke".to_owned()] ); } @@ -113,7 +113,7 @@ fn invalid_hook_peer_emits_local_fault_event() { .make_call( path(&["server"]), None, - "unshell.echo.v1.alpha.invoke", + "example.service.v1.invoke", Some(hook_id), vec![1, 2, 3], ) @@ -128,7 +128,7 @@ fn invalid_hook_peer_emits_local_fault_event() { hook_id: Some(hook_id), }, &DataMessage { - procedure_id: "unshell.echo.v1.alpha.invoke".to_owned(), + procedure_id: "example.service.v1.invoke".to_owned(), data: vec![9], end_hook: false, }, diff --git a/src/protocol/tree/endpoint/core.rs b/src/protocol/tree/endpoint/core.rs index 7bdc086..c02085c 100644 --- a/src/protocol/tree/endpoint/core.rs +++ b/src/protocol/tree/endpoint/core.rs @@ -47,13 +47,6 @@ impl ChildRoute { } } -/// Test leaf behavior implemented by the endpoint runtime. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum LeafBehavior { - /// Mirrors the incoming payload back over the declared response hook. - Echo, -} - /// Static leaf metadata used for procedure dispatch and introspection. #[derive(Debug, Clone, PartialEq, Eq)] pub struct LeafSpec { @@ -61,8 +54,6 @@ pub struct LeafSpec { pub name: String, /// Procedures supported by the leaf. pub procedures: Vec, - /// Built-in behavior used by the lightweight test runtime. - pub behavior: LeafBehavior, } /// Where a frame entered the local endpoint. diff --git a/src/protocol/tree/endpoint/introspection.rs b/src/protocol/tree/endpoint/introspection.rs index aa149e5..2aa1198 100644 --- a/src/protocol/tree/endpoint/introspection.rs +++ b/src/protocol/tree/endpoint/introspection.rs @@ -42,6 +42,12 @@ impl ProtocolEndpoint { .to_vec() } else { to_bytes::(&EndpointIntrospection { + sub_endpoints: self + .children + .iter() + .filter(|child| child.state == super::core::ConnectionState::Registered) + .filter_map(|child| child.path.get(self.path.len()).cloned()) + .collect(), leaves: self .leaves .values() diff --git a/src/protocol/tree/endpoint/mod.rs b/src/protocol/tree/endpoint/mod.rs index d132437..7cbbe35 100644 --- a/src/protocol/tree/endpoint/mod.rs +++ b/src/protocol/tree/endpoint/mod.rs @@ -17,6 +17,6 @@ mod introspection; mod receive; pub use core::{ - ChildRoute, ConnectionState, Endpoint, EndpointError, EndpointOutcome, Ingress, LeafBehavior, - LeafSpec, LocalEvent, ProtocolEndpoint, + ChildRoute, ConnectionState, Endpoint, EndpointError, EndpointOutcome, Ingress, LeafSpec, + LocalEvent, ProtocolEndpoint, }; diff --git a/src/protocol/tree/endpoint/receive.rs b/src/protocol/tree/endpoint/receive.rs index 41fa27e..84c2279 100644 --- a/src/protocol/tree/endpoint/receive.rs +++ b/src/protocol/tree/endpoint/receive.rs @@ -6,8 +6,8 @@ use alloc::vec; use crate::protocol::{ - CallMessage, DataMessage, PacketType, ProtocolFault, decode_frame, - introspection::INTROSPECTION_PROCEDURE_ID, validate_call, validate_header, + CallMessage, PacketType, ProtocolFault, decode_frame, introspection::INTROSPECTION_PROCEDURE_ID, + validate_call, validate_header, }; use super::super::{HookKey, PendingHook, RouteDecision}; @@ -77,51 +77,10 @@ impl ProtocolEndpoint { self.hooks.activate_pending(key, header.src_path.clone()); } - match header - .dst_leaf - .as_ref() - .and_then(|name| self.leaves.get(name)) - { - Some(leaf) if leaf.behavior == super::core::LeafBehavior::Echo && key.is_some() => { - let hook = message.response_hook.expect("synchronized"); - let response = DataMessage { - procedure_id: message.procedure_id.clone(), - data: message.data, - end_hook: true, - }; - let response_header = crate::protocol::PacketHeader { - packet_type: PacketType::Data, - src_path: self.path.clone(), - dst_path: hook.return_path.clone(), - dst_leaf: None, - hook_id: Some(hook.hook_id), - }; - let route = self.decide_route(&hook.return_path); - self.hooks - .remove_active(&HookKey::new(hook.return_path.clone(), hook.hook_id)); - - match route { - RouteDecision::Local => Ok(EndpointOutcome { - events: vec![LocalEvent::Data { - header: response_header, - message: response, - }], - ..EndpointOutcome::default() - }), - _ => { - let frame = crate::protocol::encode_packet(&response_header, &response)?; - Ok(EndpointOutcome { - forwards: vec![(route, frame)], - ..EndpointOutcome::default() - }) - } - } - } - _ => Ok(EndpointOutcome { - events: vec![LocalEvent::Call { header, message }], - ..EndpointOutcome::default() - }), - } + Ok(EndpointOutcome { + events: vec![LocalEvent::Call { header, message }], + ..EndpointOutcome::default() + }) } } diff --git a/src/protocol/tree/mod.rs b/src/protocol/tree/mod.rs index d930d09..73f83db 100644 --- a/src/protocol/tree/mod.rs +++ b/src/protocol/tree/mod.rs @@ -5,8 +5,8 @@ mod hook; mod routing; pub use endpoint::{ - ChildRoute, ConnectionState, Endpoint, EndpointError, EndpointOutcome, Ingress, LeafBehavior, - LeafSpec, LocalEvent, ProtocolEndpoint, + ChildRoute, ConnectionState, Endpoint, EndpointError, EndpointOutcome, Ingress, LeafSpec, + LocalEvent, ProtocolEndpoint, }; pub use hook::{ActiveHook, HookConflict, HookKey, HookTable, PendingHook}; pub use routing::{ diff --git a/src/protocol/validation.rs b/src/protocol/validation.rs index 08a52c4..a73827d 100644 --- a/src/protocol/validation.rs +++ b/src/protocol/validation.rs @@ -60,7 +60,7 @@ pub fn validate_header(header: &PacketHeader) -> Result<(), ValidationError> { Ok(()) } -/// Validates the canonical dotted `procedure_id` shape. +/// Validates the protocol-level `procedure_id` invariant. pub fn validate_procedure_id(procedure_id: &str) -> Result<(), ValidationError> { if procedure_id == INTROSPECTION_PROCEDURE_ID { return Ok(()); @@ -72,15 +72,6 @@ pub fn validate_procedure_id(procedure_id: &str) -> Result<(), ValidationError> )); } - if !procedure_id - .chars() - .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '.') - { - return Err(ValidationError::ProcedureId( - "procedure identifier should use alphanumeric characters, dots, and underscores", - )); - } - Ok(()) } diff --git a/treetest/src/model.rs b/treetest/src/model.rs index aa05921..78f59e4 100644 --- a/treetest/src/model.rs +++ b/treetest/src/model.rs @@ -14,7 +14,7 @@ pub struct NodeId(pub usize); /// Supported demo leaf kinds. #[derive(Debug, Clone, PartialEq, Eq)] pub enum LeafKind { - /// Uses the built-in echo leaf behavior from `unshell`. + /// Demo leaf that returns the incoming payload through the declared hook. Echo, } diff --git a/treetest/src/scenarios/simple.rs b/treetest/src/scenarios/simple.rs index 1f60f69..707dde1 100644 --- a/treetest/src/scenarios/simple.rs +++ b/treetest/src/scenarios/simple.rs @@ -74,7 +74,7 @@ fn echo_leaf() -> ScenarioDefinition { name: "Echo Leaf".to_owned(), description: "Call a concrete leaf and watch the hook finish normally.".to_owned(), highlights: vec![ - "The leaf uses the built-in `Echo` behavior from the core runtime.".to_owned(), + "The demo application echoes the payload after the protocol runtime delivers the call locally.".to_owned(), "The final response sets `end_hook = true`.".to_owned(), ], root: NodeSpec { diff --git a/treetest/src/sim/build.rs b/treetest/src/sim/build.rs index 35c9d1d..3888e64 100644 --- a/treetest/src/sim/build.rs +++ b/treetest/src/sim/build.rs @@ -6,9 +6,9 @@ use std::collections::{BTreeMap, VecDeque}; use crossbeam_channel::unbounded; -use unshell::protocol::tree::{ChildRoute, ConnectionState, LeafBehavior, ProtocolEndpoint}; +use unshell::protocol::tree::{ChildRoute, ConnectionState, ProtocolEndpoint}; -use crate::model::{DemoTree, LeafKind, NodeId, ScenarioDefinition, Selection}; +use crate::model::{DemoTree, NodeId, ScenarioDefinition, Selection}; use super::knowledge::{InspectorMode, RootKnowledge}; use super::types::{ChatSession, SimError, SimNode, Simulation}; @@ -53,9 +53,6 @@ impl Simulation { .map(|leaf| unshell::protocol::tree::LeafSpec { name: leaf.name.clone(), procedures: leaf.procedures.clone(), - behavior: match leaf.kind { - LeafKind::Echo => LeafBehavior::Echo, - }, }) .collect::>(); diff --git a/treetest/src/sim/runtime/events/application.rs b/treetest/src/sim/runtime/events/application.rs index 41377ef..29713fc 100644 --- a/treetest/src/sim/runtime/events/application.rs +++ b/treetest/src/sim/runtime/events/application.rs @@ -7,7 +7,7 @@ use unshell::protocol::{CallMessage, PacketHeader}; -use crate::model::{EndpointProcedureKind, EndpointProcedureSpec, NodeId}; +use crate::model::{EndpointProcedureKind, EndpointProcedureSpec, LeafKind, NodeId}; use super::super::super::types::{SimError, Simulation}; @@ -17,13 +17,32 @@ impl Simulation { pub(super) fn handle_application_call( &mut self, node_id: NodeId, - _header: &PacketHeader, + header: &PacketHeader, message: &CallMessage, ) -> Result<(), SimError> { let Some(hook) = &message.response_hook else { return Ok(()); }; + if let Some(leaf_name) = &header.dst_leaf { + let leaf = self.require_leaf(node_id, leaf_name)?.clone(); + match leaf.kind { + LeafKind::Echo => { + let frame = self.make_endpoint_data_frame( + node_id, + hook.return_path.clone(), + hook.hook_id, + message.procedure_id.clone(), + message.data.clone(), + true, + )?; + self.record_trace(node_id, format!("leaf {leaf_name} echoed {} bytes", message.data.len())); + self.process_local_frame(node_id, frame)?; + } + } + return Ok(()); + } + // Clone the procedure spec once so later reply generation can borrow the // rest of the simulator state freely. let procedure = self