qemu: add wait_stat verb for polling stat checks
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
This commit is contained in:
+55
-18
@@ -78,6 +78,11 @@ math, this file only drives it.
|
||||
1 dimmed, 2 asleep), read off a fresh `stats`
|
||||
reply (warden_debug.c). Same OP vocabulary as
|
||||
assert_json.
|
||||
wait_stat FIELD OP VALUE TIMEOUT_S
|
||||
assert_stat, polled every 0.5s (poll_until) until
|
||||
it holds or TIMEOUT_S elapses -- for a field like
|
||||
fps that a rolling window can still report as 0
|
||||
right after the page holding it opens.
|
||||
capture_region NAME X Y W H TOLERANCE
|
||||
screendump now, crop to X,Y,WxH, and (over)write
|
||||
NAME in the refs file with both a phash and a
|
||||
@@ -588,6 +593,29 @@ def eval_json(ctl, path, op, value):
|
||||
return ok, "" if ok else f"{path} is {actual!r}"
|
||||
|
||||
|
||||
# The fields assert_stat/wait_stat expose. parse_stats() itself recognizes a
|
||||
# couple more (STATS_FIELD_RE has 'releases') that these verbs don't -- this
|
||||
# is the whitelist both verbs enforce, not everything a `stats` reply carries.
|
||||
STAT_FIELDS = ("cpu", "fps", "render", "idle", "presses", "termbusy", "termintr", "termfg", "termsig")
|
||||
|
||||
|
||||
def eval_stat(ctl, field, op, value):
|
||||
"""One `stats` fetch + field-lookup + op-apply round for
|
||||
assert_stat/wait_stat. -> (ok, detail); detail is empty on success,
|
||||
otherwise the reason."""
|
||||
if field not in STAT_FIELDS:
|
||||
return False, (f"unknown stat field {field!r} "
|
||||
f"(known: {', '.join(STAT_FIELDS)})")
|
||||
stats = parse_stats(ctl.send("stats"))
|
||||
if field not in stats:
|
||||
return False, "no such field"
|
||||
try:
|
||||
ok = apply_op(op, stats[field], value)
|
||||
except (TypeError, ValueError) as e:
|
||||
return False, str(e)
|
||||
return ok, "" if ok else f"{field} is {stats[field]}"
|
||||
|
||||
|
||||
STATS_FIELD_RE = {
|
||||
"cpu": re.compile(r'^cpu:\s*(-?\d+(?:\.\d+)?)%?\s*$'),
|
||||
"fps": re.compile(r'^fps:\s*(-?\d+(?:\.\d+)?)\s*$'),
|
||||
@@ -908,18 +936,26 @@ def verb_wait_json(ctx, lineno, cmd, args, line):
|
||||
def verb_assert_stat(ctx, lineno, cmd, args, line):
|
||||
# assert_stat FIELD OP VALUE: FIELD off a fresh `stats` reply.
|
||||
ctx.need_ctl(lineno, cmd)
|
||||
field, op, value = args[0], args[1], args[2]
|
||||
if field not in ("cpu", "fps", "render", "idle", "presses", "termbusy", "termintr", "termfg", "termsig"):
|
||||
return "fail", (f"unknown stat field {field!r} "
|
||||
f"(known: cpu, fps, render, idle, presses, termbusy, termintr, termfg, termsig)")
|
||||
stats = parse_stats(ctx.ctl.send("stats"))
|
||||
if field not in stats:
|
||||
return "fail", "no such field"
|
||||
try:
|
||||
ok = apply_op(op, stats[field], value)
|
||||
except (TypeError, ValueError) as e:
|
||||
return "fail", str(e)
|
||||
return ("ok" if ok else "fail"), ("" if ok else f"{field} is {stats[field]}")
|
||||
ok, detail = eval_stat(ctx.ctl, args[0], args[1], args[2])
|
||||
return ("ok" if ok else "fail"), detail
|
||||
|
||||
|
||||
def verb_wait_stat(ctx, lineno, cmd, args, line):
|
||||
# wait_stat FIELD OP VALUE TIMEOUT_S: eval_stat polled every 0.5s
|
||||
# (poll_until) until it holds or the deadline passes. warden-ui's fps
|
||||
# counter is a rolling one-second window (warden_debug.c): a single
|
||||
# sample taken right after a page opens can read 0 even though the UI
|
||||
# is live and about to report a real rate, on a runner slow enough that
|
||||
# window hasn't filled yet (flare-edge #44). This exists for
|
||||
# exactly that shape of check, the same reason wait_json exists for the
|
||||
# asynchronously-written status file.
|
||||
ctx.need_ctl(lineno, cmd)
|
||||
field, op, value, timeout_s = args[0], args[1], args[2], float(args[3])
|
||||
ok, detail, waited, fatal = poll_until(
|
||||
lambda: eval_stat(ctx.ctl, field, op, value), timeout_s)
|
||||
if fatal:
|
||||
return "fatal", detail
|
||||
return ("ok" if ok else "fail"), (f"waited {waited:.1f}s" if ok else detail)
|
||||
|
||||
|
||||
def verb_capture_region(ctx, lineno, cmd, args, line):
|
||||
@@ -1057,6 +1093,7 @@ VERBS = {
|
||||
"assert_json": verb_assert_json,
|
||||
"wait_json": verb_wait_json,
|
||||
"assert_stat": verb_assert_stat,
|
||||
"wait_stat": verb_wait_stat,
|
||||
"capture_region": verb_capture_region,
|
||||
"assert_region": verb_assert_region,
|
||||
"wait_region": verb_wait_region,
|
||||
@@ -1112,12 +1149,12 @@ def drive(s, f, script_path, outdir, size, ctl_path=None, console_path=None, ref
|
||||
# reason: 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,
|
||||
# capture_region, wait_region), and a malformed or missing
|
||||
# argument -- a typo'd coordinate, a hand-edited *.txt script, a
|
||||
# future flowc.py bug -- used to raise straight out of drive()
|
||||
# and silently drop every row from that line onward, the exact
|
||||
# truncated-run failure mode this file exists to rule out
|
||||
# (flare-edge #244).
|
||||
# wait_stat, capture_region, wait_region), and a malformed or
|
||||
# missing argument -- a typo'd coordinate, a hand-edited *.txt
|
||||
# script, a future flowc.py bug -- used to raise straight out
|
||||
# of drive() and silently drop every row from that line
|
||||
# onward, the exact truncated-run failure mode this file
|
||||
# exists to rule out (flare-edge #244).
|
||||
# Recorded the same as any other fatal row: the run keeps going
|
||||
# past it.
|
||||
result = ("fatal", str(e))
|
||||
|
||||
Reference in New Issue
Block a user