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, BorrowedFd, OwnedFd};
6use std::process::{Command, Stdio};
7use std::thread;
8use std::time::{Duration, Instant};
9
10use rmux_os::process_tree::ProcessTreeChild;
11use rmux_proto::AttachShellCommand;
12use rustix::fs::{fcntl_getfl, fcntl_setfl, OFlags};
13use rustix::process::{kill_process, Pid, Signal};
14use rustix::termios::{
15    tcflush, tcgetattr, tcsetattr, OptionalActions, QueueSelector, SpecialCodeIndex, Termios,
16};
17
18use super::terminal_cleanup::fallback_attach_stop_sequence;
19use super::termination;
20
21const TERMINATION_OUTPUT_RETRY: Duration = Duration::from_millis(100);
22const TERMINATION_OUTPUT_RETRY_INTERVAL: Duration = Duration::from_millis(1);
23const SHELL_CHILD_WAIT_INTERVAL: Duration = Duration::from_millis(10);
24const SHELL_CHILD_TERMINATION_GRACE: Duration = Duration::from_millis(250);
25
26pub(super) fn current_process_pid() -> io::Result<Pid> {
27    let raw = i32::try_from(std::process::id())
28        .map_err(|_| io::Error::other("process id does not fit in i32"))?;
29    Pid::from_raw(raw).ok_or_else(|| io::Error::other("process id must be positive"))
30}
31
32/// Result type for raw-terminal lifecycle operations.
33pub type Result<T> = std::result::Result<T, AttachError>;
34
35/// Errors produced while entering or restoring raw terminal mode.
36#[derive(Debug)]
37pub enum AttachError {
38    /// Duplicating the target file descriptor failed before raw mode was applied.
39    Io(io::Error),
40    /// A termios syscall failed while applying or restoring raw mode.
41    Termios(rustix::io::Errno),
42}
43
44impl fmt::Display for AttachError {
45    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
46        match self {
47            Self::Io(error) => write!(formatter, "terminal descriptor operation failed: {error}"),
48            Self::Termios(errno) => write!(formatter, "terminal mode operation failed: {errno}"),
49        }
50    }
51}
52
53impl StdError for AttachError {
54    fn source(&self) -> Option<&(dyn StdError + 'static)> {
55        match self {
56            Self::Io(error) => Some(error),
57            Self::Termios(errno) => Some(errno),
58        }
59    }
60}
61
62impl From<io::Error> for AttachError {
63    fn from(error: io::Error) -> Self {
64        Self::Io(error)
65    }
66}
67
68impl From<rustix::io::Errno> for AttachError {
69    fn from(errno: rustix::io::Errno) -> Self {
70        Self::Termios(errno)
71    }
72}
73
74/// A drop guard that applies raw mode to a terminal and restores the original
75/// settings when dropped.
76///
77/// The guard duplicates the target file descriptor so restoration still works
78/// even if the caller later drops the original handle.
79#[derive(Debug)]
80#[must_use = "keep the guard alive for as long as raw terminal mode is required"]
81pub struct RawTerminal {
82    fd: OwnedFd,
83    original_termios: Termios,
84}
85
86impl RawTerminal {
87    /// Enters raw mode for the process stdin file descriptor.
88    pub fn enter() -> Result<Self> {
89        Self::from_fd(&io::stdin())
90    }
91
92    /// Enters raw mode for the provided terminal file descriptor.
93    ///
94    /// The descriptor must refer to a terminal device. The guard duplicates the
95    /// descriptor before applying raw mode so the caller may drop the original
96    /// handle after creation.
97    pub fn from_fd<Fd>(fd: &Fd) -> Result<Self>
98    where
99        Fd: AsFd,
100    {
101        let owned_fd = fd.as_fd().try_clone_to_owned()?;
102        let original_termios = tcgetattr(&owned_fd)?;
103        let mut raw_termios = original_termios.clone();
104        configure_raw_mode(&mut raw_termios);
105        tcsetattr(&owned_fd, OptionalActions::Now, &raw_termios)?;
106
107        Ok(Self {
108            fd: owned_fd,
109            original_termios,
110        })
111    }
112
113    /// Restores the terminal settings captured when the guard was created.
114    ///
115    /// This provides explicit restore support for callers that want error
116    /// feedback before the guard later runs its drop path.
117    pub fn restore(&self) -> Result<()> {
118        tcsetattr(&self.fd, OptionalActions::Now, &self.original_termios)?;
119        Ok(())
120    }
121
122    fn reapply_raw_mode(&self) -> Result<()> {
123        let mut raw_termios = self.original_termios.clone();
124        configure_raw_mode(&mut raw_termios);
125        tcsetattr(&self.fd, OptionalActions::Now, &raw_termios)?;
126        Ok(())
127    }
128
129    pub(super) fn run_lock_command(&self, command: &str) -> Result<()> {
130        self.restore()?;
131        let result = run_shell_command_with_terminal(&self.fd, "sh", command, None);
132        let reapply_result = self.reapply_raw_mode();
133        if let Err(error) = result {
134            reapply_result?;
135            return Err(error);
136        }
137        reapply_result?;
138        Ok(())
139    }
140
141    pub(super) fn run_lock_shell_command(&self, command: &AttachShellCommand) -> Result<()> {
142        self.restore()?;
143        let result = run_shell_command_with_terminal(
144            &self.fd,
145            command.shell(),
146            command.command(),
147            Some(command.cwd()),
148        );
149        let reapply_result = self.reapply_raw_mode();
150        if let Err(error) = result {
151            reapply_result?;
152            return Err(error);
153        }
154        reapply_result
155    }
156
157    pub(super) fn suspend_self(&self) -> Result<()> {
158        self.restore()?;
159        kill_process(current_process_pid()?, Signal::TSTP)?;
160        self.reapply_raw_mode()?;
161        Ok(())
162    }
163
164    pub(super) fn run_detach_exec_command(&self, command: &str) -> Result<()> {
165        self.restore()?;
166        run_shell_command_with_terminal(&self.fd, "sh", command, None)
167    }
168
169    pub(super) fn run_detach_exec_shell_command(&self, command: &AttachShellCommand) -> Result<()> {
170        self.restore()?;
171        run_shell_command_with_terminal(
172            &self.fd,
173            command.shell(),
174            command.command(),
175            Some(command.cwd()),
176        )
177    }
178
179    pub(super) fn restore_attach_terminal_state(&self) -> Result<()> {
180        let mut terminal = File::from(self.fd.as_fd().try_clone_to_owned()?);
181        let term = std::env::var("TERM").unwrap_or_default();
182        terminal.write_all(&fallback_attach_stop_sequence(&term))?;
183        terminal.flush()?;
184        Ok(())
185    }
186
187    pub(super) fn restore_after_termination(&self) -> Result<()> {
188        self.restore()?;
189        let _flags = self.interrupt_output_writer()?;
190        let mut terminal = File::from(self.fd.as_fd().try_clone_to_owned()?);
191        write_cleanup_with_deadline(
192            &mut terminal,
193            &fallback_attach_stop_sequence(&std::env::var("TERM").unwrap_or_default()),
194        )
195        .map_err(AttachError::Io)
196    }
197
198    pub(super) fn interrupt_output_writer(&self) -> Result<FileStatusFlagsGuard<'_>> {
199        let flags = FileStatusFlagsGuard::set_nonblocking(self.fd.as_fd())?;
200        tcflush(&self.fd, QueueSelector::OFlush)?;
201        Ok(flags)
202    }
203
204    pub(super) fn flush_pending_input(&self) -> Result<()> {
205        tcflush(&self.fd, QueueSelector::IFlush)?;
206        Ok(())
207    }
208}
209
210pub(super) struct FileStatusFlagsGuard<'fd> {
211    fd: BorrowedFd<'fd>,
212    original: OFlags,
213}
214
215impl<'fd> FileStatusFlagsGuard<'fd> {
216    fn set_nonblocking(fd: BorrowedFd<'fd>) -> Result<Self> {
217        let original = fcntl_getfl(fd).map_err(io::Error::from)?;
218        fcntl_setfl(fd, original | OFlags::NONBLOCK).map_err(io::Error::from)?;
219        Ok(Self { fd, original })
220    }
221}
222
223impl Drop for FileStatusFlagsGuard<'_> {
224    fn drop(&mut self) {
225        let _ = fcntl_setfl(self.fd, self.original);
226    }
227}
228
229fn write_cleanup_with_deadline(output: &mut File, mut bytes: &[u8]) -> io::Result<()> {
230    let deadline = Instant::now() + TERMINATION_OUTPUT_RETRY;
231    while !bytes.is_empty() {
232        match output.write(bytes) {
233            Ok(0) => {
234                return Err(io::Error::new(
235                    io::ErrorKind::WriteZero,
236                    "failed to write terminal cleanup after attach termination",
237                ))
238            }
239            Ok(written) => bytes = &bytes[written..],
240            Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
241            Err(error)
242                if error.kind() == io::ErrorKind::WouldBlock && Instant::now() < deadline =>
243            {
244                thread::sleep(TERMINATION_OUTPUT_RETRY_INTERVAL);
245            }
246            Err(error) => return Err(error),
247        }
248    }
249    output.flush()
250}
251
252impl Drop for RawTerminal {
253    fn drop(&mut self) {
254        let _ = self.restore();
255    }
256}
257
258fn configure_raw_mode(termios: &mut Termios) {
259    termios.make_raw();
260    termios.special_codes[SpecialCodeIndex::VMIN] = 1;
261    termios.special_codes[SpecialCodeIndex::VTIME] = 0;
262}
263
264fn run_shell_command_with_terminal(
265    fd: &OwnedFd,
266    shell: &str,
267    command: &str,
268    cwd: Option<&str>,
269) -> Result<()> {
270    let stdin = File::from(fd.as_fd().try_clone_to_owned()?);
271    let stdout = File::from(fd.as_fd().try_clone_to_owned()?);
272    let stderr = File::from(fd.as_fd().try_clone_to_owned()?);
273    let mut process = Command::new(shell);
274    process
275        .arg("-c")
276        .arg(command)
277        .stdin(Stdio::from(stdin))
278        .stdout(Stdio::from(stdout))
279        .stderr(Stdio::from(stderr));
280    if let Some(cwd) = cwd {
281        process.current_dir(cwd);
282    }
283    let mut child = ProcessTreeChild::spawn(&mut process).map_err(AttachError::Io)?;
284    let foreground = child.foreground_terminal(fd).map_err(AttachError::Io)?;
285    let wait_result = wait_for_shell_child(&mut child, termination::requested_signal);
286    let restore_result = foreground.restore();
287    if let Err(error) = wait_result {
288        restore_result?;
289        return Err(AttachError::Io(error));
290    }
291    restore_result?;
292    Ok(())
293}
294
295fn wait_for_shell_child(
296    child: &mut ProcessTreeChild,
297    requested_signal: impl Fn() -> Option<i32>,
298) -> io::Result<()> {
299    loop {
300        if child.has_exited()? {
301            child.wait()?;
302            return Ok(());
303        }
304        if child.has_stopped()? {
305            terminate_stopped_shell_tree(child)?;
306            return Ok(());
307        }
308        let Some(signal) = requested_signal() else {
309            thread::sleep(SHELL_CHILD_WAIT_INTERVAL);
310            continue;
311        };
312
313        child.forward_signal(signal).map_err(|error| {
314            io::Error::new(
315                error.kind(),
316                format!("failed to forward attach interruption to shell tree: {error}"),
317            )
318        })?;
319        let _ = wait_for_child_exit_until(child, Instant::now() + SHELL_CHILD_TERMINATION_GRACE)?;
320
321        // Keep the exited Unix group leader waitable until after the force
322        // signal. That prevents its PGID from being recycled while also
323        // terminating descendants that ignored the forwarded signal.
324        child.terminate().map_err(|error| {
325            io::Error::new(
326                error.kind(),
327                format!("failed to terminate interrupted shell tree: {error}"),
328            )
329        })?;
330        let _ = wait_for_child_exit_until(child, Instant::now() + SHELL_CHILD_TERMINATION_GRACE);
331        child.wait().map_err(|error| {
332            io::Error::new(
333                error.kind(),
334                format!("failed to reap interrupted shell tree: {error}"),
335            )
336        })?;
337        return Err(termination::interruption_error());
338    }
339}
340
341fn terminate_stopped_shell_tree(child: &mut ProcessTreeChild) -> io::Result<()> {
342    child.terminate().map_err(|error| {
343        io::Error::new(
344            error.kind(),
345            format!("failed to terminate stopped shell tree: {error}"),
346        )
347    })?;
348    child.wait().map_err(|error| {
349        io::Error::new(
350            error.kind(),
351            format!("failed to reap stopped shell tree: {error}"),
352        )
353    })?;
354    Ok(())
355}
356
357fn wait_for_child_exit_until(child: &mut ProcessTreeChild, deadline: Instant) -> io::Result<bool> {
358    while Instant::now() < deadline {
359        if child.has_exited()? {
360            return Ok(true);
361        }
362        thread::sleep(SHELL_CHILD_WAIT_INTERVAL);
363    }
364    child.has_exited()
365}
366
367#[cfg(test)]
368mod tests {
369    use std::path::PathBuf;
370    use std::process::{Command, Stdio};
371    use std::sync::atomic::{AtomicI32, Ordering};
372    use std::sync::Arc;
373    use std::time::{Duration, Instant};
374
375    use rmux_os::process_tree::ProcessTreeChild;
376
377    use super::wait_for_shell_child;
378
379    fn spawn_shell(script: &str) -> ProcessTreeChild {
380        let mut command = Command::new("sh");
381        command
382            .args(["-c", script])
383            .stdin(Stdio::null())
384            .stdout(Stdio::null())
385            .stderr(Stdio::null());
386        ProcessTreeChild::spawn(&mut command).expect("spawn isolated shell child")
387    }
388
389    #[test]
390    fn shell_child_wait_is_bounded_after_hup_or_term() {
391        for signal in [libc::SIGHUP, libc::SIGTERM] {
392            let mut child = spawn_shell("exec sleep 60");
393            let requested = Arc::new(AtomicI32::new(0));
394            let request_from_thread = Arc::clone(&requested);
395            std::thread::spawn(move || {
396                std::thread::sleep(Duration::from_millis(40));
397                request_from_thread.store(signal, Ordering::SeqCst);
398            });
399
400            let started = Instant::now();
401            let error = wait_for_shell_child(&mut child, || {
402                let signal = requested.load(Ordering::SeqCst);
403                (signal != 0).then_some(signal)
404            })
405            .expect_err("termination must interrupt the shell wait");
406
407            assert_eq!(
408                error.kind(),
409                std::io::ErrorKind::Interrupted,
410                "unexpected shell wait error after signal {signal}: {error}"
411            );
412            assert!(
413                started.elapsed() < Duration::from_secs(2),
414                "shell wait should not remain blocked after signal {signal}"
415            );
416            assert!(
417                child.has_exited().expect("query child status"),
418                "the interrupted shell child should be reaped"
419            );
420        }
421    }
422
423    #[test]
424    fn shell_child_wait_force_kills_a_child_that_ignores_term() {
425        let mut child = spawn_shell("trap '' TERM; exec sleep 60");
426        std::thread::sleep(Duration::from_millis(40));
427
428        let started = Instant::now();
429        let error = wait_for_shell_child(&mut child, || Some(libc::SIGTERM))
430            .expect_err("ignored termination must still bound the shell wait");
431
432        assert_eq!(error.kind(), std::io::ErrorKind::Interrupted);
433        assert!(started.elapsed() < Duration::from_secs(2));
434        assert!(
435            child.has_exited().expect("query child status"),
436            "the force-killed shell child should be reaped"
437        );
438    }
439
440    #[test]
441    fn shell_child_wait_terminates_signal_ignoring_descendants() {
442        let pid_file = unique_descendant_pid_file();
443        let mut command = Command::new("sh");
444        command
445            .args([
446                "-c",
447                "sh -c 'trap \"\" TERM; exec sleep 60' & printf '%s' \"$!\" > \"$RMUX_DESCENDANT_PID\"; wait",
448            ])
449            .env("RMUX_DESCENDANT_PID", &pid_file)
450            .stdin(Stdio::null())
451            .stdout(Stdio::null())
452            .stderr(Stdio::null());
453        let mut child = ProcessTreeChild::spawn(&mut command).expect("spawn shell process tree");
454
455        let descendant_pid = wait_for_descendant_pid(&pid_file);
456        let cleanup = DescendantCleanup {
457            pid: descendant_pid,
458            pid_file,
459        };
460        let error = wait_for_shell_child(&mut child, || Some(libc::SIGTERM))
461            .expect_err("termination must interrupt the complete shell process tree");
462
463        assert_eq!(error.kind(), std::io::ErrorKind::Interrupted);
464        let deadline = Instant::now() + Duration::from_secs(2);
465        while process_exists(descendant_pid) && Instant::now() < deadline {
466            std::thread::sleep(Duration::from_millis(10));
467        }
468        assert!(
469            !process_exists(descendant_pid),
470            "signal-ignoring descendant {descendant_pid} survived attach interruption"
471        );
472        drop(cleanup);
473    }
474
475    #[test]
476    fn shell_child_wait_preserves_background_descendants_after_normal_exit() {
477        let pid_file = unique_descendant_pid_file();
478        let mut command = Command::new("sh");
479        command
480            .args([
481                "-c",
482                "sleep 60 </dev/null >/dev/null 2>&1 & printf '%s' \"$!\" > \"$RMUX_DESCENDANT_PID\"",
483            ])
484            .env("RMUX_DESCENDANT_PID", &pid_file)
485            .stdin(Stdio::null())
486            .stdout(Stdio::null())
487            .stderr(Stdio::null());
488        let mut child = ProcessTreeChild::spawn(&mut command).expect("spawn shell process tree");
489
490        let descendant_pid = wait_for_descendant_pid(&pid_file);
491        let cleanup = DescendantCleanup {
492            pid: descendant_pid,
493            pid_file,
494        };
495        wait_for_shell_child(&mut child, || None).expect("foreground shell should exit normally");
496
497        assert!(
498            process_exists(descendant_pid),
499            "normal shell completion must preserve an intentional background descendant"
500        );
501        drop(cleanup);
502    }
503
504    struct DescendantCleanup {
505        pid: i32,
506        pid_file: PathBuf,
507    }
508
509    impl Drop for DescendantCleanup {
510        fn drop(&mut self) {
511            unsafe {
512                // SAFETY: The test owns the spawned descendant PID and uses a
513                // force signal only as failure-path cleanup.
514                libc::kill(self.pid, libc::SIGKILL);
515            }
516            let _ = std::fs::remove_file(&self.pid_file);
517        }
518    }
519
520    fn unique_descendant_pid_file() -> PathBuf {
521        std::env::temp_dir().join(format!(
522            "rmux-attach-descendant-{}-{}",
523            std::process::id(),
524            std::time::SystemTime::now()
525                .duration_since(std::time::UNIX_EPOCH)
526                .expect("system clock after Unix epoch")
527                .as_nanos()
528        ))
529    }
530
531    fn wait_for_descendant_pid(pid_file: &PathBuf) -> i32 {
532        let deadline = Instant::now() + Duration::from_secs(2);
533        loop {
534            if let Ok(contents) = std::fs::read_to_string(pid_file) {
535                if let Ok(pid) = contents.parse() {
536                    return pid;
537                }
538            }
539            assert!(
540                Instant::now() < deadline,
541                "shell did not publish its descendant pid"
542            );
543            std::thread::sleep(Duration::from_millis(10));
544        }
545    }
546
547    fn process_exists(pid: i32) -> bool {
548        let result = unsafe {
549            // SAFETY: Signal zero performs an existence check without changing
550            // the target process.
551            libc::kill(pid, 0)
552        };
553        result == 0 || std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH)
554    }
555}
556
557#[cfg(all(
558    test,
559    not(any(
560        target_os = "cygwin",
561        target_os = "horizon",
562        target_os = "openbsd",
563        target_os = "redox",
564        target_os = "wasi"
565    ))
566))]
567#[path = "terminal_stopped_tests.rs"]
568mod stopped_tests;