From 921ea838c48e948831305931405bcc7ef9b1134f Mon Sep 17 00:00:00 2001 From: Michael Mikovsky <77305074+Astatin3@users.noreply.github.com> Date: Mon, 1 Jun 2026 13:39:48 -0600 Subject: [PATCH] Split protocol internals by responsibility --- src/protocol/endpoint/connections.rs | 39 ++++ src/protocol/endpoint/hook_output.rs | 77 +++++++ src/protocol/endpoint/hooks.rs | 104 +++------ src/protocol/endpoint/mod.rs | 213 ++---------------- src/protocol/endpoint/queues.rs | 127 +++++++++++ src/protocol/procedure/contract.rs | 26 +++ src/protocol/procedure/mod.rs | 7 + .../{procedure.rs => procedure/out.rs} | 29 +-- src/protocol/session.rs | 195 ---------------- src/protocol/session/contract.rs | 72 ++++++ src/protocol/session/error.rs | 39 ++++ src/protocol/session/mod.rs | 11 + src/protocol/session/status.rs | 15 ++ src/protocol/session/storage.rs | 70 ++++++ 14 files changed, 533 insertions(+), 491 deletions(-) create mode 100644 src/protocol/endpoint/connections.rs create mode 100644 src/protocol/endpoint/hook_output.rs create mode 100644 src/protocol/endpoint/queues.rs create mode 100644 src/protocol/procedure/contract.rs create mode 100644 src/protocol/procedure/mod.rs rename src/protocol/{procedure.rs => procedure/out.rs} (58%) delete mode 100644 src/protocol/session.rs create mode 100644 src/protocol/session/contract.rs create mode 100644 src/protocol/session/error.rs create mode 100644 src/protocol/session/mod.rs create mode 100644 src/protocol/session/status.rs create mode 100644 src/protocol/session/storage.rs diff --git a/src/protocol/endpoint/connections.rs b/src/protocol/endpoint/connections.rs new file mode 100644 index 0000000..6b3ad4f --- /dev/null +++ b/src/protocol/endpoint/connections.rs @@ -0,0 +1,39 @@ +use crate::protocol::{Endpoint, EndpointName}; + +impl Endpoint { + /// Registers an adjacent endpoint and returns whether this is a new edge. + /// + /// Endpoint routing tables are intentionally tiny in the minimized firmware + /// profile. A linear vector keeps that profile from linking tree-map machinery + /// while preserving the old set semantics: duplicate connection registrations do + /// not create duplicate route entries. + pub fn add_connection(&mut self, remote_id: EndpointName, is_authority: bool) -> bool { + let connection = (remote_id, is_authority); + + if self.connection_contains(remote_id, is_authority) { + false + } else { + self.connections.push(connection); + true + } + } + + /// Removes an adjacent endpoint registration and reports whether it existed. + pub fn remove_connection(&mut self, remote_id: EndpointName, is_authority: bool) -> bool { + let Some(index) = self + .connections + .iter() + .position(|connection| *connection == (remote_id, is_authority)) + else { + return false; + }; + + self.connections.remove(index); + true + } + + /// Returns whether an adjacent endpoint is registered in the requested direction. + pub fn connection_contains(&self, remote_id: EndpointName, is_authority: bool) -> bool { + self.connections.contains(&(remote_id, is_authority)) + } +} diff --git a/src/protocol/endpoint/hook_output.rs b/src/protocol/endpoint/hook_output.rs new file mode 100644 index 0000000..9afe244 --- /dev/null +++ b/src/protocol/endpoint/hook_output.rs @@ -0,0 +1,77 @@ +use alloc::vec::Vec; + +use crate::protocol::{Endpoint, EndpointError, Packet}; + +use super::HookID; + +impl Endpoint { + /// Returns the destination path for packets sent back over `hook_id`. + /// + /// Hooks record the adjacent peer that paved the return channel. This helper turns + /// that peer into the packet path required by the current router: parent peers map + /// to the parent path, and child peers map to the direct child path. Session logic + /// should not store this path itself. + pub(crate) fn hook_path(&self, hook_id: HookID) -> Result, EndpointError> { + let peer = self + .hook_peer(hook_id) + .ok_or(EndpointError::UnknownHook { hook_id })?; + + if self.path.is_empty() { + return Err(EndpointError::EndpointPathUnset); + } + + if self.path.len() > 1 && self.path[self.path.len() - 2] == peer { + Ok(self.path[..self.path.len() - 1].to_vec()) + } else { + let mut path = self.path.clone(); + path.push(peer); + Ok(path) + } + } + + /// Routes raw response data over an existing hook immediately. + /// + /// This is the compact session-output path: it avoids an intermediate context and + /// retry queue. If a final packet cannot route, the local hook is still removed so + /// an implant does not retain dead hook state forever. + pub fn send_hook_raw( + &mut self, + hook_id: HookID, + procedure_id: u32, + data: Vec, + end_hook: bool, + ) -> Result<(), EndpointError> { + let path = self.hook_path(hook_id)?; + let packet = Packet { + hook_id, + end_hook, + path, + procedure_id, + data, + }; + + let result = self.add_outbound(packet); + + if result.is_err() && end_hook { + self.close_hook(hook_id); + } + + result + } + + /// Routes a one-byte-opcode response frame over an existing hook immediately. + pub fn send_hook_frame( + &mut self, + hook_id: HookID, + procedure_id: u32, + opcode: u8, + payload: &[u8], + end_hook: bool, + ) -> Result<(), EndpointError> { + let mut data = Vec::with_capacity(payload.len() + 1); + data.push(opcode); + data.extend_from_slice(payload); + + self.send_hook_raw(hook_id, procedure_id, data, end_hook) + } +} diff --git a/src/protocol/endpoint/hooks.rs b/src/protocol/endpoint/hooks.rs index 1cf7a03..531389c 100644 --- a/src/protocol/endpoint/hooks.rs +++ b/src/protocol/endpoint/hooks.rs @@ -1,6 +1,4 @@ -use alloc::vec::Vec; - -use crate::protocol::{Endpoint, EndpointError, EndpointName, Packet}; +use crate::protocol::{Endpoint, EndpointError, EndpointName}; /// Compact identifier for one routed return channel. /// @@ -86,76 +84,6 @@ impl Endpoint { self.close_hook(hook_id) } - /// Returns the destination path for packets sent back over `hook_id`. - /// - /// Hooks record the adjacent peer that paved the return channel. This helper turns - /// that peer into the packet path required by the current router: parent peers map - /// to the parent path, and child peers map to the direct child path. Session logic - /// should not store this path itself. - pub(crate) fn hook_path(&self, hook_id: HookID) -> Result, EndpointError> { - let peer = self - .hook_peer(hook_id) - .ok_or(EndpointError::UnknownHook { hook_id })?; - - if self.path.is_empty() { - return Err(EndpointError::EndpointPathUnset); - } - - if self.path.len() > 1 && self.path[self.path.len() - 2] == peer { - Ok(self.path[..self.path.len() - 1].to_vec()) - } else { - let mut path = self.path.clone(); - path.push(peer); - Ok(path) - } - } - - /// Routes raw response data over an existing hook immediately. - /// - /// This is the compact session-output path: it avoids an intermediate context and - /// retry queue. If a final packet cannot route, the local hook is still removed so - /// an implant does not retain dead hook state forever. - pub fn send_hook_raw( - &mut self, - hook_id: HookID, - procedure_id: u32, - data: Vec, - end_hook: bool, - ) -> Result<(), EndpointError> { - let path = self.hook_path(hook_id)?; - let packet = Packet { - hook_id, - end_hook, - path, - procedure_id, - data, - }; - - let result = self.add_outbound(packet); - - if result.is_err() && end_hook { - self.close_hook(hook_id); - } - - result - } - - /// Routes a one-byte-opcode response frame over an existing hook immediately. - pub fn send_hook_frame( - &mut self, - hook_id: HookID, - procedure_id: u32, - opcode: u8, - payload: &[u8], - end_hook: bool, - ) -> Result<(), EndpointError> { - let mut data = Vec::with_capacity(payload.len() + 1); - data.push(opcode); - data.extend_from_slice(payload); - - self.send_hook_raw(hook_id, procedure_id, data, end_hook) - } - /// Validates that `actual_peer` is the peer allowed to use `hook_id`. pub(crate) fn ensure_hook_peer( &self, @@ -186,4 +114,34 @@ impl Endpoint { pub(crate) fn close_hook(&mut self, hook_id: HookID) -> bool { self.hook_remove(hook_id).is_some() } + + /// Inserts or updates a hook and returns the previously associated peer. + pub(crate) fn hook_insert( + &mut self, + hook_id: HookID, + peer: EndpointName, + ) -> Option { + if let Some((_, existing_peer)) = self + .hooks + .iter_mut() + .find(|(existing_hook, _)| *existing_hook == hook_id) + { + let previous = *existing_peer; + *existing_peer = peer; + Some(previous) + } else { + self.hooks.push((hook_id, peer)); + None + } + } + + /// Removes a hook and returns the peer it pointed at. + pub(crate) fn hook_remove(&mut self, hook_id: HookID) -> Option { + let index = self + .hooks + .iter() + .position(|(existing_hook, _)| *existing_hook == hook_id)?; + + Some(self.hooks.remove(index).1) + } } diff --git a/src/protocol/endpoint/mod.rs b/src/protocol/endpoint/mod.rs index f2f11f2..9ebb31d 100644 --- a/src/protocol/endpoint/mod.rs +++ b/src/protocol/endpoint/mod.rs @@ -1,4 +1,7 @@ +mod connections; +mod hook_output; mod hooks; +mod queues; mod routing; pub use hooks::HookID; @@ -7,28 +10,34 @@ use alloc::vec::Vec; use crate::{ crypto::Counter, - protocol::{ConnectionSet, EndpointName, HookMap, Packet, PacketQueue, Path, RouteMap}, + protocol::{ConnectionSet, HookMap, Path, RouteMap}, }; +/// Local routing state for one protocol node. +/// +/// `Endpoint` deliberately owns only route, hook, and connection tables. Leaves are +/// caller-owned concrete values, which keeps small firmware-style binaries from +/// linking dynamic leaf registries or boxed trait objects. pub struct Endpoint { - // This endpoint's identifier + /// This endpoint's identifier. pub id: u32, - // A counter that creates unique hook IDs. + /// Counter used to allocate locally unique hook ids. pub(crate) last_hook: Counter, - // Absolute path for this node. Must be set by some leaf + /// Absolute path for this node. An empty path means routing is not initialized. pub path: Path, - // Map of connections so that we can know what is connected - // and which endpoints are authorities + /// Adjacent endpoints and whether each adjacent endpoint is upstream/authority. pub connections: ConnectionSet, - // Local list of hooks. + /// Active hook id to adjacent peer mappings. pub(crate) hooks: HookMap, - // Map of endpoints to packet queues + /// Packets delivered locally and waiting for leaf consumption. pub(crate) inbound: RouteMap, + + /// Packets queued for adjacent endpoints and waiting for transport leaves. pub(crate) outbound: RouteMap, } @@ -53,192 +62,4 @@ impl Endpoint { outbound: Vec::new(), } } - - /// Registers an adjacent endpoint and returns whether this is a new edge. - /// - /// Endpoint routing tables are intentionally tiny in the minimized firmware - /// profile. A linear vector keeps that profile from linking tree-map machinery - /// while preserving the old set semantics: duplicate connection registrations do - /// not create duplicate route entries. - pub fn add_connection(&mut self, remote_id: EndpointName, is_authority: bool) -> bool { - let connection = (remote_id, is_authority); - - if self.connection_contains(remote_id, is_authority) { - false - } else { - self.connections.push(connection); - true - } - } - - /// Removes an adjacent endpoint registration and reports whether it existed. - pub fn remove_connection(&mut self, remote_id: EndpointName, is_authority: bool) -> bool { - let Some(index) = self - .connections - .iter() - .position(|connection| *connection == (remote_id, is_authority)) - else { - return false; - }; - - self.connections.remove(index); - true - } - - /// Returns whether an adjacent endpoint is registered in the requested direction. - pub fn connection_contains(&self, remote_id: EndpointName, is_authority: bool) -> bool { - self.connections.contains(&(remote_id, is_authority)) - } - - /// 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); - } - - /// 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::route_remove(path, &mut self.inbound) 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::route_queue_mut(path, &mut self.inbound).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 - 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) = Self::route_queue_mut_existing(path, queue) { - for packet in queue.iter() { - f(packet); - } - - queue.clear(); - } - } - - /// Appends a packet to the route queue for `endpoint`. - pub(crate) fn route_push(endpoint: EndpointName, packet: Packet, routes: &mut RouteMap) { - Self::route_queue_mut(endpoint, routes).push_back(packet); - } - - /// Returns the route queue for `endpoint` if one exists. - #[cfg(test)] - pub(crate) fn route_get(endpoint: EndpointName, routes: &RouteMap) -> Option<&PacketQueue> { - routes - .iter() - .find(|(queued_endpoint, _)| *queued_endpoint == endpoint) - .map(|(_, queue)| queue) - } - - /// Removes and returns the queue for `endpoint`. - pub(crate) fn route_remove( - endpoint: EndpointName, - routes: &mut RouteMap, - ) -> Option { - let index = routes - .iter() - .position(|(queued_endpoint, _)| *queued_endpoint == endpoint)?; - - Some(routes.remove(index).1) - } - - /// Returns whether a route queue exists for `endpoint`. - #[cfg(test)] - pub(crate) fn route_contains(endpoint: EndpointName, routes: &RouteMap) -> bool { - Self::route_get(endpoint, routes).is_some() - } - - /// Returns whether no route queues are present. - #[cfg(test)] - pub(crate) fn routes_is_empty(routes: &RouteMap) -> bool { - routes.is_empty() - } - - /// Returns the route queue for `endpoint`, creating it on first use. - fn route_queue_mut(endpoint: EndpointName, routes: &mut RouteMap) -> &mut PacketQueue { - if let Some(index) = routes - .iter() - .position(|(queued_endpoint, _)| *queued_endpoint == endpoint) - { - &mut routes[index].1 - } else { - routes.push((endpoint, PacketQueue::new())); - &mut routes.last_mut().unwrap().1 - } - } - - /// Returns the existing route queue for `endpoint` without allocating a new one. - fn route_queue_mut_existing( - endpoint: EndpointName, - routes: &mut RouteMap, - ) -> Option<&mut PacketQueue> { - routes - .iter_mut() - .find(|(queued_endpoint, _)| *queued_endpoint == endpoint) - .map(|(_, queue)| queue) - } - - /// Inserts or updates a hook and returns the previously associated peer. - pub(crate) fn hook_insert( - &mut self, - hook_id: HookID, - peer: EndpointName, - ) -> Option { - if let Some((_, existing_peer)) = self - .hooks - .iter_mut() - .find(|(existing_hook, _)| *existing_hook == hook_id) - { - let previous = *existing_peer; - *existing_peer = peer; - Some(previous) - } else { - self.hooks.push((hook_id, peer)); - None - } - } - - /// Removes a hook and returns the peer it pointed at. - pub(crate) fn hook_remove(&mut self, hook_id: HookID) -> Option { - let index = self - .hooks - .iter() - .position(|(existing_hook, _)| *existing_hook == hook_id)?; - - Some(self.hooks.remove(index).1) - } } diff --git a/src/protocol/endpoint/queues.rs b/src/protocol/endpoint/queues.rs new file mode 100644 index 0000000..ed09b9e --- /dev/null +++ b/src/protocol/endpoint/queues.rs @@ -0,0 +1,127 @@ +use alloc::vec::Vec; + +use crate::protocol::{Endpoint, EndpointName, Packet, PacketQueue, RouteMap}; + +impl Endpoint { + /// Runs a function over all inbound packets for `path`, then clears that queue. + pub fn take_inbound_clear(&mut self, path: u32, f: F) + where + F: FnMut(&Packet), + { + 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::route_remove(path, &mut self.inbound) 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::route_queue_mut(path, &mut self.inbound).extend(unmatched); + } + } + + /// Runs a function over all outbound packets for `path`, then clears that queue. + 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) = Self::route_queue_mut_existing(path, queue) { + for packet in queue.iter() { + f(packet); + } + + queue.clear(); + } + } + + /// Appends a packet to the route queue for `endpoint`. + pub(crate) fn route_push(endpoint: EndpointName, packet: Packet, routes: &mut RouteMap) { + Self::route_queue_mut(endpoint, routes).push_back(packet); + } + + /// Returns the route queue for `endpoint` if one exists. + #[cfg(test)] + pub(crate) fn route_get(endpoint: EndpointName, routes: &RouteMap) -> Option<&PacketQueue> { + routes + .iter() + .find(|(queued_endpoint, _)| *queued_endpoint == endpoint) + .map(|(_, queue)| queue) + } + + /// Removes and returns the queue for `endpoint`. + pub(crate) fn route_remove( + endpoint: EndpointName, + routes: &mut RouteMap, + ) -> Option { + let index = routes + .iter() + .position(|(queued_endpoint, _)| *queued_endpoint == endpoint)?; + + Some(routes.remove(index).1) + } + + /// Returns whether a route queue exists for `endpoint`. + #[cfg(test)] + pub(crate) fn route_contains(endpoint: EndpointName, routes: &RouteMap) -> bool { + Self::route_get(endpoint, routes).is_some() + } + + /// Returns whether no route queues are present. + #[cfg(test)] + pub(crate) fn routes_is_empty(routes: &RouteMap) -> bool { + routes.is_empty() + } + + /// Returns the route queue for `endpoint`, creating it on first use. + fn route_queue_mut(endpoint: EndpointName, routes: &mut RouteMap) -> &mut PacketQueue { + if let Some(index) = routes + .iter() + .position(|(queued_endpoint, _)| *queued_endpoint == endpoint) + { + &mut routes[index].1 + } else { + routes.push((endpoint, PacketQueue::new())); + &mut routes.last_mut().unwrap().1 + } + } + + /// Returns the existing route queue for `endpoint` without allocating a new one. + fn route_queue_mut_existing( + endpoint: EndpointName, + routes: &mut RouteMap, + ) -> Option<&mut PacketQueue> { + routes + .iter_mut() + .find(|(queued_endpoint, _)| *queued_endpoint == endpoint) + .map(|(_, queue)| queue) + } +} diff --git a/src/protocol/procedure/contract.rs b/src/protocol/procedure/contract.rs new file mode 100644 index 0000000..511d704 --- /dev/null +++ b/src/protocol/procedure/contract.rs @@ -0,0 +1,26 @@ +use crate::protocol::{Endpoint, Packet, ProcedureOut}; + +#[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, +/// or health checks. Long-running conversations should use [`Session`](crate::protocol::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); + + #[cfg(feature = "interface_ratatui")] + fn render_ratatui( + _: &L, + _: &mut ProcedureView, + _: &mut ratatui::Frame<'_>, + _: ratatui::layout::Rect, + ) { + } +} diff --git a/src/protocol/procedure/mod.rs b/src/protocol/procedure/mod.rs new file mode 100644 index 0000000..63ff28d --- /dev/null +++ b/src/protocol/procedure/mod.rs @@ -0,0 +1,7 @@ +//! One-shot procedure contracts and response output helpers. + +mod contract; +mod out; + +pub use contract::Procedure; +pub use out::ProcedureOut; diff --git a/src/protocol/procedure.rs b/src/protocol/procedure/out.rs similarity index 58% rename from src/protocol/procedure.rs rename to src/protocol/procedure/out.rs index 0ee7a00..9c13f53 100644 --- a/src/protocol/procedure.rs +++ b/src/protocol/procedure/out.rs @@ -1,33 +1,8 @@ use alloc::vec::Vec; -use crate::protocol::{Endpoint, HookID, Packet, PacketQueue}; +use crate::protocol::{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, -/// 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); - - #[cfg(feature = "interface_ratatui")] - fn render_ratatui( - _: &L, - _: &mut ProcedureView, - _: &mut ratatui::Frame<'_>, - _: ratatui::layout::Rect, - ) { - } -} - -/// Output accumulator passed to [`Procedure::handle`]. +/// Output accumulator passed to [`Procedure::handle`](super::Procedure::handle). pub struct ProcedureOut { hook_id: HookID, reply_path: Vec, diff --git a/src/protocol/session.rs b/src/protocol/session.rs deleted file mode 100644 index 405b990..0000000 --- a/src/protocol/session.rs +++ /dev/null @@ -1,195 +0,0 @@ -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 -/// generated leaf owns packet grouping, retry-safe output flushing, and final cleanup; -/// the session value owns one hook's application behavior and mutable state. -/// -/// # Example -/// -/// ```rust,ignore -/// impl Session for MySessionState { -/// const PROCEDURE_ID: u32 = 7; -/// -/// fn init( -/// leaf: &mut MyLeafState, -/// packet: Packet, -/// ) -> Result { -/// Ok(MySessionState::from_open(leaf, packet)) -/// } -/// -/// fn update( -/// leaf: &mut MyLeafState, -/// session: &mut Self, -/// incoming: &mut PacketQueue, -/// endpoint: &mut Endpoint, -/// ) -> SessionStatus { -/// while let Some(packet) = incoming.pop_front() { -/// session.apply(leaf, packet, endpoint); -/// } -/// SessionStatus::Running -/// } -/// } -/// ``` -pub trait Session: Sized { - /// Outer packet procedure id used by every packet in this session family. - const PROCEDURE_ID: u32; - - /// Creates one session value from a packet whose hook has no active session. - /// - /// The generated runtime derives all response routing from hook state. Session - /// initialization therefore returns only application state or a protocol-level - /// rejection; it never stores or receives a caller reply path. - fn init(leaf: &mut L, packet: Packet) -> Result; - - /// 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. Session - /// output is routed immediately through `endpoint`; callers that need retry - /// semantics should keep their own compact application state and retry on a later - /// tick. - fn update( - leaf: &mut L, - session: &mut Self, - incoming: &mut PacketQueue, - endpoint: &mut Endpoint, - ) -> SessionStatus; - - #[cfg(feature = "interface_ratatui")] - fn render_ratatui( - _: &L, - _: &Self, - _: &mut SessionView, - _: &mut ratatui::Frame<'_>, - _: ratatui::layout::Rect, - ) { - } -} - -/// Error returned when a packet cannot create a new session. -pub enum SessionInitError { - /// The packet was intentionally consumed without creating state or sending output. - Rejected, - - /// The packet was rejected with response data that should be sent on the same hook. - Response { - /// Raw `Packet::data` for the response frame. - data: Vec, - - /// Whether the response should close the hook after successful routing. - end_hook: bool, - }, -} - -impl SessionInitError { - /// Creates a silent session rejection. - pub fn rejected() -> Self { - Self::Rejected - } - - /// Creates a non-final response for a rejected session open. - pub fn response(data: Vec) -> Self { - Self::Response { - data, - end_hook: false, - } - } - - /// Creates a final response for a rejected session open. - pub fn response_final(data: Vec) -> Self { - Self::Response { - data, - end_hook: true, - } - } -} - -/// 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 removes the entry after the update tick. Final packets are - /// routed immediately by the session before returning this status. - Closed, -} - -/// 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, - - /// Whether application logic has finished and should be removed after update. - 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(); - } - - 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 { - Self { - hook_id, - state, - inbox: PacketQueue::new(), - closed: false, - } - } -} diff --git a/src/protocol/session/contract.rs b/src/protocol/session/contract.rs new file mode 100644 index 0000000..97dcf81 --- /dev/null +++ b/src/protocol/session/contract.rs @@ -0,0 +1,72 @@ +use crate::protocol::{Endpoint, Packet, PacketQueue, SessionInitError, SessionStatus}; + +#[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 +/// generated leaf owns packet grouping, retry-safe output flushing, and final cleanup; +/// the session value owns one hook's application behavior and mutable state. +/// +/// # Example +/// +/// ```rust,ignore +/// impl Session for MySessionState { +/// const PROCEDURE_ID: u32 = 7; +/// +/// fn init( +/// leaf: &mut MyLeafState, +/// packet: Packet, +/// ) -> Result { +/// Ok(MySessionState::from_open(leaf, packet)) +/// } +/// +/// fn update( +/// leaf: &mut MyLeafState, +/// session: &mut Self, +/// incoming: &mut PacketQueue, +/// endpoint: &mut Endpoint, +/// ) -> SessionStatus { +/// while let Some(packet) = incoming.pop_front() { +/// session.apply(leaf, packet, endpoint); +/// } +/// SessionStatus::Running +/// } +/// } +/// ``` +pub trait Session: Sized { + /// Outer packet procedure id used by every packet in this session family. + const PROCEDURE_ID: u32; + + /// Creates one session value from a packet whose hook has no active session. + /// + /// The generated runtime derives all response routing from hook state. Session + /// initialization therefore returns only application state or a protocol-level + /// rejection; it never stores or receives a caller reply path. + fn init(leaf: &mut L, packet: Packet) -> Result; + + /// 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. Session + /// output is routed immediately through `endpoint`; callers that need retry + /// semantics should keep their own compact application state and retry on a later + /// tick. + fn update( + leaf: &mut L, + session: &mut Self, + incoming: &mut PacketQueue, + endpoint: &mut Endpoint, + ) -> SessionStatus; + + #[cfg(feature = "interface_ratatui")] + fn render_ratatui( + _: &L, + _: &Self, + _: &mut SessionView, + _: &mut ratatui::Frame<'_>, + _: ratatui::layout::Rect, + ) { + } +} diff --git a/src/protocol/session/error.rs b/src/protocol/session/error.rs new file mode 100644 index 0000000..0e6bde4 --- /dev/null +++ b/src/protocol/session/error.rs @@ -0,0 +1,39 @@ +use alloc::vec::Vec; + +/// Error returned when a packet cannot create a new session. +pub enum SessionInitError { + /// The packet was intentionally consumed without creating state or sending output. + Rejected, + + /// The packet was rejected with response data that should be sent on the same hook. + Response { + /// Raw `Packet::data` for the response frame. + data: Vec, + + /// Whether the response should close the hook after successful routing. + end_hook: bool, + }, +} + +impl SessionInitError { + /// Creates a silent session rejection. + pub fn rejected() -> Self { + Self::Rejected + } + + /// Creates a non-final response for a rejected session open. + pub fn response(data: Vec) -> Self { + Self::Response { + data, + end_hook: false, + } + } + + /// Creates a final response for a rejected session open. + pub fn response_final(data: Vec) -> Self { + Self::Response { + data, + end_hook: true, + } + } +} diff --git a/src/protocol/session/mod.rs b/src/protocol/session/mod.rs new file mode 100644 index 0000000..90845c8 --- /dev/null +++ b/src/protocol/session/mod.rs @@ -0,0 +1,11 @@ +//! Hook-backed session contracts and generated session storage. + +mod contract; +mod error; +mod status; +mod storage; + +pub use contract::Session; +pub use error::SessionInitError; +pub use status::SessionStatus; +pub use storage::{SessionEntry, SessionFamily}; diff --git a/src/protocol/session/status.rs b/src/protocol/session/status.rs new file mode 100644 index 0000000..c1b21f7 --- /dev/null +++ b/src/protocol/session/status.rs @@ -0,0 +1,15 @@ +/// Session lifecycle status returned from [`Session::update`](super::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 removes the entry after the update tick. Final packets are + /// routed immediately by the session before returning this status. + Closed, +} diff --git a/src/protocol/session/storage.rs b/src/protocol/session/storage.rs new file mode 100644 index 0000000..19bdc5a --- /dev/null +++ b/src/protocol/session/storage.rs @@ -0,0 +1,70 @@ +use alloc::vec::Vec; + +use crate::protocol::{HookID, PacketQueue}; + +/// 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, + + /// Whether application logic has finished and should be removed after update. + 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(); + } + + 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 { + Self { + hook_id, + state, + inbox: PacketQueue::new(), + closed: false, + } + } +}