blob: 573514a9c8fd8f0e31ed29ca4d442d7f23c7d8c5 [file]
#!/usr/bin/env fuchsia-vendored-python
# Copyright 2026 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Linux procfs-based process tree inspection tool.
Dual-use: importable library and standalone CLI.
Inspects /proc to snapshot process trees, states, command lines, and wait channels
without relying on external system utilities.
"""
from __future__ import annotations
import argparse
import json
import sys
from collections.abc import Callable, Iterable, Iterator, Sequence
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from build.profile import procfs
# JSON type aliases
_JSONPrimitive = str | int | float | bool | None
JSONValue = _JSONPrimitive | dict[str, Any] | list[Any]
JSONObject = dict[str, JSONValue]
JSONArray = list[JSONValue]
# Formatter closure type alias
Formatter = Callable[["ProcessNode"], Iterable[str]]
@dataclass(frozen=True)
class ActiveLeafSummary:
"""Summary of active leaf processes extracted from a process tree."""
processes: list[str] = field(default_factory=list)
total_matching_leaves: int = 0
total_leaves: int = 0
# Maps procfs.ProcessInfo.state codes to execution state sorting priority.
# Active states (CPU, DISK-WAIT) come first, followed by sleeping/stopped.
_STATE_PRIORITIES_MAP: dict[str, int] = {
"R": 0, # [CPU]
"D": 1, # [DISK-WAIT]
"S": 2, # [WAITING]
"T": 3, # [STOPPED]
"Z": 4, # [ZOMBIE]
}
_DEFAULT_STATE_PRIORITY: int = 5
# Maps procfs.ProcessInfo.state codes to human-readable display tags.
_STATE_TAGS_MAP: dict[str, str] = {
"R": "[CPU]",
"D": "[DISK-WAIT]",
"S": "[WAITING]",
"T": "[STOPPED]",
"Z": "[ZOMBIE]",
}
def proc_state_to_priority(state: str) -> int:
"""Returns the sorting priority for a procfs.ProcessInfo.state code."""
return _STATE_PRIORITIES_MAP.get(state, _DEFAULT_STATE_PRIORITY)
def proc_state_to_display_tag(state: str) -> str:
"""Returns the human-readable display tag for a procfs.ProcessInfo.state code."""
return _STATE_TAGS_MAP.get(state, f"[{state}]")
DEFAULT_ACTIVE_CMD_MAX_LEN: int = 120
def rank_active_leaf_processes(
leaves: Iterable[ProcessNode],
filter_fn: Callable[[ProcessNode], bool] | None = None,
) -> list[ProcessNode]:
"""Filters and ranks leaf processes by activity.
Args:
leaves: Iterable of ProcessNode leaf nodes.
filter_fn: Optional predicate returning True for nodes to include.
Sort order:
1. Execution state priority ([CPU] -> [DISK-WAIT] -> [WAITING] -> other).
2. CPU time consumed (utime + stime descending).
3. PID ascending.
"""
all_leaves = list(leaves)
candidates = (
all_leaves
if not filter_fn
else [node for node in all_leaves if filter_fn(node)]
)
# If custom filter excluded everything (e.g. all leaf processes were
# daemons because all compilers had already terminated or failed to spawn),
# fall back to returning unfiltered leaves so the diagnostic summary is
# never completely empty when processes are alive in the process group.
if not candidates and all_leaves:
candidates = all_leaves
def sort_key(n: ProcessNode) -> tuple[int, int, int]:
cpu_time = n.utime + n.stime
return (proc_state_to_priority(n.state), -cpu_time, n.pid)
return sorted(candidates, key=sort_key)
def _int_val(val: JSONValue, default: int = 0) -> int:
"""Safely converts a JSONValue to int, raising ValueError if invalid."""
if isinstance(val, (int, float)):
return int(val)
if isinstance(val, str):
return int(val)
if val is None:
return default
raise ValueError(
f"Expected int or int-convertible value, got {type(val).__name__}"
)
@dataclass
class ProcessNode:
"""Represents a process and its children in the process hierarchy."""
info: procfs.ProcessInfo
children: list[ProcessNode] = field(default_factory=list)
@property
def pid(self) -> int:
return self.info.pid
@property
def ppid(self) -> int:
return self.info.ppid
@property
def comm(self) -> str:
return self.info.comm
@property
def state(self) -> str:
return self.info.state
@property
def cmdline(self) -> list[str]:
return self.info.cmdline
@property
def pgrp(self) -> int | None:
return self.info.pgrp
@property
def wchan(self) -> str | None:
return self.info.wchan
@property
def utime(self) -> int:
return self.info.utime
@property
def stime(self) -> int:
return self.info.stime
def to_dict(self) -> JSONObject:
"""Serializes the process node and all descendants to a JSON object."""
return {
"pid": self.info.pid,
"ppid": self.info.ppid,
"comm": self.info.comm,
"state": self.info.state,
"cmdline": self.info.cmdline,
"pgrp": self.info.pgrp,
"wchan": self.info.wchan,
"utime": self.info.utime,
"stime": self.info.stime,
"children": [child.to_dict() for child in self.children],
}
@classmethod
def from_dict(cls, data: JSONObject) -> ProcessNode:
"""Deserializes a ProcessNode and all its descendants from a dictionary."""
if "pid" not in data or "ppid" not in data:
raise KeyError("ProcessNode dict missing required 'pid' or 'ppid'")
raw_cmdline = data.get("cmdline")
cmdline = (
[str(arg) for arg in raw_cmdline]
if isinstance(raw_cmdline, list)
else []
)
pgrp_val = data.get("pgrp")
wchan_val = data.get("wchan")
info = procfs.ProcessInfo(
pid=_int_val(data["pid"]),
ppid=_int_val(data["ppid"]),
comm=str(data.get("comm", "")),
state=str(data.get("state", "")),
cmdline=cmdline,
pgrp=_int_val(pgrp_val) if pgrp_val is not None else None,
wchan=str(wchan_val) if isinstance(wchan_val, str) else None,
utime=_int_val(data.get("utime", 0)),
stime=_int_val(data.get("stime", 0)),
)
raw_children = data.get("children")
children = (
[
cls.from_dict(child)
for child in raw_children
if isinstance(child, dict)
]
if isinstance(raw_children, list)
else []
)
return cls(info=info, children=children)
def cmdline_str(self) -> str:
"""Returns the command line as a string, falling back to comm.
Embedded newlines and carriage returns are escaped so output lines
remain on a single line per process.
"""
return self.info.cmdline_str()
@classmethod
def from_process_info(cls, info: procfs.ProcessInfo) -> ProcessNode:
"""Instantiates a ProcessNode from a procfs.ProcessInfo record."""
return cls(info=info)
@classmethod
def create(
cls,
pid: int,
ppid: int = 0,
comm: str = "",
state: str = "S",
cmdline: list[str] | None = None,
pgrp: int | None = None,
wchan: str | None = None,
utime: int = 0,
stime: int = 0,
children: list[ProcessNode] | None = None,
) -> ProcessNode:
"""Convenience factory to create a ProcessNode from raw field values."""
return cls(
info=procfs.ProcessInfo(
pid=pid,
ppid=ppid,
comm=comm,
state=state,
pgrp=pgrp,
cmdline=cmdline or [],
wchan=wchan,
utime=utime,
stime=stime,
),
children=children or [],
)
def format_line(self) -> str:
"""Formats a single process summary line."""
wchan_str = f" [{self.wchan}]" if self.wchan else ""
return (
f"{self.pid} {self.comm} (state: {self.state}{wchan_str}): "
f"{self.cmdline_str()}"
)
def format_active_line(
self, max_cmd_len: int = DEFAULT_ACTIVE_CMD_MAX_LEN
) -> str:
"""Formats an active process line with human-readable state tag and deemphasized PID.
Format:
[{STATE_TAG}] {cmd_preview} (pid {pid})
"""
tag = proc_state_to_display_tag(self.state)
cmd = self.cmdline_str()
if len(cmd) > max_cmd_len:
cmd = cmd[: max_cmd_len - 3] + "..."
return f"{tag} {cmd} (pid {self.pid})"
def format_tree(
self,
prefix: str = "",
is_last: bool = True,
is_root: bool = True,
) -> Iterator[str]:
"""Yields formatted lines for the process tree rooted at self using ASCII branch graphics."""
if is_root:
yield self.format_line()
next_prefix = ""
else:
branch = "+-- "
yield f"{prefix}{branch}{self.format_line()}"
next_prefix = prefix + (" " if is_last else "| ")
num_children = len(self.children)
for i, child in enumerate(self.children):
child_is_last = i == num_children - 1
yield from child.format_tree(
prefix=next_prefix,
is_last=child_is_last,
is_root=False,
)
def find_leaves(self) -> Iterator[ProcessNode]:
"""Yields all descendant leaf processes (nodes with no children)."""
if not self.children:
yield self
return
for child in self.children:
yield from child.find_leaves()
def summarize_active_leaves(
self,
max_count: int = 5,
filter_fn: Callable[[ProcessNode], bool] | None = None,
max_cmd_len: int = DEFAULT_ACTIVE_CMD_MAX_LEN,
) -> ActiveLeafSummary:
"""Extracts, ranks, and formats the top active leaf processes from the subtree.
Args:
max_count: Maximum number of formatted process lines to return.
filter_fn: Optional predicate returning True to include a leaf node.
max_cmd_len: Maximum length of the command preview string.
Returns:
ActiveLeafSummary containing formatted lines, matching count, and total count.
"""
all_leaves = list(self.find_leaves())
total_leaves = len(all_leaves)
ranked = rank_active_leaf_processes(all_leaves, filter_fn=filter_fn)
total_matching = len(ranked)
formatted = [
node.format_active_line(max_cmd_len=max_cmd_len)
for node in ranked[:max_count]
]
return ActiveLeafSummary(
processes=formatted,
total_matching_leaves=total_matching,
total_leaves=total_leaves,
)
def read_process_node(
pid: int, proc_root: Path = procfs.DEFAULT_PROC_ROOT
) -> ProcessNode | None:
"""Reads process metadata for a given PID from procfs."""
info = procfs.read_process_info(pid, proc_root=proc_root)
return ProcessNode(info) if info else None
def scan_all_processes(
proc_root: Path = procfs.DEFAULT_PROC_ROOT,
) -> dict[int, ProcessNode]:
"""Scans all numeric PID directories under procfs."""
return {
pid: ProcessNode(info)
for pid, info in procfs.read_all_processes(proc_root=proc_root).items()
}
def build_process_tree(
root_pid: int,
proc_root: Path = procfs.DEFAULT_PROC_ROOT,
include_pgrp: bool = True,
) -> ProcessNode | None:
"""Builds a process subtree rooted at root_pid from procfs.
If include_pgrp is True and root_pid is the process group leader, any
orphaned child processes sharing the same process group (e.g. reparented to
init after an intermediate parent exited) are also attached under the root.
"""
all_processes = scan_all_processes(proc_root=proc_root)
if root_pid not in all_processes:
# Fall back to checking root_pid directly in case scan missed it
direct_node = read_process_node(root_pid, proc_root=proc_root)
if not direct_node:
return None
all_processes[root_pid] = direct_node
root_node = all_processes[root_pid]
# Link children to parents while avoiding self-parenting and cycles
for pid, node in all_processes.items():
if pid == root_pid or node.ppid == pid:
continue
# Verify node.ppid does not create a cycle back to pid
curr = node.ppid
has_cycle = False
visited = {pid}
while curr in all_processes and curr != root_pid:
if curr in visited:
has_cycle = True
break
visited.add(curr)
curr = all_processes[curr].ppid
if has_cycle:
continue
if node.ppid in all_processes:
all_processes[node.ppid].children.append(node)
# Attach orphaned descendants sharing the process group
if include_pgrp and root_pid != 1 and root_node.pgrp == root_pid:
reachable: set[int] = set()
def _collect_reachable(n: ProcessNode) -> None:
if n.pid in reachable:
return
reachable.add(n.pid)
for child in n.children:
_collect_reachable(child)
_collect_reachable(root_node)
def _has_pgrp_ancestor(node: ProcessNode) -> bool:
curr = node.ppid
visited = {node.pid}
while curr in all_processes and curr not in visited:
if curr == root_pid or all_processes[curr].pgrp == root_pid:
return True
visited.add(curr)
curr = all_processes[curr].ppid
return False
for pid, node in all_processes.items():
if node.pgrp == root_pid and pid not in reachable:
# Only attach an unreachable pgrp node to root when no ancestor
# along its ppid chain is root_pid or has pgrp == root_pid.
if not _has_pgrp_ancestor(node):
root_node.children.append(node)
_collect_reachable(node)
# Sort children by PID for deterministic output
for node in all_processes.values():
node.children.sort(key=lambda c: c.pid)
return root_node
def dump_tree_json(root: ProcessNode, indent: int = 2) -> str:
"""Serializes the process tree rooted at root to a JSON string."""
return json.dumps(root.to_dict(), indent=indent)
# Map of format names to formatter closures
FORMATTERS: dict[str, Formatter] = {
"tree": lambda root: root.format_tree(),
"leaves": lambda root: (leaf.format_line() for leaf in root.find_leaves()),
"json": lambda root: (dump_tree_json(root),),
}
def _main_argparser() -> argparse.ArgumentParser:
"""Creates the argument parser for standalone CLI execution."""
parser = argparse.ArgumentParser(
description="Inspect process tree using procfs."
)
parser.add_argument(
"pid",
nargs="?",
type=int,
default=None,
help="Root PID of the process tree (defaults to 1 for full system tree).",
)
parser.add_argument(
"--root",
type=int,
dest="root_pid",
default=None,
help="Root PID of the process tree.",
)
parser.add_argument(
"--proc-root",
type=Path,
default=procfs.DEFAULT_PROC_ROOT,
help="Procfs root directory (defaults to /proc).",
)
parser.add_argument(
"--format",
choices=list(FORMATTERS.keys()),
default="tree",
help="Output format (default: tree).",
)
parser.add_argument(
"--output",
"-o",
type=Path,
default=None,
help="Path to write output to (defaults to stdout).",
)
return parser
def main(argv: Sequence[str] | None = None) -> int:
"""CLI entrypoint."""
if argv is None:
argv = sys.argv[1:]
parser = _main_argparser()
args = parser.parse_args(argv)
root_pid = args.root_pid or args.pid
if root_pid is None:
root_pid = 1
tree = build_process_tree(root_pid, proc_root=args.proc_root)
if not tree:
print(
f"Process {root_pid} not found in {args.proc_root}",
file=sys.stderr,
)
return 1
formatter = FORMATTERS[args.format]
output_text = "\n".join(formatter(tree)) + "\n"
if args.output:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(output_text, encoding="utf-8")
return 0
try:
sys.stdout.write(output_text)
sys.stdout.flush()
except BrokenPipeError:
# Downstream pipe closed prematurely (e.g. piped into `head` or `grep -m 1`).
# Close sys.stdout so Python runtime teardown doesn't attempt to flush remaining
# buffered output to the broken pipe on exit, and exit cleanly with status 0.
try:
sys.stdout.close()
except OSError:
pass
return 0
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))