Files
spoopyghosty0--frameport/tests/test_monitor.py
T

296 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Monitor client: the `_monitor` line protocol (samples, replies, end of stream) and the pure helpers."""
import io
import json
import queue
import threading
import time
from types import SimpleNamespace
import pytest
from frameport.frame import monitor as M
class _Chan:
def __init__(self):
self.shut = False
def settimeout(self, t):
pass
def shutdown_write(self):
self.shut = True
class _In(io.StringIO):
def __init__(self, on_line=None):
super().__init__()
self.channel = _Chan()
self.on_line = on_line
def write(self, s):
n = super().write(s)
if self.on_line:
for line in s.splitlines():
self.on_line(json.loads(line))
return n
def close(self):
pass
class _Out:
"""stdout of the SSH channel: lines are fed by the test; None ends it, an exception breaks it."""
def __init__(self):
self.q = queue.Queue()
self.channel = _Chan()
def feed(self, obj):
self.q.put(obj if obj is None or isinstance(obj, Exception) else json.dumps(obj) + "\n")
def readline(self):
item = self.q.get(timeout=5)
return item or ""
def __iter__(self):
while True:
item = self.q.get(timeout=5)
if item is None:
return
if isinstance(item, Exception):
raise item
yield item
def _session(ready=None, on_line=None):
out = _Out()
out.feed(ready if ready is not None else {"ready": 1, "static": {"agent": 60, "cores": 8}})
stdin = _In(on_line)
client = SimpleNamespace(exec_command=lambda cmd, timeout=None: (stdin, out, None))
frame = SimpleNamespace(client=client, home="/home/steamos", ensure_agent=lambda: None)
samples, ended = [], []
return frame, stdin, out, samples, ended
def test_stream_samples_controls_and_close():
frame, stdin, out, samples, ended = _session()
s = M.MonitorSession(frame, samples.append, ended.append)
assert s.static == {"agent": 60, "cores": 8}
out.feed({"t": 1, "cpu": {"total": 5}})
out.feed("not json") # ignored
out.feed({"t": 2})
deadline = time.time() + 5
while len(samples) < 2 and time.time() < deadline:
time.sleep(0.01)
assert [x["t"] for x in samples] == [1, 2]
s.set_interval(2)
s.set_filter("all")
s.pause(True)
s.close()
out.feed(None)
assert [json.loads(x) for x in stdin.getvalue().splitlines()] == [{"interval": 2}, {"procs": "all"},
{"pause": True}]
assert stdin.channel.shut and s.closed
s._thread.join(5)
assert ended == [] # closed by us: no "ended" callback
s.set_interval(5) # after close: ignored
assert len(stdin.getvalue().splitlines()) == 3
def test_replies_kill_end_game_and_critical():
holder = {}
def answer(msg): # the "agent": answers each request on stdout
out = holder["out"]
if msg.get("kill") == 101 and not msg.get("force"):
out.feed({"reply": msg["id"], "ok": False, "error": "CRITICAL: vrcompositor"})
elif msg.get("kill") == 1:
out.feed({"reply": msg["id"], "ok": False, "error": "systemd (1) is part of the system"})
elif "kill" in msg:
out.feed({"reply": msg["id"], "ok": True, "result": {"ended": True, "pid": msg["kill"],
"sig": msg["sig"], "force": msg["force"]}})
elif "end_game" in msg:
out.feed({"reply": msg["id"], "ok": True, "result": {"ended": True, "via": "steam"}})
frame, stdin, out, samples, ended = _session(on_line=answer)
holder["out"] = out
s = M.MonitorSession(frame, samples.append, ended.append)
assert s.kill(42) == {"ended": True, "pid": 42, "sig": "TERM", "force": False}
assert s.kill(101, "KILL", force=True)["force"] is True
with pytest.raises(M.ProcessCritical, match="vrcompositor"):
s.kill(101)
with pytest.raises(RuntimeError, match="part of the system"):
s.kill(1)
assert s.end_game("com.x.y") == {"ended": True, "via": "steam"}
s.close()
out.feed(None)
def test_stream_lost_calls_on_end_and_unblocks_requests():
frame, stdin, out, samples, ended = _session()
s = M.MonitorSession(frame, samples.append, ended.append)
result = {}
def ask():
try:
s.kill(5)
except M.MonitorUnavailable as exc:
result["err"] = str(exc)
t = threading.Thread(target=ask)
t.start()
time.sleep(0.1)
out.feed(OSError("Socket is closed"))
t.join(5)
s._thread.join(5)
assert ended == ["Socket is closed"] and s.closed
assert "stopped" in result["err"]
def test_refused_by_an_old_agent():
frame, *_ = _session(ready={"ok": False, "error": "usage: frameport_agent.py <...>"})
with pytest.raises(M.MonitorUnavailable, match="usage"):
M.MonitorSession(frame, lambda s: None)
def test_history_and_series():
h = M.History(size=3)
for v in (1, 2, None, 4):
h.add("cpu", v)
assert h.get("cpu") == [2, None, 4] and h.get("nope") == []
s = {"cpu": {"total": 12.5}, "gpu": {"busy": None}, "mem": {"total": 1000, "avail": 250},
"temps": {"CPU": 50.0, "GPU": 61.5}, "power": {"system": 6.2}, "battery": {"percent": 80},
"games": [{"fps": 71.9}]}
assert M.series_of(s) == {"cpu": 12.5, "gpu": None, "mem": 75.0, "temp": 61.5, "power": 6.2, "battery": 80,
"fps": 71.9, "temp:CPU": 50.0, "temp:GPU": 61.5}
assert M.series_of({**s, "fan": 8200})["fan"] == 8200
def test_history_buckets_by_time():
"""Several samples within one second become one (averaged) point: 2 minutes of chart at any interval."""
h = M.History(size=3)
for t, v in ((10.0, 2), (10.5, 4), (11.2, 6), (11.9, None), (12.0, 1), (13.0, 5)):
h.add("cpu", v, t)
assert h.get("cpu") == [6.0, 1.0, 5.0] # 10 s = (2+4)/2 rolled out of the 3-point window
h.add("x", None, 1.0)
assert h.get("x") == [None]
def test_group_procs():
procs = [{"pid": 1, "ppid": 10, "name": "vrwebhelper", "cpu": 1.0, "gpu": 0, "rss": 5, "age": 30},
{"pid": 2, "ppid": 99, "name": "steam", "cpu": 0.8, "gpu": 0, "rss": 9, "age": 50},
{"pid": 3, "ppid": 10, "name": "vrwebhelper", "cpu": 0.5, "gpu": 2, "rss": 7, "age": 40,
"critical": True},
{"pid": 4, "ppid": 11, "name": "vrwebhelper", "cpu": 0.1, "gpu": 0, "rss": 1, "age": 5}]
rows = M.group_procs(procs)
assert [r["kind"] for r in rows] == ["group", "proc", "proc"]
g = rows[0]
assert g["name"] == "vrwebhelper" and g["count"] == 2 and g["cpu"] == 1.5 and g["gpu"] == 2 and g["rss"] == 12
assert g["age"] == 40 and g["critical"] and not g["expanded"] and rows[2]["pid"] == 4 # other parent: alone
rows = M.group_procs(procs, {(10, "vrwebhelper")})
assert [(r["kind"], r.get("pid")) for r in rows] == [("group", None), ("member", 1), ("member", 3),
("proc", 2), ("proc", 4)]
assert M.fmt_interval(0.25) == "0.25 s" and M.fmt_interval(1) == "1 s" and M.DEFAULT_INTERVAL in M.INTERVALS
assert M.series_of({})["fps"] is None and M.series_of({})["mem"] is None
def test_formatting():
assert M.fmt_bytes(512) == "512 B" and M.fmt_bytes(2048) == "2 KB"
assert M.fmt_bytes(5 * 1024 ** 2) == "5.0 MB" and M.fmt_bytes(16 * 1024 ** 3) == "16.0 GB"
assert M.fmt_bytes(None) == "–" and M.fmt_rate(2048) == "2 KB/s"
assert M.fmt_watts(3.558) == "3.56 W" and M.fmt_watts(12.34) == "12.3 W"
assert M.fmt_duration(45) == "45 s" and M.fmt_duration(720) == "12 min" and M.fmt_duration(3900) == "1 h 05 min"
assert M.fmt_pct(33.4) == "33%" and M.fmt_pct(None) == "–"
def test_levels():
assert M.level("cpu", 50) == "ok" and M.level("cpu", 90) == "warn" and M.level("cpu", 99) == "error"
assert M.level("temp", 79.9) == "ok" and M.level("temp", 85) == "warn" and M.level("temp", 90) == "error"
assert M.temp_level("Battery", 46) == "warn" and M.temp_level("GPU", 46) == "ok"
assert M.level("fps_ratio", 1.0) == "ok" and M.level("fps_ratio", 0.8) == "warn"
assert M.level("fps_ratio", 0.5) == "error"
assert M.level("unknown", 1e9) == "ok" and M.level("cpu", None) == "ok"
def test_sort_filter_and_labels():
procs = [{"pid": 1, "name": "steam", "cpu": 5, "rss": 10, "game": None, "gpu": 0},
{"pid": 22, "name": "com.game", "cpu": 40, "rss": 5, "game": "com.ex.game", "gpu": 30},
{"pid": 3, "name": "Xwayland", "cpu": 5, "rss": 50, "game": None, "gpu": 2}]
assert [p["pid"] for p in M.sort_filter(procs)] == [22, 3, 1]
assert [p["pid"] for p in M.sort_filter(procs, key="name", descending=False)] == [22, 1, 3]
assert [p["pid"] for p in M.sort_filter(procs, "ex.game")] == [22]
assert [p["pid"] for p in M.sort_filter(procs, "3")] == [3] # a PID matches exactly
assert M.group_label("game:com.x") == "Game" and M.group_label("steamvr") == "SteamVR"
assert M.group_label("other") == "Other"
def test_power_split_and_battery_line():
split = M.power_split({"system": 6.0, "cpu": 1.5, "gpu": 2.0, "npu": 0.1})
assert split == [("CPU", 1.5), ("GPU", 2.0), ("NPU", 0.1), ("Other", pytest.approx(2.4))]
assert M.battery_line(None) == "No battery"
assert M.battery_line({"status": "Discharging", "draining": True, "empty_s": 7800, "watts": -4.0}) == \
"2 h 10 min left · 4.00 W"
assert M.battery_line({"status": "Charging", "plugged": True, "full_s": 2100}) == "Charging · full in 35 min"
assert M.battery_line({"status": "Full"}) == "Full"
def test_fps_target():
assert M.fps_target([]) is None and M.fps_target([None]) is None
assert M.fps_target([50.0, 71.9, 72.4]) == 72 and M.fps_target([88.0, 90.1]) == 90
assert M.fps_target([200]) == 144
def test_modules_need_the_agent_that_has_them():
"""Per-module collection came to main with agent 73 (main's 72 had `_monitor` without it): an older agent would
ignore {"modules": …} and keep sending everything, so the client must not count on it."""
import ast
from pathlib import Path
src = (Path(__file__).resolve().parents[1] / "agent" / "frameport_agent.py").read_text()
version = next(n.value.value for n in ast.parse(src).body if isinstance(n, ast.Assign)
and getattr(n.targets[0], "id", "") == "AGENT_VERSION")
assert "def set_modules" in src and M.MIN_AGENT_MODULES <= version
assert M.MIN_AGENT_MODULES == 73
s = SimpleNamespace(static={"agent": 72})
assert not M.MonitorSession.supports_modules.fget(s)
s.static["agent"] = 73
assert M.MonitorSession.supports_modules.fget(s)
def test_smoother_steadies_flickering_numbers_whatever_the_interval():
# fps flickering 71/73 every 0.1 s settles near 72 instead of jumping with each tick
sm = M.Smoother()
shown = [sm.value("fps", 71 if i % 2 else 73, i * 0.1) for i in range(60)]
assert max(shown[30:]) - min(shown[30:]) < 0.3
# a real change still comes through within a few seconds, at 0.1 s and at 2 s ticks alike
for dt in (0.1, 2.0):
sm = M.Smoother()
sm.value("cpu", 10, 0.0)
t, v = 0.0, 10.0
while t < 4.5:
t += dt
v = sm.value("cpu", 50, t)
assert v > 45
def test_smoother_restarts_after_a_gap_or_a_missing_value():
sm = M.Smoother()
sm.value("gpu", 10, 0.0)
assert sm.value("gpu", 90, 30.0) == 90 # paused / reconnected: no slow slide from old numbers
assert sm.value("gpu", None, 30.5) is None
assert sm.value("gpu", 40, 31.0) == 40
def test_smoother_sample_smooths_tiles_and_games_and_keeps_the_rest():
sm = M.Smoother()
s1 = {"t": 0.0, "cpu": {"total": 20.0, "mhz": [2000]}, "mem": {"total": 100, "avail": 50},
"temps": {"cpu": 40.0}, "power": {"system": 5.0}, "battery": {"percent": 80},
"games": [{"package": "a.b", "fps": 72.0, "title": "A"}]}
sm.sample(s1)
s2 = {**s1, "t": 0.5, "cpu": {"total": 60.0, "mhz": [2400]},
"games": [{"package": "a.b", "fps": 60.0, "title": "A"}]}
out = sm.sample(s2)
assert 20 < out["cpu"]["total"] < 60 and out["cpu"]["mhz"] == [2400]
assert 60 < out["games"][0]["fps"] < 72 and out["games"][0]["title"] == "A"
assert out["battery"] == {"percent": 80} and out["mem"]["total"] == 100
assert s2["cpu"]["total"] == 60.0 # the raw sample (history, details, processes) is untouched