use std::collections::{BTreeMap, HashMap};
use std::io;
use std::path::{Path, PathBuf};
use std::process::{Child, Command};
use std::sync::{Arc, Mutex};
use crate::lsp::registry::ServerKind;
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct LspChildRootHealth {
pub root: String,
pub kind: String,
pub count: usize,
pub rss_bytes: u64,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct LspChildHealth {
pub spawned: usize,
pub cwd_gone: usize,
pub children_total: usize,
pub children_by_root: Vec<LspChildRootHealth>,
pub children_roots_total: usize,
pub children_roots_omitted: usize,
pub children_omitted_total: usize,
pub children_omitted_rss_bytes: u64,
pub children_without_client: usize,
pub children_with_deleted_cwd: usize,
}
#[derive(Clone, Debug, Default)]
struct TrackedChild {
root: Option<PathBuf>,
server_root: Option<PathBuf>,
kind: Option<ServerKind>,
client_live: bool,
}
#[derive(Clone, Default)]
pub struct LspChildRegistry {
inner: Arc<Mutex<HashMap<u32, TrackedChild>>>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ReapSignal {
Sigterm,
}
impl LspChildRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn track(&self, pid: u32) {
self.track_in_root(pid, None);
}
pub fn track_in_root(&self, pid: u32, root: Option<&Path>) {
self.track_child(pid, root, None, None);
}
pub fn track_child(
&self,
pid: u32,
root: Option<&Path>,
server_root: Option<&Path>,
kind: Option<&ServerKind>,
) {
if let Ok(mut children) = self.inner.lock() {
children.insert(
pid,
TrackedChild {
root: root.map(Path::to_path_buf),
server_root: server_root.map(Path::to_path_buf),
kind: kind.cloned(),
client_live: false,
},
);
}
}
pub fn spawn_tracked(&self, command: &mut Command) -> io::Result<Child> {
self.spawn_tracked_in_root(command, None)
}
pub fn spawn_tracked_in_root(
&self,
command: &mut Command,
root: Option<&Path>,
) -> io::Result<Child> {
self.spawn_tracked_child(command, root, None, None)
}
pub fn spawn_tracked_child(
&self,
command: &mut Command,
root: Option<&Path>,
server_root: Option<&Path>,
kind: Option<&ServerKind>,
) -> io::Result<Child> {
let mut children = self
.inner
.lock()
.map_err(|_| io::Error::other("LSP child registry mutex poisoned"))?;
let child = command.spawn()?;
children.insert(
child.id(),
TrackedChild {
root: root.map(Path::to_path_buf),
server_root: server_root.map(Path::to_path_buf),
kind: kind.cloned(),
client_live: false,
},
);
Ok(child)
}
pub(crate) fn mark_client_live(&self, pid: u32) {
if let Ok(mut children) = self.inner.lock() {
if let Some(child) = children.get_mut(&pid) {
child.client_live = true;
}
}
}
pub(crate) fn mark_client_gone(&self, pid: u32) {
if let Ok(mut children) = self.inner.lock() {
if let Some(child) = children.get_mut(&pid) {
child.client_live = false;
}
}
}
pub fn untrack(&self, pid: u32) {
if let Ok(mut children) = self.inner.lock() {
children.remove(&pid);
}
}
pub fn pids(&self) -> Vec<u32> {
self.tracked_children()
.into_iter()
.map(|(pid, _)| pid)
.collect()
}
fn tracked_children(&self) -> Vec<(u32, TrackedChild)> {
self.inner
.lock()
.map(|children| {
children
.iter()
.map(|(pid, tracked)| (*pid, tracked.clone()))
.collect()
})
.unwrap_or_default()
}
pub fn pids_for_server(&self, server_root: &Path, kind: &ServerKind) -> Vec<u32> {
self.tracked_children()
.into_iter()
.filter(|(_, tracked)| {
tracked.server_root.as_deref() == Some(server_root)
&& tracked.kind.as_ref() == Some(kind)
})
.map(|(pid, _)| pid)
.collect()
}
pub fn reap_pids(&self, pids: &[u32]) -> usize {
let mut reaped = 0;
for pid in pids {
if kill_child_process_group(*pid) {
self.untrack(*pid);
reaped += 1;
}
}
reaped
}
pub fn health_snapshot(&self) -> LspChildHealth {
health_for_children(self.tracked_children())
}
pub fn try_health_snapshot(&self) -> Option<LspChildHealth> {
let children = self
.inner
.try_lock()
.ok()?
.iter()
.map(|(pid, tracked)| (*pid, tracked.clone()))
.collect::<Vec<_>>();
Some(health_for_children(children))
}
pub fn reap_children_without_client(&self) -> usize {
let pids = self
.tracked_children()
.into_iter()
.filter_map(|(pid, tracked)| (!tracked.client_live).then_some(pid))
.collect::<Vec<_>>();
self.reap_pids(&pids)
}
pub fn reap_children_with_gone_cwd(&self) -> usize {
self.reap_children_without_client()
+ self.reap_children_using(false, |pid, _| kill_child_process_group(pid))
}
pub fn reap_children_with_gone_cwd_or_reclaimed_root(&self) -> usize {
self.reap_children_without_client()
+ self.reap_children_using(true, |pid, _| kill_child_process_group(pid))
}
fn reap_children_using<Terminate>(
&self,
include_reclaimed_roots: bool,
mut terminate: Terminate,
) -> usize
where
Terminate: FnMut(u32, ReapSignal) -> bool,
{
let mut reaped = 0;
for (pid, tracked) in self.tracked_children() {
let has_gone_cwd = matches!(child_cwd_state(pid), ChildCwdState::Gone);
let has_reclaimed_root = include_reclaimed_roots
&& tracked.root.as_deref().is_some_and(root_has_reclaim_marker);
if !has_gone_cwd && !has_reclaimed_root {
continue;
}
if terminate(pid, ReapSignal::Sigterm) {
self.untrack(pid);
reaped += 1;
}
}
reaped
}
#[cfg(unix)]
pub fn kill_all(&self) -> usize {
use std::os::raw::c_int;
let pids = self.pids();
let mut killed = 0;
for pid in pids {
unsafe {
let pgid = pid as libc::pid_t;
let rc = libc::killpg(pgid, 9 as c_int);
if rc == 0 {
killed += 1;
}
}
}
killed
}
#[cfg(not(unix))]
pub fn kill_all(&self) -> usize {
let pids = self.pids();
let mut killed = 0;
for pid in pids {
if std::process::Command::new("taskkill")
.args(["/F", "/T", "/PID", &pid.to_string()])
.status()
.is_ok()
{
killed += 1;
}
}
killed
}
}
pub fn reclaim_marker_path(root: &Path) -> PathBuf {
let mut marker = root.as_os_str().to_os_string();
marker.push(".reclaimed");
PathBuf::from(marker)
}
fn root_has_reclaim_marker(root: &Path) -> bool {
reclaim_marker_path(root).is_file()
}
const LSP_CHILD_ROOT_DETAIL_CAP: usize = 8;
fn health_for_children(children: Vec<(u32, TrackedChild)>) -> LspChildHealth {
#[derive(Default)]
struct RootAggregate {
count: usize,
rss_bytes: u64,
by_kind: BTreeMap<String, (usize, u64)>,
}
let mut roots = BTreeMap::<String, RootAggregate>::new();
let mut cwd_gone = 0;
let mut without_client = 0;
for (pid, tracked) in &children {
if matches!(child_cwd_state(*pid), ChildCwdState::Gone) {
cwd_gone += 1;
}
if !tracked.client_live {
without_client += 1;
}
let root = tracked
.server_root
.as_ref()
.or(tracked.root.as_ref())
.map(|root| root.display().to_string())
.unwrap_or_else(|| "<unknown>".to_string());
let kind = tracked
.kind
.as_ref()
.map(|kind| kind.id_str().to_string())
.unwrap_or_else(|| "unknown".to_string());
let rss_bytes = child_rss_bytes(*pid);
let aggregate = roots.entry(root).or_default();
aggregate.count += 1;
aggregate.rss_bytes = aggregate.rss_bytes.saturating_add(rss_bytes);
let kind_aggregate = aggregate.by_kind.entry(kind).or_default();
kind_aggregate.0 += 1;
kind_aggregate.1 = kind_aggregate.1.saturating_add(rss_bytes);
}
let roots_total = roots.len();
let mut roots = roots.into_iter().collect::<Vec<_>>();
roots.sort_by(|(left_root, left), (right_root, right)| {
right
.count
.cmp(&left.count)
.then_with(|| left_root.cmp(right_root))
});
let omitted = if roots.len() > LSP_CHILD_ROOT_DETAIL_CAP {
roots.split_off(LSP_CHILD_ROOT_DETAIL_CAP)
} else {
Vec::new()
};
let children_omitted_total = omitted.iter().map(|(_, root)| root.count).sum();
let children_omitted_rss_bytes = omitted
.iter()
.map(|(_, root)| root.rss_bytes)
.fold(0u64, u64::saturating_add);
let mut children_by_root = roots
.into_iter()
.flat_map(|(root, aggregate)| {
aggregate
.by_kind
.into_iter()
.map(move |(kind, (count, rss_bytes))| LspChildRootHealth {
root: root.clone(),
kind,
count,
rss_bytes,
})
})
.collect::<Vec<_>>();
children_by_root.sort_by(|left, right| {
left.root
.cmp(&right.root)
.then_with(|| left.kind.cmp(&right.kind))
});
LspChildHealth {
spawned: children.len(),
cwd_gone,
children_total: children.len(),
children_by_root,
children_roots_total: roots_total,
children_roots_omitted: omitted.len(),
children_omitted_total,
children_omitted_rss_bytes,
children_without_client: without_client,
children_with_deleted_cwd: cwd_gone,
}
}
#[cfg(target_os = "linux")]
fn child_rss_bytes(pid: u32) -> u64 {
let Ok(statm) = std::fs::read_to_string(format!("/proc/{pid}/statm")) else {
return 0;
};
let Some(resident_pages) = statm
.split_whitespace()
.nth(1)
.and_then(|pages| pages.parse::<u64>().ok())
else {
return 0;
};
let page_size = unsafe { libc::sysconf(libc::_SC_PAGESIZE) };
u64::try_from(page_size)
.ok()
.and_then(|page_size| resident_pages.checked_mul(page_size))
.unwrap_or(0)
}
#[cfg(target_os = "macos")]
fn child_rss_bytes(pid: u32) -> u64 {
const PROC_PIDTASKINFO: libc::c_int = 4;
#[repr(C)]
struct ProcTaskInfo {
_virtual_size: u64,
resident_size: u64,
_total_user: u64,
_total_system: u64,
_threads_user: u64,
_threads_system: u64,
_policy: i32,
_faults: i32,
_pageins: i32,
_cow_faults: i32,
_messages_sent: i32,
_messages_received: i32,
_syscalls_mach: i32,
_syscalls_unix: i32,
_context_switches: i32,
_thread_count: i32,
_running_thread_count: i32,
_priority: i32,
}
#[link(name = "proc")]
extern "C" {
fn proc_pidinfo(
pid: libc::c_int,
flavor: libc::c_int,
arg: u64,
buffer: *mut libc::c_void,
buffer_size: libc::c_int,
) -> libc::c_int;
}
let Ok(pid) = libc::c_int::try_from(pid) else {
return 0;
};
let mut info: ProcTaskInfo = unsafe { std::mem::zeroed() };
let Ok(buffer_size) = libc::c_int::try_from(std::mem::size_of::<ProcTaskInfo>()) else {
return 0;
};
let bytes = unsafe {
proc_pidinfo(
pid,
PROC_PIDTASKINFO,
0,
(&mut info as *mut ProcTaskInfo).cast(),
buffer_size,
)
};
(bytes > 0).then_some(info.resident_size).unwrap_or(0)
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
fn child_rss_bytes(_pid: u32) -> u64 {
0
}
#[derive(Debug)]
enum ChildCwdState {
Present,
Gone,
Unknown,
}
fn child_cwd_state(pid: u32) -> ChildCwdState {
let Ok(cwd) = child_cwd(pid) else {
return ChildCwdState::Unknown;
};
match cwd.try_exists() {
Ok(true) => ChildCwdState::Present,
Ok(false) => ChildCwdState::Gone,
Err(_) => ChildCwdState::Unknown,
}
}
#[cfg(target_os = "linux")]
fn child_cwd(pid: u32) -> io::Result<PathBuf> {
std::fs::read_link(format!("/proc/{pid}/cwd"))
}
#[cfg(target_os = "macos")]
fn child_cwd(pid: u32) -> io::Result<PathBuf> {
use std::ffi::CStr;
use std::mem::{size_of, zeroed};
use std::os::unix::ffi::OsStrExt;
const PROC_PIDVNODEPATHINFO: libc::c_int = 9;
#[repr(C)]
struct VInfoStat {
dev: u32,
mode: u16,
nlink: u16,
ino: u64,
uid: u32,
gid: u32,
atime: i64,
atime_nsec: i64,
mtime: i64,
mtime_nsec: i64,
ctime: i64,
ctime_nsec: i64,
birthtime: i64,
birthtime_nsec: i64,
size: i64,
blocks: i64,
block_size: i32,
flags: u32,
generation: u32,
raw_device: u32,
spare: [i64; 2],
}
#[repr(C)]
struct VnodeInfo {
stat: VInfoStat,
vnode_type: i32,
pad: i32,
fsid: [i32; 2],
}
#[repr(C)]
struct VnodeInfoPath {
info: VnodeInfo,
path: [libc::c_char; libc::MAXPATHLEN as usize],
}
#[repr(C)]
struct ProcVnodePathInfo {
cwd: VnodeInfoPath,
root: VnodeInfoPath,
}
#[link(name = "proc")]
extern "C" {
fn proc_pidinfo(
pid: libc::c_int,
flavor: libc::c_int,
arg: u64,
buffer: *mut libc::c_void,
buffer_size: libc::c_int,
) -> libc::c_int;
}
let pid = libc::c_int::try_from(pid)
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "PID exceeds c_int"))?;
let mut info: ProcVnodePathInfo = unsafe { zeroed() };
let buffer_size = libc::c_int::try_from(size_of::<ProcVnodePathInfo>())
.map_err(|_| io::Error::other("proc vnode path buffer is too large"))?;
let bytes = unsafe {
proc_pidinfo(
pid,
PROC_PIDVNODEPATHINFO,
0,
(&mut info as *mut ProcVnodePathInfo).cast(),
buffer_size,
)
};
if bytes <= 0 {
return Err(io::Error::last_os_error());
}
let cwd = unsafe { CStr::from_ptr(info.cwd.path.as_ptr()) };
Ok(PathBuf::from(std::ffi::OsStr::from_bytes(cwd.to_bytes())))
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
fn child_cwd(_pid: u32) -> io::Result<PathBuf> {
Err(io::Error::new(
io::ErrorKind::Unsupported,
"child cwd lookup is unsupported on this platform",
))
}
#[cfg(unix)]
fn kill_child_process_group(pid: u32) -> bool {
let Ok(pgid) = libc::pid_t::try_from(pid) else {
return false;
};
let result = unsafe { libc::killpg(pgid, libc::SIGTERM) };
result == 0 || io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH)
}
#[cfg(not(unix))]
fn kill_child_process_group(pid: u32) -> bool {
std::process::Command::new("taskkill")
.args(["/F", "/T", "/PID", &pid.to_string()])
.status()
.is_ok_and(|status| status.success())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn track_untrack_pids_round_trip() {
let reg = LspChildRegistry::new();
reg.track(100);
reg.track(200);
let mut pids = reg.pids();
pids.sort();
assert_eq!(pids, vec![100, 200]);
reg.untrack(100);
assert_eq!(reg.pids(), vec![200]);
}
#[test]
fn clones_share_state() {
let a = LspChildRegistry::new();
let b = a.clone();
a.track(42);
assert_eq!(b.pids(), vec![42]);
b.untrack(42);
assert!(a.pids().is_empty());
}
#[test]
fn pids_for_server_filters_by_root_and_kind() {
let reg = LspChildRegistry::new();
let root_a = PathBuf::from("/tmp/a");
let root_b = PathBuf::from("/tmp/b");
reg.track_child(1, Some(&root_a), Some(&root_a), Some(&ServerKind::Rust));
reg.track_child(
2,
Some(&root_a),
Some(&root_a),
Some(&ServerKind::TypeScript),
);
reg.track_child(3, Some(&root_b), Some(&root_b), Some(&ServerKind::Rust));
let mut rust_a = reg.pids_for_server(&root_a, &ServerKind::Rust);
rust_a.sort();
assert_eq!(rust_a, vec![1]);
reg.untrack(1);
reg.untrack(2);
reg.untrack(3);
}
#[test]
fn untracking_unknown_pid_is_safe() {
let reg = LspChildRegistry::new();
reg.untrack(999); assert!(reg.pids().is_empty());
}
#[test]
fn health_snapshot_counts_spawned_child_with_live_cwd() {
let reg = LspChildRegistry::new();
reg.track(std::process::id());
let health = reg.health_snapshot();
assert_eq!(health.spawned, 1);
assert_eq!(health.children_total, 1);
assert_eq!(health.cwd_gone, 0);
assert_eq!(health.children_with_deleted_cwd, 0);
assert_eq!(health.children_without_client, 1);
reg.untrack(std::process::id());
}
#[test]
fn kill_all_with_no_pids_returns_zero() {
let reg = LspChildRegistry::new();
assert_eq!(reg.kill_all(), 0);
}
#[test]
fn spawn_tracked_records_pid_before_returning() {
let reg = LspChildRegistry::new();
let mut command = if cfg!(windows) {
let mut command = std::process::Command::new("cmd");
command.args(["/C", "exit", "0"]);
command
} else {
let mut command = std::process::Command::new("sh");
command.args(["-c", "exit 0"]);
command
};
let mut child = reg.spawn_tracked(&mut command).expect("spawn tracked");
let pid = child.id();
assert!(reg.pids().contains(&pid));
let _ = child.wait();
reg.untrack(pid);
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn maintenance_reaps_child_whose_cwd_was_deleted() {
use std::os::unix::process::CommandExt;
let root = tempfile::tempdir().expect("tempdir");
let reg = LspChildRegistry::new();
let mut command = Command::new("sh");
command
.args(["-c", "exec sleep 60"])
.current_dir(root.path());
unsafe {
command.pre_exec(|| {
if libc::setsid() == -1 {
return Err(io::Error::last_os_error());
}
Ok(())
});
}
let mut child = reg.spawn_tracked(&mut command).expect("spawn child");
reg.mark_client_live(child.id());
root.close().expect("delete child cwd");
let health = reg.health_snapshot();
assert_eq!(health.spawned, 1);
assert_eq!(health.children_total, 1);
assert_eq!(health.cwd_gone, 1);
assert_eq!(health.children_with_deleted_cwd, 1);
assert_eq!(health.children_without_client, 0);
assert_eq!(reg.reap_children_with_gone_cwd(), 1);
child.wait().expect("reap child");
assert_eq!(reg.health_snapshot(), LspChildHealth::default());
}
#[cfg(unix)]
#[test]
fn kill_all_kills_process_group_not_just_wrapper_pid() {
use std::os::unix::process::CommandExt;
use std::process::Command;
use std::thread;
use std::time::{Duration, Instant};
fn process_running(pid: u32) -> bool {
let Ok(pid_i) = i32::try_from(pid) else {
return false;
};
let output = Command::new("ps")
.args(["-o", "stat=", "-p", &pid_i.to_string()])
.output()
.expect("ps");
if !output.status.success() {
return false;
}
let stat = String::from_utf8_lossy(&output.stdout);
!stat.is_empty() && !stat.contains('Z')
}
fn wait_until_not_running(pid: u32, timeout: Duration) -> bool {
let started = Instant::now();
while started.elapsed() < timeout {
if !process_running(pid) {
return true;
}
thread::sleep(Duration::from_millis(50));
}
false
}
let dir = tempfile::tempdir().expect("tempdir");
let pid_file = dir.path().join("grandchild.pid");
const PID_FILE_ENV: &str = "AFT_LSP_KILLALL_TEST_PID_FILE";
let mut child = unsafe {
let mut cmd = Command::new("sh");
cmd.arg("-c")
.arg("sleep 60 & echo $! > \"$AFT_LSP_KILLALL_TEST_PID_FILE\"; wait")
.env(PID_FILE_ENV, &pid_file);
cmd.pre_exec(|| {
if libc::setsid() == -1 {
return Err(std::io::Error::last_os_error());
}
Ok(())
});
cmd.spawn().expect("spawn wrapper")
};
let wrapper_pid = child.id();
let started = Instant::now();
let grandchild_pid: u32 = loop {
if let Some(pid) = std::fs::read_to_string(&pid_file)
.ok()
.and_then(|contents| contents.trim().parse::<u32>().ok())
{
break pid;
}
assert!(
started.elapsed() < Duration::from_secs(5),
"timed out waiting for a parseable grandchild pid file"
);
thread::sleep(Duration::from_millis(20));
};
assert!(process_running(wrapper_pid), "wrapper should be running");
assert!(
process_running(grandchild_pid),
"grandchild should be running"
);
let reg = LspChildRegistry::new();
reg.track(wrapper_pid);
let killed = reg.kill_all();
assert_eq!(killed, 1, "should report 1 group killed");
let _ = child.wait();
assert!(
wait_until_not_running(wrapper_pid, Duration::from_secs(5)),
"wrapper must stop after killpg"
);
assert!(
wait_until_not_running(grandchild_pid, Duration::from_secs(5)),
"grandchild must stop after killpg (this was the npm-wrapper orphan bug)"
);
}
#[test]
fn maintenance_reaps_child_at_existing_reclaimed_worktree() {
let parent = tempfile::tempdir().expect("tempdir");
let worktree = parent.path().join("task-worktree");
std::fs::create_dir(&worktree).expect("create worktree");
std::fs::write(reclaim_marker_path(&worktree), "settled\n").expect("write reclaim marker");
let registry = LspChildRegistry::new();
registry.track_in_root(42, Some(&worktree));
registry.mark_client_live(42);
let mut signals = Vec::new();
let reaped = registry.reap_children_using(true, |pid, signal| {
signals.push((pid, signal));
true
});
assert!(
worktree.is_dir(),
"the marker must reap an existing worktree"
);
assert_eq!(reaped, 1);
assert_eq!(signals, vec![(42, ReapSignal::Sigterm)]);
assert!(registry.pids().is_empty(), "reaped child must be untracked");
}
#[test]
fn maintenance_keeps_child_when_existing_worktree_has_no_reclaim_marker() {
let parent = tempfile::tempdir().expect("tempdir");
let worktree = parent.path().join("active-worktree");
std::fs::create_dir(&worktree).expect("create worktree");
let registry = LspChildRegistry::new();
registry.track_in_root(42, Some(&worktree));
registry.mark_client_live(42);
let mut signals = Vec::new();
let reaped = registry.reap_children_using(true, |pid, signal| {
signals.push((pid, signal));
true
});
assert_eq!(reaped, 0);
assert!(
signals.is_empty(),
"active worktrees must not receive SIGTERM"
);
assert_eq!(registry.pids(), vec![42]);
}
}