Skip to main content

consortium/
propagation.rs

1//! Tree propagation engine.
2//!
3//! Rust implementation of `ClusterShell.Propagation`.
4//!
5//! This module provides:
6//! - [`PropagationTreeRouter`]: Routes commands through the topology tree by
7//!   computing next-hop gateways and managing fanout/load-balancing.
8//! - [`PropagationChannel`]: State machine for gateway-to-gateway channel
9//!   lifecycle (configuration, control, gather phases).
10
11use std::collections::{HashMap, VecDeque};
12
13use crate::communication::{Channel, Message, MessageProcessingError};
14use crate::node_set::NodeSet;
15use crate::topology::{TopologyError, TopologyTree};
16
17use thiserror::Error;
18
19// ============================================================================
20// Errors
21// ============================================================================
22
23/// Errors raised during route resolving operations.
24#[derive(Error, Debug)]
25pub enum RouteResolvingError {
26    /// Invalid root or gateway node.
27    #[error("Invalid root or gateway node: {0}")]
28    InvalidRoot(String),
29
30    /// No route available to the destination.
31    #[error("No route available to {0}")]
32    NoRoute(String),
33
34    /// Destination host is unreachable.
35    #[error("Invalid destination: {0}, host is unreachable")]
36    Unreachable(String),
37
38    /// Source equals destination.
39    #[error("Invalid resolution request: {0} -> {1}")]
40    SameSourceDest(String, String),
41
42    /// Topology error.
43    #[error("Topology error: {0}")]
44    Topology(#[from] TopologyError),
45
46    /// NodeSet error.
47    #[error("NodeSet error: {0}")]
48    NodeSet(#[from] crate::node_set::NodeSetError),
49}
50
51/// Errors raised by [`PropagationChannel::recv`].
52#[derive(Error, Debug)]
53pub enum RecvError {
54    /// Protocol/message-level failure (invalid payload, parse errors, ...).
55    #[error("{0}")]
56    Message(#[from] MessageProcessingError),
57
58    /// Gateway runtime error received after channel setup. Upstream raises
59    /// `TopologyError("%s: %s" % (gateway, reason))` here
60    /// (PropagationChannel.recv_ctl); the rendered text is kept identical,
61    /// e.g. "gw1: bad news".
62    #[error("{0}")]
63    Gateway(String),
64}
65
66// ============================================================================
67// PropagationTreeRouter
68// ============================================================================
69
70/// A routing table entry: (reachable network, next-hop gateways).
71#[derive(Debug, Clone)]
72pub struct RouteEntry {
73    /// Set of all nodes reachable through this route (transitive children).
74    network: NodeSet,
75    /// Direct next-hop gateway(s) for this route.
76    nexthops: NodeSet,
77}
78
79/// Performs route resolving operations within a propagation tree.
80///
81/// The router parses the topology tree to generate a routing table, then
82/// provides [`next_hop()`](PropagationTreeRouter::next_hop) and
83/// [`dispatch()`](PropagationTreeRouter::dispatch) methods for directing
84/// messages to the correct gateway.
85///
86/// Load balancing is achieved by tracking the number of connections
87/// (fanin) per gateway node.
88#[derive(Debug)]
89pub struct PropagationTreeRouter {
90    /// The root node of this router.
91    root: String,
92    /// Fanout setting (0 = no limit).
93    fanout: usize,
94    /// Connections count per gateway node (for load balancing).
95    nodes_fanin: HashMap<String, usize>,
96    /// Routing table: list of (network, nexthops).
97    table: Vec<RouteEntry>,
98    /// Set of nodes marked as unreachable.
99    unreachable_hosts: NodeSet,
100}
101
102impl PropagationTreeRouter {
103    /// Create a new router from a root node and topology tree.
104    ///
105    /// The routing table is generated by traversing the tree from the root
106    /// node's position downward.
107    ///
108    /// # Errors
109    /// Returns [`RouteResolvingError::InvalidRoot`] if the root node cannot
110    /// be found in the topology.
111    pub fn new(
112        root: &str,
113        topology: &TopologyTree,
114        fanout: usize,
115    ) -> Result<Self, RouteResolvingError> {
116        let mut router = Self {
117            root: root.to_string(),
118            fanout,
119            nodes_fanin: HashMap::new(),
120            table: Vec::new(),
121            unreachable_hosts: NodeSet::new(),
122        };
123        router.table_generate(root, topology)?;
124        Ok(router)
125    }
126
127    /// Generate the routing table from the topology tree.
128    ///
129    /// For each direct child group of the root, compute the set of all
130    /// transitively reachable nodes (the "network") and store it alongside
131    /// the next-hop gateway nodeset.
132    fn table_generate(
133        &mut self,
134        root: &str,
135        topology: &TopologyTree,
136    ) -> Result<(), RouteResolvingError> {
137        // Validate the root exists in the topology
138        let _root_group = topology
139            .find_nodegroup(root)
140            .map_err(|_| RouteResolvingError::InvalidRoot(root.to_string()))?;
141
142        self.table.clear();
143
144        // We need to find the root TopologyNodeGroup and enumerate its
145        // direct children. Each child group becomes a route entry, with
146        // its "network" being all transitively reachable nodes below it.
147        let root_tng = topology
148            .root()
149            .ok_or_else(|| RouteResolvingError::InvalidRoot(root.to_string()))?;
150
151        // The root of the topology tree should contain our root node.
152        // If the root_tng doesn't contain our node, we need to find
153        // the subtree rooted at our node.
154        let subtree_root = Self::find_group_containing(root_tng, root)
155            .ok_or_else(|| RouteResolvingError::InvalidRoot(root.to_string()))?;
156
157        for child in subtree_root.children() {
158            let mut network = NodeSet::new();
159            Self::collect_reachable(child, &mut network);
160            self.table.push(RouteEntry {
161                network,
162                nexthops: child.nodeset.clone(),
163            });
164        }
165
166        Ok(())
167    }
168
169    /// Find the TopologyNodeGroup that contains the given node name.
170    fn find_group_containing<'a>(
171        group: &'a crate::topology::TopologyNodeGroup,
172        node: &str,
173    ) -> Option<&'a crate::topology::TopologyNodeGroup> {
174        if group.nodeset.contains(node) {
175            return Some(group);
176        }
177        for child in group.children() {
178            if let Some(found) = Self::find_group_containing(child, node) {
179                return Some(found);
180            }
181        }
182        None
183    }
184
185    /// Recursively collect all nodes reachable below a group (its children,
186    /// grandchildren, etc.) into `dest`. Does NOT include the group itself.
187    fn collect_reachable(group: &crate::topology::TopologyNodeGroup, dest: &mut NodeSet) {
188        for child in group.children() {
189            dest.update(&child.nodeset);
190            Self::collect_reachable(child, dest);
191        }
192    }
193
194    /// Dispatch nodes from a target nodeset to directly connected gateways.
195    ///
196    /// Acts as an iterator, yielding `(gateway_nodeset, target_nodes)` pairs.
197    /// Provides load balancing between gateways. Remaining nodes that don't
198    /// match any route are treated as directly connected.
199    pub fn dispatch(&self, dst: &NodeSet) -> Vec<(NodeSet, NodeSet)> {
200        let mut remaining = dst.clone();
201        let mut results = Vec::new();
202
203        // Check for remote targets requiring a gateway
204        for entry in &self.table {
205            let dst_inter = entry.network.intersection(&remaining);
206            if !dst_inter.is_empty() {
207                remaining.difference_update(&dst_inter);
208                // For each host in the intersection, find best next hop
209                for host_name in dst_inter.iter() {
210                    if let Ok(hop) = self.next_hop_str(&host_name) {
211                        let mut hop_ns = NodeSet::new();
212                        let _ = hop_ns.update_str(&hop);
213                        let mut host_ns = NodeSet::new();
214                        let _ = host_ns.update_str(&host_name);
215                        results.push((hop_ns, host_ns));
216                    }
217                }
218            }
219        }
220
221        // Remaining nodes are directly connected
222        if !remaining.is_empty() {
223            results.push((remaining.clone(), remaining));
224        }
225
226        results
227    }
228
229    /// Perform next-hop resolution for a single destination node.
230    ///
231    /// Returns the best gateway to use based on current load (connections
232    /// tracked in `nodes_fanin`).
233    pub fn next_hop(&mut self, dst: &NodeSet) -> Result<NodeSet, RouteResolvingError> {
234        let dst_str = dst.to_string();
235
236        // Check unreachable
237        if !self.unreachable_hosts.intersection(dst).is_empty() {
238            return Err(RouteResolvingError::Unreachable(dst_str));
239        }
240
241        // Can't resolve if source == destination
242        if self.root == dst_str {
243            return Err(RouteResolvingError::SameSourceDest(
244                self.root.clone(),
245                dst_str,
246            ));
247        }
248
249        for entry in &self.table {
250            // destination contained in current network
251            if !entry.network.intersection(dst).is_empty() {
252                let res = self.best_next_hop(&entry.nexthops)?;
253                let res_str = res.to_string();
254                *self.nodes_fanin.entry(res_str).or_insert(0) += dst.len();
255                return Ok(res);
256            }
257            // destination is a direct next-hop
258            if !entry.nexthops.intersection(dst).is_empty() {
259                return Ok(dst.clone());
260            }
261        }
262
263        Err(RouteResolvingError::NoRoute(dst_str))
264    }
265
266    /// String-based next_hop for convenience (non-mutating for dispatch).
267    fn next_hop_str(&self, dst: &str) -> Result<String, RouteResolvingError> {
268        // Check unreachable
269        if self.unreachable_hosts.contains(dst) {
270            return Err(RouteResolvingError::Unreachable(dst.to_string()));
271        }
272
273        if self.root == dst {
274            return Err(RouteResolvingError::SameSourceDest(
275                self.root.clone(),
276                dst.to_string(),
277            ));
278        }
279
280        let dst_ns = NodeSet::parse(dst)?;
281
282        for entry in &self.table {
283            if !entry.network.intersection(&dst_ns).is_empty() {
284                let res = self.best_next_hop(&entry.nexthops)?;
285                return Ok(res.to_string());
286            }
287            if !entry.nexthops.intersection(&dst_ns).is_empty() {
288                return Ok(dst.to_string());
289            }
290        }
291
292        Err(RouteResolvingError::NoRoute(dst.to_string()))
293    }
294
295    /// Mark a node as unreachable.
296    pub fn mark_unreachable(&mut self, dst: &str) {
297        let _ = self.unreachable_hosts.update_str(dst);
298    }
299
300    /// Find the best next-hop from a set of candidates.
301    ///
302    /// Selects the gateway with the fewest current connections (load balancing).
303    fn best_next_hop(&self, candidates: &NodeSet) -> Result<NodeSet, RouteResolvingError> {
304        let available = candidates.difference(&self.unreachable_hosts);
305        if available.is_empty() {
306            return Err(RouteResolvingError::NoRoute(
307                "all candidates unreachable".into(),
308            ));
309        }
310
311        let mut best: Option<String> = None;
312        let mut best_connections = usize::MAX;
313
314        for host in available.iter() {
315            let connections = self.nodes_fanin.get(&host).copied().unwrap_or(0);
316            if connections < best_connections {
317                best_connections = connections;
318                best = Some(host);
319            }
320        }
321
322        match best {
323            Some(host) => {
324                let mut ns = NodeSet::new();
325                let _ = ns.update_str(&host);
326                Ok(ns)
327            }
328            None => Err(RouteResolvingError::NoRoute(
329                "no candidate available".into(),
330            )),
331        }
332    }
333
334    /// Get the root node name.
335    pub fn root(&self) -> &str {
336        &self.root
337    }
338
339    /// Get the fanout setting.
340    pub fn fanout(&self) -> usize {
341        self.fanout
342    }
343
344    /// Get the routing table for inspection.
345    pub fn table(&self) -> &[RouteEntry] {
346        &self.table
347    }
348}
349
350// ============================================================================
351// PropagationChannel
352// ============================================================================
353
354/// State of a PropagationChannel's finite state machine.
355#[derive(Debug, Clone, Copy, PartialEq, Eq)]
356pub enum ChannelState {
357    /// Initial state — channel not yet opened.
358    Init,
359    /// Configuration sent, waiting for ACK.
360    Config,
361    /// Control message sent, waiting for ACK.
362    Control,
363    /// Gathering results from the subtree.
364    Gather,
365    /// Channel is closed.
366    Closed,
367}
368
369/// Admin-node propagation channel.
370///
371/// Manages the lifecycle of communication with a gateway node:
372/// 1. **Init**: Send configuration (serialized topology).
373/// 2. **Config**: Wait for ACK, then send control message (command).
374/// 3. **Control**: Wait for ACK, then enter gather state.
375/// 4. **Gather**: Collect results (stdout, stderr, retcodes, timeouts, routing).
376///
377/// This is a state machine that drives the [`Channel`] transport.
378#[derive(Debug)]
379pub struct PropagationChannel {
380    /// The underlying communication channel.
381    pub channel: Channel,
382    /// The gateway node this channel connects to.
383    pub gateway: String,
384    /// Current FSM state.
385    pub state: ChannelState,
386    /// Queue of control messages waiting to be sent, with the target nodes
387    /// whose pickup event must fire once the message is actually sent.
388    /// Mirrors upstream's `self._sendq` of (ctl, pickup_worker, pickup_nodes).
389    send_queue: VecDeque<(Message, Vec<String>)>,
390    /// Pickup notifications produced when a CTL actually goes on the wire
391    /// (upstream #594: `ev_pickup` fires only when the command really left
392    /// the local process). Drained by the caller (TreeWorker), which
393    /// forwards them as `ev_pickup`. write()/eof CTLs never add entries.
394    pub pickup_events: Vec<String>,
395    /// Last return code from the gateway process.
396    pub last_rc: Option<i32>,
397    /// Write history for tracking ACKs to write operations.
398    /// Each entry: (msgid, nodes, byte_count).
399    write_history: VecDeque<(u64, String, usize)>,
400    /// Collected results: (node, stream_name, data).
401    pub results: Vec<PropagationResult>,
402}
403
404/// A result received from the propagation subtree.
405#[derive(Debug, Clone)]
406pub enum PropagationResult {
407    /// Standard output line from a node.
408    StdOut { node: String, data: Vec<u8> },
409    /// Standard error line from a node.
410    StdErr { node: String, data: Vec<u8> },
411    /// Return code from a node.
412    Retcode { node: String, rc: i32 },
413    /// Timeout for a node.
414    Timeout { node: String },
415    /// Routing event.
416    Routing {
417        event: String,
418        gateway: String,
419        targets: String,
420    },
421}
422
423/// Split a byte buffer into lines, mirroring Python's `bytes.splitlines()`
424/// for the ASCII line boundaries used by the tree protocol (\n, \r\n, \r).
425/// Lines are returned without their terminator; a trailing partial line is
426/// kept. Matches the oracle's per-line stderr/stdout reporting.
427fn split_lines(buf: &[u8]) -> Vec<Vec<u8>> {
428    let mut lines = Vec::new();
429    let mut start = 0;
430    let mut i = 0;
431    while i < buf.len() {
432        if buf[i] == b'\n' || buf[i] == b'\r' {
433            lines.push(buf[start..i].to_vec());
434            if buf[i] == b'\r' && i + 1 < buf.len() && buf[i + 1] == b'\n' {
435                i += 1;
436            }
437            i += 1;
438            start = i;
439        } else {
440            i += 1;
441        }
442    }
443    if start < buf.len() {
444        lines.push(buf[start..].to_vec());
445    }
446    lines
447}
448
449impl PropagationChannel {
450    /// Create a new propagation channel for a gateway.
451    pub fn new(gateway: &str) -> Self {
452        Self {
453            channel: Channel::new(true), // initiator
454            gateway: gateway.to_string(),
455            state: ChannelState::Init,
456            send_queue: VecDeque::new(),
457            pickup_events: Vec::new(),
458            last_rc: None,
459            write_history: VecDeque::new(),
460            results: Vec::new(),
461        }
462    }
463
464    /// Start the propagation channel — send configuration.
465    ///
466    /// Serializes the topology and sends it as a ConfigurationMessage.
467    pub fn start(&mut self, topology_bytes: &[u8]) {
468        // Open the channel
469        self.channel.opened = true;
470
471        // Send configuration message
472        let mut cfg = Message::configuration(&self.gateway);
473        cfg.data_encode(topology_bytes);
474        self.channel.send(&cfg);
475        self.state = ChannelState::Config;
476    }
477
478    /// Actually push a CTL message on the wire.
479    ///
480    /// Mirrors upstream `PropagationChannel._send_ctl`: if `pickup_nodes` is
481    /// non-empty, a pickup notification is recorded for every target node
482    /// AFTER the send — so a pickup event only fires when the command really
483    /// left the local process (#594). write()/set_write_eof() CTLs never
484    /// carry pickup nodes.
485    fn send_ctl(&mut self, ctl: Message, pickup_nodes: Vec<String>) {
486        self.channel.send(&ctl);
487        self.pickup_events.extend(pickup_nodes);
488    }
489
490    /// Enqueue a control message for sending.
491    ///
492    /// If the channel is already set up and the queue is empty, the message
493    /// is sent immediately. Otherwise it's queued for later.
494    pub fn send_queued(&mut self, msg: Message) {
495        self.send_queued_pickup(msg, Vec::new());
496    }
497
498    /// Helper used to send a message, using the msg queue if needed, with
499    /// optional pickup nodes (mirrors upstream `send_queued(ctl,
500    /// pickup_worker=..., pickup_nodes=...)`).
501    fn send_queued_pickup(&mut self, msg: Message, pickup_nodes: Vec<String>) {
502        if self.channel.setup && self.send_queue.is_empty() {
503            // send now if channel is setup and sendq empty
504            self.send_ctl(msg, pickup_nodes);
505        } else {
506            self.send_queue.push_back((msg, pickup_nodes));
507        }
508    }
509
510    /// Send one queued message, if any (mirrors upstream `send_dequeue`:
511    /// pickup notifications fire here too, at actual send time).
512    fn send_dequeue(&mut self) {
513        if let Some((msg, pickup_nodes)) = self.send_queue.pop_front() {
514            self.send_ctl(msg, pickup_nodes);
515        }
516    }
517
518    /// Create and enqueue a shell command for execution through the channel.
519    ///
520    /// Mirrors upstream `PropagationChannel.shell`: the target nodes are
521    /// registered for pickup notification, which fires only when the CTL is
522    /// actually written on the wire (#594).
523    pub fn shell(&mut self, nodes: &str, command: &[u8], worker_id: u64) {
524        let mut ctl = Message::control(worker_id);
525        if let Message::Control {
526            ref mut action,
527            ref mut target,
528            ..
529        } = ctl
530        {
531            *action = "shell".to_string();
532            *target = nodes.to_string();
533        }
534        ctl.data_encode(command);
535        // Pass nodes so the pickup notification fires only when the CTL
536        // actually leaves the local process (upstream #594).
537        let pickup_nodes = NodeSet::parse(nodes)
538            .map(|ns| ns.iter().collect())
539            .unwrap_or_else(|_| vec![nodes.to_string()]);
540        self.send_queued_pickup(ctl, pickup_nodes);
541    }
542
543    /// Create and enqueue a write operation through the channel.
544    pub fn write(&mut self, nodes: &str, buf: &[u8], worker_id: u64) {
545        let mut ctl = Message::control(worker_id);
546        if let Message::Control {
547            ref mut action,
548            ref mut target,
549            ..
550        } = ctl
551        {
552            *action = "write".to_string();
553            *target = nodes.to_string();
554        }
555        let msgid = ctl.msgid();
556        ctl.data_encode(buf);
557        self.write_history
558            .push_back((msgid, nodes.to_string(), buf.len()));
559        self.send_queued(ctl);
560    }
561
562    /// Create and enqueue an EOF operation through the channel.
563    pub fn set_write_eof(&mut self, nodes: &str, worker_id: u64) {
564        let mut ctl = Message::control(worker_id);
565        if let Message::Control {
566            ref mut action,
567            ref mut target,
568            ..
569        } = ctl
570        {
571            *action = "eof".to_string();
572            *target = nodes.to_string();
573        }
574        self.send_queued(ctl);
575    }
576
577    /// Report `buf` as stderr of `nodeset`, as seen from the gateway.
578    ///
579    /// Mirrors upstream `PropagationChannel._report_stderr`: a trailing
580    /// newline is appended before splitting so that an empty buffer still
581    /// yields one (empty) line (#249), and each line is reported separately
582    /// for every node of the nodeset.
583    fn report_stderr(&self, nodeset: &NodeSet, buf: &[u8]) -> Vec<PropagationResult> {
584        let mut terminated = buf.to_vec();
585        terminated.push(b'\n');
586        let mut results = Vec::new();
587        for line in split_lines(&terminated) {
588            for node in nodeset.iter() {
589                results.push(PropagationResult::StdErr {
590                    node,
591                    data: line.clone(),
592                });
593            }
594        }
595        results
596    }
597
598    /// Process an incoming message according to the current FSM state.
599    ///
600    /// Returns any propagation results generated by this message.
601    pub fn recv(&mut self, msg: Message) -> Result<Vec<PropagationResult>, RecvError> {
602        let mut new_results = Vec::new();
603
604        // EndMessage: close the channel (upstream: got EndMessage; closing)
605        if msg.ident() == "END" {
606            self.state = ChannelState::Closed;
607            self.channel.close();
608            return Ok(new_results);
609        }
610
611        // Non-routed stderr (srcid == 0): error messages received when the
612        // channel is not established yet, or gateway-related messages, are
613        // reported as stderr of the given nodes in ANY state — upstream
614        // handles this before the setup dispatch (90d3195-range).
615        if msg.ident() == "SER" && msg.srcid() == Some(0) {
616            let decoded = msg.data_decode()?;
617            let nodeset = NodeSet::parse(msg.nodes().unwrap_or_default()).unwrap_or_default();
618            let results = self.report_stderr(&nodeset, &decoded);
619            self.results.extend(results.clone());
620            return Ok(results);
621        }
622
623        // StartMessage: gateway greeting opens the channel
624        if msg.ident() == "CHA" {
625            self.channel.opened = true;
626            return Ok(new_results);
627        }
628
629        match self.state {
630            ChannelState::Config => {
631                // Waiting for ACK to configuration
632                match msg.ident() {
633                    "ACK" => {
634                        self.channel.setup = true;
635                        self.state = ChannelState::Gather;
636                        self.send_dequeue();
637                    }
638                    "ERR" => {
639                        // Gateway error before setup (eg. invalid CFG
640                        // payload): report the reason as gateway stderr
641                        // instead of dropping it (upstream 90d3195).
642                        if let Message::Error { reason, .. } = &msg {
643                            let gw = NodeSet::parse(&self.gateway).unwrap_or_default();
644                            let results = self.report_stderr(&gw, reason.as_bytes());
645                            self.results.extend(results.clone());
646                            return Ok(results);
647                        }
648                    }
649                    _ => {}
650                }
651            }
652            ChannelState::Control | ChannelState::Gather => {
653                match msg.ident() {
654                    "ACK" => {
655                        // Check if ACK matches a write history entry
656                        if let Message::Ack { ack, .. } = &msg {
657                            if let Some(back) = self.write_history.back() {
658                                if back.0 == *ack {
659                                    let _ = self.write_history.pop_back();
660                                }
661                            }
662                        }
663                        self.send_dequeue();
664                    }
665                    "OUT" => {
666                        if let Some(nodes_str) = msg.nodes() {
667                            if let Ok(decoded) = msg.data_decode() {
668                                let ns = NodeSet::parse(nodes_str).unwrap_or_default();
669                                for node in ns.iter() {
670                                    let result = PropagationResult::StdOut {
671                                        node,
672                                        data: decoded.clone(),
673                                    };
674                                    new_results.push(result);
675                                }
676                            }
677                        }
678                    }
679                    "SER" => {
680                        // Routed stderr: reported per line, like upstream
681                        // recv_ctl's `decoded + b'\n'` splitlines() loop.
682                        if let Some(nodes_str) = msg.nodes() {
683                            if let Ok(decoded) = msg.data_decode() {
684                                let ns = NodeSet::parse(nodes_str).unwrap_or_default();
685                                new_results.extend(self.report_stderr(&ns, &decoded));
686                            }
687                        }
688                    }
689                    "RET" => {
690                        if let Message::Retcode { retcode, nodes, .. } = &msg {
691                            let ns = NodeSet::parse(nodes).unwrap_or_default();
692                            for node in ns.iter() {
693                                new_results.push(PropagationResult::Retcode { node, rc: *retcode });
694                            }
695                        }
696                    }
697                    "TIM" => {
698                        if let Some(nodes_str) = msg.nodes() {
699                            let ns = NodeSet::parse(nodes_str).unwrap_or_default();
700                            for node in ns.iter() {
701                                new_results.push(PropagationResult::Timeout { node });
702                            }
703                        }
704                    }
705                    "RTR" => {
706                        if let Message::Routing {
707                            event,
708                            gateway,
709                            targets,
710                            ..
711                        } = &msg
712                        {
713                            new_results.push(PropagationResult::Routing {
714                                event: event.clone(),
715                                gateway: gateway.clone(),
716                                targets: targets.clone(),
717                            });
718                        }
719                    }
720                    "ERR" => {
721                        // Tree runtime error after setup: fatal to the
722                        // channel (upstream raises TopologyError(
723                        // "%s: %s" % (gateway, reason))).
724                        if let Message::Error { reason, .. } = &msg {
725                            self.channel.close();
726                            self.state = ChannelState::Closed;
727                            return Err(RecvError::Gateway(format!(
728                                "{}: {}",
729                                self.gateway, reason
730                            )));
731                        }
732                    }
733                    _ => {
734                        // Ignore unknown messages in gather state
735                    }
736                }
737            }
738            _ => {}
739        }
740
741        self.results.extend(new_results.clone());
742        Ok(new_results)
743    }
744
745    /// Handle the gateway process closing.
746    pub fn ev_hup(&mut self, rc: i32) {
747        self.last_rc = Some(rc);
748    }
749
750    /// Handle the channel closing.
751    pub fn ev_close(&mut self) {
752        self.state = ChannelState::Closed;
753    }
754
755    /// Get the current channel state.
756    pub fn current_state(&self) -> ChannelState {
757        self.state
758    }
759
760    /// Check if the channel is in gather state (ready for results).
761    pub fn is_gathering(&self) -> bool {
762        self.state == ChannelState::Gather
763    }
764
765    /// Check if the channel is closed.
766    pub fn is_closed(&self) -> bool {
767        self.state == ChannelState::Closed
768    }
769}
770
771// ============================================================================
772// Tests
773// ============================================================================
774
775#[cfg(test)]
776mod tests {
777    use super::*;
778    use crate::topology::{TopologyGraph, TopologyTree};
779
780    /// Helper to build a simple topology tree for testing.
781    /// Structure: admin -> gw1 -> node[1-4], gw2 -> node[5-8]
782    fn build_test_topology() -> TopologyTree {
783        let mut graph = TopologyGraph::new();
784        let admin = NodeSet::parse("admin").unwrap();
785        let gw1 = NodeSet::parse("gw1").unwrap();
786        let gw2 = NodeSet::parse("gw2").unwrap();
787        let nodes1 = NodeSet::parse("node[1-4]").unwrap();
788        let nodes2 = NodeSet::parse("node[5-8]").unwrap();
789
790        graph.add_route(admin.clone(), gw1.clone()).unwrap();
791        graph.add_route(admin, gw2.clone()).unwrap();
792        graph.add_route(gw1, nodes1).unwrap();
793        graph.add_route(gw2, nodes2).unwrap();
794        graph.to_tree("admin").unwrap()
795    }
796
797    // -- RouteResolvingError tests ------------------------------------------
798
799    #[test]
800    fn test_error_display() {
801        let err = RouteResolvingError::InvalidRoot("badnode".into());
802        assert!(err.to_string().contains("badnode"));
803
804        let err = RouteResolvingError::NoRoute("node99".into());
805        assert!(err.to_string().contains("node99"));
806
807        let err = RouteResolvingError::Unreachable("gw1".into());
808        assert!(err.to_string().contains("gw1"));
809
810        let err = RouteResolvingError::SameSourceDest("a".into(), "a".into());
811        assert!(err.to_string().contains("a"));
812    }
813
814    // -- PropagationTreeRouter tests ----------------------------------------
815
816    #[test]
817    fn test_router_creation() {
818        let tree = build_test_topology();
819        let router = PropagationTreeRouter::new("admin", &tree, 0).unwrap();
820        assert_eq!(router.root(), "admin");
821        assert_eq!(router.fanout(), 0);
822    }
823
824    #[test]
825    fn test_router_invalid_root() {
826        let tree = build_test_topology();
827        let result = PropagationTreeRouter::new("nonexistent", &tree, 0);
828        assert!(result.is_err());
829    }
830
831    #[test]
832    fn test_router_next_hop() {
833        let tree = build_test_topology();
834        let mut router = PropagationTreeRouter::new("admin", &tree, 0).unwrap();
835
836        // node1 should be reachable through gw1
837        let dst = NodeSet::parse("node1").unwrap();
838        let hop = router.next_hop(&dst).unwrap();
839        assert_eq!(hop.to_string(), "gw1");
840
841        // node5 should be reachable through gw2
842        let dst = NodeSet::parse("node5").unwrap();
843        let hop = router.next_hop(&dst).unwrap();
844        assert_eq!(hop.to_string(), "gw2");
845    }
846
847    #[test]
848    fn test_router_same_source_dest() {
849        let tree = build_test_topology();
850        let mut router = PropagationTreeRouter::new("admin", &tree, 0).unwrap();
851        let dst = NodeSet::parse("admin").unwrap();
852        let result = router.next_hop(&dst);
853        assert!(result.is_err());
854    }
855
856    #[test]
857    fn test_router_mark_unreachable() {
858        let tree = build_test_topology();
859        let mut router = PropagationTreeRouter::new("admin", &tree, 0).unwrap();
860
861        router.mark_unreachable("gw1");
862
863        // node1 goes through gw1, but gw1 is unreachable
864        // The best_next_hop should fail since the only candidate is unreachable
865        let dst = NodeSet::parse("node1").unwrap();
866        let result = router.next_hop(&dst);
867        assert!(result.is_err());
868    }
869
870    #[test]
871    fn test_router_dispatch_directly_connected() {
872        let tree = build_test_topology();
873        let router = PropagationTreeRouter::new("admin", &tree, 0).unwrap();
874
875        // Dispatch nodes not in any route -> treated as directly connected
876        let dst = NodeSet::parse("localnode").unwrap();
877        let results = router.dispatch(&dst);
878        assert_eq!(results.len(), 1);
879        assert_eq!(results[0].0.to_string(), "localnode");
880    }
881
882    #[test]
883    fn test_router_dispatch_remote() {
884        let tree = build_test_topology();
885        let router = PropagationTreeRouter::new("admin", &tree, 0).unwrap();
886
887        let dst = NodeSet::parse("node[1-2]").unwrap();
888        let results = router.dispatch(&dst);
889        // Should dispatch through gw1
890        assert!(!results.is_empty());
891        for (hop, _target) in &results {
892            assert_eq!(hop.to_string(), "gw1");
893        }
894    }
895
896    #[test]
897    fn test_router_load_balancing() {
898        // Build a topology with two gateways to same network
899        // admin -> gw[1-2] -> node[1-8]
900        let mut graph = TopologyGraph::new();
901        let admin = NodeSet::parse("admin").unwrap();
902        let gws = NodeSet::parse("gw[1-2]").unwrap();
903        let nodes = NodeSet::parse("node[1-8]").unwrap();
904        graph.add_route(admin, gws).unwrap();
905
906        // Each gw gets half the nodes
907        let gw1 = NodeSet::parse("gw1").unwrap();
908        let gw2 = NodeSet::parse("gw2").unwrap();
909        graph
910            .add_route(gw1, NodeSet::parse("node[1-4]").unwrap())
911            .unwrap();
912        graph
913            .add_route(gw2, NodeSet::parse("node[5-8]").unwrap())
914            .unwrap();
915
916        let tree = graph.to_tree("admin").unwrap();
917        let mut router = PropagationTreeRouter::new("admin", &tree, 0).unwrap();
918
919        // Route several nodes — should distribute across gateways
920        let d1 = NodeSet::parse("node1").unwrap();
921        let _h1 = router.next_hop(&d1).unwrap();
922        let d2 = NodeSet::parse("node5").unwrap();
923        let _h2 = router.next_hop(&d2).unwrap();
924
925        // Both should resolve to different gateways
926        // (node1 -> gw1, node5 -> gw2 based on topology)
927    }
928
929    // -- PropagationChannel tests -------------------------------------------
930
931    #[test]
932    fn test_channel_creation() {
933        let ch = PropagationChannel::new("gw1");
934        assert_eq!(ch.gateway, "gw1");
935        assert_eq!(ch.current_state(), ChannelState::Init);
936        assert!(!ch.is_gathering());
937        assert!(!ch.is_closed());
938    }
939
940    #[test]
941    fn test_channel_start() {
942        let mut ch = PropagationChannel::new("gw1");
943        ch.start(b"topology data");
944        assert_eq!(ch.current_state(), ChannelState::Config);
945        assert!(ch.channel.opened);
946
947        // Should have enqueued a configuration message
948        let outgoing = ch.channel.take_outgoing().unwrap();
949        let xml = String::from_utf8(outgoing).unwrap();
950        assert!(xml.contains("type=\"CFG\""));
951        assert!(xml.contains("gateway=\"gw1\""));
952    }
953
954    #[test]
955    fn test_channel_config_ack() {
956        let mut ch = PropagationChannel::new("gw1");
957        ch.start(b"topo");
958        assert_eq!(ch.state, ChannelState::Config);
959
960        // Receive ACK
961        let ack = Message::ack(0);
962        let results = ch.recv(ack).unwrap();
963        assert!(results.is_empty());
964        assert_eq!(ch.state, ChannelState::Gather);
965        assert!(ch.channel.setup);
966    }
967
968    #[test]
969    fn test_channel_shell_command() {
970        let mut ch = PropagationChannel::new("gw1");
971        ch.start(b"topo");
972
973        // ACK the config
974        ch.recv(Message::ack(0)).unwrap();
975
976        // Send shell command
977        ch.shell("node[1-4]", b"uname -a", 42);
978
979        // Should have outgoing message
980        let outgoing = ch.channel.take_outgoing().unwrap();
981        // skip the cfg message
982        let outgoing2 = ch.channel.take_outgoing().unwrap();
983        let xml = String::from_utf8(outgoing2).unwrap();
984        assert!(xml.contains("type=\"CTL\""));
985        assert!(xml.contains("action=\"shell\""));
986        assert!(xml.contains("target=\"node[1-4]\""));
987    }
988
989    #[test]
990    fn test_channel_recv_stdout() {
991        let mut ch = PropagationChannel::new("gw1");
992        ch.start(b"topo");
993        ch.recv(Message::ack(0)).unwrap();
994
995        // Simulate stdout message
996        let mut stdout = Message::stdout("node[1-2]", 1);
997        stdout.data_encode(b"hello world");
998        let results = ch.recv(stdout).unwrap();
999
1000        assert_eq!(results.len(), 2);
1001        for r in &results {
1002            if let PropagationResult::StdOut { data, .. } = r {
1003                assert_eq!(data, b"hello world");
1004            } else {
1005                panic!("expected StdOut");
1006            }
1007        }
1008    }
1009
1010    #[test]
1011    fn test_channel_recv_stderr() {
1012        let mut ch = PropagationChannel::new("gw1");
1013        ch.start(b"topo");
1014        ch.recv(Message::ack(0)).unwrap();
1015
1016        let mut stderr = Message::stderr("node1", 1);
1017        stderr.data_encode(b"error output");
1018        let results = ch.recv(stderr).unwrap();
1019
1020        assert_eq!(results.len(), 1);
1021        if let PropagationResult::StdErr { node, data } = &results[0] {
1022            assert_eq!(node, "node1");
1023            assert_eq!(data, b"error output");
1024        } else {
1025            panic!("expected StdErr");
1026        }
1027    }
1028
1029    #[test]
1030    fn test_channel_recv_retcode() {
1031        let mut ch = PropagationChannel::new("gw1");
1032        ch.start(b"topo");
1033        ch.recv(Message::ack(0)).unwrap();
1034
1035        let ret = Message::retcode("node[1-3]", 0, 1);
1036        let results = ch.recv(ret).unwrap();
1037
1038        assert_eq!(results.len(), 3);
1039        for r in &results {
1040            if let PropagationResult::Retcode { rc, .. } = r {
1041                assert_eq!(*rc, 0);
1042            } else {
1043                panic!("expected Retcode");
1044            }
1045        }
1046    }
1047
1048    #[test]
1049    fn test_channel_recv_timeout() {
1050        let mut ch = PropagationChannel::new("gw1");
1051        ch.start(b"topo");
1052        ch.recv(Message::ack(0)).unwrap();
1053
1054        let tim = Message::timeout("node[5-6]", 1);
1055        let results = ch.recv(tim).unwrap();
1056
1057        assert_eq!(results.len(), 2);
1058        for r in &results {
1059            assert!(matches!(r, PropagationResult::Timeout { .. }));
1060        }
1061    }
1062
1063    #[test]
1064    fn test_channel_recv_routing() {
1065        let mut ch = PropagationChannel::new("gw1");
1066        ch.start(b"topo");
1067        ch.recv(Message::ack(0)).unwrap();
1068
1069        let rtr = Message::routing("connect", "gw2", "node[10-20]", 1);
1070        let results = ch.recv(rtr).unwrap();
1071
1072        assert_eq!(results.len(), 1);
1073        if let PropagationResult::Routing {
1074            event,
1075            gateway,
1076            targets,
1077        } = &results[0]
1078        {
1079            assert_eq!(event, "connect");
1080            assert_eq!(gateway, "gw2");
1081            assert_eq!(targets, "node[10-20]");
1082        } else {
1083            panic!("expected Routing");
1084        }
1085    }
1086
1087    #[test]
1088    fn test_channel_recv_error() {
1089        let mut ch = PropagationChannel::new("gw1");
1090        ch.start(b"topo");
1091        ch.recv(Message::ack(0)).unwrap();
1092
1093        let err = Message::error("something went wrong");
1094        let result = ch.recv(err);
1095        assert!(result.is_err());
1096    }
1097
1098    #[test]
1099    fn test_channel_recv_end() {
1100        let mut ch = PropagationChannel::new("gw1");
1101        ch.start(b"topo");
1102        ch.recv(Message::ack(0)).unwrap();
1103
1104        let end = Message::end();
1105        ch.recv(end).unwrap();
1106        assert!(ch.is_closed());
1107    }
1108
1109    #[test]
1110    fn test_channel_ev_hup() {
1111        let mut ch = PropagationChannel::new("gw1");
1112        ch.ev_hup(1);
1113        assert_eq!(ch.last_rc, Some(1));
1114    }
1115
1116    #[test]
1117    fn test_channel_ev_close() {
1118        let mut ch = PropagationChannel::new("gw1");
1119        ch.ev_close();
1120        assert!(ch.is_closed());
1121    }
1122
1123    #[test]
1124    fn test_channel_send_queue() {
1125        let mut ch = PropagationChannel::new("gw1");
1126        ch.start(b"topo");
1127        // Before ACK, messages should be queued
1128        ch.shell("node1", b"echo hi", 1);
1129
1130        // Only the config message should be in outbox
1131        let _cfg = ch.channel.take_outgoing().unwrap();
1132        assert!(ch.channel.take_outgoing().is_none());
1133
1134        // After ACK, queued message should be sent
1135        ch.recv(Message::ack(0)).unwrap();
1136        let ctl = ch.channel.take_outgoing().unwrap();
1137        let xml = String::from_utf8(ctl).unwrap();
1138        assert!(xml.contains("type=\"CTL\""));
1139    }
1140
1141    #[test]
1142    fn test_channel_write_and_eof() {
1143        let mut ch = PropagationChannel::new("gw1");
1144        ch.start(b"topo");
1145        ch.recv(Message::ack(0)).unwrap();
1146
1147        ch.write("node1", b"input data", 1);
1148        ch.set_write_eof("node1", 1);
1149
1150        let _ = ch.channel.take_outgoing(); // cfg
1151        let write_msg = ch.channel.take_outgoing().unwrap();
1152        let xml = String::from_utf8(write_msg).unwrap();
1153        assert!(xml.contains("action=\"write\""));
1154
1155        let eof_msg = ch.channel.take_outgoing().unwrap();
1156        let xml2 = String::from_utf8(eof_msg).unwrap();
1157        assert!(xml2.contains("action=\"eof\""));
1158    }
1159
1160    #[test]
1161    fn test_channel_results_accumulate() {
1162        let mut ch = PropagationChannel::new("gw1");
1163        ch.start(b"topo");
1164        ch.recv(Message::ack(0)).unwrap();
1165
1166        // Send multiple results
1167        let mut out1 = Message::stdout("node1", 1);
1168        out1.data_encode(b"line1");
1169        ch.recv(out1).unwrap();
1170
1171        let mut out2 = Message::stdout("node2", 1);
1172        out2.data_encode(b"line2");
1173        ch.recv(out2).unwrap();
1174
1175        let ret = Message::retcode("node[1-2]", 0, 1);
1176        ch.recv(ret).unwrap();
1177
1178        // Should have accumulated 4 results (2 stdout + 2 retcode)
1179        assert_eq!(ch.results.len(), 4);
1180    }
1181
1182    // -- Upstream 1.10.1 ports (90d3195, Propagation._send_ctl/_report_stderr)
1183
1184    /// Mirror of TreeHeadChannelErrorTest::test_head_channel_gw_error_before_setup:
1185    /// an ErrorMessage received before the gateway ACK is reported as stderr
1186    /// of the gateway node (instead of being dropped).
1187    #[test]
1188    fn test_head_channel_gw_error_before_setup() {
1189        let mut ch = PropagationChannel::new("gw1");
1190        ch.start(b"topo");
1191        assert_eq!(ch.state, ChannelState::Config);
1192
1193        let err =
1194            Message::error("Message CFG has an invalid payload (unsupported pickle protocol: 5)");
1195        let results = ch.recv(err).unwrap();
1196
1197        assert_eq!(results.len(), 1);
1198        match &results[0] {
1199            PropagationResult::StdErr { node, data } => {
1200                assert_eq!(node, "gw1");
1201                assert_eq!(
1202                    data,
1203                    b"Message CFG has an invalid payload (unsupported pickle protocol: 5)"
1204                );
1205            }
1206            other => panic!("expected StdErr, got {:?}", other),
1207        }
1208        // not a fatal error at this stage: channel stays in Config
1209        assert_eq!(ch.state, ChannelState::Config);
1210    }
1211
1212    /// Mirror of TreeHeadChannelErrorTest::test_head_channel_gw_error_empty_reason:
1213    /// an empty reason is still reported as one (empty) stderr line (#249).
1214    #[test]
1215    fn test_head_channel_gw_error_empty_reason() {
1216        let mut ch = PropagationChannel::new("gw1");
1217        ch.start(b"topo");
1218
1219        let results = ch.recv(Message::error("")).unwrap();
1220        assert_eq!(results.len(), 1);
1221        match &results[0] {
1222            PropagationResult::StdErr { node, data } => {
1223                assert_eq!(node, "gw1");
1224                assert_eq!(data, b"");
1225            }
1226            other => panic!("expected StdErr, got {:?}", other),
1227        }
1228    }
1229
1230    /// Mirror of TreeHeadChannelErrorTest::test_head_channel_gw_error_after_setup:
1231    /// an ErrorMessage after setup raises a TopologyError-style failure
1232    /// ("gw: reason") and produces NO stderr results.
1233    #[test]
1234    fn test_head_channel_gw_error_after_setup() {
1235        let mut ch = PropagationChannel::new("gw1");
1236        ch.start(b"topo");
1237        ch.recv(Message::ack(0)).unwrap();
1238        assert!(ch.channel.setup);
1239
1240        let result = ch.recv(Message::error("bad news"));
1241        match result.unwrap_err() {
1242            RecvError::Gateway(text) => assert_eq!(text, "gw1: bad news"),
1243            other => panic!("expected Gateway error, got {:?}", other),
1244        }
1245        // no stderr results were produced
1246        assert!(ch.results.is_empty());
1247        // fatal: channel is closed
1248        assert!(ch.channel.closed);
1249    }
1250
1251    /// Mirror of TreeHeadChannelErrorTest::test_head_channel_err_invalid_tag
1252    /// (logic-level): a malformed tag on the wire becomes a synthetic SER
1253    /// message (srcid 0) which the channel reports as gateway stderr; the
1254    /// channel is closed (fatal).
1255    #[test]
1256    fn test_head_channel_err_invalid_tag_reported_as_stderr() {
1257        let mut ch = PropagationChannel::new("gw1");
1258        // gateway greeting opens the channel
1259        let msgs = ch.channel.feed("<channel version=\"1.10.1\">").unwrap();
1260        for m in msgs {
1261            ch.recv(m).unwrap();
1262        }
1263        assert!(ch.channel.opened);
1264
1265        // invalid tag on the wire: parse fails, initiator side converts to
1266        // a synthetic stderr message (6f45bf5), then closes the channel
1267        let err = ch.channel.feed("<foo></foo>").unwrap_err();
1268        let synthetic = ch
1269            .channel
1270            .handle_channel_error("gw1", &err)
1271            .expect("initiator must produce a synthetic stderr message");
1272        assert!(ch.channel.closed);
1273
1274        let results = ch.recv(synthetic).unwrap();
1275        assert_eq!(results.len(), 1);
1276        match &results[0] {
1277            PropagationResult::StdErr { node, data } => {
1278                assert_eq!(node, "gw1");
1279                assert_eq!(data, b"Invalid starting tag foo");
1280            }
1281            other => panic!("expected StdErr, got {:?}", other),
1282        }
1283    }
1284
1285    /// Mirror of TreeHeadChannelErrorTest::test_head_channel_err_parse_error:
1286    /// malformed XML is reported as gateway stderr containing "not
1287    /// well-formed", then the channel closes.
1288    #[test]
1289    fn test_head_channel_err_parse_error_reported_as_stderr() {
1290        let mut ch = PropagationChannel::new("gw1");
1291        let msgs = ch.channel.feed("<channel version=\"1.10.1\">").unwrap();
1292        for m in msgs {
1293            ch.recv(m).unwrap();
1294        }
1295
1296        let err = ch
1297            .channel
1298            .feed("<message type=\"ABC\"</message>")
1299            .unwrap_err();
1300        let synthetic = ch
1301            .channel
1302            .handle_channel_error("gw1", &err)
1303            .expect("initiator must produce a synthetic stderr message");
1304        assert!(ch.channel.closed);
1305
1306        let results = ch.recv(synthetic).unwrap();
1307        assert_eq!(results.len(), 1);
1308        match &results[0] {
1309            PropagationResult::StdErr { node, data } => {
1310                assert_eq!(node, "gw1");
1311                assert!(
1312                    String::from_utf8_lossy(data).contains("not well-formed"),
1313                    "expected 'not well-formed' in {:?}",
1314                    data
1315                );
1316            }
1317            other => panic!("expected StdErr, got {:?}", other),
1318        }
1319    }
1320
1321    /// Non-routed stderr (SER with srcid 0) is reported per line in ANY
1322    /// state — here in Gather (upstream: handled before setup dispatch).
1323    #[test]
1324    fn test_channel_recv_nonrouted_stderr_srcid0() {
1325        let mut ch = PropagationChannel::new("gw1");
1326        ch.start(b"topo");
1327        ch.recv(Message::ack(0)).unwrap();
1328
1329        let mut ser = Message::stderr("node[1-2]", 0);
1330        ser.data_encode(b"line a\nline b");
1331        let results = ch.recv(ser).unwrap();
1332
1333        // 2 lines x 2 nodes, per-line ordering (line outer, node inner)
1334        let collected: Vec<(&str, &[u8])> = results
1335            .iter()
1336            .map(|r| match r {
1337                PropagationResult::StdErr { node, data } => (node.as_str(), data.as_slice()),
1338                other => panic!("expected StdErr, got {:?}", other),
1339            })
1340            .collect();
1341        assert_eq!(
1342            collected,
1343            vec![
1344                ("node1", b"line a".as_slice()),
1345                ("node2", b"line a".as_slice()),
1346                ("node1", b"line b".as_slice()),
1347                ("node2", b"line b".as_slice()),
1348            ]
1349        );
1350    }
1351
1352    /// Routed stderr is also reported per line (upstream recv_ctl's
1353    /// `decoded + b'\n'` splitlines loop), without trailing newline kept.
1354    #[test]
1355    fn test_channel_recv_routed_stderr_per_line() {
1356        let mut ch = PropagationChannel::new("gw1");
1357        ch.start(b"topo");
1358        ch.recv(Message::ack(0)).unwrap();
1359
1360        let mut ser = Message::stderr("node1", 7);
1361        ser.data_encode(b"first\nsecond\n");
1362        let results = ch.recv(ser).unwrap();
1363
1364        // like upstream `decoded + b'\n'` + splitlines(): a payload already
1365        // ending with '\n' yields a trailing empty line too
1366        let datas: Vec<&[u8]> = results
1367            .iter()
1368            .map(|r| match r {
1369                PropagationResult::StdErr { data, .. } => data.as_slice(),
1370                other => panic!("expected StdErr, got {:?}", other),
1371            })
1372            .collect();
1373        assert_eq!(
1374            datas,
1375            vec![b"first".as_slice(), b"second".as_slice(), b"".as_slice()]
1376        );
1377    }
1378
1379    /// _report_stderr semantics: an empty buffer still yields one empty
1380    /// line per node (upstream #249 — trailing newline before splitlines).
1381    #[test]
1382    fn test_report_stderr_empty_buffer_yields_empty_line() {
1383        let ch = PropagationChannel::new("gw1");
1384        let ns = NodeSet::parse("node1").unwrap();
1385        let results = ch.report_stderr(&ns, b"");
1386        assert_eq!(results.len(), 1);
1387        match &results[0] {
1388            PropagationResult::StdErr { node, data } => {
1389                assert_eq!(node, "node1");
1390                assert_eq!(data, b"");
1391            }
1392            other => panic!("expected StdErr, got {:?}", other),
1393        }
1394    }
1395
1396    /// CRLF and CR line boundaries split like Python's bytes.splitlines().
1397    #[test]
1398    fn test_split_lines_boundaries() {
1399        assert_eq!(
1400            split_lines(b"a\r\nb\rc\n"),
1401            vec![b"a".to_vec(), b"b".to_vec(), b"c".to_vec()]
1402        );
1403        assert_eq!(split_lines(b"no-newline"), vec![b"no-newline".to_vec()]);
1404        assert_eq!(split_lines(b"\n"), vec![b"".to_vec()]);
1405        assert!(split_lines(b"").is_empty());
1406    }
1407
1408    /// #594 pickup semantics: shell() registers pickup nodes, but the
1409    /// notification fires only when the CTL actually goes on the wire —
1410    /// immediately when setup, or later via send_dequeue after the ACK.
1411    #[test]
1412    fn test_send_ctl_pickup_fires_on_actual_send() {
1413        let mut ch = PropagationChannel::new("gw1");
1414        ch.start(b"topo");
1415
1416        // queued before setup: no pickup yet
1417        ch.shell("node[1-2]", b"echo hi", 1);
1418        assert!(ch.pickup_events.is_empty());
1419
1420        // ACK dequeues and sends the CTL: pickup fires now
1421        ch.recv(Message::ack(0)).unwrap();
1422        assert_eq!(
1423            ch.pickup_events,
1424            vec!["node1".to_string(), "node2".to_string()]
1425        );
1426
1427        // setup channel: shell sends immediately, pickup fires at once
1428        ch.shell("node3", b"echo hi", 1);
1429        assert_eq!(
1430            ch.pickup_events,
1431            vec![
1432                "node1".to_string(),
1433                "node2".to_string(),
1434                "node3".to_string()
1435            ]
1436        );
1437    }
1438
1439    /// write() and set_write_eof() never carry pickup nodes (upstream:
1440    /// "write()/set_write_eof() don't carry pickup args").
1441    #[test]
1442    fn test_write_and_eof_no_pickup() {
1443        let mut ch = PropagationChannel::new("gw1");
1444        ch.start(b"topo");
1445        ch.recv(Message::ack(0)).unwrap();
1446
1447        ch.write("node1", b"input", 1);
1448        ch.set_write_eof("node1", 1);
1449        assert!(ch.pickup_events.is_empty());
1450    }
1451
1452    /// The queue preserves FIFO order with pickup metadata attached
1453    /// (upstream appendleft/pop deque semantics).
1454    #[test]
1455    fn test_send_queue_fifo_with_pickup() {
1456        let mut ch = PropagationChannel::new("gw1");
1457        ch.start(b"topo");
1458
1459        ch.shell("node1", b"cmd1", 1);
1460        ch.shell("node2", b"cmd2", 2);
1461
1462        let _cfg = ch.channel.take_outgoing().unwrap();
1463        assert!(ch.channel.take_outgoing().is_none());
1464
1465        ch.recv(Message::ack(0)).unwrap();
1466        let first = String::from_utf8(ch.channel.take_outgoing().unwrap()).unwrap();
1467        assert!(first.contains("target=\"node1\""));
1468        // only the first queued CTL was dequeued by the ACK
1469        assert_eq!(ch.pickup_events, vec!["node1".to_string()]);
1470    }
1471}