assert_stat samples a stat field once. warden-ui's fps counter is a rolling one-second window, so a single sample right after a page opens can read 0 even though the UI is live (flare-edge #44). Add wait_stat FIELD OP VALUE TIMEOUT, the same polling pattern as wait_hit/wait_json, built on a shared eval_stat() that assert_stat now uses too. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SMRwnkPp1upR6QFWCZouE7
1148 lines
49 KiB
Python
Executable File
1148 lines
49 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Offline tests for qmp.py's drive(): the QMP socket and the control channel
|
|
are faked, so this needs no VM and runs in a few seconds -- most of that is
|
|
test_mismatches_are_fails_not_stops deliberately waiting out two real
|
|
one-second timeouts to prove wait_json/wait_hit honour them.
|
|
|
|
What is worth pinning is the contract the docstring makes: every step gets a
|
|
results.jsonl row of ok / fail / fatal and the run CONTINUES, so one run
|
|
reports every broken expectation. The region verbs are where that was once
|
|
false (flare-edge issue #147): a reference whose box did not fit the
|
|
screendump raised out of drive() as a traceback, and a tolerance on the line
|
|
was never compared with the one the reference was captured under.
|
|
|
|
python3 test_qmp_drive.py
|
|
"""
|
|
import json
|
|
import os
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
import unittest
|
|
from types import SimpleNamespace
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
sys.path.insert(0, HERE)
|
|
import qmp # noqa: E402
|
|
|
|
SIZE = 64
|
|
|
|
|
|
def ppm_bytes(fill=0):
|
|
return b"P6\n%d %d\n255\n" % (SIZE, SIZE) + bytes([fill]) * (SIZE * SIZE * 3)
|
|
|
|
|
|
class FakeCtl:
|
|
"""Canned replies in the shapes warden_debug.c actually produces."""
|
|
|
|
def __init__(self, path, timeout=15.0):
|
|
self.path = path
|
|
self.stats_calls = 0
|
|
|
|
def send(self, cmd):
|
|
if cmd == "page":
|
|
return "Demo/Rows"
|
|
if cmd == "stats":
|
|
self.stats_calls += 1
|
|
return ("page: Demo/Rows\ncpu: 12%\nfps: 10\nrender: 3.20 ms/frame\n"
|
|
f"rga: 0%\nidle: 0\npresses: {self.stats_calls}\n"
|
|
f"releases: {self.stats_calls}\ntermbusy: 1\n"
|
|
"termintr: 2\ntermfg: -1\ntermsig: 3")
|
|
if cmd == "wake":
|
|
return "wake: ok"
|
|
if cmd == "home":
|
|
return "home: ok"
|
|
if cmd.startswith("scroll "):
|
|
arg = cmd[len("scroll "):]
|
|
if not cmd.endswith(" 0"):
|
|
return "scroll: ok y=300 of 900"
|
|
return "scroll: expected X Y DY (DY non-zero pixels), got '%s'" % arg
|
|
if cmd.startswith("@cat "):
|
|
return json.dumps({"a": {"b": 1}, "list": [1, 2], "name": "warden"})
|
|
if cmd.startswith("hit "):
|
|
return 'hit 47,676: obj text="" box=12,640,72x72'
|
|
if cmd.startswith("nav "):
|
|
return cmd + ": ok"
|
|
return ""
|
|
|
|
|
|
class DyingCtl:
|
|
"""A control channel that answers once -- a page still building, not
|
|
yet a match, the exact case wait_json/wait_hit poll for -- and then
|
|
dies on every later call, the way a real Ctl.send() does on EOF
|
|
(RuntimeError) or a socket timeout past its own 15s (socket.timeout,
|
|
an OSError). Regression fixture: that used to raise straight out of
|
|
drive() and take every later step with it."""
|
|
|
|
def __init__(self, path, timeout=15.0):
|
|
self.calls = 0
|
|
|
|
def send(self, cmd):
|
|
self.calls += 1
|
|
if self.calls == 1:
|
|
if cmd.startswith("@cat "):
|
|
return json.dumps({"a": {"b": 0}})
|
|
if cmd.startswith("hit "):
|
|
return "hit 47,676: nothing"
|
|
return ""
|
|
if self.calls == 2:
|
|
raise RuntimeError("control channel closed")
|
|
raise socket.timeout("timed out")
|
|
|
|
|
|
class ImmediateDyingCtl:
|
|
"""A control channel that is already dead before the first command --
|
|
models a crash that happened between two script steps, not mid-poll.
|
|
Regression fixture for drive()'s own top-level guard around handler():
|
|
before that guard existed, any non-wait_* verb (nav, wake, scroll/home,
|
|
page/hit/stats/ctl, assert_page, assert_hit, assert_json, assert_stat)
|
|
raised this straight out of drive() as an unhandled traceback, ending
|
|
the run instead of recording one fatal row and continuing (flare-edge
|
|
round-4 review)."""
|
|
|
|
def __init__(self, path, timeout=15.0):
|
|
pass
|
|
|
|
def send(self, cmd):
|
|
raise RuntimeError("control channel closed")
|
|
|
|
|
|
class FullscreenCtl:
|
|
"""Models the dashboard's REAL fullscreen state plus warden_debug.c's
|
|
`fullscreen`/`home` verbs closely enough to drive
|
|
qemu/tests/scripts/fullscreen-toggle-tracks-real-state.txt offline: `home`
|
|
always clears real_fs directly (warden_screen_overview_set_fullscreen(false)
|
|
in the `home` handler), and `hit 47 676` answers with the gear's box when
|
|
real_fs is false or the fullscreen canvas's box when it is true, exactly
|
|
like the live dashboard's tap-catcher target (overview.targets.yaml).
|
|
|
|
Subclasses disagree only about what `fullscreen toggle` reads to decide
|
|
its next state -- the one line of behaviour the underlying bug is about."""
|
|
|
|
def __init__(self, path, timeout=15.0):
|
|
self.real_fs = False
|
|
|
|
def send(self, cmd):
|
|
if cmd == "wake":
|
|
return "wake: ok"
|
|
if cmd == "page":
|
|
return "Dashboard/Dashboard"
|
|
if cmd == "home":
|
|
self.real_fs = False
|
|
return "home: ok"
|
|
if cmd.startswith("hit "):
|
|
if self.real_fs:
|
|
return 'hit 47,676: widget text="" box=0,0,720x720'
|
|
return 'hit 47,676: obj text="" box=12,640,72x72'
|
|
if cmd in ("fullscreen on", "fullscreen off", "fullscreen toggle"):
|
|
want = self._decide(cmd)
|
|
self.real_fs = want
|
|
self._remember(want)
|
|
return f"fullscreen {'on' if want else 'off'}: ok"
|
|
return ""
|
|
|
|
def _decide(self, cmd):
|
|
if cmd == "fullscreen on":
|
|
return True
|
|
if cmd == "fullscreen off":
|
|
return False
|
|
return self._toggle_target()
|
|
|
|
def _remember(self, want):
|
|
pass
|
|
|
|
def _toggle_target(self):
|
|
raise NotImplementedError
|
|
|
|
|
|
class FixedFullscreenCtl(FullscreenCtl):
|
|
"""The fix: `toggle` reads the SAME ground truth `home` just changed
|
|
(warden_screen_overview_get_fullscreen()), so it can never disagree with
|
|
what `home` (or a real tap) already did."""
|
|
|
|
def _toggle_target(self):
|
|
return not self.real_fs
|
|
|
|
|
|
class StaleBeliefFullscreenCtl(FullscreenCtl):
|
|
"""Pre-fix: `toggle` reads a belief of its own that `home` never touches
|
|
(warden_debug.c used to keep a private `static bool on`), so it can drift
|
|
from the real state and then toggle AWAY from that stale belief instead of
|
|
away from reality."""
|
|
|
|
def __init__(self, path, timeout=15.0):
|
|
super().__init__(path, timeout)
|
|
self.belief = False
|
|
|
|
def _remember(self, want):
|
|
self.belief = want
|
|
|
|
def _toggle_target(self):
|
|
return not self.belief
|
|
|
|
|
|
def fake_rpc(sock, sock_file, obj):
|
|
if obj.get("execute") == "screendump":
|
|
with open(obj["arguments"]["filename"], "wb") as fh:
|
|
fh.write(ppm_bytes())
|
|
return {}
|
|
|
|
|
|
def run_script(text, refs=None, rs485_control=None, ctl_cls=None, rpc_fn=None, console_path=None):
|
|
"""-> (exit code or None, {cmd: row}, rows) for one drive() over TEXT."""
|
|
outdir = tempfile.mkdtemp(prefix="qmpdrive.")
|
|
script = os.path.join(outdir, "s.txt")
|
|
with open(script, "w") as fh:
|
|
fh.write(text)
|
|
refs_path = os.path.join(outdir, "refs.json")
|
|
if refs is not None:
|
|
with open(refs_path, "w") as fh:
|
|
json.dump(refs, fh)
|
|
saved = qmp.rpc, qmp.Ctl
|
|
qmp.rpc, qmp.Ctl = (rpc_fn or fake_rpc), (ctl_cls or FakeCtl)
|
|
rc = None
|
|
try:
|
|
try:
|
|
qmp.drive(None, None, script, outdir, SIZE, ctl_path="fake",
|
|
console_path=console_path, refs_path=refs_path, rs485_control=rs485_control)
|
|
except SystemExit as e:
|
|
rc = e.code
|
|
finally:
|
|
qmp.rpc, qmp.Ctl = saved
|
|
with open(os.path.join(outdir, "results.jsonl")) as fh:
|
|
rows = [json.loads(line) for line in fh if line.strip()]
|
|
return rc, {r["cmd"]: r for r in rows}, rows
|
|
|
|
|
|
class PureHelpers(unittest.TestCase):
|
|
def test_tap_holds_press_across_loaded_guest_polls(self):
|
|
calls = []
|
|
saved_send, saved_sleep = qmp.send_events, qmp.time.sleep
|
|
qmp.send_events = lambda _s, _f, events: calls.append(events)
|
|
qmp.time.sleep = lambda seconds: calls.append(seconds)
|
|
try:
|
|
qmp.do_tap(None, None, 123, 456)
|
|
finally:
|
|
qmp.send_events, qmp.time.sleep = saved_send, saved_sleep
|
|
|
|
self.assertEqual(calls[0], [qmp.abs_ev("x", 123), qmp.abs_ev("y", 456), qmp.btn_ev(True)])
|
|
self.assertGreaterEqual(calls[1], 0.25)
|
|
self.assertLess(calls[1], 0.4)
|
|
self.assertEqual(calls[2], [qmp.btn_ev(False)])
|
|
|
|
def test_observed_tap_releases_after_guest_consumes_press(self):
|
|
calls = []
|
|
|
|
class ObservingCtl:
|
|
def __init__(self):
|
|
self.calls = 0
|
|
|
|
def send(self, cmd):
|
|
self.calls += 1
|
|
presses = 0 if self.calls < 3 else 1
|
|
releases = 0 if self.calls < 4 else 1
|
|
return f"presses: {presses}\nreleases: {releases}"
|
|
|
|
saved_send, saved_sleep = qmp.send_events, qmp.time.sleep
|
|
qmp.send_events = lambda _s, _f, events: calls.append(events)
|
|
qmp.time.sleep = lambda _seconds: None
|
|
try:
|
|
observed = qmp.do_observed_tap(
|
|
SimpleNamespace(s=None, f=None, ctl=ObservingCtl()), 123, 456)
|
|
finally:
|
|
qmp.send_events, qmp.time.sleep = saved_send, saved_sleep
|
|
|
|
self.assertTrue(observed)
|
|
self.assertEqual(calls, [
|
|
[qmp.abs_ev("x", 123), qmp.abs_ev("y", 456), qmp.btn_ev(True)],
|
|
[qmp.btn_ev(False)],
|
|
])
|
|
|
|
def test_observed_tap_timeout_still_releases_press(self):
|
|
calls = []
|
|
clock = iter((0.0, 0.0, qmp.TAP_OBSERVE_TIMEOUT_S + 0.1))
|
|
|
|
class UnobservingCtl:
|
|
def send(self, cmd):
|
|
return "presses: 0\nreleases: 0"
|
|
|
|
saved_send, saved_sleep, saved_monotonic = (
|
|
qmp.send_events, qmp.time.sleep, qmp.time.monotonic)
|
|
qmp.send_events = lambda _s, _f, events: calls.append(events)
|
|
qmp.time.sleep = lambda _seconds: None
|
|
qmp.time.monotonic = lambda: next(clock)
|
|
try:
|
|
observed = qmp.do_observed_tap(
|
|
SimpleNamespace(s=None, f=None, ctl=UnobservingCtl()), 123, 456)
|
|
finally:
|
|
qmp.send_events = saved_send
|
|
qmp.time.sleep = saved_sleep
|
|
qmp.time.monotonic = saved_monotonic
|
|
|
|
self.assertFalse(observed)
|
|
self.assertEqual(calls[-1], [qmp.btn_ev(False)])
|
|
|
|
def test_observed_swipe_moves_after_guest_consumes_press(self):
|
|
calls = []
|
|
|
|
class ObservingCtl:
|
|
def __init__(self):
|
|
self.calls = 0
|
|
|
|
def send(self, cmd):
|
|
self.calls += 1
|
|
presses = 0 if self.calls < 3 else 1
|
|
releases = 0 if self.calls < 4 else 1
|
|
return f"presses: {presses}\nreleases: {releases}"
|
|
|
|
saved_send, saved_sleep = qmp.send_events, qmp.time.sleep
|
|
qmp.send_events = lambda _s, _f, events: calls.append(events)
|
|
qmp.time.sleep = lambda _seconds: None
|
|
try:
|
|
observed = qmp.do_observed_swipe(
|
|
SimpleNamespace(s=None, f=None, ctl=ObservingCtl(), size=720),
|
|
10, 20, 110, 120, ms=50, steps=2)
|
|
finally:
|
|
qmp.send_events, qmp.time.sleep = saved_send, saved_sleep
|
|
|
|
self.assertTrue(observed)
|
|
self.assertEqual(calls[0][-1], qmp.btn_ev(True))
|
|
self.assertEqual(calls[-1], [qmp.btn_ev(False)])
|
|
self.assertEqual(len(calls), 4)
|
|
|
|
def test_observed_swipe_timeout_still_releases_press(self):
|
|
calls = []
|
|
clock = iter((0.0, 0.0, qmp.TAP_OBSERVE_TIMEOUT_S + 0.1))
|
|
|
|
class UnobservingCtl:
|
|
def send(self, cmd):
|
|
return "presses: 0\nreleases: 0"
|
|
|
|
saved_send, saved_sleep, saved_monotonic = (
|
|
qmp.send_events, qmp.time.sleep, qmp.time.monotonic)
|
|
qmp.send_events = lambda _s, _f, events: calls.append(events)
|
|
qmp.time.sleep = lambda _seconds: None
|
|
qmp.time.monotonic = lambda: next(clock)
|
|
try:
|
|
observed = qmp.do_observed_swipe(
|
|
SimpleNamespace(s=None, f=None, ctl=UnobservingCtl(), size=720),
|
|
10, 20, 110, 120, ms=50, steps=2)
|
|
finally:
|
|
qmp.send_events = saved_send
|
|
qmp.time.sleep = saved_sleep
|
|
qmp.time.monotonic = saved_monotonic
|
|
|
|
self.assertFalse(observed)
|
|
self.assertEqual(len(calls), 2)
|
|
self.assertEqual(calls[-1], [qmp.btn_ev(False)])
|
|
|
|
def test_every_pixel_round_trips_through_lvgl_calibration(self):
|
|
# lv_evdev.c _evdev_calibrate: px = axis * (width - 1) / AXIS_MAX,
|
|
# integer division. A tap requested at px must land at px, for every
|
|
# px, or a hit-confirmed target can be missed by one pixel.
|
|
for size in (720, 480, 1024):
|
|
for px in range(size):
|
|
axis = qmp.to_axis(px, size)
|
|
self.assertTrue(0 <= axis <= qmp.AXIS_MAX)
|
|
back = axis * (size - 1) // qmp.AXIS_MAX
|
|
self.assertEqual(back, px, f"size {size}: px {px} -> axis {axis} -> {back}")
|
|
|
|
def test_resolve_path_and_ops(self):
|
|
doc = {"a": {"b": [5, 6]}, "s": "connected"}
|
|
self.assertEqual(qmp.resolve_path(doc, "a.b[1]"), (6, None))
|
|
self.assertIsNotNone(qmp.resolve_path(doc, "a.c")[1])
|
|
self.assertTrue(qmp.apply_op("eq", 1, "1"))
|
|
self.assertTrue(qmp.apply_op("ne", 1, "2"))
|
|
self.assertTrue(qmp.apply_op("contains", "connected", "nect"))
|
|
self.assertTrue(qmp.apply_op("len_ge", [1, 2], "2"))
|
|
self.assertTrue(qmp.apply_op("len_eq", [1, 2], "2"))
|
|
self.assertTrue(qmp.apply_op("gt", 3.0, "2"))
|
|
self.assertTrue(qmp.apply_op("lt", 1, "2"))
|
|
self.assertTrue(qmp.apply_op("eq", "connected", "connected"))
|
|
|
|
def test_apply_op_rejects_bad_combinations(self):
|
|
# eval_json and verb_assert_stat both catch (TypeError, ValueError)
|
|
# specifically so a malformed OP in a hand-written or generated
|
|
# script reads as a `fail` row with a reason, not a driver crash --
|
|
# that contract depends on apply_op actually raising these, which
|
|
# nothing exercised directly before.
|
|
with self.assertRaises(ValueError):
|
|
qmp.apply_op("bogus", 1, "1")
|
|
with self.assertRaises(TypeError):
|
|
qmp.apply_op("contains", 5, "1")
|
|
|
|
def test_flag_reads_an_optional_argv_pair_or_the_default(self):
|
|
argv = ["qmp.py", "sock", "drive", "s.txt", "out", "--size", "480"]
|
|
self.assertEqual(qmp.flag(argv, "--size"), "480")
|
|
self.assertEqual(qmp.flag(argv, "--ctl"), None)
|
|
self.assertEqual(qmp.flag(argv, "--ctl", "default"), "default")
|
|
|
|
def test_parse_stats(self):
|
|
got = qmp.parse_stats(FakeCtl("x").send("stats"))
|
|
self.assertEqual(got, {"cpu": 12.0, "fps": 10.0, "render": 3.2,
|
|
"idle": 0.0, "presses": 1.0, "releases": 1.0,
|
|
"termbusy": 1.0, "termintr": 2.0,
|
|
"termfg": -1.0, "termsig": 3.0})
|
|
|
|
def test_eval_stat_rejects_unknown_fields_and_catches_bad_ops(self):
|
|
# eval_stat's own whitelist is narrower than parse_stats(): 'releases'
|
|
# is a real field in a `stats` reply (see test_parse_stats) but not
|
|
# one assert_stat/wait_stat expose. And it must catch apply_op's
|
|
# (TypeError, ValueError) the same way eval_json does, so a malformed
|
|
# OP reads as an ordinary fail, not a driver crash, for both the
|
|
# single-read and the polling verb built on top of it.
|
|
ctl = FakeCtl("x")
|
|
ok, detail = qmp.eval_stat(ctl, "releases", "eq", "1")
|
|
self.assertFalse(ok)
|
|
self.assertIn("unknown stat field", detail)
|
|
ok, detail = qmp.eval_stat(ctl, "fps", "bogus", "1")
|
|
self.assertFalse(ok)
|
|
self.assertIn("bogus", detail)
|
|
ok, detail = qmp.eval_stat(ctl, "fps", "eq", "10")
|
|
self.assertTrue(ok)
|
|
self.assertEqual(detail, "")
|
|
|
|
def test_poll_until_turns_a_channel_fault_into_fatal_not_a_raise(self):
|
|
# A CHECK that raises RuntimeError or OSError (Ctl.send on EOF or a
|
|
# socket timeout) must stop poll_until() and come back with the
|
|
# exception text and fatal=True instead of propagating -- the wait_*
|
|
# verbs are the only thing standing between that and drive() dying
|
|
# with an unhandled traceback mid-script.
|
|
ok, detail, waited, fatal = qmp.poll_until(
|
|
lambda: (_ for _ in ()).throw(RuntimeError("control channel closed")), 5)
|
|
self.assertFalse(ok)
|
|
self.assertEqual(detail, "control channel closed")
|
|
self.assertTrue(fatal)
|
|
self.assertLess(waited, 1, "a raise must stop the poll immediately, not wait out TIMEOUT_S")
|
|
|
|
ok, detail, waited, fatal = qmp.poll_until(
|
|
lambda: (_ for _ in ()).throw(socket.timeout("timed out")), 5)
|
|
self.assertFalse(ok)
|
|
self.assertTrue(fatal, "socket.timeout is an OSError and must be caught the same way")
|
|
|
|
# The ordinary paths still return the same 4-tuple shape.
|
|
ok, detail, waited, fatal = qmp.poll_until(lambda: (True, ""), 5)
|
|
self.assertTrue(ok)
|
|
self.assertFalse(fatal)
|
|
ok, detail, waited, fatal = qmp.poll_until(lambda: (False, "not yet"), 0.1, period=0.05)
|
|
self.assertFalse(ok)
|
|
self.assertFalse(fatal)
|
|
|
|
|
|
class DriveVerbs(unittest.TestCase):
|
|
def test_every_channel_passes_on_a_healthy_ui(self):
|
|
rc, by, rows = run_script(
|
|
"wake\n"
|
|
"assert_page Demo/Rows\n"
|
|
"assert_hit 47 676 obj box=12,640,72x72\n"
|
|
"assert_stat idle eq 0\n"
|
|
"wait_json a.b eq 1 2\n"
|
|
"wait_json list len_ge 2 2\n"
|
|
"assert_json name eq warden\n"
|
|
"assert_stat fps gt 0\n"
|
|
"wait_stat fps gt 0 2\n"
|
|
"nav Demo/Rows\n"
|
|
"capture_region r1 0 0 8 8 exact\n"
|
|
"assert_region r1 exact\n"
|
|
"assert_region r1\n"
|
|
"wait_hit 47 676 obj 2 box=12,640,72x72\n"
|
|
"wait_region r1 exact 2\n"
|
|
"scroll 360 400 300\n"
|
|
"home\n"
|
|
)
|
|
self.assertIsNone(rc, [r for r in rows if r["status"] != "ok"])
|
|
self.assertEqual(len(rows), 17)
|
|
self.assertIn("waited", by["wait_hit 47 676 obj 2 box=12,640,72x72"]["detail"])
|
|
self.assertIn("waited", by["wait_stat fps gt 0 2"]["detail"])
|
|
self.assertTrue(all(r["status"] == "ok" for r in rows))
|
|
|
|
def test_mismatches_are_fails_not_stops(self):
|
|
t0 = time.monotonic()
|
|
rc, by, rows = run_script(
|
|
"assert_page Demo/Other\n"
|
|
"assert_hit 47 676 obj box=0,0,1x1\n"
|
|
"wait_json a.b eq 2 1\n"
|
|
"assert_json a.zz eq 1\n"
|
|
"assert_stat fps lt 0\n"
|
|
"wait_stat fps lt 0 1\n"
|
|
"scroll 360 400 0\n"
|
|
"wait_hit 47 676 obj 1 box=0,0,1x1\n"
|
|
"assert_page Demo/Rows\n"
|
|
)
|
|
self.assertEqual(rc, 1)
|
|
self.assertEqual([r["status"] for r in rows],
|
|
["fail", "fail", "fail", "fail", "fail", "fail", "fail", "fail", "ok"])
|
|
self.assertIn("moved", by["assert_hit 47 676 obj box=0,0,1x1"]["detail"])
|
|
self.assertIn("moved", by["wait_hit 47 676 obj 1 box=0,0,1x1"]["detail"])
|
|
self.assertIn("fps is 10.0", by["wait_stat fps lt 0 1"]["detail"])
|
|
self.assertGreaterEqual(time.monotonic() - t0, 3.0,
|
|
"wait_json, wait_stat and wait_hit must each honour their timeout")
|
|
|
|
def test_dying_channel_is_fatal_for_the_step_not_a_crash(self):
|
|
# A page still building when the control channel itself drops mid-
|
|
# poll -- Ctl.send() raising RuntimeError on EOF or socket.timeout
|
|
# past its own 15s -- used to escape drive() as an unhandled
|
|
# traceback, losing the row for the step that was polling and every
|
|
# step after it. wait_json and wait_hit (both poll_until) must
|
|
# instead read as one `fatal` row, with the run continuing past it.
|
|
rc, by, rows = run_script(
|
|
"wait_json a.b eq 1 2\n"
|
|
"sleep 0\n",
|
|
ctl_cls=DyingCtl,
|
|
)
|
|
self.assertEqual(rc, 1)
|
|
self.assertEqual([r["status"] for r in rows], ["fatal", "ok"])
|
|
self.assertIn("control channel closed", by["wait_json a.b eq 1 2"]["detail"])
|
|
|
|
rc, by, rows = run_script(
|
|
"wait_hit 47 676 obj 2\n"
|
|
"sleep 0\n",
|
|
ctl_cls=DyingCtl,
|
|
)
|
|
self.assertEqual(rc, 1)
|
|
self.assertEqual([r["status"] for r in rows], ["fatal", "ok"])
|
|
self.assertIn("control channel closed", by["wait_hit 47 676 obj 2"]["detail"])
|
|
|
|
rc, by, rows = run_script(
|
|
"wait_stat fps gt 0 2\n"
|
|
"sleep 0\n",
|
|
ctl_cls=DyingCtl,
|
|
)
|
|
self.assertEqual(rc, 1)
|
|
self.assertEqual([r["status"] for r in rows], ["fatal", "ok"])
|
|
self.assertIn("control channel closed", by["wait_stat fps gt 0 2"]["detail"])
|
|
|
|
def test_dying_channel_is_fatal_for_every_verb_not_just_wait(self):
|
|
# wait_hit/wait_json/wait_stat route the channel through poll_until,
|
|
# which has always caught this. Every other verb that reaches
|
|
# ctl.send() directly did not, and used to crash the whole run
|
|
# instead of recording one fatal row and moving on. One
|
|
# representative of each family, back to back: every one must read
|
|
# as its own `fatal` row and the script must still reach the last
|
|
# line.
|
|
rc, by, rows = run_script(
|
|
"nav Demo/Rows\n"
|
|
"wake\n"
|
|
"home\n"
|
|
"page\n"
|
|
"assert_page Demo/Rows\n"
|
|
"assert_hit 47 676 obj\n"
|
|
"assert_json a.b eq 1\n"
|
|
"assert_stat idle eq 0\n"
|
|
"wait_stat fps gt 0 1\n"
|
|
"sleep 0\n",
|
|
ctl_cls=ImmediateDyingCtl,
|
|
)
|
|
self.assertEqual(rc, 1)
|
|
self.assertEqual([r["status"] for r in rows],
|
|
["fatal"] * 9 + ["ok"],
|
|
"a dead channel must not crash the run: every ctl "
|
|
"verb gets its own fatal row and sleep still runs")
|
|
for row in rows[:-1]:
|
|
self.assertIn("control channel closed", row["detail"])
|
|
|
|
def test_dying_qmp_socket_is_fatal_not_a_crash(self):
|
|
# tap/swipe/fling/shot (and the region verbs) reach the QMP socket
|
|
# through rpc(), a separate channel from ctl.send(), guarded by the
|
|
# same top-level try/except in drive(). A RuntimeError from a QMP
|
|
# error reply, or an OSError from the socket itself going away, must
|
|
# read as a fatal row per step, not crash the run.
|
|
def dying_rpc(sock, sock_file, obj):
|
|
raise OSError("QMP socket closed")
|
|
|
|
rc, by, rows = run_script(
|
|
"shot before\n"
|
|
"tap 10 10\n"
|
|
"sleep 0\n",
|
|
rpc_fn=dying_rpc,
|
|
)
|
|
self.assertEqual(rc, 1)
|
|
self.assertEqual([r["status"] for r in rows], ["fatal", "fatal", "ok"])
|
|
for row in rows[:-1]:
|
|
self.assertIn("QMP socket closed", row["detail"])
|
|
|
|
def test_malformed_numeric_argument_is_fatal_for_the_step_not_a_crash(self):
|
|
# Several verbs parse their own arguments with bare
|
|
# int()/float()/positional indexing before any handler-local guard
|
|
# (tap, swipe, fling, sleep, wait_hit, wait_json, wait_stat,
|
|
# capture_region, wait_region). A typo'd coordinate or a missing
|
|
# argument -- exactly what a hand-edited *.txt script or a flowc.py
|
|
# bug can produce -- used to raise ValueError/IndexError straight
|
|
# out of drive(), losing every row from that line onward instead of
|
|
# reading as its own fatal row (flare-edge #244).
|
|
rc, by, rows = run_script(
|
|
"tap 10 abc\n"
|
|
"sleep 0\n",
|
|
)
|
|
self.assertEqual(rc, 1)
|
|
self.assertEqual([r["status"] for r in rows], ["fatal", "ok"])
|
|
self.assertIn("invalid literal", by["tap 10 abc"]["detail"])
|
|
|
|
rc, by, rows = run_script(
|
|
"tap 10\n"
|
|
"sleep 0\n",
|
|
)
|
|
self.assertEqual(rc, 1)
|
|
self.assertEqual([r["status"] for r in rows], ["fatal", "ok"])
|
|
self.assertIn("list index out of range", by["tap 10"]["detail"])
|
|
|
|
rc, by, rows = run_script(
|
|
"capture_region r1 0 0 8 notanumber exact\n"
|
|
"sleep 0\n",
|
|
)
|
|
self.assertEqual(rc, 1)
|
|
self.assertEqual([r["status"] for r in rows], ["fatal", "ok"])
|
|
self.assertIn("invalid literal",
|
|
by["capture_region r1 0 0 8 notanumber exact"]["detail"])
|
|
|
|
rc, by, rows = run_script(
|
|
"wait_stat fps gt 0 notanumber\n"
|
|
"sleep 0\n",
|
|
)
|
|
self.assertEqual(rc, 1)
|
|
self.assertEqual([r["status"] for r in rows], ["fatal", "ok"])
|
|
self.assertIn("could not convert string to float",
|
|
by["wait_stat fps gt 0 notanumber"]["detail"])
|
|
|
|
def test_bad_op_reads_as_a_fail_row_not_a_crash(self):
|
|
# apply_op's error paths (unknown OP -> ValueError, 'contains'
|
|
# against the wrong type -> TypeError) are caught by both callers
|
|
# (eval_json, verb_assert_stat) and must read as an ordinary `fail`
|
|
# row through the real verb handlers, not an uncaught exception or a
|
|
# SystemExit out of drive() itself.
|
|
rc, by, rows = run_script(
|
|
"assert_json a.b bogus 1\n"
|
|
"assert_stat fps bogus 1\n"
|
|
)
|
|
self.assertEqual(rc, 1)
|
|
self.assertEqual([r["status"] for r in rows], ["fail", "fail"])
|
|
self.assertIn("bogus", by["assert_json a.b bogus 1"]["detail"])
|
|
self.assertIn("bogus", by["assert_stat fps bogus 1"]["detail"])
|
|
|
|
def test_assert_ocr_reports_no_tesseract_ocr_failure_and_match_or_not(self):
|
|
# assert_ocr's own surface -- the tesseract-not-installed fatal, the
|
|
# subprocess call, its exception net, and the final regex decision
|
|
# -- had no coverage at all: a regression here would only be caught
|
|
# by a live rig run against real tesseract. shutil.which and
|
|
# subprocess.run are swapped the same way rs485_send is above, since
|
|
# both are stdlib calls qmp.py makes directly, not seams of its own.
|
|
refs = {"r1": {"x": 0, "y": 0, "w": 8, "h": 8, "tolerance": "exact",
|
|
"phash": "0" * 16, "structural": "00" * 32}}
|
|
saved_which, saved_run = qmp.shutil.which, qmp.subprocess.run
|
|
|
|
def restore():
|
|
qmp.shutil.which, qmp.subprocess.run = saved_which, saved_run
|
|
|
|
try:
|
|
qmp.shutil.which = lambda name: None
|
|
rc, by, rows = run_script("assert_ocr r1 hello\n", refs=refs)
|
|
self.assertEqual(rows[0]["status"], "fatal")
|
|
self.assertIn("tesseract not installed", rows[0]["detail"])
|
|
|
|
qmp.shutil.which = lambda name: "/usr/bin/tesseract"
|
|
|
|
def crashing_run(*a, **k):
|
|
raise OSError("tesseract crashed")
|
|
|
|
qmp.subprocess.run = crashing_run
|
|
rc, by, rows = run_script("assert_ocr r1 hello\n", refs=refs)
|
|
self.assertEqual(rows[0]["status"], "fatal")
|
|
self.assertIn("ocr failed", rows[0]["detail"])
|
|
|
|
def matching_run(cmd, **k):
|
|
return type("R", (), {"stdout": "hello world\n"})()
|
|
|
|
qmp.subprocess.run = matching_run
|
|
rc, by, rows = run_script("assert_ocr r1 hello\n", refs=refs)
|
|
self.assertEqual(rows[0]["status"], "ok")
|
|
|
|
def nonmatching_run(cmd, **k):
|
|
return type("R", (), {"stdout": "goodbye\n"})()
|
|
|
|
qmp.subprocess.run = nonmatching_run
|
|
rc, by, rows = run_script("assert_ocr r1 hello\n", refs=refs)
|
|
self.assertEqual(rows[0]["status"], "fail")
|
|
self.assertIn("goodbye", rows[0]["detail"])
|
|
finally:
|
|
restore()
|
|
|
|
def test_region_faults_are_per_step_fatal(self):
|
|
# A pre-seeded reference whose box does not fit a 64x64 screendump,
|
|
# a name with no reference, a tolerance other than the captured one,
|
|
# and a capture box off the screen: each is FATAL for its own step
|
|
# and the assert_page after them still runs.
|
|
refs = {"big": {"x": 10, "y": 10, "w": 1000, "h": 1000, "tolerance": "exact",
|
|
"phash": "0" * 16, "structural": "00" * 32}}
|
|
rc, by, rows = run_script(
|
|
"assert_region big exact\n"
|
|
"assert_region nope exact\n"
|
|
"capture_region r1 0 0 8 8 exact\n"
|
|
"assert_region r1 loose\n"
|
|
"capture_region huge 0 0 999 999 exact\n"
|
|
"capture_region r2 0 0 8 8 fuzzy\n"
|
|
"assert_page Demo/Rows\n",
|
|
refs=refs,
|
|
)
|
|
self.assertEqual(rc, 1)
|
|
self.assertEqual([r["status"] for r in rows],
|
|
["fatal", "fatal", "ok", "fatal", "fatal", "fail", "ok"])
|
|
self.assertIn("unusable", by["assert_region big exact"]["detail"])
|
|
self.assertIn("no reference", by["assert_region nope exact"]["detail"])
|
|
self.assertIn("captured as exact, script expects loose",
|
|
by["assert_region r1 loose"]["detail"])
|
|
self.assertIn("cannot capture", by["capture_region huge 0 0 999 999 exact"]["detail"])
|
|
self.assertIn("unknown tolerance", by["capture_region r2 0 0 8 8 fuzzy"]["detail"])
|
|
|
|
def test_rs485_verb_talks_to_the_simulator_or_is_fatal(self):
|
|
sent = []
|
|
|
|
def fake_send(path, line):
|
|
sent.append((path, line))
|
|
return "ok 5 silent" if line.startswith("silence") else "error no simulated device at 9"
|
|
|
|
saved = qmp.rs485_send
|
|
qmp.rs485_send = fake_send
|
|
try:
|
|
rc, by, rows = run_script("rs485 silence 5\nrs485 restore 9\nrs485 bounce 5\n",
|
|
rs485_control="fake.ctl")
|
|
finally:
|
|
qmp.rs485_send = saved
|
|
self.assertEqual([r["status"] for r in rows], ["ok", "fail", "fatal"])
|
|
self.assertEqual(sent, [("fake.ctl", "silence 5"), ("fake.ctl", "restore 9")])
|
|
rc, by, rows = run_script("rs485 silence 5\n")
|
|
self.assertEqual(rows[0]["status"], "fatal", "no bus in the run must not look like a pass")
|
|
self.assertIn("no simulated RS485 bus", rows[0]["detail"])
|
|
|
|
def test_shot_and_region_names_cannot_escape_outdir(self):
|
|
# flowc.py does not restrict shot/region names beyond forbidding
|
|
# whitespace (see its own `shot` and `one_token` comments), so an
|
|
# absolute or '../'-relative name must fail the step closed instead
|
|
# of ever reaching the screendump RPC -- an absolute name used to
|
|
# make os.path.join() discard outdir entirely and write there
|
|
# verbatim (flare-edge security review).
|
|
calls = []
|
|
|
|
def counting_rpc(sock, sock_file, obj):
|
|
calls.append(obj)
|
|
return fake_rpc(sock, sock_file, obj)
|
|
|
|
rc, by, rows = run_script(
|
|
"shot /etc/cron.d/evil\n"
|
|
"shot ../../../tmp/evil\n"
|
|
"capture_region ../evil 0 0 8 8 exact\n",
|
|
rpc_fn=counting_rpc,
|
|
)
|
|
self.assertEqual(rc, 1)
|
|
self.assertEqual([r["status"] for r in rows], ["fail", "fail", "fail"])
|
|
for row in rows:
|
|
self.assertIn("must not contain '/'", row["detail"])
|
|
self.assertEqual(calls, [], "a malformed name must never reach a screendump RPC")
|
|
|
|
# The ordinary case -- a plain name -- must still work.
|
|
rc, by, rows = run_script("shot 01-overview\n", rpc_fn=counting_rpc)
|
|
self.assertIsNone(rc)
|
|
self.assertEqual(rows[0]["status"], "ok")
|
|
|
|
def test_assert_region_refuses_a_refs_entry_whose_name_could_escape_outdir(self):
|
|
# capture_region can no longer write such a name (previous test),
|
|
# but refs.json is a hand-editable file on disk; assert_region (and
|
|
# wait_region, same fresh_region() call) must refuse a bad name from
|
|
# there too, not just at capture time.
|
|
refs = {"../evil": {"x": 0, "y": 0, "w": 8, "h": 8, "tolerance": "exact",
|
|
"phash": "0" * 16, "structural": "00" * 32}}
|
|
rc, by, rows = run_script("assert_region ../evil exact\n", refs=refs)
|
|
self.assertEqual(rc, 1)
|
|
self.assertEqual(rows[0]["status"], "fatal")
|
|
self.assertIn("must not contain '/'", rows[0]["detail"])
|
|
|
|
def test_wait_region_hits_the_same_fatal_paths_as_assert_region(self):
|
|
# wait_region drives the same fresh_region() call as assert_region
|
|
# (comment on verb_wait_region), so a missing reference, a foreign
|
|
# tolerance, and a refs.json name that could escape outdir must all
|
|
# be fatal here too -- and, since none of them can ever start
|
|
# passing, each must stop on its first check instead of waiting out
|
|
# TIMEOUT_S (poll_until's check_region() signals this by returning
|
|
# ok=True, caught via the `fault` list).
|
|
refs = {
|
|
"r1": {"x": 0, "y": 0, "w": 8, "h": 8, "tolerance": "exact",
|
|
"phash": "0" * 16, "structural": "00" * 32},
|
|
"../evil": {"x": 0, "y": 0, "w": 8, "h": 8, "tolerance": "exact",
|
|
"phash": "0" * 16, "structural": "00" * 32},
|
|
}
|
|
t0 = time.monotonic()
|
|
rc, by, rows = run_script(
|
|
"wait_region nope exact 2\n"
|
|
"wait_region r1 loose 2\n"
|
|
"wait_region ../evil exact 2\n",
|
|
refs=refs,
|
|
)
|
|
waited = time.monotonic() - t0
|
|
self.assertEqual(rc, 1)
|
|
self.assertEqual([r["status"] for r in rows], ["fatal", "fatal", "fatal"])
|
|
self.assertIn("no reference", by["wait_region nope exact 2"]["detail"])
|
|
self.assertIn("captured as exact, script expects loose",
|
|
by["wait_region r1 loose 2"]["detail"])
|
|
self.assertIn("must not contain '/'", by["wait_region ../evil exact 2"]["detail"])
|
|
self.assertLess(waited, 2.0,
|
|
"none of these three can ever pass, so none may wait "
|
|
"out its TIMEOUT_S of 2s each")
|
|
|
|
def test_unknown_verb_is_fatal_for_the_run(self):
|
|
# A silently-ignored line is a test that proves nothing, so this one
|
|
# is the documented exception to "the run continues": drive() exits
|
|
# with the message rather than recording a row.
|
|
rc, by, rows = run_script("assert_page Demo/Rows\nfrobnicate 1 2\n")
|
|
self.assertIsInstance(rc, str)
|
|
self.assertIn("unknown command 'frobnicate'", rc)
|
|
self.assertEqual([r["status"] for r in rows], ["ok"])
|
|
|
|
|
|
class ConsoleWatchTests(unittest.TestCase):
|
|
"""ConsoleWatch replaced a ui_exited() that reread and rescanned the
|
|
WHOLE console log on every one of drive()'s per-step checks -- O(steps *
|
|
final_size) for a file that only grows over a run. These pin the two
|
|
properties a byte-offset-tracking rewrite must not lose: a marker split
|
|
across two reads is still caught, and a match is cached (never rereads
|
|
again)."""
|
|
|
|
def test_finds_a_marker_split_across_two_reads_and_caches_it(self):
|
|
d = tempfile.mkdtemp(prefix="console.")
|
|
path = os.path.join(d, "console.log")
|
|
with open(path, "wb") as fh:
|
|
fh.write(b"boot log line one\nboot log line two\nwarden-ui EXI")
|
|
watch = qmp.ConsoleWatch(path)
|
|
self.assertIsNone(watch.check(), "no full marker written yet")
|
|
|
|
with open(path, "ab") as fh:
|
|
fh.write(b"TED at pc=0x1234\n")
|
|
got = watch.check()
|
|
self.assertIsNotNone(got, "the marker's second half arrived in this read")
|
|
self.assertTrue(got.startswith("warden-ui EXITED"), got)
|
|
|
|
# Cached: removing the file out from under a later check must not
|
|
# un-find the marker or raise.
|
|
os.remove(path)
|
|
self.assertEqual(watch.check(), got)
|
|
|
|
def test_no_console_path_is_always_none(self):
|
|
watch = qmp.ConsoleWatch(None)
|
|
self.assertIsNone(watch.check())
|
|
self.assertIsNone(watch.check())
|
|
|
|
def test_missing_file_returns_none_without_raising(self):
|
|
watch = qmp.ConsoleWatch(os.path.join(tempfile.mkdtemp(), "does-not-exist.log"))
|
|
self.assertIsNone(watch.check())
|
|
|
|
|
|
class DriveConsoleCheck(unittest.TestCase):
|
|
def test_a_console_crash_is_a_fatal_row_and_stops_the_run(self):
|
|
# End-to-end through drive(), not just ConsoleWatch on its own: the
|
|
# stage-2 init's marker on the console must still stop the script at
|
|
# the step that caused it (module docstring), the same contract
|
|
# ui_exited() made before this became an incremental read.
|
|
d = tempfile.mkdtemp(prefix="console.")
|
|
console_path = os.path.join(d, "console.log")
|
|
with open(console_path, "wb") as fh:
|
|
fh.write(b"warden-ui EXITED signal=11\n")
|
|
rc, by, rows = run_script("wake\nsleep 0\n", console_path=console_path)
|
|
self.assertEqual(rc, 1)
|
|
# drive() records the step's own result (wake's "ok") and then a
|
|
# second row for the crash it finds right after -- both pinned to
|
|
# line 1 -- and never reaches the `sleep 0` on line 2.
|
|
self.assertEqual(len(rows), 2, "drive() must stop at the crashing step")
|
|
self.assertEqual(rows[-1]["status"], "fatal")
|
|
self.assertIn("warden-ui EXITED", rows[-1]["detail"])
|
|
self.assertTrue(all(r["line"] == 1 for r in rows))
|
|
|
|
|
|
class FullscreenToggleTracksRealState(unittest.TestCase):
|
|
"""qemu/tests/scripts/fullscreen-toggle-tracks-real-state.txt, offline:
|
|
`home` clears the dashboard's real fullscreen state directly, and a
|
|
`fullscreen toggle` right after it must read THAT, not a belief `home`
|
|
bypassed (warden_debug.c's `fullscreen` verb). Run twice against the same
|
|
script to pin both sides: the fix passes it, the pre-fix shape fails on
|
|
exactly the step the bug breaks."""
|
|
|
|
SCRIPT = (
|
|
"wake\n"
|
|
"assert_page Dashboard/Dashboard\n"
|
|
"assert_hit 47 676 obj box=12,640,72x72\n"
|
|
"ctl fullscreen on\n"
|
|
"assert_hit 47 676 widget box=0,0,720x720\n"
|
|
"home\n"
|
|
"assert_hit 47 676 obj box=12,640,72x72\n"
|
|
"ctl fullscreen toggle\n"
|
|
"assert_hit 47 676 widget box=0,0,720x720\n"
|
|
)
|
|
|
|
def test_fixed_channel_passes_every_step(self):
|
|
rc, by, rows = run_script(self.SCRIPT, ctl_cls=FixedFullscreenCtl)
|
|
self.assertIsNone(rc, [r for r in rows if r["status"] != "ok"])
|
|
self.assertEqual(len(rows), 9)
|
|
self.assertTrue(all(r["status"] == "ok" for r in rows))
|
|
|
|
def test_stale_belief_channel_fails_the_toggle_after_home(self):
|
|
rc, by, rows = run_script(self.SCRIPT, ctl_cls=StaleBeliefFullscreenCtl)
|
|
self.assertEqual(rc, 1)
|
|
# Every step up to and including the post-home reset still passes --
|
|
# the drift is invisible until the toggle right after it.
|
|
self.assertEqual([r["status"] for r in rows[:7]], ["ok"] * 7)
|
|
self.assertEqual(rows[7]["cmd"], "ctl fullscreen toggle")
|
|
self.assertEqual(rows[8]["status"], "fail",
|
|
"toggle read the stale belief (still 'on' from the earlier "
|
|
"explicit call) and flipped away from it, landing back on "
|
|
"the already-off real state instead of turning it on")
|
|
|
|
|
|
class LazyImports(unittest.TestCase):
|
|
def test_module_import_does_not_pull_in_imgtools_or_pillow(self):
|
|
# imgtools.py imports Pillow at its own module scope; qmp.py used to
|
|
# `import imgtools` at ITS module scope too, so every subprocess
|
|
# invocation of qmp.py paid that cost even for screendump/tap/quit,
|
|
# which never touch a pixel -- defeating the Pillow-free boot-wait
|
|
# loop ui-drive.sh's own comment documents. A subprocess (not just
|
|
# checking qmp.imgtools in-process) is what actually pins this: the
|
|
# other tests in this file exercise region verbs and so leave the
|
|
# lazy slot filled in for the rest of THIS process.
|
|
script = (
|
|
"import sys\n"
|
|
f"sys.path.insert(0, {HERE!r})\n"
|
|
"import qmp\n"
|
|
"assert 'imgtools' not in sys.modules, 'imgtools imported eagerly'\n"
|
|
"assert 'PIL' not in sys.modules, 'Pillow imported eagerly'\n"
|
|
)
|
|
result = subprocess.run([sys.executable, "-c", script],
|
|
capture_output=True, text=True, timeout=10)
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
|
|
|
|
def _bare_ctl(sock):
|
|
"""A Ctl instance around an already-connected socket, bypassing
|
|
__init__'s own socket()+connect() (there is no path on disk to connect
|
|
to -- these tests drive a socketpair() end directly)."""
|
|
ctl = qmp.Ctl.__new__(qmp.Ctl)
|
|
ctl.sock = sock
|
|
ctl.buf = b""
|
|
return ctl
|
|
|
|
|
|
class CtlSocketProtocol(unittest.TestCase):
|
|
"""Ctl.send() itself -- the line-buffering loop that reassembles a reply
|
|
across possibly many recv() calls, skips the cooked-mode echo of the
|
|
command it just sent, and stops on the SENTINEL line -- has zero
|
|
coverage anywhere else in this file: every FakeCtl/DyingCtl/etc. above
|
|
replaces the whole class, never exercising the real one. This drives the
|
|
real qmp.Ctl over a live AF_UNIX socketpair standing in for the FIFO
|
|
bridge in rootfs/sbin/init, so the actual wire protocol gets checked
|
|
without a VM or rootfs changes."""
|
|
|
|
def setUp(self):
|
|
self.client_sock, self.server_sock = socket.socketpair(
|
|
socket.AF_UNIX, socket.SOCK_STREAM)
|
|
self.client_sock.settimeout(5.0)
|
|
self.ctl = _bare_ctl(self.client_sock)
|
|
|
|
def tearDown(self):
|
|
self.client_sock.close()
|
|
self.server_sock.close()
|
|
|
|
def test_reassembles_a_reply_split_across_two_recv_calls(self):
|
|
# The reply plus SENTINEL arrive in two separate writes, forcing
|
|
# Ctl.send() through at least two recv() calls for one line: the
|
|
# exact shape a reply straddling a 4096-byte read boundary takes on
|
|
# real hardware.
|
|
def server():
|
|
self.server_sock.recv(4096) # the command line
|
|
self.server_sock.sendall(b"first line\nsecond ")
|
|
time.sleep(0.05)
|
|
self.server_sock.sendall(b"line\n<<END>>\n")
|
|
|
|
th = threading.Thread(target=server, daemon=True)
|
|
th.start()
|
|
try:
|
|
got = self.ctl.send("stats")
|
|
finally:
|
|
th.join(timeout=2)
|
|
self.assertEqual(got, "first line\nsecond line")
|
|
|
|
def test_strips_the_cooked_mode_echo_of_the_command(self):
|
|
# The tty is in cooked mode, so the command comes back echoed before
|
|
# the real reply; Ctl.send() must drop that line, not treat it as
|
|
# part of the answer.
|
|
def server():
|
|
cmd_line = self.server_sock.recv(4096)
|
|
self.server_sock.sendall(cmd_line) # cooked-mode echo
|
|
self.server_sock.sendall(b"the actual reply\n<<END>>\n")
|
|
|
|
th = threading.Thread(target=server, daemon=True)
|
|
th.start()
|
|
try:
|
|
got = self.ctl.send("page")
|
|
finally:
|
|
th.join(timeout=2)
|
|
self.assertEqual(got, "the actual reply")
|
|
|
|
def test_leftover_bytes_after_sentinel_carry_over_to_the_next_send(self):
|
|
# One write carries this reply's SENTINEL immediately followed by
|
|
# bytes belonging to the NEXT command's reply -- proving self.buf
|
|
# correctly holds the leftover across two separate send() calls
|
|
# instead of dropping or re-reading it.
|
|
def server():
|
|
self.server_sock.recv(4096)
|
|
self.server_sock.sendall(b"reply one\n<<END>>\nreply two\n<<END>>\n")
|
|
|
|
th = threading.Thread(target=server, daemon=True)
|
|
th.start()
|
|
try:
|
|
got1 = self.ctl.send("cmd1")
|
|
finally:
|
|
th.join(timeout=2)
|
|
self.assertEqual(got1, "reply one")
|
|
|
|
# cmd2's own reply is already sitting in self.ctl.buf from the single
|
|
# write above; send() must serve it without another recv().
|
|
got2 = self.ctl.send("cmd2")
|
|
self.assertEqual(got2, "reply two")
|
|
|
|
|
|
class CtlBufferCap(unittest.TestCase):
|
|
"""Regression for Ctl.send() growing self.buf without bound: a peer that
|
|
keeps streaming bytes fast enough to beat the per-recv() socket timeout,
|
|
but never emits a newline or SENTINEL, used to grow self.buf forever
|
|
instead of failing closed."""
|
|
|
|
def test_raises_instead_of_growing_self_buf_without_bound(self):
|
|
client_sock, server_sock = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
client_sock.settimeout(3.0)
|
|
ctl = _bare_ctl(client_sock)
|
|
|
|
def server():
|
|
server_sock.recv(4096)
|
|
target = qmp.Ctl.MAX_BUF + 8192
|
|
sent = 0
|
|
try:
|
|
while sent < target:
|
|
server_sock.sendall(b"x" * 4096) # no newline, ever
|
|
sent += 4096
|
|
except OSError:
|
|
pass # the client closed once the cap tripped; nothing left to send to
|
|
|
|
th = threading.Thread(target=server, daemon=True)
|
|
th.start()
|
|
try:
|
|
with self.assertRaises(RuntimeError) as cm:
|
|
ctl.send("stats")
|
|
self.assertIn("too large", str(cm.exception))
|
|
self.assertLessEqual(
|
|
len(ctl.buf), qmp.Ctl.MAX_BUF + 4096,
|
|
"must fail as soon as the cap is crossed, not keep draining "
|
|
"an unbounded peer first")
|
|
finally:
|
|
client_sock.close()
|
|
server_sock.close()
|
|
th.join(timeout=2)
|
|
|
|
|
|
class QmpSocketTimeout(unittest.TestCase):
|
|
"""Regression for the QMP unix socket having no timeout: a peer that
|
|
accepts the connection but never answers (a wedged VM -- a TCG stall or
|
|
a kernel panic loop) used to block main()'s greeting readline() forever.
|
|
ui-drive.sh's own cleanup() calls `quit` on this exact socket before it
|
|
reaches reap("$QEMU_PID"), so an unbounded hang here defeats the one
|
|
thing meant to guarantee a wedged qemu-system-arm cannot outlive the
|
|
script."""
|
|
|
|
def test_main_bounds_a_wedged_qmp_peer_instead_of_hanging_forever(self):
|
|
d = tempfile.mkdtemp(prefix="qmpsock.")
|
|
sock_path = os.path.join(d, "qmp.sock")
|
|
srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
srv.bind(sock_path)
|
|
srv.listen(1)
|
|
|
|
def accept_and_hang():
|
|
conn, _ = srv.accept()
|
|
time.sleep(5) # never answer the greeting/qmp_capabilities handshake
|
|
conn.close()
|
|
|
|
th = threading.Thread(target=accept_and_hang, daemon=True)
|
|
th.start()
|
|
saved_timeout, saved_argv = qmp.QMP_TIMEOUT_S, sys.argv
|
|
qmp.QMP_TIMEOUT_S = 0.3
|
|
sys.argv = ["qmp.py", sock_path, "quit"]
|
|
try:
|
|
t0 = time.monotonic()
|
|
with self.assertRaises(OSError):
|
|
qmp.main()
|
|
elapsed = time.monotonic() - t0
|
|
self.assertLess(
|
|
elapsed, 2.0,
|
|
"a wedged QMP peer must be bounded by QMP_TIMEOUT_S, not hang "
|
|
"indefinitely (main()'s socket needs its own settimeout(), the "
|
|
"same way Ctl's already has one)")
|
|
finally:
|
|
sys.argv = saved_argv
|
|
qmp.QMP_TIMEOUT_S = saved_timeout
|
|
srv.close()
|
|
th.join(timeout=6)
|
|
|
|
|
|
class JsonStatusFetch(unittest.TestCase):
|
|
"""fetch_status_json()'s two failure branches -- the guest's snapshot not
|
|
existing yet (the bridge answers 'bridge: no such file: PATH' for the
|
|
first couple of seconds after boot, before webstatus.c's first 2s timer
|
|
tick) and a torn/invalid JSON snapshot -- have no coverage anywhere else
|
|
in this file: every FakeCtl-style '@cat' handler above always returns
|
|
valid JSON."""
|
|
|
|
class StubCtl:
|
|
def __init__(self, reply):
|
|
self.reply = reply
|
|
|
|
def send(self, cmd):
|
|
assert cmd.startswith("@cat "), cmd
|
|
return self.reply
|
|
|
|
def test_missing_snapshot_file_is_a_detail_not_a_crash(self):
|
|
ctl = self.StubCtl("bridge: no such file: /tmp/warden-web-status.json")
|
|
doc, err = qmp.fetch_status_json(ctl)
|
|
self.assertIsNone(doc)
|
|
self.assertEqual(err, "bridge: no such file: /tmp/warden-web-status.json")
|
|
|
|
def test_torn_json_is_a_detail_not_a_crash(self):
|
|
ctl = self.StubCtl('{"a": 1, "b":')
|
|
doc, err = qmp.fetch_status_json(ctl)
|
|
self.assertIsNone(doc)
|
|
self.assertIn("status json unparsable", err)
|
|
|
|
def test_eval_json_turns_a_missing_snapshot_into_an_ordinary_fail(self):
|
|
ctl = self.StubCtl("bridge: no such file: /tmp/warden-web-status.json")
|
|
ok, detail = qmp.eval_json(ctl, "a.b", "eq", "1")
|
|
self.assertFalse(ok)
|
|
self.assertEqual(detail, "bridge: no such file: /tmp/warden-web-status.json")
|
|
|
|
def test_eval_json_turns_torn_json_into_an_ordinary_fail(self):
|
|
ctl = self.StubCtl('{"a": 1, "b":')
|
|
ok, detail = qmp.eval_json(ctl, "a.b", "eq", "1")
|
|
self.assertFalse(ok)
|
|
self.assertIn("status json unparsable", detail)
|
|
|
|
def test_wait_json_retries_a_missing_snapshot_instead_of_treating_it_fatal(self):
|
|
# A missing snapshot is an ordinary not-yet-true check, so wait_json
|
|
# must poll it out to TIMEOUT_S like any other fail -- not read the
|
|
# bridge's plain-text error as a channel fault the way a dead
|
|
# RuntimeError/OSError from ctl.send() itself already is.
|
|
ctl = self.StubCtl("bridge: no such file: /tmp/warden-web-status.json")
|
|
t0 = time.monotonic()
|
|
ok, detail, waited, fatal = qmp.poll_until(
|
|
lambda: qmp.eval_json(ctl, "a.b", "eq", "1"), 0.6, period=0.2)
|
|
self.assertFalse(ok)
|
|
self.assertFalse(fatal, "a missing snapshot is a fail to retry, not a channel fault")
|
|
self.assertGreaterEqual(time.monotonic() - t0, 0.6)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main(verbosity=1)
|