Fix: LSP process-group cleanup no longer requires process-table enumeration

An independently-started LSP process is already its own POSIX process group leader
(start_new_session=True), so cleanup can signal that known group directly via os.killpg
instead of walking the tree with psutil.Process.children(recursive=True), which requires
system-wide process-table enumeration that can be denied even for processes we started
and own (#1818).
This commit is contained in:
Amir Fathi committed 2026-08-09 12:13:14 +00:00
1 parent c3f5aab59e
commit ec18c48202
4 files changed
+284 -4

No files matched your search

+11 -1
View File
@@ -1,6 +1,7 @@
import asyncio
import json
import logging
import os
import socket
import subprocess
import threading
@@ -510,6 +511,9 @@ class StdioLanguageServer(LanguageServerInterface):
self.process_launch_info = process_launch_info
self.process: subprocess.Popen[bytes] | None = None
self.start_independent_lsp_process = start_independent_lsp_process
self._process_group_id: int | None = None
"""the POSIX process group ID owned by ``self.process``, captured at launch time (see
``_start``); ``None`` on Windows or when the process was not started in its own session"""
self._stdin_lock = threading.Lock()
@@ -523,6 +527,8 @@ class StdioLanguageServer(LanguageServerInterface):
self.process_launch_info, start_new_session=self.start_independent_lsp_process
)
self.process = process
# start_new_session=True makes the new process its own group leader, so its PGID is its PID.
self._process_group_id = process.pid if (self.start_independent_lsp_process and os.name == "posix") else None
# Check if process terminated immediately
if process.returncode is not None:
@@ -559,10 +565,14 @@ class StdioLanguageServer(LanguageServerInterface):
# Ignore errors here, we are proceeding to terminate anyway.
# terminate the process
subprocess_util.terminate_process_tree_with_kill_fallback(
self.process, terminate_timeout=timeout, process_name=f"LS[{self.ls_id.value}]"
self.process,
terminate_timeout=timeout,
process_name=f"LS[{self.ls_id.value}]",
process_group_id=self._process_group_id,
)
finally:
self.process = None
self._process_group_id = None
@staticmethod
def _safely_close_pipe(pipe: IO[AnyStr] | None) -> None:
+43 -3
View File
@@ -246,7 +246,33 @@ def _signal_process_tree(process: subprocess.Popen[bytes], terminate: bool = Tru
signal_process(process)
def terminate_process_tree_with_kill_fallback(process: subprocess.Popen, terminate_timeout: float, process_name: str = "Process") -> None:
def _signal_process_group(pgid: int, terminate: bool = True) -> None:
"""
Sends a signal to every process in the given POSIX process group by group ID, without
enumerating the system process table. Requires the caller to know the group already exists
and is owned by us (see ``terminate_process_tree_with_kill_fallback``'s ``process_group_id``).
:param pgid: the process group ID to signal
:param terminate: if True, signal terminate (SIGTERM), otherwise signal kill (SIGKILL)
"""
sig = signal.SIGTERM if terminate else signal.SIGKILL
try:
os.killpg(pgid, sig)
except ProcessLookupError:
# the group is already gone; nothing left to signal
pass
except PermissionError as e:
log.warning(f"Permission denied signaling process group {pgid} with {sig.name}: {e}")
except Exception as e:
log.warning(f"Unexpected error signaling process group {pgid} with {sig.name}: {e}")
def terminate_process_tree_with_kill_fallback(
process: subprocess.Popen,
terminate_timeout: float,
process_name: str = "Process",
process_group_id: int | None = None,
) -> None:
"""
Attempts to terminate the given process and its children by signaling them to terminate,
and if that fails (i.e. they don't exit within the given timeout), forcefully kills them.
@@ -256,9 +282,23 @@ def terminate_process_tree_with_kill_fallback(process: subprocess.Popen, termina
:param process: the process to terminate
:param terminate_timeout: the time to wait for the process to terminate gracefully before killing it
:param process_name: the name of the process (used for logging purposes); should start with capital letter
:param process_group_id: if given, the POSIX process group ID that ``process`` leads (i.e. it was
launched with ``start_new_session=True``, so its PGID equals its PID). When set, cleanup
signals the whole group directly via ``os.killpg`` instead of walking the process tree with
``psutil``, which requires system-wide process-table enumeration (``sysctl(KERN_PROC_ALL)`` on
macOS) that can be denied even for processes we started and own. Only pass this for a process
that was started in its own session: signaling the group of a process that shares ours would
also signal us.
"""
log.debug(f"Terminating process {process.pid}, current status: {process.poll()}")
_signal_process_tree(process, terminate=True)
def signal_tree(terminate: bool) -> None:
if process_group_id is not None:
_signal_process_group(process_group_id, terminate=terminate)
else:
_signal_process_tree(process, terminate=terminate)
signal_tree(terminate=True)
try:
log.debug(f"Waiting for process {process.pid} to terminate...")
exit_code = process.wait(timeout=terminate_timeout)
@@ -266,7 +306,7 @@ def terminate_process_tree_with_kill_fallback(process: subprocess.Popen, termina
except subprocess.TimeoutExpired:
# If termination failed, forcefully kill the process
log.warning(f"{process_name} (pid={process.pid}) termination timed out, killing process forcefully...")
_signal_process_tree(process, terminate=False)
signal_tree(terminate=False)
try:
exit_code = process.wait(timeout=2.0)
log.info(f"{process_name} killed successfully with exit code {exit_code}.")