git/orchard/main
tests/test_procwatch.py
336 lines11 KB last change raw · history
"""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/<pid>/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/<pid>/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())