Skip to main content

coreshift_core/io/
drain.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/
4
5//! High-level process I/O management.
6//!
7//! This module provides the [`DrainState`] structure, which coordinates the
8//! simultaneous reading from process output pipes and writing to process
9//! input pipes.
10//!
11//! This is an advanced helper for callers that already own child-process file
12//! descriptors and want non-blocking drain semantics without reimplementing
13//! the bookkeeping.
14
15use crate::CoreError;
16use crate::fd::{Fd, Token};
17use crate::io::buffer::{BufferState, ChunkSink, ReadState};
18use crate::io::writer::WriterState;
19
20/// Time bound (ms) for a [`DrainState::write_input`] `poll(POLLOUT)` wait. The
21/// pty master is `O_NONBLOCK`, so a write returns `EAGAIN` once the child's tty
22/// input buffer is full; the write then waits for writability. Bounded so a
23/// wedged child (never draining its stdin) cannot stall the caller forever.
24const WRITE_INPUT_POLL_TIMEOUT_MS: i32 = 2_000;
25
26#[inline(always)]
27fn errno() -> i32 {
28    std::io::Error::last_os_error().raw_os_error().unwrap_or(0)
29}
30
31/// Associates a file descriptor with an optional reactor token.
32pub(crate) struct FdSlot {
33    /// Token assigned by the reactor for this descriptor. `None` while the fd
34    /// is paused (removed from the reactor) or not yet registered.
35    pub token: Option<Token>,
36    /// The managed file descriptor.
37    pub fd: Fd,
38}
39
40/// Orchestrates non-blocking process I/O.
41///
42/// `DrainState` tracks the state of stdin, stdout, and stderr pipes for a
43/// single process. It handles the multiplexing of data between these pipes
44/// and internal buffers.
45///
46/// # Example
47/// ```no_run
48/// # use coreshift_core::io::DrainState;
49/// # use coreshift_core::reactor::Reactor;
50/// # fn example(mut drain: DrainState<fn(&[u8]) -> bool>, mut reactor: Reactor) -> Result<(), Box<dyn std::error::Error>> {
51/// while !drain.is_done() {
52///     let mut events = Vec::new();
53///     reactor.wait(&mut events, 64, -1)?;
54///     for ev in events {
55///         // Map event tokens to drain calls...
56///     }
57/// }
58/// # Ok(())
59/// # }
60/// ```
61#[repr(align(64))]
62pub struct DrainState<F>
63where
64    F: FnMut(&[u8]) -> bool,
65{
66    pub(crate) stdout_slot: Option<FdSlot>,
67    pub(crate) stderr_slot: Option<FdSlot>,
68    pub(crate) stdin_slot: Option<FdSlot>,
69
70    pub(crate) buffer: BufferState,
71    pub(crate) writer: WriterState,
72
73    pub(crate) early_exit: Option<F>,
74
75    /// `true` when the stdout slot is a pty master rather than a pipe. A pty
76    /// master reports EOF as `EIO` (returned once the session leader and all
77    /// slave holders have closed), so the read path maps a stdout `EIO` to a
78    /// clean [`ReadState::Eof`] instead of surfacing it as an I/O error.
79    pub(crate) pty_master: bool,
80}
81
82impl<F> DrainState<F>
83where
84    F: FnMut(&[u8]) -> bool,
85{
86    /// Initialize a new drain state for the provided descriptors.
87    ///
88    /// This consumes the descriptors and sets them to non-blocking mode.
89    /// `chunk_sink` enables streaming mode: every retained output chunk is
90    /// forwarded to the sink instead of being accumulated into the internal
91    /// buffers.
92    ///
93    /// ### Errors
94    /// - `EBADF`: One of the provided file descriptors is invalid.
95    pub fn new(
96        stdin_fd: Option<Fd>,
97        stdin_buf: Option<Box<[u8]>>,
98        stdout_fd: Option<Fd>,
99        stderr_fd: Option<Fd>,
100        limit: usize,
101        early_exit: Option<F>,
102        chunk_sink: Option<ChunkSink>,
103        pty_master: bool,
104    ) -> Result<Self, CoreError> {
105        let stdin_slot = if stdin_buf.is_some() {
106            if let Some(fd) = stdin_fd {
107                fd.set_nonblock()?;
108                Some(FdSlot { token: None, fd })
109            } else {
110                None
111            }
112        } else {
113            None
114        };
115
116        let stdout_slot = if let Some(fd) = stdout_fd {
117            fd.set_nonblock()?;
118            Some(FdSlot { token: None, fd })
119        } else {
120            None
121        };
122
123        let stderr_slot = if let Some(fd) = stderr_fd {
124            fd.set_nonblock()?;
125            Some(FdSlot { token: None, fd })
126        } else {
127            None
128        };
129
130        Ok(Self {
131            stdin_slot,
132            stdout_slot,
133            stderr_slot,
134            buffer: BufferState::new(limit, chunk_sink),
135            writer: WriterState::new(stdin_buf),
136            early_exit,
137            pty_master,
138        })
139    }
140
141    /// Returns `true` if all pipes have been closed or fully drained.
142    #[inline(always)]
143    pub fn is_done(&self) -> bool {
144        self.stdin_slot.is_none() && self.stdout_slot.is_none() && self.stderr_slot.is_none()
145    }
146
147    /// Apply a new window size (`TIOCSWINSZ`) to the pty master's terminal.
148    ///
149    /// Sending a signal to the foreground process group after a resize is the
150    /// caller's job (SIGWINCH); this only updates the kernel's `winsize` so a
151    /// subsequent `TIOCGWINSZ`/`SIGWINCH`-driven refresh reads the new size.
152    ///
153    /// ### Errors
154    /// - `EINVAL`: No pty master is present (non-pty spawn or stream already
155    ///   closed), or `rows`/`cols` is zero.
156    /// - `ENOTTY`: The stdout descriptor is not a terminal.
157    pub(crate) fn resize_pty(&self, rows: u16, cols: u16) -> Result<(), CoreError> {
158        if rows == 0 || cols == 0 {
159            return Err(CoreError::sys(
160                libc::EINVAL,
161                "resize_pty: rows and cols must be non-zero",
162            ));
163        }
164        let Some(slot) = &self.stdout_slot else {
165            return Err(CoreError::sys(libc::EINVAL, "resize_pty: no pty master"));
166        };
167        let ws = libc::winsize {
168            ws_row: rows,
169            ws_col: cols,
170            ws_xpixel: 0,
171            ws_ypixel: 0,
172        };
173        let r = unsafe { libc::ioctl(slot.fd.raw(), libc::TIOCSWINSZ as libc::Ioctl, &ws) };
174        crate::error::syscall_ret(r, "TIOCSWINSZ")
175    }
176
177    /// Write bytes to the pty master — the child's stdin on a pty spawn.
178    ///
179    /// The master is `O_NONBLOCK`, so a full write waits for `POLLOUT` (bounded
180    /// by [`WRITE_INPUT_POLL_TIMEOUT_MS`]) when the tty input buffer is full,
181    /// and returns once every byte has been accepted by the line discipline.
182    ///
183    /// ### Errors
184    /// - `EINVAL`: Not a pty spawn, or the pty master is already closed.
185    /// - `EIO`: All slave holders have closed (master-side write failure).
186    /// - `ETIMEDOUT`: The child did not drain its input within the poll bound.
187    pub(crate) fn write_input(&self, bytes: &[u8]) -> Result<usize, CoreError> {
188        if !self.pty_master {
189            return Err(CoreError::sys(libc::EINVAL, "write_input: not a pty spawn"));
190        }
191        let Some(slot) = &self.stdout_slot else {
192            return Err(CoreError::sys(libc::EINVAL, "write_input: pty master closed"));
193        };
194        let fd = slot.fd.raw();
195        let mut written = 0usize;
196        while written < bytes.len() {
197            let n = unsafe {
198                libc::write(
199                    fd,
200                    bytes[written..].as_ptr() as *const libc::c_void,
201                    bytes.len() - written,
202                )
203            };
204            if n < 0 {
205                let e = errno();
206                if e == libc::EINTR {
207                    continue;
208                }
209                if e == libc::EAGAIN {
210                    let mut pfd = libc::pollfd {
211                        fd,
212                        events: libc::POLLOUT,
213                        revents: 0,
214                    };
215                    let rc = unsafe { libc::poll(&mut pfd, 1, WRITE_INPUT_POLL_TIMEOUT_MS) };
216                    if rc < 0 {
217                        let pe = errno();
218                        if pe == libc::EINTR {
219                            continue;
220                        }
221                        return Err(CoreError::sys(pe, "write_input:poll"));
222                    }
223                    if rc == 0 {
224                        return Err(CoreError::sys(
225                            libc::ETIMEDOUT,
226                            "write_input: tty input buffer stayed full",
227                        ));
228                    }
229                    continue;
230                }
231                return Err(CoreError::sys(e, "write_input"));
232            }
233            written += n as usize;
234        }
235        Ok(written)
236    }
237
238    /// Perform a non-blocking write to stdin if pending.
239    ///
240    /// Returns `Ok(true)` if the write buffer is empty or the descriptor is
241    /// closed.
242    ///
243    /// ### Errors
244    /// - `EPIPE`: The child process closed its reading end of the pipe.
245    /// - `EIO`: Low-level I/O error.
246    #[inline(always)]
247    pub fn write_stdin(&mut self) -> Result<bool, CoreError> {
248        let fd = if let Some(s) = &self.stdin_slot {
249            &s.fd
250        } else {
251            return Ok(true);
252        };
253
254        let done = self.writer.write_to_fd(fd)?;
255        if done {
256            self.stdin_slot.take();
257            return Ok(true);
258        }
259        Ok(false)
260    }
261
262    /// Read from a slot's descriptor, mapping a pty-master `EIO` EOF to a
263    /// clean [`ReadState::Eof`]. All other errors propagate.
264    ///
265    /// Associated fn (no `self` receiver) so callers can borrow `buffer` and
266    /// `early_exit` mutably while a `stdout_slot`/`stderr_slot` borrow is
267    /// still live — disjoint field borrows the compiler can see.
268    #[inline(always)]
269    fn read_from_slot(
270        buffer: &mut BufferState,
271        fd: &Fd,
272        is_stdout: bool,
273        early_exit: &mut Option<F>,
274        pty_master: bool,
275    ) -> Result<ReadState, CoreError> {
276        match buffer.read_from_fd(fd, is_stdout, early_exit) {
277            Err(e) if pty_master && is_stdout && e.raw_os_error() == Some(libc::EIO) => {
278                Ok(ReadState::Eof)
279            }
280            other => other,
281        }
282    }
283
284    /// Perform a non-blocking read from stdout or stderr.
285    ///
286    /// Returns `Ok(true)` if the stream reached EOF, the early-exit condition
287    /// was met, or the stream is paused on a full sink queue (the caller
288    /// resumes it later). In the paused case the slot is retained.
289    ///
290    /// ### Errors
291    /// - `EOVERFLOW`: The captured output exceeded the specified limit.
292    /// - `EIO`: Low-level I/O error.
293    #[inline(always)]
294    pub fn read_fd(&mut self, is_stdout: bool) -> Result<bool, CoreError> {
295        let pty_master = self.pty_master;
296        let read_state = {
297            let slot = if is_stdout {
298                &self.stdout_slot
299            } else {
300                &self.stderr_slot
301            };
302            let fd = if let Some(s) = slot {
303                &s.fd
304            } else {
305                return Ok(true);
306            };
307            Self::read_from_slot(&mut self.buffer, fd, is_stdout, &mut self.early_exit, pty_master)?
308        };
309
310        match read_state {
311            ReadState::Open => Ok(false),
312            ReadState::Paused => Ok(false),
313            ReadState::Eof | ReadState::EarlyExit => {
314                if is_stdout {
315                    self.stdout_slot.take();
316                } else {
317                    self.stderr_slot.take();
318                }
319                Ok(true)
320            }
321        }
322    }
323
324    /// Extract all active slots for cleanup or reactor removal.
325    pub(crate) fn take_all_slots(&mut self) -> Vec<FdSlot> {
326        let mut slots = Vec::new();
327        if let Some(slot) = self.stdin_slot.take() {
328            slots.push(slot);
329        }
330        if let Some(slot) = self.stdout_slot.take() {
331            slots.push(slot);
332        }
333        if let Some(slot) = self.stderr_slot.take() {
334            slots.push(slot);
335        }
336        slots
337    }
338
339    pub(crate) fn register_with_reactor(
340        &mut self,
341        reactor: &mut crate::reactor::Reactor,
342    ) -> Result<(), CoreError> {
343        register_slot(reactor, &mut self.stdin_slot, false, true)?;
344        register_slot(reactor, &mut self.stdout_slot, true, false)?;
345        register_slot(reactor, &mut self.stderr_slot, true, false)?;
346        Ok(())
347    }
348
349    pub(crate) fn stdout_matches(&self, token: Token) -> bool {
350        self.stdout_slot
351            .as_ref()
352            .is_some_and(|slot| slot.token == Some(token))
353    }
354
355    pub(crate) fn stderr_matches(&self, token: Token) -> bool {
356        self.stderr_slot
357            .as_ref()
358            .is_some_and(|slot| slot.token == Some(token))
359    }
360
361    pub(crate) fn stdin_matches(&self, token: Token) -> bool {
362        self.stdin_slot
363            .as_ref()
364            .is_some_and(|slot| slot.token == Some(token))
365    }
366
367    pub(crate) fn drop_stdout(
368        &mut self,
369        reactor: &mut crate::reactor::Reactor,
370    ) -> Result<(), CoreError> {
371        if let Some(slot) = self.stdout_slot.take() {
372            del_slot(reactor, &slot)?;
373        }
374        Ok(())
375    }
376
377    pub(crate) fn drop_stderr(
378        &mut self,
379        reactor: &mut crate::reactor::Reactor,
380    ) -> Result<(), CoreError> {
381        if let Some(slot) = self.stderr_slot.take() {
382            del_slot(reactor, &slot)?;
383        }
384        Ok(())
385    }
386
387    pub(crate) fn drop_stdin(
388        &mut self,
389        reactor: &mut crate::reactor::Reactor,
390    ) -> Result<(), CoreError> {
391        if let Some(slot) = self.stdin_slot.take() {
392            del_slot(reactor, &slot)?;
393        }
394        self.writer.buf = None;
395        Ok(())
396    }
397
398    pub(crate) fn handle_stdout_ready(
399        &mut self,
400        reactor: &mut crate::reactor::Reactor,
401    ) -> Result<(), CoreError> {
402        if let Some(slot) = &self.stdout_slot {
403            let read_state = Self::read_from_slot(
404                &mut self.buffer,
405                &slot.fd,
406                true,
407                &mut self.early_exit,
408                self.pty_master,
409            )?;
410            match read_state {
411                ReadState::Open => {}
412                ReadState::Paused => {
413                    // Sink queue full: remove the fd from the reactor so the
414                    // edge-triggered readiness does not spin the loop; the
415                    // caller re-registers via `resume_stdout` when it has
416                    // drained the queue.
417                    self.pause_stdout(reactor)?;
418                }
419                ReadState::Eof | ReadState::EarlyExit => {
420                    self.drop_stdout(reactor)?;
421                }
422            }
423        }
424        Ok(())
425    }
426
427    pub(crate) fn handle_stderr_ready(
428        &mut self,
429        reactor: &mut crate::reactor::Reactor,
430    ) -> Result<(), CoreError> {
431        if let Some(slot) = &self.stderr_slot {
432            let read_state = Self::read_from_slot(
433                &mut self.buffer,
434                &slot.fd,
435                false,
436                &mut self.early_exit,
437                self.pty_master,
438            )?;
439            match read_state {
440                ReadState::Open => {}
441                ReadState::Paused => {
442                    self.pause_stderr(reactor)?;
443                }
444                ReadState::Eof | ReadState::EarlyExit => {
445                    self.drop_stderr(reactor)?;
446                }
447            }
448        }
449        Ok(())
450    }
451
452    /// Remove the stdout fd from the reactor while its sink queue is full.
453    /// The slot is retained (token cleared) so the stream can be resumed.
454    pub(crate) fn pause_stdout(
455        &mut self,
456        reactor: &mut crate::reactor::Reactor,
457    ) -> Result<(), CoreError> {
458        pause_slot(reactor, &mut self.stdout_slot)
459    }
460
461    /// Remove the stderr fd from the reactor while its sink queue is full.
462    pub(crate) fn pause_stderr(
463        &mut self,
464        reactor: &mut crate::reactor::Reactor,
465    ) -> Result<(), CoreError> {
466        pause_slot(reactor, &mut self.stderr_slot)
467    }
468
469    /// Return whether the stdout stream is paused on a full sink queue.
470    pub fn stdout_paused(&self) -> bool {
471        self.buffer.stdout_paused()
472    }
473
474    /// Return whether the stderr stream is paused on a full sink queue.
475    pub fn stderr_paused(&self) -> bool {
476        self.buffer.stderr_paused()
477    }
478
479    /// Re-deliver the held stdout chunk (if any) and re-register the fd when
480    /// the sink has room again. Returns `true` when the stream is resumed,
481    /// `false` when the sink is still full and the stream stays paused.
482    pub fn resume_stdout(
483        &mut self,
484        reactor: &mut crate::reactor::Reactor,
485    ) -> Result<bool, CoreError> {
486        if !self.buffer.deliver_pending_stdout()? {
487            return Ok(false);
488        }
489        register_slot(reactor, &mut self.stdout_slot, true, false)?;
490        Ok(true)
491    }
492
493    /// Re-deliver the held stderr chunk (if any) and re-register the fd when
494    /// the sink has room again. Returns `true` when the stream is resumed,
495    /// `false` when the sink is still full and the stream stays paused.
496    pub fn resume_stderr(
497        &mut self,
498        reactor: &mut crate::reactor::Reactor,
499    ) -> Result<bool, CoreError> {
500        if !self.buffer.deliver_pending_stderr()? {
501            return Ok(false);
502        }
503        register_slot(reactor, &mut self.stderr_slot, true, false)?;
504        Ok(true)
505    }
506
507    /// Take the un-delivered stdout chunk (streaming mode), if any.
508    pub(crate) fn take_stdout_pending(&mut self) -> Option<Vec<u8>> {
509        self.buffer.take_stdout_pending()
510    }
511
512    /// Take the un-delivered stderr chunk (streaming mode), if any.
513    pub(crate) fn take_stderr_pending(&mut self) -> Option<Vec<u8>> {
514        self.buffer.take_stderr_pending()
515    }
516
517    pub(crate) fn handle_stdin_writable(
518        &mut self,
519        reactor: &mut crate::reactor::Reactor,
520    ) -> Result<(), CoreError> {
521        if let Some(slot) = &self.stdin_slot {
522            let done = self.writer.write_to_fd(&slot.fd)?;
523            if done {
524                self.drop_stdin(reactor)?;
525            }
526        }
527        Ok(())
528    }
529
530    /// Consume the state and return (stdout, stderr) buffers.
531    pub fn into_parts(mut self) -> (Vec<u8>, Vec<u8>) {
532        let (stdout, stderr, _, _) = std::mem::take(&mut self.buffer).into_parts();
533        (stdout, stderr)
534    }
535
536    /// Return whether the combined stdout+stderr output limit was exceeded.
537    #[inline(always)]
538    pub fn output_limit_exceeded(&self) -> bool {
539        self.buffer.output_limit_exceeded()
540    }
541
542    /// Return whether stdout was explicitly stopped by the early-exit predicate.
543    #[inline(always)]
544    pub fn stdout_early_exited(&self) -> bool {
545        self.buffer.stdout_early_exited()
546    }
547
548    /// Consume the state and return buffers plus drain flags.
549    pub(crate) fn into_parts_with_state(mut self) -> (Vec<u8>, Vec<u8>, bool, bool) {
550        std::mem::take(&mut self.buffer).into_parts()
551    }
552}
553
554/// Register one slot, leaving it in place on failure and treating a second
555/// registration as a no-op so the fd and stream are never lost.
556fn register_slot(
557    reactor: &mut crate::reactor::Reactor,
558    slot: &mut Option<FdSlot>,
559    readable: bool,
560    writable: bool,
561) -> Result<(), CoreError> {
562    let Some(s) = slot.as_mut() else {
563        return Ok(());
564    };
565    if s.token.is_some() {
566        return Ok(());
567    }
568    s.token = Some(reactor.add(&s.fd, readable, writable)?);
569    Ok(())
570}
571
572/// Remove a slot's fd from the reactor, skipping an already-paused (tokenless)
573/// slot. `ENOENT` is tolerated: the fd may already have been removed by a
574/// pause or by reactor teardown.
575fn del_slot(
576    reactor: &crate::reactor::Reactor,
577    slot: &FdSlot,
578) -> Result<(), CoreError> {
579    if slot.token.is_none() {
580        return Ok(());
581    }
582    match reactor.del(&slot.fd) {
583        Ok(()) => Ok(()),
584        Err(e) if e.raw_os_error() == Some(libc::ENOENT) => Ok(()),
585        Err(e) => Err(e),
586    }
587}
588
589/// Remove a slot's fd from the reactor and clear its token, keeping the slot
590/// so the stream can be resumed later.
591fn pause_slot(
592    reactor: &mut crate::reactor::Reactor,
593    slot: &mut Option<FdSlot>,
594) -> Result<(), CoreError> {
595    let Some(s) = slot.as_mut() else {
596        return Ok(());
597    };
598    if s.token.is_none() {
599        return Ok(());
600    }
601    s.token = None;
602    match reactor.del(&s.fd) {
603        Ok(()) => Ok(()),
604        Err(e) if e.raw_os_error() == Some(libc::ENOENT) => Ok(()),
605        Err(e) => Err(e),
606    }
607}