#![cfg(unix)] #[path = "common/registry.rs"] mod fixture; use std::fs::File; use std::io::{Read, Write}; use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; use std::os::unix::process::CommandExt; use std::process::{Child, Command, Stdio}; use std::sync::Mutex; use std::sync::atomic::Ordering; use std::thread; use std::time::{Duration, Instant}; use fixture::Registry; const DEADLINE: Duration = Duration::from_secs(10); struct Pty { child: Child, master: File, bytes: Vec, cursor_scan: usize, eof: bool, } impl Pty { fn spawn(args: &[String], directory: &std::path::Path) -> Self { let winsize = libc::winsize { ws_row: 24, ws_col: 80, ws_xpixel: 0, ws_ypixel: 0, }; let (mut master, mut slave) = (-1, -1); assert_eq!( unsafe { libc::openpty( &mut master, &mut slave, std::ptr::null_mut(), std::ptr::null(), &winsize, ) }, 0 ); let master = unsafe { OwnedFd::from_raw_fd(master) }; let slave = unsafe { OwnedFd::from_raw_fd(slave) }; for fd in [&master, &slave] { let flags = unsafe { libc::fcntl(fd.as_raw_fd(), libc::F_GETFD) }; assert!(flags >= 0); assert_eq!( unsafe { libc::fcntl(fd.as_raw_fd(), libc::F_SETFD, flags | libc::FD_CLOEXEC) }, 0 ); } let mut command = Command::new(env!("CARGO_BIN_EXE_avril")); command .args(args) .current_dir(directory) .env("TERM", "xterm") .env("NO_COLOR", "1") .stdin(Stdio::from(slave.try_clone().unwrap())) .stdout(Stdio::from(slave.try_clone().unwrap())) .stderr(Stdio::from(slave.try_clone().unwrap())); unsafe { command.pre_exec(|| { if libc::setsid() < 0 || libc::ioctl(libc::STDIN_FILENO, libc::TIOCSCTTY, 0) < 0 { return Err(std::io::Error::last_os_error()); } libc::signal(libc::SIGINT, libc::SIG_DFL); libc::signal(libc::SIGTERM, libc::SIG_DFL); Ok(()) }); } let child = command.spawn().unwrap(); drop(slave); Self { child, master: File::from(master), bytes: Vec::new(), cursor_scan: 0, eof: false, } } fn write(&mut self, bytes: &[u8]) { self.master.write_all(bytes).unwrap(); } fn read_until(&mut self, needle: &[u8]) -> usize { self.read_until_from(0, needle) } fn read_until_from(&mut self, start: usize, needle: &[u8]) -> usize { let deadline = Instant::now() + DEADLINE; loop { if let Some(position) = self.bytes[start..] .windows(needle.len()) .position(|w| w == needle) { return start + position + needle.len(); } assert!( Instant::now() < deadline, "timed out waiting for {:?}; got {:?}", String::from_utf8_lossy(needle), String::from_utf8_lossy(&self.bytes) ); assert!( self.read_more(deadline), "PTY closed before {:?}: {}", String::from_utf8_lossy(needle), String::from_utf8_lossy(&self.bytes) ); } } fn read_more(&mut self, deadline: Instant) -> bool { if self.eof { return false; } let mut poll = libc::pollfd { fd: self.master.as_raw_fd(), events: libc::POLLIN, revents: 0, }; let timeout = deadline .saturating_duration_since(Instant::now()) .as_millis() .min(i32::MAX as u128) as i32; let ready = unsafe { libc::poll(&mut poll, 1, timeout) }; if ready < 0 && std::io::Error::last_os_error().kind() == std::io::ErrorKind::Interrupted { return true; } assert!( ready >= 0, "PTY poll failed: {}", std::io::Error::last_os_error() ); if ready == 0 { return false; } let mut buffer = [0u8; 4096]; match self.master.read(&mut buffer) { Ok(0) => self.eof = true, Ok(count) => { self.bytes.extend_from_slice(&buffer[..count]); self.answer_cursor_queries(); return true; } Err(error) if error.raw_os_error() == Some(libc::EIO) => self.eof = true, Err(error) if error.kind() == std::io::ErrorKind::Interrupted => return true, Err(error) => panic!("PTY read failed: {error}"), } false } fn answer_cursor_queries(&mut self) { while let Some(position) = self.bytes[self.cursor_scan..] .windows(4) .position(|w| w == b"\x1b[6n") { self.cursor_scan += position + 4; self.write(b"\x1b[1;1R"); } } fn wait_exit(&mut self) { let deadline = Instant::now() + DEADLINE; loop { if let Some(status) = self.child.try_wait().unwrap() { while self.read_more(Instant::now()) {} assert!( status.success(), "child exited {status}: {}", String::from_utf8_lossy(&self.bytes) ); return; } assert!(Instant::now() < deadline, "child did not exit"); self.read_more(deadline); thread::yield_now(); } } } impl Drop for Pty { fn drop(&mut self) { if self.child.try_wait().ok().flatten().is_none() { let _ = self.child.kill(); let _ = self.child.wait(); } } } fn wait_for(observed: &Mutex>, path: &str) { let deadline = Instant::now() + DEADLINE; while !observed.lock().unwrap().iter().any(|item| item == path) { assert!(Instant::now() < deadline, "server did not record {path}"); thread::yield_now(); } } #[test] fn interrupted_repl_suppresses_distinct_late_http_progress() { let server = Registry::new(); server.concurrent.store(true, Ordering::Relaxed); server.files.lock().unwrap().insert( "/mod.ts".into(), b"import './helper.ts'; export const value = 42;".to_vec(), ); server.files.lock().unwrap().insert( "/helper.ts".into(), b"console.log('LATE_HELPER_COMPLETE'); export {};".to_vec(), ); server .hold_paths .lock() .unwrap() .extend(["/mod.ts".into(), "/helper.ts".into()]); let directory = tempfile::tempdir().unwrap(); let mut child = Pty::spawn( &[ "repl".into(), format!("--cache-dir={}", directory.path().join("cache").display()), ], directory.path(), ); child.read_until(b"> "); // Keep module names out of echoed input so it cannot masquerade as progress. child.write( format!( "await (await import('{}' + 'mod' + '.ts')).value\n", server.url ) .as_bytes(), ); wait_for(&server.requests, "/mod.ts"); let progress = child.read_until(b"mod.ts"); assert_eq!( unsafe { libc::kill(child.child.id() as i32, libc::SIGINT) }, 0 ); let prompt = child.read_until_from(progress, b"> "); server.hold_paths.lock().unwrap().remove("/mod.ts"); wait_for(&server.requests, "/helper.ts"); server.hold_paths.lock().unwrap().remove("/helper.ts"); wait_for(&server.responses, "/mod.ts"); wait_for(&server.responses, "/helper.ts"); let barrier = child.bytes.len(); child.write( format!( "await import('{}' + 'helper' + '.ts'); ['PROGRESS', 'BARRIER'].join('_')\n", server.url ) .as_bytes(), ); child.read_until_from(prompt, b"LATE_HELPER_COMPLETE"); let completion = child.read_until_from(barrier, b"PROGRESS_BARRIER"); child.read_until_from(completion, b"> "); let idle_output = String::from_utf8_lossy(&child.bytes[prompt..]); for target in ["mod.ts", "helper.ts"] { assert!( !idle_output.contains(target), "late progress escaped scope: {idle_output}" ); } child.write(b".exit\n"); child.wait_exit(); assert!(!directory.path().join("avril.lock").exists()); }