Files
firmware/bin/lib/test-state.sh
Thomas Göttgens 9c0a331309 fix(test): stop the survivor scan reporting a hit as a miss (#11603)
* test-state: match the sandbox HOME in-shell so a survivor hit cannot report as a miss

* Trim the survivor-scan comment to two lines

* test-state: read the environ with a NUL-delimited read loop, not mapfile -d

* test-state-check: report the survivor's actual HOME and the wrapper's stderr

* test-state-check: re-run the scan when it reports a miss, to separate a race from a mismatch

* test-state-check: let the survivor fixture finish exec before the suite returns

* test-state-check: fail the survivor fixture instead of staging a pid it never saw exec
2026-08-25 10:08:32 +00:00

227 lines
9.4 KiB
Bash

# shellcheck shell=bash
#
# Shared helpers for the native test harness's shared-state check. Sourced by
# bin/pio-test-isolate.sh (which enforces it per suite) and bin/test-state-check.sh (which proves
# the checker itself still works). Nothing here executes on its own.
#
# Linux-only, like the rest of the native harness: this uses GNU coreutils behaviour (`find -printf`,
# md5sum) rather than carrying a per-host fallback. bin/run-tests.sh states and enforces that.
#
# The check answers one question: did this suite change any file it did not declare? It deliberately
# does NOT compare file *contents* against a baseline. Content baselines over protobuf bytes are
# snapshot tests - add a field to NodeInfoLite and every recorded hash in the repo churns, which is
# how snapshot suites turn into an --update-all ritual and then into noise. Hashes are used only to
# answer the boolean "did this change?"; what gets declared and reviewed is the set of paths.
STATE_MANIFEST_DEFAULT="test/state-manifest.tsv"
# Files the native binaries persist under $HOME. Listed for documentation and for the
# --write-manifest hint; the scan itself is unfiltered, so a suite writing somewhere unexpected is
# still caught.
# shellcheck disable=SC2034 # referenced by callers and by the docs
STATE_KNOWN_FILES="nodes.proto config.proto channels.proto module.proto device.proto warm.dat transmit_history.dat"
# Guard the guard: refuse to run a suite against a sandbox that is not empty. If isolation ever
# leaks, the after-diff measures against the wrong baseline and the whole check reports CLEAN while
# meaning nothing - so before-empty is as load-bearing as after-diff. Returns non-zero and explains
# itself rather than carrying on.
state_assert_empty() {
local dir="$1"
if [[ -n $(find "$dir" -mindepth 1 -print -quit 2>/dev/null) ]]; then
echo "test-state: sandbox $dir is not empty before the suite ran - isolation is broken" >&2
return 1
fi
return 0
}
# Fingerprint every file under $1 as "<relative-path> <md5>", sorted. Empty output for an empty or
# missing tree. Output is fed to comm/diff, so the sort order has to be stable across calls.
#
# GNU `find -printf` and md5sum(1), deliberately: this harness is Linux-only and bin/run-tests.sh
# refuses to start anywhere else, so there is no host here that needs a BSD fallback.
state_fingerprint() {
local root="$1"
[[ -d $root ]] || return 0
(
cd "$root" || return 0
find . -type f -printf '%P\n' 2>/dev/null | LC_ALL=C sort | while IFS= read -r rel; do
printf '%s %s\n' "$rel" "$(md5sum -- "$rel" 2>/dev/null | cut -d' ' -f1)"
done
)
}
# Processes still running inside the suite's sandbox $HOME. Prints one PID per line.
#
# A suite that ends on a bare UNITY_END() does not stop: setup() returns, the runtime keeps calling
# loop(), and PlatformIO - which reports a suite from its Unity output, not from process exit -
# moves on with the binary still resident. Nothing else notices, and the damage is quiet: the
# sandbox gets deleted under a live process, so the after-fingerprint below describes what the suite
# had written when we stopped looking rather than what it left behind, and .gcda plus LeakSanitizer
# both flush from atexit handlers that never run.
#
# Matching on the environment rather than on a remembered PID is deliberate: the sandbox HOME is
# mktemp-unique per suite, so this identifies survivors whatever their parentage - a fork, a
# grandchild, a process already reparented to init - none of which a $! comparison would catch.
# Scoped to this user's processes: /proc/<pid>/environ is unreadable for anyone else's anyway, and
# the narrower sweep costs ~270ms against ~460ms for all of /proc.
state_find_survivors() {
local home="$1" pid entry
[[ -n $home ]] || return 0
for pid in $(ps -u "$(id -u)" -o pid= 2>/dev/null); do
[[ $pid == "$$" ]] && continue
# In-shell, not `tr | grep -qxF`: -q closes the pipe on the match, tr takes SIGPIPE, and
# under `set -o pipefail` the hit reports as a miss. Grouped so a vanished pid is silent.
{
while IFS= read -r -d '' entry; do
if [[ $entry == "HOME=$home" ]]; then
printf '%s\n' "$pid"
break
fi
done <"/proc/$pid/environ"
} 2>/dev/null
done
}
# Paths present in the "after" fingerprint ($2) that are absent or different in "before" ($1).
# Prints one relative path per line.
state_changed_paths() {
local before="$1" after="$2"
LC_ALL=C comm -13 <(LC_ALL=C sort "$before") <(LC_ALL=C sort "$after") | awk '{print $1}' | LC_ALL=C sort -u
}
# Read a suite's flag string out of the manifest. Empty when the suite has no entry, which is the
# default and means "isolated": fresh state in, contents discarded out.
#
# Manifest format - TSV, three columns, the same shape as an allowlist entry: the thing, what it is
# allowed to do, and why. The reason column is mandatory and is what a reviewer reads.
#
# test_nodedb_blocked<TAB>state=per-suite writes=nodes.proto<TAB>saturates the DB to test the cap
state_manifest_flags() {
local suite="$1" manifest="${2:-$STATE_MANIFEST_DEFAULT}"
[[ -f $manifest ]] || return 0
awk -F'\t' -v s="$suite" '!/^[[:space:]]*#/ && $1 == s { print $2; exit }' "$manifest"
}
# Pull one flag's value out of a flag string: state_flag_value "writes" "state=per-suite writes=a,b"
state_flag_value() {
local key="$1" flags="$2" f
for f in $flags; do
[[ $f == "$key="* ]] && {
printf '%s' "${f#"$key="}"
return 0
}
done
return 0
}
# Does a changed path match a declared write? A declaration matches either the full path relative to
# the scratch HOME or just the basename, because the useful name for these is the basename
# (`nodes.proto`) and nobody should have to write .portduino/default/prefs/ in front of it.
state_path_declared() {
local path="$1" declared="$2" entry
IFS=',' read -ra _entries <<<"$declared"
for entry in "${_entries[@]}"; do
[[ -z $entry ]] && continue
[[ $path == "$entry" || ${path##*/} == "$entry" ]] && return 0
done
return 1
}
# Classify a suite's leftovers. Prints "<verdict>\t<detail>" where verdict is one of:
#
# CLEAN nothing changed, or everything that changed was declared
# DIRTY at least one undeclared path changed - the finding this whole check exists for
# MISSING every changed path was declared, but a declared path did NOT change
#
# MISSING is reported separately rather than folded into DIRTY because it catches the opposite bug:
# persistence that silently stopped happening. That is a real class here - the TAK config bug
# upstream was a has_ flag never being set, so the save wrote nothing and no test noticed. It starts
# as a warning because some declared writes are legitimately conditional.
state_classify() {
local changed="$1" declared="$2"
local undeclared=() missing=() path entry found
while IFS= read -r path; do
[[ -z $path ]] && continue
if ! state_path_declared "$path" "$declared"; then
undeclared+=("$path")
fi
done <<<"$changed"
# Ask state_path_declared() in the other direction rather than matching by hand: one rule for
# "does this path match this declaration", so the two directions cannot drift apart. Matching
# an entry as a regex would also let a metacharacter in a manifest name (`.`, `+`) match a file
# that is not the declared one.
IFS=',' read -ra _declared <<<"$declared"
for entry in "${_declared[@]}"; do
[[ -z $entry ]] && continue
found=1
while IFS= read -r path; do
[[ -z $path ]] && continue
if state_path_declared "$path" "$entry"; then
found=0
break
fi
done <<<"$changed"
((found)) && missing+=("$entry")
done
if ((${#undeclared[@]} > 0)); then
printf 'DIRTY\tundeclared: %s\n' "${undeclared[*]}"
elif ((${#missing[@]} > 0)); then
printf 'MISSING\tdeclared but unwritten: %s\n' "${missing[*]}"
else
printf 'CLEAN\t\n'
fi
}
# --- Error-line budget -------------------------------------------------------------------------
#
# A second orthogonal axis, like CLEAN/DIRTY above: a suite can pass while emitting six figures of
# LOG_ERROR, which buries a real failure and trains everyone to skim. The budget is declared in the
# same manifest, as an `errors=` flag, and it is a RANGE rather than a ceiling - for a fuzz suite the
# floor is the load-bearing half. test_fuzz_decode logging ~100k rejections is it working; the same
# suite logging none means it stopped feeding malformed input, and every case would still pass.
#
# Undeclared suites get ERROR_BUDGET_DEFAULT. Declared forms: "N" (max), "MIN..MAX", "MIN.." (floor
# only). Everything is inclusive.
ERROR_BUDGET_DEFAULT=100
# Count LOG_ERROR lines in a suite's captured output.
state_count_errors() {
local log="$1"
[[ -f $log ]] || {
printf '0'
return 0
}
# `|| true`, not `|| printf 0`: grep -c already prints 0 before exiting 1 on no match, so a
# fallback that prints appends a second line and the caller gets "0\n0" to do arithmetic on.
grep -cE '^ERROR +\|' "$log" 2>/dev/null || true
}
# VERDICT<TAB>DETAIL. WITHIN / OVER / UNDER, mirroring state_classify()'s shape.
state_classify_errors() {
local count="$1" declared="$2" min=0 max="$ERROR_BUDGET_DEFAULT"
if [[ -n $declared ]]; then
if [[ $declared == *".."* ]]; then
min="${declared%%..*}"
max="${declared##*..}"
[[ -z $max ]] && max=""
else
max="$declared"
fi
fi
if [[ -n $max ]] && ((count > max)); then
printf 'OVER\t%d error line(s), budget %s' "$count" "${declared:-$ERROR_BUDGET_DEFAULT}"
return 0
fi
if ((count < min)); then
printf 'UNDER\t%d error line(s), expected at least %d - is it still exercising the path?' \
"$count" "$min"
return 0
fi
printf 'WITHIN\t%d' "$count"
}