21 Commits

Author SHA1 Message Date
122e17bd06 The inspector gets an address root, and the daemon a verb for it
`i' in the break buffer sent a local's *name* to be evaluated, and an
expression is evaluated where the evaluator stands. On the innermost frame
that is the right frame; on any other it may resolve to a global, to another
binding of the same name, or to nothing, with the listing above it showing the
frame's own storage and nothing saying the two disagree.

The shadow stack is what makes the second rooting mode cheap: a frame's
address and every slot's type are both here, so a step into a field is an
address plus an offset with that field's type — the arithmetic
`Render.render' already does for the listing. `Session.render_slot' is
`render_locals' with a path applied to the root and one line out.

The slot travels by *index*, because a name is not unique: two `v's is two
slots and both are in the listing, and a refused slot is not, so the position
in the list is not an identifier either. So `locals' now puts the index on
each line.

The frame checks are `locals'' by construction — `stopped_frame' is one
function now, and an inspector with its own copy would be free to read a frame
whose body was redefined since it was entered. The build-and-read tail is one
function too, for the reason this file already records about the fingerprint.

And `layout' said union values were milestone 6, which they have not been
since today.
2026-09-12 20:21:14 +07:00
03f7609201 The structural printer reads only the case in hand
print, the REPL inspector and the break buffer's locals all walk a concrete
type through render.ml, and a union fell through its Named arm to <Shape>.
It now recovers the case from the tag by a chain of comparisons -- the same
shape the enum arm already had, and for the same reason: the name is erased
before any backend sees it -- and reads the fields of that case only. Reading
the others would be reading a payload that is not there.

It prints (Shape.Dot {.x 1.5 .y -2.5}), which is what the source would write.

The union table has to reach the walk, so Render.ctx grew a field and its
three construction sites in session.ml and one in check.ml pass it. That is
the whole of the session.ml change.

test/programs/unions.flan is the program: a case with no fields, a case wider
than another, a case holding a string, a union in a struct, a union through a
call in both directions, ZII, reassignment, and printing. Its layout was
checked against clang's for the same declaration -- 32 bytes aligned 8 with
the payload at offset 8, and 40/8 for the struct holding it.
2026-09-12 16:53:26 +07:00
635d12782d The globals a stopped stack reaches, in one section and not under a frame
A global is program state a frame happened to touch, not part of it, so
nesting it under one implies an ownership that is not there and repeats the
name once per frame that reads it. One section instead, holding the union of
the globals every frame on the stack references — the compiler does the
choosing, since Reach.expr_refs already answers a body's reference set, and
listing every global a program has would bury the one that matters under the
prelude's PRNG state.

Each entry says which frames touch it, by the index the stack section already
numbers them with, which recovers what per-frame nesting would have told you
at no cost in duplication. Ordered by the innermost frame that touches it:
a deep stack makes the union large and proximity to the error is what puts
the likely culprit on top.

Simpler than locals, because a global is reached by name rather than by
address. Emit.redefinition writes a global the host has as external, so the
thunk binds to the program's own storage and nothing is asked of the stopped
thread — no dev-slot round trip and no not-yet-bound case to refuse.

A frame that cannot be attributed contributes nothing and is named in
:skipped; the union being incomplete and the union being complete are
different answers. The hole in that is stated rather than papered over:
slot_fingerprint hashes a body's slots, which is the right cut for locals and
not for this, so a body that names different globals while binding the same
locals is not caught. The test drives the case that is.

MANUAL.md also loses a stale paragraph claiming the fingerprint check never
fires with a failing test pinned to it. It fires, and test_dev covers it.
2026-09-12 16:22:58 +07:00
0ff4ce56a5 The locals of a stopped frame, read where they live
The half the shadow stack was built for. A slot's entry in the frame is its
address, null until the binding that fills it has run, so "not bound yet at
this point" is a null and needs no liveness analysis. The daemon compiles a
thunk that renders the types it already knows -- Tast.fn.slots, with snames
beside them -- at the addresses the stopped program supplies, and reads the
text back the way C-x C-e does. Nothing is copied out, because a value with
no header is bytes with no meaning anywhere but in the program that holds
it.

That is render.ml's walk with its root changed, which is the pointer-rooted
thunk NEXT.md said this needed, and one new arm in the backend: a cast from
one pointer type to another, which emits nothing.

Only named slots are recorded. A recorded slot escapes and stops being
promotable, and the slots that would cost most are the ones with nothing to
show -- dotimes' bound, the temporaries min and max use, the walk's own
scratch. They are refused by name rather than shown under an invented one.
Recording every slot was built and timed and is inside the noise, so the
rule stands on what it shows.

Four refusals, each by name and with its reason: a slot nobody named, a
slot the program has not reached, a type the printer has no arm for, and
two whole frames -- an evaluation's thunk, and a frame running a body that
has been redefined since, where every slot index would be a guess.

Measured, minimum of nine runs: +61% on call-heavy code over globals
against +33% for the frames alone, 0.06% of a frame at 60fps.
2026-09-12 12:00:29 +07:00
96ab4c9cf0 Retire the per-type printers, since print says all of it
print-str, print-i64, print-f64, print-bytes, print-line and newline leave
the prelude. print and println are the whole printing surface now, and print
is the better call at every one of the sites that used them: it is the same
structural walk without the newline, so the no-newline case the family was
kept for is covered, and it takes the value as it is. The old print-i64
forced an explicit (i64 x) at every call site, because this language widens
nothing implicitly; that cast is gone from 127 places.

Dropping it moves one answer. hash-grid returns u64, and the cast through
the signed printer showed sand-headless's hash as -2851001042534928384.
print routes a u64 through flan_u64_to_bytes, so it now prints
15595743031174623232 — the same 64 bits, read as the unsigned number they
are. The pinned expectation follows the correction.

test-flan-dev.el and test_session.ml both reached for print-line as "a name
the prelude has"; they reach for rand-seed instead.
2026-09-12 05:32:25 +07:00
4001c3246c Merge branch 'dwarf-names' into dev-loop
A let-bound local is its own name under lldb now, and a redefinition
module carries DWARF when the daemon was asked for it.

Resolved against the println track in session.ml: the thunk keeps the
render walk's appended slots and gains the names beside them, the walk's
own scratch having none to keep.
2026-09-12 05:19:38 +07:00
d0a8339bb5 DWARF in a redefinition, and one flag that means it everywhere
Emit.redefinition has taken ~debug since it was written and was tested
with it; Session.eval never passed it, so every body installed by C-c C-c
lost its debug info in the running process.

Passing it alone would have been half a fix. Build.shared is what forces
-O0, and dev.ml built modules at -O2, so the llvm.dbg.declares would have
been emitted and then deleted by mem2reg: a line table, and no locals.
And a module with DWARF loaded into a host without it lines up against
nothing. So it is one flag — flan dev --debug and flan reload --debug —
and it sets the host build, the module builds and the emitted metadata
together. Off by default: a debug build is an -O0 build, and quietly
making every reloaded body -O0 changes the frame time of the one function
you are iterating on, in the loop whose point is watching that number.

What a dlopen'd module does to a breakpoint, measured against the reload
fixture rather than reasoned about:

  - lldb reads the new module's DWARF on the dlopen and says so: "1
    location added to breakpoint 3".
  - A breakpoint set by NAME gains a second location either way, so
    dlopen was never the difficulty. What the line table buys is that it
    stops with source instead of disassembly.
  - A FILE AND LINE breakpoint on the new body resolves only with it;
    without, it sits at locations = 0 (pending) forever.
  - A FILE AND LINE breakpoint on the HOST's copy stays pinned at
    locations = 1. That is correct, not stale: the old body is still
    mapped and every call site that has not gone through its cell again
    still reaches it.
  - The stack crosses intact — a frame in the reloaded .so and the one
    below it in the host each name their own .flan file.

    (lldb) frame variable
    (long) step = 10
    (long) prior = 11

The transcripts are in flan-dape.el, replacing the note that said the
module carries no DWARF yet.

flan-cnr.el's stack pane was refusing for the wrong reason. DWARF was
never its gap; nothing is attached to the stopped program, and a socket
cannot read another process's frames. Reworded to say that.

Source interleaving in the disassembly buffer is unblocked and not done:
objdump -dS interleaves a --debug module's Flan source correctly, so
Dev.asm_of needs the -S and a parse_listing that tolerates source lines.
2026-09-12 05:14:03 +07:00
e6594fd554 The name the source gave a local, all the way to the debugger
A let-bound local printed as s0 under lldb. Parameters were fine, because
the driver recovered their names from the AST and handed them down in
pnames; everything else was a slot index, since Check knew the name in its
scope list and dropped it at allocation.

Tast.fn now carries snames beside slots, Check fills it in at bind, and
Emit prefers it over pnames. A slot the compiler invented keeps s<index>:
fresh_slot takes the name as an optional argument, so dotimes' hidden
bound and the pair min and max evaluate into say nothing and get None
without any of their call sites changing. Naming those something plausible
would put a variable in the debugger that is not in the file.

Shadowing needed deciding rather than assuming. Every DILocalVariable is
scoped to the subprogram — the typed IR has no block structure to build a
DILexicalBlock from — so two slots called v landed in one flat scope, and
lldb answered p v with the outer one while the body computed with the
inner, which it did not list at all. A debugger confident and wrong is the
one outcome worse than s0, so a repeat of a name already bound in this
function gets a ~2 suffix: ~ is the reader's delimiter and cannot occur in
a source symbol, so v~2 is unambiguous and visibly the compiler's. It is a
way of not lying, not a way of being right; scoping properly means a
lexical block per Let and the declares moved out of the entry block.

  (lldb) breakpoint set --file debug.flan --line 20
  (lldb) frame variable
  (Cell *) c = 0x00007fffffffd970
  (int) n = 41
  (int) bump = 42

The test breaks after the binding on purpose. A name breakpoint stops on
the function's first line, before the let has stored anything, and a
variable is nominally in scope from entry — so the name is checked there
and the value only where it means something.
2026-09-12 05:05:40 +07:00
93231e8c9e println, the structural printer, shared with the REPL
session.ml already had this: a compile-time walk over a Tast type that
emits the calls to print a value of it, handling every concrete type the
language has. It was dev-build-only and went to flan_dev_emit, and
prelude.ml justified the per-type print-* functions by saying a real
println had to wait for milestone 5 and generics. It did not. plan.org
specifies println as compiler-provided and per concrete type, which is
not overloading: there is nothing to dispatch on at run time and no
user-supplied printer to choose between, so no type variables appear.

The walk moves to render.ml, parameterised on an emitter and a slot
allocator. The emitter is five functions rather than five extern names
because the two sides are not both extern calls -- the REPL's are, and
stdout's compose a conversion with a write. The slot allocator differs
too: the REPL builds a thunk's frame, println takes slots from the
enclosing function being checked, once per call site.

Two runtime shims, both only reachable from the walk. flan_u64_to_bytes,
because routing u64 through the signed printer makes 0xFFFF...F read as
-1, which is the one way println could disagree with the REPL about a
value both can hold. flan_escape_bytes, so a string nested in a printed
structure is quoted and escaped -- same table as flan_dev_emit_str, noted
in both, because the REPL and println must not disagree about what a
struct looks like.

A string at top level prints raw and nested prints quoted. Not a conflict:
(println "hello") has to print hello, and a struct's string field has to
be distinguishable from the punctuation around it. The split is top-level
vs nested, so it lives in check.ml and not in the walk.

Found on the way: a field of an Option had no gep in emit.ml, so the
walk's Option arm had never run -- the REPL would have failed on one too.
Option is { i8, T } with no declared name, so its layout is now spelled
out. Nothing in the surface language reaches a field of an Option; the
printer does, to read the tag without unwrapping a None.

The print-* functions stay. They print without a newline, which println
cannot express -- slices.flan's show prints elements separated by spaces
-- and they are raw where print is structural.

println.flan covers every arm at -O0 and -O2: the u64, the raw/quoted
split, both Option arms, the depth and span caps, and the slice arm's
loop twice over plus once inside a dotimes, which is where per-call-site
slot allocation would show if it were per-iteration.
2026-09-12 04:55:42 +07:00
fcebe03576 A file that is a package is still a package to the editor
package_of matched the file being edited against a package's directory, which
is right for every package that is one — and answers "not a package" for one
that is a single file, because the file's directory is not the file. A form
typed into sand.flan with the headless driver running would have spliced as a
bare step, the evaluation would have said ok, and the program would have gone
on calling the step it already had. That is the exact silent failure the
function exists to prevent, so it now matches the file too.
2026-09-11 20:15:31 +07:00
f78c935b95 Say what a package is now, since the answer changed three times
NEXT.md described a packaging system with no visibility, no nesting and a link
that ignored the program, and explained sand's two files by it. All four are
now wrong. The Packages section says what the rules are; a new section says how
the link is decided and why the pruning has to take the functions as well as
the flags; and the sand section keeps the part that still stands — the headless
test needs no window on any target, which is a reason for two entry points and
never was a reason for two files.

The comments in load.ml and session.ml that used sim.flan to explain package
qualification now use vendor/agent, which is the package left with a defn in
it.
2026-09-11 20:12:34 +07:00
b34d0d7d59 Say that refusing a signature change is a stopgap
plan.org's hot reload section now says a signature-changing redefinition should
make a new internal function version with its own trampoline: new code resolves
the name to it, existing callers and stored Fn values keep the old one safely,
and the session warns at every tracked caller site still on the old signature.
session.ml refuses the change outright, with a reason that reads like the final
answer.

None of the three parts exists - no function versions, no trampolines, and no
record of which source location called what - so the refusal stays, because the
alternative to refusing is not the new design, it is a silent argument
mismatch. What changes here is only that the code and NEXT.md now say which one
it is, so the next person reads it as the stopgap it is.
2026-09-11 12:26:28 +07:00
2fadf82e23 A redefinition carries its own handler clauses
The reload path had never seen a restart-case or a handler-bind: the
acceptance table's dev build proves whole-program codegen with cells, but not
Emit.redefinition, where the callees are declares or cell loads and the restart
frame is an alloca in a module the process was not built with. Driving it found
a hole step 1 left - a lifted clause was numbered by its position in the whole
program's lifted list, so the name was neither stable against an unrelated
handler-bind being added nor attributable to the function it came out of, and
redefining a function that established a handler died in llc with an undefined
value.

A clause is now named after its parent - handler/step/0/Missing - and carries
Tast.fn.fparent, which is what lets a redefinition module emit the clauses
belonging to the bodies it is replacing and nothing else. They are hidden for
the same reason a redefined body is: taking the address of an interposable
symbol would resolve to the host's copy, so the module would install the very
handler it was replacing. A clause is reached by address from its parent and
from nowhere else, so it is kept out of the cell and registry machinery
entirely rather than given a slot nobody uses.

test_dev.ml now sends a third evaluation: step redefined to a restart-case
whose frame is an alloca in the new module, whose guarded call goes through the
host's cell, and whose transfer starts in a handler and crosses probe, which
the host was compiled with. The transcript's fourth line is the clause's value.
2026-09-11 08:17:07 +07:00
7faab27ea2 restart-case and invoke-restart, which are the transfer
spec-conditions.md §3 to §6. A handler runs where the signal was, decides, and
control resumes at a restart-case further out - so unlike step 1 this one does
alter control flow, and it is lowered explicitly rather than through platform
unwinding, because wasm32 cannot unwind and because a cmp/jne after a call
reads like ordinary code.

The channel is the out-parameter §6 settled on: one ptr appended to every Flan
signature, written by an invoke-restart and checked after every call. The
return type stays what the source says, one pointer threads down the whole
chain, and a frame that sees the channel set just returns early - which reuses
the existing return path and with it §5's defers for free. Emit.signature was
already the one place a signature is spelled, which is what made that part
small.

Every function is transfer-transparent, release included. §6's escape analysis
is an optimisation; in a dev build a cell can hold anything, so the honest
answer to what a call can reach is anything, and uniform means redefinition
acquires no new refusal class.

The transfer target is the restart frame's own address and not a static clause
id, which corrects what the handoff note had settled. An id has to be unique
against every module a running program may later load, and a hash is only
probably unique - two restart-cases colliding means the inner one silently
catches a transfer aimed at the outer. The frame is an alloca in the function
that offers it, so the address is exact and it also says which clause, which is
how clause ids disappeared. Re-entering a restart-case then needs nothing
extra, since each activation allocates its own frames.

Cleanup is landing blocks, one per region rather than one per function: a
restart-case's pops its frames and either dispatches or forwards, a
handler-bind's pops the handler frames on the way past, and the function's own
runs its defers and returns. One function-wide block would have jumped straight
past the very restart-case that was meant to catch the transfer. The channel is
cleared before any cleanup runs and put back after, or a defer's first call
would branch straight back into the block it came from.

flan_signal takes the channel and passes it to each handler, stopping once one
writes to it. That makes the one C frame every handler is reached through
transparent to a transfer, which it has to be; it is also the only one, since
extern is Flan-to-C only and there are no function values yet.

Refused by name with the reason, each with a test on the reason: restarts with
parameters, return inside a restart-case body, one restart-case offering a name
twice, and invoke-restart inside a defer - a defer is the cleanup a transfer
already runs, so starting one there leaves the defers half run with two targets
and no way to choose. The lexical case is the checker's and the one that
reaches a function through a call is trapped at run time. No restart of that
name is a located runtime error at the invoke site, because there is nowhere to
resume.

Two things found on the way. `{ ctx with in_handler = true }` was a latent bug:
ctx.slots is mutable, so a copy allocated the body's slots into a record the
function never saw again - harmless only because no handler-bind body in the
tests had a let in it. And test/reload_host.c calls flan.outer through an asm
label, which does not fail at link time when the prototype is a parameter
short; it reads garbage as the channel and dies somewhere else.

test/programs/restarts.flan runs at -O2, at -O0 and as a dev build. -O0 is not
redundant: the guard after every call is control flow the optimiser would
otherwise launder, and the dev build is where each of those calls goes through
a cell.
2026-09-11 08:14:03 +07:00
5993875539 A prompt on the running program
flan-repl.el is a comint buffer whose every line goes through the same
eval-expr request C-x C-e uses - no new protocol, no compiler support. Deriving
from comint rather than hand-rolling a prompt is the same call as deriving
flan-mode from lisp-mode: history, the input ring and kill/yank already exist
and are not worth rewriting. There is no subprocess behind it; the "process" is
a stub comint needs in order to have a prompt.

It is program-scoped: a name typed at the prompt resolves against the running
program's top-level namespace, so in sand you write sim/settle. A buffer
visiting a package's file gets the alias applied for it because the file says
which package it belongs to, and a prompt has no file to derive one from. RET
on a half-typed form opens a line instead of sending it, with balance checked
through the Flan syntax table so a paren inside a string does not count.

A value and the program's output are different things and arrive by different
routes: the value is the result of the request and appears at the prompt, while
anything printed rides along on the same reply into *flan-output*. Showing them
in one place would be convenient and wrong, so there is a test for the
separation - and it caught a real bug. The renderer's Unit case emitted () with
no evaluation at all, so (print-line "x"), the most ordinary thing anyone types
at a prompt, answered while nothing happened. A Unit expression is almost
always a call made for its effect; it is evaluated and then reported.
2026-09-11 07:21:37 +07:00
20fedd4ad8 Printers for every shape a value can have
C-x C-e rendered the scalars and refused the rest, which made it a calculator
rather than a REPL. The renderer is now a compile-time walk over the type,
emitting a piece at a time: structs, nested structs, fixed arrays, slices,
options, enums by name, and pointers as their shape. A raylib Color comes back
through the FFI as (rl/Color {:r 17 :g 34 :b 51 :a 68}).

Piecewise emission is what makes composites possible at all - a struct is its
fields with punctuation between them, and concatenating that in generated IR
would need an allocator the language does not have.

u64 now renders, in C, with %llu. It used to refuse because i64->bytes is
signed and it would otherwise come back as -1, but refusing a whole struct
because one field is a u64 is much worse than adding a runtime entry point.
Strings are quoted and escaped in C for the same reason: unescaped content does
not round-trip and reads as a framing bug rather than as the value it is.

An enum renders as :name, recovered from the checker's table as a chain of
comparisons, since members are erased to i32 before the backend sees them; a
value outside the declared members falls through to its number, which is what
you would want to see. A pointer is rendered and never followed - it is the
only thing that could make the walk cycle, and dereferencing one a REPL was
handed is not a safe thing to do on someone's behalf.

Three bounds, easy to conflate. depth and span bound the walk, so sand's
[100 [100 u32]] grid does not unroll into ten thousand render sites. The output
is bounded once in the runtime, since a slice renders through a loop the
compiler cannot bound, and one place enforcing it means no renderer carries a
budget.

emit.ml's cast now treats an enum as the i32 it is. Nothing in the surface
language produces that - a keyword resolves against its enum and never widens -
but the renderer needs an enum's number when it falls outside the members.
2026-09-11 07:18:07 +07:00
335e817676 Two kinds of defconst, and only one of them is unreloadable
Refusing every defconst was right about the class and wrong about most of the
instances. A constant the checker consumed - (defconst rows (/ h c)), which
decides grid's type before anything else resolves - is in the shape of the
program and no store can reach it. A constant that is only ever read at run
time is just bytes in memory. sand's colors is the second kind, and tuning a
colour table live is exactly the thing you would want a dev loop for.

So a dev build emits every defconst as a mutable global rather than a constant.
LLVM can then no longer fold a read of it and a module can store into it, and a
changed one is published at the frame boundary the same way a new function body
is. Release builds emit constant and get all the folding back.

Tast.global.gfolded records which kind it is, because nothing downstream of the
checker can tell: env.consts holds exactly the constants the folding pass
consumed, and membership is the question "is this value in the program's
shape?". The session keys its refusal on that, with a message that says what
the constant is used for rather than just that it changed.

Verified against a running sand: sim/colors is accepted, sim/rows is refused
and says why.
2026-09-11 07:03:08 +07:00
7ce1d09900 C-x C-e: an expression, evaluated inside the running program
A different primitive from redefining a name. There is no name to install a
body into, so the expression is wrapped in a function with nowhere to be called
from; the module exports flan_reload_call to say "run this once", and the agent
calls it after the install - on the game thread, at a frame boundary, so an
expression that reads the program's state sees a point the program agrees is
consistent.

Nothing is marshalled back because nothing could be. A Flan value carries no
header, so no code at run time can say what it is; the compiler knows the type
and renders it there, in the thunk. That is the layout decision's bill, and it
is why the printer set is the scalars rather than everything.

The rendering does not go through stdout. Stdout belongs to the program, it is
in the hot path for anything that prints, and a dev-only feature must not put a
branch in it - so flan_rt.c is untouched and the value goes to flan_dev_result,
read back over the agent's socket. Safe without a handshake because the
generation counter is bumped last: the daemon waits for it to move rather than
assuming the program has reached a frame boundary.

u64 refuses by name, because i64->bytes is signed and anything past 2^63 would
come back negative. Everything without a derived printer refuses the same way.
A number that is quietly wrong is the failure this whole thing exists to
prevent.

An evaluation is not a declaration: the thunk is built against the program and
never spliced into it, so describe does not fill up with an eval/N for every
expression ever typed.

The test that matters is the same expression twice. The fixture increments
ticks every frame, so two evaluations must disagree - a value computed in the
compiler, or read from a copy of the program's state, would not.
2026-09-11 07:00:58 +07:00
d9711f82bc A form from a package file means what the import made it mean
C-c C-c on settle inside sand-sim/sim.flan declared settle, but the running
program only ever knew it as sim/settle. The form spliced as a brand-new
unrelated name, the evaluation answered ok, and nothing changed. Sand's
simulation lives in a package, so the one thing worth tuning live was the one
thing that silently did nothing - and reported success while doing it.

Load now records what alias each package directory was imported under and what
names it owns, because a file on disk does not say what it is called from
outside; the importer chooses that. A session looks the editing file's
directory up in that table and qualifies the incoming forms through Load's own
qualify_decl, so a redefined settle lands on sim/settle and its call to
move-grain lands on sim/move-grain, by the same rule the import used. A name
the package does not own - the prelude's - is left alone.

Derived from the path rather than sent by the editor, which is where this
departs from CIDER's ns key: a Clojure namespace is declared in the file, but a
Flan alias is not written anywhere the editor can see it. One directory
imported under two aliases is refused with the reason instead of resolved to
either.
2026-09-10 22:50:50 +07:00
52d3898116 Four ways C-c C-c could lie, found by trying a defconst
Asked whether a defconst could be redefined, probed it, and got ":status ok"
for a change that did nothing at all - the module was built, delivered,
installed, and the program went on using the old value. That is the
silent-wrongness class the house rule exists to prevent, so it is now four
refusals and a fix.

A defconst's value is folded into its call sites - into an array length at
worst, which is decided before any type resolves - so it lives in the running
program's code and not only in its storage. Refused. A defenum member is the
same thing: :space is erased to an i32 literal in the caller. Refused, and
compared over declarations rather than over Tast.program, which carries no
enums at all for exactly that reason.

A defvar's initial value is deliberately not refused. Its storage holds live
state the program moved past long ago, and refusing to change the initialiser
would be refusing "edit the code, keep the sand". Same Tast.global record as a
defconst, opposite answers, told apart by gconst.

The value comparison is structural and conservative - anything it does not
recognise counts as changed. Comparing emitted text would be wrong, since
Emit.const on a string allocates a name off a per-module counter and two
different strings in two throwaway modules both come out as @".str.0".

Third: a new global's declared initial value was being dropped. flan_dev_global
callocs, so (defvar n i64 42) added at run time was silently zero. It now takes
the initial value as a blob, copies it on the allocation and ignores it
afterwards - the second half being where "a reload must not reset the program's
state" lives. In the allocation path rather than a branch at the call site, so
it cannot be got wrong at one of them.

Fourth: a change with no body to publish and no storage to allocate now answers
"nothing to install" instead of shipping an empty module. That is what the
defconst probe actually did, and it cost the program a frame's worth of reload
it did not need.
2026-09-10 22:36:55 +07:00
a420bb1b1d The session: a program as a live thing
lib/session.ml holds the declarations a running process was built from plus
every change accepted since, which is what an editor needs and what a one-shot
compiler cannot have.

Transactionality came for free. Check.program builds a fresh environment from a
declaration list on every call, so a form that fails to check mutates nothing
and the accumulated list is simply not replaced - no scratch-environment
machinery, which is what I was about to build. Re-checking the whole program
each evaluation costs the frontend, under 10ms, less than the llc after it.
There is a test for the case that matters: a typo, then a good form, in the
same session.

Which names the process was built with comes from the checked program, not from
any accumulated AST, because Check.program prepends the prelude and no AST
contains it. Derive it from declarations and print-line reads as new, gets a
registry cell nobody publishes, and the first call jumps to null.

Three changes are refused with a reason rather than loaded. A function's
signature, because a cell is a bare ptr and every call site compiled before the
change still passes the old arguments through it. A global's type, because the
storage exists and has a shape - reusing it reads at the wrong offsets, and
replacing it discards the state the reload exists to preserve. A struct's
fields, because the values the process is holding have the old layout. Note
what the checker already catches on its own: change a parameter type and the
caller fails to type check first, loudly. These rules only get a turn on a
change the checker accepts, which is a name nothing else in the program uses -
exactly where the silent version lives. Hence an unused defvar and a C-called
defn in the fixtures.

The accumulated list is the post-Load one, so an evaluated import is spliced as
its expansion. Otherwise re-evaluating a file that imports something appends a
second import, Load expands it again, and the duplicate-name pass rejects it.
C-c C-k on sand.flan's own text is the test.

flan reload now takes a program and a file of changed forms rather than a list
of function names and a --new list: the session works out which names are new,
which is the thing a bare CLI could not.

Also fixed, found by running the agent test under load: the agent took SIGPIPE
when a sender read part of a reply and closed. Replies go out with
MSG_NOSIGNAL, per call rather than by installing a handler, because the signal
disposition belongs to the program the agent is embedded in.
2026-09-10 21:48:45 +07:00