process: Fix tty resolving

Current tty resolution can give wrong result e.g. if the process has
closed all fds of its controlling terminal. The proper way to do it
seems to be based on field in /proc/<pid>/stat, which gives a device
number of the tty. Converting that to a device path requires parsing
/proc/tty/drivers (according to strace of upstream ps).

After this `ps`, `ps -x` and `ps -a` give same processes as upstream ps.
This commit is contained in:
Tuomas Tynkkynen
2025-10-31 23:04:47 +02:00
parent 833ea21f12
commit 316f8fc5f9
3 changed files with 192 additions and 70 deletions
+183 -63
View File
@@ -6,6 +6,8 @@
use regex::Regex;
use std::fs::read_link;
use std::hash::Hash;
#[cfg(target_os = "linux")]
use std::ops::RangeInclusive;
use std::sync::{LazyLock, OnceLock};
use std::{
collections::HashMap,
@@ -15,20 +17,130 @@ use std::{
};
use walkdir::{DirEntry, WalkDir};
/// Represents a TTY driver entry from /proc/tty/drivers
#[cfg(target_os = "linux")]
#[derive(Debug, Clone, PartialEq, Eq)]
struct TtyDriverEntry {
device_prefix: String,
major: u32,
minor_range: RangeInclusive<u32>,
}
#[cfg(target_os = "linux")]
impl TtyDriverEntry {
fn new(device_prefix: String, major: u32, minor_range: RangeInclusive<u32>) -> Self {
Self {
device_prefix,
major,
minor_range,
}
}
fn device_path_if_matches(&self, major: u32, minor: u32) -> Option<String> {
if self.major != major || !self.minor_range.contains(&minor) {
return None;
}
// /dev/pts devices are in a subdirectory unlike others
if self.device_prefix == "/dev/pts" {
return Some(format!("/dev/pts/{}", minor));
}
// If there is only one minor (e.g. /dev/console) it should not get a number
if self.minor_range.start() == self.minor_range.end() {
Some(self.device_prefix.clone())
} else {
let device_number = minor - self.minor_range.start();
Some(format!("{}{}", self.device_prefix, device_number))
}
}
}
#[cfg(target_os = "linux")]
static TTY_DRIVERS_CACHE: LazyLock<Vec<TtyDriverEntry>> = LazyLock::new(|| {
fs::read_to_string("/proc/tty/drivers")
.map(|content| parse_proc_tty_drivers(&content))
.unwrap_or_default()
});
#[cfg(target_os = "linux")]
fn parse_proc_tty_drivers(drivers_content: &str) -> Vec<TtyDriverEntry> {
// Example lines:
// /dev/tty /dev/tty 5 0 system:/dev/tty
// /dev/vc/0 /dev/vc/0 4 0 system:vtmaster
// hvc /dev/hvc 229 0-7 system
// serial /dev/ttyS 4 64-95 serial
// pty_slave /dev/pts 136 0-1048575 pty:slave
let regex = Regex::new(r"^[^ ]+ +([^ ]+) +(\d+) +(\d+)(?:-(\d+))?").unwrap();
let mut entries = Vec::new();
for line in drivers_content.lines() {
let Some(captures) = regex.captures(line) else {
continue;
};
let device_prefix = captures[1].to_string();
let Ok(major) = captures[2].parse::<u32>() else {
continue;
};
let Ok(min_minor) = captures[3].parse::<u32>() else {
continue;
};
let max_minor = captures
.get(4)
.and_then(|m| m.as_str().parse::<u32>().ok())
.unwrap_or(min_minor);
entries.push(TtyDriverEntry::new(
device_prefix,
major,
min_minor..=max_minor,
));
}
entries
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Teletype {
Tty(u64),
TtyS(u64),
Pts(u64),
Known(String),
Unknown,
}
impl Teletype {
#[cfg(target_os = "linux")]
pub fn from_tty_nr(tty_nr: u64) -> Self {
Self::from_tty_nr_impl(tty_nr, &TTY_DRIVERS_CACHE)
}
#[cfg(not(target_os = "linux"))]
pub fn from_tty_nr(_tty_nr: u64) -> Self {
Self::Unknown
}
#[cfg(target_os = "linux")]
fn from_tty_nr_impl(tty_nr: u64, drivers: &[TtyDriverEntry]) -> Self {
use uucore::libc::{major, minor};
if tty_nr == 0 {
return Self::Unknown;
}
let (major_dev, minor_dev) = (major(tty_nr), minor(tty_nr));
for entry in drivers.iter() {
if let Some(device_path) = entry.device_path_if_matches(major_dev, minor_dev) {
return Self::Known(device_path);
}
}
Self::Unknown
}
}
impl Display for Teletype {
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
match self {
Self::Tty(id) => write!(f, "/dev/pts/{id}"),
Self::TtyS(id) => write!(f, "/dev/tty{id}"),
Self::Pts(id) => write!(f, "/dev/ttyS{id}"),
Self::Known(device_path) => write!(f, "{}", device_path),
Self::Unknown => write!(f, "?"),
}
}
@@ -58,43 +170,8 @@ impl TryFrom<PathBuf> for Teletype {
type Error = ();
fn try_from(value: PathBuf) -> Result<Self, Self::Error> {
// Three case: /dev/pts/* , /dev/ttyS**, /dev/tty**
let mut iter = value.iter();
// Case 1
// Considering this format: **/**/pts/<num>
if let (Some(_), Some(num)) = (iter.find(|it| *it == "pts"), iter.next()) {
return num
.to_str()
.ok_or(())?
.parse::<u64>()
.map_err(|_| ())
.map(Teletype::Pts);
};
// Considering this format: **/**/ttyS** then **/**/tty**
let path = value.to_str().ok_or(())?;
let f = |prefix: &str| {
value
.iter()
.next_back()?
.to_str()?
.strip_prefix(prefix)?
.parse::<u64>()
.ok()
};
if path.contains("ttyS") {
// Case 2
f("ttyS").ok_or(()).map(Teletype::TtyS)
} else if path.contains("tty") {
// Case 3
f("tty").ok_or(()).map(Teletype::Tty)
} else {
Err(())
}
let path_str = value.to_str().ok_or(())?;
Ok(Self::Known(path_str.to_string()))
}
}
@@ -602,28 +679,17 @@ impl ProcessInformation {
RunState::try_from(self.stat().get(2).unwrap().as_str())
}
/// This function will scan the `/proc/<pid>/fd` directory
/// Get the controlling terminal from the tty_nr field in /proc/<pid>/stat
///
/// If the process does not belong to any terminal and mismatched permission,
/// the result will contain [TerminalType::Unknown].
///
/// Otherwise [TerminalType::Unknown] does not appear in the result.
pub fn tty(&self) -> Teletype {
let path = PathBuf::from(format!("/proc/{}/fd", self.pid));
let Ok(result) = fs::read_dir(path) else {
return Teletype::Unknown;
/// Returns Teletype::Unknown if the process has no controlling terminal (tty_nr == 0)
/// or if the tty_nr cannot be resolved to a device.
pub fn tty(&mut self) -> Teletype {
let tty_nr = match self.get_numeric_stat_field(6) {
Ok(tty_nr) => tty_nr,
Err(_) => return Teletype::Unknown,
};
for dir in result.flatten().filter(|it| it.path().is_symlink()) {
if let Ok(path) = fs::read_link(dir.path()) {
if let Ok(tty) = Teletype::try_from(path) {
return tty;
}
}
}
Teletype::Unknown
Teletype::from_tty_nr(tty_nr)
}
pub fn thread_ids(&mut self) -> &[usize] {
@@ -734,6 +800,53 @@ mod tests {
#[cfg(target_os = "linux")]
use uucore::process::getpid;
#[test]
#[cfg(target_os = "linux")]
fn test_tty_resolution() {
let test_content = r#"/dev/tty /dev/tty 5 0 system:/dev/tty
/dev/console /dev/console 5 1 system:console
/dev/ptmx /dev/ptmx 5 2 system
/dev/vc/0 /dev/vc/0 4 0 system:vtmaster
hvc /dev/hvc 229 0-7 system
serial /dev/ttyS 4 64-95 serial
pty_slave /dev/pts 136 0-1048575 pty:slave
pty_master /dev/ptm 128 0-1048575 pty:master
unknown /dev/tty 4 1-63 console"#;
let expected_entries = vec![
TtyDriverEntry::new("/dev/tty".to_string(), 5, 0..=0),
TtyDriverEntry::new("/dev/console".to_string(), 5, 1..=1),
TtyDriverEntry::new("/dev/ptmx".to_string(), 5, 2..=2),
TtyDriverEntry::new("/dev/vc/0".to_string(), 4, 0..=0),
TtyDriverEntry::new("/dev/hvc".to_string(), 229, 0..=7),
TtyDriverEntry::new("/dev/ttyS".to_string(), 4, 64..=95),
TtyDriverEntry::new("/dev/pts".to_string(), 136, 0..=1048575),
TtyDriverEntry::new("/dev/ptm".to_string(), 128, 0..=1048575),
TtyDriverEntry::new("/dev/tty".to_string(), 4, 1..=63),
];
let parsed_entries = parse_proc_tty_drivers(test_content);
assert_eq!(parsed_entries, expected_entries);
let test_cases = vec![
// (major, minor, expected_result)
(0, 0, Teletype::Unknown),
(5, 0, Teletype::Known("/dev/tty".to_string())),
(5, 1, Teletype::Known("/dev/console".to_string())),
(136, 123, Teletype::Known("/dev/pts/123".to_string())),
(4, 64, Teletype::Known("/dev/ttyS0".to_string())),
(4, 65, Teletype::Known("/dev/ttyS1".to_string())),
(229, 3, Teletype::Known("/dev/hvc3".to_string())),
(999, 999, Teletype::Unknown),
];
for (major, minor, expected) in test_cases {
let tty_nr = uucore::libc::makedev(major, minor);
let result = Teletype::from_tty_nr_impl(tty_nr, &parsed_entries);
assert_eq!(result, expected);
}
}
#[test]
fn test_run_state_conversion() {
assert_eq!(RunState::try_from("R").unwrap(), RunState::Running);
@@ -763,7 +876,14 @@ mod tests {
#[test]
#[cfg(target_os = "linux")]
fn test_pid_entry() {
let pid_entry = ProcessInformation::current_process_info().unwrap();
use std::io::IsTerminal;
let mut pid_entry = ProcessInformation::current_process_info().unwrap();
if !std::io::stdout().is_terminal() && !std::io::stderr().is_terminal() {
assert_eq!(pid_entry.tty(), Teletype::Unknown);
return;
}
let mut result = WalkDir::new(format!("/proc/{}/fd", getpid()))
.into_iter()
.flatten()
+5 -4
View File
@@ -135,10 +135,11 @@ fn sid(proc_info: RefCell<ProcessInformation>) -> String {
}
fn tty(proc_info: RefCell<ProcessInformation>) -> String {
match proc_info.borrow().tty() {
Teletype::Tty(tty) => format!("tty{tty}"),
Teletype::TtyS(ttys) => format!("ttyS{ttys}"),
Teletype::Pts(pts) => format!("pts/{pts}"),
match proc_info.borrow_mut().tty() {
Teletype::Known(device_path) => device_path
.strip_prefix("/dev/")
.unwrap_or(&device_path)
.to_owned(),
Teletype::Unknown => "?".to_owned(),
}
}
+4 -3
View File
@@ -129,7 +129,7 @@ pub fn ask_user(pid: u32) -> bool {
let process = process_snapshot().process(Pid::from_u32(pid)).unwrap();
let tty = ProcessInformation::try_new(PathBuf::from_str(&format!("/proc/{pid}")).unwrap())
.map(|v| v.tty().to_string())
.map(|mut v| v.tty().to_string())
.unwrap_or(String::from("?"));
let user = process
@@ -188,7 +188,8 @@ pub fn construct_verbose_result(
let process = process_snapshot().process(Pid::from_u32(pid)).unwrap();
let tty =
ProcessInformation::try_new(PathBuf::from_str(&format!("/proc/{pid}")).unwrap());
ProcessInformation::try_new(PathBuf::from_str(&format!("/proc/{pid}")).unwrap())
.map(|mut v| v.tty().to_string());
let user = process
.user_id()
@@ -214,7 +215,7 @@ pub fn construct_verbose_result(
row![pid]
}
Some((tty, user, cmd, action)) => {
row![tty.unwrap().tty(), user, pid, cmd, action]
row![tty.unwrap(), user, pid, cmd, action]
}
})
.collect::<Table>();