305 Commits

Author SHA1 Message Date
ccb100d74c The command list is all eleven, the environment is written down, and one promise is withdrawn
README documented four subcommands of eleven. The seven missing ones are
there now, with import-c and generate-c given a worked example each -- they
are the most valuable thing here that nothing documented at all.

An environment table, checked against the getenv sites rather than against a
list: thirteen variables, each with where it is read, plus the llc/clang
version coupling that breaks C-c C-c while flan build keeps working. The
FLAN_DEV_* set that flan dev hands itself across its own exec is named as
internal rather than left looking settable.

DISCUSS.md's survey of what the x86 backend had no plan for still listed the
whole condition family. x86.ml:1587-1615 lowers all of it and the survey is
104/104; the row is struck through and corrected in place, because other
files cite that table by position.

prelude.ml promised a core: package at milestone 3. Milestone 3 came and went
and the package did not, so the docstring states the limit instead of
promising a way out of it. The loader could carry one -- what is missing is
the decision about what core: means for a program that imports nothing.
2026-09-17 21:50:33 +07:00
223e282e44 An escaped diagnostic says which file and which line, not (_)
Three suite runs left "Fatal error: exception Flan.Loc.Error(_)" on stderr:
OCaml's default handler knows nothing about the diag record, so a process
that does not catch prints a constructor name and none of the message. The
trigger was a corpus file the test binaries check directly and nothing there
wraps. Registering a Printexc printer changes no control flow and costs the
drivers that do catch nothing; it only makes the corpse legible.
2026-09-17 21:44:46 +07:00
ac7ee912e7 A wrong main signature points at the main that is wrong
check_main raised against Loc.unknown, so both of its refusals opened with
<unknown>:0:0. env.locs is the table of where each type was declared and a
function is not in it, so the location comes from the declaration list the
caller already holds. A main that arrived without a defn keeps the unknown
span rather than being given an invented one.
2026-09-17 21:44:40 +07:00
d57eeb3265 The refusal was about teardown, and a region has none
A (Vec Value) where a Value may itself hold a (Vec Value) — the recursive
dynamic value an EDN reader has to answer with when nobody hands it a target
struct type — was refused five different ways, and every one of the five gave
the same reason: the container runtime is type-erased, so it copies and
releases slots bytewise and cannot reach inside a slot. A free would release
the slots and leave every block they point at stranded.

That reason is about teardown, and it does not hold for a region. free-all
never releases an individual slot; it takes the whole arena, and every block
the elements own is in it, because they came out of it. The refusals were
over-broad, and what they were guarding was never ownership — ownership
tracking is untouched here, moves are still moves, and Types.is_move_only is
the same function it was.

So the question moved rather than disappeared. It could not stay at the type,
because can-free is a capability on an allocator value and with-allocator
rebinds a dynamic variable: which tier a (vec-new) will meet is not a property
of the place its type is written. What is decided at compile time is only
whether to ask, which is a property of the element type; the answer is a
run-time branch on the allocator, one per container and never per element,
because the alternative is a walk at release and a walk at release is the
registry of destructors the frame tier's reset exists to not have. It is
emitted at every growth and not only at the construction, because ZII means a
container can exist without ever passing through (vec-new) — a case field left
out of a literal, a global that starts zeroed — and those adopt the context on
their first push.

free on such a container is refused rather than made quietly shallow. It cannot
recurse, which is the whole premise, and releasing the outer block alone would
be "I freed it" written over a program that stranded everything inside; this
runtime refuses that collapse everywhere else. The message names free-all,
which is reachable by construction. clone stays refused for a reason the region
does not dissolve, and the old message had bundled the two failures under one
sentence: what disqualifies clone is not that it copies a header — so do at and
get, and they are fine, because they promise nothing — it is that clone
allocates a new block and promises independence, and a bytewise copy hands back
elements still pointing into the original's region.

A struct or union field is admitted only where the field's container holds
owning elements, because that container can only have been built against a
region. A field holding a plain (Vec u8) stays refused: nothing would force
that one into a region, and two copies of the aggregate would be two headers
over one heap block. vec-in-struct.flan still pins that.

The epoch already covered use after free-all, including the case this makes
reachable — an inner header copied out of an arena-held element into a local
still traps, because an Allocator is a pointer and a copied-by-value one would
carry its own epoch.

arena-value.flan builds the value by hand; arena-edn.flan reads a real document
through the tokenizer, and its reader takes no allocator and names none,
because spec-memory.md already puts the allocator in the calling convention.
arena-region.flan is the branch itself: run 0 is the (Vec (Vec i32)) control
that must not trap, and runs 1 and 2 are the two ways this dies.
2026-09-17 20:37:08 +07:00
22866d864a The tagged sum is defdata, and defunion is C's union 2026-09-17 20:17:37 +07:00
aa9a2e03bb A union member is refused in a constant as well as in a global
The refusal was on the defvar path alone, so (defconst c U (U {.i 1}))
still came back from the emitter as "a global's value must be a
compile-time constant — this one is computed", which is true and says
nothing about unions. Both kinds of global reach the same encoder, so
both get the same message.

It is decided on the checked value rather than on the declared type now,
which is what lets the one initialiser that *is* a constant through: (U
{}) is all-bytes-zero, the same value a declaration with no value gets,
and refusing it would have been telling someone to write the thing they
had written.
2026-09-17 20:08:04 +07:00
6cc94e00d6 defunion is C's union, and reading the member you did not write is defined
The name freed up by the rename now means what C means by it: the members
overlay one storage, the size is the largest of them, the alignment the
strictest, and nothing anywhere records which one was written. It serves
two things that wanted it. Binding a C header means holding the union the
library holds and reading whichever member the library's own tag says is
live -- a tag Flan cannot see, because the rule relating them is prose in
a manual. Overlaying an f32 on a u32 to look at its bits is the other,
and it is the same read.

So that read is defined rather than refused. This is the one place in the
checker where bytes win over safety on purpose, and the alternative was
not a safer language, it was no feature: type punning *is* reading the
member that was not written. The promise is the one C's implementations
make and C's standard does not -- the layout is the target's, the bytes
are the bytes, a read is a reinterpretation of them -- and what is not
promised is anything about bytes nobody wrote, where a member wider than
the one last stored reads a tail that is indeterminate exactly as a
struct's padding is. ZII narrows that to almost nothing: a union starts
all-bytes-zero unless uninit says otherwise.

uninit on one is allowed, unlike on a defdata. The refusal there was
never about garbage; it is that a tag steers, and a tag no case names
falls past every comparison in a match into a block LLVM may treat as
unreachable. An untagged union steers nothing.

Which is also why three things are refused, each for a reason that does
not expire with a milestone. No move-only member: nothing knows which
member is live, so nothing can tear one down, and unlike the struct and
defdata refusals this is not waiting on recursive teardown -- there is no
fact for teardown to read. No bool at any depth: an i1 loaded from a byte
that is neither 0 nor 1 is a value the optimiser may assume cannot exist,
and a union is the only type that can produce one. No defdata at any
depth, for the reason uninit gives, arriving the other way round. An
Option member is fine and the walk says why: its match is a tag test and
a branch, not a chain with an unreachable tail.

Two members in one literal, a match on a union, a union map key and a
member written into a global initialiser are each refused by name.

A union is a field list whose every offset is zero, so it travels as a
Tast.structure and the checker, the emitter and the x86 backend each grow
one table rather than one shape. A value is a zeroed temporary and a
store -- Set over Pfield, which every backend already has -- so there is
no new IR node and no layout rule spelled out a second time per backend.
The LLVM type is the blob clang gives a union, the DWARF is
DW_TAG_union_type with every member at zero, and the printer names the
type and does not walk it: it cannot know which member is live, and one
of them may be a pointer.

cimport can now check what it could not. A C record holding a union
member was not recorded at all, so the defstruct beside it went unchecked
rather than checked wrongly; a named union member resolves to a defunion
now and the whole record is compared field by field. The defunion itself
is compared against the header's union as a set and not in order --
every member is at offset zero, so a permuted one is the same type and
reporting it would be a finding that is not one -- while a member the
header has and Flan lacks is reported, because that is what changes the
size. A defunion against a C struct, or a defstruct against a C union,
is reported in both directions. An anonymous union member is still
skipped, and the comment now says that the gap is on the Flan side:
there is nothing to declare.
2026-09-17 19:54:32 +07:00
5a46ab488f The one caller of ~hidden is named, and the pair it must never form is refused
Emit.program ~hidden and ~dev are opposites: a dev build exports its cells,
globals and flan.abi.* so that a redefinition module can bind against them, and
hiding those would give a host that links, runs and silently installs nothing.
Nothing in the types stopped the combination being written, so it fails with a
sentence instead. Build.macro_module is the only caller that asks for hidden and
it never asks for dev.

The handoff's verification is narrowed to what it actually covers: the 540-file
IR diff is about Emit.program's default, and the new path is read off nm -D on
the linked module. It also now says why sand-headless.flan is the fixture that
checks 65d14f4 -- it reaches rl/with-drawing through an import, which is the
package-macro shape the reproduced bug had.
2026-09-17 19:52:35 +07:00
3eaa3e23bd A macro module keeps its own prelude, and --x86 gets the merged daemon
flan dev's merged build is the program and the compiler in one -rdynamic
executable, so it exports every flan.* body it has, and ELF gives it precedence
over anything dlopened afterwards. The compiler expands a macro by dlopening a
module into that same process, and the module is built by Emit.program whatever
backend the session uses -- so under --x86 the caller was LLVM's and the body it
landed in was the dev backend's, which is a crossed pair. It died with SIGSEGV
inside flan.[clamp] during the first expansion, before the program had run a
line, and Dev.start refused the combination rather than do that.

Build.macro_module now asks Emit.program for hidden visibility on the module's
own Flan definitions. There is nothing left for the host to interpose, and the
flan.macro.* thunks stay exported because dlsym is how the compiler reaches
them -- nm -D on the built module lists those three and nothing else of Flan's.
The -Wl,-Bsymbolic that had been binding everything locally since 65d14f4 goes
with it: the module links its own flan_rt.c, and binding that locally aimed its
calls at a runtime flan_rt_init never ran on, with a null flan_exit_hook, so a
trap raised inside an expansion would have exited the process instead of parking
it.

Nothing about the host moved, which is what keeps redefinition modules reaching
its cells, its globals and flan_dev_cell. hidden defaults to false, and the 540
IR files this compiler emits for the test corpus are byte-identical to the ones
before it.

test_dev.ml's assertion that the merged daemon refuses --x86 becomes the session
it was standing in for: dev-macro.flan calls a prelude macro at the top level,
so the daemon coming up at all is the old crash not happening, and one build
then carries C-x C-e, a C-c C-c whose body calls a macro again, the park and the
rerun.
2026-09-17 19:50:29 +07:00
ff2c949361 The tagged sum is defdata, and the old spelling is an error by name
Flan's tagged sum has been spelled defunion since it landed, which was
accurate right up until the language wanted C's untagged union as well.
Both cannot be called the same thing, and the tagged one is the one with
an alternative name that says what it is: a case, its fields, and a tag
that steers which case is live is a data type, not a union.

So the form is defdata everywhere -- the parser, the AST, the checker,
both backends, the prelude's Form, the editor's font-locking and imenu,
the docs and every .flan file in the tree. The internal vocabulary moves
with it: Tast.union is Tast.data, uname is dname, the tables the checker
and the emitter keep are datas. Leaving them would have inverted the
words permanently, with surface defunion meaning one thing and
env.unions meaning the other, which is exactly the kind of drift the
comments in those files exist to prevent. What did not move is case,
variant and vfields: a tagged sum still has cases, and it still has one
live at a time.

defunion is not kept as an alias. An alias would compile the day the
untagged form lands and mean the opposite of what it used to -- the same
silent misparse that made defn's return type mandatory, and worse,
because the reader would have no reason to look. The old spelling is a
named refusal instead, parse/defunion-renamed, which says what it is now
called and that the name is reserved for something else. It fires on the
head alone, so (defunion U [A B]) -- which would otherwise have parsed
cleanly as one field A of type B -- is refused with the rest.
2026-09-17 19:03:27 +07:00
836c23295c Two sentences a parked program was still getting wrong
basis had the same false promise the eval note did. It branches on the break
state, which answers "running" while the program is parked — the agent's
listener is alive and nothing has stopped — so disassemble reported that a body
delivered while parked installs at the next frame boundary, which is not a
boundary the program will reach until somebody runs it again.

And flan-rerun had no autoload. The keymap binds it, and flan-mode.el says in
as many words why every command there needs a real autoload rather than a
declare-function: a user who loaded only flan-mode would otherwise find the key
bound to nothing at the exact moment they most want it.
2026-09-17 19:01:49 +07:00
0c523cfe8b The program can be run again, in the process that is already there
You run a program under flan dev, it opens a raylib window, you close the
window, main returns — and there is no way to get another window short of
flan-dev-restart-program, which throws away the build, the session and every
global with it. In Common Lisp or Clojure the image outlives main, so you call
it again. The process here already outlived main: the exit hook flushed, closed
stdout and sat in for (;;) pause(). Nothing could wake it.

So main() is a loop. The hook records the status and longjmps back into a
setjmp in main() — there is no return available, since flan_exit is reached
from wherever the program happened to be — and the thread waits on a condition
variable until the new rerun op signals it. The main thread is the one that
runs main again: a window belongs to the thread that opened it, and on macOS to
the first thread of the process. A longjmp pops no frame, so the park first
empties the handler stack, the restart stack and the shadow frame chain, each
of which was a chain of allocas in stack the next run is about to write over.
Nothing else is reset; the second run reads whatever the first left in the
globals, which is the semantics that was asked for.

Closing stdout had to go with it. That was how the compiler learned the program
was done, but a pipe delivers EOF once, so the signal and the program's output
were the same resource and spending it left the second run with nowhere to
print. The descriptor hazard the old code reopened /dev/null for goes away with
the close that caused it. Liveness is asked for instead, through a weak symbol
in the same style as the agent's, and is now three states rather than two: Live,
Parked and Gone. Every guard branches on that before consulting the break
state, because the agent's listener answers "running" while the program is
parked and telling somebody whose program has finished that it is running is
worse than saying nothing. Only eval accepts a parked program — it queues and
waits for nothing, and the queued module installs at the first frame boundary
of the next run, so a body can be fixed while parked and the re-run executes
it. Everything else needs a frame boundary or a stopped stack, has neither, and
says which, naming the command that gets the program back.

A re-run while the program is running is refused rather than queued: the test
and the signal happen under one mutex, so two mains writing the same globals at
once never starts.

:parked rides on every reply beside :stopped, for the reason :stopped does —
finishing is as unannounced as stopping, more so when the way it happens is a
mouse click on a title bar. Emacs shows flan:parked in the modeline and binds
flan-rerun to C-c C-M-x.
2026-09-17 19:01:01 +07:00
c124df36ca A move-only global is borrowed, never moved 2026-09-17 18:42:37 +07:00
69f5d8a05a A global Vec is borrowed, never moved, and outlives every entry to main
A program that wants to load its data once and keep it could not say so. Every
move-only global was refused where it was declared, on an argument about the
dead set being per function: two functions each freeing the same global would
be a double free nothing could see. The argument was sound and the conclusion
was too strong. It assumed a global has an owner. It does not.

Reading a move-only global is now always a borrow. Nothing may take ownership
of one, so nothing may free one, and with no owner to hand over there is no
double free left to catch. This is not a general ownership model for globals
and is not meant to grow into one: it is sound precisely because the lifetime
question that model would exist to answer has a constant answer here, the
process's. The refusal lands at the read, which is where a move would have been
recorded for a local -- passing the global to something that owns its
parameter, binding it to a local, returning it and freeing it all reach the
same place, and each is told to borrow instead, or to clone if it really wants
something of its own.

Such a global is mutable where it stands. push, put, reserve and set already
take their target through the borrow path, so a global (Vec u8) is filled and
grown in place, and the aliasing that raises is the one every Vec has:
spec-memory.md's explicit Zig/Odin contract, where a push that reallocates
invalidates a slice taken before it and the dev build's generation word traps
on the stale one. Globals get no borrow rule locals do not have, because the
hazard is not new and the trap lives on the Vec rather than on the binding.

What a move-only global may not do is carry a computed initialiser. A global's
initialiser is a link-time constant -- there is no init-at-startup path in the
LLVM backend by design, and the x86 backend that has one deliberately leaves it
out of a reload module, because re-running an initialiser wipes the live state
reloading exists to preserve. So the global starts zeroed, which for a Vec is
an empty Vec and therefore a value rather than a placeholder, and the load is
an ordinary assignment in whichever function loads it. That is also what makes
the data survive: nothing runs between one entry to main and the next, so a
re-entered main finds the global as it left it. A defconst cannot be one at
all, since a constant is not an assignable place and nothing could ever load
it; both refusals name the (defvar g (Vec u8)) that works.

The reload fixture gains a global Vec in the host and another that arrives at
run time, because that is where declaring instead of defining has teeth: a
module that defined the host's Vec would take a zeroed header of its own and
strand the block the process is still using, which a re-zeroed i64 cannot
demonstrate.
2026-09-17 18:42:01 +07:00
de3ad8c052 defenum members may leave their value to the previous one 2026-09-17 18:36:34 +07:00
3771f6820b An enum member's value is optional, and a value nobody chose is refused
Every defenum member had to carry a literal integer, so an enum of twenty keys
was twenty numbers typed by hand and renumbered by hand the first time a member
was inserted in the middle. A value may now be left out, and then it is the one
above it plus one, starting at 0 -- C's rule, because the enums written here are
as often a transcription of a header as they are original.

Autoincrement brings its own silent failure with it. Renumber a member, or slip
one into the middle, and the member below can land on a value some other member
already holds: two names for one number, the program still compiles, and one of
the two is now unreachable through a match on the other, with nothing in the
source saying so. So a duplicate that was *written* is kept -- a Count or a Last
pointing at an existing value is a real idiom and is somebody's decision -- and
a duplicate autoincrement walked into is refused, naming both members and the
number they collide on, and saying that writing the value out is how the alias
is declared to be intended.

The rule lives in the parser rather than beside the duplicate-name check in the
checker because it is a question about the source text. Ast.Defenum holds
resolved numbers and no per-member locations, so by the time the checker has an
enum in hand it can no longer tell which of the values were typed, nor point at
the other member. All members are resolved before any of them is checked: the
value collided with is as often below as above, and (defenum E [A B 0]) has to
refuse A.
2026-09-17 18:34:52 +07:00
798c852934 Where a program starts, how a big array clears, and which key folds
flan-dev's start command proposed the last program it had started, so
invoking it from a fresh project's buffer offered the previous project's
file. It now proposes the buffer it was called from; restarting the
previous program is what flan-dev-restart-program is for.

Zeroing a fixed array wrote one typed store per element. Above 64 bytes
that becomes a memset, which LLVM can lower as a bulk clear; below it the
inline stores are still cheaper than a call.

Outline's minor-mode map owned TAB in the lowering buffer, so the folding
keys that buffer defines never ran. A buffer-local overriding map gives
them back without touching Outline anywhere else.

FIX.org collects the rough edges found while using the dev loop.
2026-09-17 18:08:51 +07:00
65d14f42f0 A dlopened macro binds to its own copy, not the merged host's
flan dev's one-process build links the program and the compiler into one
-rdynamic executable, so it exports flan.rl/with-drawing -- the package's
defmacro compiled as an ordinary function, whose body was qualified at the
Ast level, after the quasiquote had already become a string literal. ELF
gives the executable precedence over a dlopened object, so the macro module
called the host's copy and (rl/with-drawing ...) expanded to an unqualified
begin-drawing the checker then refused. The module is self-contained, so
-Wl,-Bsymbolic is the whole fix.

merged_setup reports a Loc.Error as a diagnostic now, which is how the
failure had a location at all instead of an exception constructor after an
apparently successful build.
2026-09-14 22:26:52 +07:00
238f65db59 A listing says which form it came from, and what each slot is
`flan emit --x86` printed a three-line header and then nothing but .byte
blobs. The information was all there and none of it was written down.

Each run of bytes is now headed by the Flan form that produced it, with the
position it was written at, indented by how deeply the form nests. The
headings are queued rather than written, so a form that emits nothing does
not leave its heading on the next form's bytes; atoms queue none at all,
because a literal operand would otherwise steal the heading standing above
the imul that consumes it.

Above each function is a frame map, which is the half no disassembly
recovers: every value in this backend lives in a frame temporary, so
-0x20(%rbp) is the whole vocabulary of the listing and nothing says what it
means. It is read out of what emit_fn already keeps, so it cannot drift.
Beside it, where the arguments arrived and whether there is a hidden sret.

And the bookkeeping is named where it appears -- the transfer guard, the
bounds triple, the arithmetic guards, rep movsb, the dev indirection cell --
with each explained once in a legend at the top rather than at every site.

Always on for `emit --x86`, which exists to be read, and never for a build,
whose .s is a temp file handed to clang. spike/x86/annot.sh is the check that
this costs no byte: emit both ways, assemble both, compare every section.
342 SAME / 0 DIFFER over the corpus in default, --dev and --debug. dump.sh
now shows the annotated listing beside objdump's disassembly -- why beside
what, which is the pairing that answers the mnemonics question.

survey.sh has not been run on this; see the handoff.
2026-09-14 11:54:36 +07:00
f182fb4728 flan dev --x86: the host and its modules, chosen together
Item 3, and the reason the backend was written. Until now --x86 was read only
by flan build's argument list; the daemon built both halves through LLVM, so
none of this reached the dev loop at all.

The choice is a session setting, not a per-command flag, and it is spelled
exactly as [debug] already is -- one field on Session.t, set once in Dev.start,
carried on every change the session emits. session.ml's comment on [debug]
already gives the reason and it is the same one: the modules have to match the
process they are loaded into. Session.redefinition is the single place that
picks a backend, so the six call sites cannot disagree and the refusal has one
home. Session.change carries the answer beside the text, so the builder and the
text can never come from two different decisions.

There is no fallback and there must not be one. X86.redefinition refusing a form
is reported to the editor; quietly building an LLVM module instead is precisely
the crossed pair flan.abi.x86 exists to refuse at dlopen. A refusal reaches the
editor as a diagnostic like any other -- X86.Unsupported is re-raised as a
Loc.Error at the form it is about, because every caller already handles that and
none handled the other, and a session that died on the first unsupported form
would be worse than one that says so and stays up.

flan reload got the same flag at the same time. A command that could build a
module for a host the other backend compiled is how the crossed pair was
reachable from the CLI at all; the aggregate handoff's two-line reproduction no
longer has a second half.

And the finding: flan dev --x86 refuses the merged daemon. A merged build is the
program and the compiler in one process, and the compiler expands macros by
dlopening a module Build.macro_module made through Emit.program, cached on disk
by the macro source rather than by the backend. The merged host is linked
-rdynamic so a redefinition module can reach its cells, which also exports every
flan.* body it has -- so the macro module's own copy of a prelude function is
interposed by the host's. With an LLVM host nobody notices. With an --x86 host
the caller is LLVM and the body it lands in is this backend's, and the process
dies inside flan.[clamp] during the first macro expansion, before the program
has started. flan.abi.x86 does not catch it and was never meant to: a macro
module deliberately neither defines nor requires a marker. The honest fix is
hidden visibility on a macro module's Flan bodies, which changes the cached
object for both backends and wants a lane of its own. Until then the refusal
names the mechanism and the remedy, and --two-process has no such meeting.

start_merged keeps its --x86 plumbing, unreachable for now, because it is the
half that is right and will be wanted the day the macro module is fixed.

test_dev.ml drives an --x86 daemon through C-c C-c, C-x C-e, a literal, a new
defvar with a value of its own and a new defn, and asserts (twice fresh) is 82 --
which only holds if both registry lookups resolved. The merged refusal is
asserted there too. bin/main.ml learned to print a bare Failure as a sentence
rather than an uncaught exception and its backtrace.
2026-09-14 10:34:49 +07:00
5b1567c49f The transient thunk, so an expression has something to compile to
Item 2 of HANDOFF-x86-redef.md. C-x C-e and every value a break loop computes
are not a redefinition: there is no name to install a body into, so the
expression is wrapped in a function with nowhere to be called from and the
module says "run this once". flan_reload_call is that wrapper, and the agent
dlsyms exactly that spelling.

Its shape is emit_main's rather than a body's: no caller hands it a transfer
channel, so it owns a null cell on its own frame and passes that cell's address
on. Sixteen bytes of frame rather than eight, because rsp has to be 16-aligned
at the call and that is the whole of what the ABI asks of a frame making one.

The thunk itself is excluded from everything else the module does -- no cell, no
publish, no registry slot. There are 4096 slots and an expression evaluated in a
loop would exhaust them, and a module with nothing pointing into it is what lets
the agent unload it at all.

@flan_reload_transient is that claim, under emit.ml's three conditions. The
third is about data rather than text and is the one that can be got wrong in the
dangerous direction: a string literal lives in this module's image, an
expression may store one anywhere it likes, and a global left pointing into an
unmapped image is silent garbage rather than a fault. So the count is kept where
the literals are made -- string_const bumps the same Emit.m.nstr field emit.ml
counts on, and float constants, which are loaded and never retained,
deliberately do not. The install function's own registry name strings go through
string_const too, which is right rather than incidental: a module that interned
a name left something behind.
2026-09-14 10:34:29 +07:00
5a67cf458a A name the host was never built with, compiled by the x86 backend
The first of the three things HANDOFF-x86-redef.md left: a function or a
defvar the running process has no symbol for. ELF cannot grow one, so the
address is asked for by string at install time -- flan_dev_cell for a cell,
flan_dev_global for a global's storage -- and parked in a slot this module
defines.

The reference side is one new [loc] case and nothing else. [Lslot] loads the
slot and answers [Reg (scratch, d)], which is exactly what [Lgot] already did
with [Got] where this has [Sym]; every site that reaches a cell already
double-loads, so no call site, no place expression and no [sym_loc] caller had
to learn a third case. [fnctx.slot] is a second predicate rather than a widened
[ext] because they answer different questions -- [ext] says "the host's, reach
it through the GOT", [slot] says "nobody's yet, reach it through a slot I
filled". It defaults to [fun _ -> None], so the whole-program path emits
byte-identical output and the survey goes on being a structural check.

flan_reload_install is now a function with a frame rather than a run of loads
and stores, because it makes calls and a call on an unaligned stack faults
inside glibc's movaps rather than anywhere a reader would look. Its shape is
emit_globals_init's, down to owning the null transfer cell no caller hands it.

A new global's declared value travels with it: flan_dev_global copies the image
onto the allocation the first time the name is interned and ignores it after,
which is where "a reload must not reset the state" lives. emit.ml folds that
value into an LLVM constant and this file has no folder, so the image is a
module-local buffer written by the initialiser lowered as ordinary code -- the
same bargain emit_globals_data already documents.

Republishing a defconst came free once the rest was there: one store of the new
constant into the host's global, which is what emit.ml does.

reload-v6.flan is new. v3's [extra] is declared zero, which calloc also gives,
so a run-time-new global whose initial value never arrived would still pass;
v6's [tuning] is 42 and the host prints 88.

test_reload.ml's x86 section now runs all four modules against the same
transcript the LLVM path is held to, and the refusal it used to assert is gone.
2026-09-14 10:16:42 +07:00
a9b7eb1556 The survey landed, and neither census comment states a number any more 2026-09-14 08:49:43 +07:00
37d94ed66f The pointer arm agrees promised, and the comment stops promising it 2026-09-14 08:37:30 +07:00
ff2bd1da12 A socket file must not outlive the process that bound it
The merged daemon now unlinks its socket on the ways out it does not
control as well as the one it does: an atexit for exit(3), which is what a
runtime trap takes, and by hand in die_now and in main's fallback, which
are _exit and skip the chain on purpose.

The client says so too. A refusal on a path that exists is a leftover, not
a daemon declining, and the raw 'Connection refused' has now misdirected
two investigations.

Separately, and it is separate: dev-repl.flan gets dev-robust.flan's
24000-tick budget. Twenty seconds of program under a two-minute test is a
second flake waiting its turn, and it is not the one fixed above -- that
one fails honestly, saying the program exited.
2026-09-14 08:11:35 +07:00
fbafee4957 An editor that leaves mid-reply is a closed connection, not a dead session
SIGPIPE's default action was killing the daemon outright. In the merged
build that is the program, the compiler and the listener at once, and the
socket file is left behind - so the next client got ECONNREFUSED on a path
that exists, which reads like anything but what it is.

Two halves: ignore the signal where the listening socket is bound, and
guard the reply write, because an [exception] case on a [match] covers the
scrutinee and not the branch body, so the EPIPE went past both handlers
below it and out of the accept loop.

test_emacs.ml keeps the daemon's wait status and the program's output on a
failure now, instead of removing the evidence before reading the exit code.
2026-09-14 08:11:35 +07:00
0251cf4aaa Both comments predated the marker, and now neither does 2026-09-14 07:57:41 +07:00
5a4f12bacd Three type-constructor comments spell the map type the way the parser does 2026-09-14 07:36:24 +07:00
dcba88ffd6 The 09-13 handoff says it is a snapshot, and two settled questions stop reading as open
NEXT.md prepends, so its second section still opened with "Read this first" while
pinning a tree six lanes behind and listing as live a queue most of which has
landed. It keeps its contents; what it gains is the sentence saying when it
stopped being where the tree is, and the survey count it quotes measured again on
this tree -- 103 MATCH, 0 DIFFER, 38 skipped.

The two generics entries get the file's own strikethrough treatment, which their
siblings already had: the {K V} catch resolved exactly as it predicted it would,
and there are five predicates rather than four.

Also here: conditions.org gains ArithError beside BoundsError, with the split
between the two runtime conditions that offer retry and the two that deliberately
offer nothing; and tast.ml's header stops naming a tree-walking interpreter among
the backends that consume the typed IR.
2026-09-14 07:36:24 +07:00
5b54e1b7da Merge the tidy: the crossed-pair case is the marker's, and the citations follow the move 2026-09-14 07:30:14 +07:00
fa83056708 A backend mismatch is refused by the loader, and says so 2026-09-14 07:15:13 +07:00
9d5689ffa2 Every citation of a moved document now resolves from where it is written 2026-09-14 07:12:27 +07:00
4c46a56827 Merge branch 'worktree-agent-a123a91f32f4c9779' into dev-loop 2026-09-14 06:55:39 +07:00
70e95119e9 Merge branch 'worktree-agent-a03eb07b90913c874' into dev-loop 2026-09-14 06:55:39 +07:00
c9c23c5079 Call frame information, which is the thing the assembler gets right
The debug-information section added in the last commit is about what GAS
gets wrong against a file with no instructions in it: .loc is flushed when
an instruction is assembled, and this backend assembles none. CFI is the
opposite case and worth recording beside it. Its advances come from frag
positions, so .cfi_def_cfa_offset interleaved with .byte comes out exact --
measured, readelf --debug-dump=frames on a .byte-only function gives the
right advances.

The content is a constant, and that is the header's claim about the frame
model paying for itself. rsp is written exactly twice, so: on entry the CFA
is rsp+8; push rbp makes it rsp+16 with the saved rbp at cfa-16; mov rsp,rbp
moves the rule onto rbp and it stays there for the whole body; after leave
rsp is rbp+8 and the CFA is rsp+8 again. Five directives, three sites --
emit_fn, emit_main and emit_globals_init, which have the same prologue.

emit_main has no closing rule because it has no epilogue: it leaves through
flan_exit and the ud2 after that is unreachable, so the rbp rule holds to
the last byte, which is what a backtrace out of anything main called wants.

gdb did not need this -- its prologue analyser already unwound out of
flan_bounds_error into flan.main with a line number, because push rbp;
mov rbp,rsp; sub rsp,N is the pattern it recognises. It is here because the
description is now stated rather than guessed, and because a break at the
very first byte of a function -- before the push -- now unwinds from a rule
rather than from a heuristic.

Gated on --debug so a release build's assembly stays byte-for-byte what it
was. That is conservative rather than principled: the description is correct
in every build and a release build is where a crash would most want it. What
stops it being unconditional is only that nothing measures the .eh_frame it
would add, and another lane is measuring backend cost right now.
2026-09-13 23:32:35 +07:00
201cd87bc9 Debug information for --x86, written out as bytes
--x86 --debug was refused in build.ml for want of DWARF. It emits it now:
a compile unit, a subprogram per function, and a line table, so gdb breaks
by Flan file and line and a backtrace names Flan source.

The reason it is written as data rather than as .loc directives is the
finding worth carrying forward, and HANDOFF-x86-debug.md leads with it.
GAS builds its line table out of dwarf2_emit_insn, which runs only when an
instruction is assembled, and this backend assembles none -- everything is
a .byte blob. A pending .loc therefore sits until the next .loc and is
flushed at whatever the location counter has reached by then: every row
comes out one statement late and the last statement of every function gets
no row at all. Measured on GAS 2.44, and interposing labels does not help.

So .debug_line is emitted here the way the instructions are. Every row is
a full DW_LNE_set_address on a label rather than an advance_pc with a
computed delta, because a delta would be a difference of two labels inside
a .uleb128 -- a value whose size changes the offsets after it, and whose
failure would look exactly like a DWARF bug.

The .file 1 directive at the top of the assembly is not about our table at
all: without it clang's integrated assembler generates a compile unit of
its own over the .s, whose rows land at the call mnemonics, and those
addresses are inside the functions we already describe. -gdwarf-4 goes
with it so the empty stub it leaves behind parses.

Rows are deduplicated on the byte counter as well as on the position.
lower recurses, so an outer form and the inner one that emits its first
byte both ask for a row at the same address; keeping the first is smaller
and is the better answer, since a debugger takes the last of a run. buf.n
already existed and was written but never read. This is its first reader.

No locals and no types, deliberately. A slot here is a bump-allocated
frame temporary whose offset is known but whose lifetime is not modelled
-- scoped reclaims temporaries and a later expression reuses the bytes --
so a DW_TAG_variable would be right at some addresses and confidently
wrong at others. That is the call build.ml already makes about wasm32's
member offsets.

Verified under gdb against test/programs/debug.flan, and against an LLVM
--debug build of the same program. Identical bar the parameter values,
which is the locals work. Transcript in the handoff.
2026-09-13 23:32:35 +07:00
853bc35be7 A release build stops calling into a registry that is switched off
emit.ml:1918 drops the allocation registry's notes in a release build --
the checker builds a Tast.Rt it cannot know is unwanted, because it does
not know whether this is a dev build. The x86 backend had no counterpart
and emitted the calls for real: correct, since the registry answers
nothing when it is disabled, but one call per container operation into a
function that returns immediately.

The guard is emit.ml's byte for byte, strict > 17 included: bare
flan_dev_reg_note is the runtime's own C entry point and is never a
Tast.Rt, and what check.ml builds is the _vec, _map and _pool wrappers.
emit.ml drops the note before the arguments are walked so that taking the
address of the container does not leave an escaped alloca behind; here the
arguments are not touched until call_rt, so answering () is already early
enough.

Measured, call sites of flan_dev_reg_note in the disassembly:

  vec.flan       46 -> 1     (--dev: 46)
  maps.flan      55 -> 1     (--dev: 55)
  registry.flan  36 -> 1     (--dev: 36)

The one left in each is not emitted code -- it is inside the runtime's own
flan_dev_reg_note_vec. An LLVM release build of vec.flan has the same one,
so the two backends now agree.

HANDOFF-x86-debug.md is the stub for item 6, which is next, and it leads
with the finding that changes that item's plan: .loc does not work against
a backend that emits .byte blobs, so the line table has to be written out
by hand.
2026-09-13 23:32:35 +07:00
c9394a6bcd The mismatch is reachable from flan reload, and the guard does not close it 2026-09-13 23:18:25 +07:00
ed32915501 The two redefinition builders refuse to be crossed 2026-09-13 23:15:40 +07:00
b0f4fb73e1 The x86 backend stops leaving a divide by zero to the hardware
Item 3 of HANDOFF-x86-rt.md, which was blocked on the language decision rather
than on code. check_div and check_cast sit beside check_at and check_slice and
reuse bounds_call unchanged; flan_arith_error takes three extras, so the
channel lands in r9 and the argument registers are exactly full.

Three things differ from the LLVM side because the instruction set does: two
branches rather than one branch and a select, since there is no select and a
second compare on the cold path is free; the cast bounds compared in the
source's own precision rather than widened to a double, which is exact because
every bound is a power of two; and NaN excluded by choosing the direction of
each compare, because ucomis sets CF, ZF and PF together when either operand is
unordered.

Both programs now print byte-identical stdout, stderr and exit status through
either backend.
2026-09-13 23:07:23 +07:00
a431cddd3b A divide by zero names the file and the line, and is answerable
Three arithmetic situations had no defined behaviour and the two backends
disagreed about all three: a divide or remainder by zero, which was a raw
SIGFPE with no message and no location; (/ min -1), whose quotient is one past
the top of the type; and a float to integer cast whose value does not fit,
which LLVM called undefined and would fold to anything.

They now signal ArithError with `error`, exactly as a bad index signals
BoundsError, and die with a sentence naming the file, the line and the operands
only if nothing answered. The guards ride the same --checks flag as the bounds
check and are elided with it.

No restart is established at the failing operation. The sketch this started
from asked for use-value, and the implementation ruled it out: a restart frame
is allocated by the restart-case that offers it, on its own stack, so the
runtime cannot hold one on a program's behalf and use-value here would mean an
alloca and a restart frame at every division in every checked build. That is
the cost already refused for indexing, buying a silently different answer.

The x86 backend is unchanged and is the next commit.
2026-09-13 22:55:26 +07:00
60d4c762e1 Merge branch 'worktree-agent-ab6daf83988bee428' into dev-loop 2026-09-13 22:50:40 +07:00
624a94af7b A smaller hunk, and the restart-case shape that is not a counterexample 2026-09-13 22:46:16 +07:00
939417446d Item 5's guard dropped, item 4's reached and correct 2026-09-13 22:42:42 +07:00
bd69f684ed An --x86 host reloads an --x86 module, checked by running it 2026-09-13 22:40:52 +07:00
69ea66deca An x86 redefinition emitter, and GOT addressing for the host's symbols 2026-09-13 22:38:48 +07:00
e725a5aab4 Merge branch 'worktree-agent-af9064091c0602dc8' into dev-loop 2026-09-13 21:24:12 +07:00
81f7e464ec The indirection cell on the x86 backend, and --x86 --dev with it
FnAddr (Fnval n) emitted the symbol, which is right for a whole-program build
and wrong the instant anything is redefined into it. It now reads the cell,
and so does every direct call, which is what emit.ml's body_of does and is the
half that matters: a redefinition is one store, and it has to reach call sites
that already exist.

What is emitted, all of it behind dev:

  - one cell per function in .data, .globl, initialised to the body this build
    compiled. Spelled exactly as Emit.cellname spells it, because the point of
    having one here is that an LLVM-built module binds
    @"flan.cell.<n>" = external global ptr against it. nm -D over the two
    builds of the same program gives identical sets of 68 cell symbols.
  - the cell load placed after the arguments, which emit.ml has as a
    load-bearing comment: a redefinition landing between two calls must not
    land in the middle of one. CallPtr stays the other way round.
  - the flan_dev_reg_enable constructor, which arms the allocation registry.

Not emitted: Emit.cellptr, the deeper spelling for a name the host was never
built with. It cannot arise in a whole-program build and belongs with the
redefinition module that would introduce one.

The --x86 --dev refusal is relaxed, and the argument is that flan dev never
reaches this fork: --x86 is read only by flan build, and the daemon builds
host and modules through Build.executable / Build.shared without it. So the
flag means a host whose call sites are redefinable, and nothing claims the
module that would redefine through them exists.

Two things were needed to believe any of that. First, the corpus with --dev on
both sides: 97 MATCH, 0 DIFFER, same as without it. Before the constructor was
added that read 96/1 — registry.flan asks (live? ...) and got four zeroes,
which is the whole of what a dev host does differently besides the cells.

Second, and the corpus cannot do this one: a dev build starts with every cell
pointing at the body this build compiled, so it prints what a release build
prints whether anything reads the cell or not. spike/x86/cells.sh preloads a
shared object whose constructor dlsyms flan.cell.twice and stores a different
body there -- the one store a redefinition ends in, done from outside, no
compiler involved. Both dev builds then print the new answer for a direct call
and for a function value, and both release builds are unchanged, which is what
says the change came from the indirection and not from symbol interposition.

One thing the later lane inherits, now written in both headers rather than
left to be discovered. x86.ml licenses its own calling convention on the
grounds that a dev build is compiled entirely here and a release build
entirely by LLVM, so the two never meet in one process. A cell an LLVM-built
module can store into is the first thing that could make that false: the
conventions agree on scalars and disagree on every aggregate, so an
Emit.redefinition module dlopened into an --x86 host would be right until the
first redefined function took or returned a struct. The answer is a
redefinition emitter here, not a classifier.
2026-09-13 21:17:39 +07:00
9534d5c077 The printed form round-trips, and the macro is named before the walk replaces it
Two things the first pass left on unspecified ground.

Form.to_source is the only field in the protocol carrying arbitrary literal
data -- a macro may build any literal at all -- and Wire.quote escapes only
the quote and the backslash, on the stated ground that both readers take
everything else as itself. test_repl checks that ground now: every escape the
reader knows, both byte-literal spellings to_string would have written raw,
and a whole float, each sent through the printer and the socket and back.

And expand_all read the macro's name out of a tuple beside the walk that
replaces it. OCaml does not promise which half runs first; the failure would
have been the wrong macro named, never an error.
2026-09-13 21:09:50 +07:00
3dd9f61b7d A macro call says what it expands to, and a Form learns to print itself
C-c C-m. One step on the bare key, the fixpoint under C-u: a macro may
quasiquote a call to another macro, and Loc.from_macro is outermost-wins, so
by the time a full expansion settles the intermediate name is gone. One step
is the only thing that can say which macro produced what.

The expansion runs against the macros the *session* holds -- the prelude's,
its imports', and every defmacro evaluated since it started -- and writes
nothing back: a defmacro handed to C-c C-m does not join the session by having
been looked at.

Both non-termination refusals stay refusals, and only where they are needed.
One step makes one call and does not look at the answer, so (s/spin) one-
stepped answers with itself; all the way hits the fuel and names the macro,
inside Dev.serve's guard, so the daemon replies rather than hanging. Macro's
module handling is a Fun.protect now -- a build that raised was a process
about to exit, and the daemon is not that process.

No printer for a Form existed. Form.to_string is an error-message renderer and
is what Macro.key digests, so it is untouched; Form.to_source round-trips
floats, strings and bytes through the reader, and Form.pretty decides where
the line breaks go and leaves the columns to flan-mode.

The answer is a read-only flan-mode buffer shaped like the disassembly one,
with cnr's idea in it: m expands the form at point one more step in place.
Three inherited keys refuse by name -- an expansion is in no file. The text is
sent padded onto its own line and its own column, unlike C-x C-e, so the
refusal lands on the call and not at the start of its line.
2026-09-13 21:06:58 +07:00