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() -}