Skip to main content

consortium/
task.rs

1//! Task orchestration.
2//!
3//! Rust implementation of `ClusterShell.Task`.
4//!
5//! A Task manages a coordinated collection of independent parallel Worker
6//! objects, providing a high-level API for executing commands locally or
7//! across remote nodes and collecting their results.
8//!
9//! # Example
10//!
11//! ```rust,no_run
12//! use consortium::task::Task;
13//!
14//! let mut task = Task::new();
15//! task.shell("hostname", None, None);
16//! task.run(None).unwrap();
17//!
18//! for (rc, nodes) in task.iter_retcodes(None) {
19//!     println!("rc={}: {:?}", rc, nodes);
20//! }
21//! ```
22
23use std::collections::{BTreeMap, HashMap, HashSet};
24use std::path::PathBuf;
25use std::sync::atomic::{AtomicBool, Ordering};
26use std::sync::Arc;
27use std::time::Duration;
28
29use crate::defaults::{config_paths, Defaults};
30use crate::msg_tree::{MsgTree, MsgTreeMode};
31use crate::node_set::NodeSet;
32use crate::propagation::PropagationTreeRouter;
33use crate::topology::TopologyParser;
34use crate::worker::exec::ExecWorker;
35use crate::worker::{EventHandler, Worker, WorkerError, WorkerState};
36
37/// Error types for the task module.
38#[derive(Debug, thiserror::Error)]
39pub enum TaskError {
40    /// A worker error occurred.
41    #[error("worker error: {0}")]
42    Worker(#[from] WorkerError),
43    /// Task timed out.
44    #[error("task timeout")]
45    Timeout,
46    /// Task is already running.
47    #[error("task is already running")]
48    AlreadyRunning,
49    /// MsgTree not enabled for the requested stream.
50    #[error("{0}_msgtree not set")]
51    MsgTreeDisabled(String),
52    /// Topology error.
53    #[error("topology error: {0}")]
54    Topology(String),
55    /// General task error.
56    #[error("task error: {0}")]
57    General(String),
58}
59
60/// Result type alias for task operations.
61pub type Result<T> = std::result::Result<T, TaskError>;
62
63/// Identifies a source of output: a (worker_id, node_name) pair.
64///
65/// In the Python version, the source is `(worker_instance, node_string)`.
66/// Here we use a numeric worker ID for ownership safety.
67pub type Source = (usize, String);
68
69/// Current state of the task.
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum TaskState {
72    /// Task is idle — workers may be queued but not running.
73    Idle,
74    /// Task is currently running its workers.
75    Running,
76    /// Task has completed.
77    Done,
78    /// Task was aborted.
79    Aborted,
80}
81
82/// Configuration for the task's default behavior.
83///
84/// Maps to ClusterShell's `_task_default` dictionary.
85#[derive(Debug, Clone)]
86pub struct TaskDefaults {
87    /// Whether to enable stderr separation (default: false).
88    pub stderr: bool,
89    /// Whether to enable stdin (default: true).
90    pub stdin: bool,
91    /// Whether to record stdout into a MsgTree (default: true).
92    pub stdout_msgtree: bool,
93    /// Whether to record stderr into a MsgTree (default: true).
94    pub stderr_msgtree: bool,
95    /// Auto tree mode (use tree topology when available; default: true,
96    /// matching upstream `_TASK_DEFAULT["auto_tree"]`).
97    pub auto_tree: bool,
98}
99
100impl Default for TaskDefaults {
101    fn default() -> Self {
102        Self {
103            stderr: false,
104            stdin: true,
105            stdout_msgtree: true,
106            stderr_msgtree: true,
107            auto_tree: true,
108        }
109    }
110}
111
112impl TaskDefaults {
113    /// Create task defaults from a Defaults config.
114    pub fn from_defaults(defaults: &Defaults) -> Self {
115        Self {
116            stderr: defaults.stderr(),
117            stdin: defaults.stdin(),
118            stdout_msgtree: true,
119            stderr_msgtree: true,
120            auto_tree: defaults.get("auto_tree").map_or(true, |v| v.as_bool()),
121        }
122    }
123}
124
125/// Runtime info for the task.
126///
127/// Maps to ClusterShell's `_task_info` dictionary.
128#[derive(Debug, Clone)]
129pub struct TaskInfo {
130    /// Enable debug messages.
131    pub debug: bool,
132    /// Maximum fanout (concurrent workers).
133    pub fanout: usize,
134    /// Grooming delay for message traffic shaping (seconds; default: 0.25,
135    /// matching upstream `_TASK_INFO["grooming_delay"]`).
136    pub grooming_delay: f64,
137    /// Connection timeout (seconds).
138    pub connect_timeout: f64,
139    /// Command timeout (seconds, 0 = unlimited).
140    pub command_timeout: f64,
141}
142
143impl Default for TaskInfo {
144    fn default() -> Self {
145        Self {
146            debug: false,
147            fanout: 64,
148            grooming_delay: 0.25,
149            connect_timeout: 10.0,
150            command_timeout: 0.0,
151        }
152    }
153}
154
155impl TaskInfo {
156    /// Create task info from a Defaults config.
157    pub fn from_defaults(defaults: &Defaults) -> Self {
158        Self {
159            debug: false,
160            fanout: defaults.fanout() as usize,
161            grooming_delay: defaults.grooming_delay(),
162            connect_timeout: defaults.connect_timeout(),
163            command_timeout: defaults.command_timeout(),
164        }
165    }
166}
167
168/// Topology configuration file paths.
169fn topology_configs() -> Vec<PathBuf> {
170    config_paths("topology.conf")
171}
172
173/// Get the short hostname (hostname up to the first dot).
174fn get_short_hostname() -> String {
175    hostname::get()
176        .map(|h: std::ffi::OsString| {
177            h.to_string_lossy()
178                .to_string()
179                .split('.')
180                .next()
181                .unwrap_or("localhost")
182                .to_string()
183        })
184        .unwrap_or_else(|_| "localhost".to_string())
185}
186
187/// A managed worker entry inside a Task.
188struct ManagedWorker {
189    /// The worker instance.
190    worker: Box<dyn Worker>,
191    /// Whether this worker updates task-level return codes.
192    update_task_rc: bool,
193    /// Whether this worker auto-closes when all other workers finish.
194    autoclose: bool,
195}
196
197/// An event-gathering handler that records output into the Task's result stores.
198///
199/// This handler is installed on workers when they are scheduled. It forwards
200/// data to the task's internal MsgTrees and retcode maps via channels or
201/// direct callback.
202struct TaskGatheringHandler {
203    /// Worker ID that this handler is associated with.
204    #[allow(dead_code)]
205    worker_id: usize,
206    /// Collected stdout lines: node -> Vec<lines>.
207    stdout: HashMap<String, Vec<Vec<u8>>>,
208    /// Collected stderr lines: node -> Vec<lines>.
209    stderr: HashMap<String, Vec<Vec<u8>>>,
210    /// Return codes: node -> rc.
211    retcodes: HashMap<String, i32>,
212    /// Timed-out nodes.
213    timeouts: HashSet<String>,
214    /// User-provided handler to chain to, if any.
215    user_handler: Option<Box<dyn EventHandler>>,
216}
217
218impl TaskGatheringHandler {
219    fn new(worker_id: usize, user_handler: Option<Box<dyn EventHandler>>) -> Self {
220        Self {
221            worker_id,
222            stdout: HashMap::new(),
223            stderr: HashMap::new(),
224            retcodes: HashMap::new(),
225            timeouts: HashSet::new(),
226            user_handler,
227        }
228    }
229}
230
231impl EventHandler for TaskGatheringHandler {
232    fn on_start(&mut self, worker: &dyn Worker) {
233        if let Some(ref mut h) = self.user_handler {
234            h.on_start(worker);
235        }
236    }
237
238    fn on_read(&mut self, node: &str, fd: std::os::unix::io::RawFd, msg: &[u8]) {
239        // fd 1 = stdout, fd 2 = stderr
240        let map = if fd == 2 {
241            &mut self.stderr
242        } else {
243            &mut self.stdout
244        };
245        map.entry(node.to_string()).or_default().push(msg.to_vec());
246
247        if let Some(ref mut h) = self.user_handler {
248            h.on_read(node, fd, msg);
249        }
250    }
251
252    fn on_close(&mut self, node: &str, rc: i32) {
253        self.retcodes.insert(node.to_string(), rc);
254
255        if let Some(ref mut h) = self.user_handler {
256            h.on_close(node, rc);
257        }
258    }
259
260    fn on_timeout(&mut self, node: &str) {
261        self.timeouts.insert(node.to_string());
262
263        if let Some(ref mut h) = self.user_handler {
264            h.on_timeout(node);
265        }
266    }
267
268    fn on_error(&mut self, node: &str, error: &WorkerError) {
269        if let Some(ref mut h) = self.user_handler {
270            h.on_error(node, error);
271        }
272    }
273
274    fn take_buffers(
275        &mut self,
276    ) -> (
277        HashMap<String, Vec<Vec<u8>>>,
278        HashMap<String, Vec<Vec<u8>>>,
279        HashSet<String>,
280    ) {
281        (
282            std::mem::take(&mut self.stdout),
283            std::mem::take(&mut self.stderr),
284            std::mem::take(&mut self.timeouts),
285        )
286    }
287}
288
289/// A thread-safe handle for aborting a [`Task`] from another thread.
290///
291/// Obtained via [`Task::abort_handle`]. Cloning the handle is cheap; all
292/// clones signal the same task. This mirrors Python ClusterShell's ability
293/// to abort a running task from any thread (`task.abort()`,
294/// `task_cleanup()`), which the single-owner Rust `Task` cannot do through
295/// `&mut self` while `resume()` is running.
296#[derive(Debug, Clone)]
297pub struct TaskAbortHandle {
298    /// Shared abort-request flag (mirrors `Task._quit`).
299    quit: Arc<AtomicBool>,
300    /// Shared kill flag latched when an abort requests killing workers.
301    kill: Arc<AtomicBool>,
302}
303
304impl TaskAbortHandle {
305    /// Request task abort. If `kill` is true, running workers are killed.
306    ///
307    /// The request is observed by the task's run loop, which then performs
308    /// the (idempotent) terminate pass; calling `abort()` multiple times is
309    /// safe and does not double-fire worker termination (#110).
310    pub fn abort(&self, kill: bool) {
311        if kill {
312            self.kill.store(true, Ordering::SeqCst);
313        }
314        self.quit.store(true, Ordering::SeqCst);
315    }
316}
317
318/// A Task manages a set of workers and their results.
319///
320/// The Task is the primary orchestrator in consortium. It schedules workers
321/// for execution, runs them (respecting fanout limits), and collects their
322/// output, return codes, and timeout information.
323///
324/// # Design Differences from Python
325///
326/// The Python Task is heavily thread-based with a thread-local singleton
327/// pattern (`task_self()`). The Rust version is single-threaded and owned,
328/// using explicit lifetimes rather than thread-local storage. Multi-threaded
329/// usage should wrap `Task` in `Arc<Mutex<Task>>` if needed.
330pub struct Task {
331    /// Task state.
332    state: TaskState,
333    /// Task-level defaults.
334    defaults: TaskDefaults,
335    /// Task-level runtime info.
336    info: TaskInfo,
337    /// Global task timeout (set via resume/run).
338    timeout: Option<Duration>,
339
340    // -- Termination state (mirrors Python Task._quit / engine-released) --
341    /// Abort request flag, shared with any `TaskAbortHandle` so a running
342    /// task can be aborted from another thread (mirrors `Task._quit`).
343    quit: Arc<AtomicBool>,
344    /// Kill flag latched alongside `quit` when an abort requests killing
345    /// workers (mirrors the `kill` argument of `EngineAbortException`).
346    kill_requested: Arc<AtomicBool>,
347    /// Set once the task has been fully terminated with kill=True
348    /// (mirrors `self._engine = None` after a kill-terminate upstream).
349    /// Guards against double termination (#110): any further terminate
350    /// pass is a no-op once this is set.
351    terminated_kill: bool,
352
353    // -- Worker management --
354    /// Next worker ID to assign.
355    next_worker_id: usize,
356    /// Managed workers (worker_id -> ManagedWorker).
357    workers: Vec<(usize, ManagedWorker)>,
358
359    // -- Result stores --
360    /// MsgTree for stdout, keyed by (worker_id, node).
361    stdout_tree: Option<MsgTree<Source>>,
362    /// MsgTree for stderr, keyed by (worker_id, node).
363    stderr_tree: Option<MsgTree<Source>>,
364    /// Return codes by source: (worker_id, node) -> rc.
365    d_source_rc: HashMap<Source, i32>,
366    /// Sources by return code: rc -> set of sources.
367    d_rc_sources: BTreeMap<i32, HashSet<Source>>,
368    /// Maximum return code seen.
369    max_rc: Option<i32>,
370    /// Timed-out sources.
371    timeout_sources: HashSet<Source>,
372
373    // -- Topology / Tree mode --
374    /// Loaded topology parser (owns the tree).
375    topology_parser: Option<TopologyParser>,
376    /// Propagation tree router (shared across tree workers).
377    router: Option<PropagationTreeRouter>,
378    /// Gateway channels: gateway_name -> worker_id.
379    #[allow(dead_code)]
380    gateways: HashMap<String, usize>,
381}
382
383impl Task {
384    /// Create a new Task with default settings.
385    pub fn new() -> Self {
386        let defaults = TaskDefaults::default();
387        let info = TaskInfo::default();
388        Self::with_config(defaults, info)
389    }
390
391    /// Create a new Task from a Defaults configuration.
392    pub fn from_defaults(defaults: &Defaults) -> Self {
393        let task_defaults = TaskDefaults::from_defaults(defaults);
394        let task_info = TaskInfo::from_defaults(defaults);
395        Self::with_config(task_defaults, task_info)
396    }
397
398    /// Create a new Task with explicit configuration.
399    pub fn with_config(defaults: TaskDefaults, info: TaskInfo) -> Self {
400        let stdout_tree = if defaults.stdout_msgtree {
401            Some(MsgTree::new(MsgTreeMode::Shift))
402        } else {
403            None
404        };
405        let stderr_tree = if defaults.stderr_msgtree {
406            Some(MsgTree::new(MsgTreeMode::Shift))
407        } else {
408            None
409        };
410
411        Self {
412            state: TaskState::Idle,
413            defaults,
414            info,
415            timeout: None,
416            quit: Arc::new(AtomicBool::new(false)),
417            kill_requested: Arc::new(AtomicBool::new(false)),
418            terminated_kill: false,
419            next_worker_id: 0,
420            workers: Vec::new(),
421            stdout_tree,
422            stderr_tree,
423            d_source_rc: HashMap::new(),
424            d_rc_sources: BTreeMap::new(),
425            max_rc: None,
426            timeout_sources: HashSet::new(),
427            topology_parser: None,
428            router: None,
429            gateways: HashMap::new(),
430        }
431    }
432
433    // -----------------------------------------------------------------------
434    // Defaults & Info
435    // -----------------------------------------------------------------------
436
437    /// Get task defaults.
438    pub fn defaults(&self) -> &TaskDefaults {
439        &self.defaults
440    }
441
442    /// Get mutable task defaults.
443    pub fn defaults_mut(&mut self) -> &mut TaskDefaults {
444        &mut self.defaults
445    }
446
447    /// Get task info.
448    pub fn info(&self) -> &TaskInfo {
449        &self.info
450    }
451
452    /// Get mutable task info.
453    pub fn info_mut(&mut self) -> &mut TaskInfo {
454        &mut self.info
455    }
456
457    /// Set the fanout (maximum concurrent workers/processes).
458    pub fn set_fanout(&mut self, fanout: usize) {
459        self.info.fanout = fanout;
460    }
461
462    /// Get the current fanout.
463    pub fn fanout(&self) -> usize {
464        self.info.fanout
465    }
466
467    // -----------------------------------------------------------------------
468    // Topology
469    // -----------------------------------------------------------------------
470
471    /// Load a propagation topology from a topology configuration file.
472    ///
473    /// On success, stores the parser (which caches the tree internally).
474    pub fn load_topology(&mut self, path: &str) -> Result<()> {
475        let hostname = get_short_hostname();
476        let mut parser = TopologyParser::new();
477        parser
478            .load(path)
479            .map_err(|e| TaskError::Topology(format!("failed to load topology: {}", e)))?;
480        // Validate that we can build the tree for our hostname
481        parser
482            .tree(&hostname)
483            .map_err(|e| TaskError::Topology(format!("failed to build tree: {}", e)))?;
484        self.topology_parser = Some(parser);
485        Ok(())
486    }
487
488    /// Try to auto-load topology from default config paths.
489    fn try_load_default_topology(&mut self) -> bool {
490        if self.topology_parser.is_some() {
491            return true;
492        }
493        let configs = topology_configs();
494        for path in configs.iter().rev() {
495            if path.exists() {
496                if let Ok(()) = self.load_topology(&path.to_string_lossy()) {
497                    return true;
498                }
499            }
500        }
501        false
502    }
503
504    /// Check if tree mode is enabled by default.
505    pub fn default_tree_is_enabled(&mut self) -> bool {
506        if self.topology_parser.is_none() {
507            self.try_load_default_topology();
508        }
509        self.topology_parser.is_some() && self.defaults.auto_tree
510    }
511
512    /// Get or create the default PropagationTreeRouter.
513    pub fn default_router(&mut self) -> Result<&PropagationTreeRouter> {
514        if self.router.is_none() {
515            let hostname = get_short_hostname();
516            let parser = self
517                .topology_parser
518                .as_mut()
519                .ok_or_else(|| TaskError::Topology("no topology loaded".to_string()))?;
520            let topology = parser
521                .tree(&hostname)
522                .map_err(|e| TaskError::Topology(format!("failed to build tree: {}", e)))?;
523            let root_ns = topology
524                .root()
525                .ok_or_else(|| TaskError::Topology("topology has no root".to_string()))?;
526            let root_str = root_ns.nodeset.to_string();
527            let fanout = self.info.fanout;
528            let router = PropagationTreeRouter::new(&root_str, topology, fanout)
529                .map_err(|e| TaskError::Topology(format!("router creation failed: {}", e)))?;
530            self.router = Some(router);
531        }
532        Ok(self.router.as_ref().unwrap())
533    }
534
535    /// Check if a topology is loaded.
536    pub fn has_topology(&self) -> bool {
537        self.topology_parser.is_some()
538    }
539
540    // -----------------------------------------------------------------------
541    // Worker scheduling
542    // -----------------------------------------------------------------------
543
544    /// Schedule a local shell command for execution.
545    ///
546    /// If `nodes` is None, executes locally on a single node.
547    /// If `nodes` is Some, executes the command on each node in the set.
548    ///
549    /// Returns the worker ID assigned to this worker.
550    pub fn shell(
551        &mut self,
552        command: &str,
553        nodes: Option<&str>,
554        handler: Option<Box<dyn EventHandler>>,
555    ) -> usize {
556        self.shell_with_opts(command, nodes, handler, None, false)
557    }
558
559    /// Schedule a shell command with full options.
560    ///
561    /// Returns the worker ID.
562    pub fn shell_with_opts(
563        &mut self,
564        command: &str,
565        nodes: Option<&str>,
566        handler: Option<Box<dyn EventHandler>>,
567        timeout: Option<Duration>,
568        autoclose: bool,
569    ) -> usize {
570        let node_list: Vec<String> = match nodes {
571            Some(ns) => {
572                // Parse as NodeSet for expansion
573                match NodeSet::parse(ns) {
574                    Ok(nodeset) => nodeset.iter().collect::<Vec<String>>(),
575                    Err(_) => {
576                        // Fall back to treating it as a single node
577                        vec![ns.to_string()]
578                    }
579                }
580            }
581            None => {
582                // Local execution — use localhost
583                vec!["localhost".to_string()]
584            }
585        };
586
587        let fanout = self.info.fanout;
588        let worker_timeout = timeout.or_else(|| {
589            if self.info.command_timeout > 0.0 {
590                Some(Duration::from_secs_f64(self.info.command_timeout))
591            } else {
592                None
593            }
594        });
595
596        let mut worker = ExecWorker::new(node_list, command.to_string(), fanout, worker_timeout);
597        worker = worker.with_stderr(self.defaults.stderr);
598
599        self.schedule(Box::new(worker), handler, autoclose)
600    }
601
602    /// Schedule a pre-built worker for execution.
603    ///
604    /// The worker will be started when `resume()` or `run()` is called.
605    /// Returns the worker ID.
606    pub fn schedule(
607        &mut self,
608        worker: Box<dyn Worker>,
609        handler: Option<Box<dyn EventHandler>>,
610        autoclose: bool,
611    ) -> usize {
612        let worker_id = self.next_worker_id;
613        self.next_worker_id += 1;
614
615        // Wrap with a gathering handler
616        let gathering = TaskGatheringHandler::new(worker_id, handler);
617        let mut managed_worker = ManagedWorker {
618            worker,
619            update_task_rc: true,
620            autoclose,
621        };
622        managed_worker.worker.set_handler(Box::new(gathering));
623
624        self.workers.push((worker_id, managed_worker));
625        worker_id
626    }
627
628    // -----------------------------------------------------------------------
629    // Execution
630    // -----------------------------------------------------------------------
631
632    /// Run the task: start all scheduled workers and block until they complete.
633    ///
634    /// Optionally schedule a shell command first (like Python's `task.run(cmd, ...)`).
635    ///
636    /// Returns Ok(()) on success, or the first error encountered.
637    pub fn run(&mut self, timeout: Option<Duration>) -> Result<()> {
638        self.timeout = timeout;
639        self.resume()
640    }
641
642    /// Run with a command: convenience for `shell(cmd) + run()`.
643    pub fn run_command(
644        &mut self,
645        command: &str,
646        nodes: Option<&str>,
647        handler: Option<Box<dyn EventHandler>>,
648        timeout: Option<Duration>,
649    ) -> Result<usize> {
650        let wid = self.shell(command, nodes, handler);
651        self.timeout = timeout;
652        self.resume()?;
653        Ok(wid)
654    }
655
656    /// Resume task execution — start all pending workers and run until done.
657    pub fn resume(&mut self) -> Result<()> {
658        if self.state == TaskState::Running {
659            return Err(TaskError::AlreadyRunning);
660        }
661
662        self.reset();
663        self.state = TaskState::Running;
664
665        // Start all pending workers, respecting fanout
666        let mut _running_count = 0;
667        let mut errors: Vec<TaskError> = Vec::new();
668
669        // Start workers
670        for (_wid, mw) in &mut self.workers {
671            if mw.worker.state() == WorkerState::Pending {
672                match mw.worker.start() {
673                    Ok(()) => _running_count += 1,
674                    Err(e) => errors.push(TaskError::Worker(e)),
675                }
676            }
677        }
678
679        // Poll workers until all are done
680        let start = std::time::Instant::now();
681        loop {
682            // Abort requested while running — either from a prior abort()
683            // call or from another thread via TaskAbortHandle (mirrors
684            // EngineAbortException handling in Python Task._resume()):
685            // terminate once and unwind the run normally.
686            if self.quit.load(Ordering::SeqCst) {
687                let kill = self.kill_requested.load(Ordering::SeqCst);
688                self.terminate(kill);
689                return Ok(());
690            }
691
692            let mut any_running = false;
693            for (_wid, mw) in &mut self.workers {
694                if !mw.autoclose && !mw.worker.is_done() {
695                    // Drive the worker: read from its fds and check for exits
696                    let fds = mw.worker.read_fds();
697                    for fd in &fds {
698                        let _ = mw.worker.handle_read(*fd);
699                    }
700                    if !mw.worker.is_done() {
701                        any_running = true;
702                    }
703                }
704            }
705            if !any_running {
706                break;
707            }
708
709            // Check task-level timeout
710            if let Some(timeout) = self.timeout {
711                if start.elapsed() >= timeout {
712                    // Abort all running workers
713                    for (_wid, mw) in &mut self.workers {
714                        if !mw.worker.is_done() {
715                            mw.worker.abort(true);
716                        }
717                    }
718                    self.state = TaskState::Done;
719                    return Err(TaskError::Timeout);
720                }
721            }
722
723            // Small sleep to avoid busy-wait
724            std::thread::sleep(Duration::from_millis(10));
725        }
726
727        // Collect results from all workers
728        self.collect_results();
729
730        self.state = TaskState::Done;
731
732        if let Some(err) = errors.into_iter().next() {
733            Err(err)
734        } else {
735            Ok(())
736        }
737    }
738
739    /// Abort the task, stopping all running workers.
740    ///
741    /// If `kill` is true, running worker processes are killed and the task
742    /// is fully terminated: any further `abort()`/`terminate()` call is a
743    /// no-op (upstream ClusterShell #110 — "only terminate Task once").
744    ///
745    /// Aborting clears the task's result stores, mirroring Python
746    /// `Task._terminate()` which calls `Task._reset()`.
747    pub fn abort(&mut self, kill: bool) {
748        // Signal any in-progress run loop (mirrors `Task._quit = True`),
749        // so a resume() executing on another thread terminates too.
750        if kill {
751            self.kill_requested.store(true, Ordering::SeqCst);
752        }
753        self.quit.store(true, Ordering::SeqCst);
754        self.terminate(kill);
755    }
756
757    /// Return a thread-safe handle that can abort this task from another
758    /// thread while `resume()`/`run()` is blocked in the run loop.
759    ///
760    /// This is the Rust equivalent of calling `task.abort()` (or
761    /// `task_cleanup()`) from a different thread in Python ClusterShell.
762    pub fn abort_handle(&self) -> TaskAbortHandle {
763        TaskAbortHandle {
764            quit: Arc::clone(&self.quit),
765            kill: Arc::clone(&self.kill_requested),
766        }
767    }
768
769    /// Abort completion subroutine — mirrors Python `Task._terminate(kill)`.
770    ///
771    /// Idempotence guard (#110): once the task has been terminated with
772    /// kill=True (upstream: `self._engine is None` after release), repeated
773    /// terminate passes return early so events are not double-fired and
774    /// task state is not corrupted.
775    fn terminate(&mut self, kill: bool) {
776        // already fully terminated by a prior abort pass (#110)
777        if self.terminated_kill {
778            return;
779        }
780
781        // stop workers (upstream: `self._engine.clear(clear_ports=kill)`)
782        for (_wid, mw) in &mut self.workers {
783            if !mw.worker.is_done() {
784                mw.worker.abort(kill);
785            }
786        }
787
788        if kill {
789            // upstream: `self._engine.release(); self._engine = None`
790            self.terminated_kill = true;
791        }
792
793        // clear result objects (upstream: `self._reset()`)
794        self.reset();
795
796        self.state = TaskState::Aborted;
797    }
798
799    /// Check if the task is currently running.
800    pub fn running(&self) -> bool {
801        self.state == TaskState::Running
802    }
803
804    /// Get the current task state.
805    pub fn state(&self) -> TaskState {
806        self.state
807    }
808
809    // -----------------------------------------------------------------------
810    // Result collection (internal)
811    // -----------------------------------------------------------------------
812
813    /// Reset result buffers for a new run.
814    fn reset(&mut self) {
815        if let Some(ref mut tree) = self.stdout_tree {
816            tree.clear();
817        }
818        if let Some(ref mut tree) = self.stderr_tree {
819            tree.clear();
820        }
821        self.d_source_rc.clear();
822        self.d_rc_sources.clear();
823        self.max_rc = None;
824        self.timeout_sources.clear();
825    }
826
827    /// Collect results from all workers' gathering handlers into the task stores.
828    fn collect_results(&mut self) {
829        for (wid, mw) in &mut self.workers {
830            let worker_id = *wid;
831
832            // Collect return codes from the worker's retcodes map
833            if mw.update_task_rc {
834                for (node, &rc) in mw.worker.retcodes() {
835                    let source: Source = (worker_id, node.clone());
836                    self.d_source_rc.insert(source.clone(), rc);
837                    self.d_rc_sources.entry(rc).or_default().insert(source);
838
839                    if self.max_rc.map_or(true, |max| rc > max) {
840                        self.max_rc = Some(rc);
841                    }
842                }
843            }
844
845            // Extract buffered stdout/stderr/timeouts from the gathering handler.
846            // take_handler() removes the handler from the worker, then we call
847            // take_buffers() to drain the collected data into our MsgTrees.
848            if let Some(mut handler) = mw.worker.take_handler() {
849                let (stdout, stderr, timeouts) = handler.take_buffers();
850
851                // Populate stdout MsgTree
852                if let Some(ref mut tree) = self.stdout_tree {
853                    for (node, chunks) in stdout {
854                        let source: Source = (worker_id, node);
855                        for chunk in chunks {
856                            tree.add(source.clone(), chunk);
857                        }
858                    }
859                }
860
861                // Populate stderr MsgTree
862                if let Some(ref mut tree) = self.stderr_tree {
863                    for (node, chunks) in stderr {
864                        let source: Source = (worker_id, node);
865                        for chunk in chunks {
866                            tree.add(source.clone(), chunk);
867                        }
868                    }
869                }
870
871                // Record timed-out nodes
872                for node in timeouts {
873                    self.timeout_sources.insert((worker_id, node));
874                }
875            }
876        }
877    }
878
879    // -----------------------------------------------------------------------
880    // Result accessors
881    // -----------------------------------------------------------------------
882
883    /// Get the maximum return code from the last run, or None if:
884    /// - All commands timed out.
885    /// - No command-based worker was executed.
886    pub fn max_retcode(&self) -> Option<i32> {
887        self.max_rc
888    }
889
890    /// Get the return code for a specific node.
891    ///
892    /// When the node is associated with multiple workers, returns the max
893    /// return code from those workers. Returns None if not found.
894    pub fn node_retcode(&self, node: &str) -> Option<i32> {
895        let mut max_rc: Option<i32> = None;
896        for ((_, n), &rc) in &self.d_source_rc {
897            if n == node {
898                max_rc = Some(max_rc.map_or(rc, |m: i32| m.max(rc)));
899            }
900        }
901        max_rc
902    }
903
904    /// Iterate over return codes and their associated node lists.
905    ///
906    /// Returns an iterator of `(rc, Vec<node_name>)` tuples.
907    /// If `match_keys` is Some, only includes matching nodes.
908    pub fn iter_retcodes(&self, match_keys: Option<&HashSet<String>>) -> Vec<(i32, Vec<String>)> {
909        let mut result = Vec::new();
910        for (&rc, sources) in &self.d_rc_sources {
911            let nodes: Vec<String> = sources
912                .iter()
913                .filter(|(_, n)| match_keys.map_or(true, |keys| keys.contains(n)))
914                .map(|(_, n)| n.clone())
915                .collect();
916            if !nodes.is_empty() {
917                result.push((rc, nodes));
918            }
919        }
920        result
921    }
922
923    /// Iterate over return codes for a specific worker.
924    pub fn iter_retcodes_by_worker(
925        &self,
926        worker_id: usize,
927        match_keys: Option<&HashSet<String>>,
928    ) -> Vec<(i32, Vec<String>)> {
929        let mut result = Vec::new();
930        for (&rc, sources) in &self.d_rc_sources {
931            let nodes: Vec<String> = sources
932                .iter()
933                .filter(|(wid, n)| {
934                    *wid == worker_id && match_keys.map_or(true, |keys| keys.contains(n))
935                })
936                .map(|(_, n)| n.clone())
937                .collect();
938            if !nodes.is_empty() {
939                result.push((rc, nodes));
940            }
941        }
942        result
943    }
944
945    /// Get the number of timed-out nodes.
946    pub fn num_timeout(&self) -> usize {
947        self.timeout_sources.len()
948    }
949
950    /// Iterate over timed-out nodes.
951    pub fn iter_keys_timeout(&self) -> impl Iterator<Item = &str> {
952        self.timeout_sources.iter().map(|(_, n)| n.as_str())
953    }
954
955    /// Get the number of timed-out nodes for a specific worker.
956    pub fn num_timeout_by_worker(&self, worker_id: usize) -> usize {
957        self.timeout_sources
958            .iter()
959            .filter(|(wid, _)| *wid == worker_id)
960            .count()
961    }
962
963    /// Iterate over timed-out nodes for a specific worker.
964    pub fn iter_keys_timeout_by_worker(&self, worker_id: usize) -> impl Iterator<Item = &str> {
965        self.timeout_sources
966            .iter()
967            .filter(move |(wid, _)| *wid == worker_id)
968            .map(|(_, n)| n.as_str())
969    }
970
971    // -----------------------------------------------------------------------
972    // Buffer accessors (MsgTree-based)
973    // -----------------------------------------------------------------------
974
975    /// Get the stdout buffer for a specific node across all workers.
976    ///
977    /// Returns a concatenation of all stdout messages for that node.
978    pub fn node_buffer(&self, node: &str) -> Result<Vec<u8>> {
979        let tree = self
980            .stdout_tree
981            .as_ref()
982            .ok_or_else(|| TaskError::MsgTreeDisabled("stdout".to_string()))?;
983        let mut buf = Vec::new();
984        for key in tree.keys() {
985            if key.1 == node {
986                if let Some(elem) = tree.get(key) {
987                    let msg = elem.message();
988                    if !buf.is_empty() {
989                        buf.push(b'\n');
990                    }
991                    buf.extend_from_slice(&msg);
992                }
993            }
994        }
995        Ok(buf)
996    }
997
998    /// Get the stderr buffer for a specific node.
999    pub fn node_error(&self, node: &str) -> Result<Vec<u8>> {
1000        let tree = self
1001            .stderr_tree
1002            .as_ref()
1003            .ok_or_else(|| TaskError::MsgTreeDisabled("stderr".to_string()))?;
1004        let mut buf = Vec::new();
1005        for key in tree.keys() {
1006            if key.1 == node {
1007                if let Some(elem) = tree.get(key) {
1008                    let msg = elem.message();
1009                    if !buf.is_empty() {
1010                        buf.push(b'\n');
1011                    }
1012                    buf.extend_from_slice(&msg);
1013                }
1014            }
1015        }
1016        Ok(buf)
1017    }
1018
1019    /// Flush all stdout buffers.
1020    pub fn flush_buffers(&mut self) {
1021        if let Some(ref mut tree) = self.stdout_tree {
1022            tree.clear();
1023        }
1024    }
1025
1026    /// Flush all stderr buffers.
1027    pub fn flush_errors(&mut self) {
1028        if let Some(ref mut tree) = self.stderr_tree {
1029            tree.clear();
1030        }
1031    }
1032
1033    // -----------------------------------------------------------------------
1034    // Worker accessors
1035    // -----------------------------------------------------------------------
1036
1037    /// Get the number of scheduled workers.
1038    pub fn num_workers(&self) -> usize {
1039        self.workers.len()
1040    }
1041
1042    /// Get a reference to a worker by ID.
1043    pub fn worker(&self, worker_id: usize) -> Option<&dyn Worker> {
1044        self.workers
1045            .iter()
1046            .find(|(wid, _)| *wid == worker_id)
1047            .map(|(_, mw)| mw.worker.as_ref())
1048    }
1049
1050    /// Get the return codes for a specific worker.
1051    pub fn worker_retcodes(&self, worker_id: usize) -> Option<&HashMap<String, i32>> {
1052        self.workers
1053            .iter()
1054            .find(|(wid, _)| *wid == worker_id)
1055            .map(|(_, mw)| mw.worker.retcodes())
1056    }
1057}
1058
1059impl Default for Task {
1060    fn default() -> Self {
1061        Self::new()
1062    }
1063}
1064
1065// ============================================================================
1066// Tests
1067// ============================================================================
1068
1069#[cfg(test)]
1070mod tests {
1071    use super::*;
1072    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
1073    use std::sync::Arc;
1074
1075    // -- TaskState tests --
1076
1077    #[test]
1078    fn test_task_state_variants() {
1079        assert_eq!(TaskState::Idle, TaskState::Idle);
1080        assert_eq!(TaskState::Running, TaskState::Running);
1081        assert_eq!(TaskState::Done, TaskState::Done);
1082        assert_eq!(TaskState::Aborted, TaskState::Aborted);
1083        assert_ne!(TaskState::Idle, TaskState::Running);
1084    }
1085
1086    #[test]
1087    fn test_task_state_debug() {
1088        assert_eq!(format!("{:?}", TaskState::Idle), "Idle");
1089        assert_eq!(format!("{:?}", TaskState::Running), "Running");
1090        assert_eq!(format!("{:?}", TaskState::Done), "Done");
1091        assert_eq!(format!("{:?}", TaskState::Aborted), "Aborted");
1092    }
1093
1094    #[test]
1095    fn test_task_state_clone_copy() {
1096        let s = TaskState::Running;
1097        let s2 = s;
1098        assert_eq!(s, s2);
1099    }
1100
1101    // -- TaskError tests --
1102
1103    #[test]
1104    fn test_task_error_display() {
1105        let e = TaskError::Timeout;
1106        assert_eq!(e.to_string(), "task timeout");
1107
1108        let e = TaskError::AlreadyRunning;
1109        assert_eq!(e.to_string(), "task is already running");
1110
1111        let e = TaskError::MsgTreeDisabled("stdout".to_string());
1112        assert_eq!(e.to_string(), "stdout_msgtree not set");
1113
1114        let e = TaskError::General("test error".to_string());
1115        assert_eq!(e.to_string(), "task error: test error");
1116
1117        let e = TaskError::Topology("no topology".to_string());
1118        assert_eq!(e.to_string(), "topology error: no topology");
1119    }
1120
1121    #[test]
1122    fn test_task_error_from_worker_error() {
1123        let we = WorkerError::Timeout;
1124        let te: TaskError = we.into();
1125        assert!(matches!(te, TaskError::Worker(WorkerError::Timeout)));
1126    }
1127
1128    // -- TaskDefaults tests --
1129
1130    #[test]
1131    fn test_task_defaults_default() {
1132        let d = TaskDefaults::default();
1133        assert!(!d.stderr);
1134        assert!(d.stdin);
1135        assert!(d.stdout_msgtree);
1136        assert!(d.stderr_msgtree);
1137        // upstream _TASK_DEFAULT["auto_tree"] is True
1138        assert!(d.auto_tree);
1139    }
1140
1141    #[test]
1142    fn test_task_defaults_from_defaults() {
1143        let defaults = Defaults::new();
1144        let d = TaskDefaults::from_defaults(&defaults);
1145        assert!(!d.stderr);
1146        assert!(d.stdin);
1147        assert!(d.auto_tree);
1148    }
1149
1150    // -- TaskInfo tests --
1151
1152    #[test]
1153    fn test_task_info_default() {
1154        let info = TaskInfo::default();
1155        assert!(!info.debug);
1156        assert_eq!(info.fanout, 64);
1157        // upstream _TASK_INFO["grooming_delay"] is 0.25
1158        assert_eq!(info.grooming_delay, 0.25);
1159        assert_eq!(info.connect_timeout, 10.0);
1160        assert_eq!(info.command_timeout, 0.0);
1161    }
1162
1163    #[test]
1164    fn test_task_info_from_defaults() {
1165        let defaults = Defaults::new();
1166        let info = TaskInfo::from_defaults(&defaults);
1167        assert_eq!(info.fanout, defaults.fanout() as usize);
1168        assert_eq!(info.grooming_delay, defaults.grooming_delay());
1169        assert_eq!(info.connect_timeout, defaults.connect_timeout());
1170        assert_eq!(info.command_timeout, defaults.command_timeout());
1171    }
1172
1173    // -- Task construction tests --
1174
1175    #[test]
1176    fn test_task_new() {
1177        let task = Task::new();
1178        assert_eq!(task.state(), TaskState::Idle);
1179        assert_eq!(task.num_workers(), 0);
1180        assert_eq!(task.max_retcode(), None);
1181        assert_eq!(task.num_timeout(), 0);
1182        assert!(!task.running());
1183    }
1184
1185    #[test]
1186    fn test_task_default() {
1187        let task = Task::default();
1188        assert_eq!(task.state(), TaskState::Idle);
1189    }
1190
1191    #[test]
1192    fn test_task_from_defaults() {
1193        let defaults = Defaults::new();
1194        let task = Task::from_defaults(&defaults);
1195        assert_eq!(task.state(), TaskState::Idle);
1196        assert_eq!(task.fanout(), defaults.fanout() as usize);
1197    }
1198
1199    #[test]
1200    fn test_task_with_config() {
1201        let mut td = TaskDefaults::default();
1202        td.stderr = true;
1203        td.stdout_msgtree = false;
1204
1205        let mut ti = TaskInfo::default();
1206        ti.fanout = 32;
1207
1208        let task = Task::with_config(td, ti);
1209        assert!(task.defaults().stderr);
1210        assert!(!task.defaults().stdout_msgtree);
1211        assert_eq!(task.fanout(), 32);
1212        // stdout_msgtree disabled → no stdout tree
1213        assert!(task.stdout_tree.is_none());
1214        assert!(task.stderr_tree.is_some());
1215    }
1216
1217    // -- Fanout tests --
1218
1219    #[test]
1220    fn test_task_set_fanout() {
1221        let mut task = Task::new();
1222        assert_eq!(task.fanout(), 64);
1223        task.set_fanout(128);
1224        assert_eq!(task.fanout(), 128);
1225    }
1226
1227    // -- Worker scheduling tests --
1228
1229    #[test]
1230    fn test_task_shell_local() {
1231        let mut task = Task::new();
1232        let wid = task.shell("echo hello", None, None);
1233        assert_eq!(wid, 0);
1234        assert_eq!(task.num_workers(), 1);
1235    }
1236
1237    #[test]
1238    fn test_task_shell_remote() {
1239        let mut task = Task::new();
1240        let wid = task.shell("hostname", Some("node[1-3]"), None);
1241        assert_eq!(wid, 0);
1242        assert_eq!(task.num_workers(), 1);
1243    }
1244
1245    #[test]
1246    fn test_task_shell_multiple() {
1247        let mut task = Task::new();
1248        let wid1 = task.shell("echo 1", None, None);
1249        let wid2 = task.shell("echo 2", None, None);
1250        assert_eq!(wid1, 0);
1251        assert_eq!(wid2, 1);
1252        assert_eq!(task.num_workers(), 2);
1253    }
1254
1255    #[test]
1256    fn test_task_shell_with_opts() {
1257        let mut task = Task::new();
1258        let wid = task.shell_with_opts(
1259            "echo test",
1260            Some("node[1-5]"),
1261            None,
1262            Some(Duration::from_secs(30)),
1263            true,
1264        );
1265        assert_eq!(wid, 0);
1266        assert_eq!(task.num_workers(), 1);
1267    }
1268
1269    // -- Execution tests --
1270
1271    #[test]
1272    fn test_task_run_local_command() {
1273        let mut task = Task::new();
1274        task.shell("echo hello", None, None);
1275        let result = task.run(None);
1276        assert!(result.is_ok());
1277        assert_eq!(task.state(), TaskState::Done);
1278    }
1279
1280    #[test]
1281    fn test_task_run_returns_retcode() {
1282        let mut task = Task::new();
1283        task.shell("true", None, None);
1284        task.run(None).unwrap();
1285        assert_eq!(task.max_retcode(), Some(0));
1286    }
1287
1288    #[test]
1289    fn test_task_run_failing_command() {
1290        let mut task = Task::new();
1291        task.shell("false", None, None);
1292        task.run(None).unwrap();
1293        assert_eq!(task.max_retcode(), Some(1));
1294    }
1295
1296    #[test]
1297    fn test_task_run_mixed_retcodes() {
1298        let mut task = Task::new();
1299        task.shell("true", None, None);
1300        task.shell("exit 42", None, None);
1301        task.run(None).unwrap();
1302        assert_eq!(task.max_retcode(), Some(42));
1303    }
1304
1305    #[test]
1306    fn test_task_run_command_convenience() {
1307        let mut task = Task::new();
1308        let wid = task.run_command("echo test", None, None, None).unwrap();
1309        assert_eq!(wid, 0);
1310        assert_eq!(task.max_retcode(), Some(0));
1311    }
1312
1313    #[test]
1314    fn test_task_run_multiple_nodes() {
1315        let mut task = Task::new();
1316        task.shell("echo hi", Some("node[1-3]"), None);
1317        task.run(None).unwrap();
1318        // Each "node" runs locally via ExecWorker (echo hi runs 3 times)
1319        assert_eq!(task.max_retcode(), Some(0));
1320
1321        let retcodes = task.iter_retcodes(None);
1322        // All should be rc=0
1323        for (rc, _nodes) in &retcodes {
1324            assert_eq!(*rc, 0);
1325        }
1326    }
1327
1328    // -- Retcode accessor tests --
1329
1330    #[test]
1331    fn test_task_node_retcode() {
1332        let mut task = Task::new();
1333        task.shell("true", None, None);
1334        task.run(None).unwrap();
1335        // The node is "localhost" for local commands
1336        assert_eq!(task.node_retcode("localhost"), Some(0));
1337        assert_eq!(task.node_retcode("nonexistent"), None);
1338    }
1339
1340    #[test]
1341    fn test_task_iter_retcodes_with_filter() {
1342        let mut task = Task::new();
1343        task.shell("echo hi", Some("node[1-3]"), None);
1344        task.run(None).unwrap();
1345
1346        let mut filter = HashSet::new();
1347        filter.insert("node1".to_string());
1348        let retcodes = task.iter_retcodes(Some(&filter));
1349        for (_rc, nodes) in &retcodes {
1350            for n in nodes {
1351                assert_eq!(n, "node1");
1352            }
1353        }
1354    }
1355
1356    #[test]
1357    fn test_task_iter_retcodes_by_worker() {
1358        let mut task = Task::new();
1359        let wid1 = task.shell("true", None, None);
1360        let wid2 = task.shell("exit 7", None, None);
1361        task.run(None).unwrap();
1362
1363        let rc1 = task.iter_retcodes_by_worker(wid1, None);
1364        assert_eq!(rc1.len(), 1);
1365        assert_eq!(rc1[0].0, 0);
1366
1367        let rc2 = task.iter_retcodes_by_worker(wid2, None);
1368        assert_eq!(rc2.len(), 1);
1369        assert_eq!(rc2[0].0, 7);
1370    }
1371
1372    // -- Abort tests --
1373
1374    #[test]
1375    fn test_task_abort() {
1376        let mut task = Task::new();
1377        task.shell("sleep 100", None, None);
1378        // Don't run, just abort
1379        task.abort(true);
1380        assert_eq!(task.state(), TaskState::Aborted);
1381    }
1382
1383    /// Test worker double that counts abort() invocations.
1384    ///
1385    /// `never_done` simulates a worker that never reports completion on its
1386    /// own (e.g. an autoclose port worker), so a missing task-level
1387    /// terminate guard would be visible as repeated abort() calls.
1388    struct CountingWorker {
1389        abort_count: Arc<AtomicUsize>,
1390        aborted: bool,
1391        never_done: bool,
1392        retcodes: HashMap<String, i32>,
1393    }
1394
1395    impl CountingWorker {
1396        fn new(abort_count: Arc<AtomicUsize>, never_done: bool) -> Self {
1397            Self {
1398                abort_count,
1399                aborted: false,
1400                never_done,
1401                retcodes: HashMap::new(),
1402            }
1403        }
1404    }
1405
1406    impl Worker for CountingWorker {
1407        fn start(&mut self) -> crate::worker::Result<()> {
1408            Ok(())
1409        }
1410
1411        fn abort(&mut self, _kill: bool) {
1412            self.abort_count.fetch_add(1, Ordering::SeqCst);
1413            self.aborted = true;
1414        }
1415
1416        fn state(&self) -> WorkerState {
1417            if self.aborted {
1418                WorkerState::Aborted
1419            } else {
1420                WorkerState::Running
1421            }
1422        }
1423
1424        fn set_handler(&mut self, _handler: Box<dyn EventHandler>) {}
1425
1426        fn read_fds(&self) -> Vec<std::os::unix::io::RawFd> {
1427            Vec::new()
1428        }
1429
1430        fn write_fds(&self) -> Vec<std::os::unix::io::RawFd> {
1431            Vec::new()
1432        }
1433
1434        fn handle_read(&mut self, _fd: std::os::unix::io::RawFd) -> crate::worker::Result<()> {
1435            Ok(())
1436        }
1437
1438        fn handle_write(&mut self, _fd: std::os::unix::io::RawFd) -> crate::worker::Result<()> {
1439            Ok(())
1440        }
1441
1442        fn is_done(&self) -> bool {
1443            !self.never_done && self.aborted
1444        }
1445
1446        fn retcodes(&self) -> &HashMap<String, i32> {
1447            &self.retcodes
1448        }
1449
1450        fn num_nodes(&self) -> usize {
1451            1
1452        }
1453
1454        fn take_handler(&mut self) -> Option<Box<dyn EventHandler>> {
1455            None
1456        }
1457    }
1458
1459    #[test]
1460    fn test_task_terminate_only_once_on_repeated_abort() {
1461        // Mirrors upstream ClusterShell #110 ("Task: only terminate Task
1462        // once"): repeated terminate/abort calls after a kill-terminate
1463        // must be no-ops and must not double-fire worker termination.
1464        let count = Arc::new(AtomicUsize::new(0));
1465        let mut task = Task::new();
1466        task.schedule(
1467            Box::new(CountingWorker::new(count.clone(), true)),
1468            None,
1469            false,
1470        );
1471
1472        task.abort(true);
1473        assert_eq!(task.state(), TaskState::Aborted);
1474        assert_eq!(count.load(Ordering::SeqCst), 1);
1475        assert!(task.terminated_kill);
1476
1477        // A running task aborted with kill=True terminated twice upstream
1478        // (via _resume() and _thread_start()); the second pass must be
1479        // suppressed by the terminate-once guard.
1480        task.abort(true);
1481        task.abort(false);
1482        assert_eq!(count.load(Ordering::SeqCst), 1);
1483        assert_eq!(task.state(), TaskState::Aborted);
1484    }
1485
1486    #[test]
1487    fn test_task_terminate_guard_direct() {
1488        // Directly exercise Task::terminate() the way Python's #110
1489        // regression triggered it: two terminate passes, one worker that
1490        // never reports done. The guard must prevent the second abort.
1491        let count = Arc::new(AtomicUsize::new(0));
1492        let mut task = Task::new();
1493        task.schedule(
1494            Box::new(CountingWorker::new(count.clone(), true)),
1495            None,
1496            false,
1497        );
1498
1499        task.terminate(true);
1500        task.terminate(true);
1501        task.terminate(false);
1502        assert_eq!(count.load(Ordering::SeqCst), 1);
1503    }
1504
1505    #[test]
1506    fn test_task_repeated_soft_abort_then_kill() {
1507        // kill=False terminate passes do not release the engine upstream,
1508        // so a later kill=True pass still runs; but an already-aborted
1509        // worker must not be aborted again (engine.clear() removed it).
1510        let count = Arc::new(AtomicUsize::new(0));
1511        let mut task = Task::new();
1512        task.schedule(
1513            Box::new(CountingWorker::new(count.clone(), false)),
1514            None,
1515            false,
1516        );
1517
1518        task.abort(false);
1519        assert_eq!(task.state(), TaskState::Aborted);
1520        assert!(!task.terminated_kill);
1521        task.abort(false);
1522        assert_eq!(count.load(Ordering::SeqCst), 1);
1523
1524        task.abort(true);
1525        assert!(task.terminated_kill);
1526        task.abort(true);
1527        assert_eq!(count.load(Ordering::SeqCst), 1);
1528        assert_eq!(task.state(), TaskState::Aborted);
1529    }
1530
1531    #[test]
1532    fn test_task_abort_clears_results() {
1533        // Python Task._terminate() calls Task._reset(): aborting wipes the
1534        // collected result stores.
1535        let mut task = Task::new();
1536        task.shell("echo hello", None, None);
1537        task.run(None).unwrap();
1538        assert_eq!(task.max_retcode(), Some(0));
1539        assert!(!task.node_buffer("localhost").unwrap().is_empty());
1540
1541        task.abort(false);
1542        assert_eq!(task.max_retcode(), None);
1543        assert!(task.node_buffer("localhost").unwrap().is_empty());
1544        assert_eq!(task.num_timeout(), 0);
1545    }
1546
1547    #[test]
1548    fn test_task_terminate_once_on_running_abort() {
1549        // Mirrors tests/TaskThreadJoinTest.py
1550        // ::testThreadTaskTerminateOnceOnRunningAbort (#110): several task
1551        // threads are aborted (task_cleanup() equivalent) while inside
1552        // their run loops; every thread must unwind cleanly — joinable, no
1553        // panic, terminated exactly once.
1554        let mut abort_handles = Vec::new();
1555        let mut join_handles = Vec::new();
1556
1557        for i in 1..=4u64 {
1558            let mut task = Task::new();
1559            task.shell(&format!("sleep {}", 4 + i), None, None);
1560            abort_handles.push(task.abort_handle());
1561            join_handles.push(std::thread::spawn(move || {
1562                let result = task.run(None);
1563                (result, task.state())
1564            }));
1565        }
1566
1567        // Give the task threads time to actually enter their run loops.
1568        std::thread::sleep(Duration::from_millis(300));
1569
1570        // task_cleanup() equivalent: abort every running task with kill.
1571        for handle in &abort_handles {
1572            handle.abort(true);
1573        }
1574
1575        // Python joins each task thread and asserts no AttributeError /
1576        // Traceback leaked to stderr; the Rust equivalent is a clean join
1577        // (no panic payload) with an unwound, aborted task.
1578        for join in join_handles {
1579            let (result, state) = join.join().expect("task thread panicked on abort");
1580            assert!(result.is_ok(), "aborted run should unwind normally");
1581            assert_eq!(state, TaskState::Aborted);
1582        }
1583    }
1584
1585    #[test]
1586    fn test_task_abort_handle_repeated_abort_during_run() {
1587        // Repeated abort signals while a task is running must terminate it
1588        // exactly once and leave it in a clean, joinable state.
1589        let mut task = Task::new();
1590        task.shell("sleep 8", None, None);
1591        let handle = task.abort_handle();
1592
1593        let join = std::thread::spawn(move || {
1594            let result = task.run(None);
1595            (result, task.state())
1596        });
1597
1598        std::thread::sleep(Duration::from_millis(300));
1599        handle.abort(true);
1600        handle.abort(true);
1601        handle.abort(false);
1602
1603        let (result, state) = join.join().expect("task thread panicked on abort");
1604        assert!(result.is_ok());
1605        assert_eq!(state, TaskState::Aborted);
1606    }
1607
1608    // -- Timeout tests --
1609
1610    #[test]
1611    fn test_task_run_with_timeout() {
1612        let mut task = Task::new();
1613        task.shell("sleep 100", None, None);
1614        let result = task.run(Some(Duration::from_millis(100)));
1615        assert!(result.is_err());
1616        match result.unwrap_err() {
1617            TaskError::Timeout => {}
1618            other => panic!("expected Timeout, got: {:?}", other),
1619        }
1620    }
1621
1622    // -- Event handler chaining tests --
1623
1624    #[test]
1625    fn test_task_with_custom_handler() {
1626        let started = Arc::new(AtomicBool::new(false));
1627        let closed = Arc::new(AtomicBool::new(false));
1628        let started_clone = started.clone();
1629        let closed_clone = closed.clone();
1630
1631        struct TestHandler {
1632            started: Arc<AtomicBool>,
1633            closed: Arc<AtomicBool>,
1634        }
1635
1636        impl EventHandler for TestHandler {
1637            fn on_start(&mut self, _worker: &dyn Worker) {
1638                self.started.store(true, Ordering::SeqCst);
1639            }
1640            fn on_close(&mut self, _node: &str, _rc: i32) {
1641                self.closed.store(true, Ordering::SeqCst);
1642            }
1643        }
1644
1645        let handler = TestHandler {
1646            started: started_clone,
1647            closed: closed_clone,
1648        };
1649
1650        let mut task = Task::new();
1651        task.shell("echo hello", None, Some(Box::new(handler)));
1652        task.run(None).unwrap();
1653
1654        assert!(started.load(Ordering::SeqCst), "on_start should be called");
1655        assert!(closed.load(Ordering::SeqCst), "on_close should be called");
1656    }
1657
1658    // -- Reset tests --
1659
1660    #[test]
1661    fn test_task_reset_between_runs() {
1662        let mut task = Task::new();
1663        task.shell("exit 5", None, None);
1664        task.run(None).unwrap();
1665        assert_eq!(task.max_retcode(), Some(5));
1666
1667        // Schedule new work and run again
1668        task.shell("exit 3", None, None);
1669        // Reset state to allow re-run
1670        task.state = TaskState::Idle;
1671        task.run(None).unwrap();
1672        // Both workers are in the list. The first (exit 5) is Done and
1673        // won't re-run, but collect_results() still picks up its stored
1674        // retcodes. So max_rc is max(5, 3) = 5.
1675        assert_eq!(task.max_retcode(), Some(5));
1676    }
1677
1678    // -- Topology tests --
1679
1680    #[test]
1681    fn test_task_topology_none_by_default() {
1682        let task = Task::new();
1683        assert!(!task.has_topology());
1684    }
1685
1686    #[test]
1687    fn test_task_default_tree_disabled() {
1688        let mut task = Task::new();
1689        assert!(!task.default_tree_is_enabled());
1690    }
1691
1692    // -- Buffer accessor tests --
1693
1694    #[test]
1695    fn test_task_node_buffer_empty() {
1696        let task = Task::new();
1697        let buf = task.node_buffer("somenode").unwrap();
1698        assert!(buf.is_empty());
1699    }
1700
1701    #[test]
1702    fn test_task_node_error_empty() {
1703        let task = Task::new();
1704        let buf = task.node_error("somenode").unwrap();
1705        assert!(buf.is_empty());
1706    }
1707
1708    #[test]
1709    fn test_task_node_buffer_disabled() {
1710        let mut td = TaskDefaults::default();
1711        td.stdout_msgtree = false;
1712        let task = Task::with_config(td, TaskInfo::default());
1713        let result = task.node_buffer("somenode");
1714        assert!(result.is_err());
1715    }
1716
1717    #[test]
1718    fn test_task_flush_buffers() {
1719        let mut task = Task::new();
1720        task.flush_buffers();
1721        task.flush_errors();
1722        // Should not panic
1723    }
1724
1725    // -- Worker accessor tests --
1726
1727    #[test]
1728    fn test_task_worker_accessor() {
1729        let mut task = Task::new();
1730        let wid = task.shell("echo test", None, None);
1731        assert!(task.worker(wid).is_some());
1732        assert!(task.worker(999).is_none());
1733    }
1734
1735    #[test]
1736    fn test_task_worker_retcodes_accessor() {
1737        let mut task = Task::new();
1738        let wid = task.shell("echo test", None, None);
1739        task.run(None).unwrap();
1740        let retcodes = task.worker_retcodes(wid);
1741        assert!(retcodes.is_some());
1742        assert_eq!(*retcodes.unwrap().get("localhost").unwrap(), 0);
1743    }
1744
1745    // -- Info/Defaults mutability tests --
1746
1747    #[test]
1748    fn test_task_defaults_mut() {
1749        let mut task = Task::new();
1750        assert!(!task.defaults().stderr);
1751        task.defaults_mut().stderr = true;
1752        assert!(task.defaults().stderr);
1753    }
1754
1755    #[test]
1756    fn test_task_info_mut() {
1757        let mut task = Task::new();
1758        assert_eq!(task.info().fanout, 64);
1759        task.info_mut().fanout = 256;
1760        assert_eq!(task.info().fanout, 256);
1761    }
1762
1763    // -- Hostname helper --
1764
1765    #[test]
1766    fn test_get_short_hostname() {
1767        let hostname = get_short_hostname();
1768        assert!(!hostname.is_empty());
1769        assert!(!hostname.contains('.'));
1770    }
1771
1772    // -- Gathering handler tests --
1773
1774    #[test]
1775    fn test_gathering_handler_collects_retcodes() {
1776        let mut handler = TaskGatheringHandler::new(0, None);
1777        handler.on_close("node1", 0);
1778        handler.on_close("node2", 1);
1779        assert_eq!(handler.retcodes.len(), 2);
1780        assert_eq!(handler.retcodes["node1"], 0);
1781        assert_eq!(handler.retcodes["node2"], 1);
1782    }
1783
1784    #[test]
1785    fn test_gathering_handler_collects_stdout() {
1786        let mut handler = TaskGatheringHandler::new(0, None);
1787        handler.on_read("node1", 1, b"hello");
1788        handler.on_read("node1", 1, b"world");
1789        handler.on_read("node2", 1, b"hi");
1790        assert_eq!(handler.stdout["node1"].len(), 2);
1791        assert_eq!(handler.stdout["node2"].len(), 1);
1792    }
1793
1794    #[test]
1795    fn test_gathering_handler_collects_stderr() {
1796        let mut handler = TaskGatheringHandler::new(0, None);
1797        handler.on_read("node1", 2, b"error msg");
1798        assert_eq!(handler.stderr["node1"].len(), 1);
1799        assert!(handler.stdout.is_empty());
1800    }
1801
1802    #[test]
1803    fn test_gathering_handler_collects_timeouts() {
1804        let mut handler = TaskGatheringHandler::new(0, None);
1805        handler.on_timeout("node1");
1806        handler.on_timeout("node2");
1807        assert_eq!(handler.timeouts.len(), 2);
1808        assert!(handler.timeouts.contains("node1"));
1809    }
1810
1811    #[test]
1812    fn test_gathering_handler_chains_user_handler() {
1813        let called = Arc::new(AtomicUsize::new(0));
1814        let called_clone = called.clone();
1815
1816        struct CountHandler(Arc<AtomicUsize>);
1817        impl EventHandler for CountHandler {
1818            fn on_close(&mut self, _node: &str, _rc: i32) {
1819                self.0.fetch_add(1, Ordering::SeqCst);
1820            }
1821        }
1822
1823        let user_handler = Box::new(CountHandler(called_clone));
1824        let mut handler = TaskGatheringHandler::new(0, Some(user_handler));
1825        handler.on_close("node1", 0);
1826        handler.on_close("node2", 0);
1827
1828        assert_eq!(called.load(Ordering::SeqCst), 2);
1829        assert_eq!(handler.retcodes.len(), 2);
1830    }
1831}