Zum Inhalt

tests/test_gui_control_sentinel.py

# tests/test_gui_control_sentinel.py
import os
import subprocess
import sys
import time

import psutil

from touchstone.gui import control


def test_write_read_roundtrip(tmp_path):
    rd = tmp_path / "run1"
    rd.mkdir()
    control.write_sentinel(
        rd,
        kind="eval",
        pid=os.getpid(),
        pack_path="packs/ndassist.yaml",
        config_path="config.m5.yaml",
    )
    s = control.read_sentinel(rd)
    assert s is not None
    assert s["kind"] == "eval" and s["pid"] == os.getpid()
    assert s["state"] == "running"


def test_is_active_true_for_live_pid(tmp_path):
    rd = tmp_path / "run2"
    rd.mkdir()
    control.write_sentinel(rd, kind="eval", pid=os.getpid(), pack_path="p", config_path="c")
    assert control.is_active(control.read_sentinel(rd)) is True


def test_is_active_false_for_dead_pid(tmp_path):
    rd = tmp_path / "run3"
    rd.mkdir()
    control.write_sentinel(
        rd,
        kind="eval",
        pid=2_000_000_000,  # almost certainly dead
        pack_path="p",
        config_path="c",
    )
    assert control.is_active(control.read_sentinel(rd)) is False


def test_is_active_false_for_zombie_child(tmp_path):
    """A finished-but-unreaped child (zombie) must read as not-active.

    The GUI server spawns the eval/judge CLI via Popen and never wait()s it, so a
    finished run lingers as a zombie. os.kill(zombie, 0) succeeds, which kept the
    overview card / SSE stream stuck on 'running' forever (the reported bug).
    """
    proc = subprocess.Popen([sys.executable, "-c", ""])
    try:
        deadline = time.time() + 5
        while time.time() < deadline:
            if psutil.Process(proc.pid).status() == psutil.STATUS_ZOMBIE:
                break
            time.sleep(0.01)
        rd = tmp_path / "zrun"
        rd.mkdir()
        control.write_sentinel(rd, kind="judge", pid=proc.pid, pack_path="p", config_path="c")
        assert control.is_active(control.read_sentinel(rd)) is False
    finally:
        proc.wait()  # reap the zombie


def test_finalize_sentinel_marks_finished(tmp_path):
    rd = tmp_path / "runf"
    rd.mkdir()
    control.write_sentinel(rd, kind="eval", pid=os.getpid(), pack_path="p", config_path="c")
    control.finalize_sentinel(rd, ok=True)
    assert control.read_sentinel(rd)["state"] == "finished"


def test_finalize_sentinel_marks_failed(tmp_path):
    rd = tmp_path / "runx"
    rd.mkdir()
    control.write_sentinel(rd, kind="judge", pid=os.getpid(), pack_path="p", config_path="c")
    control.finalize_sentinel(rd, ok=False)
    assert control.read_sentinel(rd)["state"] == "failed"


def test_finalize_sentinel_noop_without_sentinel(tmp_path):
    # A plain CLI run (no GUI) has no run.json — finalize must never create one.
    rd = tmp_path / "bare"
    rd.mkdir()
    control.finalize_sentinel(rd, ok=True)
    assert control.read_sentinel(rd) is None


def test_finalize_sentinel_keeps_stopped(tmp_path):
    # User pressed stop → state 'stopped'; a late finalize must not clobber it.
    rd = tmp_path / "runs"
    rd.mkdir()
    control.write_sentinel(rd, kind="eval", pid=os.getpid(), pack_path="p", config_path="c")
    control.mark_sentinel(rd, "stopped")
    control.finalize_sentinel(rd, ok=True)
    assert control.read_sentinel(rd)["state"] == "stopped"


def test_read_missing_returns_none(tmp_path):
    assert control.read_sentinel(tmp_path / "nope") is None


def test_mark_and_clear(tmp_path):
    rd = tmp_path / "run4"
    rd.mkdir()
    control.write_sentinel(rd, kind="judge", pid=os.getpid(), pack_path="p", config_path="c")
    control.mark_sentinel(rd, "finished")
    assert control.read_sentinel(rd)["state"] == "finished"
    control.clear_sentinel(rd)
    assert control.read_sentinel(rd) is None