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/// Buffered output taken from an [`EventHandler`]: (stdout, stderr, timeouts),
257/// with stdout/stderr keyed by node.
258pub type TakenBuffers = (
259    HashMap<String, Vec<Vec<u8>>>,
260    HashMap<String, Vec<Vec<u8>>>,
261    HashSet<String>,
262);
263
264/// Event handler trait for worker events.
265///
266/// Implement this trait to receive notifications about worker events.
267pub trait EventHandler: Send {
268    /// Called when the worker starts.
269    fn on_start(&mut self, worker: &dyn Worker) {
270        let _ = worker;
271    }
272
273    /// Called when data is available for reading from a node.
274    fn on_read(&mut self, node: &str, fd: RawFd, msg: &[u8]) {
275        let _ = (node, fd, msg);
276    }
277
278    /// Called when a connection to a node is closed.
279    fn on_close(&mut self, node: &str, rc: i32) {
280        let _ = (node, rc);
281    }
282
283    /// Called when a node operation times out.
284    fn on_timeout(&mut self, node: &str) {
285        let _ = node;
286    }
287
288    /// Called when an error occurs for a node.
289    fn on_error(&mut self, node: &str, error: &WorkerError) {
290        let _ = (node, error);
291    }
292
293    /// Extract buffered stdout/stderr data from this handler.
294    /// Returns (stdout, stderr, timeouts) where stdout/stderr are node -> `Vec<chunks>`.
295    /// Default returns empty maps. Used by Task to collect results after execution.
296    fn take_buffers(&mut self) -> TakenBuffers {
297        (HashMap::new(), HashMap::new(), HashSet::new())
298    }
299}
300
301/// Trait for worker implementations.
302///
303/// A worker executes commands across multiple nodes, handling I/O
304/// events and managing child processes.
305pub trait Worker: Send {
306    /// Start the worker.
307    fn start(&mut self) -> Result<()>;
308
309    /// Abort the worker, optionally killing child processes.
310    fn abort(&mut self, kill: bool);
311
312    /// Get the worker's current state.
313    fn state(&self) -> WorkerState;
314
315    /// Set the event handler for this worker.
316    fn set_handler(&mut self, handler: Box<dyn EventHandler>);
317
318    /// Get file descriptors for read interest.
319    fn read_fds(&self) -> Vec<RawFd>;
320
321    /// Get file descriptors for write interest.
322    fn write_fds(&self) -> Vec<RawFd>;
323
324    /// Handle a read event on the given file descriptor.
325    fn handle_read(&mut self, fd: RawFd) -> Result<()>;
326
327    /// Handle a write event on the given file descriptor.
328    fn handle_write(&mut self, fd: RawFd) -> Result<()>;
329
330    /// Check if the worker has completed.
331    fn is_done(&self) -> bool;
332
333    /// Get the return codes map: node -> return_code.
334    fn retcodes(&self) -> &HashMap<String, i32>;
335
336    /// Get the number of nodes.
337    fn num_nodes(&self) -> usize;
338
339    /// Take the event handler out of the worker (used by Task to extract gathered data).
340    fn take_handler(&mut self) -> Option<Box<dyn EventHandler>>;
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346    use std::time::Duration;
347
348    /// Test WorkerState enum variants
349    #[test]
350    fn test_worker_state_variants() {
351        assert_eq!(WorkerState::Pending, WorkerState::Pending);
352        assert_eq!(WorkerState::Running, WorkerState::Running);
353        assert_eq!(WorkerState::Done, WorkerState::Done);
354        assert_eq!(WorkerState::Aborted, WorkerState::Aborted);
355    }
356
357    /// Test WorkerState is Debug
358    #[test]
359    fn test_worker_state_debug() {
360        assert_eq!(format!("{:?}", WorkerState::Pending), "Pending");
361        assert_eq!(format!("{:?}", WorkerState::Running), "Running");
362        assert_eq!(format!("{:?}", WorkerState::Done), "Done");
363        assert_eq!(format!("{:?}", WorkerState::Aborted), "Aborted");
364    }
365
366    /// Test WorkerState is Clone
367    #[test]
368    fn test_worker_state_clone() {
369        let state = WorkerState::Pending;
370        let cloned = state;
371        assert_eq!(state, cloned);
372    }
373
374    /// Test WorkerState is Copy
375    #[test]
376    fn test_worker_state_copy() {
377        fn take_copy<T: Copy>(_: T) {}
378        take_copy(WorkerState::Pending);
379    }
380
381    /// Test WorkerState is PartialEq
382    #[test]
383    fn test_worker_state_partial_eq() {
384        assert!(WorkerState::Pending == WorkerState::Pending);
385        assert!(WorkerState::Running != WorkerState::Pending);
386    }
387
388    /// Test WorkerError variants
389    #[test]
390    fn test_worker_error_variants() {
391        let io_err = WorkerError::Io(std::io::Error::new(
392            std::io::ErrorKind::NotFound,
393            "not found",
394        ));
395        let gen_err = WorkerError::General("something went wrong".to_string());
396        let timeout_err = WorkerError::Timeout;
397
398        matches!(io_err, WorkerError::Io(_));
399        matches!(gen_err, WorkerError::General(_));
400        matches!(timeout_err, WorkerError::Timeout);
401    }
402
403    /// Test WorkerError implements Error trait
404    #[test]
405    fn test_worker_error_impls_error() {
406        use std::error::Error;
407
408        let gen_err = WorkerError::General("test".to_string());
409        assert!(gen_err.source().is_none());
410
411        let timeout_err = WorkerError::Timeout;
412        assert!(timeout_err.source().is_none());
413    }
414
415    /// Test WorkerError implements Display trait
416    #[test]
417    fn test_worker_error_impls_display() {
418        let io_err = WorkerError::Io(std::io::Error::new(
419            std::io::ErrorKind::NotFound,
420            "file.txt",
421        ));
422        assert_eq!(io_err.to_string(), "io error: file.txt");
423
424        let gen_err = WorkerError::General("test error".to_string());
425        assert_eq!(gen_err.to_string(), "worker error: test error");
426
427        let timeout_err = WorkerError::Timeout;
428        assert_eq!(timeout_err.to_string(), "timeout");
429    }
430
431    /// Test EventHandler default methods compile
432    #[test]
433    fn test_event_handler_default_methods() {
434        struct TestHandler;
435
436        impl EventHandler for TestHandler {}
437
438        let handler = TestHandler;
439
440        // These should compile with default implementations (no-op tests)
441        // We just verify the methods exist and can be called
442        let _ = handler;
443    }
444
445    /// Test that EventHandler is object-safe
446    #[test]
447    fn test_event_handler_object_safe() {
448        struct TestHandler;
449
450        impl EventHandler for TestHandler {}
451
452        let _handler: Box<dyn EventHandler> = Box::new(TestHandler);
453    }
454
455    /// Test EventHandler is Send
456    #[test]
457    fn test_event_handler_is_send() {
458        fn assert_send<T: Send>() {}
459        assert_send::<Box<dyn EventHandler>>();
460    }
461
462    /// Test WorkerError Result type alias
463    #[test]
464    fn test_result_type_alias() {
465        let ok: Result<()> = Ok(());
466        let err: Result<()> = Err(WorkerError::Timeout);
467
468        assert!(ok.is_ok());
469        assert!(err.is_err());
470    }
471
472    // -- StreamWriters tests (cfe0448 port) ----------------------------------
473
474    /// Create a pipe, returning (read_fd, write_fd).
475    ///
476    /// Both ends are marked FD_CLOEXEC: tests run multi-threaded in one
477    /// process, and child processes spawned by other tests must not inherit
478    /// these fds (an inherited read end would keep the pipe open and mask
479    /// the EPIPE we are testing for).
480    fn make_pipe() -> (RawFd, RawFd) {
481        let mut fds = [0; 2];
482        let rc = unsafe { libc::pipe(fds.as_mut_ptr()) };
483        assert_eq!(rc, 0, "pipe() failed");
484        for fd in fds {
485            unsafe {
486                libc::fcntl(fd, libc::F_SETFD, libc::FD_CLOEXEC);
487            }
488        }
489        (fds[0], fds[1])
490    }
491
492    /// Read up to `buf.len()` bytes from fd (blocking).
493    fn read_fd(fd: RawFd, buf: &mut [u8]) -> usize {
494        unsafe { libc::read(fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) as usize }
495    }
496
497    fn close_fd(fd: RawFd) {
498        unsafe {
499            libc::close(fd);
500        }
501    }
502
503    /// Mirror of StreamWorkerTest::test_011_broken_pipe_on_write_twice.
504    ///
505    /// After a broken pipe, the stream is removed at the point of error
506    /// (cfe0448) and subsequent writes/EOF are safely dropped instead of
507    /// erroring repeatedly. Logic-level: the upstream `check_written == 1`
508    /// corresponds to the first successful flush, and `check_hup == 1` to
509    /// the stream disappearing (removal) rather than raising.
510    #[test]
511    fn test_stream_writer_broken_pipe_on_write_twice() {
512        let (rfd, wfd) = make_pipe();
513        let mut writers = StreamWriters::new();
514        writers.set_writer("test", wfd);
515
516        // initial write goes through
517        writers.write("test", b"initial");
518        assert_eq!(
519            writers.handle_write("test").unwrap(),
520            StreamWriteOutcome::Flushed
521        );
522        let mut buf = [0u8; 16];
523        let n = read_fd(rfd, &mut buf);
524        assert_eq!(&buf[..n], b"initial");
525
526        // close the reader: the pipe is now broken
527        close_fd(rfd);
528
529        // EPIPE removes the stream (no retry). Concurrently spawned child
530        // processes from other tests may transiently hold the read end
531        // (fork/exec window before FD_CLOEXEC applies), so retry the write
532        // until the kernel reports EPIPE, bounded by a deadline.
533        let deadline = std::time::Instant::now() + Duration::from_secs(5);
534        loop {
535            writers.write("test", b"final");
536            match writers.handle_write("test").unwrap() {
537                StreamWriteOutcome::BrokenPipe => break,
538                _ => {
539                    assert!(
540                        std::time::Instant::now() < deadline,
541                        "pipe never reported EPIPE"
542                    );
543                    std::thread::sleep(Duration::from_millis(5));
544                }
545            }
546        }
547        assert!(!writers.contains("test"));
548        assert!(writers.is_empty());
549
550        // subsequent writes to the removed stream are dropped without error
551        writers.write("test", b"more");
552        assert_eq!(writers.buffered_len("test"), 0);
553        writers.set_write_eof("test");
554        assert_eq!(
555            writers.handle_write("test").unwrap(),
556            StreamWriteOutcome::Unknown
557        );
558
559        close_fd(wfd);
560    }
561
562    /// EOF requested with pending data: the stream is removed once the
563    /// buffer has drained (mirrors upstream's eof + empty wbuf path).
564    #[test]
565    fn test_stream_writer_eof_closes_after_drain() {
566        let (rfd, wfd) = make_pipe();
567        let mut writers = StreamWriters::new();
568        writers.set_writer("stdin", wfd);
569
570        writers.write("stdin", b"payload");
571        writers.set_write_eof("stdin");
572        assert_eq!(
573            writers.handle_write("stdin").unwrap(),
574            StreamWriteOutcome::Closed
575        );
576        assert!(!writers.contains("stdin"));
577
578        let mut buf = [0u8; 16];
579        let n = read_fd(rfd, &mut buf);
580        assert_eq!(&buf[..n], b"payload");
581
582        close_fd(rfd);
583        close_fd(wfd);
584    }
585
586    /// EOF requested with an empty buffer removes the stream immediately.
587    #[test]
588    fn test_stream_writer_eof_empty_buffer_closes() {
589        let (rfd, wfd) = make_pipe();
590        let mut writers = StreamWriters::new();
591        writers.set_writer("test", wfd);
592        writers.set_write_eof("test");
593        assert_eq!(
594            writers.handle_write("test").unwrap(),
595            StreamWriteOutcome::Closed
596        );
597        assert!(!writers.contains("test"));
598        close_fd(rfd);
599        close_fd(wfd);
600    }
601
602    /// EAGAIN keeps the data buffered and the stream registered (mirrors
603    /// upstream's `_set_writing` retry-later path).
604    #[test]
605    fn test_stream_writer_eagain_keeps_buffer() {
606        let (rfd, wfd) = make_pipe();
607        // non-blocking write end
608        unsafe {
609            let flags = libc::fcntl(wfd, libc::F_GETFL);
610            libc::fcntl(wfd, libc::F_SETFL, flags | libc::O_NONBLOCK);
611        }
612
613        let mut writers = StreamWriters::new();
614        writers.set_writer("test", wfd);
615
616        // Fill the pipe beyond capacity without reading: eventually EAGAIN.
617        let chunk = [b'x'; 65536];
618        let mut saw_pending = false;
619        for _ in 0..256 {
620            writers.write("test", &chunk);
621            match writers.handle_write("test").unwrap() {
622                StreamWriteOutcome::Pending => {
623                    saw_pending = true;
624                    break;
625                }
626                StreamWriteOutcome::Flushed => continue,
627                other => panic!("unexpected outcome {:?}", other),
628            }
629        }
630        assert!(saw_pending, "pipe should fill up and report EAGAIN");
631        assert!(
632            writers.contains("test"),
633            "stream must stay registered on EAGAIN"
634        );
635        assert!(
636            writers.buffered_len("test") > 0,
637            "unwritten data stays buffered"
638        );
639
640        close_fd(rfd);
641        close_fd(wfd);
642    }
643
644    /// Operations on a never-registered stream are dropped/ignored (cfe0448:
645    /// "Drop writes to an unknown or closed stream, like set_write_eof()
646    /// already does").
647    #[test]
648    fn test_stream_writer_unknown_stream_ops_dropped() {
649        let mut writers = StreamWriters::new();
650        writers.write("nope", b"data");
651        writers.set_write_eof("nope");
652        assert_eq!(writers.buffered_len("nope"), 0);
653        assert!(!writers.contains("nope"));
654        assert_eq!(
655            writers.handle_write("nope").unwrap(),
656            StreamWriteOutcome::Unknown
657        );
658    }
659
660    /// Explicit removal mirrors Engine.remove_stream; later writes drop.
661    #[test]
662    fn test_stream_writer_remove() {
663        let (rfd, wfd) = make_pipe();
664        let mut writers = StreamWriters::new();
665        writers.set_writer("test", wfd);
666        writers.write("test", b"queued");
667        assert!(writers.remove("test"));
668        assert!(!writers.remove("test"));
669        writers.write("test", b"more");
670        assert_eq!(writers.buffered_len("test"), 0);
671        close_fd(rfd);
672        close_fd(wfd);
673    }
674
675    /// Pending fds reflect buffered data / requested EOF (write interest).
676    #[test]
677    fn test_stream_writer_pending_fds() {
678        let (rfd, wfd) = make_pipe();
679        let mut writers = StreamWriters::new();
680        writers.set_writer("test", wfd);
681        assert!(writers.pending_fds().is_empty());
682        writers.write("test", b"x");
683        assert_eq!(writers.pending_fds(), vec![wfd]);
684        assert_eq!(
685            writers.handle_write("test").unwrap(),
686            StreamWriteOutcome::Flushed
687        );
688        assert!(writers.pending_fds().is_empty());
689        close_fd(rfd);
690        close_fd(wfd);
691    }
692}