flan/FIX.org
Joseph Ferano 76270eac0d The test directory had been copying its own plumbing, file by file
Ten test binaries share a directory and had shared nothing in it but
watchdog.ml. Everything else each one needed it wrote out again: the
failure counter and its FAIL line, the three-line report tail, a poll,
a socket connect, the wait for a [flan dev] daemon to bind, a substring
search, and the Load -> Check -> Reach.link front half of a compile.

[listening] was the clearest case. Three copies, byte for byte apart
from one comment, and two of them said in that comment that they were
kept separate because "these three files have no module between them".
That was not true when it was written: watchdog.ml was already named in
the same (modules ...) stanzas. test_support.ml is the second such
module, wired the same way, and those two sentences go with the copies
they were explaining.

test_repl.ml's [quote] was Wire.quote character for character, in a file
that already links Wire and already names Wire.quote in a comment about
what the case below it is checking. It is Wire.quote now.

One real behaviour change, and it is a fix. [connect] existed twice over
with different retries: the agent's narrowed to ECONNREFUSED with a
comment saying why -- the socket file appears at bind, a moment before
listen -- while dev's and repl's retried any Unix_error, which meant an
ENOENT or an EACCES was retried to the full timeout before raising
something the reader still had to interpret. The shared one takes the
narrow version. Every caller connects to a socket [listening] has
already seen on disk, so the race it does catch is the only one left.

The rest is left where it is, on purpose. The three output-capturing
[run]s differ in what they wrap -- a pid suffix, a sanitizer environment,
a valgrind invocation -- and are not the same function. The report tails
in test_repl, test_web and the two sweep binaries print different things
for different reasons. The per-file scratch prefixes are the feature that
keeps two suites running at once from unlinking each other's sockets, so
the shared helper takes the prefix rather than choosing one. And the
[match Sys.command "command -v clang ..."] probes stay as they are:
their skip lines are output this suite pins.

bin/main.ml has the compile pipeline written out twice more. Left alone
-- this was a test/-scoped change and bin/ should not be reaching into a
test module -- and noted in FIX.org as what it actually needs, which is
the pipeline moving into lib/.

dune test: exit 0, and its output is the same line for line once the
temp-directory hash and the millisecond counts are normalised.
2026-09-20 11:56:30 +07:00

822 lines
49 KiB
Org Mode
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

* Stuff I've found
** Why do I need to call flan to open another window?
let: flan: the program exited; restart flan dev
** I can't eval a toplevel defvar, need to eval-defun (C-c C-c)
For Flans intended live-program workflow, C-x C-e on any complete top-level form should do the natural thing:
- expression → compile/run temporary thunk; print its value
- defn, defvar, defmacro, etc. → compile/install it; report what changed
The compiler already has both paths. The current split is an editor/UI artifact: C-x C-e is wired directly to eval-expr, while C-c C-c is wired to declaration reload. It is not a fundamental limitation.
A good fix would make C-x C-e context-aware: if the enclosing form is top-level, send it through the declaration evaluator; otherwise use expression evaluation. Then C-c C-c can remain a convenient explicit “reload this definition” alias, but not the only way defvar works.
** I can't eval a top level Vec
slurp returns (Vec u8), an owning, move-only buffer. Flan currently forbids every move-only global because it has no global ownership/lifetime model: any function could read and free it, while ownership tracking only exists within one function.
For data that is fixed at build time, use an embedded immutable array instead:
(defconst the-data (embed "game-data.edn"))
That produces a fixed [u8], not a heap-owning Vec, so it can live globally. It also resolves relative to sand.flan.
If game-data.edn genuinely must be loaded at runtime, then today it has to be owned by a local—typically load it in main and pass it through the functions that need it. For a game-wide runtime-owned data asset, that is a missing language/runtime feature, not a bad use case on your part.
** The edn module seems to need a struct declaration, it should do both; go into a struct but also return a Map with Vecs and Sets when we don't provide a type
** defenum needs optional autoincrementing discriminants
** We need a javascript backend so we can reach the world
** We need to have C-style unions, maybe those are called defunion, and then sum types are defdata or deftype
* Decisions, 2026-09-17
** 1. Re-runnable main — DISPATCHED
The process does not actually die. [flan_exit_hook] is [flan_merged_exit]
(lib/dev.ml:2669): it flushes, reclaims fd 1, and parks in [for (;;) pause()].
What is missing is a way to wake it. The park becomes a condvar wait, a daemon
op signals it, and the main thread — not a new one, because raylib wants the
main thread — re-enters [flan_program_main]. [alive] gains a third state,
parked-and-re-runnable, and each of the ten guard sites decides for itself
whether it accepts one.
Globals are NOT reset between runs. That is the CL/Clojure semantics asked for:
the process never died, so a second (main) sees what the first one left.
** 2. C-x C-e on a top-level form — QUEUED behind 1
Same file as 1 (emacs/flan.el), so it waits rather than merging by hand.
No design questions; the note specifies it.
** 3. Runtime-loaded owning globals — DISPATCHED
Not a missing global ownership model. One rule: a move-only global is legal,
and reading one is always a borrow, never a move. Nothing takes ownership,
nothing frees it, its lifetime is the process's. Sound precisely because the
lifetime question has a constant answer.
Mutable in place as well — a global Vec can be pushed to. Aliasing follows
whatever locals already do; no new borrow regime for globals that locals lack.
[embed] (lib/check.ml:4265) still covers build-time data and is untouched.
** 4. edn both typed and dynamic — REDIRECTED to arenas, drop parked
Two projects, not one. (read-edn T bytes) does not exist — vendor/edn/edn.flan
is only a tokenizer, and the compile-time struct walk is NEXT.md item 9.
[drop] was dispatched to unblock the dynamic half and is being PARKED unmerged
on its branch, not reverted, because the premise was wrong. The refusal at
check.ml:599 is about *teardown*, not ownership: the type-erased runtime
releases slots bytewise and cannot walk a move-only element. An arena never
releases a slot — [free-all] takes the whole region — so the premise does not
hold there.
That is also what Odin does, which NEXT.md:1620 already recorded: no
destructors, no drop, no finalizers; [delete] frees container memory and
nothing else. core:encoding/json ships a hand-written recursive
[destroy_value] in the *library*, and the idiomatic alternative is to parse
against temp_allocator and [free_all]. Neither is a language feature. Building
[drop] was a departure from NEXT.md:1589's settled "defer stays the answer",
taken on the assistant's prompting and withdrawn.
So: lift check.ml:599 for arena-allocated containers, and let read-edn take an
allocator — which is already the idiom, since spec-memory.md:283 makes the
allocator part of the calling convention with an explicit override.
The real cost, stated because it is not free: [can-free] is a RUNTIME
capability on the allocator value while check.ml:599 is a COMPILE-TIME refusal,
and the compiler cannot generally know statically that a construction site's
allocator is an arena. The spec's answer for the analogous drop case is a check
at the point of construction, one branch per container — a runtime branch. This
likely becomes a runtime trap rather than a static guarantee.
Ownership tracking itself is untouched. Moves are still tracked; what is given
up is freeing one element individually, which is the point of an arena.
** 5. defenum autoincrement — DISPATCHED
C's rule: no value means previous+1, the first is 0, explicit and implicit mix.
Duplicates: an explicitly written one is an intended alias and is allowed. One
produced by autoincrement walking into a value another member holds is an
accident and is refused, naming both members.
** 6. JavaScript backend — HELD
wasm32 already works: test/wasm-run.mjs is a WASI host, the test table runs
wasm32 builds, web/index.html is in the tree. A second backend beside emit.ml
and x86.ml is the largest item here and the dev loop comes first.
** 7. defdata and defunion — QUEUED last
Today's [defunion] is already the tagged sum type. It is renamed [defdata],
and [defunion] becomes the C-style untagged one. Serves both FFI and type
punning, and cimport verifies it against the header where one exists —
cimport.ml:295 currently skips any record holding an anonymous union, leaving
the defstruct beside it unchecked.
Last, because the rename sweeps parse/check/emit/prelude/docs and every .flan
file, and would conflict with everything above.
* Open, found while working the list
** A transient signal -11 on the globals daemon
Seen once, in one of three consecutive test runs, by the agent doing the
C-x C-e work; the runs either side of it were clean. Not reproduced since —
three forced full runs (dune test --force) are green, 232 checks, 0 failures.
Worth remembering rather than chasing, because the daemon it appeared on is
one the move-only-global work (c124df3) changed: test_reload's fixture gained
a host global Vec and a run-time-new one. A teardown or a reload module that
defines rather than declares a global Vec would strand the block the live
process is using, which is exactly the shape a rare SIGSEGV takes. If it comes
back, start there.
* Status, end of 2026-09-17
Six of seven items are merged on dev-loop and green (dune build, dune test
--force, @x86, @page). One agent is still running: the arena work for item 4.
| item | what | state |
|------+------+-------|
| 1 | re-runnable main after the window closes | merged |
| 2 | C-x C-e installs a top-level form | merged |
| 3 | move-only globals, borrowed never moved | merged |
| 4 | edn dynamic value | merged (arena route) |
| 5 | defenum autoincrement | merged |
| 6 | javascript backend | first lane IN FLIGHT, 2026-09-17 evening |
| 7 | defdata rename + C-style defunion | merged |
Also merged, not from the list: macro-module symbol visibility, which
unblocked [flan dev --x86] in one process. sand.flan --x86 builds in 292ms
against 985ms on LLVM.
** Parked branches, kept deliberately
- worktree-agent-a18e9e62485eaedb5 — [drop] and recursive teardown. Finished
and green, not merged. See docs/handoffs/HANDOFF-drop.md, which is the part
worth keeping. Withdrawn because the refusal it answered is about teardown,
and an arena has none; see item 4 above.
** Open, carried forward
- A transient signal -11 on the globals daemon, seen once, not reproduced.
Recorded below.
- [drop]'s handoff flags that the [clone] / [get] / [map-next] refusals are
needed by the arena route too — they are about a copy of a header, which an
arena does not make safe — and that a Map has no operation answering *where*
a value lives, which is what reading an arena-parsed EDN document back would
need. Both were relayed to the arena agent.
- [Tast.Addr (Tast.Pfield ...)] on an Option: closed, and closed as
unreachable rather than fixed. Nothing in the source language builds it.
[.field] goes through [struct_target], which admits a struct and a pointer
to one and refuses everything else by name with a location — "(Option Point)
is not a struct, so it has no fields" — so [(addr (.x o))] never reaches a
place for [addr] to take. The node the drop lane hit was one the compiler
built for itself. Two rows in test_flan.ml pin the refusal, on the bare field
and on the address of one.
What is still asymmetric, and is a note rather than a bug: [x86.ml]'s
[field_loc] does lay out an Option's tag and value, and [emit.ml]'s [place]
admits only a named struct. Neither is reachable, so neither is tested, and
growing the LLVM side to match would be untestable code written to balance a
path nothing takes.
- Re-run still does not work under --two-process: a finished child is
genuinely gone. It now works under --x86 because --x86 runs merged.
- sand.flan still holds an uncommitted experiment line that is refused with a
message naming the fix: use (defvar the-data (Vec u8)) and fill it in a
function.
* Landed on dev-loop
Items 1, 2, 3 and 5 are merged and green (dune test --force, 232 elisp checks,
0 failures). Items 4 (drop) and 7 (defdata) are still being written. Item 6 is
held.
** Re-run is merged, and does not work under --x86
Park and re-run live in the merged entry point's main(), and --x86 refuses the
merged daemon by design: a merged host exports every flan.* body for -rdynamic
and so interposes the prelude bodies of the LLVM-built macro module the
compiler loads into itself. --x86 therefore runs --two-process, where the
program is a child, and a child that finishes is genuinely Gone — there is
nothing to wake. [Program.rerun] answers with the two-process refusal rather
than the merged one, and a test pins it.
Re-run on x86 needs the merged daemon to accept --x86 first. Separate work.
** C-x C-e answers against a parked program
The complaint: (+ 1 1) at the top of a buffer was refused with "an expression
is evaluated at a frame boundary, and a parked program reaches none". True, and
about the wrong thing — the expression needs nothing from the program, and the
ones that do need globals the parked process is still holding.
The fix adds a second place a thunk can run rather than loosening what a place
has to be. [flan_merged_park] now waits on two flags: [program_asked] leaves
the park and runs main, and [program_poll] — set by [Program.wake], which
[eval_expr] calls after the delivery — drains the agent's ring and waits again.
The thread stays PROGRAM_PARKED throughout, so [:parked t] rides on the reply
that carries the value.
Why this is safe without a new concurrency model: while parked there is no
concurrency at all. The program's thread is asleep on a condvar, no frame is
executing, no global is being written — which is precisely what a frame
boundary provides. The break loop is the precedent, a thread servicing the same
ring while it is not running frames. Common Lisp answers the same question by
giving evaluation a thread of its own (SWANK's [thread-for-evaluation]) and
documents the resulting race as the programmer's problem; there is no race here
to document.
Merged-build only, and for the reason re-run is: [liveness_of] maps a finished
child to Gone under --two-process, so there is no parked thread to wake.
What became answerable with it: a thunk can now stop in the break loop on the
parked thread, so backtrace, break, restart, restart-at, abort, locals/inspect
and globals stop refusing on the state alone and refuse on [parked_break]
instead — a paused expression against the park would otherwise be unresumable.
Refused still, because their cause is not the park: nothing else.
** The headline complaint is verified fixed on sand.flan
Window opened, closed, the daemon reported parked, (:op "rerun") returned ok,
and xdotool found a live window from the second run.
The earlier claim that [flan dev sand.flan] failed with "unknown function
begin-drawing" was true only on the stale base the work started from, and was
retracted after a re-test. Nothing to chase.
* Evening of 2026-09-17 — the review, and four lanes off it
docs/REVIEW-production-readiness.md is the production-readiness review, written to be
implemented from. Item 4 (arena EDN) merged green before it was written, so the FIX list
proper is six of seven done and one in flight.
Four agent lanes are running off the review, each in its own worktree:
- Tier 1 + the runtime half of Tier 4: overflow guards, map removal (Odin's
backward-shift), the registry race, the scratch buffer, Addr-on-Option, the dev-runtime
aborts. One lane because they share runtime/*.c.
- Tier 3: clock, math, getenv, basic file ops. Appends to flan_rt.c in its own section so
the merge with the lane above stays clean.
- Tier 4 without the runtime: CLI error arms, flan run flags and -O, CI, the stale
DISCUSS.md x86 table, README's missing subcommands and env-var table.
- The JS backend's first slice, per docs/DISCUSS.md §5's settled decisions: #_ first, then
one function under node, then the corpus with a MATCH/DIFFER/REFUSED survey. New
reference clones for it are recorded in docs/REFERENCES.md ("Compiling to JavaScript").
Tier 2 (install and shipping) was explicitly passed over. Package visibility is skipped in
every lane — it needs a syntax decision from the author.
** All four lanes merged, end of 2026-09-17 evening
Tier 1 (runtime correctness + the runtime strays of Tier 4), Tier 3 (clock,
libm, getenv, file verbs), Tier 4 (CLI arms, flags, CI, docs) and the JS
dialect's first slice are all on dev-loop. Verified together: dune test
--force 232/0, @x86 116 MATCH / 0 DIFFER, @js 0 DIFFER, @sanitize clean.
Left for the author, recorded where each lives:
- Package visibility needs a syntax decision (review Tier 4 item 5).
- The v->gen word: spec-memory.md mandates it, nothing reads it; delete or
implement is a spec amendment (BUILT.md records the two options).
- sand.flan:167 still holds the refused defconst experiment; the diagnostic
now prints in full and names the fix.
- Tier 2 (install and shipping) deliberately not started.
* The repeal, 2026-09-18
The ownership flow analysis is removed: the per-function dead set, the borrow
flag, the loop-iteration diff, and the borrowed-never-moved rule for globals.
Use-after-move and double-free are no longer compile errors. What stands:
move-only as a type property (assignment hands over the header, clone is the
only copy), the struct/union/pool ownership rules, defconst-vs-defvar for
move-only globals, defer, all allocator capabilities, and the dev build's
generation checks — now the primary net, which is the Odin position the
memory design came from.
Decided after the bug hunt put four of its ten lanes inside this machinery.
An unsound checker is worse than none, because it is believed. The door back
is spec-memory.md's provenance pass: removal widened acceptance without
changing any accepted program's meaning, so a stricter pass can return
additively. spec-memory.md "The repeal" is the amendment; BUILT.md and
NEXT.md are annotated at their live claims.
Two of the day's fix lanes were cancelled with this (borrowed-flag, region
element); the while-condition fix merged in the morning is deleted again by
the repeal, and its pin with it.
* The second round, 2026-09-18 — Pool, move-only, and the gen word
Ordered by the author after the ownership repeal, on the same argument: the
Odin position, full stop.
- Pool and (Handle T) are gone — types, checker arms, runtime section,
fixtures. Two containers are enough; a slab with generational handles is a
library over a Vec when a program wants one.
- The move-only concept is gone: everything copies as its header, copyable?
left the predicate list (four remain), and the struct/defdata/defunion
owning-field refusals are lifted. The region rule stands untouched — a
container of owning elements is still built against a region and released
by one free-all.
- The gen word left both container headers (read by nothing since it was
written). A Vec is ptr len cap allocator epoch; the epoch trap stays.
- Container globals keep both declaration rules, reworded: they start zeroed
(a global initialiser is a compile-time constant, and a container's only
constant is the empty one), and a defconst container is refused since a
constant is not an assignable place. sand.flan's experiment line would now
be refused with the reworded sentence.
Everything verified together: dune test --force 232/0 with zero suite FAILs,
@x86 122 MATCH / 0 DIFFER, @sanitize clean, and the whole-repo check sweep
against the parent differs only where it should: the two negative fixtures
now accepted (vec-in-struct, the clause-less generics corpus), the two pool
fixtures deleted.
* End of 2026-09-18 — the hunt closed out
Ten bug lanes and three demolitions, all on dev-loop and verified together:
232 checks / 0 failures, @x86 122 MATCH / 0 DIFFER, @sanitize clean.
- Fixed: while-condition move (then repealed with the machinery), defenum i32
range, the Emacs client's framing/poll/point-min/quit bugs, the session
NULL-cell rollback, the stdout pipe drains, x86 shift masking, the reversed
slice traps in every build, NaN prints unsigned, the registry answers
honestly under churn, and reg at's stopped-only race.
- Removed by decision: ownership flow tracking, Pool and Handle, the
move-only concept, the v->gen word. spec-memory.md carries the repeal.
- Still recorded, not scheduled: trap paths that bypass the break loop,
parked orphans outliving dead daemons, emit.ml's transient test and
globals, map-grow's stale quote, x86's slice-from-ptr sentence, the
float->int UB divergence, the narrowed-buffer C-x C-e quirk, and the JS
backend's items (deprioritised).
* Late 2026-09-18 — the last two lanes
- Six trap paths park instead of killing the session — merged (e6d85c8).
All six park for inspection; transfer-fail and restart-unarmed refuse the
resume with the trap's sentence. New flan_trap_hook beside flan_break_hook,
whose contract could not carry these. Standalone builds die as before.
- Parked orphans exiting with their daemon — merged. PDEATHSIG on the
two-process child, armed in the agent, with a spawn-SIGKILL-reap test.
Found in passing: the eight orphans split 4/4 — four are MERGED daemons
whose editor vanished, a separate defect (accept_loop has no client
liveness), recorded here. The eight were killed by hand on 2026-09-18.
- That second defect is now fixed too. accept_loop keeps a grace since the
last client let go of the socket, and ends the session when it runs out.
The connection is per session and not per request — Emacs holds one
make-network-process for the whole of flan-dev and every deliberate
teardown sends [close] first — so an editor left open and idle is an editor
still attached, and the clock cannot run under it. Armed only after a first
client has connected, so a headless daemon waiting for one is untouched.
Two graces: 5 minutes parked, 30 minutes live, because a parked program is
invisible (which is why four piled up) and a live one is a window somebody
may be watching. FLAN_DEV_CLIENT_GRACE overrides in seconds; non-positive
turns it off. Six unit rows on the decision and one end-to-end daemon whose
client drops without a [close].
* The dynamic half of item 4, finished
Item 4 above is the complaint at line 23: the edn module should read into a
struct *and* answer a dynamic value when no type is given. The dynamic half is
now the package's rather than a test program's.
- [#{}] is read, not refused. The tokenizer's stated reason ("needs a hash set
to even represent") was a claim about a reader, and a tokenizer represents
nothing; [#{] pushes [}] on the same balance stack [{] does, one new token
kind, and [err-set] is gone rather than kept with a new message.
- [vendor/edn/read.flan] holds the [Value] data type and [(edn/read bytes)],
which answers an [(Option Value)] against the calling convention's
allocator. A set is [Value.Set] holding a deduplicated [(Vec Value)] —
[(Map Value bool)] does not typecheck, because [keyable] refuses a key
holding a Vec or a Map, and restricting set elements to keyable Values would
refuse [#{[0 0] [1 0]}], which is the file this was built for.
- A Value COPIES every string into the allocator; a Token stays a view. The
two layers diverge deliberately and both headers say so. A view handed out
of the function that owns the buffer is a dangling pointer no free-all would
even take back.
- Needed one compiler change to be possible at all: an imported [defdata] was
a refusal in load.ml ("not implemented yet, milestone 4"). It is a rename of
the type's name plus the [Type.Case] half of a constructor symbol; a match
pattern resolves its case against the scrutinee's type and never needed one.
Still not built, still item 9 on docs/PORTING.md's list: [(read-edn T bytes)],
the typed half. It wants a compile-time walk over a struct's fields and there
is no run-time type information to do it with at run time.
* Session close, 2026-09-20 — dynamic-first M1 landed
Merged on dev-loop, all green (dune test --force 0 failures, @x86 130 match,
@sanitize clean): the dyn type (unannotated defn params/returns are dyn,
NaN-boxed runtime, mark-sweep GC, --no-gc refuses residual dyn by location),
the provider macros (defedn/defjson off macro-slurp; NEXT.md item 9 closed),
computed global initialisers on both backends, x86 frame pushes (inspector
works under the x86 default), the !-suffix retirement, and the flan-dev→flan
rename. typed-flan branch freezes the static language pre-dyn.
** Still in flight, worktree branches to merge when they report
- x86 dyn lowering + the x86/LLVM invoke-restart divergence (one lane, two
commits) — the author is waiting on this one to start playing.
- The writable inspector (SLY-style set + editable render buffer).
- docs/SPIKE-DUPLICITY.md, the dyn/native boundary audit (report only).
** Open, author's call
- sand.flan holds uncommitted WIP: a defvar initialiser reading game-data.edn
at startup aborts the headless import (unhandled FileError at the test's
CWD). Options on the table: embed, handler-bind fallback, or harness dep.
- Signature pairing's cold-rebuild edge: a later type definition can silently
re-pair an unannotated parameter vector; a changed-pairing warning between
compiles was proposed and not yet queued.
* M2 queue, decided with the author 2026-09-20 — in order
1. dyn maps + keywords (interned, O(1) equality). Retires edn/Value after.
2. Per-type descriptors: dyn fields in structs/conditions become markable.
3. Typed containers into dyn as VIEWS — one descriptor word in the box,
reads box the element, writes tag-check. Rides on 2. No copies.
DECIDED 2026-09-19: the descriptor is its own thing, not the slice type
reused. Two reasons. A dyn value is a single word and a slice is two, so
reusing the slice buys no allocation back — the descriptor goes on the
heap either way. And a slice carries where and how many but not of what,
which is the one fact dyn needs, since boxing a read and tag-checking a
write both require the element type. The descriptor is therefore pointer,
length, and element type: a slice plus the piece a slice is missing.
Left open until the lane is built: whether the descriptor points at the
container or is a fattened slice stored beside it. That only bites if the
container can grow and move, which would leave a push through dyn holding
a stale pointer. — LANDED. Settled: a Vec view holds the address of the
Vec's own header and reads its ptr/len live on every operation, so a push
that reallocates cannot go stale — there is no snapshot to invalidate,
because flan_vec_grow overwrites that same header in place. A slice and a
fixed array cannot grow, so a flat view snapshots pointer and length once,
which is sound for both and is not the weaker half of an asymmetric
choice — pointing a flat view at its own value's slot instead would be
worse, since a slot's lifetime is not the slice's. The element set is i64,
f64 and bool only: a string element's dyn form is a pointer into the
collector's heap, and a typed container's storage is memory the collector
never scans, so a wider set would let a write plant a live reference
nothing traces. (Vec string) and (Map K V) keep the refusal [box] already
gave every container. Both backends, runtime/flan_dyn.c and .h, checker
tests, an acceptance row per backend, and a survey program
(dyn-view.flan) proving the view against both a growing Vec and a fixed
array/slice, plus its own two trap modes.
REVIEW, 2026-09-20: relocation was proved sound but relocation was not
the hazard that mattered — a view can outlive the frame its Vec header
sits in, which nothing could reach before this lane because [box]
refused every container outright. Three routes, all newly constructible,
all stack-use-after-return: returning a view, stashing one in a dyn
global, leaving one behind across a condition transfer. AUTHOR'S RULE:
on the dynamic side Flan aims where Clojure and Common Lisp are — holding
a value should not hand you garbage — so a container may cross into dyn as
a view only when its own storage is permanent — a global's.
[permanent_root] in check.ml decides it: a global, a field of one, an
element of a permanent ARRAY (an element of a slice is NOT — a slice holds
only ptr+len, and what they point at can be a frame already gone; the [At]
arm steps every index of a multi-index [(at g i j)] the way [indexed] does
and demands an array at each level, because the whole index list rides on
one node and reading the target's type alone settled level zero only), or
a slice cut directly from one at the call (the trace is lost the moment
it is bound to a name first). Everything else — a local, a parameter, a
temporary, anything behind a (Ptr T) — is refused by name, pointing at
the defvar spelling that works. A heap-held header is not expressible
soundly at this milestone for a structural reason rather than a missing
feature: a (Ptr (Vec i64)) taken off a heap block and one taken off a
local are the same type, so admitting a Ptr as permanent would readmit
the exact hole this closes.
The rule is a narrowing, not a proof, and flan_dyn.h states the property
that actually holds: a view is exactly as stale-safe as the thing it is a
view of, no more and no less. A global [i64] whose data was cut from a
frame that has since returned still passes [permanent_root] and still
reads a dead frame. What the guard closes is the routes the checker can
see, not every route.
An arena-held header is not a separate case for [permanent_root] — an
arena changes where a Vec's elements live, never where its own header
(the binding) lives, so the cases above already decide it — but that is
coverage of the HEADER's lifetime only, and releasing the arena under a
live view is a separate hazard handled at RUN time, not here.
[view_vec_check] in flan_dyn.c is what handles it: a Vec records its
allocator's epoch and every view operation re-checks it, so (free-all ar)
with a live view over an arena-grown global Vec traps cleanly and by name
at the next read — verified. (arena-destroy ar) is the gap: it frees the
allocator block itself, so the epoch [view_vec_check] goes to read is
freed memory. Run plainly it happens to trap anyway — the freed block
still held the bumped epoch — but that is the allocator not having reused
it yet, not a check that held; under ASan the same program is a
heap-use-after-free in [view_vec_check] before it decides anything. Left
standing rather than fixed with this lane: the typed side has it
identically in [flan_vec_check], flan_rt.c, which reads the same freed
allocator's epoch, so it is a repo-level question about arena-destroy's
ordering and not about views.
Three more, all in the runtime rather than the boundary: [view_vec_check]
recursed into itself rendering the very view it had just declared unsafe
to read (fixed by never rendering it — the sentence names the epochs and
nothing else); [dyn_equal]'s VEC arm read raw [len]/[items] regardless of
kind, so two views with different contents compared equal and a map keyed
by a view collided with every other view (fixed with view-aware
length/element readers, [vecish_len]/[vecish_at]); and the three
restatements of flan_vec's layout (flan_rt.c, flan_dyn.c, dyn_ops.c) had
nothing tying them together despite a comment's claim that they did — a
[layout] probe on each, compared field by field in dyn_ops.c's new
"layout" mode, makes a disagreement a FAIL line instead of a silent
corruption.
4. nil: arrives with maps. nil <-> None at (Option T) boundaries, trap at
bare T, (Some nil) unconstructible. — LANDED, 3c1fb1b. The bare-T trap is
split: a literal nil the checker can see is refused at compile time, in
expect itself; a dyn only known nil at run time still reaches
flan_dyn_need_i64's existing trap unchanged. (Option (Option T)) does not
cross either direction, same ambiguity as (Some nil). (Option dyn) is a
legal type the boundary code already treats correctly — the payload is
the identity, box and unbox both — but not yet a storable value anywhere:
the per-type-descriptor pass (item 2) refuses it the way it refuses (Vec
dyn), and item 4 does not lift that gate.
5. Typed = and != grow strings: bytewise, length + same-pointer fast paths,
both backends, one survey program. Ordering stays refused. — LANDED, daed039
6. defclass = named dyn map + shape tag; CLOS class dispatch AND
Clojure-style arbitrary dispatch functions. After 1.
7. dyn if: truthiness (nil/false are false, all else true). Typed stays
strict bool. — LANDED, 264765a
Reaches when, cond, if's own condition, and's condition, or's
condition, not and while for free or by hand, all through one funnel
in check.ml (check_truthy). Two things fell out of it that nobody had
decided going in, one fixed on review and one left as the author's
call:
- Neither and nor or handed back the operand that decided it.
Clojure's rule is that both do; each answered a bare bool sentinel
on its deciding path instead. and's "false" sat in the else arm, so
check_if typed the real branch first and boxed the sentinel to
match: an all-truthy and did carry its last dyn operand through,
but a falsey one answered false where Clojure answers the falsey
operand — (and (box 1) (box nil) x) printed false, not nil. or's
"true" sat in the then arm, the one check_if types first, so the
sentinel decided the whole expression's type and a later non-bool
dyn answer hit the strict bool boundary and trapped: (or nil "x"),
the canonical (or x default) idiom, crashed rather than answering
"x". FIXED for or in ad0f1fb and for and in this pass: both now
bind the test to a temp and answer the temp on the deciding path,
Clojure's own expansion — (let [t a] (if t t b)) for or and (let [t
a] (if t b t)) for and — evaluating each test exactly once. The
asymmetry between the two forms is fully closed; the survey program
(test/programs/dyn-if-truthy.flan) pins both, short-circuit and
single-evaluation included, and test_flan.ml pins both desugarings
down to the bound name and the bound value.
Two things came with that pass. The temp binding and the if it
feeds now carry the *operand's* loc rather than the whole form's,
which ad0f1fb had lost for or: (or (vec-new i32) v) blamed the
enclosing form at 3:13 and now points at the operand at 3:18, and
and's second operand gained the same precision. And, noted and not
acted on: with both arms of the desugared if now holding real
values, a dyn operand mixed with a typed bool one makes check_if
unify them by the then arm, so a non-bool dyn value on the losing
side traps at the strict bool boundary — (or false (box "s")) and
(and (box nil) some-bool) both do. Each form used to be safe in
exactly one of those directions, because the sentinel it answered
was a bool literal that boxed to fit the real branch; neither is
now, and they are at least symmetric about it. (and (box nil)
some-bool) printing false is the one previously-compiling behaviour
this pass changed. Making a bool arm and a dyn arm join as dyn is a
check_if question and the author's call, not settled here.
- A bare keyword condition used to be checked with want:Bool from the
start and refused by the keyword arm's enum-or-refuse case: ":kw is
an enum member where an enum is expected and a dyn keyword
elsewhere, but bool is expected here", there being no enum in play.
Checked with no expectation first, as every scrutinee now is, it
resolves as the dyn keyword instead, and a dyn keyword is
unconditionally truthy — a typed if with a bare keyword condition
now compiles and always takes the then branch. The author's call:
lispy truthiness wins here, the lost diagnostic is not brought back.
Pinned in test_flan.ml so it does not regress by accident.
Also noted at check_truthy (check.ml) and not acted on: check_truthy's
own retry-on-failure, needed to keep a refused literal's or None's
message unchanged, re-runs the whole failing subtree rather than only
the leaf that needs it, which is exponential in how deep a chain of
nested not gets on a program that does not type-check. Moot for
anything that compiles; visible only around twenty levels deep, and
only the dev daemon's half-typed-form recompiles could ever feel it.
A cheaper retry was tried and shelved — it would need to thread want
exactly as far as the full retry already does, or it changes which
literal further inside a compound condition gets the nicer message,
not just the speed.
8. Return slot stays mandatory (dyn or ()) — the parse ambiguity it closes
is real; revisit only if it grates. SETTLED 2026-09-19, reconfirmed with
the author: both spellings stay legal, () is not collapsing into dyn.
No work follows from this one.
All of it dispatches after the x86-dyn lane lands. The struct dyn-field
refusal (01e60fa) is the stopgap 2 lifts.
** The two models, named 2026-09-19
With a collector in the runtime, the direction has a shorter statement than
it used to. The dynamic paths mimic Clojure. The static paths mimic Odin.
Both carry a little more ML than either of them does.
That is a tiebreaker, not a slogan. A question on the dyn side that Clojure
has already answered takes Clojure's answer unless there is a reason to
depart, and the same holds for Odin on the static side. Keywords, maps and
nil landed under that reasoning without it being written down yet.
Common Lisp is consulted alongside Clojure on the dynamic side, and on some
questions it is the better authority of the two. The condition system is the
standing proof: handler-bind, the restarts and invoke-restart are Common
Lisp, and Clojure has nothing resembling them. handler-case is the same
lineage — Clojure's try/catch is the shape most reached for, but the form
being added is Common Lisp's, and it is named for the Lisp rather than the
Clojure because it is the unwinding half of a pair whose other half is
already CL's.
Where the two disagree, the question is which one the rest of Flan already
agrees with. Conditions say Common Lisp. Maps, keywords and nil say Clojure.
Neither answer generalises to the other's territory.
The ML share is the part neither model supplies — the type system, the
options, the exhaustive matching, and whatever a second ML surface would
eventually add if the deferred syntax question ever reopens.
** Arithmetic semantics do not fork across the two spaces, decided 2026-09-20
One operator, one meaning, both sides. `/` on integers truncates toward zero
and `%` is its remainder, sign following the dividend — LLVM's sdiv/srem, the
x86 backend's cqo/idiv, and flan_dyn.c's arith all already agree, and that
agreement is now the rule rather than a coincidence. The author's call: this
sort of semantics is normalized across the dynamic and static spaces, so the
Clojure tiebreaker above does not reach it. Clojure's flooring `mod` (sign of
the divisor) is NOT to be added as a dyn-side-only behavior of `%`; if a
flooring mod is ever wanted it is a second, separately named operation
available to both spaces, the way Common Lisp keeps `rem` and `mod` side by
side. Division by zero and INT64_MIN / -1 trap identically on both sides,
and float `%` is fmod on both backends and in dyn.
** handler-case, decided 2026-09-19
Flan has handler-bind, which is the resuming handler: it runs where the
condition was signalled, with the stack still standing, and carries on by
invoking a restart. What it has no spelling for is the other half — unwind,
and answer the whole form with a value. Clojure spells that try/catch and
reaches for it constantly; the closest thing here is a handler-bind plus a
use-value dance that is far heavier than the intent, or a pre-check that
races the read it guards.
The gap showed itself when edn/read-file stopped returning an Option. The
caller that used to write or-else against a None had nothing left to write,
because the missing file now arrives as a FileError condition and the only
concise way to answer a condition with a default did not exist. The shape
wanted is:
(handler-case (edn/read-file "game-data.edn")
[(FileError [c] nil)])
which keeps read-file's decision intact — the caller still says what a
missing file means — while costing one form instead of a machine. Until it
lands, sand.flan guards the read with file-exists?, which is a stopgap and
racy, and should be rewritten the moment this exists.
** The JS backend answers string equality wrongly, parked 2026-09-19
Typed = and != grew strings in daed039, and the JS dialect was not taught the
case. A string there is a view object and the arm at lib/js.ml:856 compares
with ===, which asks whether two views are the same object rather than
whether their bytes agree. The arm was unreachable for strings until the
checker stopped refusing them, so the lane made an existing hole live without
touching the file. Equal literals still answer true, because equal literals
intern to one view, which is what makes the wrong answer quiet rather than
obvious: (= s (string (slice (bytes s) 0 3))) is true natively and false
under --target=js.
The author parked it. JS stays deprioritised and the fix is not queued. The
option on the table when it is picked up again is a loud refusal in that arm
rather than a real implementation, so the dialect says it cannot do this
instead of saying something false.
** Sweep policy, decided 2026-09-19
A lane runs the fast check and nothing more. `dune test` is the whole of a
lane's obligation. It used to be judged by reading the printed output rather
than by trusting the exit status, on the theory that some path through the
acceptance runner could print a FAIL and still exit 0. That theory did not
hold up: test_acceptance.ml is one match on whether clang is on PATH, the
wasmtime/raylib/lldb probes inside it are ordinary `if`s that fall through to
the same tail rather than branches that leave early, and the tail already
turned a nonzero failure count into exit 1 — so did every other test binary's
tail, checked the same way. test_acceptance.ml now also carries an `at_exit`
guard, but it closes no open gap; it is insurance against a future case
leaving past the tail instead of through it. The exit status was already
trustworthy and stays that way, so either check does. Running one program
directly to capture its real output for an acceptance row is still expected;
that is cheap. What a lane may no longer do is sweep.
The x86 survey and the sanitizer sweep run once, after several lanes have
landed, and whatever they turn up is dispatched as fixes in a single batch.
The reason is arithmetic: a survey walks all 156 programs across three modes,
and a lane that touches a handful of them was paying that cost in full to
learn nothing about the rest. Paid once for several lanes, the same sweep
answers the same question at a fraction of the wall clock. The consequence to
accept is that a lane is reviewed on its code rather than on sweep numbers it
no longer produces, which is what the review before a merge is for.
* handler-case, decided 2026-09-19
Built, both backends, and it needed no backend work at all: it is a
handler-bind whose clause invokes a restart the form established around
itself, which is spec-conditions.md's one open question about the operator
answered in the affirmative. The shape is (handler-case BODY [(T [c] ...)]),
body first and clauses after, the opposite of handler-bind's order because a
handler-bind reads as something put around a body and this one reads as a body
with answers hung off it.
Everything the unwinding form needs it inherits. Defers and the
with-allocator restore run on the way out because a transfer already runs them
for every frame it leaves. The body and every clause agree on one type because
§3 already says a restart-case's do, and a clause that disagrees is refused
with the same message an if with disagreeing arms gets. A condition no clause
lists installs no matching frame and carries on outward untouched. A clause
runs at the form, so it sees the establishing function's locals, which a
handler-bind clause cannot — that is the whole difference, and it falls out of
where a restart clause runs rather than being arranged for.
The one wart, noted and left: the restart the form makes up for itself is on
the restart stack like any other, so a break loop entered underneath one lists
it. Choosing it there is refused loudly rather than answered wrongly, and
hiding it would mean a new field in a frame layout written out in emit.ml, in
x86.ml and in flan_rt.c.
* Surface syntax discussion, 2026-09-19
The author wants an F#-ish indentation-based ML surface living side by side
with s-expressions, not replacing them. The languages that disappear for the
author, in the order named: Python first, then Odin, then F#. That ordering
is the case for why Flan's own parens might be costing more than they look
like they cost.
The architecture agreed if it is ever built: one AST, the existing forms
unchanged, and a second reader in front of it. Macros stay usable from
either surface, since they operate on the same AST either way. A Nim-style
quote-block was floated as the way a macro's own body could be written in
the ML syntax rather than in s-expressions, without needing a third
representation.
Middle options came up and were set aside rather than chosen. Parinfer stays
an editor trick — it never changes the language, only how parens are typed,
so it does not touch the actual complaint. Wisp and sweet-expressions
(indentation implying the parens) were considered and are closer to a real
second surface than Parinfer, but still read as a compromise rather than the
ML syntax the author actually wants. A simplified in-paren syntax was also
on the table and rejected on the same grounds — it thins the parens without
removing them. Rhombus was named as the maximal reference point: whatever a
full second surface costs, Rhombus is roughly what it costs to do properly.
Decided: deferred, no spike queued. The author's working hypothesis is that
the friction with Clojure may not be the parens at all — it may be
immutability, and the discipline of planning a shape ahead of time that
comes with it. The plan is to write imperative Flan as it stands and see
whether the parens still grate once that variable is gone. Revisit this once
that evidence exists.
* The x86 backend tracks LLVM -O0, decided 2026-09-20
The ruling, in the author's words: the x86 backend must behave as closely to
LLVM at -O0 as possible. A construct LLVM compiles, x86 compiles, and the two
must agree on what the program observably does. The backend is allowed to
refuse a node it does not lower — that is what X86.Unsupported is for and it
is how the survey reports a gap — but a refusal is a bug to be closed, not a
position. "LLVM takes this and x86 does not" is by itself a defect report.
What made it a ruling was typed float %. emit.ml's prim arm emits frem for Rem
on a float, so (% 7.5 2) compiled under LLVM and printed 1.5; the matching arm
in x86.ml had no float Rem case and died at build time with an unlocated
internal error, "x86: that operator on f64". Dyn % on floats worked on both backends the whole time
— flan_dyn.c's arith implements the fmod identity — so deleting the
annotations made the program build again, which is exactly backwards. x86 is
the dev loop's default backend, which is what turned a backend gap into a
thing the author hit while writing ordinary code.
Fixed by calling the same function LLVM calls. There is no SSE remainder
instruction and LLVM does not invent one: a frem that reaches the code
generator becomes a call to fmod or fmodf, which objdump shows as a call to
the PLT stub — fourteen of them in a build of the probe whose operands come
through globals, and none at all in one written with float literals, where the
pair is folded to its answer before any call exists. x86.ml now loads the two operands into xmm0 and
xmm1 — already the SysV argument registers — and calls fmod or fmodf by width.
Agreement is then by construction rather than by a second hand-written
identity that would have to get every rounding, every signed zero and every
infinity right on its own. Nothing new had to be arranged for the link: the
prelude already declares both symbols as fmod-f32 and fmod-f64, and every
link passes -lm.
Rem was the only gap. Walking emit.ml's prim arm against x86.ml's: the whole
float surface is Add, Sub, Mul, Div, Rem and the six comparisons. x86 had four
of the five arithmetic operators and all six comparisons, and the comparisons
match LLVM's ordered predicates — oeq and one are built there from a setcc
against ucomis plus the setnp that rules out the unordered case, which is what
the o in the LLVM predicate means. The bitwise and shift arms are integer-only
on both sides. So nothing else was missing.
** The aspiration: tests that say x86 still tracks -O0
Wanted, and half of it exists. @x86 (test/dune:239) is already the diff: it
builds every program in test/programs, spike/x86 and spike/js twice — once
through LLVM, once through --x86 — runs both, and compares stdout, stderr and
the exit status. SURVEY_STRICT makes a DIFFER or a by-name refusal a failing
build. So a corpus program that exercises a construct is already a test that
the two backends agree about it, and the float % cases added to math3.flan are
in that set by being in test/programs.
What @x86 does not do is pin the LLVM side at -O0. It builds both sides at the
default -O2, so a construct LLVM folds at compile time — a % over two float
literals is one: that build contains no fmod call — is compared as a constant
against the x86 backend's actual lowering. The float % block in math3.flan
goes through globals for that reason, the same reason arith.flan gives for its
own. Two things would close the rest of the gap: an -O0 pass of the sweep, so
the LLVM side emits the calls and branches rather than the answers — the
script already has SURVEY_FLAGS, which hands the same extra flags to both
sides, and both sides do accept -O0 — and something that walks the two prim
match arms mechanically rather than relying on somebody reading them side by
side, which is how this gap survived. Neither is queued.
** bin/main.ml still spells the compile pipeline out by hand
The test directory's copies of Load → Check → Reach.link now go through
[Test_support.linked] (test/test_support.ml). bin/main.ml has the same shape
twice more — :684 and :864, each a load, a check and a [Reach.link] feeding
[Build.executable] — and they were left alone, because the lane that did this
was test/-scoped and because they are not quite the same three calls: the CLI
loads through its own [load] and checks with [Check.program_all] rather than
[Check.program]. So closing this is not a matter of calling the test module
from bin/, which would be backwards anyway; it means the pipeline moving into
lib/ — Build, or a small front-end module beside it — with the two checkers'
difference made an argument, and bin/ and test_support.ml both calling that.
Not queued.