From ce3e879e834951a303a0e24d616e6c78d2447f51 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Fri, 18 Sep 2026 07:08:49 +0700 Subject: [PATCH] The hunt's ledger: five dispatched, the rest recorded --- docs/BUGS-2026-09-18.md | 128 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 docs/BUGS-2026-09-18.md diff --git a/docs/BUGS-2026-09-18.md b/docs/BUGS-2026-09-18.md new file mode 100644 index 0000000..1a3b14c --- /dev/null +++ b/docs/BUGS-2026-09-18.md @@ -0,0 +1,128 @@ +# 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, `g` freed, `(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; `at` then + hands out an owning header, `free` accepts 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: refuse `free` of a non-binding target, or region-check the element at + `push`/`put`. Sixth on the list; needs a deliberate mixed-allocator construction. +- **Reversed slice under `--no-bounds-checks`**: the `lo <= hi` test is a representation + invariant, not a bounds check, but sits behind `if f.md.checks` in both backends + (`emit.ml:1037`, `x86.ml:2164`; same for SliceFromPtr's `n >= 0`). A negative-length + slice reaches user code. `flan_vec_as_slice` checks unconditionally — the model. +- **`reg leaks` lies at >3072 live blocks** (`runtime/flan_dev.c:1521`): compaction then + fires on every allocation, the epoch stays odd, all 8 scan retries fail, and + `flan_dev_reg_by_type` returns 0 — measured 198/200 wrong answers on a full table. + Plus `flan_reg_snap` failure is `continue`d 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-reply` reads before it deletes, so an unreadable payload + wedges the connection permanently (:187). One ordering fix covers both. +- **Emacs poll vs watch**: `flan-dev--poll` bypasses `flan-dev-settle-hook` and consumes + the watch's reply; the two consumers stay swapped for the session (`flan-dev.el:406`). + Also `request` reconnects 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:1745` + `backward-sexp` no-op unchecked); same predicate fires inside strings and misjudges + narrowed buffers. +- **`flan-connect` + `flan-dev-quit` kill two sessions** (`flan-dev.el:721`): quit sends + `close` down the current connection and kills the daemon it started for another program. +- **`reg at` TOCTOU** (`dev.ml:1488`): the stopped-only gate is checked three round trips + before the render thunk runs; a `restart` in between lets the thunk chase freed memory + with the gate's blessing. +- **defenum values never range-checked to i32** (`parse.ml:994`, `check.ml:1666`): the + collision rule compares i64s, so `[A 0 B 4294967296]` passes and both are 0 at runtime; + autoincrement can overflow silently on --x86. +- **NaN sign**: LLVM constant-folds `0.0/0.0` to `nan`, x86 computes `-nan` — a stdout + DIFFER on a two-line program. Runtime paths agree (`-nan` both). +- **`emit.ml:3369` transient test ignores `new_globals`**: on the `retains=false` path 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:889` still matches 1-arg `*ToBytes`; commit 81b807f made them 2-arg, so + every number-printing program is refused — JS corpus MATCH went 24 → 3 and `@js` + stays green because a refusal counts as success. Needs a MATCH floor in strict mode. +- f32 literals keep double precision (`js.ml:671` discards the fkind; `Math.fround` it). +- 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.