diff --git a/docs/testing.md b/docs/testing.md index d113342..1ce7054 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -13,6 +13,16 @@ Host build requirements: - `codesign` - GNU `make` - GNU `objcopy` or `llvm-objcopy` +- `bash` 3.2+ (the version Apple ships as `/bin/bash`) is sufficient for + the test harness; no Homebrew `bash` is required. See + `tests/lib/bash-compat.sh` for the cross-version shims (a portable + microsecond clock and the parallel-array lookup pattern that replaces + associative arrays). When editing a shell script under `tests/` or + `scripts/`, the conventions in that file's header are the source of + truth: no `EPOCHREALTIME`, no `declare -A`, no `mapfile`, no + `${var^^}` / `${var,,}` case-conversion, and guard any potentially + empty array expansion with `${arr[@]+"${arr[@]}"}` so `set -u` does + not trip on it. Guest test builds additionally require: diff --git a/tests/driver.sh b/tests/driver.sh index 164cf70..19c67bf 100755 --- a/tests/driver.sh +++ b/tests/driver.sh @@ -299,13 +299,16 @@ for i in "${filtered_idx[@]}"; do read -r -a argv <<< "$cmd_line" binary="${argv[0]}" - unset 'argv[0]' if [[ "$binary" != /* ]]; then binary="$TESTDIR_ABS/$binary" fi + # bash 3.2 + set -u rejects "${argv[@]}" after 'unset argv[0]' has + # left the array empty ("unbound variable"). The offset form + # "${argv[@]:1}" is well-defined to produce zero elements when the + # array has only one slot, so it works in every supported bash. args=() - for arg in "${argv[@]}"; do + for arg in "${argv[@]:1}"; do arg="${arg//\$TESTDIR/$TESTDIR_ABS}" args+=("$arg") done @@ -344,7 +347,10 @@ for i in "${filtered_idx[@]}"; do fi output="" - if output=$(timeout "$TIMEOUT" "$ELFUSE" "$binary" "${args[@]}" 2>&1); then + # ${args[@]+...} guards the array expansion so a test with no extra + # arguments (args=()) does not trip bash 3.2's set -u rejection of + # an empty "${array[@]}". + if output=$(timeout "$TIMEOUT" "$ELFUSE" "$binary" ${args[@]+"${args[@]}"} 2>&1); then rc=0 else rc=$? diff --git a/tests/fetch-fixtures.sh b/tests/fetch-fixtures.sh index 6edc295..87a6e36 100755 --- a/tests/fetch-fixtures.sh +++ b/tests/fetch-fixtures.sh @@ -32,6 +32,9 @@ set -euo pipefail +# shellcheck source=tests/lib/bash-compat.sh +. "$(dirname "$0")/lib/bash-compat.sh" + ALPINE_VERSION="${ALPINE_VERSION:-3.21}" ALPINE_PATCH="${ALPINE_PATCH:-3.21.0}" ALPINE_ARCH="${ALPINE_ARCH:-aarch64}" @@ -50,48 +53,70 @@ KEYS_DIR="${FIXTURES}/keys" STATICBIN="${FIXTURES}/aarch64-musl/staticbin/bin" INITRAMFS="${FIXTURES}/initramfs.cpio.gz" -# Pinned package versions (Alpine 3.21). When bumping ALPINE_VERSION, refresh -# these by querying the repo's APKINDEX. -declare -A PKGS=( - ["main:linux-virt"]="6.12.91-r0" - ["main:busybox-static"]="1.37.0-r14" - ["main:dropbear"]="2024.86-r0" - ["main:zlib"]="1.3.2-r0" - ["main:utmps-libs"]="0.1.2.3-r2" - ["main:skalibs-libs"]="2.14.3.0-r0" - ["main:musl"]="1.2.5-r11" - ["main:musl-dev"]="1.2.5-r11" - ["main:musl-utils"]="1.2.5-r11" - ["main:libgcc"]="14.2.0-r4" - ["main:libcrypto3"]="3.3.7-r0" - ["main:acl-libs"]="2.3.2-r1" - ["main:libattr"]="2.5.2-r2" - ["main:pcre2"]="10.43-r0" - ["main:coreutils"]="9.5-r2" - ["main:coreutils-env"]="9.5-r2" - ["main:coreutils-fmt"]="9.5-r2" - ["main:coreutils-sha512sum"]="9.5-r2" - ["main:bash"]="5.2.37-r0" - ["main:dash"]="0.5.12-r2" - ["main:findutils"]="4.10.0-r0" - ["main:diffutils"]="3.10-r0" - ["main:grep"]="3.11-r0" - ["main:sed"]="4.9-r2" - ["main:gawk"]="5.3.1-r0" - ["main:gmp"]="6.3.0-r2" - ["main:readline"]="8.2.13-r0" - ["main:libncursesw"]="6.5_p20241006-r3" - ["main:ncurses-terminfo-base"]="6.5_p20241006-r3" - ["main:lua5.4"]="5.4.7-r0" - ["main:lua5.4-libs"]="5.4.7-r0" - ["main:luajit"]="2.1_p20240815-r0" - ["main:jq"]="1.7.1-r0" - ["main:oniguruma"]="6.9.9-r0" - ["main:sqlite"]="3.48.0-r4" - ["main:sqlite-libs"]="3.48.0-r4" - ["main:tree"]="2.2.1-r0" +# Pinned package versions (Alpine 3.21). When bumping ALPINE_VERSION, +# refresh these by querying the repo's APKINDEX. +# +# Encoded as "repo:name:version" tuples so bash 3.2 hosts (stock macOS +# /bin/bash) do not need associative arrays. Lookup goes through +# pkg_version below. +PKGS=( + "main:linux-virt:6.12.91-r0" + "main:busybox-static:1.37.0-r14" + "main:dropbear:2024.86-r0" + "main:zlib:1.3.2-r0" + "main:utmps-libs:0.1.2.3-r2" + "main:skalibs-libs:2.14.3.0-r0" + "main:musl:1.2.5-r11" + "main:musl-dev:1.2.5-r11" + "main:musl-utils:1.2.5-r11" + "main:libgcc:14.2.0-r4" + "main:libcrypto3:3.3.7-r0" + "main:acl-libs:2.3.2-r1" + "main:libattr:2.5.2-r2" + "main:pcre2:10.43-r0" + "main:coreutils:9.5-r2" + "main:coreutils-env:9.5-r2" + "main:coreutils-fmt:9.5-r2" + "main:coreutils-sha512sum:9.5-r2" + "main:bash:5.2.37-r0" + "main:dash:0.5.12-r2" + "main:findutils:4.10.0-r0" + "main:diffutils:3.10-r0" + "main:grep:3.11-r0" + "main:sed:4.9-r2" + "main:gawk:5.3.1-r0" + "main:gmp:6.3.0-r2" + "main:readline:8.2.13-r0" + "main:libncursesw:6.5_p20241006-r3" + "main:ncurses-terminfo-base:6.5_p20241006-r3" + "main:lua5.4:5.4.7-r0" + "main:lua5.4-libs:5.4.7-r0" + "main:luajit:2.1_p20240815-r0" + "main:jq:1.7.1-r0" + "main:oniguruma:6.9.9-r0" + "main:sqlite:3.48.0-r4" + "main:sqlite-libs:3.48.0-r4" + "main:tree:2.2.1-r0" ) +# Look up a package version by its "repo:name" prefix. Returns the +# version on stdout and rc=0 on hit, rc=1 (silently) on miss so the +# old ${PKGS[key]:-} fallback callers keep working. +pkg_version() +{ + local target="$1:" + local entry + for entry in "${PKGS[@]}"; do + case "$entry" in + "$target"*) + printf '%s\n' "${entry#"$target"}" + return 0 + ;; + esac + done + return 1 +} + # Subset whose binaries are exposed as standalone "static-bins" suite paths. # Most are dynamic but link only against musl/zlib/etc., already in rootfs/. # Applet list (hardcoded -- busybox 1.37 inventory). Busybox does not have @@ -185,8 +210,12 @@ main() mkdir -p "$CACHE" "$KERNEL_DIR" "$KEYS_DIR" "$STATICBIN" "$ROOTFS" # Download all required apk packages. - for key in "${!PKGS[@]}"; do - local repo="${key%%:*}" name="${key##*:}" version="${PKGS[$key]}" + local entry repo name version + for entry in "${PKGS[@]}"; do + repo="${entry%%:*}" + name="${entry#*:}" + name="${name%:*}" + version="${entry##*:}" fetch "$(apk_url "$repo" "$name" "$version")" "$(apk_path "$name" "$version")" done @@ -201,16 +230,20 @@ main() # Overlay every cached apk except linux-virt (kernel goes elsewhere). # The kernel apk's lib/modules/ tree IS overlayed (needed for modprobe). - for key in "${!PKGS[@]}"; do - local name="${key##*:}" version="${PKGS[$key]}" + local entry name version + for entry in "${PKGS[@]}"; do + name="${entry#*:}" + name="${name%:*}" + version="${entry##*:}" [ "$name" = "linux-virt" ] && continue extract_apk_to "$(apk_path "$name" "$version")" "$ROOTFS" done # Extract just the kernel-modules subtree from linux-virt. - local modstage + local modstage linux_virt_ver + linux_virt_ver="$(pkg_version "main:linux-virt")" modstage="$(mktemp -d)" - tar xzf "$(apk_path linux-virt "${PKGS["main:linux-virt"]}")" \ + tar xzf "$(apk_path linux-virt "$linux_virt_ver")" \ -C "$modstage" 'lib/modules' 2> /dev/null cp -R "$modstage/lib/modules" "$ROOTFS/lib/" 2> /dev/null rm -rf "$modstage" @@ -295,9 +328,11 @@ EOF # Extract the kernel from linux-virt. if [ ! -s "${KERNEL_DIR}/vmlinuz-virt" ] || [ "${FORCE:-0}" = "1" ]; then log "extract kernel" + local linux_virt_ver + linux_virt_ver="$(pkg_version "main:linux-virt")" rm -rf "${KERNEL_DIR}/work" mkdir -p "${KERNEL_DIR}/work" - tar xzf "$(apk_path linux-virt "${PKGS["main:linux-virt"]}")" \ + tar xzf "$(apk_path linux-virt "$linux_virt_ver")" \ -C "${KERNEL_DIR}/work" boot/vmlinuz-virt 2> /dev/null mv "${KERNEL_DIR}/work/boot/vmlinuz-virt" "${KERNEL_DIR}/vmlinuz-virt" rm -rf "${KERNEL_DIR}/work" @@ -340,11 +375,13 @@ EOF # Stage the static-bin tree. if [ ! -s "${STATICBIN}/busybox" ] || [ "${FORCE:-0}" = "1" ]; then log "stage static-bin tree" + local busybox_ver + busybox_ver="$(pkg_version "main:busybox-static")" rm -rf "${STATICBIN}" mkdir -p "${STATICBIN}" local stage stage="$(mktemp -d)" - tar xzf "$(apk_path busybox-static "${PKGS["main:busybox-static"]}")" -C "$stage" 2> /dev/null + tar xzf "$(apk_path busybox-static "$busybox_ver")" -C "$stage" 2> /dev/null mv "${stage}/bin/busybox.static" "${STATICBIN}/busybox" chmod 755 "${STATICBIN}/busybox" rm -rf "$stage" @@ -388,11 +425,15 @@ fetch_x86_64_userspace() local x86_minirootfs="alpine-minirootfs-${ALPINE_PATCH}-x86_64.tar.gz" log "x86_64: fetch packages" - for key in "${!PKGS[@]}"; do - local repo="${key%%:*}" name="${key##*:}" version="${PKGS[$key]}" + local entry repo name version x86_url x86_dest + for entry in "${PKGS[@]}"; do + repo="${entry%%:*}" + name="${entry#*:}" + name="${name%:*}" + version="${entry##*:}" [ "$repo" = "main" ] || continue - local x86_url="${x86_main}/${name}-${version}.apk" - local x86_dest="${x86_cache}/${name}-${version}.apk" + x86_url="${x86_main}/${name}-${version}.apk" + x86_dest="${x86_cache}/${name}-${version}.apk" fetch "$x86_url" "$x86_dest" done fetch "${x86_releases}/${x86_minirootfs}" "${x86_cache}/${x86_minirootfs}" @@ -402,11 +443,15 @@ fetch_x86_64_userspace() rm -rf "$x86_rootfs" mkdir -p "$x86_rootfs" tar xzf "${x86_cache}/${x86_minirootfs}" -C "$x86_rootfs" 2> /dev/null - for key in "${!PKGS[@]}"; do - local repo="${key%%:*}" name="${key##*:}" version="${PKGS[$key]}" - [ "$repo" = "main" ] || continue - [ "$name" = "linux-virt" ] && continue - extract_apk_to "${x86_cache}/${name}-${version}.apk" "$x86_rootfs" + local stage_entry stage_repo stage_name stage_version + for stage_entry in "${PKGS[@]}"; do + stage_repo="${stage_entry%%:*}" + stage_name="${stage_entry#*:}" + stage_name="${stage_name%:*}" + stage_version="${stage_entry##*:}" + [ "$stage_repo" = "main" ] || continue + [ "$stage_name" = "linux-virt" ] && continue + extract_apk_to "${x86_cache}/${stage_name}-${stage_version}.apk" "$x86_rootfs" done touch "${x86_rootfs}/.staged" fi @@ -414,11 +459,13 @@ fetch_x86_64_userspace() if [ ! -s "${x86_staticbin}/busybox" ] || [ "${FORCE:-0}" = "1" ]; then log "x86_64: stage static-bin tree" + local busybox_ver + busybox_ver="$(pkg_version "main:busybox-static")" rm -rf "$x86_staticbin" mkdir -p "$x86_staticbin" local stage stage="$(mktemp -d)" - tar xzf "${x86_cache}/busybox-static-${PKGS["main:busybox-static"]}.apk" \ + tar xzf "${x86_cache}/busybox-static-${busybox_ver}.apk" \ -C "$stage" 2> /dev/null mv "${stage}/bin/busybox.static" "${x86_staticbin}/busybox" chmod 755 "${x86_staticbin}/busybox" diff --git a/tests/lib/bash-compat.sh b/tests/lib/bash-compat.sh new file mode 100644 index 0000000..c78353e --- /dev/null +++ b/tests/lib/bash-compat.sh @@ -0,0 +1,115 @@ +# bash-compat.sh -- Shared bash 3.2+ compatibility helpers for the elfuse +# test harness. +# +# Copyright 2026 elfuse contributors +# SPDX-License-Identifier: Apache-2.0 +# +# shellcheck shell=bash +# +# macOS still ships bash 3.2.57 as /bin/bash (frozen since 2007 over the +# GPLv3 move). The Makefile invokes scripts via `bash tests/foo.sh`, so +# stock macOS picks up /bin/bash regardless of the env-bash shebang. This +# helper consolidates the small set of cross-version shims the suite +# needs so each script does not have to re-derive them. +# +# Source it as the first action after `set -uo pipefail`: +# +# # shellcheck source=tests/lib/bash-compat.sh +# . "$(dirname "${BASH_SOURCE[0]}")/lib/bash-compat.sh" +# +# Provides: +# epoch_us -- print current wall-clock time in microseconds +# bash_compat_require -- abort with a helpful message if BASH is too old +# +# Conventions for portable bash: +# - Do not expand "${array[@]}" when the array may be empty under set -u +# (bash 3.2 reports "unbound variable"). Use "${array[@]:+...}" or +# "${array[@]:0}" / "${array[@]:1}" offset forms instead, which are +# safe even on empty arrays. +# - Do not use 'declare -A' (associative arrays); use parallel indexed +# arrays plus a lookup helper. +# - Do not use $EPOCHREALTIME / $EPOCHSECONDS directly; call epoch_us +# from this helper. +# - Do not use ${var^^} / ${var,,} case-conversion; pipe through tr. +# - Do not use 'mapfile' / 'readarray'; use a 'while read' loop. + +# Minimum bash version that the elfuse harness supports. Stays at 3.2 so +# the stock macOS /bin/bash works without Homebrew. +: "${BASH_COMPAT_MIN_MAJOR:=3}" +: "${BASH_COMPAT_MIN_MINOR:=2}" + +bash_compat_require() +{ + if [ -z "${BASH_VERSINFO+set}" ]; then + echo "elfuse test harness must run under bash, not /bin/sh." >&2 + exit 127 + fi + local maj="${BASH_VERSINFO[0]}" + local min="${BASH_VERSINFO[1]}" + if [ "$maj" -lt "$BASH_COMPAT_MIN_MAJOR" ] \ + || { + [ "$maj" -eq "$BASH_COMPAT_MIN_MAJOR" ] \ + && [ "$min" -lt "$BASH_COMPAT_MIN_MINOR" ] + }; then + echo "elfuse test harness requires bash >=" \ + "${BASH_COMPAT_MIN_MAJOR}.${BASH_COMPAT_MIN_MINOR}" \ + "(found ${BASH_VERSION:-unknown})." >&2 + exit 127 + fi +} + +bash_compat_require + +# Pick the best available microsecond clock source. The chosen +# implementation is bound to 'epoch_us' so callers stay version-agnostic. +# +# Ordered for lowest per-call cost first: +# 1. $EPOCHREALTIME (bash 5.0+) -- builtin, no fork. +# 2. date '+%s %N' -- one fork; works under GNU date and +# BSD date on macOS 14+ (Sonoma added +# %N to the system date). +# 3. python3 -c ... -- one fork plus interpreter spin-up. +# 4. perl -MTime::HiRes ... -- one fork plus interpreter spin-up. +# 5. date +%s * 1e6 -- whole-second fallback so the suite +# does not silently abort. Comparisons +# lose microsecond resolution; callers +# that need it should ensure one of the +# earlier sources is available. +if [ -n "${EPOCHREALTIME:-}" ]; then + epoch_us() + { + local t="$EPOCHREALTIME" + local sec="${t%%.*}" + local frac="${t##*.}000000" + printf '%s\n' "$((sec * 1000000 + 10#${frac:0:6}))" + } +else + _bash_compat_ns_probe="$(date '+%N' 2> /dev/null || true)" + if [ "${#_bash_compat_ns_probe}" -eq 9 ] \ + && [ "${_bash_compat_ns_probe#N}" = "$_bash_compat_ns_probe" ]; then + epoch_us() + { + local out sec ns + out="$(date '+%s %N')" + sec="${out%% *}" + ns="${out##* }" + printf '%s\n' "$((sec * 1000000 + 10#$ns / 1000))" + } + elif command -v python3 > /dev/null 2>&1; then + epoch_us() + { + python3 -c 'import time; print(int(time.time()*1000000))' + } + elif command -v perl > /dev/null 2>&1; then + epoch_us() + { + perl -MTime::HiRes=time -e 'printf "%d\n", time()*1000000' + } + else + epoch_us() + { + printf '%s\n' "$(($(date '+%s') * 1000000))" + } + fi + unset _bash_compat_ns_probe +fi diff --git a/tests/lib/test-runner.sh b/tests/lib/test-runner.sh index 40b97bf..809f76c 100644 --- a/tests/lib/test-runner.sh +++ b/tests/lib/test-runner.sh @@ -7,6 +7,9 @@ # shellcheck shell=bash # shellcheck disable=SC2034 +# shellcheck source=tests/lib/bash-compat.sh +. "$(dirname "${BASH_SOURCE[0]}")/bash-compat.sh" + : "${TEST_LABEL_WIDTH:=14}" : "${TEST_TIMEOUT:=10}" @@ -45,20 +48,13 @@ elif ! command -v timeout > /dev/null 2>&1; then unset _timeout_bin _candidate fi -# Convert bash $EPOCHREALTIME (seconds.microseconds) to integer microseconds. -# run() uses this to disambiguate guest timeout(1) returning rc=124 from a -# harness watchdog firing at TEST_TIMEOUT; SECONDS resolution would mistake -# either case at short caps. Requires bash 5.0+, already assumed elsewhere -# (e.g. tests/test-perf.sh epoch_us). -_test_runner_epoch_us() -{ - local t="$EPOCHREALTIME" - local sec="${t%%.*}" - local frac="${t##*.}" - frac="${frac}000000" - frac="${frac:0:6}" - printf '%s' "$((sec * 1000000 + 10#$frac))" -} +# epoch_us is provided by bash-compat.sh: it picks the lowest-cost +# microsecond clock the host supports ($EPOCHREALTIME on bash 5.0+, +# 'date +%s %N' on macOS 14+/GNU coreutils, python3, perl, or a +# whole-second fallback). run() uses it to disambiguate the guest +# timeout(1) returning rc=124 from the harness watchdog firing at +# TEST_TIMEOUT; SECONDS resolution would mistake either case at short +# caps. if [ -t 1 ]; then # Use ANSI-C quoting so the variables hold real ESC bytes, not the literal @@ -147,20 +143,19 @@ run() # alone cannot tell the two apart, so wall-clock elapsed time is # used as an out-of-band marker: a harness firing means elapsed is # at or above TEST_TIMEOUT, while the guest case completes well - # under it. EPOCHREALTIME (bash 5.0+, already required elsewhere in - # this suite) is microsecond-resolution; comparing seconds alone - # via SECONDS could undercount by almost a full second and let a - # real harness timeout slip through as a guest-OK at small - # TEST_TIMEOUT values. + # under it. epoch_us (from bash-compat.sh) gives microsecond + # resolution; comparing seconds alone via SECONDS could undercount + # by almost a full second and let a real harness timeout slip + # through as a guest-OK at small TEST_TIMEOUT values. local start_us end_us elapsed_us limit_us - start_us=$(_test_runner_epoch_us) - if output=$(timeout "$TEST_TIMEOUT" "${TEST_RUNNER[@]}" \ + start_us=$(epoch_us) + if output=$(timeout "$TEST_TIMEOUT" ${TEST_RUNNER[@]+"${TEST_RUNNER[@]}"} \ "$(test_tool_path "$tool")" "$@" 2>&1); then rc=0 else rc=$? fi - end_us=$(_test_runner_epoch_us) + end_us=$(epoch_us) elapsed_us=$((end_us - start_us)) limit_us=$((TEST_TIMEOUT * 1000000)) local harness_timed_out=0 @@ -203,7 +198,7 @@ run_check() # See run() for the timeout-vs-expected ordering rationale. run_check # has no explicit expect_rc parameter (zero is implied), so any rc=124 # here is treated as a harness timeout. - if output=$(timeout "$TEST_TIMEOUT" "${TEST_RUNNER[@]}" \ + if output=$(timeout "$TEST_TIMEOUT" ${TEST_RUNNER[@]+"${TEST_RUNNER[@]}"} \ "$(test_tool_path "$tool")" "$@" 2>&1); then rc=0 else @@ -261,7 +256,7 @@ run_pipe() fi if output=$(printf '%s' "$input" \ - | timeout "$TEST_TIMEOUT" "${TEST_RUNNER[@]}" "$(test_tool_path "$tool")" "$@" 2>&1); then + | timeout "$TEST_TIMEOUT" ${TEST_RUNNER[@]+"${TEST_RUNNER[@]}"} "$(test_tool_path "$tool")" "$@" 2>&1); then rc=0 else rc=$? @@ -295,7 +290,7 @@ run_timeout() return fi - if output=$(timeout "$secs" "${TEST_RUNNER[@]}" "$(test_tool_path "$tool")" "$@" 2>&1); then + if output=$(timeout "$secs" ${TEST_RUNNER[@]+"${TEST_RUNNER[@]}"} "$(test_tool_path "$tool")" "$@" 2>&1); then rc=0 else rc=$? diff --git a/tests/test-matrix.sh b/tests/test-matrix.sh index ad6921b..9b0d8b6 100755 --- a/tests/test-matrix.sh +++ b/tests/test-matrix.sh @@ -154,7 +154,7 @@ run_elfuse() "${FIXTURES}/aarch64-musl/dyn-bin"/*) args+=(--sysroot "$GUEST_SYSROOT") ;; esac fi - timeout 30 "$ELFUSE" "${args[@]}" "$@" 2> /dev/null + timeout 30 "$ELFUSE" ${args[@]+"${args[@]}"} "$@" 2> /dev/null } # 'timeout' cannot wrap a shell function, so this runner inlines the path @@ -168,12 +168,14 @@ run_qemu() local args=() a for a in "$@"; do case "$a" in - "${REPO_ROOT}"/*) args+=("/mnt/host/${a#${REPO_ROOT}/}") ;; + "${REPO_ROOT}"/*) args+=("/mnt/host/${a#"${REPO_ROOT}"/}") ;; *) args+=("$a") ;; esac done - local quoted - printf -v quoted '%q ' "${args[@]}" + local quoted="" + if [ "${#args[@]}" -gt 0 ]; then + printf -v quoted '%q ' "${args[@]}" + fi timeout 60 ssh \ -o StrictHostKeyChecking=no \ -o UserKnownHostsFile=/dev/null \ @@ -865,36 +867,60 @@ run_suite() # Baselines". Bump these counts in the same commit that grows or trims # any sub-suite's Results line so the matrix gate stays in sync. # -# Keys MUST match the lookup strings exactly. Every subscript here is -# explicitly quoted ("elfuse-aarch64", etc.) because shfmt parses -# unquoted bareword subscripts as arithmetic, which expands [a-b] to -# [a - b]. Bash then treats the spaced and unspaced forms as different -# keys, so the baseline gate silently goes dead. This regression has -# bitten the tree three times; keep the quotes and do not rewrite this -# block back into the 'declare -A NAME=( ... )' initialiser form -# either (the same arithmetic-rewrite hits subscripts in that form). -declare -A EXPECTED_MIN_PASS -declare -A EXPECTED_FAIL -EXPECTED_MIN_PASS["elfuse-aarch64"]=180 -EXPECTED_FAIL["elfuse-aarch64"]=0 -EXPECTED_MIN_PASS["qemu-aarch64"]=180 -EXPECTED_FAIL["qemu-aarch64"]=1 - -# x86_64 baselines are keyed by detected host SoC class -# (see detect_x86_64_host_class below). The two M-series classes -# diverge inside sys_mmap_fixed_high_va on IPA width: apple-m1-m2 is -# 36-bit (overflow-segment path), apple-m3-plus is 40-bit (bisected -# -slab path on M5). The seven Rosetta sub-suites currently emit fixed -# pass counts regardless of IPA width, so both rows start at 71; an -# operator with M3+ hardware updates the apple-m3-plus row in place -# when their observed counts diverge. apple-unknown is the fallback -# row for SoC strings the detector does not recognise yet. -EXPECTED_MIN_PASS["elfuse-x86_64:apple-m1-m2"]=71 -EXPECTED_FAIL["elfuse-x86_64:apple-m1-m2"]=0 -EXPECTED_MIN_PASS["elfuse-x86_64:apple-m3-plus"]=71 -EXPECTED_FAIL["elfuse-x86_64:apple-m3-plus"]=0 -EXPECTED_MIN_PASS["elfuse-x86_64:apple-unknown"]=71 -EXPECTED_FAIL["elfuse-x86_64:apple-unknown"]=0 +# Per-mode baseline gate. Encoded as "key|min_pass|max_fail" tuples in a +# single indexed array so stock macOS bash 3.2 (which lacks 'declare -A') +# works the same as bash 4+. +# +# The bareword-subscript hazard the prior 'declare -A' form had to dodge +# (shfmt rewriting [a-b] into [a - b] inside subscripts) does not apply +# to this encoding: keys live inside a quoted string, never as a +# subscript expression. +# +# Order: aarch64 baselines first, then x86_64 baselines keyed by detected +# host SoC class. The two M-series classes diverge inside +# sys_mmap_fixed_high_va on IPA width (apple-m1-m2 is 36-bit, the +# overflow-segment path; apple-m3-plus is 40-bit, the bisected-slab +# path on M5). The seven Rosetta sub-suites currently emit fixed pass +# counts regardless of IPA width, so both rows start at 71; an operator +# with M3+ hardware updates the apple-m3-plus row in place when their +# observed counts diverge. apple-unknown is the fallback row for SoC +# strings the detector does not recognize yet. +EXPECTED_BASELINES=( + "elfuse-aarch64|180|0" + "qemu-aarch64|180|1" + "elfuse-x86_64:apple-m1-m2|71|0" + "elfuse-x86_64:apple-m3-plus|71|0" + "elfuse-x86_64:apple-unknown|71|0" +) + +# Look up the (min_pass, max_fail) baseline for a mode key. Writes the +# values into the named output variables and returns 0 on hit, 1 on +# miss. Callers use the rc=1 path as "no recorded baseline for this key, +# stay silent". +# +# Parses from the right so a key containing a literal '|' would still +# split correctly (today the schema has none, but the right-anchored +# form is no harder to read and removes the trap). printf -v sets the +# named output without eval; printf -v exists in bash 3.1+. +expected_baseline_get() +{ + local target="$1" + local out_min="$2" + local out_fail="$3" + local entry head key min max + for entry in "${EXPECTED_BASELINES[@]}"; do + max="${entry##*|}" + head="${entry%|*}" + min="${head##*|}" + key="${head%|*}" + if [ "$key" = "$target" ]; then + printf -v "$out_min" '%s' "$min" + printf -v "$out_fail" '%s' "$max" + return 0 + fi + done + return 1 +} # Host SoC class detector for x86_64 baseline selection. Reads # machdep.cpu.brand_string (sysctl), which Apple Silicon Macs publish @@ -961,9 +987,8 @@ verify_expected_counts() key="${mode}:${host_class}" fi - local exp_min="${EXPECTED_MIN_PASS[$key]:-}" - local exp_fail="${EXPECTED_FAIL[$key]:-}" - if [ -z "$exp_min" ] || [ -z "$exp_fail" ]; then + local exp_min="" exp_fail="" + if ! expected_baseline_get "$key" exp_min exp_fail; then # No recorded baseline for this key (experimental local mode, or # an x86_64 host class the detector did not classify). Stay # silent so the matrix runner remains usable as a smoke probe. diff --git a/tests/test-perf.sh b/tests/test-perf.sh index 78f90a6..82fa5b0 100755 --- a/tests/test-perf.sh +++ b/tests/test-perf.sh @@ -10,7 +10,10 @@ # comes from VM startup (~1-3ms) and per-syscall vmexits (~1-5us each). # Pure computation (regex matching etc.) runs at native speed. # -# Timing uses bash $EPOCHREALTIME (microsecond precision, no external deps). +# Timing uses the epoch_us helper from tests/lib/bash-compat.sh, which +# prefers $EPOCHREALTIME (bash 5.0+) and falls back to 'date +%s %N' +# (macOS 14+/GNU coreutils) or python3 / perl so the script works on +# stock macOS bash 3.2 too. # # Usage: tests/test-perf.sh # Example: tests/test-perf.sh build/elfuse /path/to/tool/bin @@ -21,6 +24,9 @@ set -euo pipefail # Without pipefail, a producer crash returns rc=0 from the pipeline, # so the elfuse-side failure was silently smoothed into a "fast" sample. +# shellcheck source=tests/lib/bash-compat.sh +. "$(dirname "$0")/lib/bash-compat.sh" + ELFUSE="${1:?Usage: $0 }" TOOL_BIN="${2:?Usage: $0 }" SRCDIR="$(cd "$(dirname "$0")/.." && pwd)" @@ -36,19 +42,6 @@ RESET='\033[0m' RUNS=10 PATTERN="syscall" -# Convert $EPOCHREALTIME (seconds.microseconds) to integer microseconds. -# Bash arithmetic can't handle floats, so we split on '.' and combine. -epoch_us() -{ - local t="$EPOCHREALTIME" - local sec="${t%%.*}" - local frac="${t##*.}" - # Pad/truncate frac to 6 digits - frac="${frac}000000" - frac="${frac:0:6}" - echo $((sec * 1000000 + 10#$frac)) -} - PERF_FAILED=0 # Collect $RUNS timing samples for a command, print median and stats. diff --git a/tests/test-rosetta-alpine.sh b/tests/test-rosetta-alpine.sh index fbae766..4b073a2 100755 --- a/tests/test-rosetta-alpine.sh +++ b/tests/test-rosetta-alpine.sh @@ -195,8 +195,8 @@ run_pipe() # that fails after emitting matching text would silently pass. out="$( set -o pipefail - "$TIMEOUT" 15 "$ELFUSE" "${args_a[@]}" 2> /dev/null \ - | "$TIMEOUT" 15 "$ELFUSE" "${args_b[@]}" 2> /dev/null + "$TIMEOUT" 15 "$ELFUSE" ${args_a[@]+"${args_a[@]}"} 2> /dev/null \ + | "$TIMEOUT" 15 "$ELFUSE" ${args_b[@]+"${args_b[@]}"} 2> /dev/null )" rc=$? set -e