The members of a defenum are i32 at run time, but the reader hands the parser an int64, so a value too large for the type arrived looking ordinary: truncated by the x86 backend, malformed in the LLVM IR, and -- the reason this is a correctness hole and not a nicety -- invisible to the duplicate-value rule sitting right below it. That rule compares int64s, so (defenum E [A 0 B 4294967296]) passed it: the two differ as int64 and are both 0 as i32, and the one check written to catch two names for one number waved through exactly the case it exists for. Each value is now checked where it is resolved, which is before the collision scan runs, so the scan compares the numbers the program will actually have. A value that does not fit is refused rather than quietly made to fit, naming the member, its enum, and the value, with a different sentence for a value that was written and one autoincrement walked into -- nothing in the source wrote 2147483648, so the refusal has to say where it came from before it can say it is wrong. The check is bound with a let rather than inlined into the cons, and that is load-bearing: OCaml leaves :: operand order unspecified and takes the tail first, so an inlined check would run after the recursive Int64.add and let (defenum E [A 9223372036854775807 B]) wrap to min_int and refuse B for a number in no one's source. Bound first, A is refused and the wrap is unreachable. The parser is the only place this needs to happen: Parse.decl is the sole constructor of Ast.Defenum's member values, and Load only re-qualifies the enum's name. Explicit-duplicate aliasing is untouched; that rule is deliberate.
8.7 KiB
Bug hunt, 2026-09-18
Five search agents swept the runtime, checker, backends, dev loop, and Emacs client. Everything below was either executed to failure or traced to the exact line. The five marked DISPATCHED have fix lanes; the rest are recorded here and wait.
Dispatched
1. borrowed grants the borrow flag to whole subtrees — moves inside container targets vanish
lib/check.ml:2062-2076. The flag gates both moved (2003) and global_borrow (2033),
and is set across the entire checking of the target: the index argument of at, the base
of any Field. A move nested there is never recorded.
Confirmed by execution, two programs:
(println (at rows (eat w)))then(free w)— double free, exit 134.- Same shape with a move-only global
g: accepted,gfreed,(len g)reads a freed header, exit 0 silent. Defeats the rule test/test_flan.ml:1067-1068 pins.
Fix direction: narrow the flag to the target's own read — restore ctx.borrow for the
index of at; treat Field as simple only when its base chain bottoms out at a Var.
2. A while condition is move-checked outside in_loop — double free on iteration two
lib/check.ml:1688-1691. The condition is checked before in_loop is entered, but
emit re-runs it every trip (emit.ml:1838). A condition that moves a local frees it
once per iteration. Confirmed: glibc double-free abort, exit 134.
dotimes' count (2504) and loop's inits (2561) are also outside but evaluate once — correct.
Fix: check the condition inside in_loop.
3. A checked declaration that fails to build or deliver leaves a NULL cell the session will call
lib/session.ml:601 commits t.program before Dev.eval builds (dev.ml:603 can raise)
or delivers (dev.ml:596 can refuse — e.g. queue full at vendor/agent/flan_agent.c:1186,
which a parked program guarantees after 64 installs since nothing drains the ring while
parked). The session keeps the declaration; the process never got the body; the next
thunk's install prologue interns the name with cell = NULL (runtime/flan_dev.c:65)
and body_of calls through it with no null test (emit.ml:1472) — jump to address 0
on the game thread. Nearest reachable mechanism to FIX.org's transient SIGSEGV; the
stranded-Vec hypothesis there was read out and refuted (reloads never redefine a host
global; storage addresses are stable; global_borrow holds).
Related, same lane: the queue-full refusal says "the program is not calling agent/poll",
which is the wrong cause for a parked program.
4. The merged build never drains the program's stdout pipe while serving a request
fd 1 is a 64K pipe whose only reader is accept_loop's select (lib/dev.ml:2739), not
running while serve handles a request. eval_expr's wait (664-672) and
run_render_thunk's wait (1026-1036) poll for five seconds and never drain, so a
program that prints (sand.flan does) blocks in flan_write_stdout, stalls the frame
thread for the whole timeout, and gets the false diagnostic "is it calling (agent/poll)?".
Same root on the exit path: flan_merged_exit/park fflush(NULL) before
program_state = PROGRAM_PARKED (dev.ml:3000/3017/3024), so a full pipe delays the park
and rerun refuses a finished program as "already running".
Also same family: rt_die (runtime/flan_rt.c:384-388) starts with fflush(stdout) —
a bounds trap can hang on the full pipe — and calls exit(134), the route die_now
documents as unsafe (atexit/ELF destructors want the loader lock a dlopening thread may
hold); it should _exit like the break loop does.
5. x86 shifts are always 64-bit, so the count is masked to 63 instead of width−1
lib/x86.ml:2530-2534 / shift_cl at 369 (rex ~w:true unconditionally). The comment
claims the hardware masks to operand width; it masks to 63. emit.ml:2041 masks to
bits-1 explicitly (NEXT.md "Sharp edges" records this as the language's rule). Six
confirmed divergences, e.g. (<< x 32) on i32: LLVM 1, x86 0; (>> i8min 8): LLVM
-128, x86 -1. Invisible because spike/x86/survey.sh:80 never globs spike/js/*.flan,
where p1-int-semantics.flan already catches it — widen the glob in the same lane.
Same wide-compute root, second divergence: float→int overflow under --no-bounds-checks
gives 0 on x86 (64-bit cvttsd2si then truncate) vs INT_MIN on LLVM. Acknowledged-UB
territory; fix or record, the lane's call.
Recorded, not scheduled
- Region guard never asks about elements (
lib/check.ml:1272, refusals 4092/4165): a heap-backed inner Vec pushed into an arena-backed outer passes the guard;atthen hands out an owning header,freeaccepts it, and the later read is a confirmed UAF (printed garbage). Breaks the premise stated in the clone note at check.ml:4152. Candidate fixes: refusefreeof a non-binding target, or region-check the element atpush/put. Sixth on the list; needs a deliberate mixed-allocator construction. - Reversed slice under
--no-bounds-checks: thelo <= hitest is a representation invariant, not a bounds check, but sits behindif f.md.checksin both backends (emit.ml:1037,x86.ml:2164; same for SliceFromPtr'sn >= 0). A negative-length slice reaches user code.flan_vec_as_slicechecks unconditionally — the model. reg leakslies at >3072 live blocks (runtime/flan_dev.c:1521): compaction then fires on every allocation, the epoch stays odd, all 8 scan retries fail, andflan_dev_reg_by_typereturns 0 — measured 198/200 wrong answers on a full table. Plusflan_reg_snapfailure iscontinued past, contradicting its own contract.- Emacs framing: a truncated frame raises wrong-type instead of the timeout message
and leaves the partial frame in the buffer, desyncing every later request by one frame
(
flan-dev.el:220);extract-replyreads before it deletes, so an unreadable payload wedges the connection permanently (:187). One ordering fix covers both. - Emacs poll vs watch:
flan-dev--pollbypassesflan-dev-settle-hookand consumes the watch's reply; the two consumers stay swapped for the session (flan-dev.el:406). Alsorequestreconnects before running the settle hook — 30s freeze after a daemon restart with the watch armed. - C-x C-e at point-min installs an empty declaration (
flan-dev.el:1745backward-sexpno-op unchecked); same predicate fires inside strings and misjudges narrowed buffers. flan-connect+flan-dev-quitkill two sessions (flan-dev.el:721): quit sendsclosedown the current connection and kills the daemon it started for another program.reg atTOCTOU (dev.ml:1488): the stopped-only gate is checked three round trips before the render thunk runs; arestartin between lets the thunk chase freed memory with the gate's blessing.defenum values never range-checked to i32— FIXED. Every resolved member value, explicit or autoincremented, is range-checked against i32 in the parser before the collision scan, so the scan compares the numbers the program will actually have. Out of range is refused by name (parse/enum-value-out-of-range); explicit-duplicate aliasing stays legal.- NaN sign: LLVM constant-folds
0.0/0.0tonan, x86 computes-nan— a stdout DIFFER on a two-line program. Runtime paths agree (-nanboth). emit.ml:3369transient test ignoresnew_globals: on theretains=falsepath a module first to intern a global gets dlclosed; zero-init makes it moot today, a literal init would dangle.- x86 to-bytes helper-return clobber:
(defn numstr [n] (string (i64->bytes n)))works on LLVM, garbage on x86 — documented caller's-problem UB, but the divergence makes the documented edge invisible. - map-grow at log2cap>=40 quotes a stale failure (
flan_rt.c:2438, pre-dates yesterday).
JS backend (deprioritised 2026-09-18, do not schedule)
lib/js.ml:889still matches 1-arg*ToBytes; commit81b807fmade them 2-arg, so every number-printing program is refused — JS corpus MATCH went 24 → 3 and@jsstays green because a refusal counts as success. Needs a MATCH floor in strict mode.- f32 literals keep double precision (
js.ml:671discards the fkind;Math.froundit). - The cast-range trap quotes the cast's column, not the operand's (
js.ml:1077).
Verified clean, don't re-hunt
flan_map_remove (two randomized ASan harnesses incl. non-power-of-two cells, zero
mismatches), the grow-path overflow guards, the seqlock protocol itself, the park/rerun
condvar, to_bytes's 64-byte slot arithmetic, elisp multibyte framing (unibyte
throughout, tested), JS struct value semantics and 64-bit integer semantics (broad
probes), division sign/INT_MIN traps at all widths, if/match dead-set joins,
defer's kill-at-registration, index widening rules.