tastty-driver 0.1.0

Terminal automation driver built on tastty
//! Child-process lifecycle and resize.

#[cfg(feature = "async")]
use std::sync::Arc;
use std::time::Duration;

use super::Session;
use super::signal::send_group_signal_silencing_esrch;
use crate::{Error, ExitStatus, Result, Signal};
use tastty::TerminalSize;

impl Session {
    /// Return whether the child process is still running.
    ///
    /// Mirrors the polling shape of [`Session::try_wait`] but boils the answer
    /// down to a single boolean for callers that only care about liveness.
    /// Returns `false` once the child has been observed to exit.
    ///
    /// If the underlying reap fails, returns `true`: the convention is "I do
    /// not know that the child is dead, so assume it is not". Callers that
    /// need to distinguish a reap failure from a still-running child should
    /// use [`Session::try_wait`] instead and inspect the [`Result`].
    #[must_use]
    pub fn is_alive(&self) -> bool {
        match self.terminal.try_wait() {
            Ok(Some(_)) => false,
            Ok(None) | Err(_) => true,
        }
    }

    /// Return the child exit status if it is already available without
    /// blocking.
    ///
    /// Modeled on [`std::process::Child::try_wait`]:
    /// `Ok(Some(status))` if the child has exited, `Ok(None)` if it is
    /// still running.
    ///
    /// # Errors
    ///
    /// Returns [`Error::ExitStatus`] wrapping
    /// [`tastty::Error::ExitStatusUnavailable`] if the internal waiter
    /// thread has dropped its sender (the session was joined or the
    /// terminal was destroyed).
    pub fn try_wait(&self) -> Result<Option<ExitStatus>> {
        self.terminal
            .try_wait()
            .map(|maybe| maybe.map(ExitStatus::from_tastty))
            .map_err(Error::ExitStatus)
    }

    /// Return the managed child process id, if available.
    #[must_use]
    pub fn process_id(&self) -> Option<u32> {
        self.terminal.process_id()
    }

    /// Block the calling thread until the child exits.
    ///
    /// Coalesces every concurrent waiter (sync and async) onto the
    /// session's exit reaper: the reaper polls `try_wait` once per tick
    /// and notifies a shared condvar plus any registered async wakers
    /// when it observes the exit. The calling thread parks on the
    /// condvar until then, with no per-call polling thread and no busy-
    /// poll between ticks.
    ///
    /// # Errors
    ///
    /// Returns [`Error::ExitStatus`] wrapping
    /// [`tastty::Error::ExitStatusUnavailable`] if the reaper finalised
    /// without observing a clean exit (a `try_wait` syscall failure or
    /// the [`Session`] being dropped before the child exited).
    pub fn wait_exit(&self) -> Result<ExitStatus> {
        self.exit_notifier.wait_blocking()
    }

    /// Asynchronous variant of [`Session::wait_exit`].
    ///
    /// Returns a runtime-agnostic [`Future`](std::future::Future) built
    /// on `std` primitives only (no tokio dependency). The future
    /// registers a [`Waker`](std::task::Waker) on the session's shared
    /// exit notifier; the reaper fires the waker when it observes the
    /// child exit. There is no per-future worker thread, no busy-poll,
    /// and no timeout - callers that want one layer it themselves (for
    /// example, with `tokio::time::timeout`).
    ///
    /// # Cancellation
    ///
    /// Dropping the returned future removes its waker entry from the
    /// notifier. No background thread is spawned on its behalf, so
    /// cancellation is immediate and leak-free.
    ///
    /// # Errors
    ///
    /// Resolves to [`Error::ExitStatus`] wrapping
    /// [`tastty::Error::ExitStatusUnavailable`] if the reaper finalised
    /// without observing a clean exit (a `try_wait` syscall failure or
    /// the [`Session`] being dropped before the child exited).
    ///
    /// # Send bound
    ///
    /// The returned future is `Send + 'static` so it can be spawned on
    /// any standard executor.
    #[cfg(feature = "async")]
    pub fn wait_exit_async(
        &self,
    ) -> impl std::future::Future<Output = Result<ExitStatus>> + Send + 'static {
        crate::wait::exit::ExitWaitFuture::new(Arc::clone(&self.exit_notifier))
    }

    /// Send a termination signal to the process group when supported.
    ///
    /// # Errors
    ///
    /// Same as [`Session::signal_group`].
    pub fn terminate(&self) -> Result<()> {
        self.signal_group(Signal::TERM)
    }

    /// Terminate the child gracefully, escalating to `SIGKILL` after
    /// `grace` if it has not exited.
    ///
    /// Sends `SIGTERM` to the child process group and parks on the
    /// shared exit notifier. If the child exits within `grace`, the
    /// observed [`ExitStatus`] is returned and no further signals are
    /// sent. Otherwise `SIGKILL` is sent to the same group and the
    /// call blocks (without further bound) until the reaper observes
    /// the exit; `SIGKILL` cannot be caught or ignored, so the wait
    /// terminates as soon as the kernel delivers and the reaper next
    /// polls.
    ///
    /// Both `kill(2)` calls silence `ESRCH`: a "no such process" error
    /// at either step means the desired post-condition (process gone)
    /// already holds, so the call falls through to the cached exit
    /// status instead of surfacing a misleading [`Error::Signal`].
    /// Other signal-send errors are returned as [`Error::Signal`].
    ///
    /// # Returned status
    ///
    /// On the graceful path, [`ExitStatus::signal`] reflects the signal
    /// the kernel actually recorded for the leader (typically `SIGTERM`
    /// for cooperative children, though a child that installs a handler
    /// may exit cleanly or through a different signal). On the hard
    /// path, [`ExitStatus::signal`] is `SIGKILL`. On the already-exited
    /// path the cached status is returned unchanged.
    ///
    /// # Errors
    ///
    /// Returns [`Error::ExitStatus`] if the exit reaper finalised the
    /// notifier without observing a clean exit (a `try_wait` syscall
    /// failure or the [`Session`] being dropped concurrently). Returns
    /// [`Error::Signal`] for `kill(2)` failures other than `ESRCH`.
    pub fn terminate_with_grace(&self, grace: Duration) -> Result<ExitStatus> {
        send_group_signal_silencing_esrch(self, Signal::TERM)?;
        if let Some(status) = self.exit_notifier.wait_blocking_timeout(grace) {
            return status;
        }
        send_group_signal_silencing_esrch(self, Signal::KILL)?;
        self.wait_exit()
    }

    /// Terminate and, if needed, force-kill the process group.
    ///
    /// # Errors
    ///
    /// Returns [`Error::Kill`] wrapping the underlying [`tastty::Error`]
    /// (typically [`Error::TerminateFailed`] or [`Error::ForceKillFailed`])
    /// when either signal-send fails.
    ///
    /// [`Error::TerminateFailed`]: tastty::Error::TerminateFailed
    /// [`Error::ForceKillFailed`]: tastty::Error::ForceKillFailed
    pub fn kill(&self) -> Result<()> {
        self.terminal.kill().map_err(Error::Kill)
    }

    /// Resize the PTY and parsed screen.
    ///
    /// # Errors
    ///
    /// Returns [`Error::Resize`] wrapping [`tastty::Error::InvalidResize`]
    /// when either dimension is zero, or [`tastty::Error::ResizeFailed`]
    /// when the underlying PTY `TIOCSWINSZ` ioctl fails.
    pub fn resize(&self, size: TerminalSize) -> Result<()> {
        self.terminal.resize(size).map_err(Error::Resize)?;
        if let Some(observer) = &self.observer {
            observer.on_resize(size);
        }
        Ok(())
    }
}