diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c12c23..4181a82 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,6 +25,7 @@ permissions: jobs: test: runs-on: ubuntu-latest + timeout-minutes: 25 outputs: passed: ${{ steps.result.outputs.passed }} coverage: ${{ steps.result.outputs.coverage }} @@ -58,6 +59,7 @@ jobs: mcdc: # 100% MC/DC (condition coverage) enforced on every Tier-1 driver harness. runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: actions/checkout@v4 - name: install gcc-14 @@ -78,6 +80,7 @@ jobs: # Smoke-run the sim micro-benchmarks and emit the ns/op trend JSON. Regression # gating against stored history is future work (no flare-edge pattern to copy). runs-on: ubuntu-latest + timeout-minutes: 20 steps: - uses: actions/checkout@v4 - name: run sim benchmarks @@ -99,6 +102,7 @@ jobs: # fail-closed sha), and build the A/B disk image (unprivileged mkfs -d). # Booting needs a zImage and therefore lives in kernel-build's smoke step. runs-on: ubuntu-latest + timeout-minutes: 20 steps: - uses: actions/checkout@v4 - name: shellcheck qemu scripts @@ -118,6 +122,7 @@ jobs: patches-apply: # The RV1106 series must apply cleanly onto pristine linux-6.18.46. runs-on: ubuntu-latest + timeout-minutes: 20 steps: - uses: actions/checkout@v4 - name: cache pristine kernel tarball @@ -125,11 +130,8 @@ jobs: with: path: ~/linux-6.18.46.tar.xz key: linux-6.18.46-tarball - - name: fetch + verify pristine - run: | - [ -f ~/linux-6.18.46.tar.xz ] || \ - curl -fSL https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-6.18.46.tar.xz -o ~/linux-6.18.46.tar.xz - echo "$(cat build/linux-6.18.46.tar.xz.sha256) $HOME/linux-6.18.46.tar.xz" | sha256sum -c - + - name: fetch + verify pristine (shared fail-closed fetcher) + run: bash build/fetch-kernel-tarball.sh "$HOME/linux-6.18.46.tar.xz" - name: apply the series in order run: | tar -C /tmp -xf ~/linux-6.18.46.tar.xz @@ -148,6 +150,7 @@ jobs: prune-artifacts: if: github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest + timeout-minutes: 10 permissions: actions: write steps: @@ -178,13 +181,14 @@ jobs: if: github.event_name == 'workflow_dispatch' needs: [prune-artifacts] runs-on: ubuntu-latest + timeout-minutes: 60 steps: - uses: actions/checkout@v4 - name: install cross toolchain + kernel build deps + qemu run: | sudo apt-get update -qq sudo apt-get install -y -qq gcc-arm-linux-gnueabihf qemu-system-arm \ - cpio bc bison flex libssl-dev + cpio bc bison flex libssl-dev ccache - name: provision `python` (SDK quirk — build calls bare python) run: | mkdir -p "$RUNNER_TEMP/bin" @@ -195,22 +199,32 @@ jobs: with: path: ~/linux-6.18.46.tar.xz key: linux-6.18.46-tarball + # Ephemeral runners rebuild the whole tree every dispatch (~9 min of + # compile); ccache recovers most of it for an unchanged/lightly-changed + # series. Keyed on the config + patches so a real change misses cleanly. + - name: cache ccache + uses: actions/cache@v4 + with: + path: ~/.ccache + key: kbuild-ccache-${{ hashFiles('build/warden_defconfig', 'patches/*.patch') }} + restore-keys: kbuild-ccache- - name: build zImage + rv1106-warden.dtb env: # WORK must be OUTSIDE the repo checkout: build-kernel.sh applies the patch # series with `git apply`, which silently ignores out-of-subdir paths when # run inside another git repo (issue #1). $RUNNER_TEMP is outside the checkout. WORK: ${{ runner.temp }}/kbuild-out - # Reuse the cached tarball when present (build-kernel.sh still verifies - # the sha256 pin fail-closed either way; it downloads if the file is absent). - KERNEL_TARBALL: ~/linux-6.18.46.tar.xz # The kernel is freestanding; the generic arm cross toolchain links it. CROSS_COMPILE: arm-linux-gnueabihf- + WARDEN_CCACHE: 1 + CCACHE_DIR: /home/runner/.ccache + # KERNEL_TARBALL is exported from the SHELL so $HOME expands — a literal + # `~` in a YAML env: value is never tilde-expanded and broke every + # dispatch until caught in review. run: | - [ -f ~/linux-6.18.46.tar.xz ] || \ - curl -fSL https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-6.18.46.tar.xz \ - -o ~/linux-6.18.46.tar.xz + export KERNEL_TARBALL="$HOME/linux-6.18.46.tar.xz" bash build/build-kernel.sh + ccache -s | head -4 # Boot smoke under QEMU: the zImage this job just built must reach the # initramfs sentinel on -M virt (verified 2026-08-29: the canonical # config boots virt as-is). FAIL-CLOSED on a missing qemu-system-arm. @@ -239,6 +253,7 @@ jobs: needs: [test] if: github.event_name == 'push' && github.ref == 'refs/heads/main' runs-on: ubuntu-latest + timeout-minutes: 15 permissions: contents: write steps: diff --git a/.gitignore b/.gitignore index cb4c663..8493652 100644 --- a/.gitignore +++ b/.gitignore @@ -14,7 +14,7 @@ target/ __pycache__/ *.pyc -# local tooling state (code-review harness cross-session memory, etc.) +# local tooling state .claude/ # driver MC/DC harness build dirs diff --git a/README.md b/README.md index d51b25c..e39d0ca 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,9 @@ Evaluated against the stack philosophy — **openness, hardness, modernness**: ## Relationship to flare-edge flare-edge (WardenOS: the LVGL UI + the `flared` daemon) is the product; warden-sdk -is what builds and tests it. During bootstrap, flare-edge consumes warden-sdk piece +is what builds and tests it. flare-edge is BlueFlare's private companion repo — +not publicly available — so flare-edge issue references and checkout paths in +this repo's docs are context, not reachable links. During bootstrap, flare-edge consumes warden-sdk piece by piece: first the simulator (as a dev/test dependency), later the image build. No flare-edge code moves here — only the SDK/build/sim/driver-seam layer. diff --git a/build/build-kernel.sh b/build/build-kernel.sh index b346ef7..f5db1e9 100755 --- a/build/build-kernel.sh +++ b/build/build-kernel.sh @@ -14,7 +14,9 @@ # JOBS parallel make jobs (default: nproc) # WARDEN_KCONFIG_FRAGMENT # optional kconfig fragment merged onto warden_defconfig -# (qemu/configs/virt.fragment builds the QEMU -M virt variant) +# (qemu/configs/virt.fragment builds the QEMU -M virt variant); +# every fragment option is verified to have taken effect +# WARDEN_CCACHE=1 compile through ccache (CI caches ~/.ccache) # # Requires: `python` (not python3) on PATH — the SDK quirk; the CI runner provides # a project-local venv. Builds are SERIAL on the shared SDK box — never run two. @@ -24,9 +26,8 @@ KVER=6.18.46 HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # build/ REPO="$(cd "$HERE/.." && pwd)" PATCHES="$REPO/patches" -SHA_FILE="$HERE/linux-$KVER.tar.xz.sha256" JOBS="${JOBS:-$(nproc)}" -URL="https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-$KVER.tar.xz" +# The tarball URL + sha256 pin live in fetch-kernel-tarball.sh (shared with CI). # A caller-provided WORK (e.g. CI's ${{ github.workspace }}/kbuild-out, from which # artifacts are uploaded) is left intact; a scratch dir we mktemp'd here is our own @@ -47,21 +48,9 @@ command -v python >/dev/null || { echo "need 'python' (not python3) on PATH — # 1. obtain + verify the pristine tarball mkdir -p "$WORK" TB="${KERNEL_TARBALL:-$WORK/linux-$KVER.tar.xz}" -if [ ! -f "$TB" ]; then - log "downloading $URL" - curl -fSL "$URL" -o "$TB" -fi -# Fail closed: a missing pin (e.g. forgotten on a KVER bump) or a KERNEL_TARBALL -# pointed at an arbitrary file must refuse to build, never silently skip the check -# — the pristine tarball is the ONLY external input and integrity is the whole point. -[ -f "$SHA_FILE" ] || { - echo "FATAL: no pinned sha256 for linux-$KVER (expected $SHA_FILE) — refusing to build from an unverified tarball" >&2 - exit 1 -} -want="$(cat "$SHA_FILE")" -got="$(sha256sum "$TB" | awk '{print $1}')" -[ "$want" = "$got" ] || { echo "tarball sha256 mismatch: want $want got $got" >&2; exit 1; } -log "tarball sha256 verified" +# Fetch + fail-closed sha256 verification live in ONE place shared with CI +# (a missing pin or a mismatch always refuses to build). +bash "$HERE/fetch-kernel-tarball.sh" "$TB" # 2. extract pristine SRC="$WORK/linux-$KVER" @@ -124,9 +113,41 @@ command -v "${CROSS_COMPILE}gcc" >/dev/null \ || { echo "cross toolchain ${CROSS_COMPILE}gcc not on PATH (set SDK_TC, or CROSS_COMPILE to one that is)" >&2; exit 1; } make -C "$SRC" ARCH=arm CROSS_COMPILE="$CROSS_COMPILE" olddefconfig >/dev/null +# Fragment took-effect assertion: merge_config -m only pastes text, and +# olddefconfig silently resolves any symbol whose dependencies are unmet — +# a fragment option could be dropped without a word. Verify every explicit +# request in the fragment survived into the final .config; fail loud if not. +if [ -n "${WARDEN_KCONFIG_FRAGMENT:-}" ]; then + frag_fail=0 + while IFS= read -r line; do + case "$line" in + CONFIG_*=*) + grep -qxF "$line" "$SRC/.config" || { + echo "FATAL: fragment option '$line' did not take effect (unmet Kconfig dependency?)" >&2 + frag_fail=1 + } ;; + "# CONFIG_"*" is not set") + opt="${line#\# }"; opt="${opt% is not set}" + grep -qE "^$opt=" "$SRC/.config" && { + echo "FATAL: fragment disabled '$opt' but it is set in the final .config" >&2 + frag_fail=1 + } ;; + esac + done < "$FRAG" + [ "$frag_fail" = 0 ] || exit 1 + log "fragment options verified in final .config" +fi + +# Optional ccache (CI: cache ~/.ccache across dispatches; harmless if unset). +KCC="${CROSS_COMPILE}gcc" +if [ "${WARDEN_CCACHE:-0}" = 1 ]; then + command -v ccache >/dev/null || { echo "FATAL: WARDEN_CCACHE=1 but ccache not installed" >&2; exit 1; } + KCC="ccache ${CROSS_COMPILE}gcc" +fi + # 5. build zImage + the board dtb log "building zImage + rv1106-warden.dtb (-j$JOBS)" -make -C "$SRC" ARCH=arm CROSS_COMPILE="$CROSS_COMPILE" -j"$JOBS" \ +make -C "$SRC" ARCH=arm CROSS_COMPILE="$CROSS_COMPILE" CC="$KCC" -j"$JOBS" \ zImage rockchip/rv1106-warden.dtb Z="$SRC/arch/arm/boot/zImage" diff --git a/build/fetch-kernel-tarball.sh b/build/fetch-kernel-tarball.sh new file mode 100644 index 0000000..ffc996a --- /dev/null +++ b/build/fetch-kernel-tarball.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Fetch (with retries) and sha256-verify the pristine kernel tarball into $1. +# Single source of truth for the URL + verification used by build-kernel.sh +# and both CI jobs (patches-apply, kernel-build) — a KVER bump edits this file +# and build-kernel.sh only. FAILS CLOSED: a missing pin refuses to proceed. +# +# Usage: fetch-kernel-tarball.sh +set -euo pipefail + +KVER=6.18.46 +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # build/ +SHA_FILE="$HERE/linux-$KVER.tar.xz.sha256" +URL="https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-$KVER.tar.xz" + +TB="${1:?usage: fetch-kernel-tarball.sh }" + +if [ ! -f "$TB" ]; then + echo "== downloading $URL" + curl --retry 3 --retry-delay 5 -fSL "$URL" -o "$TB" +fi +[ -f "$SHA_FILE" ] || { + echo "FATAL: no pinned sha256 for linux-$KVER (expected $SHA_FILE) — refusing an unverified tarball" >&2 + exit 1 +} +want="$(cat "$SHA_FILE")" +got="$(sha256sum "$TB" | awk '{print $1}')" +[ "$want" = "$got" ] || { echo "FATAL: tarball sha256 mismatch: want $want got $got" >&2; exit 1; } +echo "== tarball sha256 verified: $TB" diff --git a/docs/architecture.md b/docs/architecture.md index ffb3ebf..812773d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -65,7 +65,7 @@ supervisor logic runs in CI with no panel. the boot-mode register's survives-warm-reset / cleared-by-POR behaviour (the MaskRom recovery maneuver). The matching firmware-side `Bus` seam on flared's `devmem` — so the shipped ladder can be asserted to poke the confirmed offset, never the wrong-SoC - one — lands when flare-edge consumes warden-sdk (§7 item 3, maintainer-gated), not yet on + one — lands when flare-edge consumes warden-sdk (§8 item 3, maintainer-gated), not yet on flare-edge `main`. - **`modbus` — RS-485 device end.** Done. `ModbusSlave`: a byte-in/byte-out RTU slave (CRC16 byte-identical to the master, FC 0x01–0x06/0x0F/0x10/0x11, exception replies, diff --git a/docs/ci-cd.md b/docs/ci-cd.md index 1389666..5e7794e 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -2,8 +2,8 @@ `.github/workflows/ci.yml` — every job runs on GitHub-hosted `ubuntu-latest`. No self-hosted runner is (or may be) reachable from this repo's workflows: -the repo is public, and a fork PR that gets one approved run could otherwise -execute code on private infrastructure (ADR-0007). +the repo is going public, and a fork PR that gets one approved run could +otherwise execute code on private infrastructure (ADR-0007). ## Jobs diff --git a/docs/decisions/0003-standalone-repo.md b/docs/decisions/0003-standalone-repo.md index 0b15fd2..b08f93c 100644 --- a/docs/decisions/0003-standalone-repo.md +++ b/docs/decisions/0003-standalone-repo.md @@ -1,6 +1,8 @@ # ADR 0003 — warden-sdk is a standalone repo -**Status:** Accepted (2026-08-25). +**Status:** Accepted (2026-08-25). Repo-visibility half superseded by ADR-0007 +(2026-08-30) — warden-sdk went public; the "private for now" consequence below +no longer holds. Original decision kept for the record. ## Context Our real SDK changes lived as uncommitted edits in a 2GB opaque vendor fork, with @@ -10,7 +12,7 @@ no CI, tests, or versioning of their own. The SDK requirement (future-features-2 ## Decision A **private** `bfe-noah/warden-sdk` GitHub repo, standalone from day one with its own CI/versioning. Work lands on a `bringup` branch; the first commit to `main` is gated -on a passing code-review-harness run, green CI, and the maintainer's fresh explicit go-ahead. +on a passing review run, green CI, and the maintainer's fresh explicit go-ahead. ## Consequences - flare-edge consumes warden-sdk later (flared depending on `warden-sim`, drivers diff --git a/kernel/rv1106-enablement/build-m2.sh b/kernel/rv1106-enablement/build-m2.sh index 9a651dd..c8b5f90 100755 --- a/kernel/rv1106-enablement/build-m2.sh +++ b/kernel/rv1106-enablement/build-m2.sh @@ -8,7 +8,7 @@ # Defaults match this workspace. set -euo pipefail -FE="${FE:-}" +FE="${FE:?set FE to a flare-edge checkout path}" KTREE="${KTREE:-$FE/research/linux-6.18.46}" SDK_TC="${SDK_TC:-$FE/sdk/tools/linux/toolchain/arm-rockchip830-linux-uclibcgnueabihf/bin}" HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" diff --git a/kernel/rv1106-enablement/mailbox/scr1-echo/Makefile b/kernel/rv1106-enablement/mailbox/scr1-echo/Makefile index 5008b9f..54678a7 100644 --- a/kernel/rv1106-enablement/mailbox/scr1-echo/Makefile +++ b/kernel/rv1106-enablement/mailbox/scr1-echo/Makefile @@ -1,7 +1,8 @@ # HPMCU mailbox-echo firmware — bare-metal RV32IMC for the RV1106 SCR1 core. # Same xPack riscv-none-embed-gcc 10.2.0 + flags as the watchdog firmware. -XPACK ?= /sdk/sysdrv/source/mcu/prebuilts/gcc/linux-x86/riscv64/xpack-riscv-none-embed-gcc-10.2.0-1.2/bin +# Point XPACK at the xpack riscv toolchain bin/ inside a flare-edge SDK checkout. +XPACK ?= $(error set XPACK to the xpack-riscv-none-embed-gcc bin/ directory) CROSS ?= $(XPACK)/riscv-none-embed- CC = $(CROSS)gcc diff --git a/kernel/rv1106-enablement/rga/PORT-PLAN.md b/kernel/rv1106-enablement/rga/PORT-PLAN.md index 79f1cea..6e1c01b 100644 --- a/kernel/rv1106-enablement/rga/PORT-PLAN.md +++ b/kernel/rv1106-enablement/rga/PORT-PLAN.md @@ -295,7 +295,8 @@ implied by this kernel port. canvas + scanout mirror at 720×720×4B ≈ 2 MiB each); carry the same size unless a future accounting shows it's tight. - **Driver-parity table** (`../DRIVER-PARITY.md`) should move `RGA 2D (rga2)` from [ ] to - [wip]/[x] as these steps land, same convention as every other M-milestone row. + [wip]/[x] as these steps land, same convention as every other M-milestone row +(the parity row already reads [x]). ## Sources diff --git a/qemu/README.md b/qemu/README.md index 37d81c8..02af94b 100644 --- a/qemu/README.md +++ b/qemu/README.md @@ -89,7 +89,8 @@ stage-2 init when present. - A serial port that is closed discards incoming bytes: hold ONE fd open across write and read when scripting the guest side of the RS485 bridge. - `highmem=off` and `-global virtio-mmio.force-legacy=false` are load-bearing - (32-bit ECAM reach; virtio-1-only gpu/input) — both live in run.sh. + (32-bit ECAM reach; virtio-1-only gpu/input) — both live ONLY in run.sh, + which every script (boot smoke included) delegates to. - Never pass `earlyprintk`: DEBUG_UART_PHYS is the RV1106's 0xff4c0000. ## Host requirements diff --git a/qemu/lib.sh b/qemu/lib.sh index cd8b2ed..4249709 100644 --- a/qemu/lib.sh +++ b/qemu/lib.sh @@ -18,7 +18,7 @@ qemu_get_busybox() { BB="${BUSYBOX:-$out/busybox-armv7l}" if [ ! -f "$BB" ]; then qemu_log "downloading $BB_URL" - curl -fSL "$BB_URL" -o "$BB" + curl --retry 3 --retry-delay 5 -fSL "$BB_URL" -o "$BB" fi [ -f "$sha_file" ] || { echo "FATAL: no pinned sha256 for busybox (expected $sha_file) — refusing to build from an unverified binary" >&2 diff --git a/qemu/mkimage.sh b/qemu/mkimage.sh index 96036ba..7d38e98 100755 --- a/qemu/mkimage.sh +++ b/qemu/mkimage.sh @@ -30,7 +30,17 @@ FW_VERSION="0.0.1" while [ $# -gt 0 ]; do case "$1" in --portal-url) PORTAL_URL="${2:?--portal-url needs a value}"; shift 2 ;; - --state) STATE_KV+=("${2:?--state needs KEY=VALUE}"); shift 2 ;; + --state) + case "${2:?--state needs KEY=VALUE}" in + *=*) ;; + *) echo "FATAL: --state needs KEY=VALUE, got '$2'" >&2; exit 1 ;; + esac + case "${2%%=*}" in + *[!A-Za-z0-9_.]*|'') + echo "FATAL: --state key '${2%%=*}' must match [A-Za-z0-9_.]+ (it becomes a filename)" >&2 + exit 1 ;; + esac + STATE_KV+=("$2"); shift 2 ;; --fw-version) FW_VERSION="${2:?--fw-version needs a value}"; shift 2 ;; *) echo "FATAL: unknown argument '$1' (usage: mkimage.sh [--portal-url URL] [--state KEY=VALUE]... [--fw-version V])" >&2; exit 1 ;; esac @@ -83,7 +93,11 @@ place_partition() { rootfs_a|rootfs_b) stage="$ROOT" ;; userdata) stage="$UDATA" ;; oem_a|oem_b) stage="$SCRATCH/empty" ;; - *) stage="" ;; # boot-chain partition: left zeroed + # Boot-chain partitions the VM never reads: present at the right offsets, + # left zeroed. Enumerated (not a wildcard) so a typo'd name in + # blkdevparts.conf fails HERE, not as a confusing mount error at boot. + env|idblock|uboot|misc|boot_a|boot_b|recovery) stage="" ;; + *) echo "FATAL: unknown partition name '$name' in blkdevparts.conf" >&2; exit 1 ;; esac # dd in 4K blocks — every offset in the canonical layout is 4K-aligned; # assert rather than assume, a misaligned write would corrupt a neighbor. @@ -91,7 +105,8 @@ place_partition() { echo "FATAL: partition $name not 4K-aligned (off=$off size=$size)" >&2 exit 1 fi - DISK_END=$((off + size)) + # Max, not last: blkdevparts grammar permits explicit @offsets out of order. + [ $((off + size)) -gt "$DISK_END" ] && DISK_END=$((off + size)) [ -z "$stage" ] && return 0 local img="$SCRATCH/$name.img" mkfs_part "$stage" "$size" "$img" diff --git a/qemu/rootfs/etc/rc b/qemu/rootfs/etc/rc index f868fd5..fa2c335 100755 --- a/qemu/rootfs/etc/rc +++ b/qemu/rootfs/etc/rc @@ -1,37 +1,19 @@ #!/bin/busybox sh # Stage-1 rc: sourced by /init (still PID 1, initramfs root) when a virtio -# disk is present. Emulates U-Boot's slot choice — parse warden.slot= from the -# cmdline, mount that rootfs, switch_root into it. This is an EMULATION of the -# A/B selection outcome, not the BCB/bootcount mechanism itself. +# disk is present. Emulates U-Boot's slot choice — mount the validated slot's +# rootfs and switch_root into it. This is an EMULATION of the A/B selection +# outcome, not the BCB/bootcount mechanism itself. +# +# Every guarded failure path `return`s to /init (valid in a sourced script; +# /init then falls through to shell/poweroff). The final exec is the one +# unguardable step: if switch_root itself fails to launch, the shell — PID 1 — +# exits and the kernel panics; the applet-existence check below catches the +# only preventable variant of that. -# /dev/block/by-name/ symlinks: the contract flare-edge slotctl.rs -# relies on. blkdevparts= gives every vda partition a PARTNAME in sysfs. -mkdir -p /dev/block/by-name -for uev in /sys/class/block/vda*/uevent; do - [ -f "$uev" ] || continue - partname="" - devname="" - while IFS='=' read -r k v; do - case "$k" in - PARTNAME) partname="$v" ;; - DEVNAME) devname="$v" ;; - esac - done < "$uev" - [ -n "$partname" ] && [ -n "$devname" ] \ - && ln -sf "/dev/$devname" "/dev/block/by-name/$partname" -done +. /etc/warden-lib.sh -# Slot select: whole-token parse of warden.slot= (never a substring match). -slot="_a" -for tok in $(cat /proc/cmdline); do - case "$tok" in - warden.slot=*) slot="${tok#warden.slot=}" ;; - esac -done -case "$slot" in - _a|_b) ;; - *) echo "rc: bad warden.slot='$slot', falling back to _a"; slot="_a" ;; -esac +warden_populate_by_name +slot="$(warden_slot)" root="/dev/block/by-name/rootfs${slot}" if [ ! -e "$root" ]; then @@ -49,6 +31,11 @@ if [ ! -x /mnt/sbin/init ]; then umount /mnt return 0 fi +if ! command -v switch_root >/dev/null; then + echo "rc: busybox lacks switch_root — staying in initramfs" + umount /mnt + return 0 +fi echo "rc: switching root to rootfs${slot} ($root)" exec switch_root /mnt /sbin/init diff --git a/qemu/rootfs/etc/warden-lib.sh b/qemu/rootfs/etc/warden-lib.sh new file mode 100644 index 0000000..72801dc --- /dev/null +++ b/qemu/rootfs/etc/warden-lib.sh @@ -0,0 +1,42 @@ +# shellcheck shell=sh +# Shared helpers for the VM's stage-1 (/init + /etc/rc, initramfs) and stage-2 +# (/sbin/init, disk rootfs) boot scripts. Present in both filesystems because +# both are staged from the same qemu/rootfs/ skeleton. ONE copy of each rule — +# the slot-validation drift between two hand-copied parsers was a real +# review finding. + +# Populate /dev/block/by-name/ symlinks from sysfs uevents — the +# contract flare-edge's slotctl.rs relies on. blkdevparts= gives every vda +# partition a PARTNAME. +warden_populate_by_name() { + mkdir -p /dev/block/by-name + for uev in /sys/class/block/vda*/uevent; do + [ -f "$uev" ] || continue + partname="" + devname="" + while IFS='=' read -r k v; do + case "$k" in + PARTNAME) partname="$v" ;; + DEVNAME) devname="$v" ;; + esac + done < "$uev" + [ -n "$partname" ] && [ -n "$devname" ] \ + && ln -sf "/dev/$devname" "/dev/block/by-name/$partname" + done +} + +# Parse warden.slot= from the cmdline (whole-token, never substring) and +# VALIDATE it — echoes "_a" or "_b", falling back to _a with a warning. +warden_slot() { + slot="_a" + for tok in $(cat /proc/cmdline); do + case "$tok" in + warden.slot=*) slot="${tok#warden.slot=}" ;; + esac + done + case "$slot" in + _a|_b) ;; + *) echo "warden-lib: bad warden.slot='$slot', falling back to _a" >&2; slot="_a" ;; + esac + echo "$slot" +} diff --git a/qemu/rootfs/sbin/init b/qemu/rootfs/sbin/init index 8eb2abd..c13b15c 100755 --- a/qemu/rootfs/sbin/init +++ b/qemu/rootfs/sbin/init @@ -13,33 +13,29 @@ mount -t proc proc /proc mount -t sysfs sysfs /sys mount -t tmpfs tmpfs /tmp -# Fresh devtmpfs — repopulate the by-name contract (slotctl.rs depends on it). -mkdir -p /dev/block/by-name -for uev in /sys/class/block/vda*/uevent; do - [ -f "$uev" ] || continue - partname="" - devname="" - while IFS='=' read -r k v; do - case "$k" in - PARTNAME) partname="$v" ;; - DEVNAME) devname="$v" ;; - esac - done < "$uev" - [ -n "$partname" ] && [ -n "$devname" ] \ - && ln -sf "/dev/$devname" "/dev/block/by-name/$partname" -done +. /etc/warden-lib.sh -# Slot (whole-token parse, same rule as stage 1). -slot="_a" -for tok in $(cat /proc/cmdline); do - case "$tok" in - warden.slot=*) slot="${tok#warden.slot=}" ;; - esac -done +# Fresh devtmpfs — repopulate the by-name contract; same VALIDATED slot rule +# as stage 1 (shared helper, so the two can never drift). +warden_populate_by_name +slot="$(warden_slot)" # The device's matched mounts: persistent state and the slot's oem partition. -mount -t ext4 /dev/block/by-name/userdata /userdata || echo "init: userdata mount failed" -mount -t ext4 "/dev/block/by-name/oem${slot}" /oem || echo "init: oem${slot} mount failed" +# Fail-fast: a scenario against an image whose userdata cannot mount would +# otherwise burn its whole deadline before failing generically. warden.shell +# still gets a shell for post-mortem. +mount_fatal() { + if ! mount -t ext4 "/dev/block/by-name/$1" "$2"; then + echo "WARDEN-QEMU-MOUNT-FAILED $1" + if grep -qw warden.shell /proc/cmdline; then + echo "warden.shell: post-mortem shell (exit powers off)" + setsid cttyhack sh + fi + poweroff -f + fi +} +mount_fatal userdata /userdata +mount_fatal "oem${slot}" /oem mkdir -p /userdata/warden # RS485: warden-modbus hardcodes /dev/ttyS4 at compile time; alias it to the @@ -47,10 +43,13 @@ mkdir -p /userdata/warden [ -c /dev/ttyS0 ] && ln -sf /dev/ttyS0 /dev/ttyS4 # Network: slirp user-mode net on eth0 (DHCP, fallback to QEMU's static map). +# The fallback keys off the interface actually having an address — udhcpc +# exiting 0 only proves a lease, not that the hook script applied it. ip link set lo up if [ -e /sys/class/net/eth0 ]; then ip link set eth0 up - if ! udhcpc -i eth0 -n -q -t 5 -T 2 >/dev/null 2>&1; then + udhcpc -i eth0 -n -q -t 5 -T 2 >/dev/null 2>&1 || true + if ! ip -4 addr show dev eth0 | grep -q 'inet '; then ip addr add 10.0.2.15/24 dev eth0 2>/dev/null ip route replace default via 10.0.2.2 dev eth0 echo "nameserver 10.0.2.3" > /etc/resolv.conf diff --git a/qemu/rs485-bridge/src/lib.rs b/qemu/rs485-bridge/src/lib.rs index 92023ad..bb2fae2 100644 --- a/qemu/rs485-bridge/src/lib.rs +++ b/qemu/rs485-bridge/src/lib.rs @@ -24,6 +24,11 @@ use warden_sim::ModbusSlave; /// response timeout is orders of magnitude larger. pub const DEFAULT_GAP: Duration = Duration::from_millis(10); +/// Accumulation cap: a Modbus RTU ADU is at most 256 bytes, so anything past +/// 2x that without an inter-frame gap is a misbehaving master streaming +/// continuously — drop the buffer instead of growing without bound. +const MAX_PENDING: usize = 512; + /// The shared bus: the slave plus its declared dimensions. The sim's register /// setters panic on out-of-range indices (deliberate test-harness semantics); /// the control channel must bounds-check first so a typo in a scenario script @@ -64,7 +69,17 @@ pub fn pump_serial( } return Ok(()); } - Ok(n) => buf.extend_from_slice(&chunk[..n]), + Ok(n) => { + buf.extend_from_slice(&chunk[..n]); + if buf.len() > MAX_PENDING { + eprintln!( + "rs485: {} bytes buffered with no inter-frame gap — discarding \ + (misbehaving master streaming continuously?)", + buf.len() + ); + buf.clear(); + } + } Err(e) if e.kind() == std::io::ErrorKind::WouldBlock || e.kind() == std::io::ErrorKind::TimedOut => @@ -127,10 +142,10 @@ pub fn handle_control_line(line: &str, bus: &Bus) -> String { if words.next().is_some() { return format!("err trailing arguments after '{cmd}'"); } - let bound = |cmd: &str| match cmd { - "holding" | "input" | "get-holding" => bus.regs, - _ => bus.bits, - }; + // Each arm states its own bound (bus.regs for register space, bus.bits for + // bit space) INLINE — a previous string-keyed lookup defaulted silently to + // the bit bound, which would have handed a future `get-input` command the + // wrong range and reintroduced the out-of-range panic this check prevents. let mut s = bus.slave.lock().unwrap(); match (cmd, arg) { ("ping", None) => "ok".into(), @@ -152,39 +167,63 @@ pub fn handle_control_line(line: &str, bus: &Bus) -> String { } _ => format!("err bad exception code '{c}'"), }, - ("holding" | "input" | "coil" | "discrete", Some(kv)) => { - let (addr, val) = match kv.split_once('=') { - Some((a, v)) => (parse_u16(a), parse_u16(v)), - None => (None, None), - }; - match (addr, val) { - (Some(a), _) if (a as usize) >= bound(cmd) => { - format!("err address {a} out of range (0..{})", bound(cmd)) + ("holding" | "input", Some(kv)) => match parse_addr_val(kv, bus.regs) { + Ok((a, v)) => { + if cmd == "holding" { + s.set_holding(a, v); + } else { + s.set_input(a, v); } - (Some(a), Some(v)) => { - match cmd { - "holding" => s.set_holding(a as usize, v), - "input" => s.set_input(a as usize, v), - "coil" => s.set_coil(a as usize, v != 0), - _ => s.set_discrete(a as usize, v != 0), - } - "ok".into() + "ok".into() + } + Err(e) => e, + }, + ("coil" | "discrete", Some(kv)) => match parse_addr_val(kv, bus.bits) { + Ok((a, v)) => { + if cmd == "coil" { + s.set_coil(a, v != 0); + } else { + s.set_discrete(a, v != 0); } - _ => format!("err expected =, got '{kv}'"), + "ok".into() } - } - ("get-holding" | "get-coil", Some(a)) => match parse_u16(a) { - Some(a) if (a as usize) >= bound(cmd) => { - format!("err address {a} out of range (0..{})", bound(cmd)) - } - Some(a) if cmd == "get-holding" => format!("ok {}", s.holding(a as usize)), - Some(a) => format!("ok {}", u8::from(s.coil(a as usize))), - None => format!("err bad address '{a}'"), + Err(e) => e, + }, + ("get-holding", Some(a)) => match parse_addr(a, bus.regs) { + Ok(a) => format!("ok {}", s.holding(a)), + Err(e) => e, + }, + ("get-coil", Some(a)) => match parse_addr(a, bus.bits) { + Ok(a) => format!("ok {}", u8::from(s.coil(a))), + Err(e) => e, }, _ => format!("err unknown or malformed command '{line}'"), } } +/// Parse "=" with the address bounds-checked against `bound`. +fn parse_addr_val(kv: &str, bound: usize) -> Result<(usize, u16), String> { + let Some((a, v)) = kv.split_once('=') else { + return Err(format!("err expected =, got '{kv}'")); + }; + let (Some(a), Some(v)) = (parse_u16(a), parse_u16(v)) else { + return Err(format!("err expected =, got '{kv}'")); + }; + if (a as usize) >= bound { + return Err(format!("err address {a} out of range (0..{bound})")); + } + Ok((a as usize, v)) +} + +/// Parse a bare address, bounds-checked against `bound`. +fn parse_addr(a: &str, bound: usize) -> Result { + match parse_u16(a) { + Some(v) if (v as usize) < bound => Ok(v as usize), + Some(v) => Err(format!("err address {v} out of range (0..{bound})")), + None => Err(format!("err bad address '{a}'")), + } +} + fn parse_u16(s: &str) -> Option { if let Some(h) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) { u16::from_str_radix(h, 16).ok() @@ -200,10 +239,13 @@ mod tests { use std::time::Duration; use warden_sim::modbus::{crc_ok, read_holding}; - // Test gap is larger than DEFAULT_GAP so a loaded CI runner cannot split - // a frame that the test wrote in two deliberate chunks. - const GAP: Duration = Duration::from_millis(25); - const SETTLE: Duration = Duration::from_millis(100); + // Test gap is much larger than DEFAULT_GAP so a loaded CI runner cannot + // split a frame the test wrote in two deliberate chunks: the 2ms + // inter-chunk pause has a 60x margin against the 120ms dispatch gap + // (25ms gave only 12.5x and was flagged as a flake risk on contended + // 2-vCPU hosted runners). + const GAP: Duration = Duration::from_millis(120); + const SETTLE: Duration = Duration::from_millis(400); fn bus() -> Bus { let b = Bus::new(1, 16, 16); @@ -221,7 +263,9 @@ mod tests { } fn read_reply(master: &UnixStream) -> Vec { - master.set_read_timeout(Some(Duration::from_secs(2))).unwrap(); + master + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); let mut buf = [0u8; 256]; let n = (&*master).read(&mut buf).expect("expected a reply frame"); buf[..n].to_vec() @@ -274,7 +318,10 @@ mod tests { with_pump(&s, |master| { (&*master).write_all(&read_holding(1, 2, 1)).unwrap(); master - .set_read_timeout(Some(Duration::from_millis(200))) + // Well past GAP: the dropped frame must have been dispatched + // (and answered with silence) before the next request is + // written, or the two would merge in the pending buffer. + .set_read_timeout(Some(Duration::from_millis(500))) .unwrap(); let mut buf = [0u8; 16]; assert!( diff --git a/qemu/rs485-bridge/src/main.rs b/qemu/rs485-bridge/src/main.rs index f9ac955..6e1cc7e 100644 --- a/qemu/rs485-bridge/src/main.rs +++ b/qemu/rs485-bridge/src/main.rs @@ -2,10 +2,14 @@ //! there); this file only parses arguments, connects sockets, and spawns the //! control listener. //! -//! Typical use (matches qemu/run.sh --rs485): +//! Typical use (matches qemu/run.sh --rs485). Put the sockets in a private +//! per-run directory (mktemp -d) — short (AF_UNIX caps paths at ~108 chars) +//! and not guessable/pre-creatable by other local users, unlike a fixed +//! /tmp name: //! -//! qemu/run.sh --kernel ... --rs485 /tmp/warden-rs485.sock & -//! rs485-bridge --serial /tmp/warden-rs485.sock --control /tmp/warden-rs485-ctl.sock +//! d=$(mktemp -d /tmp/rs485.XXXXXX) +//! qemu/run.sh --kernel ... --rs485 "$d/serial.sock" & +//! rs485-bridge --serial "$d/serial.sock" --control "$d/ctl.sock" use std::io::{BufRead, BufReader, Write}; use std::os::unix::net::{UnixListener, UnixStream}; @@ -31,10 +35,19 @@ fn main() { let mut args = std::env::args().skip(1); while let Some(a) = args.next() { - let mut val = |name: &str| args.next().unwrap_or_else(|| { - eprintln!("{name} needs a value"); - usage() - }); + let mut val = |name: &str| { + let v = args.next().unwrap_or_else(|| { + eprintln!("{name} needs a value"); + usage() + }); + // A following flag means the value was omitted — report the real + // problem instead of swallowing the flag as a bogus value. + if v.starts_with("--") { + eprintln!("{name} needs a value, got flag '{v}'"); + usage() + } + v + }; match a.as_str() { "--serial" => serial = Some(val("--serial")), "--control" => control = Some(val("--control")), @@ -55,14 +68,32 @@ fn main() { let bus: &'static Bus = Box::leak(Box::new(Bus::new(address, regs, bits))); if let Some(path) = control { - let _ = std::fs::remove_file(&path); // stale socket from a previous run + // Clear a stale socket from a previous run. A failure here that is not + // "nothing to remove" (e.g. someone else's file behind /tmp's sticky + // bit) will make the bind below fail — surface both errors. + let removed = std::fs::remove_file(&path); let listener = UnixListener::bind(&path).unwrap_or_else(|e| { eprintln!("FATAL: cannot bind control socket {path}: {e}"); + if let Err(re) = removed { + if re.kind() != std::io::ErrorKind::NotFound { + eprintln!(" (removing the pre-existing file also failed: {re})"); + } + } exit(1); }); eprintln!("rs485: control socket at {path}"); std::thread::spawn(move || { - for conn in listener.incoming().flatten() { + // Explicit error handling: `.flatten()` would turn a persistent + // accept() failure (fd exhaustion etc.) into a silent hot loop. + for conn in listener.incoming() { + let conn = match conn { + Ok(c) => c, + Err(e) => { + eprintln!("rs485: control accept failed: {e} — backing off"); + std::thread::sleep(Duration::from_millis(200)); + continue; + } + }; let reader = BufReader::new(conn.try_clone().expect("clone control conn")); let mut writer = conn; for line in reader.lines() { diff --git a/qemu/run.sh b/qemu/run.sh index 7d895c5..a40dde5 100755 --- a/qemu/run.sh +++ b/qemu/run.sh @@ -18,9 +18,9 @@ # --qmp SOCK QMP unix socket (screendump, input-send-event, quit) # --display MODE off (default, -nographic) | on (gtk window) | headless # (virtio-gpu without a window; screendump via --qmp) -# --ssh-port N hostfwd 127.0.0.1:N -> guest :22 (default 2222) -# --http-port N hostfwd 127.0.0.1:N -> guest :80 (default 8080) -# --api-port N hostfwd 127.0.0.1:N -> guest :28443 (default 28443) +# --ssh-port N hostfwd 127.0.0.1:N -> guest :22 (default 2222; 0 disables) +# --http-port N hostfwd 127.0.0.1:N -> guest :80 (default 8080; 0 disables) +# --api-port N hostfwd 127.0.0.1:N -> guest :28443 (default 28443; 0 disables) # --shell interactive shell in the guest instead of daemon hold set -euo pipefail @@ -76,13 +76,19 @@ fi # NOTE: never add `earlyprintk` — the config's DEBUG_UART_PHYS is the RV1106's # 0xff4c0000, which does not exist on -M virt. APPEND="console=ttyAMA0 rdinit=/init" +# Port 0 disables a forward — a boot smoke needs no host ports and must not +# fail on a busy default port. +NETDEV="user,id=n0" +[ "$SSH_PORT" != 0 ] && NETDEV="$NETDEV,hostfwd=tcp:127.0.0.1:${SSH_PORT}-:22" +[ "$HTTP_PORT" != 0 ] && NETDEV="$NETDEV,hostfwd=tcp:127.0.0.1:${HTTP_PORT}-:80" +[ "$API_PORT" != 0 ] && NETDEV="$NETDEV,hostfwd=tcp:127.0.0.1:${API_PORT}-:28443" ARGS=( -M "virt,highmem=off" -cpu cortex-a7 -smp 1 -m 256M # virtio-mmio defaults to the legacy (0.9) transport; virtio-gpu and # virtio-input are VERSION_1-only devices and never bind without this. -global "virtio-mmio.force-legacy=false" -kernel "$KERNEL" -initrd "$INITRD" - -netdev "user,id=n0,hostfwd=tcp:127.0.0.1:${SSH_PORT}-:22,hostfwd=tcp:127.0.0.1:${HTTP_PORT}-:80,hostfwd=tcp:127.0.0.1:${API_PORT}-:28443" + -netdev "$NETDEV" -device "virtio-net-device,netdev=n0" -no-reboot ) diff --git a/qemu/tests/boot-smoke.sh b/qemu/tests/boot-smoke.sh index c8ab969..15338e4 100755 --- a/qemu/tests/boot-smoke.sh +++ b/qemu/tests/boot-smoke.sh @@ -27,13 +27,15 @@ command -v qemu-system-arm >/dev/null || { LOG="$(mktemp "${TMPDIR:-/tmp}/warden-qemu-smoke.XXXXXX")" trap 'rm -f "$LOG"' EXIT -# NOTE: never pass `earlyprintk` — the config's DEBUG_UART_PHYS is the RV1106's -# 0xff4c0000, which does not exist on -M virt. -timeout 180 qemu-system-arm \ - -M virt,highmem=off -cpu cortex-a7 -smp 1 -m 256M \ - -kernel "$ZIMAGE" -initrd "$INITRD" \ - -append "console=ttyAMA0 rdinit=/init" \ - -nographic -no-reboot &2 exit 1 } diff --git a/qemu/tests/portal-scenario.sh b/qemu/tests/portal-scenario.sh index bd185be..89e101f 100755 --- a/qemu/tests/portal-scenario.sh +++ b/qemu/tests/portal-scenario.sh @@ -62,11 +62,19 @@ python3 "$FLARE_EDGE/tools/mock-flare-portal.py" \ --port "$PORT" --device "$DEVICE_ID:$API_KEY" \ --wfw "$WORK/offer.wfw" > "$WORK/mock.log" 2>&1 & MOCK_PID=$! +mock_ready=0 for _ in $(seq 1 50); do - curl -so /dev/null "http://127.0.0.1:$PORT/" && break + curl -so /dev/null "http://127.0.0.1:$PORT/" && { mock_ready=1; break; } kill -0 "$MOCK_PID" 2>/dev/null || { echo "FATAL: mock portal died:" >&2; cat "$WORK/mock.log" >&2; exit 1; } sleep 0.2 done +# The loop must not fall through silently: an alive-but-unresponsive mock +# would otherwise surface 420s later as an unrelated assertion timeout. +[ "$mock_ready" = 1 ] || { + echo "FATAL: mock portal never answered on :$PORT within 10s" >&2 + tail -10 "$WORK/mock.log" >&2 + exit 1 +} echo "== mock portal on :$PORT, device $DEVICE_ID" # 2. image seeded with the portal URL + credentials. @@ -79,11 +87,34 @@ bash "$QDIR/mkimage.sh" \ --state "flare.api_key=$API_KEY" \ --state "flare.site=qemu-devsim" -# 3. boot the VM headless (daemons run; console log to file). -bash "$QDIR/run.sh" --kernel "$ZIMAGE" \ - --ssh-port $((PORT + 1)) --http-port $((PORT + 2)) --api-port $((PORT + 3)) \ - > "$WORK/console.log" 2>&1 & -QEMU_PID=$! +# 3. boot the VM headless (daemons run; console log to file). Random hostfwd +# ports can collide with another process — detect the early qemu bind +# failure and retry with a fresh base rather than failing spuriously. +QEMU_PID="" +for _attempt in 1 2 3; do + VMBASE=$((20000 + RANDOM % 20000)) + : > "$WORK/console.log" + bash "$QDIR/run.sh" --kernel "$ZIMAGE" \ + --ssh-port "$VMBASE" --http-port $((VMBASE + 1)) --api-port $((VMBASE + 2)) \ + > "$WORK/console.log" 2>&1 & + QEMU_PID=$! + sleep 3 + if kill -0 "$QEMU_PID" 2>/dev/null; then + break + fi + if grep -aq 'Could not set up host forwarding' "$WORK/console.log"; then + echo "== hostfwd port collision on base $VMBASE — retrying" + QEMU_PID="" + continue + fi + echo "FATAL: VM died at launch:" >&2 + tail -20 "$WORK/console.log" >&2 + exit 1 +done +if [ -z "$QEMU_PID" ] || ! kill -0 "$QEMU_PID" 2>/dev/null; then + echo "FATAL: could not launch the VM after 3 port attempts" >&2 + exit 1 +fi # 4. assert: rootfs up, and the portal saw — from OUR device id — an # authenticated check-in, the firmware desired-state pull, and the signed @@ -99,6 +130,11 @@ while [ $SECONDS -lt $deadline ]; do grep -aq "GET /api/v1/devices/$DEVICE_ID/firmware -> 200" "$WORK/mock.log" && ok_fw=1 grep -aq "GET /api/v1/devices/$DEVICE_ID/firmware/assets/.* -> 200" "$WORK/mock.log" && ok_asset=1 [ $ok_report -eq 1 ] && [ $ok_fw -eq 1 ] && [ $ok_asset -eq 1 ] && break + grep -aq 'WARDEN-QEMU-MOUNT-FAILED' "$WORK/console.log" && { + echo "FATAL: guest partition mount failed (bad image?):" >&2 + grep -a 'WARDEN-QEMU-MOUNT-FAILED' "$WORK/console.log" >&2 + exit 1 + } kill -0 "$QEMU_PID" 2>/dev/null || { echo "FATAL: VM exited early" >&2; tail -30 "$WORK/console.log" >&2; exit 1; } sleep 2 done diff --git a/qemu/tests/qmp.py b/qemu/tests/qmp.py index 5c4668d..0737cfb 100755 --- a/qemu/tests/qmp.py +++ b/qemu/tests/qmp.py @@ -11,7 +11,7 @@ import sys import time -def rpc(sock, obj): +def rpc(sock, sock_file, obj): sock.sendall((json.dumps(obj) + "\n").encode()) while True: line = sock_file.readline() @@ -29,15 +29,20 @@ def main(): if len(sys.argv) < 3: sys.exit(__doc__) path, cmd = sys.argv[1], sys.argv[2] - global sock_file + need = {"screendump": 4, "tap": 5, "quit": 3} + if cmd not in need: + sys.exit(f"unknown command {cmd}\n{__doc__}") + if len(sys.argv) < need[cmd]: + sys.exit(f"{cmd}: missing argument(s)\n{__doc__}") + s = socket.socket(socket.AF_UNIX) s.connect(path) - sock_file = s.makefile("r") - sock_file.readline() # greeting banner - rpc(s, {"execute": "qmp_capabilities"}) + f = s.makefile("r") + f.readline() # greeting banner + rpc(s, f, {"execute": "qmp_capabilities"}) if cmd == "screendump": - rpc(s, {"execute": "screendump", "arguments": {"filename": sys.argv[3]}}) + rpc(s, f, {"execute": "screendump", "arguments": {"filename": sys.argv[3]}}) elif cmd == "tap": x, y = int(sys.argv[3]), int(sys.argv[4]) press = [ @@ -46,16 +51,14 @@ def main(): {"type": "btn", "data": {"down": True, "button": "left"}}, ] release = [{"type": "btn", "data": {"down": False, "button": "left"}}] - rpc(s, {"execute": "input-send-event", "arguments": {"events": press}}) + rpc(s, f, {"execute": "input-send-event", "arguments": {"events": press}}) # Hold the press across several LVGL indev poll periods (33 ms each): # an instantaneous press+release lands inside one poll and no click # is ever registered. time.sleep(0.2) - rpc(s, {"execute": "input-send-event", "arguments": {"events": release}}) + rpc(s, f, {"execute": "input-send-event", "arguments": {"events": release}}) elif cmd == "quit": s.sendall(b'{"execute":"quit"}\n') - else: - sys.exit(f"unknown command {cmd}") if __name__ == "__main__": diff --git a/qemu/tests/ui-shot.sh b/qemu/tests/ui-shot.sh index 82250cc..c58ee83 100755 --- a/qemu/tests/ui-shot.sh +++ b/qemu/tests/ui-shot.sh @@ -1,9 +1,10 @@ #!/usr/bin/env bash # Display + touch scenario: boot the VM headless with virtio-gpu, wait for the -# LVGL UI (fbdev build) to start, screendump over QMP, inject an absolute -# touch tap (virtio-tablet), screendump again. Asserts the first frame is -# non-blank; reports (does not assert) whether the tap changed pixels — the -# device-level analogue of flare-edge's Xvfb/xdotool sim-test.sh. +# LVGL UI (fbdev build) to render a real frame, then inject an absolute touch +# tap on the Metrics tab (virtio-tablet) and ASSERT the frame changed — the +# device-level analogue of flare-edge's Xvfb/xdotool sim-test.sh. Readiness is +# polled from screendumps on bounded deadlines, never guessed with fixed +# sleeps: TCG renders CPU-bound and a loaded host can be arbitrarily slow. # # FAILS CLOSED on missing prerequisites. # @@ -40,11 +41,30 @@ trap cleanup EXIT bash "$QDIR/mkinitramfs.sh" bash "$QDIR/mkimage.sh" -PORT=$((21000 + RANDOM % 20000)) -bash "$QDIR/run.sh" --kernel "$ZIMAGE" --display headless --qmp "$WORK/qmp.sock" \ - --ssh-port "$PORT" --http-port $((PORT + 1)) --api-port $((PORT + 2)) \ - > "$WORK/console.log" 2>&1 & -QEMU_PID=$! +# Random hostfwd ports can collide — detect qemu's early bind failure and +# retry with a fresh base rather than failing spuriously. +for _attempt in 1 2 3; do + PORT=$((21000 + RANDOM % 20000)) + : > "$WORK/console.log" + bash "$QDIR/run.sh" --kernel "$ZIMAGE" --display headless --qmp "$WORK/qmp.sock" \ + --ssh-port "$PORT" --http-port $((PORT + 1)) --api-port $((PORT + 2)) \ + > "$WORK/console.log" 2>&1 & + QEMU_PID=$! + sleep 3 + kill -0 "$QEMU_PID" 2>/dev/null && break + if grep -aq 'Could not set up host forwarding' "$WORK/console.log"; then + echo "== hostfwd port collision on base $PORT — retrying" + QEMU_PID="" + continue + fi + echo "FATAL: VM died at launch:" >&2 + tail -20 "$WORK/console.log" >&2 + exit 1 +done +if [ -z "$QEMU_PID" ] || ! kill -0 "$QEMU_PID" 2>/dev/null; then + echo "FATAL: could not launch the VM after 3 port attempts" >&2 + exit 1 +fi deadline=$((SECONDS + 120)) while [ $SECONDS -lt $deadline ]; do @@ -57,36 +77,55 @@ grep -aq 'init: starting warden-ui' "$WORK/console.log" || { tail -25 "$WORK/console.log" >&2 exit 1 } -sleep 8 # let LVGL render the first frames qmp() { python3 "$HERE/qmp.py" "$WORK/qmp.sock" "$@"; } -qmp screendump "$WORK/shot1.ppm" +# Frame is "real" once it has more than a handful of distinct colors (a blank +# or console-only frame has very few). +frame_rendered() { # $1 = ppm path + python3 - "$1" <<'EOF' +import sys +data = open(sys.argv[1], "rb").read() +parts = data.split(b"\n", 3) # P6 header: magic, dims, maxval, raw RGB +pixels = parts[3] if len(parts) == 4 else b"" +distinct = len(set(pixels[i:i+3] for i in range(0, min(len(pixels), 3*720*720), 3))) +print(f"{sys.argv[1]}: {len(pixels)} bytes, {distinct} distinct colors") +sys.exit(0 if distinct > 16 else 1) +EOF +} + +# Poll for the first rendered frame (bounded, no guessed sleep). +rendered=0 +deadline=$((SECONDS + 90)) +while [ $SECONDS -lt $deadline ]; do + qmp screendump "$WORK/shot1.ppm" + if frame_rendered "$WORK/shot1.ppm"; then rendered=1; break; fi + sleep 3 +done +[ "$rendered" = 1 ] || { + echo "FATAL: UI never rendered a non-blank frame within 90s" >&2 + exit 1 +} + # Tap the "Metrics" tab: pixel (373,40) of 720x720 scaled to the QMP absolute -# range 0..32767 — switching tabs must repaint the content area. +# range 0..32767 — switching tabs must repaint the content area. Poll for the +# repaint rather than guessing a delay. qmp tap 16975 1820 -sleep 3 -qmp screendump "$WORK/shot2.ppm" +changed=0 +deadline=$((SECONDS + 30)) +while [ $SECONDS -lt $deadline ]; do + sleep 2 + qmp screendump "$WORK/shot2.ppm" + if ! cmp -s "$WORK/shot1.ppm" "$WORK/shot2.ppm"; then changed=1; break; fi +done mkdir -p "$OUTDIR" cp "$WORK/shot1.ppm" "$OUTDIR/ui-shot1.ppm" -cp "$WORK/shot2.ppm" "$OUTDIR/ui-shot2.ppm" +cp "$WORK/shot2.ppm" "$OUTDIR/ui-shot2.ppm" 2>/dev/null || true -# Non-blank: more than one distinct pixel value in the raw PPM payload. -python3 - "$WORK/shot1.ppm" <<'EOF' -import sys -data = open(sys.argv[1], "rb").read() -# P6 header: magic, dims, maxval, then raw RGB -parts = data.split(b"\n", 3) -pixels = parts[3] if len(parts) == 4 else b"" -distinct = len(set(pixels[i:i+3] for i in range(0, min(len(pixels), 3*720*720), 3))) -print(f"shot1: {len(pixels)} bytes of pixels, {distinct} distinct colors") -sys.exit(0 if distinct > 1 else 1) -EOF - -if cmp -s "$WORK/shot1.ppm" "$WORK/shot2.ppm"; then - echo "FATAL: tapping the Metrics tab did not change the frame — touch is not reaching the UI" >&2 +[ "$changed" = 1 ] || { + echo "FATAL: tapping the Metrics tab did not change the frame within 30s — touch is not reaching the UI" >&2 exit 1 -fi +} echo "tap on the Metrics tab repainted the frame (touch reached the UI)" echo "UI-SHOT-PASS (screenshots in $OUTDIR/ui-shot{1,2}.ppm)"