Probe: uninitialised arena reads, 2026-09-11
Status: probe record. The instrumented runtime was thrown away; this is the artifact. Nothing was committed to
runtime/tycho_rt.c.
Verdict: 371 programs, 0 uninitialised arena reads; 972 instrumented tool runs, 0 reads. Probed 2026-09-11, tools sweep closed 2026-09-18. Outcome: no lane added — the method stays recorded for re-runs after codegen work touching initialisation.
Why
Rule: the four sanitizer lanes cover addresses (ASan), undefined behaviour (UBSan), leaks (LSan), and races (TSan). None of them sees a read of memory that was never written.
That gap matters here. arena_alloc is a bump pointer over a block the runtime got from malloc: it hands back memory that is *not* zeroed and that ASan considers valid, because the enclosing block is a live allocation. So a codegen path that reads a struct field or array slot before writing it is invisible to every lane in the tree, and prints whatever the arena last held. It also tends to pass a golden — a fresh page reads as zeros, so the output looks right until the allocation pattern shifts.
Valgrind's memcheck closes that gap. This tree already uses valgrind client requests for the constant-time crypto proof (docs/internals/ffi-review-2026-08-14.md), so the technique is established; it had never been pointed at the arena.
What was run
Two sweeps over the 288 flat fixtures in tests/*.ty, each built and then run under valgrind --error-exitcode=42 --errors-for-leak-kinds=none.
1. Plain memcheck — 288 ran, 0 errors. Weak by construction: valgrind sees the arena as one large malloc and cannot tell a slot inside it from any other byte. This sweep tests roughly what ASan already tests.
2. Arena-instrumented memcheck — 288 ran, 0 errors. A copy of runtime/tycho_rt.c was patched so every arena handout is marked undefined:
static inline void *arena_alloc_vg(Arena *a, size_t n) {
void *p = arena_alloc_i(a, n);
if (p) VALGRIND_MAKE_MEM_UNDEFINED(p, n);
return p;
}
#define arena_alloc(a, n) arena_alloc_vg((a), (n))
The macro matters: internal callers reach arena_alloc_i through it, so patching only the exported arena_alloc function would have instrumented almost nothing. Fixtures were built against the patched copy with ./tychoc1 <f> --runtime <copy>, which is why the repo needed no change.
The control, because a clean sweep from an unproven instrument is worth nothing
A detector that cannot report is indistinguishable from a clean tree. So the instrument was made to fire on purpose — a C program including the patched runtime, allocating 64 bytes from a real Arena and branching on them without writing:
exit=42
==2128933== Conditional jump or move depends on uninitialised value(s)
It reports. The 288/0 above was measured against a working detector.
3. Extended to corelib and the examples — 83 ran, 0 errors, 0 failed to build. All 46 packages under corelib/test/ and all 37 under examples/corelib/, same instrumented runtime. This is the stronger half of the result: the flat fixtures are mostly minimal feature probes, while these are real programs that allocate, grow and partially fill buffers — bignum, decimal, regex, json, compress, sha256, zip — the shape that produces a read of a slot nobody wrote. The libcurl-, libpng- and sqlite-backed packages built and ran too, so nothing was silently skipped.
371 programs total, 0 uninitialised arena reads.
What this does and does not establish
Does: across 371 programs — every flat fixture, every corelib test package and every corelib example — no execution reads an arena slot it never wrote, with an instrument demonstrated to catch exactly that.
Does not: prove the property. The corpus exercises the paths the corpus exercises — a codegen path nothing reaches is as untested here as anywhere. And memcheck reports the read, not the missing write, so a real finding would still need tracing back to the construct that failed to initialise.
4. tools/ — closed 2026-09-18, and it was not the job this record expected
This record first called driving 30 programs "a bigger job than this probe". The arguments already existed: 27 of the 30 tools ship their own run.sh gate that feeds them real inputs, and every one resolves its compiler as TYCHOC="${TYCHOC:-./tychoc1}". So nothing had to be invented and no gate had to be edited — a wrapper standing in for tychoc1 does both halves:
- appends
--runtime <instrumented copy>, and - replaces the produced binary with a shim that runs it under
valgrind -q --log-file=....
Findings go to a log, deliberately not to --error-exitcode. The gates' exit codes and stdout have to stay byte-identical or they stop testing what they test, and a probe that quietly breaks the suite it is riding on is worse than no probe.
The instrument was proved in BOTH directions this time:
| control | expected | measured |
|---|---|---|
| read an arena slot nobody wrote | must fire | exit 42, Conditional jump or move depends on uninitialised value(s) |
| the same slot, written first | must stay silent | exit 0, nothing reported |
The negative half is not decoration: without it a scanner that matches anything is indistinguishable from a working one.
27 tools, 972 instrumented runs, 0 uninitialised arena reads.
Two of the gaps this record first reported were closed the same day:
tycho-fh— now covered. It compiles its own binaries withccafter--emit-c, so thetychoc1wrapper alone never reached them. A second shim, oncc, wraps only *linked executables* and leaves-cobjects alone — hand the linker a shell script and the gate fails for the wrong reason. Gate green, 6 instrumented runs, 0 reads.tycho-debug— mostly covered. Legs [1]–[6] pass under instrumentation — scripted session,-b, run-to-completion, Ctrl-C, fail-closed, thetycho debugwrapper — with 12 instrumented runs and 0 reads. Only leg [7] fails, and it is the one leg that *cannot* survive this harness: it asserts howtycho-debuglocates a compiler (beside the binary, viaTYCHOC, viaPATH), while the probe's whole method is replacing the compiler with a wrapper somewhere else. The leg is measuring the variable the probe changes.
What is NOT covered, named rather than rounded up:
tycho-debugleg [7] — see above; not closable by this method at all, and the only thing intools/that is not.
tycho-chess was the third gap and it is closed too: leaving it running. The gate passes *under memcheck* — 24 published and oracle perft totals, the divides against golden, 5 search positions deterministic and TT-invariant, parallel == serial — across 63 instrumented runs, 0 reads. It was stopped at 41 runs earlier for cost: perft is pure compute, so it buys minutes of memcheck per arena allocation it does not make.
tycho-fetch,prof,prunnerand the single-file tools — norun.sh.
One failure that is the INSTRUMENT, not the tree. tycho-flow fails under memcheck: *"the pool drained out of order only 77 of 200 runs"*. Running the same gate with the same compiler and no valgrind is green and reorders on essentially all 200 — memcheck serialises threads, so an assertion that needs real parallelism degrades. Recorded because the next run pointing valgrind at a concurrency gate will meet it again.
Found on the way: five of these gates silently discard the TYCHOC override and run the C bootstrap instead of the shipped compiler — FRICTION 93. That is why the sweep initially reported "no instrumented run" for them.
Why no lane was added
It found nothing, and a lane costs time on every run forever. The method is cheap to re-run from this record when there is a reason — after codegen work that touches initialisation, or if a golden ever differs in a way that smells like stale bytes. Recorded rather than institutionalised.