review: iteration-1 fixes across CI, bridge, VM harness, and docs

CI/pipeline:
- KERNEL_TARBALL passed as a YAML env literal '~' was never tilde-expanded
  and would have failed every hosted kernel-build dispatch; the path is now
  exported from the shell. Verified reproducible before the fix.
- Every job gets timeout-minutes; boot smoke uses timeout -k so a wedged
  qemu is SIGKILLed instead of holding the job.
- Tarball fetch + fail-closed sha256 verification deduplicated into
  build/fetch-kernel-tarball.sh (with curl retries), used by build-kernel.sh
  and both CI jobs. busybox fetch gains retries too.
- ccache layer for kernel-build (cache keyed on defconfig+patches) recovers
  the incremental-compile speed the ephemeral-runner move cost.
- build-kernel.sh now asserts every fragment option survived olddefconfig —
  merge_config -m pastes text and Kconfig silently drops unmet symbols.

rs485-bridge:
- pending-buffer cap (2x max RTU ADU) instead of unbounded growth;
  explicit accept-loop error handling with backoff instead of .flatten();
  per-arm inline bounds instead of the string-keyed lookup whose default
  would have mis-bounded a future get-input; control-socket cleanup errors
  surfaced; flag-shaped values rejected in arg parsing; doc example uses a
  private mktemp dir. Test timing margins widened for contended runners
  (gap 25->120ms, 60x margin on the split-frame test).

VM harness:
- stage-1/stage-2 boot scripts share one validated slot parser and one
  by-name populator (qemu/rootfs/etc/warden-lib.sh) — the duplicated
  parser had already diverged on validation; userdata/oem mount failures
  now fail fast with a greppable sentinel; udhcpc fallback keys off the
  interface actually having an address; switch_root applet guarded.
- boot-smoke delegates the qemu invocation to run.sh (machine shape lives
  in ONE place); run.sh port 0 disables a hostfwd.
- mkimage: unknown partition names fail at build time; DISK_END is a max,
  not last-entry; --state keys validated as filenames.
- portal-scenario: mock readiness is asserted (no silent fall-through),
  hostfwd port collisions retried, mount-failure sentinel fails fast.
- ui-shot: fixed sleeps replaced with bounded screendump polling; the
  repaint assertion is real and documented as such. qmp.py loses its
  module-global and gains argv validation.

Docs/scrub: bench-host paths and the site AP name removed from six more
port docs and two evidence tables; path-bearing build artifacts (.elf,
.map) untracked (the 154-byte firmware .bin is path-free and stays);
ADR-0003 marked visibility-superseded by ADR-0007; stale section
cross-reference fixed; flare-edge noted as private for outside readers;
stale root-level review report removed per the new workspace rule.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018HUayid7W5w7jBdb9Rrj1K
This commit is contained in:
BFE Engineering
2026-08-30 08:19:17 -06:00
co-authored by Claude Fable 5
parent b667ff5b1e
commit 2756de0b46
24 changed files with 481 additions and 203 deletions
+27 -12
View File
@@ -25,6 +25,7 @@ permissions:
jobs: jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 25
outputs: outputs:
passed: ${{ steps.result.outputs.passed }} passed: ${{ steps.result.outputs.passed }}
coverage: ${{ steps.result.outputs.coverage }} coverage: ${{ steps.result.outputs.coverage }}
@@ -58,6 +59,7 @@ jobs:
mcdc: mcdc:
# 100% MC/DC (condition coverage) enforced on every Tier-1 driver harness. # 100% MC/DC (condition coverage) enforced on every Tier-1 driver harness.
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 15
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: install gcc-14 - name: install gcc-14
@@ -78,6 +80,7 @@ jobs:
# Smoke-run the sim micro-benchmarks and emit the ns/op trend JSON. Regression # 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). # gating against stored history is future work (no flare-edge pattern to copy).
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 20
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: run sim benchmarks - name: run sim benchmarks
@@ -99,6 +102,7 @@ jobs:
# fail-closed sha), and build the A/B disk image (unprivileged mkfs -d). # 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. # Booting needs a zImage and therefore lives in kernel-build's smoke step.
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 20
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: shellcheck qemu scripts - name: shellcheck qemu scripts
@@ -118,6 +122,7 @@ jobs:
patches-apply: patches-apply:
# The RV1106 series must apply cleanly onto pristine linux-6.18.46. # The RV1106 series must apply cleanly onto pristine linux-6.18.46.
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 20
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: cache pristine kernel tarball - name: cache pristine kernel tarball
@@ -125,11 +130,8 @@ jobs:
with: with:
path: ~/linux-6.18.46.tar.xz path: ~/linux-6.18.46.tar.xz
key: linux-6.18.46-tarball key: linux-6.18.46-tarball
- name: fetch + verify pristine - name: fetch + verify pristine (shared fail-closed fetcher)
run: | run: bash build/fetch-kernel-tarball.sh "$HOME/linux-6.18.46.tar.xz"
[ -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: apply the series in order - name: apply the series in order
run: | run: |
tar -C /tmp -xf ~/linux-6.18.46.tar.xz tar -C /tmp -xf ~/linux-6.18.46.tar.xz
@@ -148,6 +150,7 @@ jobs:
prune-artifacts: prune-artifacts:
if: github.event_name == 'workflow_dispatch' if: github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10
permissions: permissions:
actions: write actions: write
steps: steps:
@@ -178,13 +181,14 @@ jobs:
if: github.event_name == 'workflow_dispatch' if: github.event_name == 'workflow_dispatch'
needs: [prune-artifacts] needs: [prune-artifacts]
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 60
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: install cross toolchain + kernel build deps + qemu - name: install cross toolchain + kernel build deps + qemu
run: | run: |
sudo apt-get update -qq sudo apt-get update -qq
sudo apt-get install -y -qq gcc-arm-linux-gnueabihf qemu-system-arm \ 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) - name: provision `python` (SDK quirk — build calls bare python)
run: | run: |
mkdir -p "$RUNNER_TEMP/bin" mkdir -p "$RUNNER_TEMP/bin"
@@ -195,22 +199,32 @@ jobs:
with: with:
path: ~/linux-6.18.46.tar.xz path: ~/linux-6.18.46.tar.xz
key: linux-6.18.46-tarball 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 - name: build zImage + rv1106-warden.dtb
env: env:
# WORK must be OUTSIDE the repo checkout: build-kernel.sh applies the patch # 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 # 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. # run inside another git repo (issue #1). $RUNNER_TEMP is outside the checkout.
WORK: ${{ runner.temp }}/kbuild-out 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. # The kernel is freestanding; the generic arm cross toolchain links it.
CROSS_COMPILE: arm-linux-gnueabihf- 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: | run: |
[ -f ~/linux-6.18.46.tar.xz ] || \ export KERNEL_TARBALL="$HOME/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
bash build/build-kernel.sh bash build/build-kernel.sh
ccache -s | head -4
# Boot smoke under QEMU: the zImage this job just built must reach the # Boot smoke under QEMU: the zImage this job just built must reach the
# initramfs sentinel on -M virt (verified 2026-08-29: the canonical # initramfs sentinel on -M virt (verified 2026-08-29: the canonical
# config boots virt as-is). FAIL-CLOSED on a missing qemu-system-arm. # config boots virt as-is). FAIL-CLOSED on a missing qemu-system-arm.
@@ -239,6 +253,7 @@ jobs:
needs: [test] needs: [test]
if: github.event_name == 'push' && github.ref == 'refs/heads/main' if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 15
permissions: permissions:
contents: write contents: write
steps: steps:
+1 -1
View File
@@ -14,7 +14,7 @@ target/
__pycache__/ __pycache__/
*.pyc *.pyc
# local tooling state (code-review harness cross-session memory, etc.) # local tooling state
.claude/ .claude/
# driver MC/DC harness build dirs # driver MC/DC harness build dirs
+3 -1
View File
@@ -121,7 +121,9 @@ Evaluated against the stack philosophy — **openness, hardness, modernness**:
## Relationship to flare-edge ## Relationship to flare-edge
flare-edge (WardenOS: the LVGL UI + the `flared` daemon) is the product; warden-sdk 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. 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. No flare-edge code moves here — only the SDK/build/sim/driver-seam layer.
+40 -19
View File
@@ -14,7 +14,9 @@
# JOBS parallel make jobs (default: nproc) # JOBS parallel make jobs (default: nproc)
# WARDEN_KCONFIG_FRAGMENT # WARDEN_KCONFIG_FRAGMENT
# optional kconfig fragment merged onto warden_defconfig # 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 # 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. # 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/ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # build/
REPO="$(cd "$HERE/.." && pwd)" REPO="$(cd "$HERE/.." && pwd)"
PATCHES="$REPO/patches" PATCHES="$REPO/patches"
SHA_FILE="$HERE/linux-$KVER.tar.xz.sha256"
JOBS="${JOBS:-$(nproc)}" 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 # 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 # 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 # 1. obtain + verify the pristine tarball
mkdir -p "$WORK" mkdir -p "$WORK"
TB="${KERNEL_TARBALL:-$WORK/linux-$KVER.tar.xz}" TB="${KERNEL_TARBALL:-$WORK/linux-$KVER.tar.xz}"
if [ ! -f "$TB" ]; then # Fetch + fail-closed sha256 verification live in ONE place shared with CI
log "downloading $URL" # (a missing pin or a mismatch always refuses to build).
curl -fSL "$URL" -o "$TB" bash "$HERE/fetch-kernel-tarball.sh" "$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"
# 2. extract pristine # 2. extract pristine
SRC="$WORK/linux-$KVER" 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; } || { 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 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 # 5. build zImage + the board dtb
log "building zImage + rv1106-warden.dtb (-j$JOBS)" 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 zImage rockchip/rv1106-warden.dtb
Z="$SRC/arch/arm/boot/zImage" Z="$SRC/arch/arm/boot/zImage"
+28
View File
@@ -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 <destination-path>
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 <destination-path>}"
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"
+1 -1
View File
@@ -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 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 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 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`. flare-edge `main`.
- **`modbus` — RS-485 device end.** Done. `ModbusSlave`: a byte-in/byte-out RTU slave - **`modbus` — RS-485 device end.** Done. `ModbusSlave`: a byte-in/byte-out RTU slave
(CRC16 byte-identical to the master, FC 0x010x06/0x0F/0x10/0x11, exception replies, (CRC16 byte-identical to the master, FC 0x010x06/0x0F/0x10/0x11, exception replies,
+2 -2
View File
@@ -2,8 +2,8 @@
`.github/workflows/ci.yml` — every job runs on GitHub-hosted `ubuntu-latest`. `.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: 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 the repo is going public, and a fork PR that gets one approved run could
execute code on private infrastructure (ADR-0007). otherwise execute code on private infrastructure (ADR-0007).
## Jobs ## Jobs
+4 -2
View File
@@ -1,6 +1,8 @@
# ADR 0003 — warden-sdk is a standalone repo # 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 ## Context
Our real SDK changes lived as uncommitted edits in a 2GB opaque vendor fork, with 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 ## Decision
A **private** `bfe-noah/warden-sdk` GitHub repo, standalone from day one with its own 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 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 ## Consequences
- flare-edge consumes warden-sdk later (flared depending on `warden-sim`, drivers - flare-edge consumes warden-sdk later (flared depending on `warden-sim`, drivers
+1 -1
View File
@@ -8,7 +8,7 @@
# Defaults match this workspace. # Defaults match this workspace.
set -euo pipefail set -euo pipefail
FE="${FE:-<flare-edge>}" FE="${FE:?set FE to a flare-edge checkout path}"
KTREE="${KTREE:-$FE/research/linux-6.18.46}" KTREE="${KTREE:-$FE/research/linux-6.18.46}"
SDK_TC="${SDK_TC:-$FE/sdk/tools/linux/toolchain/arm-rockchip830-linux-uclibcgnueabihf/bin}" SDK_TC="${SDK_TC:-$FE/sdk/tools/linux/toolchain/arm-rockchip830-linux-uclibcgnueabihf/bin}"
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -1,7 +1,8 @@
# HPMCU mailbox-echo firmware — bare-metal RV32IMC for the RV1106 SCR1 core. # 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. # Same xPack riscv-none-embed-gcc 10.2.0 + flags as the watchdog firmware.
XPACK ?= <flare-edge>/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- CROSS ?= $(XPACK)/riscv-none-embed-
CC = $(CROSS)gcc CC = $(CROSS)gcc
+2 -1
View File
@@ -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 canvas + scanout mirror at 720×720×4B ≈ 2 MiB each); carry the same size unless a
future accounting shows it's tight. future accounting shows it's tight.
- **Driver-parity table** (`../DRIVER-PARITY.md`) should move `RGA 2D (rga2)` from [ ] to - **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 ## Sources
+2 -1
View File
@@ -89,7 +89,8 @@ stage-2 init when present.
- A serial port that is closed discards incoming bytes: hold ONE fd open - 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. 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 - `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. - Never pass `earlyprintk`: DEBUG_UART_PHYS is the RV1106's 0xff4c0000.
## Host requirements ## Host requirements
+1 -1
View File
@@ -18,7 +18,7 @@ qemu_get_busybox() {
BB="${BUSYBOX:-$out/busybox-armv7l}" BB="${BUSYBOX:-$out/busybox-armv7l}"
if [ ! -f "$BB" ]; then if [ ! -f "$BB" ]; then
qemu_log "downloading $BB_URL" qemu_log "downloading $BB_URL"
curl -fSL "$BB_URL" -o "$BB" curl --retry 3 --retry-delay 5 -fSL "$BB_URL" -o "$BB"
fi fi
[ -f "$sha_file" ] || { [ -f "$sha_file" ] || {
echo "FATAL: no pinned sha256 for busybox (expected $sha_file) — refusing to build from an unverified binary" >&2 echo "FATAL: no pinned sha256 for busybox (expected $sha_file) — refusing to build from an unverified binary" >&2
+18 -3
View File
@@ -30,7 +30,17 @@ FW_VERSION="0.0.1"
while [ $# -gt 0 ]; do while [ $# -gt 0 ]; do
case "$1" in case "$1" in
--portal-url) PORTAL_URL="${2:?--portal-url needs a value}"; shift 2 ;; --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 ;; --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 ;; *) echo "FATAL: unknown argument '$1' (usage: mkimage.sh [--portal-url URL] [--state KEY=VALUE]... [--fw-version V])" >&2; exit 1 ;;
esac esac
@@ -83,7 +93,11 @@ place_partition() {
rootfs_a|rootfs_b) stage="$ROOT" ;; rootfs_a|rootfs_b) stage="$ROOT" ;;
userdata) stage="$UDATA" ;; userdata) stage="$UDATA" ;;
oem_a|oem_b) stage="$SCRATCH/empty" ;; 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 esac
# dd in 4K blocks — every offset in the canonical layout is 4K-aligned; # dd in 4K blocks — every offset in the canonical layout is 4K-aligned;
# assert rather than assume, a misaligned write would corrupt a neighbor. # 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 echo "FATAL: partition $name not 4K-aligned (off=$off size=$size)" >&2
exit 1 exit 1
fi 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 [ -z "$stage" ] && return 0
local img="$SCRATCH/$name.img" local img="$SCRATCH/$name.img"
mkfs_part "$stage" "$size" "$img" mkfs_part "$stage" "$size" "$img"
+17 -30
View File
@@ -1,37 +1,19 @@
#!/bin/busybox sh #!/bin/busybox sh
# Stage-1 rc: sourced by /init (still PID 1, initramfs root) when a virtio # 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 # disk is present. Emulates U-Boot's slot choice — mount the validated slot's
# cmdline, mount that rootfs, switch_root into it. This is an EMULATION of the # rootfs and switch_root into it. This is an EMULATION of the A/B selection
# A/B selection outcome, not the BCB/bootcount mechanism itself. # 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/<PARTNAME> symlinks: the contract flare-edge slotctl.rs . /etc/warden-lib.sh
# 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
# Slot select: whole-token parse of warden.slot= (never a substring match). warden_populate_by_name
slot="_a" slot="$(warden_slot)"
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
root="/dev/block/by-name/rootfs${slot}" root="/dev/block/by-name/rootfs${slot}"
if [ ! -e "$root" ]; then if [ ! -e "$root" ]; then
@@ -49,6 +31,11 @@ if [ ! -x /mnt/sbin/init ]; then
umount /mnt umount /mnt
return 0 return 0
fi 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)" echo "rc: switching root to rootfs${slot} ($root)"
exec switch_root /mnt /sbin/init exec switch_root /mnt /sbin/init
+42
View File
@@ -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/<PARTNAME> 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"
}
+24 -25
View File
@@ -13,33 +13,29 @@ mount -t proc proc /proc
mount -t sysfs sysfs /sys mount -t sysfs sysfs /sys
mount -t tmpfs tmpfs /tmp mount -t tmpfs tmpfs /tmp
# Fresh devtmpfs — repopulate the by-name contract (slotctl.rs depends on it). . /etc/warden-lib.sh
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
# Slot (whole-token parse, same rule as stage 1). # Fresh devtmpfs — repopulate the by-name contract; same VALIDATED slot rule
slot="_a" # as stage 1 (shared helper, so the two can never drift).
for tok in $(cat /proc/cmdline); do warden_populate_by_name
case "$tok" in slot="$(warden_slot)"
warden.slot=*) slot="${tok#warden.slot=}" ;;
esac
done
# The device's matched mounts: persistent state and the slot's oem partition. # 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" # Fail-fast: a scenario against an image whose userdata cannot mount would
mount -t ext4 "/dev/block/by-name/oem${slot}" /oem || echo "init: oem${slot} mount failed" # 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 mkdir -p /userdata/warden
# RS485: warden-modbus hardcodes /dev/ttyS4 at compile time; alias it to the # 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 [ -c /dev/ttyS0 ] && ln -sf /dev/ttyS0 /dev/ttyS4
# Network: slirp user-mode net on eth0 (DHCP, fallback to QEMU's static map). # 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 ip link set lo up
if [ -e /sys/class/net/eth0 ]; then if [ -e /sys/class/net/eth0 ]; then
ip link set eth0 up 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 addr add 10.0.2.15/24 dev eth0 2>/dev/null
ip route replace default via 10.0.2.2 dev eth0 ip route replace default via 10.0.2.2 dev eth0
echo "nameserver 10.0.2.3" > /etc/resolv.conf echo "nameserver 10.0.2.3" > /etc/resolv.conf
+81 -34
View File
@@ -24,6 +24,11 @@ use warden_sim::ModbusSlave;
/// response timeout is orders of magnitude larger. /// response timeout is orders of magnitude larger.
pub const DEFAULT_GAP: Duration = Duration::from_millis(10); 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 /// The shared bus: the slave plus its declared dimensions. The sim's register
/// setters panic on out-of-range indices (deliberate test-harness semantics); /// 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 /// the control channel must bounds-check first so a typo in a scenario script
@@ -64,7 +69,17 @@ pub fn pump_serial(
} }
return Ok(()); 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) Err(e)
if e.kind() == std::io::ErrorKind::WouldBlock if e.kind() == std::io::ErrorKind::WouldBlock
|| e.kind() == std::io::ErrorKind::TimedOut => || 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() { if words.next().is_some() {
return format!("err trailing arguments after '{cmd}'"); return format!("err trailing arguments after '{cmd}'");
} }
let bound = |cmd: &str| match cmd { // Each arm states its own bound (bus.regs for register space, bus.bits for
"holding" | "input" | "get-holding" => bus.regs, // bit space) INLINE — a previous string-keyed lookup defaulted silently to
_ => bus.bits, // 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(); let mut s = bus.slave.lock().unwrap();
match (cmd, arg) { match (cmd, arg) {
("ping", None) => "ok".into(), ("ping", None) => "ok".into(),
@@ -152,39 +167,63 @@ pub fn handle_control_line(line: &str, bus: &Bus) -> String {
} }
_ => format!("err bad exception code '{c}'"), _ => format!("err bad exception code '{c}'"),
}, },
("holding" | "input" | "coil" | "discrete", Some(kv)) => { ("holding" | "input", Some(kv)) => match parse_addr_val(kv, bus.regs) {
let (addr, val) = match kv.split_once('=') { Ok((a, v)) => {
Some((a, v)) => (parse_u16(a), parse_u16(v)), if cmd == "holding" {
None => (None, None), s.set_holding(a, v);
}; } else {
match (addr, val) { s.set_input(a, v);
(Some(a), _) if (a as usize) >= bound(cmd) => {
format!("err address {a} out of range (0..{})", bound(cmd))
}
(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()
} }
_ => format!("err expected <addr>=<value>, got '{kv}'"), 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);
} }
"ok".into()
} }
("get-holding" | "get-coil", Some(a)) => match parse_u16(a) { Err(e) => e,
Some(a) if (a as usize) >= bound(cmd) => { },
format!("err address {a} out of range (0..{})", bound(cmd)) ("get-holding", Some(a)) => match parse_addr(a, bus.regs) {
} Ok(a) => format!("ok {}", s.holding(a)),
Some(a) if cmd == "get-holding" => format!("ok {}", s.holding(a as usize)), Err(e) => e,
Some(a) => format!("ok {}", u8::from(s.coil(a as usize))), },
None => format!("err bad address '{a}'"), ("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}'"), _ => format!("err unknown or malformed command '{line}'"),
} }
} }
/// Parse "<addr>=<value>" 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 <addr>=<value>, got '{kv}'"));
};
let (Some(a), Some(v)) = (parse_u16(a), parse_u16(v)) else {
return Err(format!("err expected <addr>=<value>, 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<usize, String> {
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<u16> { fn parse_u16(s: &str) -> Option<u16> {
if let Some(h) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) { if let Some(h) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) {
u16::from_str_radix(h, 16).ok() u16::from_str_radix(h, 16).ok()
@@ -200,10 +239,13 @@ mod tests {
use std::time::Duration; use std::time::Duration;
use warden_sim::modbus::{crc_ok, read_holding}; use warden_sim::modbus::{crc_ok, read_holding};
// Test gap is larger than DEFAULT_GAP so a loaded CI runner cannot split // Test gap is much larger than DEFAULT_GAP so a loaded CI runner cannot
// a frame that the test wrote in two deliberate chunks. // split a frame the test wrote in two deliberate chunks: the 2ms
const GAP: Duration = Duration::from_millis(25); // inter-chunk pause has a 60x margin against the 120ms dispatch gap
const SETTLE: Duration = Duration::from_millis(100); // (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 { fn bus() -> Bus {
let b = Bus::new(1, 16, 16); let b = Bus::new(1, 16, 16);
@@ -221,7 +263,9 @@ mod tests {
} }
fn read_reply(master: &UnixStream) -> Vec<u8> { fn read_reply(master: &UnixStream) -> Vec<u8> {
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 mut buf = [0u8; 256];
let n = (&*master).read(&mut buf).expect("expected a reply frame"); let n = (&*master).read(&mut buf).expect("expected a reply frame");
buf[..n].to_vec() buf[..n].to_vec()
@@ -274,7 +318,10 @@ mod tests {
with_pump(&s, |master| { with_pump(&s, |master| {
(&*master).write_all(&read_holding(1, 2, 1)).unwrap(); (&*master).write_all(&read_holding(1, 2, 1)).unwrap();
master 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(); .unwrap();
let mut buf = [0u8; 16]; let mut buf = [0u8; 16];
assert!( assert!(
+37 -6
View File
@@ -2,10 +2,14 @@
//! there); this file only parses arguments, connects sockets, and spawns the //! there); this file only parses arguments, connects sockets, and spawns the
//! control listener. //! 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 & //! d=$(mktemp -d /tmp/rs485.XXXXXX)
//! rs485-bridge --serial /tmp/warden-rs485.sock --control /tmp/warden-rs485-ctl.sock //! 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::io::{BufRead, BufReader, Write};
use std::os::unix::net::{UnixListener, UnixStream}; use std::os::unix::net::{UnixListener, UnixStream};
@@ -31,10 +35,19 @@ fn main() {
let mut args = std::env::args().skip(1); let mut args = std::env::args().skip(1);
while let Some(a) = args.next() { while let Some(a) = args.next() {
let mut val = |name: &str| args.next().unwrap_or_else(|| { let mut val = |name: &str| {
let v = args.next().unwrap_or_else(|| {
eprintln!("{name} needs a value"); eprintln!("{name} needs a value");
usage() 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() { match a.as_str() {
"--serial" => serial = Some(val("--serial")), "--serial" => serial = Some(val("--serial")),
"--control" => control = Some(val("--control")), "--control" => control = Some(val("--control")),
@@ -55,14 +68,32 @@ fn main() {
let bus: &'static Bus = Box::leak(Box::new(Bus::new(address, regs, bits))); let bus: &'static Bus = Box::leak(Box::new(Bus::new(address, regs, bits)));
if let Some(path) = control { 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| { let listener = UnixListener::bind(&path).unwrap_or_else(|e| {
eprintln!("FATAL: cannot bind control socket {path}: {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); exit(1);
}); });
eprintln!("rs485: control socket at {path}"); eprintln!("rs485: control socket at {path}");
std::thread::spawn(move || { 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 reader = BufReader::new(conn.try_clone().expect("clone control conn"));
let mut writer = conn; let mut writer = conn;
for line in reader.lines() { for line in reader.lines() {
+10 -4
View File
@@ -18,9 +18,9 @@
# --qmp SOCK QMP unix socket (screendump, input-send-event, quit) # --qmp SOCK QMP unix socket (screendump, input-send-event, quit)
# --display MODE off (default, -nographic) | on (gtk window) | headless # --display MODE off (default, -nographic) | on (gtk window) | headless
# (virtio-gpu without a window; screendump via --qmp) # (virtio-gpu without a window; screendump via --qmp)
# --ssh-port N hostfwd 127.0.0.1:N -> guest :22 (default 2222) # --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) # --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) # --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 # --shell interactive shell in the guest instead of daemon hold
set -euo pipefail set -euo pipefail
@@ -76,13 +76,19 @@ fi
# NOTE: never add `earlyprintk` — the config's DEBUG_UART_PHYS is the RV1106's # NOTE: never add `earlyprintk` — the config's DEBUG_UART_PHYS is the RV1106's
# 0xff4c0000, which does not exist on -M virt. # 0xff4c0000, which does not exist on -M virt.
APPEND="console=ttyAMA0 rdinit=/init" 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=( ARGS=(
-M "virt,highmem=off" -cpu cortex-a7 -smp 1 -m 256M -M "virt,highmem=off" -cpu cortex-a7 -smp 1 -m 256M
# virtio-mmio defaults to the legacy (0.9) transport; virtio-gpu and # virtio-mmio defaults to the legacy (0.9) transport; virtio-gpu and
# virtio-input are VERSION_1-only devices and never bind without this. # virtio-input are VERSION_1-only devices and never bind without this.
-global "virtio-mmio.force-legacy=false" -global "virtio-mmio.force-legacy=false"
-kernel "$KERNEL" -initrd "$INITRD" -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" -device "virtio-net-device,netdev=n0"
-no-reboot -no-reboot
) )
+9 -7
View File
@@ -27,13 +27,15 @@ command -v qemu-system-arm >/dev/null || {
LOG="$(mktemp "${TMPDIR:-/tmp}/warden-qemu-smoke.XXXXXX")" LOG="$(mktemp "${TMPDIR:-/tmp}/warden-qemu-smoke.XXXXXX")"
trap 'rm -f "$LOG"' EXIT trap 'rm -f "$LOG"' EXIT
# NOTE: never pass `earlyprintk` — the config's DEBUG_UART_PHYS is the RV1106's # Delegate the qemu invocation to run.sh (--no-disk) so the machine shape
# 0xff4c0000, which does not exist on -M virt. # (-M virt,highmem=off, cpu, memory, virtio topology) lives in exactly one
timeout 180 qemu-system-arm \ # place — the two hand-copied invocations had already drifted once.
-M virt,highmem=off -cpu cortex-a7 -smp 1 -m 256M \ # timeout -k: a wedged qemu that ignores SIGTERM gets SIGKILLed 10s later
-kernel "$ZIMAGE" -initrd "$INITRD" \ # instead of holding the job until the workflow-level timeout.
-append "console=ttyAMA0 rdinit=/init" \ timeout -k 10 180 bash "$QDIR/run.sh" \
-nographic -no-reboot </dev/null | tee "$LOG" || { --kernel "$ZIMAGE" --initrd "$INITRD" --no-disk \
--ssh-port 0 --http-port 0 --api-port 0 \
</dev/null | tee "$LOG" || {
echo "FATAL: qemu exited non-zero (or hung until the 180s timeout)" >&2 echo "FATAL: qemu exited non-zero (or hung until the 180s timeout)" >&2
exit 1 exit 1
} }
+39 -3
View File
@@ -62,11 +62,19 @@ python3 "$FLARE_EDGE/tools/mock-flare-portal.py" \
--port "$PORT" --device "$DEVICE_ID:$API_KEY" \ --port "$PORT" --device "$DEVICE_ID:$API_KEY" \
--wfw "$WORK/offer.wfw" > "$WORK/mock.log" 2>&1 & --wfw "$WORK/offer.wfw" > "$WORK/mock.log" 2>&1 &
MOCK_PID=$! MOCK_PID=$!
mock_ready=0
for _ in $(seq 1 50); do 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; } kill -0 "$MOCK_PID" 2>/dev/null || { echo "FATAL: mock portal died:" >&2; cat "$WORK/mock.log" >&2; exit 1; }
sleep 0.2 sleep 0.2
done 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" echo "== mock portal on :$PORT, device $DEVICE_ID"
# 2. image seeded with the portal URL + credentials. # 2. image seeded with the portal URL + credentials.
@@ -79,11 +87,34 @@ bash "$QDIR/mkimage.sh" \
--state "flare.api_key=$API_KEY" \ --state "flare.api_key=$API_KEY" \
--state "flare.site=qemu-devsim" --state "flare.site=qemu-devsim"
# 3. boot the VM headless (daemons run; console log to file). # 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" \ bash "$QDIR/run.sh" --kernel "$ZIMAGE" \
--ssh-port $((PORT + 1)) --http-port $((PORT + 2)) --api-port $((PORT + 3)) \ --ssh-port "$VMBASE" --http-port $((VMBASE + 1)) --api-port $((VMBASE + 2)) \
> "$WORK/console.log" 2>&1 & > "$WORK/console.log" 2>&1 &
QEMU_PID=$! 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 # 4. assert: rootfs up, and the portal saw — from OUR device id — an
# authenticated check-in, the firmware desired-state pull, and the signed # 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 -> 200" "$WORK/mock.log" && ok_fw=1
grep -aq "GET /api/v1/devices/$DEVICE_ID/firmware/assets/.* -> 200" "$WORK/mock.log" && ok_asset=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 [ $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; } kill -0 "$QEMU_PID" 2>/dev/null || { echo "FATAL: VM exited early" >&2; tail -30 "$WORK/console.log" >&2; exit 1; }
sleep 2 sleep 2
done done
+13 -10
View File
@@ -11,7 +11,7 @@ import sys
import time import time
def rpc(sock, obj): def rpc(sock, sock_file, obj):
sock.sendall((json.dumps(obj) + "\n").encode()) sock.sendall((json.dumps(obj) + "\n").encode())
while True: while True:
line = sock_file.readline() line = sock_file.readline()
@@ -29,15 +29,20 @@ def main():
if len(sys.argv) < 3: if len(sys.argv) < 3:
sys.exit(__doc__) sys.exit(__doc__)
path, cmd = sys.argv[1], sys.argv[2] 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 = socket.socket(socket.AF_UNIX)
s.connect(path) s.connect(path)
sock_file = s.makefile("r") f = s.makefile("r")
sock_file.readline() # greeting banner f.readline() # greeting banner
rpc(s, {"execute": "qmp_capabilities"}) rpc(s, f, {"execute": "qmp_capabilities"})
if cmd == "screendump": 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": elif cmd == "tap":
x, y = int(sys.argv[3]), int(sys.argv[4]) x, y = int(sys.argv[3]), int(sys.argv[4])
press = [ press = [
@@ -46,16 +51,14 @@ def main():
{"type": "btn", "data": {"down": True, "button": "left"}}, {"type": "btn", "data": {"down": True, "button": "left"}},
] ]
release = [{"type": "btn", "data": {"down": False, "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): # Hold the press across several LVGL indev poll periods (33 ms each):
# an instantaneous press+release lands inside one poll and no click # an instantaneous press+release lands inside one poll and no click
# is ever registered. # is ever registered.
time.sleep(0.2) 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": elif cmd == "quit":
s.sendall(b'{"execute":"quit"}\n') s.sendall(b'{"execute":"quit"}\n')
else:
sys.exit(f"unknown command {cmd}")
if __name__ == "__main__": if __name__ == "__main__":
+63 -24
View File
@@ -1,9 +1,10 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Display + touch scenario: boot the VM headless with virtio-gpu, wait for the # 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 # LVGL UI (fbdev build) to render a real frame, then inject an absolute touch
# touch tap (virtio-tablet), screendump again. Asserts the first frame is # tap on the Metrics tab (virtio-tablet) and ASSERT the frame changed — the
# non-blank; reports (does not assert) whether the tap changed pixels — the # device-level analogue of flare-edge's Xvfb/xdotool sim-test.sh. Readiness is
# device-level analogue of flare-edge's Xvfb/xdotool sim-test.sh. # 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. # FAILS CLOSED on missing prerequisites.
# #
@@ -40,11 +41,30 @@ trap cleanup EXIT
bash "$QDIR/mkinitramfs.sh" bash "$QDIR/mkinitramfs.sh"
bash "$QDIR/mkimage.sh" bash "$QDIR/mkimage.sh"
# 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)) PORT=$((21000 + RANDOM % 20000))
: > "$WORK/console.log"
bash "$QDIR/run.sh" --kernel "$ZIMAGE" --display headless --qmp "$WORK/qmp.sock" \ bash "$QDIR/run.sh" --kernel "$ZIMAGE" --display headless --qmp "$WORK/qmp.sock" \
--ssh-port "$PORT" --http-port $((PORT + 1)) --api-port $((PORT + 2)) \ --ssh-port "$PORT" --http-port $((PORT + 1)) --api-port $((PORT + 2)) \
> "$WORK/console.log" 2>&1 & > "$WORK/console.log" 2>&1 &
QEMU_PID=$! 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)) deadline=$((SECONDS + 120))
while [ $SECONDS -lt $deadline ]; do while [ $SECONDS -lt $deadline ]; do
@@ -57,36 +77,55 @@ grep -aq 'init: starting warden-ui' "$WORK/console.log" || {
tail -25 "$WORK/console.log" >&2 tail -25 "$WORK/console.log" >&2
exit 1 exit 1
} }
sleep 8 # let LVGL render the first frames
qmp() { python3 "$HERE/qmp.py" "$WORK/qmp.sock" "$@"; } qmp() { python3 "$HERE/qmp.py" "$WORK/qmp.sock" "$@"; }
# 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" qmp screendump "$WORK/shot1.ppm"
# Tap the "Metrics" tab: pixel (373,40) of 720x720 scaled to the QMP absolute if frame_rendered "$WORK/shot1.ppm"; then rendered=1; break; fi
# range 0..32767 — switching tabs must repaint the content area.
qmp tap 16975 1820
sleep 3 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. Poll for the
# repaint rather than guessing a delay.
qmp tap 16975 1820
changed=0
deadline=$((SECONDS + 30))
while [ $SECONDS -lt $deadline ]; do
sleep 2
qmp screendump "$WORK/shot2.ppm" qmp screendump "$WORK/shot2.ppm"
if ! cmp -s "$WORK/shot1.ppm" "$WORK/shot2.ppm"; then changed=1; break; fi
done
mkdir -p "$OUTDIR" mkdir -p "$OUTDIR"
cp "$WORK/shot1.ppm" "$OUTDIR/ui-shot1.ppm" 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. [ "$changed" = 1 ] || {
python3 - "$WORK/shot1.ppm" <<'EOF' echo "FATAL: tapping the Metrics tab did not change the frame within 30s — touch is not reaching the UI" >&2
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
exit 1 exit 1
fi }
echo "tap on the Metrics tab repainted the frame (touch reached the UI)" 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)" echo "UI-SHOT-PASS (screenshots in $OUTDIR/ui-shot{1,2}.ppm)"