blob: 79424abec2c93e57efba0a2c7339cb25645eeaef [file] [edit]
#!/usr/bin/env python3
# Copyright (c) 2009, Giampaolo Rodola'. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Iterate over all process PIDs and for each one of them invoke and
test all psutil.Process() methods.
"""
import collections
import enum
import errno
import os
import stat
import time
import traceback
import pytest
import psutil
from psutil import AIX
from psutil import BSD
from psutil import FREEBSD
from psutil import LINUX
from psutil import MACOS
from psutil import NETBSD
from psutil import OPENBSD
from psutil import POSIX
from psutil import WINDOWS
from . import CI_TESTING
from . import VALID_PROC_STATUSES
from . import PsutilTestCase
from . import check_connection_ntuple
from . import check_fun_type_hints
from . import check_ntuple_type_hints
from . import is_namedtuple
from . import is_win_secure_system_proc
from . import process_namespace
API_DURATIONS = collections.Counter()
PF_KTHREAD = 0x00200000 # include/linux/sched.h
@pytest.hookimpl(wrapper=True)
def pytest_runtest_makereport(item, call):
# Attach the timings to test_all's report, so they survive the
# trip from the xdist worker to the master.
report = yield
if call.when == "call" and item.nodeid.endswith("::test_all"):
if API_DURATIONS:
report.user_properties.append(
("api_durations", API_DURATIONS.most_common())
)
return report
def pytest_terminal_summary(terminalreporter):
"""Show top slowest APIs in pytest summary."""
top_slowest = terminalreporter.config.getoption("durations")
if top_slowest is None:
# show them only if --durations was passed
return
for reports in terminalreporter.stats.values():
for report in reports:
for key, value in getattr(report, "user_properties", []):
if key == "api_durations":
terminalreporter.write_sep(
"=",
f"test_all: slowest {top_slowest} APIs (cumulative)",
)
for fun_name, secs in value[:top_slowest]:
line = f"{secs:.2f}s call {fun_name}()"
terminalreporter.write_line(line)
class ProcInfo:
def __init__(self, pid):
self.pid = pid
self.proc = None
self.name = None
self.ppid = None
self.tcase = PsutilTestCase()
def is_kernel_thread(self):
# The kernel renames these continuously, e.g. "kworker/1:0-events"
# -> "kworker/1:0+events" -> "kworker/1:0-kblockd", so their name
# says nothing about whether it's still the same process.
if not LINUX:
return False
try:
with open(f"{psutil.PROCFS_PATH}/{self.pid}/stat", "rb") as f:
data = f.read()
except OSError:
return False # process is gone; caller handles that
fields = data[data.rfind(b")") + 2 :].split()
return bool(int(fields[6]) & PF_KTHREAD)
def check_exception(self, exc):
assert exc.pid == self.pid
if (
exc.name is not None
and exc.name != self.name
and not self.is_kernel_thread()
):
# The process may have renamed itself in the meantime.
try:
curname = psutil.Process(self.pid).name()
except psutil.Error:
curname = None
# if the name did not change then something else is wrong
assert curname != self.name
if isinstance(exc, psutil.ZombieProcess):
try:
self.tcase.assert_proc_zombie(self.proc)
except (psutil.NoSuchProcess, AssertionError):
# Prevent race conditions: if zombie disappears while
# assert_proc_zombie analyzes it we fail only if its
# PID still exists.
if self.pid in psutil.pids():
raise
if exc.ppid is not None:
assert exc.ppid >= 0
assert exc.ppid == self.ppid
elif isinstance(exc, psutil.NoSuchProcess):
self.tcase.assert_proc_gone(self.proc)
str(exc)
repr(exc)
def safe_repr(self):
# repr() calls name() and status(), which may fail themselves.
if self.proc is None:
return f"pid {self.pid}"
try:
return repr(self.proc)
except Exception as exc: # noqa: BLE001
return f"psutil.Process(pid={self.pid}, repr_error={exc!r})"
def check_wait(self):
if self.pid != 0:
try:
self.proc.wait(0)
except psutil.Error as exc:
self.check_exception(exc)
def should_skip(self, fun_name):
# XXX: memory_info_ex() needs task_for_pid() for fields
# with no pid-based source (peak_rss, compressed, ...).
# task_for_pid() can hang forever when taskgated is
# wedged, which happens on headless CI but not on real
# machines. See:
# https://github.com/giampaolo/psutil/issues/2885
return MACOS and CI_TESTING and fun_name == "memory_info_ex"
def call_getters(self):
info = {'pid': self.pid}
durations = {}
ns = process_namespace(self.proc)
# We don't use oneshot() because in order not to fool
# check_exception() in case of NSP.
for fun, fun_name in ns.iter(ns.getters, clear_cache=False):
if self.should_skip(fun_name):
continue
t = time.perf_counter()
try:
ret = fun()
except psutil.Error as exc:
self.check_exception(exc)
continue
else:
check_fun_type_hints(fun, ret)
if is_namedtuple(ret):
check_ntuple_type_hints(ret)
info[fun_name] = ret
finally:
durations[fun_name] = time.perf_counter() - t
info["_durations"] = durations
info["_repr"] = self.safe_repr()
return info
def run(self):
try:
self.proc = psutil.Process(self.pid)
except psutil.NoSuchProcess:
self.tcase.assert_pid_gone(self.pid)
return {}
try:
d = self.proc.as_dict(['ppid', 'name'])
except psutil.NoSuchProcess:
self.tcase.assert_proc_gone(self.proc)
return {}
self.name, self.ppid = d['name'], d['ppid']
info = self.call_getters()
self.check_wait()
return info
def proc_info(pid):
obj = ProcInfo(pid)
try:
return obj.run()
except Exception as exc:
# Attach the process repr (name + status) to any unexpected
# error, no matter which part of the collection raised it.
msg = f"proc: {obj.safe_repr()}"
if hasattr(exc, "add_note"): # python 3.11+
exc.add_note(msg)
raise
raise RuntimeError(msg) from exc
class TestFetchAllProcesses(PsutilTestCase):
"""Test which iterates over all running processes and performs
some sanity checks against Process API's returned values.
Uses a process pool to get info about all processes.
"""
def setUp(self):
psutil._set_debug(False)
if POSIX:
self.parent, self.zombie = self.spawn_zombie()
def tearDown(self):
if POSIX:
self.parent.terminate()
self.parent.wait()
self.zombie.wait()
psutil._set_debug(True)
def iter_proc_info(self):
ls = [proc_info(pid) for pid in psutil.pids()]
return ls
def test_all(self):
failures = []
durations = collections.Counter()
for info in self.iter_proc_info():
durations.update(info.pop("_durations", {}))
proc_repr = info.pop("_repr", None)
for name, value in info.items():
meth = getattr(self, name)
try:
meth(value, info)
except Exception: # noqa: BLE001
s = '\n' + '=' * 70 + '\n'
s += "FAIL: name=test_{}, pid={}, ret={}\n\n".format(
name,
info['pid'],
repr(value),
)
s += f"proc={proc_repr}\n\n"
s += '-' * 70
s += f"\n{traceback.format_exc()}"
s = "\n".join((" " * 4) + i for i in s.splitlines()) + "\n"
failures.append(s)
else:
if value not in (0, 0.0, [], None, '', {}):
assert value, value
API_DURATIONS.update(durations)
if failures:
return pytest.fail(''.join(failures))
def cmdline(self, ret, info):
assert isinstance(ret, list)
for part in ret:
assert isinstance(part, str)
def exe(self, ret, info):
assert isinstance(ret, str)
assert ret.strip() == ret
if ret:
if WINDOWS and not ret.endswith('.exe'):
return # May be "Registry", "MemCompression", ...
assert os.path.isabs(ret), ret
# Note: os.stat() may return False even if the file is there
# hence we skip the test, see:
# http://stackoverflow.com/questions/3112546/os-path-exists-lies
if POSIX and os.path.isfile(ret):
if hasattr(os, 'access') and hasattr(os, "X_OK"):
# XXX: may fail on MACOS
try:
assert os.access(ret, os.X_OK)
except AssertionError:
if os.path.exists(ret) and not CI_TESTING:
raise
def pid(self, ret, info):
assert isinstance(ret, int)
assert ret >= 0
def ppid(self, ret, info):
assert isinstance(ret, int)
assert ret >= 0
proc_info(ret)
def name(self, ret, info):
assert isinstance(ret, str)
if WINDOWS and not ret and is_win_secure_system_proc(info['pid']):
# https://github.com/giampaolo/psutil/issues/2338
return
# on AIX, "<exiting>" processes don't have names
if not AIX:
assert ret, repr(ret)
def create_time(self, ret, info):
assert isinstance(ret, float)
try:
assert ret >= 0
except AssertionError:
# XXX
if OPENBSD and info['status'] == psutil.STATUS_ZOMBIE:
pass
else:
raise
# this can't be taken for granted on all platforms
# assert ret >= psutil.boot_time())
# make sure returned value can be pretty printed
# with strftime
time.strftime("%Y %m %d %H:%M:%S", time.localtime(ret))
def uids(self, ret, info):
for uid in ret:
assert isinstance(uid, int)
assert uid >= 0
def gids(self, ret, info):
# note: testing all gids as above seems not to be reliable for
# gid == 30 (nodoby); not sure why.
for gid in ret:
assert isinstance(gid, int)
if not MACOS and not NETBSD:
assert gid >= 0
def username(self, ret, info):
assert isinstance(ret, str)
assert ret.strip() == ret
assert ret.strip()
def status(self, ret, info):
assert isinstance(ret, str)
assert ret, ret
assert ret != '?' # XXX
assert ret in VALID_PROC_STATUSES
def io_counters(self, ret, info):
for field in ret:
assert isinstance(field, int)
if field != -1:
assert field >= 0
def ionice(self, ret, info):
if LINUX:
assert isinstance(ret.ioclass, int)
assert isinstance(ret.value, int)
assert ret.ioclass >= 0
assert ret.value >= 0
else: # Windows, Cygwin
choices = [
psutil.IOPRIO_VERYLOW,
psutil.IOPRIO_LOW,
psutil.IOPRIO_NORMAL,
psutil.IOPRIO_HIGH,
]
assert isinstance(ret, int)
assert ret >= 0
assert ret in choices
def num_threads(self, ret, info):
assert isinstance(ret, int)
if WINDOWS and ret == 0 and is_win_secure_system_proc(info['pid']):
# https://github.com/giampaolo/psutil/issues/2338
return
if POSIX and ret == 0 and info['pid'] == self.zombie.pid:
return
assert ret >= 1
def threads(self, ret, info):
assert isinstance(ret, list)
for t in ret:
assert t.id >= 0
assert t.user_time >= 0
assert t.system_time >= 0
for field in t:
assert isinstance(field, (int, float))
def cpu_times(self, ret, info):
for n in ret:
assert isinstance(n, float)
assert n >= 0
# TODO: check ntuple fields
def cpu_num(self, ret, info):
assert isinstance(ret, int)
if FREEBSD and ret == -1:
return
assert ret >= 0
if psutil.cpu_count() == 1:
assert ret == 0
assert ret in list(range(psutil.cpu_count()))
def memory_info(self, ret, info):
self.check_proc_memory(ret)
def memory_info_ex(self, ret, info):
self.check_proc_memory(ret)
def memory_footprint(self, ret, info):
for name in ret._fields:
value = getattr(ret, name)
assert isinstance(value, int)
assert value >= 0
def open_files(self, ret, info):
assert isinstance(ret, list)
for f in ret:
assert isinstance(f.fd, int)
assert isinstance(f.path, str)
assert f.path.strip() == f.path
if WINDOWS:
assert f.fd == -1
elif LINUX:
assert isinstance(f.position, int)
assert isinstance(f.mode, str)
assert isinstance(f.flags, int)
assert f.position >= 0
assert f.mode in {'r', 'w', 'a', 'r+', 'a+'}
assert f.flags > 0
elif BSD and not f.path:
# XXX see: https://github.com/giampaolo/psutil/issues/595
continue
assert os.path.isabs(f.path), f
try:
st = os.stat(f.path)
except FileNotFoundError:
pass
else:
assert stat.S_ISREG(st.st_mode), f
def num_fds(self, ret, info):
assert isinstance(ret, int)
assert ret >= 0
def net_connections(self, ret, info):
assert len(ret) == len(set(ret))
for conn in ret:
check_connection_ntuple(conn)
def cwd(self, ret, info):
assert isinstance(ret, str)
assert ret.strip() == ret
if ret:
assert os.path.isabs(ret), ret
try:
st = os.stat(ret)
except OSError as err:
if WINDOWS and psutil._psplatform.is_permission_err(err):
pass
# directory has been removed in mean time
elif err.errno != errno.ENOENT:
raise
else:
assert stat.S_ISDIR(st.st_mode)
def cpu_affinity(self, ret, info):
assert isinstance(ret, list)
assert ret != []
cpus = list(range(psutil.cpu_count()))
for n in ret:
assert isinstance(n, int)
assert n in cpus
def terminal(self, ret, info):
assert isinstance(ret, (str, type(None)))
if ret is not None:
assert os.path.isabs(ret), ret
assert os.path.exists(ret), ret
def memory_maps(self, ret, info):
for nt in ret:
if hasattr(nt, "addr"):
assert isinstance(nt.addr, str)
if hasattr(nt, "perms"):
assert isinstance(nt.perms, str)
assert isinstance(nt.path, str)
for fname in nt._fields:
value = getattr(nt, fname)
if fname == 'path':
if value.startswith(("[", "anon_inode:")): # linux
continue
if BSD and value == "pvclock": # seen on FreeBSD
continue
assert os.path.isabs(nt.path), nt.path
# commented as on Linux we might get
# '/foo/bar (deleted)'
# assert os.path.exists(nt.path), nt.path
elif fname == 'addr':
assert value, repr(value)
elif fname == 'perms':
if not WINDOWS:
assert value, repr(value)
else:
assert isinstance(value, int)
assert value >= 0
def num_handles(self, ret, info):
assert isinstance(ret, int)
assert ret >= 0
def page_faults(self, ret, info):
assert isinstance(ret.minor, int)
assert isinstance(ret.major, int)
assert ret.minor >= 0
assert ret.major >= 0
def nice(self, ret, info):
assert isinstance(ret, int)
if POSIX:
assert -20 <= ret <= 20, ret
else:
priorities = [
getattr(psutil, x)
for x in dir(psutil)
if x.endswith('_PRIORITY_CLASS')
]
assert ret in priorities
assert isinstance(ret, enum.IntEnum)
def num_ctx_switches(self, ret, info):
for value in ret:
assert isinstance(value, int)
assert value >= 0
def rlimit(self, ret, info):
assert isinstance(ret, tuple)
assert len(ret) == 2
assert ret[0] >= -1
assert ret[1] >= -1
def environ(self, ret, info):
assert isinstance(ret, dict)
for k, v in ret.items():
assert isinstance(k, str)
assert isinstance(v, str)
class TestPidsRange(PsutilTestCase):
"""Given pid_exists() return value for a range of PIDs which may or
may not exist, make sure that psutil.Process() and psutil.pids()
agree with pid_exists(). This guarantees that the 3 APIs are all
consistent with each other. See:
https://github.com/giampaolo/psutil/issues/2359
XXX - Note about Windows: it turns out there are some "hidden" PIDs
which are not returned by psutil.pids() and are also not revealed
by taskmgr.exe and ProcessHacker, still they can be instantiated by
psutil.Process() and queried. One of such PIDs is "conhost.exe".
Running as_dict() for it reveals that some Process() APIs
erroneously raise NoSuchProcess, so we know we have problem there.
Let's ignore this for now, since it's quite a corner case (who even
imagined hidden PIDs existed on Windows?).
"""
def setUp(self):
psutil._set_debug(False)
def tearDown(self):
psutil._set_debug(True)
def test_it(self):
def is_linux_tid(pid):
try:
f = open(f"/proc/{pid}/status", "rb") # noqa: SIM115
except FileNotFoundError:
return False
else:
with f:
for line in f:
if line.startswith(b"Tgid:"):
tgid = int(line.split()[1])
# If tgid and pid are different then we're
# dealing with a process TID.
return tgid != pid
raise ValueError("'Tgid' line not found")
def check(pid):
# In case of failure retry up to 3 times in order to avoid
# race conditions, especially when running in a CI
# environment where PIDs may appear and disappear at any
# time.
x = 3
while True:
exists = psutil.pid_exists(pid)
try:
if exists:
psutil.Process(pid)
if not WINDOWS: # see docstring
assert pid in psutil.pids()
else:
# On OpenBSD thread IDs can be instantiated,
# and oneshot() succeeds, but other APIs fail
# with EINVAL.
if not OPENBSD:
with pytest.raises(psutil.NoSuchProcess):
psutil.Process(pid)
if not WINDOWS: # see docstring
assert pid not in psutil.pids()
except (psutil.Error, AssertionError):
x -= 1
if x == 0:
raise
else:
return
for pid in range(1, 3000):
if LINUX and is_linux_tid(pid):
# On Linux a TID (thread ID) can be passed to the
# Process class and is querable like a PID (process
# ID). Skip it.
continue
check(pid)