testcases/tools/jit_diff fuzzes the softcode layer -- softcode to HIR, then
JIT against ast_eval. Nothing fuzzed the layer below it, which is where
every DBT defect this cycle has actually lived: #1147 stale patch sites,
#1148 SysV stack alignment, #1151 unchecked guest pointers, #1152 an x86
decode applied to AArch64, #1153 block cache double insert. The
hand-assembled cases in dbt_test.cpp were the only coverage of that layer,
and only about six of their thirty-nine functions drive the DBT at all.
So: generate random RV64 sequences, run each through the reference
interpreter and through the host DBT, compare all 32 integer and 32 FP
registers, and delta-debug any mismatch down to a minimal sequence before
printing it.
Two constraints keep "random" from meaning "crashes for uninteresting
reasons", and both come from real properties of the system rather than
from convenience.
Memory operands are confined to a data window addressed through one
reserved base register. The DBT's inline loads and stores are
deliberately unchecked -- #1151 bounded that fix to the intrinsic stubs,
because a compare on every guest access is the cost a JIT exists to avoid
-- so an unconstrained address is a genuine wild access in the DBT and a
clean refusal in the interpreter. That is a known, accepted difference,
not a finding.
Control flow is structured. rv64_interp_run takes no instruction cap, so
a random back edge hangs the fuzzer rather than failing it. Instead the
generator emits a counted loop: a reserved counter register, a body that
cannot write it, and a decrement-and-branch epilogue, with forward
branches inside the body bounded so they cannot skip the epilogue. That
yields real back edges -- and block chaining, superblock formation and
side exits do not exist until a branch does -- while termination stays a
property of the construction rather than of luck.
The shrinker is structure-aware for the same reason: it refuses to delete
the loop initialiser, the decrement or the branch, and re-encodes the back
branch after any deletion inside the loop. Deleting the initialiser is the
subtle one -- the counter starts at zero, the decrement makes it -1, and the
branch against x0 then runs about 2^64 times.
Deterministic by default, so a CI failure reproduces exactly:
DBT_FUZZ_SEED, DBT_FUZZ_ITERS, plus DBT_FUZZ_TRACE / DBT_FUZZ_DUMP=<n> /
DBT_FUZZ_ONLY=interp|dbt, which are how a divergence gets isolated to one
sequence and one route.
`make test` runs 300 sequences and is clean. Soaking finds more: at 20000
sequences it reports #1337 (NaN payload propagation) and #1338 (a
control-flow divergence), both filed and both pre-existing. Those are the
harness working, not a reason to hold it back -- the alternative is that
nothing is watching this layer at all.
It has already paid for itself: #1311, #1313 and #1320 were all found this
way. Its blind spot is worth stating plainly, though, and CLAUDE.md now
does: a differential test cannot see a bug both routes share, which is
exactly how #1319 and #1320 survived. That class needs an external oracle,
and tests/dbt/test_interp.cpp carries qemu-derived golden values for it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>