use crate::reap::{
matches_exempt, parent_comm_is_systemd, read_proc_table, ProcEntry, ProcTable, ReapPolicy,
};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum DesktopVerdict {
Test { why: String },
Desktop { reason: String },
NotABrowser,
}
const HEADLESS_MARKERS: &[&str] = &["--headless", "headless_shell"];
const TEST_PROFILE_MARKERS: &[&str] = &[
"playwright",
"puppeteer",
"chromium-",
"chrome-",
"headless",
".cache",
"tmp",
];
pub(crate) fn classify_desktop_session(args: &str) -> DesktopVerdict {
let lower = args.to_ascii_lowercase();
if !lower.contains("chrom") {
return DesktopVerdict::NotABrowser;
}
if HEADLESS_MARKERS.iter().any(|marker| lower.contains(marker)) {
return DesktopVerdict::Test {
why: "headless".to_string(),
};
}
let profile = extract_user_data_dir(&lower);
match profile {
None => DesktopVerdict::Desktop {
reason: "no --user-data-dir: a normal browser session".to_string(),
},
Some(path) => {
if TEST_PROFILE_MARKERS.iter().any(|m| path.contains(m)) {
DesktopVerdict::Test {
why: format!("test profile {path}"),
}
} else {
DesktopVerdict::Desktop {
reason: format!("profile {path} is not a test profile"),
}
}
}
}
}
fn extract_user_data_dir_raw(args: &str) -> Option<String> {
let idx = args.find("--user-data-dir=")?;
let rest = &args[idx + "--user-data-dir=".len()..];
let end = rest
.find(|c: char| c.is_whitespace() || c == '"' || c == '\'')
.unwrap_or(rest.len());
let value = rest[..end].trim();
if value.is_empty() {
None
} else {
Some(value.to_string())
}
}
pub(crate) fn devtools_port(args: &str) -> Option<u16> {
const MARKER: &str = "--remote-debugging-port=";
let idx = args.find(MARKER)?;
let rest = &args[idx + MARKER.len()..];
let end = rest
.find(|c: char| !c.is_ascii_digit())
.unwrap_or(rest.len());
let port: u16 = rest[..end].parse().ok()?;
if port == 0 {
None
} else {
Some(port)
}
}
pub(crate) fn devtools_port_from_profile(args: &str) -> Option<u16> {
let profile = extract_user_data_dir_raw(args)?;
let text = std::fs::read_to_string(Path::new(&profile).join("DevToolsActivePort")).ok()?;
text.lines().next()?.trim().parse().ok()
}
pub(crate) fn tree_devtools_port(tree: &ForgottenTree) -> Option<u16> {
devtools_port(&tree.root_args).or_else(|| devtools_port_from_profile(&tree.root_args))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Usage {
Driven,
Undriven,
Unknown,
}
pub(crate) fn tcp_table_has_established(path: &Path, port: u16) -> bool {
let Ok(text) = std::fs::read_to_string(path) else {
return false;
};
for line in text.lines().skip(1) {
let Some((local_port, remote_port, state)) = parse_proc_tcp_line(line) else {
continue;
};
if state != "01" {
continue;
}
if local_port == port || remote_port == port {
return true;
}
}
false
}
fn proc_tcp_port(token: &str) -> Option<u16> {
let (_, port) = token.rsplit_once(':')?;
if port.len() != 4 || !port.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
u16::from_str_radix(port, 16).ok()
}
pub(crate) fn parse_proc_tcp_line(line: &str) -> Option<(u16, u16, &str)> {
let tokens: Vec<&str> = line.split_whitespace().collect();
for i in 0..tokens.len().saturating_sub(2) {
let (Some(local), Some(remote)) = (proc_tcp_port(tokens[i]), proc_tcp_port(tokens[i + 1]))
else {
continue;
};
let state = tokens[i + 2];
if state.len() == 2 && state.chars().all(|c| c.is_ascii_hexdigit()) {
return Some((local, remote, state));
}
}
None
}
pub(crate) fn port_has_live_client(port: u16) -> bool {
tcp_table_has_established(Path::new("/proc/net/tcp"), port)
|| tcp_table_has_established(Path::new("/proc/net/tcp6"), port)
}
pub(crate) fn tree_usage(tree: &ForgottenTree) -> Usage {
match tree_devtools_port(tree) {
Some(port) => {
if port_has_live_client(port) {
Usage::Driven
} else {
Usage::Undriven
}
}
None => Usage::Unknown,
}
}
pub(crate) fn tree_user_data_dir(tree: &ForgottenTree) -> Option<PathBuf> {
extract_user_data_dir_raw(&tree.root_args).map(PathBuf::from)
}
pub(crate) fn tree_yields_profile(tree: &ForgottenTree, members_left: usize) -> Option<PathBuf> {
if members_left > 0 {
return None;
}
tree_user_data_dir(tree)
}
fn extract_user_data_dir(lower_args: &str) -> Option<String> {
let idx = lower_args.find("--user-data-dir=")?;
let rest = &lower_args[idx + "--user-data-dir=".len()..];
let end = rest
.find(|c: char| c.is_whitespace() || c == '"' || c == '\'')
.unwrap_or(rest.len());
let value = rest[..end].trim();
if value.is_empty() {
None
} else {
Some(value.to_string())
}
}
pub(crate) fn forgotten_reap_allowed_under_pressure(pressure: &str) -> bool {
matches!(pressure, "warn" | "critical")
}
pub(crate) const FORGOTTEN_MIN_TREE_AGE_SECS: u64 = 300;
pub(crate) fn browser_decides_by_veto(args: &str) -> bool {
args.to_ascii_lowercase().contains("chrom")
}
#[derive(Debug, Clone, serde::Serialize)]
pub(crate) struct TreeMember {
pub(crate) pid: i32,
pub(crate) comm: String,
pub(crate) rss_mb: u64,
pub(crate) swap_mb: u64,
pub(crate) footprint_mb: u64,
pub(crate) depth: u32,
pub(crate) role: String,
#[serde(skip_serializing)]
pub(crate) starttime: u64,
}
#[derive(Debug, Clone, serde::Serialize)]
pub(crate) struct ForgottenTree {
pub(crate) root_pid: i32,
pub(crate) root_comm: String,
pub(crate) root_args: String,
pub(crate) aggregate_footprint_mb: u64,
pub(crate) root_footprint_mb: u64,
pub(crate) member_count: usize,
pub(crate) age_secs: u64,
pub(crate) cpu_seconds: u64,
pub(crate) why: String,
pub(crate) members: Vec<TreeMember>,
#[serde(skip_serializing)]
pub(crate) root_starttime: u64,
}
#[derive(Debug, Clone, serde::Serialize)]
pub(crate) struct VetoedTree {
pub(crate) root_pid: i32,
pub(crate) root_comm: String,
pub(crate) aggregate_footprint_mb: u64,
pub(crate) member_count: usize,
pub(crate) reason: String,
}
#[derive(Debug, Clone, serde::Serialize)]
pub(crate) struct ProtectedTree {
pub(crate) root_pid: i32,
pub(crate) root_comm: String,
pub(crate) aggregate_footprint_mb: u64,
pub(crate) member_count: usize,
pub(crate) reason: String,
}
#[derive(Debug, Clone, Default)]
pub(crate) struct ForgottenTreeScan {
pub(crate) candidates: Vec<ForgottenTree>,
pub(crate) vetoed: Vec<VetoedTree>,
pub(crate) protected: Vec<ProtectedTree>,
}
fn crashpad_handler_pid(args: &str) -> Option<i32> {
const MARKER: &str = "--crashpad-handler-pid=";
let idx = args.find(MARKER)?;
let rest = &args[idx + MARKER.len()..];
let end = rest
.find(|c: char| !c.is_ascii_digit())
.unwrap_or(rest.len());
rest[..end].parse().ok()
}
fn member_weight(proc_root: &Path, entry: &ProcEntry) -> (u64, u64) {
let dir = proc_root.join(entry.pid.to_string());
let rss_mb = crate::reap::rss_mb_from_status(&dir);
let swap_mb = crate::reap::swap_mb_from_status(&dir);
(rss_mb, swap_mb)
}
fn browser_family_member(root: &ProcEntry, member: &ProcEntry) -> bool {
browser_decides_by_veto(&root.args) && member.exe == root.exe
}
fn build_tree(
proc_root: &Path,
table: &ProcTable,
root_pid: i32,
policy: &ReapPolicy,
boot_time: u64,
now: u64,
ticks_per_sec: u64,
) -> ForgottenTree {
let Some(root) = table.get(root_pid) else {
unreachable!("build_tree is only called for a root that is in the table");
};
let mut members: Vec<TreeMember> = Vec::new();
let mut seen: HashSet<i32> = HashSet::new();
seen.insert(root_pid);
let (root_rss, root_swap) = member_weight(proc_root, root);
members.push(TreeMember {
pid: root.pid,
comm: root.comm.clone(),
rss_mb: root_rss,
swap_mb: root_swap,
footprint_mb: root_rss.saturating_add(root_swap),
depth: 0,
role: "root".to_string(),
starttime: root.starttime,
});
let mut handler_links: Vec<(i32, u32)> = Vec::new();
for (pid, depth) in table.descendants(root_pid) {
let Some(entry) = table.get(pid) else { continue };
if let Some(handler) = crashpad_handler_pid(&entry.args) {
handler_links.push((handler, depth + 1));
}
if !browser_family_member(root, entry) && matches_exempt(&entry.exe, &policy.exempt_names) {
continue;
}
if !seen.insert(pid) {
continue;
}
let (rss_mb, swap_mb) = member_weight(proc_root, entry);
members.push(TreeMember {
pid: entry.pid,
comm: entry.comm.clone(),
rss_mb,
swap_mb,
footprint_mb: rss_mb.saturating_add(swap_mb),
depth,
role: "descendant".to_string(),
starttime: entry.starttime,
});
}
for (handler_pid, depth) in handler_links {
if seen.contains(&handler_pid) {
continue;
}
let Some(handler) = table.get(handler_pid) else {
continue;
};
if !parent_comm_is_systemd(proc_root, handler.ppid) {
continue;
}
if !browser_family_member(root, handler) && matches_exempt(&handler.exe, &policy.exempt_names)
{
continue;
}
seen.insert(handler_pid);
let (rss_mb, swap_mb) = member_weight(proc_root, handler);
members.push(TreeMember {
pid: handler.pid,
comm: handler.comm.clone(),
rss_mb,
swap_mb,
footprint_mb: rss_mb.saturating_add(swap_mb),
depth,
role: "crashpad handler".to_string(),
starttime: handler.starttime,
});
}
let aggregate_footprint_mb = members
.iter()
.fold(0u64, |acc, m| acc.saturating_add(m.footprint_mb));
let root_footprint_mb = members.first().map(|m| m.footprint_mb).unwrap_or(0);
let cpu_seconds = root.cpu_ticks / ticks_per_sec.max(1);
let age_secs = crate::reap::elapsed_secs(root.starttime, ticks_per_sec, boot_time, now);
ForgottenTree {
root_pid: root.pid,
root_comm: root.comm.clone(),
root_args: root.args.clone(),
aggregate_footprint_mb,
root_footprint_mb,
member_count: members.len(),
age_secs,
cpu_seconds,
why: String::new(),
members,
root_starttime: root.starttime,
}
}
pub(crate) fn measure_tree_footprint(
proc_root: &Path,
root_pid: i32,
members: &[TreeMember],
) -> (u64, u64) {
let mut aggregate = 0u64;
let mut root_footprint = 0u64;
for member in members {
let dir = proc_root.join(member.pid.to_string());
let rss_mb = crate::reap::rss_mb_from_status(&dir);
let swap_mb = crate::reap::swap_mb_from_status(&dir);
let footprint = rss_mb.saturating_add(swap_mb);
aggregate = aggregate.saturating_add(footprint);
if member.pid == root_pid {
root_footprint = footprint;
}
}
(root_footprint, aggregate)
}
pub(crate) fn scan_forgotten_orphan_trees(
proc_root: &Path,
policy: &ReapPolicy,
boot_time: u64,
now: u64,
ticks_per_sec: u64,
per_process_floor_mb: u64,
tree_floor_mb: u64,
) -> ForgottenTreeScan {
let mut out = ForgottenTreeScan::default();
let table = read_proc_table(proc_root);
let self_pid = std::process::id() as i32;
let mut roots: Vec<i32> = Vec::new();
for entry in table.entries.values() {
if entry.pid <= 1 || entry.pid == self_pid {
continue;
}
if !parent_comm_is_systemd(proc_root, entry.ppid) {
continue;
}
if entry.tty_nr != 0 {
continue;
}
if entry.state != 'S' {
continue;
}
if !browser_decides_by_veto(&entry.args)
&& matches_exempt(&entry.exe, &policy.exempt_names)
{
continue;
}
let age_secs = crate::reap::elapsed_secs(entry.starttime, ticks_per_sec, boot_time, now);
if age_secs < FORGOTTEN_MIN_TREE_AGE_SECS {
continue;
}
roots.push(entry.pid);
}
roots.sort_unstable();
let mut claimed: HashSet<i32> = HashSet::new();
for root_pid in roots {
if claimed.contains(&root_pid) {
continue;
}
let Some(root) = table.get(root_pid) else { continue };
let verdict = classify_desktop_session(&root.args);
let why = match verdict {
DesktopVerdict::Test { why } => why,
DesktopVerdict::Desktop { reason } => {
let tree = build_tree(proc_root, &table, root_pid, policy, boot_time, now, ticks_per_sec);
claimed.extend(tree.members.iter().map(|m| m.pid));
if tree.aggregate_footprint_mb >= tree_floor_mb
|| tree.root_footprint_mb >= per_process_floor_mb
{
out.vetoed.push(VetoedTree {
root_pid: tree.root_pid,
root_comm: tree.root_comm.clone(),
aggregate_footprint_mb: tree.aggregate_footprint_mb,
member_count: tree.member_count,
reason,
});
}
continue;
}
DesktopVerdict::NotABrowser => continue,
};
let tree = build_tree(proc_root, &table, root_pid, policy, boot_time, now, ticks_per_sec);
claimed.extend(tree.members.iter().map(|m| m.pid));
if tree.aggregate_footprint_mb < tree_floor_mb
&& tree.root_footprint_mb < per_process_floor_mb
{
continue;
}
let usage = tree_usage(&tree);
let busy = tree.cpu_seconds > policy.max_cpu_seconds;
match usage {
Usage::Driven => {
out.protected.push(ProtectedTree {
root_pid: tree.root_pid,
root_comm: tree.root_comm.clone(),
aggregate_footprint_mb: tree.aggregate_footprint_mb,
member_count: tree.member_count,
reason: "a client is still connected to it — in use, not reaped".to_string(),
});
continue;
}
Usage::Undriven => {
out.candidates.push(ForgottenTree { why, ..tree });
continue;
}
Usage::Unknown => {
if busy {
out.protected.push(ProtectedTree {
root_pid: tree.root_pid,
root_comm: tree.root_comm.clone(),
aggregate_footprint_mb: tree.aggregate_footprint_mb,
member_count: tree.member_count,
reason: format!(
"busy — {}s of CPU and no debugging port to check for a client",
tree.cpu_seconds
),
});
continue;
}
}
}
out.candidates.push(ForgottenTree { why, ..tree });
}
out.candidates
.sort_by_key(|c| std::cmp::Reverse(c.aggregate_footprint_mb));
out.vetoed
.sort_by_key(|c| std::cmp::Reverse(c.aggregate_footprint_mb));
out.protected
.sort_by_key(|c| std::cmp::Reverse(c.aggregate_footprint_mb));
out
}