From 95217b27aac0886565539900d96a989e6549e75e Mon Sep 17 00:00:00 2001 From: Jake Abendroth Date: Fri, 9 Oct 2026 20:01:35 -0700 Subject: [PATCH 1/2] uutests: open simulated terminals close-on-exec openpty doesn't set close-on-exec, so every command that another test started while a terminal was simulated inherited its PTYs, up to six descriptors. Tests that run under a low descriptor limit then failed. Open the PTY close-on-exec through rustix where the OS supports it (Linux, Android, FreeBSD, NetBSD), and set it right after openpty elsewhere, which leaves a short window. The stdin PTY stayed open only through the copy the child inherited. Read its echo in a thread, as a terminal would, which keeps it open until the child closes it; macOS also waits at exit for an unread echo. --- tests/by-util/test_env.rs | 17 +++++++++ tests/uutests/Cargo.toml | 2 +- tests/uutests/src/lib/util.rs | 72 ++++++++++++++++++++++++++++++++--- 3 files changed, 84 insertions(+), 7 deletions(-) diff --git a/tests/by-util/test_env.rs b/tests/by-util/test_env.rs index a4515196bc3..97a7a62e6ee 100644 --- a/tests/by-util/test_env.rs +++ b/tests/by-util/test_env.rs @@ -1885,6 +1885,23 @@ fn test_simulation_of_terminal_pty_pipes_into_data_and_sends_eot_automatically() std::assert_eq!(String::from_utf8_lossy(out.stderr()), ""); } +#[cfg(target_os = "linux")] +#[test] +fn test_simulation_of_terminal_not_inherited_by_other_commands() { + let scene = TestScenario::new("util"); + + let mut cmd = scene.ccmd("env"); + cmd.args(&["sleep", "1"]); + cmd.terminal_simulation(true); + let mut child = cmd.run_no_wait(); + scene + .ccmd("env") + .args(&["ls", "-l", "/proc/self/fd/"]) + .succeeds() + .stdout_does_not_contain("/dev/ptmx"); + child.kill(); +} + #[test] #[cfg(not(windows))] fn test_emoji_env_vars() { diff --git a/tests/uutests/Cargo.toml b/tests/uutests/Cargo.toml index affc98cf28a..ee810110c32 100644 --- a/tests/uutests/Cargo.toml +++ b/tests/uutests/Cargo.toml @@ -37,7 +37,7 @@ uucore = { workspace = true, features = [ [target.'cfg(unix)'.dependencies] nix = { workspace = true, features = ["fs", "term"] } -rustix = { workspace = true, features = ["process"] } +rustix = { workspace = true, features = ["process", "pty", "termios"] } [target.'cfg(any(target_os = "freebsd", target_os = "hurd", target_os = "linux", target_os = "android", target_os = "netbsd"))'.dependencies] xattr = { workspace = true } diff --git a/tests/uutests/src/lib/util.rs b/tests/uutests/src/lib/util.rs index 074d3eec226..ae86e348265 100644 --- a/tests/uutests/src/lib/util.rs +++ b/tests/uutests/src/lib/util.rs @@ -3,7 +3,7 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -//spell-checker:ignore (linux) rlimit prlimit coreutil ggroups uchild uncaptured scmd SHLVL canonicalized openpty +//spell-checker:ignore (linux) rlimit prlimit coreutil ggroups uchild uncaptured scmd SHLVL canonicalized openpty openpt grantpt unlockpt ptsname tcsetwinsize CLOEXEC RDWR SETFD //spell-checker:ignore (linux) winsize xpixel ypixel setrlimit Fsize SIGBUS SIGSEGV SIGXFSZ EFBIG sigbus tmpfs mksocket //spell-checker:ignore (ToDO) ttyname @@ -2038,7 +2038,11 @@ impl UCommand { let OpenptyResult { slave: pi_slave, master: pi_master, - } = nix::pty::openpty(&terminal_size, None).unwrap(); + } = open_pty(Some(&terminal_size)); + // Read the echo, as a terminal would. This also keeps the PTY + // open until the child closes it, so closing stdin doesn't hang it up. + let mut echo = File::from(pi_master.try_clone().unwrap()); + thread::spawn(move || io::copy(&mut echo, &mut io::sink())); stdin_pty = Some(File::from(pi_master)); command.stdin(pi_slave); } @@ -2047,7 +2051,7 @@ impl UCommand { let OpenptyResult { slave: po_slave, master: po_master, - } = nix::pty::openpty(&terminal_size, None).unwrap(); + } = open_pty(Some(&terminal_size)); captured_stdout = Self::spawn_reader_thread( captured_stdout, po_master, @@ -2060,7 +2064,7 @@ impl UCommand { let OpenptyResult { slave: pe_slave, master: pe_master, - } = nix::pty::openpty(&terminal_size, None).unwrap(); + } = open_pty(Some(&terminal_size)); captured_stderr = Self::spawn_reader_thread( captured_stderr, pe_master, @@ -3038,6 +3042,63 @@ pub fn whoami() -> String { }) } +/// Open a PTY whose two ends are close-on-exec, so that commands started by +/// other tests in the meantime don't inherit them. +#[cfg(all(unix, not(target_os = "redox")))] +fn open_pty(size: Option<&libc::winsize>) -> OpenptyResult { + #[cfg(any( + target_os = "linux", + target_os = "android", + target_os = "freebsd", + target_os = "netbsd" + ))] + { + use rustix::fs::{Mode, OFlags, open}; + use rustix::pty::{OpenptFlags, grantpt, openpt, ptsname, unlockpt}; + use rustix::termios::{Winsize, tcsetwinsize}; + + let master = + openpt(OpenptFlags::RDWR | OpenptFlags::NOCTTY | OpenptFlags::CLOEXEC).unwrap(); + grantpt(&master).unwrap(); + unlockpt(&master).unwrap(); + let name = ptsname(&master, Vec::new()).unwrap(); + let slave = open( + name, + OFlags::RDWR | OFlags::NOCTTY | OFlags::CLOEXEC, + Mode::empty(), + ) + .unwrap(); + if let Some(size) = size { + let size = Winsize { + ws_row: size.ws_row, + ws_col: size.ws_col, + ws_xpixel: size.ws_xpixel, + ws_ypixel: size.ws_ypixel, + }; + tcsetwinsize(&slave, size).unwrap(); + } + OpenptyResult { master, slave } + } + + // Elsewhere a PTY can't be opened close-on-exec, so a command started + // between openpty and fcntl can still inherit it. + #[cfg(not(any( + target_os = "linux", + target_os = "android", + target_os = "freebsd", + target_os = "netbsd" + )))] + { + use nix::fcntl::{FcntlArg, FdFlag, fcntl}; + + let pty = nix::pty::openpty(size, None).unwrap(); + for fd in [&pty.master, &pty.slave] { + fcntl(fd, FcntlArg::F_SETFD(FdFlag::FD_CLOEXEC)).unwrap(); + } + pty + } +} + /// Create a PTY (pseudo-terminal) for testing utilities that require a TTY. /// /// Returns a tuple of (path, controller, replica) where: @@ -3046,9 +3107,8 @@ pub fn whoami() -> String { /// - replica: The replica file #[cfg(all(unix, not(target_os = "redox")))] pub fn pty_path() -> (String, File, File) { - use nix::pty::openpty; use nix::unistd::ttyname; - let pty = openpty(None, None).expect("Failed to create PTY"); + let pty = open_pty(None); let path = ttyname(&pty.slave) .expect("Failed to get PTY path") .to_string_lossy() From 823ef1ba88f15b60c4a762cea3fd81f9703cd418 Mon Sep 17 00:00:00 2001 From: Jake Abendroth Date: Sun, 11 Oct 2026 16:20:21 -0700 Subject: [PATCH 2/2] tests: more: kill more at the end of each pty test --- tests/by-util/test_more.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/by-util/test_more.rs b/tests/by-util/test_more.rs index 0a538c64802..7bf12cd0048 100644 --- a/tests/by-util/test_more.rs +++ b/tests/by-util/test_more.rs @@ -47,6 +47,9 @@ fn run_more_with_pty( fn quit_more(controller: &mut File, mut child: uutests::util::UChild) { controller.write_all(b"q").unwrap(); child.delay(50); + // Not all options quit on `q` here, and a `more` left behind spins once + // its terminal hangs up. + child.kill(); } #[cfg(unix)] @@ -110,9 +113,7 @@ fn test_alive(args: &[&str]) { assert!(child.is_alive(), "Command should still be alive"); - // cleanup - controller.write_all(b"q").unwrap(); - child.delay(50); + quit_more(&mut controller, child); } #[test]