The hunt's ledger: five dispatched, the rest recorded

This commit is contained in:
Joseph Ferano 2026-09-18 07:08:49 +07:00
parent 1f75744b06
commit ce3e879e83

128
docs/BUGS-2026-09-18.md Normal file
View File

@ -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 width1
`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.