mirror of
https://github.com/spoopyghosty0/frameport.git
synced 2026-10-19 19:02:22 +02:00
296 lines
12 KiB
Python
296 lines
12 KiB
Python
"""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
|