use anyhow::{Context, Result};
use std::process::Command;
use super::utils::{log_info, log_warning};
const REAP_THRESHOLD: f64 = 0.75;
const FALLBACK_CEILING: usize = 511;
#[derive(Debug, Clone, Copy)]
pub struct PtyPressure {
pub in_use: usize,
pub ceiling: usize,
}
impl PtyPressure {
fn saturation(&self) -> f64 {
if self.ceiling == 0 {
return 0.0;
}
self.in_use as f64 / self.ceiling as f64
}
fn pct(&self) -> u32 {
(self.saturation() * 100.0).round() as u32
}
}
pub fn measure() -> Result<PtyPressure> {
let in_use = std::fs::read_dir("/dev")
.context("read /dev to count allocated ptys")?
.filter_map(|e| e.ok())
.filter(|e| {
e.file_name()
.to_str()
.is_some_and(|n| n.starts_with("ttys"))
})
.count();
let ceiling = Command::new("sysctl")
.args(["-n", "kern.tty.ptmx_max"])
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())
.and_then(|s| s.trim().parse::<usize>().ok())
.unwrap_or(FALLBACK_CEILING);
Ok(PtyPressure { in_use, ceiling })
}
#[derive(Debug, Clone, Copy)]
struct OrphanShell(i32);
fn find_orphan_login_shells() -> Result<Vec<OrphanShell>> {
let out = Command::new("ps")
.args(["-Ao", "pid=,ppid=,uid=,comm="])
.output()
.context("enumerate processes with ps")?;
if !out.status.success() {
anyhow::bail!("ps exited {}", out.status);
}
Ok(select_orphans(
&String::from_utf8_lossy(&out.stdout),
users_uid(),
))
}
fn select_orphans(ps_output: &str, uid: u32) -> Vec<OrphanShell> {
let mut found = Vec::new();
for line in ps_output.lines() {
let mut f = line.split_whitespace();
let (Some(pid), Some(ppid), Some(puid), Some(comm)) =
(f.next(), f.next(), f.next(), f.next())
else {
continue;
};
if comm != "-sh" || f.next().is_some() {
continue;
}
if ppid != "1" {
continue;
}
if puid.parse::<u32>().ok() != Some(uid) {
continue;
}
if let Ok(pid) = pid.parse::<i32>() {
found.push(OrphanShell(pid));
}
}
found
}
fn users_uid() -> u32 {
unsafe { libc::getuid() }
}
fn reap(shells: &[OrphanShell]) -> usize {
for sig in [libc::SIGTERM, libc::SIGKILL] {
for OrphanShell(pid) in shells {
unsafe {
libc::kill(*pid, sig);
}
}
std::thread::sleep(std::time::Duration::from_millis(400));
}
shells.len()
}
pub fn preflight() {
let Ok(before) = measure() else {
log_warning("pty pre-flight: could not count ptys — running the gate unguarded");
return;
};
if before.saturation() < REAP_THRESHOLD {
return;
}
log_warning(&format!(
"pty pressure {}/{} ({}%) — reaping orphaned login shells before the e2e gate",
before.in_use,
before.ceiling,
before.pct()
));
let orphans = match find_orphan_login_shells() {
Ok(o) => o,
Err(e) => {
log_warning(&format!("pty pre-flight: could not enumerate processes ({e:#})"));
return;
}
};
if orphans.is_empty() {
log_warning(
"pty pre-flight: no ORPHANED login shells to reap — the ptys belong to live \
sessions. If the gate now fails to spawn a shell, that is this host being out \
of ptys, NOT the candidate closure.",
);
return;
}
let killed = reap(&orphans);
let after = measure().unwrap_or(before);
log_info(&format!(
"pty pre-flight: reaped {} orphaned login shell(s) — ptys {} -> {} of {}",
killed, before.in_use, after.in_use, after.ceiling
));
if after.saturation() >= REAP_THRESHOLD {
log_warning(&format!(
"pty pressure still {}% after reaping — the leak is LIVE and has a producer \
this guard cannot see. If the e2e gate fails to spawn a shell, read it as pty \
exhaustion on this host, not as a broken closure.",
after.pct()
));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn saturation_is_a_ratio_of_ceiling() {
let p = PtyPressure {
in_use: 500,
ceiling: 511,
};
assert!(p.saturation() > REAP_THRESHOLD);
assert_eq!(p.pct(), 98);
}
#[test]
fn a_quiet_host_is_below_the_threshold() {
let p = PtyPressure {
in_use: 27,
ceiling: 511,
};
assert!(p.saturation() < REAP_THRESHOLD);
}
#[test]
fn zero_ceiling_is_not_saturated() {
let p = PtyPressure {
in_use: 10,
ceiling: 0,
};
assert_eq!(p.saturation(), 0.0);
assert!(p.saturation() < REAP_THRESHOLD);
}
const PS_FIXTURE: &str = "\
34917 1 501 -sh
36108 1 501 -sh
90863 84137 501 -sh
41758 1 502 -sh
1193 1 501 tobira
22835 21209 501 claude
4572 1 501 /nix/store/xxx-bash-5.3p3/bin/bash
7781 1 501 Google Chrome Helper --shared-files --seatbelt -sh
";
#[test]
fn selects_only_orphaned_login_shells_of_this_user() {
let picked: Vec<i32> = select_orphans(PS_FIXTURE, 501)
.iter()
.map(|OrphanShell(p)| *p)
.collect();
assert_eq!(picked, vec![34917, 36108]);
}
#[test]
fn never_reaps_a_shell_with_a_live_parent() {
let picked = select_orphans("90863 84137 501 -sh\n", 501);
assert!(picked.is_empty(), "a live-parent shell must never be selected");
}
#[test]
fn never_reaps_another_users_shell() {
let picked = select_orphans("41758 1 502 -sh\n", 501);
assert!(picked.is_empty());
}
#[test]
fn never_matches_dash_sh_inside_an_argv() {
let picked = select_orphans(
"7781 1 501 Google Chrome Helper --shared-files --seatbelt -sh\n",
501,
);
assert!(picked.is_empty());
}
#[test]
fn measure_reads_a_real_ceiling() {
let p = measure().expect("ptys are countable on a unix host");
assert!(p.ceiling > 0, "ceiling must be positive, got {}", p.ceiling);
}
}