aboutsummaryrefslogtreecommitdiffstats
path: root/zellij-server
diff options
context:
space:
mode:
authorChris Frost <chris.frost@stellar.org>2026-07-08 06:57:51 -0700
committerGitHub <noreply@github.com>2026-07-08 15:57:51 +0200
commitc632605c360f333324ef42dd9b6524cd4328884c (patch)
tree57ecea2a04f0b6c3ae8bef5a1ad8153318ecac8c /zellij-server
parentStacked list fixes (#5342) (diff)
downloadsl0ppij-c632605c360f333324ef42dd9b6524cd4328884c.tar.gz
sl0ppij-c632605c360f333324ef42dd9b6524cd4328884c.tar.xz
sl0ppij-c632605c360f333324ef42dd9b6524cd4328884c.zip
perf: foreground pane-command discovery without scanning the whole process table (#5324)
* build(deps): bump sysinfo to 0.37 for scan-free partial process refresh Bump it to pull in sysinfo 0.37's change "Linux: greatly improve partial processes retrieval", so that refreshing a specific set of pids (ProcessesToUpdate::Some) no longer walks the entire /proc tree. This makes the existing get_cwd/get_cwds polling O(panes) instead of O(host processes) and unblocks doing the same for foreground-command discovery. * perf: discover foreground pane commands w/o scanning all processes Foreground command discovery was reading information about every process on the host, on each pane/tab creation and on every periodic poll. On hosts with many processes (eg, 50k) this stalled pane creation for tens of seconds and overran the action-completion timeout. Replace that O(host processes) lookup with an O(panes) lookup. Instead of looking up the parent pid of every process to find the child, get the foreground process-group leader's pid from a call to tcgetpgrp on the pane's controlling terminal. When the leader is the shell itself (idle prompt or a command sharing the shell's group without job control) the pane is omitted and the caller falls back to the shell command, matching prior behavior; with job control on it now reports the true foreground job instead of possibly a background one. This changes the paths for Linux, macOS, and BSD. It leaves Windows unchanged. * refactor(os_input): extract apply_post_command_hook helper The command -> run_command_hook -> trim/split pipeline was duplicated between the #[cfg(not(unix))] get_all_cmds_by_ppid and the unix get_foreground_cmds. Extract a single apply_post_command_hook helper and call it from both sites, routing the hook-failure fallback through the project's non_fatal() logging idiom (CONTRIBUTING.md "Logging errors") instead of log::error!. * review: simplify tcgetpgrp locking, trim comments, drop changelog entries - Remove the lock-drop dance and its TOCTOU caveat from tcgetpgrp, to make it mirror tcdrain - Prune AI comments - Remove CHANGELOG entries
Diffstat (limited to 'zellij-server')
-rw-r--r--zellij-server/Cargo.toml2
-rw-r--r--zellij-server/src/os_input_output.rs162
-rw-r--r--zellij-server/src/os_input_output_unix.rs7
-rw-r--r--zellij-server/src/pty.rs39
-rw-r--r--zellij-server/src/unit/os_input_output_tests.rs54
-rw-r--r--zellij-server/src/unit/pty_tests.rs15
6 files changed, 195 insertions, 84 deletions
diff --git a/zellij-server/Cargo.toml b/zellij-server/Cargo.toml
index 72bf3b46..0f04acef 100644
--- a/zellij-server/Cargo.toml
+++ b/zellij-server/Cargo.toml
@@ -33,7 +33,7 @@ serde = { workspace = true }
serde_json = { workspace = true }
sixel-image = { version = "0.2.1", default-features = false }
sixel-tokenizer = { version = "0.1.0", default-features = false }
-sysinfo = { version = "0.33", default-features = false, features = ["system"] }
+sysinfo = { version = "0.37", default-features = false, features = ["system"] }
tempfile = { workspace = true }
tokio = { workspace = true }
typetag = { version = "0.1.7", default-features = false }
diff --git a/zellij-server/src/os_input_output.rs b/zellij-server/src/os_input_output.rs
index dd7225ad..376de666 100644
--- a/zellij-server/src/os_input_output.rs
+++ b/zellij-server/src/os_input_output.rs
@@ -348,6 +348,15 @@ pub trait ServerOsApi: Send + Sync {
fn get_all_cmds_by_ppid(&self, _post_hook: &Option<String>) -> HashMap<String, Vec<String>> {
HashMap::new()
}
+ /// For each `(terminal_id, shell_pid)` pane, return the foreground command running in its
+ /// controlling terminal, keyed by `terminal_id`.
+ fn get_foreground_cmds(
+ &self,
+ _panes: &[(u32, u32)],
+ _post_hook: &Option<String>,
+ ) -> HashMap<u32, Vec<String>> {
+ HashMap::new()
+ }
/// Writes the given buffer to a string
fn write_to_file(&mut self, buf: String, file: Option<String>) -> Result<()>;
@@ -545,54 +554,6 @@ impl ServerOsApi for ServerOsInputOutput {
(cwds, cmds)
}
- #[cfg(unix)]
- fn get_all_cmds_by_ppid(&self, post_hook: &Option<String>) -> HashMap<String, Vec<String>> {
- // the key is the stringified ppid
- let mut cmds = HashMap::new();
- if let Some(output) = Command::new("ps")
- .args(vec!["-ao", "ppid,args"])
- .output()
- .ok()
- {
- let output = String::from_utf8(output.stdout.clone())
- .unwrap_or_else(|_| String::from_utf8_lossy(&output.stdout).to_string());
- for line in output.lines() {
- let line_parts: Vec<String> = line
- .trim()
- .split_ascii_whitespace()
- .map(|p| p.to_owned())
- .collect();
- let mut line_parts = line_parts.into_iter();
- let ppid = line_parts.next();
- if let Some(ppid) = ppid {
- match &post_hook {
- Some(post_hook) => {
- let command: Vec<String> = line_parts.clone().collect();
- let stringified = command.join(" ");
- let cmd = match run_command_hook(&stringified, post_hook) {
- Ok(command) => command,
- Err(e) => {
- log::error!("Post command hook failed to run: {}", e);
- stringified.to_owned()
- },
- };
- let line_parts: Vec<String> = cmd
- .trim()
- .split_ascii_whitespace()
- .map(|p| p.to_owned())
- .collect();
- cmds.insert(ppid.into(), line_parts);
- },
- None => {
- cmds.insert(ppid.into(), line_parts.collect());
- },
- }
- }
- }
- }
- cmds
- }
-
#[cfg(not(unix))]
fn get_all_cmds_by_ppid(&self, post_hook: &Option<String>) -> HashMap<String, Vec<String>> {
let mut system_info = System::new();
@@ -610,29 +571,76 @@ impl ServerOsApi for ServerOsInputOutput {
if command.is_empty() {
continue;
}
- match post_hook {
- Some(post_hook) => {
- let stringified = command.join(" ");
- let cmd = match run_command_hook(&stringified, post_hook) {
- Ok(command) => command,
- Err(e) => {
- log::error!("Post command hook failed to run: {}", e);
- stringified.to_owned()
- },
- };
- let line_parts: Vec<String> = cmd
- .trim()
- .split_ascii_whitespace()
- .map(|p| p.to_owned())
- .collect();
- cmds.insert(ppid_str, line_parts);
- },
- None => {
- cmds.insert(ppid_str, command);
- },
+ cmds.insert(ppid_str, apply_post_command_hook(command, post_hook));
+ }
+ }
+ cmds
+ }
+
+ #[cfg(unix)]
+ fn get_foreground_cmds(
+ &self,
+ panes: &[(u32, u32)],
+ post_hook: &Option<String>,
+ ) -> HashMap<u32, Vec<String>> {
+ let mut terminal_to_fg_pid: HashMap<u32, u32> = HashMap::new();
+ for &(terminal_id, shell_pid) in panes {
+ if let Some(fpgid) = self.pty_backend.tcgetpgrp(terminal_id) {
+ if fpgid > 0 && fpgid as u32 != shell_pid {
+ terminal_to_fg_pid.insert(terminal_id, fpgid as u32);
}
}
}
+ if terminal_to_fg_pid.is_empty() {
+ return HashMap::new();
+ }
+
+ let sysinfo_pids: Vec<sysinfo::Pid> = terminal_to_fg_pid
+ .values()
+ .map(|&p| sysinfo::Pid::from_u32(p))
+ .collect();
+ let mut system_info = System::new();
+ let refresh_kind = ProcessRefreshKind::nothing().with_cmd(UpdateKind::Always);
+ system_info.refresh_processes_specifics(
+ ProcessesToUpdate::Some(&sysinfo_pids),
+ false,
+ refresh_kind,
+ );
+
+ let mut cmds = HashMap::new();
+ for (terminal_id, fg_pid) in terminal_to_fg_pid {
+ let Some(process) = system_info.process(sysinfo::Pid::from_u32(fg_pid)) else {
+ continue;
+ };
+ let command: Vec<String> = process
+ .cmd()
+ .iter()
+ .map(|s| s.to_string_lossy().into_owned())
+ .collect();
+ if command.is_empty() {
+ continue;
+ }
+ let command = apply_post_command_hook(command, post_hook);
+ cmds.insert(terminal_id, command);
+ }
+ cmds
+ }
+
+ #[cfg(not(unix))]
+ fn get_foreground_cmds(
+ &self,
+ panes: &[(u32, u32)],
+ post_hook: &Option<String>,
+ ) -> HashMap<u32, Vec<String>> {
+ // Use ppid-based discovery on Windows, because it has no controlling-terminal
+ // foreground group.
+ let ppids_to_cmds = self.get_all_cmds_by_ppid(post_hook);
+ let mut cmds = HashMap::new();
+ for &(terminal_id, shell_pid) in panes {
+ if let Some(cmd) = ppids_to_cmds.get(&shell_pid.to_string()) {
+ cmds.insert(terminal_id, cmd.clone());
+ }
+ }
cmds
}
@@ -726,6 +734,24 @@ impl Drop for ResizeCache {
}
}
+fn apply_post_command_hook(command: Vec<String>, post_hook: &Option<String>) -> Vec<String> {
+ let Some(post_hook) = post_hook else {
+ return command;
+ };
+ let stringified = command.join(" ");
+ let cmd = match run_command_hook(&stringified, post_hook) {
+ Ok(command) => command,
+ Err(e) => {
+ Err::<(), _>(anyhow!("post command discovery hook failed to run: {e}")).non_fatal();
+ stringified
+ },
+ };
+ cmd.trim()
+ .split_ascii_whitespace()
+ .map(|p| p.to_owned())
+ .collect()
+}
+
#[cfg(not(windows))]
fn run_command_hook(
original_command: &str,
diff --git a/zellij-server/src/os_input_output_unix.rs b/zellij-server/src/os_input_output_unix.rs
index 2ce856f0..1ce8033b 100644
--- a/zellij-server/src/os_input_output_unix.rs
+++ b/zellij-server/src/os_input_output_unix.rs
@@ -414,6 +414,13 @@ impl UnixPtyBackend {
}
}
+ pub fn tcgetpgrp(&self, terminal_id: u32) -> Option<i32> {
+ match self.terminal_id_to_raw_fd.lock().ok()?.get(&terminal_id) {
+ Some(Some(fd)) => unistd::tcgetpgrp(*fd).ok().map(|pgid| pgid.as_raw()),
+ _ => None,
+ }
+ }
+
pub fn kill(&self, pid: u32) -> Result<()> {
let _ = kill(unistd::Pid::from_raw(pid as i32), Some(Signal::SIGHUP));
Ok(())
diff --git a/zellij-server/src/pty.rs b/zellij-server/src/pty.rs
index 478a60e3..13b06b57 100644
--- a/zellij-server/src/pty.rs
+++ b/zellij-server/src/pty.rs
@@ -1959,19 +1959,23 @@ impl Pty {
.as_ref()
.map(|os_input| os_input.get_cwds(pids))
.unwrap_or_default();
- let ppids_to_cmds = self
+ let panes: Vec<(u32, u32)> = terminal_ids
+ .iter()
+ .filter_map(|id| self.id_to_child_pid.get(id).map(|pid| (*id, *pid)))
+ .collect();
+ let foreground_cmds = self
.bus
.os_input
.as_ref()
- .map(|os_input| os_input.get_all_cmds_by_ppid(&self.post_command_discovery_hook))
+ .map(|os_input| os_input.get_foreground_cmds(&panes, &self.post_command_discovery_hook))
.unwrap_or_default();
for terminal_id in terminal_ids {
let process_id = self.id_to_child_pid.get(&terminal_id);
let cwd = process_id.and_then(|pid| pids_to_cwds.get(pid));
let cmd_sysinfo = process_id.and_then(|pid| pids_to_cmds.get(pid));
- let cmd_ps = process_id.and_then(|pid| ppids_to_cmds.get(&format!("{}", pid)));
- if let Some(cmd) = cmd_ps {
+ let cmd_foreground = foreground_cmds.get(&terminal_id);
+ if let Some(cmd) = cmd_foreground {
terminal_ids_to_commands.insert(terminal_id, cmd.clone());
} else if let Some(cmd) = cmd_sysinfo {
terminal_ids_to_commands.insert(terminal_id, cmd.clone());
@@ -2126,17 +2130,20 @@ impl Pty {
}
}
- let ppids_to_cmds = self
+ let panes: Vec<(u32, u32)> = active_terminal_ids
+ .iter()
+ .filter_map(|id| self.id_to_child_pid.get(id).map(|pid| (*id, *pid)))
+ .collect();
+ let foreground_cmds = self
.bus
.os_input
.as_ref()
- .map(|os_input| os_input.get_all_cmds_by_ppid(&self.post_command_discovery_hook))
+ .map(|os_input| os_input.get_foreground_cmds(&panes, &self.post_command_discovery_hook))
.unwrap_or_default();
for terminal_id in &active_terminal_ids {
- let process_id = self.id_to_child_pid.get(terminal_id);
- let foreground_cmd: Vec<String> = process_id
- .and_then(|pid| ppids_to_cmds.get(&pid.to_string()))
+ let foreground_cmd: Vec<String> = foreground_cmds
+ .get(terminal_id)
.cloned()
.unwrap_or_default();
@@ -2281,16 +2288,18 @@ impl Pty {
if let Some(&child_pid) = self.id_to_child_pid.get(&terminal_id) {
// Query OS for current running command
if let Some(os_input) = self.bus.os_input.as_ref() {
- // First, try to get child process command (e.g., nvim running in bash)
- let ppids_to_cmds =
- os_input.get_all_cmds_by_ppid(&self.post_command_discovery_hook);
- let cmd_ps = ppids_to_cmds.get(&format!("{}", child_pid));
+ // First, try to get the foreground command (e.g., nvim running in bash)
+ let foreground_cmds = os_input.get_foreground_cmds(
+ &[(terminal_id, child_pid)],
+ &self.post_command_discovery_hook,
+ );
+ let cmd_foreground = foreground_cmds.get(&terminal_id);
- // If no child process, fall back to parent process (e.g., the shell itself)
+ // If no foreground command, fall back to the shell itself
let (_cwds, cmds) = os_input.get_cwds(vec![child_pid]);
let cmd_sysinfo = cmds.get(&child_pid);
- if let Some(command_args) = cmd_ps {
+ if let Some(command_args) = cmd_foreground {
GetPaneRunningCommandResponse::Ok(command_args.clone())
} else if let Some(command_args) = cmd_sysinfo {
GetPaneRunningCommandResponse::Ok(command_args.clone())
diff --git a/zellij-server/src/unit/os_input_output_tests.rs b/zellij-server/src/unit/os_input_output_tests.rs
index 994ed984..71e08b7a 100644
--- a/zellij-server/src/unit/os_input_output_tests.rs
+++ b/zellij-server/src/unit/os_input_output_tests.rs
@@ -201,3 +201,57 @@ fn spawn_and_read_output() {
output_str
);
}
+
+#[cfg(unix)]
+#[test]
+fn tcgetpgrp_returns_foreground_group() {
+ use crate::panes::PaneId;
+ use zellij_utils::input::command::TerminalAction;
+
+ let server = make_server();
+
+ // `login_tty` in the child makes the spawned command the controlling terminal's
+ // foreground process group, so tcgetpgrp(master) should return its pid
+ let cmd = RunCommand {
+ command: PathBuf::from("sleep"),
+ args: vec!["60".to_string()],
+ ..Default::default()
+ };
+ let quit_cb: Box<dyn Fn(PaneId, Option<i32>, RunCommand) + Send> = Box::new(|_, _, _| {});
+ let (terminal_id, _reader, child_pid) = server
+ .spawn_terminal(TerminalAction::RunCommand(cmd), quit_cb, None)
+ .expect("spawn_terminal should succeed");
+
+ // poll (bounded to ~2s) for the child to finish setting up its controlling terminal
+ let mut fpgid = None;
+ for _ in 0..100 {
+ fpgid = server.pty_backend.tcgetpgrp(terminal_id);
+ let ready = match (fpgid, child_pid) {
+ (Some(p), Some(child_pid)) => p > 0 && p as u32 == child_pid,
+ (Some(p), None) => p > 0,
+ _ => false,
+ };
+ if ready {
+ break;
+ }
+ std::thread::sleep(std::time::Duration::from_millis(20));
+ }
+ assert!(
+ matches!(fpgid, Some(p) if p > 0),
+ "tcgetpgrp should return the foreground process group, got {:?}",
+ fpgid
+ );
+ if let (Some(fpgid), Some(child_pid)) = (fpgid, child_pid) {
+ assert_eq!(
+ fpgid as u32, child_pid,
+ "the spawned command leads its own foreground group"
+ );
+ }
+
+ // An unknown terminal id has no fd and must not panic.
+ assert_eq!(server.pty_backend.tcgetpgrp(u32::MAX), None);
+
+ if let Some(child_pid) = child_pid {
+ let _ = server.force_kill(child_pid);
+ }
+}
diff --git a/zellij-server/src/unit/pty_tests.rs b/zellij-server/src/unit/pty_tests.rs
index 1080b6ca..39463a46 100644
--- a/zellij-server/src/unit/pty_tests.rs
+++ b/zellij-server/src/unit/pty_tests.rs
@@ -125,6 +125,21 @@ impl ServerOsApi for MockOsApi {
fn get_all_cmds_by_ppid(&self, _: &Option<String>) -> HashMap<String, Vec<String>> {
self.cmds_by_ppid.lock().unwrap().clone()
}
+ fn get_foreground_cmds(
+ &self,
+ panes: &[(u32, u32)],
+ _: &Option<String>,
+ ) -> HashMap<u32, Vec<String>> {
+ let cmds_by_ppid = self.cmds_by_ppid.lock().unwrap();
+ panes
+ .iter()
+ .filter_map(|(terminal_id, shell_pid)| {
+ cmds_by_ppid
+ .get(&shell_pid.to_string())
+ .map(|cmd| (*terminal_id, cmd.clone()))
+ })
+ .collect()
+ }
fn write_to_file(&mut self, _: String, _: Option<String>) -> anyhow::Result<()> {
Ok(())
}