tinymux/tests/codiff/run.sh
Stephen Dennis c7f064216d test(codiff): validate the host leg by running it, not by probing a symbol (#2055)
On a box with any libmux.so on its library path, test-codiff reported

  --- comparing 0 transcript lines ---
    interp  vs host   : DIFFER (1914 lines)
    dbt     vs host   : DIFFER (1914 lines)
  FAIL: routes disagree -- see above.

Nothing disagreed. The host leg never ran -- it died with an undefined
reference to co_delete_at, and run.sh discarded its stderr -- so the reference
transcript was empty and every route "differed" from it by its whole length.
That is exactly the inversion the script's own comment says it exists to
avoid, and it named the wrong culprit while doing it.

The guard written for that hazard did not close it, for two reasons:

  * The canary decays.  It probed for co_insert_at, which the months-old
    libmux in ~/lib still exports; the symbol that actually failed was
    co_delete_at.  Any hardcoded name stops discriminating as the library
    grows past it -- silently, with no build error to notice.

  * Choosing the right directory does not mean the right library LOADS.
    -Wl,-rpath emits RUNPATH under the default --enable-new-dtags, and the
    loader searches LD_LIBRARY_PATH BEFORE RUNPATH.  So the guard selected
    mux/lib correctly and the loader ignored it.

So stop asking what a candidate exports. Link against it, run it with the path
pinned, and keep the first that produces a transcript. "Does this work" cannot
decay, and it subsumes the canary question rather than restating it.

The host leg is now built and run during selection, so the duplicate build and
run further down are gone; the run that proved the choice is the transcript
that gets compared.

Note the run check is deliberately NOT a pipeline: `!` on `a | b` tests b, and
strip_marker is a grep that exits 1 when it selects nothing -- which would have
called a good host leg broken and an empty one fine.

Verified on Kagura, where LD_LIBRARY_PATH=/home/sdennis/lib is set from the
login profile -- the condition that caused this:

  * unpinned `make test-codiff`: PASS, 1914 lines, interp OK, dbt OK.
  * negative control -- the stale libmux copied over mux/lib/libmux.so:
    both candidates are reported as not linking, naming co_delete_at, and
    the run SKIPs (exit 0) instead of failing as a route disagreement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 15:06:16 -06:00

233 lines
9.3 KiB
Bash
Executable file

#!/bin/bash
#
# run.sh — does color_ops.c mean the same thing on every route that
# actually executes it?
#
# color_ops.c is compiled TWICE -- once into libmux for the host, and
# once into the freestanding RV64 blob -- and the blob is then executed
# by two engines of our own. So there are four implementations of the
# same source in play, and #2019 is what happens when one of them
# disagrees: a change can be provably correct in C, provably correct as
# RV64, and still wrong in the artifact production runs.
#
# Four routes, one battery, one transcript format:
#
# host color_ops.c as built into libmux. When the working tree
# holds a rewrite, this leg is the PRE-change implementation
# (libmux is not rebuilt here), which makes it the
# specification rather than a hand-written expectations table.
# qemu qemu-riscv64-static running the guest ELF. The external
# oracle -- the only route here that neither we nor the tree
# wrote, so it is what settles "is the RISC-V correct".
# interp our rv64_interp_run over the same ELF.
# dbt our DBT over the same ELF. This is what the JIT runs.
#
# One guest binary serves the last three: it uses only Linux syscalls
# 64 (write) and 93 (exit), which is exactly what the ELF harness in
# mux/modules/engine/dbt_test.cpp implements, so all three execute the
# same instruction stream rather than three builds of the same source.
#
# The battery is fixed cases + max_words-cap cases + seeded random
# cases, with the SAME LCG on every route so the inputs are identical
# by construction. The cap cases are not decoration: a cap-check bug
# moves 4 transcript lines and the random leg catches it never, while a
# common-path bug moves 212. Both controls are in the header.
#
# Skips loudly, never quietly: this needs a RISC-V cross-compiler, and
# the qemu leg needs qemu-riscv64-static. A missing tool must read as
# "not tested here", not as a pass.
#
set -u
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
ROOT=$(cd "$SCRIPT_DIR/../.." && pwd)
BUILD="$SCRIPT_DIR/build"
ITERS=${CODIFF_ITERS:-200}
RVCC=${RVCC:-riscv64-unknown-elf-gcc}
QEMU=${QEMU:-qemu-riscv64-static}
# CODIFF_CO_SRC points the GUEST legs at a different color_ops.c while the
# host leg keeps using the libmux already built -- which turns this into a
# differential of a candidate rewrite against the shipped implementation,
# and is also how the negative controls are run. Defaults to the tree's.
CO_SRC=${CODIFF_CO_SRC:-$ROOT/mux/lib/color_ops.c}
fail=0
note() { printf '%s\n' "$*"; }
note "=== color_ops multi-route differential (#2019) ==="
# --- tool availability -------------------------------------------------
if ! command -v "$RVCC" >/dev/null 2>&1; then
note "SKIP: no $RVCC on this box."
note " The guest legs (qemu/interp/dbt) cannot be built, so this"
note " test covers NOTHING here. Not a pass."
exit 0
fi
ARCH=$(uname -m)
case "$ARCH" in
x86_64|aarch64|arm64) ;;
*)
note "SKIP: no DBT backend for host '$ARCH'; the DBT leg cannot run."
exit 0
;;
esac
mkdir -p "$BUILD"
strip_marker() { grep -v '^DONE$'; }
# A file named libmux.so is not necessarily a libmux this test can link
# against. mux/game/bin/libmux.so is a symlink created by `make install`,
# and on a box that has also built another line (release/2.13, say) it can
# be left pointing at that build -- so the host leg dies with an undefined
# reference and the run reads as a FAILURE of the code under test rather
# than "cannot test here". That is the exact inversion this script exists
# to avoid.
#
# Probing for a hardcoded symbol did not settle it, and failed twice over
# (#2055):
#
# * The canary decays. The probe was co_insert_at, which a months-old
# libmux in ~/lib still exported; the symbol that actually failed to
# resolve was co_delete_at. Any fixed name stops discriminating as
# the library grows past it, silently and without a build error.
# * Choosing the right directory does not mean the right library LOADS.
# -Wl,-rpath emits RUNPATH under the default --enable-new-dtags, and
# the loader searches LD_LIBRARY_PATH BEFORE RUNPATH. A developer
# with any libmux.so on their library path gets that one regardless
# of what was chosen here.
#
# So do not ask what a candidate exports. Link against it, RUN it with
# the path pinned, and keep the first one that actually produces a
# transcript. "Does this work" cannot decay.
host_build() {
gcc -O2 -DFUZZ_ITERS=$ITERS -I"$SCRIPT_DIR" -I"$ROOT/mux/include" \
-o "$BUILD/host.bin" "$SCRIPT_DIR/host_main.c" \
-L"$1" -lmux -Wl,-rpath,"$1"
}
host_exec() {
# Prepend rather than replace: the caller may need their own path for
# unrelated libraries. Ours going first is the whole point.
LD_LIBRARY_PATH="$1${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" "$BUILD/host.bin"
}
LIBMUX=""
for cand in "$ROOT/mux/lib/libmux.so" "$ROOT/mux/game/bin/libmux.so"; do
[ -f "$cand" ] || continue
dir=$(dirname "$(readlink -f "$cand" 2>/dev/null || echo "$cand")")
if ! host_build "$dir" 2> "$BUILD/host.err"; then
note "NOTE: $cand does not link -- ignoring."
sed 's/^/ /' "$BUILD/host.err" | head -3
continue
fi
# Not a pipeline: `!` on `a | b` tests b's status, and strip_marker is a
# grep that exits 1 when it selects nothing -- which would report a
# perfectly good host leg as broken and an empty one as fine.
host_exec "$dir" > "$BUILD/host.raw" 2> "$BUILD/host.err"
if [ $? -ne 0 ]; then
note "NOTE: $cand links but the host leg does not run -- ignoring."
sed 's/^/ /' "$BUILD/host.err" | head -3
note " (a stale symlink to another line's build looks exactly like this)"
continue
fi
strip_marker < "$BUILD/host.raw" > "$BUILD/host.txt"
if [ ! -s "$BUILD/host.txt" ]; then
note "NOTE: $cand ran but produced no transcript -- ignoring."
sed 's/^/ /' "$BUILD/host.err" | head -3
continue
fi
LIBMUX=$dir
break
done
if [ -z "$LIBMUX" ]; then
note "SKIP: no libmux produced a working host leg."
note " Run 'make install' from the repo root. Without it there is"
note " no host leg to compare against, so nothing was tested."
exit 0
fi
have_qemu=1
command -v "$QEMU" >/dev/null 2>&1 || have_qemu=0
if [ "$have_qemu" = 0 ]; then
note "NOTE: $QEMU absent -- running without the external oracle."
note " interp and dbt will be compared to the host leg only, so a"
note " fault shared by both of our engines would go unseen."
fi
RVFLAGS="-march=rv64imd -mabi=lp64d -O2 -fno-builtin -ffreestanding -nostdlib
-ffunction-sections -fdata-sections -DLBUF_SIZE=32768
-DFUZZ_ITERS=$ITERS"
RVINC="-isystem $ROOT/mux/rv64/src/include -I$ROOT/mux/include"
# --- build the guest ---------------------------------------------------
note "--- building guest ($($RVCC -dumpmachine) $($RVCC -dumpversion)) ---"
set -e
$RVCC $RVFLAGS $RVINC -c -o "$BUILD/co.o" "$CO_SRC"
$RVCC $RVFLAGS $RVINC -c -o "$BUILD/uni.o" "$ROOT/mux/rv64/src/unicode_tables.c"
$RVCC $RVFLAGS $RVINC -c -o "$BUILD/soft.o" "$ROOT/mux/rv64/src/softlib.c"
$RVCC $RVFLAGS -I"$SCRIPT_DIR" -c -o "$BUILD/gmain.o" "$SCRIPT_DIR/guest_main.c"
$RVCC -nostdlib -Wl,-melf64lriscv -Wl,-e,_start -Wl,--gc-sections \
-o "$BUILD/guest.elf" \
"$BUILD/gmain.o" "$BUILD/co.o" "$BUILD/uni.o" "$BUILD/soft.o"
# The host leg was already built AND run while choosing $LIBMUX -- that run
# is what proved the choice, so its transcript is the one compared below.
# --- build the two-engine runner ---------------------------------------
case "$ARCH" in
x86_64) BACKEND=dbt_x64_sysv ;;
*) BACKEND=dbt_a64_sysv ;;
esac
E="$ROOT/mux/modules/engine"
g++ -std=c++17 -O2 -DHAVE_CONFIG_H -DTINYMUX_JIT -I"$ROOT/mux/include" \
-o "$BUILD/runner" "$SCRIPT_DIR/runner.cpp" \
"$E/dbt_interp.cpp" "$E/dbt_elf64.cpp" "$E/dbt.cpp" "$E/$BACKEND.cpp"
set +e
# --- run ---------------------------------------------------------------
if [ "$have_qemu" = 1 ]; then
"$QEMU" "$BUILD/guest.elf" 2>/dev/null | strip_marker > "$BUILD/qemu.txt"
fi
"$BUILD/runner" "$BUILD/guest.elf" > "$BUILD/routes.raw" 2>&1
awk '/^--- interp ---$/{r="i"; next}
/^--- dbt/{r="d"; next}
/^rc=/{next}
r=="i"{print > BI}
r=="d"{print > BD}' \
BI="$BUILD/interp.txt" BD="$BUILD/dbt.txt" "$BUILD/routes.raw"
for f in interp dbt; do
[ -f "$BUILD/$f.txt" ] && strip_marker < "$BUILD/$f.txt" > "$BUILD/$f.body" \
&& mv "$BUILD/$f.body" "$BUILD/$f.txt"
done
lines=$(wc -l < "$BUILD/host.txt")
note "--- comparing $lines transcript lines ---"
REF="$BUILD/host.txt"; REFNAME="host"
if [ "$have_qemu" = 1 ]; then REF="$BUILD/qemu.txt"; REFNAME="qemu"; fi
for r in host qemu interp dbt; do
f="$BUILD/$r.txt"
[ -f "$f" ] || continue
[ "$r" = "$REFNAME" ] && continue
if diff -q "$REF" "$f" >/dev/null 2>&1; then
printf ' %-7s vs %-6s : OK\n' "$r" "$REFNAME"
else
n=$(diff "$REF" "$f" | grep -c '^[<>]')
printf ' %-7s vs %-6s : DIFFER (%s lines)\n' "$r" "$REFNAME" "$n"
diff "$REF" "$f" | head -6 | cut -c1-160
fail=1
fi
done
if [ "$fail" = 0 ]; then
note "PASS: all routes agree."
else
note "FAIL: routes disagree -- see above."
fi
exit $fail