From 7c0ee31d3854e3641cf96afb899b69dc5441cc7f Mon Sep 17 00:00:00 2001 From: Michael Mikovsky <77305074+Astatin3@users.noreply.github.com> Date: Thu, 30 Apr 2026 10:15:54 -0600 Subject: [PATCH 01/31] Reduce hook table public surface --- unshell-leaves/src/lib.rs | 3 ++- unshell-protocol/src/protocol/tree/hook.rs | 25 ++-------------------- 2 files changed, 4 insertions(+), 24 deletions(-) diff --git a/unshell-leaves/src/lib.rs b/unshell-leaves/src/lib.rs index 7545f0a..1578b2e 100644 --- a/unshell-leaves/src/lib.rs +++ b/unshell-leaves/src/lib.rs @@ -29,13 +29,14 @@ pub use unshell_protocol as protocol; /// 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(feature = "leaf_endpoint")] +/// # #[cfg(all(feature = "leaf_endpoint", not(feature = "leaf_tui")))] /// # let _ = core::marker::PhantomData::; /// ``` #[macro_export] diff --git a/unshell-protocol/src/protocol/tree/hook.rs b/unshell-protocol/src/protocol/tree/hook.rs index e43d713..368b4b4 100644 --- a/unshell-protocol/src/protocol/tree/hook.rs +++ b/unshell-protocol/src/protocol/tree/hook.rs @@ -365,27 +365,6 @@ impl HookTable { self.active.get(key) } - /// Returns the mutable 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, - /// })?; - /// hooks.active_mut(&key).unwrap().peer_ended = true; - /// assert!(hooks.active(&key).unwrap().peer_ended); - /// # Ok::<(), unshell::protocol::tree::HookConflict>(()) - /// ``` - pub fn active_mut(&mut self, key: &HookKey) -> Option<&mut ActiveHook> { - self.active.get_mut(key) - } - /// Resolves an active hook from either side of the conversation. /// /// The host side addresses hooks directly by `(return_path, hook_id)`. Peer-originated @@ -447,7 +426,7 @@ impl HookTable { /// # Ok::<(), unshell::protocol::tree::HookConflict>(()) /// ``` pub fn mark_local_end(&mut self, key: &HookKey) -> bool { - let Some(active) = self.active_mut(key) else { + let Some(active) = self.active.get_mut(key) else { return false; }; active.local_ended = true; @@ -474,7 +453,7 @@ impl HookTable { /// # Ok::<(), unshell::protocol::tree::HookConflict>(()) /// ``` pub fn mark_peer_end(&mut self, key: &HookKey) -> bool { - let Some(active) = self.active_mut(key) else { + let Some(active) = self.active.get_mut(key) else { return false; }; active.peer_ended = true; From 366771356c83d0edb02b178cfcb2651f3b8200e2 Mon Sep 17 00:00:00 2001 From: Michael Mikovsky <77305074+Astatin3@users.noreply.github.com> Date: Thu, 30 Apr 2026 10:23:19 -0600 Subject: [PATCH 02/31] Simplify procedure session advancement --- examples/protocol/crossbeam_channel_leaf.rs | 178 +++++++++--------- .../src/protocol/tree/procedure.rs | 74 +++----- 2 files changed, 122 insertions(+), 130 deletions(-) diff --git a/examples/protocol/crossbeam_channel_leaf.rs b/examples/protocol/crossbeam_channel_leaf.rs index dffad2e..5cf8766 100644 --- a/examples/protocol/crossbeam_channel_leaf.rs +++ b/examples/protocol/crossbeam_channel_leaf.rs @@ -18,31 +18,9 @@ use unshell::protocol::tree::{ }; fn main() -> Result<(), Box> { - 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 network = ChannelNetwork::new()?; - 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); - - call_root( - &mut root, - &root_to_agent, - &mut agent, - &mut child, - &root_rx, + network.call_root( path(&["agent"]), CrossbeamChannelLeaf::protocol_procedure_id("add_connection").expect("procedure exists"), encode_call_reply(&ConnectionRequest { @@ -50,12 +28,7 @@ fn main() -> Result<(), Box> { })?, )?; - let reply = call_root( - &mut root, - &root_to_agent, - &mut agent, - &mut child, - &root_rx, + let reply = network.call_root( path(&["agent", "child"]), CrossbeamChannelLeaf::protocol_procedure_id("get_connections").expect("procedure exists"), encode_call_reply(&())?, @@ -68,6 +41,96 @@ fn main() -> Result<(), Box> { 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, @@ -117,61 +180,6 @@ impl ChannelNode { } } -fn call_root( - root: &mut ProtocolEndpoint, - root_to_agent: &Sender, - agent: &mut ChannelNode, - child: &mut ChannelNode, - root_rx: &Receiver, - dst_path: Vec, - procedure_id: String, - data: Vec, -) -> Result, Box> { - let hook_id = root.allocate_hook_id(); - let outcome = 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()); - }; - root_to_agent.send(CrossbeamEnvelope { - ingress: Ingress::Parent, - frame, - })?; - - for _ in 0..16 { - let mut progress = 0usize; - progress += agent.drain()?; - progress += child.drain()?; - - while let Ok(envelope) = root_rx.try_recv() { - progress += 1; - let outcome = 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()) -} - fn path(parts: &[&str]) -> Vec { parts.iter().map(|part| (*part).to_owned()).collect() } diff --git a/unshell-protocol/src/protocol/tree/procedure.rs b/unshell-protocol/src/protocol/tree/procedure.rs index 47f9385..c67a266 100644 --- a/unshell-protocol/src/protocol/tree/procedure.rs +++ b/unshell-protocol/src/protocol/tree/procedure.rs @@ -500,10 +500,7 @@ where }; // Collect keys first and temporarily remove each session so procedure callbacks can // mutate the leaf without fighting the session-table borrow. - match self.poll_session(key, session)? { - Some(session_frames) => frames.extend(session_frames), - None => continue, - } + frames.extend(self.poll_session(key, session)?); } Ok(ProcedureRuntimeOutcome { @@ -530,17 +527,29 @@ where } 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, - ) -> Result>, ProcedureRuntimeError> { - let effect = match P::poll(&mut self.leaf, &mut session) { + 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(Some(frames)); + return Ok(frames); } }; @@ -564,7 +573,7 @@ where let _ = P::close(&mut self.leaf, session); } - Ok(Some(outgoing)) + Ok(outgoing) } fn process_local_event( @@ -628,45 +637,20 @@ where message: crate::protocol::DataMessage, hook_key: HookKey, ) -> Result> { - let Some(mut session) = self.leaf.procedure_sessions().remove(&hook_key) else { + let Some(session) = self.leaf.procedure_sessions().remove(&hook_key) else { return Ok(ProcedureRuntimeOutcome::default()); }; - let effect = match P::on_data( - &mut self.leaf, - &mut session, - IncomingData { - header, - message, - hook_key: hook_key.clone(), - }, - ) { - Ok(effect) => self.ensure_terminal_packet(&hook_key, effect), - Err(error) => { - let _ = P::close(&mut self.leaf, session); - let frames = self.emit_internal_fault(Some(hook_key.clone()))?; - let _ = error; - return Ok(ProcedureRuntimeOutcome { - frames, - dropped: false, - }); - } - }; - let outgoing = match self.emit_outgoing(effect.outgoing) { - Ok(outgoing) => outgoing.frames, - Err(error) => { - if !effect.close_session { - self.leaf.procedure_sessions().insert(hook_key, session); - } else { - let _ = P::close(&mut self.leaf, session); - } - return Err(error); - } - }; - if !effect.close_session { - self.leaf.procedure_sessions().insert(hook_key, session); - } else { - let _ = P::close(&mut self.leaf, session); - } + 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, From a61c0ce72de6e5424bd2bec60bc2463f1366d2e2 Mon Sep 17 00:00:00 2001 From: Michael Mikovsky <77305074+Astatin3@users.noreply.github.com> Date: Sat, 9 May 2026 12:45:14 -0600 Subject: [PATCH 03/31] Add runtime API redesign scaffold --- API.md | 305 +++++++++++++++++++ Cargo.lock | 8 + Cargo.toml | 3 + src/lib.rs | 3 + unshell-runtime/Cargo.toml | 21 ++ unshell-runtime/src/connections.rs | 291 ++++++++++++++++++ unshell-runtime/src/context.rs | 205 +++++++++++++ unshell-runtime/src/effects.rs | 56 ++++ unshell-runtime/src/leaf.rs | 110 +++++++ unshell-runtime/src/lib.rs | 122 ++++++++ unshell-runtime/src/node/mod.rs | 73 +++++ unshell-runtime/src/node/packet.rs | 86 ++++++ unshell-runtime/src/node/runtime.rs | 439 ++++++++++++++++++++++++++++ unshell-runtime/src/node/state.rs | 15 + unshell-runtime/src/transport.rs | 31 ++ ush-obfuscate/src/lib.rs | 1 - 16 files changed, 1768 insertions(+), 1 deletion(-) create mode 100644 API.md create mode 100644 unshell-runtime/Cargo.toml create mode 100644 unshell-runtime/src/connections.rs create mode 100644 unshell-runtime/src/context.rs create mode 100644 unshell-runtime/src/effects.rs create mode 100644 unshell-runtime/src/leaf.rs create mode 100644 unshell-runtime/src/lib.rs create mode 100644 unshell-runtime/src/node/mod.rs create mode 100644 unshell-runtime/src/node/packet.rs create mode 100644 unshell-runtime/src/node/runtime.rs create mode 100644 unshell-runtime/src/node/state.rs create mode 100644 unshell-runtime/src/transport.rs diff --git a/API.md b/API.md new file mode 100644 index 0000000..9fab9d4 --- /dev/null +++ b/API.md @@ -0,0 +1,305 @@ +# UnShell Runtime API Proposal + +This document records the proposed public API direction for the runtime redesign. +The goal is to split packet processing from node orchestration while keeping the +implant-facing runtime single-threaded, explicit, and hard to misuse. + +## Goals + +- Keep `unshell-protocol` focused on packet types, framing, encoding, decoding, + and static validation. +- Move endpoint state, routing state, hook state, connection admission, transport + ownership, leaf dispatch, and scheduling into `unshell-runtime`. +- Run without internal threads. Progress happens only when the caller drives the + runtime with `tick` or explicit local actions. +- Let every leaf request calls, hook data, faults, and connection changes without + giving leaves direct access to routes, hooks, endpoint internals, or transports. +- Preserve protocol authority rules by deriving ingress from registered connection + metadata, never from caller-provided values. +- Keep hot packet paths allocation-aware and move toward borrowed packet/event + views where the current protocol API permits it. + +## Crate Boundary + +```text +unshell-protocol + PacketHeader, CallMessage, DataMessage, FaultMessage + encode_packet, decode_frame + validate_header, validate_call, validate_procedure_id + introspection payload schemas + +unshell-runtime + EndpointState + NodeRuntime + Connections + Transport + Leaf, LeafContext, LeafAction + runtime effects and scheduling + +unshell + facade re-exports: protocol, runtime, leaves, macros +``` + +`EndpointState` is transitional. Today it wraps the existing +`ProtocolEndpoint`. Long term, the endpoint state machine should live in +`unshell-runtime`, while `unshell-protocol` becomes packet-only. + +## Transport API + +Transports move already-framed protocol packets. They do not know paths, leaves, +hooks, routing, or admission policy. + +```rust +pub trait Transport { + type Error; + + fn poll_recv(&mut self) -> Result, Self::Error>; + + fn send_frame( + &mut self, + connection: ConnectionId, + frame: FrameBytes, + ) -> Result<(), Self::Error>; + + fn flush(&mut self) -> Result<(), Self::Error> { + Ok(()) + } +} +``` + +Rules: + +- `poll_recv` must not block. +- `ConnectionId` is a runtime handle, not a protocol path. +- The runtime maps `ConnectionId` to protocol ingress. + +## Connection API + +Connections are not routable until registered. + +```rust +pub struct ConnectionId(u64); +pub struct ConnectionGeneration(u64); + +pub enum ConnectionDirection { + Parent, + Child, +} + +pub struct RegisteredConnection { + direction: ConnectionDirection, + peer_path: Vec, + generation: ConnectionGeneration, +} + +pub enum ConnectionState { + Connected { generation: ConnectionGeneration }, + Authenticating { generation: ConnectionGeneration }, + Registered(RegisteredConnection), + Draining { generation: ConnectionGeneration }, + Closed { generation: ConnectionGeneration }, +} +``` + +Rules: + +- Only `Registered` connections can produce protocol ingress or receive routed + frames. +- Parent registration must be exactly the direct parent path. +- Child registration must be exactly one segment below the local path. +- Registering or unregistering a connection must update connection state, + endpoint routes, hook cleanup, and queued generation checks atomically. +- Queued outbound frames carry `ConnectionGeneration`; stale sends are dropped + when a connection slot is reused. + +## Runtime API + +`NodeRuntime` owns endpoint packet state, connections, transport, and queued +effects. + +```rust +pub struct NodeRuntime { + endpoint: EndpointState, + connections: Connections, + transport: T, + effects: EffectQueue, +} + +pub struct TickBudget { + pub max_inbound_frames: usize, + pub flush_outbound: bool, +} + +pub struct TickOutcome { + pub inbound_frames: usize, + pub outbound_frames: usize, + pub dropped_frames: usize, + pub local_events: usize, +} +``` + +Primary operations: + +```rust +impl NodeRuntime { + pub fn tick(&mut self, budget: TickBudget) -> Result>; + + pub fn receive_frame( + &mut self, + connection: ConnectionId, + frame: FrameBytes, + ) -> Result<(), NodeRuntimeError>; +} +``` + +Runtime flow: + +```text +transport poll -> (ConnectionId, FrameBytes) + -> look up registered connection + -> derive Ingress from registered direction/path + -> EndpointState::process_frame + -> RuntimeEffect::SendFrame | RuntimeEffect::Local | RuntimeEffect::Dropped + -> flush SendFrame effects through Transport +``` + +Rules: + +- Callers never pass `Ingress` into `NodeRuntime`. +- Runtime counts per-tick progress, not retained backlog. +- Local events should be dispatched to leaves, not retained forever. +- Send failures must not drop unrelated queued effects. + +## Leaf API + +Leaves are request-only. They can ask the runtime to do work, but cannot mutate +endpoint state, hooks, route tables, connection maps, or transports. + +```rust +pub trait Leaf { + type Error; + + fn capabilities(&self) -> &LeafCapabilities; + + fn on_call(&mut self, ctx: &mut LeafContext<'_>, call: IncomingCall) + -> Result<(), Self::Error>; + + fn on_data(&mut self, ctx: &mut LeafContext<'_>, data: IncomingData) + -> Result<(), Self::Error>; + + fn on_fault(&mut self, ctx: &mut LeafContext<'_>, fault: IncomingFault) + -> Result<(), Self::Error>; + + fn poll(&mut self, ctx: &mut LeafContext<'_>) -> Result<(), Self::Error>; +} +``` + +Leaf permissions: + +```rust +pub struct LeafPermissions { + pub send_calls: bool, + pub send_hook_data: bool, + pub manage_connections: bool, +} +``` + +Leaf actions: + +```rust +pub enum LeafAction { + SendCall(OutboundCall), + SendHookData(OutboundHookData), + FailHook { hook_id: u64, fault: ProtocolFault }, + Connection(ConnectionAction), +} + +pub enum ConnectionAction { + Register { + connection: ConnectionId, + direction: ConnectionDirection, + peer_path: Vec, + }, + Unregister { connection: ConnectionId }, +} +``` + +Rules: + +- A leaf may queue only actions allowed by its `LeafPermissions`. +- Runtime policy still validates every action. Permission is not authority. +- Connection actions request runtime changes. They do not mutate state directly. +- Leaf callbacks must be bounded and nonblocking. +- No nested leaf dispatch. Leaf actions are applied after the callback returns. + +## Required Runtime Semantics + +### Inbound Forwarding + +```text +parent frame for /agent/grand + -> NodeRuntime derives Ingress::Parent + -> EndpointState routes to child /agent/grand + -> RuntimeEffect::SendFrame { connection: grandchild, generation, frame } + -> Transport::send_frame(grandchild, frame) +``` + +### Local Call Delivery + +```text +parent frame for local endpoint + -> NodeRuntime derives ingress + -> EndpointState validates and returns Local(Call) + -> NodeRuntime dispatches to matching Leaf::on_call + -> leaf queues LeafAction values + -> runtime validates and applies actions +``` + +### Outbound Leaf Call + +```text +leaf queues LeafAction::SendCall + -> runtime validates permission and target + -> EndpointState builds/routes call + -> pending hook is reserved if needed + -> RuntimeEffect::SendFrame or RuntimeEffect::Local +``` + +### Disconnect + +```text +connection closes or unregisters + -> mark connection Draining/Closed and advance generation + -> remove matching route entries + -> remove pending hooks associated with peer/subtree + -> remove active hooks associated with peer/subtree + -> notify or close leaf sessions + -> drop queued SendFrame effects with stale generation +``` + +## Known Gaps In The Current Branch + +- `Leaf` is defined but not yet registered or dispatched by `NodeRuntime`. +- `LeafAction` values are queued by `LeafContext` but not yet applied by + `NodeRuntime`. +- Local outbound calls through the runtime are not implemented. +- Connection registration does not yet atomically update endpoint routes. +- Disconnect does not yet clean hooks, sessions, route state, and queued effects. +- `RouteDecision::Child(index)` still depends on index compatibility with the + existing `ProtocolEndpoint` route table. +- Child ingress still allocates because the existing `Ingress::Child` owns a + `Vec`. + +## Next Implementation Slice + +Implement one narrow end-to-end path: + +1. Add a leaf registry to `NodeRuntime`. +2. Dispatch `RuntimeEffect::Local(Call)` into `Leaf::on_call`. +3. Apply `LeafAction::SendHookData` through endpoint packet state. +4. Route the produced frame through `Transport`. +5. Add tests proving a local call reaches a leaf and the leaf reply is framed and + sent through a registered connection. + +That slice forces the real architecture to work without overbuilding the rest of +the migration. diff --git a/Cargo.lock b/Cargo.lock index 86042f1..8ca27a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1447,6 +1447,7 @@ dependencies = [ "unshell-leaves", "unshell-macros", "unshell-protocol", + "unshell-runtime", ] [[package]] @@ -1477,6 +1478,13 @@ dependencies = [ "unshell-macros", ] +[[package]] +name = "unshell-runtime" +version = "0.1.0" +dependencies = [ + "unshell-protocol", +] + [[package]] name = "ush-obfuscate" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 7313486..aa75033 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "base62", "unshell-macros", "unshell-protocol", + "unshell-runtime", "unshell-leaves", "treetest", ] @@ -31,6 +32,7 @@ portable-pty = "0.9.0" crossbeam-channel = "0.5.15" unshell = { path = "." } unshell-protocol = { path = "./unshell-protocol" } +unshell-runtime = { path = "./unshell-runtime" } unshell-leaves = { path = "./unshell-leaves" } unshell-macros = { path = "./unshell-macros" } @@ -63,6 +65,7 @@ chrono = { workspace = true, optional = true } static_init = { workspace = true } unshell-macros = { workspace = true } unshell-protocol = { workspace = true } +unshell-runtime = { workspace = true } unshell-leaves = { workspace = true } [dev-dependencies] diff --git a/src/lib.rs b/src/lib.rs index f964ab6..8554e0e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -26,6 +26,9 @@ pub use unshell_protocol as protocol; /// Re-export the leaf library crate behind the historical `unshell::leaves` path pub use unshell_leaves as leaves; +/// Re-export the runtime crate behind the `unshell::runtime` path. +pub use unshell_runtime as runtime; + pub use unshell_macros::{Procedure, leaf, procedures}; /// Creates a root-assumed endpoint from one local identifier plus any number of leaf hosts. diff --git a/unshell-runtime/Cargo.toml b/unshell-runtime/Cargo.toml new file mode 100644 index 0000000..2c98898 --- /dev/null +++ b/unshell-runtime/Cargo.toml @@ -0,0 +1,21 @@ +[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 new file mode 100644 index 0000000..b4b6fe2 --- /dev/null +++ b/unshell-runtime/src/connections.rs @@ -0,0 +1,291 @@ +//! 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 + }) + }) + } +} + +/// 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 new file mode 100644 index 0000000..c51b168 --- /dev/null +++ b/unshell-runtime/src/context.rs @@ -0,0 +1,205 @@ +//! 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 new file mode 100644 index 0000000..cd39455 --- /dev/null +++ b/unshell-runtime/src/effects.rs @@ -0,0 +1,56 @@ +//! 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(..) + } +} diff --git a/unshell-runtime/src/leaf.rs b/unshell-runtime/src/leaf.rs new file mode 100644 index 0000000..c637b73 --- /dev/null +++ b/unshell-runtime/src/leaf.rs @@ -0,0 +1,110 @@ +//! Leaf-facing runtime types. + +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(()) + } +} diff --git a/unshell-runtime/src/lib.rs b/unshell-runtime/src/lib.rs new file mode 100644 index 0000000..94e6893 --- /dev/null +++ b/unshell-runtime/src/lib.rs @@ -0,0 +1,122 @@ +//! # 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}; +pub use node::{ + EndpointState, 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 new file mode 100644 index 0000000..4bfd5bf --- /dev/null +++ b/unshell-runtime/src/node/mod.rs @@ -0,0 +1,73 @@ +//! 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::{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 new file mode 100644 index 0000000..dcaacc6 --- /dev/null +++ b/unshell-runtime/src/node/packet.rs @@ -0,0 +1,86 @@ +//! 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::{FrameBytes, tree::Endpoint as ProtocolEndpointTrait}; + +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(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 + } + + /// 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 new file mode 100644 index 0000000..636fe94 --- /dev/null +++ b/unshell-runtime/src/node/runtime.rs @@ -0,0 +1,439 @@ +//! 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 dispatch and leaf-action application are +//! intentionally not implemented in this slice. + +use crate::connections::{ConnectionDirection, ConnectionId, Connections, RegisteredConnection}; +use crate::effects::{EffectQueue, RuntimeEffect}; +use crate::transport::Transport; +use unshell_protocol::FrameBytes; +use unshell_protocol::tree::{EndpointError, EndpointOutcome, Ingress, 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), +} + +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}"), + } + } +} + +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, +} + +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(), + } + } + + /// 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 + } + + /// Returns currently queued effects. + #[must_use] + pub fn effects(&self) -> &[RuntimeEffect] { + self.effects.entries() + } +} + +impl NodeRuntime +where + T: Transport, +{ + /// Processes one nonblocking runtime step. + pub fn tick(&mut self, budget: TickBudget) -> Result> { + let mut outcome = TickOutcome::default(); + + 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() + .filter(|effect| matches!(effect, RuntimeEffect::Dropped)) + .count(); + outcome.local_events += self + .effects + .entries() + .iter() + .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) + } + + 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) = 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() + .get(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, + } + .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; + for effect in self.effects.drain() { + match effect { + RuntimeEffect::SendFrame { + connection, + generation, + frame, + } if self + .connections + .registered(connection) + .is_some_and(|registered| registered.generation() == generation) => + { + self.transport + .send_frame(connection, frame) + .map_err(NodeRuntimeError::Transport)?; + 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()), + } +} + +#[cfg(test)] +mod tests { + use crate::alloc::string::String; + use crate::alloc::vec; + use crate::alloc::vec::Vec; + use crate::connections::{ + Connection, ConnectionDirection, ConnectionGeneration, ConnectionId, Connections, + }; + use crate::effects::RuntimeEffect; + use crate::transport::Transport; + use unshell_protocol::tree::{ChildRoute, ProtocolEndpoint}; + use unshell_protocol::{CallMessage, FrameBytes, PacketHeader, PacketType, encode_packet}; + + use super::{EndpointState, NodeRuntime, TickBudget}; + + #[derive(Debug, Default)] + struct RecordingTransport { + inbound: Option<(ConnectionId, FrameBytes)>, + sent: Vec<(ConnectionId, FrameBytes)>, + } + + impl Transport for RecordingTransport { + type Error = core::convert::Infallible; + + fn poll_recv(&mut self) -> Result, Self::Error> { + Ok(self.inbound.take()) + } + + fn send_frame( + &mut self, + connection: ConnectionId, + frame: FrameBytes, + ) -> Result<(), Self::Error> { + self.sent.push((connection, frame)); + Ok(()) + } + } + + #[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(), + }; + 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 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(_))); + } +} diff --git a/unshell-runtime/src/node/state.rs b/unshell-runtime/src/node/state.rs new file mode 100644 index 0000000..d15102e --- /dev/null +++ b/unshell-runtime/src/node/state.rs @@ -0,0 +1,15 @@ +//! 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 new file mode 100644 index 0000000..750f148 --- /dev/null +++ b/unshell-runtime/src/transport.rs @@ -0,0 +1,31 @@ +//! 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(()) + } +} diff --git a/ush-obfuscate/src/lib.rs b/ush-obfuscate/src/lib.rs index cb37a22..9ce4a2a 100644 --- a/ush-obfuscate/src/lib.rs +++ b/ush-obfuscate/src/lib.rs @@ -1,4 +1,3 @@ -#![feature(proc_macro_quote)] #![feature(proc_macro_span)] #![allow(dead_code, unused_macros, unused_imports)] From 0f54b53a79cdb3595daee5a45cf8cc754f2cecac Mon Sep 17 00:00:00 2001 From: Michael Mikovsky <77305074+Astatin3@users.noreply.github.com> Date: Sat, 9 May 2026 12:47:51 -0600 Subject: [PATCH 04/31] Fix runtime child route forwarding --- API.md | 2 - unshell-runtime/src/node/runtime.rs | 71 ++++++++++++++++++++++++++++- 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/API.md b/API.md index 9fab9d4..93dde60 100644 --- a/API.md +++ b/API.md @@ -285,8 +285,6 @@ connection closes or unregisters - Local outbound calls through the runtime are not implemented. - Connection registration does not yet atomically update endpoint routes. - Disconnect does not yet clean hooks, sessions, route state, and queued effects. -- `RouteDecision::Child(index)` still depends on index compatibility with the - existing `ProtocolEndpoint` route table. - Child ingress still allocates because the existing `Ingress::Child` owns a `Vec`. diff --git a/unshell-runtime/src/node/runtime.rs b/unshell-runtime/src/node/runtime.rs index 636fe94..36b929b 100644 --- a/unshell-runtime/src/node/runtime.rs +++ b/unshell-runtime/src/node/runtime.rs @@ -236,7 +236,10 @@ where .endpoint .endpoint() .child_routes() - .get(index) + .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) @@ -393,6 +396,72 @@ mod tests { assert_eq!(runtime.transport().sent[0].0, child); } + #[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(), + }; + 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); From 8bf660546a242f4ebb61918db3ebf65bf769b47d Mon Sep 17 00:00:00 2001 From: Michael Mikovsky <77305074+Astatin3@users.noreply.github.com> Date: Sat, 9 May 2026 12:51:03 -0600 Subject: [PATCH 05/31] Fix runtime tick outcome counting --- unshell-runtime/src/node/runtime.rs | 97 +++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/unshell-runtime/src/node/runtime.rs b/unshell-runtime/src/node/runtime.rs index 36b929b..d1f8d13 100644 --- a/unshell-runtime/src/node/runtime.rs +++ b/unshell-runtime/src/node/runtime.rs @@ -150,6 +150,7 @@ where /// 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 @@ -167,12 +168,14 @@ where .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(); @@ -505,4 +508,98 @@ mod tests { .expect("frame processes"); assert!(matches!(runtime.effects()[0], RuntimeEffect::Local(_))); } + + #[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(), + }; + 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 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(), + }; + 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)); + } } From 4e3f975b5485bbaf8988492d8b5ffcd9494f8b62 Mon Sep 17 00:00:00 2001 From: Michael Mikovsky <77305074+Astatin3@users.noreply.github.com> Date: Sat, 9 May 2026 12:54:11 -0600 Subject: [PATCH 06/31] Preserve runtime effects on send failure --- API.md | 2 +- unshell-runtime/src/node/runtime.rs | 110 ++++++++++++++++++++++++++-- unshell-runtime/src/transport.rs | 2 +- 3 files changed, 104 insertions(+), 10 deletions(-) diff --git a/API.md b/API.md index 93dde60..e6af581 100644 --- a/API.md +++ b/API.md @@ -58,7 +58,7 @@ pub trait Transport { fn send_frame( &mut self, connection: ConnectionId, - frame: FrameBytes, + frame: &FrameBytes, ) -> Result<(), Self::Error>; fn flush(&mut self) -> Result<(), Self::Error> { diff --git a/unshell-runtime/src/node/runtime.rs b/unshell-runtime/src/node/runtime.rs index d1f8d13..585d708 100644 --- a/unshell-runtime/src/node/runtime.rs +++ b/unshell-runtime/src/node/runtime.rs @@ -268,7 +268,9 @@ where fn flush_outbound(&mut self) -> Result> { let mut retained = EffectQueue::new(); let mut sent = 0usize; - for effect in self.effects.drain() { + 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, @@ -279,9 +281,18 @@ where .registered(connection) .is_some_and(|registered| registered.generation() == generation) => { - self.transport - .send_frame(connection, frame) - .map_err(NodeRuntimeError::Transport)?; + 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 { .. } => {} @@ -316,16 +327,20 @@ mod tests { use unshell_protocol::tree::{ChildRoute, ProtocolEndpoint}; use unshell_protocol::{CallMessage, FrameBytes, PacketHeader, PacketType, encode_packet}; - use super::{EndpointState, NodeRuntime, TickBudget}; + 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 = core::convert::Infallible; + type Error = SendError; fn poll_recv(&mut self) -> Result, Self::Error> { Ok(self.inbound.take()) @@ -334,9 +349,12 @@ mod tests { fn send_frame( &mut self, connection: ConnectionId, - frame: FrameBytes, + frame: &FrameBytes, ) -> Result<(), Self::Error> { - self.sent.push((connection, frame)); + if self.fail_send { + return Err(SendError); + } + self.sent.push((connection, frame.clone())); Ok(()) } } @@ -388,6 +406,7 @@ mod tests { let transport = RecordingTransport { inbound: Some((parent, frame)), sent: Vec::new(), + fail_send: false, }; let mut runtime = NodeRuntime::new(EndpointState::new(endpoint), connections, transport); @@ -456,6 +475,7 @@ mod tests { let transport = RecordingTransport { inbound: Some((parent, frame)), sent: Vec::new(), + fail_send: false, }; let mut runtime = NodeRuntime::new(EndpointState::new(endpoint), connections, transport); @@ -509,6 +529,78 @@ mod tests { assert!(matches!(runtime.effects()[0], RuntimeEffect::Local(_))); } + #[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); @@ -544,6 +636,7 @@ mod tests { let transport = RecordingTransport { inbound: Some((parent, frame)), sent: Vec::new(), + fail_send: false, }; let mut runtime = NodeRuntime::new(EndpointState::new(endpoint), connections, transport); @@ -591,6 +684,7 @@ mod tests { let transport = RecordingTransport { inbound: Some((child, frame)), sent: Vec::new(), + fail_send: false, }; let mut runtime = NodeRuntime::new(EndpointState::new(endpoint), connections, transport); diff --git a/unshell-runtime/src/transport.rs b/unshell-runtime/src/transport.rs index 750f148..5536664 100644 --- a/unshell-runtime/src/transport.rs +++ b/unshell-runtime/src/transport.rs @@ -21,7 +21,7 @@ pub trait Transport { fn send_frame( &mut self, connection: ConnectionId, - frame: FrameBytes, + frame: &FrameBytes, ) -> Result<(), Self::Error>; /// Flushes buffered outbound transport data, if the transport has any. From fcde5d66d27f20e8b23131799f410c7981a046fa Mon Sep 17 00:00:00 2001 From: Michael Mikovsky <77305074+Astatin3@users.noreply.github.com> Date: Sat, 9 May 2026 12:59:07 -0600 Subject: [PATCH 07/31] Add local runtime effect draining --- API.md | 3 + unshell-runtime/src/effects.rs | 59 +++++++++++++++ unshell-runtime/src/node/runtime.rs | 111 ++++++++++++++++++++++++++++ 3 files changed, 173 insertions(+) diff --git a/API.md b/API.md index e6af581..a30a8d1 100644 --- a/API.md +++ b/API.md @@ -144,6 +144,8 @@ Primary operations: impl NodeRuntime { pub fn tick(&mut self, budget: TickBudget) -> Result>; + pub fn drain_local_effects(&mut self) -> impl Iterator; + pub fn receive_frame( &mut self, connection: ConnectionId, @@ -168,6 +170,7 @@ Rules: - Callers never pass `Ingress` into `NodeRuntime`. - Runtime counts per-tick progress, not retained backlog. - Local events should be dispatched to leaves, not retained forever. +- Until leaf dispatch exists, callers may drain local/dropped effects; outbound sends remain runtime-owned. - Send failures must not drop unrelated queued effects. ## Leaf API diff --git a/unshell-runtime/src/effects.rs b/unshell-runtime/src/effects.rs index cd39455..cf1d5e0 100644 --- a/unshell-runtime/src/effects.rs +++ b/unshell-runtime/src/effects.rs @@ -53,4 +53,63 @@ impl EffectQueue { 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/node/runtime.rs b/unshell-runtime/src/node/runtime.rs index 585d708..903611f 100644 --- a/unshell-runtime/src/node/runtime.rs +++ b/unshell-runtime/src/node/runtime.rs @@ -141,6 +141,13 @@ impl NodeRuntime { 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() + } } impl NodeRuntime @@ -649,6 +656,58 @@ mod tests { 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); @@ -696,4 +755,56 @@ mod tests { 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()); + } } From 99b54b0bdfcc415e8a826f56e259ca65bf09e8bc Mon Sep 17 00:00:00 2001 From: Michael Mikovsky <77305074+Astatin3@users.noreply.github.com> Date: Sat, 9 May 2026 13:06:43 -0600 Subject: [PATCH 08/31] Add runtime connection registration helpers --- API.md | 21 +- unshell-runtime/src/connections.rs | 44 +++ unshell-runtime/src/node/runtime.rs | 397 +++++++++++++++++++++++++++- 3 files changed, 459 insertions(+), 3 deletions(-) diff --git a/API.md b/API.md index a30a8d1..e54ae89 100644 --- a/API.md +++ b/API.md @@ -152,6 +152,22 @@ impl NodeRuntime { frame: FrameBytes, ) -> Result<(), NodeRuntimeError>; } + +impl NodeRuntime { + pub fn register_parent_connection( + &mut self, + connection: ConnectionId, + parent_path: Vec, + generation: ConnectionGeneration, + ) -> Result<(), EndpointError>; + + pub fn register_child_connection( + &mut self, + connection: ConnectionId, + child_path: Vec, + generation: ConnectionGeneration, + ) -> Result<(), EndpointError>; +} ``` Runtime flow: @@ -168,6 +184,10 @@ transport poll -> (ConnectionId, FrameBytes) Rules: - Callers never pass `Ingress` into `NodeRuntime`. +- Callers should register parent and child connections through `NodeRuntime` so + route topology and connection metadata are mutated together. Directly changing + only `Connections` or only `EndpointState` can leave a connected peer + unroutable or a route without a registered connection. - Runtime counts per-tick progress, not retained backlog. - Local events should be dispatched to leaves, not retained forever. - Until leaf dispatch exists, callers may drain local/dropped effects; outbound sends remain runtime-owned. @@ -286,7 +306,6 @@ connection closes or unregisters - `LeafAction` values are queued by `LeafContext` but not yet applied by `NodeRuntime`. - Local outbound calls through the runtime are not implemented. -- Connection registration does not yet atomically update endpoint routes. - Disconnect does not yet clean hooks, sessions, route state, and queued effects. - Child ingress still allocates because the existing `Ingress::Child` owns a `Vec`. diff --git a/unshell-runtime/src/connections.rs b/unshell-runtime/src/connections.rs index b4b6fe2..557ffca 100644 --- a/unshell-runtime/src/connections.rs +++ b/unshell-runtime/src/connections.rs @@ -276,6 +276,50 @@ impl Connections { }) }) } + + /// 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. diff --git a/unshell-runtime/src/node/runtime.rs b/unshell-runtime/src/node/runtime.rs index 903611f..7c18d2a 100644 --- a/unshell-runtime/src/node/runtime.rs +++ b/unshell-runtime/src/node/runtime.rs @@ -5,10 +5,15 @@ //! queues concrete runtime effects. Leaf dispatch and leaf-action application are //! intentionally not implemented in this slice. -use crate::connections::{ConnectionDirection, ConnectionId, Connections, RegisteredConnection}; +use crate::alloc::{string::String, vec::Vec}; +use crate::connections::{ + Connection, ConnectionDirection, ConnectionGeneration, ConnectionId, ConnectionState, + Connections, RegisteredConnection, +}; use crate::effects::{EffectQueue, RuntimeEffect}; use crate::transport::Transport; use unshell_protocol::FrameBytes; +use unshell_protocol::tree::ChildRoute; use unshell_protocol::tree::{EndpointError, EndpointOutcome, Ingress, RouteDecision}; use super::{EndpointState, PacketProcessor}; @@ -136,6 +141,104 @@ impl NodeRuntime { &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] { @@ -331,7 +434,7 @@ mod tests { }; use crate::effects::RuntimeEffect; use crate::transport::Transport; - use unshell_protocol::tree::{ChildRoute, ProtocolEndpoint}; + use unshell_protocol::tree::{ChildRoute, EndpointError, ProtocolEndpoint}; use unshell_protocol::{CallMessage, FrameBytes, PacketHeader, PacketType, encode_packet}; use super::{EndpointState, NodeRuntime, NodeRuntimeError, TickBudget}; @@ -425,6 +528,296 @@ mod tests { 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); From 97f3e305bb8101fa497078467e4c5f38adba4764 Mon Sep 17 00:00:00 2001 From: Michael Mikovsky <77305074+Astatin3@users.noreply.github.com> Date: Sat, 9 May 2026 13:14:34 -0600 Subject: [PATCH 09/31] Dispatch local runtime effects to leaves --- API.md | 42 ++- unshell-runtime/src/leaf.rs | 67 ++++ unshell-runtime/src/lib.rs | 5 +- unshell-runtime/src/node/mod.rs | 2 +- unshell-runtime/src/node/runtime.rs | 505 +++++++++++++++++++++++++++- 5 files changed, 601 insertions(+), 20 deletions(-) diff --git a/API.md b/API.md index e54ae89..f20b8cf 100644 --- a/API.md +++ b/API.md @@ -118,11 +118,13 @@ Rules: effects. ```rust -pub struct NodeRuntime { +pub struct NodeRuntime { endpoint: EndpointState, connections: Connections, transport: T, effects: EffectQueue, + leaves: Vec>, + leaf_actions: Vec<(LeafId, LeafAction)>, } pub struct TickBudget { @@ -144,8 +146,6 @@ Primary operations: impl NodeRuntime { pub fn tick(&mut self, budget: TickBudget) -> Result>; - pub fn drain_local_effects(&mut self) -> impl Iterator; - pub fn receive_frame( &mut self, connection: ConnectionId, @@ -153,6 +153,24 @@ impl NodeRuntime { ) -> Result<(), NodeRuntimeError>; } +impl NodeRuntime { + pub fn new_with_leaf_error( + endpoint: EndpointState, + connections: Connections, + transport: T, + ) -> Self; + + pub fn drain_local_effects(&mut self) -> impl Iterator; + + pub fn register_leaf(&mut self, leaf: L) -> LeafId + where + L: Leaf + 'static; + + pub fn dispatch_local_effects(&mut self) -> Result>; + + pub fn drain_leaf_actions(&mut self) -> impl Iterator; +} + impl NodeRuntime { pub fn register_parent_connection( &mut self, @@ -190,7 +208,12 @@ Rules: unroutable or a route without a registered connection. - Runtime counts per-tick progress, not retained backlog. - Local events should be dispatched to leaves, not retained forever. -- Until leaf dispatch exists, callers may drain local/dropped effects; outbound sends remain runtime-owned. +- `dispatch_local_effects` attempts queued `RuntimeEffect::Local` values in FIFO + order, calls the matching leaf callback, records queued `LeafAction` values for + later reducer work, and leaves unmatched locals queued for a future attempt. +- Dispatch does not consume `SendFrame` or `Dropped` effects. Outbound sends remain + runtime-owned, and drop notifications remain available to callers that drain + local/drop effects. - Send failures must not drop unrelated queued effects. ## Leaf API @@ -275,7 +298,7 @@ parent frame for local endpoint -> EndpointState validates and returns Local(Call) -> NodeRuntime dispatches to matching Leaf::on_call -> leaf queues LeafAction values - -> runtime validates and applies actions + -> runtime retains actions for a later reducer pass ``` ### Outbound Leaf Call @@ -302,7 +325,6 @@ connection closes or unregisters ## Known Gaps In The Current Branch -- `Leaf` is defined but not yet registered or dispatched by `NodeRuntime`. - `LeafAction` values are queued by `LeafContext` but not yet applied by `NodeRuntime`. - Local outbound calls through the runtime are not implemented. @@ -314,11 +336,9 @@ connection closes or unregisters Implement one narrow end-to-end path: -1. Add a leaf registry to `NodeRuntime`. -2. Dispatch `RuntimeEffect::Local(Call)` into `Leaf::on_call`. -3. Apply `LeafAction::SendHookData` through endpoint packet state. -4. Route the produced frame through `Transport`. -5. Add tests proving a local call reaches a leaf and the leaf reply is framed and +1. Apply queued `LeafAction::SendHookData` through endpoint packet state. +2. Route the produced frame through `Transport`. +3. Add tests proving a leaf reply is framed and sent through a registered connection. That slice forces the real architecture to work without overbuilding the rest of diff --git a/unshell-runtime/src/leaf.rs b/unshell-runtime/src/leaf.rs index c637b73..49f6dfa 100644 --- a/unshell-runtime/src/leaf.rs +++ b/unshell-runtime/src/leaf.rs @@ -1,5 +1,6 @@ //! Leaf-facing runtime types. +use crate::alloc::boxed::Box; use crate::alloc::string::String; use crate::alloc::vec::Vec; use crate::context::LeafContext; @@ -108,3 +109,69 @@ pub trait Leaf { 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 index 94e6893..9c4e381 100644 --- a/unshell-runtime/src/lib.rs +++ b/unshell-runtime/src/lib.rs @@ -24,9 +24,10 @@ pub use context::{ RuntimeCapability, }; pub use effects::{EffectQueue, RuntimeEffect}; -pub use leaf::{Leaf, LeafCapabilities, LeafId, LeafPermissions}; +pub use leaf::{Leaf, LeafCapabilities, LeafId, LeafPermissions, RegisteredLeaf}; pub use node::{ - EndpointState, Node, NodeId, NodeRuntime, NodeRuntimeError, NodeState, TickBudget, TickOutcome, + EndpointState, LeafDispatchError, Node, NodeId, NodeRuntime, NodeRuntimeError, NodeState, + TickBudget, TickOutcome, }; pub use transport::Transport; diff --git a/unshell-runtime/src/node/mod.rs b/unshell-runtime/src/node/mod.rs index 4bfd5bf..b070851 100644 --- a/unshell-runtime/src/node/mod.rs +++ b/unshell-runtime/src/node/mod.rs @@ -8,7 +8,7 @@ pub mod runtime; pub mod state; pub use packet::{EndpointState, PacketProcessor}; -pub use runtime::{NodeRuntime, NodeRuntimeError, TickBudget, TickOutcome}; +pub use runtime::{LeafDispatchError, NodeRuntime, NodeRuntimeError, TickBudget, TickOutcome}; pub use state::NodeState; use crate::alloc::string::String; diff --git a/unshell-runtime/src/node/runtime.rs b/unshell-runtime/src/node/runtime.rs index 7c18d2a..86b2d2c 100644 --- a/unshell-runtime/src/node/runtime.rs +++ b/unshell-runtime/src/node/runtime.rs @@ -2,19 +2,24 @@ //! //! This first slice owns transport and connection metadata, derives ingress from //! registered connections, delegates packet invariants to [`EndpointState`], and -//! queues concrete runtime effects. Leaf dispatch and leaf-action application are -//! intentionally not implemented in this slice. +//! queues concrete runtime effects. Leaf action application is intentionally not +//! implemented in this slice. 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::{EndpointError, EndpointOutcome, Ingress, RouteDecision}; +use unshell_protocol::tree::{ + Endpoint, EndpointError, EndpointOutcome, IncomingCall, IncomingData, IncomingFault, Ingress, + LocalEvent, RouteDecision, +}; use super::{EndpointState, PacketProcessor}; @@ -62,6 +67,34 @@ pub enum NodeRuntimeError { Transport(TransportError), } +/// 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, @@ -85,11 +118,13 @@ impl core::error::Error for NodeRuntimeError whe /// Runtime owner for one endpoint, transport, and connection table. #[derive(Debug)] -pub struct NodeRuntime { +pub struct NodeRuntime { endpoint: EndpointState, connections: Connections, transport: T, effects: EffectQueue, + leaves: Vec>, + leaf_actions: Vec<(LeafId, LeafAction)>, } impl NodeRuntime { @@ -102,6 +137,27 @@ impl NodeRuntime { 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(), } } @@ -251,9 +307,170 @@ impl NodeRuntime { 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. + /// + /// These actions are intentionally only retained here; reducing them into + /// endpoint packets or connection changes belongs to a later runtime slice. + #[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 +impl NodeRuntime where T: Transport, { @@ -424,17 +641,33 @@ fn ingress_for(registered: &RegisteredConnection) -> Ingress { } } +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(), + } +} + #[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, Connections, }; + use crate::context::{LeafAction, OutboundHookData}; use crate::effects::RuntimeEffect; + use crate::leaf::{Leaf, LeafCapabilities, LeafPermissions}; use crate::transport::Transport; - use unshell_protocol::tree::{ChildRoute, EndpointError, ProtocolEndpoint}; + use unshell_protocol::tree::{ + ChildRoute, EndpointError, IncomingCall, LeafSpec, LocalEvent, ProtocolEndpoint, + }; use unshell_protocol::{CallMessage, FrameBytes, PacketHeader, PacketType, encode_packet}; use super::{EndpointState, NodeRuntime, NodeRuntimeError, TickBudget}; @@ -469,6 +702,81 @@ mod tests { } } + 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); @@ -929,6 +1237,191 @@ mod tests { 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 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); From a68e86ef6dd0d5a793f0d1c035368afc0332d79b Mon Sep 17 00:00:00 2001 From: Michael Mikovsky <77305074+Astatin3@users.noreply.github.com> Date: Sat, 9 May 2026 13:25:16 -0600 Subject: [PATCH 10/31] Reduce leaf hook data actions --- API.md | 25 +- .../src/protocol/tree/endpoint/hooks.rs | 11 +- unshell-runtime/src/node/packet.rs | 19 + unshell-runtime/src/node/runtime.rs | 381 ++++++++++++++++-- 4 files changed, 399 insertions(+), 37 deletions(-) diff --git a/API.md b/API.md index f20b8cf..cac2536 100644 --- a/API.md +++ b/API.md @@ -168,6 +168,10 @@ impl NodeRuntime { pub fn dispatch_local_effects(&mut self) -> Result>; + pub fn reduce_leaf_actions(&mut self) -> Result> + where + T: Transport; + pub fn drain_leaf_actions(&mut self) -> impl Iterator; } @@ -325,21 +329,24 @@ connection closes or unregisters ## Known Gaps In The Current Branch -- `LeafAction` values are queued by `LeafContext` but not yet applied by - `NodeRuntime`. +- `LeafAction::SendHookData` is reduced by `NodeRuntime`; other action variants + are still unsupported and must remain queued when encountered. - Local outbound calls through the runtime are not implemented. +- Hook fault actions through the runtime are not implemented. +- Connection actions through the runtime are not implemented. - Disconnect does not yet clean hooks, sessions, route state, and queued effects. - Child ingress still allocates because the existing `Ingress::Child` owns a `Vec`. ## Next Implementation Slice -Implement one narrow end-to-end path: +Implement the next narrow leaf-action path: -1. Apply queued `LeafAction::SendHookData` through endpoint packet state. -2. Route the produced frame through `Transport`. -3. Add tests proving a leaf reply is framed and - sent through a registered connection. +1. Apply queued `LeafAction::SendCall` through endpoint packet state. +2. Preserve hook reservation and routing failure semantics without dropping + unprocessed actions. +3. Add tests proving a local leaf can initiate an outbound call and receive the + response through the existing dispatch path. -That slice forces the real architecture to work without overbuilding the rest of -the migration. +That slice should continue the one-variant-at-a-time reducer approach without +implementing hook faults or connection actions early. diff --git a/unshell-protocol/src/protocol/tree/endpoint/hooks.rs b/unshell-protocol/src/protocol/tree/endpoint/hooks.rs index b226428..81e9512 100644 --- a/unshell-protocol/src/protocol/tree/endpoint/hooks.rs +++ b/unshell-protocol/src/protocol/tree/endpoint/hooks.rs @@ -133,10 +133,19 @@ impl ProtocolEndpoint { Ok(EndpointOutcome::Dropped) } - pub(crate) fn decide_route(&self, dst_path: &[String]) -> RouteDecision { + /// 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. /// diff --git a/unshell-runtime/src/node/packet.rs b/unshell-runtime/src/node/packet.rs index dcaacc6..a076fe9 100644 --- a/unshell-runtime/src/node/packet.rs +++ b/unshell-runtime/src/node/packet.rs @@ -68,6 +68,25 @@ impl EndpointState { &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) + } + /// Consumes the wrapper and returns the underlying protocol endpoint. #[must_use] pub fn into_endpoint(self) -> ProtocolEndpoint { diff --git a/unshell-runtime/src/node/runtime.rs b/unshell-runtime/src/node/runtime.rs index 86b2d2c..6c98c85 100644 --- a/unshell-runtime/src/node/runtime.rs +++ b/unshell-runtime/src/node/runtime.rs @@ -2,8 +2,8 @@ //! //! 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 application is intentionally not -//! implemented in this slice. +//! queues concrete runtime effects. Leaf action reduction is intentionally +//! narrow: this slice only turns hook-data replies into endpoint outcomes. use crate::alloc::{string::String, vec::Vec}; use crate::connections::{ @@ -65,6 +65,13 @@ pub enum NodeRuntimeError { 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. @@ -107,6 +114,13 @@ where 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() + ) + } } } } @@ -343,9 +357,6 @@ impl NodeRuntime { } /// Returns leaf actions queued by dispatched callbacks. - /// - /// These actions are intentionally only retained here; reducing them into - /// endpoint packets or connection changes belongs to a later runtime slice. #[must_use] pub fn leaf_actions(&self) -> &[(LeafId, LeafAction)] { &self.leaf_actions @@ -530,29 +541,76 @@ where self.apply_outcome(outcome) } - 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(()) + /// Reduces queued leaf actions through endpoint packet state. + /// + /// Only [`LeafAction::SendHookData`] is 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::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; + } + 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 queue_forward( - &mut self, + fn connection_for_route( + &self, route: RouteDecision, - frame: FrameBytes, - ) -> Result<(), NodeRuntimeError> { - let (connection, generation) = match route { + ) -> Option<(ConnectionId, ConnectionGeneration)> { + match route { RouteDecision::Parent => self .connections .registered_by_direction(ConnectionDirection::Parent) @@ -582,7 +640,33 @@ where }), RouteDecision::Local | RouteDecision::Drop => None, } - .ok_or(NodeRuntimeError::MissingRouteConnection)?; + } + + 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, @@ -649,6 +733,19 @@ fn local_event_leaf_name(event: &LocalEvent) -> Option<&str> { } } +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; @@ -659,16 +756,20 @@ mod tests { use crate::alloc::vec; use crate::alloc::vec::Vec; use crate::connections::{ - Connection, ConnectionDirection, ConnectionGeneration, ConnectionId, Connections, + Connection, ConnectionDirection, ConnectionGeneration, ConnectionId, ConnectionState, + Connections, }; - use crate::context::{LeafAction, OutboundHookData}; + 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, }; - use unshell_protocol::{CallMessage, FrameBytes, PacketHeader, PacketType, encode_packet}; + use unshell_protocol::{ + CallMessage, FrameBytes, HookTarget, PacketHeader, PacketType, ProtocolFault, decode_frame, + encode_packet, + }; use super::{EndpointState, NodeRuntime, NodeRuntimeError, TickBudget}; @@ -1304,6 +1405,232 @@ mod tests { 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 unsupported_leaf_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::SendCall(OutboundCall { + dst_path: vec![], + dst_leaf: None, + procedure_id: String::from("org.example.v1.echo.invoke"), + payload: vec![], + expects_response: false, + }), + )); + 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 == "SendCall" + )); + assert_eq!(runtime.leaf_actions().len(), 2); + assert!(matches!( + runtime.leaf_actions()[0].1, + LeafAction::SendCall(_) + )); + assert!(matches!( + runtime.leaf_actions()[1].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 retry_error = runtime + .reduce_leaf_actions() + .expect_err("later unsupported action is still reported"); + + assert!(matches!( + retry_error, + NodeRuntimeError::UnsupportedLeafAction { + action: "FailHook", + .. + } + )); + assert_eq!(runtime.leaf_actions().len(), 1); + assert!(matches!( + runtime.leaf_actions()[0].1, + LeafAction::FailHook { .. } + )); + assert!(matches!( + runtime.effects()[0], + RuntimeEffect::SendFrame { connection, .. } if connection == parent + )); + } + #[test] fn unmatched_local_event_remains_queued() { let mut runtime = NodeRuntime::new( From 71d1aee23567663be612a0c98e879213f4706e83 Mon Sep 17 00:00:00 2001 From: Michael Mikovsky <77305074+Astatin3@users.noreply.github.com> Date: Sat, 9 May 2026 13:40:21 -0600 Subject: [PATCH 11/31] Reduce leaf send call actions --- API.md | 18 +- .../src/protocol/tree/endpoint/core.rs | 2 +- unshell-protocol/src/protocol/tree/hook.rs | 2 +- unshell-runtime/src/node/packet.rs | 58 ++- unshell-runtime/src/node/runtime.rs | 356 +++++++++++++++++- 5 files changed, 410 insertions(+), 26 deletions(-) diff --git a/API.md b/API.md index cac2536..65d4814 100644 --- a/API.md +++ b/API.md @@ -329,9 +329,9 @@ connection closes or unregisters ## Known Gaps In The Current Branch -- `LeafAction::SendHookData` is reduced by `NodeRuntime`; other action variants - are still unsupported and must remain queued when encountered. -- Local outbound calls through the runtime are not implemented. +- `LeafAction::SendCall` and `LeafAction::SendHookData` are reduced by + `NodeRuntime`; hook fault and connection action variants are still unsupported + and must remain queued when encountered. - Hook fault actions through the runtime are not implemented. - Connection actions through the runtime are not implemented. - Disconnect does not yet clean hooks, sessions, route state, and queued effects. @@ -342,11 +342,11 @@ connection closes or unregisters Implement the next narrow leaf-action path: -1. Apply queued `LeafAction::SendCall` through endpoint packet state. -2. Preserve hook reservation and routing failure semantics without dropping - unprocessed actions. -3. Add tests proving a local leaf can initiate an outbound call and receive the - response through the existing dispatch path. +1. Apply queued `LeafAction::FailHook` through endpoint packet state. +2. Preserve pending/active hook cleanup semantics without dropping unprocessed + actions. +3. Keep connection registration actions queued until runtime-owned disconnect + cleanup can update connections, routes, hooks, and queued effects atomically. That slice should continue the one-variant-at-a-time reducer approach without -implementing hook faults or connection actions early. +implementing connection actions early. diff --git a/unshell-protocol/src/protocol/tree/endpoint/core.rs b/unshell-protocol/src/protocol/tree/endpoint/core.rs index d858f04..cacf552 100644 --- a/unshell-protocol/src/protocol/tree/endpoint/core.rs +++ b/unshell-protocol/src/protocol/tree/endpoint/core.rs @@ -311,7 +311,7 @@ pub trait Endpoint { /// let endpoint = ProtocolEndpoint::new(vec!["worker".into()], Some(Vec::new()), Vec::new(), Vec::new()); /// let _ = endpoint; /// ``` -#[derive(Debug, Default)] +#[derive(Debug, Clone, Default)] pub struct ProtocolEndpoint { pub(crate) local_id: Option, pub(crate) path: Vec, diff --git a/unshell-protocol/src/protocol/tree/hook.rs b/unshell-protocol/src/protocol/tree/hook.rs index 368b4b4..7b7b5f2 100644 --- a/unshell-protocol/src/protocol/tree/hook.rs +++ b/unshell-protocol/src/protocol/tree/hook.rs @@ -130,7 +130,7 @@ pub struct HookConflict; /// }).unwrap(); /// assert_eq!(hooks.pending_len(), 1); /// ``` -#[derive(Debug, Default)] +#[derive(Debug, Clone, Default)] pub struct HookTable { pending: BTreeMap, active: BTreeMap, diff --git a/unshell-runtime/src/node/packet.rs b/unshell-runtime/src/node/packet.rs index a076fe9..b1d3eaa 100644 --- a/unshell-runtime/src/node/packet.rs +++ b/unshell-runtime/src/node/packet.rs @@ -5,7 +5,10 @@ //! 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::{FrameBytes, tree::Endpoint as ProtocolEndpointTrait}; +use unshell_protocol::{ + CallMessage, FrameBytes, PacketHeader, PacketType, tree::Endpoint as ProtocolEndpointTrait, + validate_call, validate_header, validate_procedure_id, +}; pub use unshell_protocol::tree::{ ChildRoute, EndpointError, EndpointOutcome, HookKey, Ingress, LeafSpec, LocalEvent, @@ -32,7 +35,7 @@ pub trait PacketProcessor { /// 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(Debug, Default)] +#[derive(Clone, Debug, Default)] pub struct EndpointState { endpoint: ProtocolEndpoint, } @@ -87,6 +90,57 @@ impl EndpointState { .send_data(dst_path, hook_id, procedure_id, data, end_hook) } + /// 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 { diff --git a/unshell-runtime/src/node/runtime.rs b/unshell-runtime/src/node/runtime.rs index 6c98c85..605175a 100644 --- a/unshell-runtime/src/node/runtime.rs +++ b/unshell-runtime/src/node/runtime.rs @@ -3,7 +3,8 @@ //! 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: this slice only turns hook-data replies into endpoint outcomes. +//! narrow: this slice only turns outbound calls and hook-data replies into +//! endpoint outcomes. use crate::alloc::{string::String, vec::Vec}; use crate::connections::{ @@ -543,9 +544,10 @@ where /// Reduces queued leaf actions through endpoint packet state. /// - /// Only [`LeafAction::SendHookData`] is 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. + /// [`LeafAction::SendCall`] and [`LeafAction::SendHookData`] 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(); @@ -553,6 +555,64 @@ where 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); @@ -1476,6 +1536,279 @@ mod tests { assert!(data.end_hook); } + #[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_leaf_action_is_reported_and_retained() { let leaf_id = crate::leaf::LeafId::new(String::from("org.example.v1.echo")); @@ -1491,13 +1824,10 @@ mod tests { ); runtime.leaf_actions.push(( leaf_id.clone(), - LeafAction::SendCall(OutboundCall { - dst_path: vec![], - dst_leaf: None, - procedure_id: String::from("org.example.v1.echo.invoke"), - payload: vec![], - expects_response: false, - }), + LeafAction::FailHook { + hook_id: 7, + fault: ProtocolFault::INTERNAL_ERROR, + }, )); runtime.leaf_actions.push(( leaf_id.clone(), @@ -1513,12 +1843,12 @@ mod tests { assert!(matches!( error, NodeRuntimeError::UnsupportedLeafAction { ref leaf_id, action } - if leaf_id.as_str() == "org.example.v1.echo" && action == "SendCall" + if leaf_id.as_str() == "org.example.v1.echo" && action == "FailHook" )); assert_eq!(runtime.leaf_actions().len(), 2); assert!(matches!( runtime.leaf_actions()[0].1, - LeafAction::SendCall(_) + LeafAction::FailHook { .. } )); assert!(matches!( runtime.leaf_actions()[1].1, From da9166daf0a7ff05c4b621b124681f63b5ce48ce Mon Sep 17 00:00:00 2001 From: Michael Mikovsky <77305074+Astatin3@users.noreply.github.com> Date: Sat, 9 May 2026 13:53:49 -0600 Subject: [PATCH 12/31] Reduce leaf fail hook actions --- API.md | 17 +- .../src/protocol/tree/endpoint/hooks.rs | 25 ++ unshell-protocol/src/protocol/tree/hook.rs | 19 + unshell-runtime/src/node/packet.rs | 19 +- unshell-runtime/src/node/runtime.rs | 354 ++++++++++++++++-- 5 files changed, 395 insertions(+), 39 deletions(-) diff --git a/API.md b/API.md index 65d4814..4ab300b 100644 --- a/API.md +++ b/API.md @@ -329,10 +329,9 @@ connection closes or unregisters ## Known Gaps In The Current Branch -- `LeafAction::SendCall` and `LeafAction::SendHookData` are reduced by - `NodeRuntime`; hook fault and connection action variants are still unsupported +- `LeafAction::SendCall`, `LeafAction::SendHookData`, and `LeafAction::FailHook` + are reduced by `NodeRuntime`; connection action variants are still unsupported and must remain queued when encountered. -- Hook fault actions through the runtime are not implemented. - Connection actions through the runtime are not implemented. - Disconnect does not yet clean hooks, sessions, route state, and queued effects. - Child ingress still allocates because the existing `Ingress::Child` owns a @@ -340,13 +339,13 @@ connection closes or unregisters ## Next Implementation Slice -Implement the next narrow leaf-action path: +Implement the next narrow connection-action path: -1. Apply queued `LeafAction::FailHook` through endpoint packet state. -2. Preserve pending/active hook cleanup semantics without dropping unprocessed - actions. -3. Keep connection registration actions queued until runtime-owned disconnect +1. Keep connection registration actions queued until runtime-owned disconnect cleanup can update connections, routes, hooks, and queued effects atomically. +2. Add connection registration reduction only when route, connection, hook, and + queued-effect cleanup can be updated as one runtime transaction. +3. Preserve FIFO retry semantics for unsupported or failed connection actions. That slice should continue the one-variant-at-a-time reducer approach without -implementing connection actions early. +implementing disconnect cleanup early. diff --git a/unshell-protocol/src/protocol/tree/endpoint/hooks.rs b/unshell-protocol/src/protocol/tree/endpoint/hooks.rs index 81e9512..de6353f 100644 --- a/unshell-protocol/src/protocol/tree/endpoint/hooks.rs +++ b/unshell-protocol/src/protocol/tree/endpoint/hooks.rs @@ -10,6 +10,31 @@ 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, diff --git a/unshell-protocol/src/protocol/tree/hook.rs b/unshell-protocol/src/protocol/tree/hook.rs index 7b7b5f2..b099dd2 100644 --- a/unshell-protocol/src/protocol/tree/hook.rs +++ b/unshell-protocol/src/protocol/tree/hook.rs @@ -324,6 +324,25 @@ impl HookTable { 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 diff --git a/unshell-runtime/src/node/packet.rs b/unshell-runtime/src/node/packet.rs index b1d3eaa..9502bc8 100644 --- a/unshell-runtime/src/node/packet.rs +++ b/unshell-runtime/src/node/packet.rs @@ -6,8 +6,8 @@ //! handles, does not dispatch leaves, and does not make admission decisions. use unshell_protocol::{ - CallMessage, FrameBytes, PacketHeader, PacketType, tree::Endpoint as ProtocolEndpointTrait, - validate_call, validate_header, validate_procedure_id, + CallMessage, FrameBytes, PacketHeader, PacketType, ProtocolFault, + tree::Endpoint as ProtocolEndpointTrait, validate_call, validate_header, validate_procedure_id, }; pub use unshell_protocol::tree::{ @@ -90,6 +90,21 @@ impl EndpointState { .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, diff --git a/unshell-runtime/src/node/runtime.rs b/unshell-runtime/src/node/runtime.rs index 605175a..e6ca326 100644 --- a/unshell-runtime/src/node/runtime.rs +++ b/unshell-runtime/src/node/runtime.rs @@ -3,8 +3,7 @@ //! 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: this slice only turns outbound calls and hook-data replies into -//! endpoint outcomes. +//! narrow and grows one action family at a time. use crate::alloc::{string::String, vec::Vec}; use crate::connections::{ @@ -544,10 +543,10 @@ where /// Reduces queued leaf actions through endpoint packet state. /// - /// [`LeafAction::SendCall`] and [`LeafAction::SendHookData`] 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. + /// [`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(); @@ -649,6 +648,40 @@ where } 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)); @@ -825,6 +858,7 @@ mod tests { 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, @@ -1536,6 +1570,82 @@ mod tests { 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); @@ -1810,7 +1920,7 @@ mod tests { } #[test] - fn unsupported_leaf_action_is_reported_and_retained() { + 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( @@ -1822,13 +1932,6 @@ mod tests { Connections::new(), RecordingTransport::default(), ); - runtime.leaf_actions.push(( - leaf_id.clone(), - LeafAction::FailHook { - hook_id: 7, - fault: ProtocolFault::INTERNAL_ERROR, - }, - )); runtime.leaf_actions.push(( leaf_id.clone(), LeafAction::Connection(ConnectionAction::Unregister { @@ -1843,15 +1946,11 @@ mod tests { assert!(matches!( error, NodeRuntimeError::UnsupportedLeafAction { ref leaf_id, action } - if leaf_id.as_str() == "org.example.v1.echo" && action == "FailHook" + if leaf_id.as_str() == "org.example.v1.echo" && action == "Connection" )); - assert_eq!(runtime.leaf_actions().len(), 2); + assert_eq!(runtime.leaf_actions().len(), 1); assert!(matches!( runtime.leaf_actions()[0].1, - LeafAction::FailHook { .. } - )); - assert!(matches!( - runtime.leaf_actions()[1].1, LeafAction::Connection(_) )); } @@ -1939,26 +2038,225 @@ mod tests { runtime .register_parent_connection(parent, vec![], ConnectionGeneration::INITIAL) .expect("parent route restored"); - let retry_error = runtime + let reduced = runtime .reduce_leaf_actions() - .expect_err("later unsupported action is still reported"); + .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!( - retry_error, + error, NodeRuntimeError::UnsupportedLeafAction { - action: "FailHook", + action: "Connection", .. } )); assert_eq!(runtime.leaf_actions().len(), 1); assert!(matches!( runtime.leaf_actions()[0].1, - LeafAction::FailHook { .. } + LeafAction::Connection(_) )); - assert!(matches!( - runtime.effects()[0], - RuntimeEffect::SendFrame { connection, .. } if connection == parent + 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] 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 13/31] 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(()) - } -} From 129720145aef016333a191fc40169fd07c4c54a6 Mon Sep 17 00:00:00 2001 From: Michael Mikovsky <77305074+Astatin3@users.noreply.github.com> Date: Sat, 16 May 2026 14:14:00 -0600 Subject: [PATCH 14/31] Add packet. --- Cargo.lock | 673 +-------------------------- Cargo.toml | 80 +--- src/lib.rs | 36 +- unshell-protocol/Cargo.toml | 4 +- unshell-protocol/src/lib.rs | 18 +- unshell-protocol/src/packet/mod.rs | 167 +++++++ unshell-protocol/src/packet/tests.rs | 264 +++++++++++ unshell-protocol/src/utils.rs | 1 + 8 files changed, 470 insertions(+), 773 deletions(-) create mode 100644 unshell-protocol/src/packet/mod.rs create mode 100644 unshell-protocol/src/packet/tests.rs create mode 100644 unshell-protocol/src/utils.rs diff --git a/Cargo.lock b/Cargo.lock index 8ca27a4..a4f6013 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -22,12 +22,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "allocator-api2" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" - [[package]] name = "android_system_properties" version = "0.1.5" @@ -124,15 +118,6 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" -[[package]] -name = "castaway" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" -dependencies = [ - "rustversion", -] - [[package]] name = "cbc" version = "0.2.0" @@ -204,35 +189,12 @@ dependencies = [ "inout", ] -[[package]] -name = "compact_str" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" -dependencies = [ - "castaway", - "cfg-if", - "itoa", - "rustversion", - "ryu", - "static_assertions", -] - [[package]] name = "const-oid" version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" -[[package]] -name = "convert_case" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" -dependencies = [ - "unicode-segmentation", -] - [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -254,48 +216,6 @@ dependencies = [ "libc", ] -[[package]] -name = "crossbeam-channel" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" - -[[package]] -name = "crossterm" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" -dependencies = [ - "bitflags 2.11.1", - "crossterm_winapi", - "derive_more", - "document-features", - "mio", - "parking_lot", - "rustix", - "signal-hook", - "signal-hook-mio", - "winapi", -] - -[[package]] -name = "crossterm_winapi" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" -dependencies = [ - "winapi", -] - [[package]] name = "crypto-common" version = "0.2.1" @@ -305,71 +225,6 @@ dependencies = [ "hybrid-array", ] -[[package]] -name = "darling" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" -dependencies = [ - "darling_core", - "darling_macro", -] - -[[package]] -name = "darling_core" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" -dependencies = [ - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.117", -] - -[[package]] -name = "darling_macro" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" -dependencies = [ - "darling_core", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", -] - -[[package]] -name = "derive_more" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" -dependencies = [ - "derive_more-impl", -] - -[[package]] -name = "derive_more-impl" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" -dependencies = [ - "convert_case", - "proc-macro2", - "quote", - "rustc_version", - "syn 2.0.117", -] - [[package]] name = "digest" version = "0.11.2" @@ -381,54 +236,12 @@ dependencies = [ "crypto-common", ] -[[package]] -name = "document-features" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" -dependencies = [ - "litrs", -] - -[[package]] -name = "downcast-rs" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" - -[[package]] -name = "either" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" - [[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys", -] - -[[package]] -name = "filedescriptor" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" -dependencies = [ - "libc", - "thiserror 1.0.69", - "winapi", -] - [[package]] name = "find-msvc-tools" version = "0.1.8" @@ -441,12 +254,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" -[[package]] -name = "foldhash" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" - [[package]] name = "getrandom" version = "0.4.2" @@ -467,7 +274,7 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash 0.1.5", + "foldhash", ] [[package]] @@ -475,11 +282,6 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash 0.2.0", -] [[package]] name = "hashbrown" @@ -544,12 +346,6 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - [[package]] name = "indexmap" version = "2.13.0" @@ -562,15 +358,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "indoc" -version = "2.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" -dependencies = [ - "rustversion", -] - [[package]] name = "inout" version = "0.2.2" @@ -581,28 +368,6 @@ dependencies = [ "hybrid-array", ] -[[package]] -name = "instability" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" -dependencies = [ - "darling", - "indoc", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "itertools" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" -dependencies = [ - "either", -] - [[package]] name = "itoa" version = "1.0.18" @@ -619,23 +384,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "kasuari" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" -dependencies = [ - "hashbrown 0.16.1", - "portable-atomic", - "thiserror 2.0.18", -] - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - [[package]] name = "leb128fmt" version = "0.1.0" @@ -648,27 +396,6 @@ version = "0.2.185" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" -[[package]] -name = "line-clipping" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" -dependencies = [ - "bitflags 2.11.1", -] - -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - -[[package]] -name = "litrs" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" - [[package]] name = "lock_api" version = "0.4.14" @@ -684,33 +411,12 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" -[[package]] -name = "lru" -version = "0.16.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" -dependencies = [ - "hashbrown 0.16.1", -] - [[package]] name = "memchr" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" -[[package]] -name = "mio" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" -dependencies = [ - "libc", - "log", - "wasi", - "windows-sys", -] - [[package]] name = "munge" version = "0.4.7" @@ -731,24 +437,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "nix" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" -dependencies = [ - "bitflags 2.11.1", - "cfg-if", - "cfg_aliases 0.1.1", - "libc", -] - -[[package]] -name = "num-conv" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" - [[package]] name = "num-traits" version = "0.2.19" @@ -758,15 +446,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "num_threads" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" -dependencies = [ - "libc", -] - [[package]] name = "once_cell" version = "1.21.3" @@ -796,39 +475,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "portable-atomic" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" - -[[package]] -name = "portable-pty" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" -dependencies = [ - "anyhow", - "bitflags 1.3.2", - "downcast-rs", - "filedescriptor", - "lazy_static", - "libc", - "log", - "nix", - "serial2", - "shared_library", - "shell-words", - "winapi", - "winreg", -] - -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - [[package]] name = "prettyplease" version = "0.2.37" @@ -909,69 +555,6 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" -[[package]] -name = "ratatui" -version = "0.30.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" -dependencies = [ - "instability", - "ratatui-core", - "ratatui-crossterm", - "ratatui-widgets", -] - -[[package]] -name = "ratatui-core" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" -dependencies = [ - "bitflags 2.11.1", - "compact_str", - "hashbrown 0.16.1", - "indoc", - "itertools", - "kasuari", - "lru", - "strum", - "thiserror 2.0.18", - "unicode-segmentation", - "unicode-truncate", - "unicode-width", -] - -[[package]] -name = "ratatui-crossterm" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" -dependencies = [ - "cfg-if", - "crossterm", - "instability", - "ratatui-core", -] - -[[package]] -name = "ratatui-widgets" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" -dependencies = [ - "bitflags 2.11.1", - "hashbrown 0.16.1", - "indoc", - "instability", - "itertools", - "line-clipping", - "ratatui-core", - "strum", - "time", - "unicode-segmentation", - "unicode-width", -] - [[package]] name = "redox_syscall" version = "0.5.18" @@ -1049,40 +632,12 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags 2.11.1", - "errno", - "libc", - "linux-raw-sys", - "windows-sys", -] - [[package]] name = "rustversion" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" -[[package]] -name = "ryu" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" - [[package]] name = "scopeguard" version = "1.2.0" @@ -1137,17 +692,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "serial2" -version = "0.2.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcdbc46aa3882ec3d48ec2b5abcb4f0d863a13d7599265f3faa6d851f23c12f3" -dependencies = [ - "cfg-if", - "libc", - "winapi", -] - [[package]] name = "sha2" version = "0.11.0" @@ -1159,59 +703,12 @@ dependencies = [ "digest", ] -[[package]] -name = "shared_library" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" -dependencies = [ - "lazy_static", - "libc", -] - -[[package]] -name = "shell-words" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" - [[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" -[[package]] -name = "signal-hook" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" -dependencies = [ - "libc", - "signal-hook-registry", -] - -[[package]] -name = "signal-hook-mio" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" -dependencies = [ - "libc", - "mio", - "signal-hook", -] - -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - [[package]] name = "simdutf8" version = "0.1.5" @@ -1224,12 +721,6 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - [[package]] name = "static_init" version = "1.0.4" @@ -1258,33 +749,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "strum" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" -dependencies = [ - "strum_macros", -] - -[[package]] -name = "strum_macros" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "syn" version = "1.0.109" @@ -1307,33 +771,13 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.18", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "thiserror-impl", ] [[package]] @@ -1347,27 +791,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "time" -version = "0.3.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" -dependencies = [ - "deranged", - "libc", - "num-conv", - "num_threads", - "powerfmt", - "serde_core", - "time-core", -] - -[[package]] -name = "time-core" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" - [[package]] name = "tinyvec" version = "1.11.0" @@ -1383,17 +806,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" -[[package]] -name = "treetest" -version = "0.1.0" -dependencies = [ - "crossbeam-channel", - "crossterm", - "ratatui", - "thiserror 2.0.18", - "unshell", -] - [[package]] name = "typenum" version = "1.20.0" @@ -1406,29 +818,6 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" -[[package]] -name = "unicode-segmentation" -version = "1.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" - -[[package]] -name = "unicode-truncate" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" -dependencies = [ - "itertools", - "unicode-segmentation", - "unicode-width", -] - -[[package]] -name = "unicode-width" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" - [[package]] name = "unicode-xid" version = "0.2.6" @@ -1440,34 +829,10 @@ name = "unshell" version = "0.1.0" dependencies = [ "chrono", - "crossbeam-channel", "rkyv", "static_init", - "thiserror 2.0.18", - "unshell-leaves", - "unshell-macros", + "thiserror", "unshell-protocol", - "unshell-runtime", -] - -[[package]] -name = "unshell-leaves" -version = "0.1.0" -dependencies = [ - "crossbeam-channel", - "portable-pty", - "rkyv", - "unshell-macros", - "unshell-protocol", -] - -[[package]] -name = "unshell-macros" -version = "0.1.0" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", ] [[package]] @@ -1475,14 +840,6 @@ name = "unshell-protocol" version = "0.1.0" dependencies = [ "rkyv", - "unshell-macros", -] - -[[package]] -name = "unshell-runtime" -version = "0.1.0" -dependencies = [ - "unshell-protocol", ] [[package]] @@ -1511,12 +868,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - [[package]] name = "wasip2" version = "1.0.3+wasi-0.2.9" @@ -1695,24 +1046,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "winreg" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" -dependencies = [ - "winapi", -] - [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index e6245df..a33bee8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,11 +4,11 @@ cargo-features = ["trim-paths", "panic-immediate-abort"] members = [ "ush-obfuscate", "base62", - "unshell-macros", + # "unshell-macros", "unshell-protocol", - "unshell-runtime", - "unshell-leaves", - "treetest", + # "unshell-runtime", + # "unshell-leaves", + # "treetest", ] resolver = "2" @@ -32,9 +32,9 @@ portable-pty = "0.9.0" crossbeam-channel = "0.5.15" unshell = { path = "." } unshell-protocol = { path = "./unshell-protocol" } -unshell-runtime = { path = "./unshell-runtime" } -unshell-leaves = { path = "./unshell-leaves" } -unshell-macros = { path = "./unshell-macros" } +# unshell-runtime = { path = "./unshell-runtime" } +# unshell-leaves = { path = "./unshell-leaves" } +# unshell-macros = { path = "./unshell-macros" } # ush-obfuscate = { path = "./ush-obfuscate" } # base62 = { path = "./base62" } @@ -51,8 +51,8 @@ log = [] log_debug = ["log", "dep:chrono"] # Leaf features -leaf_endpoint = ["unshell-leaves/leaf_endpoint"] -leaf_tui = ["unshell-leaves/leaf_tui"] +# leaf_endpoint = ["unshell-leaves/leaf_endpoint"] +# leaf_tui = ["unshell-leaves/leaf_tui"] # obfuscate_aes = ["ush-obfuscate/obfuscate_aes"] # obfuscate_ref = ["ush-obfuscate/obfuscate_ref"] @@ -63,64 +63,10 @@ thiserror = { workspace = true, optional = true } chrono = { workspace = true, optional = true } # ush-obfuscate = { workspace = true } static_init = { workspace = true } -unshell-macros = { workspace = true } +# unshell-macros = { workspace = true } unshell-protocol = { workspace = true } -unshell-runtime = { workspace = true } -unshell-leaves = { workspace = true } - -[dev-dependencies] -crossbeam-channel = { workspace = true } - -[[example]] -name = "leaf_derive" -path = "examples/protocol/leaf_derive.rs" - -[[example]] -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" -required-features = ["leaf_endpoint"] - -[[example]] -name = "remote_shell_receive" -path = "examples/protocol/remote_shell_receive.rs" -required-features = ["leaf_endpoint"] - -[[example]] -name = "remote_shell_single_endpoint" -path = "examples/protocol/remote_shell_single_endpoint.rs" -required-features = ["leaf_endpoint"] - -[[example]] -name = "bench" -path = "examples/protocol/bench/bench.rs" - -[[example]] -name = "op_encode_call" -path = "examples/protocol/bench/op_encode_call.rs" - -[[example]] -name = "op_decode_call" -path = "examples/protocol/bench/op_decode_call.rs" - -[[example]] -name = "op_forward_call_receive" -path = "examples/protocol/bench/op_forward_call_receive.rs" - -[[example]] -name = "op_local_call_receive" -path = "examples/protocol/bench/op_local_call_receive.rs" - -[[example]] -name = "op_hook_data_receive" -path = "examples/protocol/bench/op_hook_data_receive.rs" +# unshell-runtime = { workspace = true } +# unshell-leaves = { workspace = true } [profile.minimize] inherits = "release" @@ -143,4 +89,4 @@ unsafe_op_in_unsafe_fn = "warn" unused_import_braces = "warn" unused_lifetimes = "warn" trivial_casts = "allow" -missing_docs = "warn" +# missing_docs = "warn" diff --git a/src/lib.rs b/src/lib.rs index 8554e0e..781d470 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -24,12 +24,12 @@ pub mod logger; pub use unshell_protocol as protocol; /// Re-export the leaf library crate behind the historical `unshell::leaves` path -pub use unshell_leaves as leaves; +// pub use unshell_leaves as leaves; /// Re-export the runtime crate behind the `unshell::runtime` path. -pub use unshell_runtime as runtime; +// pub use unshell_runtime as runtime; -pub use unshell_macros::{Procedure, leaf, procedures}; +// pub use unshell_macros::{Procedure, leaf, procedures}; /// Creates a root-assumed endpoint from one local identifier plus any number of leaf hosts. /// @@ -40,21 +40,21 @@ pub use unshell_macros::{Procedure, leaf, procedures}; /// Why it exists: the common bootstrap case should not require callers to manually construct an /// empty path, `Vec`, and a `Vec` when they already have leaf host values. /// -/// # Example -/// ```rust -/// use unshell::{create_endpoint, leaf}; -/// use unshell::protocol::tree::Endpoint; -/// -/// #[derive(Default)] -/// struct DemoLeaf; -/// -/// #[leaf(id = "org.example.v1.demo", procedures = ["ping"], endpoint_struct = DemoLeaf)] -/// struct Demo; -/// -/// let endpoint = create_endpoint!("demo", DemoLeaf::default()); -/// assert!(endpoint.path().is_empty()); -/// assert_eq!(endpoint.local_id(), Some("demo")); -/// ``` +// # Example +// ```rust +// use unshell::{create_endpoint, leaf}; +// use unshell::protocol::tree::Endpoint; + +// #[derive(Default)] +// struct DemoLeaf; + +// #[leaf(id = "org.example.v1.demo", procedures = ["ping"], endpoint_struct = DemoLeaf)] +// struct Demo; + +// let endpoint = create_endpoint!("demo", DemoLeaf::default()); +// assert!(endpoint.path().is_empty()); +// assert_eq!(endpoint.local_id(), Some("demo")); +// ``` #[macro_export] macro_rules! create_endpoint { ($id:expr $(, $leaf:expr )* $(,)?) => {{ diff --git a/unshell-protocol/Cargo.toml b/unshell-protocol/Cargo.toml index a357429..bd4d2fb 100644 --- a/unshell-protocol/Cargo.toml +++ b/unshell-protocol/Cargo.toml @@ -9,7 +9,7 @@ doctest = false [dependencies] rkyv = { workspace = true } -unshell-macros = { path = "../unshell-macros" } +# unshell-macros = { path = "../unshell-macros" } [lints.rust] elided_lifetimes_in_paths = "warn" @@ -22,4 +22,4 @@ unsafe_op_in_unsafe_fn = "warn" unused_import_braces = "warn" unused_lifetimes = "warn" trivial_casts = "allow" -missing_docs = "warn" +# missing_docs = "warn" diff --git a/unshell-protocol/src/lib.rs b/unshell-protocol/src/lib.rs index ebe2a98..0e46874 100644 --- a/unshell-protocol/src/lib.rs +++ b/unshell-protocol/src/lib.rs @@ -1,20 +1,6 @@ -//! # UnShell Protocol -//! -//! The protocol crate owns the wire types, framing, validation helpers, and the -//! small tree runtime used by endpoint implementations. - #![no_std] pub extern crate alloc; -#[allow(unused_extern_crates)] -extern crate self as unshell; -/// Keep the historical nested path so existing imports and proc-macro output can -/// continue to target `unshell::protocol::...` while the implementation lives in -/// its own crate. -pub mod protocol; - -pub use protocol::*; - -#[cfg(test)] -pub use unshell_macros::{Procedure, leaf, procedures}; +pub mod packet; +pub mod utils; diff --git a/unshell-protocol/src/packet/mod.rs b/unshell-protocol/src/packet/mod.rs new file mode 100644 index 0000000..6491b60 --- /dev/null +++ b/unshell-protocol/src/packet/mod.rs @@ -0,0 +1,167 @@ +#[cfg(test)] +mod tests; + +extern crate alloc; + +use alloc::string::String; +use alloc::vec::Vec; + +#[derive(Debug)] +pub struct Packet { + pub hook_id: u16, + pub is_upwards_call: bool, + pub end_hook: bool, + pub path: String, + // ── body (routers never read below this line) ── + pub procedure_id: String, + pub data: Vec, +} + +/// Returned by `deserialize_header` — only what a router needs. +/// `body_remainder` is a raw slice into the original buffer so the +/// entire body can be forwarded without touching it. +#[derive(Debug)] +pub struct HeaderRef<'buf> { + pub hook_id: u16, + pub is_upwards_call: bool, + pub end_hook: bool, + pub path: &'buf str, + pub body_remainder: &'buf [u8], +} + +#[derive(Debug)] +pub enum SerializeError { + PathTooLarge, + ProcIdTooLarge, + BodyTooLarge, +} + +#[derive(Debug, PartialEq)] +pub enum DeserializeError { + BufferTooShort, + BodyLengthMismatch, + PathTooLong, + ProcIdTooLong, + InvalidUtf8, +} + +impl Packet { + pub fn serialize(&self) -> Result, SerializeError> { + let path_bytes = self.path.as_bytes(); + let proc_id_bytes = self.procedure_id.as_bytes(); + + let path_len = u32::try_from(path_bytes.len()).map_err(|_| SerializeError::PathTooLarge)?; + let proc_id_len = + u32::try_from(proc_id_bytes.len()).map_err(|_| SerializeError::ProcIdTooLarge)?; + + // body = proc_id_len field + proc_id bytes + data bytes + let body_payload_len = 4usize + .checked_add(proc_id_bytes.len()) + .and_then(|n| n.checked_add(self.data.len())) + .ok_or(SerializeError::BodyTooLarge)?; + let body_len = u32::try_from(body_payload_len).map_err(|_| SerializeError::BodyTooLarge)?; + + let total = 8 + path_bytes.len() + 4 + body_payload_len; + let mut buf = Vec::with_capacity(total); + + // ── header ──────────────────────────────────────────────────────────── + let flags = (self.is_upwards_call as u8) | ((self.end_hook as u8) << 1); + buf.extend_from_slice(&self.hook_id.to_le_bytes()); + buf.push(flags); + buf.push(0u8); // padding + buf.extend_from_slice(&path_len.to_le_bytes()); + buf.extend_from_slice(path_bytes); + + // ── body ────────────────────────────────────────────────────────────── + buf.extend_from_slice(&body_len.to_le_bytes()); + buf.extend_from_slice(&proc_id_len.to_le_bytes()); + buf.extend_from_slice(proc_id_bytes); + buf.extend_from_slice(&self.data); + + Ok(buf) + } + + /// Deserialize only the header — O(path_len), body bytes are never read. + /// A router can inspect `HeaderRef` then forward the original buffer as-is. + pub fn deserialize_header(buf: &[u8]) -> Result, DeserializeError> { + // fixed prefix: hook_id (2) + flags (1) + padding (1) + path_len (4) + if buf.len() < 8 { + return Err(DeserializeError::BufferTooShort); + } + + let hook_id = u16::from_le_bytes([buf[0], buf[1]]); + let flags = buf[2]; + let is_upwards_call = flags & 0b0000_0001 != 0; + let end_hook = flags & 0b0000_0010 != 0; + let path_len = u32::from_le_bytes([buf[4], buf[5], buf[6], buf[7]]) as usize; + + let path_start = 8usize; + let path_end = path_start + .checked_add(path_len) + .ok_or(DeserializeError::PathTooLong)?; + + if buf.len() < path_end { + return Err(DeserializeError::BufferTooShort); + } + + let path = core::str::from_utf8(&buf[path_start..path_end]) + .map_err(|_| DeserializeError::InvalidUtf8)?; + + Ok(HeaderRef { + hook_id, + is_upwards_call, + end_hook, + path, + body_remainder: &buf[path_end..], + }) + } + + /// Full deserialization. Parses the header then the body. + pub fn deserialize(buf: &[u8]) -> Result { + let header = Self::deserialize_header(buf)?; + let body_buf = header.body_remainder; + + // body_len prefix + if body_buf.len() < 4 { + return Err(DeserializeError::BufferTooShort); + } + let body_len = + u32::from_le_bytes([body_buf[0], body_buf[1], body_buf[2], body_buf[3]]) as usize; + + let body_end = 4usize + .checked_add(body_len) + .ok_or(DeserializeError::BodyLengthMismatch)?; + if body_buf.len() < body_end { + return Err(DeserializeError::BodyLengthMismatch); + } + + // proc_id_len + proc_id + let inner = &body_buf[4..body_end]; + if inner.len() < 4 { + return Err(DeserializeError::BufferTooShort); + } + let proc_id_len = u32::from_le_bytes([inner[0], inner[1], inner[2], inner[3]]) as usize; + + let proc_id_start = 4usize; + let proc_id_end = proc_id_start + .checked_add(proc_id_len) + .ok_or(DeserializeError::ProcIdTooLong)?; + if inner.len() < proc_id_end { + return Err(DeserializeError::BufferTooShort); + } + + let procedure_id = core::str::from_utf8(&inner[proc_id_start..proc_id_end]) + .map_err(|_| DeserializeError::InvalidUtf8)?; + + let data = inner[proc_id_end..].to_vec(); + + Ok(Self { + hook_id: header.hook_id, + is_upwards_call: header.is_upwards_call, + end_hook: header.end_hook, + path: header.path.into(), + procedure_id: procedure_id.into(), + data, + }) + } +} diff --git a/unshell-protocol/src/packet/tests.rs b/unshell-protocol/src/packet/tests.rs new file mode 100644 index 0000000..fd4eb0d --- /dev/null +++ b/unshell-protocol/src/packet/tests.rs @@ -0,0 +1,264 @@ +use super::*; +use alloc::string::ToString; +use alloc::vec; + +// ── Helpers ─────────────────────────────────────────────────────────────── + +fn make_packet() -> Packet { + Packet { + hook_id: 42, + is_upwards_call: true, + end_hook: false, + path: "my/service/path".to_string(), + procedure_id: "my.service.Method".to_string(), + data: vec![0xDE, 0xAD, 0xBE, 0xEF], + } +} + +fn make_packet_flags(is_upwards_call: bool, end_hook: bool) -> Packet { + Packet { + is_upwards_call, + end_hook, + ..make_packet() + } +} + +// ── Round-trip ──────────────────────────────────────────────────────────── + +#[test] +fn full_round_trip() { + let packet = make_packet(); + let buf = packet.serialize().unwrap(); + let result = Packet::deserialize(&buf).unwrap(); + + assert_eq!(result.hook_id, packet.hook_id); + assert_eq!(result.is_upwards_call, packet.is_upwards_call); + assert_eq!(result.end_hook, packet.end_hook); + assert_eq!(result.path, packet.path); + assert_eq!(result.procedure_id, packet.procedure_id); + assert_eq!(result.data, packet.data); +} + +#[test] +fn header_round_trip() { + let packet = make_packet(); + let buf = packet.serialize().unwrap(); + let header = Packet::deserialize_header(&buf).unwrap(); + + assert_eq!(header.hook_id, packet.hook_id); + assert_eq!(header.is_upwards_call, packet.is_upwards_call); + assert_eq!(header.end_hook, packet.end_hook); + assert_eq!(header.path, packet.path); +} + +// ── Flags ───────────────────────────────────────────────────────────────── + +#[test] +fn flags_both_false() { + let packet = make_packet_flags(false, false); + let buf = packet.serialize().unwrap(); + let header = Packet::deserialize_header(&buf).unwrap(); + assert!(!header.is_upwards_call); + assert!(!header.end_hook); +} + +#[test] +fn flags_both_true() { + let packet = make_packet_flags(true, true); + let buf = packet.serialize().unwrap(); + let header = Packet::deserialize_header(&buf).unwrap(); + assert!(header.is_upwards_call); + assert!(header.end_hook); +} + +#[test] +fn flags_upwards_only() { + let packet = make_packet_flags(true, false); + let buf = packet.serialize().unwrap(); + let header = Packet::deserialize_header(&buf).unwrap(); + assert!(header.is_upwards_call); + assert!(!header.end_hook); +} + +#[test] +fn flags_end_hook_only() { + let packet = make_packet_flags(false, true); + let buf = packet.serialize().unwrap(); + let header = Packet::deserialize_header(&buf).unwrap(); + assert!(!header.is_upwards_call); + assert!(header.end_hook); +} + +// ── Empty fields ────────────────────────────────────────────────────────── + +#[test] +fn empty_path() { + let packet = Packet { + path: "".to_string(), + ..make_packet() + }; + let buf = packet.serialize().unwrap(); + let header = Packet::deserialize_header(&buf).unwrap(); + assert_eq!(header.path, ""); +} + +#[test] +fn empty_procedure_id() { + let packet = Packet { + procedure_id: "".to_string(), + ..make_packet() + }; + let buf = packet.serialize().unwrap(); + let result = Packet::deserialize(&buf).unwrap(); + assert_eq!(result.procedure_id, ""); +} + +#[test] +fn empty_data() { + let packet = Packet { + data: vec![], + ..make_packet() + }; + let buf = packet.serialize().unwrap(); + let result = Packet::deserialize(&buf).unwrap(); + assert_eq!(result.data, &[] as &[u8]); +} + +#[test] +fn all_fields_empty() { + let packet = Packet { + hook_id: 0, + is_upwards_call: false, + end_hook: false, + path: "".to_string(), + procedure_id: "".to_string(), + data: vec![], + }; + let buf = packet.serialize().unwrap(); + let result = Packet::deserialize(&buf).unwrap(); + assert_eq!(result.hook_id, 0); + assert_eq!(result.path, ""); + assert_eq!(result.procedure_id, ""); + assert_eq!(result.data, &[] as &[u8]); +} + +// ── Zero-copy: borrows point into the original buffer ───────────────────── + +#[test] +fn header_path_is_borrowed_from_buffer() { + let buf = make_packet().serialize().unwrap(); + let header = Packet::deserialize_header(&buf).unwrap(); + + let path_ptr = header.path.as_ptr(); + let buf_range = buf.as_ptr_range(); + assert!( + buf_range.contains(&path_ptr), + "path must be a subslice of the input buffer, not a new allocation" + ); +} + +#[test] +fn body_remainder_is_borrowed_from_buffer() { + let buf = make_packet().serialize().unwrap(); + let header = Packet::deserialize_header(&buf).unwrap(); + + let remainder_ptr = header.body_remainder.as_ptr(); + let buf_range = buf.as_ptr_range(); + assert!( + buf_range.contains(&remainder_ptr), + "body_remainder must point into the input buffer" + ); +} + +// ── Partial deserialization: body is untouched by header parse ──────────── + +#[test] +fn deserialize_header_does_not_read_body() { + let buf = make_packet().serialize().unwrap(); + let header = Packet::deserialize_header(&buf).unwrap(); + + // Re-parse body from the remainder to confirm it's intact. + let body_buf = header.body_remainder; + let body_len = + u32::from_le_bytes([body_buf[0], body_buf[1], body_buf[2], body_buf[3]]) as usize; + assert!( + body_buf.len() >= 4 + body_len, + "body_remainder must contain the full body" + ); +} + +#[test] +fn can_forward_buffer_after_header_parse() { + // Simulates a router: parse the header, then forward the raw buffer + // without touching the body. + let original = make_packet().serialize().unwrap(); + let header = Packet::deserialize_header(&original).unwrap(); + + assert_eq!(header.path, "my/service/path"); + + // "Forward" by deserializing the full original buffer downstream. + let forwarded = Packet::deserialize(&original).unwrap(); + assert_eq!(forwarded.procedure_id, "my.service.Method"); + assert_eq!(forwarded.data, &[0xDE, 0xAD, 0xBE, 0xEF]); +} + +// ── Truncation / corruption ─────────────────────────────────────────────── + +#[test] +fn truncated_in_fixed_prefix() { + let buf = make_packet().serialize().unwrap(); + // Cut inside the fixed 8-byte prefix. + assert_eq!( + Packet::deserialize_header(&buf[..4]).unwrap_err(), + DeserializeError::BufferTooShort + ); +} + +#[test] +fn truncated_in_path() { + let buf = make_packet().serialize().unwrap(); + // Cut to just past the fixed prefix, mid-path. + assert_eq!( + Packet::deserialize_header(&buf[..9]).unwrap_err(), + DeserializeError::BufferTooShort + ); +} + +#[test] +fn truncated_in_body() { + let buf = make_packet().serialize().unwrap(); + // Remove last byte — well into the body. + assert!(Packet::deserialize(&buf[..buf.len() - 1]).is_err()); +} + +#[test] +fn empty_buffer_rejected() { + assert_eq!( + Packet::deserialize_header(&[]).unwrap_err(), + DeserializeError::BufferTooShort + ); +} + +#[test] +fn invalid_utf8_in_path() { + let mut buf = make_packet().serialize().unwrap(); + // Overwrite the first byte of the path (offset 8) with an invalid UTF-8 byte. + buf[8] = 0xFF; + assert_eq!( + Packet::deserialize_header(&buf).unwrap_err(), + DeserializeError::InvalidUtf8 + ); +} + +#[test] +fn invalid_utf8_in_procedure_id() { + let mut buf = make_packet().serialize().unwrap(); + // Find where procedure_id starts: 8 + path_len + 4 (body_len) + 4 (proc_id_len) + let path_len = u32::from_le_bytes([buf[4], buf[5], buf[6], buf[7]]) as usize; + let proc_id_offset = 8 + path_len + 4 + 4; + buf[proc_id_offset] = 0xFF; + assert_eq!( + Packet::deserialize(&buf).unwrap_err(), + DeserializeError::InvalidUtf8 + ); +} diff --git a/unshell-protocol/src/utils.rs b/unshell-protocol/src/utils.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/unshell-protocol/src/utils.rs @@ -0,0 +1 @@ + From fa8cb6269c05def60ff0e2f8975c2fbef078a15a Mon Sep 17 00:00:00 2001 From: Michael Mikovsky <77305074+Astatin3@users.noreply.github.com> Date: Wed, 27 May 2026 11:04:22 -0600 Subject: [PATCH 15/31] Work on remaking routing --- Cargo.lock | 16 ++ Cargo.toml | 1 + unshell-protocol/Cargo.toml | 3 + unshell-protocol/src/endpoint/endpoint_ref.rs | 144 ++++++++++++++++++ unshell-protocol/src/endpoint/error.rs | 9 ++ unshell-protocol/src/endpoint/mod.rs | 54 +++++++ unshell-protocol/src/leaf/mod.rs | 6 + unshell-protocol/src/lib.rs | 7 +- unshell-protocol/src/tests/mod.rs | 107 +++++++++++++ unshell-protocol/src/types.rs | 14 ++ unshell-protocol/src/utils.rs | 1 - 11 files changed, 360 insertions(+), 2 deletions(-) create mode 100644 unshell-protocol/src/endpoint/endpoint_ref.rs create mode 100644 unshell-protocol/src/endpoint/error.rs create mode 100644 unshell-protocol/src/endpoint/mod.rs create mode 100644 unshell-protocol/src/leaf/mod.rs create mode 100644 unshell-protocol/src/tests/mod.rs create mode 100644 unshell-protocol/src/types.rs delete mode 100644 unshell-protocol/src/utils.rs diff --git a/Cargo.lock b/Cargo.lock index a4f6013..eef28b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -216,6 +216,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crypto-common" version = "0.2.1" @@ -839,6 +854,7 @@ dependencies = [ name = "unshell-protocol" version = "0.1.0" dependencies = [ + "crossbeam-channel", "rkyv", ] diff --git a/Cargo.toml b/Cargo.toml index a33bee8..1d73213 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -68,6 +68,7 @@ unshell-protocol = { workspace = true } # unshell-runtime = { workspace = true } # unshell-leaves = { workspace = true } + [profile.minimize] inherits = "release" strip = true # Strip symbols from the binary diff --git a/unshell-protocol/Cargo.toml b/unshell-protocol/Cargo.toml index bd4d2fb..5f56508 100644 --- a/unshell-protocol/Cargo.toml +++ b/unshell-protocol/Cargo.toml @@ -11,6 +11,9 @@ doctest = false rkyv = { workspace = true } # unshell-macros = { path = "../unshell-macros" } +[dev-dependencies] +crossbeam-channel.workspace = true + [lints.rust] elided_lifetimes_in_paths = "warn" future_incompatible = { level = "warn", priority = -1 } diff --git a/unshell-protocol/src/endpoint/endpoint_ref.rs b/unshell-protocol/src/endpoint/endpoint_ref.rs new file mode 100644 index 0000000..f31415d --- /dev/null +++ b/unshell-protocol/src/endpoint/endpoint_ref.rs @@ -0,0 +1,144 @@ +use alloc::{format, string::ToString}; + +use crate::{ + endpoint::error::EndpointError, + packet::Packet, + types::{ConnectionSet, HookMap, Path, RouteMap}, +}; + +#[derive(Debug)] +pub struct EndpointRef<'a> { + pub name: &'static str, + pub path: &'a Path, + + pub hooks: &'a mut HookMap, + + pub connections: &'a mut ConnectionSet, + + pub inbound: &'a mut RouteMap, + pub outbound: &'a mut RouteMap, +} + +impl<'a> EndpointRef<'a> { + pub fn add_inbound(&mut self, packet: Packet) -> Result<(), EndpointError> { + // If the packet is routed towards this endpoint + if packet.path.ends_with(self.name) { + if packet.is_upwards_call { + self.hooks.insert(packet.hook_id, packet.path.clone()); + } + + self.outbound + .entry(packet.path.clone()) + .or_default() + .push_back(packet); + + Ok(()) + } else { + // If the absolute path of this endpoint hasn't been set yet + if self.path.is_empty() { + return Err(EndpointError::NoAbsoultePathYet); + } + + if *self.path == packet.path { + return Err(EndpointError::IncorrectAbsolutePath); + } + + // For routing + let connection = if packet.is_upwards_call && self.path.starts_with(&packet.path) { + ( + packet + .path + .rsplit_once('/') + .map_or(packet.path.clone(), |(_, after)| after.to_string()), + true, + ) + } else if packet + .path + .starts_with(&format!("{}/{}", self.path, self.name)) + { + let concat_len = self.path.len() + self.name.len(); + + let after_self = &packet.path[concat_len..]; + + ( + after_self + .split_once('/') + .map_or(after_self.to_string(), |(before, _)| before.to_string()), + false, + ) + } else { + return Err(EndpointError::IncorrectAbsolutePath); + }; + + if !self.connections.contains(&connection) { + return Err(EndpointError::RouteNotExist); + } + + self.add_outbound(packet); + + Ok(()) + } + } + + pub fn add_outbound_upwards(&mut self, packet: Packet) -> Result<(), EndpointError> { + let next_hop = self + .hooks + .get(&packet.hook_id) + .ok_or(EndpointError::RouteNotExist)? + .clone(); + + if packet.end_hook { + let _ = self.hooks.remove(&packet.hook_id); + } + + self.outbound + .entry(next_hop.clone()) + .or_default() + .push_back(packet); + + Ok(()) + } + + pub fn add_outbound_downwards(&mut self, packet: Packet) -> Result<(), EndpointError> { + let next_hop = self + .hooks + .get(&packet.hook_id) + .ok_or(EndpointError::RouteNotExist)? + .clone(); + + if packet.end_hook { + let _ = self.hooks.remove(&packet.hook_id); + } + + self.outbound + .entry(next_hop.clone()) + .or_default() + .push_back(packet); + + Ok(()) + } + + pub fn take_intbound(&mut self, path: &str, f: F) + where + F: FnMut(&Packet), + { + if let Some(queue) = self.inbound.get_mut(path) { + let _ = queue.iter().map(f); + + queue.clear(); + } + } + + pub fn take_outbound(&mut self, path: &str, f: F) + where + F: FnMut(&Packet), + { + if let Some(queue) = self.inbound.get_mut(path) { + let _ = queue.iter().map(f); + + queue.clear(); + } + } +} + +// fn get_last_term_in_path(path: &Path) -> &str {} diff --git a/unshell-protocol/src/endpoint/error.rs b/unshell-protocol/src/endpoint/error.rs new file mode 100644 index 0000000..0a18ee7 --- /dev/null +++ b/unshell-protocol/src/endpoint/error.rs @@ -0,0 +1,9 @@ +#[derive(Debug)] +pub enum EndpointError { + NoAbsoultePathYet, + IncorrectAbsolutePath, + + RouteNotExist, + HookDuplicate, + HookNotExist, +} diff --git a/unshell-protocol/src/endpoint/mod.rs b/unshell-protocol/src/endpoint/mod.rs new file mode 100644 index 0000000..5d532b2 --- /dev/null +++ b/unshell-protocol/src/endpoint/mod.rs @@ -0,0 +1,54 @@ +mod endpoint_ref; +pub mod error; + +use alloc::{boxed::Box, string::String, vec::Vec}; + +use crate::{ + leaf::Leaf, + types::{ConnectionSet, HookMap, Path, RouteMap}, +}; + +pub use endpoint_ref::EndpointRef; + +pub struct Endpoint { + pub name: &'static str, + + // Absolute path for this node. + pub path: Path, + pub leaves: Vec>, + + pub connections: ConnectionSet, + + pub hooks: HookMap, + pub inbound: RouteMap, + pub outbound: RouteMap, +} + +impl Endpoint { + pub fn new(name: &'static str, leaves: Vec>) -> Self { + Self { + name, + path: String::new(), + leaves, + hooks: HookMap::new(), + connections: ConnectionSet::new(), + inbound: RouteMap::new(), + outbound: RouteMap::new(), + } + } + + pub fn update(&mut self) { + let mut self_ref = EndpointRef { + name: self.name, + path: &mut self.path, + hooks: &mut self.hooks, + connections: &mut self.connections, + inbound: &mut self.inbound, + outbound: &mut self.outbound, + }; + + let _ = self.leaves.iter_mut().map(|leaf| { + leaf.update(&mut self_ref); + }); + } +} diff --git a/unshell-protocol/src/leaf/mod.rs b/unshell-protocol/src/leaf/mod.rs new file mode 100644 index 0000000..5e544ce --- /dev/null +++ b/unshell-protocol/src/leaf/mod.rs @@ -0,0 +1,6 @@ +use crate::endpoint::EndpointRef; + +pub trait Leaf { + fn get_name(&self) -> &'static str; + fn update<'a>(&mut self, _: &mut EndpointRef<'a>); +} diff --git a/unshell-protocol/src/lib.rs b/unshell-protocol/src/lib.rs index 0e46874..70751de 100644 --- a/unshell-protocol/src/lib.rs +++ b/unshell-protocol/src/lib.rs @@ -2,5 +2,10 @@ pub extern crate alloc; +pub mod endpoint; +pub mod leaf; pub mod packet; -pub mod utils; +mod types; + +#[cfg(test)] +mod tests; diff --git a/unshell-protocol/src/tests/mod.rs b/unshell-protocol/src/tests/mod.rs new file mode 100644 index 0000000..9274fc6 --- /dev/null +++ b/unshell-protocol/src/tests/mod.rs @@ -0,0 +1,107 @@ +use crate::{endpoint::EndpointRef, leaf::Leaf, packet::Packet}; + +use alloc::{ + collections::vec_deque::VecDeque, + format, + string::{String, ToString}, + vec::Vec, +}; +use crossbeam_channel::{Receiver, Sender}; + +struct ControllerLeaf { + responder_id: String, + has_run: bool, +} +struct CommsLeaf { + tx: Sender>, + rx: Receiver>, + + remote_id: String, + is_authority: bool, + started: bool, +} +struct ResponderLeaf; + +impl Leaf for ControllerLeaf { + fn get_name(&self) -> &'static str { + "ControllerLeaf" + } + + fn update<'a>(&mut self, endpoint: &mut EndpointRef<'a>) { + if !self.has_run { + endpoint.add_outbound( + self.responder_id.clone(), + Packet { + hook_id: 0, + is_upwards_call: false, + end_hook: false, + path: format!("/{}", self.responder_id), + procedure_id: "echo".to_string(), + data: "ABC123".as_bytes().to_vec(), + }, + ); + + self.has_run = true; + } + } +} + +impl Leaf for CommsLeaf { + fn get_name(&self) -> &'static str { + "CommsLeaf" + } + + fn update<'a>(&mut self, endpoint: &mut EndpointRef<'a>) { + if !self.started { + endpoint + .connections + .insert((self.remote_id.clone(), self.is_authority)); + } + + while !self.rx.is_empty() { + let packet = Packet::deserialize(&self.rx.recv().unwrap()).unwrap(); + + endpoint.add_inbound(packet).unwrap(); + } + + endpoint.take_outbound(self.get_name(), |packet| { + let data = packet.serialize().unwrap(); + self.tx.send(data).unwrap(); + }); + } +} + +impl Leaf for ResponderLeaf { + fn get_name(&self) -> &'static str { + "ResponderLeaf" + } + + fn update<'a>(&mut self, endpoint: &mut EndpointRef<'a>) { + let packets = endpoint + .inbound + .get(self.get_name()) + .unwrap_or(&VecDeque::new()) + .iter() + .map(|packet| { + // let data = ; + + Packet { + hook_id: 0, + is_upwards_call: false, + end_hook: false, + path: String::new(), + // path: packet.path.clone(), + procedure_id: "echo".to_string(), + data: packet.data.clone(), + } + }) + .collect::>(); + + for packet in packets { + endpoint.add_outbound(packet); + } + } +} + +#[test] +fn test_comms() {} diff --git a/unshell-protocol/src/types.rs b/unshell-protocol/src/types.rs new file mode 100644 index 0000000..fab1a94 --- /dev/null +++ b/unshell-protocol/src/types.rs @@ -0,0 +1,14 @@ +use alloc::{ + collections::{btree_map::BTreeMap, btree_set::BTreeSet, vec_deque::VecDeque}, + string::String, +}; + +use crate::packet::Packet; + +pub type Path = String; +pub type EndpointName = String; +pub type HookID = u16; +pub type ConnectionSet = BTreeSet<(EndpointName, bool)>; +pub type HookMap = BTreeMap; +pub type PacketQueue = VecDeque; +pub type RouteMap = BTreeMap; diff --git a/unshell-protocol/src/utils.rs b/unshell-protocol/src/utils.rs deleted file mode 100644 index 8b13789..0000000 --- a/unshell-protocol/src/utils.rs +++ /dev/null @@ -1 +0,0 @@ - From 3973589a35f7ff99d3d947f5e6b2825828087907 Mon Sep 17 00:00:00 2001 From: Michael Mikovsky <77305074+Astatin3@users.noreply.github.com> Date: Thu, 28 May 2026 11:48:46 -0600 Subject: [PATCH 16/31] Improve protocol implementation --- CLAUDE.md | 71 +++++- unshell-protocol/src/endpoint/endpoint_ref.rs | 144 ----------- unshell-protocol/src/endpoint/mod.rs | 92 +++++-- unshell-protocol/src/endpoint/routing.rs | 105 ++++++++ unshell-protocol/src/leaf/mod.rs | 9 +- unshell-protocol/src/lib.rs | 2 +- unshell-protocol/src/packet/mod.rs | 32 ++- unshell-protocol/src/packet/tests.rs | 64 ++--- unshell-protocol/src/tests/mod.rs | 108 +------- unshell-protocol/src/tests/oneshot.rs | 231 ++++++++++++++++++ unshell-protocol/src/types.rs | 6 +- 11 files changed, 513 insertions(+), 351 deletions(-) delete mode 100644 unshell-protocol/src/endpoint/endpoint_ref.rs create mode 100644 unshell-protocol/src/endpoint/routing.rs create mode 100644 unshell-protocol/src/tests/oneshot.rs diff --git a/CLAUDE.md b/CLAUDE.md index 38a0003..a74beef 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,7 @@ Key routing rules: - Design system, brand → invoke design-consultation - Visual audit, design polish → invoke design-review - Architecture review → invoke plan-eng-review -- Save progress, checkpoint, resume → invoke checkpoint +- Save progress, checkpoint, resume → invoke context-save or context-restore - Code quality, health check → invoke health ## Execution standards @@ -24,13 +24,78 @@ Key routing rules: - Use exact, current dependency versions. Never guess package versions. Check the latest available release before adding or updating any dependency. If a version conflict appears, first try to resolve it while keeping dependencies on their latest compatible releases. Only fall back to an older version when the conflict is truly unavoidable, and explain why. - Leave the project warning-free. Fix all compiler, linter, and tooling warnings before finishing. If a warning cannot be eliminated cleanly, silence it in the narrowest possible scope and add a short rationale. - Document code thoroughly. Add rustdoc, module docs, examples, and inline comments where they improve comprehension. Public APIs should be documented with clear meaning and examples. Non-obvious internal logic should also be documented. Comments should explain intent, invariants, and behavior, not restate syntax. -- Maintain clear architecture. Do not allow files or functions to grow without bound. When code becomes too large or mixes concerns, split it into smaller modules, helper files, or folders with clear names. Prefer structure that improves readability, navigation, and maintenance. +- Maintain clear architecture. Do not allow files or functions to grow without bound. When code becomes too large or mixes concerns, split it into smaller modules, helper files, or folders with clear names. Prefer structure that improves readability, navigation, and maintenance. +- If a file is longer than 500 lines, split it up however seen fit. Create a rust module in place of the file, then split each component of the file into it's own file. Split utils into their own files. If it's a really big struct, split the functions into their own files with pub(super) to prevent confusion. +- If a function is longer than 150 lines, it must be split up as well. In this case, create a master function around multiple 'steps' to this larger one, describing in more detail how it works with appropriate comments. - Research library behavior when needed. Do not assume library APIs, feature flags, version compatibility, or known issues. Verify them, including online research when appropriate, before making decisions. -- Commit at every real milestone. Create a local git commit each time a meaningful milestone is reached. Commit messages must be accurate, specific, and reflect the actual change. +- Commit at every real milestone when implementation is allowed and the user has not forbidden commits. Create a local git commit each time a meaningful milestone is reached. Commit messages must be accurate, specific, and reflect the actual change. - Explain unintuitive choices. Whenever an implementation, algorithm, or control flow could appear backwards, surprising, or overly indirect, add a short rationale comment or documentation note explaining why it is correct. - Track work with TODOs. Use a task list throughout the work so progress, remaining steps, and milestone boundaries stay explicit. - ALL Sub-agents must be told to read this file before continuing. +## Comments + +Because everything must be documented, comments should look like the below. This is a very unimportant function that isn't called often. Use significantly more description for more important ones. + +```rust +/// Attaches `strace` to `process` and decodes reads/writes on `fd`. +/// +/// This is passive: it observes the legacy host's serial traffic and never +/// writes to the MCU device. It requires permission to attach to the target +/// process and will return an error if the process is not running. +pub fn trace_serial(process: &str, fd: u32) -> io::Result<()> { ... } +``` + +```rust +/// Human-readable mapping for Elegoo `DeviceSensorStatus` sensor ids. +/// +/// Source trail: +/// - `serial-test/src/protocol/device_sensor_status.rs` shows `0x48` starts with +/// a stable `sensor_id` and existing traces contain ids `0`, `1`, and `3`. +/// - `config/cc2/printer_dsp.cfg` defines the corresponding CC2 sensors: +/// `[ztemperature_sensor box] sensor_pin=PH0 #GPADC0`, `[heater_bed] +/// sensor_pin=PH1 #GPADC1`, and `[extruder] sensor_pin=toolhead:PA3`. +/// - `serial-test` samples show sensor id `1` carrying extruder up/down telemetry +/// markers (`0x96`/`0x97`), so id `1` is the toolhead/extruder stream. +/// +/// This is deliberately separate from `0x3d` live status: live `0x3d` fields are +/// useful telemetry, but they are not stable object ids in the captured stream. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SensorName { ... } +``` + +Add documentation for not what each struct and function does, but WHY as well. It's very important for debug purposes. + +In the case that a function is either user-facing in a library, or is used widely enough in a project to be considered a reference, add comments describing an example in how to use the function or struct. + +Also, add documentation inside of functions like the below: + +```rust +pub fn is_watertight(&self) -> bool { + // Create the map of edges with an approximate amount of unique edges + let mut edge_map: AHashMap<(usize, usize), usize> = + AHashMap::with_capacity(self.indices.len() * 3 / 2); + + let mut check_edge = |a: usize, b: usize| { + // Always choose smaller edge first + let (a, b) = if b < a { (b, a) } else { (a, b) }; + + // Find the pair of edges in the hash map + *edge_map.entry((a, b)).or_insert(0) += 1; + }; + + // Check each edge on each triangle + for (a, b, c) in &self.indices { + check_edge(*a, *b); + check_edge(*b, *c); + check_edge(*a, *c); + } + + // Check if all edges come in pairs + edge_map.iter().all(|(_, checked)| *checked == 2) +} +``` + ## Plan mode rules - Plan mode is strictly read-only. When plan mode is active, do not edit files, write output files, change configuration, make commits, or perform any system modifications. diff --git a/unshell-protocol/src/endpoint/endpoint_ref.rs b/unshell-protocol/src/endpoint/endpoint_ref.rs deleted file mode 100644 index f31415d..0000000 --- a/unshell-protocol/src/endpoint/endpoint_ref.rs +++ /dev/null @@ -1,144 +0,0 @@ -use alloc::{format, string::ToString}; - -use crate::{ - endpoint::error::EndpointError, - packet::Packet, - types::{ConnectionSet, HookMap, Path, RouteMap}, -}; - -#[derive(Debug)] -pub struct EndpointRef<'a> { - pub name: &'static str, - pub path: &'a Path, - - pub hooks: &'a mut HookMap, - - pub connections: &'a mut ConnectionSet, - - pub inbound: &'a mut RouteMap, - pub outbound: &'a mut RouteMap, -} - -impl<'a> EndpointRef<'a> { - pub fn add_inbound(&mut self, packet: Packet) -> Result<(), EndpointError> { - // If the packet is routed towards this endpoint - if packet.path.ends_with(self.name) { - if packet.is_upwards_call { - self.hooks.insert(packet.hook_id, packet.path.clone()); - } - - self.outbound - .entry(packet.path.clone()) - .or_default() - .push_back(packet); - - Ok(()) - } else { - // If the absolute path of this endpoint hasn't been set yet - if self.path.is_empty() { - return Err(EndpointError::NoAbsoultePathYet); - } - - if *self.path == packet.path { - return Err(EndpointError::IncorrectAbsolutePath); - } - - // For routing - let connection = if packet.is_upwards_call && self.path.starts_with(&packet.path) { - ( - packet - .path - .rsplit_once('/') - .map_or(packet.path.clone(), |(_, after)| after.to_string()), - true, - ) - } else if packet - .path - .starts_with(&format!("{}/{}", self.path, self.name)) - { - let concat_len = self.path.len() + self.name.len(); - - let after_self = &packet.path[concat_len..]; - - ( - after_self - .split_once('/') - .map_or(after_self.to_string(), |(before, _)| before.to_string()), - false, - ) - } else { - return Err(EndpointError::IncorrectAbsolutePath); - }; - - if !self.connections.contains(&connection) { - return Err(EndpointError::RouteNotExist); - } - - self.add_outbound(packet); - - Ok(()) - } - } - - pub fn add_outbound_upwards(&mut self, packet: Packet) -> Result<(), EndpointError> { - let next_hop = self - .hooks - .get(&packet.hook_id) - .ok_or(EndpointError::RouteNotExist)? - .clone(); - - if packet.end_hook { - let _ = self.hooks.remove(&packet.hook_id); - } - - self.outbound - .entry(next_hop.clone()) - .or_default() - .push_back(packet); - - Ok(()) - } - - pub fn add_outbound_downwards(&mut self, packet: Packet) -> Result<(), EndpointError> { - let next_hop = self - .hooks - .get(&packet.hook_id) - .ok_or(EndpointError::RouteNotExist)? - .clone(); - - if packet.end_hook { - let _ = self.hooks.remove(&packet.hook_id); - } - - self.outbound - .entry(next_hop.clone()) - .or_default() - .push_back(packet); - - Ok(()) - } - - pub fn take_intbound(&mut self, path: &str, f: F) - where - F: FnMut(&Packet), - { - if let Some(queue) = self.inbound.get_mut(path) { - let _ = queue.iter().map(f); - - queue.clear(); - } - } - - pub fn take_outbound(&mut self, path: &str, f: F) - where - F: FnMut(&Packet), - { - if let Some(queue) = self.inbound.get_mut(path) { - let _ = queue.iter().map(f); - - queue.clear(); - } - } -} - -// fn get_last_term_in_path(path: &Path) -> &str {} diff --git a/unshell-protocol/src/endpoint/mod.rs b/unshell-protocol/src/endpoint/mod.rs index 5d532b2..185ebd8 100644 --- a/unshell-protocol/src/endpoint/mod.rs +++ b/unshell-protocol/src/endpoint/mod.rs @@ -1,34 +1,48 @@ -mod endpoint_ref; pub mod error; +mod routing; -use alloc::{boxed::Box, string::String, vec::Vec}; +use alloc::{boxed::Box, vec::Vec}; use crate::{ leaf::Leaf, + packet::Packet, types::{ConnectionSet, HookMap, Path, RouteMap}, }; -pub use endpoint_ref::EndpointRef; - pub struct Endpoint { - pub name: &'static str, + // This endpoint's identifier + pub id: u32, - // Absolute path for this node. + // A counter that creates unique hook IDs. + // TODO: Actually check if the hook ID collides with any existing hooks. + // TODO: Randomize the hooks for more obfuscation + last_hook: u16, + + // Absolute path for this node. Must be set by some leaf pub path: Path, pub leaves: Vec>, + // Map of connections so that we can know what is connected + // and which endpoints are authorities pub connections: ConnectionSet, - pub hooks: HookMap, - pub inbound: RouteMap, - pub outbound: RouteMap, + // Local list of hooks. + pub(crate) hooks: HookMap, + + // Map of endpoints to packet queues + pub(crate) inbound: RouteMap, + pub(crate) outbound: RouteMap, } impl Endpoint { - pub fn new(name: &'static str, leaves: Vec>) -> Self { + pub fn new(id: u32, leaves: Vec>) -> Self { Self { - name, - path: String::new(), + id, + // Init the hook at 0, which will increment + last_hook: 0, + + // Set the current path as an empty vec + path: Vec::new(), leaves, hooks: HookMap::new(), connections: ConnectionSet::new(), @@ -37,18 +51,50 @@ impl Endpoint { } } + /// Pass the endpoint state into all of the leaves pub fn update(&mut self) { - let mut self_ref = EndpointRef { - name: self.name, - path: &mut self.path, - hooks: &mut self.hooks, - connections: &mut self.connections, - inbound: &mut self.inbound, - outbound: &mut self.outbound, - }; + // Grab the leaf vec temporarily so that we can iter over self + // Apparently this only swaps out pointers + let mut leaves = core::mem::take(&mut self.leaves); - let _ = self.leaves.iter_mut().map(|leaf| { - leaf.update(&mut self_ref); - }); + for leaf in leaves.iter_mut() { + leaf.update(self); + } + + self.leaves = leaves; + } + + /// Run a function over all inbound packets with some ID then clear it. + pub fn take_inbound_clear(&mut self, path: u32, f: F) + where + F: FnMut(&Packet), + { + Self::take_clear(path, f, &mut self.inbound); + } + + /// Run a function over all outbound packets with some ID then clear it. + pub fn take_outbound_clear(&mut self, path: u32, f: F) + where + F: FnMut(&Packet), + { + Self::take_clear(path, f, &mut self.outbound); + } + + fn take_clear(path: u32, mut f: F, queue: &mut RouteMap) + where + F: FnMut(&Packet), + { + if let Some(queue) = queue.get_mut(&path) { + for packet in queue.iter() { + f(packet); + } + + queue.clear(); + } + } + + pub fn get_hook_id(&mut self) -> u16 { + self.last_hook = self.last_hook.wrapping_add(1); + self.last_hook - 1 } } diff --git a/unshell-protocol/src/endpoint/routing.rs b/unshell-protocol/src/endpoint/routing.rs new file mode 100644 index 0000000..1f0e78d --- /dev/null +++ b/unshell-protocol/src/endpoint/routing.rs @@ -0,0 +1,105 @@ +use crate::{ + endpoint::{Endpoint, error::EndpointError}, + packet::Packet, +}; + +impl Endpoint { + /// Register an inbound packet and route it + pub fn add_inbound(&mut self, packet: Packet) -> Result<(), EndpointError> { + // In case some leaf hasn't assigned the endpoint a path yet. + if self.path.is_empty() { + return Err(EndpointError::NoAbsoultePathYet); + } + + // If the packet is routed towards this endpoint + if packet.path == *self.path { + // Get the last segment of the path + let local_id = self + .path + .last() + .cloned() + .ok_or(EndpointError::IncorrectAbsolutePath)?; + + self.inbound.entry(local_id).or_default().push_back(packet); + + Ok(()) + } else { + let (next_hop, is_upward) = self.next_hop_for(&packet)?; + + if !self.connections.contains(&(next_hop, is_upward)) { + return Err(EndpointError::RouteNotExist); + } + + self.queue_outbound(packet, next_hop) + } + } + + pub fn add_outbound(&mut self, packet: Packet) -> Result<(), EndpointError> { + // In case some leaf hasn't assigned the endpoint a path yet. + if self.path.is_empty() { + return Err(EndpointError::NoAbsoultePathYet); + } + + // If this packet is routed towards this node + if packet.path == *self.path { + // Grab the last endpoint ID + let local_id = self + .path + .last() + .cloned() + .ok_or(EndpointError::IncorrectAbsolutePath)?; + + // Add it to the inbound queue + self.inbound.entry(local_id).or_default().push_back(packet); + + return Ok(()); + } + + let (next_hop, is_upward) = self.next_hop_for(&packet)?; + + if !self.connections.contains(&(next_hop, is_upward)) { + return Err(EndpointError::RouteNotExist); + } + + self.queue_outbound(packet, next_hop) + } + + fn queue_outbound(&mut self, packet: Packet, next_hop: u32) -> Result<(), EndpointError> { + if packet.end_hook { + self.hooks.remove(&packet.hook_id); + } + + self.outbound.entry(next_hop).or_default().push_back(packet); + + Ok(()) + } + + fn next_hop_for(&self, packet: &Packet) -> Result<(u32, bool), EndpointError> { + // Direction is derived from the local path. The packet never gets to declare + // whether it is moving upward, because that would make the trust boundary spoofable. + if packet.path.starts_with(&self.path) { + let next_hop = packet + .path + .get(self.path.len()) + .cloned() + .ok_or(EndpointError::IncorrectAbsolutePath)?; + + Ok((next_hop, false)) + } else if self.path.starts_with(&packet.path) { + // SECURITY: All upward-routed packets must be checked against local hook state. + if !self.hooks.contains_key(&packet.hook_id) { + return Err(EndpointError::HookNotExist); + } + + let parent_index = self + .path + .len() + .checked_sub(2) + .ok_or(EndpointError::RouteNotExist)?; + + Ok((self.path[parent_index], true)) + } else { + Err(EndpointError::IncorrectAbsolutePath) + } + } +} diff --git a/unshell-protocol/src/leaf/mod.rs b/unshell-protocol/src/leaf/mod.rs index 5e544ce..ef44761 100644 --- a/unshell-protocol/src/leaf/mod.rs +++ b/unshell-protocol/src/leaf/mod.rs @@ -1,6 +1,9 @@ -use crate::endpoint::EndpointRef; +use crate::endpoint::Endpoint; pub trait Leaf { - fn get_name(&self) -> &'static str; - fn update<'a>(&mut self, _: &mut EndpointRef<'a>); + // Identifier for this leaf + fn get_id(&self) -> u32; + + // Gets called every program loop + fn update(&mut self, _: &mut Endpoint); } diff --git a/unshell-protocol/src/lib.rs b/unshell-protocol/src/lib.rs index 70751de..a519a71 100644 --- a/unshell-protocol/src/lib.rs +++ b/unshell-protocol/src/lib.rs @@ -1,6 +1,6 @@ #![no_std] -pub extern crate alloc; +extern crate alloc; pub mod endpoint; pub mod leaf; diff --git a/unshell-protocol/src/packet/mod.rs b/unshell-protocol/src/packet/mod.rs index 6491b60..ae73129 100644 --- a/unshell-protocol/src/packet/mod.rs +++ b/unshell-protocol/src/packet/mod.rs @@ -9,9 +9,8 @@ use alloc::vec::Vec; #[derive(Debug)] pub struct Packet { pub hook_id: u16, - pub is_upwards_call: bool, pub end_hook: bool, - pub path: String, + pub path: Vec, // ── body (routers never read below this line) ── pub procedure_id: String, pub data: Vec, @@ -23,9 +22,8 @@ pub struct Packet { #[derive(Debug)] pub struct HeaderRef<'buf> { pub hook_id: u16, - pub is_upwards_call: bool, pub end_hook: bool, - pub path: &'buf str, + pub path: &'buf [u32], pub body_remainder: &'buf [u8], } @@ -47,10 +45,9 @@ pub enum DeserializeError { impl Packet { pub fn serialize(&self) -> Result, SerializeError> { - let path_bytes = self.path.as_bytes(); let proc_id_bytes = self.procedure_id.as_bytes(); - let path_len = u32::try_from(path_bytes.len()).map_err(|_| SerializeError::PathTooLarge)?; + let path_len = self.path.len() as u32; let proc_id_len = u32::try_from(proc_id_bytes.len()).map_err(|_| SerializeError::ProcIdTooLarge)?; @@ -61,16 +58,18 @@ impl Packet { .ok_or(SerializeError::BodyTooLarge)?; let body_len = u32::try_from(body_payload_len).map_err(|_| SerializeError::BodyTooLarge)?; - let total = 8 + path_bytes.len() + 4 + body_payload_len; + let total = 8 + (self.path.len() * 4) + 4 + body_payload_len; let mut buf = Vec::with_capacity(total); // ── header ──────────────────────────────────────────────────────────── - let flags = (self.is_upwards_call as u8) | ((self.end_hook as u8) << 1); + let flags = self.end_hook as u8; buf.extend_from_slice(&self.hook_id.to_le_bytes()); buf.push(flags); buf.push(0u8); // padding buf.extend_from_slice(&path_len.to_le_bytes()); - buf.extend_from_slice(path_bytes); + for &segment in &self.path { + buf.extend_from_slice(&segment.to_le_bytes()); + } // ── body ────────────────────────────────────────────────────────────── buf.extend_from_slice(&body_len.to_le_bytes()); @@ -91,25 +90,25 @@ impl Packet { let hook_id = u16::from_le_bytes([buf[0], buf[1]]); let flags = buf[2]; - let is_upwards_call = flags & 0b0000_0001 != 0; - let end_hook = flags & 0b0000_0010 != 0; + let end_hook = flags & 0b0000_0001 != 0; let path_len = u32::from_le_bytes([buf[4], buf[5], buf[6], buf[7]]) as usize; let path_start = 8usize; let path_end = path_start - .checked_add(path_len) + .checked_add(path_len * 4) .ok_or(DeserializeError::PathTooLong)?; if buf.len() < path_end { return Err(DeserializeError::BufferTooShort); } - let path = core::str::from_utf8(&buf[path_start..path_end]) - .map_err(|_| DeserializeError::InvalidUtf8)?; + // Cast the buffer slice to a u32 slice. + // This requires alignment. rkyv handles this, but for a manual cast: + let path_ptr = buf[path_start..path_end].as_ptr() as *const u32; + let path = unsafe { core::slice::from_raw_parts(path_ptr, path_len) }; Ok(HeaderRef { hook_id, - is_upwards_call, end_hook, path, body_remainder: &buf[path_end..], @@ -157,9 +156,8 @@ impl Packet { Ok(Self { hook_id: header.hook_id, - is_upwards_call: header.is_upwards_call, end_hook: header.end_hook, - path: header.path.into(), + path: header.path.to_vec(), procedure_id: procedure_id.into(), data, }) diff --git a/unshell-protocol/src/packet/tests.rs b/unshell-protocol/src/packet/tests.rs index fd4eb0d..eb0e0c5 100644 --- a/unshell-protocol/src/packet/tests.rs +++ b/unshell-protocol/src/packet/tests.rs @@ -7,17 +7,15 @@ use alloc::vec; fn make_packet() -> Packet { Packet { hook_id: 42, - is_upwards_call: true, end_hook: false, - path: "my/service/path".to_string(), + path: vec![1, 2, 3], procedure_id: "my.service.Method".to_string(), data: vec![0xDE, 0xAD, 0xBE, 0xEF], } } -fn make_packet_flags(is_upwards_call: bool, end_hook: bool) -> Packet { +fn make_packet_flags(end_hook: bool) -> Packet { Packet { - is_upwards_call, end_hook, ..make_packet() } @@ -32,7 +30,6 @@ fn full_round_trip() { let result = Packet::deserialize(&buf).unwrap(); assert_eq!(result.hook_id, packet.hook_id); - assert_eq!(result.is_upwards_call, packet.is_upwards_call); assert_eq!(result.end_hook, packet.end_hook); assert_eq!(result.path, packet.path); assert_eq!(result.procedure_id, packet.procedure_id); @@ -46,7 +43,6 @@ fn header_round_trip() { let header = Packet::deserialize_header(&buf).unwrap(); assert_eq!(header.hook_id, packet.hook_id); - assert_eq!(header.is_upwards_call, packet.is_upwards_call); assert_eq!(header.end_hook, packet.end_hook); assert_eq!(header.path, packet.path); } @@ -54,38 +50,18 @@ fn header_round_trip() { // ── Flags ───────────────────────────────────────────────────────────────── #[test] -fn flags_both_false() { - let packet = make_packet_flags(false, false); +fn flags_end_hook_false() { + let packet = make_packet_flags(false); let buf = packet.serialize().unwrap(); let header = Packet::deserialize_header(&buf).unwrap(); - assert!(!header.is_upwards_call); assert!(!header.end_hook); } #[test] -fn flags_both_true() { - let packet = make_packet_flags(true, true); +fn flags_end_hook_true() { + let packet = make_packet_flags(true); let buf = packet.serialize().unwrap(); let header = Packet::deserialize_header(&buf).unwrap(); - assert!(header.is_upwards_call); - assert!(header.end_hook); -} - -#[test] -fn flags_upwards_only() { - let packet = make_packet_flags(true, false); - let buf = packet.serialize().unwrap(); - let header = Packet::deserialize_header(&buf).unwrap(); - assert!(header.is_upwards_call); - assert!(!header.end_hook); -} - -#[test] -fn flags_end_hook_only() { - let packet = make_packet_flags(false, true); - let buf = packet.serialize().unwrap(); - let header = Packet::deserialize_header(&buf).unwrap(); - assert!(!header.is_upwards_call); assert!(header.end_hook); } @@ -94,12 +70,12 @@ fn flags_end_hook_only() { #[test] fn empty_path() { let packet = Packet { - path: "".to_string(), + path: vec![], ..make_packet() }; let buf = packet.serialize().unwrap(); let header = Packet::deserialize_header(&buf).unwrap(); - assert_eq!(header.path, ""); + assert_eq!(header.path, &[] as &[u32]); } #[test] @@ -128,16 +104,15 @@ fn empty_data() { fn all_fields_empty() { let packet = Packet { hook_id: 0, - is_upwards_call: false, end_hook: false, - path: "".to_string(), + path: vec![], procedure_id: "".to_string(), data: vec![], }; let buf = packet.serialize().unwrap(); let result = Packet::deserialize(&buf).unwrap(); assert_eq!(result.hook_id, 0); - assert_eq!(result.path, ""); + assert_eq!(result.path, Vec::::new()); assert_eq!(result.procedure_id, ""); assert_eq!(result.data, &[] as &[u8]); } @@ -149,7 +124,7 @@ fn header_path_is_borrowed_from_buffer() { let buf = make_packet().serialize().unwrap(); let header = Packet::deserialize_header(&buf).unwrap(); - let path_ptr = header.path.as_ptr(); + let path_ptr = header.path.as_ptr() as *const u8; let buf_range = buf.as_ptr_range(); assert!( buf_range.contains(&path_ptr), @@ -194,7 +169,7 @@ fn can_forward_buffer_after_header_parse() { let original = make_packet().serialize().unwrap(); let header = Packet::deserialize_header(&original).unwrap(); - assert_eq!(header.path, "my/service/path"); + assert_eq!(header.path, &[1, 2, 3]); // "Forward" by deserializing the full original buffer downstream. let forwarded = Packet::deserialize(&original).unwrap(); @@ -239,23 +214,12 @@ fn empty_buffer_rejected() { ); } -#[test] -fn invalid_utf8_in_path() { - let mut buf = make_packet().serialize().unwrap(); - // Overwrite the first byte of the path (offset 8) with an invalid UTF-8 byte. - buf[8] = 0xFF; - assert_eq!( - Packet::deserialize_header(&buf).unwrap_err(), - DeserializeError::InvalidUtf8 - ); -} - #[test] fn invalid_utf8_in_procedure_id() { let mut buf = make_packet().serialize().unwrap(); - // Find where procedure_id starts: 8 + path_len + 4 (body_len) + 4 (proc_id_len) + // Find where procedure_id starts: 8 + path_len*4 + 4 (body_len) + 4 (proc_id_len) let path_len = u32::from_le_bytes([buf[4], buf[5], buf[6], buf[7]]) as usize; - let proc_id_offset = 8 + path_len + 4 + 4; + let proc_id_offset = 8 + (path_len * 4) + 4 + 4; buf[proc_id_offset] = 0xFF; assert_eq!( Packet::deserialize(&buf).unwrap_err(), diff --git a/unshell-protocol/src/tests/mod.rs b/unshell-protocol/src/tests/mod.rs index 9274fc6..99c7b29 100644 --- a/unshell-protocol/src/tests/mod.rs +++ b/unshell-protocol/src/tests/mod.rs @@ -1,107 +1 @@ -use crate::{endpoint::EndpointRef, leaf::Leaf, packet::Packet}; - -use alloc::{ - collections::vec_deque::VecDeque, - format, - string::{String, ToString}, - vec::Vec, -}; -use crossbeam_channel::{Receiver, Sender}; - -struct ControllerLeaf { - responder_id: String, - has_run: bool, -} -struct CommsLeaf { - tx: Sender>, - rx: Receiver>, - - remote_id: String, - is_authority: bool, - started: bool, -} -struct ResponderLeaf; - -impl Leaf for ControllerLeaf { - fn get_name(&self) -> &'static str { - "ControllerLeaf" - } - - fn update<'a>(&mut self, endpoint: &mut EndpointRef<'a>) { - if !self.has_run { - endpoint.add_outbound( - self.responder_id.clone(), - Packet { - hook_id: 0, - is_upwards_call: false, - end_hook: false, - path: format!("/{}", self.responder_id), - procedure_id: "echo".to_string(), - data: "ABC123".as_bytes().to_vec(), - }, - ); - - self.has_run = true; - } - } -} - -impl Leaf for CommsLeaf { - fn get_name(&self) -> &'static str { - "CommsLeaf" - } - - fn update<'a>(&mut self, endpoint: &mut EndpointRef<'a>) { - if !self.started { - endpoint - .connections - .insert((self.remote_id.clone(), self.is_authority)); - } - - while !self.rx.is_empty() { - let packet = Packet::deserialize(&self.rx.recv().unwrap()).unwrap(); - - endpoint.add_inbound(packet).unwrap(); - } - - endpoint.take_outbound(self.get_name(), |packet| { - let data = packet.serialize().unwrap(); - self.tx.send(data).unwrap(); - }); - } -} - -impl Leaf for ResponderLeaf { - fn get_name(&self) -> &'static str { - "ResponderLeaf" - } - - fn update<'a>(&mut self, endpoint: &mut EndpointRef<'a>) { - let packets = endpoint - .inbound - .get(self.get_name()) - .unwrap_or(&VecDeque::new()) - .iter() - .map(|packet| { - // let data = ; - - Packet { - hook_id: 0, - is_upwards_call: false, - end_hook: false, - path: String::new(), - // path: packet.path.clone(), - procedure_id: "echo".to_string(), - data: packet.data.clone(), - } - }) - .collect::>(); - - for packet in packets { - endpoint.add_outbound(packet); - } - } -} - -#[test] -fn test_comms() {} +mod oneshot; diff --git a/unshell-protocol/src/tests/oneshot.rs b/unshell-protocol/src/tests/oneshot.rs new file mode 100644 index 0000000..7a5a182 --- /dev/null +++ b/unshell-protocol/src/tests/oneshot.rs @@ -0,0 +1,231 @@ +use crate::{ + endpoint::{Endpoint, error::EndpointError}, + leaf::Leaf, + packet::Packet, +}; + +use alloc::{boxed::Box, string::ToString, vec, vec::Vec}; +use crossbeam_channel::{Receiver, Sender}; + +const ENDPOINT_A: u32 = 0; +const ENDPOINT_B: u32 = 1; + +const LEAF_CONTROLLER: u32 = 100; +const LEAF_COMMS: u32 = 101; +const LEAF_RESPONDER: u32 = 102; +// const HOOK_ECHO: u16 = 500; + +fn echo_packet(path: Vec, hook_id: u16) -> Packet { + Packet { + hook_id, + end_hook: false, + path, + procedure_id: "echo".to_string(), + data: "ABC123".as_bytes().to_vec(), + } +} + +struct ControllerLeaf { + has_run: bool, +} +struct CommsLeaf { + tx: Sender>, + rx: Receiver>, + + remote_id: u32, + is_authority: bool, + started: bool, +} +struct ResponderLeaf; + +impl Leaf for ControllerLeaf { + fn get_id(&self) -> u32 { + LEAF_CONTROLLER + } + + fn update(&mut self, endpoint: &mut Endpoint) { + if !self.has_run { + // Get next free available hook id + let hook_id = endpoint.get_hook_id(); + + // Create packet + let packet = echo_packet(vec![ENDPOINT_A, ENDPOINT_B], hook_id); + + // Add packet to queue + let _ = endpoint.add_outbound(packet); + + // Don't run again + self.has_run = true; + } + } +} + +impl Leaf for CommsLeaf { + fn get_id(&self) -> u32 { + LEAF_COMMS + } + + fn update(&mut self, endpoint: &mut Endpoint) { + if !self.started { + endpoint + .connections + .insert((self.remote_id, self.is_authority)); + } + + while !self.rx.is_empty() { + let packet = Packet::deserialize(&self.rx.recv().unwrap()).unwrap(); + + let _ = endpoint.add_inbound(packet); + } + + endpoint.take_outbound_clear(self.remote_id, |packet| { + let data = packet.serialize().unwrap(); + let _ = self.tx.send(data); + }); + } +} + +impl Leaf for ResponderLeaf { + fn get_id(&self) -> u32 { + LEAF_RESPONDER + } + + fn update(&mut self, endpoint: &mut Endpoint) { + let local_id = endpoint.path.last().cloned().unwrap_or(0); + let mut packets = Vec::new(); + + endpoint.take_inbound_clear(local_id, |packet| { + let mut response = echo_packet(vec![ENDPOINT_A], packet.hook_id); + response.hook_id = packet.hook_id; + response.data = packet.data.clone(); + packets.push(response); + }); + + for packet in packets { + endpoint.hooks.insert(packet.hook_id, 0); + let _ = endpoint.add_outbound(packet); + } + } +} + +#[test] +fn test_oneshot() { + let (tx_a, rx_a) = crossbeam_channel::unbounded(); + let (tx_b, rx_b) = crossbeam_channel::unbounded(); + + let mut endpoint_a = crate::endpoint::Endpoint::new( + ENDPOINT_A, + vec![ + Box::new(ControllerLeaf { has_run: false }), + Box::new(CommsLeaf { + tx: tx_b, + rx: rx_a, + remote_id: ENDPOINT_B, + is_authority: false, + started: false, + }), + ], + ); + endpoint_a.path = vec![ENDPOINT_A]; + + let mut endpoint_b = crate::endpoint::Endpoint::new( + ENDPOINT_B, + vec![ + Box::new(ResponderLeaf), + Box::new(CommsLeaf { + tx: tx_a, + rx: rx_b, + remote_id: ENDPOINT_A, + is_authority: true, + started: false, + }), + ], + ); + endpoint_b.path = vec![ENDPOINT_A, ENDPOINT_B]; + + // Connections are registered routing state. The comms leaves also insert them + // during updates, but the first application packet should not depend on leaf order. + endpoint_a.connections.insert((ENDPOINT_B, false)); + endpoint_b.connections.insert((ENDPOINT_A, true)); + + // Cycle 1: A sends request to B + endpoint_a.update(); + endpoint_b.update(); + + // Cycle 2: B receives request and sends response to A + endpoint_b.update(); + endpoint_a.update(); + + // Cycle 3: A's CommsLeaf needs one more update to pull the packet from the channel + // and put it into the inbound queue. + endpoint_a.update(); + + // Assertions on state + assert!( + endpoint_a.inbound.contains_key(&ENDPOINT_A), + "Endpoint A should have received response" + ); + assert_eq!( + endpoint_a.inbound.get(&ENDPOINT_A).unwrap().len(), + 1, + "Endpoint A should have exactly one packet" + ); + let response = &endpoint_a + .inbound + .get(&ENDPOINT_A) + .unwrap() + .front() + .unwrap(); + assert_eq!(response.data, "ABC123".as_bytes()); + // assert_eq!(response.hook_id, HOOK_ECHO); +} + +#[test] +fn upward_outbound_without_hook_is_rejected() { + let mut endpoint = Endpoint::new(ENDPOINT_B, vec![]); + endpoint.path = vec![ENDPOINT_A, ENDPOINT_B]; + endpoint.connections.insert((ENDPOINT_A, true)); + + let new_hook = endpoint.get_hook_id(); + + let error = endpoint + .add_outbound(echo_packet(vec![ENDPOINT_A], new_hook)) + .unwrap_err(); + + assert!(matches!(error, EndpointError::HookNotExist)); + assert!(endpoint.outbound.is_empty()); +} + +#[test] +fn downward_outbound_without_hook_is_allowed() { + let mut endpoint = crate::endpoint::Endpoint::new(ENDPOINT_A, vec![]); + endpoint.path = vec![ENDPOINT_A]; + endpoint.connections.insert((ENDPOINT_B, false)); + + let new_hook = endpoint.get_hook_id(); + + endpoint + .add_outbound(echo_packet(vec![ENDPOINT_A, ENDPOINT_B], new_hook)) + .unwrap(); + + assert_eq!(endpoint.outbound.get(&ENDPOINT_B).unwrap().len(), 1); +} + +#[test] +fn deeper_upward_route_uses_parent_as_next_hop() { + const ENDPOINT_C: u32 = 2; + + let mut endpoint = crate::endpoint::Endpoint::new(ENDPOINT_C, vec![]); + let new_hook = endpoint.get_hook_id(); + + endpoint.path = vec![ENDPOINT_A, ENDPOINT_B, ENDPOINT_C]; + endpoint.hooks.insert(new_hook, ENDPOINT_A); + endpoint.connections.insert((ENDPOINT_B, true)); + + endpoint + .add_outbound(echo_packet(vec![ENDPOINT_A], new_hook)) + .unwrap(); + + assert!(endpoint.outbound.contains_key(&ENDPOINT_B)); + assert!(!endpoint.outbound.contains_key(&ENDPOINT_A)); +} diff --git a/unshell-protocol/src/types.rs b/unshell-protocol/src/types.rs index fab1a94..3d078ab 100644 --- a/unshell-protocol/src/types.rs +++ b/unshell-protocol/src/types.rs @@ -1,12 +1,12 @@ use alloc::{ collections::{btree_map::BTreeMap, btree_set::BTreeSet, vec_deque::VecDeque}, - string::String, + vec::Vec, }; use crate::packet::Packet; -pub type Path = String; -pub type EndpointName = String; +pub type Path = Vec; +pub type EndpointName = u32; pub type HookID = u16; pub type ConnectionSet = BTreeSet<(EndpointName, bool)>; pub type HookMap = BTreeMap; From 65a7f675a9940649a2a860e27f7b268fb13df932 Mon Sep 17 00:00:00 2001 From: Michael Mikovsky <77305074+Astatin3@users.noreply.github.com> Date: Thu, 28 May 2026 12:41:32 -0600 Subject: [PATCH 17/31] Make new error structs, improve tests, remake file structure. --- src/lib.rs | 37 +- unshell-protocol/src/endpoint/error.rs | 9 - unshell-protocol/src/endpoint/mod.rs | 7 +- unshell-protocol/src/endpoint/routing.rs | 164 +++--- unshell-protocol/src/error.rs | 134 +++++ unshell-protocol/src/leaf/mod.rs | 9 - unshell-protocol/src/lib.rs | 38 +- .../src/{packet/mod.rs => packet.rs} | 21 +- unshell-protocol/src/tests/mod.rs | 1 - unshell-protocol/src/tests/oneshot.rs | 231 --------- unshell-protocol/src/tests/oneshot/mod.rs | 472 ++++++++++++++++++ unshell-protocol/src/tests/oneshot/support.rs | 176 +++++++ .../src/{packet/tests.rs => tests/packet.rs} | 30 +- unshell-protocol/src/types.rs | 14 - 14 files changed, 958 insertions(+), 385 deletions(-) delete mode 100644 unshell-protocol/src/endpoint/error.rs create mode 100644 unshell-protocol/src/error.rs delete mode 100644 unshell-protocol/src/leaf/mod.rs rename unshell-protocol/src/{packet/mod.rs => packet.rs} (95%) delete mode 100644 unshell-protocol/src/tests/mod.rs delete mode 100644 unshell-protocol/src/tests/oneshot.rs create mode 100644 unshell-protocol/src/tests/oneshot/mod.rs create mode 100644 unshell-protocol/src/tests/oneshot/support.rs rename unshell-protocol/src/{packet/tests.rs => tests/packet.rs} (91%) delete mode 100644 unshell-protocol/src/types.rs diff --git a/src/lib.rs b/src/lib.rs index 781d470..3f7a827 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -23,10 +23,12 @@ pub mod logger; /// proc-macro output and downstream code do not need a second migration. pub use unshell_protocol as protocol; -/// Re-export the leaf library crate behind the historical `unshell::leaves` path +// Re-export the leaf library crate behind the historical `unshell::leaves` path +// once the leaf crate is part of the active workspace again. // pub use unshell_leaves as leaves; -/// Re-export the runtime crate behind the `unshell::runtime` path. +// Re-export the runtime crate behind the `unshell::runtime` path once the runtime +// crate is part of the active workspace again. // pub use unshell_runtime as runtime; // pub use unshell_macros::{Procedure, leaf, procedures}; @@ -40,21 +42,22 @@ pub use unshell_protocol as protocol; /// Why it exists: the common bootstrap case should not require callers to manually construct an /// empty path, `Vec`, and a `Vec` when they already have leaf host values. /// -// # Example -// ```rust -// use unshell::{create_endpoint, leaf}; -// use unshell::protocol::tree::Endpoint; - -// #[derive(Default)] -// struct DemoLeaf; - -// #[leaf(id = "org.example.v1.demo", procedures = ["ping"], endpoint_struct = DemoLeaf)] -// struct Demo; - -// let endpoint = create_endpoint!("demo", DemoLeaf::default()); -// assert!(endpoint.path().is_empty()); -// assert_eq!(endpoint.local_id(), Some("demo")); -// ``` +/// # Example +/// +/// ```rust,ignore +/// use unshell::{create_endpoint, leaf}; +/// use unshell::protocol::tree::Endpoint; +/// +/// #[derive(Default)] +/// struct DemoLeaf; +/// +/// #[leaf(id = "org.example.v1.demo", procedures = ["ping"], endpoint_struct = DemoLeaf)] +/// struct Demo; +/// +/// let endpoint = create_endpoint!("demo", DemoLeaf::default()); +/// assert!(endpoint.path().is_empty()); +/// assert_eq!(endpoint.local_id(), Some("demo")); +/// ``` #[macro_export] macro_rules! create_endpoint { ($id:expr $(, $leaf:expr )* $(,)?) => {{ diff --git a/unshell-protocol/src/endpoint/error.rs b/unshell-protocol/src/endpoint/error.rs deleted file mode 100644 index 0a18ee7..0000000 --- a/unshell-protocol/src/endpoint/error.rs +++ /dev/null @@ -1,9 +0,0 @@ -#[derive(Debug)] -pub enum EndpointError { - NoAbsoultePathYet, - IncorrectAbsolutePath, - - RouteNotExist, - HookDuplicate, - HookNotExist, -} diff --git a/unshell-protocol/src/endpoint/mod.rs b/unshell-protocol/src/endpoint/mod.rs index 185ebd8..b525013 100644 --- a/unshell-protocol/src/endpoint/mod.rs +++ b/unshell-protocol/src/endpoint/mod.rs @@ -1,13 +1,8 @@ -pub mod error; mod routing; use alloc::{boxed::Box, vec::Vec}; -use crate::{ - leaf::Leaf, - packet::Packet, - types::{ConnectionSet, HookMap, Path, RouteMap}, -}; +use crate::{ConnectionSet, HookMap, Leaf, Packet, Path, RouteMap}; pub struct Endpoint { // This endpoint's identifier diff --git a/unshell-protocol/src/endpoint/routing.rs b/unshell-protocol/src/endpoint/routing.rs index 1f0e78d..1bc419b 100644 --- a/unshell-protocol/src/endpoint/routing.rs +++ b/unshell-protocol/src/endpoint/routing.rs @@ -1,105 +1,127 @@ -use crate::{ - endpoint::{Endpoint, error::EndpointError}, - packet::Packet, -}; +use crate::{Endpoint, EndpointError, Packet, RouteDirection}; impl Endpoint { - /// Register an inbound packet and route it + /// Register an inbound packet and route it through the local endpoint state. + /// + /// Inbound transport data still uses the same local routing rules as packets + /// generated by leaves: local destinations are delivered to `inbound`, and + /// transit destinations are queued by their immediate next hop. pub fn add_inbound(&mut self, packet: Packet) -> Result<(), EndpointError> { - // In case some leaf hasn't assigned the endpoint a path yet. - if self.path.is_empty() { - return Err(EndpointError::NoAbsoultePathYet); - } - - // If the packet is routed towards this endpoint - if packet.path == *self.path { - // Get the last segment of the path - let local_id = self - .path - .last() - .cloned() - .ok_or(EndpointError::IncorrectAbsolutePath)?; - - self.inbound.entry(local_id).or_default().push_back(packet); - - Ok(()) - } else { - let (next_hop, is_upward) = self.next_hop_for(&packet)?; - - if !self.connections.contains(&(next_hop, is_upward)) { - return Err(EndpointError::RouteNotExist); - } - - self.queue_outbound(packet, next_hop) - } + self.route_packet(packet) } + /// Register an outbound packet produced locally and route it to the next queue. + /// + /// This intentionally shares the same implementation as [`Self::add_inbound`] + /// so local leaf output and received transport packets cannot drift into subtly + /// different route semantics. pub fn add_outbound(&mut self, packet: Packet) -> Result<(), EndpointError> { - // In case some leaf hasn't assigned the endpoint a path yet. - if self.path.is_empty() { - return Err(EndpointError::NoAbsoultePathYet); - } + self.route_packet(packet) + } - // If this packet is routed towards this node - if packet.path == *self.path { - // Grab the last endpoint ID + /// Route a packet by classifying its destination and mutating exactly one queue. + /// + /// Hook cleanup is deliberately last. A packet with `end_hook = true` should not + /// tear down local hook state unless the packet has a valid route and is actually + /// queued for forwarding. The route branches are kept inline rather than using + /// an intermediate decision enum so size-focused builds have less structure to + /// optimize away. + fn route_packet(&mut self, packet: Packet) -> Result<(), EndpointError> { + self.ensure_path_is_set()?; + + if packet.path == self.path { let local_id = self .path .last() - .cloned() - .ok_or(EndpointError::IncorrectAbsolutePath)?; + .copied() + .ok_or(EndpointError::EndpointPathUnset)?; - // Add it to the inbound queue self.inbound.entry(local_id).or_default().push_back(packet); - return Ok(()); } - let (next_hop, is_upward) = self.next_hop_for(&packet)?; - - if !self.connections.contains(&(next_hop, is_upward)) { - return Err(EndpointError::RouteNotExist); - } - - self.queue_outbound(packet, next_hop) - } - - fn queue_outbound(&mut self, packet: Packet, next_hop: u32) -> Result<(), EndpointError> { - if packet.end_hook { - self.hooks.remove(&packet.hook_id); - } - - self.outbound.entry(next_hop).or_default().push_back(packet); - - Ok(()) - } - - fn next_hop_for(&self, packet: &Packet) -> Result<(u32, bool), EndpointError> { // Direction is derived from the local path. The packet never gets to declare // whether it is moving upward, because that would make the trust boundary spoofable. if packet.path.starts_with(&self.path) { let next_hop = packet .path .get(self.path.len()) - .cloned() - .ok_or(EndpointError::IncorrectAbsolutePath)?; + .copied() + .ok_or(EndpointError::DestinationOutsideLocalTree)?; - Ok((next_hop, false)) - } else if self.path.starts_with(&packet.path) { - // SECURITY: All upward-routed packets must be checked against local hook state. + self.ensure_registered_connection(next_hop, RouteDirection::Downward)?; + self.queue_outbound(packet, next_hop, RouteDirection::Downward); + return Ok(()); + } + + if self.path.starts_with(&packet.path) { + // Upward-routed packets must be tied to local hook state. Otherwise a + // peer could forge a packet to an ancestor by choosing an older path. if !self.hooks.contains_key(&packet.hook_id) { - return Err(EndpointError::HookNotExist); + return Err(EndpointError::UnknownHook { + hook_id: packet.hook_id, + }); } let parent_index = self .path .len() .checked_sub(2) - .ok_or(EndpointError::RouteNotExist)?; + .ok_or(EndpointError::MissingParentRoute)?; - Ok((self.path[parent_index], true)) + let next_hop = self.path[parent_index]; + self.ensure_registered_connection(next_hop, RouteDirection::Upward)?; + self.queue_outbound(packet, next_hop, RouteDirection::Upward); + return Ok(()); + } + + Err(EndpointError::DestinationOutsideLocalTree) + } + + /// Reject routing before path-relative decisions when no absolute path is known. + /// + /// This preserves the current runtime sentinel where an empty path means the + /// endpoint has not been attached to the tree yet. + fn ensure_path_is_set(&self) -> Result<(), EndpointError> { + if self.path.is_empty() { + Err(EndpointError::EndpointPathUnset) } else { - Err(EndpointError::IncorrectAbsolutePath) + Ok(()) } } + + /// Verify that the derived adjacent endpoint is registered in this direction. + /// + /// The current connection table stores direction as a boolean. Keeping the bool + /// conversion here confines that legacy representation to one place in routing. + fn ensure_registered_connection( + &self, + next_hop: u32, + direction: RouteDirection, + ) -> Result<(), EndpointError> { + let is_upward = matches!(direction, RouteDirection::Upward); + + if self.connections.contains(&(next_hop, is_upward)) { + Ok(()) + } else { + Err(EndpointError::MissingConnection { + next_hop, + direction, + }) + } + } + + /// Queue `packet` after all route validation has already succeeded. + /// + /// `end_hook` closes local hook state only when hook traffic is moving upward + /// toward the hook host. Downward calls may carry a response hook id, but that + /// id is only a promise for future upward traffic and must not delete local + /// state if it happens to collide with an existing hook id. + fn queue_outbound(&mut self, packet: Packet, next_hop: u32, direction: RouteDirection) { + if matches!(direction, RouteDirection::Upward) && packet.end_hook { + self.hooks.remove(&packet.hook_id); + } + + self.outbound.entry(next_hop).or_default().push_back(packet); + } } diff --git a/unshell-protocol/src/error.rs b/unshell-protocol/src/error.rs new file mode 100644 index 0000000..43f6858 --- /dev/null +++ b/unshell-protocol/src/error.rs @@ -0,0 +1,134 @@ +/// Direction across the next local routing boundary. +/// +/// The endpoint derives this from its own absolute path and the packet's +/// destination path. Packets are never trusted to declare their direction because +/// that would let an untrusted peer spoof the local routing boundary. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RouteDirection { + /// The packet moves toward this endpoint's direct parent. + Upward, + + /// The packet moves toward one of this endpoint's direct children. + Downward, +} + +/// Top-level endpoint failure for packet conversion and local routing. +/// +/// These are local processing failures, not protocol fault packets. A transport or +/// leaf may choose to drop the packet, log it, or translate it into a higher-level +/// fault depending on where the packet came from. Route variants stay flat so the +/// hot route path does not need a second nested enum just to explain the failure. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EndpointError { + /// This endpoint cannot route because its absolute path has not been assigned. + /// + /// The current runtime uses an empty path as "not initialized". If the protocol + /// later supports an empty root path, route initialization should become an + /// explicit flag instead of being inferred from `path.is_empty()`. + EndpointPathUnset, + + /// The packet destination is not local, below this endpoint, or above this endpoint. + /// + /// This catches sideways or forged paths, for example local `/a/b` receiving a + /// packet addressed to `/a/c`. + DestinationOutsideLocalTree, + + /// A route points upward, but this endpoint has no parent segment to forward to. + /// + /// This means the path topology is internally inconsistent for upward routing. + MissingParentRoute, + + /// The packet needs a registered connection for the computed next hop, but none exists. + /// + /// Route derivation succeeded. Delivery fails only because the local connection + /// table does not contain the adjacent endpoint in the required direction. + MissingConnection { + /// Adjacent endpoint that should receive the packet next. + next_hop: u32, + + /// Direction that the local connection must be registered for. + direction: RouteDirection, + }, + + /// The packet is trying to move upward without known hook state. + /// + /// Upward hook traffic is gated by local hook state so a peer cannot forge a + /// return path just by choosing an ancestor destination. + UnknownHook { + /// Hook id claimed by the upward packet. + hook_id: u16, + }, + + /// A packet could not be converted into bytes for transport. + /// + /// Endpoint-level code that drains outbound queues often wants one error type + /// for both routing and framing. Keeping the source error preserves the exact + /// packet-size invariant that failed. + PacketSerialize { + /// Exact packet serialization failure. + source: SerializeError, + }, + + /// Incoming bytes could not be parsed into a packet. + /// + /// This represents a frame rejection before routing begins. The source error is + /// retained so callers can distinguish truncation from malformed body fields. + PacketDeserialize { + /// Exact packet deserialization failure. + source: DeserializeError, + }, +} + +/// Errors produced while converting a [`Packet`] into its wire representation. +/// +/// These failures are size-bound checks rather than transport errors. They protect +/// the length fields in the frame from integer overflow or values that cannot be +/// represented by the protocol's current `u32` length fields. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SerializeError { + /// The packet path contains more bytes than the frame length field can represent. + PathTooLarge, + + /// The procedure identifier is too large to encode in a `u32` length field. + ProcIdTooLarge, + + /// The body section is too large to encode in a `u32` length field. + BodyTooLarge, +} + +/// Errors produced while parsing a [`Packet`] from untrusted wire bytes. +/// +/// Deserialization rejects partial, inconsistent, or invalid UTF-8 frames before +/// endpoint routing sees them. Keeping these separate from route failures makes it +/// clear whether a packet failed before or after it became structured data. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DeserializeError { + /// The buffer ended before the parser could read the required field. + BufferTooShort, + + /// The advertised body length does not fit inside the provided buffer. + BodyLengthMismatch, + + /// The path length overflowed while computing the path byte range. + PathTooLong, + + /// The procedure id length overflowed while computing the body byte range. + ProcIdTooLong, + + /// The encoded procedure id was not valid UTF-8. + InvalidUtf8, +} + +impl From for EndpointError { + /// Wraps packet serialization failures for endpoint-level callers. + fn from(source: SerializeError) -> Self { + Self::PacketSerialize { source } + } +} + +impl From for EndpointError { + /// Wraps packet deserialization failures for endpoint-level callers. + fn from(source: DeserializeError) -> Self { + Self::PacketDeserialize { source } + } +} diff --git a/unshell-protocol/src/leaf/mod.rs b/unshell-protocol/src/leaf/mod.rs deleted file mode 100644 index ef44761..0000000 --- a/unshell-protocol/src/leaf/mod.rs +++ /dev/null @@ -1,9 +0,0 @@ -use crate::endpoint::Endpoint; - -pub trait Leaf { - // Identifier for this leaf - fn get_id(&self) -> u32; - - // Gets called every program loop - fn update(&mut self, _: &mut Endpoint); -} diff --git a/unshell-protocol/src/lib.rs b/unshell-protocol/src/lib.rs index a519a71..f8bfbd2 100644 --- a/unshell-protocol/src/lib.rs +++ b/unshell-protocol/src/lib.rs @@ -2,10 +2,38 @@ extern crate alloc; -pub mod endpoint; -pub mod leaf; -pub mod packet; -mod types; +mod endpoint; +mod error; +mod packet; + +pub use endpoint::Endpoint; +pub use error::*; +pub use packet::Packet; + +pub trait Leaf { + // Identifier for this leaf + fn get_id(&self) -> u32; + + // Gets called every program loop + fn update(&mut self, _: &mut Endpoint); +} + +// Various named types used for brevity +use alloc::{ + collections::{btree_map::BTreeMap, btree_set::BTreeSet, vec_deque::VecDeque}, + vec::Vec, +}; + +type Path = Vec; +type EndpointName = u32; +type HookID = u16; +type ConnectionSet = BTreeSet<(EndpointName, bool)>; +type HookMap = BTreeMap; +type PacketQueue = VecDeque; +type RouteMap = BTreeMap; #[cfg(test)] -mod tests; +mod tests { + mod oneshot; + mod packet; +} diff --git a/unshell-protocol/src/packet/mod.rs b/unshell-protocol/src/packet.rs similarity index 95% rename from unshell-protocol/src/packet/mod.rs rename to unshell-protocol/src/packet.rs index ae73129..7f5926a 100644 --- a/unshell-protocol/src/packet/mod.rs +++ b/unshell-protocol/src/packet.rs @@ -1,11 +1,10 @@ -#[cfg(test)] -mod tests; - extern crate alloc; use alloc::string::String; use alloc::vec::Vec; +use crate::{DeserializeError, SerializeError}; + #[derive(Debug)] pub struct Packet { pub hook_id: u16, @@ -27,22 +26,6 @@ pub struct HeaderRef<'buf> { pub body_remainder: &'buf [u8], } -#[derive(Debug)] -pub enum SerializeError { - PathTooLarge, - ProcIdTooLarge, - BodyTooLarge, -} - -#[derive(Debug, PartialEq)] -pub enum DeserializeError { - BufferTooShort, - BodyLengthMismatch, - PathTooLong, - ProcIdTooLong, - InvalidUtf8, -} - impl Packet { pub fn serialize(&self) -> Result, SerializeError> { let proc_id_bytes = self.procedure_id.as_bytes(); diff --git a/unshell-protocol/src/tests/mod.rs b/unshell-protocol/src/tests/mod.rs deleted file mode 100644 index 99c7b29..0000000 --- a/unshell-protocol/src/tests/mod.rs +++ /dev/null @@ -1 +0,0 @@ -mod oneshot; diff --git a/unshell-protocol/src/tests/oneshot.rs b/unshell-protocol/src/tests/oneshot.rs deleted file mode 100644 index 7a5a182..0000000 --- a/unshell-protocol/src/tests/oneshot.rs +++ /dev/null @@ -1,231 +0,0 @@ -use crate::{ - endpoint::{Endpoint, error::EndpointError}, - leaf::Leaf, - packet::Packet, -}; - -use alloc::{boxed::Box, string::ToString, vec, vec::Vec}; -use crossbeam_channel::{Receiver, Sender}; - -const ENDPOINT_A: u32 = 0; -const ENDPOINT_B: u32 = 1; - -const LEAF_CONTROLLER: u32 = 100; -const LEAF_COMMS: u32 = 101; -const LEAF_RESPONDER: u32 = 102; -// const HOOK_ECHO: u16 = 500; - -fn echo_packet(path: Vec, hook_id: u16) -> Packet { - Packet { - hook_id, - end_hook: false, - path, - procedure_id: "echo".to_string(), - data: "ABC123".as_bytes().to_vec(), - } -} - -struct ControllerLeaf { - has_run: bool, -} -struct CommsLeaf { - tx: Sender>, - rx: Receiver>, - - remote_id: u32, - is_authority: bool, - started: bool, -} -struct ResponderLeaf; - -impl Leaf for ControllerLeaf { - fn get_id(&self) -> u32 { - LEAF_CONTROLLER - } - - fn update(&mut self, endpoint: &mut Endpoint) { - if !self.has_run { - // Get next free available hook id - let hook_id = endpoint.get_hook_id(); - - // Create packet - let packet = echo_packet(vec![ENDPOINT_A, ENDPOINT_B], hook_id); - - // Add packet to queue - let _ = endpoint.add_outbound(packet); - - // Don't run again - self.has_run = true; - } - } -} - -impl Leaf for CommsLeaf { - fn get_id(&self) -> u32 { - LEAF_COMMS - } - - fn update(&mut self, endpoint: &mut Endpoint) { - if !self.started { - endpoint - .connections - .insert((self.remote_id, self.is_authority)); - } - - while !self.rx.is_empty() { - let packet = Packet::deserialize(&self.rx.recv().unwrap()).unwrap(); - - let _ = endpoint.add_inbound(packet); - } - - endpoint.take_outbound_clear(self.remote_id, |packet| { - let data = packet.serialize().unwrap(); - let _ = self.tx.send(data); - }); - } -} - -impl Leaf for ResponderLeaf { - fn get_id(&self) -> u32 { - LEAF_RESPONDER - } - - fn update(&mut self, endpoint: &mut Endpoint) { - let local_id = endpoint.path.last().cloned().unwrap_or(0); - let mut packets = Vec::new(); - - endpoint.take_inbound_clear(local_id, |packet| { - let mut response = echo_packet(vec![ENDPOINT_A], packet.hook_id); - response.hook_id = packet.hook_id; - response.data = packet.data.clone(); - packets.push(response); - }); - - for packet in packets { - endpoint.hooks.insert(packet.hook_id, 0); - let _ = endpoint.add_outbound(packet); - } - } -} - -#[test] -fn test_oneshot() { - let (tx_a, rx_a) = crossbeam_channel::unbounded(); - let (tx_b, rx_b) = crossbeam_channel::unbounded(); - - let mut endpoint_a = crate::endpoint::Endpoint::new( - ENDPOINT_A, - vec![ - Box::new(ControllerLeaf { has_run: false }), - Box::new(CommsLeaf { - tx: tx_b, - rx: rx_a, - remote_id: ENDPOINT_B, - is_authority: false, - started: false, - }), - ], - ); - endpoint_a.path = vec![ENDPOINT_A]; - - let mut endpoint_b = crate::endpoint::Endpoint::new( - ENDPOINT_B, - vec![ - Box::new(ResponderLeaf), - Box::new(CommsLeaf { - tx: tx_a, - rx: rx_b, - remote_id: ENDPOINT_A, - is_authority: true, - started: false, - }), - ], - ); - endpoint_b.path = vec![ENDPOINT_A, ENDPOINT_B]; - - // Connections are registered routing state. The comms leaves also insert them - // during updates, but the first application packet should not depend on leaf order. - endpoint_a.connections.insert((ENDPOINT_B, false)); - endpoint_b.connections.insert((ENDPOINT_A, true)); - - // Cycle 1: A sends request to B - endpoint_a.update(); - endpoint_b.update(); - - // Cycle 2: B receives request and sends response to A - endpoint_b.update(); - endpoint_a.update(); - - // Cycle 3: A's CommsLeaf needs one more update to pull the packet from the channel - // and put it into the inbound queue. - endpoint_a.update(); - - // Assertions on state - assert!( - endpoint_a.inbound.contains_key(&ENDPOINT_A), - "Endpoint A should have received response" - ); - assert_eq!( - endpoint_a.inbound.get(&ENDPOINT_A).unwrap().len(), - 1, - "Endpoint A should have exactly one packet" - ); - let response = &endpoint_a - .inbound - .get(&ENDPOINT_A) - .unwrap() - .front() - .unwrap(); - assert_eq!(response.data, "ABC123".as_bytes()); - // assert_eq!(response.hook_id, HOOK_ECHO); -} - -#[test] -fn upward_outbound_without_hook_is_rejected() { - let mut endpoint = Endpoint::new(ENDPOINT_B, vec![]); - endpoint.path = vec![ENDPOINT_A, ENDPOINT_B]; - endpoint.connections.insert((ENDPOINT_A, true)); - - let new_hook = endpoint.get_hook_id(); - - let error = endpoint - .add_outbound(echo_packet(vec![ENDPOINT_A], new_hook)) - .unwrap_err(); - - assert!(matches!(error, EndpointError::HookNotExist)); - assert!(endpoint.outbound.is_empty()); -} - -#[test] -fn downward_outbound_without_hook_is_allowed() { - let mut endpoint = crate::endpoint::Endpoint::new(ENDPOINT_A, vec![]); - endpoint.path = vec![ENDPOINT_A]; - endpoint.connections.insert((ENDPOINT_B, false)); - - let new_hook = endpoint.get_hook_id(); - - endpoint - .add_outbound(echo_packet(vec![ENDPOINT_A, ENDPOINT_B], new_hook)) - .unwrap(); - - assert_eq!(endpoint.outbound.get(&ENDPOINT_B).unwrap().len(), 1); -} - -#[test] -fn deeper_upward_route_uses_parent_as_next_hop() { - const ENDPOINT_C: u32 = 2; - - let mut endpoint = crate::endpoint::Endpoint::new(ENDPOINT_C, vec![]); - let new_hook = endpoint.get_hook_id(); - - endpoint.path = vec![ENDPOINT_A, ENDPOINT_B, ENDPOINT_C]; - endpoint.hooks.insert(new_hook, ENDPOINT_A); - endpoint.connections.insert((ENDPOINT_B, true)); - - endpoint - .add_outbound(echo_packet(vec![ENDPOINT_A], new_hook)) - .unwrap(); - - assert!(endpoint.outbound.contains_key(&ENDPOINT_B)); - assert!(!endpoint.outbound.contains_key(&ENDPOINT_A)); -} diff --git a/unshell-protocol/src/tests/oneshot/mod.rs b/unshell-protocol/src/tests/oneshot/mod.rs new file mode 100644 index 0000000..93fc793 --- /dev/null +++ b/unshell-protocol/src/tests/oneshot/mod.rs @@ -0,0 +1,472 @@ +mod support; + +use crate::{Endpoint, EndpointError, RouteDirection}; + +use alloc::{boxed::Box, vec}; + +use support::{ + CommsLeaf, ControllerLeaf, ENDPOINT_A, ENDPOINT_B, ENDPOINT_C, ResponderLeaf, + assert_hook_present, assert_hook_removed, echo_packet, endpoint_at, single_inbound_packet, + single_outbound_packet, +}; + +#[test] +fn test_oneshot() { + let (tx_a, rx_a) = crossbeam_channel::unbounded(); + let (tx_b, rx_b) = crossbeam_channel::unbounded(); + + let mut endpoint_a = crate::endpoint::Endpoint::new( + ENDPOINT_A, + vec![ + Box::new(ControllerLeaf { has_run: false }), + Box::new(CommsLeaf { + tx: tx_b, + rx: rx_a, + remote_id: ENDPOINT_B, + is_authority: false, + started: false, + }), + ], + ); + endpoint_a.path = vec![ENDPOINT_A]; + + let mut endpoint_b = crate::endpoint::Endpoint::new( + ENDPOINT_B, + vec![ + Box::new(ResponderLeaf), + Box::new(CommsLeaf { + tx: tx_a, + rx: rx_b, + remote_id: ENDPOINT_A, + is_authority: true, + started: false, + }), + ], + ); + endpoint_b.path = vec![ENDPOINT_A, ENDPOINT_B]; + + // Connections are registered routing state. The comms leaves also insert them + // during updates, but the first application packet should not depend on leaf order. + endpoint_a.connections.insert((ENDPOINT_B, false)); + endpoint_b.connections.insert((ENDPOINT_A, true)); + + // Cycle 1: A sends request to B + endpoint_a.update(); + endpoint_b.update(); + + // Cycle 2: B receives request and sends response to A + endpoint_b.update(); + endpoint_a.update(); + + // Cycle 3: A's CommsLeaf needs one more update to pull the packet from the channel + // and put it into the inbound queue. + endpoint_a.update(); + + // Assertions on state + assert!( + endpoint_a.inbound.contains_key(&ENDPOINT_A), + "Endpoint A should have received response" + ); + assert_eq!( + endpoint_a.inbound.get(&ENDPOINT_A).unwrap().len(), + 1, + "Endpoint A should have exactly one packet" + ); + let response = &endpoint_a + .inbound + .get(&ENDPOINT_A) + .unwrap() + .front() + .unwrap(); + assert!(response.end_hook); + assert_eq!(response.data, "ABC123".as_bytes()); + assert!( + endpoint_b.hooks.is_empty(), + "responder hook should be cleaned after the upward response" + ); + // assert_eq!(response.hook_id, HOOK_ECHO); +} + +#[test] +fn inbound_packet_for_local_endpoint_is_delivered_locally() { + let mut endpoint = endpoint_at(ENDPOINT_B, vec![ENDPOINT_A, ENDPOINT_B]); + let hook_id = endpoint.get_hook_id(); + endpoint.hooks.insert(hook_id, ENDPOINT_A); + + endpoint + .add_inbound(echo_packet(vec![ENDPOINT_A, ENDPOINT_B], hook_id)) + .unwrap(); + + let packet = single_inbound_packet(&endpoint, ENDPOINT_B); + assert!(packet.end_hook); + assert_eq!(packet.path, vec![ENDPOINT_A, ENDPOINT_B]); + assert_hook_present(&endpoint, hook_id); + assert!(endpoint.outbound.is_empty()); +} + +#[test] +fn outbound_packet_for_local_endpoint_is_delivered_locally() { + let mut endpoint = endpoint_at(ENDPOINT_B, vec![ENDPOINT_A, ENDPOINT_B]); + let hook_id = endpoint.get_hook_id(); + endpoint.hooks.insert(hook_id, ENDPOINT_A); + + endpoint + .add_outbound(echo_packet(vec![ENDPOINT_A, ENDPOINT_B], hook_id)) + .unwrap(); + + let packet = single_inbound_packet(&endpoint, ENDPOINT_B); + assert!(packet.end_hook); + assert_eq!(packet.data, "ABC123".as_bytes()); + assert_hook_present(&endpoint, hook_id); + assert!(endpoint.outbound.is_empty()); +} + +#[test] +fn inbound_downward_packet_routes_to_immediate_child() { + let mut endpoint = endpoint_at(ENDPOINT_A, vec![ENDPOINT_A]); + let hook_id = endpoint.get_hook_id(); + endpoint.hooks.insert(hook_id, ENDPOINT_B); + endpoint.connections.insert((ENDPOINT_B, false)); + + endpoint + .add_inbound(echo_packet( + vec![ENDPOINT_A, ENDPOINT_B, ENDPOINT_C], + hook_id, + )) + .unwrap(); + + let packet = single_outbound_packet(&endpoint, ENDPOINT_B); + assert!(packet.end_hook); + assert_eq!(packet.path, vec![ENDPOINT_A, ENDPOINT_B, ENDPOINT_C]); + assert_hook_present(&endpoint, hook_id); + assert!(!endpoint.outbound.contains_key(&ENDPOINT_C)); +} + +#[test] +fn outbound_downward_packet_routes_to_immediate_child() { + let mut endpoint = endpoint_at(ENDPOINT_A, vec![ENDPOINT_A]); + let hook_id = endpoint.get_hook_id(); + endpoint.hooks.insert(hook_id, ENDPOINT_B); + endpoint.connections.insert((ENDPOINT_B, false)); + + endpoint + .add_outbound(echo_packet( + vec![ENDPOINT_A, ENDPOINT_B, ENDPOINT_C], + hook_id, + )) + .unwrap(); + + let packet = single_outbound_packet(&endpoint, ENDPOINT_B); + assert!(packet.end_hook); + assert_eq!(packet.path, vec![ENDPOINT_A, ENDPOINT_B, ENDPOINT_C]); + assert_hook_present(&endpoint, hook_id); + assert!(!endpoint.outbound.contains_key(&ENDPOINT_C)); +} + +#[test] +fn inbound_upward_packet_with_hook_routes_to_parent() { + let mut endpoint = endpoint_at(ENDPOINT_C, vec![ENDPOINT_A, ENDPOINT_B, ENDPOINT_C]); + let hook_id = endpoint.get_hook_id(); + endpoint.hooks.insert(hook_id, ENDPOINT_A); + endpoint.connections.insert((ENDPOINT_B, true)); + + endpoint + .add_inbound(echo_packet(vec![ENDPOINT_A], hook_id)) + .unwrap(); + + let packet = single_outbound_packet(&endpoint, ENDPOINT_B); + assert!(packet.end_hook); + assert_eq!(packet.hook_id, hook_id); + assert_hook_removed(&endpoint, hook_id); + assert!(!endpoint.outbound.contains_key(&ENDPOINT_A)); +} + +#[test] +fn inbound_upward_packet_without_hook_is_rejected() { + let mut endpoint = endpoint_at(ENDPOINT_B, vec![ENDPOINT_A, ENDPOINT_B]); + let hook_id = endpoint.get_hook_id(); + endpoint.connections.insert((ENDPOINT_A, true)); + + let error = endpoint + .add_inbound(echo_packet(vec![ENDPOINT_A], hook_id)) + .unwrap_err(); + + assert!(matches!( + error, + EndpointError::UnknownHook { hook_id: observed_hook_id } if observed_hook_id == hook_id + )); + assert!(endpoint.inbound.is_empty()); + assert!(endpoint.outbound.is_empty()); +} + +#[test] +fn forged_upward_packet_with_unknown_hook_is_rejected() { + let mut endpoint = endpoint_at(ENDPOINT_C, vec![ENDPOINT_A, ENDPOINT_B, ENDPOINT_C]); + endpoint.hooks.insert(7, ENDPOINT_A); + endpoint.connections.insert((ENDPOINT_B, true)); + + let error = endpoint + .add_inbound(echo_packet(vec![ENDPOINT_A], 99)) + .unwrap_err(); + + assert!(matches!(error, EndpointError::UnknownHook { hook_id: 99 })); + assert_hook_present(&endpoint, 7); + assert!(endpoint.outbound.is_empty()); +} + +#[test] +fn forged_sideways_packet_is_rejected_as_incorrect_path() { + let mut endpoint = endpoint_at(ENDPOINT_B, vec![ENDPOINT_A, ENDPOINT_B]); + let hook_id = endpoint.get_hook_id(); + endpoint.hooks.insert(hook_id, ENDPOINT_A); + endpoint.connections.insert((ENDPOINT_A, true)); + + let error = endpoint + .add_inbound(echo_packet(vec![ENDPOINT_A, ENDPOINT_C], hook_id)) + .unwrap_err(); + + assert!(matches!(error, EndpointError::DestinationOutsideLocalTree)); + assert_hook_present(&endpoint, hook_id); + assert!(endpoint.inbound.is_empty()); + assert!(endpoint.outbound.is_empty()); +} + +#[test] +fn malformed_frame_is_dropped_by_comms_leaf() { + let (tx_to_endpoint, rx_for_endpoint) = crossbeam_channel::unbounded(); + let (tx_unused, _rx_unused) = crossbeam_channel::unbounded(); + let mut endpoint = Endpoint::new( + ENDPOINT_B, + vec![Box::new(CommsLeaf { + tx: tx_unused, + rx: rx_for_endpoint, + remote_id: ENDPOINT_A, + is_authority: true, + started: false, + })], + ); + endpoint.path = vec![ENDPOINT_A, ENDPOINT_B]; + + tx_to_endpoint.send(vec![0, 1, 2, 3]).unwrap(); + endpoint.update(); + + assert!(endpoint.inbound.is_empty()); + assert!(endpoint.outbound.is_empty()); +} + +#[test] +fn malformed_frame_does_not_block_following_valid_packet() { + let (tx_to_endpoint, rx_for_endpoint) = crossbeam_channel::unbounded(); + let (tx_unused, _rx_unused) = crossbeam_channel::unbounded(); + let hook_id = 42; + let mut endpoint = Endpoint::new( + ENDPOINT_B, + vec![Box::new(CommsLeaf { + tx: tx_unused, + rx: rx_for_endpoint, + remote_id: ENDPOINT_A, + is_authority: true, + started: false, + })], + ); + endpoint.path = vec![ENDPOINT_A, ENDPOINT_B]; + + tx_to_endpoint.send(vec![0, 1, 2, 3]).unwrap(); + tx_to_endpoint + .send( + echo_packet(vec![ENDPOINT_A, ENDPOINT_B], hook_id) + .serialize() + .unwrap(), + ) + .unwrap(); + endpoint.update(); + + let packet = single_inbound_packet(&endpoint, ENDPOINT_B); + assert!(packet.end_hook); + assert_eq!(packet.hook_id, hook_id); +} + +#[test] +fn forged_frame_without_required_hook_is_dropped_by_comms_leaf() { + let (tx_to_endpoint, rx_for_endpoint) = crossbeam_channel::unbounded(); + let (tx_unused, _rx_unused) = crossbeam_channel::unbounded(); + let mut endpoint = Endpoint::new( + ENDPOINT_B, + vec![Box::new(CommsLeaf { + tx: tx_unused, + rx: rx_for_endpoint, + remote_id: ENDPOINT_A, + is_authority: true, + started: false, + })], + ); + endpoint.path = vec![ENDPOINT_A, ENDPOINT_B]; + endpoint.hooks.insert(7, ENDPOINT_A); + + tx_to_endpoint + .send(echo_packet(vec![ENDPOINT_A], 12).serialize().unwrap()) + .unwrap(); + endpoint.update(); + + assert_hook_present(&endpoint, 7); + assert!(endpoint.inbound.is_empty()); + assert!(endpoint.outbound.is_empty()); +} + +#[test] +fn upward_outbound_without_hook_is_rejected() { + let mut endpoint = endpoint_at(ENDPOINT_B, vec![ENDPOINT_A, ENDPOINT_B]); + endpoint.hooks.insert(7, ENDPOINT_A); + endpoint.connections.insert((ENDPOINT_A, true)); + + let new_hook = endpoint.get_hook_id(); + + let error = endpoint + .add_outbound(echo_packet(vec![ENDPOINT_A], new_hook)) + .unwrap_err(); + + assert!(matches!( + error, + EndpointError::UnknownHook { hook_id: observed_hook_id } if observed_hook_id == new_hook + )); + assert_hook_present(&endpoint, 7); + assert!(endpoint.outbound.is_empty()); +} + +#[test] +fn downward_outbound_without_hook_is_allowed() { + let mut endpoint = endpoint_at(ENDPOINT_A, vec![ENDPOINT_A]); + endpoint.connections.insert((ENDPOINT_B, false)); + + let new_hook = endpoint.get_hook_id(); + endpoint.hooks.insert(new_hook, ENDPOINT_B); + + endpoint + .add_outbound(echo_packet(vec![ENDPOINT_A, ENDPOINT_B], new_hook)) + .unwrap(); + + assert_eq!(endpoint.outbound.get(&ENDPOINT_B).unwrap().len(), 1); + assert_hook_present(&endpoint, new_hook); +} + +#[test] +fn deeper_upward_route_uses_parent_as_next_hop() { + let mut endpoint = endpoint_at(ENDPOINT_C, vec![ENDPOINT_A, ENDPOINT_B, ENDPOINT_C]); + let new_hook = endpoint.get_hook_id(); + + endpoint.hooks.insert(new_hook, ENDPOINT_A); + endpoint.connections.insert((ENDPOINT_B, true)); + + endpoint + .add_outbound(echo_packet(vec![ENDPOINT_A], new_hook)) + .unwrap(); + + assert!(endpoint.outbound.contains_key(&ENDPOINT_B)); + assert!(!endpoint.outbound.contains_key(&ENDPOINT_A)); + assert_hook_removed(&endpoint, new_hook); +} + +#[test] +fn downward_route_without_connection_is_rejected() { + let mut endpoint = endpoint_at(ENDPOINT_A, vec![ENDPOINT_A]); + let hook_id = endpoint.get_hook_id(); + endpoint.hooks.insert(hook_id, ENDPOINT_B); + + let error = endpoint + .add_outbound(echo_packet(vec![ENDPOINT_A, ENDPOINT_B], hook_id)) + .unwrap_err(); + + assert!(matches!( + error, + EndpointError::MissingConnection { + next_hop: ENDPOINT_B, + direction: RouteDirection::Downward, + } + )); + assert_hook_present(&endpoint, hook_id); + assert!(endpoint.outbound.is_empty()); +} + +#[test] +fn upward_route_without_connection_is_rejected_even_with_hook() { + let mut endpoint = endpoint_at(ENDPOINT_B, vec![ENDPOINT_A, ENDPOINT_B]); + let hook_id = endpoint.get_hook_id(); + endpoint.hooks.insert(hook_id, ENDPOINT_A); + + let error = endpoint + .add_outbound(echo_packet(vec![ENDPOINT_A], hook_id)) + .unwrap_err(); + + assert!(matches!( + error, + EndpointError::MissingConnection { + next_hop: ENDPOINT_A, + direction: RouteDirection::Upward, + } + )); + assert_hook_present(&endpoint, hook_id); + assert!(endpoint.outbound.is_empty()); +} + +#[test] +fn end_hook_removes_hook_after_packet_is_queued() { + let mut endpoint = endpoint_at(ENDPOINT_B, vec![ENDPOINT_A, ENDPOINT_B]); + let hook_id = endpoint.get_hook_id(); + endpoint.hooks.insert(hook_id, ENDPOINT_A); + endpoint.connections.insert((ENDPOINT_A, true)); + + endpoint + .add_outbound(echo_packet(vec![ENDPOINT_A], hook_id)) + .unwrap(); + + assert_hook_removed(&endpoint, hook_id); + assert_eq!( + single_outbound_packet(&endpoint, ENDPOINT_A).hook_id, + hook_id + ); +} + +#[test] +fn failed_end_hook_route_keeps_hook_state() { + let mut endpoint = endpoint_at(ENDPOINT_B, vec![ENDPOINT_A, ENDPOINT_B]); + let hook_id = endpoint.get_hook_id(); + endpoint.hooks.insert(hook_id, ENDPOINT_A); + + let error = endpoint + .add_outbound(echo_packet(vec![ENDPOINT_A], hook_id)) + .unwrap_err(); + + assert!(matches!( + error, + EndpointError::MissingConnection { + next_hop: ENDPOINT_A, + direction: RouteDirection::Upward, + } + )); + assert_hook_present(&endpoint, hook_id); + assert!(endpoint.outbound.is_empty()); +} + +#[test] +fn inbound_without_absolute_path_is_rejected() { + let mut endpoint = Endpoint::new(ENDPOINT_A, vec![]); + + let error = endpoint + .add_inbound(echo_packet(vec![ENDPOINT_A], 1)) + .unwrap_err(); + + assert!(matches!(error, EndpointError::EndpointPathUnset)); + assert!(endpoint.inbound.is_empty()); +} + +#[test] +fn outbound_without_absolute_path_is_rejected() { + let mut endpoint = Endpoint::new(ENDPOINT_A, vec![]); + + let error = endpoint + .add_outbound(echo_packet(vec![ENDPOINT_A], 1)) + .unwrap_err(); + + assert!(matches!(error, EndpointError::EndpointPathUnset)); + assert!(endpoint.outbound.is_empty()); +} diff --git a/unshell-protocol/src/tests/oneshot/support.rs b/unshell-protocol/src/tests/oneshot/support.rs new file mode 100644 index 0000000..6a0b8fd --- /dev/null +++ b/unshell-protocol/src/tests/oneshot/support.rs @@ -0,0 +1,176 @@ +use crate::{Endpoint, Leaf, Packet}; + +use alloc::{string::ToString, vec, vec::Vec}; +use crossbeam_channel::{Receiver, Sender}; + +pub(super) const ENDPOINT_A: u32 = 0; +pub(super) const ENDPOINT_B: u32 = 1; +pub(super) const ENDPOINT_C: u32 = 2; + +const LEAF_CONTROLLER: u32 = 100; +const LEAF_COMMS: u32 = 101; +const LEAF_RESPONDER: u32 = 102; + +/// Builds a test packet whose route is the only field varied by routing tests. +/// +/// Keeping the payload stable makes each assertion about endpoint behavior rather +/// than packet construction, which is important because forged and malformed cases +/// should fail before any leaf-level procedure handling would matter. +pub(super) fn echo_packet(path: Vec, hook_id: u16) -> Packet { + Packet { + hook_id, + end_hook: true, + path, + procedure_id: "echo".to_string(), + data: "ABC123".as_bytes().to_vec(), + } +} + +/// Creates a bare endpoint at a known absolute path. +/// +/// Most routing tests do not need leaves; they only need the endpoint's local path, +/// connection table, and hook table. This helper keeps that setup explicit without +/// hiding the routing state that each test is validating. +pub(super) fn endpoint_at(id: u32, path: Vec) -> Endpoint { + let mut endpoint = Endpoint::new(id, vec![]); + endpoint.path = path; + endpoint +} + +/// Returns the only outbound packet queued for `next_hop`. +/// +/// Routing bugs often show up as packets being sent to the final destination rather +/// than the immediate neighbor. Tests use this helper to assert both that exactly one +/// packet exists and that it was queued for the expected adjacent endpoint. +pub(super) fn single_outbound_packet(endpoint: &Endpoint, next_hop: u32) -> &Packet { + let queue = endpoint + .outbound + .get(&next_hop) + .unwrap_or_else(|| panic!("expected one outbound queue for {next_hop}")); + assert_eq!(queue.len(), 1, "expected exactly one outbound packet"); + queue.front().unwrap() +} + +/// Returns the only inbound packet delivered to `local_id`. +/// +/// Local delivery is intentionally separate from transit forwarding, so the tests +/// assert against the local inbound queue instead of only checking that routing did +/// not produce an error. +pub(super) fn single_inbound_packet(endpoint: &Endpoint, local_id: u32) -> &Packet { + let queue = endpoint + .inbound + .get(&local_id) + .unwrap_or_else(|| panic!("expected one inbound queue for {local_id}")); + assert_eq!(queue.len(), 1, "expected exactly one inbound packet"); + queue.front().unwrap() +} + +/// Asserts that local hook state still contains `hook_id`. +/// +/// Tests use this instead of open-coded map checks so every lifecycle assertion +/// explains the intended routing invariant when it fails. +pub(super) fn assert_hook_present(endpoint: &Endpoint, hook_id: u16) { + assert!( + endpoint.hooks.contains_key(&hook_id), + "expected hook {hook_id} to remain registered" + ); +} + +/// Asserts that local hook state no longer contains `hook_id`. +/// +/// Upward `end_hook` packets are the only cases that should remove hook state; +/// downward and local packets with the same flag must leave hooks alone. +pub(super) fn assert_hook_removed(endpoint: &Endpoint, hook_id: u16) { + assert!( + !endpoint.hooks.contains_key(&hook_id), + "expected hook {hook_id} to be cleaned up" + ); +} + +pub(super) struct ControllerLeaf { + pub(super) has_run: bool, +} + +pub(super) struct CommsLeaf { + pub(super) tx: Sender>, + pub(super) rx: Receiver>, + + pub(super) remote_id: u32, + pub(super) is_authority: bool, + pub(super) started: bool, +} + +pub(super) struct ResponderLeaf; + +impl Leaf for ControllerLeaf { + fn get_id(&self) -> u32 { + LEAF_CONTROLLER + } + + fn update(&mut self, endpoint: &mut Endpoint) { + if !self.has_run { + // The controller starts exactly one request so the end-to-end test can + // assert deterministic routing without accumulating retries. + let hook_id = endpoint.get_hook_id(); + let packet = echo_packet(vec![ENDPOINT_A, ENDPOINT_B], hook_id); + let _ = endpoint.add_outbound(packet); + self.has_run = true; + } + } +} + +impl Leaf for CommsLeaf { + fn get_id(&self) -> u32 { + LEAF_COMMS + } + + fn update(&mut self, endpoint: &mut Endpoint) { + if !self.started { + endpoint + .connections + .insert((self.remote_id, self.is_authority)); + self.started = true; + } + + while !self.rx.is_empty() { + let data = self.rx.recv().unwrap(); + + // Transport bytes are untrusted. Dropping malformed frames here keeps + // the oneshot harness faithful to a router boundary: invalid wire data + // must not panic or poison later valid packets on the same connection. + if let Ok(packet) = Packet::deserialize(&data) { + let _ = endpoint.add_inbound(packet); + } + } + + endpoint.take_outbound_clear(self.remote_id, |packet| { + let data = packet.serialize().unwrap(); + let _ = self.tx.send(data); + }); + } +} + +impl Leaf for ResponderLeaf { + fn get_id(&self) -> u32 { + LEAF_RESPONDER + } + + fn update(&mut self, endpoint: &mut Endpoint) { + let local_id = endpoint.path.last().cloned().unwrap_or(0); + let mut packets = Vec::new(); + + endpoint.take_inbound_clear(local_id, |packet| { + let mut response = echo_packet(vec![ENDPOINT_A], packet.hook_id); + response.hook_id = packet.hook_id; + response.data = packet.data.clone(); + packets.push(response); + }); + + for packet in packets { + // Upward responses require local hook state before routing; this mirrors + // a callee accepting the call and authorizing the matching response hook. + endpoint.hooks.insert(packet.hook_id, 0); + let _ = endpoint.add_outbound(packet); + } + } +} diff --git a/unshell-protocol/src/packet/tests.rs b/unshell-protocol/src/tests/packet.rs similarity index 91% rename from unshell-protocol/src/packet/tests.rs rename to unshell-protocol/src/tests/packet.rs index eb0e0c5..754fa3f 100644 --- a/unshell-protocol/src/packet/tests.rs +++ b/unshell-protocol/src/tests/packet.rs @@ -1,6 +1,6 @@ -use super::*; -use alloc::string::ToString; -use alloc::vec; +use alloc::{string::ToString, vec, vec::Vec}; + +use crate::{DeserializeError, EndpointError, Packet, SerializeError}; // ── Helpers ─────────────────────────────────────────────────────────────── @@ -226,3 +226,27 @@ fn invalid_utf8_in_procedure_id() { DeserializeError::InvalidUtf8 ); } + +#[test] +fn serialize_error_wraps_into_endpoint_error() { + let error: EndpointError = SerializeError::BodyTooLarge.into(); + + assert_eq!( + error, + EndpointError::PacketSerialize { + source: SerializeError::BodyTooLarge, + } + ); +} + +#[test] +fn deserialize_error_wraps_into_endpoint_error() { + let error: EndpointError = DeserializeError::BufferTooShort.into(); + + assert_eq!( + error, + EndpointError::PacketDeserialize { + source: DeserializeError::BufferTooShort, + } + ); +} diff --git a/unshell-protocol/src/types.rs b/unshell-protocol/src/types.rs deleted file mode 100644 index 3d078ab..0000000 --- a/unshell-protocol/src/types.rs +++ /dev/null @@ -1,14 +0,0 @@ -use alloc::{ - collections::{btree_map::BTreeMap, btree_set::BTreeSet, vec_deque::VecDeque}, - vec::Vec, -}; - -use crate::packet::Packet; - -pub type Path = Vec; -pub type EndpointName = u32; -pub type HookID = u16; -pub type ConnectionSet = BTreeSet<(EndpointName, bool)>; -pub type HookMap = BTreeMap; -pub type PacketQueue = VecDeque; -pub type RouteMap = BTreeMap; From 99579495a53729b851cdd4f33310d8bbe50c0dbd Mon Sep 17 00:00:00 2001 From: Michael Mikovsky <77305074+Astatin3@users.noreply.github.com> Date: Thu, 28 May 2026 12:49:35 -0600 Subject: [PATCH 18/31] Add some tests for streams --- unshell-protocol/src/tests/oneshot/mod.rs | 1 + unshell-protocol/src/tests/oneshot/streams.rs | 332 ++++++++++++++++++ 2 files changed, 333 insertions(+) create mode 100644 unshell-protocol/src/tests/oneshot/streams.rs diff --git a/unshell-protocol/src/tests/oneshot/mod.rs b/unshell-protocol/src/tests/oneshot/mod.rs index 93fc793..90d5657 100644 --- a/unshell-protocol/src/tests/oneshot/mod.rs +++ b/unshell-protocol/src/tests/oneshot/mod.rs @@ -1,3 +1,4 @@ +mod streams; mod support; use crate::{Endpoint, EndpointError, RouteDirection}; diff --git a/unshell-protocol/src/tests/oneshot/streams.rs b/unshell-protocol/src/tests/oneshot/streams.rs new file mode 100644 index 0000000..31c9045 --- /dev/null +++ b/unshell-protocol/src/tests/oneshot/streams.rs @@ -0,0 +1,332 @@ +use crate::{Endpoint, Leaf, Packet}; + +use alloc::{boxed::Box, format, string::ToString, vec, vec::Vec}; + +use super::support::{CommsLeaf, ENDPOINT_A, ENDPOINT_B, assert_hook_present, assert_hook_removed}; + +const LEAF_STREAM_CALLER: u32 = 200; +const LEAF_STREAM_RESPONDENT: u32 = 201; +const STREAM_HOOK_ID: u16 = 0; + +/// Builds the initial downwards packet that opens the stream on the respondent. +/// +/// The request deliberately carries `end_hook = true` through `echo_packet`-style +/// semantics: downward routing must not treat that flag as local hook cleanup. The +/// respondent turns this into local stream state keyed by the caller's hook id. +fn stream_open_packet(hook_id: u16) -> Packet { + Packet { + hook_id, + end_hook: true, + path: vec![ENDPOINT_A, ENDPOINT_B], + procedure_id: "stream.open".to_string(), + data: b"open".to_vec(), + } +} + +/// Builds one upward stream frame for a previously opened hook. +/// +/// `end_hook` is false for every intermediate frame and true only for the final +/// frame. This is the behavior the routing layer relies on to keep hook state until +/// the stream has actually finished sending upward. +fn stream_frame_packet(hook_id: u16, index: usize, end_hook: bool) -> Packet { + Packet { + hook_id, + end_hook, + path: vec![ENDPOINT_A], + procedure_id: "stream.frame".to_string(), + data: format!("stream-{index}").into_bytes(), + } +} + +/// Caller leaf that opens exactly one stream request. +/// +/// The first allocated hook id is deterministic in these tests (`0`) because the +/// endpoint starts with no existing hooks. Keeping the caller this small makes the +/// per-loop stream assertions about respondent behavior rather than caller retries. +struct StreamCallerLeaf { + has_run: bool, +} + +/// Respondent leaf that converts the first request into a one-way stream. +/// +/// This mimics a leaf spawning stream state, not a new endpoint: once a request is +/// delivered locally, the leaf records the hook and emits at most one frame on each +/// later `update`. A failed route does not advance the stream, so retry behavior can +/// be tested by restoring the connection on a later loop. +struct StreamRespondentLeaf { + stream: Option, + total_packets: usize, +} + +/// In-flight stream state owned by the respondent leaf. +/// +/// The endpoint routing layer only knows hooks and packets. This leaf-level state is +/// the minimal application-side record needed to emit ordered frames one at a time. +struct StreamState { + hook_id: u16, + next_index: usize, +} + +impl StreamRespondentLeaf { + /// Creates a respondent that will emit `total_packets` stream frames. + fn new(total_packets: usize) -> Self { + Self { + stream: None, + total_packets, + } + } +} + +impl Leaf for StreamCallerLeaf { + fn get_id(&self) -> u32 { + LEAF_STREAM_CALLER + } + + fn update(&mut self, endpoint: &mut Endpoint) { + if self.has_run { + return; + } + + let hook_id = endpoint.get_hook_id(); + let _ = endpoint.add_outbound(stream_open_packet(hook_id)); + self.has_run = true; + } +} + +impl Leaf for StreamRespondentLeaf { + fn get_id(&self) -> u32 { + LEAF_STREAM_RESPONDENT + } + + fn update(&mut self, endpoint: &mut Endpoint) { + self.open_stream_from_pending_request(endpoint); + self.send_next_frame(endpoint); + } +} + +impl StreamRespondentLeaf { + /// Opens stream state from the first locally delivered request packet. + /// + /// The hook is inserted before any upward frame is routed because upward routing + /// is hook-gated. Additional requests are ignored while a stream is active so a + /// caller cannot reset ordering mid-stream in this simple one-way harness. + fn open_stream_from_pending_request(&mut self, endpoint: &mut Endpoint) { + if self.stream.is_some() { + return; + } + + let local_id = endpoint.path.last().cloned().unwrap_or(0); + let mut opened_hook = None; + + endpoint.take_inbound_clear(local_id, |packet| { + if opened_hook.is_none() { + opened_hook = Some(packet.hook_id); + } + }); + + if let Some(hook_id) = opened_hook { + endpoint.hooks.insert(hook_id, ENDPOINT_A); + self.stream = Some(StreamState { + hook_id, + next_index: 0, + }); + } + } + + /// Emits at most one frame for the active stream. + /// + /// The stream only advances after the routing layer accepts the packet. This is + /// important for final packets: a failed final route must leave hook state and + /// stream progress intact so the next loop can retry instead of silently losing + /// the end-of-stream marker. + fn send_next_frame(&mut self, endpoint: &mut Endpoint) { + let Some(stream) = self.stream.as_mut() else { + return; + }; + + if stream.next_index >= self.total_packets { + self.stream = None; + return; + } + + let index = stream.next_index; + let end_hook = index + 1 == self.total_packets; + let packet = stream_frame_packet(stream.hook_id, index, end_hook); + + if endpoint.add_outbound(packet).is_ok() { + stream.next_index += 1; + + if end_hook { + self.stream = None; + } + } + } +} + +/// Two endpoint, four leaf stream harness. +/// +/// Each endpoint has exactly one application leaf and one mock connection leaf. The +/// channel leaves are intentionally the same `CommsLeaf` used by the oneshot tests +/// so stream behavior exercises the same serialization and routing boundary. +fn stream_endpoints(total_packets: usize) -> (Endpoint, Endpoint) { + let (tx_a, rx_a) = crossbeam_channel::unbounded(); + let (tx_b, rx_b) = crossbeam_channel::unbounded(); + + let mut endpoint_a = Endpoint::new( + ENDPOINT_A, + vec![ + Box::new(StreamCallerLeaf { has_run: false }), + Box::new(CommsLeaf { + tx: tx_b, + rx: rx_a, + remote_id: ENDPOINT_B, + is_authority: false, + started: false, + }), + ], + ); + endpoint_a.path = vec![ENDPOINT_A]; + + let mut endpoint_b = Endpoint::new( + ENDPOINT_B, + vec![ + Box::new(StreamRespondentLeaf::new(total_packets)), + Box::new(CommsLeaf { + tx: tx_a, + rx: rx_b, + remote_id: ENDPOINT_A, + is_authority: true, + started: false, + }), + ], + ); + endpoint_b.path = vec![ENDPOINT_A, ENDPOINT_B]; + + // Register routes before the first application packet so leaf order is not a + // hidden prerequisite for the initial request leaving endpoint A. + endpoint_a.connections.insert((ENDPOINT_B, false)); + endpoint_b.connections.insert((ENDPOINT_A, true)); + + (endpoint_a, endpoint_b) +} + +/// Asserts the requested two-endpoint, four-leaf topology. +fn assert_four_leaf_topology(endpoint_a: &Endpoint, endpoint_b: &Endpoint) { + assert_eq!( + endpoint_a.leaves.len(), + 2, + "caller endpoint should have two leaves" + ); + assert_eq!( + endpoint_b.leaves.len(), + 2, + "respondent endpoint should have two leaves" + ); +} + +/// Drives the initial request until it is queued locally on endpoint B. +fn deliver_stream_request(endpoint_a: &mut Endpoint, endpoint_b: &mut Endpoint) { + endpoint_a.update(); + endpoint_b.update(); +} + +/// Drives one respondent stream loop and delivers any produced frame to endpoint A. +fn drive_stream_loop(endpoint_a: &mut Endpoint, endpoint_b: &mut Endpoint) { + endpoint_b.update(); + endpoint_a.update(); +} + +/// Returns stream packets that endpoint A has received so far. +fn received_stream_packets(endpoint: &Endpoint) -> Vec<&Packet> { + endpoint + .inbound + .get(&ENDPOINT_A) + .map(|queue| queue.iter().collect()) + .unwrap_or_default() +} + +/// Verifies ordered stream payloads and final-frame markers. +fn assert_received_stream(endpoint: &Endpoint, expected_count: usize, final_seen: bool) { + let packets = received_stream_packets(endpoint); + assert_eq!(packets.len(), expected_count); + + for (index, packet) in packets.iter().enumerate() { + assert_eq!(packet.hook_id, STREAM_HOOK_ID); + assert_eq!(packet.data, format!("stream-{index}").as_bytes()); + assert_eq!( + packet.end_hook, + final_seen && index + 1 == expected_count, + "only the last received packet should close the stream" + ); + } +} + +#[test] +fn one_directional_stream_returns_one_packet_per_loop() { + let total_packets = 3; + let (mut endpoint_a, mut endpoint_b) = stream_endpoints(total_packets); + assert_four_leaf_topology(&endpoint_a, &endpoint_b); + + deliver_stream_request(&mut endpoint_a, &mut endpoint_b); + + assert_received_stream(&endpoint_a, 0, false); + assert!(endpoint_b.hooks.is_empty()); + + for index in 0..total_packets { + drive_stream_loop(&mut endpoint_a, &mut endpoint_b); + let final_seen = index + 1 == total_packets; + + assert_received_stream(&endpoint_a, index + 1, final_seen); + + if final_seen { + assert_hook_removed(&endpoint_b, STREAM_HOOK_ID); + } else { + assert_hook_present(&endpoint_b, STREAM_HOOK_ID); + } + } +} + +#[test] +fn stream_does_not_emit_before_request_is_processed_by_respondent() { + let (mut endpoint_a, mut endpoint_b) = stream_endpoints(2); + + deliver_stream_request(&mut endpoint_a, &mut endpoint_b); + + assert_received_stream(&endpoint_a, 0, false); + assert!(endpoint_b.outbound.is_empty()); + assert!(endpoint_b.hooks.is_empty()); +} + +#[test] +fn stream_stops_after_final_packet() { + let total_packets = 2; + let (mut endpoint_a, mut endpoint_b) = stream_endpoints(total_packets); + + deliver_stream_request(&mut endpoint_a, &mut endpoint_b); + drive_stream_loop(&mut endpoint_a, &mut endpoint_b); + drive_stream_loop(&mut endpoint_a, &mut endpoint_b); + assert_received_stream(&endpoint_a, total_packets, true); + assert_hook_removed(&endpoint_b, STREAM_HOOK_ID); + + drive_stream_loop(&mut endpoint_a, &mut endpoint_b); + assert_received_stream(&endpoint_a, total_packets, true); + assert_hook_removed(&endpoint_b, STREAM_HOOK_ID); +} + +#[test] +fn failed_final_stream_route_keeps_hook_and_retries() { + let (mut endpoint_a, mut endpoint_b) = stream_endpoints(1); + + deliver_stream_request(&mut endpoint_a, &mut endpoint_b); + endpoint_b.connections.remove(&(ENDPOINT_A, true)); + + drive_stream_loop(&mut endpoint_a, &mut endpoint_b); + assert_received_stream(&endpoint_a, 0, false); + assert_hook_present(&endpoint_b, STREAM_HOOK_ID); + + endpoint_b.connections.insert((ENDPOINT_A, true)); + drive_stream_loop(&mut endpoint_a, &mut endpoint_b); + + assert_received_stream(&endpoint_a, 1, true); + assert_hook_removed(&endpoint_b, STREAM_HOOK_ID); +} From 84ac117ee0ec7bda2aac102b74af50eeaf5b36fd Mon Sep 17 00:00:00 2001 From: Michael Mikovsky <77305074+Astatin3@users.noreply.github.com> Date: Thu, 28 May 2026 12:56:59 -0600 Subject: [PATCH 19/31] Replace procedure name string in packet with u32, remove HeaderRef. --- unshell-protocol/src/error.rs | 15 +- unshell-protocol/src/packet.rs | 113 ++++++------ unshell-protocol/src/tests/oneshot/streams.rs | 6 +- unshell-protocol/src/tests/oneshot/support.rs | 4 +- unshell-protocol/src/tests/packet.rs | 166 ++++++++---------- 5 files changed, 127 insertions(+), 177 deletions(-) diff --git a/unshell-protocol/src/error.rs b/unshell-protocol/src/error.rs index 43f6858..3db9ac1 100644 --- a/unshell-protocol/src/error.rs +++ b/unshell-protocol/src/error.rs @@ -89,18 +89,15 @@ pub enum SerializeError { /// The packet path contains more bytes than the frame length field can represent. PathTooLarge, - /// The procedure identifier is too large to encode in a `u32` length field. - ProcIdTooLarge, - /// The body section is too large to encode in a `u32` length field. BodyTooLarge, } /// Errors produced while parsing a [`Packet`] from untrusted wire bytes. /// -/// Deserialization rejects partial, inconsistent, or invalid UTF-8 frames before -/// endpoint routing sees them. Keeping these separate from route failures makes it -/// clear whether a packet failed before or after it became structured data. +/// Deserialization rejects partial or inconsistent frames before endpoint routing +/// sees them. Keeping these separate from route failures makes it clear whether a +/// packet failed before or after it became structured data. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DeserializeError { /// The buffer ended before the parser could read the required field. @@ -111,12 +108,6 @@ pub enum DeserializeError { /// The path length overflowed while computing the path byte range. PathTooLong, - - /// The procedure id length overflowed while computing the body byte range. - ProcIdTooLong, - - /// The encoded procedure id was not valid UTF-8. - InvalidUtf8, } impl From for EndpointError { diff --git a/unshell-protocol/src/packet.rs b/unshell-protocol/src/packet.rs index 7f5926a..37d6699 100644 --- a/unshell-protocol/src/packet.rs +++ b/unshell-protocol/src/packet.rs @@ -1,47 +1,54 @@ extern crate alloc; -use alloc::string::String; use alloc::vec::Vec; use crate::{DeserializeError, SerializeError}; +/// Fully decoded UnShell test packet. +/// +/// The current protocol tests route only on hook id, hook end state, and absolute +/// path. `procedure_id` is therefore a compact numeric contract id instead of a +/// string label; application code can maintain its own id-to-name table outside the +/// hot packet path if it needs human-readable names. #[derive(Debug)] pub struct Packet { pub hook_id: u16, pub end_hook: bool, pub path: Vec, - // ── body (routers never read below this line) ── - pub procedure_id: String, + pub procedure_id: u32, pub data: Vec, } -/// Returned by `deserialize_header` — only what a router needs. -/// `body_remainder` is a raw slice into the original buffer so the -/// entire body can be forwarded without touching it. -#[derive(Debug)] -pub struct HeaderRef<'buf> { - pub hook_id: u16, - pub end_hook: bool, - pub path: &'buf [u32], - pub body_remainder: &'buf [u8], -} - impl Packet { + /// Serializes the packet into the crate's current little-endian frame format. + /// + /// Layout: + /// - fixed header: `hook_id: u16`, `flags: u8`, padding, `path_len: u32` + /// - path: `path_len` little-endian `u32` segments + /// - body: `body_len: u32`, `procedure_id: u32`, raw `data` + /// + /// Keeping `procedure_id` fixed-width removes the old string length and UTF-8 + /// validation path. That makes deserialization a single full-packet parse, + /// which matches how the endpoint mock transports actually consume packets. pub fn serialize(&self) -> Result, SerializeError> { - let proc_id_bytes = self.procedure_id.as_bytes(); + let path_len = u32::try_from(self.path.len()).map_err(|_| SerializeError::PathTooLarge)?; - let path_len = self.path.len() as u32; - let proc_id_len = - u32::try_from(proc_id_bytes.len()).map_err(|_| SerializeError::ProcIdTooLarge)?; - - // body = proc_id_len field + proc_id bytes + data bytes + // body = fixed procedure_id field + data bytes let body_payload_len = 4usize - .checked_add(proc_id_bytes.len()) - .and_then(|n| n.checked_add(self.data.len())) + .checked_add(self.data.len()) .ok_or(SerializeError::BodyTooLarge)?; let body_len = u32::try_from(body_payload_len).map_err(|_| SerializeError::BodyTooLarge)?; - let total = 8 + (self.path.len() * 4) + 4 + body_payload_len; + let path_bytes = self + .path + .len() + .checked_mul(4) + .ok_or(SerializeError::PathTooLarge)?; + let total = 8usize + .checked_add(path_bytes) + .and_then(|n| n.checked_add(4)) + .and_then(|n| n.checked_add(body_payload_len)) + .ok_or(SerializeError::BodyTooLarge)?; let mut buf = Vec::with_capacity(total); // ── header ──────────────────────────────────────────────────────────── @@ -56,16 +63,19 @@ impl Packet { // ── body ────────────────────────────────────────────────────────────── buf.extend_from_slice(&body_len.to_le_bytes()); - buf.extend_from_slice(&proc_id_len.to_le_bytes()); - buf.extend_from_slice(proc_id_bytes); + buf.extend_from_slice(&self.procedure_id.to_le_bytes()); buf.extend_from_slice(&self.data); Ok(buf) } - /// Deserialize only the header — O(path_len), body bytes are never read. - /// A router can inspect `HeaderRef` then forward the original buffer as-is. - pub fn deserialize_header(buf: &[u8]) -> Result, DeserializeError> { + /// Deserializes a full packet from untrusted transport bytes. + /// + /// This parser intentionally consumes the complete packet shape. The old + /// partial parse path was removed because current routing tests and mock + /// transports always deserialize before calling endpoint routing, so keeping a + /// borrowed header API only preserved unused unsafe casting complexity. + pub fn deserialize(buf: &[u8]) -> Result { // fixed prefix: hook_id (2) + flags (1) + padding (1) + path_len (4) if buf.len() < 8 { return Err(DeserializeError::BufferTooShort); @@ -85,25 +95,13 @@ impl Packet { return Err(DeserializeError::BufferTooShort); } - // Cast the buffer slice to a u32 slice. - // This requires alignment. rkyv handles this, but for a manual cast: - let path_ptr = buf[path_start..path_end].as_ptr() as *const u32; - let path = unsafe { core::slice::from_raw_parts(path_ptr, path_len) }; - - Ok(HeaderRef { - hook_id, - end_hook, - path, - body_remainder: &buf[path_end..], - }) - } - - /// Full deserialization. Parses the header then the body. - pub fn deserialize(buf: &[u8]) -> Result { - let header = Self::deserialize_header(buf)?; - let body_buf = header.body_remainder; + let mut path = Vec::with_capacity(path_len); + for chunk in buf[path_start..path_end].chunks_exact(4) { + path.push(u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]])); + } // body_len prefix + let body_buf = &buf[path_end..]; if body_buf.len() < 4 { return Err(DeserializeError::BufferTooShort); } @@ -117,31 +115,20 @@ impl Packet { return Err(DeserializeError::BodyLengthMismatch); } - // proc_id_len + proc_id + // procedure_id + data let inner = &body_buf[4..body_end]; if inner.len() < 4 { return Err(DeserializeError::BufferTooShort); } - let proc_id_len = u32::from_le_bytes([inner[0], inner[1], inner[2], inner[3]]) as usize; + let procedure_id = u32::from_le_bytes([inner[0], inner[1], inner[2], inner[3]]); - let proc_id_start = 4usize; - let proc_id_end = proc_id_start - .checked_add(proc_id_len) - .ok_or(DeserializeError::ProcIdTooLong)?; - if inner.len() < proc_id_end { - return Err(DeserializeError::BufferTooShort); - } - - let procedure_id = core::str::from_utf8(&inner[proc_id_start..proc_id_end]) - .map_err(|_| DeserializeError::InvalidUtf8)?; - - let data = inner[proc_id_end..].to_vec(); + let data = inner[4..].to_vec(); Ok(Self { - hook_id: header.hook_id, - end_hook: header.end_hook, - path: header.path.to_vec(), - procedure_id: procedure_id.into(), + hook_id, + end_hook, + path, + procedure_id, data, }) } diff --git a/unshell-protocol/src/tests/oneshot/streams.rs b/unshell-protocol/src/tests/oneshot/streams.rs index 31c9045..299260a 100644 --- a/unshell-protocol/src/tests/oneshot/streams.rs +++ b/unshell-protocol/src/tests/oneshot/streams.rs @@ -1,6 +1,6 @@ use crate::{Endpoint, Leaf, Packet}; -use alloc::{boxed::Box, format, string::ToString, vec, vec::Vec}; +use alloc::{boxed::Box, format, vec, vec::Vec}; use super::support::{CommsLeaf, ENDPOINT_A, ENDPOINT_B, assert_hook_present, assert_hook_removed}; @@ -18,7 +18,7 @@ fn stream_open_packet(hook_id: u16) -> Packet { hook_id, end_hook: true, path: vec![ENDPOINT_A, ENDPOINT_B], - procedure_id: "stream.open".to_string(), + procedure_id: 2, data: b"open".to_vec(), } } @@ -33,7 +33,7 @@ fn stream_frame_packet(hook_id: u16, index: usize, end_hook: bool) -> Packet { hook_id, end_hook, path: vec![ENDPOINT_A], - procedure_id: "stream.frame".to_string(), + procedure_id: 3, data: format!("stream-{index}").into_bytes(), } } diff --git a/unshell-protocol/src/tests/oneshot/support.rs b/unshell-protocol/src/tests/oneshot/support.rs index 6a0b8fd..c1ed4c1 100644 --- a/unshell-protocol/src/tests/oneshot/support.rs +++ b/unshell-protocol/src/tests/oneshot/support.rs @@ -1,6 +1,6 @@ use crate::{Endpoint, Leaf, Packet}; -use alloc::{string::ToString, vec, vec::Vec}; +use alloc::{vec, vec::Vec}; use crossbeam_channel::{Receiver, Sender}; pub(super) const ENDPOINT_A: u32 = 0; @@ -21,7 +21,7 @@ pub(super) fn echo_packet(path: Vec, hook_id: u16) -> Packet { hook_id, end_hook: true, path, - procedure_id: "echo".to_string(), + procedure_id: 1, data: "ABC123".as_bytes().to_vec(), } } diff --git a/unshell-protocol/src/tests/packet.rs b/unshell-protocol/src/tests/packet.rs index 754fa3f..83280db 100644 --- a/unshell-protocol/src/tests/packet.rs +++ b/unshell-protocol/src/tests/packet.rs @@ -1,4 +1,4 @@ -use alloc::{string::ToString, vec, vec::Vec}; +use alloc::{vec, vec::Vec}; use crate::{DeserializeError, EndpointError, Packet, SerializeError}; @@ -9,7 +9,7 @@ fn make_packet() -> Packet { hook_id: 42, end_hook: false, path: vec![1, 2, 3], - procedure_id: "my.service.Method".to_string(), + procedure_id: 0xAABB_CCDD, data: vec![0xDE, 0xAD, 0xBE, 0xEF], } } @@ -21,6 +21,15 @@ fn make_packet_flags(end_hook: bool) -> Packet { } } +fn body_len_offset(buf: &[u8]) -> usize { + let path_len = u32::from_le_bytes([buf[4], buf[5], buf[6], buf[7]]) as usize; + 8 + (path_len * 4) +} + +fn procedure_id_offset(buf: &[u8]) -> usize { + body_len_offset(buf) + 4 +} + // ── Round-trip ──────────────────────────────────────────────────────────── #[test] @@ -37,14 +46,16 @@ fn full_round_trip() { } #[test] -fn header_round_trip() { +fn procedure_id_is_fixed_width_u32() { let packet = make_packet(); let buf = packet.serialize().unwrap(); - let header = Packet::deserialize_header(&buf).unwrap(); + let proc_offset = procedure_id_offset(&buf); - assert_eq!(header.hook_id, packet.hook_id); - assert_eq!(header.end_hook, packet.end_hook); - assert_eq!(header.path, packet.path); + assert_eq!( + &buf[proc_offset..proc_offset + 4], + &packet.procedure_id.to_le_bytes() + ); + assert_eq!(&buf[proc_offset + 4..], packet.data.as_slice()); } // ── Flags ───────────────────────────────────────────────────────────────── @@ -52,17 +63,15 @@ fn header_round_trip() { #[test] fn flags_end_hook_false() { let packet = make_packet_flags(false); - let buf = packet.serialize().unwrap(); - let header = Packet::deserialize_header(&buf).unwrap(); - assert!(!header.end_hook); + let result = Packet::deserialize(&packet.serialize().unwrap()).unwrap(); + assert!(!result.end_hook); } #[test] fn flags_end_hook_true() { let packet = make_packet_flags(true); - let buf = packet.serialize().unwrap(); - let header = Packet::deserialize_header(&buf).unwrap(); - assert!(header.end_hook); + let result = Packet::deserialize(&packet.serialize().unwrap()).unwrap(); + assert!(result.end_hook); } // ── Empty fields ────────────────────────────────────────────────────────── @@ -73,20 +82,18 @@ fn empty_path() { path: vec![], ..make_packet() }; - let buf = packet.serialize().unwrap(); - let header = Packet::deserialize_header(&buf).unwrap(); - assert_eq!(header.path, &[] as &[u32]); + let result = Packet::deserialize(&packet.serialize().unwrap()).unwrap(); + assert_eq!(result.path, &[] as &[u32]); } #[test] -fn empty_procedure_id() { +fn zero_procedure_id() { let packet = Packet { - procedure_id: "".to_string(), + procedure_id: 0, ..make_packet() }; - let buf = packet.serialize().unwrap(); - let result = Packet::deserialize(&buf).unwrap(); - assert_eq!(result.procedure_id, ""); + let result = Packet::deserialize(&packet.serialize().unwrap()).unwrap(); + assert_eq!(result.procedure_id, 0); } #[test] @@ -95,8 +102,7 @@ fn empty_data() { data: vec![], ..make_packet() }; - let buf = packet.serialize().unwrap(); - let result = Packet::deserialize(&buf).unwrap(); + let result = Packet::deserialize(&packet.serialize().unwrap()).unwrap(); assert_eq!(result.data, &[] as &[u8]); } @@ -106,77 +112,16 @@ fn all_fields_empty() { hook_id: 0, end_hook: false, path: vec![], - procedure_id: "".to_string(), + procedure_id: 0, data: vec![], }; - let buf = packet.serialize().unwrap(); - let result = Packet::deserialize(&buf).unwrap(); + let result = Packet::deserialize(&packet.serialize().unwrap()).unwrap(); assert_eq!(result.hook_id, 0); assert_eq!(result.path, Vec::::new()); - assert_eq!(result.procedure_id, ""); + assert_eq!(result.procedure_id, 0); assert_eq!(result.data, &[] as &[u8]); } -// ── Zero-copy: borrows point into the original buffer ───────────────────── - -#[test] -fn header_path_is_borrowed_from_buffer() { - let buf = make_packet().serialize().unwrap(); - let header = Packet::deserialize_header(&buf).unwrap(); - - let path_ptr = header.path.as_ptr() as *const u8; - let buf_range = buf.as_ptr_range(); - assert!( - buf_range.contains(&path_ptr), - "path must be a subslice of the input buffer, not a new allocation" - ); -} - -#[test] -fn body_remainder_is_borrowed_from_buffer() { - let buf = make_packet().serialize().unwrap(); - let header = Packet::deserialize_header(&buf).unwrap(); - - let remainder_ptr = header.body_remainder.as_ptr(); - let buf_range = buf.as_ptr_range(); - assert!( - buf_range.contains(&remainder_ptr), - "body_remainder must point into the input buffer" - ); -} - -// ── Partial deserialization: body is untouched by header parse ──────────── - -#[test] -fn deserialize_header_does_not_read_body() { - let buf = make_packet().serialize().unwrap(); - let header = Packet::deserialize_header(&buf).unwrap(); - - // Re-parse body from the remainder to confirm it's intact. - let body_buf = header.body_remainder; - let body_len = - u32::from_le_bytes([body_buf[0], body_buf[1], body_buf[2], body_buf[3]]) as usize; - assert!( - body_buf.len() >= 4 + body_len, - "body_remainder must contain the full body" - ); -} - -#[test] -fn can_forward_buffer_after_header_parse() { - // Simulates a router: parse the header, then forward the raw buffer - // without touching the body. - let original = make_packet().serialize().unwrap(); - let header = Packet::deserialize_header(&original).unwrap(); - - assert_eq!(header.path, &[1, 2, 3]); - - // "Forward" by deserializing the full original buffer downstream. - let forwarded = Packet::deserialize(&original).unwrap(); - assert_eq!(forwarded.procedure_id, "my.service.Method"); - assert_eq!(forwarded.data, &[0xDE, 0xAD, 0xBE, 0xEF]); -} - // ── Truncation / corruption ─────────────────────────────────────────────── #[test] @@ -184,7 +129,7 @@ fn truncated_in_fixed_prefix() { let buf = make_packet().serialize().unwrap(); // Cut inside the fixed 8-byte prefix. assert_eq!( - Packet::deserialize_header(&buf[..4]).unwrap_err(), + Packet::deserialize(&buf[..4]).unwrap_err(), DeserializeError::BufferTooShort ); } @@ -194,7 +139,18 @@ fn truncated_in_path() { let buf = make_packet().serialize().unwrap(); // Cut to just past the fixed prefix, mid-path. assert_eq!( - Packet::deserialize_header(&buf[..9]).unwrap_err(), + Packet::deserialize(&buf[..9]).unwrap_err(), + DeserializeError::BufferTooShort + ); +} + +#[test] +fn truncated_before_body_len() { + let buf = make_packet().serialize().unwrap(); + let body_len_offset = body_len_offset(&buf); + + assert_eq!( + Packet::deserialize(&buf[..body_len_offset + 2]).unwrap_err(), DeserializeError::BufferTooShort ); } @@ -203,27 +159,43 @@ fn truncated_in_path() { fn truncated_in_body() { let buf = make_packet().serialize().unwrap(); // Remove last byte — well into the body. - assert!(Packet::deserialize(&buf[..buf.len() - 1]).is_err()); + assert_eq!( + Packet::deserialize(&buf[..buf.len() - 1]).unwrap_err(), + DeserializeError::BodyLengthMismatch + ); } #[test] fn empty_buffer_rejected() { assert_eq!( - Packet::deserialize_header(&[]).unwrap_err(), + Packet::deserialize(&[]).unwrap_err(), DeserializeError::BufferTooShort ); } #[test] -fn invalid_utf8_in_procedure_id() { +fn body_length_mismatch_is_rejected() { let mut buf = make_packet().serialize().unwrap(); - // Find where procedure_id starts: 8 + path_len*4 + 4 (body_len) + 4 (proc_id_len) - let path_len = u32::from_le_bytes([buf[4], buf[5], buf[6], buf[7]]) as usize; - let proc_id_offset = 8 + (path_len * 4) + 4 + 4; - buf[proc_id_offset] = 0xFF; + let body_len_offset = body_len_offset(&buf); + let inflated_body_len = 999u32; + buf[body_len_offset..body_len_offset + 4].copy_from_slice(&inflated_body_len.to_le_bytes()); + assert_eq!( Packet::deserialize(&buf).unwrap_err(), - DeserializeError::InvalidUtf8 + DeserializeError::BodyLengthMismatch + ); +} + +#[test] +fn body_too_short_for_procedure_id_is_rejected() { + let mut buf = make_packet().serialize().unwrap(); + let body_len_offset = body_len_offset(&buf); + let short_body_len = 3u32; + buf[body_len_offset..body_len_offset + 4].copy_from_slice(&short_body_len.to_le_bytes()); + + assert_eq!( + Packet::deserialize(&buf).unwrap_err(), + DeserializeError::BufferTooShort ); } From 388da93b2b9bf3699c7ebbf01199e00b482e34a0 Mon Sep 17 00:00:00 2001 From: Michael Mikovsky <77305074+Astatin3@users.noreply.github.com> Date: Thu, 28 May 2026 13:15:24 -0600 Subject: [PATCH 20/31] Add merkle_sync test --- unshell-protocol/src/lib.rs | 1 + .../src/tests/merkle_sync/codec.rs | 79 ++++ .../src/tests/merkle_sync/constants.rs | 27 ++ .../src/tests/merkle_sync/harness.rs | 119 ++++++ .../src/tests/merkle_sync/leaves.rs | 404 ++++++++++++++++++ unshell-protocol/src/tests/merkle_sync/mod.rs | 8 + unshell-protocol/src/tests/merkle_sync/rpc.rs | 86 ++++ .../src/tests/merkle_sync/state.rs | 98 +++++ .../src/tests/merkle_sync/tests.rs | 78 ++++ .../src/tests/merkle_sync/tree.rs | 255 +++++++++++ 10 files changed, 1155 insertions(+) create mode 100644 unshell-protocol/src/tests/merkle_sync/codec.rs create mode 100644 unshell-protocol/src/tests/merkle_sync/constants.rs create mode 100644 unshell-protocol/src/tests/merkle_sync/harness.rs create mode 100644 unshell-protocol/src/tests/merkle_sync/leaves.rs create mode 100644 unshell-protocol/src/tests/merkle_sync/mod.rs create mode 100644 unshell-protocol/src/tests/merkle_sync/rpc.rs create mode 100644 unshell-protocol/src/tests/merkle_sync/state.rs create mode 100644 unshell-protocol/src/tests/merkle_sync/tests.rs create mode 100644 unshell-protocol/src/tests/merkle_sync/tree.rs diff --git a/unshell-protocol/src/lib.rs b/unshell-protocol/src/lib.rs index f8bfbd2..184bbad 100644 --- a/unshell-protocol/src/lib.rs +++ b/unshell-protocol/src/lib.rs @@ -34,6 +34,7 @@ type RouteMap = BTreeMap; #[cfg(test)] mod tests { + mod merkle_sync; mod oneshot; mod packet; } diff --git a/unshell-protocol/src/tests/merkle_sync/codec.rs b/unshell-protocol/src/tests/merkle_sync/codec.rs new file mode 100644 index 0000000..83c4047 --- /dev/null +++ b/unshell-protocol/src/tests/merkle_sync/codec.rs @@ -0,0 +1,79 @@ +use alloc::vec::Vec; + +use super::tree::{BlockChunk, ChildKind, ChildSummary}; + +/// Encodes one `u32` request or response payload. +pub(super) fn encode_u32(value: u32) -> Vec { + value.to_le_bytes().to_vec() +} + +/// Decodes one exact `u32` payload. +pub(super) fn decode_u32(data: &[u8]) -> Option { + if data.len() == 4 { + Some(read_u32(data, 0)) + } else { + None + } +} + +/// Encodes one streamed child hash entry. +pub(super) fn encode_child_summary(summary: ChildSummary) -> Vec { + let mut data = Vec::with_capacity(12); + data.extend_from_slice(&summary.id.to_le_bytes()); + data.extend_from_slice(&summary.kind.discriminant().to_le_bytes()); + data.extend_from_slice(&summary.hash.to_le_bytes()); + data +} + +/// Decodes one streamed child hash entry. +pub(super) fn decode_child_summary(data: &[u8]) -> Option { + if data.len() != 12 { + return None; + } + + Some(ChildSummary { + id: read_u32(data, 0), + kind: ChildKind::from_discriminant(read_u32(data, 4))?, + hash: read_u32(data, 8), + }) +} + +/// Encodes one streamed block chunk. +pub(super) fn encode_block_chunk(chunk: &BlockChunk) -> Vec { + let mut data = Vec::with_capacity(16 + chunk.data.len()); + data.extend_from_slice(&chunk.block_id.to_le_bytes()); + data.extend_from_slice(&chunk.index.to_le_bytes()); + data.extend_from_slice(&chunk.total.to_le_bytes()); + data.extend_from_slice(&(chunk.data.len() as u32).to_le_bytes()); + data.extend_from_slice(&chunk.data); + data +} + +/// Decodes one streamed block chunk. +pub(super) fn decode_block_chunk(data: &[u8]) -> Option { + if data.len() < 16 { + return None; + } + + let len = read_u32(data, 12) as usize; + if data.len() != 16 + len { + return None; + } + + Some(BlockChunk { + block_id: read_u32(data, 0), + index: read_u32(data, 4), + total: read_u32(data, 8), + data: data[16..].to_vec(), + }) +} + +/// Reads a little-endian `u32` at a known-valid offset. +fn read_u32(data: &[u8], offset: usize) -> u32 { + u32::from_le_bytes([ + data[offset], + data[offset + 1], + data[offset + 2], + data[offset + 3], + ]) +} diff --git a/unshell-protocol/src/tests/merkle_sync/constants.rs b/unshell-protocol/src/tests/merkle_sync/constants.rs new file mode 100644 index 0000000..72ccca7 --- /dev/null +++ b/unshell-protocol/src/tests/merkle_sync/constants.rs @@ -0,0 +1,27 @@ +//! Shared ids for the Merkle sync protocol test. +//! +//! Keeping ids in one file makes the manually managed leaf state easier to audit +//! and mirrors the table a future leaf-state macro would generate from annotated +//! RPC definitions. + +pub(super) const ENDPOINT_CALLER: u32 = 0; +pub(super) const ENDPOINT_RESPONDENT: u32 = 1; + +pub(super) const LEAF_MERKLE_CALLER: u32 = 300; +pub(super) const LEAF_MERKLE_RESPONDENT: u32 = 301; +pub(super) const LEAF_MOCK_CONNECTION: u32 = 302; + +pub(super) const PROC_GET_ROOT_HASH: u32 = 10; +pub(super) const PROC_GET_CHILD_HASHES: u32 = 11; +pub(super) const PROC_GET_BLOCK_STREAM: u32 = 12; +pub(super) const PROC_ROOT_HASH: u32 = 20; +pub(super) const PROC_CHILD_HASH_ENTRY: u32 = 21; +pub(super) const PROC_BLOCK_CHUNK: u32 = 22; + +pub(super) const ROOT_NODE: u32 = 0; +pub(super) const BRANCH_LEFT: u32 = 1; +pub(super) const BRANCH_RIGHT: u32 = 2; +pub(super) const BLOCK_ALPHA: u32 = 10; +pub(super) const BLOCK_BRAVO: u32 = 11; +pub(super) const BLOCK_CHARLIE: u32 = 20; +pub(super) const BLOCK_DELTA: u32 = 21; diff --git a/unshell-protocol/src/tests/merkle_sync/harness.rs b/unshell-protocol/src/tests/merkle_sync/harness.rs new file mode 100644 index 0000000..34d544c --- /dev/null +++ b/unshell-protocol/src/tests/merkle_sync/harness.rs @@ -0,0 +1,119 @@ +use alloc::{boxed::Box, rc::Rc, vec}; +use core::cell::RefCell; + +use crate::Endpoint; + +use super::{ + constants::{ENDPOINT_CALLER, ENDPOINT_RESPONDENT}, + leaves::{MerkleCallerLeaf, MerkleRespondentLeaf, MockConnectionLeaf}, + state::{CallerReport, RespondentReport}, + tree::{MerkleStore, local_fixture, remote_fixture}, +}; + +/// Complete two-endpoint Merkle sync test harness. +/// +/// Endpoint A owns the caller leaf and one mock connection leaf. Endpoint B owns the +/// respondent leaf and the opposite mock connection leaf. Reports are shared out of +/// the boxed leaf objects so tests can assert state without downcasting trait +/// objects. +pub(super) struct MerkleHarness { + pub(super) endpoint_a: Endpoint, + pub(super) endpoint_b: Endpoint, + pub(super) caller_report: Rc>, + pub(super) respondent_report: Rc>, + pub(super) remote_root_hash: u32, +} + +impl MerkleHarness { + /// Creates the divergent fixture used by the main sync test. + pub(super) fn divergent() -> Self { + Self::with_stores(local_fixture(), remote_fixture()) + } + + /// Creates a custom caller/respondent fixture. + pub(super) fn with_stores(local: MerkleStore, remote: MerkleStore) -> Self { + let remote_root_hash = remote.root_hash(); + let caller_report = Rc::new(RefCell::new(CallerReport::default())); + let respondent_report = Rc::new(RefCell::new(RespondentReport::default())); + let (tx_a, rx_a) = crossbeam_channel::unbounded(); + let (tx_b, rx_b) = crossbeam_channel::unbounded(); + + let mut endpoint_a = Endpoint::new( + ENDPOINT_CALLER, + vec![ + Box::new(MerkleCallerLeaf::new(local, caller_report.clone())), + Box::new(MockConnectionLeaf::new( + tx_b, + rx_a, + ENDPOINT_RESPONDENT, + false, + )), + ], + ); + endpoint_a.path = vec![ENDPOINT_CALLER]; + + let mut endpoint_b = Endpoint::new( + ENDPOINT_RESPONDENT, + vec![ + Box::new(MerkleRespondentLeaf::new(remote, respondent_report.clone())), + Box::new(MockConnectionLeaf::new(tx_a, rx_b, ENDPOINT_CALLER, true)), + ], + ); + endpoint_b.path = vec![ENDPOINT_CALLER, ENDPOINT_RESPONDENT]; + + // Register routes before the first caller update so initial packet delivery + // does not depend on leaf ordering. + endpoint_a.connections.insert((ENDPOINT_RESPONDENT, false)); + endpoint_b.connections.insert((ENDPOINT_CALLER, true)); + + Self { + endpoint_a, + endpoint_b, + caller_report, + respondent_report, + remote_root_hash, + } + } + + /// Drives one deterministic protocol loop. + pub(super) fn tick(&mut self) { + self.endpoint_a.update(); + self.endpoint_b.update(); + } + + /// Runs until the caller reports completion. + pub(super) fn run_until_done(&mut self, max_ticks: usize) -> usize { + for tick in 1..=max_ticks { + self.tick(); + + if self.caller_report.borrow().done { + return tick; + } + } + + panic!("Merkle sync did not finish within {max_ticks} ticks"); + } + + /// Runs until the respondent has sent at least `target_frames` frames. + pub(super) fn run_until_respondent_frames( + &mut self, + target_frames: usize, + max_ticks: usize, + ) -> usize { + for tick in 1..=max_ticks { + self.tick(); + + if self.respondent_report.borrow().frames_sent >= target_frames { + return tick; + } + } + + panic!("respondent did not send {target_frames} frames within {max_ticks} ticks"); + } + + /// Verifies the requested four-leaf topology. + pub(super) fn assert_four_leaf_topology(&self) { + assert_eq!(self.endpoint_a.leaves.len(), 2); + assert_eq!(self.endpoint_b.leaves.len(), 2); + } +} diff --git a/unshell-protocol/src/tests/merkle_sync/leaves.rs b/unshell-protocol/src/tests/merkle_sync/leaves.rs new file mode 100644 index 0000000..0803972 --- /dev/null +++ b/unshell-protocol/src/tests/merkle_sync/leaves.rs @@ -0,0 +1,404 @@ +use alloc::{collections::VecDeque, rc::Rc, vec, vec::Vec}; +use core::cell::RefCell; + +use crossbeam_channel::{Receiver, Sender}; + +use crate::{Endpoint, Leaf, Packet}; + +use super::{ + codec::{decode_block_chunk, decode_child_summary, decode_u32}, + constants::{ + ENDPOINT_CALLER, ENDPOINT_RESPONDENT, LEAF_MERKLE_CALLER, LEAF_MERKLE_RESPONDENT, + LEAF_MOCK_CONNECTION, PROC_BLOCK_CHUNK, PROC_CHILD_HASH_ENTRY, PROC_GET_BLOCK_STREAM, + PROC_GET_CHILD_HASHES, PROC_GET_ROOT_HASH, PROC_ROOT_HASH, ROOT_NODE, + }, + rpc::{ + block_chunk_frame, block_stream_request, child_hash_frame, child_hashes_request, + root_hash_frame, root_hash_request, + }, + state::{CallerPhase, CallerReport, RespondentReport, ResponseStream}, + tree::{BlockChunk, ChildKind, MerkleStore}, +}; + +/// Leaf that simulates a serialized transport connection with crossbeam channels. +/// +/// This is intentionally tiny and reusable. Both endpoints in the Merkle test have +/// exactly one of these leaves, giving the requested four-leaf topology: caller, +/// respondent, and two mock connections. +pub(super) struct MockConnectionLeaf { + pub(super) tx: Sender>, + pub(super) rx: Receiver>, + pub(super) remote_id: u32, + pub(super) is_authority: bool, + pub(super) started: bool, +} + +/// Caller leaf that drives the Merkle synchronization algorithm. +pub(super) struct MerkleCallerLeaf { + local: MerkleStore, + phase: CallerPhase, + pending_nodes: VecDeque, + pending_blocks: VecDeque, + report: Rc>, +} + +/// Respondent leaf that serves Merkle hash and block streams. +pub(super) struct MerkleRespondentLeaf { + remote: MerkleStore, + active_stream: Option, + report: Rc>, +} + +impl MockConnectionLeaf { + /// Creates one side of a mock connection. + pub(super) fn new( + tx: Sender>, + rx: Receiver>, + remote_id: u32, + is_authority: bool, + ) -> Self { + Self { + tx, + rx, + remote_id, + is_authority, + started: false, + } + } +} + +impl MerkleCallerLeaf { + /// Creates a caller with a local store and externally visible report. + pub(super) fn new(local: MerkleStore, report: Rc>) -> Self { + Self { + local, + phase: CallerPhase::NeedRoot, + pending_nodes: VecDeque::new(), + pending_blocks: VecDeque::new(), + report, + } + } +} + +impl MerkleRespondentLeaf { + /// Creates a respondent backed by the authoritative remote store. + pub(super) fn new(remote: MerkleStore, report: Rc>) -> Self { + Self { + remote, + active_stream: None, + report, + } + } +} + +impl Leaf for MockConnectionLeaf { + fn get_id(&self) -> u32 { + LEAF_MOCK_CONNECTION + } + + fn update(&mut self, endpoint: &mut Endpoint) { + if !self.started { + endpoint + .connections + .insert((self.remote_id, self.is_authority)); + self.started = true; + } + + while !self.rx.is_empty() { + let data = self.rx.recv().unwrap(); + + // Mock transports move untrusted bytes. Malformed frames are dropped so + // the sync state machine is tested only after packet parsing succeeds. + if let Ok(packet) = Packet::deserialize(&data) { + let _ = endpoint.add_inbound(packet); + } + } + + endpoint.take_outbound_clear(self.remote_id, |packet| { + let data = packet.serialize().unwrap(); + let _ = self.tx.send(data); + }); + } +} + +impl Leaf for MerkleCallerLeaf { + fn get_id(&self) -> u32 { + LEAF_MERKLE_CALLER + } + + fn update(&mut self, endpoint: &mut Endpoint) { + self.receive_responses(endpoint); + self.dispatch_next_request(endpoint); + } +} + +impl Leaf for MerkleRespondentLeaf { + fn get_id(&self) -> u32 { + LEAF_MERKLE_RESPONDENT + } + + fn update(&mut self, endpoint: &mut Endpoint) { + self.open_stream_from_request(endpoint); + self.send_one_response_frame(endpoint); + } +} + +impl MerkleCallerLeaf { + /// Consumes all response packets currently delivered to endpoint A. + fn receive_responses(&mut self, endpoint: &mut Endpoint) { + endpoint.take_inbound_clear(ENDPOINT_CALLER, |packet| { + self.report + .borrow_mut() + .received_procedures + .push(packet.procedure_id); + self.handle_response_packet(packet); + }); + } + + /// Handles one response packet according to the current caller phase. + fn handle_response_packet(&mut self, packet: &Packet) { + match &mut self.phase { + CallerPhase::AwaitRoot { hook_id } => { + assert_eq!(packet.hook_id, *hook_id); + assert_eq!(packet.procedure_id, PROC_ROOT_HASH); + let remote_root = decode_u32(&packet.data).expect("root hash payload"); + + if packet.end_hook { + self.finish_root_response(remote_root); + } + } + CallerPhase::AwaitChildren { + hook_id, + node_id: _, + entries, + } => { + assert_eq!(packet.hook_id, *hook_id); + assert_eq!(packet.procedure_id, PROC_CHILD_HASH_ENTRY); + entries.push(decode_child_summary(&packet.data).expect("child summary payload")); + + if packet.end_hook { + self.finish_child_response(); + } + } + CallerPhase::AwaitBlock { + hook_id, + block_id: _, + chunks, + } => { + assert_eq!(packet.hook_id, *hook_id); + assert_eq!(packet.procedure_id, PROC_BLOCK_CHUNK); + chunks.push(decode_block_chunk(&packet.data).expect("block chunk payload")); + + if packet.end_hook { + self.finish_block_response(); + } + } + CallerPhase::NeedRoot | CallerPhase::Ready | CallerPhase::Done => { + panic!("unexpected Merkle response in phase {:?}", self.phase); + } + } + } + + /// Applies the completed root response and decides whether tree walking is needed. + fn finish_root_response(&mut self, remote_root: u32) { + if self.local.root_hash() == remote_root { + self.mark_done(); + } else { + self.pending_nodes.push_back(ROOT_NODE); + self.phase = CallerPhase::Ready; + } + } + + /// Applies a completed child-hash stream. + fn finish_child_response(&mut self) { + let CallerPhase::AwaitChildren { + hook_id: _, + node_id: _, + entries, + } = core::mem::replace(&mut self.phase, CallerPhase::Ready) + else { + unreachable!(); + }; + + for entry in entries { + if self.local.hash_for(entry.kind, entry.id) == entry.hash { + continue; + } + + match entry.kind { + ChildKind::Branch => self.pending_nodes.push_back(entry.id), + ChildKind::Block => self.pending_blocks.push_back(entry.id), + } + } + } + + /// Applies a completed block stream to the local store. + fn finish_block_response(&mut self) { + let CallerPhase::AwaitBlock { + hook_id: _, + block_id, + mut chunks, + } = core::mem::replace(&mut self.phase, CallerPhase::Ready) + else { + unreachable!(); + }; + + chunks.sort_by_key(|chunk| chunk.index); + assert_eq!( + chunks.len(), + chunks.first().map(|chunk| chunk.total).unwrap_or(0) as usize + ); + + let new_chunks: Vec> = chunks.into_iter().map(|chunk| chunk.data).collect(); + self.local.replace_block(block_id, new_chunks.clone()); + + let mut report = self.report.borrow_mut(); + report.synchronized_blocks.push(block_id); + report.applied_block_chunks.push((block_id, new_chunks)); + } + + /// Sends the next request if the caller is not waiting on a response stream. + fn dispatch_next_request(&mut self, endpoint: &mut Endpoint) { + match self.phase { + CallerPhase::NeedRoot => { + let hook_id = self.send_request(endpoint, PROC_GET_ROOT_HASH, Vec::new()); + endpoint.add_outbound(root_hash_request(hook_id)).unwrap(); + self.phase = CallerPhase::AwaitRoot { hook_id }; + } + CallerPhase::Ready => { + if let Some(node_id) = self.pending_nodes.pop_front() { + let hook_id = self.send_request(endpoint, PROC_GET_CHILD_HASHES, Vec::new()); + endpoint + .add_outbound(child_hashes_request(hook_id, node_id)) + .unwrap(); + self.phase = CallerPhase::AwaitChildren { + hook_id, + node_id, + entries: Vec::new(), + }; + } else if let Some(block_id) = self.pending_blocks.pop_front() { + let hook_id = self.send_request(endpoint, PROC_GET_BLOCK_STREAM, Vec::new()); + endpoint + .add_outbound(block_stream_request(hook_id, block_id)) + .unwrap(); + self.phase = CallerPhase::AwaitBlock { + hook_id, + block_id, + chunks: Vec::new(), + }; + } else { + self.mark_done(); + } + } + CallerPhase::AwaitRoot { .. } + | CallerPhase::AwaitChildren { .. } + | CallerPhase::AwaitBlock { .. } + | CallerPhase::Done => {} + } + } + + /// Reserves a hook id and records the logical RPC request. + fn send_request(&mut self, endpoint: &mut Endpoint, procedure_id: u32, _data: Vec) -> u16 { + let hook_id = endpoint.get_hook_id(); + self.report + .borrow_mut() + .requested_procedures + .push(procedure_id); + hook_id + } + + /// Marks the synchronization complete and records the final local root. + fn mark_done(&mut self) { + self.phase = CallerPhase::Done; + let mut report = self.report.borrow_mut(); + report.done = true; + report.final_root_hash = Some(self.local.root_hash()); + } +} + +impl MerkleRespondentLeaf { + /// Opens one response stream from the first pending local request. + fn open_stream_from_request(&mut self, endpoint: &mut Endpoint) { + if self.active_stream.is_some() { + return; + } + + let mut request = None; + endpoint.take_inbound_clear(ENDPOINT_RESPONDENT, |packet| { + if request.is_none() { + request = Some((packet.hook_id, packet.procedure_id, packet.data.clone())); + } + }); + + let Some((hook_id, procedure_id, data)) = request else { + return; + }; + + let frames = self.frames_for_request(procedure_id, &data); + endpoint.hooks.insert(hook_id, ENDPOINT_CALLER); + + self.report.borrow_mut().requests_seen.push(procedure_id); + if !frames.is_empty() { + self.report.borrow_mut().streams_started += 1; + self.active_stream = Some(ResponseStream::new(hook_id, frames)); + } + } + + /// Builds response frames for one request procedure. + fn frames_for_request(&self, procedure_id: u32, data: &[u8]) -> Vec { + match procedure_id { + PROC_GET_ROOT_HASH => vec![root_hash_frame(self.remote.root_hash())], + PROC_GET_CHILD_HASHES => { + let node_id = decode_u32(data).expect("child hash request node id"); + self.remote + .child_summaries(node_id) + .into_iter() + .map(child_hash_frame) + .collect() + } + PROC_GET_BLOCK_STREAM => { + let block_id = decode_u32(data).expect("block stream request block id"); + let chunks = self.remote.block_chunks(block_id); + let total = chunks.len() as u32; + chunks + .into_iter() + .enumerate() + .map(|(index, data)| { + block_chunk_frame(BlockChunk { + block_id, + index: index as u32, + total, + data, + }) + }) + .collect() + } + _ => Vec::new(), + } + } + + /// Sends at most one response frame per update loop. + fn send_one_response_frame(&mut self, endpoint: &mut Endpoint) { + let Some(stream) = self.active_stream.as_mut() else { + return; + }; + + if stream.is_empty() { + self.active_stream = None; + return; + } + + let packet = stream.next_packet().expect("active stream frame"); + if endpoint.add_outbound(packet).is_err() { + return; + } + + self.report.borrow_mut().frames_sent += 1; + stream.advance(); + + if stream.is_complete() { + self.report.borrow_mut().streams_completed += 1; + self.active_stream = None; + } + } +} diff --git a/unshell-protocol/src/tests/merkle_sync/mod.rs b/unshell-protocol/src/tests/merkle_sync/mod.rs new file mode 100644 index 0000000..7098fdf --- /dev/null +++ b/unshell-protocol/src/tests/merkle_sync/mod.rs @@ -0,0 +1,8 @@ +mod codec; +mod constants; +mod harness; +mod leaves; +mod rpc; +mod state; +mod tests; +mod tree; diff --git a/unshell-protocol/src/tests/merkle_sync/rpc.rs b/unshell-protocol/src/tests/merkle_sync/rpc.rs new file mode 100644 index 0000000..01440e0 --- /dev/null +++ b/unshell-protocol/src/tests/merkle_sync/rpc.rs @@ -0,0 +1,86 @@ +use alloc::{vec, vec::Vec}; + +use crate::Packet; + +use super::{ + codec::{encode_block_chunk, encode_child_summary, encode_u32}, + constants::{ + ENDPOINT_CALLER, ENDPOINT_RESPONDENT, PROC_BLOCK_CHUNK, PROC_CHILD_HASH_ENTRY, + PROC_GET_BLOCK_STREAM, PROC_GET_CHILD_HASHES, PROC_GET_ROOT_HASH, PROC_ROOT_HASH, + }, + tree::{BlockChunk, ChildSummary}, +}; + +/// One outbound response frame before it is wrapped in endpoint routing fields. +/// +/// A response stream owns a list of these frames and asks each frame to become a +/// packet only when the loop is ready to send it. That keeps retry behavior simple: +/// a failed send does not consume the frame. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct OutgoingFrame { + procedure_id: u32, + data: Vec, +} + +impl OutgoingFrame { + /// Wraps the frame in an upward packet for `hook_id`. + pub(super) fn to_packet(&self, hook_id: u16, end_hook: bool) -> Packet { + Packet { + hook_id, + end_hook, + path: vec![ENDPOINT_CALLER], + procedure_id: self.procedure_id, + data: self.data.clone(), + } + } +} + +/// Builds the initial root-hash request. +pub(super) fn root_hash_request(hook_id: u16) -> Packet { + request_packet(PROC_GET_ROOT_HASH, hook_id, Vec::new()) +} + +/// Builds a request for one branch node's child hashes. +pub(super) fn child_hashes_request(hook_id: u16, node_id: u32) -> Packet { + request_packet(PROC_GET_CHILD_HASHES, hook_id, encode_u32(node_id)) +} + +/// Builds a request for one mismatched block's data stream. +pub(super) fn block_stream_request(hook_id: u16, block_id: u32) -> Packet { + request_packet(PROC_GET_BLOCK_STREAM, hook_id, encode_u32(block_id)) +} + +/// Builds a single root-hash response frame. +pub(super) fn root_hash_frame(root_hash: u32) -> OutgoingFrame { + OutgoingFrame { + procedure_id: PROC_ROOT_HASH, + data: encode_u32(root_hash), + } +} + +/// Builds one streamed child hash entry response frame. +pub(super) fn child_hash_frame(summary: ChildSummary) -> OutgoingFrame { + OutgoingFrame { + procedure_id: PROC_CHILD_HASH_ENTRY, + data: encode_child_summary(summary), + } +} + +/// Builds one streamed block chunk response frame. +pub(super) fn block_chunk_frame(chunk: BlockChunk) -> OutgoingFrame { + OutgoingFrame { + procedure_id: PROC_BLOCK_CHUNK, + data: encode_block_chunk(&chunk), + } +} + +/// Builds a downward request packet. +fn request_packet(procedure_id: u32, hook_id: u16, data: Vec) -> Packet { + Packet { + hook_id, + end_hook: true, + path: vec![ENDPOINT_CALLER, ENDPOINT_RESPONDENT], + procedure_id, + data, + } +} diff --git a/unshell-protocol/src/tests/merkle_sync/state.rs b/unshell-protocol/src/tests/merkle_sync/state.rs new file mode 100644 index 0000000..92a8cea --- /dev/null +++ b/unshell-protocol/src/tests/merkle_sync/state.rs @@ -0,0 +1,98 @@ +use alloc::vec::Vec; + +use super::{ + rpc::OutgoingFrame, + tree::{BlockChunk, ChildSummary}, +}; + +/// Caller-side synchronization phase. +/// +/// This is the manual state machine a future macro should be able to derive from +/// RPC declarations. Each awaiting state owns the partial stream it is collecting, +/// making it clear which packets are legal at each step. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum CallerPhase { + NeedRoot, + AwaitRoot { + hook_id: u16, + }, + Ready, + AwaitChildren { + hook_id: u16, + node_id: u32, + entries: Vec, + }, + AwaitBlock { + hook_id: u16, + block_id: u32, + chunks: Vec, + }, + Done, +} + +/// Test-visible caller observations. +/// +/// The leaf itself lives behind `Box`, so the harness keeps a shared +/// report handle for assertions without needing downcasts. +#[derive(Debug, Default)] +pub(super) struct CallerReport { + pub(super) done: bool, + pub(super) requested_procedures: Vec, + pub(super) received_procedures: Vec, + pub(super) synchronized_blocks: Vec, + pub(super) applied_block_chunks: Vec<(u32, Vec>)>, + pub(super) final_root_hash: Option, +} + +/// Test-visible respondent observations. +#[derive(Debug, Default)] +pub(super) struct RespondentReport { + pub(super) requests_seen: Vec, + pub(super) streams_started: usize, + pub(super) streams_completed: usize, + pub(super) frames_sent: usize, +} + +/// Respondent-owned response stream. +/// +/// It stores encoded frames and exposes packet construction one frame at a time. +/// Since `next_packet` does not advance, a failed route can be retried by calling it +/// again on the next loop. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct ResponseStream { + hook_id: u16, + frames: Vec, + next_index: usize, +} + +impl ResponseStream { + /// Creates a response stream for one request hook. + pub(super) fn new(hook_id: u16, frames: Vec) -> Self { + Self { + hook_id, + frames, + next_index: 0, + } + } + + /// Builds the next packet without advancing the stream. + pub(super) fn next_packet(&self) -> Option { + let frame = self.frames.get(self.next_index)?; + Some(frame.to_packet(self.hook_id, self.next_index + 1 == self.frames.len())) + } + + /// Marks the current frame as successfully sent. + pub(super) fn advance(&mut self) { + self.next_index += 1; + } + + /// Returns true once every frame has been sent. + pub(super) fn is_complete(&self) -> bool { + self.next_index >= self.frames.len() + } + + /// Returns true when the request generated no frames. + pub(super) fn is_empty(&self) -> bool { + self.frames.is_empty() + } +} diff --git a/unshell-protocol/src/tests/merkle_sync/tests.rs b/unshell-protocol/src/tests/merkle_sync/tests.rs new file mode 100644 index 0000000..ecd97bb --- /dev/null +++ b/unshell-protocol/src/tests/merkle_sync/tests.rs @@ -0,0 +1,78 @@ +use super::{ + constants::{ + BLOCK_BRAVO, BLOCK_CHARLIE, PROC_GET_BLOCK_STREAM, PROC_GET_CHILD_HASHES, + PROC_GET_ROOT_HASH, + }, + harness::MerkleHarness, + tree::remote_fixture, +}; + +#[test] +fn merkle_sync_walks_hash_tree_and_streams_changed_blocks() { + let mut harness = MerkleHarness::divergent(); + harness.assert_four_leaf_topology(); + + let ticks = harness.run_until_done(100); + assert!( + ticks > 20, + "sync should require many request/stream iterations" + ); + + let caller = harness.caller_report.borrow(); + assert_eq!(caller.final_root_hash, Some(harness.remote_root_hash)); + assert_eq!(caller.synchronized_blocks, [BLOCK_BRAVO, BLOCK_CHARLIE]); + assert_eq!( + caller.requested_procedures, + [ + PROC_GET_ROOT_HASH, + PROC_GET_CHILD_HASHES, + PROC_GET_CHILD_HASHES, + PROC_GET_CHILD_HASHES, + PROC_GET_BLOCK_STREAM, + PROC_GET_BLOCK_STREAM, + ] + ); + + let respondent = harness.respondent_report.borrow(); + assert_eq!(respondent.requests_seen, caller.requested_procedures); + assert_eq!(respondent.streams_started, 6); + assert_eq!(respondent.streams_completed, 6); + assert_eq!(respondent.frames_sent, 12); + assert!(harness.endpoint_b.hooks.is_empty()); +} + +#[test] +fn identical_tree_stops_after_root_hash() { + let remote = remote_fixture(); + let mut harness = MerkleHarness::with_stores(remote.clone(), remote); + + harness.run_until_done(20); + + let caller = harness.caller_report.borrow(); + assert_eq!(caller.final_root_hash, Some(harness.remote_root_hash)); + assert_eq!(caller.requested_procedures, [PROC_GET_ROOT_HASH]); + assert!(caller.synchronized_blocks.is_empty()); + + let respondent = harness.respondent_report.borrow(); + assert_eq!(respondent.frames_sent, 1); + assert_eq!(respondent.streams_started, 1); + assert_eq!(respondent.streams_completed, 1); +} + +#[test] +fn block_stream_hook_persists_until_final_frame() { + let mut harness = MerkleHarness::divergent(); + + harness.run_until_respondent_frames(8, 100); + assert_eq!( + harness.endpoint_b.hooks.len(), + 1, + "first block stream should keep its hook after a non-final chunk" + ); + + harness.run_until_done(100); + assert!( + harness.endpoint_b.hooks.is_empty(), + "final block stream packet should clean respondent hook state" + ); +} diff --git a/unshell-protocol/src/tests/merkle_sync/tree.rs b/unshell-protocol/src/tests/merkle_sync/tree.rs new file mode 100644 index 0000000..b0c8142 --- /dev/null +++ b/unshell-protocol/src/tests/merkle_sync/tree.rs @@ -0,0 +1,255 @@ +use alloc::{collections::BTreeMap, vec, vec::Vec}; + +use super::constants::{ + BLOCK_ALPHA, BLOCK_BRAVO, BLOCK_CHARLIE, BLOCK_DELTA, BRANCH_LEFT, BRANCH_RIGHT, ROOT_NODE, +}; + +/// Type of child referenced by a Merkle node summary. +/// +/// The sync caller uses this to decide whether a mismatched child should recurse +/// with `GET_CHILD_HASHES` or transfer data with `GET_BLOCK_STREAM`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ChildKind { + Branch, + Block, +} + +/// One child entry in a streamed Merkle summary response. +/// +/// A respondent streams these one per loop. The caller compares each `hash` with +/// its local store and queues either another node walk or a block transfer. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct ChildSummary { + pub(super) id: u32, + pub(super) kind: ChildKind, + pub(super) hash: u32, +} + +/// One chunk in a streamed block response. +/// +/// Chunks carry their total so the caller can replace the local block only after +/// the final stream packet arrives. This keeps partially received data out of the +/// Merkle hash until the hook completes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct BlockChunk { + pub(super) block_id: u32, + pub(super) index: u32, + pub(super) total: u32, + pub(super) data: Vec, +} + +/// Static edge in the test Merkle tree. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct TreeChild { + id: u32, + kind: ChildKind, +} + +/// In-memory Merkle store used by the caller and respondent leaves. +/// +/// This is deliberately small but extensible: adding wider trees, extra branches, +/// or different block chunking only changes this store, not the endpoint routing +/// harness. The hash is not cryptographic; it is deterministic test content used to +/// exercise the protocol state machine. +#[derive(Debug, Clone)] +pub(super) struct MerkleStore { + root_id: u32, + children: BTreeMap>, + blocks: BTreeMap>>, +} + +impl MerkleStore { + /// Creates an empty store with the standard root id. + fn new() -> Self { + Self { + root_id: ROOT_NODE, + children: BTreeMap::new(), + blocks: BTreeMap::new(), + } + } + + /// Returns the deterministic root hash for the current tree contents. + pub(super) fn root_hash(&self) -> u32 { + self.node_hash(self.root_id) + } + + /// Returns child summaries for `node_id` in stable order. + pub(super) fn child_summaries(&self, node_id: u32) -> Vec { + self.children + .get(&node_id) + .map(|children| { + children + .iter() + .map(|child| ChildSummary { + id: child.id, + kind: child.kind, + hash: self.hash_for(child.kind, child.id), + }) + .collect() + }) + .unwrap_or_default() + } + + /// Returns the local hash for a branch or block child. + pub(super) fn hash_for(&self, kind: ChildKind, id: u32) -> u32 { + match kind { + ChildKind::Branch => self.node_hash(id), + ChildKind::Block => self.block_hash(id), + } + } + + /// Returns the stored chunks for a block, preserving stream order. + pub(super) fn block_chunks(&self, block_id: u32) -> Vec> { + self.blocks.get(&block_id).cloned().unwrap_or_default() + } + + /// Replaces one local block after a complete block stream arrives. + pub(super) fn replace_block(&mut self, block_id: u32, chunks: Vec>) { + self.blocks.insert(block_id, chunks); + } + + /// Computes a deterministic hash for a branch node. + fn node_hash(&self, node_id: u32) -> u32 { + let mut hash = mix_u32(0x4E4F_4445, node_id); + + if let Some(children) = self.children.get(&node_id) { + for child in children { + hash = mix_u32(hash, child.id); + hash = mix_u32(hash, child.kind.discriminant()); + hash = mix_u32(hash, self.hash_for(child.kind, child.id)); + } + } + + hash + } + + /// Computes a deterministic hash for a data block. + fn block_hash(&self, block_id: u32) -> u32 { + let mut hash = mix_u32(0x424C_4F43, block_id); + + if let Some(chunks) = self.blocks.get(&block_id) { + for chunk in chunks { + hash = mix_u32(hash, chunk.len() as u32); + hash = hash_bytes(hash, chunk); + } + } + + hash + } +} + +impl ChildKind { + /// Stable wire discriminant for streamed child summaries. + pub(super) fn discriminant(self) -> u32 { + match self { + ChildKind::Branch => 0, + ChildKind::Block => 1, + } + } + + /// Decodes a stable wire discriminant. + pub(super) fn from_discriminant(value: u32) -> Option { + match value { + 0 => Some(Self::Branch), + 1 => Some(Self::Block), + _ => None, + } + } +} + +/// Remote store containing the authoritative content. +pub(super) fn remote_fixture() -> MerkleStore { + let mut store = base_tree(); + store + .blocks + .insert(BLOCK_ALPHA, chunks(&["alpha-", "same"])); + store + .blocks + .insert(BLOCK_BRAVO, chunks(&["bravo-", "remote-", "v2"])); + store + .blocks + .insert(BLOCK_CHARLIE, chunks(&["charlie-", "remote"])); + store.blocks.insert(BLOCK_DELTA, chunks(&["delta-same"])); + store +} + +/// Local store with two stale blocks and two already matching blocks. +pub(super) fn local_fixture() -> MerkleStore { + let mut store = base_tree(); + store + .blocks + .insert(BLOCK_ALPHA, chunks(&["alpha-", "same"])); + store + .blocks + .insert(BLOCK_BRAVO, chunks(&["bravo-", "local-", "v1"])); + store + .blocks + .insert(BLOCK_CHARLIE, chunks(&["charlie-", "local"])); + store.blocks.insert(BLOCK_DELTA, chunks(&["delta-same"])); + store +} + +/// Tree topology shared by the local and remote fixtures. +fn base_tree() -> MerkleStore { + let mut store = MerkleStore::new(); + store.children.insert( + ROOT_NODE, + vec![ + TreeChild { + id: BRANCH_LEFT, + kind: ChildKind::Branch, + }, + TreeChild { + id: BRANCH_RIGHT, + kind: ChildKind::Branch, + }, + ], + ); + store.children.insert( + BRANCH_LEFT, + vec![ + TreeChild { + id: BLOCK_ALPHA, + kind: ChildKind::Block, + }, + TreeChild { + id: BLOCK_BRAVO, + kind: ChildKind::Block, + }, + ], + ); + store.children.insert( + BRANCH_RIGHT, + vec![ + TreeChild { + id: BLOCK_CHARLIE, + kind: ChildKind::Block, + }, + TreeChild { + id: BLOCK_DELTA, + kind: ChildKind::Block, + }, + ], + ); + store +} + +/// Converts string slices into owned byte chunks. +fn chunks(parts: &[&str]) -> Vec> { + parts.iter().map(|part| part.as_bytes().to_vec()).collect() +} + +/// FNV-like byte mixing used only for deterministic test hashes. +fn hash_bytes(mut hash: u32, bytes: &[u8]) -> u32 { + for byte in bytes { + hash ^= u32::from(*byte); + hash = hash.wrapping_mul(16_777_619); + } + + hash +} + +/// Mixes one little-endian integer into the deterministic test hash. +fn mix_u32(hash: u32, value: u32) -> u32 { + hash_bytes(hash, &value.to_le_bytes()) +} From aeffe8b8ec47621ff1f2aeed972cc51ac2b10d3b Mon Sep 17 00:00:00 2001 From: Michael Mikovsky <77305074+Astatin3@users.noreply.github.com> Date: Thu, 28 May 2026 14:46:47 -0600 Subject: [PATCH 21/31] Improve hook state and routing --- unshell-protocol/src/endpoint/hooks.rs | 111 +++++++ unshell-protocol/src/endpoint/mod.rs | 11 +- unshell-protocol/src/endpoint/routing.rs | 287 +++++++++++++----- unshell-protocol/src/error.rs | 53 ++++ unshell-protocol/src/lib.rs | 3 +- .../src/tests/merkle_sync/leaves.rs | 3 +- unshell-protocol/src/tests/merkle_sync/rpc.rs | 2 +- .../src/tests/merkle_sync/tests.rs | 6 +- unshell-protocol/src/tests/oneshot/mod.rs | 130 ++++---- unshell-protocol/src/tests/oneshot/streams.rs | 23 +- unshell-protocol/src/tests/oneshot/support.rs | 18 +- 11 files changed, 492 insertions(+), 155 deletions(-) create mode 100644 unshell-protocol/src/endpoint/hooks.rs diff --git a/unshell-protocol/src/endpoint/hooks.rs b/unshell-protocol/src/endpoint/hooks.rs new file mode 100644 index 0000000..9447b40 --- /dev/null +++ b/unshell-protocol/src/endpoint/hooks.rs @@ -0,0 +1,111 @@ +use crate::{Endpoint, EndpointError, EndpointName}; + +/// Compact identifier for one routed return channel. +/// +/// Hook ids are local endpoint state, not globally unique session ids. A downward +/// packet with `end_hook = false` reserves the id at each endpoint it crosses so +/// later upward packets can prove that the route was paved by trusted downward +/// traffic first. +pub type HookID = u16; + +impl Endpoint { + /// Allocates a hook id that is not currently active on this endpoint. + /// + /// The first id is still deterministic (`0`) for the protocol tests, but the + /// allocator now skips active hooks so long-lived streams cannot accidentally + /// reuse an id before the previous route has closed. If every `u16` id is active + /// the function panics; that is a hard local resource exhaustion condition, not a + /// recoverable packet error. + pub fn allocate_hook_id(&mut self) -> HookID { + for _ in 0..=HookID::MAX { + let candidate = self.last_hook; + self.last_hook = self.last_hook.wrapping_add(1); + + if !self.hooks.contains_key(&candidate) { + return candidate; + } + } + + // Avoid a panic message here: this crate is optimized for small binaries, + // and exhausting every `u16` hook id is unrecoverable local state corruption. + panic!(); + } + + /// Backwards-compatible name for [`Self::allocate_hook_id`]. + /// + /// Existing leaves and tests still call `get_hook_id`; new code should prefer + /// `allocate_hook_id` because it describes the reservation semantics more clearly. + pub fn get_hook_id(&mut self) -> HookID { + self.allocate_hook_id() + } + + /// Explicitly records that `peer` may use `hook_id` as this endpoint's return channel. + /// + /// Routing calls this automatically for successful downward packets whose + /// `end_hook` flag is false. The public method exists for trusted local setup and + /// tests; ordinary leaf procedures should usually let packet routing pave hooks + /// instead of mutating hook state by hand. + pub fn accept_hook(&mut self, hook_id: HookID, peer: u32) -> Option { + self.hooks.insert(hook_id, peer) + } + + /// Returns true when `hook_id` is currently active. + pub fn has_hook(&self, hook_id: HookID) -> bool { + self.hooks.contains_key(&hook_id) + } + + /// Returns the adjacent peer currently associated with `hook_id`. + /// + /// The peer is the next endpoint expected to participate in the return channel: + /// a child for downward calls that will reply upward, or a parent for a local + /// callee that will emit an upward response. + pub fn hook_peer(&self, hook_id: HookID) -> Option { + self.hooks.get(&hook_id).copied() + } + + /// Returns the number of active hooks on this endpoint. + pub fn hook_count(&self) -> usize { + self.hooks.len() + } + + /// Locally forgets a hook without sending protocol traffic. + /// + /// Graceful shutdown should use a packet with `end_hook = true` so every endpoint + /// along the route cleans up after successful delivery. This method is for local + /// emergency cleanup such as a crashed PTY process, a timed-out stream, or a lost + /// transport where no final packet can be delivered. + pub fn forget_hook(&mut self, hook_id: HookID) -> bool { + self.close_hook(hook_id) + } + + /// Validates that `actual_peer` is the peer allowed to use `hook_id`. + pub(crate) fn ensure_hook_peer( + &self, + hook_id: HookID, + actual_peer: EndpointName, + ) -> Result<(), EndpointError> { + let expected_peer = self + .hook_peer(hook_id) + .ok_or(EndpointError::UnknownHook { hook_id })?; + + if expected_peer == actual_peer { + Ok(()) + } else { + Err(EndpointError::HookPeerMismatch { + hook_id, + expected_peer, + actual_peer, + }) + } + } + + /// Opens or refreshes `hook_id` for the adjacent `peer` after downward routing succeeds. + pub(crate) fn open_hook(&mut self, hook_id: HookID, peer: EndpointName) { + self.hooks.insert(hook_id, peer); + } + + /// Removes `hook_id` and reports whether it existed. + pub(crate) fn close_hook(&mut self, hook_id: HookID) -> bool { + self.hooks.remove(&hook_id).is_some() + } +} diff --git a/unshell-protocol/src/endpoint/mod.rs b/unshell-protocol/src/endpoint/mod.rs index b525013..a9868a9 100644 --- a/unshell-protocol/src/endpoint/mod.rs +++ b/unshell-protocol/src/endpoint/mod.rs @@ -1,5 +1,8 @@ +mod hooks; mod routing; +pub use hooks::HookID; + use alloc::{boxed::Box, vec::Vec}; use crate::{ConnectionSet, HookMap, Leaf, Packet, Path, RouteMap}; @@ -9,9 +12,8 @@ pub struct Endpoint { pub id: u32, // A counter that creates unique hook IDs. - // TODO: Actually check if the hook ID collides with any existing hooks. // TODO: Randomize the hooks for more obfuscation - last_hook: u16, + pub(crate) last_hook: u16, // Absolute path for this node. Must be set by some leaf pub path: Path, @@ -87,9 +89,4 @@ impl Endpoint { queue.clear(); } } - - pub fn get_hook_id(&mut self) -> u16 { - self.last_hook = self.last_hook.wrapping_add(1); - self.last_hook - 1 - } } diff --git a/unshell-protocol/src/endpoint/routing.rs b/unshell-protocol/src/endpoint/routing.rs index 1bc419b..38a2afa 100644 --- a/unshell-protocol/src/endpoint/routing.rs +++ b/unshell-protocol/src/endpoint/routing.rs @@ -1,83 +1,186 @@ use crate::{Endpoint, EndpointError, Packet, RouteDirection}; impl Endpoint { - /// Register an inbound packet and route it through the local endpoint state. + /// Register an inbound packet from legacy trusted code. /// - /// Inbound transport data still uses the same local routing rules as packets - /// generated by leaves: local destinations are delivered to `inbound`, and - /// transit destinations are queued by their immediate next hop. + /// Transports should prefer [`Self::add_inbound_from`] because peer-bound hook + /// validation needs to know which adjacent endpoint supplied the bytes. This + /// method keeps the old trusted in-process path small: it derives path direction, + /// forwards or delivers the packet, and only checks that upward hooks exist. pub fn add_inbound(&mut self, packet: Packet) -> Result<(), EndpointError> { - self.route_packet(packet) + self.route_trusted_packet(packet) } - /// Register an outbound packet produced locally and route it to the next queue. + /// Register an inbound packet received from `remote_id` and route it locally. /// - /// This intentionally shares the same implementation as [`Self::add_inbound`] - /// so local leaf output and received transport packets cannot drift into subtly - /// different route semantics. - pub fn add_outbound(&mut self, packet: Packet) -> Result<(), EndpointError> { - self.route_packet(packet) - } - - /// Route a packet by classifying its destination and mutating exactly one queue. - /// - /// Hook cleanup is deliberately last. A packet with `end_hook = true` should not - /// tear down local hook state unless the packet has a valid route and is actually - /// queued for forwarding. The route branches are kept inline rather than using - /// an intermediate decision enum so size-focused builds have less structure to - /// optimize away. - fn route_packet(&mut self, packet: Packet) -> Result<(), EndpointError> { + /// Packets from a parent are downward traffic and pave return hooks when + /// `end_hook` is false. Packets from a child are upward traffic and must match an + /// already-paved hook for that exact child before they can move farther upward. + pub fn add_inbound_from( + &mut self, + remote_id: u32, + packet: Packet, + ) -> Result<(), EndpointError> { self.ensure_path_is_set()?; - if packet.path == self.path { - let local_id = self - .path - .last() - .copied() - .ok_or(EndpointError::EndpointPathUnset)?; + let inbound_direction = self.inbound_direction_from_peer(remote_id)?; - self.inbound.entry(local_id).or_default().push_back(packet); - return Ok(()); + if packet.path == self.path { + return match inbound_direction { + RouteDirection::Downward => self.deliver_local_downward(packet, remote_id), + RouteDirection::Upward => self.deliver_local_upward(packet, remote_id), + }; } - // Direction is derived from the local path. The packet never gets to declare - // whether it is moving upward, because that would make the trust boundary spoofable. if packet.path.starts_with(&self.path) { - let next_hop = packet - .path - .get(self.path.len()) - .copied() - .ok_or(EndpointError::DestinationOutsideLocalTree)?; - - self.ensure_registered_connection(next_hop, RouteDirection::Downward)?; - self.queue_outbound(packet, next_hop, RouteDirection::Downward); - return Ok(()); + self.ensure_inbound_direction(remote_id, inbound_direction, RouteDirection::Downward)?; + let next_hop = self.immediate_child_hop(&packet)?; + return self.route_downward(packet, next_hop); } if self.path.starts_with(&packet.path) { - // Upward-routed packets must be tied to local hook state. Otherwise a - // peer could forge a packet to an ancestor by choosing an older path. - if !self.hooks.contains_key(&packet.hook_id) { - return Err(EndpointError::UnknownHook { - hook_id: packet.hook_id, - }); - } - - let parent_index = self - .path - .len() - .checked_sub(2) - .ok_or(EndpointError::MissingParentRoute)?; - - let next_hop = self.path[parent_index]; - self.ensure_registered_connection(next_hop, RouteDirection::Upward)?; - self.queue_outbound(packet, next_hop, RouteDirection::Upward); - return Ok(()); + self.ensure_inbound_direction(remote_id, inbound_direction, RouteDirection::Upward)?; + let next_hop = self.parent_hop()?; + return self.route_upward(packet, next_hop, Some(remote_id)); } Err(EndpointError::DestinationOutsideLocalTree) } + /// Register an outbound packet produced locally and route it to the next queue. + pub fn add_outbound(&mut self, packet: Packet) -> Result<(), EndpointError> { + self.ensure_path_is_set()?; + + if packet.path == self.path { + return self.deliver_local(packet); + } + + if packet.path.starts_with(&self.path) { + let next_hop = self.immediate_child_hop(&packet)?; + return self.route_downward(packet, next_hop); + } + + if self.path.starts_with(&packet.path) { + let next_hop = self.parent_hop()?; + return self.route_upward(packet, next_hop, Some(next_hop)); + } + + Err(EndpointError::DestinationOutsideLocalTree) + } + + /// Routes a trusted packet without transport-peer direction metadata. + /// + /// This intentionally does not create local hooks on local delivery because the + /// endpoint cannot know whether the packet came from a parent or child. Transit + /// routing still maintains hook state where path direction is unambiguous. + fn route_trusted_packet(&mut self, packet: Packet) -> Result<(), EndpointError> { + self.ensure_path_is_set()?; + + if packet.path == self.path { + return self.deliver_local(packet); + } + + if packet.path.starts_with(&self.path) { + let next_hop = self.immediate_child_hop(&packet)?; + return self.route_downward(packet, next_hop); + } + + if self.path.starts_with(&packet.path) { + let next_hop = self.parent_hop()?; + return self.route_upward(packet, next_hop, None); + } + + Err(EndpointError::DestinationOutsideLocalTree) + } + + /// Delivers a packet to local leaves without changing hook state. + fn deliver_local(&mut self, packet: Packet) -> Result<(), EndpointError> { + let local_id = self.local_id()?; + self.inbound.entry(local_id).or_default().push_back(packet); + Ok(()) + } + + /// Delivers parent-originated traffic locally and applies downward hook policy. + fn deliver_local_downward(&mut self, packet: Packet, peer: u32) -> Result<(), EndpointError> { + let hook_id = packet.hook_id; + let end_hook = packet.end_hook; + + self.deliver_local(packet)?; + self.apply_downward_hook_lifecycle(hook_id, end_hook, peer); + Ok(()) + } + + /// Delivers child-originated traffic locally after validating its return hook. + fn deliver_local_upward(&mut self, packet: Packet, peer: u32) -> Result<(), EndpointError> { + let hook_id = packet.hook_id; + let end_hook = packet.end_hook; + + self.ensure_hook_peer(hook_id, peer)?; + self.deliver_local(packet)?; + self.apply_upward_hook_lifecycle(hook_id, end_hook); + Ok(()) + } + + /// Forwards a packet to a child and applies downward hook lifecycle rules. + fn route_downward(&mut self, packet: Packet, next_hop: u32) -> Result<(), EndpointError> { + let hook_id = packet.hook_id; + let end_hook = packet.end_hook; + + self.ensure_registered_connection(next_hop, RouteDirection::Downward)?; + self.outbound.entry(next_hop).or_default().push_back(packet); + self.apply_downward_hook_lifecycle(hook_id, end_hook, next_hop); + Ok(()) + } + + /// Forwards a packet toward the parent after validating hook state. + /// + /// `actual_peer` is `None` only for legacy trusted inbound routing where the + /// transport source is unknown; in that mode the endpoint can check that a hook + /// exists but cannot enforce peer ownership. + fn route_upward( + &mut self, + packet: Packet, + next_hop: u32, + actual_peer: Option, + ) -> Result<(), EndpointError> { + let hook_id = packet.hook_id; + let end_hook = packet.end_hook; + + self.ensure_upward_hook_peer(hook_id, actual_peer)?; + self.ensure_registered_connection(next_hop, RouteDirection::Upward)?; + self.outbound.entry(next_hop).or_default().push_back(packet); + self.apply_upward_hook_lifecycle(hook_id, end_hook); + Ok(()) + } + + /// Returns this endpoint's final path segment for local queueing. + fn local_id(&self) -> Result { + self.path + .last() + .copied() + .ok_or(EndpointError::EndpointPathUnset) + } + + /// Returns the child that should receive a downward packet next. + fn immediate_child_hop(&self, packet: &Packet) -> Result { + packet + .path + .get(self.path.len()) + .copied() + .ok_or(EndpointError::DestinationOutsideLocalTree) + } + + /// Returns the direct parent next hop for upward routing. + fn parent_hop(&self) -> Result { + let parent_index = self + .path + .len() + .checked_sub(2) + .ok_or(EndpointError::MissingParentRoute)?; + + Ok(self.path[parent_index]) + } + /// Reject routing before path-relative decisions when no absolute path is known. /// /// This preserves the current runtime sentinel where an empty path means the @@ -90,6 +193,37 @@ impl Endpoint { } } + /// Derives packet direction from a registered inbound adjacent peer. + fn inbound_direction_from_peer(&self, remote_id: u32) -> Result { + let is_upstream = self.connections.contains(&(remote_id, true)); + let is_downstream = self.connections.contains(&(remote_id, false)); + + match (is_upstream, is_downstream) { + (true, false) => Ok(RouteDirection::Downward), + (false, true) => Ok(RouteDirection::Upward), + (false, false) => Err(EndpointError::UnknownConnection { remote_id }), + (true, true) => Err(EndpointError::AmbiguousConnection { remote_id }), + } + } + + /// Rejects inbound packets whose path-derived direction contradicts the connection. + fn ensure_inbound_direction( + &self, + remote_id: u32, + expected: RouteDirection, + actual: RouteDirection, + ) -> Result<(), EndpointError> { + if expected == actual { + Ok(()) + } else { + Err(EndpointError::InboundDirectionMismatch { + remote_id, + expected, + actual, + }) + } + } + /// Verify that the derived adjacent endpoint is registered in this direction. /// /// The current connection table stores direction as a boolean. Keeping the bool @@ -111,17 +245,34 @@ impl Endpoint { } } - /// Queue `packet` after all route validation has already succeeded. - /// - /// `end_hook` closes local hook state only when hook traffic is moving upward - /// toward the hook host. Downward calls may carry a response hook id, but that - /// id is only a promise for future upward traffic and must not delete local - /// state if it happens to collide with an existing hook id. - fn queue_outbound(&mut self, packet: Packet, next_hop: u32, direction: RouteDirection) { - if matches!(direction, RouteDirection::Upward) && packet.end_hook { - self.hooks.remove(&packet.hook_id); + /// Validates hook state for upward routing. + fn ensure_upward_hook_peer( + &self, + hook_id: u16, + actual_peer: Option, + ) -> Result<(), EndpointError> { + if let Some(actual_peer) = actual_peer { + self.ensure_hook_peer(hook_id, actual_peer) + } else if self.has_hook(hook_id) { + Ok(()) + } else { + Err(EndpointError::UnknownHook { hook_id }) } + } - self.outbound.entry(next_hop).or_default().push_back(packet); + /// Applies hook state for successfully routed downward packets. + fn apply_downward_hook_lifecycle(&mut self, hook_id: u16, end_hook: bool, peer: u32) { + if end_hook { + self.close_hook(hook_id); + } else { + self.open_hook(hook_id, peer); + } + } + + /// Applies hook cleanup for successfully routed upward final packets. + fn apply_upward_hook_lifecycle(&mut self, hook_id: u16, end_hook: bool) { + if end_hook { + self.close_hook(hook_id); + } } } diff --git a/unshell-protocol/src/error.rs b/unshell-protocol/src/error.rs index 3db9ac1..773e722 100644 --- a/unshell-protocol/src/error.rs +++ b/unshell-protocol/src/error.rs @@ -50,6 +50,42 @@ pub enum EndpointError { direction: RouteDirection, }, + /// Inbound transport bytes arrived from an endpoint that is not registered locally. + /// + /// Direction-aware routing needs to know whether the remote endpoint is the + /// parent or a child before it can decide whether local delivery is downward or + /// upward traffic. Unknown peers are rejected before hook state can be mutated. + UnknownConnection { + /// Adjacent endpoint that supplied the inbound packet. + remote_id: u32, + }, + + /// The same adjacent endpoint is registered as both parent and child. + /// + /// The legacy connection table stores direction as a boolean. Both entries being + /// present would make inbound hook policy ambiguous, so the endpoint refuses to + /// route the packet until the connection state is made unambiguous. + AmbiguousConnection { + /// Adjacent endpoint whose direction cannot be inferred. + remote_id: u32, + }, + + /// An inbound packet tried to move in the opposite direction from its connection. + /// + /// A parent/upstream peer may send packets downward, while a child/downstream + /// peer may send packets upward. This prevents a child from using its transport + /// link to forge downward traffic to siblings or descendants. + InboundDirectionMismatch { + /// Adjacent endpoint that supplied the inbound packet. + remote_id: u32, + + /// Direction allowed by the registered connection. + expected: RouteDirection, + + /// Direction implied by the packet destination path. + actual: RouteDirection, + }, + /// The packet is trying to move upward without known hook state. /// /// Upward hook traffic is gated by local hook state so a peer cannot forge a @@ -59,6 +95,23 @@ pub enum EndpointError { hook_id: u16, }, + /// The hook exists, but it is registered for a different adjacent peer. + /// + /// Hook state is peer-bound so one child cannot reuse another child's paved + /// return channel. For locally generated upward traffic, `actual_peer` is the + /// parent next hop; for inbound upward traffic, it is the child that supplied the + /// frame. + HookPeerMismatch { + /// Hook id claimed by the upward packet. + hook_id: u16, + + /// Adjacent peer recorded when the hook was paved. + expected_peer: u32, + + /// Adjacent peer trying to use the hook now. + actual_peer: u32, + }, + /// A packet could not be converted into bytes for transport. /// /// Endpoint-level code that drains outbound queues often wants one error type diff --git a/unshell-protocol/src/lib.rs b/unshell-protocol/src/lib.rs index 184bbad..e8ff7c6 100644 --- a/unshell-protocol/src/lib.rs +++ b/unshell-protocol/src/lib.rs @@ -6,7 +6,7 @@ mod endpoint; mod error; mod packet; -pub use endpoint::Endpoint; +pub use endpoint::{Endpoint, HookID}; pub use error::*; pub use packet::Packet; @@ -26,7 +26,6 @@ use alloc::{ type Path = Vec; type EndpointName = u32; -type HookID = u16; type ConnectionSet = BTreeSet<(EndpointName, bool)>; type HookMap = BTreeMap; type PacketQueue = VecDeque; diff --git a/unshell-protocol/src/tests/merkle_sync/leaves.rs b/unshell-protocol/src/tests/merkle_sync/leaves.rs index 0803972..b1b75b9 100644 --- a/unshell-protocol/src/tests/merkle_sync/leaves.rs +++ b/unshell-protocol/src/tests/merkle_sync/leaves.rs @@ -110,7 +110,7 @@ impl Leaf for MockConnectionLeaf { // Mock transports move untrusted bytes. Malformed frames are dropped so // the sync state machine is tested only after packet parsing succeeds. if let Ok(packet) = Packet::deserialize(&data) { - let _ = endpoint.add_inbound(packet); + let _ = endpoint.add_inbound_from(self.remote_id, packet); } } @@ -335,7 +335,6 @@ impl MerkleRespondentLeaf { }; let frames = self.frames_for_request(procedure_id, &data); - endpoint.hooks.insert(hook_id, ENDPOINT_CALLER); self.report.borrow_mut().requests_seen.push(procedure_id); if !frames.is_empty() { diff --git a/unshell-protocol/src/tests/merkle_sync/rpc.rs b/unshell-protocol/src/tests/merkle_sync/rpc.rs index 01440e0..83ba211 100644 --- a/unshell-protocol/src/tests/merkle_sync/rpc.rs +++ b/unshell-protocol/src/tests/merkle_sync/rpc.rs @@ -78,7 +78,7 @@ pub(super) fn block_chunk_frame(chunk: BlockChunk) -> OutgoingFrame { fn request_packet(procedure_id: u32, hook_id: u16, data: Vec) -> Packet { Packet { hook_id, - end_hook: true, + end_hook: false, path: vec![ENDPOINT_CALLER, ENDPOINT_RESPONDENT], procedure_id, data, diff --git a/unshell-protocol/src/tests/merkle_sync/tests.rs b/unshell-protocol/src/tests/merkle_sync/tests.rs index ecd97bb..8011840 100644 --- a/unshell-protocol/src/tests/merkle_sync/tests.rs +++ b/unshell-protocol/src/tests/merkle_sync/tests.rs @@ -38,7 +38,7 @@ fn merkle_sync_walks_hash_tree_and_streams_changed_blocks() { assert_eq!(respondent.streams_started, 6); assert_eq!(respondent.streams_completed, 6); assert_eq!(respondent.frames_sent, 12); - assert!(harness.endpoint_b.hooks.is_empty()); + assert_eq!(harness.endpoint_b.hook_count(), 0); } #[test] @@ -65,14 +65,14 @@ fn block_stream_hook_persists_until_final_frame() { harness.run_until_respondent_frames(8, 100); assert_eq!( - harness.endpoint_b.hooks.len(), + harness.endpoint_b.hook_count(), 1, "first block stream should keep its hook after a non-final chunk" ); harness.run_until_done(100); assert!( - harness.endpoint_b.hooks.is_empty(), + harness.endpoint_b.hook_count() == 0, "final block stream packet should clean respondent hook state" ); } diff --git a/unshell-protocol/src/tests/oneshot/mod.rs b/unshell-protocol/src/tests/oneshot/mod.rs index 90d5657..d2fae29 100644 --- a/unshell-protocol/src/tests/oneshot/mod.rs +++ b/unshell-protocol/src/tests/oneshot/mod.rs @@ -7,8 +7,8 @@ use alloc::{boxed::Box, vec}; use support::{ CommsLeaf, ControllerLeaf, ENDPOINT_A, ENDPOINT_B, ENDPOINT_C, ResponderLeaf, - assert_hook_present, assert_hook_removed, echo_packet, endpoint_at, single_inbound_packet, - single_outbound_packet, + assert_hook_present, assert_hook_removed, echo_packet, echo_packet_with_end, endpoint_at, + single_inbound_packet, single_outbound_packet, }; #[test] @@ -82,26 +82,30 @@ fn test_oneshot() { assert!(response.end_hook); assert_eq!(response.data, "ABC123".as_bytes()); assert!( - endpoint_b.hooks.is_empty(), + endpoint_b.hook_count() == 0, "responder hook should be cleaned after the upward response" ); // assert_eq!(response.hook_id, HOOK_ECHO); } #[test] -fn inbound_packet_for_local_endpoint_is_delivered_locally() { +fn inbound_downward_packet_for_local_endpoint_opens_hook() { let mut endpoint = endpoint_at(ENDPOINT_B, vec![ENDPOINT_A, ENDPOINT_B]); let hook_id = endpoint.get_hook_id(); - endpoint.hooks.insert(hook_id, ENDPOINT_A); + endpoint.connections.insert((ENDPOINT_A, true)); endpoint - .add_inbound(echo_packet(vec![ENDPOINT_A, ENDPOINT_B], hook_id)) + .add_inbound_from( + ENDPOINT_A, + echo_packet(vec![ENDPOINT_A, ENDPOINT_B], hook_id), + ) .unwrap(); let packet = single_inbound_packet(&endpoint, ENDPOINT_B); - assert!(packet.end_hook); + assert!(!packet.end_hook); assert_eq!(packet.path, vec![ENDPOINT_A, ENDPOINT_B]); assert_hook_present(&endpoint, hook_id); + assert_eq!(endpoint.hook_peer(hook_id), Some(ENDPOINT_A)); assert!(endpoint.outbound.is_empty()); } @@ -109,77 +113,82 @@ fn inbound_packet_for_local_endpoint_is_delivered_locally() { fn outbound_packet_for_local_endpoint_is_delivered_locally() { let mut endpoint = endpoint_at(ENDPOINT_B, vec![ENDPOINT_A, ENDPOINT_B]); let hook_id = endpoint.get_hook_id(); - endpoint.hooks.insert(hook_id, ENDPOINT_A); endpoint .add_outbound(echo_packet(vec![ENDPOINT_A, ENDPOINT_B], hook_id)) .unwrap(); let packet = single_inbound_packet(&endpoint, ENDPOINT_B); - assert!(packet.end_hook); + assert!(!packet.end_hook); assert_eq!(packet.data, "ABC123".as_bytes()); - assert_hook_present(&endpoint, hook_id); + assert_hook_removed(&endpoint, hook_id); assert!(endpoint.outbound.is_empty()); } #[test] fn inbound_downward_packet_routes_to_immediate_child() { - let mut endpoint = endpoint_at(ENDPOINT_A, vec![ENDPOINT_A]); + let mut endpoint = endpoint_at(ENDPOINT_B, vec![ENDPOINT_A, ENDPOINT_B]); let hook_id = endpoint.get_hook_id(); - endpoint.hooks.insert(hook_id, ENDPOINT_B); - endpoint.connections.insert((ENDPOINT_B, false)); + endpoint.connections.insert((ENDPOINT_A, true)); + endpoint.connections.insert((ENDPOINT_C, false)); endpoint - .add_inbound(echo_packet( - vec![ENDPOINT_A, ENDPOINT_B, ENDPOINT_C], - hook_id, - )) + .add_inbound_from( + ENDPOINT_A, + echo_packet(vec![ENDPOINT_A, ENDPOINT_B, ENDPOINT_C], hook_id), + ) .unwrap(); - let packet = single_outbound_packet(&endpoint, ENDPOINT_B); - assert!(packet.end_hook); + let packet = single_outbound_packet(&endpoint, ENDPOINT_C); + assert!(!packet.end_hook); assert_eq!(packet.path, vec![ENDPOINT_A, ENDPOINT_B, ENDPOINT_C]); assert_hook_present(&endpoint, hook_id); - assert!(!endpoint.outbound.contains_key(&ENDPOINT_C)); + assert_eq!(endpoint.hook_peer(hook_id), Some(ENDPOINT_C)); + assert!(!endpoint.outbound.contains_key(&ENDPOINT_A)); } #[test] fn outbound_downward_packet_routes_to_immediate_child() { let mut endpoint = endpoint_at(ENDPOINT_A, vec![ENDPOINT_A]); let hook_id = endpoint.get_hook_id(); - endpoint.hooks.insert(hook_id, ENDPOINT_B); + endpoint.accept_hook(hook_id, ENDPOINT_B); endpoint.connections.insert((ENDPOINT_B, false)); endpoint - .add_outbound(echo_packet( + .add_outbound(echo_packet_with_end( vec![ENDPOINT_A, ENDPOINT_B, ENDPOINT_C], hook_id, + true, )) .unwrap(); let packet = single_outbound_packet(&endpoint, ENDPOINT_B); assert!(packet.end_hook); assert_eq!(packet.path, vec![ENDPOINT_A, ENDPOINT_B, ENDPOINT_C]); - assert_hook_present(&endpoint, hook_id); + assert_hook_removed(&endpoint, hook_id); assert!(!endpoint.outbound.contains_key(&ENDPOINT_C)); } #[test] fn inbound_upward_packet_with_hook_routes_to_parent() { - let mut endpoint = endpoint_at(ENDPOINT_C, vec![ENDPOINT_A, ENDPOINT_B, ENDPOINT_C]); + let mut endpoint = endpoint_at(ENDPOINT_B, vec![ENDPOINT_A, ENDPOINT_B]); let hook_id = endpoint.get_hook_id(); - endpoint.hooks.insert(hook_id, ENDPOINT_A); - endpoint.connections.insert((ENDPOINT_B, true)); + endpoint.accept_hook(hook_id, ENDPOINT_C); + endpoint.connections.insert((ENDPOINT_A, true)); + endpoint.connections.insert((ENDPOINT_C, false)); endpoint - .add_inbound(echo_packet(vec![ENDPOINT_A], hook_id)) + .add_inbound_from( + ENDPOINT_C, + echo_packet_with_end(vec![ENDPOINT_A], hook_id, true), + ) .unwrap(); - let packet = single_outbound_packet(&endpoint, ENDPOINT_B); + let packet = single_outbound_packet(&endpoint, ENDPOINT_A); assert!(packet.end_hook); assert_eq!(packet.hook_id, hook_id); assert_hook_removed(&endpoint, hook_id); - assert!(!endpoint.outbound.contains_key(&ENDPOINT_A)); + assert!(!endpoint.outbound.contains_key(&ENDPOINT_C)); } #[test] @@ -187,9 +196,13 @@ fn inbound_upward_packet_without_hook_is_rejected() { let mut endpoint = endpoint_at(ENDPOINT_B, vec![ENDPOINT_A, ENDPOINT_B]); let hook_id = endpoint.get_hook_id(); endpoint.connections.insert((ENDPOINT_A, true)); + endpoint.connections.insert((ENDPOINT_C, false)); let error = endpoint - .add_inbound(echo_packet(vec![ENDPOINT_A], hook_id)) + .add_inbound_from( + ENDPOINT_C, + echo_packet_with_end(vec![ENDPOINT_A], hook_id, true), + ) .unwrap_err(); assert!(matches!( @@ -202,12 +215,13 @@ fn inbound_upward_packet_without_hook_is_rejected() { #[test] fn forged_upward_packet_with_unknown_hook_is_rejected() { - let mut endpoint = endpoint_at(ENDPOINT_C, vec![ENDPOINT_A, ENDPOINT_B, ENDPOINT_C]); - endpoint.hooks.insert(7, ENDPOINT_A); - endpoint.connections.insert((ENDPOINT_B, true)); + let mut endpoint = endpoint_at(ENDPOINT_B, vec![ENDPOINT_A, ENDPOINT_B]); + endpoint.accept_hook(7, ENDPOINT_C); + endpoint.connections.insert((ENDPOINT_A, true)); + endpoint.connections.insert((ENDPOINT_C, false)); let error = endpoint - .add_inbound(echo_packet(vec![ENDPOINT_A], 99)) + .add_inbound_from(ENDPOINT_C, echo_packet_with_end(vec![ENDPOINT_A], 99, true)) .unwrap_err(); assert!(matches!(error, EndpointError::UnknownHook { hook_id: 99 })); @@ -219,11 +233,14 @@ fn forged_upward_packet_with_unknown_hook_is_rejected() { fn forged_sideways_packet_is_rejected_as_incorrect_path() { let mut endpoint = endpoint_at(ENDPOINT_B, vec![ENDPOINT_A, ENDPOINT_B]); let hook_id = endpoint.get_hook_id(); - endpoint.hooks.insert(hook_id, ENDPOINT_A); + endpoint.accept_hook(hook_id, ENDPOINT_A); endpoint.connections.insert((ENDPOINT_A, true)); let error = endpoint - .add_inbound(echo_packet(vec![ENDPOINT_A, ENDPOINT_C], hook_id)) + .add_inbound_from( + ENDPOINT_A, + echo_packet(vec![ENDPOINT_A, ENDPOINT_C], hook_id), + ) .unwrap_err(); assert!(matches!(error, EndpointError::DestinationOutsideLocalTree)); @@ -283,8 +300,9 @@ fn malformed_frame_does_not_block_following_valid_packet() { endpoint.update(); let packet = single_inbound_packet(&endpoint, ENDPOINT_B); - assert!(packet.end_hook); + assert!(!packet.end_hook); assert_eq!(packet.hook_id, hook_id); + assert_hook_present(&endpoint, hook_id); } #[test] @@ -296,16 +314,21 @@ fn forged_frame_without_required_hook_is_dropped_by_comms_leaf() { vec![Box::new(CommsLeaf { tx: tx_unused, rx: rx_for_endpoint, - remote_id: ENDPOINT_A, - is_authority: true, + remote_id: ENDPOINT_C, + is_authority: false, started: false, })], ); endpoint.path = vec![ENDPOINT_A, ENDPOINT_B]; - endpoint.hooks.insert(7, ENDPOINT_A); + endpoint.accept_hook(7, ENDPOINT_C); + endpoint.connections.insert((ENDPOINT_A, true)); tx_to_endpoint - .send(echo_packet(vec![ENDPOINT_A], 12).serialize().unwrap()) + .send( + echo_packet_with_end(vec![ENDPOINT_A], 12, true) + .serialize() + .unwrap(), + ) .unwrap(); endpoint.update(); @@ -317,13 +340,13 @@ fn forged_frame_without_required_hook_is_dropped_by_comms_leaf() { #[test] fn upward_outbound_without_hook_is_rejected() { let mut endpoint = endpoint_at(ENDPOINT_B, vec![ENDPOINT_A, ENDPOINT_B]); - endpoint.hooks.insert(7, ENDPOINT_A); + endpoint.accept_hook(7, ENDPOINT_A); endpoint.connections.insert((ENDPOINT_A, true)); let new_hook = endpoint.get_hook_id(); let error = endpoint - .add_outbound(echo_packet(vec![ENDPOINT_A], new_hook)) + .add_outbound(echo_packet_with_end(vec![ENDPOINT_A], new_hook, true)) .unwrap_err(); assert!(matches!( @@ -340,7 +363,6 @@ fn downward_outbound_without_hook_is_allowed() { endpoint.connections.insert((ENDPOINT_B, false)); let new_hook = endpoint.get_hook_id(); - endpoint.hooks.insert(new_hook, ENDPOINT_B); endpoint .add_outbound(echo_packet(vec![ENDPOINT_A, ENDPOINT_B], new_hook)) @@ -348,6 +370,7 @@ fn downward_outbound_without_hook_is_allowed() { assert_eq!(endpoint.outbound.get(&ENDPOINT_B).unwrap().len(), 1); assert_hook_present(&endpoint, new_hook); + assert_eq!(endpoint.hook_peer(new_hook), Some(ENDPOINT_B)); } #[test] @@ -355,11 +378,11 @@ fn deeper_upward_route_uses_parent_as_next_hop() { let mut endpoint = endpoint_at(ENDPOINT_C, vec![ENDPOINT_A, ENDPOINT_B, ENDPOINT_C]); let new_hook = endpoint.get_hook_id(); - endpoint.hooks.insert(new_hook, ENDPOINT_A); + endpoint.accept_hook(new_hook, ENDPOINT_B); endpoint.connections.insert((ENDPOINT_B, true)); endpoint - .add_outbound(echo_packet(vec![ENDPOINT_A], new_hook)) + .add_outbound(echo_packet_with_end(vec![ENDPOINT_A], new_hook, true)) .unwrap(); assert!(endpoint.outbound.contains_key(&ENDPOINT_B)); @@ -371,7 +394,6 @@ fn deeper_upward_route_uses_parent_as_next_hop() { fn downward_route_without_connection_is_rejected() { let mut endpoint = endpoint_at(ENDPOINT_A, vec![ENDPOINT_A]); let hook_id = endpoint.get_hook_id(); - endpoint.hooks.insert(hook_id, ENDPOINT_B); let error = endpoint .add_outbound(echo_packet(vec![ENDPOINT_A, ENDPOINT_B], hook_id)) @@ -384,7 +406,7 @@ fn downward_route_without_connection_is_rejected() { direction: RouteDirection::Downward, } )); - assert_hook_present(&endpoint, hook_id); + assert_hook_removed(&endpoint, hook_id); assert!(endpoint.outbound.is_empty()); } @@ -392,10 +414,10 @@ fn downward_route_without_connection_is_rejected() { fn upward_route_without_connection_is_rejected_even_with_hook() { let mut endpoint = endpoint_at(ENDPOINT_B, vec![ENDPOINT_A, ENDPOINT_B]); let hook_id = endpoint.get_hook_id(); - endpoint.hooks.insert(hook_id, ENDPOINT_A); + endpoint.accept_hook(hook_id, ENDPOINT_A); let error = endpoint - .add_outbound(echo_packet(vec![ENDPOINT_A], hook_id)) + .add_outbound(echo_packet_with_end(vec![ENDPOINT_A], hook_id, true)) .unwrap_err(); assert!(matches!( @@ -413,11 +435,11 @@ fn upward_route_without_connection_is_rejected_even_with_hook() { fn end_hook_removes_hook_after_packet_is_queued() { let mut endpoint = endpoint_at(ENDPOINT_B, vec![ENDPOINT_A, ENDPOINT_B]); let hook_id = endpoint.get_hook_id(); - endpoint.hooks.insert(hook_id, ENDPOINT_A); + endpoint.accept_hook(hook_id, ENDPOINT_A); endpoint.connections.insert((ENDPOINT_A, true)); endpoint - .add_outbound(echo_packet(vec![ENDPOINT_A], hook_id)) + .add_outbound(echo_packet_with_end(vec![ENDPOINT_A], hook_id, true)) .unwrap(); assert_hook_removed(&endpoint, hook_id); @@ -431,10 +453,10 @@ fn end_hook_removes_hook_after_packet_is_queued() { fn failed_end_hook_route_keeps_hook_state() { let mut endpoint = endpoint_at(ENDPOINT_B, vec![ENDPOINT_A, ENDPOINT_B]); let hook_id = endpoint.get_hook_id(); - endpoint.hooks.insert(hook_id, ENDPOINT_A); + endpoint.accept_hook(hook_id, ENDPOINT_A); let error = endpoint - .add_outbound(echo_packet(vec![ENDPOINT_A], hook_id)) + .add_outbound(echo_packet_with_end(vec![ENDPOINT_A], hook_id, true)) .unwrap_err(); assert!(matches!( diff --git a/unshell-protocol/src/tests/oneshot/streams.rs b/unshell-protocol/src/tests/oneshot/streams.rs index 299260a..f38e2f8 100644 --- a/unshell-protocol/src/tests/oneshot/streams.rs +++ b/unshell-protocol/src/tests/oneshot/streams.rs @@ -10,13 +10,13 @@ const STREAM_HOOK_ID: u16 = 0; /// Builds the initial downwards packet that opens the stream on the respondent. /// -/// The request deliberately carries `end_hook = true` through `echo_packet`-style -/// semantics: downward routing must not treat that flag as local hook cleanup. The -/// respondent turns this into local stream state keyed by the caller's hook id. +/// The request keeps `end_hook = false` because it expects a return stream. Downward +/// routing now paves that hook automatically at every endpoint that accepts or +/// forwards the request. fn stream_open_packet(hook_id: u16) -> Packet { Packet { hook_id, - end_hook: true, + end_hook: false, path: vec![ENDPOINT_A, ENDPOINT_B], procedure_id: 2, data: b"open".to_vec(), @@ -107,9 +107,9 @@ impl Leaf for StreamRespondentLeaf { impl StreamRespondentLeaf { /// Opens stream state from the first locally delivered request packet. /// - /// The hook is inserted before any upward frame is routed because upward routing - /// is hook-gated. Additional requests are ignored while a stream is active so a - /// caller cannot reset ordering mid-stream in this simple one-way harness. + /// Downward request routing has already paved the hook before the packet reaches + /// this leaf. The leaf only owns stream ordering; endpoint routing owns hook + /// authorization and cleanup. fn open_stream_from_pending_request(&mut self, endpoint: &mut Endpoint) { if self.stream.is_some() { return; @@ -125,7 +125,6 @@ impl StreamRespondentLeaf { }); if let Some(hook_id) = opened_hook { - endpoint.hooks.insert(hook_id, ENDPOINT_A); self.stream = Some(StreamState { hook_id, next_index: 0, @@ -270,7 +269,8 @@ fn one_directional_stream_returns_one_packet_per_loop() { deliver_stream_request(&mut endpoint_a, &mut endpoint_b); assert_received_stream(&endpoint_a, 0, false); - assert!(endpoint_b.hooks.is_empty()); + assert_hook_present(&endpoint_a, STREAM_HOOK_ID); + assert_hook_present(&endpoint_b, STREAM_HOOK_ID); for index in 0..total_packets { drive_stream_loop(&mut endpoint_a, &mut endpoint_b); @@ -279,8 +279,10 @@ fn one_directional_stream_returns_one_packet_per_loop() { assert_received_stream(&endpoint_a, index + 1, final_seen); if final_seen { + assert_hook_removed(&endpoint_a, STREAM_HOOK_ID); assert_hook_removed(&endpoint_b, STREAM_HOOK_ID); } else { + assert_hook_present(&endpoint_a, STREAM_HOOK_ID); assert_hook_present(&endpoint_b, STREAM_HOOK_ID); } } @@ -294,7 +296,8 @@ fn stream_does_not_emit_before_request_is_processed_by_respondent() { assert_received_stream(&endpoint_a, 0, false); assert!(endpoint_b.outbound.is_empty()); - assert!(endpoint_b.hooks.is_empty()); + assert_hook_present(&endpoint_a, STREAM_HOOK_ID); + assert_hook_present(&endpoint_b, STREAM_HOOK_ID); } #[test] diff --git a/unshell-protocol/src/tests/oneshot/support.rs b/unshell-protocol/src/tests/oneshot/support.rs index c1ed4c1..06796ae 100644 --- a/unshell-protocol/src/tests/oneshot/support.rs +++ b/unshell-protocol/src/tests/oneshot/support.rs @@ -17,9 +17,14 @@ const LEAF_RESPONDER: u32 = 102; /// than packet construction, which is important because forged and malformed cases /// should fail before any leaf-level procedure handling would matter. pub(super) fn echo_packet(path: Vec, hook_id: u16) -> Packet { + echo_packet_with_end(path, hook_id, false) +} + +/// Builds a test packet with an explicit hook-lifetime marker. +pub(super) fn echo_packet_with_end(path: Vec, hook_id: u16, end_hook: bool) -> Packet { Packet { hook_id, - end_hook: true, + end_hook, path, procedure_id: 1, data: "ABC123".as_bytes().to_vec(), @@ -71,7 +76,7 @@ pub(super) fn single_inbound_packet(endpoint: &Endpoint, local_id: u32) -> &Pack /// explains the intended routing invariant when it fails. pub(super) fn assert_hook_present(endpoint: &Endpoint, hook_id: u16) { assert!( - endpoint.hooks.contains_key(&hook_id), + endpoint.has_hook(hook_id), "expected hook {hook_id} to remain registered" ); } @@ -82,7 +87,7 @@ pub(super) fn assert_hook_present(endpoint: &Endpoint, hook_id: u16) { /// downward and local packets with the same flag must leave hooks alone. pub(super) fn assert_hook_removed(endpoint: &Endpoint, hook_id: u16) { assert!( - !endpoint.hooks.contains_key(&hook_id), + !endpoint.has_hook(hook_id), "expected hook {hook_id} to be cleaned up" ); } @@ -139,7 +144,7 @@ impl Leaf for CommsLeaf { // the oneshot harness faithful to a router boundary: invalid wire data // must not panic or poison later valid packets on the same connection. if let Ok(packet) = Packet::deserialize(&data) { - let _ = endpoint.add_inbound(packet); + let _ = endpoint.add_inbound_from(self.remote_id, packet); } } @@ -160,16 +165,13 @@ impl Leaf for ResponderLeaf { let mut packets = Vec::new(); endpoint.take_inbound_clear(local_id, |packet| { - let mut response = echo_packet(vec![ENDPOINT_A], packet.hook_id); + let mut response = echo_packet_with_end(vec![ENDPOINT_A], packet.hook_id, true); response.hook_id = packet.hook_id; response.data = packet.data.clone(); packets.push(response); }); for packet in packets { - // Upward responses require local hook state before routing; this mirrors - // a callee accepting the call and authorizing the matching response hook. - endpoint.hooks.insert(packet.hook_id, 0); let _ = endpoint.add_outbound(packet); } } From fc82f4f921862a1a47d235aff4f1775805fdc2b6 Mon Sep 17 00:00:00 2001 From: Michael Mikovsky <77305074+Astatin3@users.noreply.github.com> Date: Thu, 28 May 2026 18:17:01 -0600 Subject: [PATCH 22/31] Make macro system and PTY test leaf --- Cargo.lock | 24 + Cargo.toml | 22 +- src/lib.rs | 57 +-- unshell-leaves/leaf-pty/Cargo.toml | 20 + unshell-leaves/leaf-pty/src/codec.rs | 106 +++++ unshell-leaves/leaf-pty/src/constants.rs | 35 ++ unshell-leaves/leaf-pty/src/lib.rs | 26 ++ unshell-leaves/leaf-pty/src/session.rs | 116 +++++ unshell-leaves/leaf-pty/src/state.rs | 37 ++ unshell-leaves/leaf-pty/src/tests.rs | 393 ++++++++++++++++ unshell-leaves/src/lib.rs | 1 - unshell-macros-core/Cargo.toml | 22 + unshell-macros-core/src/leaf/args.rs | 78 ++++ unshell-macros-core/src/leaf/generator.rs | 434 ++++++++++++++++++ unshell-macros-core/src/leaf/mod.rs | 76 +++ unshell-macros-core/src/leaf/names.rs | 58 +++ unshell-macros-core/src/lib.rs | 9 + {unshell-leaves => unshell-macros}/Cargo.toml | 17 +- unshell-macros/src/lib.rs | 15 + unshell-protocol/src/endpoint/mod.rs | 31 ++ unshell-protocol/src/leaf.rs | 359 +++++++++++++++ unshell-protocol/src/lib.rs | 14 +- unshell-protocol/src/packet.rs | 2 +- 23 files changed, 1866 insertions(+), 86 deletions(-) create mode 100644 unshell-leaves/leaf-pty/Cargo.toml create mode 100644 unshell-leaves/leaf-pty/src/codec.rs create mode 100644 unshell-leaves/leaf-pty/src/constants.rs create mode 100644 unshell-leaves/leaf-pty/src/lib.rs create mode 100644 unshell-leaves/leaf-pty/src/session.rs create mode 100644 unshell-leaves/leaf-pty/src/state.rs create mode 100644 unshell-leaves/leaf-pty/src/tests.rs delete mode 100644 unshell-leaves/src/lib.rs create mode 100644 unshell-macros-core/Cargo.toml create mode 100644 unshell-macros-core/src/leaf/args.rs create mode 100644 unshell-macros-core/src/leaf/generator.rs create mode 100644 unshell-macros-core/src/leaf/mod.rs create mode 100644 unshell-macros-core/src/leaf/names.rs create mode 100644 unshell-macros-core/src/lib.rs rename {unshell-leaves => unshell-macros}/Cargo.toml (57%) create mode 100644 unshell-macros/src/lib.rs create mode 100644 unshell-protocol/src/leaf.rs diff --git a/Cargo.lock b/Cargo.lock index eef28b9..207f8b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -399,6 +399,13 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "leaf-pty" +version = "0.1.0" +dependencies = [ + "unshell", +] + [[package]] name = "leb128fmt" version = "0.1.0" @@ -847,9 +854,26 @@ dependencies = [ "rkyv", "static_init", "thiserror", + "unshell-macros", "unshell-protocol", ] +[[package]] +name = "unshell-macros" +version = "0.1.0" +dependencies = [ + "unshell-macros-core", +] + +[[package]] +name = "unshell-macros-core" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "unshell-protocol" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 1d73213..7c0ebd8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,11 +4,12 @@ cargo-features = ["trim-paths", "panic-immediate-abort"] members = [ "ush-obfuscate", "base62", - # "unshell-macros", + "unshell-macros-core", + "unshell-macros", + "unshell-protocol", - # "unshell-runtime", - # "unshell-leaves", - # "treetest", + + "unshell-leaves/leaf-pty", ] resolver = "2" @@ -30,15 +31,19 @@ quote = "1.0.45" proc-macro2 = "1.0.106" portable-pty = "0.9.0" crossbeam-channel = "0.5.15" + unshell = { path = "." } unshell-protocol = { path = "./unshell-protocol" } -# unshell-runtime = { path = "./unshell-runtime" } -# unshell-leaves = { path = "./unshell-leaves" } -# unshell-macros = { path = "./unshell-macros" } +unshell-macros-core = { path = "./unshell-macros-core" } +unshell-macros = { path = "./unshell-macros" } # ush-obfuscate = { path = "./ush-obfuscate" } # base62 = { path = "./base62" } +# Leaves +leaf-pty = { path = "./unshell-leaves/leaf-pty" } + + [package] name = "unshell" version.workspace = true @@ -63,7 +68,8 @@ thiserror = { workspace = true, optional = true } chrono = { workspace = true, optional = true } # ush-obfuscate = { workspace = true } static_init = { workspace = true } -# unshell-macros = { workspace = true } + +unshell-macros = { workspace = true } unshell-protocol = { workspace = true } # unshell-runtime = { workspace = true } # unshell-leaves = { workspace = true } diff --git a/src/lib.rs b/src/lib.rs index 3f7a827..365f64c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,62 +12,11 @@ #![no_std] pub extern crate alloc; -// Re-export derive macros against a stable `::unshell` path, including when the -// macros are used inside this crate's own examples and tests. -#[allow(unused_extern_crates)] -extern crate self as unshell; pub mod logger; -/// Re-export the protocol crate behind the historical `unshell::protocol` path so -/// proc-macro output and downstream code do not need a second migration. -pub use unshell_protocol as protocol; +pub mod protocol { + pub use unshell_protocol::*; -// Re-export the leaf library crate behind the historical `unshell::leaves` path -// once the leaf crate is part of the active workspace again. -// pub use unshell_leaves as leaves; - -// Re-export the runtime crate behind the `unshell::runtime` path once the runtime -// crate is part of the active workspace again. -// pub use unshell_runtime as runtime; - -// pub use unshell_macros::{Procedure, leaf, procedures}; - -/// Creates a root-assumed endpoint from one local identifier plus any number of leaf hosts. -/// -/// What it is: a convenience macro that builds a `ProtocolEndpoint` whose protocol path starts at -/// root, with no parent or children, and whose leaf inventory is inferred from the supplied host -/// values. -/// -/// Why it exists: the common bootstrap case should not require callers to manually construct an -/// empty path, `Vec`, and a `Vec` when they already have leaf host values. -/// -/// # Example -/// -/// ```rust,ignore -/// use unshell::{create_endpoint, leaf}; -/// use unshell::protocol::tree::Endpoint; -/// -/// #[derive(Default)] -/// struct DemoLeaf; -/// -/// #[leaf(id = "org.example.v1.demo", procedures = ["ping"], endpoint_struct = DemoLeaf)] -/// struct Demo; -/// -/// let endpoint = create_endpoint!("demo", DemoLeaf::default()); -/// assert!(endpoint.path().is_empty()); -/// assert_eq!(endpoint.local_id(), Some("demo")); -/// ``` -#[macro_export] -macro_rules! create_endpoint { - ($id:expr $(, $leaf:expr )* $(,)?) => {{ - let mut __unshell_leaf_specs = ::unshell::alloc::vec::Vec::new(); - $( - let __unshell_leaf = $leaf; - __unshell_leaf_specs.push(::unshell::protocol::tree::leaf_spec_of(&__unshell_leaf)); - )* - ::unshell::protocol::tree::ProtocolEndpoint::root($id, __unshell_leaf_specs) - }}; + pub use unshell_macros::unshell_leaf; } - -// pub use ush_obfuscate as obfuscate; diff --git a/unshell-leaves/leaf-pty/Cargo.toml b/unshell-leaves/leaf-pty/Cargo.toml new file mode 100644 index 0000000..e16c044 --- /dev/null +++ b/unshell-leaves/leaf-pty/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "leaf-pty" +version.workspace = true +edition.workspace = true +description = "Hook-backed PTY leaf implementation for UnShell" + +[dependencies] +unshell = { 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" diff --git a/unshell-leaves/leaf-pty/src/codec.rs b/unshell-leaves/leaf-pty/src/codec.rs new file mode 100644 index 0000000..f421385 --- /dev/null +++ b/unshell-leaves/leaf-pty/src/codec.rs @@ -0,0 +1,106 @@ +use alloc::vec::Vec; + +use unshell::protocol::{HookID, Packet}; + +use crate::{OP_ERROR, OP_OPEN, PROC_PTY}; + +/// Encodes a tiny PTY frame into `Packet::data`. +pub fn encode_frame(opcode: u8, payload: &[u8]) -> Vec { + let mut data = Vec::with_capacity(1 + payload.len()); + data.push(opcode); + data.extend_from_slice(payload); + data +} + +/// Encodes an `Open` payload with the caller's reply path. +pub fn encode_open(reply_path: &[u32]) -> Vec { + let mut data = Vec::with_capacity(2 + reply_path.len() * 4); + data.push(OP_OPEN); + data.push(reply_path.len() as u8); + + for segment in reply_path { + data.extend_from_slice(&segment.to_le_bytes()); + } + + data +} + +/// Decodes the reply path embedded in an `Open` payload after the opcode byte. +pub fn decode_open_reply_path(payload: &[u8]) -> Option> { + let path_len = usize::from(*payload.first()?); + let path_bytes = path_len.checked_mul(4)?; + let expected_len = 1usize.checked_add(path_bytes)?; + + if payload.len() != expected_len { + return None; + } + + let mut path = Vec::with_capacity(path_len); + for chunk in payload[1..].chunks_exact(4) { + path.push(u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]])); + } + + Some(path) +} + +/// Returns the opcode byte from a PTY packet, if present. +pub fn frame_opcode(packet: &Packet) -> Option { + packet.data.first().copied() +} + +/// Returns the frame payload after the opcode byte. +pub fn frame_payload(packet: &Packet) -> &[u8] { + if packet.data.len() > 1 { + &packet.data[1..] + } else { + &[] + } +} + +/// Builds an outer PTY packet for callers and tests. +pub fn pty_packet( + path: Vec, + hook_id: HookID, + end_hook: bool, + opcode: u8, + payload: &[u8], +) -> Packet { + Packet { + hook_id, + end_hook, + path, + procedure_id: PROC_PTY, + data: encode_frame(opcode, payload), + } +} + +/// Builds an outer PTY open packet with the specialized open payload shape. +pub fn pty_open_packet(path: Vec, hook_id: HookID, reply_path: &[u32]) -> Packet { + Packet { + hook_id, + end_hook: false, + path, + procedure_id: PROC_PTY, + data: encode_open(reply_path), + } +} + +/// Builds a final error packet for session initialization failures. +pub(crate) fn error_packet(hook_id: HookID, reply_path: Vec, payload: &[u8]) -> Packet { + Packet { + hook_id, + end_hook: true, + path: reply_path, + procedure_id: PROC_PTY, + data: encode_frame(OP_ERROR, payload), + } +} + +/// Infers the caller reply path from a locally delivered destination path. +pub(crate) fn reply_path_from_destination(destination: &[u32]) -> Vec { + if destination.len() > 1 { + destination[..destination.len() - 1].to_vec() + } else { + destination.to_vec() + } +} diff --git a/unshell-leaves/leaf-pty/src/constants.rs b/unshell-leaves/leaf-pty/src/constants.rs new file mode 100644 index 0000000..a73be52 --- /dev/null +++ b/unshell-leaves/leaf-pty/src/constants.rs @@ -0,0 +1,35 @@ +/// Leaf id used by the generated fake PTY wrapper. +pub const LEAF_FAKE_PTY: u32 = 300; + +/// Outer procedure id used by all fake PTY session packets. +pub const PROC_PTY: u32 = 30; + +/// Downward opcode that opens one PTY session. +pub const OP_OPEN: u8 = 0; + +/// Upward opcode acknowledging an opened PTY session. +pub const OP_OPENED: u8 = 1; + +/// Downward opcode carrying PTY stdin bytes. +pub const OP_INPUT: u8 = 2; + +/// Downward opcode representing terminal resize. +pub const OP_RESIZE: u8 = 3; + +/// Downward opcode closing PTY stdin without closing the session hook. +pub const OP_STDIN_EOF: u8 = 4; + +/// Downward opcode asking the remote process to terminate gracefully. +pub const OP_TERMINATE: u8 = 5; + +/// Downward opcode aborting the session without an acknowledgement. +pub const OP_ABORT: u8 = 6; + +/// Upward opcode carrying PTY stdout/stderr bytes. +pub const OP_OUTPUT: u8 = 7; + +/// Upward final opcode carrying the process exit status. +pub const OP_EXIT: u8 = 8; + +/// Upward final opcode carrying a fatal PTY protocol error. +pub const OP_ERROR: u8 = 9; diff --git a/unshell-leaves/leaf-pty/src/lib.rs b/unshell-leaves/leaf-pty/src/lib.rs new file mode 100644 index 0000000..ab00097 --- /dev/null +++ b/unshell-leaves/leaf-pty/src/lib.rs @@ -0,0 +1,26 @@ +//! PTY leaf support for UnShell. +//! +//! This crate currently contains a deterministic fake PTY session used to prove the +//! macro-generated leaf shape. The fake leaf exercises the same hook-backed protocol +//! invariants as a real PTY worker without pulling OS-specific PTY code into +//! `unshell-protocol`. + +#![no_std] + +extern crate alloc; + +mod codec; +mod constants; +mod session; +mod state; + +pub use codec::{ + decode_open_reply_path, encode_frame, encode_open, frame_opcode, frame_payload, + pty_open_packet, pty_packet, +}; +pub use constants::*; +pub use session::{PtySession, PtySessionState}; +pub use state::{FakePtyLeaf, FakePtyState}; + +#[cfg(test)] +mod tests; diff --git a/unshell-leaves/leaf-pty/src/session.rs b/unshell-leaves/leaf-pty/src/session.rs new file mode 100644 index 0000000..eccd085 --- /dev/null +++ b/unshell-leaves/leaf-pty/src/session.rs @@ -0,0 +1,116 @@ +use alloc::vec::Vec; + +use unshell::protocol::{ + HookID, Packet, PacketQueue, Session, SessionCtx, SessionInit, SessionInitResult, SessionStatus, +}; + +use crate::{ + codec::{ + decode_open_reply_path, error_packet, frame_opcode, frame_payload, + reply_path_from_destination, + }, + constants::{ + OP_ABORT, OP_ERROR, OP_EXIT, OP_INPUT, OP_OPEN, OP_OPENED, OP_OUTPUT, OP_STDIN_EOF, + OP_TERMINATE, PROC_PTY, + }, + state::FakePtyState, +}; + +/// Session contract for one hook-backed fake PTY. +pub struct PtySession; + +/// Per-hook fake PTY session state. +/// +/// A real PTY leaf will replace the pending flags with a worker handle. The reply path +/// and hook lifecycle behavior should stay the same. +pub struct PtySessionState { + hook_id: HookID, + reply_path: Vec, + opened_pending: bool, + stdin_closed: bool, +} + +impl Session for PtySession { + const PROCEDURE_ID: u32 = PROC_PTY; + + type State = PtySessionState; + + fn reply_path(session: &Self::State) -> &[u32] { + &session.reply_path + } + + fn init( + leaf: &mut FakePtyState, + packet: Packet, + ctx: &mut SessionInit, + ) -> SessionInitResult { + if frame_opcode(&packet) != Some(OP_OPEN) { + return SessionInitResult::RejectedWith(error_packet( + ctx.hook_id(), + reply_path_from_destination(ctx.packet_path()), + b"unknown-session", + )); + } + + let reply_path = decode_open_reply_path(frame_payload(&packet)) + .unwrap_or_else(|| reply_path_from_destination(ctx.packet_path())); + + leaf.active_count += 1; + leaf.total_opened += 1; + + SessionInitResult::Created(PtySessionState { + hook_id: ctx.hook_id(), + reply_path, + opened_pending: true, + stdin_closed: false, + }) + } + + fn update( + leaf: &mut FakePtyState, + session: &mut Self::State, + incoming: &mut PacketQueue, + ctx: &mut SessionCtx<'_>, + ) -> SessionStatus { + if session.opened_pending { + ctx.send(OP_OPENED, &[]); + session.opened_pending = false; + } + + while let Some(packet) = incoming.pop_front() { + match frame_opcode(&packet) { + Some(OP_INPUT) => ctx.send(OP_OUTPUT, frame_payload(&packet)), + Some(OP_STDIN_EOF) => { + session.stdin_closed = true; + leaf.last_stdin_eof_hook = Some(session.hook_id); + } + Some(OP_TERMINATE) => { + ctx.send_final(OP_EXIT, &[0]); + close_session(leaf); + return SessionStatus::Closed; + } + Some(OP_ABORT) => { + close_session(leaf); + return SessionStatus::Closed; + } + Some(OP_OPEN) => { + ctx.send_final(OP_ERROR, b"duplicate-open"); + close_session(leaf); + return SessionStatus::Closed; + } + _ => { + ctx.send_final(OP_ERROR, b"unknown-opcode"); + close_session(leaf); + return SessionStatus::Closed; + } + } + } + + SessionStatus::Running + } +} + +/// Decrements the active-session counter exactly once for a terminal session path. +fn close_session(leaf: &mut FakePtyState) { + leaf.active_count = leaf.active_count.saturating_sub(1); +} diff --git a/unshell-leaves/leaf-pty/src/state.rs b/unshell-leaves/leaf-pty/src/state.rs new file mode 100644 index 0000000..eaf771b --- /dev/null +++ b/unshell-leaves/leaf-pty/src/state.rs @@ -0,0 +1,37 @@ +use unshell::protocol::{HookID, unshell_leaf}; + +use crate::{constants::LEAF_FAKE_PTY, session::PtySession}; + +/// User-owned state for the generated fake PTY leaf. +/// +/// The macro-generated `FakePtyLeaf` wrapper stores sessions and retry queues around +/// this struct. Keeping counters here makes tests and future procedures observe leaf +/// behavior without reaching into generated session storage. +#[unshell_leaf(leaf = FakePtyLeaf, id = LEAF_FAKE_PTY, sessions(PtySession))] +pub struct FakePtyState { + /// Number of sessions that application logic considers active. + pub active_count: usize, + + /// Total number of successfully opened sessions. + pub total_opened: u64, + + /// Last hook that received stdin EOF. + pub last_stdin_eof_hook: Option, +} + +impl FakePtyState { + /// Creates a fake PTY state with no active sessions. + pub fn new() -> Self { + Self { + active_count: 0, + total_opened: 0, + last_stdin_eof_hook: None, + } + } +} + +impl Default for FakePtyState { + fn default() -> Self { + Self::new() + } +} diff --git a/unshell-leaves/leaf-pty/src/tests.rs b/unshell-leaves/leaf-pty/src/tests.rs new file mode 100644 index 0000000..513fcf0 --- /dev/null +++ b/unshell-leaves/leaf-pty/src/tests.rs @@ -0,0 +1,393 @@ +use alloc::{vec, vec::Vec}; + +use unshell::protocol::{Endpoint, Leaf, Packet}; + +use super::{ + FakePtyLeaf, FakePtyState, OP_ABORT, OP_ERROR, OP_EXIT, OP_INPUT, OP_OPENED, OP_OUTPUT, + OP_STDIN_EOF, OP_TERMINATE, PROC_PTY, frame_opcode, frame_payload, pty_open_packet, pty_packet, +}; + +const ENDPOINT_A: u32 = 0; +const ENDPOINT_B: u32 = 1; +const PROC_OTHER: u32 = 31; + +/// Creates a bare endpoint at a known absolute path. +fn endpoint_at(id: u32, path: Vec) -> Endpoint { + let mut endpoint = Endpoint::new(id, vec![]); + endpoint.path = path; + endpoint +} + +/// Creates the parent/child endpoint pair used by PTY session tests. +fn pty_endpoints() -> (Endpoint, Endpoint) { + let mut endpoint_a = endpoint_at(ENDPOINT_A, vec![ENDPOINT_A]); + let mut endpoint_b = endpoint_at(ENDPOINT_B, vec![ENDPOINT_A, ENDPOINT_B]); + + endpoint_a.connections.insert((ENDPOINT_B, false)); + endpoint_b.connections.insert((ENDPOINT_A, true)); + + (endpoint_a, endpoint_b) +} + +/// Transfers every queued packet for `next_hop` into `receiver` as `remote_id` traffic. +fn transfer_packets(sender: &mut Endpoint, receiver: &mut Endpoint, next_hop: u32, remote_id: u32) { + let mut packets = Vec::new(); + sender.take_outbound_clear(next_hop, |packet| packets.push(packet.clone())); + + for packet in packets { + receiver.add_inbound_from(remote_id, packet).unwrap(); + } +} + +/// Sends one downward PTY frame from endpoint A to endpoint B. +fn send_downward_frame( + endpoint_a: &mut Endpoint, + endpoint_b: &mut Endpoint, + hook_id: u16, + opcode: u8, + payload: &[u8], + end_hook: bool, +) { + endpoint_a + .add_outbound(pty_packet( + vec![ENDPOINT_A, ENDPOINT_B], + hook_id, + end_hook, + opcode, + payload, + )) + .unwrap(); + transfer_packets(endpoint_a, endpoint_b, ENDPOINT_B, ENDPOINT_A); +} + +/// Opens a fake PTY session and delivers the `Opened` response to endpoint A. +fn open_pty_session( + endpoint_a: &mut Endpoint, + endpoint_b: &mut Endpoint, + leaf: &mut FakePtyLeaf, +) -> u16 { + let hook_id = endpoint_a.get_hook_id(); + endpoint_a + .add_outbound(pty_open_packet( + vec![ENDPOINT_A, ENDPOINT_B], + hook_id, + &[ENDPOINT_A], + )) + .unwrap(); + + transfer_packets(endpoint_a, endpoint_b, ENDPOINT_B, ENDPOINT_A); + leaf.update(endpoint_b); + transfer_packets(endpoint_b, endpoint_a, ENDPOINT_A, ENDPOINT_B); + + hook_id +} + +/// Drains PTY packets delivered to endpoint A. +fn drain_parent_pty_packets(endpoint: &mut Endpoint) -> Vec { + let mut packets = Vec::new(); + endpoint.take_inbound_matching( + ENDPOINT_A, + |packet| packet.procedure_id == PROC_PTY, + |packet| packets.push(packet), + ); + packets +} + +/// Asserts that local hook state still contains `hook_id`. +fn assert_hook_present(endpoint: &Endpoint, hook_id: u16) { + assert!(endpoint.has_hook(hook_id)); +} + +/// Asserts that local hook state no longer contains `hook_id`. +fn assert_hook_removed(endpoint: &Endpoint, hook_id: u16) { + assert!(!endpoint.has_hook(hook_id)); +} + +/// Asserts that `packet` carries the expected PTY frame. +fn assert_frame(packet: &Packet, hook_id: u16, opcode: u8, end_hook: bool, payload: &[u8]) { + assert_eq!(packet.hook_id, hook_id); + assert_eq!(packet.end_hook, end_hook); + assert_eq!(frame_opcode(packet), Some(opcode)); + assert_eq!(frame_payload(packet), payload); +} + +/// Returns true when `packets` contains the requested frame. +fn has_frame(packets: &[Packet], hook_id: u16, opcode: u8, payload: &[u8]) -> bool { + packets.iter().any(|packet| { + packet.hook_id == hook_id + && frame_opcode(packet) == Some(opcode) + && frame_payload(packet) == payload + }) +} + +#[test] +fn open_pty_paves_hook_and_creates_session() { + let (mut endpoint_a, mut endpoint_b) = pty_endpoints(); + let mut leaf = FakePtyLeaf::new(FakePtyState::new()); + + let hook_id = open_pty_session(&mut endpoint_a, &mut endpoint_b, &mut leaf); + let packets = drain_parent_pty_packets(&mut endpoint_a); + + assert_eq!(leaf.active_session_count(), 1); + assert_eq!(leaf.state().active_count, 1); + assert_eq!(leaf.state().total_opened, 1); + assert_hook_present(&endpoint_a, hook_id); + assert_hook_present(&endpoint_b, hook_id); + assert_eq!(packets.len(), 1); + assert_frame(&packets[0], hook_id, OP_OPENED, false, &[]); +} + +#[test] +fn input_and_output_share_one_hook() { + let (mut endpoint_a, mut endpoint_b) = pty_endpoints(); + let mut leaf = FakePtyLeaf::new(FakePtyState::new()); + let hook_id = open_pty_session(&mut endpoint_a, &mut endpoint_b, &mut leaf); + drain_parent_pty_packets(&mut endpoint_a); + + send_downward_frame( + &mut endpoint_a, + &mut endpoint_b, + hook_id, + OP_INPUT, + b"hello", + false, + ); + leaf.update(&mut endpoint_b); + transfer_packets(&mut endpoint_b, &mut endpoint_a, ENDPOINT_A, ENDPOINT_B); + let packets = drain_parent_pty_packets(&mut endpoint_a); + + assert_eq!(packets.len(), 1); + assert_frame(&packets[0], hook_id, OP_OUTPUT, false, b"hello"); + assert_hook_present(&endpoint_a, hook_id); + assert_hook_present(&endpoint_b, hook_id); +} + +#[test] +fn stdin_eof_keeps_hook_until_exit() { + let (mut endpoint_a, mut endpoint_b) = pty_endpoints(); + let mut leaf = FakePtyLeaf::new(FakePtyState::new()); + let hook_id = open_pty_session(&mut endpoint_a, &mut endpoint_b, &mut leaf); + drain_parent_pty_packets(&mut endpoint_a); + + send_downward_frame( + &mut endpoint_a, + &mut endpoint_b, + hook_id, + OP_STDIN_EOF, + &[], + false, + ); + leaf.update(&mut endpoint_b); + transfer_packets(&mut endpoint_b, &mut endpoint_a, ENDPOINT_A, ENDPOINT_B); + + assert_eq!(leaf.state().last_stdin_eof_hook, Some(hook_id)); + assert!(drain_parent_pty_packets(&mut endpoint_a).is_empty()); + assert_hook_present(&endpoint_a, hook_id); + assert_hook_present(&endpoint_b, hook_id); + + send_downward_frame( + &mut endpoint_a, + &mut endpoint_b, + hook_id, + OP_TERMINATE, + &[], + false, + ); + leaf.update(&mut endpoint_b); + transfer_packets(&mut endpoint_b, &mut endpoint_a, ENDPOINT_A, ENDPOINT_B); + let packets = drain_parent_pty_packets(&mut endpoint_a); + + assert_eq!(packets.len(), 1); + assert_frame(&packets[0], hook_id, OP_EXIT, true, &[0]); + assert_eq!(leaf.active_session_count(), 0); + assert_hook_removed(&endpoint_a, hook_id); + assert_hook_removed(&endpoint_b, hook_id); +} + +#[test] +fn exit_end_hook_cleans_route_and_session() { + let (mut endpoint_a, mut endpoint_b) = pty_endpoints(); + let mut leaf = FakePtyLeaf::new(FakePtyState::new()); + let hook_id = open_pty_session(&mut endpoint_a, &mut endpoint_b, &mut leaf); + drain_parent_pty_packets(&mut endpoint_a); + + send_downward_frame( + &mut endpoint_a, + &mut endpoint_b, + hook_id, + OP_TERMINATE, + &[], + false, + ); + leaf.update(&mut endpoint_b); + transfer_packets(&mut endpoint_b, &mut endpoint_a, ENDPOINT_A, ENDPOINT_B); + let packets = drain_parent_pty_packets(&mut endpoint_a); + + assert_eq!(packets.len(), 1); + assert_frame(&packets[0], hook_id, OP_EXIT, true, &[0]); + assert_eq!(leaf.active_session_count(), 0); + assert_hook_removed(&endpoint_a, hook_id); + assert_hook_removed(&endpoint_b, hook_id); +} + +#[test] +fn failed_final_exit_route_retries_without_losing_session() { + let (mut endpoint_a, mut endpoint_b) = pty_endpoints(); + let mut leaf = FakePtyLeaf::new(FakePtyState::new()); + let hook_id = open_pty_session(&mut endpoint_a, &mut endpoint_b, &mut leaf); + drain_parent_pty_packets(&mut endpoint_a); + + send_downward_frame( + &mut endpoint_a, + &mut endpoint_b, + hook_id, + OP_TERMINATE, + &[], + false, + ); + endpoint_b.connections.remove(&(ENDPOINT_A, true)); + leaf.update(&mut endpoint_b); + + assert_eq!(leaf.active_session_count(), 1); + assert_eq!(leaf.pending_packet_count(), 1); + assert_hook_present(&endpoint_b, hook_id); + + endpoint_b.connections.insert((ENDPOINT_A, true)); + leaf.update(&mut endpoint_b); + transfer_packets(&mut endpoint_b, &mut endpoint_a, ENDPOINT_A, ENDPOINT_B); + let packets = drain_parent_pty_packets(&mut endpoint_a); + + assert_eq!(packets.len(), 1); + assert_frame(&packets[0], hook_id, OP_EXIT, true, &[0]); + assert_eq!(leaf.active_session_count(), 0); + assert_hook_removed(&endpoint_a, hook_id); + assert_hook_removed(&endpoint_b, hook_id); +} + +#[test] +fn abort_downward_end_hook_closes_without_ack() { + let (mut endpoint_a, mut endpoint_b) = pty_endpoints(); + let mut leaf = FakePtyLeaf::new(FakePtyState::new()); + let hook_id = open_pty_session(&mut endpoint_a, &mut endpoint_b, &mut leaf); + drain_parent_pty_packets(&mut endpoint_a); + + send_downward_frame( + &mut endpoint_a, + &mut endpoint_b, + hook_id, + OP_ABORT, + &[], + true, + ); + leaf.update(&mut endpoint_b); + transfer_packets(&mut endpoint_b, &mut endpoint_a, ENDPOINT_A, ENDPOINT_B); + + assert_eq!(leaf.active_session_count(), 0); + assert!(drain_parent_pty_packets(&mut endpoint_a).is_empty()); + assert_hook_removed(&endpoint_a, hook_id); + assert_hook_removed(&endpoint_b, hook_id); +} + +#[test] +fn unknown_session_input_returns_error_end_hook() { + let (mut endpoint_a, mut endpoint_b) = pty_endpoints(); + let mut leaf = FakePtyLeaf::new(FakePtyState::new()); + let hook_id = endpoint_a.get_hook_id(); + + send_downward_frame( + &mut endpoint_a, + &mut endpoint_b, + hook_id, + OP_INPUT, + b"orphan", + false, + ); + leaf.update(&mut endpoint_b); + transfer_packets(&mut endpoint_b, &mut endpoint_a, ENDPOINT_A, ENDPOINT_B); + let packets = drain_parent_pty_packets(&mut endpoint_a); + + assert_eq!(packets.len(), 1); + assert_frame(&packets[0], hook_id, OP_ERROR, true, b"unknown-session"); + assert_eq!(leaf.active_session_count(), 0); + assert_hook_removed(&endpoint_a, hook_id); + assert_hook_removed(&endpoint_b, hook_id); +} + +#[test] +fn two_pty_sessions_interleave_without_crossing_hooks() { + let (mut endpoint_a, mut endpoint_b) = pty_endpoints(); + let mut leaf = FakePtyLeaf::new(FakePtyState::new()); + + let first_hook = open_pty_session(&mut endpoint_a, &mut endpoint_b, &mut leaf); + let second_hook = open_pty_session(&mut endpoint_a, &mut endpoint_b, &mut leaf); + drain_parent_pty_packets(&mut endpoint_a); + + send_downward_frame( + &mut endpoint_a, + &mut endpoint_b, + second_hook, + OP_INPUT, + b"second", + false, + ); + send_downward_frame( + &mut endpoint_a, + &mut endpoint_b, + first_hook, + OP_INPUT, + b"first", + false, + ); + leaf.update(&mut endpoint_b); + transfer_packets(&mut endpoint_b, &mut endpoint_a, ENDPOINT_A, ENDPOINT_B); + let packets = drain_parent_pty_packets(&mut endpoint_a); + + assert_eq!(leaf.active_session_count(), 2); + assert_eq!(packets.len(), 2); + assert!(has_frame(&packets, first_hook, OP_OUTPUT, b"first")); + assert!(has_frame(&packets, second_hook, OP_OUTPUT, b"second")); + assert_hook_present(&endpoint_a, first_hook); + assert_hook_present(&endpoint_a, second_hook); + assert_hook_present(&endpoint_b, first_hook); + assert_hook_present(&endpoint_b, second_hook); +} + +#[test] +fn pty_leaf_does_not_consume_other_leaf_packets() { + let mut endpoint = endpoint_at(ENDPOINT_B, vec![ENDPOINT_A, ENDPOINT_B]); + let mut leaf = FakePtyLeaf::new(FakePtyState::new()); + endpoint.connections.insert((ENDPOINT_A, true)); + + endpoint + .add_inbound_from( + ENDPOINT_A, + pty_open_packet(vec![ENDPOINT_A, ENDPOINT_B], 7, &[ENDPOINT_A]), + ) + .unwrap(); + endpoint + .add_inbound_from( + ENDPOINT_A, + Packet { + hook_id: 8, + end_hook: false, + path: vec![ENDPOINT_A, ENDPOINT_B], + procedure_id: PROC_OTHER, + data: b"leave-me".to_vec(), + }, + ) + .unwrap(); + + leaf.update(&mut endpoint); + + let mut other_packets = Vec::new(); + endpoint.take_inbound_matching( + ENDPOINT_B, + |packet| packet.procedure_id == PROC_OTHER, + |packet| other_packets.push(packet), + ); + + assert_eq!(leaf.active_session_count(), 1); + assert_eq!(other_packets.len(), 1); + assert_eq!(other_packets[0].procedure_id, PROC_OTHER); + assert_eq!(other_packets[0].data, b"leave-me".to_vec()); +} diff --git a/unshell-leaves/src/lib.rs b/unshell-leaves/src/lib.rs deleted file mode 100644 index 8b13789..0000000 --- a/unshell-leaves/src/lib.rs +++ /dev/null @@ -1 +0,0 @@ - diff --git a/unshell-macros-core/Cargo.toml b/unshell-macros-core/Cargo.toml new file mode 100644 index 0000000..0bd9c20 --- /dev/null +++ b/unshell-macros-core/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "unshell-macros-core" +version.workspace = true +edition.workspace = true +description = "Parser and code generator for UnShell procedural macros" + +[dependencies] +proc-macro2 = { workspace = true } +quote = { workspace = true } +syn = { workspace = true, features = ["full", "extra-traits"] } + +[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" diff --git a/unshell-macros-core/src/leaf/args.rs b/unshell-macros-core/src/leaf/args.rs new file mode 100644 index 0000000..357644f --- /dev/null +++ b/unshell-macros-core/src/leaf/args.rs @@ -0,0 +1,78 @@ +use syn::{ + Expr, Ident, Result, Token, Type, + parse::{Parse, ParseStream}, +}; + +/// Parsed arguments from `#[unshell_leaf(...)]`. +#[derive(Debug)] +pub(crate) struct UnshellLeafArgs { + pub(crate) leaf: Ident, + pub(crate) id: Expr, + pub(crate) sessions: Vec, + pub(crate) procedures: Vec, +} + +impl Parse for UnshellLeafArgs { + fn parse(input: ParseStream<'_>) -> Result { + let mut leaf = None; + let mut id = None; + let mut sessions = Vec::new(); + let mut procedures = Vec::new(); + + while !input.is_empty() { + let key: Ident = input.parse()?; + match key.to_string().as_str() { + "leaf" => { + reject_duplicate(&leaf, &key)?; + input.parse::()?; + leaf = Some(input.parse()?); + } + "id" => { + reject_duplicate(&id, &key)?; + input.parse::()?; + id = Some(input.parse()?); + } + "sessions" => { + sessions = parse_type_list(input)?; + } + "procedures" => { + procedures = parse_type_list(input)?; + } + _ => { + return Err(syn::Error::new( + key.span(), + "expected `leaf`, `id`, `sessions`, or `procedures`", + )); + } + } + + if input.peek(Token![,]) { + input.parse::()?; + } + } + + Ok(Self { + leaf: leaf.ok_or_else(|| input.error("missing `leaf = WrapperName`"))?, + id: id.ok_or_else(|| input.error("missing `id = LEAF_ID`"))?, + sessions, + procedures, + }) + } +} + +/// Rejects repeated scalar keys while keeping repeated list keys additive by design. +fn reject_duplicate(slot: &Option, key: &Ident) -> Result<()> { + if slot.is_some() { + Err(syn::Error::new(key.span(), "duplicate key")) + } else { + Ok(()) + } +} + +/// Parses `name(Type, Type)` argument payloads. +fn parse_type_list(input: ParseStream<'_>) -> Result> { + let content; + syn::parenthesized!(content in input); + let parsed = content.parse_terminated(Type::parse, Token![,])?; + Ok(parsed.into_iter().collect()) +} diff --git a/unshell-macros-core/src/leaf/generator.rs b/unshell-macros-core/src/leaf/generator.rs new file mode 100644 index 0000000..d237e55 --- /dev/null +++ b/unshell-macros-core/src/leaf/generator.rs @@ -0,0 +1,434 @@ +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; +use syn::{Ident, ItemStruct, Result, Type}; + +use super::{ + UnshellLeafArgs, + names::{last_type_ident, to_snake_case}, +}; + +/// Code generator state for one `#[unshell_leaf]` expansion. +pub(crate) struct LeafGenerator { + args: UnshellLeafArgs, + state: ItemStruct, +} + +impl LeafGenerator { + /// Creates a generator for one parsed state struct. + pub(crate) fn new(args: UnshellLeafArgs, state: ItemStruct) -> Self { + Self { args, state } + } + + /// Emits the original state struct plus the generated wrapper leaf. + pub(crate) fn expand(self) -> Result { + let state = &self.state; + let state_ident = &state.ident; + let leaf_ident = &self.args.leaf; + let leaf_id = &self.args.id; + let vis = &state.vis; + let generics = &state.generics; + let (impl_generics, ty_generics, where_clause) = generics.split_for_impl(); + let state_type = quote!(#state_ident #ty_generics); + + let session_stores = self.session_stores()?; + let fields = self.store_fields(&session_stores, &state_type); + let initializers = self.store_initializers(&session_stores); + let packet_predicates = self.packet_predicates(&state_type); + let dispatch_arms = self.dispatch_arms(&session_stores, &state_type); + let session_updates = self.session_updates(&session_stores, &state_type); + let session_flushes = self.session_flushes(&session_stores); + let session_retains = self.session_retains(&session_stores); + let active_count_terms = self.active_count_terms(&session_stores); + let pending_count_terms = self.pending_count_terms(&session_stores); + let id_checks = self.id_checks(&state_type); + + Ok(quote! { + #state + + #vis struct #leaf_ident #generics #where_clause { + state: #state_type, + __unshell_procedure_outbox: ::unshell::protocol::PacketQueue, + #(#fields,)* + } + + impl #impl_generics #leaf_ident #ty_generics #where_clause { + const __UNSHELL_PROCEDURE_ID_CHECKS: () = { + #(#id_checks)* + }; + + /// Creates the generated leaf wrapper around user-owned state. + pub fn new(state: #state_type) -> Self { + Self { + state, + __unshell_procedure_outbox: ::unshell::protocol::PacketQueue::new(), + #(#initializers,)* + } + } + + /// Returns immutable access to the user-owned leaf state. + pub fn state(&self) -> &#state_type { + &self.state + } + + /// Returns mutable access to the user-owned leaf state. + pub fn state_mut(&mut self) -> &mut #state_type { + &mut self.state + } + + /// Returns the number of active session entries across all session families. + pub fn active_session_count(&self) -> usize { + 0usize #(+ #active_count_terms)* + } + + /// Returns queued inbound and outbound packets owned by this generated leaf. + pub fn pending_packet_count(&self) -> usize { + let mut __unshell_count = self.__unshell_procedure_outbox.len(); + #(#pending_count_terms)* + __unshell_count + } + + fn __unshell_packet_is_owned(packet: &::unshell::protocol::Packet) -> bool { + false #(|| #packet_predicates)* + } + + fn __unshell_dispatch( + &mut self, + endpoint: &mut ::unshell::protocol::Endpoint, + packet: ::unshell::protocol::Packet, + ) { + #(#dispatch_arms)* + } + + fn __unshell_update_sessions(&mut self) { + #(#session_updates)* + } + + fn __unshell_flush_all(&mut self, endpoint: &mut ::unshell::protocol::Endpoint) { + ::unshell::protocol::flush_packet_queue( + endpoint, + &mut self.__unshell_procedure_outbox, + ); + #(#session_flushes)* + #(#session_retains)* + } + + fn __unshell_parent_reply_path( + endpoint: &::unshell::protocol::Endpoint, + ) -> ::unshell::protocol::alloc::vec::Vec { + if endpoint.path.len() > 1 { + endpoint.path[..endpoint.path.len() - 1].to_vec() + } else { + endpoint.path.clone() + } + } + } + + impl #impl_generics ::unshell::protocol::Leaf for #leaf_ident #ty_generics #where_clause { + fn get_id(&self) -> u32 { + #leaf_id + } + + fn update(&mut self, endpoint: &mut ::unshell::protocol::Endpoint) { + self.__unshell_flush_all(endpoint); + + let Some(__unshell_local_id) = endpoint.path.last().copied() else { + return; + }; + + let mut __unshell_packets = ::unshell::protocol::alloc::vec::Vec::new(); + endpoint.take_inbound_matching( + __unshell_local_id, + Self::__unshell_packet_is_owned, + |packet| __unshell_packets.push(packet), + ); + + for __unshell_packet in __unshell_packets { + self.__unshell_dispatch(endpoint, __unshell_packet); + } + + self.__unshell_update_sessions(); + self.__unshell_flush_all(endpoint); + } + } + }) + } + + /// Computes one generated store name per session type. + fn session_stores(&self) -> Result> { + self.args + .sessions + .iter() + .map(|session| { + let suffix = last_type_ident(session)?; + let field_suffix = to_snake_case(&suffix.to_string()); + Ok(SessionStore { + ty: session.clone(), + field: format_ident!("__unshell_{}_sessions", field_suffix), + }) + }) + .collect() + } + + /// Emits wrapper fields for session stores. + fn store_fields(&self, stores: &[SessionStore], state_type: &TokenStream) -> Vec { + stores + .iter() + .map(|store| { + let field = &store.field; + let session_ty = &store.ty; + quote! { + #field: ::unshell::protocol::alloc::vec::Vec< + ::unshell::protocol::SessionEntry< + <#session_ty as ::unshell::protocol::Session<#state_type>>::State + > + > + } + }) + .collect() + } + + /// Emits constructor field initializers for session stores. + fn store_initializers(&self, stores: &[SessionStore]) -> Vec { + stores + .iter() + .map(|store| { + let field = &store.field; + quote!(#field: ::unshell::protocol::alloc::vec::Vec::new()) + }) + .collect() + } + + /// Emits boolean procedure-id ownership checks for the filtered endpoint drain. + fn packet_predicates(&self, state_type: &TokenStream) -> Vec { + let session_checks = self.args.sessions.iter().map(|session_ty| { + quote! { + packet.procedure_id + == <#session_ty as ::unshell::protocol::Session<#state_type>>::PROCEDURE_ID + } + }); + let procedure_checks = self.args.procedures.iter().map(|procedure_ty| { + quote! { + packet.procedure_id + == <#procedure_ty as ::unshell::protocol::Procedure<#state_type>>::PROCEDURE_ID + } + }); + + session_checks.chain(procedure_checks).collect() + } + + /// Emits static dispatch branches for every session and procedure type. + fn dispatch_arms(&self, stores: &[SessionStore], state_type: &TokenStream) -> Vec { + let mut arms = Vec::new(); + + for store in stores { + let field = &store.field; + let session_ty = &store.ty; + arms.push(quote! { + if packet.procedure_id + == <#session_ty as ::unshell::protocol::Session<#state_type>>::PROCEDURE_ID + { + if let Some(__unshell_entry) = self + .#field + .iter_mut() + .find(|entry| entry.hook_id == packet.hook_id) + { + __unshell_entry.inbox.push_back(packet); + } else { + let __unshell_hook_id = packet.hook_id; + let __unshell_packet_path = packet.path.clone(); + let mut __unshell_init = ::unshell::protocol::SessionInit::new( + __unshell_hook_id, + __unshell_packet_path, + ); + + match <#session_ty as ::unshell::protocol::Session<#state_type>>::init( + &mut self.state, + packet, + &mut __unshell_init, + ) { + ::unshell::protocol::SessionInitResult::Created(__unshell_state) => { + self.#field.push(::unshell::protocol::SessionEntry::new( + __unshell_hook_id, + __unshell_state, + )); + } + ::unshell::protocol::SessionInitResult::Rejected => {} + ::unshell::protocol::SessionInitResult::RejectedWith(__unshell_packet) => { + self.__unshell_procedure_outbox.push_back(__unshell_packet); + } + } + } + return; + } + }); + } + + for procedure_ty in &self.args.procedures { + arms.push(quote! { + if packet.procedure_id + == <#procedure_ty as ::unshell::protocol::Procedure<#state_type>>::PROCEDURE_ID + { + let mut __unshell_out = ::unshell::protocol::ProcedureOut::new( + packet.hook_id, + Self::__unshell_parent_reply_path(endpoint), + <#procedure_ty as ::unshell::protocol::Procedure<#state_type>>::PROCEDURE_ID, + ); + <#procedure_ty as ::unshell::protocol::Procedure<#state_type>>::handle( + &mut self.state, + endpoint, + packet, + &mut __unshell_out, + ); + self.__unshell_procedure_outbox.extend(__unshell_out.into_packets()); + return; + } + }); + } + + arms + } + + /// Emits the per-session update loop for every session family. + fn session_updates( + &self, + stores: &[SessionStore], + state_type: &TokenStream, + ) -> Vec { + stores + .iter() + .map(|store| { + let field = &store.field; + let session_ty = &store.ty; + quote! { + for __unshell_entry in &mut self.#field { + if __unshell_entry.closed { + continue; + } + + let __unshell_reply_path = + <#session_ty as ::unshell::protocol::Session<#state_type>>::reply_path( + &__unshell_entry.state, + ) + .to_vec(); + let mut __unshell_ctx = ::unshell::protocol::SessionCtx::new( + __unshell_entry.hook_id, + __unshell_reply_path, + <#session_ty as ::unshell::protocol::Session<#state_type>>::PROCEDURE_ID, + &mut __unshell_entry.outbox, + ); + let __unshell_status = + <#session_ty as ::unshell::protocol::Session<#state_type>>::update( + &mut self.state, + &mut __unshell_entry.state, + &mut __unshell_entry.inbox, + &mut __unshell_ctx, + ); + + if ::core::matches!( + __unshell_status, + ::unshell::protocol::SessionStatus::Closed + ) { + __unshell_entry.closed = true; + } + } + } + }) + .collect() + } + + /// Emits retry flushing for every session outbox. + fn session_flushes(&self, stores: &[SessionStore]) -> Vec { + stores + .iter() + .map(|store| { + let field = &store.field; + quote! { + for __unshell_entry in &mut self.#field { + ::unshell::protocol::flush_packet_queue( + endpoint, + &mut __unshell_entry.outbox, + ); + } + } + }) + .collect() + } + + /// Emits removal of closed sessions whose final packets have routed. + fn session_retains(&self, stores: &[SessionStore]) -> Vec { + stores + .iter() + .map(|store| { + let field = &store.field; + quote! { + self.#field + .retain(|entry| !entry.closed || !entry.outbox.is_empty()); + } + }) + .collect() + } + + /// Emits additive terms for active session counts. + fn active_count_terms(&self, stores: &[SessionStore]) -> Vec { + stores + .iter() + .map(|store| { + let field = &store.field; + quote!(self.#field.len()) + }) + .collect() + } + + /// Emits statements that accumulate pending packet counts. + fn pending_count_terms(&self, stores: &[SessionStore]) -> Vec { + stores + .iter() + .map(|store| { + let field = &store.field; + quote! { + for __unshell_entry in &self.#field { + __unshell_count += + __unshell_entry.inbox.len() + __unshell_entry.outbox.len(); + } + } + }) + .collect() + } + + /// Emits pairwise const assertions for procedure-id uniqueness. + fn id_checks(&self, state_type: &TokenStream) -> Vec { + let mut ids = Vec::new(); + for session_ty in &self.args.sessions { + ids.push( + quote!(<#session_ty as ::unshell::protocol::Session<#state_type>>::PROCEDURE_ID), + ); + } + for procedure_ty in &self.args.procedures { + ids.push( + quote!(<#procedure_ty as ::unshell::protocol::Procedure<#state_type>>::PROCEDURE_ID), + ); + } + + let mut checks = Vec::new(); + for left in 0..ids.len() { + for right in (left + 1)..ids.len() { + let left_id = &ids[left]; + let right_id = &ids[right]; + checks.push(quote! { + assert!( + #left_id != #right_id, + "duplicate unshell procedure id in #[unshell_leaf]" + ); + }); + } + } + + checks + } +} + +/// Generated storage metadata for one session family. +struct SessionStore { + ty: Type, + field: Ident, +} diff --git a/unshell-macros-core/src/leaf/mod.rs b/unshell-macros-core/src/leaf/mod.rs new file mode 100644 index 0000000..9f267ea --- /dev/null +++ b/unshell-macros-core/src/leaf/mod.rs @@ -0,0 +1,76 @@ +//! Leaf wrapper macro implementation. +//! +//! Everything in this module is specific to `#[unshell_leaf]`: argument parsing, +//! generated wrapper storage, static dispatch, and retry-safe session output. Future +//! macro families should be added as sibling modules instead of sharing this internal +//! structure. + +mod args; +mod generator; +mod names; + +use proc_macro2::TokenStream; +use syn::{ItemStruct, Result, parse2}; + +pub(crate) use args::UnshellLeafArgs; +pub(crate) use generator::LeafGenerator; + +/// Expands `#[unshell_leaf(...)]` into a wrapper leaf and `Leaf` implementation. +/// +/// Errors are returned as tokenized `compile_error!` output so the proc-macro shim can +/// stay a thin transport layer from compiler tokens to this core implementation. +pub fn expand_unshell_leaf(attr: TokenStream, item: TokenStream) -> TokenStream { + match expand_unshell_leaf_result(attr, item) { + Ok(tokens) => tokens, + Err(error) => error.to_compile_error(), + } +} + +/// Fallible expansion path used by unit tests. +pub fn expand_unshell_leaf_result(attr: TokenStream, item: TokenStream) -> Result { + let args = parse2::(attr)?; + let state = parse2::(item)?; + LeafGenerator::new(args, state).expand() +} + +#[cfg(test)] +mod tests { + use super::*; + use quote::quote; + + #[test] + fn parses_leaf_arguments() { + let args = parse2::(quote! { + leaf = DemoLeaf, + id = 42, + sessions(DemoSession), + procedures(PingProcedure) + }) + .unwrap(); + + assert_eq!(args.leaf, "DemoLeaf"); + assert_eq!(args.sessions.len(), 1); + assert_eq!(args.procedures.len(), 1); + } + + #[test] + fn missing_leaf_is_rejected() { + let error = parse2::(quote! { id = 42 }).unwrap_err(); + + assert!(error.to_string().contains("missing `leaf")); + } + + #[test] + fn expansion_contains_static_dispatch() { + let expanded = expand_unshell_leaf_result( + quote! { leaf = DemoLeaf, id = 9, sessions(DemoSession) }, + quote! { pub struct DemoState; }, + ) + .unwrap() + .to_string(); + + assert!(expanded.contains("struct DemoLeaf")); + assert!(expanded.contains("impl :: unshell :: protocol :: Leaf for DemoLeaf")); + assert!(expanded.contains("DemoSession")); + } +} diff --git a/unshell-macros-core/src/leaf/names.rs b/unshell-macros-core/src/leaf/names.rs new file mode 100644 index 0000000..d242fb9 --- /dev/null +++ b/unshell-macros-core/src/leaf/names.rs @@ -0,0 +1,58 @@ +use syn::{Ident, Result, Type}; + +/// Returns the final path segment for a session type. +pub(crate) fn last_type_ident(ty: &Type) -> Result { + let Type::Path(path) = ty else { + return Err(syn::Error::new_spanned( + ty, + "session types must be named paths", + )); + }; + let Some(segment) = path.path.segments.last() else { + return Err(syn::Error::new_spanned(ty, "session type path is empty")); + }; + + Ok(segment.ident.clone()) +} + +/// Converts a Rust type name into a snake-case fragment for generated private fields. +pub(crate) fn to_snake_case(name: &str) -> String { + let mut output = String::with_capacity(name.len()); + let chars: Vec = name.chars().collect(); + + for (index, character) in chars.iter().copied().enumerate() { + if character.is_ascii_uppercase() { + let previous = index + .checked_sub(1) + .and_then(|previous| chars.get(previous)); + let next = chars.get(index + 1); + let previous_needs_boundary = previous + .map(|previous| previous.is_ascii_lowercase() || previous.is_ascii_digit()) + .unwrap_or(false); + let acronym_needs_boundary = previous + .map(|previous| previous.is_ascii_uppercase()) + .unwrap_or(false) + && next.map(|next| next.is_ascii_lowercase()).unwrap_or(false); + + if previous_needs_boundary || acronym_needs_boundary { + output.push('_'); + } + output.push(character.to_ascii_lowercase()); + } else { + output.push(character); + } + } + + output +} + +#[cfg(test)] +mod tests { + use super::to_snake_case; + + #[test] + fn session_store_fields_are_snake_case() { + assert_eq!(to_snake_case("PtySession"), "pty_session"); + assert_eq!(to_snake_case("HTTPServer"), "http_server"); + } +} diff --git a/unshell-macros-core/src/lib.rs b/unshell-macros-core/src/lib.rs new file mode 100644 index 0000000..f7b8f9a --- /dev/null +++ b/unshell-macros-core/src/lib.rs @@ -0,0 +1,9 @@ +//! Parser and code generator for UnShell procedural macros. +//! +//! This crate is intentionally not a proc-macro crate. Keeping each macro family's +//! parser and code generator here makes them unit-testable and prevents parsing +//! dependencies from leaking into runtime crates. + +mod leaf; + +pub use leaf::{expand_unshell_leaf, expand_unshell_leaf_result}; diff --git a/unshell-leaves/Cargo.toml b/unshell-macros/Cargo.toml similarity index 57% rename from unshell-leaves/Cargo.toml rename to unshell-macros/Cargo.toml index f27c2e1..1fb533e 100644 --- a/unshell-leaves/Cargo.toml +++ b/unshell-macros/Cargo.toml @@ -1,20 +1,14 @@ [package] -name = "unshell-leaves" +name = "unshell-macros" version.workspace = true edition.workspace = true -description = "Application-layer UnShell leaves and client surfaces" +description = "Procedural macros for UnShell leaves" -[features] -default = [] -leaf_endpoint = [] -leaf_tui = [] +[lib] +proc-macro = true [dependencies] -rkyv = { workspace = true } -portable-pty = { workspace = true } -crossbeam-channel = { workspace = true } -unshell-macros = { workspace = true } -unshell-protocol = { workspace = true } +unshell-macros-core = { workspace = true } [lints.rust] elided_lifetimes_in_paths = "warn" @@ -27,4 +21,3 @@ unsafe_op_in_unsafe_fn = "warn" unused_import_braces = "warn" unused_lifetimes = "warn" trivial_casts = "allow" -missing_docs = "warn" diff --git a/unshell-macros/src/lib.rs b/unshell-macros/src/lib.rs new file mode 100644 index 0000000..9751ccc --- /dev/null +++ b/unshell-macros/src/lib.rs @@ -0,0 +1,15 @@ +//! Procedural macro shim for UnShell. +//! +//! The real parser and code generator live in `unshell-macros-core` so they can be +//! tested as ordinary Rust. This crate only adapts compiler `TokenStream`s. + +use proc_macro::TokenStream; + +/// Generates an `unshell_protocol::Leaf` wrapper for a user-owned state struct. +/// +/// See `LEAF_MACRO_INTERFACE.md` for the design contract. The generated wrapper owns +/// session stores, retry queues, filtered packet dispatch, and final-frame cleanup. +#[proc_macro_attribute] +pub fn unshell_leaf(attr: TokenStream, item: TokenStream) -> TokenStream { + unshell_macros_core::expand_unshell_leaf(attr.into(), item.into()).into() +} diff --git a/unshell-protocol/src/endpoint/mod.rs b/unshell-protocol/src/endpoint/mod.rs index a9868a9..c57772b 100644 --- a/unshell-protocol/src/endpoint/mod.rs +++ b/unshell-protocol/src/endpoint/mod.rs @@ -69,6 +69,37 @@ impl Endpoint { Self::take_clear(path, f, &mut self.inbound); } + /// Drain inbound packets for `path` that match `predicate` and preserve the rest. + /// + /// Generated leaf dispatch uses this instead of [`Self::take_inbound_clear`] so + /// one leaf can consume only its procedure or session packets without stealing + /// traffic intended for another leaf. Matching packets are passed by value because + /// most handlers need to move payload bytes into application state; unmatched + /// packets are reinserted in their original FIFO order. + pub fn take_inbound_matching(&mut self, path: u32, mut predicate: P, mut f: F) + where + P: FnMut(&Packet) -> bool, + F: FnMut(Packet), + { + let Some(mut queue) = self.inbound.remove(&path) else { + return; + }; + + let mut unmatched = Vec::new(); + + while let Some(packet) = queue.pop_front() { + if predicate(&packet) { + f(packet); + } else { + unmatched.push(packet); + } + } + + if !unmatched.is_empty() { + self.inbound.entry(path).or_default().extend(unmatched); + } + } + /// Run a function over all outbound packets with some ID then clear it. pub fn take_outbound_clear(&mut self, path: u32, f: F) where diff --git a/unshell-protocol/src/leaf.rs b/unshell-protocol/src/leaf.rs new file mode 100644 index 0000000..aa4491b --- /dev/null +++ b/unshell-protocol/src/leaf.rs @@ -0,0 +1,359 @@ +use crate::{Endpoint, HookID, Packet, PacketQueue}; + +use alloc::vec::Vec; + +/// Application extension point hosted by an [`Endpoint`]. +/// +/// A leaf owns product-specific state and reacts to packets that endpoint routing has +/// already delivered locally. The trait intentionally stays small so handwritten +/// leaves, generated leaves, and test leaves can all share the same endpoint loop. +pub trait Leaf { + /// Returns the stable local identifier for this leaf implementation. + fn get_id(&self) -> u32; + + /// Advances the leaf by one endpoint update tick. + /// + /// Implementations normally drain matching inbound packets, mutate leaf-owned + /// state, then enqueue outbound packets with [`Endpoint::add_outbound`]. + fn update(&mut self, _: &mut Endpoint); +} + +/// Contract implemented by one hook-backed generated session family. +/// +/// A session family maps one outer `procedure_id` to many live hook instances. The +/// generated leaf owns packet grouping, retry-safe output flushing, and final cleanup; +/// the session implementation owns only application behavior. +/// +/// # Example +/// +/// ```rust,ignore +/// impl Session for MySession { +/// const PROCEDURE_ID: u32 = 7; +/// type State = MySessionState; +/// +/// fn reply_path(state: &Self::State) -> &[u32] { +/// &state.reply_path +/// } +/// +/// fn init( +/// leaf: &mut MyLeafState, +/// packet: Packet, +/// ctx: &mut SessionInit, +/// ) -> SessionInitResult { +/// SessionInitResult::Created(MySessionState::from_open(leaf, packet, ctx)) +/// } +/// +/// fn update( +/// leaf: &mut MyLeafState, +/// session: &mut Self::State, +/// incoming: &mut PacketQueue, +/// ctx: &mut SessionCtx<'_>, +/// ) -> SessionStatus { +/// while let Some(packet) = incoming.pop_front() { +/// session.apply(leaf, packet, ctx); +/// } +/// SessionStatus::Running +/// } +/// } +/// ``` +pub trait Session { + /// Outer packet procedure id used by every packet in this session family. + const PROCEDURE_ID: u32; + + /// Application state stored for one live hook. + type State; + + /// Returns the destination path for responses emitted by this session. + /// + /// `Packet` currently carries only a destination path, so protocols that need to + /// reply to a caller should capture a reply path during [`Self::init`]. The + /// generated leaf clones this path into [`SessionCtx`] before calling update so + /// session code can mutably borrow its state while emitting frames. + fn reply_path(session: &Self::State) -> &[u32]; + + /// Creates one session state from a packet whose hook has no active session. + /// + /// Returning [`SessionInitResult::RejectedWith`] lets the generated leaf route a + /// protocol-level failure response with the same retry guarantees as normal + /// output. Returning [`SessionInitResult::Rejected`] silently consumes the packet. + fn init(leaf: &mut L, packet: Packet, ctx: &mut SessionInit) -> SessionInitResult; + + /// Advances one active hook session. + /// + /// The generated leaf calls this for every live session on each update tick so + /// sessions can poll external workers even when no new packet arrived. Outbound + /// packets must be queued through `ctx`; direct endpoint routing would bypass the + /// generated retry rules. + fn update( + leaf: &mut L, + session: &mut Self::State, + incoming: &mut PacketQueue, + ctx: &mut SessionCtx<'_>, + ) -> SessionStatus; +} + +/// Contract implemented by one generated one-packet procedure handler. +/// +/// Procedures are for stateless or short-lived operations such as ping, capabilities, +/// or health checks. Long-running conversations should use [`Session`] so final +/// packet cleanup and retries remain tied to hook state. +pub trait Procedure { + /// Outer packet procedure id handled by this procedure. + const PROCEDURE_ID: u32; + + /// Handles one packet and optionally queues response packets in `out`. + fn handle(leaf: &mut L, endpoint: &mut Endpoint, packet: Packet, out: &mut ProcedureOut); +} + +/// Context passed to [`Session::init`]. +/// +/// This carries routing metadata that the generated leaf already knows before the +/// session state exists. Protocols that need source paths should encode them in the +/// packet payload; `packet_path` is the destination path that routed the packet here. +pub struct SessionInit { + hook_id: HookID, + packet_path: Vec, +} + +impl SessionInit { + /// Creates initialization metadata for a delivered packet. + pub fn new(hook_id: HookID, packet_path: Vec) -> Self { + Self { + hook_id, + packet_path, + } + } + + /// Returns the hook id that will identify the new session. + pub fn hook_id(&self) -> HookID { + self.hook_id + } + + /// Returns the destination path from the packet that reached this leaf. + pub fn packet_path(&self) -> &[u32] { + &self.packet_path + } +} + +/// Result of trying to create a session from a packet without an active hook entry. +pub enum SessionInitResult { + /// A new session was created and should be stored by the generated leaf. + Created(S), + + /// The packet was intentionally consumed without creating state or a response. + Rejected, + + /// The packet was rejected with a response that the generated leaf must route. + RejectedWith(Packet), +} + +/// Session lifecycle status returned from [`Session::update`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SessionStatus { + /// The session is active and should receive future update ticks. + Running, + + /// The session is winding down but still needs future update ticks. + Closing, + + /// The session has finished application work. + /// + /// The generated leaf still retains the entry until every queued packet routes + /// successfully, which prevents a failed final frame from losing session cleanup. + Closed, +} + +/// Mutable output context passed to [`Session::update`]. +/// +/// The context queues packets only; it never routes them immediately. Centralizing +/// routing in generated code is what makes final-frame retries reliable. +pub struct SessionCtx<'a> { + hook_id: HookID, + reply_path: Vec, + procedure_id: u32, + outbox: &'a mut PacketQueue, +} + +impl<'a> SessionCtx<'a> { + /// Creates a context for one session update call. + pub fn new( + hook_id: HookID, + reply_path: Vec, + procedure_id: u32, + outbox: &'a mut PacketQueue, + ) -> Self { + Self { + hook_id, + reply_path, + procedure_id, + outbox, + } + } + + /// Returns the hook id used for packets emitted through this context. + pub fn hook_id(&self) -> HookID { + self.hook_id + } + + /// Returns the destination path used for packets emitted through this context. + pub fn reply_path(&self) -> &[u32] { + &self.reply_path + } + + /// Queues a one-byte-opcode frame without closing the hook. + pub fn send(&mut self, opcode: u8, data: &[u8]) { + self.send_frame(opcode, data, false); + } + + /// Queues a one-byte-opcode frame that closes the hook after successful routing. + pub fn send_final(&mut self, opcode: u8, data: &[u8]) { + self.send_frame(opcode, data, true); + } + + /// Queues a protocol-specific error frame without closing the hook. + /// + /// The `code` is used as the frame opcode because the protocol layer does not + /// reserve a universal error opcode. Leaves that have a dedicated error opcode can + /// pass that value here or call [`Self::send`] directly. + pub fn error(&mut self, code: u8, data: &[u8]) { + self.send(code, data); + } + + /// Queues a protocol-specific error frame that closes the hook after routing. + pub fn error_final(&mut self, code: u8, data: &[u8]) { + self.send_final(code, data); + } + + /// Queues raw packet data without adding an opcode byte. + pub fn send_raw(&mut self, data: &[u8]) { + self.send_raw_with_end(data, false); + } + + /// Queues raw packet data and closes the hook after successful routing. + pub fn send_raw_final(&mut self, data: &[u8]) { + self.send_raw_with_end(data, true); + } + + fn send_frame(&mut self, opcode: u8, data: &[u8], end_hook: bool) { + let mut frame = Vec::with_capacity(data.len() + 1); + frame.push(opcode); + frame.extend_from_slice(data); + self.enqueue_data(frame, end_hook); + } + + fn send_raw_with_end(&mut self, data: &[u8], end_hook: bool) { + self.enqueue_data(data.to_vec(), end_hook); + } + + fn enqueue_data(&mut self, data: Vec, end_hook: bool) { + self.outbox.push_back(Packet { + hook_id: self.hook_id, + end_hook, + path: self.reply_path.clone(), + procedure_id: self.procedure_id, + data, + }); + } +} + +/// Output accumulator passed to [`Procedure::handle`]. +pub struct ProcedureOut { + hook_id: HookID, + reply_path: Vec, + procedure_id: u32, + outbox: PacketQueue, +} + +impl ProcedureOut { + /// Creates an empty procedure output queue. + pub fn new(hook_id: HookID, reply_path: Vec, procedure_id: u32) -> Self { + Self { + hook_id, + reply_path, + procedure_id, + outbox: PacketQueue::new(), + } + } + + /// Replaces the response path used by later [`Self::send`] calls. + pub fn set_reply_path(&mut self, reply_path: Vec) { + self.reply_path = reply_path; + } + + /// Queues raw response data without closing the hook. + pub fn send(&mut self, data: &[u8]) { + self.send_with_end(data, false); + } + + /// Queues raw response data that closes the hook after successful routing. + pub fn send_final(&mut self, data: &[u8]) { + self.send_with_end(data, true); + } + + /// Consumes the output accumulator and returns packets for generated retry logic. + pub fn into_packets(self) -> PacketQueue { + self.outbox + } + + fn send_with_end(&mut self, data: &[u8], end_hook: bool) { + self.outbox.push_back(Packet { + hook_id: self.hook_id, + end_hook, + path: self.reply_path.clone(), + procedure_id: self.procedure_id, + data: data.to_vec(), + }); + } +} + +/// Storage entry used by macro-generated session stores. +/// +/// The fields are public so generated code in downstream crates can keep the update +/// loop straightforward and static. Handwritten leaves may also use this type, but it +/// is intentionally small rather than a full session framework. +pub struct SessionEntry { + /// Hook id associated with this live session. + pub hook_id: HookID, + + /// Application-owned session state. + pub state: S, + + /// Packets delivered for this hook but not yet consumed by the session. + pub inbox: PacketQueue, + + /// Packets emitted by the session but not yet accepted by endpoint routing. + pub outbox: PacketQueue, + + /// Whether application logic has finished and only retry flushing may remain. + pub closed: bool, +} + +impl SessionEntry { + /// Creates one active session entry for `hook_id`. + pub fn new(hook_id: HookID, state: S) -> Self { + Self { + hook_id, + state, + inbox: PacketQueue::new(), + outbox: PacketQueue::new(), + closed: false, + } + } +} + +/// Flushes a retry queue through [`Endpoint::add_outbound`]. +/// +/// The packet at the front is cloned for each attempt and removed only after routing +/// succeeds. This preserves final frames when a route is temporarily unavailable. +/// The return value is true when the queue was fully drained. +pub fn flush_packet_queue(endpoint: &mut Endpoint, outbox: &mut PacketQueue) -> bool { + while let Some(packet) = outbox.front().cloned() { + if endpoint.add_outbound(packet).is_err() { + return false; + } + + outbox.pop_front(); + } + + true +} diff --git a/unshell-protocol/src/lib.rs b/unshell-protocol/src/lib.rs index e8ff7c6..1ec332e 100644 --- a/unshell-protocol/src/lib.rs +++ b/unshell-protocol/src/lib.rs @@ -1,23 +1,17 @@ #![no_std] -extern crate alloc; +pub extern crate alloc; mod endpoint; mod error; +mod leaf; mod packet; pub use endpoint::{Endpoint, HookID}; pub use error::*; +pub use leaf::*; pub use packet::Packet; -pub trait Leaf { - // Identifier for this leaf - fn get_id(&self) -> u32; - - // Gets called every program loop - fn update(&mut self, _: &mut Endpoint); -} - // Various named types used for brevity use alloc::{ collections::{btree_map::BTreeMap, btree_set::BTreeSet, vec_deque::VecDeque}, @@ -28,7 +22,7 @@ type Path = Vec; type EndpointName = u32; type ConnectionSet = BTreeSet<(EndpointName, bool)>; type HookMap = BTreeMap; -type PacketQueue = VecDeque; +pub type PacketQueue = VecDeque; type RouteMap = BTreeMap; #[cfg(test)] diff --git a/unshell-protocol/src/packet.rs b/unshell-protocol/src/packet.rs index 37d6699..f908f3f 100644 --- a/unshell-protocol/src/packet.rs +++ b/unshell-protocol/src/packet.rs @@ -10,7 +10,7 @@ use crate::{DeserializeError, SerializeError}; /// path. `procedure_id` is therefore a compact numeric contract id instead of a /// string label; application code can maintain its own id-to-name table outside the /// hot packet path if it needs human-readable names. -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct Packet { pub hook_id: u16, pub end_hook: bool, From ca1daedebe4e0ae39712f2c5181764414c836af4 Mon Sep 17 00:00:00 2001 From: Michael Mikovsky <77305074+Astatin3@users.noreply.github.com> Date: Fri, 29 May 2026 11:38:14 -0600 Subject: [PATCH 23/31] Add temporary hash function. --- examples/hashtest.rs | 22 +++++++++ src/hash.rs | 57 ++++++++++++++++++++++++ src/lib.rs | 5 +++ unshell-leaves/leaf-pty/src/constants.rs | 4 +- 4 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 examples/hashtest.rs create mode 100644 src/hash.rs diff --git a/examples/hashtest.rs b/examples/hashtest.rs new file mode 100644 index 0000000..0dd59c7 --- /dev/null +++ b/examples/hashtest.rs @@ -0,0 +1,22 @@ +use unshell::hash; + +macro_rules! hashtest { + ($input:tt) => { + ($input, hash($input)) + }; +} + +const MAP: [(&'static str, u32); 6] = [ + hashtest!("abc123"), + hashtest!("abc124"), + hashtest!("abc125"), + hashtest!("abc122"), + hashtest!("somethingelse"), + hashtest!("org.io.abc1234"), +]; + +pub fn main() { + for (a, b) in MAP { + println!("unshell::hash(\"{}\") = {}", a, b) + } +} diff --git a/src/hash.rs b/src/hash.rs new file mode 100644 index 0000000..70e8d28 --- /dev/null +++ b/src/hash.rs @@ -0,0 +1,57 @@ +//! Temporary hash function + +const fn hash_recursive<'a>(state: &mut [u8; 4], input: &'a [u8]) { + match input.len() { + 3 => { + state[0] ^= input[0]; + state[1] ^= input[1]; + state[2] ^= input[2]; + } + 2 => { + state[0] ^= input[0]; + state[1] ^= input[1]; + } + 1 => { + state[0] ^= input[0]; + } + 0 => {} + _ => { + state[0] ^= input[0]; + state[1] ^= input[1]; + state[2] ^= input[2]; + state[3] ^= input[3]; + + // Mess with the state quite a bit + state[0] = u8::reverse_bits(state[0]) ^ state[2]; + state[2] = state[0].wrapping_add(state[2]).wrapping_add(state[3]) ^ state[0]; + state[3] = state[2].wrapping_add(state[3] << 2) ^ state[1]; + state[1] = state[3] ^ 0xa3; + + hash_recursive(state, &input[1..]); + } + } +} + +pub const fn hash(input: &'static str) -> u32 { + let mut data = [0xDE, 0xED, 0xBE, 0xEF]; + hash_recursive(&mut data, input.as_bytes()); + + // throw the data back into itself because why not + let input2 = [ + u8::reverse_bits(data[1]), + data[2], + data[2], + data[1], + u8::reverse_bits(data[0]), + data[2], + u8::reverse_bits(data[3]), + u8::reverse_bits(data[2]), + data[3], + u8::reverse_bits(data[3]), + u8::reverse_bits(data[2]), + data[0], + ]; + hash_recursive(&mut data, &input2); + + u32::from_be_bytes(data) +} diff --git a/src/lib.rs b/src/lib.rs index 365f64c..3dbfd0d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,9 +10,12 @@ //! The library requires `alloc` for path and payload management. #![no_std] +#![feature(const_index)] +#![feature(const_trait_impl)] pub extern crate alloc; +mod hash; pub mod logger; pub mod protocol { @@ -20,3 +23,5 @@ pub mod protocol { pub use unshell_macros::unshell_leaf; } + +pub use hash::hash; diff --git a/unshell-leaves/leaf-pty/src/constants.rs b/unshell-leaves/leaf-pty/src/constants.rs index a73be52..4341c4d 100644 --- a/unshell-leaves/leaf-pty/src/constants.rs +++ b/unshell-leaves/leaf-pty/src/constants.rs @@ -1,8 +1,8 @@ /// Leaf id used by the generated fake PTY wrapper. -pub const LEAF_FAKE_PTY: u32 = 300; +pub const LEAF_FAKE_PTY: u32 = unshell::hash("dev.unshell.v1.pty"); /// Outer procedure id used by all fake PTY session packets. -pub const PROC_PTY: u32 = 30; +pub const PROC_PTY: u32 = unshell::hash("dev.unshell.v1.pty.pty"); /// Downward opcode that opens one PTY session. pub const OP_OPEN: u8 = 0; From 0a44bc93de90905996a269ec28853857c472f3d2 Mon Sep 17 00:00:00 2001 From: Michael Mikovsky <77305074+Astatin3@users.noreply.github.com> Date: Sun, 31 May 2026 08:58:08 -0600 Subject: [PATCH 24/31] Move protocol to workspace root. --- Cargo.lock | 10 +- Cargo.toml | 8 +- LEAF_MACRO_INTERFACE.md | 810 ++++++++++++++++++ examples/hashtest.rs | 2 +- src/hash.rs | 2 +- src/interface/mod.rs | 1 + src/lib.rs | 9 +- .../src => src/protocol}/endpoint/hooks.rs | 2 +- .../src => src/protocol}/endpoint/mod.rs | 2 +- .../src => src/protocol}/endpoint/routing.rs | 2 +- .../src => src/protocol}/error.rs | 0 .../src => src/protocol}/leaf.rs | 2 +- .../src/lib.rs => src/protocol/mod.rs | 5 +- .../src => src/protocol}/packet.rs | 2 +- .../protocol}/tests/merkle_sync/codec.rs | 0 .../protocol}/tests/merkle_sync/constants.rs | 0 .../protocol}/tests/merkle_sync/harness.rs | 2 +- .../protocol}/tests/merkle_sync/leaves.rs | 2 +- .../protocol}/tests/merkle_sync/mod.rs | 0 .../protocol}/tests/merkle_sync/rpc.rs | 2 +- .../protocol}/tests/merkle_sync/state.rs | 4 +- .../protocol}/tests/merkle_sync/tests.rs | 0 .../protocol}/tests/merkle_sync/tree.rs | 0 .../src => src/protocol}/tests/oneshot/mod.rs | 6 +- .../protocol}/tests/oneshot/streams.rs | 2 +- .../protocol}/tests/oneshot/support.rs | 2 +- .../src => src/protocol}/tests/packet.rs | 2 +- unshell-macros-core/src/leaf/generator.rs | 8 +- unshell-protocol/Cargo.toml | 28 - 29 files changed, 844 insertions(+), 71 deletions(-) create mode 100644 LEAF_MACRO_INTERFACE.md create mode 100644 src/interface/mod.rs rename {unshell-protocol/src => src/protocol}/endpoint/hooks.rs (98%) rename {unshell-protocol/src => src/protocol}/endpoint/mod.rs (97%) rename {unshell-protocol/src => src/protocol}/endpoint/routing.rs (99%) rename {unshell-protocol/src => src/protocol}/error.rs (100%) rename {unshell-protocol/src => src/protocol}/leaf.rs (99%) rename unshell-protocol/src/lib.rs => src/protocol/mod.rs (94%) rename {unshell-protocol/src => src/protocol}/packet.rs (98%) rename {unshell-protocol/src => src/protocol}/tests/merkle_sync/codec.rs (100%) rename {unshell-protocol/src => src/protocol}/tests/merkle_sync/constants.rs (100%) rename {unshell-protocol/src => src/protocol}/tests/merkle_sync/harness.rs (99%) rename {unshell-protocol/src => src/protocol}/tests/merkle_sync/leaves.rs (99%) rename {unshell-protocol/src => src/protocol}/tests/merkle_sync/mod.rs (100%) rename {unshell-protocol/src => src/protocol}/tests/merkle_sync/rpc.rs (98%) rename {unshell-protocol/src => src/protocol}/tests/merkle_sync/state.rs (96%) rename {unshell-protocol/src => src/protocol}/tests/merkle_sync/tests.rs (100%) rename {unshell-protocol/src => src/protocol}/tests/merkle_sync/tree.rs (100%) rename {unshell-protocol/src => src/protocol}/tests/oneshot/mod.rs (98%) rename {unshell-protocol/src => src/protocol}/tests/oneshot/streams.rs (99%) rename {unshell-protocol/src => src/protocol}/tests/oneshot/support.rs (99%) rename {unshell-protocol/src => src/protocol}/tests/packet.rs (98%) delete mode 100644 unshell-protocol/Cargo.toml diff --git a/Cargo.lock b/Cargo.lock index 207f8b5..8400a75 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -851,11 +851,11 @@ name = "unshell" version = "0.1.0" dependencies = [ "chrono", + "crossbeam-channel", "rkyv", "static_init", "thiserror", "unshell-macros", - "unshell-protocol", ] [[package]] @@ -874,14 +874,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "unshell-protocol" -version = "0.1.0" -dependencies = [ - "crossbeam-channel", - "rkyv", -] - [[package]] name = "ush-obfuscate" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 7c0ebd8..b18814b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = [ "unshell-macros-core", "unshell-macros", - "unshell-protocol", + # "unshell-protocol", "unshell-leaves/leaf-pty", ] @@ -33,7 +33,7 @@ portable-pty = "0.9.0" crossbeam-channel = "0.5.15" unshell = { path = "." } -unshell-protocol = { path = "./unshell-protocol" } +# unshell-protocol = { path = "./unshell-protocol" } unshell-macros-core = { path = "./unshell-macros-core" } unshell-macros = { path = "./unshell-macros" } @@ -70,10 +70,12 @@ chrono = { workspace = true, optional = true } static_init = { workspace = true } unshell-macros = { workspace = true } -unshell-protocol = { workspace = true } +# unshell-protocol = { workspace = true } # unshell-runtime = { workspace = true } # unshell-leaves = { workspace = true } +[dev-dependencies] +crossbeam-channel.workspace = true [profile.minimize] inherits = "release" diff --git a/LEAF_MACRO_INTERFACE.md b/LEAF_MACRO_INTERFACE.md new file mode 100644 index 0000000..9fa798e --- /dev/null +++ b/LEAF_MACRO_INTERFACE.md @@ -0,0 +1,810 @@ +# Macro-Generated Leaf Interface Design + +**Status:** Draft +**Last updated:** 2026-05-28 +**Primary use case:** Remote PTY sessions over hook-backed UnShell packets + +## Summary + +This document proposes a generated leaf interface for UnShell. The goal is to make +stateful leaves, such as a remote PTY leaf, easy to write without forcing every leaf +author to hand-code packet draining, procedure dispatch, session lookup, hook +lifetime handling, and retry-safe final-frame cleanup. + +The user writes the application logic: + +- the leaf state struct +- one or more session types for long-running hook-backed conversations +- one or more procedure types for one-packet operations +- payload encoding and decoding +- OS-specific behavior, such as spawning or polling a PTY + +The macro generates the plumbing: + +- the `Leaf` implementation +- the generated wrapper that owns session stores and retry queues +- inbound packet filtering for the leaf's procedure ids +- per-packet procedure dispatch +- per-hook session dispatch +- retry-safe outbound flushing +- final-frame session removal only after routing succeeds + +The macro should generate ordinary Rust. No runtime registry, no boxed procedure +objects, and no hidden dynamic dispatch in the hot path. + +## Problem + +The current `Leaf` trait is deliberately small: + +```rust +pub trait Leaf { + fn get_id(&self) -> u32; + + fn update(&mut self, endpoint: &mut Endpoint); +} +``` + +That makes the protocol runtime flexible, but it also means every non-trivial leaf +has to solve the same set of problems by hand: + +- select only the inbound packets that belong to this leaf +- distinguish one-shot procedures from long-running sessions +- group session packets by `hook_id` +- keep per-session application state +- build response packets with the right `hook_id`, path, procedure id, and `end_hook` +- retry failed outbound packets without losing stream progress +- remove session state only after final packets route successfully +- avoid consuming packets intended for other leaves + +A remote PTY leaf makes this pain obvious. A PTY session is bidirectional and long +lived. The same hook carries `Open`, `Input`, `Resize`, `StdinEof`, `Output`, `Exit`, +and errors. The endpoint already owns hook authorization, but the leaf still needs a +safe session state machine. + +## Goals + +- Automate the repetitive `Leaf::update` machinery. +- Keep application logic explicit and testable. +- Keep `unshell-protocol` minimal and no_std-friendly. +- Keep OS-specific PTY code outside `unshell-protocol`. +- Preserve the new endpoint hook model: downward packets pave hooks, final packets close hooks. +- Make final-frame retries hard to get wrong. +- Keep generated runtime code static and size-conscious. +- Let sessions and procedures mutate shared leaf state without directly accessing each other. +- Make multiple sessions of the same type cheap and predictable. + +## Non-Goals + +- Do not define a full actor framework. +- Do not add async requirements to the protocol layer. +- Do not make sessions discover or mutate other sessions directly. +- Do not introduce `Vec>` or `Vec>` runtime registries. +- Do not hide PTY business logic inside the macro. +- Do not add source-path fields to `Packet` just for PTY. The PTY `Open` payload can carry the reply path. + +## Current Protocol Assumptions + +The endpoint routing layer now owns hook lifetime: + +```text +validated downward packet, end_hook=false -> open or refresh peer-bound hook +validated downward packet, end_hook=true -> close hook after successful route or delivery +validated upward packet -> require matching hook +validated upward packet, end_hook=true -> close hook after successful route or delivery +``` + +For PTY, this means: + +- `Open` uses `end_hook = false` because it expects returned output. +- `Input`, `Resize`, `StdinEof`, and `Terminate` use `end_hook = false`. +- `StdinEof` is not `end_hook`. EOF closes stdin, not the whole PTY session. +- `Abort` may use `end_hook = true` if no acknowledgement is expected. +- `Output` uses `end_hook = false`. +- `Exit` or fatal `Error` uses `end_hook = true`. + +## Crate Boundary + +```text +unshell-protocol + Endpoint + Packet + Leaf + hook routing rules + filtered inbound drain API + +unshell-runtime or unshell-leaves + real PTY worker implementation + std-only integrations + portable-pty adapter + +unshell-macros + tiny proc-macro shim + +unshell-macros-core + syn, quote, proc-macro2, deluxe or darling + parser and code generator tests +``` + +The generated code runs in the final binary. Macro parsing dependencies do not. +That means `syn`, `quote`, `deluxe`, and `darling` are acceptable in the macro crates, +but not in the protocol runtime. + +## Required Endpoint Addition + +The generated leaf must be able to drain only the packets it owns. The current +`take_inbound_clear` drains a whole local queue, which is unsafe once multiple +application leaves share an endpoint. + +Add a filtered drain API: + +```rust +impl Endpoint { + /// Drains packets from `local_id` that match `predicate`, preserving all other + /// packets in their original relative order. + pub fn take_inbound_matching(&mut self, local_id: u32, predicate: P, f: F) + where + P: FnMut(&Packet) -> bool, + F: FnMut(&Packet); +} +``` + +For generated leaves, matching usually means: + +```rust +packet.procedure_id == PROC_PTY + || packet.procedure_id == PROC_PING + || packet.procedure_id == PROC_CAPABILITIES +``` + +This is the one endpoint API the macro needs before it can be safe in mixed-leaf endpoints. + +## User-Facing Macro + +Use an attribute macro that wraps a user-owned state struct and generates a leaf type. +A derive macro alone cannot add storage fields to the original struct, so the macro +must generate a companion wrapper. + +```rust +#[unshell_leaf( + leaf = RemotePtyLeaf, + id = LEAF_REMOTE_PTY, + sessions(PtySession), + procedures(PingProcedure, CapabilitiesProcedure) +)] +pub struct RemotePtyState { + max_sessions: usize, + default_rows: u16, + default_cols: u16, +} +``` + +The macro emits roughly: + +```rust +pub struct RemotePtyLeaf { + state: RemotePtyState, + pty_sessions: SessionStore, + retry: PacketQueue, +} + +impl RemotePtyLeaf { + pub fn new(state: RemotePtyState) -> Self { ... } +} + +impl Leaf for RemotePtyLeaf { + fn get_id(&self) -> u32 { + LEAF_REMOTE_PTY + } + + fn update(&mut self, endpoint: &mut Endpoint) { + ... generated dispatch ... + } +} +``` + +The leaf wrapper is the object stored in `Endpoint::leaves`. The state struct stays +small and owned by the user. + +## Core Model + +```text +Endpoint inbound queue + | + v ++-------------------------------+ +| generated RemotePtyLeaf | +| | +| state: RemotePtyState | +| sessions: SessionStore | +| retry: PacketQueue | ++-------------------------------+ + | + +--> Procedure packet -> Procedure::handle(...) + | + +--> Session packet -> by hook_id + create or update session + session queues outbound frames + generated flush handles retry +``` + +Procedures and sessions can both mutate `RemotePtyState`. They cannot directly +borrow or mutate each other. Communication between them must happen through leaf +state or through packets. + +## Sessions + +A session is a long-running hook-backed conversation. PTY is the main example. + +```rust +pub trait Session { + /// All packets for this session type use this outer procedure id. + const PROCEDURE_ID: u32; + + /// State owned for one active hook/session. + type State; + + /// Attempts to create a new session from an incoming packet. + fn init( + leaf: &mut L, + packet: Packet, + ctx: &mut SessionInit, + ) -> SessionInitResult; + + /// Advances one session. The generated leaf passes all queued packets for this + /// hook and one context that can enqueue outbound frames. + fn update( + leaf: &mut L, + session: &mut Self::State, + incoming: &mut PacketQueue, + ctx: &mut SessionCtx, + ) -> SessionStatus; +} +``` + +`L` is the user state type, for example `RemotePtyState`. + +### Session Init Context + +```rust +pub struct SessionInit { + hook_id: HookID, + packet_path: Vec, +} + +pub enum SessionInitResult { + Created(S), + Rejected, + RejectedWith(Packet), +} +``` + +`RejectedWith(Packet)` is intended for cases where the initializer can build a +protocol-level failure response, such as "too many PTY sessions". The generated leaf +still owns routing and retry for that packet. + +The PTY `Open` payload should include the caller reply path because `Packet` does +not currently carry source path. + +```text +Open payload: + opcode + reply_path_len + reply_path segments + rows + cols + command/env/options +``` + +The session stores that reply path and uses it for upward output packets. + +### Session Update Context + +Sessions should use a context wrapper rather than directly constructing packets. +The context can still carry restricted endpoint access when absolutely necessary, +but the normal output path should be helper methods. + +```rust +pub struct SessionCtx<'a> { + endpoint: &'a mut Endpoint, + hook_id: HookID, + reply_path: &'a [u32], + procedure_id: u32, + outbox: &'a mut PacketQueue, +} +``` + +Helpers: + +```rust +impl<'a> SessionCtx<'a> { + pub fn send(&mut self, opcode: u8, data: &[u8]); + + pub fn send_final(&mut self, opcode: u8, data: &[u8]); + + pub fn error(&mut self, code: u8, data: &[u8]); + + pub fn error_final(&mut self, code: u8, data: &[u8]); + +} +``` + +These helpers build packets like: + +```rust +Packet { + hook_id: self.hook_id, + end_hook, + path: self.reply_path.to_vec(), + procedure_id: self.procedure_id, + data: encode_frame(opcode, payload), +} +``` + +The helper only queues packets. It does not route them immediately. The generated +leaf owns flushing and retry. + +### Session Status + +```rust +pub enum SessionStatus { + Running, + Closing, + Closed, +} +``` + +`Closed` means the session has no more application work. The generated leaf still +must keep the session until any final packet has routed successfully. + +## Procedures + +A procedure is a one-packet operation. It is appropriate for introspection, ping, +capabilities, and simple state queries. + +```rust +pub trait Procedure { + const PROCEDURE_ID: u32; + + fn handle( + leaf: &mut L, + endpoint: &mut Endpoint, + packet: Packet, + out: &mut ProcedureOut, + ); +} +``` + +Procedure helpers mirror session helpers but operate on one incoming packet: + +```rust +pub struct ProcedureOut { + hook_id: HookID, + reply_path: Vec, + procedure_id: u32, + outbox: PacketQueue, +} + +impl ProcedureOut { + pub fn send(&mut self, data: &[u8]); + + pub fn send_final(&mut self, data: &[u8]); + +} +``` + +Procedures do not directly access sessions. If a procedure needs information about +sessions, that information should be mirrored into leaf state by the session code. + +## PTY Binary Protocol + +One PTY session uses one hook and one outer procedure id. The inner PTY protocol is +a tiny binary frame in `Packet::data`. + +If API names use `end_state` at the session layer, it maps directly to +`Packet::end_hook` at the protocol layer. + +```text +Packet { + hook_id: session hook, + procedure_id: PROC_PTY, + data: [opcode, payload...], + end_hook: session lifetime marker, +} +``` + +Suggested opcodes: + +| Opcode | Direction | Meaning | end_hook | +|---:|---|---|---| +| 0 | Downward | Open PTY | false | +| 1 | Upward | Opened | false | +| 2 | Downward | Input bytes | false | +| 3 | Downward | Resize | false | +| 4 | Downward | Stdin EOF | false | +| 5 | Downward | Terminate process | false | +| 6 | Downward | Abort session without acknowledgement | true | +| 7 | Upward | Output bytes | false | +| 8 | Upward | Exit status | true | +| 9 | Upward | Fatal error | true | + +`StdinEof` must not set `end_hook = true`. The remote process may still emit output +after stdin closes. + +## Generated Update Loop + +The generated `Leaf::update` should follow this shape: + +```text +update(endpoint) + 1. flush retry queue first + 2. drain matching inbound packets only + 3. dispatch procedure packets + 4. group session packets by hook_id + 5. create sessions for open packets + 6. update active sessions + 7. flush outbound frames + 8. remove sessions whose final packet routed successfully +``` + +More concrete flow: + +```text ++----------------------------+ +| generated update | ++----------------------------+ + | + v +flush retry packets + | + v +take_inbound_matching(local_id, owns_packet) + | + +--> procedure id match -> Procedure::handle + | + +--> session id match -> session inbox by hook_id + | + v +for each session inbox + | + +--> existing hook -> Session::update + | + +--> no hook -> Session::init, then Session::update + | + v +flush session/procedure outbox + | + +--> route success -> advance/remove when safe + | + +--> route failure -> keep retry packet and keep state +``` + +The retry rule is the most important generated invariant: + +```text +If endpoint.add_outbound(packet) fails, the generated leaf must not: + - drop the packet + - advance a final frame + - remove the session + - consume state that cannot be reconstructed +``` + +## Generated Dispatch + +The macro should emit static matches, not runtime registries. + +Good: + +```rust +match packet.procedure_id { + PtySession::PROCEDURE_ID => self.dispatch_pty_session(packet), + PingProcedure::PROCEDURE_ID => PingProcedure::handle(...), + CapabilitiesProcedure::PROCEDURE_ID => CapabilitiesProcedure::handle(...), + _ => self.handle_unknown(packet), +} +``` + +Avoid: + +```rust +Vec> +Vec> +``` + +Static dispatch keeps the generated code visible to LLVM and avoids registry setup +costs in constrained binaries. + +## Session Store + +The first implementation can use a small `Vec` or `VecDeque` store. + +```rust +pub struct SessionEntry { + hook_id: HookID, + state: S, + inbox: PacketQueue, + outbox: PacketQueue, + retry: Option, + closing: bool, +} +``` + +For minimal binaries, a later implementation can replace this with a fixed-capacity +table under a feature flag. + +Required operations: + +- find by `hook_id` +- insert if capacity allows +- push incoming packet to inbox +- enqueue outbound packet +- retain session while retry packet exists +- remove only after session is closed and outbox/retry are empty + +## Unknown Packets + +The generated leaf should have configurable unknown-packet behavior. + +Default behavior: + +- unknown procedure id remains in the endpoint queue because `take_inbound_matching` does not drain it +- unknown session opcode for a known session produces a fatal error frame and closes the session +- packet for unknown hook under a session procedure produces a fatal error frame if the payload is not a valid `Open` + +For PTY: + +```text +unknown hook + Open opcode -> create session +unknown hook + non-Open opcode -> Error end_hook=true +known hook + known opcode -> session update +known hook + unknown opcode -> Error end_hook=true +``` + +## Access Boundaries + +Sessions and procedures can mutate leaf state: + +```rust +fn update(leaf: &mut RemotePtyState, ...) +``` + +They should not directly access each other: + +```text +Procedure -> no direct SessionStore access +Session -> no direct Procedure access +``` + +If cross-cutting data is needed, mirror it into `RemotePtyState`: + +```rust +pub struct RemotePtyState { + active_count: usize, + total_spawned: u64, + max_sessions: usize, +} +``` + +This keeps borrowing simple and avoids turning the generated leaf into a shared +mutable object graph. + +## Endpoint Access + +The user proposed passing `&mut Endpoint` to sessions and procedures. The safest +design is slightly narrower: + +- procedures may receive `&mut Endpoint` because they handle one packet and return immediately +- sessions should receive `SessionCtx`, which can expose narrow endpoint helpers +- a raw endpoint escape hatch can be added later if a real leaf proves it needs one + +Rationale: sessions are long-lived and retry-sensitive. If session code calls +`endpoint.add_outbound` directly, it can bypass generated retry handling and lose a +final packet. The helper path keeps the dangerous part centralized. + +## Remote PTY Example + +User code: + +```rust +#[unshell_leaf( + leaf = RemotePtyLeaf, + id = LEAF_REMOTE_PTY, + sessions(PtySession), + procedures(PtyCapabilities) +)] +pub struct RemotePtyState { + max_sessions: usize, + default_rows: u16, + default_cols: u16, +} + +pub struct PtySession; + +pub struct PtyState { + hook_id: HookID, + reply_path: Vec, + worker: PtyWorker, + saw_stdin_eof: bool, +} + +impl Session for PtySession { + const PROCEDURE_ID: u32 = PROC_PTY; + + type State = PtyState; + + fn init( + leaf: &mut RemotePtyState, + packet: Packet, + ctx: &mut SessionInit, + ) -> SessionInitResult { + let open = decode_open(&packet.data)?; + + if leaf.active_count >= leaf.max_sessions { + return SessionInitResult::RejectedWith(pty_error_busy(packet.hook_id)); + } + + let worker = PtyWorker::spawn(open.command, open.rows, open.cols)?; + + SessionInitResult::Created(PtyState { + hook_id: ctx.hook_id(), + reply_path: open.reply_path, + worker, + saw_stdin_eof: false, + }) + } + + fn update( + leaf: &mut RemotePtyState, + session: &mut Self::State, + incoming: &mut PacketQueue, + ctx: &mut SessionCtx, + ) -> SessionStatus { + while let Some(packet) = incoming.pop_front() { + match decode_pty_frame(&packet.data) { + PtyFrame::Input(bytes) => session.worker.write(&bytes), + PtyFrame::Resize { rows, cols } => session.worker.resize(rows, cols), + PtyFrame::StdinEof => { + session.saw_stdin_eof = true; + session.worker.close_stdin(); + } + PtyFrame::Terminate => session.worker.terminate(), + PtyFrame::Abort => return SessionStatus::Closed, + _ => ctx.error_final(ERR_BAD_OPCODE, b"bad pty opcode"), + } + } + + while let Some(bytes) = session.worker.poll_output() { + ctx.send(OP_OUTPUT, &bytes); + } + + if let Some(status) = session.worker.poll_exit() { + ctx.send_final(OP_EXIT, &encode_exit(status)); + leaf.active_count -= 1; + return SessionStatus::Closed; + } + + SessionStatus::Running + } +} +``` + +The exact error handling syntax above is illustrative. The final API should avoid +`?` unless `SessionInitResult` has a clear conversion story. + +## Generated Remote PTY Flow + +```text +Caller Remote endpoint RemotePtyLeaf +------ --------------- ------------- +allocate hook +Open end=false -------------> downward route opens hook ----> init PtyState +Input false -------------> hook remains active -----------> write PTY stdin +Resize false -------------> hook remains active -----------> resize PTY +StdinEof false -------------> hook remains active -----------> close PTY stdin + +Output false <------------- upward route requires hook <---- ctx.send +Output false <------------- upward route requires hook <---- ctx.send +Exit true <------------- upward route closes hook <---- ctx.send_final + +caller cleanup remove session +``` + +## Proc Macro Implementation + +Rust still requires a proc-macro crate for real attribute macros. Keep that crate +thin and put the real logic in a normal testable crate. + +```text +unshell-macros + #[proc_macro_attribute] + pub fn unshell_leaf(attr, item) -> TokenStream + +unshell-macros-core + parse UnshellLeafArgs + parse state struct + generate wrapper + generate Leaf impl + generate dispatch methods + tests over proc_macro2::TokenStream +``` + +Recommended parser helper: + +- `deluxe` for attribute parsing if the desired syntax uses richer Rust tokens +- `darling` if the syntax stays close to normal Rust meta attributes + +Current recommendation: use `deluxe` for attribute parsing and `syn`/`quote` for +code generation. + +## Generated Code Requirements + +- Must compile without requiring std in `unshell-protocol`. +- Must not allocate handler trait objects. +- Must preserve generic parameters and where clauses on the state struct. +- Must emit useful compile errors for duplicate procedure ids. +- Must emit useful compile errors for duplicate session procedure ids. +- Must reject session and procedure id collisions unless explicitly allowed. +- Must preserve user attributes that are not consumed by the macro. +- Must not require users to manually implement `Leaf` for the generated wrapper. + +## Testing Strategy + +Phase 1 tests should use a fake PTY session, not `portable-pty`. + +Protocol tests: + +- `open_pty_paves_hook_and_creates_session` +- `input_and_output_share_one_hook` +- `stdin_eof_keeps_hook_until_exit` +- `exit_end_hook_cleans_route_and_session` +- `failed_final_exit_route_retries_without_losing_session` +- `abort_downward_end_hook_closes_without_ack` +- `unknown_session_input_returns_error_end_hook` +- `two_pty_sessions_interleave_without_crossing_hooks` +- `pty_leaf_does_not_consume_other_leaf_packets` + +Macro tests: + +- generated leaf implements `Leaf` +- duplicate procedure ids fail at compile time +- duplicate session procedure ids fail at compile time +- generic state structs expand correctly +- generated code routes final frames retry-safely +- generated code preserves unmatched inbound packets + +Use `trybuild` for compile-fail macro tests once `unshell-macros` exists. + +## Rollout Plan + +1. Add `Endpoint::take_inbound_matching`. +2. Write a manual fake PTY leaf that follows the exact generated shape. +3. Add PTY session tests against the manual fake leaf. +4. Create `unshell-macros` and `unshell-macros-core`. +5. Generate the same shape as the manual fake leaf. +6. Port fake PTY tests to the generated leaf. +7. Add compile-fail macro tests. +8. Implement the real std-only PTY worker in `unshell-leaves` or `unshell-runtime`. +9. Add integration tests for the real worker where the platform supports PTYs. + +This avoids writing a macro against an unproven design. First make the generated +shape real by hand, then teach the macro to emit it. + +## Open Questions + +- Should `SessionCtx` expose any raw `Endpoint` access, or only narrow helpers? +- Should session stores be `Vec` first, or should fixed-capacity storage be designed immediately? +- Should unknown opcodes produce an error packet by default, or should each session type decide? +- Should `Open` always carry `reply_path`, or should the packet format eventually add source path? +- Should `ProcedureOut` be retry-safe like session output, or are procedures allowed to fail fast? +- Should macro-generated leaves expose counters for active sessions and retry queue depth? + +## Recommendation + +Implement this as static generated code with a narrow context API. + +Do not build a runtime plugin system. Do not give sessions raw access to session +stores. Do not let session code bypass generated output flushing. The macro should +make the correct routing and hook behavior the easiest path, especially for final +frames. + +The first proof point should be fake PTY. If fake PTY works cleanly, real PTY is +mostly OS plumbing. diff --git a/examples/hashtest.rs b/examples/hashtest.rs index 0dd59c7..8520c21 100644 --- a/examples/hashtest.rs +++ b/examples/hashtest.rs @@ -6,7 +6,7 @@ macro_rules! hashtest { }; } -const MAP: [(&'static str, u32); 6] = [ +const MAP: [(&str, u32); 6] = [ hashtest!("abc123"), hashtest!("abc124"), hashtest!("abc125"), diff --git a/src/hash.rs b/src/hash.rs index 70e8d28..a7b6d85 100644 --- a/src/hash.rs +++ b/src/hash.rs @@ -1,6 +1,6 @@ //! Temporary hash function -const fn hash_recursive<'a>(state: &mut [u8; 4], input: &'a [u8]) { +const fn hash_recursive(state: &mut [u8; 4], input: &[u8]) { match input.len() { 3 => { state[0] ^= input[0]; diff --git a/src/interface/mod.rs b/src/interface/mod.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/interface/mod.rs @@ -0,0 +1 @@ + diff --git a/src/lib.rs b/src/lib.rs index 3dbfd0d..0d4f202 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,7 @@ //! ## Architecture //! //! - [`protocol`] - Wire types, framing, stateless validation, and routing/runtime. +//! - [`interface`] - Typed control surfaces used by UI adapters and control leaves. //! //! The library requires `alloc` for path and payload management. @@ -16,12 +17,8 @@ pub extern crate alloc; mod hash; +pub mod interface; pub mod logger; - -pub mod protocol { - pub use unshell_protocol::*; - - pub use unshell_macros::unshell_leaf; -} +pub mod protocol; pub use hash::hash; diff --git a/unshell-protocol/src/endpoint/hooks.rs b/src/protocol/endpoint/hooks.rs similarity index 98% rename from unshell-protocol/src/endpoint/hooks.rs rename to src/protocol/endpoint/hooks.rs index 9447b40..6e14043 100644 --- a/unshell-protocol/src/endpoint/hooks.rs +++ b/src/protocol/endpoint/hooks.rs @@ -1,4 +1,4 @@ -use crate::{Endpoint, EndpointError, EndpointName}; +use crate::protocol::{Endpoint, EndpointError, EndpointName}; /// Compact identifier for one routed return channel. /// diff --git a/unshell-protocol/src/endpoint/mod.rs b/src/protocol/endpoint/mod.rs similarity index 97% rename from unshell-protocol/src/endpoint/mod.rs rename to src/protocol/endpoint/mod.rs index c57772b..eaac715 100644 --- a/unshell-protocol/src/endpoint/mod.rs +++ b/src/protocol/endpoint/mod.rs @@ -5,7 +5,7 @@ pub use hooks::HookID; use alloc::{boxed::Box, vec::Vec}; -use crate::{ConnectionSet, HookMap, Leaf, Packet, Path, RouteMap}; +use crate::protocol::{ConnectionSet, HookMap, Leaf, Packet, Path, RouteMap}; pub struct Endpoint { // This endpoint's identifier diff --git a/unshell-protocol/src/endpoint/routing.rs b/src/protocol/endpoint/routing.rs similarity index 99% rename from unshell-protocol/src/endpoint/routing.rs rename to src/protocol/endpoint/routing.rs index 38a2afa..01af5c9 100644 --- a/unshell-protocol/src/endpoint/routing.rs +++ b/src/protocol/endpoint/routing.rs @@ -1,4 +1,4 @@ -use crate::{Endpoint, EndpointError, Packet, RouteDirection}; +use crate::protocol::{Endpoint, EndpointError, Packet, RouteDirection}; impl Endpoint { /// Register an inbound packet from legacy trusted code. diff --git a/unshell-protocol/src/error.rs b/src/protocol/error.rs similarity index 100% rename from unshell-protocol/src/error.rs rename to src/protocol/error.rs diff --git a/unshell-protocol/src/leaf.rs b/src/protocol/leaf.rs similarity index 99% rename from unshell-protocol/src/leaf.rs rename to src/protocol/leaf.rs index aa4491b..dfd2646 100644 --- a/unshell-protocol/src/leaf.rs +++ b/src/protocol/leaf.rs @@ -1,4 +1,4 @@ -use crate::{Endpoint, HookID, Packet, PacketQueue}; +use crate::protocol::{Endpoint, HookID, Packet, PacketQueue}; use alloc::vec::Vec; diff --git a/unshell-protocol/src/lib.rs b/src/protocol/mod.rs similarity index 94% rename from unshell-protocol/src/lib.rs rename to src/protocol/mod.rs index 1ec332e..dbf6c80 100644 --- a/unshell-protocol/src/lib.rs +++ b/src/protocol/mod.rs @@ -1,7 +1,3 @@ -#![no_std] - -pub extern crate alloc; - mod endpoint; mod error; mod leaf; @@ -11,6 +7,7 @@ pub use endpoint::{Endpoint, HookID}; pub use error::*; pub use leaf::*; pub use packet::Packet; +pub use unshell_macros::unshell_leaf; // Various named types used for brevity use alloc::{ diff --git a/unshell-protocol/src/packet.rs b/src/protocol/packet.rs similarity index 98% rename from unshell-protocol/src/packet.rs rename to src/protocol/packet.rs index f908f3f..3d07ecc 100644 --- a/unshell-protocol/src/packet.rs +++ b/src/protocol/packet.rs @@ -2,7 +2,7 @@ extern crate alloc; use alloc::vec::Vec; -use crate::{DeserializeError, SerializeError}; +use crate::protocol::{DeserializeError, SerializeError}; /// Fully decoded UnShell test packet. /// diff --git a/unshell-protocol/src/tests/merkle_sync/codec.rs b/src/protocol/tests/merkle_sync/codec.rs similarity index 100% rename from unshell-protocol/src/tests/merkle_sync/codec.rs rename to src/protocol/tests/merkle_sync/codec.rs diff --git a/unshell-protocol/src/tests/merkle_sync/constants.rs b/src/protocol/tests/merkle_sync/constants.rs similarity index 100% rename from unshell-protocol/src/tests/merkle_sync/constants.rs rename to src/protocol/tests/merkle_sync/constants.rs diff --git a/unshell-protocol/src/tests/merkle_sync/harness.rs b/src/protocol/tests/merkle_sync/harness.rs similarity index 99% rename from unshell-protocol/src/tests/merkle_sync/harness.rs rename to src/protocol/tests/merkle_sync/harness.rs index 34d544c..120ff9a 100644 --- a/unshell-protocol/src/tests/merkle_sync/harness.rs +++ b/src/protocol/tests/merkle_sync/harness.rs @@ -1,7 +1,7 @@ use alloc::{boxed::Box, rc::Rc, vec}; use core::cell::RefCell; -use crate::Endpoint; +use crate::protocol::Endpoint; use super::{ constants::{ENDPOINT_CALLER, ENDPOINT_RESPONDENT}, diff --git a/unshell-protocol/src/tests/merkle_sync/leaves.rs b/src/protocol/tests/merkle_sync/leaves.rs similarity index 99% rename from unshell-protocol/src/tests/merkle_sync/leaves.rs rename to src/protocol/tests/merkle_sync/leaves.rs index b1b75b9..0c9ddcd 100644 --- a/unshell-protocol/src/tests/merkle_sync/leaves.rs +++ b/src/protocol/tests/merkle_sync/leaves.rs @@ -3,7 +3,7 @@ use core::cell::RefCell; use crossbeam_channel::{Receiver, Sender}; -use crate::{Endpoint, Leaf, Packet}; +use crate::protocol::{Endpoint, Leaf, Packet}; use super::{ codec::{decode_block_chunk, decode_child_summary, decode_u32}, diff --git a/unshell-protocol/src/tests/merkle_sync/mod.rs b/src/protocol/tests/merkle_sync/mod.rs similarity index 100% rename from unshell-protocol/src/tests/merkle_sync/mod.rs rename to src/protocol/tests/merkle_sync/mod.rs diff --git a/unshell-protocol/src/tests/merkle_sync/rpc.rs b/src/protocol/tests/merkle_sync/rpc.rs similarity index 98% rename from unshell-protocol/src/tests/merkle_sync/rpc.rs rename to src/protocol/tests/merkle_sync/rpc.rs index 83ba211..56cefad 100644 --- a/unshell-protocol/src/tests/merkle_sync/rpc.rs +++ b/src/protocol/tests/merkle_sync/rpc.rs @@ -1,6 +1,6 @@ use alloc::{vec, vec::Vec}; -use crate::Packet; +use crate::protocol::Packet; use super::{ codec::{encode_block_chunk, encode_child_summary, encode_u32}, diff --git a/unshell-protocol/src/tests/merkle_sync/state.rs b/src/protocol/tests/merkle_sync/state.rs similarity index 96% rename from unshell-protocol/src/tests/merkle_sync/state.rs rename to src/protocol/tests/merkle_sync/state.rs index 92a8cea..dfcf725 100644 --- a/unshell-protocol/src/tests/merkle_sync/state.rs +++ b/src/protocol/tests/merkle_sync/state.rs @@ -1,5 +1,7 @@ use alloc::vec::Vec; +use crate::protocol::Packet; + use super::{ rpc::OutgoingFrame, tree::{BlockChunk, ChildSummary}, @@ -76,7 +78,7 @@ impl ResponseStream { } /// Builds the next packet without advancing the stream. - pub(super) fn next_packet(&self) -> Option { + pub(super) fn next_packet(&self) -> Option { let frame = self.frames.get(self.next_index)?; Some(frame.to_packet(self.hook_id, self.next_index + 1 == self.frames.len())) } diff --git a/unshell-protocol/src/tests/merkle_sync/tests.rs b/src/protocol/tests/merkle_sync/tests.rs similarity index 100% rename from unshell-protocol/src/tests/merkle_sync/tests.rs rename to src/protocol/tests/merkle_sync/tests.rs diff --git a/unshell-protocol/src/tests/merkle_sync/tree.rs b/src/protocol/tests/merkle_sync/tree.rs similarity index 100% rename from unshell-protocol/src/tests/merkle_sync/tree.rs rename to src/protocol/tests/merkle_sync/tree.rs diff --git a/unshell-protocol/src/tests/oneshot/mod.rs b/src/protocol/tests/oneshot/mod.rs similarity index 98% rename from unshell-protocol/src/tests/oneshot/mod.rs rename to src/protocol/tests/oneshot/mod.rs index d2fae29..310c562 100644 --- a/unshell-protocol/src/tests/oneshot/mod.rs +++ b/src/protocol/tests/oneshot/mod.rs @@ -1,7 +1,7 @@ mod streams; mod support; -use crate::{Endpoint, EndpointError, RouteDirection}; +use crate::protocol::{Endpoint, EndpointError, RouteDirection}; use alloc::{boxed::Box, vec}; @@ -16,7 +16,7 @@ fn test_oneshot() { let (tx_a, rx_a) = crossbeam_channel::unbounded(); let (tx_b, rx_b) = crossbeam_channel::unbounded(); - let mut endpoint_a = crate::endpoint::Endpoint::new( + let mut endpoint_a = Endpoint::new( ENDPOINT_A, vec![ Box::new(ControllerLeaf { has_run: false }), @@ -31,7 +31,7 @@ fn test_oneshot() { ); endpoint_a.path = vec![ENDPOINT_A]; - let mut endpoint_b = crate::endpoint::Endpoint::new( + let mut endpoint_b = Endpoint::new( ENDPOINT_B, vec![ Box::new(ResponderLeaf), diff --git a/unshell-protocol/src/tests/oneshot/streams.rs b/src/protocol/tests/oneshot/streams.rs similarity index 99% rename from unshell-protocol/src/tests/oneshot/streams.rs rename to src/protocol/tests/oneshot/streams.rs index f38e2f8..b5d2180 100644 --- a/unshell-protocol/src/tests/oneshot/streams.rs +++ b/src/protocol/tests/oneshot/streams.rs @@ -1,4 +1,4 @@ -use crate::{Endpoint, Leaf, Packet}; +use crate::protocol::{Endpoint, Leaf, Packet}; use alloc::{boxed::Box, format, vec, vec::Vec}; diff --git a/unshell-protocol/src/tests/oneshot/support.rs b/src/protocol/tests/oneshot/support.rs similarity index 99% rename from unshell-protocol/src/tests/oneshot/support.rs rename to src/protocol/tests/oneshot/support.rs index 06796ae..89fb069 100644 --- a/unshell-protocol/src/tests/oneshot/support.rs +++ b/src/protocol/tests/oneshot/support.rs @@ -1,4 +1,4 @@ -use crate::{Endpoint, Leaf, Packet}; +use crate::protocol::{Endpoint, Leaf, Packet}; use alloc::{vec, vec::Vec}; use crossbeam_channel::{Receiver, Sender}; diff --git a/unshell-protocol/src/tests/packet.rs b/src/protocol/tests/packet.rs similarity index 98% rename from unshell-protocol/src/tests/packet.rs rename to src/protocol/tests/packet.rs index 83280db..fb07dd1 100644 --- a/unshell-protocol/src/tests/packet.rs +++ b/src/protocol/tests/packet.rs @@ -1,6 +1,6 @@ use alloc::{vec, vec::Vec}; -use crate::{DeserializeError, EndpointError, Packet, SerializeError}; +use crate::protocol::{DeserializeError, EndpointError, Packet, SerializeError}; // ── Helpers ─────────────────────────────────────────────────────────────── diff --git a/unshell-macros-core/src/leaf/generator.rs b/unshell-macros-core/src/leaf/generator.rs index d237e55..8a14f05 100644 --- a/unshell-macros-core/src/leaf/generator.rs +++ b/unshell-macros-core/src/leaf/generator.rs @@ -114,7 +114,7 @@ impl LeafGenerator { fn __unshell_parent_reply_path( endpoint: &::unshell::protocol::Endpoint, - ) -> ::unshell::protocol::alloc::vec::Vec { + ) -> ::unshell::alloc::vec::Vec { if endpoint.path.len() > 1 { endpoint.path[..endpoint.path.len() - 1].to_vec() } else { @@ -135,7 +135,7 @@ impl LeafGenerator { return; }; - let mut __unshell_packets = ::unshell::protocol::alloc::vec::Vec::new(); + let mut __unshell_packets = ::unshell::alloc::vec::Vec::new(); endpoint.take_inbound_matching( __unshell_local_id, Self::__unshell_packet_is_owned, @@ -177,7 +177,7 @@ impl LeafGenerator { let field = &store.field; let session_ty = &store.ty; quote! { - #field: ::unshell::protocol::alloc::vec::Vec< + #field: ::unshell::alloc::vec::Vec< ::unshell::protocol::SessionEntry< <#session_ty as ::unshell::protocol::Session<#state_type>>::State > @@ -193,7 +193,7 @@ impl LeafGenerator { .iter() .map(|store| { let field = &store.field; - quote!(#field: ::unshell::protocol::alloc::vec::Vec::new()) + quote!(#field: ::unshell::alloc::vec::Vec::new()) }) .collect() } diff --git a/unshell-protocol/Cargo.toml b/unshell-protocol/Cargo.toml deleted file mode 100644 index 5f56508..0000000 --- a/unshell-protocol/Cargo.toml +++ /dev/null @@ -1,28 +0,0 @@ -[package] -name = "unshell-protocol" -version.workspace = true -edition.workspace = true -description = "Wire protocol, framing, validation, and endpoint runtime for UnShell" - -[lib] -doctest = false - -[dependencies] -rkyv = { workspace = true } -# unshell-macros = { path = "../unshell-macros" } - -[dev-dependencies] -crossbeam-channel.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" From f595b5aa98a3d30c59706fd31c22c7d88102ac0c Mon Sep 17 00:00:00 2001 From: Michael Mikovsky <77305074+Astatin3@users.noreply.github.com> Date: Sun, 31 May 2026 10:26:57 -0600 Subject: [PATCH 25/31] Add "LeafMeta" struct to leaf --- Cargo.lock | 1120 +++++++++++++++++++++- Cargo.toml | 21 +- src/protocol/endpoint/mod.rs | 7 + src/protocol/leaf.rs | 351 +------ src/protocol/leaf_meta.rs | 8 + src/protocol/mod.rs | 8 +- src/protocol/procedure.rs | 66 ++ src/protocol/session.rs | 280 ++++++ src/protocol/tests/merkle_sync/leaves.rs | 33 + src/protocol/tests/oneshot/streams.rs | 23 + src/protocol/tests/oneshot/support.rs | 33 + 11 files changed, 1576 insertions(+), 374 deletions(-) create mode 100644 src/protocol/leaf_meta.rs create mode 100644 src/protocol/procedure.rs create mode 100644 src/protocol/session.rs diff --git a/Cargo.lock b/Cargo.lock index 8400a75..a6f4172 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10,7 +10,7 @@ checksum = "66bd29a732b644c0431c6140f370d097879203d79b80c94a6747ba0872adaef8" dependencies = [ "cipher", "cpubits", - "cpufeatures", + "cpufeatures 0.3.0", ] [[package]] @@ -22,6 +22,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -37,6 +43,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -50,9 +65,30 @@ dependencies = [ "aes", "cbc", "regex", - "sha2", + "sha2 0.11.0", ] +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + [[package]] name = "bitflags" version = "1.3.2" @@ -65,6 +101,15 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "block-buffer" version = "0.12.0" @@ -112,12 +157,27 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + [[package]] name = "bytes" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cbc" version = "0.2.0" @@ -162,8 +222,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" dependencies = [ "cfg-if", - "cpufeatures", - "rand_core", + "cpufeatures 0.3.0", + "rand_core 0.10.1", ] [[package]] @@ -185,16 +245,39 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e34d8227fe1ba289043aeb13792056ff80fd6de1a9f49137a5f499de8e8c78ea" dependencies = [ - "crypto-common", + "crypto-common 0.2.1", "inout", ] +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "const-oid" version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -207,6 +290,15 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ef0c543070d296ea414df2dd7625d1b24866ce206709d8a4a424f28377f5861" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.3.0" @@ -231,6 +323,43 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.11.1", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "crypto-common" version = "0.2.1" @@ -240,35 +369,227 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + [[package]] name = "digest" version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" dependencies = [ - "block-buffer", + "block-buffer 0.12.0", "const-oid", - "crypto-common", + "crypto-common 0.2.1", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + [[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "find-msvc-tools" version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" +[[package]] +name = "finl_unicode" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.2" @@ -277,8 +598,8 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", - "rand_core", + "r-efi 6.0.0", + "rand_core 0.10.1", "wasip2", "wasip3", ] @@ -289,7 +610,7 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -297,6 +618,11 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] name = "hashbrown" @@ -361,6 +687,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "indexmap" version = "2.13.0" @@ -373,6 +705,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "inout" version = "0.2.2" @@ -383,6 +724,28 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -399,6 +762,29 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.18", +] + +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "leaf-pty" version = "0.1.0" @@ -418,6 +804,27 @@ version = "0.2.185" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" +[[package]] +name = "line-clipping" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -433,12 +840,64 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix", + "winapi", +] + [[package]] name = "memchr" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys", +] + [[package]] name = "munge" version = "0.4.7" @@ -459,6 +918,46 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "cfg_aliases 0.2.1", + "libc", + "memoffset", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -468,12 +967,30 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "once_cell" version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -497,6 +1014,113 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2 0.10.9", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.6", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "prettyplease" version = "0.2.37" @@ -545,6 +1169,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" @@ -560,6 +1190,15 @@ dependencies = [ "ptr_meta", ] +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.10.1" @@ -567,16 +1206,107 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ "chacha20", - "getrandom", - "rand_core", + "getrandom 0.4.2", + "rand_core 0.10.1", ] +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + [[package]] name = "rand_core" version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "ratatui" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termwiz", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" +dependencies = [ + "bitflags 2.11.1", + "compact_str", + "hashbrown 0.16.1", + "indoc", + "itertools", + "kasuari", + "lru", + "strum", + "thiserror 2.0.18", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" +dependencies = [ + "bitflags 2.11.1", + "hashbrown 0.16.1", + "indoc", + "instability", + "itertools", + "line-clipping", + "ratatui-core", + "strum", + "time", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -654,12 +1384,40 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "rustversion" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "scopeguard" version = "1.2.0" @@ -679,6 +1437,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", + "serde_derive", ] [[package]] @@ -714,6 +1473,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sha2" version = "0.11.0" @@ -721,8 +1491,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.3.0", + "digest 0.11.2", ] [[package]] @@ -731,18 +1501,61 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simdutf8" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + [[package]] name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "static_init" version = "1.0.4" @@ -771,6 +1584,33 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "syn" version = "1.0.109" @@ -793,13 +1633,96 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom", + "phf", + "phf_codegen", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64", + "bitflags 2.11.1", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf", + "sha2 0.10.9", + "signal-hook", + "siphasher", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -813,6 +1736,27 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + [[package]] name = "tinyvec" version = "1.11.0" @@ -834,12 +1778,41 @@ version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "unicode-ident" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -852,9 +1825,10 @@ version = "0.1.0" dependencies = [ "chrono", "crossbeam-channel", + "ratatui", "rkyv", "static_init", - "thiserror", + "thiserror 2.0.18", "unshell-macros", ] @@ -880,26 +1854,55 @@ version = "0.1.0" dependencies = [ "base62", "block-padding", - "getrandom", + "getrandom 0.4.2", "hex", "hex-literal", "proc-macro2", "quote", - "rand", + "rand 0.10.1", "static_init", "syn 2.0.117", ] +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ + "atomic", + "getrandom 0.4.2", "js-sys", "wasm-bindgen", ] +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" version = "1.0.3+wasi-0.2.9" @@ -997,6 +2000,78 @@ dependencies = [ "semver", ] +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom 0.3.4", + "mac_address", + "sha2 0.10.9", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + [[package]] name = "winapi" version = "0.3.9" @@ -1078,6 +2153,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index b18814b..eb1a275 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,8 +7,6 @@ members = [ "unshell-macros-core", "unshell-macros", - # "unshell-protocol", - "unshell-leaves/leaf-pty", ] resolver = "2" @@ -32,6 +30,8 @@ proc-macro2 = "1.0.106" portable-pty = "0.9.0" crossbeam-channel = "0.5.15" +ratatui = "0.30.0" + unshell = { path = "." } # unshell-protocol = { path = "./unshell-protocol" } unshell-macros-core = { path = "./unshell-macros-core" } @@ -51,28 +51,23 @@ edition.workspace = true description = "Pure no_std implementation of the UnShell Protocol" [features] -default = [] +# default = ["interface_ratatui"] + log = [] log_debug = ["log", "dep:chrono"] -# Leaf features -# leaf_endpoint = ["unshell-leaves/leaf_endpoint"] -# leaf_tui = ["unshell-leaves/leaf_tui"] - -# obfuscate_aes = ["ush-obfuscate/obfuscate_aes"] -# obfuscate_ref = ["ush-obfuscate/obfuscate_ref"] +interface = [] +interface_ratatui = ["interface", "dep:ratatui"] [dependencies] rkyv = { workspace = true } thiserror = { workspace = true, optional = true } chrono = { workspace = true, optional = true } -# ush-obfuscate = { workspace = true } static_init = { workspace = true } +ratatui = { workspace = true, optional = true } + unshell-macros = { workspace = true } -# unshell-protocol = { workspace = true } -# unshell-runtime = { workspace = true } -# unshell-leaves = { workspace = true } [dev-dependencies] crossbeam-channel.workspace = true diff --git a/src/protocol/endpoint/mod.rs b/src/protocol/endpoint/mod.rs index eaac715..495c985 100644 --- a/src/protocol/endpoint/mod.rs +++ b/src/protocol/endpoint/mod.rs @@ -120,4 +120,11 @@ impl Endpoint { queue.clear(); } } + + pub fn iter_leaves(&mut self) -> core::slice::IterMut<'_, Box> + where + F: FnMut(&Packet), + { + self.leaves.iter_mut() + } } diff --git a/src/protocol/leaf.rs b/src/protocol/leaf.rs index dfd2646..872efd9 100644 --- a/src/protocol/leaf.rs +++ b/src/protocol/leaf.rs @@ -1,6 +1,7 @@ -use crate::protocol::{Endpoint, HookID, Packet, PacketQueue}; +use crate::protocol::Endpoint; -use alloc::vec::Vec; +#[cfg(feature = "interface")] +use crate::protocol::leaf_meta::LeafMeta; /// Application extension point hosted by an [`Endpoint`]. /// @@ -16,344 +17,10 @@ pub trait Leaf { /// Implementations normally drain matching inbound packets, mutate leaf-owned /// state, then enqueue outbound packets with [`Endpoint::add_outbound`]. fn update(&mut self, _: &mut Endpoint); -} - -/// Contract implemented by one hook-backed generated session family. -/// -/// A session family maps one outer `procedure_id` to many live hook instances. The -/// generated leaf owns packet grouping, retry-safe output flushing, and final cleanup; -/// the session implementation owns only application behavior. -/// -/// # Example -/// -/// ```rust,ignore -/// impl Session for MySession { -/// const PROCEDURE_ID: u32 = 7; -/// type State = MySessionState; -/// -/// fn reply_path(state: &Self::State) -> &[u32] { -/// &state.reply_path -/// } -/// -/// fn init( -/// leaf: &mut MyLeafState, -/// packet: Packet, -/// ctx: &mut SessionInit, -/// ) -> SessionInitResult { -/// SessionInitResult::Created(MySessionState::from_open(leaf, packet, ctx)) -/// } -/// -/// fn update( -/// leaf: &mut MyLeafState, -/// session: &mut Self::State, -/// incoming: &mut PacketQueue, -/// ctx: &mut SessionCtx<'_>, -/// ) -> SessionStatus { -/// while let Some(packet) = incoming.pop_front() { -/// session.apply(leaf, packet, ctx); -/// } -/// SessionStatus::Running -/// } -/// } -/// ``` -pub trait Session { - /// Outer packet procedure id used by every packet in this session family. - const PROCEDURE_ID: u32; - - /// Application state stored for one live hook. - type State; - - /// Returns the destination path for responses emitted by this session. - /// - /// `Packet` currently carries only a destination path, so protocols that need to - /// reply to a caller should capture a reply path during [`Self::init`]. The - /// generated leaf clones this path into [`SessionCtx`] before calling update so - /// session code can mutably borrow its state while emitting frames. - fn reply_path(session: &Self::State) -> &[u32]; - - /// Creates one session state from a packet whose hook has no active session. - /// - /// Returning [`SessionInitResult::RejectedWith`] lets the generated leaf route a - /// protocol-level failure response with the same retry guarantees as normal - /// output. Returning [`SessionInitResult::Rejected`] silently consumes the packet. - fn init(leaf: &mut L, packet: Packet, ctx: &mut SessionInit) -> SessionInitResult; - - /// Advances one active hook session. - /// - /// The generated leaf calls this for every live session on each update tick so - /// sessions can poll external workers even when no new packet arrived. Outbound - /// packets must be queued through `ctx`; direct endpoint routing would bypass the - /// generated retry rules. - fn update( - leaf: &mut L, - session: &mut Self::State, - incoming: &mut PacketQueue, - ctx: &mut SessionCtx<'_>, - ) -> SessionStatus; -} - -/// Contract implemented by one generated one-packet procedure handler. -/// -/// Procedures are for stateless or short-lived operations such as ping, capabilities, -/// or health checks. Long-running conversations should use [`Session`] so final -/// packet cleanup and retries remain tied to hook state. -pub trait Procedure { - /// Outer packet procedure id handled by this procedure. - const PROCEDURE_ID: u32; - - /// Handles one packet and optionally queues response packets in `out`. - fn handle(leaf: &mut L, endpoint: &mut Endpoint, packet: Packet, out: &mut ProcedureOut); -} - -/// Context passed to [`Session::init`]. -/// -/// This carries routing metadata that the generated leaf already knows before the -/// session state exists. Protocols that need source paths should encode them in the -/// packet payload; `packet_path` is the destination path that routed the packet here. -pub struct SessionInit { - hook_id: HookID, - packet_path: Vec, -} - -impl SessionInit { - /// Creates initialization metadata for a delivered packet. - pub fn new(hook_id: HookID, packet_path: Vec) -> Self { - Self { - hook_id, - packet_path, - } - } - - /// Returns the hook id that will identify the new session. - pub fn hook_id(&self) -> HookID { - self.hook_id - } - - /// Returns the destination path from the packet that reached this leaf. - pub fn packet_path(&self) -> &[u32] { - &self.packet_path - } -} - -/// Result of trying to create a session from a packet without an active hook entry. -pub enum SessionInitResult { - /// A new session was created and should be stored by the generated leaf. - Created(S), - - /// The packet was intentionally consumed without creating state or a response. - Rejected, - - /// The packet was rejected with a response that the generated leaf must route. - RejectedWith(Packet), -} - -/// Session lifecycle status returned from [`Session::update`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SessionStatus { - /// The session is active and should receive future update ticks. - Running, - - /// The session is winding down but still needs future update ticks. - Closing, - - /// The session has finished application work. - /// - /// The generated leaf still retains the entry until every queued packet routes - /// successfully, which prevents a failed final frame from losing session cleanup. - Closed, -} - -/// Mutable output context passed to [`Session::update`]. -/// -/// The context queues packets only; it never routes them immediately. Centralizing -/// routing in generated code is what makes final-frame retries reliable. -pub struct SessionCtx<'a> { - hook_id: HookID, - reply_path: Vec, - procedure_id: u32, - outbox: &'a mut PacketQueue, -} - -impl<'a> SessionCtx<'a> { - /// Creates a context for one session update call. - pub fn new( - hook_id: HookID, - reply_path: Vec, - procedure_id: u32, - outbox: &'a mut PacketQueue, - ) -> Self { - Self { - hook_id, - reply_path, - procedure_id, - outbox, - } - } - - /// Returns the hook id used for packets emitted through this context. - pub fn hook_id(&self) -> HookID { - self.hook_id - } - - /// Returns the destination path used for packets emitted through this context. - pub fn reply_path(&self) -> &[u32] { - &self.reply_path - } - - /// Queues a one-byte-opcode frame without closing the hook. - pub fn send(&mut self, opcode: u8, data: &[u8]) { - self.send_frame(opcode, data, false); - } - - /// Queues a one-byte-opcode frame that closes the hook after successful routing. - pub fn send_final(&mut self, opcode: u8, data: &[u8]) { - self.send_frame(opcode, data, true); - } - - /// Queues a protocol-specific error frame without closing the hook. - /// - /// The `code` is used as the frame opcode because the protocol layer does not - /// reserve a universal error opcode. Leaves that have a dedicated error opcode can - /// pass that value here or call [`Self::send`] directly. - pub fn error(&mut self, code: u8, data: &[u8]) { - self.send(code, data); - } - - /// Queues a protocol-specific error frame that closes the hook after routing. - pub fn error_final(&mut self, code: u8, data: &[u8]) { - self.send_final(code, data); - } - - /// Queues raw packet data without adding an opcode byte. - pub fn send_raw(&mut self, data: &[u8]) { - self.send_raw_with_end(data, false); - } - - /// Queues raw packet data and closes the hook after successful routing. - pub fn send_raw_final(&mut self, data: &[u8]) { - self.send_raw_with_end(data, true); - } - - fn send_frame(&mut self, opcode: u8, data: &[u8], end_hook: bool) { - let mut frame = Vec::with_capacity(data.len() + 1); - frame.push(opcode); - frame.extend_from_slice(data); - self.enqueue_data(frame, end_hook); - } - - fn send_raw_with_end(&mut self, data: &[u8], end_hook: bool) { - self.enqueue_data(data.to_vec(), end_hook); - } - - fn enqueue_data(&mut self, data: Vec, end_hook: bool) { - self.outbox.push_back(Packet { - hook_id: self.hook_id, - end_hook, - path: self.reply_path.clone(), - procedure_id: self.procedure_id, - data, - }); - } -} - -/// Output accumulator passed to [`Procedure::handle`]. -pub struct ProcedureOut { - hook_id: HookID, - reply_path: Vec, - procedure_id: u32, - outbox: PacketQueue, -} - -impl ProcedureOut { - /// Creates an empty procedure output queue. - pub fn new(hook_id: HookID, reply_path: Vec, procedure_id: u32) -> Self { - Self { - hook_id, - reply_path, - procedure_id, - outbox: PacketQueue::new(), - } - } - - /// Replaces the response path used by later [`Self::send`] calls. - pub fn set_reply_path(&mut self, reply_path: Vec) { - self.reply_path = reply_path; - } - - /// Queues raw response data without closing the hook. - pub fn send(&mut self, data: &[u8]) { - self.send_with_end(data, false); - } - - /// Queues raw response data that closes the hook after successful routing. - pub fn send_final(&mut self, data: &[u8]) { - self.send_with_end(data, true); - } - - /// Consumes the output accumulator and returns packets for generated retry logic. - pub fn into_packets(self) -> PacketQueue { - self.outbox - } - - fn send_with_end(&mut self, data: &[u8], end_hook: bool) { - self.outbox.push_back(Packet { - hook_id: self.hook_id, - end_hook, - path: self.reply_path.clone(), - procedure_id: self.procedure_id, - data: data.to_vec(), - }); - } -} - -/// Storage entry used by macro-generated session stores. -/// -/// The fields are public so generated code in downstream crates can keep the update -/// loop straightforward and static. Handwritten leaves may also use this type, but it -/// is intentionally small rather than a full session framework. -pub struct SessionEntry { - /// Hook id associated with this live session. - pub hook_id: HookID, - - /// Application-owned session state. - pub state: S, - - /// Packets delivered for this hook but not yet consumed by the session. - pub inbox: PacketQueue, - - /// Packets emitted by the session but not yet accepted by endpoint routing. - pub outbox: PacketQueue, - - /// Whether application logic has finished and only retry flushing may remain. - pub closed: bool, -} - -impl SessionEntry { - /// Creates one active session entry for `hook_id`. - pub fn new(hook_id: HookID, state: S) -> Self { - Self { - hook_id, - state, - inbox: PacketQueue::new(), - outbox: PacketQueue::new(), - closed: false, - } - } -} - -/// Flushes a retry queue through [`Endpoint::add_outbound`]. -/// -/// The packet at the front is cloned for each attempt and removed only after routing -/// succeeds. This preserves final frames when a route is temporarily unavailable. -/// The return value is true when the queue was fully drained. -pub fn flush_packet_queue(endpoint: &mut Endpoint, outbox: &mut PacketQueue) -> bool { - while let Some(packet) = outbox.front().cloned() { - if endpoint.add_outbound(packet).is_err() { - return false; - } - - outbox.pop_front(); - } - - true + + #[cfg(feature = "interface")] + fn get_meta(&self) -> LeafMeta; + + #[cfg(feature = "interface_ratatui")] + fn render_ratatui(&mut self, _: &mut ratatui::Frame<'_>, _: ratatui::layout::Rect) {} } diff --git a/src/protocol/leaf_meta.rs b/src/protocol/leaf_meta.rs new file mode 100644 index 0000000..c74c9ac --- /dev/null +++ b/src/protocol/leaf_meta.rs @@ -0,0 +1,8 @@ +use alloc::vec::Vec; + +pub struct LeafMeta { + pub name: &'static str, + pub identifier: &'static str, + pub version: &'static str, + pub authors: Vec<&'static str>, +} diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs index dbf6c80..fba9ffa 100644 --- a/src/protocol/mod.rs +++ b/src/protocol/mod.rs @@ -1,12 +1,18 @@ mod endpoint; mod error; mod leaf; +mod leaf_meta; mod packet; +mod procedure; +mod session; pub use endpoint::{Endpoint, HookID}; pub use error::*; -pub use leaf::*; +pub use leaf::Leaf; +pub use leaf_meta::LeafMeta; pub use packet::Packet; +pub use procedure::*; +pub use session::*; pub use unshell_macros::unshell_leaf; // Various named types used for brevity diff --git a/src/protocol/procedure.rs b/src/protocol/procedure.rs new file mode 100644 index 0000000..366a8d6 --- /dev/null +++ b/src/protocol/procedure.rs @@ -0,0 +1,66 @@ +use alloc::vec::Vec; + +use crate::protocol::{Endpoint, HookID, Packet, PacketQueue}; + +/// Contract implemented by one generated one-packet procedure handler. +/// +/// Procedures are for stateless or short-lived operations such as ping, capabilities, +/// or health checks. Long-running conversations should use [`Session`] so final +/// packet cleanup and retries remain tied to hook state. +pub trait Procedure { + /// Outer packet procedure id handled by this procedure. + const PROCEDURE_ID: u32; + + /// Handles one packet and optionally queues response packets in `out`. + fn handle(leaf: &mut L, endpoint: &mut Endpoint, packet: Packet, out: &mut ProcedureOut); +} + +/// Output accumulator passed to [`Procedure::handle`]. +pub struct ProcedureOut { + hook_id: HookID, + reply_path: Vec, + procedure_id: u32, + outbox: PacketQueue, +} + +impl ProcedureOut { + /// Creates an empty procedure output queue. + pub fn new(hook_id: HookID, reply_path: Vec, procedure_id: u32) -> Self { + Self { + hook_id, + reply_path, + procedure_id, + outbox: PacketQueue::new(), + } + } + + /// Replaces the response path used by later [`Self::send`] calls. + pub fn set_reply_path(&mut self, reply_path: Vec) { + self.reply_path = reply_path; + } + + /// Queues raw response data without closing the hook. + pub fn send(&mut self, data: &[u8]) { + self.send_with_end(data, false); + } + + /// Queues raw response data that closes the hook after successful routing. + pub fn send_final(&mut self, data: &[u8]) { + self.send_with_end(data, true); + } + + /// Consumes the output accumulator and returns packets for generated retry logic. + pub fn into_packets(self) -> PacketQueue { + self.outbox + } + + fn send_with_end(&mut self, data: &[u8], end_hook: bool) { + self.outbox.push_back(Packet { + hook_id: self.hook_id, + end_hook, + path: self.reply_path.clone(), + procedure_id: self.procedure_id, + data: data.to_vec(), + }); + } +} diff --git a/src/protocol/session.rs b/src/protocol/session.rs new file mode 100644 index 0000000..2ff94a2 --- /dev/null +++ b/src/protocol/session.rs @@ -0,0 +1,280 @@ +use alloc::vec::Vec; + +use crate::protocol::{Endpoint, HookID, Packet, PacketQueue}; + +/// Contract implemented by one hook-backed generated session family. +/// +/// A session family maps one outer `procedure_id` to many live hook instances. The +/// generated leaf owns packet grouping, retry-safe output flushing, and final cleanup; +/// the session implementation owns only application behavior. +/// +/// # Example +/// +/// ```rust,ignore +/// impl Session for MySession { +/// const PROCEDURE_ID: u32 = 7; +/// type State = MySessionState; +/// +/// fn reply_path(state: &Self::State) -> &[u32] { +/// &state.reply_path +/// } +/// +/// fn init( +/// leaf: &mut MyLeafState, +/// packet: Packet, +/// ctx: &mut SessionInit, +/// ) -> SessionInitResult { +/// SessionInitResult::Created(MySessionState::from_open(leaf, packet, ctx)) +/// } +/// +/// fn update( +/// leaf: &mut MyLeafState, +/// session: &mut Self::State, +/// incoming: &mut PacketQueue, +/// ctx: &mut SessionCtx<'_>, +/// ) -> SessionStatus { +/// while let Some(packet) = incoming.pop_front() { +/// session.apply(leaf, packet, ctx); +/// } +/// SessionStatus::Running +/// } +/// } +/// ``` +pub trait Session { + /// Outer packet procedure id used by every packet in this session family. + const PROCEDURE_ID: u32; + + /// Application state stored for one live hook. + type State; + + /// Returns the destination path for responses emitted by this session. + /// + /// `Packet` currently carries only a destination path, so protocols that need to + /// reply to a caller should capture a reply path during [`Self::init`]. The + /// generated leaf clones this path into [`SessionCtx`] before calling update so + /// session code can mutably borrow its state while emitting frames. + fn reply_path(session: &Self::State) -> &[u32]; + + /// Creates one session state from a packet whose hook has no active session. + /// + /// Returning [`SessionInitResult::RejectedWith`] lets the generated leaf route a + /// protocol-level failure response with the same retry guarantees as normal + /// output. Returning [`SessionInitResult::Rejected`] silently consumes the packet. + fn init(leaf: &mut L, packet: Packet, ctx: &mut SessionInit) -> SessionInitResult; + + /// Advances one active hook session. + /// + /// The generated leaf calls this for every live session on each update tick so + /// sessions can poll external workers even when no new packet arrived. Outbound + /// packets must be queued through `ctx`; direct endpoint routing would bypass the + /// generated retry rules. + fn update( + leaf: &mut L, + session: &mut Self::State, + incoming: &mut PacketQueue, + ctx: &mut SessionCtx<'_>, + ) -> SessionStatus; +} + +/// Context passed to [`Session::init`]. +/// +/// This carries routing metadata that the generated leaf already knows before the +/// session state exists. Protocols that need source paths should encode them in the +/// packet payload; `packet_path` is the destination path that routed the packet here. +pub struct SessionInit { + hook_id: HookID, + packet_path: Vec, +} + +impl SessionInit { + /// Creates initialization metadata for a delivered packet. + pub fn new(hook_id: HookID, packet_path: Vec) -> Self { + Self { + hook_id, + packet_path, + } + } + + /// Returns the hook id that will identify the new session. + pub fn hook_id(&self) -> HookID { + self.hook_id + } + + /// Returns the destination path from the packet that reached this leaf. + pub fn packet_path(&self) -> &[u32] { + &self.packet_path + } +} + +/// Result of trying to create a session from a packet without an active hook entry. +pub enum SessionInitResult { + /// A new session was created and should be stored by the generated leaf. + Created(S), + + /// The packet was intentionally consumed without creating state or a response. + Rejected, + + /// The packet was rejected with a response that the generated leaf must route. + RejectedWith(Packet), +} + +/// Session lifecycle status returned from [`Session::update`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SessionStatus { + /// The session is active and should receive future update ticks. + Running, + + /// The session is winding down but still needs future update ticks. + Closing, + + /// The session has finished application work. + /// + /// The generated leaf still retains the entry until every queued packet routes + /// successfully, which prevents a failed final frame from losing session cleanup. + Closed, +} + +/// Mutable output context passed to [`Session::update`]. +/// +/// The context queues packets only; it never routes them immediately. Centralizing +/// routing in generated code is what makes final-frame retries reliable. +pub struct SessionCtx<'a> { + hook_id: HookID, + reply_path: Vec, + procedure_id: u32, + outbox: &'a mut PacketQueue, +} + +impl<'a> SessionCtx<'a> { + /// Creates a context for one session update call. + pub fn new( + hook_id: HookID, + reply_path: Vec, + procedure_id: u32, + outbox: &'a mut PacketQueue, + ) -> Self { + Self { + hook_id, + reply_path, + procedure_id, + outbox, + } + } + + /// Returns the hook id used for packets emitted through this context. + pub fn hook_id(&self) -> HookID { + self.hook_id + } + + /// Returns the destination path used for packets emitted through this context. + pub fn reply_path(&self) -> &[u32] { + &self.reply_path + } + + /// Queues a one-byte-opcode frame without closing the hook. + pub fn send(&mut self, opcode: u8, data: &[u8]) { + self.send_frame(opcode, data, false); + } + + /// Queues a one-byte-opcode frame that closes the hook after successful routing. + pub fn send_final(&mut self, opcode: u8, data: &[u8]) { + self.send_frame(opcode, data, true); + } + + /// Queues a protocol-specific error frame without closing the hook. + /// + /// The `code` is used as the frame opcode because the protocol layer does not + /// reserve a universal error opcode. Leaves that have a dedicated error opcode can + /// pass that value here or call [`Self::send`] directly. + pub fn error(&mut self, code: u8, data: &[u8]) { + self.send(code, data); + } + + /// Queues a protocol-specific error frame that closes the hook after routing. + pub fn error_final(&mut self, code: u8, data: &[u8]) { + self.send_final(code, data); + } + + /// Queues raw packet data without adding an opcode byte. + pub fn send_raw(&mut self, data: &[u8]) { + self.send_raw_with_end(data, false); + } + + /// Queues raw packet data and closes the hook after successful routing. + pub fn send_raw_final(&mut self, data: &[u8]) { + self.send_raw_with_end(data, true); + } + + fn send_frame(&mut self, opcode: u8, data: &[u8], end_hook: bool) { + let mut frame = Vec::with_capacity(data.len() + 1); + frame.push(opcode); + frame.extend_from_slice(data); + self.enqueue_data(frame, end_hook); + } + + fn send_raw_with_end(&mut self, data: &[u8], end_hook: bool) { + self.enqueue_data(data.to_vec(), end_hook); + } + + fn enqueue_data(&mut self, data: Vec, end_hook: bool) { + self.outbox.push_back(Packet { + hook_id: self.hook_id, + end_hook, + path: self.reply_path.clone(), + procedure_id: self.procedure_id, + data, + }); + } +} + +/// Storage entry used by macro-generated session stores. +/// +/// The fields are public so generated code in downstream crates can keep the update +/// loop straightforward and static. Handwritten leaves may also use this type, but it +/// is intentionally small rather than a full session framework. +pub struct SessionEntry { + /// Hook id associated with this live session. + pub hook_id: HookID, + + /// Application-owned session state. + pub state: S, + + /// Packets delivered for this hook but not yet consumed by the session. + pub inbox: PacketQueue, + + /// Packets emitted by the session but not yet accepted by endpoint routing. + pub outbox: PacketQueue, + + /// Whether application logic has finished and only retry flushing may remain. + pub closed: bool, +} + +impl SessionEntry { + /// Creates one active session entry for `hook_id`. + pub fn new(hook_id: HookID, state: S) -> Self { + Self { + hook_id, + state, + inbox: PacketQueue::new(), + outbox: PacketQueue::new(), + closed: false, + } + } +} + +/// Flushes a retry queue through [`Endpoint::add_outbound`]. +/// +/// The packet at the front is cloned for each attempt and removed only after routing +/// succeeds. This preserves final frames when a route is temporarily unavailable. +/// The return value is true when the queue was fully drained. +pub fn flush_packet_queue(endpoint: &mut Endpoint, outbox: &mut PacketQueue) -> bool { + while let Some(packet) = outbox.front().cloned() { + if endpoint.add_outbound(packet).is_err() { + return false; + } + + outbox.pop_front(); + } + + true +} diff --git a/src/protocol/tests/merkle_sync/leaves.rs b/src/protocol/tests/merkle_sync/leaves.rs index 0c9ddcd..3a8d22f 100644 --- a/src/protocol/tests/merkle_sync/leaves.rs +++ b/src/protocol/tests/merkle_sync/leaves.rs @@ -5,6 +5,9 @@ use crossbeam_channel::{Receiver, Sender}; use crate::protocol::{Endpoint, Leaf, Packet}; +#[cfg(feature = "interface")] +use crate::protocol::LeafMeta; + use super::{ codec::{decode_block_chunk, decode_child_summary, decode_u32}, constants::{ @@ -96,6 +99,16 @@ impl Leaf for MockConnectionLeaf { LEAF_MOCK_CONNECTION } + #[cfg(feature = "interface")] + fn get_meta(&self) -> LeafMeta { + LeafMeta { + name: "Merke Connection Leaf", + identifier: "dev.unshell.test.merkle.connection", + version: "v0", + authors: vec!["ASTATIN3"], + } + } + fn update(&mut self, endpoint: &mut Endpoint) { if !self.started { endpoint @@ -126,6 +139,16 @@ impl Leaf for MerkleCallerLeaf { LEAF_MERKLE_CALLER } + #[cfg(feature = "interface")] + fn get_meta(&self) -> LeafMeta { + LeafMeta { + name: "Merke Caller Leaf", + identifier: "dev.unshell.test.merkle.caller", + version: "v0", + authors: vec!["ASTATIN3"], + } + } + fn update(&mut self, endpoint: &mut Endpoint) { self.receive_responses(endpoint); self.dispatch_next_request(endpoint); @@ -137,6 +160,16 @@ impl Leaf for MerkleRespondentLeaf { LEAF_MERKLE_RESPONDENT } + #[cfg(feature = "interface")] + fn get_meta(&self) -> LeafMeta { + LeafMeta { + name: "Merke Respondent Leaf", + identifier: "dev.unshell.test.merkle.respondent", + version: "v0", + authors: vec!["ASTATIN3"], + } + } + fn update(&mut self, endpoint: &mut Endpoint) { self.open_stream_from_request(endpoint); self.send_one_response_frame(endpoint); diff --git a/src/protocol/tests/oneshot/streams.rs b/src/protocol/tests/oneshot/streams.rs index b5d2180..ce3711d 100644 --- a/src/protocol/tests/oneshot/streams.rs +++ b/src/protocol/tests/oneshot/streams.rs @@ -1,5 +1,8 @@ use crate::protocol::{Endpoint, Leaf, Packet}; +#[cfg(feature = "interface")] +use crate::protocol::LeafMeta; + use alloc::{boxed::Box, format, vec, vec::Vec}; use super::support::{CommsLeaf, ENDPOINT_A, ENDPOINT_B, assert_hook_present, assert_hook_removed}; @@ -82,6 +85,16 @@ impl Leaf for StreamCallerLeaf { LEAF_STREAM_CALLER } + #[cfg(feature = "interface")] + fn get_meta(&self) -> LeafMeta { + LeafMeta { + name: "Stream Caller Leaf", + identifier: "dev.unshell.test.stream_caller_leaf", + version: "v0", + authors: vec!["ASTATIN3"], + } + } + fn update(&mut self, endpoint: &mut Endpoint) { if self.has_run { return; @@ -98,6 +111,16 @@ impl Leaf for StreamRespondentLeaf { LEAF_STREAM_RESPONDENT } + #[cfg(feature = "interface")] + fn get_meta(&self) -> LeafMeta { + LeafMeta { + name: "Stream Respondant Leaf", + identifier: "dev.unshell.test.stream_respondent_leaf", + version: "v0", + authors: vec!["ASTATIN3"], + } + } + fn update(&mut self, endpoint: &mut Endpoint) { self.open_stream_from_pending_request(endpoint); self.send_next_frame(endpoint); diff --git a/src/protocol/tests/oneshot/support.rs b/src/protocol/tests/oneshot/support.rs index 89fb069..2c1f19b 100644 --- a/src/protocol/tests/oneshot/support.rs +++ b/src/protocol/tests/oneshot/support.rs @@ -1,5 +1,8 @@ use crate::protocol::{Endpoint, Leaf, Packet}; +#[cfg(feature = "interface")] +use crate::protocol::LeafMeta; + use alloc::{vec, vec::Vec}; use crossbeam_channel::{Receiver, Sender}; @@ -112,6 +115,16 @@ impl Leaf for ControllerLeaf { LEAF_CONTROLLER } + #[cfg(feature = "interface")] + fn get_meta(&self) -> LeafMeta { + LeafMeta { + name: "Controller Leaf", + identifier: "dev.unshell.test.controller_leaf", + version: "v0", + authors: vec!["ASTATIN3"], + } + } + fn update(&mut self, endpoint: &mut Endpoint) { if !self.has_run { // The controller starts exactly one request so the end-to-end test can @@ -129,6 +142,16 @@ impl Leaf for CommsLeaf { LEAF_COMMS } + #[cfg(feature = "interface")] + fn get_meta(&self) -> LeafMeta { + LeafMeta { + name: "Comms Leaf", + identifier: "dev.unshell.test.comms_leaf", + version: "v0", + authors: vec!["ASTATIN3"], + } + } + fn update(&mut self, endpoint: &mut Endpoint) { if !self.started { endpoint @@ -160,6 +183,16 @@ impl Leaf for ResponderLeaf { LEAF_RESPONDER } + #[cfg(feature = "interface")] + fn get_meta(&self) -> LeafMeta { + LeafMeta { + name: "Responder Leaf", + identifier: "dev.unshell.test.responder_leaf", + version: "v0", + authors: vec!["ASTATIN3"], + } + } + fn update(&mut self, endpoint: &mut Endpoint) { let local_id = endpoint.path.last().cloned().unwrap_or(0); let mut packets = Vec::new(); From 43a84c46f75e6d6002a9e91751923abe7806dd52 Mon Sep 17 00:00:00 2001 From: Michael Mikovsky <77305074+Astatin3@users.noreply.github.com> Date: Sun, 31 May 2026 12:14:36 -0600 Subject: [PATCH 26/31] Work on implementing interface. --- Cargo.lock | 17 - Cargo.toml | 9 - src/interface/mod.rs | 405 ++++++++++++++++++++ src/protocol/leaf.rs | 24 +- src/protocol/leaf_meta.rs | 16 + src/protocol/leaf_template.rs | 250 +++++++++++++ src/protocol/mod.rs | 8 +- src/protocol/procedure.rs | 12 + src/protocol/runtime.rs | 270 ++++++++++++++ src/protocol/session.rs | 49 +++ unshell-leaves/leaf-pty/Cargo.toml | 5 + unshell-leaves/leaf-pty/src/state.rs | 23 +- unshell-leaves/leaf-pty/src/tests.rs | 39 ++ unshell-macros-core/Cargo.toml | 22 -- unshell-macros-core/src/leaf/args.rs | 78 ---- unshell-macros-core/src/leaf/generator.rs | 434 ---------------------- unshell-macros-core/src/leaf/mod.rs | 76 ---- unshell-macros-core/src/leaf/names.rs | 58 --- unshell-macros-core/src/lib.rs | 9 - unshell-macros/Cargo.toml | 23 -- unshell-macros/src/lib.rs | 15 - 21 files changed, 1093 insertions(+), 749 deletions(-) create mode 100644 src/protocol/leaf_template.rs create mode 100644 src/protocol/runtime.rs delete mode 100644 unshell-macros-core/Cargo.toml delete mode 100644 unshell-macros-core/src/leaf/args.rs delete mode 100644 unshell-macros-core/src/leaf/generator.rs delete mode 100644 unshell-macros-core/src/leaf/mod.rs delete mode 100644 unshell-macros-core/src/leaf/names.rs delete mode 100644 unshell-macros-core/src/lib.rs delete mode 100644 unshell-macros/Cargo.toml delete mode 100644 unshell-macros/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index a6f4172..b96cd60 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1829,23 +1829,6 @@ dependencies = [ "rkyv", "static_init", "thiserror 2.0.18", - "unshell-macros", -] - -[[package]] -name = "unshell-macros" -version = "0.1.0" -dependencies = [ - "unshell-macros-core", -] - -[[package]] -name = "unshell-macros-core" -version = "0.1.0" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index eb1a275..ae45e4f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,8 +4,6 @@ cargo-features = ["trim-paths", "panic-immediate-abort"] members = [ "ush-obfuscate", "base62", - "unshell-macros-core", - "unshell-macros", "unshell-leaves/leaf-pty", ] @@ -24,9 +22,6 @@ rkyv = "0.8.16" thiserror = "2.0.18" chrono = "0.4.44" static_init = "1.0.4" -syn = "2.0.117" -quote = "1.0.45" -proc-macro2 = "1.0.106" portable-pty = "0.9.0" crossbeam-channel = "0.5.15" @@ -34,8 +29,6 @@ ratatui = "0.30.0" unshell = { path = "." } # unshell-protocol = { path = "./unshell-protocol" } -unshell-macros-core = { path = "./unshell-macros-core" } -unshell-macros = { path = "./unshell-macros" } # ush-obfuscate = { path = "./ush-obfuscate" } # base62 = { path = "./base62" } @@ -67,8 +60,6 @@ static_init = { workspace = true } ratatui = { workspace = true, optional = true } -unshell-macros = { workspace = true } - [dev-dependencies] crossbeam-channel.workspace = true diff --git a/src/interface/mod.rs b/src/interface/mod.rs index 8b13789..29186a1 100644 --- a/src/interface/mod.rs +++ b/src/interface/mod.rs @@ -1 +1,406 @@ +use alloc::{collections::BTreeMap, vec::Vec}; +use crate::protocol::{EndpointError, HookID, Packet, SessionStatus}; + +/// Caller-owned view and packet-flow store for interface frontends. +/// +/// Generated leaves receive a mutable reference to this store during interface-aware +/// updates. They decide which leaf/session/procedure keys to touch, but the storage +/// itself stays with the renderer or application shell so protocol state remains +/// headless and reusable. +pub struct InterfaceStore { + next_sequence: u64, + now_ns: Option, + events: Vec, + sessions: BTreeMap, + procedures: BTreeMap, +} + +impl InterfaceStore { + /// Creates an empty caller-owned interface store. + pub fn new() -> Self { + Self { + next_sequence: 0, + now_ns: None, + events: Vec::new(), + sessions: BTreeMap::new(), + procedures: BTreeMap::new(), + } + } + + /// Sets the timestamp attached to later events. + /// + /// The core crate stays `no_std`, so the caller supplies time from its runtime. + /// Passing `None` keeps event ordering without pretending the protocol owns a + /// clock. + pub fn set_now_ns(&mut self, now_ns: Option) { + self.now_ns = now_ns; + } + + /// Returns the timestamp that will be attached to new events. + pub fn now_ns(&self) -> Option { + self.now_ns + } + + /// Returns all recorded events in insertion order. + pub fn events(&self) -> &[InterfaceEvent] { + &self.events + } + + /// Returns all session views keyed by leaf, procedure, and hook id. + pub fn session_views(&self) -> &BTreeMap { + &self.sessions + } + + /// Returns all procedure views keyed by leaf and procedure id. + pub fn procedure_views(&self) -> &BTreeMap { + &self.procedures + } + + /// Returns or creates the view for a hook-backed session. + pub fn session_view_mut( + &mut self, + leaf_id: u32, + procedure_id: u32, + hook_id: HookID, + ) -> &mut SessionView { + self.sessions + .entry(SessionKey { + leaf_id, + procedure_id, + hook_id, + }) + .or_insert_with(SessionView::new) + } + + /// Returns or creates the view for a one-shot procedure family. + pub fn procedure_view_mut(&mut self, leaf_id: u32, procedure_id: u32) -> &mut ProcedureView { + self.procedures + .entry(ProcedureKey { + leaf_id, + procedure_id, + }) + .or_insert_with(ProcedureView::new) + } + + /// Records a packet delivered to a generated leaf. + pub fn record_inbound(&mut self, leaf_id: u32, packet: &Packet) { + let index = self.push_event( + leaf_id, + InterfaceEventKind::Inbound { + packet: packet.clone(), + }, + ); + self.link_packet_event(leaf_id, packet, index); + } + + /// Records that a packet was queued for an existing session inbox. + pub fn record_session_packet_queued( + &mut self, + leaf_id: u32, + procedure_id: u32, + hook_id: HookID, + ) { + let index = self.push_event( + leaf_id, + InterfaceEventKind::SessionPacketQueued { + procedure_id, + hook_id, + }, + ); + self.session_view_mut(leaf_id, procedure_id, hook_id) + .events + .push(index); + } + + /// Records successful creation of a new session state. + pub fn record_session_created( + &mut self, + leaf_id: u32, + procedure_id: u32, + hook_id: HookID, + started_ns: Option, + ) { + let index = self.push_event( + leaf_id, + InterfaceEventKind::SessionCreated { + procedure_id, + hook_id, + started_ns, + finished_ns: self.now_ns, + }, + ); + let view = self.session_view_mut(leaf_id, procedure_id, hook_id); + view.status = SessionViewStatus::Running; + view.events.push(index); + } + + /// Records rejection of a packet that could not create a session. + pub fn record_session_rejected( + &mut self, + leaf_id: u32, + procedure_id: u32, + hook_id: HookID, + started_ns: Option, + ) { + let index = self.push_event( + leaf_id, + InterfaceEventKind::SessionRejected { + procedure_id, + hook_id, + started_ns, + finished_ns: self.now_ns, + }, + ); + let view = self.session_view_mut(leaf_id, procedure_id, hook_id); + view.status = SessionViewStatus::Rejected; + view.events.push(index); + } + + /// Records one session update tick. + pub fn record_session_update( + &mut self, + leaf_id: u32, + procedure_id: u32, + hook_id: HookID, + status: SessionStatus, + started_ns: Option, + ) { + let index = self.push_event( + leaf_id, + InterfaceEventKind::SessionUpdated { + procedure_id, + hook_id, + status, + started_ns, + finished_ns: self.now_ns, + }, + ); + let view = self.session_view_mut(leaf_id, procedure_id, hook_id); + view.status = SessionViewStatus::from_session_status(status); + view.events.push(index); + } + + /// Records one procedure call. + pub fn record_procedure_call( + &mut self, + leaf_id: u32, + procedure_id: u32, + hook_id: HookID, + started_ns: Option, + ) { + let index = self.push_event( + leaf_id, + InterfaceEventKind::ProcedureCalled { + procedure_id, + hook_id, + started_ns, + finished_ns: self.now_ns, + }, + ); + self.procedure_view_mut(leaf_id, procedure_id) + .events + .push(index); + } + + /// Records a packet emitted by leaf logic before route retry handling. + pub fn record_outbound_queued(&mut self, leaf_id: u32, packet: &Packet) { + let index = self.push_event( + leaf_id, + InterfaceEventKind::OutboundQueued { + packet: packet.clone(), + }, + ); + self.link_packet_event(leaf_id, packet, index); + } + + /// Records a route attempt for a queued outbound packet. + pub fn record_route_attempt(&mut self, leaf_id: u32, packet: &Packet) { + let index = self.push_event( + leaf_id, + InterfaceEventKind::RouteAttempt { + packet: packet.clone(), + }, + ); + self.link_packet_event(leaf_id, packet, index); + } + + /// Records a successful route attempt. + pub fn record_route_success(&mut self, leaf_id: u32, packet: &Packet) { + let index = self.push_event( + leaf_id, + InterfaceEventKind::RouteSuccess { + packet: packet.clone(), + }, + ); + self.link_packet_event(leaf_id, packet, index); + } + + /// Records a failed route attempt without removing the packet from retry state. + pub fn record_route_failure(&mut self, leaf_id: u32, packet: &Packet, error: EndpointError) { + let index = self.push_event( + leaf_id, + InterfaceEventKind::RouteFailure { + packet: packet.clone(), + error, + }, + ); + self.link_packet_event(leaf_id, packet, index); + } + + fn push_event(&mut self, leaf_id: u32, kind: InterfaceEventKind) -> usize { + let sequence = self.next_sequence; + self.next_sequence = self.next_sequence.wrapping_add(1); + let index = self.events.len(); + + self.events.push(InterfaceEvent { + sequence, + time_ns: self.now_ns, + leaf_id, + kind, + }); + + index + } + + fn link_packet_event(&mut self, leaf_id: u32, packet: &Packet, index: usize) { + self.session_view_mut(leaf_id, packet.procedure_id, packet.hook_id) + .events + .push(index); + } +} + +impl Default for InterfaceStore { + fn default() -> Self { + Self::new() + } +} + +/// Reborrows an optional interface store for one helper call. +/// +/// Generated leaf templates pass the same optional store through several helper +/// calls in one update. This small function keeps that reborrow explicit and avoids +/// every generated call site having to spell out `Option<&mut &mut T>` plumbing. +pub fn borrow_store<'a>( + store: &'a mut Option<&mut InterfaceStore>, +) -> Option<&'a mut InterfaceStore> { + store.as_mut().map(|store| &mut **store) +} + +/// Stable identity for one generated session view. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct SessionKey { + pub leaf_id: u32, + pub procedure_id: u32, + pub hook_id: HookID, +} + +/// Stable identity for one generated procedure view. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct ProcedureKey { + pub leaf_id: u32, + pub procedure_id: u32, +} + +/// Ordered event stored by [`InterfaceStore`]. +pub struct InterfaceEvent { + pub sequence: u64, + pub time_ns: Option, + pub leaf_id: u32, + pub kind: InterfaceEventKind, +} + +/// Interface-visible event emitted by generated helpers. +pub enum InterfaceEventKind { + Inbound { + packet: Packet, + }, + SessionPacketQueued { + procedure_id: u32, + hook_id: HookID, + }, + SessionCreated { + procedure_id: u32, + hook_id: HookID, + started_ns: Option, + finished_ns: Option, + }, + SessionRejected { + procedure_id: u32, + hook_id: HookID, + started_ns: Option, + finished_ns: Option, + }, + SessionUpdated { + procedure_id: u32, + hook_id: HookID, + status: SessionStatus, + started_ns: Option, + finished_ns: Option, + }, + ProcedureCalled { + procedure_id: u32, + hook_id: HookID, + started_ns: Option, + finished_ns: Option, + }, + OutboundQueued { + packet: Packet, + }, + RouteAttempt { + packet: Packet, + }, + RouteSuccess { + packet: Packet, + }, + RouteFailure { + packet: Packet, + error: EndpointError, + }, +} + +/// Caller-owned render view for one hook-backed session. +pub struct SessionView { + pub status: SessionViewStatus, + pub events: Vec, +} + +impl SessionView { + fn new() -> Self { + Self { + status: SessionViewStatus::Pending, + events: Vec::new(), + } + } +} + +/// Caller-owned render view for one one-shot procedure family. +pub struct ProcedureView { + pub events: Vec, +} + +impl ProcedureView { + fn new() -> Self { + Self { events: Vec::new() } + } +} + +/// Interface lifecycle state for one session view. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SessionViewStatus { + Pending, + Running, + Closing, + Closed, + Rejected, +} + +impl SessionViewStatus { + fn from_session_status(status: SessionStatus) -> Self { + match status { + SessionStatus::Running => Self::Running, + SessionStatus::Closing => Self::Closing, + SessionStatus::Closed => Self::Closed, + } + } +} diff --git a/src/protocol/leaf.rs b/src/protocol/leaf.rs index 872efd9..fbcf265 100644 --- a/src/protocol/leaf.rs +++ b/src/protocol/leaf.rs @@ -1,7 +1,7 @@ use crate::protocol::Endpoint; #[cfg(feature = "interface")] -use crate::protocol::leaf_meta::LeafMeta; +use crate::{interface::InterfaceStore, protocol::leaf_meta::LeafMeta}; /// Application extension point hosted by an [`Endpoint`]. /// @@ -18,9 +18,27 @@ pub trait Leaf { /// state, then enqueue outbound packets with [`Endpoint::add_outbound`]. fn update(&mut self, _: &mut Endpoint); + /// Advances the leaf while recording caller-owned interface state. + /// + /// Plain handwritten leaves can ignore the interface store and reuse their normal + /// update path. Generated leaves override this to route through the same template + /// helpers with packet flow logging enabled. #[cfg(feature = "interface")] - fn get_meta(&self) -> LeafMeta; + fn update_interface(&mut self, endpoint: &mut Endpoint, _: &mut InterfaceStore) { + self.update(endpoint); + } + + #[cfg(feature = "interface")] + fn get_meta(&self) -> LeafMeta { + LeafMeta::anonymous() + } #[cfg(feature = "interface_ratatui")] - fn render_ratatui(&mut self, _: &mut ratatui::Frame<'_>, _: ratatui::layout::Rect) {} + fn render_ratatui( + &mut self, + _: &mut ratatui::Frame<'_>, + _: ratatui::layout::Rect, + _: &mut InterfaceStore, + ) { + } } diff --git a/src/protocol/leaf_meta.rs b/src/protocol/leaf_meta.rs index c74c9ac..1599191 100644 --- a/src/protocol/leaf_meta.rs +++ b/src/protocol/leaf_meta.rs @@ -1,8 +1,24 @@ use alloc::vec::Vec; +/// Human-facing metadata for a leaf implementation. +/// +/// This is intentionally static text plus an allocated author list. It is only used +/// by interface frontends and diagnostics, not by hot packet routing. pub struct LeafMeta { pub name: &'static str, pub identifier: &'static str, pub version: &'static str, pub authors: Vec<&'static str>, } + +impl LeafMeta { + /// Builds metadata for leaves that have not opted into a richer interface label. + pub fn anonymous() -> Self { + Self { + name: "Unnamed Leaf", + identifier: "dev.unshell.unknown", + version: "v0", + authors: Vec::new(), + } + } +} diff --git a/src/protocol/leaf_template.rs b/src/protocol/leaf_template.rs new file mode 100644 index 0000000..15c9727 --- /dev/null +++ b/src/protocol/leaf_template.rs @@ -0,0 +1,250 @@ +/// Declares a generated leaf wrapper using a small template-like syntax. +/// +/// The macro deliberately requires callers to name every generated session field. It +/// does not infer identifiers, inspect struct fields, or hide behavior inside a proc +/// macro. All real dispatch and retry behavior lives in normal Rust helpers. +#[macro_export] +macro_rules! unshell_leaf { + ( + $vis:vis leaf $Leaf:ident for $State:ty { + id: $id:expr, + meta: $meta:expr, + sessions { $( $session_field:ident : $Session:ty ),* $(,)? } + procedures { $( $procedure_field:ident : $Procedure:ty ),* $(,)? } + } + ) => { + $vis struct $Leaf { + state: $State, + outbox: $crate::protocol::LeafOutbox, + $( + $session_field: $crate::protocol::SessionFamily< + <$Session as $crate::protocol::Session<$State>>::State, + >, + )* + } + + impl $Leaf { + /// Creates the generated leaf wrapper around user-owned state. + pub fn new(state: $State) -> Self { + Self { + state, + outbox: $crate::protocol::LeafOutbox::new(), + $( + $session_field: $crate::protocol::SessionFamily::new(), + )* + } + } + + /// Returns immutable access to the user-owned leaf state. + pub fn state(&self) -> &$State { + &self.state + } + + /// Returns mutable access to the user-owned leaf state. + pub fn state_mut(&mut self) -> &mut $State { + &mut self.state + } + + /// Returns the number of active session entries across all families. + pub fn active_session_count(&self) -> usize { + 0usize $(+ self.$session_field.entries.len())* + } + + /// Returns queued packets owned by this generated leaf. + pub fn pending_packet_count(&self) -> usize { + let mut count = self.outbox.len(); + $( + count += self.$session_field.pending_packet_count(); + )* + count + } + + fn __unshell_packet_is_owned(packet: &$crate::protocol::Packet) -> bool { + false + $( + || packet.procedure_id + == <$Session as $crate::protocol::Session<$State>>::PROCEDURE_ID + )* + $( + || packet.procedure_id + == <$Procedure as $crate::protocol::Procedure<$State>>::PROCEDURE_ID + )* + } + + fn __unshell_update_inner( + &mut self, + endpoint: &mut $crate::protocol::Endpoint, + mut interface: Option<&mut $crate::interface::InterfaceStore>, + ) { + let leaf_id = $id; + self.__unshell_flush_all(endpoint, $crate::interface::borrow_store(&mut interface)); + + let Some(local_id) = endpoint.path.last().copied() else { + return; + }; + + let mut packets = $crate::alloc::vec::Vec::new(); + endpoint.take_inbound_matching( + local_id, + Self::__unshell_packet_is_owned, + |packet| packets.push(packet), + ); + + for packet in packets { + self.__unshell_dispatch_packet( + endpoint, + packet, + $crate::interface::borrow_store(&mut interface), + ); + } + + $( + $crate::protocol::update_session_family::<$State, $Session>( + leaf_id, + &mut self.state, + &mut self.$session_field, + $crate::interface::borrow_store(&mut interface), + ); + )* + + self.__unshell_flush_all(endpoint, $crate::interface::borrow_store(&mut interface)); + } + + fn __unshell_dispatch_packet( + &mut self, + endpoint: &mut $crate::protocol::Endpoint, + packet: $crate::protocol::Packet, + mut interface: Option<&mut $crate::interface::InterfaceStore>, + ) { + let leaf_id = $id; + + $( + if packet.procedure_id + == <$Session as $crate::protocol::Session<$State>>::PROCEDURE_ID + { + $crate::protocol::dispatch_session::<$State, $Session>( + leaf_id, + &mut self.state, + &mut self.$session_field, + packet, + &mut self.outbox, + $crate::interface::borrow_store(&mut interface), + ); + return; + } + )* + + $( + if packet.procedure_id + == <$Procedure as $crate::protocol::Procedure<$State>>::PROCEDURE_ID + { + let _ = stringify!($procedure_field); + $crate::protocol::dispatch_procedure::<$State, $Procedure>( + leaf_id, + &mut self.state, + endpoint, + packet, + &mut self.outbox, + $crate::interface::borrow_store(&mut interface), + ); + return; + } + )* + + let _ = endpoint; + let _ = packet; + } + + fn __unshell_flush_all( + &mut self, + endpoint: &mut $crate::protocol::Endpoint, + mut interface: Option<&mut $crate::interface::InterfaceStore>, + ) { + let leaf_id = $id; + + $crate::protocol::flush_leaf_outbox( + endpoint, + leaf_id, + &mut self.outbox, + $crate::interface::borrow_store(&mut interface), + ); + + $( + $crate::protocol::flush_session_family::<$State, $Session>( + endpoint, + leaf_id, + &mut self.$session_field, + $crate::interface::borrow_store(&mut interface), + ); + )* + } + } + + impl $crate::protocol::Leaf for $Leaf { + fn get_id(&self) -> u32 { + $id + } + + fn update(&mut self, endpoint: &mut $crate::protocol::Endpoint) { + self.__unshell_update_inner(endpoint, None); + } + + #[cfg(feature = "interface")] + fn update_interface( + &mut self, + endpoint: &mut $crate::protocol::Endpoint, + interface: &mut $crate::interface::InterfaceStore, + ) { + self.__unshell_update_inner(endpoint, Some(interface)); + } + + #[cfg(feature = "interface")] + fn get_meta(&self) -> $crate::protocol::LeafMeta { + $meta + } + + #[cfg(feature = "interface_ratatui")] + fn render_ratatui( + &mut self, + frame: &mut $crate::protocol::ratatui::Frame<'_>, + area: $crate::protocol::ratatui::layout::Rect, + interface: &mut $crate::interface::InterfaceStore, + ) { + let leaf_id = $id; + + $( + for entry in &mut self.$session_field.entries { + let view = interface.session_view_mut( + leaf_id, + <$Session as $crate::protocol::Session<$State>>::PROCEDURE_ID, + entry.hook_id, + ); + <$Session as $crate::protocol::Session<$State>>::render_ratatui( + &self.state, + &entry.state, + view, + frame, + area, + ); + } + )* + + $( + { + let _ = stringify!($procedure_field); + let view = interface.procedure_view_mut( + leaf_id, + <$Procedure as $crate::protocol::Procedure<$State>>::PROCEDURE_ID, + ); + <$Procedure as $crate::protocol::Procedure<$State>>::render_ratatui( + &self.state, + view, + frame, + area, + ); + } + )* + } + } + }; +} diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs index fba9ffa..c794b58 100644 --- a/src/protocol/mod.rs +++ b/src/protocol/mod.rs @@ -2,18 +2,24 @@ mod endpoint; mod error; mod leaf; mod leaf_meta; +mod leaf_template; mod packet; mod procedure; +mod runtime; mod session; +pub use crate::unshell_leaf; pub use endpoint::{Endpoint, HookID}; pub use error::*; pub use leaf::Leaf; pub use leaf_meta::LeafMeta; pub use packet::Packet; pub use procedure::*; +pub use runtime::*; pub use session::*; -pub use unshell_macros::unshell_leaf; + +#[cfg(feature = "interface_ratatui")] +pub use ratatui; // Various named types used for brevity use alloc::{ diff --git a/src/protocol/procedure.rs b/src/protocol/procedure.rs index 366a8d6..0ee7a00 100644 --- a/src/protocol/procedure.rs +++ b/src/protocol/procedure.rs @@ -2,6 +2,9 @@ use alloc::vec::Vec; use crate::protocol::{Endpoint, HookID, Packet, PacketQueue}; +#[cfg(feature = "interface_ratatui")] +use crate::interface::ProcedureView; + /// Contract implemented by one generated one-packet procedure handler. /// /// Procedures are for stateless or short-lived operations such as ping, capabilities, @@ -13,6 +16,15 @@ pub trait Procedure { /// Handles one packet and optionally queues response packets in `out`. fn handle(leaf: &mut L, endpoint: &mut Endpoint, packet: Packet, out: &mut ProcedureOut); + + #[cfg(feature = "interface_ratatui")] + fn render_ratatui( + _: &L, + _: &mut ProcedureView, + _: &mut ratatui::Frame<'_>, + _: ratatui::layout::Rect, + ) { + } } /// Output accumulator passed to [`Procedure::handle`]. diff --git a/src/protocol/runtime.rs b/src/protocol/runtime.rs new file mode 100644 index 0000000..049c613 --- /dev/null +++ b/src/protocol/runtime.rs @@ -0,0 +1,270 @@ +use crate::{ + interface::InterfaceStore, + protocol::{ + Endpoint, Packet, PacketQueue, Procedure, ProcedureOut, Session, SessionCtx, SessionEntry, + SessionFamily, SessionInit, SessionInitResult, SessionStatus, + }, +}; + +/// Retry queue shared by generated leaves. +/// +/// Sessions already own per-hook outboxes. This leaf-level queue is for rejected +/// session initialization responses and one-shot procedures, both of which need the +/// same retry semantics as session output without becoming separate framework types. +pub struct LeafOutbox { + packets: PacketQueue, +} + +impl LeafOutbox { + /// Creates an empty leaf-level outbox. + pub fn new() -> Self { + Self { + packets: PacketQueue::new(), + } + } + + /// Adds one packet to the retry queue. + pub fn push(&mut self, packet: Packet) { + self.packets.push_back(packet); + } + + /// Adds all packets from `packets` in FIFO order. + pub fn extend(&mut self, packets: PacketQueue) { + self.packets.extend(packets); + } + + /// Returns the number of queued packets. + pub fn len(&self) -> usize { + self.packets.len() + } + + /// Returns true when the queue has no pending packets. + pub fn is_empty(&self) -> bool { + self.packets.is_empty() + } +} + +impl Default for LeafOutbox { + fn default() -> Self { + Self::new() + } +} + +/// Dispatches one packet into a generated session family. +/// +/// The macro picks `S` and the family field. This helper owns the boring details: +/// find the hook, initialize missing sessions, queue rejected responses, and update +/// interface state when a caller supplied one. +pub fn dispatch_session( + leaf_id: u32, + leaf: &mut L, + family: &mut SessionFamily, + packet: Packet, + outbox: &mut LeafOutbox, + mut interface: Option<&mut InterfaceStore>, +) where + S: Session, +{ + let hook_id = packet.hook_id; + let procedure_id = S::PROCEDURE_ID; + + if let Some(store) = crate::interface::borrow_store(&mut interface) { + store.record_inbound(leaf_id, &packet); + } + + if let Some(entry) = family + .entries + .iter_mut() + .find(|entry| entry.hook_id == hook_id) + { + entry.inbox.push_back(packet); + + if let Some(store) = interface { + store.record_session_packet_queued(leaf_id, procedure_id, hook_id); + } + + return; + } + + let started_ns = interface.as_ref().and_then(|store| store.now_ns()); + let packet_path = packet.path.clone(); + let mut init = SessionInit::new(hook_id, packet_path); + + match S::init(leaf, packet, &mut init) { + SessionInitResult::Created(state) => { + family.entries.push(SessionEntry::new(hook_id, state)); + + if let Some(store) = interface { + store.record_session_created(leaf_id, procedure_id, hook_id, started_ns); + } + } + SessionInitResult::Rejected => { + if let Some(store) = interface { + store.record_session_rejected(leaf_id, procedure_id, hook_id, started_ns); + } + } + SessionInitResult::RejectedWith(packet) => { + if let Some(store) = interface { + store.record_session_rejected(leaf_id, procedure_id, hook_id, started_ns); + store.record_outbound_queued(leaf_id, &packet); + } + + outbox.push(packet); + } + } +} + +/// Updates every live session in one generated session family. +pub fn update_session_family( + leaf_id: u32, + leaf: &mut L, + family: &mut SessionFamily, + mut interface: Option<&mut InterfaceStore>, +) where + S: Session, +{ + for entry in &mut family.entries { + if entry.closed { + continue; + } + + let started_ns = interface.as_ref().and_then(|store| store.now_ns()); + let reply_path = S::reply_path(&entry.state).to_vec(); + let mut ctx = SessionCtx::new( + entry.hook_id, + reply_path, + S::PROCEDURE_ID, + &mut entry.outbox, + ); + let status = S::update(leaf, &mut entry.state, &mut entry.inbox, &mut ctx); + + if let Some(store) = crate::interface::borrow_store(&mut interface) { + store.record_session_update( + leaf_id, + S::PROCEDURE_ID, + entry.hook_id, + status, + started_ns, + ); + } + + if matches!(status, SessionStatus::Closed) { + entry.closed = true; + } + } +} + +/// Dispatches one packet into a generated one-shot procedure. +pub fn dispatch_procedure( + leaf_id: u32, + leaf: &mut L, + endpoint: &mut Endpoint, + packet: Packet, + outbox: &mut LeafOutbox, + mut interface: Option<&mut InterfaceStore>, +) where + P: Procedure, +{ + let started_ns = interface.as_ref().and_then(|store| store.now_ns()); + + if let Some(store) = crate::interface::borrow_store(&mut interface) { + store.record_inbound(leaf_id, &packet); + } + + let hook_id = packet.hook_id; + let mut procedure_out = + ProcedureOut::new(hook_id, parent_reply_path(endpoint), P::PROCEDURE_ID); + + P::handle(leaf, endpoint, packet, &mut procedure_out); + + let packets = procedure_out.into_packets(); + + if let Some(store) = interface { + store.record_procedure_call(leaf_id, P::PROCEDURE_ID, hook_id, started_ns); + + for packet in &packets { + store.record_outbound_queued(leaf_id, packet); + } + } + + outbox.extend(packets); +} + +/// Flushes a generated leaf-level outbox through endpoint routing. +pub fn flush_leaf_outbox( + endpoint: &mut Endpoint, + leaf_id: u32, + outbox: &mut LeafOutbox, + interface: Option<&mut InterfaceStore>, +) -> bool { + flush_packet_queue_with_interface(endpoint, leaf_id, &mut outbox.packets, interface) +} + +/// Flushes and retains one generated session family. +pub fn flush_session_family( + endpoint: &mut Endpoint, + leaf_id: u32, + family: &mut SessionFamily, + mut interface: Option<&mut InterfaceStore>, +) where + S: Session, +{ + for entry in &mut family.entries { + flush_packet_queue_with_interface( + endpoint, + leaf_id, + &mut entry.outbox, + crate::interface::borrow_store(&mut interface), + ); + } + + family + .entries + .retain(|entry| !entry.closed || !entry.outbox.is_empty()); +} + +/// Flushes a retry queue through [`Endpoint::add_outbound`]. +/// +/// This is the interface-aware version of [`crate::protocol::flush_packet_queue`]. It +/// logs route attempts before trying them, then logs either success or the route error +/// without dropping the packet on failure. +pub fn flush_packet_queue_with_interface( + endpoint: &mut Endpoint, + leaf_id: u32, + outbox: &mut PacketQueue, + mut interface: Option<&mut InterfaceStore>, +) -> bool { + while let Some(packet) = outbox.front().cloned() { + if let Some(store) = crate::interface::borrow_store(&mut interface) { + store.record_route_attempt(leaf_id, &packet); + } + + match endpoint.add_outbound(packet.clone()) { + Ok(()) => { + if let Some(store) = crate::interface::borrow_store(&mut interface) { + store.record_route_success(leaf_id, &packet); + } + + outbox.pop_front(); + } + Err(error) => { + if let Some(store) = interface { + store.record_route_failure(leaf_id, &packet, error); + } + + return false; + } + } + } + + true +} + +/// Returns the path used by generated procedure responses. +fn parent_reply_path(endpoint: &Endpoint) -> alloc::vec::Vec { + if endpoint.path.len() > 1 { + endpoint.path[..endpoint.path.len() - 1].to_vec() + } else { + endpoint.path.clone() + } +} diff --git a/src/protocol/session.rs b/src/protocol/session.rs index 2ff94a2..eefb3bb 100644 --- a/src/protocol/session.rs +++ b/src/protocol/session.rs @@ -2,6 +2,9 @@ use alloc::vec::Vec; use crate::protocol::{Endpoint, HookID, Packet, PacketQueue}; +#[cfg(feature = "interface_ratatui")] +use crate::interface::SessionView; + /// Contract implemented by one hook-backed generated session family. /// /// A session family maps one outer `procedure_id` to many live hook instances. The @@ -74,6 +77,16 @@ pub trait Session { incoming: &mut PacketQueue, ctx: &mut SessionCtx<'_>, ) -> SessionStatus; + + #[cfg(feature = "interface_ratatui")] + fn render_ratatui( + _: &L, + _: &Self::State, + _: &mut SessionView, + _: &mut ratatui::Frame<'_>, + _: ratatui::layout::Rect, + ) { + } } /// Context passed to [`Session::init`]. @@ -249,6 +262,42 @@ pub struct SessionEntry { pub closed: bool, } +/// Generated storage for one session family. +/// +/// The macro only names this field and picks the concrete `Session` type. All update, +/// retry, and cleanup behavior lives in normal Rust helpers so the template stays +/// small and readable. +pub struct SessionFamily { + /// Active hook-backed sessions for this family. + pub entries: Vec>, +} + +impl SessionFamily { + /// Creates an empty session family. + pub fn new() -> Self { + Self { + entries: Vec::new(), + } + } + + /// Counts packets retained by this family for retry or future session work. + pub fn pending_packet_count(&self) -> usize { + let mut count = 0usize; + + for entry in &self.entries { + count += entry.inbox.len() + entry.outbox.len(); + } + + count + } +} + +impl Default for SessionFamily { + fn default() -> Self { + Self::new() + } +} + impl SessionEntry { /// Creates one active session entry for `hook_id`. pub fn new(hook_id: HookID, state: S) -> Self { diff --git a/unshell-leaves/leaf-pty/Cargo.toml b/unshell-leaves/leaf-pty/Cargo.toml index e16c044..b96fd12 100644 --- a/unshell-leaves/leaf-pty/Cargo.toml +++ b/unshell-leaves/leaf-pty/Cargo.toml @@ -7,6 +7,11 @@ description = "Hook-backed PTY leaf implementation for UnShell" [dependencies] unshell = { workspace = true } +[features] +default = [] +interface = ["unshell/interface"] +interface_ratatui = ["interface", "unshell/interface_ratatui"] + [lints.rust] elided_lifetimes_in_paths = "warn" future_incompatible = { level = "warn", priority = -1 } diff --git a/unshell-leaves/leaf-pty/src/state.rs b/unshell-leaves/leaf-pty/src/state.rs index eaf771b..f73cfd7 100644 --- a/unshell-leaves/leaf-pty/src/state.rs +++ b/unshell-leaves/leaf-pty/src/state.rs @@ -4,10 +4,9 @@ use crate::{constants::LEAF_FAKE_PTY, session::PtySession}; /// User-owned state for the generated fake PTY leaf. /// -/// The macro-generated `FakePtyLeaf` wrapper stores sessions and retry queues around -/// this struct. Keeping counters here makes tests and future procedures observe leaf -/// behavior without reaching into generated session storage. -#[unshell_leaf(leaf = FakePtyLeaf, id = LEAF_FAKE_PTY, sessions(PtySession))] +/// The `unshell_leaf!` template stores sessions and retry queues around this struct. +/// Keeping counters here makes tests and future procedures observe leaf behavior +/// without reaching into generated session storage. pub struct FakePtyState { /// Number of sessions that application logic considers active. pub active_count: usize, @@ -35,3 +34,19 @@ impl Default for FakePtyState { Self::new() } } + +unshell_leaf! { + pub leaf FakePtyLeaf for FakePtyState { + id: LEAF_FAKE_PTY, + meta: unshell::protocol::LeafMeta { + name: "Fake PTY Leaf", + identifier: "dev.unshell.v1.pty", + version: "v0", + authors: unshell::alloc::vec!["ASTATIN3"], + }, + sessions { + pty: PtySession, + } + procedures {} + } +} diff --git a/unshell-leaves/leaf-pty/src/tests.rs b/unshell-leaves/leaf-pty/src/tests.rs index 513fcf0..5bf5efc 100644 --- a/unshell-leaves/leaf-pty/src/tests.rs +++ b/unshell-leaves/leaf-pty/src/tests.rs @@ -2,6 +2,9 @@ use alloc::{vec, vec::Vec}; use unshell::protocol::{Endpoint, Leaf, Packet}; +#[cfg(feature = "interface")] +use unshell::interface::{InterfaceEventKind, InterfaceStore}; + use super::{ FakePtyLeaf, FakePtyState, OP_ABORT, OP_ERROR, OP_EXIT, OP_INPUT, OP_OPENED, OP_OUTPUT, OP_STDIN_EOF, OP_TERMINATE, PROC_PTY, frame_opcode, frame_payload, pty_open_packet, pty_packet, @@ -391,3 +394,39 @@ fn pty_leaf_does_not_consume_other_leaf_packets() { assert_eq!(other_packets[0].procedure_id, PROC_OTHER); assert_eq!(other_packets[0].data, b"leave-me".to_vec()); } + +#[cfg(feature = "interface")] +#[test] +fn interface_update_records_session_flow() { + let (mut endpoint_a, mut endpoint_b) = pty_endpoints(); + let mut leaf = FakePtyLeaf::new(FakePtyState::new()); + let mut interface = InterfaceStore::new(); + let hook_id = endpoint_a.get_hook_id(); + + endpoint_a + .add_outbound(pty_open_packet( + vec![ENDPOINT_A, ENDPOINT_B], + hook_id, + &[ENDPOINT_A], + )) + .unwrap(); + transfer_packets(&mut endpoint_a, &mut endpoint_b, ENDPOINT_B, ENDPOINT_A); + + leaf.update_interface(&mut endpoint_b, &mut interface); + + assert_eq!(leaf.active_session_count(), 1); + assert!(interface.events().iter().any(|event| { + matches!( + &event.kind, + InterfaceEventKind::SessionCreated { hook_id: recorded_hook, .. } + if *recorded_hook == hook_id + ) + })); + assert!(interface.events().iter().any(|event| { + matches!( + &event.kind, + InterfaceEventKind::RouteSuccess { packet } + if packet.hook_id == hook_id && frame_opcode(packet) == Some(OP_OPENED) + ) + })); +} diff --git a/unshell-macros-core/Cargo.toml b/unshell-macros-core/Cargo.toml deleted file mode 100644 index 0bd9c20..0000000 --- a/unshell-macros-core/Cargo.toml +++ /dev/null @@ -1,22 +0,0 @@ -[package] -name = "unshell-macros-core" -version.workspace = true -edition.workspace = true -description = "Parser and code generator for UnShell procedural macros" - -[dependencies] -proc-macro2 = { workspace = true } -quote = { workspace = true } -syn = { workspace = true, features = ["full", "extra-traits"] } - -[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" diff --git a/unshell-macros-core/src/leaf/args.rs b/unshell-macros-core/src/leaf/args.rs deleted file mode 100644 index 357644f..0000000 --- a/unshell-macros-core/src/leaf/args.rs +++ /dev/null @@ -1,78 +0,0 @@ -use syn::{ - Expr, Ident, Result, Token, Type, - parse::{Parse, ParseStream}, -}; - -/// Parsed arguments from `#[unshell_leaf(...)]`. -#[derive(Debug)] -pub(crate) struct UnshellLeafArgs { - pub(crate) leaf: Ident, - pub(crate) id: Expr, - pub(crate) sessions: Vec, - pub(crate) procedures: Vec, -} - -impl Parse for UnshellLeafArgs { - fn parse(input: ParseStream<'_>) -> Result { - let mut leaf = None; - let mut id = None; - let mut sessions = Vec::new(); - let mut procedures = Vec::new(); - - while !input.is_empty() { - let key: Ident = input.parse()?; - match key.to_string().as_str() { - "leaf" => { - reject_duplicate(&leaf, &key)?; - input.parse::()?; - leaf = Some(input.parse()?); - } - "id" => { - reject_duplicate(&id, &key)?; - input.parse::()?; - id = Some(input.parse()?); - } - "sessions" => { - sessions = parse_type_list(input)?; - } - "procedures" => { - procedures = parse_type_list(input)?; - } - _ => { - return Err(syn::Error::new( - key.span(), - "expected `leaf`, `id`, `sessions`, or `procedures`", - )); - } - } - - if input.peek(Token![,]) { - input.parse::()?; - } - } - - Ok(Self { - leaf: leaf.ok_or_else(|| input.error("missing `leaf = WrapperName`"))?, - id: id.ok_or_else(|| input.error("missing `id = LEAF_ID`"))?, - sessions, - procedures, - }) - } -} - -/// Rejects repeated scalar keys while keeping repeated list keys additive by design. -fn reject_duplicate(slot: &Option, key: &Ident) -> Result<()> { - if slot.is_some() { - Err(syn::Error::new(key.span(), "duplicate key")) - } else { - Ok(()) - } -} - -/// Parses `name(Type, Type)` argument payloads. -fn parse_type_list(input: ParseStream<'_>) -> Result> { - let content; - syn::parenthesized!(content in input); - let parsed = content.parse_terminated(Type::parse, Token![,])?; - Ok(parsed.into_iter().collect()) -} diff --git a/unshell-macros-core/src/leaf/generator.rs b/unshell-macros-core/src/leaf/generator.rs deleted file mode 100644 index 8a14f05..0000000 --- a/unshell-macros-core/src/leaf/generator.rs +++ /dev/null @@ -1,434 +0,0 @@ -use proc_macro2::TokenStream; -use quote::{format_ident, quote}; -use syn::{Ident, ItemStruct, Result, Type}; - -use super::{ - UnshellLeafArgs, - names::{last_type_ident, to_snake_case}, -}; - -/// Code generator state for one `#[unshell_leaf]` expansion. -pub(crate) struct LeafGenerator { - args: UnshellLeafArgs, - state: ItemStruct, -} - -impl LeafGenerator { - /// Creates a generator for one parsed state struct. - pub(crate) fn new(args: UnshellLeafArgs, state: ItemStruct) -> Self { - Self { args, state } - } - - /// Emits the original state struct plus the generated wrapper leaf. - pub(crate) fn expand(self) -> Result { - let state = &self.state; - let state_ident = &state.ident; - let leaf_ident = &self.args.leaf; - let leaf_id = &self.args.id; - let vis = &state.vis; - let generics = &state.generics; - let (impl_generics, ty_generics, where_clause) = generics.split_for_impl(); - let state_type = quote!(#state_ident #ty_generics); - - let session_stores = self.session_stores()?; - let fields = self.store_fields(&session_stores, &state_type); - let initializers = self.store_initializers(&session_stores); - let packet_predicates = self.packet_predicates(&state_type); - let dispatch_arms = self.dispatch_arms(&session_stores, &state_type); - let session_updates = self.session_updates(&session_stores, &state_type); - let session_flushes = self.session_flushes(&session_stores); - let session_retains = self.session_retains(&session_stores); - let active_count_terms = self.active_count_terms(&session_stores); - let pending_count_terms = self.pending_count_terms(&session_stores); - let id_checks = self.id_checks(&state_type); - - Ok(quote! { - #state - - #vis struct #leaf_ident #generics #where_clause { - state: #state_type, - __unshell_procedure_outbox: ::unshell::protocol::PacketQueue, - #(#fields,)* - } - - impl #impl_generics #leaf_ident #ty_generics #where_clause { - const __UNSHELL_PROCEDURE_ID_CHECKS: () = { - #(#id_checks)* - }; - - /// Creates the generated leaf wrapper around user-owned state. - pub fn new(state: #state_type) -> Self { - Self { - state, - __unshell_procedure_outbox: ::unshell::protocol::PacketQueue::new(), - #(#initializers,)* - } - } - - /// Returns immutable access to the user-owned leaf state. - pub fn state(&self) -> &#state_type { - &self.state - } - - /// Returns mutable access to the user-owned leaf state. - pub fn state_mut(&mut self) -> &mut #state_type { - &mut self.state - } - - /// Returns the number of active session entries across all session families. - pub fn active_session_count(&self) -> usize { - 0usize #(+ #active_count_terms)* - } - - /// Returns queued inbound and outbound packets owned by this generated leaf. - pub fn pending_packet_count(&self) -> usize { - let mut __unshell_count = self.__unshell_procedure_outbox.len(); - #(#pending_count_terms)* - __unshell_count - } - - fn __unshell_packet_is_owned(packet: &::unshell::protocol::Packet) -> bool { - false #(|| #packet_predicates)* - } - - fn __unshell_dispatch( - &mut self, - endpoint: &mut ::unshell::protocol::Endpoint, - packet: ::unshell::protocol::Packet, - ) { - #(#dispatch_arms)* - } - - fn __unshell_update_sessions(&mut self) { - #(#session_updates)* - } - - fn __unshell_flush_all(&mut self, endpoint: &mut ::unshell::protocol::Endpoint) { - ::unshell::protocol::flush_packet_queue( - endpoint, - &mut self.__unshell_procedure_outbox, - ); - #(#session_flushes)* - #(#session_retains)* - } - - fn __unshell_parent_reply_path( - endpoint: &::unshell::protocol::Endpoint, - ) -> ::unshell::alloc::vec::Vec { - if endpoint.path.len() > 1 { - endpoint.path[..endpoint.path.len() - 1].to_vec() - } else { - endpoint.path.clone() - } - } - } - - impl #impl_generics ::unshell::protocol::Leaf for #leaf_ident #ty_generics #where_clause { - fn get_id(&self) -> u32 { - #leaf_id - } - - fn update(&mut self, endpoint: &mut ::unshell::protocol::Endpoint) { - self.__unshell_flush_all(endpoint); - - let Some(__unshell_local_id) = endpoint.path.last().copied() else { - return; - }; - - let mut __unshell_packets = ::unshell::alloc::vec::Vec::new(); - endpoint.take_inbound_matching( - __unshell_local_id, - Self::__unshell_packet_is_owned, - |packet| __unshell_packets.push(packet), - ); - - for __unshell_packet in __unshell_packets { - self.__unshell_dispatch(endpoint, __unshell_packet); - } - - self.__unshell_update_sessions(); - self.__unshell_flush_all(endpoint); - } - } - }) - } - - /// Computes one generated store name per session type. - fn session_stores(&self) -> Result> { - self.args - .sessions - .iter() - .map(|session| { - let suffix = last_type_ident(session)?; - let field_suffix = to_snake_case(&suffix.to_string()); - Ok(SessionStore { - ty: session.clone(), - field: format_ident!("__unshell_{}_sessions", field_suffix), - }) - }) - .collect() - } - - /// Emits wrapper fields for session stores. - fn store_fields(&self, stores: &[SessionStore], state_type: &TokenStream) -> Vec { - stores - .iter() - .map(|store| { - let field = &store.field; - let session_ty = &store.ty; - quote! { - #field: ::unshell::alloc::vec::Vec< - ::unshell::protocol::SessionEntry< - <#session_ty as ::unshell::protocol::Session<#state_type>>::State - > - > - } - }) - .collect() - } - - /// Emits constructor field initializers for session stores. - fn store_initializers(&self, stores: &[SessionStore]) -> Vec { - stores - .iter() - .map(|store| { - let field = &store.field; - quote!(#field: ::unshell::alloc::vec::Vec::new()) - }) - .collect() - } - - /// Emits boolean procedure-id ownership checks for the filtered endpoint drain. - fn packet_predicates(&self, state_type: &TokenStream) -> Vec { - let session_checks = self.args.sessions.iter().map(|session_ty| { - quote! { - packet.procedure_id - == <#session_ty as ::unshell::protocol::Session<#state_type>>::PROCEDURE_ID - } - }); - let procedure_checks = self.args.procedures.iter().map(|procedure_ty| { - quote! { - packet.procedure_id - == <#procedure_ty as ::unshell::protocol::Procedure<#state_type>>::PROCEDURE_ID - } - }); - - session_checks.chain(procedure_checks).collect() - } - - /// Emits static dispatch branches for every session and procedure type. - fn dispatch_arms(&self, stores: &[SessionStore], state_type: &TokenStream) -> Vec { - let mut arms = Vec::new(); - - for store in stores { - let field = &store.field; - let session_ty = &store.ty; - arms.push(quote! { - if packet.procedure_id - == <#session_ty as ::unshell::protocol::Session<#state_type>>::PROCEDURE_ID - { - if let Some(__unshell_entry) = self - .#field - .iter_mut() - .find(|entry| entry.hook_id == packet.hook_id) - { - __unshell_entry.inbox.push_back(packet); - } else { - let __unshell_hook_id = packet.hook_id; - let __unshell_packet_path = packet.path.clone(); - let mut __unshell_init = ::unshell::protocol::SessionInit::new( - __unshell_hook_id, - __unshell_packet_path, - ); - - match <#session_ty as ::unshell::protocol::Session<#state_type>>::init( - &mut self.state, - packet, - &mut __unshell_init, - ) { - ::unshell::protocol::SessionInitResult::Created(__unshell_state) => { - self.#field.push(::unshell::protocol::SessionEntry::new( - __unshell_hook_id, - __unshell_state, - )); - } - ::unshell::protocol::SessionInitResult::Rejected => {} - ::unshell::protocol::SessionInitResult::RejectedWith(__unshell_packet) => { - self.__unshell_procedure_outbox.push_back(__unshell_packet); - } - } - } - return; - } - }); - } - - for procedure_ty in &self.args.procedures { - arms.push(quote! { - if packet.procedure_id - == <#procedure_ty as ::unshell::protocol::Procedure<#state_type>>::PROCEDURE_ID - { - let mut __unshell_out = ::unshell::protocol::ProcedureOut::new( - packet.hook_id, - Self::__unshell_parent_reply_path(endpoint), - <#procedure_ty as ::unshell::protocol::Procedure<#state_type>>::PROCEDURE_ID, - ); - <#procedure_ty as ::unshell::protocol::Procedure<#state_type>>::handle( - &mut self.state, - endpoint, - packet, - &mut __unshell_out, - ); - self.__unshell_procedure_outbox.extend(__unshell_out.into_packets()); - return; - } - }); - } - - arms - } - - /// Emits the per-session update loop for every session family. - fn session_updates( - &self, - stores: &[SessionStore], - state_type: &TokenStream, - ) -> Vec { - stores - .iter() - .map(|store| { - let field = &store.field; - let session_ty = &store.ty; - quote! { - for __unshell_entry in &mut self.#field { - if __unshell_entry.closed { - continue; - } - - let __unshell_reply_path = - <#session_ty as ::unshell::protocol::Session<#state_type>>::reply_path( - &__unshell_entry.state, - ) - .to_vec(); - let mut __unshell_ctx = ::unshell::protocol::SessionCtx::new( - __unshell_entry.hook_id, - __unshell_reply_path, - <#session_ty as ::unshell::protocol::Session<#state_type>>::PROCEDURE_ID, - &mut __unshell_entry.outbox, - ); - let __unshell_status = - <#session_ty as ::unshell::protocol::Session<#state_type>>::update( - &mut self.state, - &mut __unshell_entry.state, - &mut __unshell_entry.inbox, - &mut __unshell_ctx, - ); - - if ::core::matches!( - __unshell_status, - ::unshell::protocol::SessionStatus::Closed - ) { - __unshell_entry.closed = true; - } - } - } - }) - .collect() - } - - /// Emits retry flushing for every session outbox. - fn session_flushes(&self, stores: &[SessionStore]) -> Vec { - stores - .iter() - .map(|store| { - let field = &store.field; - quote! { - for __unshell_entry in &mut self.#field { - ::unshell::protocol::flush_packet_queue( - endpoint, - &mut __unshell_entry.outbox, - ); - } - } - }) - .collect() - } - - /// Emits removal of closed sessions whose final packets have routed. - fn session_retains(&self, stores: &[SessionStore]) -> Vec { - stores - .iter() - .map(|store| { - let field = &store.field; - quote! { - self.#field - .retain(|entry| !entry.closed || !entry.outbox.is_empty()); - } - }) - .collect() - } - - /// Emits additive terms for active session counts. - fn active_count_terms(&self, stores: &[SessionStore]) -> Vec { - stores - .iter() - .map(|store| { - let field = &store.field; - quote!(self.#field.len()) - }) - .collect() - } - - /// Emits statements that accumulate pending packet counts. - fn pending_count_terms(&self, stores: &[SessionStore]) -> Vec { - stores - .iter() - .map(|store| { - let field = &store.field; - quote! { - for __unshell_entry in &self.#field { - __unshell_count += - __unshell_entry.inbox.len() + __unshell_entry.outbox.len(); - } - } - }) - .collect() - } - - /// Emits pairwise const assertions for procedure-id uniqueness. - fn id_checks(&self, state_type: &TokenStream) -> Vec { - let mut ids = Vec::new(); - for session_ty in &self.args.sessions { - ids.push( - quote!(<#session_ty as ::unshell::protocol::Session<#state_type>>::PROCEDURE_ID), - ); - } - for procedure_ty in &self.args.procedures { - ids.push( - quote!(<#procedure_ty as ::unshell::protocol::Procedure<#state_type>>::PROCEDURE_ID), - ); - } - - let mut checks = Vec::new(); - for left in 0..ids.len() { - for right in (left + 1)..ids.len() { - let left_id = &ids[left]; - let right_id = &ids[right]; - checks.push(quote! { - assert!( - #left_id != #right_id, - "duplicate unshell procedure id in #[unshell_leaf]" - ); - }); - } - } - - checks - } -} - -/// Generated storage metadata for one session family. -struct SessionStore { - ty: Type, - field: Ident, -} diff --git a/unshell-macros-core/src/leaf/mod.rs b/unshell-macros-core/src/leaf/mod.rs deleted file mode 100644 index 9f267ea..0000000 --- a/unshell-macros-core/src/leaf/mod.rs +++ /dev/null @@ -1,76 +0,0 @@ -//! Leaf wrapper macro implementation. -//! -//! Everything in this module is specific to `#[unshell_leaf]`: argument parsing, -//! generated wrapper storage, static dispatch, and retry-safe session output. Future -//! macro families should be added as sibling modules instead of sharing this internal -//! structure. - -mod args; -mod generator; -mod names; - -use proc_macro2::TokenStream; -use syn::{ItemStruct, Result, parse2}; - -pub(crate) use args::UnshellLeafArgs; -pub(crate) use generator::LeafGenerator; - -/// Expands `#[unshell_leaf(...)]` into a wrapper leaf and `Leaf` implementation. -/// -/// Errors are returned as tokenized `compile_error!` output so the proc-macro shim can -/// stay a thin transport layer from compiler tokens to this core implementation. -pub fn expand_unshell_leaf(attr: TokenStream, item: TokenStream) -> TokenStream { - match expand_unshell_leaf_result(attr, item) { - Ok(tokens) => tokens, - Err(error) => error.to_compile_error(), - } -} - -/// Fallible expansion path used by unit tests. -pub fn expand_unshell_leaf_result(attr: TokenStream, item: TokenStream) -> Result { - let args = parse2::(attr)?; - let state = parse2::(item)?; - LeafGenerator::new(args, state).expand() -} - -#[cfg(test)] -mod tests { - use super::*; - use quote::quote; - - #[test] - fn parses_leaf_arguments() { - let args = parse2::(quote! { - leaf = DemoLeaf, - id = 42, - sessions(DemoSession), - procedures(PingProcedure) - }) - .unwrap(); - - assert_eq!(args.leaf, "DemoLeaf"); - assert_eq!(args.sessions.len(), 1); - assert_eq!(args.procedures.len(), 1); - } - - #[test] - fn missing_leaf_is_rejected() { - let error = parse2::(quote! { id = 42 }).unwrap_err(); - - assert!(error.to_string().contains("missing `leaf")); - } - - #[test] - fn expansion_contains_static_dispatch() { - let expanded = expand_unshell_leaf_result( - quote! { leaf = DemoLeaf, id = 9, sessions(DemoSession) }, - quote! { pub struct DemoState; }, - ) - .unwrap() - .to_string(); - - assert!(expanded.contains("struct DemoLeaf")); - assert!(expanded.contains("impl :: unshell :: protocol :: Leaf for DemoLeaf")); - assert!(expanded.contains("DemoSession")); - } -} diff --git a/unshell-macros-core/src/leaf/names.rs b/unshell-macros-core/src/leaf/names.rs deleted file mode 100644 index d242fb9..0000000 --- a/unshell-macros-core/src/leaf/names.rs +++ /dev/null @@ -1,58 +0,0 @@ -use syn::{Ident, Result, Type}; - -/// Returns the final path segment for a session type. -pub(crate) fn last_type_ident(ty: &Type) -> Result { - let Type::Path(path) = ty else { - return Err(syn::Error::new_spanned( - ty, - "session types must be named paths", - )); - }; - let Some(segment) = path.path.segments.last() else { - return Err(syn::Error::new_spanned(ty, "session type path is empty")); - }; - - Ok(segment.ident.clone()) -} - -/// Converts a Rust type name into a snake-case fragment for generated private fields. -pub(crate) fn to_snake_case(name: &str) -> String { - let mut output = String::with_capacity(name.len()); - let chars: Vec = name.chars().collect(); - - for (index, character) in chars.iter().copied().enumerate() { - if character.is_ascii_uppercase() { - let previous = index - .checked_sub(1) - .and_then(|previous| chars.get(previous)); - let next = chars.get(index + 1); - let previous_needs_boundary = previous - .map(|previous| previous.is_ascii_lowercase() || previous.is_ascii_digit()) - .unwrap_or(false); - let acronym_needs_boundary = previous - .map(|previous| previous.is_ascii_uppercase()) - .unwrap_or(false) - && next.map(|next| next.is_ascii_lowercase()).unwrap_or(false); - - if previous_needs_boundary || acronym_needs_boundary { - output.push('_'); - } - output.push(character.to_ascii_lowercase()); - } else { - output.push(character); - } - } - - output -} - -#[cfg(test)] -mod tests { - use super::to_snake_case; - - #[test] - fn session_store_fields_are_snake_case() { - assert_eq!(to_snake_case("PtySession"), "pty_session"); - assert_eq!(to_snake_case("HTTPServer"), "http_server"); - } -} diff --git a/unshell-macros-core/src/lib.rs b/unshell-macros-core/src/lib.rs deleted file mode 100644 index f7b8f9a..0000000 --- a/unshell-macros-core/src/lib.rs +++ /dev/null @@ -1,9 +0,0 @@ -//! Parser and code generator for UnShell procedural macros. -//! -//! This crate is intentionally not a proc-macro crate. Keeping each macro family's -//! parser and code generator here makes them unit-testable and prevents parsing -//! dependencies from leaking into runtime crates. - -mod leaf; - -pub use leaf::{expand_unshell_leaf, expand_unshell_leaf_result}; diff --git a/unshell-macros/Cargo.toml b/unshell-macros/Cargo.toml deleted file mode 100644 index 1fb533e..0000000 --- a/unshell-macros/Cargo.toml +++ /dev/null @@ -1,23 +0,0 @@ -[package] -name = "unshell-macros" -version.workspace = true -edition.workspace = true -description = "Procedural macros for UnShell leaves" - -[lib] -proc-macro = true - -[dependencies] -unshell-macros-core = { 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" diff --git a/unshell-macros/src/lib.rs b/unshell-macros/src/lib.rs deleted file mode 100644 index 9751ccc..0000000 --- a/unshell-macros/src/lib.rs +++ /dev/null @@ -1,15 +0,0 @@ -//! Procedural macro shim for UnShell. -//! -//! The real parser and code generator live in `unshell-macros-core` so they can be -//! tested as ordinary Rust. This crate only adapts compiler `TokenStream`s. - -use proc_macro::TokenStream; - -/// Generates an `unshell_protocol::Leaf` wrapper for a user-owned state struct. -/// -/// See `LEAF_MACRO_INTERFACE.md` for the design contract. The generated wrapper owns -/// session stores, retry queues, filtered packet dispatch, and final-frame cleanup. -#[proc_macro_attribute] -pub fn unshell_leaf(attr: TokenStream, item: TokenStream) -> TokenStream { - unshell_macros_core::expand_unshell_leaf(attr.into(), item.into()).into() -} From ba3a419bb2d4f74da1d725df1f99b7f51ee105a1 Mon Sep 17 00:00:00 2001 From: Michael Mikovsky <77305074+Astatin3@users.noreply.github.com> Date: Sun, 31 May 2026 12:21:33 -0600 Subject: [PATCH 27/31] Split interface store into modules. --- LEAF_MACRO_INTERFACE.md | 891 +++++----------------------------- src/interface/event.rs | 76 +++ src/interface/key.rs | 24 + src/interface/mod.rs | 416 +--------------- src/interface/store.rs | 311 ++++++++++++ src/interface/view.rs | 65 +++ src/protocol/leaf_template.rs | 20 +- src/protocol/runtime.rs | 41 +- 8 files changed, 635 insertions(+), 1209 deletions(-) create mode 100644 src/interface/event.rs create mode 100644 src/interface/key.rs create mode 100644 src/interface/store.rs create mode 100644 src/interface/view.rs diff --git a/LEAF_MACRO_INTERFACE.md b/LEAF_MACRO_INTERFACE.md index 9fa798e..c8f3d87 100644 --- a/LEAF_MACRO_INTERFACE.md +++ b/LEAF_MACRO_INTERFACE.md @@ -1,810 +1,157 @@ -# Macro-Generated Leaf Interface Design +# Template Leaf Interface Design -**Status:** Draft -**Last updated:** 2026-05-28 -**Primary use case:** Remote PTY sessions over hook-backed UnShell packets +**Status:** Implemented draft +**Last updated:** 2026-05-31 +**Primary use case:** Small generated leaf wrappers without proc-macro machinery ## Summary -This document proposes a generated leaf interface for UnShell. The goal is to make -stateful leaves, such as a remote PTY leaf, easy to write without forcing every leaf -author to hand-code packet draining, procedure dispatch, session lookup, hook -lifetime handling, and retry-safe final-frame cleanup. +Leaf generation now uses a declarative `unshell_leaf!` template instead of the old +`#[unshell_leaf]` proc macro. The goal is to make generated code obvious, closer to +an HTML template than an AST transformation. -The user writes the application logic: +The macro only fills slots: -- the leaf state struct -- one or more session types for long-running hook-backed conversations -- one or more procedure types for one-packet operations -- payload encoding and decoding -- OS-specific behavior, such as spawning or polling a PTY +- wrapper name +- user state type +- leaf id +- interface metadata +- named session families +- named procedure families -The macro generates the plumbing: +All real behavior lives in normal Rust helpers under `src/protocol/runtime.rs`. +Those helpers are testable without macro parsing, `syn`, `quote`, or generated name +inference. -- the `Leaf` implementation -- the generated wrapper that owns session stores and retry queues -- inbound packet filtering for the leaf's procedure ids -- per-packet procedure dispatch -- per-hook session dispatch -- retry-safe outbound flushing -- final-frame session removal only after routing succeeds - -The macro should generate ordinary Rust. No runtime registry, no boxed procedure -objects, and no hidden dynamic dispatch in the hot path. - -## Problem - -The current `Leaf` trait is deliberately small: +## User Shape ```rust -pub trait Leaf { - fn get_id(&self) -> u32; - - fn update(&mut self, endpoint: &mut Endpoint); -} -``` - -That makes the protocol runtime flexible, but it also means every non-trivial leaf -has to solve the same set of problems by hand: - -- select only the inbound packets that belong to this leaf -- distinguish one-shot procedures from long-running sessions -- group session packets by `hook_id` -- keep per-session application state -- build response packets with the right `hook_id`, path, procedure id, and `end_hook` -- retry failed outbound packets without losing stream progress -- remove session state only after final packets route successfully -- avoid consuming packets intended for other leaves - -A remote PTY leaf makes this pain obvious. A PTY session is bidirectional and long -lived. The same hook carries `Open`, `Input`, `Resize`, `StdinEof`, `Output`, `Exit`, -and errors. The endpoint already owns hook authorization, but the leaf still needs a -safe session state machine. - -## Goals - -- Automate the repetitive `Leaf::update` machinery. -- Keep application logic explicit and testable. -- Keep `unshell-protocol` minimal and no_std-friendly. -- Keep OS-specific PTY code outside `unshell-protocol`. -- Preserve the new endpoint hook model: downward packets pave hooks, final packets close hooks. -- Make final-frame retries hard to get wrong. -- Keep generated runtime code static and size-conscious. -- Let sessions and procedures mutate shared leaf state without directly accessing each other. -- Make multiple sessions of the same type cheap and predictable. - -## Non-Goals - -- Do not define a full actor framework. -- Do not add async requirements to the protocol layer. -- Do not make sessions discover or mutate other sessions directly. -- Do not introduce `Vec>` or `Vec>` runtime registries. -- Do not hide PTY business logic inside the macro. -- Do not add source-path fields to `Packet` just for PTY. The PTY `Open` payload can carry the reply path. - -## Current Protocol Assumptions - -The endpoint routing layer now owns hook lifetime: - -```text -validated downward packet, end_hook=false -> open or refresh peer-bound hook -validated downward packet, end_hook=true -> close hook after successful route or delivery -validated upward packet -> require matching hook -validated upward packet, end_hook=true -> close hook after successful route or delivery -``` - -For PTY, this means: - -- `Open` uses `end_hook = false` because it expects returned output. -- `Input`, `Resize`, `StdinEof`, and `Terminate` use `end_hook = false`. -- `StdinEof` is not `end_hook`. EOF closes stdin, not the whole PTY session. -- `Abort` may use `end_hook = true` if no acknowledgement is expected. -- `Output` uses `end_hook = false`. -- `Exit` or fatal `Error` uses `end_hook = true`. - -## Crate Boundary - -```text -unshell-protocol - Endpoint - Packet - Leaf - hook routing rules - filtered inbound drain API - -unshell-runtime or unshell-leaves - real PTY worker implementation - std-only integrations - portable-pty adapter - -unshell-macros - tiny proc-macro shim - -unshell-macros-core - syn, quote, proc-macro2, deluxe or darling - parser and code generator tests -``` - -The generated code runs in the final binary. Macro parsing dependencies do not. -That means `syn`, `quote`, `deluxe`, and `darling` are acceptable in the macro crates, -but not in the protocol runtime. - -## Required Endpoint Addition - -The generated leaf must be able to drain only the packets it owns. The current -`take_inbound_clear` drains a whole local queue, which is unsafe once multiple -application leaves share an endpoint. - -Add a filtered drain API: - -```rust -impl Endpoint { - /// Drains packets from `local_id` that match `predicate`, preserving all other - /// packets in their original relative order. - pub fn take_inbound_matching(&mut self, local_id: u32, predicate: P, f: F) - where - P: FnMut(&Packet) -> bool, - F: FnMut(&Packet); -} -``` - -For generated leaves, matching usually means: - -```rust -packet.procedure_id == PROC_PTY - || packet.procedure_id == PROC_PING - || packet.procedure_id == PROC_CAPABILITIES -``` - -This is the one endpoint API the macro needs before it can be safe in mixed-leaf endpoints. - -## User-Facing Macro - -Use an attribute macro that wraps a user-owned state struct and generates a leaf type. -A derive macro alone cannot add storage fields to the original struct, so the macro -must generate a companion wrapper. - -```rust -#[unshell_leaf( - leaf = RemotePtyLeaf, - id = LEAF_REMOTE_PTY, - sessions(PtySession), - procedures(PingProcedure, CapabilitiesProcedure) -)] -pub struct RemotePtyState { - max_sessions: usize, - default_rows: u16, - default_cols: u16, -} -``` - -The macro emits roughly: - -```rust -pub struct RemotePtyLeaf { - state: RemotePtyState, - pty_sessions: SessionStore, - retry: PacketQueue, +pub struct FakePtyState { + pub active_count: usize, + pub total_opened: u64, } -impl RemotePtyLeaf { - pub fn new(state: RemotePtyState) -> Self { ... } -} - -impl Leaf for RemotePtyLeaf { - fn get_id(&self) -> u32 { - LEAF_REMOTE_PTY - } - - fn update(&mut self, endpoint: &mut Endpoint) { - ... generated dispatch ... +unshell_leaf! { + pub leaf FakePtyLeaf for FakePtyState { + id: LEAF_FAKE_PTY, + meta: unshell::protocol::LeafMeta { + name: "Fake PTY Leaf", + identifier: "dev.unshell.v1.pty", + version: "v0", + authors: unshell::alloc::vec!["ASTATIN3"], + }, + sessions { + pty: PtySession, + } + procedures {} } } ``` -The leaf wrapper is the object stored in `Endpoint::leaves`. The state struct stays -small and owned by the user. +The field name before each session type is explicit. The macro does not invent a +field name from the Rust type. -## Core Model +## Generated Shape + +The example above expands to the equivalent of: + +```rust +pub struct FakePtyLeaf { + state: FakePtyState, + outbox: LeafOutbox, + pty: SessionFamily<>::State>, +} +``` + +The wrapper implements: + +- `new(state)` +- `state()` +- `state_mut()` +- `active_session_count()` +- `pending_packet_count()` +- `Leaf::get_id()` +- `Leaf::update()` +- feature-gated `Leaf::update_interface()` +- feature-gated `Leaf::get_meta()` +- feature-gated `Leaf::render_ratatui()` + +## Runtime Helpers + +The macro delegates behavior to small helpers: + +- `dispatch_session` +- `update_session_family` +- `dispatch_procedure` +- `flush_leaf_outbox` +- `flush_session_family` +- `flush_packet_queue_with_interface` + +This keeps the macro readable. The helper functions own the mechanics of session +lookup, initialization, retry-safe flushing, and optional interface logging. + +## Interface Store + +`InterfaceStore` is caller-owned. It records packet flow and timing without putting +UI state inside `Endpoint` or the leaf wrapper. ```text -Endpoint inbound queue - | - v -+-------------------------------+ -| generated RemotePtyLeaf | -| | -| state: RemotePtyState | -| sessions: SessionStore | -| retry: PacketQueue | -+-------------------------------+ - | - +--> Procedure packet -> Procedure::handle(...) - | - +--> Session packet -> by hook_id - create or update session - session queues outbound frames - generated flush handles retry +InterfaceStore + events: Vec + sessions: BTreeMap + procedures: BTreeMap ``` -Procedures and sessions can both mutate `RemotePtyState`. They cannot directly -borrow or mutate each other. Communication between them must happen through leaf -state or through packets. +Generated leaves receive an optional mutable store during `update_interface`. The +helpers create and update the appropriate session/procedure views when packets are +dispatched, sessions update, and outbound routes succeed or fail. -## Sessions - -A session is a long-running hook-backed conversation. PTY is the main example. +Time remains caller-supplied: ```rust -pub trait Session { - /// All packets for this session type use this outer procedure id. - const PROCEDURE_ID: u32; - - /// State owned for one active hook/session. - type State; - - /// Attempts to create a new session from an incoming packet. - fn init( - leaf: &mut L, - packet: Packet, - ctx: &mut SessionInit, - ) -> SessionInitResult; - - /// Advances one session. The generated leaf passes all queued packets for this - /// hook and one context that can enqueue outbound frames. - fn update( - leaf: &mut L, - session: &mut Self::State, - incoming: &mut PacketQueue, - ctx: &mut SessionCtx, - ) -> SessionStatus; -} +interface.set_now_ns(Some(now_ns)); +leaf.update_interface(endpoint, &mut interface); ``` -`L` is the user state type, for example `RemotePtyState`. +No clock is embedded in the no_std protocol layer. -### Session Init Context +## Ratatui Rendering + +Ratatui rendering is a plain feature-gated pass: ```rust -pub struct SessionInit { - hook_id: HookID, - packet_path: Vec, -} +leaf.render_ratatui(frame, area, &mut interface); +``` -pub enum SessionInitResult { - Created(S), - Rejected, - RejectedWith(Packet), +Session rendering is an associated function because session families are type-level +contracts, not stored objects: + +```rust +fn render_ratatui( + leaf: &LeafState, + session: &Self::State, + view: &mut SessionView, + frame: &mut ratatui::Frame<'_>, + area: ratatui::layout::Rect, +) { } ``` -`RejectedWith(Packet)` is intended for cases where the initializer can build a -protocol-level failure response, such as "too many PTY sessions". The generated leaf -still owns routing and retry for that packet. +Procedure rendering is also associated and renders from leaf state plus the caller +owned procedure view. -The PTY `Open` payload should include the caller reply path because `Packet` does -not currently carry source path. +## Why This Replaced The Proc Macro + +The old proc macro had to parse attributes, infer names, generate many code paths, +and duplicate runtime logic inside codegen. That made the generator harder to reason +about than the leaf behavior it was trying to simplify. + +The new design is intentionally boring: ```text -Open payload: - opcode - reply_path_len - reply_path segments - rows - cols - command/env/options +macro template -> named fields and loops +runtime helpers -> behavior +caller InterfaceStore -> UI/log state ``` -The session stores that reply path and uses it for upward output packets. - -### Session Update Context - -Sessions should use a context wrapper rather than directly constructing packets. -The context can still carry restricted endpoint access when absolutely necessary, -but the normal output path should be helper methods. - -```rust -pub struct SessionCtx<'a> { - endpoint: &'a mut Endpoint, - hook_id: HookID, - reply_path: &'a [u32], - procedure_id: u32, - outbox: &'a mut PacketQueue, -} -``` - -Helpers: - -```rust -impl<'a> SessionCtx<'a> { - pub fn send(&mut self, opcode: u8, data: &[u8]); - - pub fn send_final(&mut self, opcode: u8, data: &[u8]); - - pub fn error(&mut self, code: u8, data: &[u8]); - - pub fn error_final(&mut self, code: u8, data: &[u8]); - -} -``` - -These helpers build packets like: - -```rust -Packet { - hook_id: self.hook_id, - end_hook, - path: self.reply_path.to_vec(), - procedure_id: self.procedure_id, - data: encode_frame(opcode, payload), -} -``` - -The helper only queues packets. It does not route them immediately. The generated -leaf owns flushing and retry. - -### Session Status - -```rust -pub enum SessionStatus { - Running, - Closing, - Closed, -} -``` - -`Closed` means the session has no more application work. The generated leaf still -must keep the session until any final packet has routed successfully. - -## Procedures - -A procedure is a one-packet operation. It is appropriate for introspection, ping, -capabilities, and simple state queries. - -```rust -pub trait Procedure { - const PROCEDURE_ID: u32; - - fn handle( - leaf: &mut L, - endpoint: &mut Endpoint, - packet: Packet, - out: &mut ProcedureOut, - ); -} -``` - -Procedure helpers mirror session helpers but operate on one incoming packet: - -```rust -pub struct ProcedureOut { - hook_id: HookID, - reply_path: Vec, - procedure_id: u32, - outbox: PacketQueue, -} - -impl ProcedureOut { - pub fn send(&mut self, data: &[u8]); - - pub fn send_final(&mut self, data: &[u8]); - -} -``` - -Procedures do not directly access sessions. If a procedure needs information about -sessions, that information should be mirrored into leaf state by the session code. - -## PTY Binary Protocol - -One PTY session uses one hook and one outer procedure id. The inner PTY protocol is -a tiny binary frame in `Packet::data`. - -If API names use `end_state` at the session layer, it maps directly to -`Packet::end_hook` at the protocol layer. - -```text -Packet { - hook_id: session hook, - procedure_id: PROC_PTY, - data: [opcode, payload...], - end_hook: session lifetime marker, -} -``` - -Suggested opcodes: - -| Opcode | Direction | Meaning | end_hook | -|---:|---|---|---| -| 0 | Downward | Open PTY | false | -| 1 | Upward | Opened | false | -| 2 | Downward | Input bytes | false | -| 3 | Downward | Resize | false | -| 4 | Downward | Stdin EOF | false | -| 5 | Downward | Terminate process | false | -| 6 | Downward | Abort session without acknowledgement | true | -| 7 | Upward | Output bytes | false | -| 8 | Upward | Exit status | true | -| 9 | Upward | Fatal error | true | - -`StdinEof` must not set `end_hook = true`. The remote process may still emit output -after stdin closes. - -## Generated Update Loop - -The generated `Leaf::update` should follow this shape: - -```text -update(endpoint) - 1. flush retry queue first - 2. drain matching inbound packets only - 3. dispatch procedure packets - 4. group session packets by hook_id - 5. create sessions for open packets - 6. update active sessions - 7. flush outbound frames - 8. remove sessions whose final packet routed successfully -``` - -More concrete flow: - -```text -+----------------------------+ -| generated update | -+----------------------------+ - | - v -flush retry packets - | - v -take_inbound_matching(local_id, owns_packet) - | - +--> procedure id match -> Procedure::handle - | - +--> session id match -> session inbox by hook_id - | - v -for each session inbox - | - +--> existing hook -> Session::update - | - +--> no hook -> Session::init, then Session::update - | - v -flush session/procedure outbox - | - +--> route success -> advance/remove when safe - | - +--> route failure -> keep retry packet and keep state -``` - -The retry rule is the most important generated invariant: - -```text -If endpoint.add_outbound(packet) fails, the generated leaf must not: - - drop the packet - - advance a final frame - - remove the session - - consume state that cannot be reconstructed -``` - -## Generated Dispatch - -The macro should emit static matches, not runtime registries. - -Good: - -```rust -match packet.procedure_id { - PtySession::PROCEDURE_ID => self.dispatch_pty_session(packet), - PingProcedure::PROCEDURE_ID => PingProcedure::handle(...), - CapabilitiesProcedure::PROCEDURE_ID => CapabilitiesProcedure::handle(...), - _ => self.handle_unknown(packet), -} -``` - -Avoid: - -```rust -Vec> -Vec> -``` - -Static dispatch keeps the generated code visible to LLVM and avoids registry setup -costs in constrained binaries. - -## Session Store - -The first implementation can use a small `Vec` or `VecDeque` store. - -```rust -pub struct SessionEntry { - hook_id: HookID, - state: S, - inbox: PacketQueue, - outbox: PacketQueue, - retry: Option, - closing: bool, -} -``` - -For minimal binaries, a later implementation can replace this with a fixed-capacity -table under a feature flag. - -Required operations: - -- find by `hook_id` -- insert if capacity allows -- push incoming packet to inbox -- enqueue outbound packet -- retain session while retry packet exists -- remove only after session is closed and outbox/retry are empty - -## Unknown Packets - -The generated leaf should have configurable unknown-packet behavior. - -Default behavior: - -- unknown procedure id remains in the endpoint queue because `take_inbound_matching` does not drain it -- unknown session opcode for a known session produces a fatal error frame and closes the session -- packet for unknown hook under a session procedure produces a fatal error frame if the payload is not a valid `Open` - -For PTY: - -```text -unknown hook + Open opcode -> create session -unknown hook + non-Open opcode -> Error end_hook=true -known hook + known opcode -> session update -known hook + unknown opcode -> Error end_hook=true -``` - -## Access Boundaries - -Sessions and procedures can mutate leaf state: - -```rust -fn update(leaf: &mut RemotePtyState, ...) -``` - -They should not directly access each other: - -```text -Procedure -> no direct SessionStore access -Session -> no direct Procedure access -``` - -If cross-cutting data is needed, mirror it into `RemotePtyState`: - -```rust -pub struct RemotePtyState { - active_count: usize, - total_spawned: u64, - max_sessions: usize, -} -``` - -This keeps borrowing simple and avoids turning the generated leaf into a shared -mutable object graph. - -## Endpoint Access - -The user proposed passing `&mut Endpoint` to sessions and procedures. The safest -design is slightly narrower: - -- procedures may receive `&mut Endpoint` because they handle one packet and return immediately -- sessions should receive `SessionCtx`, which can expose narrow endpoint helpers -- a raw endpoint escape hatch can be added later if a real leaf proves it needs one - -Rationale: sessions are long-lived and retry-sensitive. If session code calls -`endpoint.add_outbound` directly, it can bypass generated retry handling and lose a -final packet. The helper path keeps the dangerous part centralized. - -## Remote PTY Example - -User code: - -```rust -#[unshell_leaf( - leaf = RemotePtyLeaf, - id = LEAF_REMOTE_PTY, - sessions(PtySession), - procedures(PtyCapabilities) -)] -pub struct RemotePtyState { - max_sessions: usize, - default_rows: u16, - default_cols: u16, -} - -pub struct PtySession; - -pub struct PtyState { - hook_id: HookID, - reply_path: Vec, - worker: PtyWorker, - saw_stdin_eof: bool, -} - -impl Session for PtySession { - const PROCEDURE_ID: u32 = PROC_PTY; - - type State = PtyState; - - fn init( - leaf: &mut RemotePtyState, - packet: Packet, - ctx: &mut SessionInit, - ) -> SessionInitResult { - let open = decode_open(&packet.data)?; - - if leaf.active_count >= leaf.max_sessions { - return SessionInitResult::RejectedWith(pty_error_busy(packet.hook_id)); - } - - let worker = PtyWorker::spawn(open.command, open.rows, open.cols)?; - - SessionInitResult::Created(PtyState { - hook_id: ctx.hook_id(), - reply_path: open.reply_path, - worker, - saw_stdin_eof: false, - }) - } - - fn update( - leaf: &mut RemotePtyState, - session: &mut Self::State, - incoming: &mut PacketQueue, - ctx: &mut SessionCtx, - ) -> SessionStatus { - while let Some(packet) = incoming.pop_front() { - match decode_pty_frame(&packet.data) { - PtyFrame::Input(bytes) => session.worker.write(&bytes), - PtyFrame::Resize { rows, cols } => session.worker.resize(rows, cols), - PtyFrame::StdinEof => { - session.saw_stdin_eof = true; - session.worker.close_stdin(); - } - PtyFrame::Terminate => session.worker.terminate(), - PtyFrame::Abort => return SessionStatus::Closed, - _ => ctx.error_final(ERR_BAD_OPCODE, b"bad pty opcode"), - } - } - - while let Some(bytes) = session.worker.poll_output() { - ctx.send(OP_OUTPUT, &bytes); - } - - if let Some(status) = session.worker.poll_exit() { - ctx.send_final(OP_EXIT, &encode_exit(status)); - leaf.active_count -= 1; - return SessionStatus::Closed; - } - - SessionStatus::Running - } -} -``` - -The exact error handling syntax above is illustrative. The final API should avoid -`?` unless `SessionInitResult` has a clear conversion story. - -## Generated Remote PTY Flow - -```text -Caller Remote endpoint RemotePtyLeaf ------- --------------- ------------- -allocate hook -Open end=false -------------> downward route opens hook ----> init PtyState -Input false -------------> hook remains active -----------> write PTY stdin -Resize false -------------> hook remains active -----------> resize PTY -StdinEof false -------------> hook remains active -----------> close PTY stdin - -Output false <------------- upward route requires hook <---- ctx.send -Output false <------------- upward route requires hook <---- ctx.send -Exit true <------------- upward route closes hook <---- ctx.send_final - -caller cleanup remove session -``` - -## Proc Macro Implementation - -Rust still requires a proc-macro crate for real attribute macros. Keep that crate -thin and put the real logic in a normal testable crate. - -```text -unshell-macros - #[proc_macro_attribute] - pub fn unshell_leaf(attr, item) -> TokenStream - -unshell-macros-core - parse UnshellLeafArgs - parse state struct - generate wrapper - generate Leaf impl - generate dispatch methods - tests over proc_macro2::TokenStream -``` - -Recommended parser helper: - -- `deluxe` for attribute parsing if the desired syntax uses richer Rust tokens -- `darling` if the syntax stays close to normal Rust meta attributes - -Current recommendation: use `deluxe` for attribute parsing and `syn`/`quote` for -code generation. - -## Generated Code Requirements - -- Must compile without requiring std in `unshell-protocol`. -- Must not allocate handler trait objects. -- Must preserve generic parameters and where clauses on the state struct. -- Must emit useful compile errors for duplicate procedure ids. -- Must emit useful compile errors for duplicate session procedure ids. -- Must reject session and procedure id collisions unless explicitly allowed. -- Must preserve user attributes that are not consumed by the macro. -- Must not require users to manually implement `Leaf` for the generated wrapper. - -## Testing Strategy - -Phase 1 tests should use a fake PTY session, not `portable-pty`. - -Protocol tests: - -- `open_pty_paves_hook_and_creates_session` -- `input_and_output_share_one_hook` -- `stdin_eof_keeps_hook_until_exit` -- `exit_end_hook_cleans_route_and_session` -- `failed_final_exit_route_retries_without_losing_session` -- `abort_downward_end_hook_closes_without_ack` -- `unknown_session_input_returns_error_end_hook` -- `two_pty_sessions_interleave_without_crossing_hooks` -- `pty_leaf_does_not_consume_other_leaf_packets` - -Macro tests: - -- generated leaf implements `Leaf` -- duplicate procedure ids fail at compile time -- duplicate session procedure ids fail at compile time -- generic state structs expand correctly -- generated code routes final frames retry-safely -- generated code preserves unmatched inbound packets - -Use `trybuild` for compile-fail macro tests once `unshell-macros` exists. - -## Rollout Plan - -1. Add `Endpoint::take_inbound_matching`. -2. Write a manual fake PTY leaf that follows the exact generated shape. -3. Add PTY session tests against the manual fake leaf. -4. Create `unshell-macros` and `unshell-macros-core`. -5. Generate the same shape as the manual fake leaf. -6. Port fake PTY tests to the generated leaf. -7. Add compile-fail macro tests. -8. Implement the real std-only PTY worker in `unshell-leaves` or `unshell-runtime`. -9. Add integration tests for the real worker where the platform supports PTYs. - -This avoids writing a macro against an unproven design. First make the generated -shape real by hand, then teach the macro to emit it. - -## Open Questions - -- Should `SessionCtx` expose any raw `Endpoint` access, or only narrow helpers? -- Should session stores be `Vec` first, or should fixed-capacity storage be designed immediately? -- Should unknown opcodes produce an error packet by default, or should each session type decide? -- Should `Open` always carry `reply_path`, or should the packet format eventually add source path? -- Should `ProcedureOut` be retry-safe like session output, or are procedures allowed to fail fast? -- Should macro-generated leaves expose counters for active sessions and retry queue depth? - -## Recommendation - -Implement this as static generated code with a narrow context API. - -Do not build a runtime plugin system. Do not give sessions raw access to session -stores. Do not let session code bypass generated output flushing. The macro should -make the correct routing and hook behavior the easiest path, especially for final -frames. - -The first proof point should be fake PTY. If fake PTY works cleanly, real PTY is -mostly OS plumbing. +That is the whole game. diff --git a/src/interface/event.rs b/src/interface/event.rs new file mode 100644 index 0000000..f69aaa3 --- /dev/null +++ b/src/interface/event.rs @@ -0,0 +1,76 @@ +use crate::protocol::{EndpointError, HookID, Packet, SessionStatus}; + +/// Ordered event stored by [`crate::interface::InterfaceStore`]. +/// +/// Events are append-only. Views store indices into this list instead of copying the +/// same packet-flow records into every renderable bucket. +pub struct InterfaceEvent { + /// Monotonic event sequence assigned by the interface store. + pub sequence: u64, + + /// Caller-provided timestamp, if the frontend supplied one. + pub time_ns: Option, + + /// Leaf id that emitted or handled the event. + pub leaf_id: u32, + + /// Detailed event payload. + pub kind: InterfaceEventKind, +} + +/// Interface-visible event emitted by generated helpers. +pub enum InterfaceEventKind { + /// A packet was delivered to a generated leaf. + Inbound { packet: Packet }, + + /// A packet was queued into an already-live session inbox. + SessionPacketQueued { procedure_id: u32, hook_id: HookID }, + + /// A hook-backed session was created successfully. + SessionCreated { + procedure_id: u32, + hook_id: HookID, + started_ns: Option, + finished_ns: Option, + }, + + /// A packet could not create a new session. + SessionRejected { + procedure_id: u32, + hook_id: HookID, + started_ns: Option, + finished_ns: Option, + }, + + /// One live session received an update tick. + SessionUpdated { + procedure_id: u32, + hook_id: HookID, + status: SessionStatus, + started_ns: Option, + finished_ns: Option, + }, + + /// One one-shot procedure handler ran. + ProcedureCalled { + procedure_id: u32, + hook_id: HookID, + started_ns: Option, + finished_ns: Option, + }, + + /// A packet was emitted by leaf logic before route retry handling. + OutboundQueued { packet: Packet }, + + /// A queued outbound packet is about to enter endpoint routing. + RouteAttempt { packet: Packet }, + + /// Endpoint routing accepted a queued outbound packet. + RouteSuccess { packet: Packet }, + + /// Endpoint routing rejected a queued outbound packet. + RouteFailure { + packet: Packet, + error: EndpointError, + }, +} diff --git a/src/interface/key.rs b/src/interface/key.rs new file mode 100644 index 0000000..4d2420c --- /dev/null +++ b/src/interface/key.rs @@ -0,0 +1,24 @@ +use crate::protocol::HookID; + +/// Stable identity for one generated session view. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct SessionKey { + /// Leaf id that owns the generated session family. + pub leaf_id: u32, + + /// Procedure id shared by every packet in the session family. + pub procedure_id: u32, + + /// Hook id for the live session instance. + pub hook_id: HookID, +} + +/// Stable identity for one generated procedure view. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct ProcedureKey { + /// Leaf id that owns the generated procedure family. + pub leaf_id: u32, + + /// Procedure id handled by this one-shot procedure family. + pub procedure_id: u32, +} diff --git a/src/interface/mod.rs b/src/interface/mod.rs index 29186a1..923e381 100644 --- a/src/interface/mod.rs +++ b/src/interface/mod.rs @@ -1,406 +1,14 @@ -use alloc::{collections::BTreeMap, vec::Vec}; +//! Caller-owned interface state for UI frontends. +//! +//! Protocol leaves stay headless. When a UI wants packet flow, timing, or render +//! state, it passes an [`InterfaceStore`] through the feature-gated interface path. -use crate::protocol::{EndpointError, HookID, Packet, SessionStatus}; +mod event; +mod key; +mod store; +mod view; -/// Caller-owned view and packet-flow store for interface frontends. -/// -/// Generated leaves receive a mutable reference to this store during interface-aware -/// updates. They decide which leaf/session/procedure keys to touch, but the storage -/// itself stays with the renderer or application shell so protocol state remains -/// headless and reusable. -pub struct InterfaceStore { - next_sequence: u64, - now_ns: Option, - events: Vec, - sessions: BTreeMap, - procedures: BTreeMap, -} - -impl InterfaceStore { - /// Creates an empty caller-owned interface store. - pub fn new() -> Self { - Self { - next_sequence: 0, - now_ns: None, - events: Vec::new(), - sessions: BTreeMap::new(), - procedures: BTreeMap::new(), - } - } - - /// Sets the timestamp attached to later events. - /// - /// The core crate stays `no_std`, so the caller supplies time from its runtime. - /// Passing `None` keeps event ordering without pretending the protocol owns a - /// clock. - pub fn set_now_ns(&mut self, now_ns: Option) { - self.now_ns = now_ns; - } - - /// Returns the timestamp that will be attached to new events. - pub fn now_ns(&self) -> Option { - self.now_ns - } - - /// Returns all recorded events in insertion order. - pub fn events(&self) -> &[InterfaceEvent] { - &self.events - } - - /// Returns all session views keyed by leaf, procedure, and hook id. - pub fn session_views(&self) -> &BTreeMap { - &self.sessions - } - - /// Returns all procedure views keyed by leaf and procedure id. - pub fn procedure_views(&self) -> &BTreeMap { - &self.procedures - } - - /// Returns or creates the view for a hook-backed session. - pub fn session_view_mut( - &mut self, - leaf_id: u32, - procedure_id: u32, - hook_id: HookID, - ) -> &mut SessionView { - self.sessions - .entry(SessionKey { - leaf_id, - procedure_id, - hook_id, - }) - .or_insert_with(SessionView::new) - } - - /// Returns or creates the view for a one-shot procedure family. - pub fn procedure_view_mut(&mut self, leaf_id: u32, procedure_id: u32) -> &mut ProcedureView { - self.procedures - .entry(ProcedureKey { - leaf_id, - procedure_id, - }) - .or_insert_with(ProcedureView::new) - } - - /// Records a packet delivered to a generated leaf. - pub fn record_inbound(&mut self, leaf_id: u32, packet: &Packet) { - let index = self.push_event( - leaf_id, - InterfaceEventKind::Inbound { - packet: packet.clone(), - }, - ); - self.link_packet_event(leaf_id, packet, index); - } - - /// Records that a packet was queued for an existing session inbox. - pub fn record_session_packet_queued( - &mut self, - leaf_id: u32, - procedure_id: u32, - hook_id: HookID, - ) { - let index = self.push_event( - leaf_id, - InterfaceEventKind::SessionPacketQueued { - procedure_id, - hook_id, - }, - ); - self.session_view_mut(leaf_id, procedure_id, hook_id) - .events - .push(index); - } - - /// Records successful creation of a new session state. - pub fn record_session_created( - &mut self, - leaf_id: u32, - procedure_id: u32, - hook_id: HookID, - started_ns: Option, - ) { - let index = self.push_event( - leaf_id, - InterfaceEventKind::SessionCreated { - procedure_id, - hook_id, - started_ns, - finished_ns: self.now_ns, - }, - ); - let view = self.session_view_mut(leaf_id, procedure_id, hook_id); - view.status = SessionViewStatus::Running; - view.events.push(index); - } - - /// Records rejection of a packet that could not create a session. - pub fn record_session_rejected( - &mut self, - leaf_id: u32, - procedure_id: u32, - hook_id: HookID, - started_ns: Option, - ) { - let index = self.push_event( - leaf_id, - InterfaceEventKind::SessionRejected { - procedure_id, - hook_id, - started_ns, - finished_ns: self.now_ns, - }, - ); - let view = self.session_view_mut(leaf_id, procedure_id, hook_id); - view.status = SessionViewStatus::Rejected; - view.events.push(index); - } - - /// Records one session update tick. - pub fn record_session_update( - &mut self, - leaf_id: u32, - procedure_id: u32, - hook_id: HookID, - status: SessionStatus, - started_ns: Option, - ) { - let index = self.push_event( - leaf_id, - InterfaceEventKind::SessionUpdated { - procedure_id, - hook_id, - status, - started_ns, - finished_ns: self.now_ns, - }, - ); - let view = self.session_view_mut(leaf_id, procedure_id, hook_id); - view.status = SessionViewStatus::from_session_status(status); - view.events.push(index); - } - - /// Records one procedure call. - pub fn record_procedure_call( - &mut self, - leaf_id: u32, - procedure_id: u32, - hook_id: HookID, - started_ns: Option, - ) { - let index = self.push_event( - leaf_id, - InterfaceEventKind::ProcedureCalled { - procedure_id, - hook_id, - started_ns, - finished_ns: self.now_ns, - }, - ); - self.procedure_view_mut(leaf_id, procedure_id) - .events - .push(index); - } - - /// Records a packet emitted by leaf logic before route retry handling. - pub fn record_outbound_queued(&mut self, leaf_id: u32, packet: &Packet) { - let index = self.push_event( - leaf_id, - InterfaceEventKind::OutboundQueued { - packet: packet.clone(), - }, - ); - self.link_packet_event(leaf_id, packet, index); - } - - /// Records a route attempt for a queued outbound packet. - pub fn record_route_attempt(&mut self, leaf_id: u32, packet: &Packet) { - let index = self.push_event( - leaf_id, - InterfaceEventKind::RouteAttempt { - packet: packet.clone(), - }, - ); - self.link_packet_event(leaf_id, packet, index); - } - - /// Records a successful route attempt. - pub fn record_route_success(&mut self, leaf_id: u32, packet: &Packet) { - let index = self.push_event( - leaf_id, - InterfaceEventKind::RouteSuccess { - packet: packet.clone(), - }, - ); - self.link_packet_event(leaf_id, packet, index); - } - - /// Records a failed route attempt without removing the packet from retry state. - pub fn record_route_failure(&mut self, leaf_id: u32, packet: &Packet, error: EndpointError) { - let index = self.push_event( - leaf_id, - InterfaceEventKind::RouteFailure { - packet: packet.clone(), - error, - }, - ); - self.link_packet_event(leaf_id, packet, index); - } - - fn push_event(&mut self, leaf_id: u32, kind: InterfaceEventKind) -> usize { - let sequence = self.next_sequence; - self.next_sequence = self.next_sequence.wrapping_add(1); - let index = self.events.len(); - - self.events.push(InterfaceEvent { - sequence, - time_ns: self.now_ns, - leaf_id, - kind, - }); - - index - } - - fn link_packet_event(&mut self, leaf_id: u32, packet: &Packet, index: usize) { - self.session_view_mut(leaf_id, packet.procedure_id, packet.hook_id) - .events - .push(index); - } -} - -impl Default for InterfaceStore { - fn default() -> Self { - Self::new() - } -} - -/// Reborrows an optional interface store for one helper call. -/// -/// Generated leaf templates pass the same optional store through several helper -/// calls in one update. This small function keeps that reborrow explicit and avoids -/// every generated call site having to spell out `Option<&mut &mut T>` plumbing. -pub fn borrow_store<'a>( - store: &'a mut Option<&mut InterfaceStore>, -) -> Option<&'a mut InterfaceStore> { - store.as_mut().map(|store| &mut **store) -} - -/// Stable identity for one generated session view. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -pub struct SessionKey { - pub leaf_id: u32, - pub procedure_id: u32, - pub hook_id: HookID, -} - -/// Stable identity for one generated procedure view. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -pub struct ProcedureKey { - pub leaf_id: u32, - pub procedure_id: u32, -} - -/// Ordered event stored by [`InterfaceStore`]. -pub struct InterfaceEvent { - pub sequence: u64, - pub time_ns: Option, - pub leaf_id: u32, - pub kind: InterfaceEventKind, -} - -/// Interface-visible event emitted by generated helpers. -pub enum InterfaceEventKind { - Inbound { - packet: Packet, - }, - SessionPacketQueued { - procedure_id: u32, - hook_id: HookID, - }, - SessionCreated { - procedure_id: u32, - hook_id: HookID, - started_ns: Option, - finished_ns: Option, - }, - SessionRejected { - procedure_id: u32, - hook_id: HookID, - started_ns: Option, - finished_ns: Option, - }, - SessionUpdated { - procedure_id: u32, - hook_id: HookID, - status: SessionStatus, - started_ns: Option, - finished_ns: Option, - }, - ProcedureCalled { - procedure_id: u32, - hook_id: HookID, - started_ns: Option, - finished_ns: Option, - }, - OutboundQueued { - packet: Packet, - }, - RouteAttempt { - packet: Packet, - }, - RouteSuccess { - packet: Packet, - }, - RouteFailure { - packet: Packet, - error: EndpointError, - }, -} - -/// Caller-owned render view for one hook-backed session. -pub struct SessionView { - pub status: SessionViewStatus, - pub events: Vec, -} - -impl SessionView { - fn new() -> Self { - Self { - status: SessionViewStatus::Pending, - events: Vec::new(), - } - } -} - -/// Caller-owned render view for one one-shot procedure family. -pub struct ProcedureView { - pub events: Vec, -} - -impl ProcedureView { - fn new() -> Self { - Self { events: Vec::new() } - } -} - -/// Interface lifecycle state for one session view. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SessionViewStatus { - Pending, - Running, - Closing, - Closed, - Rejected, -} - -impl SessionViewStatus { - fn from_session_status(status: SessionStatus) -> Self { - match status { - SessionStatus::Running => Self::Running, - SessionStatus::Closing => Self::Closing, - SessionStatus::Closed => Self::Closed, - } - } -} +pub use event::{InterfaceEvent, InterfaceEventKind}; +pub use key::{ProcedureKey, SessionKey}; +pub use store::InterfaceStore; +pub use view::{ProcedureView, SessionView, SessionViewStatus}; diff --git a/src/interface/store.rs b/src/interface/store.rs new file mode 100644 index 0000000..0318303 --- /dev/null +++ b/src/interface/store.rs @@ -0,0 +1,311 @@ +use alloc::{collections::BTreeMap, vec::Vec}; + +use crate::{ + interface::{ + InterfaceEvent, InterfaceEventKind, ProcedureKey, ProcedureView, SessionKey, SessionView, + SessionViewStatus, + }, + protocol::{EndpointError, HookID, Packet, SessionStatus}, +}; + +/// Caller-owned view and packet-flow store for interface frontends. +/// +/// Generated leaves receive a mutable reference to this store during interface-aware +/// updates. They decide which leaf/session/procedure keys to touch, but the storage +/// itself stays with the renderer or application shell so protocol state remains +/// headless and reusable. +pub struct InterfaceStore { + next_sequence: u64, + now_ns: Option, + events: Vec, + sessions: BTreeMap, + procedures: BTreeMap, +} + +impl InterfaceStore { + /// Creates an empty caller-owned interface store. + pub fn new() -> Self { + Self { + next_sequence: 0, + now_ns: None, + events: Vec::new(), + sessions: BTreeMap::new(), + procedures: BTreeMap::new(), + } + } + + /// Sets the timestamp attached to later events. + /// + /// The core crate stays `no_std`, so the caller supplies time from its runtime. + /// Passing `None` keeps event ordering without pretending the protocol owns a + /// clock. + pub fn set_now_ns(&mut self, now_ns: Option) { + self.now_ns = now_ns; + } + + /// Returns the timestamp that will be attached to new events. + pub fn now_ns(&self) -> Option { + self.now_ns + } + + /// Returns all recorded events in insertion order. + pub fn events(&self) -> &[InterfaceEvent] { + &self.events + } + + /// Returns all session views keyed by leaf, procedure, and hook id. + pub fn session_views(&self) -> &BTreeMap { + &self.sessions + } + + /// Returns all procedure views keyed by leaf and procedure id. + pub fn procedure_views(&self) -> &BTreeMap { + &self.procedures + } + + /// Returns or creates the view for a hook-backed session. + pub fn session_view_mut( + &mut self, + leaf_id: u32, + procedure_id: u32, + hook_id: HookID, + ) -> &mut SessionView { + self.sessions + .entry(SessionKey { + leaf_id, + procedure_id, + hook_id, + }) + .or_insert_with(SessionView::new) + } + + /// Returns or creates the view for a one-shot procedure family. + pub fn procedure_view_mut(&mut self, leaf_id: u32, procedure_id: u32) -> &mut ProcedureView { + self.procedures + .entry(ProcedureKey { + leaf_id, + procedure_id, + }) + .or_insert_with(ProcedureView::new) + } + + /// Records a packet delivered to a generated leaf. + pub fn record_inbound(&mut self, leaf_id: u32, packet: &Packet) { + self.push_packet_event( + leaf_id, + packet, + InterfaceEventKind::Inbound { + packet: packet.clone(), + }, + ); + } + + /// Records that a packet was queued for an existing session inbox. + pub fn record_session_packet_queued( + &mut self, + leaf_id: u32, + procedure_id: u32, + hook_id: HookID, + ) { + self.push_session_event( + leaf_id, + procedure_id, + hook_id, + None, + InterfaceEventKind::SessionPacketQueued { + procedure_id, + hook_id, + }, + ); + } + + /// Records successful creation of a new session state. + pub fn record_session_created( + &mut self, + leaf_id: u32, + procedure_id: u32, + hook_id: HookID, + started_ns: Option, + ) { + self.push_session_event( + leaf_id, + procedure_id, + hook_id, + Some(SessionViewStatus::Running), + InterfaceEventKind::SessionCreated { + procedure_id, + hook_id, + started_ns, + finished_ns: self.now_ns, + }, + ); + } + + /// Records rejection of a packet that could not create a session. + pub fn record_session_rejected( + &mut self, + leaf_id: u32, + procedure_id: u32, + hook_id: HookID, + started_ns: Option, + ) { + self.push_session_event( + leaf_id, + procedure_id, + hook_id, + Some(SessionViewStatus::Rejected), + InterfaceEventKind::SessionRejected { + procedure_id, + hook_id, + started_ns, + finished_ns: self.now_ns, + }, + ); + } + + /// Records one session update tick. + pub fn record_session_update( + &mut self, + leaf_id: u32, + procedure_id: u32, + hook_id: HookID, + status: SessionStatus, + started_ns: Option, + ) { + self.push_session_event( + leaf_id, + procedure_id, + hook_id, + Some(SessionViewStatus::from_session_status(status)), + InterfaceEventKind::SessionUpdated { + procedure_id, + hook_id, + status, + started_ns, + finished_ns: self.now_ns, + }, + ); + } + + /// Records one procedure call. + pub fn record_procedure_call( + &mut self, + leaf_id: u32, + procedure_id: u32, + hook_id: HookID, + started_ns: Option, + ) { + self.push_procedure_event( + leaf_id, + procedure_id, + InterfaceEventKind::ProcedureCalled { + procedure_id, + hook_id, + started_ns, + finished_ns: self.now_ns, + }, + ); + } + + /// Records a packet emitted by leaf logic before route retry handling. + pub fn record_outbound_queued(&mut self, leaf_id: u32, packet: &Packet) { + self.push_packet_event( + leaf_id, + packet, + InterfaceEventKind::OutboundQueued { + packet: packet.clone(), + }, + ); + } + + /// Records a route attempt for a queued outbound packet. + pub fn record_route_attempt(&mut self, leaf_id: u32, packet: &Packet) { + self.push_packet_event( + leaf_id, + packet, + InterfaceEventKind::RouteAttempt { + packet: packet.clone(), + }, + ); + } + + /// Records a successful route attempt. + pub fn record_route_success(&mut self, leaf_id: u32, packet: &Packet) { + self.push_packet_event( + leaf_id, + packet, + InterfaceEventKind::RouteSuccess { + packet: packet.clone(), + }, + ); + } + + /// Records a failed route attempt without removing the packet from retry state. + pub fn record_route_failure(&mut self, leaf_id: u32, packet: &Packet, error: EndpointError) { + self.push_packet_event( + leaf_id, + packet, + InterfaceEventKind::RouteFailure { + packet: packet.clone(), + error, + }, + ); + } + + fn push_packet_event(&mut self, leaf_id: u32, packet: &Packet, kind: InterfaceEventKind) { + let index = self.push_event(leaf_id, kind); + self.link_packet_event(leaf_id, packet, index); + } + + fn push_session_event( + &mut self, + leaf_id: u32, + procedure_id: u32, + hook_id: HookID, + status: Option, + kind: InterfaceEventKind, + ) { + let index = self.push_event(leaf_id, kind); + let view = self.session_view_mut(leaf_id, procedure_id, hook_id); + + if let Some(status) = status { + view.status = status; + } + + view.events.push(index); + } + + fn push_procedure_event(&mut self, leaf_id: u32, procedure_id: u32, kind: InterfaceEventKind) { + let index = self.push_event(leaf_id, kind); + self.procedure_view_mut(leaf_id, procedure_id) + .events + .push(index); + } + + fn push_event(&mut self, leaf_id: u32, kind: InterfaceEventKind) -> usize { + let sequence = self.next_sequence; + self.next_sequence = self.next_sequence.wrapping_add(1); + let index = self.events.len(); + + self.events.push(InterfaceEvent { + sequence, + time_ns: self.now_ns, + leaf_id, + kind, + }); + + index + } + + fn link_packet_event(&mut self, leaf_id: u32, packet: &Packet, index: usize) { + self.session_view_mut(leaf_id, packet.procedure_id, packet.hook_id) + .events + .push(index); + } +} + +impl Default for InterfaceStore { + fn default() -> Self { + Self::new() + } +} diff --git a/src/interface/view.rs b/src/interface/view.rs new file mode 100644 index 0000000..b012b87 --- /dev/null +++ b/src/interface/view.rs @@ -0,0 +1,65 @@ +use alloc::vec::Vec; + +use crate::protocol::SessionStatus; + +/// Caller-owned render view for one hook-backed session. +pub struct SessionView { + /// Latest known lifecycle status. + pub status: SessionViewStatus, + + /// Indices into the store's append-only event list. + pub events: Vec, +} + +impl SessionView { + /// Creates an empty pending view. + pub(crate) fn new() -> Self { + Self { + status: SessionViewStatus::Pending, + events: Vec::new(), + } + } +} + +/// Caller-owned render view for one one-shot procedure family. +pub struct ProcedureView { + /// Indices into the store's append-only event list. + pub events: Vec, +} + +impl ProcedureView { + /// Creates an empty procedure view. + pub(crate) fn new() -> Self { + Self { events: Vec::new() } + } +} + +/// Interface lifecycle state for one session view. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SessionViewStatus { + /// The view exists because packets referenced it, but no live state exists yet. + Pending, + + /// The session is active. + Running, + + /// The session is winding down but may still emit packets. + Closing, + + /// The session reported that application work is complete. + Closed, + + /// The leaf rejected the packet that would have created this session. + Rejected, +} + +impl SessionViewStatus { + /// Converts the protocol session status into a renderable status. + pub(crate) fn from_session_status(status: SessionStatus) -> Self { + match status { + SessionStatus::Running => Self::Running, + SessionStatus::Closing => Self::Closing, + SessionStatus::Closed => Self::Closed, + } + } +} diff --git a/src/protocol/leaf_template.rs b/src/protocol/leaf_template.rs index 15c9727..cd54fde 100644 --- a/src/protocol/leaf_template.rs +++ b/src/protocol/leaf_template.rs @@ -77,7 +77,7 @@ macro_rules! unshell_leaf { mut interface: Option<&mut $crate::interface::InterfaceStore>, ) { let leaf_id = $id; - self.__unshell_flush_all(endpoint, $crate::interface::borrow_store(&mut interface)); + self.__unshell_flush_all(endpoint, &mut interface); let Some(local_id) = endpoint.path.last().copied() else { return; @@ -94,7 +94,7 @@ macro_rules! unshell_leaf { self.__unshell_dispatch_packet( endpoint, packet, - $crate::interface::borrow_store(&mut interface), + &mut interface, ); } @@ -103,18 +103,18 @@ macro_rules! unshell_leaf { leaf_id, &mut self.state, &mut self.$session_field, - $crate::interface::borrow_store(&mut interface), + &mut interface, ); )* - self.__unshell_flush_all(endpoint, $crate::interface::borrow_store(&mut interface)); + self.__unshell_flush_all(endpoint, &mut interface); } fn __unshell_dispatch_packet( &mut self, endpoint: &mut $crate::protocol::Endpoint, packet: $crate::protocol::Packet, - mut interface: Option<&mut $crate::interface::InterfaceStore>, + interface: &mut Option<&mut $crate::interface::InterfaceStore>, ) { let leaf_id = $id; @@ -128,7 +128,7 @@ macro_rules! unshell_leaf { &mut self.$session_field, packet, &mut self.outbox, - $crate::interface::borrow_store(&mut interface), + interface, ); return; } @@ -145,7 +145,7 @@ macro_rules! unshell_leaf { endpoint, packet, &mut self.outbox, - $crate::interface::borrow_store(&mut interface), + interface, ); return; } @@ -158,7 +158,7 @@ macro_rules! unshell_leaf { fn __unshell_flush_all( &mut self, endpoint: &mut $crate::protocol::Endpoint, - mut interface: Option<&mut $crate::interface::InterfaceStore>, + interface: &mut Option<&mut $crate::interface::InterfaceStore>, ) { let leaf_id = $id; @@ -166,7 +166,7 @@ macro_rules! unshell_leaf { endpoint, leaf_id, &mut self.outbox, - $crate::interface::borrow_store(&mut interface), + interface, ); $( @@ -174,7 +174,7 @@ macro_rules! unshell_leaf { endpoint, leaf_id, &mut self.$session_field, - $crate::interface::borrow_store(&mut interface), + interface, ); )* } diff --git a/src/protocol/runtime.rs b/src/protocol/runtime.rs index 049c613..ca9cb1b 100644 --- a/src/protocol/runtime.rs +++ b/src/protocol/runtime.rs @@ -61,14 +61,14 @@ pub fn dispatch_session( family: &mut SessionFamily, packet: Packet, outbox: &mut LeafOutbox, - mut interface: Option<&mut InterfaceStore>, + interface: &mut Option<&mut InterfaceStore>, ) where S: Session, { let hook_id = packet.hook_id; let procedure_id = S::PROCEDURE_ID; - if let Some(store) = crate::interface::borrow_store(&mut interface) { + if let Some(store) = interface.as_mut() { store.record_inbound(leaf_id, &packet); } @@ -79,7 +79,7 @@ pub fn dispatch_session( { entry.inbox.push_back(packet); - if let Some(store) = interface { + if let Some(store) = interface.as_mut() { store.record_session_packet_queued(leaf_id, procedure_id, hook_id); } @@ -94,17 +94,17 @@ pub fn dispatch_session( SessionInitResult::Created(state) => { family.entries.push(SessionEntry::new(hook_id, state)); - if let Some(store) = interface { + if let Some(store) = interface.as_mut() { store.record_session_created(leaf_id, procedure_id, hook_id, started_ns); } } SessionInitResult::Rejected => { - if let Some(store) = interface { + if let Some(store) = interface.as_mut() { store.record_session_rejected(leaf_id, procedure_id, hook_id, started_ns); } } SessionInitResult::RejectedWith(packet) => { - if let Some(store) = interface { + if let Some(store) = interface.as_mut() { store.record_session_rejected(leaf_id, procedure_id, hook_id, started_ns); store.record_outbound_queued(leaf_id, &packet); } @@ -119,7 +119,7 @@ pub fn update_session_family( leaf_id: u32, leaf: &mut L, family: &mut SessionFamily, - mut interface: Option<&mut InterfaceStore>, + interface: &mut Option<&mut InterfaceStore>, ) where S: Session, { @@ -138,7 +138,7 @@ pub fn update_session_family( ); let status = S::update(leaf, &mut entry.state, &mut entry.inbox, &mut ctx); - if let Some(store) = crate::interface::borrow_store(&mut interface) { + if let Some(store) = interface.as_mut() { store.record_session_update( leaf_id, S::PROCEDURE_ID, @@ -161,13 +161,13 @@ pub fn dispatch_procedure( endpoint: &mut Endpoint, packet: Packet, outbox: &mut LeafOutbox, - mut interface: Option<&mut InterfaceStore>, + interface: &mut Option<&mut InterfaceStore>, ) where P: Procedure, { let started_ns = interface.as_ref().and_then(|store| store.now_ns()); - if let Some(store) = crate::interface::borrow_store(&mut interface) { + if let Some(store) = interface.as_mut() { store.record_inbound(leaf_id, &packet); } @@ -179,7 +179,7 @@ pub fn dispatch_procedure( let packets = procedure_out.into_packets(); - if let Some(store) = interface { + if let Some(store) = interface.as_mut() { store.record_procedure_call(leaf_id, P::PROCEDURE_ID, hook_id, started_ns); for packet in &packets { @@ -195,7 +195,7 @@ pub fn flush_leaf_outbox( endpoint: &mut Endpoint, leaf_id: u32, outbox: &mut LeafOutbox, - interface: Option<&mut InterfaceStore>, + interface: &mut Option<&mut InterfaceStore>, ) -> bool { flush_packet_queue_with_interface(endpoint, leaf_id, &mut outbox.packets, interface) } @@ -205,17 +205,12 @@ pub fn flush_session_family( endpoint: &mut Endpoint, leaf_id: u32, family: &mut SessionFamily, - mut interface: Option<&mut InterfaceStore>, + interface: &mut Option<&mut InterfaceStore>, ) where S: Session, { for entry in &mut family.entries { - flush_packet_queue_with_interface( - endpoint, - leaf_id, - &mut entry.outbox, - crate::interface::borrow_store(&mut interface), - ); + flush_packet_queue_with_interface(endpoint, leaf_id, &mut entry.outbox, interface); } family @@ -232,23 +227,23 @@ pub fn flush_packet_queue_with_interface( endpoint: &mut Endpoint, leaf_id: u32, outbox: &mut PacketQueue, - mut interface: Option<&mut InterfaceStore>, + interface: &mut Option<&mut InterfaceStore>, ) -> bool { while let Some(packet) = outbox.front().cloned() { - if let Some(store) = crate::interface::borrow_store(&mut interface) { + if let Some(store) = interface.as_mut() { store.record_route_attempt(leaf_id, &packet); } match endpoint.add_outbound(packet.clone()) { Ok(()) => { - if let Some(store) = crate::interface::borrow_store(&mut interface) { + if let Some(store) = interface.as_mut() { store.record_route_success(leaf_id, &packet); } outbox.pop_front(); } Err(error) => { - if let Some(store) = interface { + if let Some(store) = interface.as_mut() { store.record_route_failure(leaf_id, &packet, error); } From 0b11f8609e2027c17e3cb3e9553724c4978088db Mon Sep 17 00:00:00 2001 From: Michael Mikovsky <77305074+Astatin3@users.noreply.github.com> Date: Sun, 31 May 2026 12:23:59 -0600 Subject: [PATCH 28/31] Test interface final-route retry logging. --- unshell-leaves/leaf-pty/src/tests.rs | 70 +++++++++++++++++++++++++++- 1 file changed, 69 insertions(+), 1 deletion(-) diff --git a/unshell-leaves/leaf-pty/src/tests.rs b/unshell-leaves/leaf-pty/src/tests.rs index 5bf5efc..8e5d62a 100644 --- a/unshell-leaves/leaf-pty/src/tests.rs +++ b/unshell-leaves/leaf-pty/src/tests.rs @@ -3,7 +3,7 @@ use alloc::{vec, vec::Vec}; use unshell::protocol::{Endpoint, Leaf, Packet}; #[cfg(feature = "interface")] -use unshell::interface::{InterfaceEventKind, InterfaceStore}; +use unshell::interface::{InterfaceEventKind, InterfaceStore, SessionKey, SessionViewStatus}; use super::{ FakePtyLeaf, FakePtyState, OP_ABORT, OP_ERROR, OP_EXIT, OP_INPUT, OP_OPENED, OP_OUTPUT, @@ -430,3 +430,71 @@ fn interface_update_records_session_flow() { ) })); } + +#[cfg(feature = "interface")] +#[test] +fn interface_update_records_failed_final_route_without_dropping_session() { + let (mut endpoint_a, mut endpoint_b) = pty_endpoints(); + let mut leaf = FakePtyLeaf::new(FakePtyState::new()); + let mut interface = InterfaceStore::new(); + let hook_id = endpoint_a.get_hook_id(); + + endpoint_a + .add_outbound(pty_open_packet( + vec![ENDPOINT_A, ENDPOINT_B], + hook_id, + &[ENDPOINT_A], + )) + .unwrap(); + transfer_packets(&mut endpoint_a, &mut endpoint_b, ENDPOINT_B, ENDPOINT_A); + leaf.update_interface(&mut endpoint_b, &mut interface); + transfer_packets(&mut endpoint_b, &mut endpoint_a, ENDPOINT_A, ENDPOINT_B); + drain_parent_pty_packets(&mut endpoint_a); + + send_downward_frame( + &mut endpoint_a, + &mut endpoint_b, + hook_id, + OP_TERMINATE, + &[], + false, + ); + endpoint_b.connections.remove(&(ENDPOINT_A, true)); + leaf.update_interface(&mut endpoint_b, &mut interface); + + let session_key = SessionKey { + leaf_id: leaf.get_id(), + procedure_id: PROC_PTY, + hook_id, + }; + + assert_eq!(leaf.active_session_count(), 1); + assert_eq!(leaf.pending_packet_count(), 1); + assert_eq!( + interface.session_views().get(&session_key).unwrap().status, + SessionViewStatus::Closed + ); + assert!(interface.events().iter().any(|event| { + matches!( + &event.kind, + InterfaceEventKind::RouteFailure { packet, .. } + if packet.hook_id == hook_id && frame_opcode(packet) == Some(OP_EXIT) + ) + })); + + endpoint_b.connections.insert((ENDPOINT_A, true)); + leaf.update_interface(&mut endpoint_b, &mut interface); + transfer_packets(&mut endpoint_b, &mut endpoint_a, ENDPOINT_A, ENDPOINT_B); + let packets = drain_parent_pty_packets(&mut endpoint_a); + + assert_eq!(leaf.active_session_count(), 0); + assert_eq!(packets.len(), 1); + assert_frame(&packets[0], hook_id, OP_EXIT, true, &[0]); + assert!(interface.events().iter().any(|event| { + matches!( + &event.kind, + InterfaceEventKind::RouteSuccess { packet } + if packet.hook_id == hook_id && frame_opcode(packet) == Some(OP_EXIT) + ) + })); +} From b2e25238603c552be0fb9e275cd8659d84d9c2bb Mon Sep 17 00:00:00 2001 From: Michael Mikovsky <77305074+Astatin3@users.noreply.github.com> Date: Sun, 31 May 2026 13:22:02 -0600 Subject: [PATCH 29/31] Add sha256 hash and ordering. --- examples/hashtest.rs | 6 +- src/crypto/hash.rs | 137 +++++++++++++++++++++++ src/crypto/mod.rs | 54 +++++++++ src/crypto/ordering.rs | 35 ++++++ src/hash.rs | 57 ---------- src/lib.rs | 6 +- unshell-leaves/leaf-pty/src/constants.rs | 6 +- 7 files changed, 234 insertions(+), 67 deletions(-) create mode 100644 src/crypto/hash.rs create mode 100644 src/crypto/mod.rs create mode 100644 src/crypto/ordering.rs delete mode 100644 src/hash.rs diff --git a/examples/hashtest.rs b/examples/hashtest.rs index 8520c21..25327d3 100644 --- a/examples/hashtest.rs +++ b/examples/hashtest.rs @@ -1,8 +1,6 @@ -use unshell::hash; - macro_rules! hashtest { ($input:tt) => { - ($input, hash($input)) + ($input, unshell::hash_32!($input)) }; } @@ -17,6 +15,6 @@ const MAP: [(&str, u32); 6] = [ pub fn main() { for (a, b) in MAP { - println!("unshell::hash(\"{}\") = {}", a, b) + println!("unshell::hash_32!(\"{}\") = {}", a, b) } } diff --git a/src/crypto/hash.rs b/src/crypto/hash.rs new file mode 100644 index 0000000..91562d0 --- /dev/null +++ b/src/crypto/hash.rs @@ -0,0 +1,137 @@ +// ── Round constants ────────────────────────────────────────────────────────── +const K: [u32; 64] = [ + 0x428A2F98, 0x71374491, 0xB5C0FBCF, 0xE9B5DBA5, 0x3956C25B, 0x59F111F1, 0x923F82A4, 0xAB1C5ED5, + 0xD807AA98, 0x12835B01, 0x243185BE, 0x550C7DC3, 0x72BE5D74, 0x80DEB1FE, 0x9BDC06A7, 0xC19BF174, + 0xE49B69C1, 0xEFBE4786, 0x0FC19DC6, 0x240CA1CC, 0x2DE92C6F, 0x4A7484AA, 0x5CB0A9DC, 0x76F988DA, + 0x983E5152, 0xA831C66D, 0xB00327C8, 0xBF597FC7, 0xC6E00BF3, 0xD5A79147, 0x06CA6351, 0x14292967, + 0x27B70A85, 0x2E1B2138, 0x4D2C6DFC, 0x53380D13, 0x650A7354, 0x766A0ABB, 0x81C2C92E, 0x92722C85, + 0xA2BFE8A1, 0xA81A664B, 0xC24B8B70, 0xC76C51A3, 0xD192E819, 0xD6990624, 0xF40E3585, 0x106AA070, + 0x19A4C116, 0x1E376C08, 0x2748774C, 0x34B0BCB5, 0x391C0CB3, 0x4ED8AA4A, 0x5B9CCA4F, 0x682E6FF3, + 0x748F82EE, 0x78A5636F, 0x84C87814, 0x8CC70208, 0x90BEFFFA, 0xA4506CEB, 0xBEF9A3F7, 0xC67178F2, +]; + +// ── Initial hash values ────────────────────────────────────────────────────── +const H: [u32; 8] = [ + 0x6A09E667, 0xBB67AE85, 0x3C6EF372, 0xA54FF53A, 0x510E527F, 0x9B05688C, 0x1F83D9AB, 0x5BE0CD19, +]; +// ── Internals ──────────────────────────────────────────────────────────────── + +/// Returns what byte `pos` should hold in the padded SHA-256 message, +/// without ever materialising the full padded buffer. +const fn padded_byte(input: &[u8], pos: usize, padded_len: usize) -> u8 { + let bit_len = (input.len() as u64) * 8; + if pos < input.len() { + input[pos] + } else if pos == input.len() { + 0x80 + } else if pos >= padded_len - 8 { + // Big-endian 64-bit length: byte 0 is the most significant. + let byte_index = pos - (padded_len - 8); + (bit_len >> (56 - byte_index * 8)) as u8 + } else { + 0x00 + } +} + +/// SHA-256 compression: mixes one 64-byte block into the hash state. +const fn compress(state: &mut [u32; 8], block: &[u8; 64]) { + // Build the 64-word message schedule from the 16-word block. + let mut w = [0u32; 64]; + let mut i = 0; + while i < 16 { + w[i] = ((block[i * 4] as u32) << 24) + | ((block[i * 4 + 1] as u32) << 16) + | ((block[i * 4 + 2] as u32) << 8) + | (block[i * 4 + 3] as u32); + i += 1; + } + while i < 64 { + let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3); + let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10); + w[i] = w[i - 16] + .wrapping_add(s0) + .wrapping_add(w[i - 7]) + .wrapping_add(s1); + i += 1; + } + + // Initialise working variables from current hash state. + let mut a = state[0]; + let mut b = state[1]; + let mut c = state[2]; + let mut d = state[3]; + let mut e = state[4]; + let mut f = state[5]; + let mut g = state[6]; + let mut h = state[7]; + + // 64 rounds. + i = 0; + while i < 64 { + let sigma1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25); + let ch = (e & f) ^ ((!e) & g); + let temp1 = h + .wrapping_add(sigma1) + .wrapping_add(ch) + .wrapping_add(K[i]) + .wrapping_add(w[i]); + + let sigma0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22); + let maj = (a & b) ^ (a & c) ^ (b & c); + let temp2 = sigma0.wrapping_add(maj); + + h = g; + g = f; + f = e; + e = d.wrapping_add(temp1); + d = c; + c = b; + b = a; + a = temp1.wrapping_add(temp2); + i += 1; + } + + // Add the compressed chunk back into the hash state. + state[0] = state[0].wrapping_add(a); + state[1] = state[1].wrapping_add(b); + state[2] = state[2].wrapping_add(c); + state[3] = state[3].wrapping_add(d); + state[4] = state[4].wrapping_add(e); + state[5] = state[5].wrapping_add(f); + state[6] = state[6].wrapping_add(g); + state[7] = state[7].wrapping_add(h); +} + +// ── Public API ─────────────────────────────────────────────────────────────── + +/// Returns the SHA-256 digest of `input` as 32 raw bytes. +pub const fn sha256(input: &[u8]) -> [u8; 32] { + // Padded length is the next multiple of 64 that fits input + 1 (0x80) + 8 (length). + let padded_len = ((input.len() + 9 + 63) / 64) * 64; + let mut state = H; + let mut block_start = 0; + + while block_start < padded_len { + // Assemble the current 64-byte block using the virtual padded view. + let mut block = [0u8; 64]; + let mut j = 0; + while j < 64 { + block[j] = padded_byte(input, block_start + j, padded_len); + j += 1; + } + compress(&mut state, &block); + block_start += 64; + } + + // Serialise the 8×u32 state as big-endian bytes. + let mut out = [0u8; 32]; + let mut i = 0; + while i < 8 { + out[i * 4] = (state[i] >> 24) as u8; + out[i * 4 + 1] = (state[i] >> 16) as u8; + out[i * 4 + 2] = (state[i] >> 8) as u8; + out[i * 4 + 3] = state[i] as u8; + i += 1; + } + out +} diff --git a/src/crypto/mod.rs b/src/crypto/mod.rs new file mode 100644 index 0000000..ebb81c4 --- /dev/null +++ b/src/crypto/mod.rs @@ -0,0 +1,54 @@ +use alloc::string::String; + +mod hash; +mod ordering; + +pub use hash::sha256; +pub use ordering::feistel_shuffle; + +#[macro_export] +macro_rules! hash_256 { + ($s:literal) => {{ + // string literal arm + const HASH: [u8; 32] = $crate::crypto::sha256($s.as_bytes()); + HASH + }}; + ($n:expr) => {{ + // integer/expression arm + const BYTES: [u8; 8] = ($n as u64).to_be_bytes(); + const HASH: [u8; 32] = $crate::crypto::sha256(&BYTES); + HASH + }}; +} + +#[macro_export] +macro_rules! hash_32 { + ($s:literal) => {{ + // string literal arm + const HASH: [u8; 32] = $crate::crypto::sha256($s.as_bytes()); + const RESULT: u32 = u32::from_be_bytes([HASH[0], HASH[8], HASH[16], HASH[24]]); + RESULT + }}; + ($n:expr) => {{ + // integer/expression arm + const BYTES: [u8; 8] = ($n as u64).to_be_bytes(); + const HASH: [u8; 32] = $crate::crypto::sha256(&BYTES); + const RESULT: u32 = u32::from_be_bytes([HASH[0], HASH[8], HASH[16], HASH[24]]); + RESULT + }}; +} + +pub fn hash_string_32(input: String) -> u32 { + let hash: [u8; 32] = sha256(input.as_bytes()); + u32::from_be_bytes([hash[0], hash[8], hash[16], hash[24]]) +} + +pub fn hash_str_32(input: &str) -> u32 { + let hash: [u8; 32] = sha256(input.as_bytes()); + u32::from_be_bytes([hash[0], hash[8], hash[16], hash[24]]) +} + +pub fn hash_32(input: u32) -> u32 { + let hash: [u8; 32] = sha256(&input.to_be_bytes()); + u32::from_be_bytes([hash[0], hash[8], hash[16], hash[24]]) +} diff --git a/src/crypto/ordering.rs b/src/crypto/ordering.rs new file mode 100644 index 0000000..28c3b74 --- /dev/null +++ b/src/crypto/ordering.rs @@ -0,0 +1,35 @@ +/// Performs a deterministic pseudo-random shuffle of a 16-bit index. +/// +/// # Arguments +/// * `index` - The input value (0..65536). +/// * `seed` - The 32-bit seed acting as the encryption key. +/// +/// # Returns +/// A unique 16-bit shuffled value. +pub fn feistel_shuffle(index: u16, seed: u32) -> u16 { + // Split 16-bit index into two 8-bit halves + let mut l = ((index >> 8) & 0xFF) as u8; + let mut r = (index & 0xFF) as u8; + + // Perform 4 rounds of Feistel mixing + for round in 0..4 { + // Derive sub-key: Rotate seed and add golden ratio constant + let rot_amount = (round * 5) % 32; + let sub_key = seed + .rotate_left(rot_amount) + .wrapping_add(round.wrapping_mul(0x9E3779B9)); + + // Round function F: Simple multiplicative hash mixing R and sub_key + // We cast to u32 for multiplication to avoid overflow, then mask back to 8 bits + let r_u32 = r as u32; + let hash_val = ((r_u32.wrapping_mul(sub_key)) ^ (r_u32 >> 4)) as u8 & 0xFF; + + // Feistel step: New L = Old R, New R = Old L XOR F(R, key) + let temp = l; + l = r; + r = temp ^ hash_val; + } + + // Recombine halves + ((l as u16) << 8) | (r as u16) +} diff --git a/src/hash.rs b/src/hash.rs deleted file mode 100644 index a7b6d85..0000000 --- a/src/hash.rs +++ /dev/null @@ -1,57 +0,0 @@ -//! Temporary hash function - -const fn hash_recursive(state: &mut [u8; 4], input: &[u8]) { - match input.len() { - 3 => { - state[0] ^= input[0]; - state[1] ^= input[1]; - state[2] ^= input[2]; - } - 2 => { - state[0] ^= input[0]; - state[1] ^= input[1]; - } - 1 => { - state[0] ^= input[0]; - } - 0 => {} - _ => { - state[0] ^= input[0]; - state[1] ^= input[1]; - state[2] ^= input[2]; - state[3] ^= input[3]; - - // Mess with the state quite a bit - state[0] = u8::reverse_bits(state[0]) ^ state[2]; - state[2] = state[0].wrapping_add(state[2]).wrapping_add(state[3]) ^ state[0]; - state[3] = state[2].wrapping_add(state[3] << 2) ^ state[1]; - state[1] = state[3] ^ 0xa3; - - hash_recursive(state, &input[1..]); - } - } -} - -pub const fn hash(input: &'static str) -> u32 { - let mut data = [0xDE, 0xED, 0xBE, 0xEF]; - hash_recursive(&mut data, input.as_bytes()); - - // throw the data back into itself because why not - let input2 = [ - u8::reverse_bits(data[1]), - data[2], - data[2], - data[1], - u8::reverse_bits(data[0]), - data[2], - u8::reverse_bits(data[3]), - u8::reverse_bits(data[2]), - data[3], - u8::reverse_bits(data[3]), - u8::reverse_bits(data[2]), - data[0], - ]; - hash_recursive(&mut data, &input2); - - u32::from_be_bytes(data) -} diff --git a/src/lib.rs b/src/lib.rs index 0d4f202..42bc584 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,14 +11,12 @@ //! The library requires `alloc` for path and payload management. #![no_std] -#![feature(const_index)] -#![feature(const_trait_impl)] pub extern crate alloc; -mod hash; +pub mod crypto; pub mod interface; pub mod logger; pub mod protocol; -pub use hash::hash; +// pub use hash::hash; diff --git a/unshell-leaves/leaf-pty/src/constants.rs b/unshell-leaves/leaf-pty/src/constants.rs index 4341c4d..cfce7fd 100644 --- a/unshell-leaves/leaf-pty/src/constants.rs +++ b/unshell-leaves/leaf-pty/src/constants.rs @@ -1,8 +1,10 @@ +use unshell::hash_32; + /// Leaf id used by the generated fake PTY wrapper. -pub const LEAF_FAKE_PTY: u32 = unshell::hash("dev.unshell.v1.pty"); +pub const LEAF_FAKE_PTY: u32 = hash_32!("dev.unshell.v1.pty"); /// Outer procedure id used by all fake PTY session packets. -pub const PROC_PTY: u32 = unshell::hash("dev.unshell.v1.pty.pty"); +pub const PROC_PTY: u32 = hash_32!("dev.unshell.v1.pty.pty"); /// Downward opcode that opens one PTY session. pub const OP_OPEN: u8 = 0; From 966f16008b9346ee20518d3e5f84ac74803438fc Mon Sep 17 00:00:00 2001 From: Michael Mikovsky <77305074+Astatin3@users.noreply.github.com> Date: Sun, 31 May 2026 14:47:25 -0600 Subject: [PATCH 30/31] Add more state objects. --- Cargo.lock | 47 ++++++++++++++++++ Cargo.toml | 21 ++++++-- examples/{hashtest.rs => hash_test.rs} | 0 src/crypto/{ordering.rs => feistel.rs} | 0 src/crypto/feistel_state.rs | 67 ++++++++++++++++++++++++++ src/crypto/mod.rs | 25 ++++++++-- src/crypto/{hash.rs => sha256.rs} | 0 src/crypto/tests.rs | 40 +++++++++++++++ src/protocol/endpoint/hooks.rs | 5 +- src/protocol/endpoint/mod.rs | 10 ++-- 10 files changed, 200 insertions(+), 15 deletions(-) rename examples/{hashtest.rs => hash_test.rs} (100%) rename src/crypto/{ordering.rs => feistel.rs} (100%) create mode 100644 src/crypto/feistel_state.rs rename src/crypto/{hash.rs => sha256.rs} (100%) create mode 100644 src/crypto/tests.rs diff --git a/Cargo.lock b/Cargo.lock index b96cd60..cb7df14 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -269,6 +269,26 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + [[package]] name = "convert_case" version = "0.10.0" @@ -350,6 +370,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -578,6 +604,17 @@ dependencies = [ "version_check", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -1757,6 +1794,15 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tinyvec" version = "1.11.0" @@ -1824,6 +1870,7 @@ name = "unshell" version = "0.1.0" dependencies = [ "chrono", + "const-random", "crossbeam-channel", "ratatui", "rkyv", diff --git a/Cargo.toml b/Cargo.toml index ae45e4f..ef2c4bf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,12 +18,14 @@ repository = "https://github.com/Astatin3/unshell" include = ["LICENSE", "**/*.rs", "Cargo.toml"] [workspace.dependencies] -rkyv = "0.8.16" -thiserror = "2.0.18" -chrono = "0.4.44" -static_init = "1.0.4" +rkyv = "0.8.16" +thiserror = "2.0.18" +chrono = "0.4.44" +static_init = "1.0.4" portable-pty = "0.9.0" crossbeam-channel = "0.5.15" +const-random = "0.1.18" + ratatui = "0.30.0" @@ -44,7 +46,7 @@ edition.workspace = true description = "Pure no_std implementation of the UnShell Protocol" [features] -# default = ["interface_ratatui"] +default = ["counter_shuffle_feistel_lcg"] log = [] log_debug = ["log", "dep:chrono"] @@ -52,17 +54,26 @@ log_debug = ["log", "dep:chrono"] interface = [] interface_ratatui = ["interface", "dep:ratatui"] +counter_shuffle_none = [] +counter_shuffle_feistel = [] +counter_shuffle_feistel_lcg = [] + [dependencies] rkyv = { workspace = true } thiserror = { workspace = true, optional = true } chrono = { workspace = true, optional = true } static_init = { workspace = true } +const-random = { workspace = true } + ratatui = { workspace = true, optional = true } [dev-dependencies] crossbeam-channel.workspace = true + +[build-dependencies] + [profile.minimize] inherits = "release" strip = true # Strip symbols from the binary diff --git a/examples/hashtest.rs b/examples/hash_test.rs similarity index 100% rename from examples/hashtest.rs rename to examples/hash_test.rs diff --git a/src/crypto/ordering.rs b/src/crypto/feistel.rs similarity index 100% rename from src/crypto/ordering.rs rename to src/crypto/feistel.rs diff --git a/src/crypto/feistel_state.rs b/src/crypto/feistel_state.rs new file mode 100644 index 0000000..d554bd3 --- /dev/null +++ b/src/crypto/feistel_state.rs @@ -0,0 +1,67 @@ +use crate::crypto::feistel_shuffle; + +#[cfg(feature = "counter_shuffle_none")] +pub type Counter = NoShuffle; +#[cfg(feature = "counter_shuffle_feistel")] +pub type Counter = FeistelShuffle; +#[cfg(feature = "counter_shuffle_feistel_lcg")] +pub type Counter = FeistelLCGShuffle; + +const NONCE16_1: u16 = const_random::const_random!(u16); +const NONCE16_2: u16 = const_random::const_random!(u16); +const NONCE32: u32 = const_random::const_random!(u32); + +pub struct NoShuffle(u16); + +/// Linear shuffle, no randomization, just a random starting point and step size +impl NoShuffle { + pub fn new() -> Self { + Self(NONCE16_1) + } + + pub fn next(&mut self) -> u16 { + self.0 = self.0.wrapping_add(1); + self.0 + } +} + +/// Shuffle all 16 bit numbers, an actual shuffle +/// But this still stores local values in a linear format +pub struct FeistelShuffle(u16, u32); + +impl FeistelShuffle { + pub fn new() -> Self { + Self(NONCE16_1, NONCE32) + } + + pub fn next(&mut self) -> u16 { + self.0 = self.0.wrapping_add(NONCE16_2); + feistel_shuffle(self.0, self.1) + } +} + +/// Linear recursive shuffle, +/// feeds back into itself and doesn't store the actual state. +/// Harder to decompile +pub struct FeistelLCGShuffle { + state: u16, + a: u16, // Multiplier (must be 1 mod 4) + c: u16, // Increment (must be odd) +} + +impl FeistelLCGShuffle { + pub fn new() -> Self { + let seed = NONCE32; + let a = (((seed & 0x3FFF) as u16) << 2) | 1; + let c = ((seed >> 16) as u16) | 1; + Self { state: 0, a, c } + } + + pub fn next(&mut self) -> u16 { + // 1. Advance state using LCG (Guarantees single cycle of 65536) + self.state = self.state.wrapping_mul(self.a).wrapping_add(self.c); + + // 2. Apply Feistel shuffle to the state (Adds randomness) + feistel_shuffle(self.state, self.a as u32) + } +} diff --git a/src/crypto/mod.rs b/src/crypto/mod.rs index ebb81c4..beb35ca 100644 --- a/src/crypto/mod.rs +++ b/src/crypto/mod.rs @@ -1,10 +1,27 @@ use alloc::string::String; -mod hash; -mod ordering; +// TODO: Make this seed dependent on env var; +pub const GLOBAL_SEED: u32 = 0xDEAFBEEF; +// pub const GLOBAL_NONCE: u32 = { +// let time = match u128::from_str_radix(env!("BUILD_TIME"), 10) { +// Ok(i) => i, +// Err(_) => panic!("Failed to parse BUILD_TIME"), +// }; -pub use hash::sha256; -pub use ordering::feistel_shuffle; +// GLOBAL_SEED ^ (time as u32) +// }; + +mod feistel; +#[allow(dead_code)] +mod feistel_state; +mod sha256; + +pub use feistel::feistel_shuffle; +pub use feistel_state::{Counter, FeistelLCGShuffle, FeistelShuffle, NoShuffle}; +pub use sha256::sha256; + +#[cfg(test)] +mod tests; #[macro_export] macro_rules! hash_256 { diff --git a/src/crypto/hash.rs b/src/crypto/sha256.rs similarity index 100% rename from src/crypto/hash.rs rename to src/crypto/sha256.rs diff --git a/src/crypto/tests.rs b/src/crypto/tests.rs new file mode 100644 index 0000000..0a954a9 --- /dev/null +++ b/src/crypto/tests.rs @@ -0,0 +1,40 @@ +use crate::crypto::{FeistelLCGShuffle, FeistelShuffle, NoShuffle}; + +#[test] +fn test_linear_shuffle() { + let mut seen = [false; 65536]; + let mut counter = NoShuffle::new(); + for _ in 0..65535 { + let val = counter.next(); + + assert!(!seen[val as usize], "Collision detected"); + + seen[val as usize] = true; + } +} + +#[test] +fn test_feistel_shuffle() { + let mut seen = [false; 65536]; + let mut counter = FeistelShuffle::new(); + for _ in 0..65535 { + let val = counter.next(); + + assert!(!seen[val as usize], "Collision detected"); + + seen[val as usize] = true; + } +} + +#[test] +fn test_fristel_lcg_shuffle() { + let mut seen = [false; 65536]; + let mut counter = FeistelLCGShuffle::new(); + for _ in 0..65535 { + let val = counter.next(); + + assert!(!seen[val as usize], "Collision detected"); + + seen[val as usize] = true; + } +} diff --git a/src/protocol/endpoint/hooks.rs b/src/protocol/endpoint/hooks.rs index 6e14043..0b4f977 100644 --- a/src/protocol/endpoint/hooks.rs +++ b/src/protocol/endpoint/hooks.rs @@ -16,10 +16,11 @@ impl Endpoint { /// reuse an id before the previous route has closed. If every `u16` id is active /// the function panics; that is a hard local resource exhaustion condition, not a /// recoverable packet error. + /// + /// TODO: Reevaluate this method of allocation checking. It can be quite slow pub fn allocate_hook_id(&mut self) -> HookID { for _ in 0..=HookID::MAX { - let candidate = self.last_hook; - self.last_hook = self.last_hook.wrapping_add(1); + let candidate = self.last_hook.next(); if !self.hooks.contains_key(&candidate) { return candidate; diff --git a/src/protocol/endpoint/mod.rs b/src/protocol/endpoint/mod.rs index 495c985..10ac013 100644 --- a/src/protocol/endpoint/mod.rs +++ b/src/protocol/endpoint/mod.rs @@ -5,15 +5,17 @@ pub use hooks::HookID; use alloc::{boxed::Box, vec::Vec}; -use crate::protocol::{ConnectionSet, HookMap, Leaf, Packet, Path, RouteMap}; +use crate::{ + crypto::Counter, + protocol::{ConnectionSet, HookMap, Leaf, Packet, Path, RouteMap}, +}; pub struct Endpoint { // This endpoint's identifier pub id: u32, // A counter that creates unique hook IDs. - // TODO: Randomize the hooks for more obfuscation - pub(crate) last_hook: u16, + pub(crate) last_hook: Counter, // Absolute path for this node. Must be set by some leaf pub path: Path, @@ -36,7 +38,7 @@ impl Endpoint { Self { id, // Init the hook at 0, which will increment - last_hook: 0, + last_hook: Counter::new(), // Set the current path as an empty vec path: Vec::new(), From 08adf12361b0560f61e70c46acd246292abc9b5e Mon Sep 17 00:00:00 2001 From: Michael Mikovsky <77305074+Astatin3@users.noreply.github.com> Date: Mon, 1 Jun 2026 09:21:10 -0600 Subject: [PATCH 31/31] Add fix for tests. --- src/crypto/feistel_state.rs | 10 ++- src/protocol/tests/oneshot/streams.rs | 103 +++++++++++++++++++------- 2 files changed, 87 insertions(+), 26 deletions(-) diff --git a/src/crypto/feistel_state.rs b/src/crypto/feistel_state.rs index d554bd3..5dabb27 100644 --- a/src/crypto/feistel_state.rs +++ b/src/crypto/feistel_state.rs @@ -11,6 +11,14 @@ const NONCE16_1: u16 = const_random::const_random!(u16); const NONCE16_2: u16 = const_random::const_random!(u16); const NONCE32: u32 = const_random::const_random!(u32); +/// Odd additive step used by [`FeistelShuffle`] before applying the permutation. +/// +/// A step through a `u16` counter only visits every possible value when it is +/// coprime with `2^16`; for powers of two, that means the step must be odd. Without +/// this constraint, a randomized even step can cycle through a subset of values and +/// collide before the hook id space is exhausted. +const FEISTEL_STEP: u16 = NONCE16_2 | 1; + pub struct NoShuffle(u16); /// Linear shuffle, no randomization, just a random starting point and step size @@ -35,7 +43,7 @@ impl FeistelShuffle { } pub fn next(&mut self) -> u16 { - self.0 = self.0.wrapping_add(NONCE16_2); + self.0 = self.0.wrapping_add(FEISTEL_STEP); feistel_shuffle(self.0, self.1) } } diff --git a/src/protocol/tests/oneshot/streams.rs b/src/protocol/tests/oneshot/streams.rs index ce3711d..cde3f42 100644 --- a/src/protocol/tests/oneshot/streams.rs +++ b/src/protocol/tests/oneshot/streams.rs @@ -9,7 +9,6 @@ use super::support::{CommsLeaf, ENDPOINT_A, ENDPOINT_B, assert_hook_present, ass const LEAF_STREAM_CALLER: u32 = 200; const LEAF_STREAM_RESPONDENT: u32 = 201; -const STREAM_HOOK_ID: u16 = 0; /// Builds the initial downwards packet that opens the stream on the respondent. /// @@ -43,9 +42,9 @@ fn stream_frame_packet(hook_id: u16, index: usize, end_hook: bool) -> Packet { /// Caller leaf that opens exactly one stream request. /// -/// The first allocated hook id is deterministic in these tests (`0`) because the -/// endpoint starts with no existing hooks. Keeping the caller this small makes the -/// per-loop stream assertions about respondent behavior rather than caller retries. +/// Keeping the caller this small makes the per-loop stream assertions about +/// respondent behavior rather than caller retries. The allocated hook id is read +/// back from endpoint state because the counter may start at a randomized offset. struct StreamCallerLeaf { has_run: bool, } @@ -252,6 +251,51 @@ fn deliver_stream_request(endpoint_a: &mut Endpoint, endpoint_b: &mut Endpoint) endpoint_b.update(); } +/// Returns the single hook opened by the stream request on both endpoints. +/// +/// The production counter intentionally does not promise that the first hook is +/// zero. Stream tests still need to prove that both endpoints agree on one routed +/// return channel, so this helper validates the topology and returns the actual id +/// allocated by `StreamCallerLeaf`. +fn opened_stream_hook_id(endpoint_a: &Endpoint, endpoint_b: &Endpoint) -> u16 { + assert_eq!( + endpoint_a.hook_count(), + 1, + "caller endpoint should have exactly one stream hook" + ); + assert_eq!( + endpoint_b.hook_count(), + 1, + "respondent endpoint should have exactly one stream hook" + ); + + let (&caller_hook, &caller_peer) = endpoint_a + .hooks + .iter() + .next() + .expect("caller endpoint should expose the opened hook"); + let (&respondent_hook, &respondent_peer) = endpoint_b + .hooks + .iter() + .next() + .expect("respondent endpoint should expose the opened hook"); + + assert_eq!( + caller_hook, respondent_hook, + "stream endpoints should agree on the hook id" + ); + assert_eq!( + caller_peer, ENDPOINT_B, + "caller hook should route stream frames through endpoint B" + ); + assert_eq!( + respondent_peer, ENDPOINT_A, + "respondent hook should route stream frames back through endpoint A" + ); + + caller_hook +} + /// Drives one respondent stream loop and delivers any produced frame to endpoint A. fn drive_stream_loop(endpoint_a: &mut Endpoint, endpoint_b: &mut Endpoint) { endpoint_b.update(); @@ -268,12 +312,17 @@ fn received_stream_packets(endpoint: &Endpoint) -> Vec<&Packet> { } /// Verifies ordered stream payloads and final-frame markers. -fn assert_received_stream(endpoint: &Endpoint, expected_count: usize, final_seen: bool) { +fn assert_received_stream( + endpoint: &Endpoint, + expected_count: usize, + final_seen: bool, + expected_hook_id: u16, +) { let packets = received_stream_packets(endpoint); assert_eq!(packets.len(), expected_count); for (index, packet) in packets.iter().enumerate() { - assert_eq!(packet.hook_id, STREAM_HOOK_ID); + assert_eq!(packet.hook_id, expected_hook_id); assert_eq!(packet.data, format!("stream-{index}").as_bytes()); assert_eq!( packet.end_hook, @@ -290,23 +339,24 @@ fn one_directional_stream_returns_one_packet_per_loop() { assert_four_leaf_topology(&endpoint_a, &endpoint_b); deliver_stream_request(&mut endpoint_a, &mut endpoint_b); + let stream_hook_id = opened_stream_hook_id(&endpoint_a, &endpoint_b); - assert_received_stream(&endpoint_a, 0, false); - assert_hook_present(&endpoint_a, STREAM_HOOK_ID); - assert_hook_present(&endpoint_b, STREAM_HOOK_ID); + assert_received_stream(&endpoint_a, 0, false, stream_hook_id); + assert_hook_present(&endpoint_a, stream_hook_id); + assert_hook_present(&endpoint_b, stream_hook_id); for index in 0..total_packets { drive_stream_loop(&mut endpoint_a, &mut endpoint_b); let final_seen = index + 1 == total_packets; - assert_received_stream(&endpoint_a, index + 1, final_seen); + assert_received_stream(&endpoint_a, index + 1, final_seen, stream_hook_id); if final_seen { - assert_hook_removed(&endpoint_a, STREAM_HOOK_ID); - assert_hook_removed(&endpoint_b, STREAM_HOOK_ID); + assert_hook_removed(&endpoint_a, stream_hook_id); + assert_hook_removed(&endpoint_b, stream_hook_id); } else { - assert_hook_present(&endpoint_a, STREAM_HOOK_ID); - assert_hook_present(&endpoint_b, STREAM_HOOK_ID); + assert_hook_present(&endpoint_a, stream_hook_id); + assert_hook_present(&endpoint_b, stream_hook_id); } } } @@ -316,11 +366,12 @@ fn stream_does_not_emit_before_request_is_processed_by_respondent() { let (mut endpoint_a, mut endpoint_b) = stream_endpoints(2); deliver_stream_request(&mut endpoint_a, &mut endpoint_b); + let stream_hook_id = opened_stream_hook_id(&endpoint_a, &endpoint_b); - assert_received_stream(&endpoint_a, 0, false); + assert_received_stream(&endpoint_a, 0, false, stream_hook_id); assert!(endpoint_b.outbound.is_empty()); - assert_hook_present(&endpoint_a, STREAM_HOOK_ID); - assert_hook_present(&endpoint_b, STREAM_HOOK_ID); + assert_hook_present(&endpoint_a, stream_hook_id); + assert_hook_present(&endpoint_b, stream_hook_id); } #[test] @@ -329,14 +380,15 @@ fn stream_stops_after_final_packet() { let (mut endpoint_a, mut endpoint_b) = stream_endpoints(total_packets); deliver_stream_request(&mut endpoint_a, &mut endpoint_b); + let stream_hook_id = opened_stream_hook_id(&endpoint_a, &endpoint_b); drive_stream_loop(&mut endpoint_a, &mut endpoint_b); drive_stream_loop(&mut endpoint_a, &mut endpoint_b); - assert_received_stream(&endpoint_a, total_packets, true); - assert_hook_removed(&endpoint_b, STREAM_HOOK_ID); + assert_received_stream(&endpoint_a, total_packets, true, stream_hook_id); + assert_hook_removed(&endpoint_b, stream_hook_id); drive_stream_loop(&mut endpoint_a, &mut endpoint_b); - assert_received_stream(&endpoint_a, total_packets, true); - assert_hook_removed(&endpoint_b, STREAM_HOOK_ID); + assert_received_stream(&endpoint_a, total_packets, true, stream_hook_id); + assert_hook_removed(&endpoint_b, stream_hook_id); } #[test] @@ -344,15 +396,16 @@ fn failed_final_stream_route_keeps_hook_and_retries() { let (mut endpoint_a, mut endpoint_b) = stream_endpoints(1); deliver_stream_request(&mut endpoint_a, &mut endpoint_b); + let stream_hook_id = opened_stream_hook_id(&endpoint_a, &endpoint_b); endpoint_b.connections.remove(&(ENDPOINT_A, true)); drive_stream_loop(&mut endpoint_a, &mut endpoint_b); - assert_received_stream(&endpoint_a, 0, false); - assert_hook_present(&endpoint_b, STREAM_HOOK_ID); + assert_received_stream(&endpoint_a, 0, false, stream_hook_id); + assert_hook_present(&endpoint_b, stream_hook_id); endpoint_b.connections.insert((ENDPOINT_A, true)); drive_stream_loop(&mut endpoint_a, &mut endpoint_b); - assert_received_stream(&endpoint_a, 1, true); - assert_hook_removed(&endpoint_b, STREAM_HOOK_ID); + assert_received_stream(&endpoint_a, 1, true, stream_hook_id); + assert_hook_removed(&endpoint_b, stream_hook_id); }