mirror of
https://github.com/Astatin3/unshell.git
synced 2026-06-08 22:38:01 -06:00
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:
@@ -9,6 +9,8 @@ pub const INTROSPECTION_PROCEDURE_ID: &str = "";
|
|||||||
/// Endpoint-wide introspection payload.
|
/// Endpoint-wide introspection payload.
|
||||||
#[derive(Archive, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
#[derive(Archive, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct EndpointIntrospection {
|
pub struct EndpointIntrospection {
|
||||||
|
/// Direct child path segments currently registered under this endpoint.
|
||||||
|
pub sub_endpoints: Vec<String>,
|
||||||
/// Hosted leaves and their supported procedures.
|
/// Hosted leaves and their supported procedures.
|
||||||
pub leaves: Vec<LeafIntrospectionSummary>,
|
pub leaves: Vec<LeafIntrospectionSummary>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,11 +16,11 @@ fn packet_framing_roundtrip_preserves_header_and_payload() {
|
|||||||
packet_type: PacketType::Call,
|
packet_type: PacketType::Call,
|
||||||
src_path: path(&["root", "caller"]),
|
src_path: path(&["root", "caller"]),
|
||||||
dst_path: path(&["root", "callee"]),
|
dst_path: path(&["root", "callee"]),
|
||||||
dst_leaf: Some("echo".to_owned()),
|
dst_leaf: Some("service".to_owned()),
|
||||||
hook_id: None,
|
hook_id: None,
|
||||||
};
|
};
|
||||||
let call = CallMessage {
|
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],
|
data: vec![1, 2, 3, 4],
|
||||||
response_hook: Some(HookTarget {
|
response_hook: Some(HookTarget {
|
||||||
hook_id: 7,
|
hook_id: 7,
|
||||||
@@ -45,7 +45,7 @@ fn header_and_call_validation_reject_invalid_combinations() {
|
|||||||
packet_type: PacketType::Data,
|
packet_type: PacketType::Data,
|
||||||
src_path: path(&["peer"]),
|
src_path: path(&["peer"]),
|
||||||
dst_path: path(&["host"]),
|
dst_path: path(&["host"]),
|
||||||
dst_leaf: Some("echo".to_owned()),
|
dst_leaf: Some("service".to_owned()),
|
||||||
hook_id: None,
|
hook_id: None,
|
||||||
};
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -59,11 +59,11 @@ fn header_and_call_validation_reject_invalid_combinations() {
|
|||||||
packet_type: PacketType::Call,
|
packet_type: PacketType::Call,
|
||||||
src_path: path(&["caller"]),
|
src_path: path(&["caller"]),
|
||||||
dst_path: path(&["callee"]),
|
dst_path: path(&["callee"]),
|
||||||
dst_leaf: Some("echo".to_owned()),
|
dst_leaf: Some("service".to_owned()),
|
||||||
hook_id: None,
|
hook_id: None,
|
||||||
};
|
};
|
||||||
let invalid_call = CallMessage {
|
let invalid_call = CallMessage {
|
||||||
procedure_id: "unshell.echo.v1.alpha.invoke".to_owned(),
|
procedure_id: "example.service.v1.invoke".to_owned(),
|
||||||
data: Vec::new(),
|
data: Vec::new(),
|
||||||
response_hook: Some(HookTarget {
|
response_hook: Some(HookTarget {
|
||||||
hook_id: 5,
|
hook_id: 5,
|
||||||
@@ -79,18 +79,10 @@ fn header_and_call_validation_reject_invalid_combinations() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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(""), Ok(()));
|
||||||
assert_eq!(
|
assert_eq!(validate_procedure_id("example.service.v01.invoke"), Ok(()));
|
||||||
validate_procedure_id("unshell.echo.v01.alpha.invoke"),
|
assert_eq!(validate_procedure_id("contains spaces"), Ok(()));
|
||||||
Ok(())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
validate_procedure_id("contains spaces"),
|
|
||||||
Err(ValidationError::ProcedureId(
|
|
||||||
"procedure identifier should use alphanumeric characters, dots, and underscores"
|
|
||||||
))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+11
-11
@@ -1,7 +1,7 @@
|
|||||||
use alloc::{borrow::ToOwned, string::String, vec, vec::Vec};
|
use alloc::{borrow::ToOwned, string::String, vec, vec::Vec};
|
||||||
|
|
||||||
use crate::protocol::tree::{
|
use crate::protocol::tree::{
|
||||||
DefaultRouteProvider, Endpoint, Ingress, LeafBehavior, LeafNode, LeafSpec, LocalEvent,
|
ChildRoute, DefaultRouteProvider, Endpoint, Ingress, LeafNode, LeafSpec, LocalEvent,
|
||||||
ProtocolEndpoint, RouteDecision, RouteProvider, TreeNode,
|
ProtocolEndpoint, RouteDecision, RouteProvider, TreeNode,
|
||||||
};
|
};
|
||||||
use crate::protocol::{
|
use crate::protocol::{
|
||||||
@@ -19,8 +19,8 @@ fn tree_node_paths_flatten_explicitly() {
|
|||||||
children: vec![TreeNode::Endpoint {
|
children: vec![TreeNode::Endpoint {
|
||||||
segment: "branch".to_owned(),
|
segment: "branch".to_owned(),
|
||||||
leaves: vec![LeafNode {
|
leaves: vec![LeafNode {
|
||||||
name: "echo".to_owned(),
|
name: "service".to_owned(),
|
||||||
procedures: vec!["unshell.echo.v1.alpha.invoke".to_owned()],
|
procedures: vec!["example.service.v1.invoke".to_owned()],
|
||||||
}],
|
}],
|
||||||
children: vec![TreeNode::Endpoint {
|
children: vec![TreeNode::Endpoint {
|
||||||
segment: "leaf".to_owned(),
|
segment: "leaf".to_owned(),
|
||||||
@@ -60,11 +60,10 @@ fn protocol_endpoint_introspection_returns_leaf_summary() {
|
|||||||
let mut endpoint = ProtocolEndpoint::new(
|
let mut endpoint = ProtocolEndpoint::new(
|
||||||
path(&["root"]),
|
path(&["root"]),
|
||||||
Some(Vec::new()),
|
Some(Vec::new()),
|
||||||
Vec::new(),
|
vec![ChildRoute::registered(path(&["root", "child"]))],
|
||||||
vec![LeafSpec {
|
vec![LeafSpec {
|
||||||
name: "echo".to_owned(),
|
name: "service".to_owned(),
|
||||||
procedures: vec!["unshell.echo.v1.alpha.invoke".to_owned()],
|
procedures: vec!["example.service.v1.invoke".to_owned()],
|
||||||
behavior: LeafBehavior::Echo,
|
|
||||||
}],
|
}],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -96,11 +95,12 @@ fn protocol_endpoint_introspection_returns_leaf_summary() {
|
|||||||
.expect("introspection payload should deserialize");
|
.expect("introspection payload should deserialize");
|
||||||
|
|
||||||
assert!(response.end_hook);
|
assert!(response.end_hook);
|
||||||
|
assert_eq!(introspection.sub_endpoints, vec!["child".to_owned()]);
|
||||||
assert_eq!(introspection.leaves.len(), 1);
|
assert_eq!(introspection.leaves.len(), 1);
|
||||||
assert_eq!(introspection.leaves[0].leaf_name, "echo");
|
assert_eq!(introspection.leaves[0].leaf_name, "service");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
introspection.leaves[0].procedures,
|
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(
|
.make_call(
|
||||||
path(&["server"]),
|
path(&["server"]),
|
||||||
None,
|
None,
|
||||||
"unshell.echo.v1.alpha.invoke",
|
"example.service.v1.invoke",
|
||||||
Some(hook_id),
|
Some(hook_id),
|
||||||
vec![1, 2, 3],
|
vec![1, 2, 3],
|
||||||
)
|
)
|
||||||
@@ -128,7 +128,7 @@ fn invalid_hook_peer_emits_local_fault_event() {
|
|||||||
hook_id: Some(hook_id),
|
hook_id: Some(hook_id),
|
||||||
},
|
},
|
||||||
&DataMessage {
|
&DataMessage {
|
||||||
procedure_id: "unshell.echo.v1.alpha.invoke".to_owned(),
|
procedure_id: "example.service.v1.invoke".to_owned(),
|
||||||
data: vec![9],
|
data: vec![9],
|
||||||
end_hook: false,
|
end_hook: false,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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.
|
/// Static leaf metadata used for procedure dispatch and introspection.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct LeafSpec {
|
pub struct LeafSpec {
|
||||||
@@ -61,8 +54,6 @@ pub struct LeafSpec {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
/// Procedures supported by the leaf.
|
/// Procedures supported by the leaf.
|
||||||
pub procedures: Vec<String>,
|
pub procedures: Vec<String>,
|
||||||
/// Built-in behavior used by the lightweight test runtime.
|
|
||||||
pub behavior: LeafBehavior,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Where a frame entered the local endpoint.
|
/// Where a frame entered the local endpoint.
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ impl ProtocolEndpoint {
|
|||||||
.to_vec()
|
.to_vec()
|
||||||
} else {
|
} else {
|
||||||
to_bytes::<RkyvError>(&EndpointIntrospection {
|
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: self
|
||||||
.leaves
|
.leaves
|
||||||
.values()
|
.values()
|
||||||
|
|||||||
@@ -17,6 +17,6 @@ mod introspection;
|
|||||||
mod receive;
|
mod receive;
|
||||||
|
|
||||||
pub use core::{
|
pub use core::{
|
||||||
ChildRoute, ConnectionState, Endpoint, EndpointError, EndpointOutcome, Ingress, LeafBehavior,
|
ChildRoute, ConnectionState, Endpoint, EndpointError, EndpointOutcome, Ingress, LeafSpec,
|
||||||
LeafSpec, LocalEvent, ProtocolEndpoint,
|
LocalEvent, ProtocolEndpoint,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
use alloc::vec;
|
use alloc::vec;
|
||||||
|
|
||||||
use crate::protocol::{
|
use crate::protocol::{
|
||||||
CallMessage, DataMessage, PacketType, ProtocolFault, decode_frame,
|
CallMessage, PacketType, ProtocolFault, decode_frame, introspection::INTROSPECTION_PROCEDURE_ID,
|
||||||
introspection::INTROSPECTION_PROCEDURE_ID, validate_call, validate_header,
|
validate_call, validate_header,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::super::{HookKey, PendingHook, RouteDecision};
|
use super::super::{HookKey, PendingHook, RouteDecision};
|
||||||
@@ -77,51 +77,10 @@ impl ProtocolEndpoint {
|
|||||||
self.hooks.activate_pending(key, header.src_path.clone());
|
self.hooks.activate_pending(key, header.src_path.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
match header
|
Ok(EndpointOutcome {
|
||||||
.dst_leaf
|
events: vec![LocalEvent::Call { header, message }],
|
||||||
.as_ref()
|
..EndpointOutcome::default()
|
||||||
.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()
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ mod hook;
|
|||||||
mod routing;
|
mod routing;
|
||||||
|
|
||||||
pub use endpoint::{
|
pub use endpoint::{
|
||||||
ChildRoute, ConnectionState, Endpoint, EndpointError, EndpointOutcome, Ingress, LeafBehavior,
|
ChildRoute, ConnectionState, Endpoint, EndpointError, EndpointOutcome, Ingress, LeafSpec,
|
||||||
LeafSpec, LocalEvent, ProtocolEndpoint,
|
LocalEvent, ProtocolEndpoint,
|
||||||
};
|
};
|
||||||
pub use hook::{ActiveHook, HookConflict, HookKey, HookTable, PendingHook};
|
pub use hook::{ActiveHook, HookConflict, HookKey, HookTable, PendingHook};
|
||||||
pub use routing::{
|
pub use routing::{
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ pub fn validate_header(header: &PacketHeader) -> Result<(), ValidationError> {
|
|||||||
Ok(())
|
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> {
|
pub fn validate_procedure_id(procedure_id: &str) -> Result<(), ValidationError> {
|
||||||
if procedure_id == INTROSPECTION_PROCEDURE_ID {
|
if procedure_id == INTROSPECTION_PROCEDURE_ID {
|
||||||
return Ok(());
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ pub struct NodeId(pub usize);
|
|||||||
/// Supported demo leaf kinds.
|
/// Supported demo leaf kinds.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum LeafKind {
|
pub enum LeafKind {
|
||||||
/// Uses the built-in echo leaf behavior from `unshell`.
|
/// Demo leaf that returns the incoming payload through the declared hook.
|
||||||
Echo,
|
Echo,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ fn echo_leaf() -> ScenarioDefinition {
|
|||||||
name: "Echo Leaf".to_owned(),
|
name: "Echo Leaf".to_owned(),
|
||||||
description: "Call a concrete leaf and watch the hook finish normally.".to_owned(),
|
description: "Call a concrete leaf and watch the hook finish normally.".to_owned(),
|
||||||
highlights: vec![
|
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(),
|
"The final response sets `end_hook = true`.".to_owned(),
|
||||||
],
|
],
|
||||||
root: NodeSpec {
|
root: NodeSpec {
|
||||||
|
|||||||
@@ -6,9 +6,9 @@
|
|||||||
use std::collections::{BTreeMap, VecDeque};
|
use std::collections::{BTreeMap, VecDeque};
|
||||||
|
|
||||||
use crossbeam_channel::unbounded;
|
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::knowledge::{InspectorMode, RootKnowledge};
|
||||||
use super::types::{ChatSession, SimError, SimNode, Simulation};
|
use super::types::{ChatSession, SimError, SimNode, Simulation};
|
||||||
@@ -53,9 +53,6 @@ impl Simulation {
|
|||||||
.map(|leaf| unshell::protocol::tree::LeafSpec {
|
.map(|leaf| unshell::protocol::tree::LeafSpec {
|
||||||
name: leaf.name.clone(),
|
name: leaf.name.clone(),
|
||||||
procedures: leaf.procedures.clone(),
|
procedures: leaf.procedures.clone(),
|
||||||
behavior: match leaf.kind {
|
|
||||||
LeafKind::Echo => LeafBehavior::Echo,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
use unshell::protocol::{CallMessage, PacketHeader};
|
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};
|
use super::super::super::types::{SimError, Simulation};
|
||||||
|
|
||||||
@@ -17,13 +17,32 @@ impl Simulation {
|
|||||||
pub(super) fn handle_application_call(
|
pub(super) fn handle_application_call(
|
||||||
&mut self,
|
&mut self,
|
||||||
node_id: NodeId,
|
node_id: NodeId,
|
||||||
_header: &PacketHeader,
|
header: &PacketHeader,
|
||||||
message: &CallMessage,
|
message: &CallMessage,
|
||||||
) -> Result<(), SimError> {
|
) -> Result<(), SimError> {
|
||||||
let Some(hook) = &message.response_hook else {
|
let Some(hook) = &message.response_hook else {
|
||||||
return Ok(());
|
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
|
// Clone the procedure spec once so later reply generation can borrow the
|
||||||
// rest of the simulator state freely.
|
// rest of the simulator state freely.
|
||||||
let procedure = self
|
let procedure = self
|
||||||
|
|||||||
Reference in New Issue
Block a user