Skip to main content

rmux_client/attach/
terminal.rs

1use std::error::Error as StdError;
2use std::fmt;
3use std::fs::File;
4use std::io::{self, Write};
5use std::os::fd::{AsFd, OwnedFd};
6use std::process::{Command, Stdio};
7
8use rmux_core::{alternate_screen_enter_sequence, alternate_screen_exit_sequence};
9use rustix::process::{kill_process, Pid, Signal};
10use rustix::termios::{
11    tcflush, tcgetattr, tcsetattr, OptionalActions, QueueSelector, SpecialCodeIndex, Termios,
12};
13
14use super::terminal_cleanup::fallback_attach_stop_sequence;
15
16pub(super) fn current_process_pid() -> io::Result<Pid> {
17    let raw = i32::try_from(std::process::id())
18        .map_err(|_| io::Error::other("process id does not fit in i32"))?;
19    Pid::from_raw(raw).ok_or_else(|| io::Error::other("process id must be positive"))
20}
21
22/// Result type for raw-terminal lifecycle operations.
23pub type Result<T> = std::result::Result<T, AttachError>;
24
25/// Errors produced while entering or restoring raw terminal mode.
26#[derive(Debug)]
27pub enum AttachError {
28    /// Duplicating the target file descriptor failed before raw mode was applied.
29    Io(io::Error),
30    /// A termios syscall failed while applying or restoring raw mode.
31    Termios(rustix::io::Errno),
32}
33
34impl fmt::Display for AttachError {
35    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
36        match self {
37            Self::Io(error) => write!(formatter, "terminal descriptor operation failed: {error}"),
38            Self::Termios(errno) => write!(formatter, "terminal mode operation failed: {errno}"),
39        }
40    }
41}
42
43impl StdError for AttachError {
44    fn source(&self) -> Option<&(dyn StdError + 'static)> {
45        match self {
46            Self::Io(error) => Some(error),
47            Self::Termios(errno) => Some(errno),
48        }
49    }
50}
51
52impl From<io::Error> for AttachError {
53    fn from(error: io::Error) -> Self {
54        Self::Io(error)
55    }
56}
57
58impl From<rustix::io::Errno> for AttachError {
59    fn from(errno: rustix::io::Errno) -> Self {
60        Self::Termios(errno)
61    }
62}
63
64/// A drop guard that applies raw mode to a terminal and restores the original
65/// settings when dropped.
66///
67/// The guard duplicates the target file descriptor so restoration still works
68/// even if the caller later drops the original handle.
69#[derive(Debug)]
70#[must_use = "keep the guard alive for as long as raw terminal mode is required"]
71pub struct RawTerminal {
72    fd: OwnedFd,
73    original_termios: Termios,
74}
75
76impl RawTerminal {
77    /// Enters raw mode for the process stdin file descriptor.
78    pub fn enter() -> Result<Self> {
79        Self::from_fd(&io::stdin())
80    }
81
82    /// Enters raw mode for the provided terminal file descriptor.
83    ///
84    /// The descriptor must refer to a terminal device. The guard duplicates the
85    /// descriptor before applying raw mode so the caller may drop the original
86    /// handle after creation.
87    pub fn from_fd<Fd>(fd: &Fd) -> Result<Self>
88    where
89        Fd: AsFd,
90    {
91        let owned_fd = fd.as_fd().try_clone_to_owned()?;
92        let original_termios = tcgetattr(&owned_fd)?;
93        let mut raw_termios = original_termios.clone();
94        configure_raw_mode(&mut raw_termios);
95        tcsetattr(&owned_fd, OptionalActions::Now, &raw_termios)?;
96
97        Ok(Self {
98            fd: owned_fd,
99            original_termios,
100        })
101    }
102
103    /// Restores the terminal settings captured when the guard was created.
104    ///
105    /// This provides explicit restore support for callers that want error
106    /// feedback before the guard later runs its drop path.
107    pub fn restore(&self) -> Result<()> {
108        tcsetattr(&self.fd, OptionalActions::Now, &self.original_termios)?;
109        Ok(())
110    }
111
112    fn reapply_raw_mode(&self) -> Result<()> {
113        let mut raw_termios = self.original_termios.clone();
114        configure_raw_mode(&mut raw_termios);
115        tcsetattr(&self.fd, OptionalActions::Now, &raw_termios)?;
116        Ok(())
117    }
118
119    pub(super) fn run_lock_command(&self, command: &str) -> Result<()> {
120        self.restore()?;
121        let result = run_lock_command_with_terminal(&self.fd, command);
122        let reapply_result = self.reapply_raw_mode();
123        if let Err(error) = result {
124            reapply_result?;
125            return Err(error);
126        }
127        reapply_result?;
128        Ok(())
129    }
130
131    pub(super) fn suspend_self(&self) -> Result<()> {
132        self.restore()?;
133        kill_process(current_process_pid()?, Signal::TSTP)?;
134        self.reapply_raw_mode()?;
135        Ok(())
136    }
137
138    pub(super) fn run_detach_exec_command(&self, command: &str) -> Result<()> {
139        self.restore()?;
140        run_lock_command_with_terminal(&self.fd, command)
141    }
142
143    pub(super) fn restore_attach_terminal_state(&self) -> Result<()> {
144        let mut terminal = File::from(self.fd.as_fd().try_clone_to_owned()?);
145        let term = std::env::var("TERM").unwrap_or_default();
146        terminal.write_all(&fallback_attach_stop_sequence(&term))?;
147        terminal.flush()?;
148        Ok(())
149    }
150
151    pub(super) fn flush_pending_input(&self) -> Result<()> {
152        tcflush(&self.fd, QueueSelector::IFlush)?;
153        Ok(())
154    }
155}
156
157impl Drop for RawTerminal {
158    fn drop(&mut self) {
159        let _ = self.restore();
160    }
161}
162
163fn configure_raw_mode(termios: &mut Termios) {
164    termios.make_raw();
165    termios.special_codes[SpecialCodeIndex::VMIN] = 1;
166    termios.special_codes[SpecialCodeIndex::VTIME] = 0;
167}
168
169fn run_lock_command_with_terminal(fd: &OwnedFd, command: &str) -> Result<()> {
170    let stdin = File::from(fd.as_fd().try_clone_to_owned()?);
171    let stdout = File::from(fd.as_fd().try_clone_to_owned()?);
172    let stderr = File::from(fd.as_fd().try_clone_to_owned()?);
173    let mut terminal = File::from(fd.as_fd().try_clone_to_owned()?);
174    let term = std::env::var("TERM").unwrap_or_default();
175
176    terminal.write_all(alternate_screen_enter_sequence(&term))?;
177    terminal.flush()?;
178
179    let status_result = Command::new("sh")
180        .arg("-c")
181        .arg(command)
182        .stdin(Stdio::from(stdin))
183        .stdout(Stdio::from(stdout))
184        .stderr(Stdio::from(stderr))
185        .status();
186
187    let restore_result = terminal
188        .write_all(alternate_screen_exit_sequence(&term))
189        .and_then(|()| terminal.flush())
190        .map_err(AttachError::Io);
191
192    restore_result?;
193    status_result.map_err(AttachError::Io)?;
194    Ok(())
195}