|
| 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 | + |
1 | 9 | from __future__ import annotations |
2 | 10 |
|
3 | 11 | import os |
4 | 12 | import sys |
5 | 13 | import typing |
6 | 14 |
|
7 | | -__all__ = ["describe_process_group"] |
| 15 | +import psutil |
8 | 16 |
|
| 17 | +__all__ = ["describe_spawned_processes"] |
9 | 18 |
|
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) |
16 | 24 |
|
17 | 25 |
|
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. |
20 | 30 |
|
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. |
24 | 38 | """ |
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" |
34 | 43 |
|
| 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}" |
35 | 50 |
|
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}" |
47 | 78 |
|
48 | 79 |
|
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. |
51 | 82 |
|
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. |
60 | 89 |
|
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(): |
70 | 106 | 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. |
79 | 111 | 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 "?" |
0 commit comments