gshell 1.0.1

gshell is a shell for people who live in the terminal. It pairs familiar Unix behavior with a tighter core, fast interaction, and an interface built to stay out of the way.
Documentation
#[cfg(unix)]
mod imp {
    use std::{fs::File, io, sync::OnceLock};

    use nix::{
        sys::signal::{SigSet, SigmaskHow, Signal, pthread_sigmask},
        sys::signal::{kill, killpg},
        sys::wait::{WaitPidFlag, WaitStatus, waitpid},
        unistd::{Pid, getpgrp, getpid, setpgid, tcgetpgrp, tcsetpgrp},
    };
    use tokio::task;

    use crate::shell::{ShellError, ShellResult};

    #[derive(Debug)]
    struct JobControl {
        tty: File,
        shell_pgid: Pid,
    }

    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
    pub(crate) enum ForegroundWaitStatus {
        Exited(i32),
        Signaled(i32),
        Stopped(i32),
    }

    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
    pub(crate) enum PolledWaitStatus {
        Exited { pid: u32, code: i32 },
        Signaled { pid: u32, signal: i32 },
        Stopped { pid: u32, signal: i32 },
        Continued { pid: u32 },
    }

    static JOB_CONTROL: OnceLock<Option<JobControl>> = OnceLock::new();

    pub(crate) async fn initialize_interactive_shell() -> ShellResult<()> {
        let _ = JOB_CONTROL.get_or_init(setup_job_control);
        Ok(())
    }

    pub(crate) fn configure_process_group(
        command: &mut tokio::process::Command,
        pgid: Option<u32>,
    ) {
        let Ok(group) = i32::try_from(pgid.unwrap_or(0)) else {
            return;
        };

        command.process_group(group);
    }

    pub(crate) fn hand_terminal_to_foreground_job(pgid: u32) -> ShellResult<bool> {
        let Some(control) = JOB_CONTROL.get_or_init(setup_job_control).as_ref() else {
            return Ok(false);
        };

        let Some(pgid) = pid_from_u32(pgid) else {
            return Ok(false);
        };

        with_blocked_job_control_signals(|| tcsetpgrp(&control.tty, pgid)).map_err(nix_err)?;
        Ok(true)
    }

    pub(crate) fn reclaim_terminal_for_shell() -> ShellResult<()> {
        let Some(control) = JOB_CONTROL.get_or_init(setup_job_control).as_ref() else {
            return Ok(());
        };

        with_blocked_job_control_signals(|| tcsetpgrp(&control.tty, control.shell_pgid))
            .map_err(nix_err)
    }

    pub(crate) fn continue_process_group(pgid: u32) -> ShellResult<()> {
        let Some(pgid) = pid_from_u32(pgid) else {
            return Ok(());
        };

        killpg(pgid, Signal::SIGCONT).map_err(nix_err)
    }

    pub(crate) fn signal_process_group(pgid: u32, signal: i32) -> ShellResult<()> {
        let Some(pgid) = pid_from_u32(pgid) else {
            return Ok(());
        };
        let signal = Signal::try_from(signal)
            .map_err(|_| ShellError::message(format!("unsupported signal: {signal}")))?;

        killpg(pgid, signal).map_err(nix_err)
    }

    pub(crate) fn signal_process(pid: u32, signal: i32) -> ShellResult<()> {
        let Some(pid) = pid_from_u32(pid) else {
            return Ok(());
        };
        let signal = Signal::try_from(signal)
            .map_err(|_| ShellError::message(format!("unsupported signal: {signal}")))?;

        kill(pid, signal).map_err(nix_err)
    }

    pub(crate) async fn wait_for_foreground_process(pid: u32) -> ShellResult<ForegroundWaitStatus> {
        let Some(pid) = pid_from_u32(pid) else {
            return Ok(ForegroundWaitStatus::Exited(1));
        };

        task::spawn_blocking(move || {
            loop {
                match waitpid(pid, Some(WaitPidFlag::WUNTRACED)) {
                    Ok(WaitStatus::Exited(_, code)) => {
                        return Ok(ForegroundWaitStatus::Exited(code));
                    }
                    Ok(WaitStatus::Signaled(_, signal, _)) => {
                        return Ok(ForegroundWaitStatus::Signaled(signal as i32));
                    }
                    Ok(WaitStatus::Stopped(_, signal)) => {
                        return Ok(ForegroundWaitStatus::Stopped(signal as i32));
                    }
                    Ok(WaitStatus::StillAlive | WaitStatus::Continued(_)) => continue,
                    #[cfg(any(target_os = "linux", target_os = "android"))]
                    Ok(WaitStatus::PtraceEvent(_, _, _) | WaitStatus::PtraceSyscall(_)) => {
                        continue;
                    }
                    Err(err) => return Err(err),
                }
            }
        })
        .await
        .map_err(|err| ShellError::message(format!("failed to join wait task: {err}")))?
        .map_err(nix_err)
    }

    pub(crate) async fn poll_child_statuses() -> ShellResult<Vec<PolledWaitStatus>> {
        task::spawn_blocking(move || {
            let mut statuses = Vec::new();

            loop {
                match waitpid(
                    Pid::from_raw(-1),
                    Some(WaitPidFlag::WNOHANG | WaitPidFlag::WUNTRACED | WaitPidFlag::WCONTINUED),
                ) {
                    Ok(WaitStatus::StillAlive) => return Ok(statuses),
                    Ok(WaitStatus::Exited(pid, code)) => {
                        if let Ok(pid) = u32::try_from(pid.as_raw()) {
                            statuses.push(PolledWaitStatus::Exited { pid, code });
                        }
                    }
                    Ok(WaitStatus::Signaled(pid, signal, _)) => {
                        if let Ok(pid) = u32::try_from(pid.as_raw()) {
                            statuses.push(PolledWaitStatus::Signaled {
                                pid,
                                signal: signal as i32,
                            });
                        }
                    }
                    Ok(WaitStatus::Stopped(pid, signal)) => {
                        if let Ok(pid) = u32::try_from(pid.as_raw()) {
                            statuses.push(PolledWaitStatus::Stopped {
                                pid,
                                signal: signal as i32,
                            });
                        }
                    }
                    Ok(WaitStatus::Continued(pid)) => {
                        if let Ok(pid) = u32::try_from(pid.as_raw()) {
                            statuses.push(PolledWaitStatus::Continued { pid });
                        }
                    }
                    #[cfg(any(target_os = "linux", target_os = "android"))]
                    Ok(WaitStatus::PtraceEvent(_, _, _) | WaitStatus::PtraceSyscall(_)) => {
                        continue;
                    }
                    Err(nix::errno::Errno::ECHILD) => return Ok(statuses),
                    Err(err) => return Err(err),
                }
            }
        })
        .await
        .map_err(|err| ShellError::message(format!("failed to join wait task: {err}")))?
        .map_err(nix_err)
    }

    fn setup_job_control() -> Option<JobControl> {
        let tty = File::options()
            .read(true)
            .write(true)
            .open("/dev/tty")
            .ok()?;
        let pid = getpid();
        if getpgrp() != pid {
            let _ = setpgid(pid, pid);
        }
        let shell_pgid = getpgrp();

        if tcgetpgrp(&tty).ok()? != shell_pgid {
            with_blocked_job_control_signals(|| tcsetpgrp(&tty, shell_pgid)).ok()?;
        }

        Some(JobControl { tty, shell_pgid })
    }

    fn pid_from_u32(pid: u32) -> Option<Pid> {
        i32::try_from(pid).ok().map(Pid::from_raw)
    }

    fn with_blocked_job_control_signals<T, F>(operation: F) -> nix::Result<T>
    where
        F: FnOnce() -> nix::Result<T>,
    {
        let mut signals = SigSet::empty();
        signals.add(Signal::SIGTSTP);
        signals.add(Signal::SIGTTIN);
        signals.add(Signal::SIGTTOU);

        let mut old_mask = SigSet::empty();
        pthread_sigmask(SigmaskHow::SIG_BLOCK, Some(&signals), Some(&mut old_mask))?;
        let result = operation();
        let restore_result = pthread_sigmask(SigmaskHow::SIG_SETMASK, Some(&old_mask), None);

        match (result, restore_result) {
            (Ok(value), Ok(())) => Ok(value),
            (Err(err), _) => Err(err),
            (Ok(_), Err(err)) => Err(err),
        }
    }

    fn nix_err(err: nix::errno::Errno) -> ShellError {
        ShellError::message(io::Error::from_raw_os_error(err as i32).to_string())
    }
}

#[cfg(unix)]
pub(crate) use imp::*;

#[cfg(not(unix))]
mod imp {
    use crate::shell::ShellResult;

    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
    pub(crate) enum ForegroundWaitStatus {
        Exited(i32),
        Signaled(i32),
        Stopped(i32),
    }

    pub(crate) async fn initialize_interactive_shell() -> ShellResult<()> {
        Ok(())
    }

    pub(crate) fn configure_process_group(
        _command: &mut tokio::process::Command,
        _pgid: Option<u32>,
    ) {
    }

    pub(crate) fn hand_terminal_to_foreground_job(_pgid: u32) -> ShellResult<bool> {
        Ok(false)
    }

    pub(crate) fn reclaim_terminal_for_shell() -> ShellResult<()> {
        Ok(())
    }

    pub(crate) fn continue_process_group(_pgid: u32) -> ShellResult<()> {
        Ok(())
    }

    pub(crate) fn signal_process_group(_pgid: u32, _signal: i32) -> ShellResult<()> {
        Ok(())
    }

    pub(crate) fn signal_process(_pid: u32, _signal: i32) -> ShellResult<()> {
        Ok(())
    }

    pub(crate) async fn wait_for_foreground_process(
        _pid: u32,
    ) -> ShellResult<ForegroundWaitStatus> {
        Ok(ForegroundWaitStatus::Exited(1))
    }

    pub(crate) async fn poll_child_statuses() -> ShellResult<Vec<PolledWaitStatus>> {
        Ok(Vec::new())
    }
}

#[cfg(not(unix))]
pub(crate) use imp::*;