"""Reading build state out of /proc. The observation layer replaces parsing paru's human output, which is brittle by construction. That trade only pays if this side is right, and it is subtle: `/proc//stat` packs the command name inside parentheses *inside* a space-separated field list, and the command name may itself contain spaces and parentheses. Get it wrong and every field shifts, so the start time becomes something else and a per-package timer reports nonsense. These tests spawn real processes. Reading a real kernel is the only way to test a /proc parser. """ from __future__ import annotations import os import subprocess import sys import time from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from updater import procwatch as pw # noqa: E402 def spawn_with_comm(comm: str) -> subprocess.Popen: """A live process whose kernel command name is exactly `comm`. The trailing `sleep &` matters: a bare command at the end of `bash -c` is exec'd and replaces the shell, so the name we set would be thrown away. """ return subprocess.Popen( ["bash", "-c", f'printf "%s" {comm!r} > /proc/self/comm; sleep 30 & wait'] ) def kernel_comm(pid: int) -> str: raw = Path(f"/proc/{pid}/stat").read_text() return raw[raw.find("(") + 1 : raw.rfind(")")] # -------------------------------------------------------------------------- # parsing /proc//stat # -------------------------------------------------------------------------- def test_the_command_name_may_contain_parentheses(): """The reason the parser reads from the last `)`: the name is inside a field list, and can itself contain the delimiter.""" for comm in ("ends)", ")", "a)b)c", "((", "))((", "weird (name)"): proc = spawn_with_comm(comm) try: time.sleep(0.25) got = pw.snapshot().get(proc.pid) assert got is not None, f"{comm!r}: process was not readable" assert got.comm == kernel_comm(proc.pid), (comm, got.comm) finally: proc.kill() proc.wait() def test_the_fields_after_the_name_still_line_up(): """A name full of parens must not shift ppid or the start time.""" proc = spawn_with_comm("))((") try: time.sleep(0.4) got = pw.snapshot()[proc.pid] raw = Path(f"/proc/{proc.pid}/stat").read_text() fields = raw[raw.rfind(")") + 2 :].split() assert fields[0] in "RSDZTtWXxKWP", "field 0 should be the state letter" assert got.ppid == int(fields[1]) uptime = float(Path("/proc/uptime").read_text().split()[0]) age = uptime - (got.started - pw.boot_time()) assert 0 <= age < 5, f"start time is off by {age:.1f}s" finally: proc.kill() proc.wait() def test_a_dead_or_missing_process_is_not_an_error(): """Processes die while being read; that is the normal case, not a failure.""" proc = subprocess.Popen(["true"]) proc.wait() btime, clk = pw.boot_time(), pw._clk_tck() for pid in (proc.pid, 0, 999999): assert pw.read_proc(pid, btime, clk) is None def test_a_snapshot_survives_processes_dying_underneath_it(): spawn = [subprocess.Popen(["true"]) for _ in range(40)] snap = pw.snapshot() assert os.getpid() in snap, "the reading process should always be present" for proc in spawn: proc.wait() def test_started_time_agrees_with_ps(): import datetime proc = subprocess.Popen(["sleep", "30"]) try: time.sleep(0.3) got = pw.snapshot()[proc.pid] out = subprocess.run( ["ps", "-o", "lstart=", "-p", str(proc.pid)], capture_output=True, text=True, check=False, ).stdout.strip() if out: when = datetime.datetime.strptime(out, "%a %b %d %H:%M:%S %Y") assert abs(got.started - when.timestamp()) < 2 finally: proc.kill() proc.wait() # -------------------------------------------------------------------------- # walking the tree # -------------------------------------------------------------------------- def test_descendants_walks_a_real_tree(): proc = subprocess.Popen(["bash", "-c", "sleep 30 & sleep 30 & wait"]) try: time.sleep(0.4) snap = pw.snapshot() kids = pw.descendants(proc.pid, snap) assert len(kids) == 2, [k.argv0 for k in kids] assert all(k.argv0 == "sleep" for k in kids) with_root = pw.descendants(proc.pid, snap, include_root=True) assert len(with_root) == 3 assert proc.pid in {k.pid for k in with_root} finally: proc.kill() proc.wait() def test_descendants_of_an_unknown_pid_is_empty(): assert pw.descendants(999999, pw.snapshot()) == [] def test_descendants_is_breadth_first(): """Ordering matters for a stable display: parents before children.""" proc = subprocess.Popen(["bash", "-c", "bash -c 'sleep 30' & wait"]) try: time.sleep(0.4) snap = pw.snapshot() kids = pw.descendants(proc.pid, snap) depths = [] for kid in kids: depth, node = 0, kid while node.ppid != proc.pid and node.ppid in snap: node = snap[node.ppid] depth += 1 if depth > 10: break depths.append(depth) assert depths == sorted(depths), depths finally: proc.kill() proc.wait() # -------------------------------------------------------------------------- # classification # -------------------------------------------------------------------------- def test_classify_picks_the_most_specific_label(): """`cargo` is more useful than the `compile` its children would give.""" proc = subprocess.Popen(["bash", "-c", "exec -a cargo sleep 30"]) try: time.sleep(0.3) snap = pw.snapshot() assert pw.classify(pw.descendants(proc.pid, snap, include_root=True)) == "cargo" finally: proc.kill() proc.wait() def test_classify_of_an_unrecognised_tree_is_empty(): proc = subprocess.Popen(["sleep", "30"]) try: time.sleep(0.2) snap = pw.snapshot() assert pw.classify(pw.descendants(proc.pid, snap, include_root=True)) == "" finally: proc.kill() proc.wait() def test_classify_is_deterministic_regardless_of_order(): """The label must not depend on which process happened to be listed first.""" proc_a = subprocess.Popen(["bash", "-c", "exec -a rustc sleep 30"]) proc_b = subprocess.Popen(["bash", "-c", "exec -a cargo sleep 30"]) try: time.sleep(0.3) snap = pw.snapshot() tree = pw.descendants(proc_a.pid, snap, include_root=True) + pw.descendants( proc_b.pid, snap, include_root=True ) assert pw.classify(tree) == pw.classify(list(reversed(tree))) == "cargo" finally: proc_a.kill() proc_b.kill() proc_a.wait() proc_b.wait() # -------------------------------------------------------------------------- # mapping a working directory back to a package # -------------------------------------------------------------------------- def test_package_of_reads_the_first_path_component(): clone = Path("/tmp/clone") cases = { "/tmp/clone/foo": "foo", "/tmp/clone/foo/src/build": "foo", "/tmp/clone/foo/pkg/foo/usr": "foo", "/tmp/clone/foo-bar/sub": "foo-bar", "/tmp/clone": None, "/tmp/elsewhere/foo": None, None: None, } for path, want in cases.items(): assert pw.package_of(path, clone) == want, (path, pw.package_of(path, clone)) def test_package_of_honours_an_explicit_known_set(): clone = Path("/tmp/clone") assert pw.package_of("/tmp/clone/foo/src", clone, known={"foo"}) == "foo" assert pw.package_of("/tmp/clone/foo/src", clone, known={"bar"}) is None def test_package_of_rejects_a_prefix_that_is_not_a_boundary(): """`/tmp/clone-other/foo` is not inside `/tmp/clone`.""" assert pw.package_of("/tmp/clone-other/foo", Path("/tmp/clone")) is None # -------------------------------------------------------------------------- # durations # -------------------------------------------------------------------------- def test_format_duration(): cases = { 0: "0s", 59: "59s", 60: "1m00s", 3599: "59m59s", 3600: "1h00m", 3661: "1h01m", 86400: "24h00m", } for seconds, want in cases.items(): assert pw.format_duration(seconds) == want, seconds def test_elapsed_is_never_negative(): """A clock adjustment must not produce a negative timer on screen.""" proc = pw.Proc(pid=1, ppid=0, comm="x", cmdline=(), cwd=None, started=time.time() + 3600) assert proc.elapsed == 0.0 def test_a_package_finishes_exactly_once(): """A duplicate here is a duplicate row in the live feed. `finished` served as both the pending queue and the record of what had been reported, so clearing it to report made the next poll finish the same package again -- and the drain after the launcher exits always polls again. """ import stat import tempfile directory = Path(tempfile.mkdtemp()) clone = directory / "clone" (clone / "pkgA").mkdir(parents=True) # a launcher that spawns a child, as paru does; the root is excluded from # the tree by design, so the builder has to be a child tool = directory / "launcher" tool.write_text(f'#!/bin/bash\n( cd "{clone}/pkgA" && exec -a makepkg sleep 0.4 )\n') tool.chmod(tool.stat().st_mode | stat.S_IXUSR) watcher = pw.Watcher(clone, known={"pkgA"}) proc = subprocess.Popen([str(tool)]) finishes = [] try: while proc.poll() is None: watcher.poll(proc.pid) finishes += watcher.new_finishes() time.sleep(0.03) # the drain, then one more poll than strictly needed watcher.poll(None) finishes += watcher.new_finishes() watcher.poll(None) finishes += watcher.new_finishes() finally: proc.kill() proc.wait() assert finishes == ["pkgA"], finishes def test_new_finishes_drains(): watcher = pw.Watcher(Path("/tmp/does-not-matter")) watcher.seen.add("pkg") watcher.poll(None) assert watcher.new_finishes() == ["pkg"] assert watcher.new_finishes() == [] # and a later poll must not resurrect it watcher.poll(None) assert watcher.new_finishes() == [] def main() -> int: tests = [(n, f) for n, f in sorted(globals().items()) if n.startswith("test_") and callable(f)] failed = 0 for name, fn in tests: try: fn() except Exception as exc: # noqa: BLE001 failed += 1 print(f"FAIL {name}\n {type(exc).__name__}: {exc}") else: print(f"ok {name}") print(f"\n{len(tests) - failed}/{len(tests)} passed") return 1 if failed else 0 if __name__ == "__main__": raise SystemExit(main())