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:
Noah
2026-09-14 15:13:44 -06:00
co-authored by Claude Sonnet 5
parent cdf491caa9
commit ac8658d1d5
2 changed files with 114 additions and 35 deletions
+55 -18
View File
@@ -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))