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.
This commit is contained in:
Michael Mikovsky
2026-04-25 11:27:29 -06:00
parent ba3f28a78c
commit 6bdf59c5c9
13 changed files with 63 additions and 106 deletions
+2
View File
@@ -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<String>,
/// Hosted leaves and their supported procedures.
pub leaves: Vec<LeafIntrospectionSummary>,
}
+8 -16
View File
@@ -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]
+11 -11
View File
@@ -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,
},
-9
View File
@@ -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<String>,
/// Built-in behavior used by the lightweight test runtime.
pub behavior: LeafBehavior,
}
/// Where a frame entered the local endpoint.
@@ -42,6 +42,12 @@ impl ProtocolEndpoint {
.to_vec()
} else {
to_bytes::<RkyvError>(&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()
+2 -2
View File
@@ -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,
};
+6 -47
View File
@@ -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()
})
}
}
+2 -2
View File
@@ -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::{
+1 -10
View File
@@ -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(())
}