diff options
Diffstat (limited to 'src/platform/linux.rs')
| -rw-r--r-- | src/platform/linux.rs | 424 |
1 files changed, 393 insertions, 31 deletions
diff --git a/src/platform/linux.rs b/src/platform/linux.rs index 539c0d93..4e5574d0 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -1,8 +1,11 @@ use std::{ + collections::HashMap, io::Write, os::fd::RawFd, path::PathBuf, process::{Command, Stdio}, + sync::Mutex, + time::{Duration, Instant}, }; use super::{ @@ -10,6 +13,30 @@ use super::{ LimitedRead, Signal, }; +const FOREGROUND_MEMBERS_CACHE_TTL: Duration = Duration::from_millis(250); + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ProcGroupMember { + pid: u32, + comm: String, +} + +type ForegroundMembersByGroup = HashMap<u32, Vec<ProcGroupMember>>; + +#[derive(Debug, Clone)] +struct CachedForegroundMembers { + built_at: Instant, + by_group: ForegroundMembersByGroup, +} + +#[derive(Debug, Default)] +struct ForegroundMembersCache { + cached: Option<CachedForegroundMembers>, +} + +static FOREGROUND_MEMBERS_CACHE: Mutex<ForegroundMembersCache> = + Mutex::new(ForegroundMembersCache { cached: None }); + pub fn raise_server_nofile_limit() {} pub(crate) fn scrollback_editor_argv(path: &std::path::Path) -> std::io::Result<Vec<String>> { @@ -39,37 +66,20 @@ fn shell_quote(value: &str) -> String { /// Collect the foreground terminal job for a given child PID. pub fn foreground_job(child_pid: u32) -> Option<ForegroundJob> { let tpgid = foreground_process_group_id(child_pid)?; - let mut processes = Vec::new(); - - for entry in std::fs::read_dir("/proc").ok()? { - let entry = entry.ok()?; - let file_name = entry.file_name(); - let pid_str = file_name.to_str()?; - if !pid_str.bytes().all(|b| b.is_ascii_digit()) { - continue; - } - - let pid: u32 = match pid_str.parse() { - Ok(pid) => pid, - Err(_) => continue, - }; - - let Some((pgrp, name)) = process_pgrp_and_comm(pid) else { - continue; - }; - if pgrp as u32 != tpgid { - continue; - } - - let argv = process_argv(pid); - processes.push(ForegroundProcess { - pid, - name, - argv0: None, - cmdline: argv.as_ref().map(|parts| parts.join(" ")), - argv, - }); - } + let members = foreground_process_group_members(tpgid)?; + let processes = members + .into_iter() + .map(|member| { + let argv = process_argv(member.pid); + ForegroundProcess { + pid: member.pid, + name: member.comm, + argv0: None, + cmdline: argv.as_ref().map(|parts| parts.join(" ")), + argv, + } + }) + .collect::<Vec<_>>(); if processes.is_empty() { return None; @@ -81,6 +91,24 @@ pub fn foreground_job(child_pid: u32) -> Option<ForegroundJob> { }) } +fn foreground_process_group_members(process_group_id: u32) -> Option<Vec<ProcGroupMember>> { + let mut cache = FOREGROUND_MEMBERS_CACHE + .lock() + .unwrap_or_else(|err| err.into_inner()); + cache.members( + process_group_id, + Instant::now(), + FOREGROUND_MEMBERS_CACHE_TTL, + build_foreground_members_by_group, + live_process_group_member, + ) +} + +fn live_process_group_member(process_group_id: u32, pid: u32) -> Option<ProcGroupMember> { + let (pgrp, comm) = process_pgrp_and_comm(pid)?; + (pgrp > 0 && pgrp as u32 == process_group_id).then_some(ProcGroupMember { pid, comm }) +} + pub fn foreground_group_leader_job(process_group_id: u32) -> Option<ForegroundJob> { let (pgrp, name) = process_pgrp_and_comm(process_group_id)?; if pgrp as u32 != process_group_id { @@ -118,6 +146,10 @@ pub fn foreground_process_group_id_for_tty_fd(fd: RawFd) -> Option<u32> { fn process_pgrp_and_comm(pid: u32) -> Option<(i32, String)> { let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?; + process_pgrp_and_comm_from_stat(&stat) +} + +fn process_pgrp_and_comm_from_stat(stat: &str) -> Option<(i32, String)> { let close = stat.rfind(')')?; let comm = stat.get(1 + stat.find('(')?..close)?.to_string(); let rest = stat.get(close + 2..)?; @@ -126,6 +158,94 @@ fn process_pgrp_and_comm(pid: u32) -> Option<(i32, String)> { Some((pgrp, comm)) } +fn build_foreground_members_by_group() -> ForegroundMembersByGroup { + let entries = std::fs::read_dir("/proc") + .into_iter() + .flatten() + .flatten() + .filter_map(|entry| { + let file_name = entry.file_name(); + let pid_str = file_name.to_str()?; + if !pid_str.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + let pid = pid_str.parse::<u32>().ok()?; + let (pgrp, comm) = process_pgrp_and_comm(pid)?; + Some(ProcStatEntry { pid, pgrp, comm }) + }); + foreground_members_by_group_from_entries(entries) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ProcStatEntry { + pid: u32, + pgrp: i32, + comm: String, +} + +fn foreground_members_by_group_from_entries( + entries: impl IntoIterator<Item = ProcStatEntry>, +) -> ForegroundMembersByGroup { + let mut by_group = ForegroundMembersByGroup::default(); + for entry in entries { + if entry.pgrp <= 0 { + continue; + } + by_group + .entry(entry.pgrp as u32) + .or_default() + .push(ProcGroupMember { + pid: entry.pid, + comm: entry.comm, + }); + } + by_group +} + +impl ForegroundMembersCache { + fn members( + &mut self, + process_group_id: u32, + now: Instant, + max_age: Duration, + build: impl FnMut() -> ForegroundMembersByGroup, + mut validate: impl FnMut(u32, u32) -> Option<ProcGroupMember>, + ) -> Option<Vec<ProcGroupMember>> { + if let Some(cached) = &self.cached { + if now.duration_since(cached.built_at) < max_age { + if let Some(members) = cached.by_group.get(&process_group_id) { + let members = members + .iter() + .filter_map(|member| validate(process_group_id, member.pid)) + .collect::<Vec<_>>(); + if !members.is_empty() { + return Some(members); + } + } + return self.refresh_and_get(process_group_id, now, build); + } + } + self.refresh_and_get(process_group_id, now, build) + } + + fn refresh_and_get( + &mut self, + process_group_id: u32, + now: Instant, + build: impl FnOnce() -> ForegroundMembersByGroup, + ) -> Option<Vec<ProcGroupMember>> { + self.cached = Some(CachedForegroundMembers { + built_at: now, + by_group: build(), + }); + self.cached + .as_ref()? + .by_group + .get(&process_group_id) + .cloned() + } +} + fn process_argv(pid: u32) -> Option<Vec<String>> { let bytes = std::fs::read(format!("/proc/{pid}/cmdline")).ok()?; if bytes.is_empty() { @@ -526,6 +646,7 @@ fn process_session_id(pid: u32) -> Option<i32> { #[cfg(test)] mod tests { use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Mutex, OnceLock}; fn env_lock() -> &'static Mutex<()> { @@ -533,6 +654,247 @@ mod tests { LOCK.get_or_init(|| Mutex::new(())) } + fn proc_entry(pid: u32, pgrp: i32, comm: &str) -> ProcStatEntry { + ProcStatEntry { + pid, + pgrp, + comm: comm.to_string(), + } + } + + fn foreground_members(groups: &[(u32, &str, i32)]) -> ForegroundMembersByGroup { + foreground_members_by_group_from_entries( + groups + .iter() + .map(|(pid, comm, pgrp)| proc_entry(*pid, *pgrp, comm)), + ) + } + + fn validate_from<'a>( + groups: &'a [(u32, &'a str, i32)], + ) -> impl FnMut(u32, u32) -> Option<ProcGroupMember> + 'a { + move |process_group_id, pid| { + groups.iter().find_map(|(member_pid, comm, pgrp)| { + (*member_pid == pid && *pgrp > 0 && *pgrp as u32 == process_group_id).then(|| { + ProcGroupMember { + pid, + comm: (*comm).to_string(), + } + }) + }) + } + } + + fn member_names(by_group: &ForegroundMembersByGroup, process_group_id: u32) -> Vec<String> { + by_group + .get(&process_group_id) + .cloned() + .unwrap_or_default() + .into_iter() + .map(|member| member.comm) + .collect() + } + + #[test] + fn foreground_members_indexes_by_process_group() { + let by_group = foreground_members(&[ + (10, "shell", 10), + (11, "claude", 11), + (12, "node", 11), + (13, "ignored", -1), + ]); + + assert_eq!(member_names(&by_group, 10), vec!["shell"]); + assert_eq!(member_names(&by_group, 11), vec!["claude", "node"]); + assert_eq!(by_group.get(&13), None); + } + + #[test] + fn foreground_members_cache_reuses_snapshot_inside_ttl() { + let mut cache = ForegroundMembersCache::default(); + let now = Instant::now(); + let builds = AtomicUsize::new(0); + + let first = cache.members( + 10, + now, + FOREGROUND_MEMBERS_CACHE_TTL, + || { + builds.fetch_add(1, Ordering::Relaxed); + foreground_members(&[(10, "shell", 10)]) + }, + validate_from(&[(10, "shell", 10)]), + ); + let second = cache.members( + 10, + now + Duration::from_millis(100), + FOREGROUND_MEMBERS_CACHE_TTL, + || { + builds.fetch_add(1, Ordering::Relaxed); + foreground_members(&[(20, "new", 20)]) + }, + validate_from(&[(10, "shell-live", 10)]), + ); + + assert_eq!( + first + .unwrap() + .into_iter() + .map(|member| member.comm) + .collect::<Vec<_>>(), + vec!["shell"] + ); + assert_eq!( + second + .unwrap() + .into_iter() + .map(|member| member.comm) + .collect::<Vec<_>>(), + vec!["shell-live"] + ); + assert_eq!(builds.load(Ordering::Relaxed), 1); + } + + #[test] + fn foreground_members_cache_rebuilds_after_ttl() { + let mut cache = ForegroundMembersCache::default(); + let now = Instant::now(); + let builds = AtomicUsize::new(0); + + let _ = cache.members( + 10, + now, + FOREGROUND_MEMBERS_CACHE_TTL, + || { + builds.fetch_add(1, Ordering::Relaxed); + foreground_members(&[(10, "shell", 10)]) + }, + validate_from(&[(10, "shell", 10)]), + ); + let second = cache.members( + 20, + now + FOREGROUND_MEMBERS_CACHE_TTL, + FOREGROUND_MEMBERS_CACHE_TTL, + || { + builds.fetch_add(1, Ordering::Relaxed); + foreground_members(&[(20, "new", 20)]) + }, + validate_from(&[(20, "new", 20)]), + ); + + assert_eq!(builds.load(Ordering::Relaxed), 2); + assert_eq!( + second + .unwrap() + .into_iter() + .map(|member| member.comm) + .collect::<Vec<_>>(), + vec!["new"] + ); + } + + #[test] + fn foreground_members_cache_refreshes_reused_snapshot_on_group_miss() { + let mut cache = ForegroundMembersCache::default(); + let now = Instant::now(); + let builds = AtomicUsize::new(0); + + let stale = cache.members( + 10, + now, + FOREGROUND_MEMBERS_CACHE_TTL, + || { + builds.fetch_add(1, Ordering::Relaxed); + foreground_members(&[(10, "shell", 10)]) + }, + validate_from(&[(10, "shell", 10)]), + ); + assert_eq!( + stale + .unwrap() + .into_iter() + .map(|member| member.comm) + .collect::<Vec<_>>(), + vec!["shell"] + ); + + let refreshed = cache.members( + 42, + now + Duration::from_millis(10), + FOREGROUND_MEMBERS_CACHE_TTL, + || { + builds.fetch_add(1, Ordering::Relaxed); + foreground_members(&[(42, "claude", 42)]) + }, + validate_from(&[(42, "claude", 42)]), + ); + + assert_eq!(builds.load(Ordering::Relaxed), 2); + assert_eq!( + refreshed + .unwrap() + .into_iter() + .map(|member| member.comm) + .collect::<Vec<_>>(), + vec!["claude"] + ); + } + + #[test] + fn foreground_members_cache_refreshes_existing_group_when_cached_members_are_stale() { + let mut cache = ForegroundMembersCache::default(); + let now = Instant::now(); + let builds = AtomicUsize::new(0); + + let stale = cache.members( + 42, + now, + FOREGROUND_MEMBERS_CACHE_TTL, + || { + builds.fetch_add(1, Ordering::Relaxed); + foreground_members(&[(10, "old", 42)]) + }, + validate_from(&[(10, "old", 42)]), + ); + assert_eq!( + stale + .unwrap() + .into_iter() + .map(|member| member.comm) + .collect::<Vec<_>>(), + vec!["old"] + ); + + let refreshed = cache.members( + 42, + now + Duration::from_millis(10), + FOREGROUND_MEMBERS_CACHE_TTL, + || { + builds.fetch_add(1, Ordering::Relaxed); + foreground_members(&[(20, "new", 42)]) + }, + validate_from(&[(10, "old", 7), (20, "new", 42)]), + ); + + assert_eq!(builds.load(Ordering::Relaxed), 2); + assert_eq!( + refreshed + .unwrap() + .into_iter() + .map(|member| member.comm) + .collect::<Vec<_>>(), + vec!["new"] + ); + } + + #[test] + fn proc_stat_parsing_keeps_group_leader_inputs_live() { + assert_eq!( + process_pgrp_and_comm_from_stat("123 (name with ) paren) S 1 456 789 0 456"), + Some((456, "name with ) paren".to_string())) + ); + } + #[test] fn parse_agent_env_hint_accepts_known_agents() { assert_eq!( |
