diff --git a/.github/workflows/Build.Linux.Job.yml b/.github/workflows/Build.Linux.Job.yml index ec3684131..0d7ac962e 100644 --- a/.github/workflows/Build.Linux.Job.yml +++ b/.github/workflows/Build.Linux.Job.yml @@ -19,6 +19,8 @@ jobs: target: aarch64-unknown-linux-gnu features: '' runs-on: ${{ matrix.runner }} + # A wedged PTY child would otherwise hold the runner for the 6-hour cap. + timeout-minutes: 45 defaults: run: working-directory: src/tools/lxc @@ -131,6 +133,14 @@ jobs: cargo test --locked --release --target "${{ matrix.target }}" -p mxc-sdk -p mxc_ffi --lib state_aware cargo test --locked --release --target "${{ matrix.target }}" -p mxc-sdk --test state_aware + # This lane selects lib tests by module, so mxc_pty needs an explicit + # step; the macOS lane picks it up via the package-wide run. The tests + # drive /dev/ptmx directly and need no sandbox backend installed. + - name: Test unix pty bridge + working-directory: src + run: cargo test --locked --release --target ${{ matrix.target }} + -p mxc-sdk --lib 'core_modules::mxc_pty::' + # These need no LXC installed: the engine tests inject their probes, and # the dispatch and FFI pins are refused before a container is created. - name: Test LXC dispatch and discovery diff --git a/.github/workflows/Build.MacOS.Job.yml b/.github/workflows/Build.MacOS.Job.yml index 995735246..0a8e62023 100644 --- a/.github/workflows/Build.MacOS.Job.yml +++ b/.github/workflows/Build.MacOS.Job.yml @@ -7,6 +7,8 @@ jobs: build: name: arm64 runs-on: macos-15 + # A wedged PTY child would otherwise hold the runner for the 6-hour cap. + timeout-minutes: 45 defaults: run: working-directory: src @@ -44,17 +46,14 @@ jobs: run: cargo build --locked --release --target aarch64-apple-darwin -p mxc_darwin -p mxc-sdk -p unix_test_proxy -p mxc_ffi - # mxc-sdk includes the Seatbelt executor characterization tests. Its - # consolidated mxc_pty module was not part of this pre-consolidation - # macOS lane, so keep those host-sensitive unit tests out of this job. + # mxc-sdk includes the Seatbelt executor characterization tests. # mxc-exec-mac (built above via -p mxc_darwin) is what they drive; the # tests skip via has_platform_exec() if it is missing. sandbox-exec needs # no elevation, so they run in this standard macOS job. - name: Test run: | cargo test --locked --release --target aarch64-apple-darwin \ - -p mxc_darwin -p mxc-sdk -- \ - --skip 'core_modules::mxc_pty::tests::' + -p mxc_darwin -p mxc-sdk - name: Test state-aware SDK boundaries shell: bash diff --git a/docs/api-reference/dotnet/v1/api.md b/docs/api-reference/dotnet/v1/api.md index 101f01382..2241c4408 100644 --- a/docs/api-reference/dotnet/v1/api.md +++ b/docs/api-reference/dotnet/v1/api.md @@ -24,6 +24,10 @@ IsolationSession-only. Seatbelt PTY rejects `guiAccess` and legacy give the caller explicit input, output, resize, and process ownership; there is no separate public attached-console launch API or API accepting ["MXC request JSON"](../../../schema.md#mxc-request-json). +Read the terminal output concurrently rather than only after waiting. On macOS +a workload cannot exit while its terminal output is still unread, so a caller +that waits first without reading can block indefinitely. + ## Discovery and validation | Operation | Use it to | diff --git a/docs/api-reference/node/v1/api.md b/docs/api-reference/node/v1/api.md index e60087d77..2227f9c1d 100644 --- a/docs/api-reference/node/v1/api.md +++ b/docs/api-reference/node/v1/api.md @@ -24,6 +24,10 @@ rejects `guiAccess` and legacy `launchMethod: "open"`. Terminal handles give the caller explicit input, output, resize, and process ownership; there is no separate attached-console launch API or API accepting ["MXC request JSON"](../../../schema.md#mxc-request-json). +Read the terminal output concurrently rather than only after waiting. On macOS +a workload cannot exit while its terminal output is still unread, so a caller +that waits first without reading can block indefinitely. + ## Discovery and validation | Operation | Use it to | diff --git a/docs/api-reference/rust/v1/api.md b/docs/api-reference/rust/v1/api.md index 90b613703..15d6d0a3b 100644 --- a/docs/api-reference/rust/v1/api.md +++ b/docs/api-reference/rust/v1/api.md @@ -26,6 +26,10 @@ IsolationSession-only. Seatbelt PTY rejects `guiAccess` and legacy caller explicit input, output, resize, and process ownership instead of attaching the workload to the host process's global console streams. +Read the terminal output concurrently rather than only after `wait`. On macOS +a workload cannot exit while its terminal output is still unread, so a caller +that waits first without reading can block indefinitely. + ## Discovery and validation | Operation | Use it to | diff --git a/src/mxc-sdk/src/core/mxc_common/interruptible_reader.rs b/src/mxc-sdk/src/core/mxc_common/interruptible_reader.rs index a78861878..5887ae564 100644 --- a/src/mxc-sdk/src/core/mxc_common/interruptible_reader.rs +++ b/src/mxc-sdk/src/core/mxc_common/interruptible_reader.rs @@ -17,10 +17,9 @@ //! and sets a flag so later reads short-circuit to EOF. use std::io::{self, Read}; -use std::os::fd::{AsFd, AsRawFd, FromRawFd, OwnedFd, RawFd}; +use std::os::fd::{AsFd, AsRawFd, OwnedFd, RawFd}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; -use std::time::Duration; use crate::mxc_common::sandbox_process::StreamCloser; @@ -78,7 +77,6 @@ pub struct InterruptibleReader { /// Read end of the self-pipe; readable once cancellation writes its byte. wake_r: OwnedFd, state: Arc, - eof_probe_interval: Option, } impl InterruptibleReader { @@ -92,39 +90,12 @@ impl InterruptibleReader { /// Returns the underlying [`io::Error`] if the self-pipe cannot be created /// or descriptor flags cannot be applied. pub fn new(fd: OwnedFd) -> io::Result { - Self::new_with_eof_probe_interval(fd, None) - } - - /// Wrap an owned readable pipe and periodically probe it for EOF. - /// - /// This is needed for Darwin FIFOs, where `poll` may not wake when the - /// final writer closes even though a non-blocking `read` returns EOF. - pub fn new_with_periodic_eof_probe(fd: OwnedFd, interval: Duration) -> io::Result { - Self::new_with_eof_probe_interval(fd, Some(interval)) - } - - fn new_with_eof_probe_interval( - fd: OwnedFd, - eof_probe_interval: Option, - ) -> io::Result { set_cloexec(fd.as_raw_fd())?; set_nonblocking(fd.as_raw_fd())?; // Self-pipe for wakeups: the write end is non-blocking so `cancel` // never stalls; the read end stays blocking but is only ever polled. - let mut fds = [0 as RawFd; 2]; - // SAFETY: `fds` is a valid 2-element array for `pipe` to fill. - if unsafe { libc::pipe(fds.as_mut_ptr()) } < 0 { - return Err(io::Error::last_os_error()); - } - // SAFETY: `pipe` succeeded, so both fds are freshly owned by us. - let wake_r = unsafe { OwnedFd::from_raw_fd(fds[0]) }; - let wake_w = unsafe { OwnedFd::from_raw_fd(fds[1]) }; - // `pipe(2)` doesn't set close-on-exec, so mark both ends `FD_CLOEXEC` — - // otherwise they leak into any process this thread later forks+execs - // (e.g. another sandbox child). - set_cloexec(wake_r.as_raw_fd())?; - set_cloexec(wake_w.as_raw_fd())?; + let (wake_r, wake_w) = new_wake_pipe()?; set_nonblocking(wake_w.as_raw_fd())?; Ok(Self { @@ -134,7 +105,6 @@ impl InterruptibleReader { cancelled: AtomicBool::new(false), wake_w, }), - eof_probe_interval, }) } @@ -143,7 +113,6 @@ impl InterruptibleReader { pub fn canceller(&self) -> ReadCanceller { ReadCanceller(Arc::clone(&self.state)) } - pub fn try_clone_owned_fd(&self) -> io::Result { self.fd.as_fd().try_clone_to_owned() } @@ -194,13 +163,7 @@ impl Read for InterruptibleReader { ]; // SAFETY: `poll_fds` is a valid 2-element array of pollfds; both // fds are owned and live for the duration of the call. - let timeout = self - .eof_probe_interval - .map(|interval| { - interval.as_millis().clamp(1, libc::c_int::MAX as u128) as libc::c_int - }) - .unwrap_or(-1); - let rc = unsafe { libc::poll(poll_fds.as_mut_ptr(), 2, timeout) }; + let rc = unsafe { libc::poll(poll_fds.as_mut_ptr(), 2, -1) }; if rc < 0 { let err = io::Error::last_os_error(); if err.kind() == io::ErrorKind::Interrupted { @@ -208,13 +171,6 @@ impl Read for InterruptibleReader { } return Err(err); } - if rc == 0 { - match read_fd(self.fd.as_raw_fd(), buf) { - Ok(count) => return Ok(count), - Err(error) if error.kind() == io::ErrorKind::WouldBlock => continue, - Err(error) => return Err(error), - } - } // Cancellation wins over any pending data so a held-open pipe is // abandoned promptly. @@ -260,6 +216,37 @@ fn set_nonblocking(fd: RawFd) -> io::Result<()> { Ok(()) } +/// Create the wakeup self-pipe with both ends marked close-on-exec. +/// +/// Linux gets this atomically from `pipe2`. macOS has no such variant, so the +/// ends are inheritable between creation and the `fcntl` that marks them; a +/// spawn racing on another thread can capture them. The platform offers no +/// atomic alternative, and Rust's own `Stdio::piped()` has the same window on +/// macOS. +fn new_wake_pipe() -> io::Result<(OwnedFd, OwnedFd)> { + #[cfg(target_os = "linux")] + { + nix::unistd::pipe2(nix::fcntl::OFlag::O_CLOEXEC).map_err(io::Error::from) + } + #[cfg(not(target_os = "linux"))] + { + use std::os::fd::FromRawFd; + + let mut fds = [0 as RawFd; 2]; + // SAFETY: `fds` is a valid 2-element array for `pipe` to fill. + if unsafe { libc::pipe(fds.as_mut_ptr()) } < 0 { + return Err(io::Error::last_os_error()); + } + // SAFETY: `pipe` succeeded, so both fds are freshly owned by us. + let read = unsafe { OwnedFd::from_raw_fd(fds[0]) }; + // SAFETY: as above, for the write end. + let write = unsafe { OwnedFd::from_raw_fd(fds[1]) }; + set_cloexec(read.as_raw_fd())?; + set_cloexec(write.as_raw_fd())?; + Ok((read, write)) + } +} + /// Add `FD_CLOEXEC` to `fd`'s descriptor flags so it doesn't leak across `exec`. fn set_cloexec(fd: RawFd) -> io::Result<()> { // SAFETY: `fd` is a valid open fd; `fcntl` with these commands only reads @@ -278,6 +265,7 @@ fn set_cloexec(fd: RawFd) -> io::Result<()> { mod tests { use super::*; use std::io::Write; + use std::os::fd::FromRawFd; use std::time::{Duration, Instant}; /// Build an `InterruptibleReader` over a fresh pipe, returning it plus the @@ -304,35 +292,6 @@ mod tests { assert_eq!(reader.read(&mut buf).expect("read eof"), 0); } - #[test] - fn periodic_probe_detects_writer_close_after_data() { - let mut fds = [0 as RawFd; 2]; - assert!(unsafe { libc::pipe(fds.as_mut_ptr()) } == 0, "pipe"); - let read_end = unsafe { OwnedFd::from_raw_fd(fds[0]) }; - let write_end = unsafe { OwnedFd::from_raw_fd(fds[1]) }; - let mut reader = - InterruptibleReader::new_with_periodic_eof_probe(read_end, Duration::from_millis(10)) - .expect("wrap reader"); - let mut writer = std::fs::File::from(write_end); - writer.write_all(b"hello").expect("write"); - - let mut buf = [0u8; 16]; - let n = reader.read(&mut buf).expect("read data"); - assert_eq!(&buf[..n], b"hello"); - - let closer = std::thread::spawn(move || { - std::thread::sleep(Duration::from_millis(50)); - drop(writer); - }); - let started = Instant::now(); - assert_eq!(reader.read(&mut buf).expect("read eof"), 0); - assert!( - started.elapsed() < Duration::from_secs(5), - "periodic EOF probe did not observe writer close" - ); - closer.join().expect("writer closer"); - } - #[test] fn zero_length_read_returns_ok_zero_without_blocking() { // The write end stays open, so a normal read would block; a zero-length diff --git a/src/mxc-sdk/src/core/mxc_pty/mod.rs b/src/mxc-sdk/src/core/mxc_pty/mod.rs index b920fb793..29d7e536f 100644 --- a/src/mxc-sdk/src/core/mxc_pty/mod.rs +++ b/src/mxc-sdk/src/core/mxc_pty/mod.rs @@ -463,13 +463,7 @@ impl LivePty { let input_primary = self.primary.try_clone()?; let (input_read_fd, input_write_fd) = create_pipe()?; - #[cfg(target_os = "linux")] let input_reader = InterruptibleReader::new(input_read_fd)?; - #[cfg(target_os = "macos")] - let input_reader = InterruptibleReader::new_with_periodic_eof_probe( - input_read_fd, - std::time::Duration::from_millis(50), - )?; let input_canceller = input_reader.canceller(); let input_shutdown = BridgeShutdown::new()?; @@ -506,17 +500,26 @@ impl LivePty { } } let mut reader = output_reader; - let mut writer = File::from(output_write_fd); + let mut writer = Some(File::from(output_write_fd)); let mut buffer = [0_u8; 8192]; loop { let count = match reader.read(&mut buffer) { Ok(0) | Err(_) => return, Ok(count) => count, }; - if write_all_until_shutdown(&mut writer, &buffer[..count], &output_shutdown) + let Some(sink) = writer.as_mut() else { + continue; + }; + if write_all_until_shutdown(sink, &buffer[..count], &output_shutdown) .is_err() { - return; + if output_shutdown.is_cancelled() { + return; + } + // The consumer closed its end. Keep draining the + // terminal anyway: on Darwin the child cannot exit + // while its output sits in the primary's queue. + writer = None; } } } @@ -1111,90 +1114,19 @@ fn create_pipe() -> std::io::Result<(std::os::fd::OwnedFd, std::os::fd::OwnedFd) } #[cfg(target_os = "macos")] { - create_cloexec_fifo() - } -} - -#[cfg(target_os = "macos")] -fn create_cloexec_fifo() -> std::io::Result<(std::os::fd::OwnedFd, std::os::fd::OwnedFd)> { - use std::ffi::{CStr, CString}; - - struct FifoPath { - directory: CString, - fifo: CString, - created: bool, - } - - impl Drop for FifoPath { - fn drop(&mut self) { - if self.created { - // SAFETY: both paths remain valid NUL-terminated strings. - unsafe { - libc::unlink(self.fifo.as_ptr()); - libc::rmdir(self.directory.as_ptr()); - } - } - } - } - - let mut template = b"/tmp/mxc-pty-pipe.XXXXXX\0".to_vec(); - // SAFETY: `template` is writable and ends with six X bytes plus NUL as - // required by `mkdtemp`. - let directory = unsafe { libc::mkdtemp(template.as_mut_ptr().cast()) }; - if directory.is_null() { - return Err(std::io::Error::last_os_error()); - } - // SAFETY: successful `mkdtemp` returns a NUL-terminated pointer into - // `template`, which remains alive for this conversion. - let directory = unsafe { CStr::from_ptr(directory) }.to_owned(); - let mut fifo = directory.as_bytes().to_vec(); - fifo.extend_from_slice(b"/pipe"); - let fifo = CString::new(fifo).expect("temporary FIFO path contains no NUL"); - let mut path = FifoPath { - directory, - fifo, - created: true, - }; - - // SAFETY: `path.fifo` is a valid path in a private mode-0700 directory. - if unsafe { libc::mkfifo(path.fifo.as_ptr(), 0o600) } != 0 { - return Err(std::io::Error::last_os_error()); - } - let read = open_fifo_endpoint(&path.fifo, libc::O_RDONLY | libc::O_NONBLOCK)?; - let write = open_fifo_endpoint(&path.fifo, libc::O_WRONLY)?; - set_blocking(read.as_raw_fd())?; - - // SAFETY: open descriptors retain the FIFO after its directory entry is removed. - if unsafe { libc::unlink(path.fifo.as_ptr()) } != 0 { - return Err(std::io::Error::last_os_error()); - } - // SAFETY: the private directory is empty after unlinking the FIFO. - if unsafe { libc::rmdir(path.directory.as_ptr()) } != 0 { - return Err(std::io::Error::last_os_error()); - } - path.created = false; - Ok((read, write)) -} - -#[cfg(target_os = "macos")] -fn open_fifo_endpoint( - path: &std::ffi::CStr, - access: libc::c_int, -) -> std::io::Result { - use std::os::fd::FromRawFd; - - loop { - // SAFETY: `path` is a valid FIFO path and O_CLOEXEC is applied as part - // of descriptor creation. - let fd = unsafe { libc::open(path.as_ptr(), access | libc::O_CLOEXEC) }; - if fd >= 0 { - // SAFETY: `open` returned a new owned descriptor. - return Ok(unsafe { std::os::fd::OwnedFd::from_raw_fd(fd) }); - } - let error = std::io::Error::last_os_error(); - if error.kind() != std::io::ErrorKind::Interrupted { - return Err(error); - } + // macOS has no `pipe2`, and `O_CLOEXEC` applies only to `open`, so + // close-on-exec must be set in a second call. A `fork`+`exec` racing + // on another thread can inherit the ends in between and hold them + // open, delaying EOF. + // + // Accepted rather than fixed: Rust's own `Stdio::piped()` has the same + // window on macOS, so the descriptors carrying a workload's streams + // already have it, and spawns owned by the embedding application are + // outside this crate's control either way. + let (read, write) = nix::unistd::pipe().map_err(std::io::Error::from)?; + set_cloexec(read.as_raw_fd())?; + set_cloexec(write.as_raw_fd())?; + Ok((read, write)) } } @@ -1220,14 +1152,14 @@ fn set_nonblocking(fd: std::os::fd::RawFd) -> std::io::Result<()> { } #[cfg(target_os = "macos")] -fn set_blocking(fd: std::os::fd::RawFd) -> std::io::Result<()> { +fn set_cloexec(fd: std::os::fd::RawFd) -> std::io::Result<()> { // SAFETY: `fd` is a valid open descriptor; these commands only update its flags. - let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) }; + let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) }; if flags < 0 { return Err(std::io::Error::last_os_error()); } - // SAFETY: `fd` remains open and clearing O_NONBLOCK is valid for F_SETFL. - if unsafe { libc::fcntl(fd, libc::F_SETFL, flags & !libc::O_NONBLOCK) } < 0 { + // SAFETY: `fd` remains open and `flags | FD_CLOEXEC` is valid for F_SETFD. + if unsafe { libc::fcntl(fd, libc::F_SETFD, flags | libc::FD_CLOEXEC) } < 0 { return Err(std::io::Error::last_os_error()); } Ok(()) @@ -1571,6 +1503,154 @@ mod tests { #[cfg(any(target_os = "linux", target_os = "macos"))] static RUN_WITH_PTY_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + /// Reads a PTY-backed stream on a worker thread so the child is never + /// blocked by a full terminal output queue. + /// + /// Darwin blocks the final close of the secondary until the primary's + /// output queue drains, and `SIGKILL` does not release it. So a drain must + /// already be running before anything waits for the child. + #[cfg(any(target_os = "linux", target_os = "macos"))] + struct PtyDrain { + collected: Arc>>, + worker: Option>, + finished: std::sync::mpsc::Receiver<()>, + canceller: Option, + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + impl PtyDrain { + const TIMEOUT: Duration = Duration::from_secs(20); + /// How long [`Drop`] waits for an uncancellable worker before leaving + /// it running. Only reached while unwinding. + const DROP_GRACE: Duration = Duration::from_secs(2); + + /// Only for native-pipe readers, which have no canceller. Drains of a + /// PTY reader must use [`Self::start_cancellable`] so [`Drop`] can + /// unblock the worker. + fn start(source: Box) -> Self { + Self::start_inner(source, None) + } + + /// The canceller lets [`Drop`] unblock the worker, so a failing test + /// cannot leave it parked holding the primary. + fn start_cancellable(source: (Box, PtyReadCanceller)) -> Self { + Self::start_inner(source.0, Some(source.1)) + } + + fn start_inner( + mut source: Box, + canceller: Option, + ) -> Self { + let collected = Arc::new(Mutex::new(Vec::new())); + let sink = Arc::clone(&collected); + let (done, finished) = std::sync::mpsc::sync_channel(1); + let worker = std::thread::spawn(move || { + let mut buffer = [0u8; 8192]; + while let Ok(count) = source.read(&mut buffer) { + if count == 0 { + break; + } + sink.lock() + .unwrap_or_else(|e| e.into_inner()) + .extend_from_slice(&buffer[..count]); + } + let _ = done.send(()); + }); + Self { + collected, + worker: Some(worker), + finished, + canceller, + } + } + + /// Everything drained so far, as lossy UTF-8. + fn snapshot(&self) -> String { + let bytes = self.collected.lock().unwrap_or_else(|e| e.into_inner()); + String::from_utf8_lossy(&bytes).into_owned() + } + + /// Blocks until `marker` appears in the drained output. + fn wait_for(&self, marker: &str) -> String { + let deadline = std::time::Instant::now() + Self::TIMEOUT; + loop { + let seen = self.snapshot(); + if seen.contains(marker) { + return seen; + } + assert!( + std::time::Instant::now() < deadline, + "terminal output never contained {marker:?}; got: {seen:?}" + ); + std::thread::sleep(Duration::from_millis(10)); + } + } + + /// Cancels the read so a parked worker reaches end of input. + fn cancel(&self) { + if let Some(canceller) = &self.canceller { + canceller.close(); + } + } + + /// Waits for end of input and returns the full output. + fn finish(mut self) -> String { + self.finished + .recv_timeout(Self::TIMEOUT) + .unwrap_or_else(|_| panic!("terminal drain did not reach end of input")); + if let Some(worker) = self.worker.take() { + worker.join().expect("terminal drain thread"); + } + self.snapshot() + } + } + + /// Releases the worker rather than detaching it. A detached PTY worker + /// still owns the reader, so dropping `LivePty` would not close the final + /// primary and a failing test would hang instead of failing. + #[cfg(any(target_os = "linux", target_os = "macos"))] + impl Drop for PtyDrain { + fn drop(&mut self) { + let Some(worker) = self.worker.take() else { + return; + }; + if let Some(canceller) = &self.canceller { + // Cancelling unblocks the read, so the join cannot outlast it. + canceller.close(); + let _ = worker.join(); + return; + } + // A native-pipe drain has nothing to cancel: only the bridge closes + // the write end, and while unwinding it outlives this drop. Joining + // would hang, so wait briefly and otherwise leave the worker to + // finish on its own. It holds a pipe read end, not the primary, so + // dropping `LivePty` still closes the terminal. + if self.finished.recv_timeout(Self::DROP_GRACE).is_ok() { + let _ = worker.join(); + } + } + } + + /// Waits for `child` with a deadline instead of hanging the whole suite. + /// + /// Deliberately does not fall back to `kill`: a child stuck in the Darwin + /// terminal drain ignores `SIGKILL`. Unwinding drops `LivePty`, which + /// closes the primary and releases it. + #[cfg(any(target_os = "linux", target_os = "macos"))] + fn wait_for_exit(child: &mut std::process::Child) -> std::process::ExitStatus { + let deadline = std::time::Instant::now() + Duration::from_secs(20); + loop { + if let Some(status) = child.try_wait().expect("poll child") { + return status; + } + assert!( + std::time::Instant::now() < deadline, + "child did not exit within the test deadline" + ); + std::thread::sleep(Duration::from_millis(10)); + } + } + #[test] fn default_options() { let opts = PtyOptions::default(); @@ -1614,15 +1694,18 @@ mod tests { let mut child = command.spawn().expect("spawn child"); drop(command); - let mut reader = terminal.try_clone_reader().expect("clone reader"); + let drain = PtyDrain::start_cancellable( + terminal + .try_clone_reader_with_canceller() + .expect("clone reader"), + ); let mut writer = terminal.take_writer().expect("take writer"); writer.write_all(b"hello\r").expect("write input"); drop(writer); - let status = child.wait().expect("wait child"); - assert!(status.success()); - let mut output = String::new(); - reader.read_to_string(&mut output).expect("read output"); + assert!(wait_for_exit(&mut child).success()); + drop(terminal); + let output = drain.finish(); assert!(output.contains("40 120"), "got: {output:?}"); assert!(output.contains("reply:hello"), "got: {output:?}"); } @@ -1638,7 +1721,11 @@ mod tests { let terminal = LivePty::attach(&mut command, PtySize::default(), &[]).expect("attach PTY"); let mut child = command.spawn().expect("spawn child"); drop(command); - let mut reader = terminal.try_clone_reader().expect("clone reader"); + let drain = PtyDrain::start_cancellable( + terminal + .try_clone_reader_with_canceller() + .expect("clone reader"), + ); let mut writer = terminal.take_writer().expect("take writer"); writer @@ -1646,25 +1733,165 @@ mod tests { .expect("write partial line"); drop(writer); - let deadline = std::time::Instant::now() + Duration::from_secs(5); - loop { - if let Some(status) = child.try_wait().expect("poll child") { - assert!(status.success()); - break; - } - if std::time::Instant::now() >= deadline { - let _ = child.kill(); - let _ = child.wait(); - panic!("child did not observe canonical terminal EOF"); - } - std::thread::sleep(Duration::from_millis(10)); - } - - let mut output = String::new(); - reader.read_to_string(&mut output).expect("read output"); + assert!(wait_for_exit(&mut child).success()); + drop(terminal); + let output = drain.finish(); assert!(output.contains("terminal-eof-observed"), "got: {output:?}"); } + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn close_writer_sends_canonical_terminal_eof() { + let _guard = RUN_WITH_PTY_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let mut command = Command::new("/bin/sh"); + command + .arg("-c") + .arg("cat >/dev/null; printf 'closed-writer-eof\\n'"); + let terminal = LivePty::attach(&mut command, PtySize::default(), &[]).expect("attach PTY"); + let mut child = command.spawn().expect("spawn child"); + drop(command); + let drain = PtyDrain::start_cancellable( + terminal + .try_clone_reader_with_canceller() + .expect("clone reader"), + ); + + terminal.close_writer(); + // Closing again is a no-op, and the writer can no longer be taken. + terminal.close_writer(); + assert_eq!( + terminal + .take_writer() + .err() + .expect("writer was already closed") + .kind(), + std::io::ErrorKind::BrokenPipe + ); + + assert!(wait_for_exit(&mut child).success()); + drop(terminal); + let output = drain.finish(); + assert!(output.contains("closed-writer-eof"), "got: {output:?}"); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn close_writer_after_take_writer_leaves_the_taken_writer_usable() { + let _guard = RUN_WITH_PTY_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let mut command = Command::new("/bin/sh"); + command + .arg("-c") + .arg("read value; printf 'reply:%s\\n' \"$value\""); + let terminal = LivePty::attach(&mut command, PtySize::default(), &[]).expect("attach PTY"); + let mut child = command.spawn().expect("spawn child"); + drop(command); + let drain = PtyDrain::start_cancellable( + terminal + .try_clone_reader_with_canceller() + .expect("clone reader"), + ); + + let mut writer = terminal.take_writer().expect("take writer"); + terminal.close_writer(); + writer.write_all(b"still-writable\r").expect("write input"); + drop(writer); + + assert!(wait_for_exit(&mut child).success()); + drop(terminal); + let output = drain.finish(); + assert!(output.contains("reply:still-writable"), "got: {output:?}"); + } + + /// The drain-before-wait pattern every backend uses: nothing claimed a + /// reader, so the runner claims one and discards output while it waits. + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn take_unclaimed_reader_drains_output_so_waiting_cannot_deadlock() { + let _guard = RUN_WITH_PTY_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let mut command = Command::new("/bin/sh"); + command + .arg("-c") + .arg("head -c 200000 /dev/zero; printf 'drained-to-exit\\n'"); + let terminal = LivePty::attach(&mut command, PtySize::default(), &[]).expect("attach PTY"); + let mut child = command.spawn().expect("spawn child"); + drop(command); + + terminal.close_writer(); + let drain = PtyDrain::start_cancellable( + terminal + .take_unclaimed_reader() + .expect("take unclaimed reader") + .expect("reader was unclaimed"), + ); + assert!( + terminal + .take_unclaimed_reader() + .expect("second take") + .is_none(), + "the reader is claimed after the first take" + ); + + assert!(wait_for_exit(&mut child).success()); + let seen = drain.wait_for("drained-to-exit"); + assert!(seen.contains("drained-to-exit"), "got: {seen:?}"); + drain.cancel(); + drop(terminal); + let _ = drain.finish(); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn take_unclaimed_reader_yields_nothing_once_a_reader_is_claimed() { + let _guard = RUN_WITH_PTY_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let mut command = Command::new("/bin/sh"); + command.arg("-c").arg("printf claimed"); + let terminal = LivePty::attach(&mut command, PtySize::default(), &[]).expect("attach PTY"); + let mut child = command.spawn().expect("spawn child"); + drop(command); + let drain = PtyDrain::start_cancellable( + terminal + .try_clone_reader_with_canceller() + .expect("clone reader"), + ); + + assert!( + terminal + .take_unclaimed_reader() + .expect("take unclaimed reader") + .is_none(), + "a cloned reader belongs to the caller" + ); + + assert!(wait_for_exit(&mut child).success()); + drop(terminal); + let output = drain.finish(); + assert!(output.contains("claimed"), "got: {output:?}"); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn take_unclaimed_reader_yields_nothing_after_native_stdio() { + let _guard = RUN_WITH_PTY_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let mut command = Command::new("/bin/sh"); + command.arg("-c").arg("printf bridged"); + let terminal = LivePty::attach(&mut command, PtySize::default(), &[]).expect("attach PTY"); + let mut child = command.spawn().expect("spawn child"); + drop(command); + let stdio = terminal.take_native_stdio().expect("take native stdio"); + + assert!( + terminal + .take_unclaimed_reader() + .expect("take unclaimed reader") + .is_none(), + "the native bridge owns terminal output" + ); + + assert!(wait_for_exit(&mut child).success()); + terminal.finish_native_bridge(); + drop(stdio); + } + #[cfg(any(target_os = "linux", target_os = "macos"))] #[test] fn dropping_untouched_writer_does_not_wait_for_an_input_reader() { @@ -1674,7 +1901,11 @@ mod tests { let terminal = LivePty::attach(&mut command, PtySize::default(), &[]).expect("attach PTY"); let mut child = command.spawn().expect("spawn child"); drop(command); - let _reader = terminal.try_clone_reader().expect("clone reader"); + let drain = PtyDrain::start_cancellable( + terminal + .try_clone_reader_with_canceller() + .expect("clone reader"), + ); let started = std::time::Instant::now(); drop(terminal.take_writer().expect("take writer")); @@ -1685,6 +1916,8 @@ mod tests { let _ = child.kill(); let _ = child.wait(); + drop(terminal); + let _ = drain.finish(); } #[cfg(any(target_os = "linux", target_os = "macos"))] @@ -1696,6 +1929,11 @@ mod tests { let terminal = LivePty::attach(&mut command, PtySize::default(), &[]).expect("attach PTY"); let mut child = command.spawn().expect("spawn child"); drop(command); + let drain = PtyDrain::start_cancellable( + terminal + .try_clone_reader_with_canceller() + .expect("clone reader"), + ); let mut fill = terminal .access @@ -1721,7 +1959,13 @@ mod tests { Err(error) => panic!("fill PTY input queue: {error}"), } } + // Linux reports `EWOULDBLOCK` once the terminal input queue is full; + // Darwin's `ttyinput` silently discards the excess instead. Either way + // the queue is saturated, which is what the drop path must survive. + #[cfg(target_os = "linux")] assert!(queue_full, "PTY input queue did not reach backpressure"); + #[cfg(target_os = "macos")] + let _ = queue_full; drop(fill); let started = std::time::Instant::now(); @@ -1733,6 +1977,8 @@ mod tests { let _ = child.kill(); let _ = child.wait(); + drop(terminal); + let _ = drain.finish(); } #[cfg(any(target_os = "linux", target_os = "macos"))] @@ -1746,45 +1992,32 @@ mod tests { let mut child = command.spawn().expect("spawn child"); drop(command); - let mut output: Box = if native { + let (mut writer, drain): (Box, PtyDrain) = if native { let stdio = terminal.take_native_stdio().expect("take native stdio"); - let mut output = std::fs::File::from(stdio.stdout); - let mut ready = [0_u8; 5]; - output.read_exact(&mut ready).expect("read readiness"); - assert_eq!(&ready, b"ready"); - let mut writer = std::fs::File::from(stdio.stdin); - writer.write_all(input).expect("write terminal input"); - drop(writer); - Box::new(output) + ( + Box::new(std::fs::File::from(stdio.stdin)), + PtyDrain::start(Box::new(std::fs::File::from(stdio.stdout))), + ) } else { - let mut output = terminal.try_clone_reader().expect("clone reader"); - let mut ready = [0_u8; 5]; - output.read_exact(&mut ready).expect("read readiness"); - assert_eq!(&ready, b"ready"); - let mut writer = terminal.take_writer().expect("take writer"); - writer.write_all(input).expect("write terminal input"); - drop(writer); - output + ( + Box::new(terminal.take_writer().expect("take writer")), + PtyDrain::start_cancellable( + terminal + .try_clone_reader_with_canceller() + .expect("clone reader"), + ), + ) }; - let deadline = std::time::Instant::now() + Duration::from_secs(5); - loop { - if let Some(status) = child.try_wait().expect("poll child") { - assert!(status.success()); - break; - } - if std::time::Instant::now() >= deadline { - let _ = child.kill(); - let _ = child.wait(); - panic!("child did not observe canonical terminal EOF"); - } - std::thread::sleep(Duration::from_millis(10)); - } + drain.wait_for("ready"); + writer.write_all(input).expect("write terminal input"); + drop(writer); + + assert!(wait_for_exit(&mut child).success()); terminal.finish_native_bridge(); drop(terminal); - let mut text = String::new(); - output.read_to_string(&mut text).expect("read output"); + let text = drain.finish(); assert!(text.contains("terminal-eof-observed"), "got: {text:?}"); } @@ -1851,29 +2084,37 @@ mod tests { let mut child = command.spawn().expect("spawn child"); drop(command); - if native { + let (writer, drain): (Option>, PtyDrain) = if native { let stdio = terminal.take_native_stdio().expect("take native stdio"); - let mut output = std::fs::File::from(stdio.stdout); - let mut ready = [0_u8; 5]; - output.read_exact(&mut ready).expect("read readiness"); - assert_eq!(&ready, b"ready"); - drop(stdio.stdin); - std::thread::sleep(Duration::from_millis(200)); - assert!(child.try_wait().expect("poll child").is_none()); - let _ = child.kill(); - let _ = child.wait(); - terminal.finish_native_bridge(); + ( + Some(Box::new(std::fs::File::from(stdio.stdin))), + PtyDrain::start(Box::new(std::fs::File::from(stdio.stdout))), + ) } else { - let mut output = terminal.try_clone_reader().expect("clone reader"); - let mut ready = [0_u8; 5]; - output.read_exact(&mut ready).expect("read readiness"); - assert_eq!(&ready, b"ready"); - drop(terminal.take_writer().expect("take writer")); - std::thread::sleep(Duration::from_millis(200)); - assert!(child.try_wait().expect("poll child").is_none()); - let _ = child.kill(); - let _ = child.wait(); + ( + None, + PtyDrain::start_cancellable( + terminal + .try_clone_reader_with_canceller() + .expect("clone reader"), + ), + ) + }; + + drain.wait_for("ready"); + match writer { + Some(writer) => drop(writer), + None => drop(terminal.take_writer().expect("take writer")), } + + std::thread::sleep(Duration::from_millis(200)); + assert!(child.try_wait().expect("poll child").is_none()); + let _ = child.kill(); + let _ = child.wait(); + terminal.finish_native_bridge(); + drop(terminal); + let text = drain.finish(); + assert!(!text.contains("injected"), "got: {text:?}"); } #[cfg(any(target_os = "linux", target_os = "macos"))] @@ -1923,14 +2164,20 @@ mod tests { let mut child = command.spawn().expect("spawn child"); drop(command); - let _reader = terminal.try_clone_reader().expect("clone reader"); + let drain = PtyDrain::start_cancellable( + terminal + .try_clone_reader_with_canceller() + .expect("clone reader"), + ); let mut writer = terminal.take_writer().expect("take writer"); let mut input = b"x\n".repeat(128 * 1024); input.extend_from_slice(b"partial-line"); writer.write_all(&input).expect("write large partial input"); drop(writer); - assert!(child.wait().expect("wait child").success()); + assert!(wait_for_exit(&mut child).success()); + drop(terminal); + let _ = drain.finish(); } #[cfg(any(target_os = "linux", target_os = "macos"))] @@ -1944,20 +2191,21 @@ mod tests { let terminal = LivePty::attach(&mut command, PtySize::default(), &[]).expect("attach PTY"); let mut child = command.spawn().expect("spawn child"); drop(command); - let (mut reader, closer) = terminal - .try_clone_reader_with_canceller() - .expect("clone reader"); - - assert!(child.wait().expect("wait child").success()); - let reader_thread = std::thread::spawn(move || { - let mut output = String::new(); - reader.read_to_string(&mut output).expect("read output"); - output - }); - std::thread::sleep(Duration::from_millis(50)); - closer.close(); - let output = reader_thread.join().expect("reader thread"); + // The drain must already be running: on Darwin the foreground shell + // cannot exit while its output sits in the terminal queue. + let drain = PtyDrain::start_cancellable( + terminal + .try_clone_reader_with_canceller() + .expect("clone reader"), + ); + assert!(wait_for_exit(&mut child).success()); + drain.wait_for("foreground-exited"); + + // The backgrounded `sleep` still holds the secondary open, so the read + // parks until it is cancelled. + drain.cancel(); + let output = drain.finish(); assert!(output.contains("foreground-exited"), "got: {output:?}"); } @@ -1983,7 +2231,7 @@ mod tests { input.write_all(b"hello").expect("write partial input"); drop(input); - assert!(child.wait().expect("wait child").success()); + assert!(wait_for_exit(&mut child).success()); terminal.finish_native_bridge(); let mut text = String::new(); output.read_to_string(&mut text).expect("read output"); @@ -2012,7 +2260,7 @@ mod tests { stdio.stdout }); - assert!(child.wait().expect("wait child").success()); + assert!(wait_for_exit(&mut child).success()); terminal.finish_native_bridge(); let stdout = input_thread.join().expect("input thread"); let mut output = std::fs::File::from(stdout); @@ -2029,17 +2277,22 @@ mod tests { command.arg("-c").arg("(sleep 30) & printf 'done\\n'"); let terminal = LivePty::attach(&mut command, PtySize::default(), &[]).expect("attach PTY"); let mut child = command.spawn().expect("spawn child"); + let session = child.id() as libc::pid_t; drop(command); let stdio = terminal.take_native_stdio().expect("take native stdio"); drop(stdio.stdin); - assert!(child.wait().expect("wait child").success()); + assert!(wait_for_exit(&mut child).success()); let start = std::time::Instant::now(); terminal.finish_native_bridge(); assert!( start.elapsed() < Duration::from_secs(5), "native bridge did not cancel promptly" ); + // SAFETY: the child created its own session with its PID as the process-group ID. + unsafe { + libc::kill(-session, libc::SIGKILL); + } let mut output = std::fs::File::from(stdio.stdout); let mut text = String::new(); @@ -2047,14 +2300,62 @@ mod tests { assert!(text.contains("done"), "got: {text:?}"); } + /// Reads whatever is already buffered on `fd` without blocking. + #[cfg(any(target_os = "linux", target_os = "macos"))] + fn drain_available(fd: std::os::fd::RawFd) -> String { + // SAFETY: `fd` is owned by the caller and stays open across the call. + let previous = unsafe { libc::fcntl(fd, libc::F_GETFL) }; + if previous < 0 { + return String::new(); + } + // SAFETY: as above. + unsafe { libc::fcntl(fd, libc::F_SETFL, previous | libc::O_NONBLOCK) }; + let mut collected = Vec::new(); + let mut buffer = [0_u8; 4096]; + loop { + // SAFETY: `buffer` is a valid writable slice for the length passed. + let count = unsafe { libc::read(fd, buffer.as_mut_ptr().cast(), buffer.len()) }; + if count <= 0 { + break; + } + collected.extend_from_slice(&buffer[..count as usize]); + } + // SAFETY: restores the descriptor flags sampled above. + unsafe { libc::fcntl(fd, libc::F_SETFL, previous) }; + String::from_utf8_lossy(&collected).into_owned() + } + + /// Collects output until `marker` arrives. + #[cfg(any(target_os = "linux", target_os = "macos"))] + fn wait_for_marker(fd: std::os::fd::RawFd, marker: &str) -> String { + const MARKER_TIMEOUT: Duration = Duration::from_secs(10); + const MARKER_POLL: Duration = Duration::from_millis(10); + + let deadline = std::time::Instant::now() + MARKER_TIMEOUT; + let mut collected = String::new(); + while std::time::Instant::now() < deadline { + collected.push_str(&drain_available(fd)); + if collected.contains(marker) { + return collected; + } + std::thread::sleep(MARKER_POLL); + } + panic!("timed out waiting for {marker:?}; got: {collected:?}"); + } + #[cfg(any(target_os = "linux", target_os = "macos"))] #[test] fn native_bridge_cancellation_does_not_inject_eof() { let _guard = RUN_WITH_PTY_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let mut command = Command::new("/bin/sh"); + // The reader has to stay in the foreground process group. A + // backgrounded reader is orphaned once the parent shell exits, and + // reading a controlling terminal from an orphaned group fails with + // EIO, which looks exactly like the injected end-of-input this test + // is meant to catch. command .arg("-c") - .arg("(read value; printf eof-injected) & printf 'done\\n'"); + .arg("printf 'done\\n'; read value; printf 'eof-injected'"); let terminal = LivePty::attach(&mut command, PtySize::default(), &[]).expect("attach PTY"); let mut child = command.spawn().expect("spawn child"); let session = child.id() as libc::pid_t; @@ -2062,17 +2363,49 @@ mod tests { let stdio = terminal.take_native_stdio().expect("take native stdio"); let input = stdio.stdin; - assert!(child.wait().expect("wait child").success()); + // The shell is now blocked in `read`, so any end-of-input it observes + // has to come from the bridge. + let mut text = wait_for_marker(stdio.stdout.as_raw_fd(), "done"); + terminal.finish_native_bridge(); drop(input); + + // The bridge has stopped copying, so watch the primary directly and + // keep draining it. An undrained terminal would wedge the shell mid + // exit, which looks exactly like it never left its `read`. + let watch = std::time::Instant::now() + Duration::from_millis(500); + while std::time::Instant::now() < watch { + text.push_str(&drain_available(stdio.stdout.as_raw_fd())); + text.push_str(&drain_available(terminal.primary.as_raw_fd())); + assert!(!text.contains("eof-injected"), "got: {text:?}"); + assert!( + child.try_wait().expect("poll child").is_none(), + "cancellation ended the shell's `read`; got: {text:?}" + ); + std::thread::sleep(Duration::from_millis(10)); + } + // SAFETY: the child created its own session with its PID as the process-group ID. unsafe { libc::kill(-session, libc::SIGKILL); } - let mut output = std::fs::File::from(stdio.stdout); - let mut text = String::new(); - output.read_to_string(&mut text).expect("read output"); + // Reap on a deadline, still draining: a Darwin child stuck writing to a + // full terminal does not die until the queue drains. + let deadline = std::time::Instant::now() + Duration::from_secs(10); + loop { + text.push_str(&drain_available(stdio.stdout.as_raw_fd())); + text.push_str(&drain_available(terminal.primary.as_raw_fd())); + if child.try_wait().expect("poll child").is_some() { + break; + } + assert!( + std::time::Instant::now() < deadline, + "the shell did not exit after SIGKILL; got: {text:?}" + ); + std::thread::sleep(Duration::from_millis(10)); + } + assert!(text.contains("done"), "got: {text:?}"); assert!(!text.contains("eof-injected"), "got: {text:?}"); } @@ -2097,7 +2430,7 @@ mod tests { output.read_to_end(&mut bytes).expect("read output"); bytes }); - assert!(child.wait().expect("wait child").success()); + assert!(wait_for_exit(&mut child).success()); terminal.finish_native_bridge(); let output = output_thread.join().expect("output thread"); @@ -2125,7 +2458,7 @@ mod tests { let buffer = vec![b'x'; 1024 * 1024]; let _ = input.write_all(&buffer); }); - assert!(child.wait().expect("wait child").success()); + assert!(wait_for_exit(&mut child).success()); let start = std::time::Instant::now(); terminal.finish_native_bridge(); @@ -2149,7 +2482,32 @@ mod tests { drop(stdio.stdin); drop(stdio.stdout); - assert!(child.wait().expect("wait child").success()); + assert!(wait_for_exit(&mut child).success()); + terminal.finish_native_bridge(); + } + + /// The closed-consumer case above drains the child's whole output in one + /// read, so it only catches a bridge that stops draining because Darwin + /// blocks the secondary's close. Gate a bulk write behind the close so the + /// child also blocks on a full terminal queue, which catches it anywhere. + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn native_bridge_keeps_draining_after_the_output_consumer_closes() { + let _guard = RUN_WITH_PTY_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let mut command = Command::new("/bin/sh"); + command.arg("-c").arg("read go; head -c 1000000 /dev/zero"); + let terminal = LivePty::attach(&mut command, PtySize::default(), &[]).expect("attach PTY"); + let mut child = command.spawn().expect("spawn child"); + drop(command); + let stdio = terminal.take_native_stdio().expect("take native stdio"); + + // Close the consumer first so the echoed input already triggers EPIPE. + drop(stdio.stdout); + let mut input = std::fs::File::from(stdio.stdin); + input.write_all(b"go\n").expect("write input"); + drop(input); + + assert!(wait_for_exit(&mut child).success()); terminal.finish_native_bridge(); } @@ -2198,6 +2556,282 @@ mod tests { drop(stdio.stdout); } + /// macOS has no `F_SETPIPE_SZ`, so fill the output pipe by volume instead. + #[cfg(target_os = "macos")] + #[test] + fn native_bridge_cancels_when_the_output_pipe_fills() { + let _guard = RUN_WITH_PTY_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let mut command = Command::new("/bin/sh"); + command.arg("-c").arg("head -c 1000000 /dev/zero"); + let terminal = LivePty::attach(&mut command, PtySize::default(), &[]).expect("attach PTY"); + let mut child = command.spawn().expect("spawn child"); + drop(command); + let stdio = terminal.take_native_stdio().expect("take native stdio"); + drop(stdio.stdin); + + // Never read stdout: the pipe fills, the bridge parks in its + // cancellable write, and the child blocks on the terminal behind it. + std::thread::sleep(Duration::from_millis(250)); + assert!( + child.try_wait().expect("poll child").is_none(), + "child should still be blocked behind the full output pipe" + ); + + // Run the join off-thread so a bridge that ignores cancellation fails + // the test instead of hanging it. + let (done, finished) = std::sync::mpsc::sync_channel(1); + std::thread::scope(|scope| { + scope.spawn(|| { + terminal.finish_native_bridge(); + let _ = done.send(()); + }); + let cancelled = finished.recv_timeout(Duration::from_secs(5)).is_ok(); + if !cancelled { + // Drain so the parked bridge can finish and the scope can join. + let mut sink = std::fs::File::from(stdio.stdout.try_clone().expect("clone stdout")); + let mut scratch = [0u8; 8192]; + while sink.read(&mut scratch).unwrap_or(0) > 0 {} + } + assert!(cancelled, "full output bridge did not cancel promptly"); + }); + + drop(stdio.stdout); + drop(terminal); + let _ = wait_for_exit(&mut child); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn size_reports_the_last_applied_dimensions() { + let mut command = Command::new("/bin/sh"); + command.arg("-c").arg("true"); + let initial = PtySize { + rows: 24, + cols: 80, + pixel_width: 0, + pixel_height: 0, + }; + let terminal = LivePty::attach(&mut command, initial, &[]).expect("attach PTY"); + assert_eq!(terminal.size(), initial); + + let resized = PtySize { + rows: 40, + cols: 120, + pixel_width: 0, + pixel_height: 0, + }; + terminal.resize(resized).expect("resize PTY"); + assert_eq!(terminal.size(), resized); + + // A rejected resize must not disturb the recorded size. + assert_eq!( + terminal + .resize(PtySize { + rows: 0, + cols: 0, + pixel_width: 0, + pixel_height: 0, + }) + .expect_err("zero dimensions are rejected") + .kind(), + std::io::ErrorKind::InvalidInput + ); + assert_eq!(terminal.size(), resized); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn is_canonical_mode_follows_the_terminal_discipline() { + let _guard = RUN_WITH_PTY_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let mut command = Command::new("/bin/sh"); + command + .arg("-c") + .arg("printf 'ready\\n'; read go; stty -icanon; printf 'canonical-off\\n'; read done"); + let terminal = LivePty::attach(&mut command, PtySize::default(), &[]).expect("attach PTY"); + let mut child = command.spawn().expect("spawn child"); + drop(command); + let drain = PtyDrain::start_cancellable( + terminal + .try_clone_reader_with_canceller() + .expect("clone reader"), + ); + let mut writer = terminal.take_writer().expect("take writer"); + + drain.wait_for("ready"); + assert!(terminal.is_canonical_mode().expect("read discipline")); + + writer.write_all(b"go\r").expect("write input"); + drain.wait_for("canonical-off"); + assert!(!terminal.is_canonical_mode().expect("read discipline")); + + drop(writer); + let _ = child.kill(); + let _ = wait_for_exit(&mut child); + drop(terminal); + let _ = drain.finish(); + } + + /// `resize` must notify the foreground process group, not just set the + /// kernel's window size. + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn resize_signals_the_foreground_process_group() { + let _guard = RUN_WITH_PTY_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let mut command = Command::new("/bin/sh"); + command.arg("-c").arg( + "trap 'stty size; exit 0' WINCH; printf 'ready\\n'; \ + i=0; while [ $i -lt 200 ]; do sleep 0.1; i=$((i+1)); done", + ); + let terminal = LivePty::attach( + &mut command, + PtySize { + rows: 24, + cols: 80, + pixel_width: 0, + pixel_height: 0, + }, + &[], + ) + .expect("attach PTY"); + let mut child = command.spawn().expect("spawn child"); + drop(command); + let drain = PtyDrain::start_cancellable( + terminal + .try_clone_reader_with_canceller() + .expect("clone reader"), + ); + + drain.wait_for("ready"); + terminal + .resize(PtySize { + rows: 40, + cols: 120, + pixel_width: 0, + pixel_height: 0, + }) + .expect("resize PTY"); + + let seen = drain.wait_for("40 120"); + assert!(seen.contains("40 120"), "got: {seen:?}"); + assert!(wait_for_exit(&mut child).success()); + drop(terminal); + let _ = drain.finish(); + } + + /// A forwarding process switches the terminal to raw mode, so the live + /// discipline no longer reports an EOF character. `attach_forwarded` + /// captures it up front and still delivers it. + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn forwarded_eof_is_delivered_through_a_raw_mode_terminal() { + let _guard = RUN_WITH_PTY_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let mut command = Command::new("/bin/sh"); + command.arg("-c").arg( + "stty raw -echo; printf 'ready\\r\\n'; \ + head -c 1 | od -An -tx1 | tr -d ' \\n'; printf '\\r\\n'", + ); + let terminal = LivePty::attach_forwarded(&mut command, PtySize::default(), &[]) + .expect("attach forwarded PTY"); + let mut child = command.spawn().expect("spawn child"); + drop(command); + let drain = PtyDrain::start_cancellable( + terminal + .try_clone_reader_with_canceller() + .expect("clone reader"), + ); + + drain.wait_for("ready"); + drop(terminal.take_writer().expect("take writer")); + + assert!(wait_for_exit(&mut child).success()); + drop(terminal); + let output = drain.finish(); + assert!(output.contains("04"), "expected ^D byte; got: {output:?}"); + } + + /// The contrast to the test above: the default discipline reads the live + /// termios, which reports no EOF character once the terminal is raw. + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn current_canonical_eof_is_skipped_for_a_raw_mode_terminal() { + let _guard = RUN_WITH_PTY_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let mut command = Command::new("/bin/sh"); + command + .arg("-c") + .arg("stty raw -echo; printf 'ready\\r\\n'; head -c 1 >/dev/null"); + let terminal = LivePty::attach(&mut command, PtySize::default(), &[]).expect("attach PTY"); + let mut child = command.spawn().expect("spawn child"); + drop(command); + let drain = PtyDrain::start_cancellable( + terminal + .try_clone_reader_with_canceller() + .expect("clone reader"), + ); + + drain.wait_for("ready"); + drop(terminal.take_writer().expect("take writer")); + + std::thread::sleep(Duration::from_secs(1)); + assert!( + child.try_wait().expect("poll child").is_none(), + "no EOF byte should reach a raw-mode terminal" + ); + + let _ = child.kill(); + let _ = wait_for_exit(&mut child); + drop(terminal); + let _ = drain.finish(); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn raw_secondary_guard_sets_raw_mode_and_restores_on_drop() { + use nix::sys::termios::{self, LocalFlags}; + + let (primary, secondary) = open_pty(None).expect("open PTY pair"); + let before = termios::tcgetattr(&secondary).expect("read termios"); + assert!(before.local_flags.contains(LocalFlags::ICANON)); + + { + let _raw = RawSecondaryGuard::install(secondary.as_raw_fd()).expect("install raw mode"); + let during = termios::tcgetattr(&secondary).expect("read termios"); + assert!(!during.local_flags.contains(LocalFlags::ICANON)); + assert!(!during.local_flags.contains(LocalFlags::ECHO)); + } + + let after = termios::tcgetattr(&secondary).expect("read termios"); + // PENDIN is kernel-managed status, not settable state. + let settable = |flags: LocalFlags| flags - LocalFlags::PENDIN; + assert_eq!(settable(after.local_flags), settable(before.local_flags)); + assert_eq!(after.input_flags, before.input_flags); + assert_eq!(after.output_flags, before.output_flags); + drop(primary); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn raw_secondary_guard_declines_a_non_tty() { + let (read, write) = create_pipe().expect("create pipe"); + assert!(RawSecondaryGuard::install(read.as_raw_fd()).is_none()); + drop((read, write)); + } + + /// A failed `exec` must still surface as a spawn error after `attach` + /// has rewired stdio onto the terminal. + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn attach_still_reports_a_failed_exec() { + let _guard = RUN_WITH_PTY_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + + let mut command = Command::new("/nonexistent/mxc-pty-missing-binary"); + let terminal = LivePty::attach(&mut command, PtySize::default(), &[]).expect("attach PTY"); + let error = command + .spawn() + .expect_err("missing binary must fail to spawn"); + assert_eq!(error.kind(), std::io::ErrorKind::NotFound); + drop(terminal); + } + #[cfg(any(target_os = "linux", target_os = "macos"))] #[test] fn echo_runs_under_pty() {