Skip to main content

consortium/worker/
mod.rs

1//! Worker module for executing commands across nodes.
2//!
3//! This module provides the abstraction for executing commands
4//! across nodes, similar to ClusterShell's Worker.
5//!
6//! ## Submodules
7//!
8//! - [`exec`] — Local command execution worker (ExecWorker)
9//! - [`ssh`] — SSH-based remote execution worker (SshWorker, ScpWorker)
10//! - [`tree`] — Tree-based propagation worker (TreeWorker)
11
12pub mod exec;
13pub mod ssh;
14pub mod tree;
15
16use std::collections::{HashMap, HashSet};
17use std::os::unix::io::RawFd;
18use thiserror::Error;
19
20// ============================================================================
21// StreamWriters — named output streams with EngineClient EPIPE semantics
22// ============================================================================
23
24/// Outcome of a single stream flush attempt.
25///
26/// Mirrors the paths of ClusterShell's `EngineClient._handle_write`
27/// (Worker/EngineClient.py), including commit cfe0448 ("remove stream on
28/// broken pipe").
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum StreamWriteOutcome {
31    /// All buffered data was written; the stream stays registered.
32    Flushed,
33    /// The fd would block (EAGAIN): data stays buffered and the stream stays
34    /// registered to retry later (upstream calls `_set_writing` and returns).
35    Pending,
36    /// Broken pipe (EPIPE): the stream was removed at the point of error and
37    /// must NOT be retried (cfe0448 — engines without POLLERR, like select,
38    /// would otherwise never remove it and the run loop would not return).
39    BrokenPipe,
40    /// EOF was requested and the buffer just drained: the stream was removed.
41    Closed,
42    /// The named stream is unknown or was already removed.
43    Unknown,
44}
45
46/// State of a single named write stream (upstream `EngineStream` with
47/// `E_WRITE` events).
48#[derive(Debug)]
49struct WriteStream {
50    /// File descriptor to write to (owned by the caller).
51    fd: RawFd,
52    /// Pending output bytes (upstream `wfile.wbuf`).
53    wbuf: Vec<u8>,
54    /// Whether EOF was requested (upstream `wfile.eof`): once the buffer
55    /// drains, the stream is removed.
56    eof: bool,
57}
58
59/// Registry of named output streams with ClusterShell `EngineClient` write
60/// semantics.
61///
62/// Ported behavior (commit cfe0448):
63/// - on EPIPE the stream is removed/closed cleanly at the point of error
64///   instead of erroring repeatedly;
65/// - writes (and EOF requests) to an unknown or removed stream are dropped
66///   silently, since the stream may have been removed while an event handler
67///   is still running.
68///
69/// File descriptors are borrowed: removal never closes the fd; ownership
70/// stays with the caller.
71#[derive(Debug, Default)]
72pub struct StreamWriters {
73    streams: HashMap<String, WriteStream>,
74}
75
76impl StreamWriters {
77    /// Create an empty stream registry.
78    pub fn new() -> Self {
79        Self::default()
80    }
81
82    /// Register a named write stream over `fd` (mirrors `set_writer`).
83    /// Registering an existing name resets its buffer and EOF flag.
84    pub fn set_writer(&mut self, sname: &str, fd: RawFd) {
85        self.streams.insert(
86            sname.to_string(),
87            WriteStream {
88                fd,
89                wbuf: Vec::new(),
90                eof: false,
91            },
92        );
93    }
94
95    /// Remove a stream (mirrors `Engine.remove_stream`). Returns whether a
96    /// stream was actually removed.
97    pub fn remove(&mut self, sname: &str) -> bool {
98        self.streams.remove(sname).is_some()
99    }
100
101    /// Whether a stream with this name is currently registered.
102    pub fn contains(&self, sname: &str) -> bool {
103        self.streams.contains_key(sname)
104    }
105
106    /// Number of registered streams.
107    pub fn len(&self) -> usize {
108        self.streams.len()
109    }
110
111    /// Whether there are no registered streams.
112    pub fn is_empty(&self) -> bool {
113        self.streams.is_empty()
114    }
115
116    /// Add data to be written to the stream (mirrors `EngineClient._write`).
117    ///
118    /// Writes to an unknown or already-removed stream are dropped silently
119    /// (cfe0448): the stream may have been removed on EPIPE while an event
120    /// handler was still running.
121    pub fn write(&mut self, sname: &str, buf: &[u8]) {
122        if let Some(stream) = self.streams.get_mut(sname) {
123            stream.wbuf.extend_from_slice(buf);
124        }
125        // else: dropped, like upstream's "dropping write to unknown or
126        // closed stream" debug log.
127    }
128
129    /// Request EOF once the buffer drains (mirrors `_set_write_eof`).
130    /// Unknown streams are ignored, like upstream.
131    pub fn set_write_eof(&mut self, sname: &str) {
132        if let Some(stream) = self.streams.get_mut(sname) {
133            stream.eof = true;
134        }
135    }
136
137    /// Bytes still buffered for this stream (0 for unknown streams).
138    pub fn buffered_len(&self, sname: &str) -> usize {
139        self.streams.get(sname).map_or(0, |s| s.wbuf.len())
140    }
141
142    /// Fds with pending data or pending EOF — the write-interest set.
143    pub fn pending_fds(&self) -> Vec<RawFd> {
144        self.streams
145            .values()
146            .filter(|s| !s.wbuf.is_empty() || s.eof)
147            .map(|s| s.fd)
148            .collect()
149    }
150
151    /// Flush a stream's buffer to its fd (mirrors
152    /// `EngineClient._handle_write`).
153    ///
154    /// - empty buffer + EOF requested → stream removed, [`StreamWriteOutcome::Closed`]
155    /// - EAGAIN → buffer kept, [`StreamWriteOutcome::Pending`] (retry later)
156    /// - EPIPE → stream removed at the point of error,
157    ///   [`StreamWriteOutcome::BrokenPipe`]; NOT an error (cfe0448)
158    /// - other I/O errors are propagated
159    /// - full/partial write → [`StreamWriteOutcome::Flushed`] /
160    ///   [`StreamWriteOutcome::Pending`]; if EOF was requested and the
161    ///   buffer drained, the stream is removed ([`StreamWriteOutcome::Closed`])
162    pub fn handle_write(&mut self, sname: &str) -> std::io::Result<StreamWriteOutcome> {
163        let Some(stream) = self.streams.get_mut(sname) else {
164            return Ok(StreamWriteOutcome::Unknown);
165        };
166
167        if stream.wbuf.is_empty() {
168            if stream.eof {
169                // nothing left to write and EOF requested: remove stream
170                self.streams.remove(sname);
171                return Ok(StreamWriteOutcome::Closed);
172            }
173            return Ok(StreamWriteOutcome::Flushed);
174        }
175
176        let fd = stream.fd;
177        let written = unsafe {
178            libc::write(
179                fd,
180                stream.wbuf.as_ptr() as *const libc::c_void,
181                stream.wbuf.len(),
182            )
183        };
184
185        if written < 0 {
186            let err = std::io::Error::last_os_error();
187            match err.raw_os_error() {
188                Some(libc::EAGAIN) => {
189                    // try again later; stream stays registered
190                    return Ok(StreamWriteOutcome::Pending);
191                }
192                Some(libc::EPIPE) => {
193                    // broken pipe: remove stream now and do NOT retry
194                    // (cfe0448 — engines without POLLERR never saw the
195                    // stream go away and the run loop would not return)
196                    self.streams.remove(sname);
197                    return Ok(StreamWriteOutcome::BrokenPipe);
198                }
199                _ => return Err(err),
200            }
201        }
202
203        let wcnt = written as usize;
204        if wcnt == 0 {
205            return Ok(StreamWriteOutcome::Pending);
206        }
207
208        let stream = self
209            .streams
210            .get_mut(sname)
211            .expect("stream removed during write");
212        stream.wbuf.drain(..wcnt);
213
214        if stream.wbuf.is_empty() && stream.eof {
215            self.streams.remove(sname);
216            return Ok(StreamWriteOutcome::Closed);
217        }
218        if stream.wbuf.is_empty() {
219            Ok(StreamWriteOutcome::Flushed)
220        } else {
221            Ok(StreamWriteOutcome::Pending)
222        }
223    }
224}
225
226/// Error types for the worker module.
227#[derive(Debug, Error)]
228pub enum WorkerError {
229    /// I/O error wrapper.
230    #[error("io error: {0}")]
231    Io(#[from] std::io::Error),
232    /// General worker error.
233    #[error("worker error: {0}")]
234    General(String),
235    /// Operation timed out.
236    #[error("timeout")]
237    Timeout,
238}
239
240/// Result type alias for worker operations.
241pub type Result<T> = std::result::Result<T, WorkerError>;
242
243/// Current state of a worker.
244#[derive(Debug, Clone, Copy, PartialEq, Eq)]
245pub enum WorkerState {
246    /// Worker is created but not started yet.
247    Pending,
248    /// Worker is currently running.
249    Running,
250    /// Worker has completed.
251    Done,
252    /// Worker was aborted.
253    Aborted,
254}
255
256/// Event handler trait for worker events.
257///
258/// Implement this trait to receive notifications about worker events.
259pub trait EventHandler: Send {
260    /// Called when the worker starts.
261    fn on_start(&mut self, worker: &dyn Worker) {
262        let _ = worker;
263    }
264
265    /// Called when data is available for reading from a node.
266    fn on_read(&mut self, node: &str, fd: RawFd, msg: &[u8]) {
267        let _ = (node, fd, msg);
268    }
269
270    /// Called when a connection to a node is closed.
271    fn on_close(&mut self, node: &str, rc: i32) {
272        let _ = (node, rc);
273    }
274
275    /// Called when a node operation times out.
276    fn on_timeout(&mut self, node: &str) {
277        let _ = node;
278    }
279
280    /// Called when an error occurs for a node.
281    fn on_error(&mut self, node: &str, error: &WorkerError) {
282        let _ = (node, error);
283    }
284
285    /// Extract buffered stdout/stderr data from this handler.
286    /// Returns (stdout, stderr, timeouts) where stdout/stderr are node -> `Vec<chunks>`.
287    /// Default returns empty maps. Used by Task to collect results after execution.
288    fn take_buffers(
289        &mut self,
290    ) -> (
291        HashMap<String, Vec<Vec<u8>>>,
292        HashMap<String, Vec<Vec<u8>>>,
293        HashSet<String>,
294    ) {
295        (HashMap::new(), HashMap::new(), HashSet::new())
296    }
297}
298
299/// Trait for worker implementations.
300///
301/// A worker executes commands across multiple nodes, handling I/O
302/// events and managing child processes.
303pub trait Worker: Send {
304    /// Start the worker.
305    fn start(&mut self) -> Result<()>;
306
307    /// Abort the worker, optionally killing child processes.
308    fn abort(&mut self, kill: bool);
309
310    /// Get the worker's current state.
311    fn state(&self) -> WorkerState;
312
313    /// Set the event handler for this worker.
314    fn set_handler(&mut self, handler: Box<dyn EventHandler>);
315
316    /// Get file descriptors for read interest.
317    fn read_fds(&self) -> Vec<RawFd>;
318
319    /// Get file descriptors for write interest.
320    fn write_fds(&self) -> Vec<RawFd>;
321
322    /// Handle a read event on the given file descriptor.
323    fn handle_read(&mut self, fd: RawFd) -> Result<()>;
324
325    /// Handle a write event on the given file descriptor.
326    fn handle_write(&mut self, fd: RawFd) -> Result<()>;
327
328    /// Check if the worker has completed.
329    fn is_done(&self) -> bool;
330
331    /// Get the return codes map: node -> return_code.
332    fn retcodes(&self) -> &HashMap<String, i32>;
333
334    /// Get the number of nodes.
335    fn num_nodes(&self) -> usize;
336
337    /// Take the event handler out of the worker (used by Task to extract gathered data).
338    fn take_handler(&mut self) -> Option<Box<dyn EventHandler>>;
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344    use std::time::Duration;
345
346    /// Test WorkerState enum variants
347    #[test]
348    fn test_worker_state_variants() {
349        assert_eq!(WorkerState::Pending, WorkerState::Pending);
350        assert_eq!(WorkerState::Running, WorkerState::Running);
351        assert_eq!(WorkerState::Done, WorkerState::Done);
352        assert_eq!(WorkerState::Aborted, WorkerState::Aborted);
353    }
354
355    /// Test WorkerState is Debug
356    #[test]
357    fn test_worker_state_debug() {
358        assert_eq!(format!("{:?}", WorkerState::Pending), "Pending");
359        assert_eq!(format!("{:?}", WorkerState::Running), "Running");
360        assert_eq!(format!("{:?}", WorkerState::Done), "Done");
361        assert_eq!(format!("{:?}", WorkerState::Aborted), "Aborted");
362    }
363
364    /// Test WorkerState is Clone
365    #[test]
366    fn test_worker_state_clone() {
367        let state = WorkerState::Pending;
368        let cloned = state.clone();
369        assert_eq!(state, cloned);
370    }
371
372    /// Test WorkerState is Copy
373    #[test]
374    fn test_worker_state_copy() {
375        fn take_copy<T: Copy>(_: T) {}
376        take_copy(WorkerState::Pending);
377    }
378
379    /// Test WorkerState is PartialEq
380    #[test]
381    fn test_worker_state_partial_eq() {
382        assert!(WorkerState::Pending == WorkerState::Pending);
383        assert!(WorkerState::Running != WorkerState::Pending);
384    }
385
386    /// Test WorkerError variants
387    #[test]
388    fn test_worker_error_variants() {
389        let io_err = WorkerError::Io(std::io::Error::new(
390            std::io::ErrorKind::NotFound,
391            "not found",
392        ));
393        let gen_err = WorkerError::General("something went wrong".to_string());
394        let timeout_err = WorkerError::Timeout;
395
396        matches!(io_err, WorkerError::Io(_));
397        matches!(gen_err, WorkerError::General(_));
398        matches!(timeout_err, WorkerError::Timeout);
399    }
400
401    /// Test WorkerError implements Error trait
402    #[test]
403    fn test_worker_error_impls_error() {
404        use std::error::Error;
405
406        let gen_err = WorkerError::General("test".to_string());
407        assert!(gen_err.source().is_none());
408
409        let timeout_err = WorkerError::Timeout;
410        assert!(timeout_err.source().is_none());
411    }
412
413    /// Test WorkerError implements Display trait
414    #[test]
415    fn test_worker_error_impls_display() {
416        let io_err = WorkerError::Io(std::io::Error::new(
417            std::io::ErrorKind::NotFound,
418            "file.txt",
419        ));
420        assert_eq!(io_err.to_string(), "io error: file.txt");
421
422        let gen_err = WorkerError::General("test error".to_string());
423        assert_eq!(gen_err.to_string(), "worker error: test error");
424
425        let timeout_err = WorkerError::Timeout;
426        assert_eq!(timeout_err.to_string(), "timeout");
427    }
428
429    /// Test EventHandler default methods compile
430    #[test]
431    fn test_event_handler_default_methods() {
432        struct TestHandler;
433
434        impl EventHandler for TestHandler {}
435
436        let handler = TestHandler;
437
438        // These should compile with default implementations (no-op tests)
439        // We just verify the methods exist and can be called
440        let _ = handler;
441    }
442
443    /// Test that EventHandler is object-safe
444    #[test]
445    fn test_event_handler_object_safe() {
446        struct TestHandler;
447
448        impl EventHandler for TestHandler {}
449
450        let _handler: Box<dyn EventHandler> = Box::new(TestHandler);
451    }
452
453    /// Test EventHandler is Send
454    #[test]
455    fn test_event_handler_is_send() {
456        fn assert_send<T: Send>() {}
457        assert_send::<Box<dyn EventHandler>>();
458    }
459
460    /// Test WorkerError Result type alias
461    #[test]
462    fn test_result_type_alias() {
463        let ok: Result<()> = Ok(());
464        let err: Result<()> = Err(WorkerError::Timeout);
465
466        assert!(ok.is_ok());
467        assert!(err.is_err());
468    }
469
470    // -- StreamWriters tests (cfe0448 port) ----------------------------------
471
472    /// Create a pipe, returning (read_fd, write_fd).
473    ///
474    /// Both ends are marked FD_CLOEXEC: tests run multi-threaded in one
475    /// process, and child processes spawned by other tests must not inherit
476    /// these fds (an inherited read end would keep the pipe open and mask
477    /// the EPIPE we are testing for).
478    fn make_pipe() -> (RawFd, RawFd) {
479        let mut fds = [0; 2];
480        let rc = unsafe { libc::pipe(fds.as_mut_ptr()) };
481        assert_eq!(rc, 0, "pipe() failed");
482        for fd in fds {
483            unsafe {
484                libc::fcntl(fd, libc::F_SETFD, libc::FD_CLOEXEC);
485            }
486        }
487        (fds[0], fds[1])
488    }
489
490    /// Read up to `buf.len()` bytes from fd (blocking).
491    fn read_fd(fd: RawFd, buf: &mut [u8]) -> usize {
492        unsafe { libc::read(fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) as usize }
493    }
494
495    fn close_fd(fd: RawFd) {
496        unsafe {
497            libc::close(fd);
498        }
499    }
500
501    /// Mirror of StreamWorkerTest::test_011_broken_pipe_on_write_twice.
502    ///
503    /// After a broken pipe, the stream is removed at the point of error
504    /// (cfe0448) and subsequent writes/EOF are safely dropped instead of
505    /// erroring repeatedly. Logic-level: the upstream `check_written == 1`
506    /// corresponds to the first successful flush, and `check_hup == 1` to
507    /// the stream disappearing (removal) rather than raising.
508    #[test]
509    fn test_stream_writer_broken_pipe_on_write_twice() {
510        let (rfd, wfd) = make_pipe();
511        let mut writers = StreamWriters::new();
512        writers.set_writer("test", wfd);
513
514        // initial write goes through
515        writers.write("test", b"initial");
516        assert_eq!(
517            writers.handle_write("test").unwrap(),
518            StreamWriteOutcome::Flushed
519        );
520        let mut buf = [0u8; 16];
521        let n = read_fd(rfd, &mut buf);
522        assert_eq!(&buf[..n], b"initial");
523
524        // close the reader: the pipe is now broken
525        close_fd(rfd);
526
527        // EPIPE removes the stream (no retry). Concurrently spawned child
528        // processes from other tests may transiently hold the read end
529        // (fork/exec window before FD_CLOEXEC applies), so retry the write
530        // until the kernel reports EPIPE, bounded by a deadline.
531        let deadline = std::time::Instant::now() + Duration::from_secs(5);
532        loop {
533            writers.write("test", b"final");
534            match writers.handle_write("test").unwrap() {
535                StreamWriteOutcome::BrokenPipe => break,
536                _ => {
537                    assert!(
538                        std::time::Instant::now() < deadline,
539                        "pipe never reported EPIPE"
540                    );
541                    std::thread::sleep(Duration::from_millis(5));
542                }
543            }
544        }
545        assert!(!writers.contains("test"));
546        assert!(writers.is_empty());
547
548        // subsequent writes to the removed stream are dropped without error
549        writers.write("test", b"more");
550        assert_eq!(writers.buffered_len("test"), 0);
551        writers.set_write_eof("test");
552        assert_eq!(
553            writers.handle_write("test").unwrap(),
554            StreamWriteOutcome::Unknown
555        );
556
557        close_fd(wfd);
558    }
559
560    /// EOF requested with pending data: the stream is removed once the
561    /// buffer has drained (mirrors upstream's eof + empty wbuf path).
562    #[test]
563    fn test_stream_writer_eof_closes_after_drain() {
564        let (rfd, wfd) = make_pipe();
565        let mut writers = StreamWriters::new();
566        writers.set_writer("stdin", wfd);
567
568        writers.write("stdin", b"payload");
569        writers.set_write_eof("stdin");
570        assert_eq!(
571            writers.handle_write("stdin").unwrap(),
572            StreamWriteOutcome::Closed
573        );
574        assert!(!writers.contains("stdin"));
575
576        let mut buf = [0u8; 16];
577        let n = read_fd(rfd, &mut buf);
578        assert_eq!(&buf[..n], b"payload");
579
580        close_fd(rfd);
581        close_fd(wfd);
582    }
583
584    /// EOF requested with an empty buffer removes the stream immediately.
585    #[test]
586    fn test_stream_writer_eof_empty_buffer_closes() {
587        let (rfd, wfd) = make_pipe();
588        let mut writers = StreamWriters::new();
589        writers.set_writer("test", wfd);
590        writers.set_write_eof("test");
591        assert_eq!(
592            writers.handle_write("test").unwrap(),
593            StreamWriteOutcome::Closed
594        );
595        assert!(!writers.contains("test"));
596        close_fd(rfd);
597        close_fd(wfd);
598    }
599
600    /// EAGAIN keeps the data buffered and the stream registered (mirrors
601    /// upstream's `_set_writing` retry-later path).
602    #[test]
603    fn test_stream_writer_eagain_keeps_buffer() {
604        let (rfd, wfd) = make_pipe();
605        // non-blocking write end
606        unsafe {
607            let flags = libc::fcntl(wfd, libc::F_GETFL);
608            libc::fcntl(wfd, libc::F_SETFL, flags | libc::O_NONBLOCK);
609        }
610
611        let mut writers = StreamWriters::new();
612        writers.set_writer("test", wfd);
613
614        // Fill the pipe beyond capacity without reading: eventually EAGAIN.
615        let chunk = [b'x'; 65536];
616        let mut saw_pending = false;
617        for _ in 0..256 {
618            writers.write("test", &chunk);
619            match writers.handle_write("test").unwrap() {
620                StreamWriteOutcome::Pending => {
621                    saw_pending = true;
622                    break;
623                }
624                StreamWriteOutcome::Flushed => continue,
625                other => panic!("unexpected outcome {:?}", other),
626            }
627        }
628        assert!(saw_pending, "pipe should fill up and report EAGAIN");
629        assert!(
630            writers.contains("test"),
631            "stream must stay registered on EAGAIN"
632        );
633        assert!(
634            writers.buffered_len("test") > 0,
635            "unwritten data stays buffered"
636        );
637
638        close_fd(rfd);
639        close_fd(wfd);
640    }
641
642    /// Operations on a never-registered stream are dropped/ignored (cfe0448:
643    /// "Drop writes to an unknown or closed stream, like set_write_eof()
644    /// already does").
645    #[test]
646    fn test_stream_writer_unknown_stream_ops_dropped() {
647        let mut writers = StreamWriters::new();
648        writers.write("nope", b"data");
649        writers.set_write_eof("nope");
650        assert_eq!(writers.buffered_len("nope"), 0);
651        assert!(!writers.contains("nope"));
652        assert_eq!(
653            writers.handle_write("nope").unwrap(),
654            StreamWriteOutcome::Unknown
655        );
656    }
657
658    /// Explicit removal mirrors Engine.remove_stream; later writes drop.
659    #[test]
660    fn test_stream_writer_remove() {
661        let (rfd, wfd) = make_pipe();
662        let mut writers = StreamWriters::new();
663        writers.set_writer("test", wfd);
664        writers.write("test", b"queued");
665        assert!(writers.remove("test"));
666        assert!(!writers.remove("test"));
667        writers.write("test", b"more");
668        assert_eq!(writers.buffered_len("test"), 0);
669        close_fd(rfd);
670        close_fd(wfd);
671    }
672
673    /// Pending fds reflect buffered data / requested EOF (write interest).
674    #[test]
675    fn test_stream_writer_pending_fds() {
676        let (rfd, wfd) = make_pipe();
677        let mut writers = StreamWriters::new();
678        writers.set_writer("test", wfd);
679        assert!(writers.pending_fds().is_empty());
680        writers.write("test", b"x");
681        assert_eq!(writers.pending_fds(), vec![wfd]);
682        assert_eq!(
683            writers.handle_write("test").unwrap(),
684            StreamWriteOutcome::Flushed
685        );
686        assert!(writers.pending_fds().is_empty());
687        close_fd(rfd);
688        close_fd(wfd);
689    }
690}