umsh_node/
host.rs

1use alloc::rc::Rc;
2use alloc::vec::Vec;
3use core::cell::RefCell;
4
5use umsh_mac::{LocalIdentityId, SendOptions};
6
7use crate::dispatch::EventDispatcher;
8use crate::identity_responder::IdentityResponsePlan;
9use crate::mac::MacBackend;
10use crate::node::{LocalNode, LocalNodeState, NodeMembership, PfsLifecycle};
11use crate::receive::ReceivedPacketRef;
12use crate::{NodeIdentityPayload, OwnedMacCommand, mac_command};
13use umsh_core::PayloadType;
14
15/// Error returned when a [`Host`] cannot make progress.
16///
17/// Wraps the underlying MAC runtime failure.
18#[derive(Debug)]
19pub enum HostError<E> {
20    Mac(E),
21}
22
23/// Multi-identity orchestration layer for the node API.
24///
25/// `Host` owns the shared MAC run loop and routes inbound events to the correct
26/// [`LocalNode`], including ephemeral PFS identities that belong to a long-term node.
27/// Applications typically:
28///
29/// 1. construct a `Host` from a shared [`MacHandle`](umsh_mac::MacHandle)
30/// 2. register one or more [`LocalNode`]s with [`add_node`](Self::add_node)
31/// 3. attach callbacks to nodes / peers / wrappers
32/// 4. drive progress with [`run`](Self::run) or [`pump_once`](Self::pump_once)
33///
34/// `run()` is the preferred long-lived driver. `pump_once()` exists for callers that need to
35/// multiplex UMSH progress with other async work using `select!`.
36///
37/// # Callback re-entrancy
38///
39/// Receive and control callbacks (`on_receive`, `on_text`, `on_ack_received`, …) are invoked
40/// synchronously from inside [`pump_once`](Self::pump_once) while the coordinator borrow is
41/// held. A callback therefore MUST NOT call back into the MAC — it cannot `await` a `send`,
42/// and a blocking or re-entrant borrow of the coordinator would deadlock. Keep callbacks
43/// short and side-effect-free with respect to the MAC: record what you need (e.g. push to a
44/// queue or set a flag) and perform any follow-up sends after `pump_once` / `run` returns
45/// control to your own task.
46pub struct Host<M: MacBackend> {
47    mac: M,
48    dispatcher: Rc<RefCell<EventDispatcher>>,
49    nodes: Vec<(LocalIdentityId, LocalNode<M>)>,
50    pfs_control_options: SendOptions,
51}
52
53impl<M: MacBackend> Host<M> {
54    /// Create a host around a shared MAC backend.
55    pub fn new(mac: M) -> Self {
56        Self {
57            mac,
58            dispatcher: Rc::new(RefCell::new(EventDispatcher::new())),
59            nodes: Vec::new(),
60            pfs_control_options: SendOptions::default()
61                .with_ack_requested(true)
62                .with_flood_hops(5),
63        }
64    }
65
66    /// Return a clone of the underlying shared MAC backend.
67    pub fn mac(&self) -> M {
68        self.mac.clone()
69    }
70
71    /// Borrow the send options used for node-managed PFS control messages.
72    pub fn pfs_control_options(&self) -> &SendOptions {
73        &self.pfs_control_options
74    }
75
76    /// Replace the send options used for node-managed PFS control messages.
77    pub fn set_pfs_control_options(&mut self, options: SendOptions) {
78        self.pfs_control_options = options;
79    }
80
81    /// Create and register a [`LocalNode`] for an already-registered local identity.
82    ///
83    /// The returned handle is cheap to clone and becomes the application-facing entry point
84    /// for sending traffic and attaching callbacks for that identity.
85    pub fn add_node(&mut self, identity_id: LocalIdentityId) -> LocalNode<M> {
86        let membership = Rc::new(RefCell::new(NodeMembership::new()));
87        let state = Rc::new(RefCell::new(LocalNodeState::new()));
88        let node = LocalNode::new(
89            identity_id,
90            self.mac.clone(),
91            self.dispatcher.clone(),
92            membership,
93            state,
94        );
95        self.nodes.push((identity_id, node.clone()));
96        node
97    }
98
99    /// Look up a previously added node by identity id.
100    pub fn node(&self, identity_id: LocalIdentityId) -> Option<LocalNode<M>> {
101        self.nodes
102            .iter()
103            .find(|(id, _)| *id == identity_id)
104            .map(|(_, node)| node.clone())
105    }
106
107    fn route_node(&self, identity_id: LocalIdentityId) -> Option<LocalNode<M>> {
108        if let Some(node) = self.node(identity_id) {
109            return Some(node);
110        }
111
112        #[cfg(feature = "software-crypto")]
113        {
114            return self
115                .nodes
116                .iter()
117                .find(|(_, node)| node.owns_ephemeral_identity(identity_id))
118                .map(|(_, node)| node.clone());
119        }
120
121        #[cfg(not(feature = "software-crypto"))]
122        {
123            None
124        }
125    }
126
127    /// Drive the shared MAC until one wake cycle completes.
128    ///
129    /// This is a single wake-driven step, not a non-blocking poll. It waits until the MAC has
130    /// meaningful work to do (radio activity or a protocol deadline), dispatches any resulting
131    /// callbacks, services PFS command handling, and then returns.
132    ///
133    /// Use this when you need to multiplex UMSH progress with other async sources using
134    /// `select!`. If UMSH owns the task, prefer [`run`](Self::run).
135    pub async fn pump_once(&mut self) -> Result<(), HostError<M::RunError>> {
136        let now_ms = self.mac.now_ms().await;
137        let pending_pfs = Rc::new(RefCell::new(Vec::<(
138            LocalIdentityId,
139            umsh_core::PublicKey,
140            OwnedMacCommand,
141        )>::new()));
142        let pending_pfs_ref = pending_pfs.clone();
143        let pending_identity = Rc::new(RefCell::new(
144            Vec::<(LocalNode<M>, IdentityResponsePlan)>::new(),
145        ));
146        let pending_identity_ref = pending_identity.clone();
147        let dispatcher = self.dispatcher.clone();
148        let nodes = self.nodes.clone();
149        self.mac
150            .next_event(move |identity_id, event| {
151                dispatcher
152                    .borrow_mut()
153                    .dispatch_ticket_state(identity_id, &event);
154                let Some(node) = route_node(&nodes, identity_id) else {
155                    return;
156                };
157                match event {
158                    umsh_mac::MacEventRef::Received(packet) => {
159                        let _ = node.dispatch_received_packet(&packet);
160                        if packet.packet_type() == umsh_core::PacketType::Broadcast
161                            && packet.payload().is_empty()
162                        {
163                            if let Some(from_hint) = packet.from_hint() {
164                                node.dispatch_beacon(from_hint, packet.from_key());
165                            }
166                        } else if let Some(from) = packet.from_key() {
167                            dispatch_payload_callbacks(
168                                &node,
169                                &packet,
170                                from,
171                                &pending_pfs_ref,
172                                &pending_identity_ref,
173                                now_ms,
174                            );
175                        }
176                    }
177                    umsh_mac::MacEventRef::AckReceived { peer, receipt } => {
178                        node.dispatch_ack_received(
179                            peer,
180                            crate::SendToken::new(identity_id, receipt),
181                        );
182                    }
183                    umsh_mac::MacEventRef::AckTimeout { peer, receipt } => {
184                        node.dispatch_ack_timeout(
185                            peer,
186                            crate::SendToken::new(identity_id, receipt),
187                        );
188                    }
189                    umsh_mac::MacEventRef::Transmitted { wire_bytes, .. } => {
190                        node.dispatch_transmitted(wire_bytes);
191                    }
192                    umsh_mac::MacEventRef::Forwarded { .. } => {}
193                    // Ticket resolution (failed + finished) is handled by
194                    // dispatch_ticket_state above; there is no peer to
195                    // notify since the frame never aired.
196                    umsh_mac::MacEventRef::TxAbandoned { .. } => {}
197                }
198            })
199            .await
200            .map_err(HostError::Mac)?;
201
202        let queued: Vec<(LocalIdentityId, umsh_core::PublicKey, OwnedMacCommand)> =
203            pending_pfs.borrow_mut().drain(..).collect();
204        for (identity_id, from, command) in queued {
205            self.handle_pfs_command(identity_id, from, command).await;
206        }
207
208        let identity_replies: Vec<(LocalNode<M>, IdentityResponsePlan)> =
209            pending_identity.borrow_mut().drain(..).collect();
210        for (node, plan) in identity_replies {
211            node.send_identity_response(plan).await;
212        }
213
214        self.service_protocol_timeouts().await;
215
216        Ok(())
217    }
218
219    /// Service node-layer deadlines independently of MAC/radio wake events.
220    ///
221    /// A quiet radio is not a MAC wake source, so applications that multiplex
222    /// [`pump_once`](Self::pump_once) with other work must also call this from
223    /// their own timer. In particular, ping and PFS request timeouts must fire
224    /// even when the remote peer sends no response at all.
225    pub async fn service_protocol_timeouts(&mut self) {
226        let now_ms = self.mac.now_ms().await;
227        service_node_timeouts(&self.nodes, now_ms).await;
228    }
229
230    /// Detach an owned handle that services the same node-layer deadlines as
231    /// [`service_protocol_timeouts`](Self::service_protocol_timeouts).
232    ///
233    /// Use this when the host pump runs as its own long-lived future (so the
234    /// `Host` is exclusively borrowed) and a sibling timer task must still
235    /// fire ping/PFS timeouts — e.g. while the pump is parked awaiting a slow
236    /// physical transmit. The handle snapshots the current node set; nodes
237    /// added afterwards are not covered by it.
238    pub fn protocol_timeout_servicer(&self) -> ProtocolTimeoutServicer<M>
239    where
240        M: Clone,
241    {
242        ProtocolTimeoutServicer {
243            mac: self.mac.clone(),
244            nodes: self.nodes.clone(),
245        }
246    }
247
248    /// Run the shared MAC/Host loop forever.
249    ///
250    /// This is the preferred long-lived driver for node-based applications. It keeps the
251    /// runtime wake policy inside the MAC/Host stack rather than requiring callers to write
252    /// poll/sleep loops themselves.
253    pub async fn run(&mut self) -> Result<(), HostError<M::RunError>> {
254        loop {
255            self.pump_once().await?;
256        }
257    }
258
259    async fn handle_pfs_command(
260        &mut self,
261        identity_id: LocalIdentityId,
262        from: umsh_core::PublicKey,
263        command: OwnedMacCommand,
264    ) {
265        let Some(node) = self.route_node(identity_id) else {
266            return;
267        };
268
269        match node
270            .handle_pfs_command(&from, &command, &self.pfs_control_options)
271            .await
272        {
273            Ok(Some(PfsLifecycle::Established(peer))) => node.dispatch_pfs_established(peer),
274            Ok(Some(PfsLifecycle::Ended(peer))) => node.dispatch_pfs_ended(peer),
275            Ok(None) => {}
276            // Previously the error was dropped here, so a failed negotiation
277            // (e.g. no ephemeral identity slot) left both sides silently stuck.
278            // Surface it so applications can report the failure.
279            Err(err) => node.dispatch_pfs_failed(from, err.pfs_failure()),
280        }
281    }
282}
283
284/// Owned deadline-service handle detached from a [`Host`]; see
285/// [`Host::protocol_timeout_servicer`].
286pub struct ProtocolTimeoutServicer<M: MacBackend> {
287    mac: M,
288    nodes: Vec<(LocalIdentityId, LocalNode<M>)>,
289}
290
291impl<M: MacBackend> ProtocolTimeoutServicer<M> {
292    /// Fire any elapsed ping and PFS deadlines on the snapshot's nodes.
293    pub async fn service(&self) {
294        let now_ms = self.mac.now_ms().await;
295        service_node_timeouts(&self.nodes, now_ms).await;
296    }
297}
298
299async fn service_node_timeouts<M: MacBackend>(
300    nodes: &[(LocalIdentityId, LocalNode<M>)],
301    now_ms: u64,
302) {
303    #[cfg(feature = "software-crypto")]
304    for (_, node) in nodes {
305        if let Ok(expired) = node.expire_pfs_sessions().await {
306            for peer in expired {
307                node.dispatch_pfs_ended(peer);
308            }
309        }
310        // Abandon sent PFS requests that were never answered, so the
311        // requester reports a timeout instead of sitting in `Requested`.
312        for peer in node.expire_pfs_requests(now_ms) {
313            node.dispatch_pfs_failed(peer, crate::node::PfsFailure::Timeout);
314        }
315    }
316
317    for (_, node) in nodes {
318        node.expire_pings(now_ms);
319    }
320}
321
322fn route_node<M: MacBackend>(
323    nodes: &[(LocalIdentityId, LocalNode<M>)],
324    identity_id: LocalIdentityId,
325) -> Option<LocalNode<M>> {
326    nodes
327        .iter()
328        .find(|(id, _)| *id == identity_id)
329        .map(|(_, node)| node.clone())
330        .or_else(|| {
331            nodes
332                .iter()
333                .find(|(_, node)| node.owns_ephemeral_identity(identity_id))
334                .map(|(_, node)| node.clone())
335        })
336}
337
338fn dispatch_payload_callbacks<M: MacBackend>(
339    node: &LocalNode<M>,
340    packet: &ReceivedPacketRef<'_>,
341    from: umsh_core::PublicKey,
342    pending_pfs: &Rc<RefCell<Vec<(LocalIdentityId, umsh_core::PublicKey, OwnedMacCommand)>>>,
343    pending_identity: &Rc<RefCell<Vec<(LocalNode<M>, IdentityResponsePlan)>>>,
344    now_ms: u64,
345) {
346    if packet.payload_type() == PayloadType::NodeIdentity {
347        if let Ok(identity) = NodeIdentityPayload::from_bytes(packet.payload()) {
348            node.dispatch_node_discovered(from, identity.name.as_deref());
349        }
350        return;
351    }
352
353    if packet.payload_type() == PayloadType::MacCommand {
354        if let Ok(command) = mac_command::parse(packet.payload()) {
355            // Broadcast admits MAC commands one at a time: only a command
356            // whose definition permits broadcast carriage is acted on, and
357            // today that is the Identity Request alone. Anything else riding
358            // a broadcast is dropped here, before observers see it.
359            if packet.packet_family() == umsh_mac::PacketFamily::Broadcast
360                && !matches!(command, mac_command::MacCommand::IdentityRequest { .. })
361            {
362                return;
363            }
364            // Match borrowed variants before converting to owned.
365            if let mac_command::MacCommand::EchoResponse { data } = command {
366                node.match_pong(
367                    from,
368                    data,
369                    packet,
370                    packet.received_at_ms().unwrap_or(now_ms),
371                );
372            }
373            // Identity Request: let the built-in responder (if enabled) build a
374            // reply plan now, while the reception context is live; the async
375            // pump sends it after this synchronous dispatch returns.
376            if let mac_command::MacCommand::IdentityRequest { options } = command {
377                if let Some(plan) = node.evaluate_identity_request(packet, from, options) {
378                    pending_identity.borrow_mut().push((node.clone(), plan));
379                }
380            }
381            let owned = OwnedMacCommand::from(command);
382            node.dispatch_mac_command(from, &owned);
383            if matches!(
384                owned,
385                OwnedMacCommand::PfsSessionRequest { .. }
386                    | OwnedMacCommand::PfsSessionResponse { .. }
387                    | OwnedMacCommand::EndPfsSession
388            ) {
389                pending_pfs
390                    .borrow_mut()
391                    .push((node.identity_id(), from, owned));
392            }
393        }
394    }
395}