Skip to content

Commit 25d8568

Browse files
matej21claude
andcommitted
perf: batch dtach PID lookup via /proc instead of spawning lsof per terminal
Port detection was spawning a separate `lsof -t <socket>` process for each dtach terminal (~1s each, sequentially). On Linux, replace with a single read of /proc/net/unix + /proc/*/fd/ to resolve all socket→PID mappings at once. On macOS, use a single `lsof -F pn` call for all sockets. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 8653960 commit 25d8568

File tree

3 files changed

+289
-2
lines changed

3 files changed

+289
-2
lines changed

crates/okena-services/src/manager.rs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -868,10 +868,20 @@ impl ServiceManager {
868868
let results: Vec<((String, String), Vec<u16>)> = cx
869869
.background_executor()
870870
.spawn(async move {
871-
// Get root PIDs per service (may spawn lsof/tmux per terminal)
871+
// Get root PIDs for all services in one batch.
872+
// On Linux+dtach this reads /proc once instead of spawning lsof per terminal.
873+
let terminal_ids: Vec<&str> =
874+
services.iter().map(|(_, tid)| tid.as_str()).collect();
875+
let batch_pids = backend_ref.get_batch_service_pids(&terminal_ids);
872876
let service_root_pids: Vec<((String, String), Vec<u32>)> = services
873877
.iter()
874-
.map(|(key, tid)| (key.clone(), backend_ref.get_service_pids(tid)))
878+
.map(|(key, tid)| {
879+
let pids = batch_pids
880+
.get(tid.as_str())
881+
.cloned()
882+
.unwrap_or_default();
883+
(key.clone(), pids)
884+
})
875885
.collect();
876886

877887
// Build process tree ONCE for all services

crates/okena-terminal/src/backend.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use crate::terminal::TerminalTransport;
22
use crate::pty_manager::PtyManager;
33
use crate::shell_config::ShellType;
44
use anyhow::Result;
5+
use std::collections::HashMap;
56
use std::path::PathBuf;
67
use std::sync::Arc;
78

@@ -19,6 +20,14 @@ pub trait TerminalBackend: Send + Sync {
1920
/// Get root PIDs for port detection. With session backends (dtach/tmux),
2021
/// this returns the daemon/pane PID instead of the attach client PID.
2122
fn get_service_pids(&self, terminal_id: &str) -> Vec<u32>;
23+
/// Batch version of `get_service_pids` — returns root PIDs for multiple terminals at once.
24+
/// On Linux with dtach, this reads `/proc` once instead of spawning `lsof` per terminal.
25+
fn get_batch_service_pids(&self, terminal_ids: &[&str]) -> HashMap<String, Vec<u32>> {
26+
terminal_ids
27+
.iter()
28+
.map(|tid| (tid.to_string(), self.get_service_pids(tid)))
29+
.collect()
30+
}
2231
}
2332

2433
/// Local backend wrapping PtyManager for local terminal processes.
@@ -68,4 +77,8 @@ impl TerminalBackend for LocalBackend {
6877
fn get_service_pids(&self, terminal_id: &str) -> Vec<u32> {
6978
self.pty_manager.get_service_pids(terminal_id)
7079
}
80+
81+
fn get_batch_service_pids(&self, terminal_ids: &[&str]) -> HashMap<String, Vec<u32>> {
82+
self.pty_manager.get_batch_service_pids(terminal_ids)
83+
}
7184
}

crates/okena-terminal/src/pty_manager.rs

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -669,6 +669,94 @@ impl PtyManager {
669669
}
670670
}
671671

672+
/// Batch version of `get_service_pids` for multiple terminals at once.
673+
/// On Linux with dtach, reads `/proc` once instead of spawning `lsof` per terminal.
674+
pub fn get_batch_service_pids(&self, terminal_ids: &[&str]) -> HashMap<String, Vec<u32>> {
675+
#[cfg(unix)]
676+
{
677+
match self.session_backend {
678+
ResolvedBackend::Dtach => {
679+
return self.get_batch_dtach_service_pids(terminal_ids);
680+
}
681+
_ => {}
682+
}
683+
}
684+
// Fallback: call per-terminal method
685+
terminal_ids
686+
.iter()
687+
.map(|tid| (tid.to_string(), self.get_service_pids(tid)))
688+
.collect()
689+
}
690+
691+
/// Batch dtach PID lookup. On Linux, reads `/proc/net/unix` + `/proc/*/fd/`
692+
/// once for all sockets. On other Unix, falls back to lsof per terminal.
693+
#[cfg(unix)]
694+
fn get_batch_dtach_service_pids(&self, terminal_ids: &[&str]) -> HashMap<String, Vec<u32>> {
695+
// Collect socket paths for all terminals
696+
let mut socket_to_terminal: HashMap<std::path::PathBuf, &str> = HashMap::new();
697+
let mut attach_pids: HashMap<&str, Option<u32>> = HashMap::new();
698+
699+
for &tid in terminal_ids {
700+
let session_name = self.session_backend.session_name(tid);
701+
if let Some(p) = self.session_backend.socket_path(&session_name) {
702+
if p.exists() {
703+
socket_to_terminal.insert(p, tid);
704+
attach_pids.insert(tid, self.get_shell_pid(tid));
705+
}
706+
}
707+
}
708+
709+
// Resolve PIDs for all sockets at once
710+
let socket_pids = find_pids_for_unix_sockets(
711+
&socket_to_terminal.keys().cloned().collect::<Vec<_>>(),
712+
);
713+
714+
// Build result map
715+
let mut result: HashMap<String, Vec<u32>> = HashMap::new();
716+
for (&tid, _) in &attach_pids {
717+
let session_name = self.session_backend.session_name(tid);
718+
let socket_path = match self.session_backend.socket_path(&session_name) {
719+
Some(p) => p,
720+
None => {
721+
result.insert(
722+
tid.to_string(),
723+
self.get_shell_pid(tid).into_iter().collect(),
724+
);
725+
continue;
726+
}
727+
};
728+
729+
let attach_pid = attach_pids.get(tid).copied().flatten();
730+
let pids: Vec<u32> = socket_pids
731+
.get(&socket_path)
732+
.map(|pids| {
733+
pids.iter()
734+
.copied()
735+
.filter(|pid| Some(*pid) != attach_pid)
736+
.collect()
737+
})
738+
.unwrap_or_default();
739+
740+
if pids.is_empty() {
741+
result.insert(
742+
tid.to_string(),
743+
self.get_shell_pid(tid).into_iter().collect(),
744+
);
745+
} else {
746+
result.insert(tid.to_string(), pids);
747+
}
748+
}
749+
750+
// Terminals without a valid socket path
751+
for &tid in terminal_ids {
752+
result
753+
.entry(tid.to_string())
754+
.or_insert_with(|| self.get_shell_pid(tid).into_iter().collect());
755+
}
756+
757+
result
758+
}
759+
672760
/// Check if the session backend handles mouse events (tmux with mouse on)
673761
pub fn uses_mouse_backend(&self) -> bool {
674762
matches!(self.session_backend, ResolvedBackend::Tmux)
@@ -847,3 +935,179 @@ fn wait_for_exit_code(pid: u32) -> Option<u32> {
847935
}
848936
}
849937

938+
/// Find which PIDs have the given Unix sockets open.
939+
///
940+
/// On Linux, reads `/proc/net/unix` to map socket paths to inode numbers,
941+
/// then scans `/proc/*/fd/` to find PIDs holding those inodes.
942+
/// On other Unix systems, falls back to a single `lsof` invocation.
943+
#[cfg(unix)]
944+
fn find_pids_for_unix_sockets(
945+
socket_paths: &[std::path::PathBuf],
946+
) -> HashMap<std::path::PathBuf, Vec<u32>> {
947+
if socket_paths.is_empty() {
948+
return HashMap::new();
949+
}
950+
951+
#[cfg(target_os = "linux")]
952+
{
953+
find_pids_for_unix_sockets_linux(socket_paths)
954+
}
955+
956+
#[cfg(not(target_os = "linux"))]
957+
{
958+
find_pids_for_unix_sockets_lsof(socket_paths)
959+
}
960+
}
961+
962+
/// Linux implementation: read `/proc/net/unix` and `/proc/*/fd/` — no subprocess spawning.
963+
#[cfg(target_os = "linux")]
964+
fn find_pids_for_unix_sockets_linux(
965+
socket_paths: &[std::path::PathBuf],
966+
) -> HashMap<std::path::PathBuf, Vec<u32>> {
967+
// Step 1: Read /proc/net/unix to find inodes for our socket paths.
968+
// Format: "Num RefCount Protocol Flags Type St Inode Path"
969+
let proc_net = match std::fs::read_to_string("/proc/net/unix") {
970+
Ok(s) => s,
971+
Err(_) => return HashMap::new(),
972+
};
973+
974+
// Build a set of canonical socket paths for fast lookup
975+
let canonical_paths: HashMap<std::path::PathBuf, &std::path::PathBuf> = socket_paths
976+
.iter()
977+
.filter_map(|p| std::fs::canonicalize(p).ok().map(|c| (c, p)))
978+
.collect();
979+
980+
// Map inode -> original socket path
981+
let mut inode_to_path: HashMap<u64, &std::path::PathBuf> = HashMap::new();
982+
for line in proc_net.lines().skip(1) {
983+
// Fields are space-separated; path is the last field (may be absent)
984+
let fields: Vec<&str> = line.split_whitespace().collect();
985+
if fields.len() < 8 {
986+
continue;
987+
}
988+
let inode: u64 = match fields[6].parse() {
989+
Ok(i) => i,
990+
Err(_) => continue,
991+
};
992+
let path_str = fields[7];
993+
let path = std::path::Path::new(path_str);
994+
995+
// Check against canonical paths
996+
if let Some(&orig) = canonical_paths.get(path) {
997+
inode_to_path.insert(inode, orig);
998+
} else if let Ok(canon) = std::fs::canonicalize(path) {
999+
if let Some(&orig) = canonical_paths.get(&canon) {
1000+
inode_to_path.insert(inode, orig);
1001+
}
1002+
}
1003+
}
1004+
1005+
if inode_to_path.is_empty() {
1006+
return HashMap::new();
1007+
}
1008+
1009+
// Step 2: Scan /proc/*/fd/ to find PIDs that hold these inodes.
1010+
let mut result: HashMap<std::path::PathBuf, Vec<u32>> = HashMap::new();
1011+
1012+
let proc_dir = match std::fs::read_dir("/proc") {
1013+
Ok(d) => d,
1014+
Err(_) => return HashMap::new(),
1015+
};
1016+
1017+
for entry in proc_dir.flatten() {
1018+
let pid: u32 = match entry.file_name().to_str().and_then(|s| s.parse().ok()) {
1019+
Some(p) => p,
1020+
None => continue,
1021+
};
1022+
1023+
let fd_dir = entry.path().join("fd");
1024+
let fd_entries = match std::fs::read_dir(&fd_dir) {
1025+
Ok(d) => d,
1026+
Err(_) => continue, // permission denied or process gone
1027+
};
1028+
1029+
for fd_entry in fd_entries.flatten() {
1030+
// readlink on /proc/<pid>/fd/<n> gives "socket:[<inode>]"
1031+
let link = match std::fs::read_link(fd_entry.path()) {
1032+
Ok(l) => l,
1033+
Err(_) => continue,
1034+
};
1035+
let link_str = match link.to_str() {
1036+
Some(s) => s,
1037+
None => continue,
1038+
};
1039+
// Parse "socket:[12345]"
1040+
if let Some(inode_str) = link_str
1041+
.strip_prefix("socket:[")
1042+
.and_then(|s| s.strip_suffix(']'))
1043+
{
1044+
if let Ok(inode) = inode_str.parse::<u64>() {
1045+
if let Some(&socket_path) = inode_to_path.get(&inode) {
1046+
result
1047+
.entry(socket_path.clone())
1048+
.or_default()
1049+
.push(pid);
1050+
}
1051+
// Early exit if we found all inodes
1052+
// (not worth the bookkeeping for a small set)
1053+
}
1054+
}
1055+
}
1056+
}
1057+
1058+
result
1059+
}
1060+
1061+
/// Fallback for non-Linux Unix: single `lsof` call for all sockets.
1062+
#[cfg(all(unix, not(target_os = "linux")))]
1063+
fn find_pids_for_unix_sockets_lsof(
1064+
socket_paths: &[std::path::PathBuf],
1065+
) -> HashMap<std::path::PathBuf, Vec<u32>> {
1066+
// lsof can take multiple file arguments at once
1067+
let mut cmd = crate::process::command("lsof");
1068+
cmd.arg("-t");
1069+
for path in socket_paths {
1070+
cmd.arg(path);
1071+
}
1072+
1073+
let output = match crate::process::safe_output(&mut cmd) {
1074+
Ok(o) if o.status.success() => o,
1075+
_ => return HashMap::new(),
1076+
};
1077+
1078+
// lsof -t with multiple files just lists PIDs (no file association).
1079+
// We need per-file results, so use full output instead.
1080+
drop(output);
1081+
1082+
let mut cmd = crate::process::command("lsof");
1083+
cmd.arg("-F").arg("pn"); // machine-readable: p=PID, n=name fields
1084+
for path in socket_paths {
1085+
cmd.arg(path);
1086+
}
1087+
1088+
let output = match crate::process::safe_output(&mut cmd) {
1089+
Ok(o) if o.status.success() => o,
1090+
_ => return HashMap::new(),
1091+
};
1092+
1093+
let stdout = String::from_utf8_lossy(&output.stdout);
1094+
let mut result: HashMap<std::path::PathBuf, Vec<u32>> = HashMap::new();
1095+
let mut current_pid: Option<u32> = None;
1096+
1097+
// lsof -F output: lines starting with 'p' = PID, 'n' = name (path)
1098+
for line in stdout.lines() {
1099+
if let Some(pid_str) = line.strip_prefix('p') {
1100+
current_pid = pid_str.parse().ok();
1101+
} else if let Some(name) = line.strip_prefix('n') {
1102+
if let Some(pid) = current_pid {
1103+
let path = std::path::PathBuf::from(name);
1104+
if socket_paths.contains(&path) {
1105+
result.entry(path).or_default().push(pid);
1106+
}
1107+
}
1108+
}
1109+
}
1110+
1111+
result
1112+
}
1113+

0 commit comments

Comments
 (0)