Skip to main content

consortium/worker/
exec.rs

1//! ExecWorker implementation for running commands on nodes.
2//!
3//! This module provides ExecWorker, a worker implementation that
4//! spawns child processes to execute commands on remote or local nodes.
5
6use std::collections::HashMap;
7use std::io::Read;
8use std::os::unix::io::{AsRawFd, RawFd};
9use std::process::{Child, Command, Stdio};
10use std::time::Duration;
11
12/// Set a file descriptor to non-blocking mode.
13fn set_nonblocking(fd: RawFd) -> std::io::Result<()> {
14    unsafe {
15        let flags = libc::fcntl(fd, libc::F_GETFL);
16        if flags < 0 {
17            return Err(std::io::Error::last_os_error());
18        }
19        let result = libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK);
20        if result < 0 {
21            return Err(std::io::Error::last_os_error());
22        }
23    }
24    Ok(())
25}
26
27use crate::worker::{EventHandler, Result, Worker, WorkerError, WorkerState};
28
29/// A child process being managed by ExecWorker.
30struct ChildProcess {
31    /// The underlying child process.
32    child: Child,
33    /// File descriptor for stdout.
34    stdout_fd: RawFd,
35    /// File descriptor for stderr, if captured.
36    stderr_fd: Option<RawFd>,
37    /// Node name this process is running on.
38    node: String,
39    /// Buffered stdout data.
40    buf_stdout: Vec<u8>,
41    /// Buffered stderr data.
42    buf_stderr: Vec<u8>,
43    /// Whether stderr is being read.
44    read_stderr: bool,
45}
46
47impl ChildProcess {
48    /// Create a new ChildProcess wrapper.
49    fn new(child: Child, node: String, read_stderr: bool) -> std::io::Result<Self> {
50        let stdout_fd = child.stdout.as_ref().map_or(-1, |s| s.as_raw_fd());
51        let stderr_fd = if read_stderr {
52            child.stderr.as_ref().map(|s| s.as_raw_fd())
53        } else {
54            None
55        };
56
57        // Set fds to non-blocking so polling reads don't hang
58        if stdout_fd >= 0 {
59            let _ = set_nonblocking(stdout_fd);
60        }
61        if let Some(fd) = stderr_fd {
62            let _ = set_nonblocking(fd);
63        }
64
65        Ok(ChildProcess {
66            child,
67            stdout_fd,
68            stderr_fd,
69            node,
70            buf_stdout: Vec::new(),
71            buf_stderr: Vec::new(),
72            read_stderr,
73        })
74    }
75
76    /// Get file descriptors for read interest.
77    #[allow(dead_code)]
78    fn read_fds(&self) -> Vec<RawFd> {
79        let mut fds = vec![self.stdout_fd];
80        if self.read_stderr {
81            if let Some(fd) = self.stderr_fd {
82                fds.push(fd);
83            }
84        }
85        fds
86    }
87
88    /// Check if this process is done (exited).
89    #[allow(dead_code)]
90    fn is_done(&mut self) -> bool {
91        matches!(self.child.try_wait(), Ok(Some(_)))
92    }
93
94    /// Get the exit status if available.
95    fn try_wait(&mut self) -> std::io::Result<Option<i32>> {
96        self.child
97            .try_wait()
98            .map(|opt| opt.map(|status| status.code().unwrap_or(-1)))
99    }
100}
101
102/// Worker implementation that runs commands on nodes via local execution.
103///
104/// ExecWorker spawns child processes to execute commands on nodes.
105/// It supports fanout control (limiting concurrent processes) and
106/// node name substitution in commands (%h or {node}).
107pub struct ExecWorker {
108    /// List of nodes to run on.
109    nodes: Vec<String>,
110    /// Command to execute (may contain %h or {node} placeholders).
111    command: String,
112    /// Current worker state.
113    state: WorkerState,
114    /// Event handler for worker events.
115    handler: Option<Box<dyn EventHandler>>,
116    /// Map of node name to child process.
117    processes: HashMap<String, ChildProcess>,
118    /// Map of node name to return code.
119    retcodes: HashMap<String, i32>,
120    /// Maximum number of concurrent processes.
121    fanout: usize,
122    /// Optional timeout for each node's command.
123    #[allow(dead_code)]
124    timeout: Option<Duration>,
125    /// Whether to capture stderr.
126    stderr: bool,
127    /// Queue of nodes waiting to be processed.
128    pending_queue: Vec<String>,
129    /// Number of currently running processes.
130    running_count: usize,
131}
132
133impl ExecWorker {
134    /// Create a new ExecWorker.
135    ///
136    /// # Arguments
137    /// * `nodes` - List of node names to execute the command on.
138    /// * `command` - Command to execute. Use %h or {node} as placeholders for the node name.
139    /// * `fanout` - Maximum number of concurrent processes (default: 64).
140    /// * `timeout` - Optional timeout per node.
141    pub fn new(
142        nodes: Vec<String>,
143        command: String,
144        fanout: usize,
145        timeout: Option<Duration>,
146    ) -> Self {
147        ExecWorker {
148            nodes: nodes.clone(),
149            command,
150            state: WorkerState::Pending,
151            handler: None,
152            processes: HashMap::new(),
153            retcodes: HashMap::new(),
154            fanout,
155            timeout,
156            stderr: false,
157            pending_queue: nodes,
158            running_count: 0,
159        }
160    }
161
162    /// Create a new ExecWorker with stderr enabled.
163    pub fn with_stderr(mut self, stderr: bool) -> Self {
164        self.stderr = stderr;
165        self
166    }
167
168    /// Substitute node placeholders in the command.
169    fn substitute_node(&self, command: &str, node: &str) -> String {
170        command.replace("%h", node).replace("{node}", node)
171    }
172
173    /// Spawn a new child process for a node.
174    fn spawn_node(&mut self, node: &str) -> Result<()> {
175        let cmd = self.substitute_node(&self.command, node);
176        let child = Command::new("sh")
177            .arg("-c")
178            .arg(cmd)
179            .stdout(Stdio::piped())
180            .stderr(if self.stderr {
181                Stdio::piped()
182            } else {
183                Stdio::null()
184            })
185            .spawn()
186            .map_err(|e| {
187                WorkerError::General(format!("failed to spawn process for {}: {}", node, e))
188            })?;
189
190        let process =
191            ChildProcess::new(child, node.to_string(), self.stderr).map_err(WorkerError::Io)?;
192
193        let node_name = process.node.clone();
194        self.processes.insert(node_name.clone(), process);
195        self.running_count += 1;
196
197        // Temporarily take handler to avoid borrow conflict
198        if let Some(mut handler) = self.handler.take() {
199            handler.on_start(self);
200            self.handler = Some(handler);
201        }
202
203        Ok(())
204    }
205
206    /// Read available data from a file descriptor and notify handler.
207    fn read_from_fd(&mut self, fd: RawFd) -> Result<bool> {
208        let mut read_something = false;
209        // Collect handler events to dispatch after iteration ends,
210        // since we can't borrow self.handler while iterating self.processes.
211        let mut events: Vec<(String, RawFd, Vec<u8>)> = Vec::new();
212
213        // Try to find which process this fd belongs to
214        for (node, process) in self.processes.iter_mut() {
215            let is_stdout = process.stdout_fd == fd;
216            let is_stderr = process.read_stderr && process.stderr_fd == Some(fd);
217
218            if !is_stdout && !is_stderr {
219                continue;
220            }
221
222            let mut buffer = [0u8; 4096];
223
224            // Read from the child's stdout/stderr handles directly
225            let read_result = if is_stdout {
226                if let Some(ref mut stdout) = process.child.stdout {
227                    stdout.read(&mut buffer)
228                } else {
229                    continue;
230                }
231            } else if let Some(ref mut stderr) = process.child.stderr {
232                stderr.read(&mut buffer)
233            } else {
234                continue;
235            };
236
237            match read_result {
238                Ok(0) => {
239                    // EOF
240                    continue;
241                }
242                Ok(n) => {
243                    let msg = &buffer[..n];
244                    read_something = true;
245
246                    let event_fd = if is_stdout { process.stdout_fd } else { fd };
247
248                    // Store in buffer
249                    if is_stdout {
250                        process.buf_stdout.extend_from_slice(msg);
251                    } else {
252                        process.buf_stderr.extend_from_slice(msg);
253                    }
254
255                    // Defer handler notification
256                    events.push((node.clone(), event_fd, msg.to_vec()));
257                }
258                Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
259                    continue;
260                }
261                Err(e) => {
262                    return Err(WorkerError::Io(e));
263                }
264            }
265        }
266
267        // Dispatch deferred handler notifications
268        if let Some(ref mut handler) = self.handler {
269            for (node, event_fd, msg) in &events {
270                handler.on_read(node, *event_fd, msg);
271            }
272        }
273
274        Ok(read_something)
275    }
276
277    /// Drain all remaining data from a process's stdout/stderr.
278    /// Must be called before removing the process so no output is lost.
279    fn drain_process(process: &mut ChildProcess, handler: &mut Option<Box<dyn EventHandler>>) {
280        let mut buffer = [0u8; 4096];
281
282        // Drain stdout
283        if let Some(ref mut stdout) = process.child.stdout {
284            loop {
285                match stdout.read(&mut buffer) {
286                    Ok(0) | Err(_) => break,
287                    Ok(n) => {
288                        let msg = &buffer[..n];
289                        process.buf_stdout.extend_from_slice(msg);
290                        if let Some(ref mut h) = handler {
291                            h.on_read(&process.node, process.stdout_fd, msg);
292                        }
293                    }
294                }
295            }
296        }
297
298        // Drain stderr
299        if process.read_stderr {
300            if let Some(ref mut stderr) = process.child.stderr {
301                loop {
302                    match stderr.read(&mut buffer) {
303                        Ok(0) | Err(_) => break,
304                        Ok(n) => {
305                            let msg = &buffer[..n];
306                            process.buf_stderr.extend_from_slice(msg);
307                            if let Some(ref mut h) = handler {
308                                if let Some(fd) = process.stderr_fd {
309                                    h.on_read(&process.node, fd, msg);
310                                }
311                            }
312                        }
313                    }
314                }
315            }
316        }
317    }
318
319    /// Check for exited processes and update state.
320    fn check_exited_processes(&mut self) -> Result<bool> {
321        let mut something_changed = false;
322        let mut exited_nodes: Vec<String> = Vec::new();
323
324        for (node, process) in self.processes.iter_mut() {
325            if let Ok(Some(rc)) = process.try_wait() {
326                exited_nodes.push(node.clone());
327                self.retcodes.insert(node.clone(), rc);
328            }
329        }
330
331        for node in &exited_nodes {
332            // Drain remaining output before removing the process
333            if let Some(process) = self.processes.get_mut(node) {
334                Self::drain_process(process, &mut self.handler);
335            }
336
337            // Notify handler of close
338            if let Some(ref mut handler) = self.handler {
339                let rc = self.retcodes.get(node).copied().unwrap_or(-1);
340                handler.on_close(node, rc);
341            }
342        }
343
344        for node in exited_nodes {
345            self.processes.remove(&node);
346            self.running_count -= 1;
347            something_changed = true;
348        }
349
350        Ok(something_changed)
351    }
352}
353
354impl Worker for ExecWorker {
355    fn start(&mut self) -> Result<()> {
356        if self.state != WorkerState::Pending {
357            return Err(WorkerError::General(
358                "worker already started or aborted".to_string(),
359            ));
360        }
361
362        self.state = WorkerState::Running;
363
364        // Spawn initial batch up to fanout
365        while self.running_count < self.fanout && !self.pending_queue.is_empty() {
366            let node = self.pending_queue.remove(0);
367            self.spawn_node(&node)?;
368        }
369
370        Ok(())
371    }
372
373    fn abort(&mut self, kill: bool) {
374        if self.state == WorkerState::Done || self.state == WorkerState::Aborted {
375            return;
376        }
377
378        if kill {
379            // Kill all running processes
380            for (_node, process) in self.processes.iter_mut() {
381                let _ = process.child.kill();
382            }
383        }
384
385        // Clean up processes
386        self.processes.clear();
387        self.pending_queue.clear();
388        self.running_count = 0;
389        self.state = WorkerState::Aborted;
390    }
391
392    fn state(&self) -> WorkerState {
393        self.state
394    }
395
396    fn set_handler(&mut self, handler: Box<dyn EventHandler>) {
397        self.handler = Some(handler);
398    }
399
400    fn read_fds(&self) -> Vec<RawFd> {
401        let mut fds = Vec::new();
402        for process in self.processes.values() {
403            fds.push(process.stdout_fd);
404            if process.read_stderr {
405                if let Some(fd) = process.stderr_fd {
406                    fds.push(fd);
407                }
408            }
409        }
410        fds
411    }
412
413    fn write_fds(&self) -> Vec<RawFd> {
414        // ExecWorker doesn't currently write to child processes
415        Vec::new()
416    }
417
418    fn handle_read(&mut self, fd: RawFd) -> Result<()> {
419        // Read data from the fd
420        self.read_from_fd(fd)?;
421
422        // Check for exited processes
423        self.check_exited_processes()?;
424
425        // Spawn next pending node if space available
426        while self.running_count < self.fanout && !self.pending_queue.is_empty() {
427            let node = self.pending_queue.remove(0);
428            self.spawn_node(&node)?;
429        }
430
431        // Check if we're done
432        if self.processes.is_empty() && self.pending_queue.is_empty() {
433            self.state = WorkerState::Done;
434        }
435
436        Ok(())
437    }
438
439    fn handle_write(&mut self, _fd: RawFd) -> Result<()> {
440        // No-op for ExecWorker
441        Ok(())
442    }
443
444    fn is_done(&self) -> bool {
445        self.state == WorkerState::Done || self.state == WorkerState::Aborted
446    }
447
448    fn retcodes(&self) -> &HashMap<String, i32> {
449        &self.retcodes
450    }
451
452    fn num_nodes(&self) -> usize {
453        self.nodes.len()
454    }
455
456    fn take_handler(&mut self) -> Option<Box<dyn EventHandler>> {
457        self.handler.take()
458    }
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464    use std::sync::atomic::{AtomicUsize, Ordering};
465    use std::sync::Arc;
466
467    /// Test ExecWorker creation
468    #[test]
469    fn test_exec_worker_creation() {
470        let nodes = vec!["localhost".to_string()];
471        let worker = ExecWorker::new(nodes, "echo hello".to_string(), 1, None);
472
473        assert_eq!(worker.state(), WorkerState::Pending);
474        assert_eq!(worker.num_nodes(), 1);
475        assert!(worker.retcodes().is_empty());
476    }
477
478    /// Test ExecWorker with multiple nodes
479    #[test]
480    fn test_exec_worker_multiple_nodes() {
481        let nodes = vec![
482            "node1".to_string(),
483            "node2".to_string(),
484            "node3".to_string(),
485        ];
486        let worker = ExecWorker::new(nodes, "echo test".to_string(), 2, None);
487
488        assert_eq!(worker.state(), WorkerState::Pending);
489        assert_eq!(worker.num_nodes(), 3);
490    }
491
492    /// Test ExecWorker with timeout
493    #[test]
494    fn test_exec_worker_with_timeout() {
495        let nodes = vec!["localhost".to_string()];
496        let timeout = Duration::from_secs(30);
497        let worker = ExecWorker::new(nodes, "sleep 1".to_string(), 1, Some(timeout));
498
499        assert_eq!(worker.state(), WorkerState::Pending);
500    }
501
502    /// Test ExecWorker with stderr enabled
503    #[test]
504    fn test_exec_worker_with_stderr() {
505        let nodes = vec!["localhost".to_string()];
506        let worker = ExecWorker::new(nodes, "echo test".to_string(), 1, None).with_stderr(true);
507
508        assert!(worker.stderr);
509    }
510
511    /// Test node substitution in command (%h placeholder)
512    #[test]
513    fn test_node_substitution_percent_h() {
514        let nodes = vec!["myhost".to_string()];
515        let worker = ExecWorker::new(nodes, "ssh %h 'ls'".to_string(), 1, None);
516
517        let cmd = worker.substitute_node("ssh %h 'ls'", "myhost");
518        assert_eq!(cmd, "ssh myhost 'ls'");
519    }
520
521    /// Test node substitution in command ({node} placeholder)
522    #[test]
523    fn test_node_substitution_braces() {
524        let nodes = vec!["myhost".to_string()];
525        let worker = ExecWorker::new(nodes, "ssh {node} 'ls'".to_string(), 1, None);
526
527        let cmd = worker.substitute_node("ssh {node} 'ls'", "myhost");
528        assert_eq!(cmd, "ssh myhost 'ls'");
529    }
530
531    /// Test ExecWorker with simple echo command on localhost
532    #[test]
533    fn test_exec_worker_simple_echo() {
534        let nodes = vec!["localhost".to_string()];
535        let mut worker = ExecWorker::new(nodes, "echo hello".to_string(), 1, None);
536
537        // Worker should start in Pending state
538        assert_eq!(worker.state(), WorkerState::Pending);
539
540        // Start the worker
541        let result = worker.start();
542        assert!(result.is_ok());
543
544        // Worker should now be Running
545        assert_eq!(worker.state(), WorkerState::Running);
546
547        // Poll for completion
548        let start = std::time::Instant::now();
549        while !worker.is_done() && start.elapsed() < Duration::from_secs(5) {
550            let fds = worker.read_fds();
551            for fd in &fds {
552                let _ = worker.handle_read(*fd);
553            }
554            std::thread::sleep(Duration::from_millis(10));
555        }
556
557        // Worker should be Done
558        assert!(worker.is_done() || worker.state() == WorkerState::Done);
559
560        // Should have a return code
561        let retcodes = worker.retcodes();
562        assert!(!retcodes.is_empty());
563        assert!(retcodes.contains_key("localhost"));
564    }
565
566    /// Test EventHandler integration
567    #[test]
568    fn test_exec_worker_with_handler() {
569        let nodes = vec!["localhost".to_string()];
570        let mut worker = ExecWorker::new(nodes, "echo test".to_string(), 1, None);
571
572        struct TestHandler {
573            pub on_start_count: Arc<AtomicUsize>,
574            pub on_read_count: Arc<AtomicUsize>,
575            pub on_close_count: Arc<AtomicUsize>,
576        }
577
578        impl EventHandler for TestHandler {
579            fn on_start(&mut self, _worker: &dyn Worker) {
580                self.on_start_count.fetch_add(1, Ordering::SeqCst);
581            }
582
583            fn on_read(&mut self, _node: &str, _fd: RawFd, _msg: &[u8]) {
584                self.on_read_count.fetch_add(1, Ordering::SeqCst);
585            }
586
587            fn on_close(&mut self, _node: &str, _rc: i32) {
588                self.on_close_count.fetch_add(1, Ordering::SeqCst);
589            }
590        }
591
592        let on_start_count = Arc::new(AtomicUsize::new(0));
593        let on_read_count = Arc::new(AtomicUsize::new(0));
594        let on_close_count = Arc::new(AtomicUsize::new(0));
595
596        let handler = TestHandler {
597            on_start_count: on_start_count.clone(),
598            on_read_count: on_read_count.clone(),
599            on_close_count: on_close_count.clone(),
600        };
601
602        worker.set_handler(Box::new(handler));
603
604        // Start and run
605        let _ = worker.start();
606
607        let start = std::time::Instant::now();
608        while !worker.is_done() && start.elapsed() < Duration::from_secs(5) {
609            let fds = worker.read_fds();
610            for fd in &fds {
611                let _ = worker.handle_read(*fd);
612            }
613            std::thread::sleep(Duration::from_millis(10));
614        }
615
616        // Handlers should have been called
617        assert!(
618            on_start_count.load(Ordering::SeqCst) >= 1,
619            "on_start should be called"
620        );
621        assert!(
622            on_close_count.load(Ordering::SeqCst) >= 1,
623            "on_close should be called"
624        );
625    }
626
627    /// Test fanout control
628    #[test]
629    fn test_exec_worker_fanout() {
630        let nodes = vec![
631            "node1".to_string(),
632            "node2".to_string(),
633            "node3".to_string(),
634            "node4".to_string(),
635        ];
636        let mut worker = ExecWorker::new(nodes, "echo test".to_string(), 2, None);
637
638        // Fanout is 2, so only 2 processes should start initially
639        let _ = worker.start();
640
641        // Give a moment for processes to start
642        std::thread::sleep(Duration::from_millis(50));
643
644        // Should have at most 2 running processes
645        assert!(
646            worker.running_count <= 2,
647            "Should have at most fanout processes running"
648        );
649    }
650
651    /// Test abort functionality
652    #[test]
653    fn test_exec_worker_abort() {
654        let nodes = vec!["localhost".to_string()];
655        let mut worker = ExecWorker::new(nodes, "sleep 10".to_string(), 1, None);
656
657        let _ = worker.start();
658        assert_eq!(worker.state(), WorkerState::Running);
659
660        worker.abort(true);
661        assert_eq!(worker.state(), WorkerState::Aborted);
662        assert!(worker.processes.is_empty());
663    }
664
665    /// Test that read_fds returns correct file descriptors
666    #[test]
667    fn test_exec_worker_read_fds() {
668        let nodes = vec!["localhost".to_string()];
669        let worker = ExecWorker::new(nodes, "echo test".to_string(), 1, None);
670
671        let fds = worker.read_fds();
672        assert!(fds.is_empty(), "No fds before start");
673    }
674
675    /// Test that write_fds returns empty for ExecWorker
676    #[test]
677    fn test_exec_worker_write_fds() {
678        let nodes = vec!["localhost".to_string()];
679        let worker = ExecWorker::new(nodes, "echo test".to_string(), 1, None);
680
681        let fds = worker.write_fds();
682        assert!(fds.is_empty(), "ExecWorker doesn't write to children");
683    }
684
685    /// Test that all nodes produce output even when processes exit quickly.
686    /// Regression test: fast-exiting processes must have their output drained
687    /// before being reaped, otherwise data is lost.
688    #[test]
689    fn test_exec_worker_all_nodes_produce_output() {
690        use std::collections::HashSet;
691        use std::sync::Mutex;
692
693        let nodes: Vec<String> = (1..=5).map(|i| format!("node{i}")).collect();
694        let mut worker =
695            ExecWorker::new(nodes.clone(), "echo output_from_%h".to_string(), 64, None);
696
697        let collected = Arc::new(Mutex::new(HashMap::<String, Vec<u8>>::new()));
698        let collected_clone = collected.clone();
699        let close_count = Arc::new(AtomicUsize::new(0));
700        let close_count_clone = close_count.clone();
701
702        struct CollectorHandler {
703            collected: Arc<Mutex<HashMap<String, Vec<u8>>>>,
704            close_count: Arc<AtomicUsize>,
705        }
706
707        impl EventHandler for CollectorHandler {
708            fn on_start(&mut self, _worker: &dyn Worker) {}
709
710            fn on_read(&mut self, node: &str, _fd: RawFd, msg: &[u8]) {
711                let mut map = self.collected.lock().unwrap();
712                map.entry(node.to_string())
713                    .or_insert_with(Vec::new)
714                    .extend_from_slice(msg);
715            }
716
717            fn on_close(&mut self, _node: &str, _rc: i32) {
718                self.close_count.fetch_add(1, Ordering::SeqCst);
719            }
720        }
721
722        worker.set_handler(Box::new(CollectorHandler {
723            collected: collected_clone,
724            close_count: close_count_clone,
725        }));
726
727        let _ = worker.start();
728
729        let start = std::time::Instant::now();
730        while !worker.is_done() && start.elapsed() < Duration::from_secs(10) {
731            let fds = worker.read_fds();
732            for fd in &fds {
733                let _ = worker.handle_read(*fd);
734            }
735            std::thread::sleep(Duration::from_millis(10));
736        }
737
738        assert!(worker.is_done(), "worker should finish");
739
740        // All 5 nodes should have been closed
741        assert_eq!(
742            close_count.load(Ordering::SeqCst),
743            5,
744            "all nodes should close"
745        );
746
747        // All 5 nodes should have produced output
748        let map = collected.lock().unwrap();
749        let nodes_with_output: HashSet<&String> = map.keys().collect();
750        for node in &nodes {
751            assert!(
752                nodes_with_output.contains(node),
753                "node {} should have output, got nodes: {:?}",
754                node,
755                nodes_with_output
756            );
757            let output = String::from_utf8_lossy(&map[node]);
758            assert!(
759                output.contains(&format!("output_from_{node}")),
760                "node {} should have correct output, got: {}",
761                node,
762                output
763            );
764        }
765    }
766}