`-dev-` was in every Emacs symbol this client owns and meant nothing to anyone typing one: the daemon is `flan dev` at a shell, but from inside Emacs there is no other kind of connection to distinguish it from. `M-x flan-dev` is now `M-x flan`, `flan-dev-quit` is `flan-quit`, the private prefix `flan-dev--` is `flan--`, and every defcustom follows — ninety-odd symbols, with the two files renamed to emacs/flan.el and emacs/test-flan.el so the file names say the same thing as the symbols in them. No aliases. Renaming a defcustom breaks a config that names it and there is no way around that; the repo has no precedent for softening one, and an alias left behind is what keeps a rename from finishing. MANUAL.md says the old names are gone and how to fix a config, which is the whole of the migration path. Three strings are not symbols and keep their spelling: `.flan-dev.sock`, which bin/main.ml writes and which a renamed variable searching for a renamed file would simply never find; and the two buffer names `*flan-dev*` and ` *flan-dev*`, which name the `flan dev` subcommand's own output rather than anything in elisp. `flan dev` with a space is the CLI and is untouched everywhere. The entry point also stops asking a question it already has the answer to. From a buffer visiting a .flan file it starts that file; from anywhere else it reads one from the minibuffer as before; `C-u` reads one either way, which is how you start a second program without leaving the first. The current buffer is still the only source of the default — the bug where a previous project won over the buffer you were in was fixed by removing `flan--file` from that position, and nothing here puts it back. Four checks on the `interactive' form, evaluated on its own rather than by calling the command, because calling it would build and launch a program and the question is only which file the form arrives at and whether it had to ask. A fifth asserts that nothing answers to the old names. test/test_emacs.ml loads the test file by path and test/test_session.ml names the client file in a comment, so the rename reaches those two lines; nothing else outside emacs/ and the docs moved. Verified by byte-compiling every file clean and by `dune test` and `@page`.
221 lines
14 KiB
Markdown
221 lines
14 KiB
Markdown
# Production-readiness review — 2026-09-17
|
|
|
|
Three review lanes (C runtime, compiler robustness, tooling/UX) plus a suite run on the
|
|
merged tree (`e9d0b99`, 232 checks, 0 failures, `@x86` 104 MATCH / 0 DIFFER). This file is
|
|
written to be implemented from: each item says what is wrong, where, and what the fix is.
|
|
Items marked **(known)** are already recorded in NEXT.md/FIX.org and are listed so the
|
|
ranking is complete, not because they are news.
|
|
|
|
**The verdict in one paragraph.** The development loop is the most finished part of the
|
|
project and the shipping loop is the least. A solo developer in Emacs on this machine can
|
|
build a small raylib game today and the experience is genuinely good — the module system,
|
|
the Emacs client's failure handling, the `import-c` header diffing, the diagnostics (a real
|
|
span-and-notes system, ~365 located refusal sites, zero warning suppressions), and full
|
|
LLVM/x86 backend parity are production-grade. Nearly every gap sits at the boundary where a
|
|
second person, a second machine, or a shipped binary appears — plus three memory-safety
|
|
holes in the runtime that nothing currently mentions.
|
|
|
|
---
|
|
|
|
## Tier 1 — correctness blockers
|
|
|
|
These produce silent wrong values or memory corruption in programs that look fine.
|
|
|
|
### 1.1 Every number→string conversion aliases one static buffer **(known, unenforced)**
|
|
`runtime/flan_rt.c:217-218` — `flan_i64_to_bytes` / `flan_f64_to_bytes` / `flan_u64_to_bytes`
|
|
all return `{scratch, len}` into one shared `static char scratch[64]`, and `emit.ml:2266-2270`
|
|
never copies the bytes out. Holding two results — `(vec-push! v (str i))` in a loop, a
|
|
`(str n)` stored in a struct — silently reads clobbered bytes. No crash, no diagnostic, so
|
|
sanitizers never see it. NEXT.md's "Sharp edges" records it; the prelude's `append-i64!`
|
|
family works around it; nothing *enforces* it.
|
|
**Fix to decide, then do:** either make the runtime conversions allocate from
|
|
`context/temp` (semantic change, kills the hazard everywhere), or have the checker type
|
|
these results as a distinct short-lived slice that may not be stored or outlive the next
|
|
conversion. The first is simpler and matches the Odin idiom already adopted for arenas.
|
|
|
|
### 1.2 `cap * size` overflow at container growth
|
|
`runtime/flan_rt.c:1336` (Vec), `:1574` (Pool, `cap*size + cap*sslot`). `flan_vec_reserve`
|
|
(`:1374`) forwards arbitrary `n` and the `1<<40` clamp at `:1333` is bypassed when `want`
|
|
exceeds it (`cap = want; break;`). A wrap to a small positive allocates a tiny block while
|
|
`v->cap` stores the unwrapped value; the next push memcpys far past the block (`:1386`).
|
|
Same family, lower reach: `flan_over_budget` (`:785`), `flan_map_block_size` (`:2080`).
|
|
**Fix:** `__builtin_mul_overflow` (or `size > INT64_MAX/cap`) at every grow/reserve site;
|
|
overflow reports as `StorageExhausted` like any other allocation failure. Small, mechanical.
|
|
|
|
### 1.3 `Map` has no removal
|
|
`runtime/flan_rt.c:1788`, `:2439` — deferred, no tombstones. A `Map` you cannot delete a
|
|
key from is a daily-use gap, not an edge case (entity tables, caches).
|
|
**Fix:** Robin Hood backward-shift deletion (no tombstones needed with the existing
|
|
cache-line-run layout), a `map-remove!` builtin through check/emit/x86, tests on both
|
|
backends. Medium-sized, self-contained.
|
|
|
|
### 1.4 The dev allocation registry is read cross-thread with no synchronisation
|
|
Writer: game thread inside every alloc/free (`runtime/flan_dev.c:1130`, `:1180`). Reader:
|
|
the agent's listener thread (`vendor/agent/flan_agent.c:1081`, `:1146`) on a *running*
|
|
program — the `reg` verb has no stopped-gate, unlike the frame chain. No seqlock, no
|
|
atomics, and `flan_reg_compact` (`flan_dev.c:1099`) memsets and reinserts the whole table
|
|
mid-scan. A torn `(type, typelen)` pair is an out-of-bounds read in the listener.
|
|
Ranked Tier 1 because the dev loop is the project's priority.
|
|
**Fix:** either gate the `reg` verb on stopped (matching the frame chain — smallest change),
|
|
or give the registry the same per-slot seqlock the watch table already has
|
|
(`flan_dev.c:380-870` is the template in the same file).
|
|
|
|
### 1.5 `(addr (.field x))` on an `Option` — confirm, then fix
|
|
FIX.org records `Tast.Addr (Tast.Pfield ...)` failing on both backends (working route:
|
|
`Prim (AddrOf, [Field ...])`). `check.ml:4715` builds `Tast.Addr` from user-writable
|
|
`(addr <place>)` and `check.ml:2968` builds `Pfield` from `(.field x)`, so the combination
|
|
is expressible today; neither `emit.ml:1371-1375` nor `x86.ml:2070` has an Option arm.
|
|
**Fix:** first write the failing program to confirm reachability from source; then either
|
|
lower `Addr(Pfield)` through the AddrOf route in `check.ml`, or add the Option arm to both
|
|
backends. If unreachable from source, add the refusal-by-name that the house rule requires.
|
|
|
|
---
|
|
|
|
## Tier 2 — the install and shipping story
|
|
|
|
One coherent problem: the compiler runs only from its checkout, and its output runs only on
|
|
this machine. This is the single largest thing between "the author's language" and
|
|
"a language someone else can try."
|
|
|
|
### 2.1 There is no install path
|
|
`dune-project` is two stanzas — no `(package ...)`, so `dune install` cannot work; the
|
|
documented way to run the compiler is `dune exec`. Worse, the README's suggested workaround
|
|
(copy the binary onto PATH) silently breaks the flagship feature: the merged `flan dev`
|
|
needs `flan.cmxa` + `flan.a` *beside the binary* (`lib/dev.ml:3198-3203`) and `ocamlfind`
|
|
on PATH at runtime (`lib/dev.ml:2898`). The failure message names the escape hatches
|
|
(`FLAN_LIBDIR`, `--two-process`) but neither README nor `emacs/MANUAL.md` mentions them.
|
|
**Fix:** a `(package)` stanza with install rules that place `flan.cmxa`/`flan.a` where the
|
|
binary's own lookup finds them; document `FLAN_LIBDIR`; make the README's install section
|
|
truthful about what `flan dev` needs.
|
|
|
|
### 2.2 A built game runs only on machines configured like this one
|
|
No `-static` anywhere in `lib/build.ml`; `vendor/raylib/link` names `-l:libraylib.so.550`
|
|
by exact soname (a Fedora-ism — no unversioned symlink). The binary is otherwise genuinely
|
|
standalone (the C runtime is embedded in the compiler, `lib/dune:25-41` — right design).
|
|
**Fix:** a `flan build --static` (or bundled-raylib) option, and per-target link lines that
|
|
do not hardcode one distro's soname.
|
|
|
|
### 2.3 The env-var surface is real and entirely undocumented
|
|
`FLAN_CLANG`, `FLAN_EMCC`, `FLAN_LD`, `FLAN_LLC`, `FLAN_WASM_SYSROOT`, `FLAN_WASM_BUILTINS`,
|
|
`FLAN_CACHE_DIR`, `FLAN_OCAMLFIND`, `FLAN_LIBDIR`, `FLAN_RAYLIB_WEB` — none in the README.
|
|
Also undocumented: the live loop needs `llc`/`ld` at an LLVM version matching clang
|
|
(`lib/build.ml:886-962`), and a mismatch breaks `C-c C-c` while `flan build` keeps working.
|
|
**Fix:** an env-var table in the README, and a sentence about the llc/clang version coupling.
|
|
|
|
---
|
|
|
|
## Tier 3 — the standard library
|
|
|
|
The containers, strings/UTF-8, sequences, random, and printing layers are decent. The gaps:
|
|
|
|
- **No clock of any kind** — no `now`, no monotonic time, no `sleep`, not in prelude or
|
|
runtime. A raylib game gets time from raylib; a non-graphical tool cannot time anything.
|
|
Blocker for the "or tool" half of day-to-day use. A `time`/`sleep` builtin pair backed by
|
|
`clock_gettime` is small.
|
|
- **Math is five f32 functions** (`lib/prelude.ml:624-677`: sqrt, sin, cos, atan2, pow).
|
|
No tan/asin/acos/log/exp/fmod/abs/hypot, no f64 variants, no PI constant.
|
|
- **File IO is whole-file only** (`slurp`/`barf`). No streaming, stdin, directory listing,
|
|
metadata, delete/rename/mkdir.
|
|
- **No env vars, no process spawn.** `argv` and `exit` are the whole OS surface.
|
|
- **The prelude is an OCaml string literal** (`lib/prelude.ml:32`) — cannot be read as Flan,
|
|
extended, or replaced without rebuilding the compiler. Its own docstring promises a
|
|
`core:` package "at milestone 3" that does not exist. The `import-c` header-diffing is a
|
|
strong mitigation (users can bind libc and be told when they get it wrong), but the
|
|
promised `core:` migration is the structural fix.
|
|
|
|
---
|
|
|
|
## Tier 4 — robustness and polish (small, high-value)
|
|
|
|
- **`Sys_error` uncaught in the CLI** — `flan check nosuch.flan` →
|
|
`Fatal error: exception Sys_error(...)`. The daemon already has the arm
|
|
(`lib/dev.ml:2613-2621`); copy it into `bin/main.ml:8-32`. One line. Add a `Not_found`
|
|
backstop arm at the same time — nothing reaches it today, but the failure would be a
|
|
message-less `Fatal error: exception Not_found`.
|
|
- **Three tests leave `Fatal error: exception Flan.Loc.Error(_)` on stderr**
|
|
(test_acceptance, test_session, test_web; suite still passes). Some spawned compiler
|
|
process dies without going through the error printer — locate the spawn (grep the dune
|
|
log attribution), and either wrap it or assert on the formatted message instead.
|
|
- **`abort()` in the dev runtime** — `flan_dev.c:47-50`, `:103`: reload-name-table
|
|
exhaustion, intern OOM, and "global changed size" kill the game instead of signalling.
|
|
Against the grain of everything else in the runtime; route through `flan_error`.
|
|
- ~~**Six trap paths bypass `flan_exit_hook`** and end a merged `flan dev` session~~ —
|
|
fixed 2026-09-18. Two corrections to the entry as written. The hook bounds and
|
|
arithmetic park through is `flan_break_hook`, not `flan_exit_hook` — that second one is
|
|
normal termination, the one `main` reaches when it ends. And the six could not be routed
|
|
through it as it stands: `flan_break_hook`'s contract is that the loop may answer by
|
|
aiming a transfer channel, and these six are called by emitted code that falls off the
|
|
end with no channel in the call at all, so a restart chosen against one would be accepted
|
|
and silently dropped. So there is a second hook, `flan_trap_hook`, and a break that
|
|
refuses the resume with a reason rather than a process that exits before anyone can ask
|
|
a question. All six park, for two reasons rather than one. Four are guards that fire
|
|
*before* the operation they guard (`if (!a)` and the capability test both precede
|
|
`a->proc(...)`), so nothing is half done. The other two — `flan_transfer_fail` and
|
|
`flan_restart_unarmed` — fire mid-transfer, with this frame's defers possibly half run,
|
|
and park only to be *looked at*: stopping on a torn unwind is strictly more than exiting
|
|
before anyone can ask what tore it. Standalone is unchanged: the
|
|
hook is null in a program that did not import the agent, and the trap still exits 134
|
|
with the same sentence.
|
|
- **Unchecked `malloc` in `flan_argv`** (`flan_rt.c:162`) — the one in the file.
|
|
- ~~**`v->gen` stale-slice word is maintained and never consulted**~~ — deleted
|
|
2026-09-18 with the second round of the repeal; the header is five words now.
|
|
- **`flan_slurp_into` conflates elements and bytes and skips `flan_vec_check`**
|
|
(`flan_rt.c:2746`) — safe only because check.ml pins slurp to `(Vec u8)`; a latent trap.
|
|
- **`flan run` swallows build flags as program arguments** (`bin/main.ml:634-651`) —
|
|
`flan run game.flan --debug` hands `--debug` to the game. Filter or refuse by name like
|
|
every other subcommand. Also: no `-O` control anywhere (`Build.default` pins `-O2`;
|
|
`--debug` is the only route to `-O0`).
|
|
- **`main` signature errors print `<unknown>:0:0`** (`check.ml:6033`, `:6038`) —
|
|
`env.locs` already holds the decl location; use the existing `find_opt` idiom.
|
|
- **`flan_shim_cstr` accepts embedded NULs** that `flan_path_cstr` refuses
|
|
(`lib/shim.ml:310` vs `flan_rt.c:2639`) — pick one policy.
|
|
- **No `-Wall -Wextra` on the runtime's C compile** (`lib/build.ml:827`, `:1069`).
|
|
- **Package visibility** — everything in a package is public except `main`
|
|
(`lib/load.ml:25-31`); `rl/get-color-raw` is the recorded symptom.
|
|
- **Emacs client**: 30s hard deadline with no retry on long builds (`flan.el:155-170`);
|
|
`accept-process-output` loops can freeze Emacs up to 60s on a hung daemon (`:521`,
|
|
`:576-600`); no package headers, so not installable off MELPA or by path alone.
|
|
- **No CI** — README states it openly and records two silent-failure incidents. `@checks`
|
|
exists; a workflow that runs `dune build @checks` on push is the whole job. Note `@x86`
|
|
parity is only under `@checks`, so routine `dune test` does not protect parity.
|
|
|
|
## Documentation corrections (cheap, decision-relevant)
|
|
|
|
- **`docs/DISCUSS.md:762` is stale and argues the opposite of the truth**: it lists the
|
|
condition/restart family under "no plan" for the x86 backend; `x86.ml:1587-1615` lowers
|
|
all of it and the survey shows 104/104 parity. Anyone reading it for a production
|
|
decision concludes wrongly.
|
|
- README documents 4 of 11 subcommands — `import-c`/`generate-c`, the most valuable
|
|
undocumented feature, are missing (`README.md:105-119` vs `bin/main.ml`).
|
|
- `lib/prelude.ml:8-10` promises the nonexistent `core:` package.
|
|
- Root clutter: working artifacts (`MY-NOTES.org`, `plan.org`, committed binaries,
|
|
`sand.js`/`sand.wasm`, `old-ocaml/`) a newcomer must ignore.
|
|
|
|
---
|
|
|
|
## Deliberately out of scope — do not pick these up from this report
|
|
|
|
- **`drop` / recursive teardown** — parked on `worktree-agent-a18e9e62485eaedb5` with
|
|
`docs/handoffs/HANDOFF-drop.md`; the arena route replaced it (FIX.org item 4, merged).
|
|
- **JavaScript backend** — held (FIX.org item 6).
|
|
- **wasm32/browser** — explicitly deprioritised; the ILP32 `(size_t)` truncations and the
|
|
emscripten gaps are recorded here but not queued.
|
|
- **macOS/Windows portability** (`aligned_alloc`, `MSG_NOSIGNAL`, `__atomic_*` vs
|
|
`stdatomic.h`, `long ftell` 2 GiB cap) — real, recorded, not current-machine problems.
|
|
- **Known and accepted**: seqlock memcpy formal-UB (correctly fenced, retry-bounded);
|
|
one-`.so`-leak-per-reload (cells hold module text addresses by design); the arena route's
|
|
compile-time→runtime-trap trade (stated in FIX.org); string-literal write-through
|
|
(waiting on provenance, plan.org decision #3).
|
|
|
|
## Suggested order
|
|
|
|
1. **Quick wins, one sitting**: Sys_error + Not_found arms; `flan_argv` malloc check;
|
|
`main`-signature locations; `flan run` flag filtering; DISCUSS.md:762 correction;
|
|
README subcommand + env-var tables.
|
|
2. **Runtime correctness**: mul-overflow guards (1.2), registry sync (1.4),
|
|
scratch-buffer decision + fix (1.1), `map-remove!` (1.3), dev-runtime aborts → signals.
|
|
3. **Confirm and fix** `Addr(Pfield)` on Option (1.5); locate the stderr `Loc.Error` fatals.
|
|
4. **Install story** (2.1, 2.3), then **shipping** (2.2).
|
|
5. **Stdlib**: clock first, then math, then IO/env — each is independent.
|
|
6. **CI**: `dune build @checks` on push.
|