Testing Under Miri

What runs under Miri, what it costs, and where more coverage would come from. Written October 2026, after the recipes had rotted and were brought back.

Contents

The problem

The unsafe code is concentrated: about 1300 unsafe in datalove-rt, 400 each in the interpreter, datalove-rti and the std rider, 150 in datalove-datalit. The native suites exercise all of it, but a misaligned write or an aliasing violation passes them silently: glibc's allocator returns 16-aligned memory whatever a Vec<u8> promises, and x86 does not trap on an unaligned load. Miri checks those rules as it runs. But nobody ran the just test-miri-* recipes, so they broke: every runtime test stopped at its first RtLocal::new, which asks pthread_getattr_np for the stack bounds, and Miri does not implement that call.

What runs

Recipe What Time
just test-miri-rt datalove-rt unit tests, proptests at 8 cases 1 min
just test-miri-rt-tests all 21 rt-tests binaries ~30 min
just test-miri-libs rtdt, datalit and interpreter unit tests 3 min
just test-miri-interp FILTER engine fixtures, walker and bytecode see below
just test-miri-replay all 182 std fixtures, walker and bytecode ~2 h

Stacked borrows is the default model and the stricter one for most code. Two places need tree borrows instead: anything that reaches salsa or rayon, whose crossbeam-epoch stacked borrows rejects. That is roundtrip_tests and every fixture suite. Tree borrows is not uniformly weaker, though: it rejected debuglog_local reading a local after lending a *mut to it, which stacked borrows allows.

Under Miri, RtLocal has no native stack limit (thread_stack returns None, as on non-Linux targets), so the interpreter's stack check never fires.

Making it affordable

Miri runs code a few hundred to a few thousand times slower than native, unevenly. Three things dominated, none of them the code under test.

Proptests. At the default 256 cases the rt-tests proptests run for hours. The recipes set PROPTEST_CASES=8: what Miri checks is the code path, which a few cases cover.

Large collections. A few B-tree tests build trees of 1000 to 5000 keys, and some check the whole tree's shape after each removal. One of them ran 45 minutes without finishing. Under Miri, datalove_rt_tests::sizes and size cap them at 300. The node capacities are 11 and 12, so 300 still reaches three levels, past the 133/144/145 depth boundaries the tests are built around.

The fixture harness. A profile of one engine fixture under Miri (run with -Zmiri-report-progress=N, which prints the interpreted stack every N basic blocks and works as a sampling profiler) put 18 of 23 samples in ExampleTestRunner before any fixture ran: Path parsing and comparison of the 2059 directory entries. find_test_fixtures now matches and sorts on the file name as a string. One fixture went from 5m10s to 2m09s; most of what is left is a fixed cost per run.

That still leaves about three CPU-minutes per fixture, across the seven configurations Miri runs (walker, bytecode, plain bytecode, and the four const and specialization variants), almost all of it compiling. The whole corpus is two days of CPU. A sample is the practical unit: every 8th fixture plus the bigint ones, 137 in all, ran as three concurrent cargo miri test processes over one build (a running test holds no cargo lock) in 2h20m each. After the fixes below, all 137 pass.

Compiling natively, running under Miri

The compiler has no unsafe and is nearly all of a fixture's cost under Miri. For the standard library it is worse than that: std_engine_tests compiles sys/std before its first fixture, and under Miri that ran 360 million basic blocks without finishing. So the std rider, with 380 unsafe, was out of reach.

tests/ir_replay_tests.rs in datalove-datafun splits the two. Run natively, it compiles each fixture in fixtures/std_tests and writes its two script units (the body, and output) to target/ir-replay/std, then replays them natively as a check. Run under Miri, it only replays: it reads the units back into a ScriptExecutor::new, which needs no compiler, registers the std rider's natives from datalove_rider_sys_std::symbols() (linked in, since Miri cannot load a library), and runs the walker, the bytecode and the plain bytecode against the expected file. just test-miri-replay does both.

Two things made it fast.

A fixture costs seconds to a minute under Miri this way, against hours.

Miri adds a few ULPs of random error to exp, ln, pow and the like, since platforms are not held to exact results there, which changes 96_f64_transcendental's output. The recipe passes -Zmiri-deterministic-floats.

The same split would make the whole engine corpus affordable: its fixtures are worldfiles of several sections rather than one script, so it needs a little more than this test does.

What it found

Each of these passed the native suites, including under the leak checker.

Where coverage is missing

Most of the engine fixture corpus, for cost, until it is replayed the way the std fixtures are.

The std rider's natives that no fixture calls.

datalove-rti's call wrappers have no tests of their own; they run when a rider calls them.

The JIT and the AOT backends are out of reach by construction: Miri does not run generated code.

Next steps

In order of payoff per effort.

  1. Replay the engine corpus as the std fixtures are: a worldfile's script sections compiled natively and run under Miri, with the specialization variants compiled natively too.
  2. Direct tests of the std rider's natives. They are extern "C" functions over an RtLocal handle; calling them from a unit test needs no compiler, and runs under Miri in seconds.
  3. Tree borrows for the rt-tests as a second pass. The two models catch different things; the debuglog bug is one stacked borrows passed.
  4. A scheduled sweep of the fixture sample, since it is too slow for CI but finds real bugs; a just recipe that shards it would do.