use std::io::{self, Write};
use std::mem::size_of;
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle, RawHandle};
use std::process;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
use std::thread;
use std::time::{Duration, Instant};
use windows_sys::Win32::Foundation::{HANDLE, INVALID_HANDLE_VALUE};
use windows_sys::Win32::System::Diagnostics::ToolHelp::{
CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, TH32CS_SNAPPROCESS,
};
use conpty_oxide::Size;
#[cfg(feature = "blocking")]
pub mod sync;
#[cfg(feature = "tokio")]
pub mod tokio_support;
pub const POLL_INTERVAL: Duration = Duration::from_millis(25);
const TIMEOUT_EXIT_CODE: i32 = 101;
#[must_use = "the watchdog is disarmed as soon as the guard is dropped"]
pub struct Watchdog {
finished: Arc<AtomicBool>,
}
impl Drop for Watchdog {
fn drop(&mut self) {
self.finished.store(true, Ordering::SeqCst);
}
}
pub fn watchdog(limit: Duration) -> Watchdog {
let finished = Arc::new(AtomicBool::new(false));
let flag = Arc::clone(&finished);
let test = current_test_name();
thread::Builder::new()
.name(format!("watchdog-{test}"))
.spawn(move || {
let deadline = Instant::now() + limit;
while !flag.load(Ordering::SeqCst) {
if Instant::now() >= deadline {
eprintln!(
"\nconpty-oxide: `{test}` did not finish within {limit:?}. \
It is assumed to be deadlocked, so the test process is being \
terminated with exit code {TIMEOUT_EXIT_CODE}."
);
let _ = io::stderr().flush();
process::exit(TIMEOUT_EXIT_CODE);
}
thread::sleep(POLL_INTERVAL);
}
})
.expect("spawning the watchdog thread must succeed");
Watchdog { finished }
}
pub fn with_timeout<T>(limit: Duration, body: impl FnOnce() -> T) -> T {
let _guard = watchdog(limit);
body()
}
fn current_test_name() -> String {
let current = thread::current();
match current.name() {
Some(name) if name != "main" => name.to_owned(),
_ => std::env::args()
.skip(1)
.find(|arg| !arg.starts_with('-'))
.unwrap_or_else(|| "<unknown test>".to_owned()),
}
}
pub fn wait_until(limit: Duration, mut condition: impl FnMut() -> bool) -> bool {
let deadline = Instant::now() + limit;
loop {
if condition() {
return true;
}
if Instant::now() >= deadline {
return false;
}
thread::sleep(POLL_INTERVAL);
}
}
pub fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
mutex.lock().unwrap_or_else(PoisonError::into_inner)
}
#[must_use]
pub fn strip_escapes(text: &str) -> String {
const ESC: char = '\u{1b}';
const BEL: char = '\u{7}';
let mut out = String::with_capacity(text.len());
let mut chars = text.chars();
loop {
let Some(ch) = chars.next() else { return out };
if ch != ESC {
out.push(ch);
continue;
}
match chars.next() {
Some('[') => loop {
match chars.next() {
Some('\u{40}'..='\u{7e}') | None => break,
Some(_) => {},
}
},
Some(']' | 'P' | 'X' | '^' | '_') => loop {
match chars.next() {
Some(BEL) | None => break,
Some(ESC) => {
chars.next();
break;
},
Some(_) => {},
}
},
Some(_) | None => {},
}
}
}
#[must_use]
pub fn reported_size(raw: &str) -> Option<(u32, u32)> {
let text = strip_escapes(raw);
Some((
last_number(&text, "Lines:")?,
last_number(&text, "Columns:")?,
))
}
fn last_number(text: &str, label: &str) -> Option<u32> {
text.rmatch_indices(label).find_map(|(at, _)| {
text[at + label.len()..]
.split_whitespace()
.next()?
.parse()
.ok()
})
}
#[must_use]
pub fn expected_size(size: Size) -> (u32, u32) {
(u32::from(size.rows()), u32::from(size.cols()))
}
#[derive(Debug, Clone)]
pub struct ProcessEntry {
pub pid: u32,
pub parent_pid: u32,
pub name: String,
}
impl ProcessEntry {
fn is(&self, exe: &str) -> bool {
self.name.eq_ignore_ascii_case(exe)
}
}
const SNAPSHOT_ATTEMPTS: u32 = 20;
#[must_use]
pub fn process_snapshot() -> Vec<ProcessEntry> {
let snapshot = open_process_snapshot();
let handle = snapshot.as_raw_handle() as HANDLE;
let mut entry = PROCESSENTRY32W {
dwSize: u32::try_from(size_of::<PROCESSENTRY32W>())
.expect("PROCESSENTRY32W size must fit in a DWORD"),
..Default::default()
};
let mut processes = Vec::new();
let mut has_entry = unsafe { Process32FirstW(handle, &mut entry) } != 0;
while has_entry {
processes.push(ProcessEntry {
pid: entry.th32ProcessID,
parent_pid: entry.th32ParentProcessID,
name: wide_to_string(&entry.szExeFile),
});
has_entry = unsafe { Process32NextW(handle, &mut entry) } != 0;
}
processes
}
fn open_process_snapshot() -> OwnedHandle {
for _ in 0..SNAPSHOT_ATTEMPTS {
let raw = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) };
if raw != INVALID_HANDLE_VALUE {
return unsafe { OwnedHandle::from_raw_handle(raw as RawHandle) };
}
thread::sleep(POLL_INTERVAL);
}
panic!(
"CreateToolhelp32Snapshot kept failing: {}",
io::Error::last_os_error()
);
}
fn wide_to_string(wide: &[u16]) -> String {
let end = wide
.iter()
.position(|&unit| unit == 0)
.unwrap_or(wide.len());
String::from_utf16_lossy(&wide[..end])
}
#[must_use]
pub fn descendants_of(root: u32) -> Vec<ProcessEntry> {
let all = process_snapshot();
let mut found: Vec<ProcessEntry> = Vec::new();
let mut frontier = vec![root];
while let Some(parent) = frontier.pop() {
for entry in &all {
let known = entry.pid == root || found.iter().any(|seen| seen.pid == entry.pid);
if entry.parent_pid == parent && !known {
frontier.push(entry.pid);
found.push(entry.clone());
}
}
}
found
}
#[must_use]
pub fn find_descendant(root: u32, exe: &str) -> Option<ProcessEntry> {
descendants_of(root).into_iter().find(|entry| entry.is(exe))
}
#[must_use]
pub fn wait_for_descendant(root: u32, exe: &str, limit: Duration) -> u32 {
let mut found = None;
let appeared = wait_until(limit, || {
found = find_descendant(root, exe);
found.is_some()
});
assert!(
appeared,
"process {root} never spawned a descendant named {exe:?}"
);
found.expect("the descendant was just observed").pid
}
#[must_use]
pub fn process_is_running(pid: u32, exe: &str) -> bool {
process_snapshot()
.iter()
.any(|entry| entry.pid == pid && entry.is(exe))
}