Factor investigation notes

Fuzz testing the Zig VM

How to use the zig-vm-fuzzing branch of keiravillekode/factor-lang to fuzz the Zig VM, and what it found. Written 2026-09-15. Upstream code links are pinned to df18f5dc27, the master commit the branch is based on.

Was this zig build --fuzz?

No. zig build --fuzz runs the std.testing.fuzz tests of a build’s test steps. build.zig has a zig build test step with ordinary unit tests but no fuzz tests, and the bugs in question show up only while real Factor code runs on a ~140 MB image and the GC moves objects.

What was used instead has two parts:

  1. A differential program fuzzer (misc/fuzz/, Python). It generates random, GC-heavy but valid Factor programs from a seed. Each program runs on the C++ VM (the oracle) and on Zig VM builds, all loading the same factor.image. Their printed digests must match, and the Zig VM must not crash.
  2. GC stress flags in the Zig VM (src/, off by default). They make GC happen far more often than normal, so a pointer left unrooted across an allocation, or a missing write barrier, fails at once. Without them such a bug fails only when a collection happens to hit it.

The fuzzer is not coverage-guided; it is seeded random generation plus stress.

What is on the branch

Commit Contents
ae3a281e0b Merge of the three fix branches: factor/factor PRs #3213 (passArgsToFactor-write-barrier), #3212 (zig-vm-primitive_modify_code_heap), #3211 (zig-vm-resetTenuredCards)
4d7f065f89 zig vm: GC stress and heap verification flags for fuzzing — src/gc.zig, image.zig, main.zig, vm.zig, new src/verify_heap.zig
5f11d8c5b1 misc/fuzz: differential GC fuzzer for the Zig VM — gen.py, run.py, reduce.py, build-vms.sh, README.md

Zig VM flags

Flag Effect
-gc-zeal=N nursery GC before every Nth VM-side allocation
-gc-zeal-code=N compacting GC before every Nth code block allocation
-nursery-budget=KB only KB of nursery usable after each GC (minimum 64), so compiled code falls into minor_gc often
-verify-heap[=N] after every Nth GC, check heap invariants (object tiling, pointer targets, card/deck marks, code remembered sets, object-start maps); a violation panics with verify-heap: <check>: ...

The C++ VM has none of these; they are passed only to the Zig VMs.

Setup

Tested on Linux x86-64. You need Zig 0.16 (build.zig.zon requires it), Python 3, and the usual C++ build tools.

git clone -b zig-vm-fuzzing https://github.com/keiravillekode/factor-lang.git
cd factor-lang
./build.sh net-bootstrap                          # C++ VM + factor.image
ZIG=/path/to/zig-0.16/zig misc/fuzz/build-vms.sh  # -> misc/fuzz/vms/

build-vms.sh produces:

Both make and zig build overwrite ./factor; the script puts the previous one back.

Running a campaign

# plain: ~25 seeds/min on 12 cores, Debug + ReleaseSafe
misc/fuzz/run.py --start 0 --count 100 --jobs 8

# stress: ReleaseSafe only, long timeout
misc/fuzz/run.py --start 1000 --count 48 --jobs 6 --vms safe --timeout 400 \
    --zig-flags "-gc-zeal=100 -gc-zeal-code=25 -nursery-budget=128"

# one flag: use the = form, argparse rejects `--zig-flags -gc-zeal=5`
misc/fuzz/run.py --start 0 --count 4 --jobs 4 --zig-flags=-gc-zeal=5

Each seed also gets random -young, -aging and sometimes -tenured or -codeheap sizes. Every run is classified as one of:

Class Meaning
PASS digest matches the oracle
ZIG_CRASH panic, fault, Factor error, or verifier message on the Zig VM
MISMATCH Zig VM finished but printed different data
TIMEOUT exceeded --timeout (Debug gets twice as long)
ORACLE_FAIL the C++ VM failed: a generator bug, not a Zig VM finding

Failures are saved to misc/fuzz/findings/<CLASS>-<seed>-<vm>/, with the program, ops.json, the flags, and both VMs’ stdout and stderr. Every record is appended to misc/fuzz/results.jsonl. The run ends with a table of deduplicated signatures, and exits 1 if anything did not pass.

Other useful options:

To reproduce or minimize a finding:

F=misc/fuzz/findings/ZIG_CRASH-1007-safe
misc/fuzz/vms/safe/bin/factor -i=factor.image $(head -1 $F/flags.txt) $F/prog.factor
misc/fuzz/reduce.py $F    # -> min.factor

ReleaseSafe binaries are stripped and print “Cannot print stack trace”. For a trace, build with zig build -Doptimize=ReleaseSafe -Dkeep_symbols=true --prefix ..., or use the Debug VM.

Costs and pitfalls

Recipes that catch the three fixed bugs

Each fix was reverted on its own, in a detached worktree (the reverts are not on the branch), and fuzzed from the branch’s harness via --vm. The control is the same seeds and flags on the fixed branch.

Bug (PR) Recipe With fix reverted Control (fixed)
Missing write barrier storing argv aliens in passArgsToFactor (#3213) --zig-flags=-gc-zeal=5, Debug + ReleaseSafe 8/8 ZIG_CRASH (seeds 0–3) within 4 s: Memory protection fault during gc, Memory protection fault at address, Double fault Startup alone with -gc-zeal=5 -e='"STARTUP-OK" print': revert 0/6 start, fixed 6/6 start
Stale word_after read after jitCompileQuotation in primitive_modify_code_heap (#3212) --vms safe --jobs 6 --timeout 400 --zig-flags "-gc-zeal=100 -gc-zeal-code=25 -nursery-budget=128", seeds 1000–1047 9/48 ZIG_CRASH: panic: reached unreachable code 48/48 PASS
Checked + on wrapping card/deck offsets in resetTenuredCards (#3211) no stress flags, Debug + ReleaseSafe, seeds 0–23 ReleaseSafe 10/24 ZIG_CRASH: panic: integer overflow; Debug 0/24 48/48 PASS

Attribution:

Earlier campaigns

These ran in a separate checkout, before the harness moved into the branch:

That checkout also carried extra hardening assertions, which are not on the fuzzing branch. They are on their own branch, zig-vm-hardening-assertions (off master):

The branch is based on master, so it does not include the three fixes: its ReleaseSafe VM still hits the resetTenuredCards overflow (7/24 plain seeds crashed with integer overflow, Debug 24/24 passed). Merged onto zig-vm-fuzzing it passes: 48/48 plain runs and 12/12 with -gc-zeal=100 -gc-zeal-code=25 -nursery-budget=128. That merge conflicts in src/gc.zig, where both branches add lines at the start of gc() and collectGrowingDataHeap; keep both, the canary call and then hook_depth += 1; defer hook_depth -= 1;.

The results in the recipes table were reproduced without these assertions.

Two differences were noted but not fixed: