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