aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-07-03 04:02:45 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-07-03 04:02:45 +0300
commitef67a97047c92e4af6b9d69c7292962426109bc1 (patch)
treeaded9085e3a21c44b975a9f871a36d87321b7626
parentaa0768b734d95a1f54df1719290d977df796068d (diff)
perf(linux): cache foreground process group scans
refs #936
-rw-r--r--src/platform/linux.rs424
-rw-r--r--src/raw_input.rs2
-rw-r--r--src/terminal_modes.rs1
3 files changed, 396 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!(
diff --git a/src/raw_input.rs b/src/raw_input.rs
index 284761c6..3bf8cae9 100644
--- a/src/raw_input.rs
+++ b/src/raw_input.rs
@@ -101,6 +101,7 @@ use crate::terminal_theme::{
const ESC: u8 = 0x1b;
pub(crate) const RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS: i32 = 10;
+#[cfg(any(not(windows), test))]
pub(crate) const MOUSE_ACTIVE_LONE_ESCAPE_FLUSH_TIMEOUT_MS: i32 = 150;
pub(crate) const GHOSTTY_COLOR_SCHEME_DARK_REPORT: &[u8] = b"\x1b[?997;1n";
pub(crate) const GHOSTTY_COLOR_SCHEME_LIGHT_REPORT: &[u8] = b"\x1b[?997;2n";
@@ -207,6 +208,7 @@ impl RawInputByteFramer {
!self.buffer.is_empty()
}
+ #[cfg(any(not(windows), test))]
pub(crate) fn has_pending_lone_escape(&self) -> bool {
self.buffer.as_slice() == [ESC]
}
diff --git a/src/terminal_modes.rs b/src/terminal_modes.rs
index 953a5812..e07de629 100644
--- a/src/terminal_modes.rs
+++ b/src/terminal_modes.rs
@@ -1,5 +1,6 @@
use std::io::{self, Write};
+#[cfg(any(not(windows), test))]
const DISABLE_HOST_MOUSE_REPORTING_SEQUENCE: &[u8] =
b"\x1b[?1006l\x1b[?1016l\x1b[?1015l\x1b[?1005l\x1b[?1003l\x1b[?1002l\x1b[?1000l";