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
32pub type Result<T> = std::result::Result<T, AttachError>;
34
35#[derive(Debug)]
37pub enum AttachError {
38 Io(io::Error),
40 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#[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 pub fn enter() -> Result<Self> {
89 Self::from_fd(&io::stdin())
90 }
91
92 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 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 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 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 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;