5 Commits

Author SHA1 Message Date
3ace7c262f A hang is a failure the suite never reported
The mutation pass turned up one defect that did not make the suite go
red: a reader branch that forgets to advance reads the same character
for ever, and dune test waits as long as it is left to. In CI that is a
job killed by the runner with nothing named and no output to read.

watchdog.ml puts an alarm on every test binary — generous, because an
alarm that fires on a slow machine is a flake — and a five-second one
around each read in test_flan, where the budget really is small. The
first read that does not return wedges the rest, so a looping reader
costs five seconds and names the row instead of costing eight minutes
or never finishing. Both were watched: the string-escape loop now fails
in five seconds with the case named, and the per-binary backstop was
armed short and observed to fire.
2026-09-12 10:49:07 +07:00
3c7fdfc076 The left-redzone claim was inferred, not measured, and was wrong
A negative index into a global is silent in bounds.flan, which is
measured. "Because a global has no left redzone" was the explanation
put on it, and it does not survive the obvious test: declare another
defvar in front of arr and arr[-1] is caught, landing in that global's
right redzone. Underflow detection is a question about what the linker
put in front of the object, not about the access. Corrected in
test_sanitize, BUILT.md and NEXT.md.

NEXT.md's entry also goes back to its stated size. It had grown to 78
lines saying what BUILT.md says in the same commit range -- the
attribute, the -O0 decision, the bounds.flan table -- which is the
half-build-log the file's own header warns about. What stays here is
what is next: the UBSan gap as an undecided compiler question, the four
daemon-path buffers the corpus never reaches, and Valgrind.
2026-09-12 09:34:05 +07:00
c806125742 What the sanitizer sweep found, and what a clean run does not prove
NEXT.md's queued section becomes a landed one. The headline is not the
flag: ASan reaches Flan code only because Emit now attributes every
define, and UBSan reaches none of it and has no lever that would, so the
shift-UB and float-cast items that section listed are still open and are
a compiler feature rather than a flag.

The clean result is written with its reach. println.flan pushes a
1100-character string through escaped[1024] on purpose, so that buffer
is genuinely covered; scratch[64] never sees more than 20 characters;
and the 4K result cap, the dev registry guard, SNAP_MAX/SNAP_NAMES and
condition_name[128] are on the daemon path and not in the corpus at all
-- read, not tested. Two defects fixed, both found by reading. Three of
bounds.flan's six out-of-bounds cases caught with the checks off, with
the other three tabulated and explained, and the caveat that ASan sees
out-of-object and not out-of-subobject access, so three of six is a
ceiling and not a measurement.

BUILT.md gets the durable half: the attribute, the absent UBSan lever,
why --sanitize does not force -O0 when --debug does, and the -O0/-O2
divergence that earned it.
2026-09-12 09:31:32 +07:00
5810fa286f A negative slice length read 63 bytes off the end of whatever it pointed at
(slice s 2 1) has length 2 - 1 - 2 = -1. flan_bytes_to_i64 and
flan_bytes_to_f64 both wrote their clamp as (size_t)n < sizeof buf - 1,
and (size_t)(-1) is 18446744073709551615, which is not less than 511 --
so k took the cap and the memcpy copied 63 or 511 bytes out of a
five-byte string constant. ASan calls it a global-buffer-overflow in
flan_bytes_to_i64; the regression case is in test_sanitize.

Every other (ptr, len) entry point in the runtime already guarded the
negative case -- flan_write_stdout tests n > 0, flan_escape_bytes and
flan_dev_emit both fold a negative length to zero -- so this was two
exceptions rather than a missing convention. A checked build traps on
the reversed slice before reaching either, which is why it took an
--no-bounds-checks run to show.

Also clamps the three snprintf shims that publish scratch as a slice.
snprintf returns what it would have written, not what it did, so a
format that overran the 64-byte buffer would hand out a length past its
end. No format here can: %g is 13 characters and %lld is 20. Found by
reading, and the sweep could not have found it -- nothing in forty
programs prints a number that long.
2026-09-12 09:27:46 +07:00
c41c5812a9 The corpus a second time, under the sanitizers, behind @sanitize
Twenty-eight programs built twice -- once plain, once sanitized -- and
compared on output and exit status, plus two positive controls that are
the only reason a clean result means anything: an out-of-bounds read
that must report, and a shift by the width of the type that must not,
because UBSan cannot see hand-written IR and this file would otherwise
be claiming coverage it does not have.

Its own alias rather than dune test. A sanitized program is a statically
linked 1.8MB binary and takes tens of seconds to link; the sweep is nine
minutes against the existing suite's seconds, and a test nobody will
wait for is a test nobody runs. dune build --root . @sanitize.

The checked sweep is clean. The unchecked variant -- ASan alone, with
Flan's own bounds checks off -- catches three of bounds.flan's six
deliberate out-of-bounds cases and is listed with why for the other
three: a global has a right redzone and nothing to its left, so arr[-1]
is invisible; a read past a string constant folds away entirely at -O2
and is caught only at -O0; and a reversed slice reads nothing at all.
ASan is not a substitute for the bounds checks, and now there is a table
saying which half it covers.
2026-09-12 09:25:43 +07:00