The registry compacted whenever the table was three quarters full, and a compaction reclaims dead entries and nothing else. A program holding more than three quarters of the table in live blocks therefore compacted on every allocation for the rest of its life, reclaiming nothing each time and holding the table-wide epoch odd while it did. A listing racing that loop lost all eight of its attempts and answered with zero rows -- "nothing is held", about a program holding three thousand blocks, from the verb that exists to find a leak. Measured at 199 wrong answers in 200. The trigger now also asks whether there is an eighth of a table's worth of dead to reclaim, which is a count four places maintain: a death, an arena's free-all, a note written over a dead slot, and the sweep itself. That bounds the cost from the other side too, since a sweep that runs reclaims at least 512 slots and so cannot run twice in 512 allocations. Separately, flan_dev_reg_by_type answered a walk it could not take with zero rows, which is the same number a program that had freed everything gets, and stepped past slots flan_reg_snap could not copy while still calling the walk whole. It now counts those slots and returns -1 with the count, the agent refuses in a sentence the daemon already renders, and the snap contract says which caller keeps it and why reg_at is allowed not to. A note that finds no slot is still dropped -- dying because a diagnostic ran out of room would be the diagnostic shooting the patient -- and now says so on stderr once, quoting how many entries were dead rather than claiming the table is all live. test/dev_limits.c gains three modes, driven from test_reload: 3100 live blocks read under a writer thread (1 right in 200 before, 200 after), 3000 live with 600 churned on top of them to prove the sweep still runs, and a genuinely full table that must say so exactly once.
131 lines
8.7 KiB
Markdown
131 lines
8.7 KiB
Markdown
# 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**~~ — **fixed**. Compaction now also asks
|
||
whether there is an eighth of a table's worth of dead to reclaim, so a table full of
|
||
live blocks stops thrashing the epoch; `flan_dev_reg_by_type` returns −1 with an unread
|
||
count rather than zero rows, and the agent refuses in a sentence; a note dropped by a
|
||
genuinely full table says so on stderr once. `test/dev_limits.c regfull` pins it under
|
||
a writer thread: 3100 live blocks, 200 asks, 199 wrong before and 200 right after.
|
||
- **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.
|