qemu and build: review fixes across the rig driver, boot script, and fetch helpers
Bounded waits and validated arguments in run.sh and ui-drive.sh, a seeded settings directory and root-only staged rootfs permissions with their own tests, qmp.py and imgtools.py hardening, the fetch scripts checking what they download, and ASCII typography throughout. Each fix carries its test under qemu/tests or tests/. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N3G6m9Aw5RyVY4ZowtKzEj
This commit is contained in:
+48
-58
@@ -35,11 +35,10 @@ import json
|
||||
|
||||
from PIL import Image, ImageFilter
|
||||
|
||||
# occupancy thresholds for structural(): a downsampled cell counts as
|
||||
# "occupied" if it is meaningfully brighter than black, or sits on an edge.
|
||||
# Both are 0..255 greyscale/edge-magnitude averages over the cell.
|
||||
# structural(): a cell is occupied when its grey is this far from the crop's
|
||||
# median (its background) or its FIND_EDGES energy exceeds the edge threshold.
|
||||
# occupancy thresholds for structural(): a cell is occupied when its grey is
|
||||
# this far from the crop's median (its background), or its FIND_EDGES energy
|
||||
# exceeds the edge threshold. Both are 0..255 greyscale/edge-magnitude
|
||||
# averages over the cell.
|
||||
# Validated on real captures: a switch knob left/right differs in 240/256
|
||||
# cells, a dark card's icon and text stand out from its (7,13,29) ground.
|
||||
DEVIATION_THRESHOLD = 28
|
||||
@@ -53,64 +52,31 @@ EDGE_THRESHOLD = 24
|
||||
def load_ppm(path):
|
||||
"""Read a binary PPM (P6), return (w, h, rgb_bytes).
|
||||
|
||||
QEMU's screendump writes a comment-free header ("P6\\nW H\\n255\\n"
|
||||
then raw bytes) but the token reader here handles the general P6
|
||||
grammar -- whitespace runs and '#' comments -- since that costs
|
||||
nothing extra and means any other producer of P6 files just works.
|
||||
Delegates to Pillow's own P6 decoder (Image.open, format auto-detected
|
||||
from the magic) instead of re-parsing the PPM grammar by hand: skipping
|
||||
whitespace runs and '#' comments, and normalising any maxval to 8-bit,
|
||||
are Pillow's problem here, not ours. convert("RGB") guarantees the
|
||||
tightly-packed 3-bytes-per-pixel layout every caller in this file
|
||||
assumes regardless of the source channel depth.
|
||||
"""
|
||||
with open(path, "rb") as f:
|
||||
data = f.read()
|
||||
|
||||
def skip_ws_comments(p):
|
||||
while p < len(data):
|
||||
c = data[p]
|
||||
if c in b" \t\r\n":
|
||||
p += 1
|
||||
elif c == ord("#"):
|
||||
while p < len(data) and data[p] != ord("\n"):
|
||||
p += 1
|
||||
else:
|
||||
break
|
||||
return p
|
||||
|
||||
def read_token(p):
|
||||
p = skip_ws_comments(p)
|
||||
start = p
|
||||
while p < len(data) and data[p] not in b" \t\r\n":
|
||||
p += 1
|
||||
return data[start:p], p
|
||||
|
||||
pos = 0
|
||||
magic, pos = read_token(pos)
|
||||
if magic != b"P6":
|
||||
raise ValueError(f"{path}: not a P6 PPM (magic={magic!r})")
|
||||
w_tok, pos = read_token(pos)
|
||||
h_tok, pos = read_token(pos)
|
||||
maxval_tok, pos = read_token(pos)
|
||||
w, h, maxval = int(w_tok), int(h_tok), int(maxval_tok)
|
||||
if maxval != 255:
|
||||
raise ValueError(f"{path}: unsupported PPM maxval {maxval} (only 255 handled)")
|
||||
# exactly one whitespace byte separates the header from the binary data
|
||||
pos += 1
|
||||
need = w * h * 3
|
||||
pixels = data[pos:pos + need]
|
||||
if len(pixels) != need:
|
||||
raise ValueError(f"{path}: truncated PPM, want {need} bytes got {len(pixels)}")
|
||||
return w, h, pixels
|
||||
with Image.open(path) as im:
|
||||
im = im.convert("RGB")
|
||||
return im.width, im.height, im.tobytes()
|
||||
|
||||
|
||||
def crop(img, x, y, w, h):
|
||||
"""Crop (W,H,rgb) to the x,y,w,h box, return a new (w,h,rgb) image."""
|
||||
"""Crop (W,H,rgb) to the x,y,w,h box, return a new (w,h,rgb) image.
|
||||
|
||||
The bounds check stays explicit and fails loudly (see main()'s own
|
||||
comment on that) rather than delegating to PIL's crop, which silently
|
||||
zero-pads a box that runs outside the source image instead of raising --
|
||||
exactly the kind of stale/mistyped region box this check exists to catch.
|
||||
"""
|
||||
width, height, data = img
|
||||
if x < 0 or y < 0 or w <= 0 or h <= 0 or x + w > width or y + h > height:
|
||||
raise ValueError(f"crop box {x},{y},{w}x{h} outside image {width}x{height}")
|
||||
row_bytes = w * 3
|
||||
out = bytearray(h * row_bytes)
|
||||
for row in range(h):
|
||||
src_off = ((y + row) * width + x) * 3
|
||||
dst_off = row * row_bytes
|
||||
out[dst_off:dst_off + row_bytes] = data[src_off:src_off + row_bytes]
|
||||
return w, h, bytes(out)
|
||||
cropped = _to_pil(img).crop((x, y, x + w, y + h))
|
||||
return w, h, cropped.tobytes()
|
||||
|
||||
|
||||
def _to_pil(img):
|
||||
@@ -188,7 +154,7 @@ def phash(img):
|
||||
|
||||
def hamming(a, b):
|
||||
"""Bit-differences between two phash ints."""
|
||||
return bin(a ^ b).count("1")
|
||||
return (a ^ b).bit_count()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -241,7 +207,7 @@ def _median_grey(grey):
|
||||
|
||||
def _structural_diff(a, b):
|
||||
"""Count of differing bits between two 32-byte occupancy masks."""
|
||||
return sum(bin(x ^ y).count("1") for x, y in zip(a, b))
|
||||
return (int.from_bytes(a, "big") ^ int.from_bytes(b, "big")).bit_count()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -405,6 +371,10 @@ def cmd_bench(argv):
|
||||
print(f"bench: {cw}x{ch} region, n={n} iterations")
|
||||
for label, fn in (("phash", lambda: phash(region)),
|
||||
("structural", lambda: structural(region))):
|
||||
for _ in range(5):
|
||||
fn() # warm up: first call can carry one-off costs (e.g. module-
|
||||
# level caches settling) that don't belong in the steady-state
|
||||
# population below
|
||||
samples = timeit.repeat(fn, repeat=n, number=1)
|
||||
lo, p50, p95, hi = percentiles(samples)
|
||||
print(f" {label:<10} min={lo * 1000:7.3f}ms p50={p50 * 1000:7.3f}ms "
|
||||
@@ -493,6 +463,26 @@ def selftest():
|
||||
check("crop keeps the drawn pixel in place",
|
||||
cropped[2][px_off:px_off + 3] == bytes((220, 20, 20)))
|
||||
|
||||
# a header QEMU never writes but the P6 grammar allows: a '#'
|
||||
# comment line and a non-255 maxval. Pillow's decoder (load_ppm no
|
||||
# longer hand-parses the header) must still read it correctly.
|
||||
odd_path = os.path.join(tmp, "odd-header.ppm")
|
||||
with open(odd_path, "wb") as f:
|
||||
f.write(b"P6\n# generated for a selftest, not by QEMU\n2 2\n100\n")
|
||||
f.write(bytes((100, 0, 0, 0, 100, 0, 0, 0, 100, 100, 100, 100)))
|
||||
ow, oh, odata = load_ppm(odd_path)
|
||||
check("load_ppm reads width/height past a comment line", (ow, oh) == (2, 2))
|
||||
check("load_ppm scales a non-255 maxval channel to 8-bit",
|
||||
odata[0:3] == bytes((255, 0, 0)))
|
||||
|
||||
# crop box outside the image must still fail loudly, not silently
|
||||
# zero-pad the way PIL's own Image.crop does
|
||||
try:
|
||||
crop((bw, bh, bdata), bw - 4, bh - 4, 32, 32)
|
||||
check("crop rejects a box past the image edge", False)
|
||||
except ValueError:
|
||||
check("crop rejects a box past the image edge", True)
|
||||
|
||||
# _dct2d_32's _DCT_KEEP truncation must land on the exact same
|
||||
# coefficients an untruncated 32x32 DCT would produce
|
||||
small = _to_pil((bw, bh, bdata)).convert("L").resize((32, 32), Image.LANCZOS)
|
||||
|
||||
+91
-30
@@ -125,24 +125,40 @@ import sys
|
||||
import time
|
||||
|
||||
# imgtools.py lives beside this file and does the actual pixel math (phash,
|
||||
# structural hash, crop, compare) for the region/ocr verbs. Imported at
|
||||
# module load but never let a missing/broken imgtools take down the verbs
|
||||
# that don't need it: nav/tap/page/... must keep working while it is being
|
||||
# written, so the failure is deferred to need_imgtools() at first use.
|
||||
try:
|
||||
import imgtools
|
||||
except ImportError:
|
||||
imgtools = None
|
||||
# structural hash, crop, compare) for the region/ocr verbs -- and imports
|
||||
# Pillow at its OWN module scope to do it. Importing it here at qmp.py's
|
||||
# module scope would drag that cost onto every subprocess invocation of this
|
||||
# file, including screendump/tap/quit, which never touch a pixel:
|
||||
# ui-drive.sh's own boot-wait loop calls screendump specifically to avoid a
|
||||
# Pillow dependency (see its comment beside the plain byte-loop colour
|
||||
# count), so the other half of that same loop must not quietly re-add one.
|
||||
# Deferred to need_imgtools() at first actual use instead; a missing or
|
||||
# broken imgtools still must not take down the verbs that don't need it.
|
||||
imgtools = None
|
||||
_imgtools_import_error = None
|
||||
|
||||
# Pillow, likewise, is only needed to hand tesseract an image file (it has no
|
||||
# raw-RGB stdin mode); a host without it still runs every other verb.
|
||||
try:
|
||||
from PIL import Image as _PILImage
|
||||
except ImportError:
|
||||
_PILImage = None
|
||||
|
||||
def _load_imgtools():
|
||||
global imgtools, _imgtools_import_error
|
||||
if imgtools is None and _imgtools_import_error is None:
|
||||
try:
|
||||
import imgtools # noqa: F811 -- binds the module-level name above
|
||||
except ImportError as e:
|
||||
_imgtools_import_error = e
|
||||
return imgtools
|
||||
|
||||
AXIS_MAX = 32767
|
||||
|
||||
# Bounds every blocking read on the QMP socket -- the greeting banner, the
|
||||
# qmp_capabilities handshake, and every screendump/tap/quit round trip --
|
||||
# the same way Ctl.__init__ already bounds the control channel (see its
|
||||
# `timeout` default below). A socket with no timeout blocks forever on a
|
||||
# wedged VM (a TCG stall or a kernel panic loop), and ui-drive.sh's own
|
||||
# cleanup() calls `quit` on this socket before it ever reaches
|
||||
# `reap "$QEMU_PID"` -- the bounded kill that is supposed to guarantee a
|
||||
# wedged qemu-system-arm cannot outlive the script.
|
||||
QMP_TIMEOUT_S = 20.0
|
||||
|
||||
# Where webstatus.c (ui-src/src/warden/webstatus.c) publishes its atomic
|
||||
# snapshot inside the guest. assert_json/wait_json `@cat` this path over the
|
||||
# control channel; see the bridge in rootfs/sbin/init.
|
||||
@@ -240,8 +256,23 @@ class Ctl:
|
||||
not evidence about the UI either way.
|
||||
"""
|
||||
|
||||
# Must match, byte for byte, the `echo "<<END>>" >&3` in
|
||||
# rootfs/sbin/init's ctl bridge (the FIFO-to-socket relay this class
|
||||
# talks to). Nothing enforces the two staying in sync -- a sentinel
|
||||
# changed on one side and not the other means send() below blocks until
|
||||
# its own 15s timeout on every ctl-dependent step, with nothing at that
|
||||
# point pointing back at this mismatch as the cause.
|
||||
SENTINEL = "<<END>>"
|
||||
|
||||
# A real reply (a hit's box, the status JSON) is at most a few KB. The
|
||||
# 15s socket timeout below bounds each individual recv(), not the total
|
||||
# bytes accepted, so a peer that keeps streaming data fast enough to beat
|
||||
# that per-call timeout, but never emits a newline or SENTINEL, would
|
||||
# otherwise grow self.buf without limit and exhaust host memory before
|
||||
# anything fails. Fail closed instead, the same way a channel that goes
|
||||
# silent or closes outright already does.
|
||||
MAX_BUF = 256 * 1024
|
||||
|
||||
def __init__(self, path, timeout=15.0):
|
||||
self.sock = socket.socket(socket.AF_UNIX)
|
||||
self.sock.settimeout(timeout)
|
||||
@@ -258,6 +289,10 @@ class Ctl:
|
||||
if not chunk:
|
||||
raise RuntimeError("control channel closed")
|
||||
self.buf += chunk
|
||||
if len(self.buf) > self.MAX_BUF:
|
||||
raise RuntimeError(
|
||||
f"control channel reply too large (over {self.MAX_BUF} "
|
||||
f"bytes with no newline or {self.SENTINEL!r} seen)")
|
||||
continue
|
||||
line = self.buf[:nl].decode("utf-8", "replace").rstrip("\r")
|
||||
self.buf = self.buf[nl + 1:]
|
||||
@@ -538,11 +573,18 @@ def save_refs(path, refs):
|
||||
def write_png(img, path):
|
||||
"""imgtools' (w, h, rgb_bytes) tuple -> a PNG file, because tesseract has
|
||||
no raw-RGB input mode. Only assert_ocr needs this; assert_region and
|
||||
capture_region work on imgtools' own tuples end to end."""
|
||||
if _PILImage is None:
|
||||
raise RuntimeError("Pillow (PIL) is not installed")
|
||||
capture_region work on imgtools' own tuples end to end. Pillow is
|
||||
imported here, not at module scope, for the same reason imgtools.py's
|
||||
own import is deferred above: a bare screendump/tap/quit subprocess must
|
||||
never pay for it. By the time assert_ocr reaches this call,
|
||||
need_imgtools() has already required imgtools -- which itself imports
|
||||
Pillow -- so in practice this import is a cache hit, not a fresh cost."""
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError as e:
|
||||
raise RuntimeError(f"Pillow (PIL) is not installed: {e}") from e
|
||||
w, h, data = img
|
||||
_PILImage.frombytes("RGB", (w, h), data).save(path)
|
||||
Image.frombytes("RGB", (w, h), data).save(path)
|
||||
|
||||
|
||||
def safe_out_path(outdir, name, prefix="", suffix=""):
|
||||
@@ -599,7 +641,10 @@ class Ctx:
|
||||
# A whole-script infrastructure gap (imgtools.py absent), same class
|
||||
# as a missing --ctl: no region/ocr verb can do anything without it,
|
||||
# so this exits the process rather than recording a per-step fail.
|
||||
if imgtools is None:
|
||||
# This is also the first point that actually needs imgtools loaded
|
||||
# (see _load_imgtools above), so it is where the deferred import
|
||||
# happens, not module load.
|
||||
if _load_imgtools() is None:
|
||||
sys.exit(f"FATAL: {self.script_path}:{lineno}: '{cmd}' needs imgtools.py "
|
||||
f"next to qmp.py (vendor/warden-sdk/qemu/tests/imgtools.py)")
|
||||
|
||||
@@ -984,7 +1029,7 @@ def drive(s, f, script_path, outdir, size, ctl_path=None, console_path=None, ref
|
||||
|
||||
try:
|
||||
result = handler(ctx, lineno, cmd, args, line)
|
||||
except (RuntimeError, OSError) as e:
|
||||
except (RuntimeError, OSError, ValueError, IndexError, TypeError) as e:
|
||||
# poll_until already turns this into a fatal row for the wait_*
|
||||
# verbs; every other verb reaches ctl.send() (nav, wake,
|
||||
# scroll/home, page/hit/stats/ctl, assert_page, assert_hit,
|
||||
@@ -992,8 +1037,18 @@ def drive(s, f, script_path, outdir, size, ctl_path=None, console_path=None, ref
|
||||
# (tap/swipe/fling/shot, the region verbs) with no guard of its
|
||||
# own, so without this a dying channel or a gone QMP socket ends
|
||||
# the whole run as an unhandled traceback instead of one fatal
|
||||
# step. Recorded the same as any other fatal row: the run keeps
|
||||
# going past it.
|
||||
# step. ValueError/IndexError/TypeError are caught for the same
|
||||
# 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).
|
||||
# Recorded the same as any other fatal row: the run keeps going
|
||||
# past it.
|
||||
result = ("fatal", str(e))
|
||||
if result is not None:
|
||||
record(lineno, line, *result)
|
||||
@@ -1010,6 +1065,14 @@ def drive(s, f, script_path, outdir, size, ctl_path=None, console_path=None, ref
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def flag(argv, name, default=None):
|
||||
"""One optional `NAME VALUE` pair out of argv, or DEFAULT when NAME is
|
||||
absent. Factored so a sixth optional flag is a one-line call instead of
|
||||
another hand-rolled argv.index() lookup -- and so a copy-pasted lookup
|
||||
can no longer search for one flag while reporting a different one."""
|
||||
return argv[argv.index(name) + 1] if name in argv else default
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 3:
|
||||
sys.exit(__doc__)
|
||||
@@ -1020,16 +1083,14 @@ def main():
|
||||
if len(sys.argv) < need[cmd]:
|
||||
sys.exit(f"{cmd}: missing argument(s)\n{__doc__}")
|
||||
|
||||
size = 720
|
||||
if "--size" in sys.argv:
|
||||
size = int(sys.argv[sys.argv.index("--size") + 1])
|
||||
ctl_path = sys.argv[sys.argv.index("--ctl") + 1] if "--ctl" in sys.argv else None
|
||||
console_path = sys.argv[sys.argv.index("--console") + 1] if "--console" in sys.argv else None
|
||||
refs_path = sys.argv[sys.argv.index("--refs") + 1] if "--refs" in sys.argv else None
|
||||
rs485_control = (sys.argv[sys.argv.index("--rs485-control") + 1]
|
||||
if "--rs485-control" in sys.argv else None)
|
||||
size = int(flag(sys.argv, "--size", 720))
|
||||
ctl_path = flag(sys.argv, "--ctl")
|
||||
console_path = flag(sys.argv, "--console")
|
||||
refs_path = flag(sys.argv, "--refs")
|
||||
rs485_control = flag(sys.argv, "--rs485-control")
|
||||
|
||||
s = socket.socket(socket.AF_UNIX)
|
||||
s.settimeout(QMP_TIMEOUT_S)
|
||||
s.connect(path)
|
||||
f = s.makefile("r")
|
||||
f.readline() # greeting banner
|
||||
|
||||
Executable
+149
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env bash
|
||||
# Offline regression test for run.sh's own argv construction: no real QEMU,
|
||||
# no kernel image. A fake `qemu-system-arm` placed first on PATH dumps the
|
||||
# argv it was handed (one token per line) and exits, so this pins the exact
|
||||
# contract run.sh:121-138 and rootfs/sbin/init:54,161 share without either
|
||||
# side moving: init decides ttyS0 vs ttyS1 for the Modbus alias purely by
|
||||
# grepping warden.ctl off /proc/cmdline, so run.sh has to keep two promises
|
||||
# every single invocation -- the ctl pci-serial device, when present, comes
|
||||
# BEFORE the rs485 one in argv (virt's PCI bus enumerates in that order),
|
||||
# and an rs485 pci-serial device (real or null-backed) is always there so
|
||||
# the port count init relies on never shifts.
|
||||
#
|
||||
# What is worth pinning: nothing else exercises this. test-ui-drive-rs485.sh
|
||||
# stubs run.sh out entirely (a fake VM), and the only real boot in CI
|
||||
# (boot-smoke.sh) passes neither --ctl nor --rs485, so a swapped
|
||||
# `[ -n "$CTL" ]`/`[ -n "$RS485" ]` block, or a dropped null-chardev
|
||||
# fallback, would reach a panel as Modbus polls landing on the debug channel
|
||||
# (run.sh:131-133's own incident) before anything here caught it.
|
||||
#
|
||||
# bash run-sh-args-test.sh
|
||||
set -uo pipefail
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
RUN_SH="$HERE/../run.sh"
|
||||
SCRATCH="$(mktemp -d /tmp/runshargs.XXXXXX)"
|
||||
trap 'rm -rf "$SCRATCH"' EXIT
|
||||
FAIL=0
|
||||
pass() { printf '[PASS] %s\n' "$1"; }
|
||||
fail() { printf '[FAIL] %s\n' "$1"; FAIL=1; }
|
||||
|
||||
KERNEL="$SCRATCH/fake-zImage"
|
||||
INITRD="$SCRATCH/fake-initramfs.cpio.gz"
|
||||
: > "$KERNEL"
|
||||
: > "$INITRD"
|
||||
|
||||
BIN="$SCRATCH/bin"
|
||||
mkdir -p "$BIN"
|
||||
cat > "$BIN/qemu-system-arm" <<'STUB'
|
||||
#!/usr/bin/env bash
|
||||
# Stand-in for the real binary: record argv, one token per line, and exit
|
||||
# straight away. $ARGV_CAPTURE names where -- run.sh always `exec`s this as
|
||||
# its very last step, so nothing downstream of it ever runs.
|
||||
printf '%s\n' "$@" > "$ARGV_CAPTURE"
|
||||
STUB
|
||||
chmod +x "$BIN/qemu-system-arm"
|
||||
|
||||
# run_case ARGV_FILE EXTRA_ARGS...: invoke the real run.sh --no-disk (so
|
||||
# nothing under the real qemu/out/ is ever touched) with the fake binary
|
||||
# first on PATH, capturing its argv into ARGV_FILE. Fails the case loudly if
|
||||
# run.sh itself exits nonzero -- a silent empty capture would otherwise look
|
||||
# just like "the assertions below simply found nothing".
|
||||
run_case() {
|
||||
local argv_file="$1"; shift
|
||||
local out rc
|
||||
out="$(cd "$SCRATCH" && PATH="$BIN:$PATH" ARGV_CAPTURE="$argv_file" \
|
||||
bash "$RUN_SH" --kernel "$KERNEL" --initrd "$INITRD" --no-disk "$@" 2>&1)"
|
||||
rc=$?
|
||||
[ "$rc" -eq 0 ] || { fail "run.sh exited $rc for: $* -- output: $out"; return 1; }
|
||||
[ -s "$argv_file" ] || { fail "run.sh produced no captured argv for: $*"; return 1; }
|
||||
return 0
|
||||
}
|
||||
|
||||
# chardev_line ARGV_FILE PREFIX: 1-indexed line number of the first argv
|
||||
# token starting with PREFIX (the socket/null chardev spec, which always
|
||||
# immediately follows the "-chardev" token it belongs to), or empty.
|
||||
chardev_line() { grep -n -m1 "^$2" "$1" | cut -d: -f1; }
|
||||
|
||||
# --- case: neither --ctl nor --rs485 -> null-backed rs485, no ctl device ---
|
||||
argv="$SCRATCH/argv-neither.txt"
|
||||
if run_case "$argv"; then
|
||||
if ! grep -qF 'id=ctl' "$argv"; then
|
||||
pass "neither flag: no ctl chardev/device at all"
|
||||
else
|
||||
fail "neither flag: a ctl chardev/device appeared unrequested"
|
||||
fi
|
||||
if grep -qF -- '-append' "$argv" && ! grep -qw 'warden.ctl' "$argv"; then
|
||||
pass "neither flag: -append omits warden.ctl"
|
||||
else
|
||||
fail "neither flag: -append should omit warden.ctl"
|
||||
fi
|
||||
if [ -n "$(chardev_line "$argv" 'null,id=rs485')" ] \
|
||||
&& grep -qF 'pci-serial,chardev=rs485' "$argv"; then
|
||||
pass "neither flag: null-backed rs485 pci-serial device is still present"
|
||||
else
|
||||
fail "neither flag: expected a null-backed rs485 device (port count must not shift)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- case: --rs485 alone -> real rs485 device, still no ctl device ---------
|
||||
argv="$SCRATCH/argv-rs485-only.txt"
|
||||
if run_case "$argv" --rs485 "$SCRATCH/rs.sock"; then
|
||||
if ! grep -qF 'id=ctl' "$argv"; then
|
||||
pass "rs485 only: no ctl chardev/device"
|
||||
else
|
||||
fail "rs485 only: a ctl chardev/device appeared unrequested"
|
||||
fi
|
||||
if ! grep -qw 'warden.ctl' "$argv"; then
|
||||
pass "rs485 only: -append omits warden.ctl"
|
||||
else
|
||||
fail "rs485 only: -append should omit warden.ctl"
|
||||
fi
|
||||
if [ -n "$(chardev_line "$argv" "socket,id=rs485,path=$SCRATCH/rs.sock,")" ]; then
|
||||
pass "rs485 only: rs485 chardev carries the requested socket path"
|
||||
else
|
||||
fail "rs485 only: rs485 chardev did not carry the requested socket path"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- case: --ctl alone -> ctl device first, null-backed rs485 still present,
|
||||
# and warden.ctl on the cmdline ------------------------------------------
|
||||
argv="$SCRATCH/argv-ctl-only.txt"
|
||||
if run_case "$argv" --ctl "$SCRATCH/ctl.sock"; then
|
||||
ctl_ln="$(chardev_line "$argv" "socket,id=ctl,path=$SCRATCH/ctl.sock,")"
|
||||
rs_ln="$(chardev_line "$argv" 'null,id=rs485')"
|
||||
if [ -n "$ctl_ln" ] && [ -n "$rs_ln" ] && [ "$ctl_ln" -lt "$rs_ln" ]; then
|
||||
pass "ctl only: ctl chardev (line $ctl_ln) precedes the null rs485 chardev (line $rs_ln)"
|
||||
else
|
||||
fail "ctl only: expected ctl chardev before a null-backed rs485 chardev, got ctl=$ctl_ln rs485=$rs_ln"
|
||||
fi
|
||||
if grep -qw 'warden.ctl' "$argv"; then
|
||||
pass "ctl only: -append carries warden.ctl"
|
||||
else
|
||||
fail "ctl only: -append should carry warden.ctl"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- case: --ctl and --rs485 together -> ctl device still enumerates first -
|
||||
argv="$SCRATCH/argv-both.txt"
|
||||
if run_case "$argv" --ctl "$SCRATCH/ctl.sock" --rs485 "$SCRATCH/rs.sock"; then
|
||||
ctl_ln="$(chardev_line "$argv" "socket,id=ctl,path=$SCRATCH/ctl.sock,")"
|
||||
rs_ln="$(chardev_line "$argv" "socket,id=rs485,path=$SCRATCH/rs.sock,")"
|
||||
if [ -n "$ctl_ln" ] && [ -n "$rs_ln" ] && [ "$ctl_ln" -lt "$rs_ln" ]; then
|
||||
pass "both flags: ctl chardev (line $ctl_ln) precedes the rs485 chardev (line $rs_ln)"
|
||||
else
|
||||
fail "both flags: expected ctl chardev before rs485 chardev, got ctl=$ctl_ln rs485=$rs_ln"
|
||||
fi
|
||||
if grep -qw 'warden.ctl' "$argv"; then
|
||||
pass "both flags: -append carries warden.ctl"
|
||||
else
|
||||
fail "both flags: -append should carry warden.ctl"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$FAIL" -eq 0 ]; then
|
||||
echo "ALL RUN.SH ARGV TESTS PASSED"
|
||||
exit 0
|
||||
else
|
||||
echo "RUN.SH ARGV TESTS FAILED"
|
||||
exit 1
|
||||
fi
|
||||
Executable
+190
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression test for mkimage.sh's SEED_DIR hook (see its Env note and the
|
||||
# block right after --state is applied): a caller-supplied directory of
|
||||
# pre-built userdata/warden files, copied in whole and applied AFTER --state
|
||||
# so a seeded file can override a same-named --state value, after each entry
|
||||
# is validated the same way --state's own KEY=VALUE is validated -- a
|
||||
# symlink, a non-plain-file entry (a subdirectory included), or a name
|
||||
# outside [A-Za-z0-9_.-]+ fails closed before cp -a runs. Nothing else in
|
||||
# the qemu test suite ever sets SEED_DIR -- the CI qemu-tools job runs
|
||||
# mkimage.sh unseeded, and only ui-drive.sh --seed exercises this path, and
|
||||
# only when booting a real VM with a flare-edge checkout on hand -- so this
|
||||
# is the only offline coverage of it.
|
||||
#
|
||||
# Builds a real disk image the same way mkimage.sh always does (unprivileged
|
||||
# mkfs.ext4 -d), then reads the userdata partition back with debugfs -R
|
||||
# (read-only, no mount or loop device needed) to check what actually landed
|
||||
# on disk rather than trusting the script's own log output.
|
||||
#
|
||||
# bash seed-dir.sh
|
||||
set -uo pipefail
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # qemu/tests/
|
||||
QEMU_DIR="$(cd "$HERE/.." && pwd)" # qemu/
|
||||
FAIL=0
|
||||
pass() { printf '[PASS] %s\n' "$1"; }
|
||||
fail() { printf '[FAIL] %s\n' "$1"; FAIL=1; }
|
||||
|
||||
# mkfs.ext4 and debugfs both live in sbin, which user shells on Debian don't
|
||||
# put on PATH -- same fix mkimage.sh itself applies.
|
||||
PATH="$PATH:/usr/sbin:/sbin"
|
||||
command -v debugfs >/dev/null || {
|
||||
echo "FATAL: debugfs (e2fsprogs) not found: needed to read the userdata partition back" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# shellcheck source=../lib.sh disable=SC1091
|
||||
. "$QEMU_DIR/lib.sh"
|
||||
# shellcheck source=../blkdevparts.conf disable=SC1091
|
||||
. "$QEMU_DIR/blkdevparts.conf"
|
||||
|
||||
SCRATCH="$(mktemp -d "${TMPDIR:-/tmp}/warden-qemu-seed-dir.XXXXXX")"
|
||||
trap 'rm -rf "$SCRATCH"' EXIT
|
||||
|
||||
# userdata's byte offset/size come from the same blkdevparts string
|
||||
# mkimage.sh itself parses, not a hardcoded number: a future layout change
|
||||
# doesn't strand this test.
|
||||
USERDATA_OFF=""
|
||||
USERDATA_SIZE=""
|
||||
capture_userdata() { [ "$1" = userdata ] && { USERDATA_OFF="$2"; USERDATA_SIZE="$3"; }; return 0; }
|
||||
qemu_each_partition capture_userdata
|
||||
[ -n "$USERDATA_OFF" ] || { echo "FATAL: no 'userdata' entry in blkdevparts.conf" >&2; exit 1; }
|
||||
|
||||
# extract_userdata DISK OUTFILE: pull the userdata partition window out of a
|
||||
# built disk image. Sparse output so an otherwise near-empty 1G partition
|
||||
# costs kilobytes of scratch space, not a real gigabyte, per scenario.
|
||||
extract_userdata() {
|
||||
dd if="$1" of="$2" bs=4096 skip=$((USERDATA_OFF / 4096)) \
|
||||
count=$((USERDATA_SIZE / 4096)) conv=sparse status=none
|
||||
}
|
||||
|
||||
# Reuse an already-verified busybox (read-only) so this test stays offline
|
||||
# wherever a prior build has already produced one; only a checkout that has
|
||||
# never run mkimage.sh falls back to the same fetch+verify mkimage.sh always
|
||||
# does, once, shared by every scenario below.
|
||||
BUSYBOX_BIN="$QEMU_DIR/out/busybox-armv7l"
|
||||
if [ ! -f "$BUSYBOX_BIN" ]; then
|
||||
OUT="$SCRATCH" qemu_get_busybox
|
||||
BUSYBOX_BIN="$BB"
|
||||
fi
|
||||
|
||||
# --- scenario 1: seed applied verbatim, secret mode preserved, seed beats a same-named --state ---
|
||||
SEED="$SCRATCH/seed"
|
||||
mkdir -p "$SEED"
|
||||
printf 'plain-value\n' > "$SEED/plain.key"
|
||||
printf 'secret-value\n' > "$SEED/secret.key"
|
||||
chmod 0600 "$SEED/secret.key"
|
||||
printf 'seeded-value\n' > "$SEED/override.key"
|
||||
|
||||
OUT1="$SCRATCH/out1"
|
||||
if OUT="$OUT1" BUSYBOX="$BUSYBOX_BIN" SEED_DIR="$SEED" \
|
||||
bash "$QEMU_DIR/mkimage.sh" --state "override.key=state-value" \
|
||||
> "$SCRATCH/mkimage1.log" 2>&1; then
|
||||
UIMG="$SCRATCH/userdata1.img"
|
||||
extract_userdata "$OUT1/disk.img" "$UIMG"
|
||||
|
||||
plain_stat="$(debugfs -R "stat /warden/plain.key" "$UIMG" 2>/dev/null)"
|
||||
if [ -n "$plain_stat" ]; then
|
||||
pass "SEED_DIR: plain.key landed under userdata/warden"
|
||||
else
|
||||
fail "SEED_DIR: plain.key missing from userdata/warden"
|
||||
fi
|
||||
|
||||
secret_stat="$(debugfs -R "stat /warden/secret.key" "$UIMG" 2>/dev/null)"
|
||||
secret_mode="$(printf '%s' "$secret_stat" | grep -oE 'Mode: *[0-7]+' | grep -oE '[0-7]+$')"
|
||||
if [ "$secret_mode" = "0600" ]; then
|
||||
pass "SEED_DIR: secret.key kept mode 0600 through cp -a"
|
||||
else
|
||||
fail "SEED_DIR: secret.key mode '$secret_mode', want 0600"
|
||||
fi
|
||||
|
||||
override_content="$(debugfs -R "cat /warden/override.key" "$UIMG" 2>/dev/null)"
|
||||
if [ "$override_content" = "seeded-value" ]; then
|
||||
pass "SEED_DIR: seeded override.key beats the same-named --state value"
|
||||
else
|
||||
fail "SEED_DIR: override.key = '$override_content', want 'seeded-value' (seed must apply after --state)"
|
||||
fi
|
||||
else
|
||||
fail "SEED_DIR: mkimage.sh exited nonzero with a valid seed dir (see $SCRATCH/mkimage1.log)"
|
||||
fi
|
||||
|
||||
# --- scenario 2: SEED_DIR that is not a directory fails closed ---
|
||||
NOTADIR="$SCRATCH/notadir"
|
||||
: > "$NOTADIR"
|
||||
OUT2="$SCRATCH/out2"
|
||||
err2="$(OUT="$OUT2" BUSYBOX="$BUSYBOX_BIN" SEED_DIR="$NOTADIR" \
|
||||
bash "$QEMU_DIR/mkimage.sh" 2>&1 1>/dev/null)"
|
||||
rc2=$?
|
||||
if [ "$rc2" -ne 0 ] && printf '%s' "$err2" | grep -qF "FATAL: SEED_DIR '$NOTADIR' is not a directory"; then
|
||||
pass "SEED_DIR: a non-directory path fails closed with the FATAL message"
|
||||
else
|
||||
fail "SEED_DIR: non-directory path gave rc=$rc2, stderr='$err2' (want nonzero + the FATAL message)"
|
||||
fi
|
||||
|
||||
# --- scenario 3: a symlink entry fails closed instead of being copied verbatim ---
|
||||
SEED3="$SCRATCH/seed3"
|
||||
mkdir -p "$SEED3"
|
||||
printf 'plain-value\n' > "$SEED3/plain.key"
|
||||
ln -s /etc/passwd "$SEED3/evil.key"
|
||||
OUT3="$SCRATCH/out3"
|
||||
err3="$(OUT="$OUT3" BUSYBOX="$BUSYBOX_BIN" SEED_DIR="$SEED3" \
|
||||
bash "$QEMU_DIR/mkimage.sh" 2>&1 1>/dev/null)"
|
||||
rc3=$?
|
||||
if [ "$rc3" -ne 0 ] && printf '%s' "$err3" | grep -qF "FATAL: SEED_DIR entry 'evil.key' is a symlink"; then
|
||||
pass "SEED_DIR: a symlink entry fails closed instead of being copied verbatim"
|
||||
else
|
||||
fail "SEED_DIR: symlink entry gave rc=$rc3, stderr='$err3' (want nonzero + the symlink FATAL message)"
|
||||
fi
|
||||
[ -e "$OUT3/disk.img" ] && fail "SEED_DIR: a disk image was written despite the symlink entry"
|
||||
|
||||
# --- scenario 4: an entry with a character outside [A-Za-z0-9_.-]+ fails closed ---
|
||||
SEED4="$SCRATCH/seed4"
|
||||
mkdir -p "$SEED4"
|
||||
printf 'x\n' > "$SEED4/bad key"
|
||||
OUT4="$SCRATCH/out4"
|
||||
err4="$(OUT="$OUT4" BUSYBOX="$BUSYBOX_BIN" SEED_DIR="$SEED4" \
|
||||
bash "$QEMU_DIR/mkimage.sh" 2>&1 1>/dev/null)"
|
||||
rc4=$?
|
||||
if [ "$rc4" -ne 0 ] && printf '%s' "$err4" | grep -qF "FATAL: SEED_DIR entry 'bad key' must match [A-Za-z0-9_.-]+"; then
|
||||
pass "SEED_DIR: an entry name outside [A-Za-z0-9_.-]+ fails closed"
|
||||
else
|
||||
fail "SEED_DIR: bad-name entry gave rc=$rc4, stderr='$err4' (want nonzero + the charset FATAL message)"
|
||||
fi
|
||||
|
||||
# --- scenario 5: a subdirectory entry fails closed (not a plain file) ---
|
||||
SEED5="$SCRATCH/seed5"
|
||||
mkdir -p "$SEED5/subdir"
|
||||
printf 'x\n' > "$SEED5/subdir/leaf.key"
|
||||
OUT5="$SCRATCH/out5"
|
||||
err5="$(OUT="$OUT5" BUSYBOX="$BUSYBOX_BIN" SEED_DIR="$SEED5" \
|
||||
bash "$QEMU_DIR/mkimage.sh" 2>&1 1>/dev/null)"
|
||||
rc5=$?
|
||||
if [ "$rc5" -ne 0 ] && printf '%s' "$err5" | grep -qF "FATAL: SEED_DIR entry 'subdir' is not a plain file"; then
|
||||
pass "SEED_DIR: a subdirectory entry fails closed instead of being recursed into"
|
||||
else
|
||||
fail "SEED_DIR: subdirectory entry gave rc=$rc5, stderr='$err5' (want nonzero + the plain-file FATAL message)"
|
||||
fi
|
||||
|
||||
# --- scenario 6: a hyphenated key (seed-fixtures.py's KEY_RE, e.g.
|
||||
# "gas-plant.devices") still seeds cleanly -- guards against tightening the
|
||||
# charset to --state's stricter [A-Za-z0-9_.]+ by mistake, which would
|
||||
# reject keys committed flow specs already seed through this path ---
|
||||
SEED6="$SCRATCH/seed6"
|
||||
mkdir -p "$SEED6"
|
||||
printf 'r5\n' > "$SEED6/gas-plant.devices"
|
||||
OUT6="$SCRATCH/out6"
|
||||
if OUT="$OUT6" BUSYBOX="$BUSYBOX_BIN" SEED_DIR="$SEED6" \
|
||||
bash "$QEMU_DIR/mkimage.sh" > "$SCRATCH/mkimage6.log" 2>&1; then
|
||||
UIMG6="$SCRATCH/userdata6.img"
|
||||
extract_userdata "$OUT6/disk.img" "$UIMG6"
|
||||
hyphen_content="$(debugfs -R "cat /warden/gas-plant.devices" "$UIMG6" 2>/dev/null)"
|
||||
if [ "$hyphen_content" = "r5" ]; then
|
||||
pass "SEED_DIR: a hyphenated key (gas-plant.devices) still seeds cleanly"
|
||||
else
|
||||
fail "SEED_DIR: gas-plant.devices = '$hyphen_content', want 'r5'"
|
||||
fi
|
||||
else
|
||||
fail "SEED_DIR: mkimage.sh rejected a valid hyphenated key (see $SCRATCH/mkimage6.log)"
|
||||
fi
|
||||
|
||||
[ "$FAIL" -eq 0 ] && echo "ALL SEED_DIR TESTS PASSED" || echo "SEED_DIR TESTS FAILED"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
Executable
+63
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env bash
|
||||
# Offline regression test for qemu_stage_rootfs() (qemu/lib.sh): the staged
|
||||
# etc/shadow must come out mode 0600 regardless of the mode the SOURCE
|
||||
# qemu/rootfs/etc/shadow happens to carry in the working tree. Git tracks
|
||||
# only the executable bit, so a fresh checkout can land that source file at
|
||||
# anything a non-executable blob gets under the checking-out user's umask
|
||||
# (644 under the common 022) -- world readable, exposing root's crypt hash
|
||||
# to any unprivileged process in the guest. The test stages from an isolated
|
||||
# copy of qemu/rootfs with etc/shadow deliberately set to 0644 first, so it
|
||||
# still catches the regression even when the real working tree's copy
|
||||
# already happens to be 0600 locally (that local mode is never what ships;
|
||||
# only what git tracks does). No real busybox or QEMU needed: a stub binary
|
||||
# is enough to exercise the staging function itself.
|
||||
#
|
||||
# bash stage-rootfs-perms.sh
|
||||
set -uo pipefail
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REAL_QEMU_DIR="$(cd "$HERE/.." && pwd)"
|
||||
SCRATCH="$(mktemp -d /tmp/wqperm.XXXXXX)"
|
||||
trap 'rm -rf "$SCRATCH"' EXIT
|
||||
FAIL=0
|
||||
pass() { printf '[PASS] %s\n' "$1"; }
|
||||
fail() { printf '[FAIL] %s\n' "$1"; FAIL=1; }
|
||||
|
||||
# shellcheck source=../lib.sh disable=SC1091
|
||||
. "$REAL_QEMU_DIR/lib.sh"
|
||||
|
||||
# Isolated QEMU_DIR: a copy of the real rootfs skeleton, source etc/shadow
|
||||
# forced to 0644 to simulate the permissive-umask checkout this test must
|
||||
# catch regardless of what the working tree's own copy happens to be.
|
||||
QEMU_DIR="$SCRATCH/qemu"
|
||||
mkdir -p "$QEMU_DIR"
|
||||
cp -a "$REAL_QEMU_DIR/rootfs" "$QEMU_DIR/rootfs"
|
||||
chmod 0644 "$QEMU_DIR/rootfs/etc/shadow"
|
||||
|
||||
# Stand in for a verified busybox download: qemu_stage_rootfs only installs
|
||||
# it, never reads its content.
|
||||
BB="$SCRATCH/fake-busybox"
|
||||
printf '#!/bin/sh\nexit 0\n' > "$BB"
|
||||
chmod 0755 "$BB"
|
||||
|
||||
ROOT="$SCRATCH/root"
|
||||
qemu_stage_rootfs "$ROOT"
|
||||
|
||||
shadow_mode="$(stat -c '%a' "$ROOT/etc/shadow")"
|
||||
if [ "$shadow_mode" = "600" ]; then
|
||||
pass "etc/shadow staged at 0600"
|
||||
else
|
||||
fail "etc/shadow staged at $shadow_mode, want 600"
|
||||
fi
|
||||
|
||||
# Control: passwd/group carry no secrets and stay world-readable, same as
|
||||
# every other Linux system -- confirms the fix targets shadow specifically
|
||||
# rather than locking the whole /etc tree down.
|
||||
passwd_mode="$(stat -c '%a' "$ROOT/etc/passwd")"
|
||||
if [ "$passwd_mode" = "644" ] || [ "$passwd_mode" = "664" ]; then
|
||||
pass "etc/passwd untouched by the shadow chmod (mode $passwd_mode)"
|
||||
else
|
||||
fail "etc/passwd unexpectedly mode $passwd_mode"
|
||||
fi
|
||||
|
||||
[ "$FAIL" -eq 0 ] && echo "ALL STAGE-ROOTFS PERM TESTS PASSED" || echo "STAGE-ROOTFS PERM TESTS FAILED"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
@@ -92,8 +92,12 @@ work="$SCRATCH/a"; build_rig "$work"
|
||||
FLARE_EDGE="$SCRATCH/flare-edge-a"
|
||||
mkdir -p "$FLARE_EDGE/tools/modbus-sim"
|
||||
printf '#!/usr/bin/env python3\nimport sys; sys.exit(1)\n' > "$FLARE_EDGE/tools/modbus-sim/mbsim.py"
|
||||
RIG_OUT="$(unset TEST_MAKE_RS_SOCK; export FLARE_EDGE; run_rig "$work"; echo "$RIG_OUT"; echo "RC=$RIG_RC")"
|
||||
rc_line="$(printf '%s\n' "$RIG_OUT" | grep '^RC=')"; RIG_RC="${rc_line#RC=}"
|
||||
# A VAR=val prefix on a function call exports VAR into that one call only
|
||||
# (and any children it spawns) and restores whatever VAR held before once the
|
||||
# call returns -- the same per-case scoping a wrapping subshell gave us, but
|
||||
# without a subshell: run_rig's RIG_OUT/RIG_RC writes land here directly, so
|
||||
# nothing needs to be re-serialized as text and re-parsed back out below.
|
||||
FLARE_EDGE="$FLARE_EDGE" TEST_MAKE_RS_SOCK='' run_rig "$work"
|
||||
[ "$RIG_RC" -ne 0 ] \
|
||||
&& pass "case A: never-appeared rs.sock fails the run (rc=$RIG_RC)" \
|
||||
|| fail "case A: never-appeared rs.sock fails the run: rc=$RIG_RC, out: $RIG_OUT"
|
||||
@@ -112,8 +116,7 @@ printf '#!/usr/bin/env python3\nimport sys; sys.exit(1)\n' > "$FLARE_EDGE/tools/
|
||||
FAKEBIN="$SCRATCH/fakebin-b"; mkdir -p "$FAKEBIN"
|
||||
printf '#!/usr/bin/env bash\necho "FAKE SOCAT: simulated failure" >&2\nexit 1\n' > "$FAKEBIN/socat"
|
||||
chmod +x "$FAKEBIN/socat"
|
||||
RIG_OUT="$(export TEST_MAKE_RS_SOCK=1 FLARE_EDGE; export PATH="$FAKEBIN:$PATH"; run_rig "$work"; echo "$RIG_OUT"; echo "RC=$RIG_RC")"
|
||||
rc_line="$(printf '%s\n' "$RIG_OUT" | grep '^RC=')"; RIG_RC="${rc_line#RC=}"
|
||||
FLARE_EDGE="$FLARE_EDGE" TEST_MAKE_RS_SOCK=1 PATH="$FAKEBIN:$PATH" run_rig "$work"
|
||||
[ "$RIG_RC" -ne 0 ] \
|
||||
&& pass "case B: socat never linking rs.pty fails the run (rc=$RIG_RC)" \
|
||||
|| fail "case B: socat never linking rs.pty fails the run: rc=$RIG_RC, out: $RIG_OUT"
|
||||
@@ -131,8 +134,7 @@ import sys
|
||||
sys.stderr.write("FAKE MBSIM: simulated crash before opening the control socket\n")
|
||||
sys.exit(1)
|
||||
EOS
|
||||
RIG_OUT="$(export TEST_MAKE_RS_SOCK=1 FLARE_EDGE; unset TEST_MBSIM_PID_FILE; run_rig "$work"; echo "$RIG_OUT"; echo "RC=$RIG_RC")"
|
||||
rc_line="$(printf '%s\n' "$RIG_OUT" | grep '^RC=')"; RIG_RC="${rc_line#RC=}"
|
||||
FLARE_EDGE="$FLARE_EDGE" TEST_MAKE_RS_SOCK=1 TEST_MBSIM_PID_FILE='' run_rig "$work"
|
||||
[ "$RIG_RC" -ne 0 ] \
|
||||
&& pass "case C: mbsim.py crashing before rs.ctl fails the run (rc=$RIG_RC)" \
|
||||
|| fail "case C: mbsim.py crashing before rs.ctl fails the run: rc=$RIG_RC, out: $RIG_OUT"
|
||||
@@ -174,8 +176,7 @@ while True:
|
||||
time.sleep(1)
|
||||
EOS
|
||||
PIDFILE="$SCRATCH/mbsim-d.pid"
|
||||
RIG_OUT="$(export TEST_MAKE_RS_SOCK=1 FLARE_EDGE TEST_MBSIM_PID_FILE="$PIDFILE"; run_rig "$work"; echo "$RIG_OUT"; echo "RC=$RIG_RC")"
|
||||
rc_line="$(printf '%s\n' "$RIG_OUT" | grep '^RC=')"; RIG_RC="${rc_line#RC=}"
|
||||
FLARE_EDGE="$FLARE_EDGE" TEST_MAKE_RS_SOCK=1 TEST_MBSIM_PID_FILE="$PIDFILE" run_rig "$work"
|
||||
if [ -s "$PIDFILE" ]; then
|
||||
mbsim_pid="$(cat "$PIDFILE")"
|
||||
if ! kill -0 "$mbsim_pid" 2>/dev/null; then
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Offline tests for qmp.py's drive(): the QMP socket and the control channel
|
||||
are faked, so this runs in well under two seconds with no VM.
|
||||
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
|
||||
@@ -14,8 +16,10 @@ was never compared with the one the reference was captured under.
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
|
||||
@@ -232,6 +236,23 @@ class PureHelpers(unittest.TestCase):
|
||||
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})
|
||||
@@ -380,6 +401,102 @@ class DriveVerbs(unittest.TestCase):
|
||||
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, 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"])
|
||||
|
||||
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,
|
||||
@@ -469,6 +586,38 @@ class DriveVerbs(unittest.TestCase):
|
||||
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
|
||||
@@ -576,5 +725,251 @@ class FullscreenToggleTracksRealState(unittest.TestCase):
|
||||
"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)
|
||||
|
||||
+28
-12
@@ -114,10 +114,34 @@ reap() {
|
||||
done
|
||||
kill -KILL "$p" 2>/dev/null || true
|
||||
}
|
||||
# Poll for PATH to appear, checking every 0.1s for up to 5 seconds -- the
|
||||
# rs.sock/rs.pty/rs.ctl handshake budget below, now set in one place instead
|
||||
# of three copies that could drift out of step with each other. When PID is
|
||||
# given, also stop the moment PID has died: a process that's already gone
|
||||
# will never create the path, so there is no reason to spend the rest of the
|
||||
# budget waiting on it. The exit status carries no verdict -- each call site
|
||||
# still makes its own existence (and, for rs.ctl, liveness) check right after
|
||||
# this returns, exactly as it did before the loop was pulled out.
|
||||
wait_for_path() {
|
||||
local path="$1" pid="${2:-}" _i
|
||||
for _i in $(seq 1 50); do
|
||||
[ -e "$path" ] && return 0
|
||||
if [ -n "$pid" ]; then
|
||||
kill -0 "$pid" 2>/dev/null || return 0
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
return 0
|
||||
}
|
||||
cleanup() {
|
||||
for p in $SIM_PIDS; do reap "$p"; done
|
||||
if [ -n "$QEMU_PID" ]; then
|
||||
python3 "$HERE/qmp.py" "$WORK/qmp.sock" quit 2>/dev/null || true
|
||||
# `timeout` here is a second, independent bound on top of qmp.py's own
|
||||
# QMP_TIMEOUT_S socket timeout: whichever one it is that stalls, this
|
||||
# call must not itself keep cleanup() from reaching reap "$QEMU_PID"
|
||||
# below -- the one thing meant to guarantee a wedged qemu-system-arm
|
||||
# cannot outlive this script.
|
||||
timeout -k 5 25 python3 "$HERE/qmp.py" "$WORK/qmp.sock" quit 2>/dev/null || true
|
||||
sleep 1
|
||||
reap "$QEMU_PID"
|
||||
fi
|
||||
@@ -173,7 +197,7 @@ if [ -n "$RS485_DEVICES" ]; then
|
||||
command -v socat >/dev/null || { echo "FATAL: --rs485-devices needs socat" >&2; exit 1; }
|
||||
[ -n "${FLARE_EDGE:-}" ] && [ -f "$FLARE_EDGE/tools/modbus-sim/mbsim.py" ] || {
|
||||
echo "FATAL: --rs485-devices needs FLARE_EDGE to point at a flare-edge checkout (mbsim.py)" >&2; exit 1; }
|
||||
for _i in $(seq 1 50); do [ -S "$WORK/rs.sock" ] && break; sleep 0.1; done
|
||||
wait_for_path "$WORK/rs.sock"
|
||||
[ -S "$WORK/rs.sock" ] || {
|
||||
echo "FATAL: rs485 bus socket never appeared at $WORK/rs.sock (qemu's --rs485 chardev never came up)" >&2
|
||||
tail -25 "$WORK/console.log" >&2
|
||||
@@ -186,11 +210,7 @@ if [ -n "$RS485_DEVICES" ]; then
|
||||
# poll budget: a socat that never links the pty is usually already gone
|
||||
# (bad UNIX-CONNECT target, no pty node available), and kill -0 catches
|
||||
# that in one tick instead of five seconds.
|
||||
for _i in $(seq 1 50); do
|
||||
[ -e "$WORK/rs.pty" ] && break
|
||||
kill -0 "$socat_pid" 2>/dev/null || break
|
||||
sleep 0.1
|
||||
done
|
||||
wait_for_path "$WORK/rs.pty" "$socat_pid"
|
||||
[ -e "$WORK/rs.pty" ] || {
|
||||
echo "FATAL: rs485 socat never created rs.pty (see $WORK/socat.log)" >&2
|
||||
cat "$WORK/socat.log" >&2
|
||||
@@ -204,11 +224,7 @@ if [ -n "$RS485_DEVICES" ]; then
|
||||
python3 "$FLARE_EDGE/tools/modbus-sim/mbsim.py" --port "$WORK/rs.pty" --control "$WORK/rs.ctl" "${dev_args[@]}" > "$WORK/mbsim.log" 2>&1 &
|
||||
mbsim_pid=$!
|
||||
SIM_PIDS="$SIM_PIDS $mbsim_pid"
|
||||
for _i in $(seq 1 50); do
|
||||
[ -S "$WORK/rs.ctl" ] && break
|
||||
kill -0 "$mbsim_pid" 2>/dev/null || break
|
||||
sleep 0.1
|
||||
done
|
||||
wait_for_path "$WORK/rs.ctl" "$mbsim_pid"
|
||||
{ [ -S "$WORK/rs.ctl" ] && kill -0 "$mbsim_pid" 2>/dev/null; } || {
|
||||
echo "FATAL: rs485 simulator (mbsim.py) never came up (see $WORK/mbsim.log)" >&2
|
||||
cat "$WORK/mbsim.log" >&2
|
||||
|
||||
Reference in New Issue
Block a user