129 Commits

Author SHA1 Message Date
2afa554cce The flake note said noise; the full-suite numbers say otherwise
Isolated test_dev is 4-in-6 here against 2-in-6 at the base, which is noise.
The full suite is 5-in-5 here against 0-in-5 at the base, which is not — and
with this lane's three acceptance rows disabled it drops to 1-in-3. The rows
add compile jobs to the pool test_dev runs alongside, and a busier machine
loses the trap_park poll race more often.

Still not a new defect, and none of this lane's compiler code is implicated.
But the earlier note's suggested fix is now worth doing rather than noting,
and saying 'noise' would have sent the next reader the wrong way.
2026-09-20 19:02:53 +07:00
2240100ee9 Review follow-ups: the default's payload, four wrong reasons, a doubled prefix
1. The bare (dead-beef) built its default with Int64.of_int32, which
   sign-extends 0xDEADBEEF to -559038737 on a node tagged u32 — where the
   spelled-out literal arrives as 3735928559, because in_range admits it as
   the unsigned value it is. Masked to 32 bits, so the two spellings really
   do carry one payload; verified by diffing the emitted bodies of (dead-beef)
   and (dead-beef 0xDEADBEEF), which are now identical instruction for
   instruction.

2. The refusal's catch-all told a union and a function value that they
   'carry a tag that names a case'. Neither does: env.unions is the untagged
   unions, and an Fn is a code address. Split into one arm per reason —
   union, Fn, enum, Option, data type — and each is now pinned, so they
   cannot quietly re-merge. Same correction in FIX.org's bullet.

3. js.ml prefixed its own message with 'js: ', which bin/main.ml prepends
   too, giving 'js: js: ...'. Dropped, and the message now names the builtin
   it refuses, which its comment already claimed it did.

4. FIX.org said x86.ml reads both pattern helpers out of Emit. It reads only
   word_of_pattern; the tail walks rax with shr.
2026-09-20 18:58:32 +07:00
5ea6884d2c The diagnostics pass: every message shows, explains, and names the fix
# Conflicts:
#	FIX.org
2026-09-20 18:49:12 +07:00
3c39354833 Measure the Wire.Closed flake rather than assume it
It exits 1 with no FAIL line, which is the shape an earlier lane wrote up.
Two-of-two early failures looked like they might be this lane's, so: 4 in 6
here against 2 in 6 on a detached worktree at this branch's own base commit,
running test_dev alone. Noise at that sample size, same exception, same
mechanism.

Adds one detail to the earlier note, which had only ever seen the flake on
dev-trap-null-alloc: one of my six landed on dev-trap-free-all instead, so
what is racy is trap_park and every row that calls it.
2026-09-20 18:46:58 +07:00
78d9a0f051 The review's fixes: a suggestion that does not compile, and a confident wrong guess
F1 was the blocker and it was the worst kind of fault this pass can have: the
condition message told the reader to write (not= x 0), and not= does not
exist — the operator is !=. Applying the compiler's own advice got 'unknown
function not= — did you mean not?'. Both branches say != now, and all three
— the named form, the float zero, and the unnamed one — were checked by
compiling the sentence the compiler prints.

F5: a typo of a declared capitalised name got the generics lecture. (Piont 1
2) with Point declared was told that a capitalised name given type arguments
is milestone 5 work, which is a confident answer about a feature nobody was
reaching for. The did-you-mean runs first and, for a capitalised head only,
asks the type tables as well; the generics sentence is left for a head that
resembles nothing.

F2: flan_dyn_cast_kind had the site live and passed NULL on the trapping
path — the one entry point on this side that had a location and threw it
away. The acceptance row now pins the prefix it prints.

F3: the case-typo row used (data ...), which is not a top-level form, so it
refused as an unknown top-level form and the needle 'unknown' matched that
rather than the rule. Rewritten with defdata, and as a pair: a capitalised
head gets no accessor advice, a lowercase one does. Both halves were checked
to fail when perturbed.

F4: an end-to-end pin for the headline. programs/dyn-trap-site.flan is
compiled, run, and its stderr read for the file:line:col in front of the
sentence, on both backends and at -O0. Proven live: three failures when the
expected line is wrong.

F8: usize and size_t stay off the foreign-spelling list, and the comment now
says why — the honest answer is pointer-width, which is u64 here and u32 on
wasm32, and a tree that builds both cannot name one of them.

F10 pins the fourth dot shape. F6 moves the not-reached reasons out of the
commit bodies and into FIX.org, where they can be read without git.
2026-09-20 18:46:48 +07:00
e807986622 The dogfood batch: empty forms, comment, inc and dec, guards, limits, shorthand
# Conflicts:
#	FIX.org
2026-09-20 18:34:13 +07:00
7bd2c99353 sentinel-filled is now dead-beef, and takes the pattern
The author's revision. The name says what it writes, and the pattern is the
program's to choose: (dead-beef) is DEADBEEF, (dead-beef 0xBAADF00D) is
BA AD F0 0D. One byte-order rule covers both — a pattern's ascending bytes
are its big-endian bytes, which is how the hex literal reads left to right —
so every candidate DISCUSS.org listed is now spellable without the compiler
naming any of them.

The bare form is not a case a backend knows about: the checker writes
Tast.dead_beef_default in where the argument would have been, so
(dead-beef) and (dead-beef 0xDEADBEEF) are the same node and an acceptance
row prints both to say so.

The operand is an ordinary u32 expression, which is what the byte arm
already accepts for its byte. A literal is byte-reversed at compile time and
still reaches the loop as an immediate; a computed one is reversed at run
time, by llvm.bswap.i32 on one backend and bswap on the other, after which
the tail shifts its bytes out of the word rather than folding them. The
program runs a computed pattern over lengths 6 and 7 deliberately: that is
the case a constant-only implementation would pass by accident.

filled is untouched, and so is the fill boundary.
2026-09-20 18:33:05 +07:00
92bf091361 A struct writes itself where its type is already known, and positionally 2026-09-20 18:23:42 +07:00
eeac54a8e3 The parameter list, its tests, and the note about what it breaks 2026-09-20 18:21:54 +07:00
de3a3ed3c8 The writeup said eleven checker rows; there are fourteen
Two accepting and twelve refusing. Also records that the three acceptance
rows were confirmed to run rather than inferred from a green exit: the
expectation was broken on purpose once and all three reported.
2026-09-20 18:19:45 +07:00
1702a62308 Pin the () body guards, the match-arm rule, and correct two comments 2026-09-20 18:18:20 +07:00
99f519ba6f Two byte fills: (filled BYTE) and (sentinel-filled)
DISCUSS.org's sentinel-fill idea, built as two builtins because the author
asked for both: a memset with a byte the program picks, and the fixed
DE AD BE EF pattern a hex dump reads as DEADBEEF.

Both are spelled the way (zeroed) is — the value of whatever type is
expected of them — so (set grid (filled 0xFF)) fills a place and there is
no second, place-taking form beside set.

What may be filled is numbers, and structs and fixed arrays built out of
them. Everything else is refused by name: a filled dyn is a collector root
pointing at nothing, a filled Vec header frees a wild address, a filled
slice length is a bounds check that passes, and a filled bool is an i1 to
LLVM and a whole byte to x86, which is the one divergence this feature
cannot have.

The byte fill is llvm.memset / rep stosb. The four-byte pattern cannot be
a memset on either side — the intrinsic takes one repeated i8 — so it is a
counted dword loop in emit.ml and rep stosd in x86.ml, with the pattern
bytes and their little-endian word living once, in Emit. A size that is
not a multiple of four ends on DE, DE AD, or DE AD BE.
2026-09-20 18:15:19 +07:00
c1b18cb181 Two struct spellings the parser was never able to decide
A bare {.field v} had its refusal in Parse.expr, before any checking, so a
defn whose return type was the only place the struct's name appeared could
not build one. The refusal moves to Check: Parse builds an Ast.Bare out of
the same struct_fields the named form uses, and check_bare reads the type
name off the expectation and hands that very list to check_struct. ZII, the
unknown-field refusal and the duplicate-field refusal are therefore not
copies of the named form's rules but the named form's rules.

Braces at a dyn want are the dyn map literal and stay exactly that. A
.field-keyed brace was never part of that spelling, and at a dyn want it is
refused by name rather than given a second meaning.

(Cell 1 2) is the other half, and it is character-for-character an ordinary
call, so only the symbol table separates them. It is decided on the last arm
of named_call, after a local of function type, a generic and the function
table -- so a defclass constructor, which is a real defn, resolves above it
and is untouched. Arity is exact: ZII is what the braces do, and a positional
list cannot say which field it left out, so it is not allowed to leave one
out. The refusal names the first field it did not reach and points at the
spelling that does mean "zero the rest".

Both are gone before any backend sees them -- Tast.Make either way -- and the
three acceptance rows print the same lines to say so.
2026-09-20 18:12:45 +07:00
fa2b56ba5a Empty fn bodies, the pins for all six items, and the FIX.org entry 2026-09-20 18:11:20 +07:00
1526b6fe3f Typed structs do not version; an evolving shape is a defclass 2026-09-20 17:58:26 +07:00
Joseph Ferano
36624d6e33 An abort's reply races the process it ends
test_dev.ml's abort asks were a bare Wire.send/Wire.recv pair against a
daemon that the abort itself is killing. In a merged flan dev the daemon
is the program: flan_agent.c's listener writes ok and sets aborting, and
the break loop's next pass _exit(134)s from the program thread while the
editor's reply is still being composed on the serve thread. Nothing
orders the two, so the reply arrives or the socket closes, at random.

When it closed, Wire.recv raised Closed, nothing caught it, and the test
binary died with no FAIL line and every case after it unrun. Four runs
out of four at the null-allocator trap.

Both endings mean the same thing and neither is the assertion: the
waitpid wait underneath each row is what says the program went. aborted
answers None for the end that arrived as an exit; the three sites that
abort a stopped or trapped program go through it. The fourth abort is
refused by a running program and ends nothing, so it is left alone.

trap_park's describe poll is guarded with the other answer: these traps
park because flan_trap_hook is installed, so a socket closing there is
the trap having ended the program instead of stopping it, which is the
failure that row already names. And SIGPIPE is ignored for the
watchdog's reason, so a send into the socket a dead daemon left behind
cannot kill the binary silently from the write side.
2026-09-20 17:07:15 +07:00
0b4bf850ec The random exit 1 is test_dev's trap rows, and it predates this lane
dune test exits 1 roughly two runs in five with no FAIL line anywhere:
test_dev's trap_park polls a daemon that has just aborted at the break
loop, using a bare Wire.send/Wire.recv pair, and a daemon that exits
between the two raises Wire.Closed with nothing to catch it. The binary
dies and every row after it is skipped.

Measured on a detached worktree at dev-loop's tip with none of this lane
in it: 2 of 5 runs, same exception, same row. Same rate as this branch,
because it is the same code. Written down rather than patched -- the fix
is a claim about what those rows mean when the program ends under them,
which belongs to whoever owns them.
2026-09-20 15:47:06 +07:00
bd29eee3e4 The rebase is what was run, so it is what the note says
The what-was-run paragraph counted three commits and no rebase. It is
five commits and a rebase onto the defvar-dyn lane now, whose load.ml
and ast.ml arms sit beside the four this lane added and were read
against them by hand rather than left to the auto-merge.
2026-09-20 15:38:19 +07:00
ac7608f94b A method's parameter names are bound in parallel, not in sequence
A let binds in sequence, so binding a method's names pairwise from the
generic's reads a name it has just bound. A generic [a b] with a method
[b a] -- a swap, which is what renaming parameters most often is -- was
handed its first argument twice and could not reach its second at all;
[b c] is the same bug one step shorter. Every argument is now copied
into a temp in the unspellable ~ namespace first and every method name
bound from a temp, uniformly rather than only for the pairs that
collide, because a rule that fires on the tangled case alone is one
nobody exercises. Both shapes are in dyn-class.flan, where the values
are what is wrong rather than the types, and across all three rows.

With it, two things the descriptor fix left behind. descriptors_asm
wrote the descriptors into .rodata and a descriptor holds the address of
its own offset table, so every one of them was a relocation in a
read-only section -- a DT_TEXTREL, which ld warns about in a PIE and
refuses in a shared object, and which was warning in the new daemon
case's own output. They go in .data.rel.ro now, in both the executable
and the reload module; readelf -d on a reload module from each backend
shows no TEXTREL. And FIX.org: the stale held line for item 6, the
fourth read site of the shape tag (say_render, not just print), the
warning that a class's qualifier is the importer's alias so a
hand-written :a/point is coupled to one import's name, and the gap
flagged for the next sweep -- marking through a descriptor an x86 reload
module emitted is still unexercised.
2026-09-20 15:36:27 +07:00
90110f525a Classes and generic functions, written down
Item 6 marked LANDED, and the section under it: the four spellings with
an example each, why the shape tag is a header field and not the
reserved key the queue's note assumed, why the method bodies are inlined
rather than lifted, and what was deferred -- inheritance, multi-argument
dispatch, the qualifier methods, named-slot construction, unknown-slot
checking, computed dispatch values -- each with the reason rather than a
list. Plus the two findings that outlive the lane: the x86 redefinition
module's missing descriptors, and eval-expr never answering a dyn in
:value.
2026-09-20 15:36:27 +07:00
c4e07256db A three-element defvar reads its third element: a type is zeroed static, anything else is dyn
# Conflicts:
#	FIX.org
2026-09-20 15:14:56 +07:00
Joseph Ferano
9321547822 A defvar's third element decides: a type is the static, anything else is dyn 2026-09-20 15:00:15 +07:00
49ee8774d3 The case inside a struct literal is the same unwritable value as a bare one
const_defconst_init matched MakeCase at the top level only, so a case nested in
a struct literal got the general computed message, whose advice nobody could
follow. Emit.const found it by recursing; this finds it the same way, left to
right and first offender wins, which is the order the emitter spelled fields
in. FIX.org's claim that the messages did not change is rewritten to say what
did change about the general one.
2026-09-20 14:47:19 +07:00
ce93ac7622 A defconst's value is what the linker writes, on both backends
The refusal moves to the checker: Emit.const refused a computed defconst by
name while the x86 backend ran it through the startup function behind an
.init~once. flag, like a defvar, so the two backends disagreed about the same
program. One refusal in Check.const_defconst_init ends that, and it is the only
place that can name the way through.

The accepted set is unchanged: Tast.const_init's, which is Emit.const's and the
x86 data_sym path's, plus the integer arithmetic collect's folding pass has
already turned into an Int before the initialiser is looked at.

Emit.const's two refusals become a failwith no program reaches; emit_global's
gconst || const_init loses its left half; x86 needed no edit, since it never
classified by the form. test_flan's infers probe asks Check.expression now that
a defconst can no longer wrap an arbitrary expression.
2026-09-20 14:47:19 +07:00
e6ac833626 The follow-ups the day's reviews left behind, each re-verified
flan.abi.require was spelled by hand in both backends, which is the one
job Mangle has. Moved; emit and x86 produce byte-identical output on the
reload path either way.

Four comments in the dyn-cast code asserted things that are not true.
The warning's location prefix now reads like every other loc-bearing
runtime diagnostic instead of inventing a shape. widen's contract says
what cast_dyn actually does with it. The thread-safety note names the
torn {ptr,len} overread rather than a duplicated line, and says why no
lock. The site table's borrowed loc pointer names what keeps it valid.

The memory op's note claimed a completeness it does not have: dyn push
and put may allocate and are deliberately silent. Said so, in the note,
in the classifier, and in FIX.org where the decision belongs.

The documented flycheck form only matched warnings, so a real error
made it say the checker returned non-zero and found nothing.

flan-clear-memory cleared one buffer where the toggle clears all.

Two comments claimed test/dyn_ops.c calls every function flan_dyn.h
declares; six are declared and never called there.

flan_dyn_stub.c's deadness is written into FIX.org for the author to
decide on. Not deleted here.
2026-09-20 14:32:17 +07:00
34790e78af The cast that opens a box, written down 2026-09-20 13:58:15 +07:00
b0ee9a3ce0 The runtime structs are one table, and the mangles one module 2026-09-20 13:20:47 +07:00
0e9707c9ef The review batch: honest headers, named temps, the seqlock's odd window
# Conflicts:
#	FIX.org
2026-09-20 13:11:36 +07:00
811c37dca4 Two sentences said more than was checked
The defconst divergence is dated from the history rather than asserted, and
the claim that it is the backends' only disagreement is gone — x86.ml has
unsupported paths of its own.
2026-09-20 13:09:04 +07:00
5b39730f07 The init-once flag moves out of the namespace a program can write
The guard flag was named .init-once.<global>, and . and - are ordinary
symbol constituents, so (defvar .init-once.x i64 7) beside a computed x
emitted the same symbol twice: the dev build died at the assembler on both
backends, and the flag's Bool was registered over the user's global in
Emit.globals so the store came out as an i1. It is .init~once.<global> now;
~ terminates a symbol in the reader, the same trick destructure~N uses.
test/programs/dev-rerun.flan carries such a global and no new printed line.

Three places said a defconst is the linker's image on one backend and a
constructor's stores on the other and a re-run reaches neither. Tast.const_init
splits on the initialiser and not on the form, so that holds only for a
constant initialiser; a computed defconst is guarded like a defvar on x86 and
refused outright by emit.ml's const. The sentences now say that, including
the divergence.

emit.ml also claimed Check.no_transfer_in_init made it impossible to leave the
guarded branch between the store and the flag. It is syntactic over the
written initialiser only: a callee can signal unhandled and take the call's
transfer edge out, leaving the flag false — which is what should happen, since
the next run retries. Read off the emitted IR for such a program.

The x86 float-Rem comment says why the dead movabs before fmod is kept, and
its mid-sentence line break is gone; math3.flan's first float-% line prints
six values, not four.
2026-09-20 13:07:09 +07:00
dd239d6cd5 Two review findings this lane could not fix, written down 2026-09-20 13:03:01 +07:00
f030c5f7f1 Memory diagnostics on demand: gc and native allocation sites, faintly
# Conflicts:
#	FIX.org
2026-09-20 12:57:30 +07:00
cb58288f4d The test binaries share one support module instead of ten forks
# Conflicts:
#	FIX.org
2026-09-20 12:49:32 +07:00
0b4f5e139e defvar keeps its value across re-run; the form is the contract
# Conflicts:
#	FIX.org
#	test/test_dev.ml
2026-09-20 12:01:50 +07:00
Joseph Ferano
5b0062219e Ask which lines allocate, and believe the runtime over the enumeration
[Check.memory_sites] is the pass [Check.no_gc]'s shape: it runs over the
finished program, answers a diagnostic list, and tells nothing downstream
that it ran. Two classes on the diagnostic's kind — the collector's heap
and an allocator the program named — so the CLI, the daemon and the editor
dispatch on one field and none of them reads a message to find the class.

[--warn-memory] on check and build prints them where errors go, in the
shape flycheck parses, without moving the exit status. [(:op "memory")]
answers the same list over a session's last checked program, needing no
program on the far end. [M-x flan-check-memory] paints it two faces fainter
than an error's, cleared by an edit or by asking again.

Two of the spec's own examples turned out not to allocate, and the
precision rule outranks the enumeration: (vec-new T) passes a capacity of
literal zero to flan_vec_init, which returns before the grow, and
flan_map_init takes no block at all and says so in its own comment. The
block arrives at the first push, which is the line that is marked. The
classifier reads the capacity argument rather than the symbol, which is
what lets slurp be marked through the same entry point vec-new is silent
through. FIX.org has the rest of the evidence.
2026-09-20 11:58:47 +07:00
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
7db5ec1885 Float % on x86 is a libcall, and the parity ruling is written down 2026-09-20 11:33:26 +07:00
da40dc2de2 The rule a re-run follows is written down, and a test fails without it 2026-09-20 11:30:58 +07:00
9611fce108 Arithmetic does not fork across the spaces; the tiebreaker stops at semantics 2026-09-20 11:29:20 +07:00
Joseph Ferano
6896128407 Say what the objdump actually showed, and where it showed nothing
The first version of both notes claimed a call to fmod in any build with a
typed float % in it. A literal pair is folded before any call exists, which
is the same fact two paragraphs further down explaining why the corpus block
uses globals. Both now say the measurement: the calls are in the build whose
operands come through globals.
2026-09-20 11:25:49 +07:00
Joseph Ferano
58d5ccda94 Typed float % on x86: the same fmod LLVM calls
There is no SSE remainder instruction, and LLVM does not invent one: at -O0
it lowers frem to fmod or fmodf. The backend now calls those two symbols
rather than refusing the operator, which is agreement by construction rather
than a second hand-written identity that would have to get every rounding,
every signed zero and every infinity right on its own.

Rem was the only gap. emit.ml's float surface is Add, Sub, Mul, Div, Rem and
the six comparisons; x86 had everything but Rem, and its comparisons already
build LLVM's ordered predicates out of ucomis, setcc and setnp.

math3.flan grows the operator spelling beside the fmod-f32/fmod-f64 calls it
already had, through globals so the pair is not folded before either backend
sees an operator. FIX.org records the ruling the fix came from.
2026-09-20 11:21:25 +07:00
eec9efc94c M2 item 3: typed containers cross into dyn as views of permanent storage
# Conflicts:
#	lib/emit.ml
#	runtime/flan_dyn.h
2026-09-20 11:18:18 +07:00
c765aad70f A slice level after the first index was invisible to the lifetime rule
(at g i j) is one Tast node carrying the whole index list, so the At
arm's guard on target.ty settled level zero and nothing after it. A
global [2 [[3 i64]]] indexed twice reached a slice's element, crossed
into dyn as a view, and printed a returned frame's contents with exit
0 (ASan: stack-use-after-scope in view_box). The arm now steps each
index the way [indexed] does and demands an array at every level;
the Field and Slice arms inherit the fix by recursing into it.
2026-09-20 10:51:22 +07:00
bddc8fc5dd and hands back its deciding operand too, and both locs get sharper
(and a b) desugared to (if a b false), so it answered the last operand
only when every operand was truthy; a falsey one came back as a bare
false, where Clojure answers the falsey operand itself. It now uses the
same expansion or got in ad0f1fb -- (let [t a] (if t b t)) against or's
(let [t a] (if t t b)) -- so the operand that decided the form is the
answer, and the test is still evaluated exactly once.

The temp binding and its if now carry the operand's own loc rather than
the whole form's, which the or fix had lost: (or (vec-new i32) v) blamed
the enclosing form at 3:13 and now points at 3:18, the operand, and and's
second operand gained the same precision.

The parse pins in test_flan.ml now tie the bound name to the temp the if
tests and the bound value to the first operand, so a desugaring that
dropped the temp and wrote the operand into the arm twice no longer
passes; and has its own pin. dyn-if-truthy.flan grows the falsey-nil and
falsey-false answers, 0 and "" as truthy operands, one- and zero-operand
forms, and a printing operand that proves both the short circuit and the
single evaluation.

One behaviour that used to compile changed: with both arms of the
desugared if now holding real values, (and dyn-value typed-bool) unifies
on the typed arm and a non-bool dyn decider traps at the strict bool
boundary -- (and (box nil) some-bool) printed false and now traps, the
mirror of what (or false (box "s")) already did on dev-loop. Recorded in
FIX.org as the author's call on how check_if should join a bool arm and a
dyn arm.
2026-09-20 10:36:05 +07:00
633a7b2025 A global slice's element is not permanent, and four comments that were not true
The [At] arm of [permanent_root] recursed through any indexed target, so an
element of a global SLICE answered permanent the way an element of a global
ARRAY does. An array's elements are inside the global's storage; a slice's
are ptr+len pointing wherever, which can be a frame already returned — the
program that stashes (slice local 0 2) in a global slice and views an element
compiled and segfaulted with no diagnostic. The arm now recurses only when
the target's own type is an Array.

With it, the refusal/acceptance pair in test_flan.ml (one word apart) and a
view over an element of a global array in dyn-view.flan's mode 0.

The element check now runs before the lifetime check in all three container
arms: a local (Vec string) was told to make it a global, and a global
(Vec string) is refused anyway, so the advice was a dead end.

And the four strings that claimed more than the code does. flan_dyn.h
already had the honest version — a view is exactly as stale-safe as the
thing it is a view of — so the refusal message, box's comment and FIX.org
now say that instead of promising a dyn value can never dangle; a global
[i64] cut from a dead frame still passes and still reads it (ASan:
stack-use-after-scope in view_box). The element message no longer tells a
(Vec string) that string is not the case the restriction exists for.
dyn_ops.c's hand_vec comment no longer says flan_rt.c is unlinked when it
calls two of its functions; flan_rt.c said the same thing and is fixed too.
FIX.org's arena paragraph now separates the header's lifetime (compile time,
already covered) from releasing the arena under a live view: free-all traps
cleanly on the epoch, arena-destroy is a heap-use-after-free in
view_vec_check, the same gap flan_vec_check has on the typed side.
2026-09-20 10:34:23 +07:00
3f7c42257f Review found the hazard relocation-safety missed: a view can outlive its frame
Relocation was proved sound and stayed sound — a Vec view holding the
header's own address survives a push that grows and moves it, because
there is no snapshot to invalidate. That was never the whole of the hazard.
Refusing every container into dyn outright, before this lane, meant a
dangling view was unreachable; the moment box stopped refusing, three
routes opened at once — a view returned from the function whose frame the
Vec lived in, one stashed in a dyn global and read after that frame is
gone, and one left behind when a condition transfer unwinds it. All three
are stack-use-after-return, reachable for the first time.

The rule: a typed container crosses into dyn as a view only when its own
storage is permanent — a global's. On the dynamic side Flan follows Clojure
and Common Lisp, where holding a value can never hand you garbage; treating
a view as a bare pointer and calling the lifetime the programmer's problem
is the Odin answer, and it is the wrong trade on this side of the language.
check.ml's permanent_root walks the checked expression back to its root: a
global is permanent, a field or an array element of one is permanent at the
same fixed offset, and a slice cut directly from one at the call site
inherits it — the trace is what a slice carries, and it is lost the moment
the slice is bound to a name first, so that case is refused too rather than
guessed at. Everything else answers false: a local, a parameter, a
temporary, and anything reached through a (Ptr T), because a heap-durable
pointer and a frame's own are the same type and the checker cannot tell
them apart — admitting one admits the other, which is the whole hazard this
closes. An arena-held header turns out not to be a separate case at all: an
arena changes where a Vec's elements live, never where its own header — the
binding — lives, so it is already covered by the storage-class check above.
Both directions of the F1 escape were reproduced before the fix (a genuine
ASan stack-use-after-return, reproduced by building the pre-fix tree) and
confirmed refused at check time after it, for all three routes.

Three more findings, all in the runtime rather than the boundary:

view_vec_check, on finding a stale container, rendered the very view it had
just declared unsafe to read — which called back into the same check,
unconditionally, an infinite recursion rather than the intended trap. Fixed
by never rendering the container in the stale message at all; the sentence
names the two epochs and nothing else, which is everything a reader needs
and the one thing that was safe to read.

dyn_equal's VEC arm read x->len and x->u.v.items regardless of kind, which
for a view answers 0 and the union's other member reinterpreted as dyn
words: two views with different contents compared equal, a view and an
equal heap vec compared unequal, and a map keyed by any view collided with
every other view, silently. vecish_len and vecish_at read either shape
correctly and the arm now goes through them. obj_words gets the same
explicit OBJ_VIEW case on the same reasoning, unreachable today only
because mark_push's own gate already excludes the kind — this is the belt
next to that brace.

The three restatements of flan_vec's layout — flan_rt.c's real struct,
flan_dyn.c's mirror, and dyn_ops.c's hand-built one — had a comment
claiming a reorder would not compile or link, which was never true of a
void*-typed forward declaration. flan_vec_layout and
flan_dyn_vec_hdr_layout each report their struct's size and field offsets;
dyn_ops.c's new "layout" mode compares both against offsetof on its own
hand_vec, so a disagreement is a FAIL line in dune test instead of a
silent corruption at whichever view reads through the wrong offset next.

Also: the survey program's comment excusing a by-value parameter's view as
"value semantics, not a hole" was wrong on its own terms — a write through
such a view does reach the caller's storage, only growth diverges — but the
question is moot now: every container the program views is a global, and
the file was rewritten around that rather than patched. And an i32 element
does not cross into a view either, but the refusal used to say why in words
that were true only of a string element; it now says what i32 actually is
and what the restriction is actually for.

Rebased onto dev-loop's item-4 landing (221df5a).
2026-09-20 10:10:52 +07:00
b5f17826fe The queue records item 7's review pass: the or fix, and two things left alone
FIX.org's item 7 gets the full account of the review that followed
landing: or's fix (ad0f1fb), named there rather than left as "review
pass"; the keyword-condition diagnostic given up on purpose, the
author's call; and the exponential retry on a chain of nested not that
does not type-check, looked at and left alone since a cheaper retry
would cost message fidelity on a compound condition wrapping a literal,
not just speed.
2026-09-20 09:57:17 +07:00
29a9441f12 Typed containers into dyn as views — M2 item 3
A (Vec T), a slice or a fixed array crossing into dyn no longer refuses; it
is a view, one word in the box, over the container's own storage. Reads box
the element on the way out; writes tag-check the dyn value's tag against the
element type on the way in and trap, by name, on a mismatch, never coercing
or silently storing.

The open question the decision left — whether the descriptor points at the
container or snapshots pointer and length beside it — is settled by kind. A
Vec view holds the address of the Vec's own header (flan_rt.c's flan_vec,
restated in flan_dyn.c under the file's standing "if either table changes,
change both" rule) and reads ptr and len live on every operation, so a push
that reallocates cannot leave it stale: flan_vec_grow overwrites that same
header in place, and there is nothing captured at the crossing for the
growth to invalidate. A slice and a fixed array cannot grow, so a flat view
snapshots data and length once; pointing it at the value's own slot instead
would be worse, since a slot's lifetime is not the slice's.

The element set is i64, f64 and bool, not everything box already handles
typed-to-dyn. A string element's dyn form is a pointer into the collector's
heap, and a typed container's storage is arena or stack memory the collector
never scans — a wider set would let a write plant a live reference nothing
ever traces, which no care at the write site closes. (Vec string) and a
typed (Map K V) keep the "does not cross into dyn yet" refusal, now for that
reason.

flan_dyn.c gains a fourth object kind, OBJ_VIEW, and flan_dyn_len/at/set_at/
push and the printer each grow one branch for it beside the existing vec
one. A view's own stale-container check is the runtime's own spelling
(flan_trap, park-and-inspect) rather than flan_rt.c's rt_die, per the
duplicity doctrine; growing a Vec through a view calls flan_rt.c's own
flan_vec_push rather than re-implementing doubling and allocator adoption a
second time. (set (at target i) x) against a dyn target — a plain dyn vec or
a view alike — was a hole in the base dyn milestone rather than something
item 3 introduced; it is wired to flan_dyn_set_at here because a view's
writes needed it to exist at all.

Both backends: emit.ml and x86.ml both already passed a Vec or a Map to a
runtime call by address rather than by value; a fixed array crossing into a
view needed the same arm added in both, for the same reason — a copy would
view the copy and never see a write to the caller's own array.

test/dyn_ops.c drives the runtime directly with a hand-built Vec header and
a plain C array, ahead of any compiler involvement: reads, writes on both
element kinds, the tag-check refusal on every element kind, the range
refusal, and the push that grows and moves a hand-built header out from
under the view watching it. test_flan.ml turns the old "does not cross into
dyn yet" refusal into acceptances for Vec/slice/array, keeps it for a string
element and for Map, and adds the element-restriction refusal by name.
test/programs/dyn-view.flan is the compiler-level survey: a Vec view mutated
through both sides including the grow-and-move case, a fixed array's and a
slice's views, a bool Vec's view, and its own two trapping modes for the
acceptance rows to run against. test_sanitize.ml carries the survey's happy
path; test_dyn.ml's new refusals are the runtime's own.
2026-09-20 09:19:17 +07:00
d7070501e4 The queue marks item 7 landed, and names the or asymmetry it turned up
dyn if's truthiness rule reached when, cond, and, or, not and while
through one checker funnel, and turned up something nobody had decided:
and's short-circuit answer already carries a non-bool dyn value through,
Clojure-style, but or's does not, because its sentinel occupies the arm
check_if types first. Written down here rather than fixed, since the
queue's items get decided one at a time and this one is not yet.
2026-09-20 09:16:21 +07:00
c71ae8020c M2 item 4 landed at 3c1fb1b 2026-09-20 07:20:45 +07:00