Skip to content

Commit f7fa6cd

Browse files
committed
Snapshot spawned server processes with psutil on every platform
The startup-timeout snapshot read /proc directly, so it returned an empty string off Linux and a failed start on Windows or macOS said nothing about what the spawned server was doing at the deadline. - Replace describe_process_group with describe_spawned_processes, built on psutil. On POSIX the server is spawned with start_new_session=True, so its pid is its process group and every member is listed, including children reparented after their parent exited. Windows has no process group, so the tree rooted at the spawned pid is listed by parent pid, falling back to processes whose ppid is the root when it already died. Windows lines omit state= (psutil reports `running` for nearly every process) and add ppid and thread count. - An unavailable snapshot now says why ("process snapshot unavailable: <reason>") instead of a fixed string, and never raises so it cannot mask the start error it explains. - Declare psutil as a finecode_jsonrpc dependency; it was previously only a dev/test dependency of the root project. - Cover the per-platform behaviour in test_proc_snapshot and the ServerFailedToStart message in test_startup_timeline. - Update wm-server-internals.md; the Job Object replacement that would make Windows membership a spawn-side record is tracked in issue 43.
1 parent 1edf7d5 commit f7fa6cd

7 files changed

Lines changed: 427 additions & 102 deletions

File tree

‎docs/guides/wm-server-internals.md‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -468,9 +468,14 @@ WARNING by `_start_extension_runner_process`, carrying `spawn_to_output_ms`, `sp
468468
and `spawn_to_connected_ms` plus the host's memory/IO pressure; a faster start is logged at DEBUG
469469
with the same fields. When the port handshake itself times out, the `ServerFailedToStart` message
470470
names the timeline (when it was spawned, whether the server produced any output, when its port
471-
line arrived) and a snapshot of the spawned process group at the deadline
471+
line arrived) and a snapshot of the spawned server's processes at the deadline
472472
(`finecode_jsonrpc._proc_snapshot`), followed by the stdout/stderr tails; the failing-start log
473-
line also carries `spawned_ago_ms` and host pressure. A start that reaches RUNNING logs
473+
line also carries `spawned_ago_ms` and host pressure. The snapshot is per-platform: on POSIX the
474+
server is spawned with `start_new_session=True`, so its pid is its process group and every member
475+
is listed (including children reparented after their parent exited); on Windows there is no process
476+
group, so the process tree rooted at the spawned pid is listed by parent pid, with no `state=`
477+
(psutil reports `running` for nearly every Windows process — see issue 43 for the Job Object
478+
replacement that would make the membership a spawn-side record). A start that reaches RUNNING logs
474479
`Runner <id> ready: connected_to_running_ms=… spawn_to_running_ms=…` (DEBUG, WARNING at
475480
≥ 10 s total); a start abandoned before RUNNING logs `Runner <id> abandoned:`
476481
`connected_to_abandon_ms=…` when its channel had connected. `initialize` / `updateConfig` failures

‎finecode_jsonrpc/pyproject.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ dependencies = [
1010
"culsans==0.11.*",
1111
"finecode_extension_api~=0.5.0a0",
1212
"loguru==0.7.*",
13+
"psutil==7.2.*",
1314
"typing-extensions (>=4.12.2,<5.0.0)",
1415
]
1516

Lines changed: 142 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,89 +1,162 @@
1+
"""Per-platform diagnostic snapshot of a spawned server's processes.
2+
3+
A start failure needs to say what the spawned server was doing at the
4+
deadline: its processes listed, "no live process" if it already exited, or
5+
"snapshot unavailable" with a reason. Never raises: a snapshot failure must
6+
not replace the start error it is explaining.
7+
"""
8+
19
from __future__ import annotations
210

311
import os
412
import sys
513
import typing
614

7-
__all__ = ["describe_process_group"]
15+
import psutil
816

17+
__all__ = ["describe_spawned_processes"]
918

10-
class _ProcStat(typing.NamedTuple):
11-
comm: str
12-
state: str
13-
pgrp: int
14-
utime: int
15-
stime: int
19+
# ``os.getpgid`` is POSIX-only in typeshed, but the name must resolve on
20+
# Windows too, where CI-win's type check runs with the win32 platform. Probed
21+
# 2026-09-24: a direct ``os.getpgid`` → ``missing-attribute`` under
22+
# ``--python-platform win32``, so it is looked up dynamically at module level.
23+
_getpgid = getattr(os, "getpgid", None)
1624

1725

18-
def _parse_stat(text: str) -> _ProcStat:
19-
"""Parse ``/proc/<pid>/stat``.
26+
def describe_spawned_processes(
27+
root_pid: int, limit: int = 10, platform: str = sys.platform
28+
) -> str:
29+
"""One diagnostic line per process the spawned server consists of.
2030
21-
``comm`` is wrapped in parentheses and may itself contain spaces or
22-
parentheses, so the fields after it are read from the *last* ``)`` — a
23-
naive ``split()`` would misalign on such a name.
31+
POSIX: the server is spawned with ``start_new_session=True``, so its pid is
32+
its process group; every member is listed, including children reparented
33+
after their parent exited. Windows has no process group: the tree rooted at
34+
*root_pid*, by parent pid (see issue 43 for the Job Object replacement).
35+
Returns the lines, ``"no live process in …"``, or
36+
``"process snapshot unavailable: <reason>"``. Never raises: a snapshot
37+
failure must not replace the start error it is explaining.
2438
"""
25-
comm = text[text.index("(") + 1 : text.rfind(")")]
26-
fields = text[text.rfind(")") + 2 :].split()
27-
return _ProcStat(
28-
comm=comm,
29-
state=fields[0],
30-
pgrp=int(fields[2]),
31-
utime=int(fields[11]),
32-
stime=int(fields[12]),
33-
)
39+
by_group = platform != "win32"
40+
with_swap = platform.startswith("linux")
41+
with_state = platform != "win32"
42+
with_tree_fields = platform == "win32"
3443

44+
try:
45+
members = _members(root_pid, by_group)
46+
if not members:
47+
if by_group:
48+
return f"no live process in process group {root_pid}"
49+
return f"no live process in the process tree of {root_pid}"
3550

36-
def _read_status_memory(pid: int) -> tuple[int, int]:
37-
"""(VmRSS, VmSwap) in kB; zeros where the kernel does not report them."""
38-
rss_kb = 0
39-
swap_kb = 0
40-
with open(f"/proc/{pid}/status") as status_file:
41-
for line in status_file:
42-
if line.startswith("VmRSS:"):
43-
rss_kb = int(line.split()[1])
44-
elif line.startswith("VmSwap:"):
45-
swap_kb = int(line.split()[1])
46-
return rss_kb, swap_kb
51+
lines: list[str] = []
52+
for pid in sorted(members):
53+
process = members[pid]
54+
with process.oneshot():
55+
try:
56+
name = process.name()
57+
except (psutil.NoSuchProcess, psutil.ZombieProcess):
58+
# It vanished mid-read — it is not part of the snapshot,
59+
# and not an error.
60+
continue
61+
fields = [f"{pid} {name}"]
62+
if with_state:
63+
fields.append(f"state={_read_status(process)}")
64+
if with_tree_fields:
65+
fields.append(f"ppid={_read_ppid(process)}")
66+
fields.append(f"cpu={_read_cpu_seconds(process)}")
67+
fields.append(f"rss={_read_rss(process)}")
68+
if with_swap:
69+
fields.append(f"swap={_read_swap(process)}")
70+
if with_tree_fields:
71+
fields.append(f"threads={_read_threads(process)}")
72+
lines.append(" ".join(fields))
73+
if len(lines) >= limit:
74+
break
75+
return "\n".join(lines)
76+
except Exception as exception: # noqa: BLE001
77+
return f"process snapshot unavailable: {type(exception).__name__}: {exception}"
4778

4879

49-
def describe_process_group(pgid: int, limit: int = 10) -> str:
50-
"""One diagnostic line per process in process group *pgid*, or ``""`` off Linux.
80+
def _members(root_pid: int, by_group: bool) -> dict[int, psutil.Process]:
81+
"""The processes the spawned server consists of, keyed by pid.
5182
52-
A start failure needs to say what the spawned process group was doing at the
53-
deadline: a process group with no members means the shell already exited, one
54-
running at full CPU is working, one parked in ``D`` state is blocked on I/O.
55-
Never raises: a snapshot failure must not replace the start error it is
56-
explaining.
57-
"""
58-
if not sys.platform.startswith("linux"):
59-
return ""
83+
Tree (Windows): ``Process(root_pid)`` plus ``.children(recursive=True)``
84+
— psutil drops a child whose creation time is earlier than the parent's,
85+
which is the pid-reuse guard. If the root is dead, every process whose
86+
parent pid equals *root_pid*, plus their descendants, is added instead:
87+
Windows never rewrites ppid, so a child orphaned by its server's exit is
88+
still found this way.
6089
61-
try:
62-
clock_ticks = os.sysconf("SC_CLK_TCK")
63-
lines: list[str] = []
64-
for entry in sorted(
65-
os.listdir("/proc"), key=lambda name: int(name) if name.isdigit() else 0
66-
):
67-
if not entry.isdigit():
68-
continue
69-
pid = int(entry)
90+
Group (POSIX): every process whose process group id equals *root_pid*,
91+
found through ``os.getpgid`` even when a member was reparented after its
92+
parent exited.
93+
"""
94+
members: dict[int, psutil.Process] = {}
95+
if not by_group:
96+
try:
97+
members[root_pid] = psutil.Process(root_pid)
98+
except psutil.NoSuchProcess:
99+
for entry in psutil.process_iter(["ppid"]):
100+
if entry.info["ppid"] == root_pid:
101+
members[entry.pid] = entry
102+
for pid in list(members):
103+
_add_children(members, pid)
104+
elif _getpgid is not None:
105+
for entry in psutil.process_iter():
70106
try:
71-
with open(f"/proc/{pid}/stat") as stat_file:
72-
stat = _parse_stat(stat_file.read())
73-
if stat.pgrp != pgid:
74-
continue
75-
rss_kb, swap_kb = _read_status_memory(pid)
76-
except OSError:
77-
# The process vanished while we were reading it — it is not
78-
# part of the snapshot, and not an error.
107+
if _getpgid(entry.pid) == root_pid:
108+
members[entry.pid] = entry
109+
except (ProcessLookupError, PermissionError):
110+
# Vanished or owned by another user — not part of the group.
79111
continue
80-
cpu_sec = (stat.utime + stat.stime) / clock_ticks
81-
lines.append(
82-
f"{pid} {stat.comm} state={stat.state}"
83-
f" cpu={cpu_sec:.2f}s rss={rss_kb // 1024}MB swap={swap_kb // 1024}MB"
84-
)
85-
if len(lines) >= limit:
86-
break
87-
return "\n".join(lines)
88-
except Exception: # noqa: BLE001
89-
return "process group snapshot unavailable"
112+
return members
113+
114+
115+
def _add_children(members: dict[int, psutil.Process], parent_pid: int) -> None:
116+
try:
117+
children = members[parent_pid].children(recursive=True)
118+
except psutil.NoSuchProcess:
119+
return
120+
for child in children:
121+
members.setdefault(child.pid, child)
122+
123+
124+
def _read_status(process: psutil.Process) -> str:
125+
return _read(lambda: str(process.status()))
126+
127+
128+
def _read_ppid(process: psutil.Process) -> str:
129+
return _read(lambda: str(process.ppid()))
130+
131+
132+
def _read_threads(process: psutil.Process) -> str:
133+
return _read(lambda: str(process.num_threads()))
134+
135+
136+
def _read_cpu_seconds(process: psutil.Process) -> str:
137+
return _read(
138+
lambda: f"{process.cpu_times().user + process.cpu_times().system:.2f}s"
139+
)
140+
141+
142+
def _read_rss(process: psutil.Process) -> str:
143+
return _read(lambda: f"{process.memory_info().rss // 2**20}MB")
144+
145+
146+
def _read_swap(process: psutil.Process) -> str:
147+
return _read(lambda: f"{process.memory_full_info().swap // 2**20}MB")
148+
149+
150+
def _read(read: typing.Callable[[], object]) -> str:
151+
"""One per-process field, stringified; ``?`` when the read is denied.
152+
153+
``AccessDenied`` is expected for processes owned by another user; a process
154+
that vanished mid-read is reported the same way rather than dropped for the
155+
rest of its line.
156+
"""
157+
try:
158+
return str(read())
159+
except psutil.AccessDenied:
160+
return "?"
161+
except (psutil.NoSuchProcess, psutil.ZombieProcess):
162+
return "?"

‎finecode_jsonrpc/src/finecode_jsonrpc/client.py‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
from finecode_jsonrpc import _io_thread, _spawn, error_codes
3333
from finecode_jsonrpc._converter import converter as _converter
3434
from finecode_jsonrpc._loop_event import LoopAwareEvent
35-
from finecode_jsonrpc._proc_snapshot import describe_process_group
35+
from finecode_jsonrpc._proc_snapshot import describe_spawned_processes
3636
from finecode_jsonrpc.tracing import ITracingHooks
3737

3838

@@ -1016,15 +1016,15 @@ async def _connect_to_server_io(self, timeout: float | None) -> None:
10161016
for task in self._async_tasks_in_io_thread:
10171017
task.cancel()
10181018

1019-
process_group_snapshot = (
1020-
describe_process_group(self.pid)
1019+
process_snapshot = (
1020+
describe_spawned_processes(self.pid)
10211021
if self.pid is not None
1022-
else "process group snapshot unavailable"
1022+
else "process snapshot unavailable: server pid unknown"
10231023
)
10241024
raise ServerFailedToStart(
10251025
f"Didn't get port in {timeout} seconds"
10261026
f"\nStartup timeline: {self.startup_timeline.describe(time.monotonic())}"
1027-
f"\nServer process group at timeout:\n{process_group_snapshot}"
1027+
f"\nServer processes at timeout:\n{process_snapshot}"
10281028
f"{self._stdout_tail()}{self._stderr_tail()}"
10291029
) from exception
10301030

0 commit comments

Comments
 (0)