#!/usr/bin/env bash # Self-test for the shared-state checker in bin/pio-test-isolate.sh. # # A checker that silently matches everything passes forever and nobody finds out - which is exactly # how the leak it exists to catch survived. So it ships with fixtures: stand-in "suites" that write # nothing, write exactly what they declare, write something undeclared, and declare a write they # never make, asserting CLEAN / CLEAN / DIRTY / MISSING respectively. Plus one that proves the # before-empty assertion fires, because an after-diff measured against a dirty baseline reports # green while meaning nothing. # # Not a Unity suite and not a test_* directory, so outside the suite count run-tests.sh derives # from test/ - the same arrangement as bin/test-config-check.sh, and for the same reason: what it # asserts is the behaviour of a process, not of a linkable function. # # Usage: ./bin/test-state-check.sh (exit 0 = all fixtures behaved) set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" cd "$ROOT_DIR" || exit 1 # shellcheck source=bin/lib/test-state.sh source "$SCRIPT_DIR/lib/test-state.sh" WORK="$(mktemp -d -t meshstatecheck.XXXXXX)" trap 'rm -rf "$WORK"' EXIT PREFS=".portduino/default/prefs" MANIFEST="$WORK/manifest.tsv" cat >"$MANIFEST" <"$FAKE" <<'EOF' #!/usr/bin/env bash set -u echo "test/${FIXTURE_SUITE}/test_main.cpp:1:test_fixture:PASS" mkdir -p "$HOME/.portduino/default/prefs" for f in ${FIXTURE_WRITES:-}; do printf 'fixture payload\n' > "$HOME/.portduino/default/prefs/$f" done exit "${FIXTURE_RC:-0}" EOF chmod +x "$FAKE" PASSES=0 FAILURES=0 # Run one fixture through the real wrapper and compare the verdict it recorded. expect_verdict() { local label="$1" suite="$2" writes="$3" want="$4" local state_dir="$WORK/state-$suite" local summary="$state_dir/summary.tsv" rm -rf "$state_dir" mkdir -p "$state_dir" FIXTURE_SUITE="$suite" FIXTURE_WRITES="$writes" \ MESHTASTIC_TEST_STATE_DIR="$state_dir" \ MESHTASTIC_TEST_STATE_SUMMARY="$summary" \ MESHTASTIC_TEST_STATE_MANIFEST="$MANIFEST" \ "$SCRIPT_DIR/pio-test-isolate.sh" "$FAKE" >/dev/null 2>&1 local got got="$(awk -F'\t' -v s="$suite" '$1 == s { print $3; exit }' "$summary" 2>/dev/null)" if [[ $got == "$want" ]]; then echo " PASS $label - $want" PASSES=$((PASSES + 1)) else echo " FAIL $label - expected $want, got '${got:-}'" FAILURES=$((FAILURES + 1)) fi } echo "Fixture suites (verdict axis):" expect_verdict "writes nothing" test_fixture_clean "" CLEAN expect_verdict "writes what it declares" test_fixture_declared "nodes.proto" CLEAN expect_verdict "writes something undeclared" test_fixture_undeclared "nodes.proto warm.dat" DIRTY expect_verdict "declares a write it skips" test_fixture_missing "" MISSING # Survivor axis. Stands in for a suite that ends on a bare UNITY_END(): it prints its Unity line and # returns, but leaves a process running inside the sandbox $HOME, exactly as the runtime's loop() # does. Asserts the wrapper both records it and kills it - a detector that reports without reaping # would leave the host accumulating processes, which is half the harm. echo echo "Survivor axis (state_find_survivors):" LEAKY="$WORK/leaky-suite.sh" cat >"$LEAKY" <<'EOF' #!/usr/bin/env bash set -u echo "test/${FIXTURE_SUITE}/test_main.cpp:1:test_fixture:PASS" mkdir -p "$HOME/.portduino/default/prefs" # Detached from this shell's stdout so the wrapper's `| tee` sees EOF and the pipeline returns - # the survivor outlives the suite exactly as a spun loop() does. setsid sleep 300 >/dev/null 2>&1 & survivor=$! # A real survivor has been running for the whole suite; this one is a microsecond old, and the # wrapper scans the instant this shell exits - so wait for the exec before reporting it up. for _ in {1..500}; do [[ "$(cat "/proc/$survivor/comm" 2>/dev/null)" == sleep ]] && break; sleep 0.01; done # Recheck rather than trust the loop: falling out of it on the timeout would stage a pid the # fixture never saw reach exec, which is the race this wait exists to close. [[ "$(cat "/proc/$survivor/comm" 2>/dev/null)" == sleep ]] || { echo "fixture: survivor never reached exec" >&2; exit 1; } printf '%s\n' "$survivor" > "$HOME/../survivor.pid" exit 0 EOF chmod +x "$LEAKY" survivor_dir="$WORK/state-survivor" mkdir -p "$survivor_dir" # KEEP_STATE so the sandbox stays whatever the verdict: without it a missed survivor also deletes # the pid file staged inside it, and the reap assertion below fails for the wrong reason. FIXTURE_SUITE=test_fixture_survivor \ MESHTASTIC_TEST_STATE_DIR="$survivor_dir" \ MESHTASTIC_TEST_STATE_SUMMARY="$survivor_dir/summary.tsv" \ MESHTASTIC_TEST_STATE_MANIFEST="$MANIFEST" \ MESHTASTIC_TEST_KEEP_STATE=1 \ "$SCRIPT_DIR/pio-test-isolate.sh" "$LEAKY" >/dev/null 2>"$survivor_dir/wrapper.err" # Find the pid file by search, not by a glob that assumes a directory depth: the wrapper renames # its mktemp'd sandbox to the suite name when it keeps it, so the path is not fixed. leaked_pid="$(head -1 "$(find "$survivor_dir" -name survivor.pid -print -quit 2>/dev/null)" 2>/dev/null)" recorded="$(awk -F'\t' '$1 == "test_fixture_survivor" { print $6; exit }' "$survivor_dir/summary.tsv" 2>/dev/null)" if [[ -n ${recorded// /} ]]; then echo " PASS a process outliving the suite is reported" PASSES=$((PASSES + 1)) else echo " FAIL a process outliving the suite went unreported" # Name the cause here rather than spending a CI round-trip on it: whether the fixture's # process exists at all, whether the scan can see it, and what the wrapper recorded instead. visible_pids="$(ps -u "$(id -u)" -o pid= 2>/dev/null | tr -d ' ')" if [[ -z $leaked_pid ]]; then alive="no pid staged" listed="n/a" its_home="" else kill -0 "$leaked_pid" 2>/dev/null && alive="alive" || alive="gone" grep -Fxq "$leaked_pid" <<<"$visible_pids" && listed="yes" || listed="no" its_home="$(tr '\0' '\n' <"/proc/$leaked_pid/environ" 2>/dev/null | grep -m1 '^HOME=')" fi echo " summary line: $(awk -F'\t' '$1 == "test_fixture_survivor"' "$survivor_dir/summary.tsv" 2>/dev/null | tr '\t' '|')" echo " staged pid ${leaked_pid:-}: $alive, listed by ps: $listed, its ${its_home:-}" echo " ps -u $(id -u) listed $(grep -c . <<<"$visible_pids") pids; the sandbox is under $survivor_dir" echo " wrapper stderr: $(tr '\n' '|' <"$survivor_dir/wrapper.err" 2>/dev/null)" echo " same scan, run again now: [$(state_find_survivors "${its_home#HOME=}" | tr '\n' ' ')]" FAILURES=$((FAILURES + 1)) fi # Assert the pid file was found BEFORE asserting the process is gone - otherwise an empty pid takes # the "not running" branch and the check passes without having checked anything. if [[ -z $leaked_pid ]]; then echo " FAIL no survivor pid recorded - the reap assertion would pass vacuously" FAILURES=$((FAILURES + 1)) elif kill -0 "$leaked_pid" 2>/dev/null; then echo " FAIL the survivor was reported but left running (pid $leaked_pid)" kill -9 "$leaked_pid" 2>/dev/null FAILURES=$((FAILURES + 1)) else echo " PASS the survivor is reaped, not just reported (pid $leaked_pid)" PASSES=$((PASSES + 1)) fi # Guard the guard. The wrapper mktemp's its own sandbox name, so the leak cannot be staged through # it; exercise the assertion the wrapper actually calls instead - same function, same code path. echo echo "Before-empty assertion (state_assert_empty):" seeded="$WORK/seeded" mkdir -p "$seeded/$PREFS" printf 'stale\n' >"$seeded/$PREFS/nodes.proto" if state_assert_empty "$seeded" 2>/dev/null; then echo " FAIL a dirty sandbox was accepted - the after-diff would measure against the wrong baseline" FAILURES=$((FAILURES + 1)) else echo " PASS a dirty sandbox is refused" PASSES=$((PASSES + 1)) fi empty="$WORK/empty" mkdir -p "$empty" if state_assert_empty "$empty" 2>/dev/null; then echo " PASS an empty sandbox is accepted" PASSES=$((PASSES + 1)) else echo " FAIL an empty sandbox was refused - the assertion matches everything" FAILURES=$((FAILURES + 1)) fi echo if ((FAILURES > 0)); then echo "RESULT: RED state-checker self-test - $FAILURES of $((PASSES + FAILURES)) fixtures behaved unexpectedly" exit 1 fi echo "RESULT: GREEN state-checker self-test - $PASSES/$PASSES fixtures behaved as specified" exit 0