diff options
Diffstat (limited to 'tests/util/server/src/pty.rs')
-rw-r--r-- | tests/util/server/src/pty.rs | 770 |
1 files changed, 770 insertions, 0 deletions
diff --git a/tests/util/server/src/pty.rs b/tests/util/server/src/pty.rs new file mode 100644 index 000000000..3e3331b84 --- /dev/null +++ b/tests/util/server/src/pty.rs @@ -0,0 +1,770 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. + +use std::borrow::Cow; +use std::collections::HashMap; +use std::collections::HashSet; +use std::io::Read; +use std::io::Write; +use std::path::Path; +use std::time::Duration; +use std::time::Instant; + +use crate::strip_ansi_codes; + +/// Points to know about when writing pty tests: +/// +/// - Consecutive writes cause issues where you might write while a prompt +/// is not showing. So when you write, always `.expect(...)` on the output. +/// - Similar to the last point, using `.expect(...)` can help make the test +/// more deterministic. If the test is flaky, try adding more `.expect(...)`s +pub struct Pty { + pty: Box<dyn SystemPty>, + read_bytes: Vec<u8>, + last_index: usize, +} + +impl Pty { + pub fn new( + program: &Path, + args: &[&str], + cwd: &Path, + env_vars: Option<HashMap<String, String>>, + ) -> Self { + let pty = create_pty(program, args, cwd, env_vars); + let mut pty = Self { + pty, + read_bytes: Vec::new(), + last_index: 0, + }; + if args.is_empty() || args[0] == "repl" && !args.contains(&"--quiet") { + // wait for the repl to start up before writing to it + pty.read_until_condition_with_timeout( + |pty| { + pty + .all_output() + .contains("exit using ctrl+d, ctrl+c, or close()") + }, + // it sometimes takes a while to startup on the CI, so use a longer timeout + Duration::from_secs(60), + ); + } + + pty + } + + pub fn is_supported() -> bool { + let is_windows = cfg!(windows); + if is_windows && std::env::var("CI").is_ok() { + // the pty tests don't really start up on the windows CI for some reason + // so ignore them for now + eprintln!("Ignoring windows CI."); + false + } else { + true + } + } + + #[track_caller] + pub fn write_raw(&mut self, line: impl AsRef<str>) { + let line = if cfg!(windows) { + line.as_ref().replace('\n', "\r\n") + } else { + line.as_ref().to_string() + }; + if let Err(err) = self.pty.write(line.as_bytes()) { + panic!("{:#}", err) + } + self.pty.flush().unwrap(); + } + + #[track_caller] + pub fn write_line(&mut self, line: impl AsRef<str>) { + self.write_line_raw(&line); + + // expect what was written to show up in the output + // due to "pty echo" + for line in line.as_ref().lines() { + self.expect(line); + } + } + + /// Writes a line without checking if it's in the output. + #[track_caller] + pub fn write_line_raw(&mut self, line: impl AsRef<str>) { + self.write_raw(format!("{}\n", line.as_ref())); + } + + #[track_caller] + pub fn read_until(&mut self, end_text: impl AsRef<str>) -> String { + self.read_until_with_advancing(|text| { + text + .find(end_text.as_ref()) + .map(|index| index + end_text.as_ref().len()) + }) + } + + #[track_caller] + pub fn expect(&mut self, text: impl AsRef<str>) { + self.read_until(text.as_ref()); + } + + #[track_caller] + pub fn expect_any(&mut self, texts: &[&str]) { + self.read_until_with_advancing(|text| { + for find_text in texts { + if let Some(index) = text.find(find_text) { + return Some(index); + } + } + None + }); + } + + /// Consumes and expects to find all the text until a timeout is hit. + #[track_caller] + pub fn expect_all(&mut self, texts: &[&str]) { + let mut pending_texts: HashSet<&&str> = HashSet::from_iter(texts); + let mut max_index: Option<usize> = None; + self.read_until_with_advancing(|text| { + for pending_text in pending_texts.clone() { + if let Some(index) = text.find(pending_text) { + let index = index + pending_text.len(); + match &max_index { + Some(current) => { + if *current < index { + max_index = Some(index); + } + } + None => { + max_index = Some(index); + } + } + pending_texts.remove(pending_text); + } + } + if pending_texts.is_empty() { + max_index + } else { + None + } + }); + } + + /// Expects the raw text to be found, which may include ANSI codes. + /// Note: this expects the raw bytes in any output that has already + /// occurred or may occur within the next few seconds. + #[track_caller] + pub fn expect_raw_in_current_output(&mut self, text: impl AsRef<str>) { + self.read_until_condition(|pty| { + let data = String::from_utf8_lossy(&pty.read_bytes); + data.contains(text.as_ref()) + }); + } + + pub fn all_output(&self) -> Cow<str> { + String::from_utf8_lossy(&self.read_bytes) + } + + #[track_caller] + fn read_until_with_advancing( + &mut self, + mut condition: impl FnMut(&str) -> Option<usize>, + ) -> String { + let mut final_text = String::new(); + self.read_until_condition(|pty| { + let text = pty.next_text(); + if let Some(end_index) = condition(&text) { + pty.last_index += end_index; + final_text = text[..end_index].to_string(); + true + } else { + false + } + }); + final_text + } + + #[track_caller] + fn read_until_condition(&mut self, condition: impl FnMut(&mut Self) -> bool) { + self.read_until_condition_with_timeout(condition, Duration::from_secs(15)); + } + + #[track_caller] + fn read_until_condition_with_timeout( + &mut self, + condition: impl FnMut(&mut Self) -> bool, + timeout_duration: Duration, + ) { + if self.try_read_until_condition_with_timeout(condition, timeout_duration) { + return; + } + + panic!("Timed out.") + } + + /// Reads until the specified condition with a timeout duration returning + /// `true` on success or `false` on timeout. + fn try_read_until_condition_with_timeout( + &mut self, + mut condition: impl FnMut(&mut Self) -> bool, + timeout_duration: Duration, + ) -> bool { + let timeout_time = Instant::now().checked_add(timeout_duration).unwrap(); + while Instant::now() < timeout_time { + self.fill_more_bytes(); + if condition(self) { + return true; + } + } + + let text = self.next_text(); + eprintln!( + "------ Start Full Text ------\n{:?}\n------- End Full Text -------", + String::from_utf8_lossy(&self.read_bytes) + ); + eprintln!("Next text: {:?}", text); + + false + } + + fn next_text(&self) -> String { + let text = String::from_utf8_lossy(&self.read_bytes).to_string(); + let text = strip_ansi_codes(&text); + text[self.last_index..].to_string() + } + + fn fill_more_bytes(&mut self) { + let mut buf = [0; 256]; + match self.pty.read(&mut buf) { + Ok(count) if count > 0 => { + self.read_bytes.extend(&buf[..count]); + } + _ => { + std::thread::sleep(Duration::from_millis(15)); + } + } + } +} + +trait SystemPty: Read + Write {} + +impl SystemPty for std::fs::File {} + +#[cfg(unix)] +fn setup_pty(fd: i32) { + use nix::fcntl::fcntl; + use nix::fcntl::FcntlArg; + use nix::fcntl::OFlag; + use nix::sys::termios; + use nix::sys::termios::tcgetattr; + use nix::sys::termios::tcsetattr; + use nix::sys::termios::SetArg; + + let mut term = tcgetattr(fd).unwrap(); + // disable cooked mode + term.local_flags.remove(termios::LocalFlags::ICANON); + tcsetattr(fd, SetArg::TCSANOW, &term).unwrap(); + + // turn on non-blocking mode so we get timeouts + let flags = fcntl(fd, FcntlArg::F_GETFL).unwrap(); + let new_flags = OFlag::from_bits_truncate(flags) | OFlag::O_NONBLOCK; + fcntl(fd, FcntlArg::F_SETFL(new_flags)).unwrap(); +} + +#[cfg(unix)] +fn create_pty( + program: &Path, + args: &[&str], + cwd: &Path, + env_vars: Option<HashMap<String, String>>, +) -> Box<dyn SystemPty> { + use crate::pty::unix::UnixPty; + use std::os::unix::process::CommandExt; + + // Manually open pty main/secondary sides in the test process. Since we're not actually + // changing uid/gid here, this is the easiest way to do it. + + // SAFETY: Posix APIs + let (fdm, fds) = unsafe { + let fdm = libc::posix_openpt(libc::O_RDWR); + if fdm < 0 { + panic!("posix_openpt failed"); + } + let res = libc::grantpt(fdm); + if res != 0 { + panic!("grantpt failed"); + } + let res = libc::unlockpt(fdm); + if res != 0 { + panic!("unlockpt failed"); + } + let fds = libc::open(libc::ptsname(fdm), libc::O_RDWR); + if fdm < 0 { + panic!("open(ptsname) failed"); + } + (fdm, fds) + }; + + // SAFETY: Posix APIs + unsafe { + let cmd = std::process::Command::new(program) + .current_dir(cwd) + .args(args) + .envs(env_vars.unwrap_or_default()) + .pre_exec(move || { + // Close parent's main handle + libc::close(fdm); + libc::dup2(fds, 0); + libc::dup2(fds, 1); + libc::dup2(fds, 2); + // Note that we could close `fds` here as well, but this is a short-lived process and + // we're just not going to worry about "leaking" it + Ok(()) + }) + .spawn() + .unwrap(); + + // Close child's secondary handle + libc::close(fds); + setup_pty(fdm); + + use std::os::fd::FromRawFd; + let pid = nix::unistd::Pid::from_raw(cmd.id() as _); + let file = std::fs::File::from_raw_fd(fdm); + Box::new(UnixPty { pid, file }) + } +} + +#[cfg(unix)] +mod unix { + use std::io::Read; + use std::io::Write; + + use super::SystemPty; + + pub struct UnixPty { + pub pid: nix::unistd::Pid, + pub file: std::fs::File, + } + + impl Drop for UnixPty { + fn drop(&mut self) { + use nix::sys::signal::kill; + use nix::sys::signal::Signal; + kill(self.pid, Signal::SIGTERM).unwrap() + } + } + + impl SystemPty for UnixPty {} + + impl Read for UnixPty { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> { + self.file.read(buf) + } + } + + impl Write for UnixPty { + fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> { + self.file.write(buf) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.file.flush() + } + } +} + +#[cfg(target_os = "windows")] +fn create_pty( + program: &Path, + args: &[&str], + cwd: &Path, + env_vars: Option<HashMap<String, String>>, +) -> Box<dyn SystemPty> { + let pty = windows::WinPseudoConsole::new(program, args, cwd, env_vars); + Box::new(pty) +} + +#[cfg(target_os = "windows")] +mod windows { + use std::collections::HashMap; + use std::io::ErrorKind; + use std::io::Read; + use std::path::Path; + use std::ptr; + use std::time::Duration; + + use winapi::shared::minwindef::FALSE; + use winapi::shared::minwindef::LPVOID; + use winapi::shared::minwindef::TRUE; + use winapi::shared::winerror::S_OK; + use winapi::um::consoleapi::ClosePseudoConsole; + use winapi::um::consoleapi::CreatePseudoConsole; + use winapi::um::fileapi::FlushFileBuffers; + use winapi::um::fileapi::ReadFile; + use winapi::um::fileapi::WriteFile; + use winapi::um::handleapi::DuplicateHandle; + use winapi::um::handleapi::INVALID_HANDLE_VALUE; + use winapi::um::namedpipeapi::CreatePipe; + use winapi::um::namedpipeapi::PeekNamedPipe; + use winapi::um::processthreadsapi::CreateProcessW; + use winapi::um::processthreadsapi::DeleteProcThreadAttributeList; + use winapi::um::processthreadsapi::GetCurrentProcess; + use winapi::um::processthreadsapi::InitializeProcThreadAttributeList; + use winapi::um::processthreadsapi::UpdateProcThreadAttribute; + use winapi::um::processthreadsapi::LPPROC_THREAD_ATTRIBUTE_LIST; + use winapi::um::processthreadsapi::PROCESS_INFORMATION; + use winapi::um::synchapi::WaitForSingleObject; + use winapi::um::winbase::CREATE_UNICODE_ENVIRONMENT; + use winapi::um::winbase::EXTENDED_STARTUPINFO_PRESENT; + use winapi::um::winbase::INFINITE; + use winapi::um::winbase::STARTUPINFOEXW; + use winapi::um::wincontypes::COORD; + use winapi::um::wincontypes::HPCON; + use winapi::um::winnt::DUPLICATE_SAME_ACCESS; + use winapi::um::winnt::HANDLE; + + use super::SystemPty; + + macro_rules! assert_win_success { + ($expression:expr) => { + let success = $expression; + if success != TRUE { + panic!("{}", std::io::Error::last_os_error().to_string()) + } + }; + } + + macro_rules! handle_err { + ($expression:expr) => { + let success = $expression; + if success != TRUE { + return Err(std::io::Error::last_os_error()); + } + }; + } + + pub struct WinPseudoConsole { + stdin_write_handle: WinHandle, + stdout_read_handle: WinHandle, + // keep these alive for the duration of the pseudo console + _process_handle: WinHandle, + _thread_handle: WinHandle, + _attribute_list: ProcThreadAttributeList, + } + + impl WinPseudoConsole { + pub fn new( + program: &Path, + args: &[&str], + cwd: &Path, + maybe_env_vars: Option<HashMap<String, String>>, + ) -> Self { + // https://docs.microsoft.com/en-us/windows/console/creating-a-pseudoconsole-session + // SAFETY: + // Generous use of winapi to create a PTY (thus large unsafe block). + unsafe { + let mut size: COORD = std::mem::zeroed(); + size.X = 800; + size.Y = 500; + let mut console_handle = std::ptr::null_mut(); + let (stdin_read_handle, stdin_write_handle) = create_pipe(); + let (stdout_read_handle, stdout_write_handle) = create_pipe(); + + let result = CreatePseudoConsole( + size, + stdin_read_handle.as_raw_handle(), + stdout_write_handle.as_raw_handle(), + 0, + &mut console_handle, + ); + assert_eq!(result, S_OK); + + let mut environment_vars = maybe_env_vars.map(get_env_vars); + let mut attribute_list = ProcThreadAttributeList::new(console_handle); + let mut startup_info: STARTUPINFOEXW = std::mem::zeroed(); + startup_info.StartupInfo.cb = + std::mem::size_of::<STARTUPINFOEXW>() as u32; + startup_info.lpAttributeList = attribute_list.as_mut_ptr(); + + let mut proc_info: PROCESS_INFORMATION = std::mem::zeroed(); + let command = format!( + "\"{}\" {}", + program.to_string_lossy(), + args + .iter() + .map(|a| format!("\"{}\"", a)) + .collect::<Vec<_>>() + .join(" ") + ) + .trim() + .to_string(); + let mut application_str = to_windows_str(&program.to_string_lossy()); + let mut command_str = to_windows_str(&command); + let cwd = cwd.to_string_lossy().replace('/', "\\"); + let mut cwd = to_windows_str(&cwd); + + assert_win_success!(CreateProcessW( + application_str.as_mut_ptr(), + command_str.as_mut_ptr(), + ptr::null_mut(), + ptr::null_mut(), + FALSE, + EXTENDED_STARTUPINFO_PRESENT | CREATE_UNICODE_ENVIRONMENT, + environment_vars + .as_mut() + .map(|v| v.as_mut_ptr() as LPVOID) + .unwrap_or(ptr::null_mut()), + cwd.as_mut_ptr(), + &mut startup_info.StartupInfo, + &mut proc_info, + )); + + // close the handles that the pseudoconsole now has + drop(stdin_read_handle); + drop(stdout_write_handle); + + // start a thread that will close the pseudoconsole on process exit + let thread_handle = WinHandle::new(proc_info.hThread); + std::thread::spawn({ + let thread_handle = thread_handle.duplicate(); + let console_handle = WinHandle::new(console_handle); + move || { + WaitForSingleObject(thread_handle.as_raw_handle(), INFINITE); + // wait for the reading thread to catch up + std::thread::sleep(Duration::from_millis(200)); + // close the console handle which will close the + // stdout pipe for the reader + ClosePseudoConsole(console_handle.into_raw_handle()); + } + }); + + Self { + stdin_write_handle, + stdout_read_handle, + _process_handle: WinHandle::new(proc_info.hProcess), + _thread_handle: thread_handle, + _attribute_list: attribute_list, + } + } + } + } + + impl Read for WinPseudoConsole { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> { + // don't do a blocking read in order to support timing out + let mut bytes_available = 0; + // SAFETY: winapi call + handle_err!(unsafe { + PeekNamedPipe( + self.stdout_read_handle.as_raw_handle(), + ptr::null_mut(), + 0, + ptr::null_mut(), + &mut bytes_available, + ptr::null_mut(), + ) + }); + if bytes_available == 0 { + return Err(std::io::Error::new(ErrorKind::WouldBlock, "Would block.")); + } + + let mut bytes_read = 0; + // SAFETY: winapi call + handle_err!(unsafe { + ReadFile( + self.stdout_read_handle.as_raw_handle(), + buf.as_mut_ptr() as _, + buf.len() as u32, + &mut bytes_read, + ptr::null_mut(), + ) + }); + + Ok(bytes_read as usize) + } + } + + impl SystemPty for WinPseudoConsole {} + + impl std::io::Write for WinPseudoConsole { + fn write(&mut self, buffer: &[u8]) -> std::io::Result<usize> { + let mut bytes_written = 0; + // SAFETY: + // winapi call + handle_err!(unsafe { + WriteFile( + self.stdin_write_handle.as_raw_handle(), + buffer.as_ptr() as *const _, + buffer.len() as u32, + &mut bytes_written, + ptr::null_mut(), + ) + }); + Ok(bytes_written as usize) + } + + fn flush(&mut self) -> std::io::Result<()> { + // SAFETY: winapi call + handle_err!(unsafe { + FlushFileBuffers(self.stdin_write_handle.as_raw_handle()) + }); + Ok(()) + } + } + + struct WinHandle { + inner: HANDLE, + } + + impl WinHandle { + pub fn new(handle: HANDLE) -> Self { + WinHandle { inner: handle } + } + + pub fn duplicate(&self) -> WinHandle { + // SAFETY: winapi call + let process_handle = unsafe { GetCurrentProcess() }; + let mut duplicate_handle = ptr::null_mut(); + // SAFETY: winapi call + assert_win_success!(unsafe { + DuplicateHandle( + process_handle, + self.inner, + process_handle, + &mut duplicate_handle, + 0, + 0, + DUPLICATE_SAME_ACCESS, + ) + }); + + WinHandle::new(duplicate_handle) + } + + pub fn as_raw_handle(&self) -> HANDLE { + self.inner + } + + pub fn into_raw_handle(self) -> HANDLE { + let handle = self.inner; + // skip the drop implementation in order to not close the handle + std::mem::forget(self); + handle + } + } + + // SAFETY: These handles are ok to send across threads. + unsafe impl Send for WinHandle {} + // SAFETY: These handles are ok to send across threads. + unsafe impl Sync for WinHandle {} + + impl Drop for WinHandle { + fn drop(&mut self) { + if !self.inner.is_null() && self.inner != INVALID_HANDLE_VALUE { + // SAFETY: winapi call + unsafe { + winapi::um::handleapi::CloseHandle(self.inner); + } + } + } + } + + struct ProcThreadAttributeList { + buffer: Vec<u8>, + } + + impl ProcThreadAttributeList { + pub fn new(console_handle: HPCON) -> Self { + // SAFETY: + // Generous use of unsafe winapi calls to create a ProcThreadAttributeList. + unsafe { + // discover size required for the list + let mut size = 0; + let attribute_count = 1; + assert_eq!( + InitializeProcThreadAttributeList( + ptr::null_mut(), + attribute_count, + 0, + &mut size + ), + FALSE + ); + + let mut buffer = vec![0u8; size]; + let attribute_list_ptr = buffer.as_mut_ptr() as _; + + assert_win_success!(InitializeProcThreadAttributeList( + attribute_list_ptr, + attribute_count, + 0, + &mut size, + )); + + const PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE: usize = 0x00020016; + assert_win_success!(UpdateProcThreadAttribute( + attribute_list_ptr, + 0, + PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, + console_handle, + std::mem::size_of::<HPCON>(), + ptr::null_mut(), + ptr::null_mut(), + )); + + ProcThreadAttributeList { buffer } + } + } + + pub fn as_mut_ptr(&mut self) -> LPPROC_THREAD_ATTRIBUTE_LIST { + self.buffer.as_mut_slice().as_mut_ptr() as *mut _ + } + } + + impl Drop for ProcThreadAttributeList { + fn drop(&mut self) { + // SAFETY: winapi call + unsafe { DeleteProcThreadAttributeList(self.as_mut_ptr()) }; + } + } + + fn create_pipe() -> (WinHandle, WinHandle) { + let mut read_handle = std::ptr::null_mut(); + let mut write_handle = std::ptr::null_mut(); + + // SAFETY: Creating an anonymous pipe with winapi. + assert_win_success!(unsafe { + CreatePipe(&mut read_handle, &mut write_handle, ptr::null_mut(), 0) + }); + + (WinHandle::new(read_handle), WinHandle::new(write_handle)) + } + + fn to_windows_str(str: &str) -> Vec<u16> { + use std::os::windows::prelude::OsStrExt; + std::ffi::OsStr::new(str) + .encode_wide() + .chain(Some(0)) + .collect() + } + + fn get_env_vars(env_vars: HashMap<String, String>) -> Vec<u16> { + // See lpEnvironment: https://docs.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-createprocessw + let mut parts = env_vars + .into_iter() + // each environment variable is in the form `name=value\0` + .map(|(key, value)| format!("{key}={value}\0")) + .collect::<Vec<_>>(); + + // all strings in an environment block must be case insensitively + // sorted alphabetically by name + // https://docs.microsoft.com/en-us/windows/win32/procthread/changing-environment-variables + parts.sort_by_key(|part| part.to_lowercase()); + + // the entire block is terminated by NULL (\0) + format!("{}\0", parts.join("")) + .encode_utf16() + .collect::<Vec<_>>() + } +} |