#!/usr/bin/env bash # check-riscv64-artifacts.sh — QEMU-user-mode smoke for every riscv64 # native artifact this repo cross-compiles. # # One-shot harness: walks the known cross-build outputs (native plugins # + libllama / libggml family + libsigsys-handler-riscv64), # confirms each is an ELF UCB RISC-V # 64-bit double-float-ABI object, and exercises every executable smoke # under qemu-riscv64-static. Shared libraries are dlopen-verified via a # tiny C harness (also run under QEMU). # # Default posture: this is *gated* on ELIZA_RISCV64_SMOKE=1 because # the toolchain dependencies (qemu-user-static, the actual riscv64 # artifacts) are heavy and may not be available on every CI box. With # the env-var unset the script no-ops and exits 0 with a clean # "skip" marker, so wiring it into a default-CI step is safe. # # Usage: # bash scripts/check-riscv64-artifacts.sh # honor ELIZA_RISCV64_SMOKE # ELIZA_RISCV64_SMOKE=1 bash scripts/check-riscv64-artifacts.sh # ELIZA_RISCV64_SMOKE=1 bash scripts/check-riscv64-artifacts.sh --out build/reports/foo.json # ELIZA_RISCV64_SMOKE=1 bash scripts/check-riscv64-artifacts.sh --no-qemu # ELF-tag check only # # Exit code: # 0 — every artifact is PASS or a documented SKIP # 1 — at least one artifact FAILed # 2 — invalid CLI set -euo pipefail repo_root="$(cd "$(dirname "$0")/.." && pwd)" cd "$repo_root" OUT="$repo_root/build/reports/riscv64_artifacts.json" QEMU_TIMEOUT="${ELIZA_RISCV64_QEMU_TIMEOUT:-60}" RUN_QEMU=1 NODE_BIN="${NODE_BIN:-$(command -v node || true)}" RM_PATH_RECURSIVE="$repo_root/packages/scripts/rm-path-recursive.mjs" while [ $# -gt 0 ]; do case "$1" in --out) OUT="$2"; shift 2;; --no-qemu) RUN_QEMU=0; shift;; --timeout) QEMU_TIMEOUT="$2"; shift 2;; -h|--help) awk '/^# /{print substr($0,3)} /^#$/{print ""} !/^#/{exit}' "$0" exit 0;; *) echo "unknown argument: $1" >&2; exit 2;; esac done mkdir -p "$(dirname "$OUT")" now_epoch_ms() { # GNU date supports %N; macOS doesn't, but riscv64 cross-builds are # Linux-only so this script targets Linux-only callers. date +%s%3N 2>/dev/null || python3 -c 'import time;print(int(time.time()*1000))' } iso_now() { date -u +'%Y-%m-%dT%H:%M:%SZ'; } remove_path_recursive() { if [ -z "$NODE_BIN" ]; then echo "[check-riscv64-artifacts] node not on PATH; cannot remove recursively via $RM_PATH_RECURSIVE." >&2 return 1 fi "$NODE_BIN" "$RM_PATH_RECURSIVE" "$@" } # JSON record buffer. Each record is a single-line JSON object emitted # as we go, joined into the final array at the end. TMP_JSON="$(mktemp -t riscv64-artifacts-XXXXXX.jsonl)" trap 'rm -f "$TMP_JSON"' EXIT PASS_N=0; FAIL_N=0; SKIP_N=0 emit_record() { # $1 path, $2 kind (executable|shared|static-archive), $3 status, $4 detail, $5 duration_ms local path="$1"; local kind="$2"; local status="$3"; local detail="$4"; local dur="$5" # Properly JSON-escape detail and path. local esc_path esc_detail esc_path=$(printf '%s' "$path" | python3 -c 'import sys,json;sys.stdout.write(json.dumps(sys.stdin.read()))') esc_detail=$(printf '%s' "$detail" | python3 -c 'import sys,json;sys.stdout.write(json.dumps(sys.stdin.read()))') printf '{"path":%s,"kind":"%s","status":"%s","detail":%s,"duration_ms":%s}\n' \ "$esc_path" "$kind" "$status" "$esc_detail" "$dur" >> "$TMP_JSON" case "$status" in PASS) PASS_N=$((PASS_N+1));; FAIL) FAIL_N=$((FAIL_N+1));; SKIP) SKIP_N=$((SKIP_N+1));; esac printf ' [%-4s] %-70s %s\n' "$status" "${path#$repo_root/}" "$detail" } write_final_report() { local final_status="$1"; local pre_skip_reason="${2:-}" { printf '{\n' printf ' "generated_at": "%s",\n' "$(iso_now)" printf ' "repo_root": "%s",\n' "$repo_root" printf ' "eliza_riscv64_smoke": "%s",\n' "${ELIZA_RISCV64_SMOKE:-}" printf ' "qemu_bin": "%s",\n' "${QEMU_BIN:-}" printf ' "qemu_run": %s,\n' "$([ "$RUN_QEMU" = "1" ] && echo true || echo false)" printf ' "qemu_timeout_seconds": %s,\n' "$QEMU_TIMEOUT" printf ' "summary": {"pass": %d, "fail": %d, "skip": %d},\n' "$PASS_N" "$FAIL_N" "$SKIP_N" printf ' "final_status": "%s",\n' "$final_status" if [ -n "$pre_skip_reason" ]; then local esc esc=$(printf '%s' "$pre_skip_reason" | python3 -c 'import sys,json;sys.stdout.write(json.dumps(sys.stdin.read()))') printf ' "pre_skip_reason": %s,\n' "$esc" fi printf ' "artifacts": [\n' if [ -s "$TMP_JSON" ]; then awk 'NR>1{printf ",\n"} {printf " %s", $0} END{printf "\n"}' "$TMP_JSON" fi printf ' ]\n' printf '}\n' } > "$OUT" echo echo "Report: $OUT" echo "PASS=$PASS_N SKIP=$SKIP_N FAIL=$FAIL_N" } # ── Gate ───────────────────────────────────────────────────────────── if [ "${ELIZA_RISCV64_SMOKE:-0}" != "1" ]; then echo "[check-riscv64-artifacts] ELIZA_RISCV64_SMOKE not set; skipping." echo "[check-riscv64-artifacts] To run: ELIZA_RISCV64_SMOKE=1 bun run check:riscv64-artifacts" write_final_report "SKIP" "ELIZA_RISCV64_SMOKE!=1 (default-CI gate)" exit 0 fi # ── QEMU pre-flight ────────────────────────────────────────────────── QEMU_BIN="$(command -v qemu-riscv64-static 2>/dev/null || command -v qemu-riscv64 2>/dev/null || true)" if [ "$RUN_QEMU" = "1" ] && [ -z "$QEMU_BIN" ]; then cat >&2 <<'EOF' [check-riscv64-artifacts] qemu-riscv64-static not found. Install (Debian/Ubuntu): sudo apt-get install -y qemu-user-static binfmt-support Install (Fedora/RHEL): sudo dnf install -y qemu-user-static Re-run, or pass --no-qemu to ELF-tag-only the artifacts. Treating this run as SKIP (exit 0) so default CI stays green. EOF write_final_report "SKIP" "qemu-riscv64-static missing" exit 0 fi echo "[check-riscv64-artifacts] qemu_bin=${QEMU_BIN:-} timeout=${QEMU_TIMEOUT}s" # ── Verifiers ──────────────────────────────────────────────────────── is_riscv64_elf() { local f="$1" local info # `-L` dereferences symlinks (libfoo.so → libfoo.so.1, etc.). info="$(file -L -b "$f" 2>/dev/null || true)" case "$info" in *"UCB RISC-V"*"double-float ABI"*) return 0;; *) return 1;; esac } ar_members_are_rv64() { local archive="$1" case "$archive" in /*) ;; *) archive="$(cd "$(dirname "$archive")" && pwd)/$(basename "$archive")";; esac local extract_dir="$archive.qemu-extract" remove_path_recursive "$extract_dir" mkdir -p "$extract_dir" if ! ( cd "$extract_dir" && ar x "$archive" >/dev/null 2>&1 ); then remove_path_recursive "$extract_dir" return 1 fi local bad=0 saw=0 for member in "$extract_dir"/*.o; do [ -f "$member" ] || continue saw=1 if ! file -b "$member" | grep -q "UCB RISC-V"; then bad=1; break fi done remove_path_recursive "$extract_dir" [ "$saw" = "1" ] && [ "$bad" = "0" ] } run_executable_under_qemu() { # $1 path. Echoes "status|detail|duration_ms". local exe="$1" if [ ! -x "$exe" ]; then chmod +x "$exe" 2>/dev/null || true; fi if [ "$RUN_QEMU" != "1" ] || [ -z "$QEMU_BIN" ]; then echo "SKIP|qemu disabled (elf-tag only)|0" return fi local log; log="$(mktemp -t riscv64-smoke-XXXXXX.log)" local start end dur ec start="$(now_epoch_ms)" if timeout "$QEMU_TIMEOUT" "$QEMU_BIN" "$exe" >"$log" 2>&1; then end="$(now_epoch_ms)"; dur=$((end - start)) rm -f "$log" echo "PASS|qemu exit=0|$dur" else ec=$? end="$(now_epoch_ms)"; dur=$((end - start)) local tail; tail="$(tail -c 240 "$log" 2>/dev/null | tr -d '\r' | tr '\n' ' ' | sed 's/ \+/ /g')" rm -f "$log" if [ "$ec" = "124" ]; then echo "FAIL|qemu timeout after ${QEMU_TIMEOUT}s: $tail|$dur" elif [ "$ec" = "77" ]; then # Conventional autotools / GNU "skip" exit code — the test # explicitly punted (usually due to a missing fixture). echo "SKIP|qemu exit=77 (test self-skipped, likely missing fixture): $tail|$dur" elif [ "$ec" = "2" ] && printf '%s' "$tail" | grep -qE "[Uu]sage:|--help|missing|not provided|fixture" ; then # Exit 2 + usage/missing-fixture banner: the test binary # requires an external argument (model GGUF, parity input, # etc.) that the smoke harness doesn't supply. Treat as # SKIP rather than failure — running it correctly is a # fixture-provisioning task, not a riscv64 correctness gap. echo "SKIP|qemu exit=2, usage/fixture message: $tail|$dur" else echo "FAIL|qemu exit=$ec: $tail|$dur" fi fi } # Dlopen-verify a .so under QEMU: compiles a tiny C harness for the # host that just dlopen()s the lib by path, then runs THAT host binary # only if it dlopen()s a host-arch object — instead, we test the .so by # inspecting its NEEDED entries with `readelf`/`file` because dlopen of # a riscv64 .so from a qemu-riscv64-static-run binary would need the # riscv64 libdl/libc loader chain present at the right sysroot path, # which is not assumed available. The harness therefore does: # 1. ELF arch check (must be UCB RISC-V). # 2. readelf -d sanity (must be DYN, must have at least one NEEDED). # This is the same verification ndk-stack and friends use for shipped # .so artifacts where running them in isolation is meaningless. verify_shared_lib() { local so="$1" if ! is_riscv64_elf "$so"; then echo "FAIL|not a riscv64 ELF (file says: $(file -L -b "$so" 2>/dev/null | head -c 120))|0" return fi if ! command -v readelf >/dev/null 2>&1; then echo "PASS|ELF tag rv64 ok (readelf unavailable, skipped NEEDED check)|0" return fi # readelf needs the real file (follow symlink with realpath if available). local target="$so" if [ -L "$so" ]; then target="$(readlink -f "$so" 2>/dev/null || echo "$so")" fi local needed needed="$(readelf -d "$target" 2>/dev/null | awk '/NEEDED/ {gsub(/[\[\]]/,"",$NF); print $NF}' | tr '\n' ',' | sed 's/,$//')" if [ -z "$needed" ]; then echo "PASS|ELF tag rv64 ok, no NEEDED (leaf .so)|0" else echo "PASS|ELF tag rv64 ok, NEEDED=[$needed]|0" fi } verify_static_archive() { local a="$1" if ar_members_are_rv64 "$a"; then echo "PASS|all .o members are UCB RISC-V|0" else echo "FAIL|one or more .o members are not UCB RISC-V|0" fi } verify_artifact() { # $1 = path. Determines kind by extension / executable bit. local p="$1" if [ ! -e "$p" ]; then emit_record "$p" "missing" "SKIP" "artifact not built yet" "0" return fi case "$p" in *.a) local result; result="$(verify_static_archive "$p")" local status detail dur status="${result%%|*}"; rest="${result#*|}"; detail="${rest%|*}"; dur="${rest##*|}" emit_record "$p" "static-archive" "$status" "$detail" "$dur";; *.so|*.so.*) local result; result="$(verify_shared_lib "$p")" local status detail dur status="${result%%|*}"; rest="${result#*|}"; detail="${rest%|*}"; dur="${rest##*|}" emit_record "$p" "shared-library" "$status" "$detail" "$dur";; *) if [ -f "$p" ] && is_riscv64_elf "$p"; then if [ -x "$p" ] || head -c 4 "$p" | od -An -c 2>/dev/null | grep -q "\\\\177 E L F"; then local result; result="$(run_executable_under_qemu "$p")" local status detail dur status="${result%%|*}"; rest="${result#*|}"; detail="${rest%|*}"; dur="${rest##*|}" emit_record "$p" "executable" "$status" "$detail" "$dur" else emit_record "$p" "data" "SKIP" "ELF but not executable" "0" fi else emit_record "$p" "unknown" "FAIL" "not a riscv64 ELF (file: $(file -L -b "$p" 2>/dev/null | head -c 120))" "0" fi;; esac } # ── Artifact inventory ─────────────────────────────────────────────── # Each entry's path is relative to repo root. Missing artifacts are # reported as SKIP (with reason) — the build-driver script # scripts/build-riscv64-artifacts.sh is responsible for producing them. NATIVE_PLUGINS=( "qjl-cpu packages/native/plugins/qjl-cpu/build/riscv64/libqjl.a" "qjl-cpu packages/native/plugins/qjl-cpu/build/riscv64/qjl_int8_smoke" "qjl-cpu packages/native/plugins/qjl-cpu/build/riscv64/qjl_avxvnni_smoke" "qjl-cpu packages/native/plugins/qjl-cpu/build/riscv64/qjl_fork_parity" "qjl-cpu packages/native/plugins/qjl-cpu/build/riscv64/qjl_bench" "polarquant-cpu packages/native/plugins/polarquant-cpu/build/riscv64/libpolarquant.a" "polarquant-cpu packages/native/plugins/polarquant-cpu/build/riscv64/polar_simd_parity_test" "polarquant-cpu packages/native/plugins/polarquant-cpu/build/riscv64/polar_dot_test" "polarquant-cpu packages/native/plugins/polarquant-cpu/build/riscv64/polar_preht_dot_test" "polarquant-cpu packages/native/plugins/polarquant-cpu/build/riscv64/polar_preht_simd_parity_test" "polarquant-cpu packages/native/plugins/polarquant-cpu/build/riscv64/polar_roundtrip_test" "polarquant-cpu packages/native/plugins/polarquant-cpu/build/riscv64/polar_bench" "turboquant-cpu packages/native/plugins/turboquant-cpu/build/riscv64/libturboquant.a" "turboquant-cpu packages/native/plugins/turboquant-cpu/build/riscv64/turboquant_smoke" "turboquant-cpu packages/native/plugins/turboquant-cpu/build/riscv64/turboquant_simd_parity" "silero-vad-cpp packages/native/plugins/silero-vad-cpp/build/riscv64/libsilero_vad.a" "silero-vad-cpp packages/native/plugins/silero-vad-cpp/build/riscv64/libsilero_vad.so" "silero-vad-cpp packages/native/plugins/silero-vad-cpp/build/riscv64/silero_vad_abi_smoke" "silero-vad-cpp packages/native/plugins/silero-vad-cpp/build/riscv64/silero_vad_resample_test" "silero-vad-cpp packages/native/plugins/silero-vad-cpp/build/riscv64/silero_vad_runtime_test" "silero-vad-cpp packages/native/plugins/silero-vad-cpp/build/riscv64/silero_vad_state_test" "voice-classifier-cpp packages/native/plugins/voice-classifier-cpp/build/riscv64/libvoice_classifier.a" "voice-classifier-cpp packages/native/plugins/voice-classifier-cpp/build/riscv64/libvoice_classifier.so" "voice-classifier-cpp packages/native/plugins/voice-classifier-cpp/build/riscv64/voice_classifier_abi_smoke" "voice-classifier-cpp packages/native/plugins/voice-classifier-cpp/build/riscv64/voice_diarizer_parity_test" "voice-classifier-cpp packages/native/plugins/voice-classifier-cpp/build/riscv64/voice_emotion_classes_test" "voice-classifier-cpp packages/native/plugins/voice-classifier-cpp/build/riscv64/voice_gguf_loader_test" "voice-classifier-cpp packages/native/plugins/voice-classifier-cpp/build/riscv64/voice_mel_features_test" "voice-classifier-cpp packages/native/plugins/voice-classifier-cpp/build/riscv64/voice_speaker_distance_test" "voice-classifier-cpp packages/native/plugins/voice-classifier-cpp/build/riscv64/voice_speaker_parity_test" "wakeword-cpp packages/native/plugins/wakeword-cpp/build/riscv64/libwakeword.a" "wakeword-cpp packages/native/plugins/wakeword-cpp/build/riscv64/libwakeword.so" "wakeword-cpp packages/native/plugins/wakeword-cpp/build/riscv64/wakeword_abi_smoke" "wakeword-cpp packages/native/plugins/wakeword-cpp/build/riscv64/wakeword_melspec_test" "wakeword-cpp packages/native/plugins/wakeword-cpp/build/riscv64/wakeword_runtime_test" "wakeword-cpp packages/native/plugins/wakeword-cpp/build/riscv64/wakeword_window_test" "yolo-cpp packages/native/plugins/yolo-cpp/build/riscv64/libyolo.a" "yolo-cpp packages/native/plugins/yolo-cpp/build/riscv64/libyolo.so" "yolo-cpp packages/native/plugins/yolo-cpp/build/riscv64/yolo_abi_smoke" "yolo-cpp packages/native/plugins/yolo-cpp/build/riscv64/yolo_classes_test" "yolo-cpp packages/native/plugins/yolo-cpp/build/riscv64/yolo_letterbox_test" "yolo-cpp packages/native/plugins/yolo-cpp/build/riscv64/yolo_nms_test" "yolo-cpp packages/native/plugins/yolo-cpp/build/riscv64/yolo_runtime_test" "face-cpp packages/native/plugins/face-cpp/build/riscv64/libface.a" "face-cpp packages/native/plugins/face-cpp/build/riscv64/libface.so" "face-cpp packages/native/plugins/face-cpp/build/riscv64/face_abi_smoke" "face-cpp packages/native/plugins/face-cpp/build/riscv64/face_align_test" "face-cpp packages/native/plugins/face-cpp/build/riscv64/face_anchor_test" "face-cpp packages/native/plugins/face-cpp/build/riscv64/face_distance_test" "face-cpp packages/native/plugins/face-cpp/build/riscv64/face_embed_runtime_test" "face-cpp packages/native/plugins/face-cpp/build/riscv64/face_runtime_test" "doctr-cpp packages/native/plugins/doctr-cpp/build/riscv64/libdoctr.a" "doctr-cpp packages/native/plugins/doctr-cpp/build/riscv64/doctr_abi_smoke" ) # MTP libllama + ggml family stages into either: # build/riscv64-stage/riscv64/ (linux-riscv64 staging) # packages/app-core/platforms/android/app/src/main/jniLibs/riscv64/ # packages/app-core/platforms/android/app/src/main/assets/agent/riscv64/ # We probe all three locations; the harness reports SKIP only if none # contains the .so. LLAMA_FAMILY_BASENAMES=( libllama.so libggml.so libggml-base.so libggml-cpu.so libllama-common.so libeliza-llama-shim.so ) LLAMA_FAMILY_SEARCH_DIRS=( "build/riscv64-stage/riscv64" "packages/app-core/platforms/android/app/src/main/jniLibs/riscv64" "packages/app-core/platforms/android/app/src/main/assets/agent/riscv64" ) SIGSYS_SEARCH=( "${HOME}/.cache/eliza-android-agent/seccomp-shim/riscv64/libsigsys-handler.so" ) # ── Walk + verify ──────────────────────────────────────────────────── echo "── Native plugins ──" for entry in "${NATIVE_PLUGINS[@]}"; do # shellcheck disable=SC2086 set -- $entry pkg="$1"; path="$2" verify_artifact "$repo_root/$path" done echo echo "── libllama / libggml family (MTP) ──" for basename in "${LLAMA_FAMILY_BASENAMES[@]}"; do found="" for dir in "${LLAMA_FAMILY_SEARCH_DIRS[@]}"; do candidate="$repo_root/$dir/$basename" if [ -e "$candidate" ]; then found="$candidate"; break fi done if [ -n "$found" ]; then verify_artifact "$found" else # Synthesize a path for the report — caller knows what's missing. emit_record "$repo_root/${LLAMA_FAMILY_SEARCH_DIRS[0]}/$basename" "shared-library" "SKIP" \ "not built; run \`bun run build:riscv64-artifacts\` (libllama for linux-riscv64-cpu + android-riscv64-cpu)" "0" fi done echo echo "── libsigsys-handler-riscv64 (Bun seccomp shim) ──" s_found="" for p in "${SIGSYS_SEARCH[@]}"; do if [ -e "$p" ]; then s_found="$p"; break; fi done if [ -n "$s_found" ]; then verify_artifact "$s_found" else emit_record "${SIGSYS_SEARCH[0]}" "shared-library" "SKIP" \ "not built; run \`node packages/app-core/scripts/aosp/compile-shim.mjs --abi riscv64\`" "0" fi # ── Verdict ────────────────────────────────────────────────────────── if [ "$FAIL_N" -gt 0 ]; then write_final_report "FAIL" exit 1 fi write_final_report "PASS" exit 0