Summary
Fuzz (nightly) has failed 60 out of its last 60 runs — every run back to its oldest
recorded one, 2026-06-01. It has, as far as the run history shows, never passed.
$ gh run list --workflow "Fuzz (nightly)" --limit 60 --json conclusion \
--jq '[.[]|.conclusion]|group_by(.)|map({(.[0]):length})'
[{"failure":60}]
Trace-Topology Fixture Generation is in the identical state and is filed separately.
Nothing surfaced this, because neither workflow is a required context — so a
100%-failure gate is indistinguishable on the PR page from a gate that is working. Sixty
consecutive reds produced zero signal. That is the part worth fixing beyond the immediate
cause: a gate nobody reads is not a gate.
The failure
fuzz_scheduler_solver (1h) OOMs about 10 minutes in. The other two targets
(fuzz_aadl_parse, fuzz_codegen_roundtrip) pass.
==5922== ERROR: libFuzzer: out-of-memory (used: 2056Mb; limit: 2048Mb)
SUMMARY: libFuzzer: out-of-memory
Error: Fuzz target exited with exit status: 71
It is NOT a defect in solve_milp — evidence, not assertion
This was investigated with a two-arm experiment rather than by reading code alone.
1. The live heap at OOM is 27.7 MB. From the job's own ASan report:
Live Heap Allocations: 27727651 bytes in 8340 chunks;
quarantined: 155736296 bytes in 2104213 chunks;
2232955 other chunks; total chunks: 4345508
8,340 live chunks after 185,235 executions. Any per-call leak would put that in the
hundreds of thousands. 4.3M chunks are retained by the sanitizer allocator while only
27.7 MB is live.
2. RSS tracks execution count, not input size. RSS was already 207 MB while
libFuzzer's max input length was 21 bytes:
#4281 lim: 21 rss: 207Mb
#98083 lim: 580 rss: 1035Mb
#185235 lim: 1240 rss: 2020Mb
Throughput collapsed 7772 → 311 exec/s — the signature of a growing allocator working set.
3. Same workload, ASan on vs off. A standalone driver building the same MILP shape:
| arm |
iterations |
RSS |
live heap |
| no ASan |
20,000 |
12.5 MB, flat after warm-up |
— |
| ASan |
120,000 |
582 MB |
172 KB, constant from iter ~2,000 on |
4. Turning down sanitizer bookkeeping alone cuts RSS 3×, with zero code change:
ASAN_OPTIONS |
RSS @ 20k iters |
| defaults |
417 MB |
quarantine_size_mb=16 |
187 MB |
quarantine_size_mb=16:malloc_context_size=2 |
140 MB |
solve_milp is bounded by counts, never by values — independently re-checked against
the source:
milp.rs:67 — .filter(|(_, t)| t.period_ps > 0), so the zero-period
divide-by-zero hypothesis is ruled out, not merely untested.
milp.rs:95 — (0..num_valid * num_procs) ≤ 8 × 4 = 32 binaries.
milp.rs:161 — vec![0.0_f64; num_procs], ≤ 4.
memory_bytes (the only u32-wide fuzz field) is never read by solve_milp — its
sole occurrence in the file is milp.rs:247, inside the test module.
The saved artifact oom-c5aafda340317c72f5ba9eb2431f0c189d159c9d (76 bytes) is not a
reproducer — it is whichever input happened to be executing when the RSS watchdog fired.
Replaying it will not OOM. Worth stating explicitly so nobody burns a day on it.
Why only this target: it is the only one running a C++ HiGHS MIP presolve + branch-and-bound
per iteration, i.e. thousands of small new/delete per exec, each becoming a quarantined
ASan chunk.
Fix
.github/workflows/fuzz-nightly.yml:62 currently passes only -max_total_time=3600 -timeout=10
and sets no ASAN_OPTIONS.
-max_len=128. The harness consumes at most 8 tasks (~7 bytes each) + 4 processors
(~5 bytes) ≈ 80 bytes of entropy; letting libFuzzer grow to 1240-byte inputs is wasted
work that also inflates per-exec churn. This improves fuzzing efficiency as well as memory.
ASAN_OPTIONS: quarantine_size_mb=32:malloc_context_size=5 — measured 3× reduction.
Deep allocation stacks buy little for a target whose only invariant is "must not panic."
-rss_limit_mb=4096 as headroom. Blunt; growth decelerates but does not fully plateau,
so this buys room rather than a guarantee. Prefer 1 and 2.
The part that outlives this bug
A nightly that has never once passed should not be able to stay quiet for two months. Worth
deciding: either make these workflows report failure somewhere a human sees (issue-on-fail,
notification), or accept they are advisory and stop presenting them as assurance. Related:
#358, where a gate reported passed: 20 / failed: 0 over a scope that excluded the very
artifact it was meant to check — same shape, different surface.
Verified against spar at 457592f; workflow line and history re-checked directly.
Summary
Fuzz (nightly)has failed 60 out of its last 60 runs — every run back to its oldestrecorded one,
2026-06-01. It has, as far as the run history shows, never passed.Trace-Topology Fixture Generationis in the identical state and is filed separately.Nothing surfaced this, because neither workflow is a required context — so a
100%-failure gate is indistinguishable on the PR page from a gate that is working. Sixty
consecutive reds produced zero signal. That is the part worth fixing beyond the immediate
cause: a gate nobody reads is not a gate.
The failure
fuzz_scheduler_solver (1h)OOMs about 10 minutes in. The other two targets(
fuzz_aadl_parse,fuzz_codegen_roundtrip) pass.It is NOT a defect in
solve_milp— evidence, not assertionThis was investigated with a two-arm experiment rather than by reading code alone.
1. The live heap at OOM is 27.7 MB. From the job's own ASan report:
8,340 live chunks after 185,235 executions. Any per-call leak would put that in the
hundreds of thousands. 4.3M chunks are retained by the sanitizer allocator while only
27.7 MB is live.
2. RSS tracks execution count, not input size. RSS was already 207 MB while
libFuzzer's max input length was 21 bytes:
Throughput collapsed 7772 → 311 exec/s — the signature of a growing allocator working set.
3. Same workload, ASan on vs off. A standalone driver building the same MILP shape:
4. Turning down sanitizer bookkeeping alone cuts RSS 3×, with zero code change:
ASAN_OPTIONSquarantine_size_mb=16quarantine_size_mb=16:malloc_context_size=2solve_milpis bounded by counts, never by values — independently re-checked againstthe source:
milp.rs:67—.filter(|(_, t)| t.period_ps > 0), so the zero-perioddivide-by-zero hypothesis is ruled out, not merely untested.
milp.rs:95—(0..num_valid * num_procs)≤ 8 × 4 = 32 binaries.milp.rs:161—vec![0.0_f64; num_procs], ≤ 4.memory_bytes(the onlyu32-wide fuzz field) is never read bysolve_milp— itssole occurrence in the file is
milp.rs:247, inside the test module.The saved artifact
oom-c5aafda340317c72f5ba9eb2431f0c189d159c9d(76 bytes) is not areproducer — it is whichever input happened to be executing when the RSS watchdog fired.
Replaying it will not OOM. Worth stating explicitly so nobody burns a day on it.
Why only this target: it is the only one running a C++ HiGHS MIP presolve + branch-and-bound
per iteration, i.e. thousands of small
new/deleteper exec, each becoming a quarantinedASan chunk.
Fix
.github/workflows/fuzz-nightly.yml:62currently passes only-max_total_time=3600 -timeout=10and sets no
ASAN_OPTIONS.-max_len=128. The harness consumes at most 8 tasks (~7 bytes each) + 4 processors(~5 bytes) ≈ 80 bytes of entropy; letting libFuzzer grow to 1240-byte inputs is wasted
work that also inflates per-exec churn. This improves fuzzing efficiency as well as memory.
ASAN_OPTIONS: quarantine_size_mb=32:malloc_context_size=5— measured 3× reduction.Deep allocation stacks buy little for a target whose only invariant is "must not panic."
-rss_limit_mb=4096as headroom. Blunt; growth decelerates but does not fully plateau,so this buys room rather than a guarantee. Prefer 1 and 2.
The part that outlives this bug
A nightly that has never once passed should not be able to stay quiet for two months. Worth
deciding: either make these workflows report failure somewhere a human sees (issue-on-fail,
notification), or accept they are advisory and stop presenting them as assurance. Related:
#358, where a gate reported
passed: 20 / failed: 0over a scope that excluded the veryartifact it was meant to check — same shape, different surface.
Verified against
sparat457592f; workflow line and history re-checked directly.