146 Commits

Author SHA1 Message Date
cb83014715 (clone xs) copies any slice's elements into a block from the context allocator or a named one, through the lowering (bytes s) uses 2026-09-25 11:40:42 +07:00
c6f0dd5450 A dev build fills a Vec's old buffer with 0xDEADBEEF when a push moves it, so a stale slice reads a value nobody wrote 2026-09-25 11:36:59 +07:00
fe20f68ada The runtime's races are driven by tests, a destroyed arena's record is reused, and a dyn index names its site 2026-09-25 10:51:08 +07:00
cc39ff2a01 A destroyed arena's allocator is reused by the next arena-new with its epoch kept, a stale dyn view names its site, and the spec fixes the Vec at five words in every build 2026-09-25 10:48:30 +07:00
0beafedd14 Dead code is gone, and the daemon says what to fix when a session cannot start or reach its program 2026-09-25 10:45:17 +07:00
5581bafacb A dyn at, set-at or push that traps names the file, line and column it was written at 2026-09-25 10:25:28 +07:00
cf04bfc8fd A u64 converts to and from a float on x86 as it does under LLVM, across the top half of its range 2026-09-25 10:21:23 +07:00
4d10f3c327 An allocator outlives the arena arena-destroy hands back, so a container made from it traps on a read of live memory 2026-09-25 10:18:31 +07:00
ddf7ca4974 The flan_dyn.h ABI has one implementation, flan_dyn.c 2026-09-25 10:16:02 +07:00
c2f26a9909 The result read's losing side of the seqlock is driven by a hook inside the window between the copy and the check 2026-09-25 10:12:39 +07:00
00f116ea29 Six small refusals and conversions say what the program wrote 2026-09-25 08:41:31 +07:00
6f0f4957f4 The break buffer, the inspector and the watch table show what a stopped program holds 2026-09-25 08:37:15 +07:00
ff744120fc A release build's watch is only its value, next-error lets go of a stack that has resumed, and the frame a stop is in is always shown 2026-09-25 08:36:53 +07:00
cbd910c117 An arena's budget holds when a block grows in place, and a shrink is never over budget 2026-09-25 07:36:52 +07:00
85b9f35a66 (watch "name" v) renders any value into the watch table the way print renders it 2026-09-25 07:22:59 +07:00
aa5052e401 A dyn value an expression answers is the reply's value, not the program's output 2026-09-25 07:14:13 +07:00
8b354d4bc8 The notes say what is built: no pool, frames on x86, implicit widening, and the budget in the spec 2026-09-25 07:05:55 +07:00
3a3674efb7 A gensym is never the same name twice in one compiler process, however many macro modules it loads 2026-09-25 07:04:10 +07:00
6b9d1fa644 A cast of NaN or an infinity to an integer says so by name, on both backends 2026-09-25 06:58:06 +07:00
57fe91f303 Five records become one, and every citation lands somewhere
FIX.org, NEXT.md, DISCUSS.org, docs/DISCUSS.md and the session handoff at the
root are one TODO.org now: 293 entries under seven subsystem headings, each
carrying an org keyword that says where it stands. A DONE entry is a few lines
saying what was decided and what that rules out; the reasoning that would not
compress — the embedding spike and the four reports the hand-written x86
backend was built from — moved into docs/BUILT.md instead, and its entries
point there in one line.

Every entry was checked against the tree before it got a keyword, and the
prose was wrong in both directions. Things the deleted files called open were
built: the first-evaluation stall, main being redefinable, macro parameter
lists, the type-limit constants, the array constructors, the byte fills,
inc/dec, the discard's fontification, the Emacs buffers, rt_die's _exit, the
backtrace surface, and the acceptance failure that could print and still exit
zero. Things they called done were not: the backend reports' no-plan buckets
had gone stale in the other direction, the value-dependent defvar was
superseded rather than built, and macro-expansion source locations are on an
unmerged lane, so that entry is NEXT and names the branch.

Every comment that cited one of the five by name now cites a heading that
exists, in TODO.org or in docs/BUILT.md. The session reports under
docs/handoffs/ keep naming the files they worked on, because rewriting them
would falsify what those sessions did; each carries a note saying where the
content went.
2026-09-21 21:05:48 +07:00
2572f0a537 An fn sees the locals it was written among, and Fn says so in its type
spec-memory.md's case 2, capture by value into a stack environment, and
the calling convention the author's rulings asked for.

    (Fn  [i32] i32)   captures; {code, env}; the common case
    (CFn [i32] i32)   the bare address; one word; cannot capture

A local of the enclosing function that an fn names is copied into a
struct the checker synthesises, held in a slot of that function's frame,
and the value carries its address; the lifted body reads the copies back
into named slots of its own, once, at entry.  So the name in the body
means what the local held at the instant the value was made --
fn-capture.flan changes the local through a pointer after the value
exists and the fn still answers with the old one.

Two types rather than a uniform environment parameter: "while it's dyn
first, static side should never have to pay the price for the existence
of the dyn side... if you fully opt out, for instance, using --no-gc
flag, then we should be operating under Odin/C semantics and never paying
any runtime costs."  The environment is declared by exactly the bodies an
(Fn ...) value can reach -- a lifted literal in an Fn position, every
handler clause, and the widening thunks -- and by nothing else.  An
ordinary defn emits the signature it always did; calc-me and fourteen
corpus programs were diffed to say so.

CFn, because the C carries information: a value with no environment is
the only kind that could ever cross to C, and under the --no-conditions
direction FIX.org records it becomes literally a C function pointer.  It
is not that today -- a declare cannot take a function type at all -- and
crossable's refusal says so where a reader would otherwise be misled.
Nobody needs CFn: Fn accepts everything, and the commonest reason to
reach for the narrow one is that a *named* function handed to an Fn pays
a hop through the widening thunk where a CFn is a direct call.

That thunk is one small function per distinct signature widened, which
reads the bare address back out of the environment and calls it.  The
cheaper trick -- the environment last, ignored by a body that never
declared it -- is legal under SysV and is a trap under wasm32's
call_indirect, which compares the signature at the call.  Every indirect
call is exactly typed now.

A handler clause captures the same way and is sound with nothing left
over: its frame is popped by the body that pushed it.  What is refused
there is a *store* into a captured name -- it is a copy, and writing to
it would leave the local as it was.

And the other half, which is what "non-escaping" means: a value carrying
an environment may be called, passed down and let-bound, and may not be
returned, stored, pointed at or pushed into a container.  A parameter of
type Fn is treated as one, which answers "passed to something that stores
it" with no interprocedural analysis -- the store is refused inside the
callee.  Everything of type CFn is clean for free, which is the second
thing having two types buys.  Every refusal names case 3, the environment
the collector owns.

Two pre-existing bugs fell out on the way.  A lifted fn asked for Fnval,
so `flan reload' on any function containing an fn literal died at llc
with an undefined cell; it takes Flanfn now, which is the choice a
handler clause always made.  And a redefinition module now carries its
own hidden copy of every thunk it names, which is the same bug shape
caught before it shipped.
2026-09-21 13:41:44 +07:00
54d12811f8 Merge branch 'worktree-agent-afd7ad8d87f71fa16' into dev-loop
# Conflicts:
#	FIX.org
2026-09-21 12:19:15 +07:00
d6fc15474b The count is length, so len is a name a program can have
The author: "I think I prefer length over len, because then I'll use len as
the variable name". One arm in check.ml, one row in the table beside it, and
every (len x) in lib, test, examples, vendor, spike, docs, web, emacs,
plan.org and NEXT.md rewritten.

Shadowing and builtin/ had already taken most of the sting out: a (defn len
...) was legal and won in its own file, and builtin/len reached past it. What
was left is that len was still a builtin — the defn earned a warning, and a
wrapper had to say builtin/ at every inner call. Now there is nothing under
the short name: len is an ordinary identifier in every position, which is
what (let [len (length xs)] ...) wants.

length takes over as shadowing's worked example rather than the feature
losing one. shadow-builtin.flan, builtin-qualified.flan, pkgs/shadowed and the
builtin/ rows in test_flan move to it and go on testing shadowing.

A call to a len nothing defines is answered where an unknown function is,
after every table and after the shadowing guard, so a program with its own len
never reaches it. The sentence is said rather than guessed at — len and length
are three edits apart and the did-you-mean's net is one — and the call is
written back out through spell_arg, as-slice's spelling lifted out of it and
now shared, so what is printed compiles.

sand.flan:33 still calls the old name and is the author's to change; until it
does, test_acceptance and test_session abort there. Both were run green
against a copy with that one line changed. FIX.org says so.
2026-09-21 11:58:56 +07:00
df2f23609b The three the review asked for: the last bare retry, and a bound that was a typical
[flan_dev_reg_at] still went straight round its walk-level retries — a failed
scan_open and a failed scan_ok both back to the top. It is the one site where
the argument for leaving it holds water, since the agent gates the verb behind
a stopped program and there is no writer to lose to. It loses to the other
argument: it is the same mistake the slot read was making, three lines under
the note explaining why it is a mistake, and a bare retry left next to that
note teaches that the rule has exceptions it does not have.

And the cost of a refusal was written as a bound when it is a typical. "One
writer, so at most one slot of a walk is odd" is true at any instant and false
across a walk — flan_reg_compact writes every slot under its own counter, so a
repeatedly preempted writer can charge the full patience against several slots
of one walk, 4096 of them in the arithmetic worst case. Both docs now say
typical and give the worst, and record what the larger figure means for
request_lock, which is held across handle_line: a refusing listing delays an
abort by that much. Nothing depended on the old number.

Also written down, because it is the clearest statement of why this was not a
timeout: sixty-four bare re-reads of one word finish in about two microseconds,
so against a writer a millisecond from running the old budget was not short, it
was zero wall-clock.
2026-09-21 11:52:59 +07:00
8f05ece441 The reader spun where it should have waited, and called a still table unreadable
test_reload's registry-under-a-writer case failed about one run in five on a
loaded machine and never on an idle one. The refusals were always unread=1:
one slot, on the best of eight walks, that would not copy. In that mode the
table cannot even move — re-noting live blocks kills nothing, the dead count
stays at zero, and the compaction trigger never fires — so the reader was
refusing a table that sat perfectly still for it.

flan_reg_snap re-read a slot's counter sixty-four times with nothing between
the tries. That is right for the writer it was written for, which holds the
counter odd for seven stores. It is hopeless against a writer the scheduler
took the core from mid-write, which holds it odd for a quantum: the reader
burns all sixty-four looks inside a fraction of that, and the looking is what
keeps the core the writer needs. Hence load-only.

The walk-level retry already had the answer written up at length — a spin takes
a core from the thread being waited on. The per-slot retry never got it. One
pause helper now, shared by both: eight bare looks for the running writer, then
the same 250us step. A refusal means the table would not hold still.

480 contended runs of regfull after, no refusals; 46 of 240 before.
2026-09-21 11:49:29 +07:00
3672da28be A diagnostic is for someone who has only this compiler, and says what to write 2026-09-21 11:44:59 +07:00
9ce51ba94e One slice over everything with elements, and the warning at the push
as-slice was a warning, not an operation. The input type already decides
which of the two things happens — a Vec can only be borrowed, an array or a
string can only be viewed, and no call site picks between them — so the second
name expressed no choice a reader could make. And it warned at the moment the
view is taken, which is the one moment nothing is wrong; the danger arrives
later, at the push. slice now takes a Vec at all three arities and as-slice
is gone.

(slice v lo) was free, and is the arity the Vec never had: the runtime already
reads a hi of -1 as "to the end", so the tail form passes the caller's lo and
the same -1 — no slot, no length read, no second evaluation. The merge is
entirely in the checker; the Vec path builds the flan_vec_as_slice call it
always built and neither backend has a line about any of it.

A Vec a call returned is refused at every arity, and not for the array's
reason. (slice (mk)) over an array dangles. (slice (make-vec)) does not — the
storage outlives the expression — but the header is a temporary, so nothing
can ever free the block. The refusal says that and names the let.

The name's own refusal sits in ordinary_call after every table, so a program
that defines an as-slice still reaches its own. It reads for somebody who has
never heard of the old name and writes the call back out, spelling each
argument that is a name or a number.

The warning moved to where it bites: BUILT.md gains a section beside the Vec
table and the push row points at it, spec-memory.md's Borrowing says the same.
Investigated and deliberately not built — a diagnostic for a live view at the
push. (reserve v 100) then a slice, a push and a read is correct code under
the contract the spec chose, so any flag on it is a false positive by the
language's own semantics rather than by an approximation. FIX.org has the
finding and the syntactic sketch that does not work.
2026-09-21 09:51:35 +07:00
41334b3a8f Review follow-ups: a trap is not an evaluation, and the way out cannot be truncated away
The break buffer captioned every refused restart "below this evaluation", which
is the wrong sentence at a trap: there is no transfer channel, nothing on the
list can be taken, and there is no evaluation to abandon. The terminal listing
had always said the two apart. The wire now does too — a bare ! line ahead of
the entries, because a trap with no restarts at all still has to be able to say
so, and because :abandon being nil means three different things.

The boundary was also the first entry truncation dropped: snap_push walks
innermost first, and the boundary is the outermost frame of the evaluation. A
slot and a name's worth of bytes are kept back for it.

flan_break_resume goes; nothing has called it since choices became positions,
and NEXT.md already said there was no such function. eval_boundary is cleared
between runs beside the two stacks that already were. And the note on a taken
restart stops costing a second round trip: the agent answers ok abandon for the
boundary, which is the end that knows.

Nested boundaries are tested rather than argued: two evaluations, six restarts,
and abandoning the inner leaves the outer with its own still on offer.
2026-09-21 09:04:35 +07:00
fe6744ae41 An evaluation that signals can be abandoned, and the program keeps running
A C-x C-e that blew up took the session with it. The expression's break offered
either nothing at all — a bad index establishes no restart, and the program's
own are below the thunk boundary where a transfer has nowhere to land — or a
list on which every entry was refused. That left abort, and abort is _exit(134)
over a mistyped index.

So the boundary offers a restart of its own. The agent pushes a real frame
around every evaluation, after the floor is read so that it lands above it;
taking it unwinds to the thunk, flan_reload_call drops the channel it holds,
and the poll returns to the game loop. It abandons and does not undo, which is
said in the agent's line, the daemon's note, the buffer's row and the manual.

The other half was a silence. The break buffer drew every restart as takeable
and ignored the :unreachable the wire already carried, so a digit on one went
out to be refused and nothing came back. Those rows now carry the reason and
are refused where they are read, and :abandon names the position that drops the
evaluation — a position, because a program may establish a restart of that name
itself.

Not the threading, which is what the report suspected. The thunk does run on
the game thread; a thunk on a thread of its own would have had the same empty
list and the same abort.
2026-09-21 09:00:26 +07:00
a64bee6d96 Review follow-ups: a new def's image, the keyword that cannot change, and the sweep
Three defects, all from lifting every def initialiser, none of which the
suite caught:

A def typed fresh into a live session came up zero and stayed zero. The
image flan_dev_global copies on the allocation is the only value a new
global ever gets — the host's .init-globals never calls its initialiser —
and both backends chose that image with Tast.const_init, which a def's
lifted Call fails by construction. Emit.initial_image reads the constant
back out of the lifted body; the x86 twin had the same bug.

Changing a global between def and defonce was silently ineffective: the
guard lives in the startup function compiled into the host, which a reload
cannot republish. Session.compatible refuses both directions and says to
restart; editing the value stays allowed.

And global/<n> no longer leaks into the signature refusal when a def is
retyped — the global loop names the same fact in words a reader can act on.

flan check prints def, defonce or defconst off grerun; (defvar) with no
arguments names the shapes rather than offering (defonce ); the docs,
plan.org, runtime comments and valgrind.supp are swept; BUILT.md states
the release-build cost and the uninit caveat.
2026-09-21 07:19:33 +07:00
d1443808c6 Review follow-ups: the park had the original bug inside it
- SA_NODEFER. sigaction without it blocks the handler's own signal for the
  whole handler, and here the handler is the park — it never returns. A
  hardware SIGSEGV delivered while SIGSEGV is blocked is not handled: the
  kernel forces the default action. Fault, park, eval something at the
  break loop that faults, daemon gone, exactly the author's session one
  level in. Measured both ways; flan_crash_entered is cleared before the
  hook so each break-loop fault still gets its line, and the case is pinned
  (trap_park ~refault:true), confirmed to fail without the flag.
- Scope the handler to the thread it was armed on. A disposition is per
  process and a merged dev session is one process, so this was shadowing
  OCaml's SIGSEGV handler — and Stack_overflow — for the daemon's whole
  life. Other threads chain to what was installed before. Arming per run
  would leave the parked prompt's evaluations unprotected, since those are
  program code too; the comment says so. Also makes the per-thread
  sigaltstack honest.
- Sweep dyn-view.flan and string-eq.flan, which dev-loop added after the
  first sweep. string-eq:46 wanted the aliasing outright: its comment is
  about two slices sharing a base pointer.
- A StorageExhausted row for bytes, asserting the retry copies once and
  whole rather than re-evaluating its argument.
- Gate the flan_dev_crash_enable declare to dev builds, so this lane adds
  no dev-only text to a release module. flan_bytes_dup stays ungated: a
  release build really calls it.
- Guard the section for wasm32, which compiles this file and has no
  signals.
2026-09-20 23:35:03 +07:00
2e203f64b8 bytes copies, bytes-view aliases, and a dev-session segfault parks
The INSERTIONSORT crash, all three rulings (FIX.org 2026-09-20):

- (bytes s) allocates a writable copy through the allocator surface —
  context or (bytes s a), StorageExhausted with retry, a registry note in
  dev builds (flan_bytes_dup, lowered like vec-new). (bytes-view s) is the
  old zero-cost reinterpret, renamed, read-only by convention; every
  in-repo reader swept over to it. (string b) unchanged.
- String constants were already read-only on both backends at -O0; now
  pinned — bytes-copy.flan rows on LLVM/-O0/--x86, and dies_segv rows
  asserting the write-through-view trap on both backends.
- A dev build installs a SIGSEGV/SIGBUS handler by the same dev-only
  constructor slot that arms the registry: one line naming the address and
  the innermost frame, then the trap-hook park — stopped, not dead, the
  daemon serving. No agent: message and re-raise. Release builds untouched.
  Pinned by trap_park over dev-segv.flan.
2026-09-20 23:12:42 +07:00
ac440d44b0 Say why the byte emitter takes no check_shared 2026-09-20 22:58:54 +07:00
831cab9fb2 Review follow-ups: x86 parity, ArithError, and three refusal bugs
Five fixes off the independent review, plus the author's u8 ruling.

x86 parity: the bad-index block always ran x86 (it is flan dev's
default) and now says so with an explicit --x86; the condition render
gets an assertion under the x86 backend too, beside the LLVM one, and
a user error is pinned as carrying no site on both.

ArithError's layout is now pinned: {i32 op; i64 lhs, rhs} in C against
the prelude's defstruct, read field by field through the break loop's
render, driven from the editor through a divide under a restart-case.
That also covers condition and site on LLVM.

Three refusals that were wrong: trap_site tested the prefix "err"
and so ate any site whose path began with those letters; source_line
let Sys_error from input_line escape and take the whole break reply
with it, leaking the handle; and a condition with no fields was
reported as a name no struct has. The daemon now sends its own field
count and the buffer tells the two empties apart.

Nits taken: an over-long site is dropped rather than silently
truncated into a plausible one; the caret pads with the source line's
own tabs; the headline says when it has cut the field list;
flan-cnr-layout is live again as the single spelling of that request
rather than dead beside an inlined copy.

And the ruling: a u8 renders as 97 (\a) where a person is inspecting
and stays 97 where the program is printing.
2026-09-20 22:55:11 +07:00
1cbe8ed386 The break loop keeps its condition, and the daemon renders it
The break loop used to discard the pointer it was handed, so the buffer
could name a BoundsError's fields and never show 648. Now the snapshot
stashes it, flan_agent_condition hands it back on the stopped thread,
and a daemon-built thunk — locals pointed at the condition — renders
each field. Delivered at-stop, so a resume-and-restop cannot get the
old type read over the new pointer.

The trap sites publish their loc around the hook call, the snapshot
copies it, and break answers :site with the line's text as :source —
the frame lines say where each call was; this is the only record of
the indexing itself.

Compiler temps are hidden from the locals listing rather than refused
as s4; a shadowing rebind strips its ~N except where the outer binding
is on the same list, where both keep their raw spelling.
2026-09-20 22:39:08 +07:00
f8dfdaa9a0 Merge branch 'dev-loop' into lane-implicit-widening
# Conflicts:
#	FIX.org
2026-09-20 20:01:54 +07:00
5cfefa586b The rebase's four repairs, and two pins that were not pinning anything
Rebased onto dev-loop. Three conflicts were additive and both sides are
kept: FIX.org's two appended sections, want_map's diagnostics argument
against the class_sync inserted beside it, and test_dev.ml's agent-socket
block against this lane's migration block, whose comment no longer says
"the block above" now that something sits between.

The fourth is the one the auto-merge hid. flan_dyn_class_def's argument
check was written against the pre-diagnostics trap1 and merged clean into
a tree where trap1 takes a location first, so the class name would have
been read as a length. dune build does not compile flan_dyn.c, so the
green build said nothing; caught by compiling a program.

Two pins in test_session.ml asserted "flan_dyn_class_def" against the IR
text, which every module contains because emit.ml declares every runtime
entry point in all of them. Both now assert the call and the packed slot
list. Checked by mutation: with the thunk suppressed the old needles pass
and the new ones fail, along with the daemon's slot count.

Also disclosed: say_render is the second raw reader beside render, and
neither syncs, so a stale instance shows its old slots in the inspector
until something touches it. That is the editor-facing consequence of
keeping the printers printers, and it is now in the runtime comment and in
FIX.org rather than left to be met. And the stale-caller walk says in as
many words that it is a tripwire, unreachable on purpose, not a filter to
be tidied away.
2026-09-20 19:54:53 +07:00
7f92136401 Old instances of a redefined class now follow the class
CLHS 4.3.6's update protocol, minus the user hook, on the dyn side's
defclass. Redefining a class used to be silent: a class is sugar for a
constructor defn, so the edit replaced a body and the instances already in
the program kept their old keys for ever.

Three pieces. A registry in flan_dyn.c holding each class's current slot
list and a generation, made only of interned kw_entry pointers so the
collector has nothing to trace in it and no root to push for it. A uint32
generation on the instance, fitted into the padding kind and mark leave in
front of len's alignment — sizeof(flan_obj) is 48 with it and was 48
without, and flan_dyn_obj_size is there so a later field that moves it
fails a test. And a registration thunk per reload, run by the agent
through flan_reload_call after the module's bodies are published: it has
to be a thunk, because the case this exists for is a class redefined and
not constructed.

Migration is lazy, at want_map, len's map arm and dyn_equal's. Slots kept
by name, gained slots nil, dropped slots gone, identity preserved, entries
rebuilt in the class's order so a migrated instance is indistinguishable
from a fresh one. Equality migrates both operands first, so it is over the
class as it is now.

The session had to stop refusing the constructor's signature change, and
does so only for a defclass and only when no compiled caller is left
behind. The checker gets there first in practice; the walk in eval holds
the reason locally rather than inheriting it.

The registry is advisory: a class instance is an open map, so a key a raw
put wrote that the class never declared is dropped by the next migration.
FIX.org says that plainly rather than pretending enforcement.
2026-09-20 19:46:57 +07:00
657f640ec7 A reconsidered operand must leave nothing behind, and a literal is never reconsidered 2026-09-20 19:30:04 +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
6d82221978 A dyn trap that says where: the nine trapping operators take a site
The dyn arithmetic and ordering entry points printed their sentence with no
file, no line and no column, which in a dynamic-first language is the type
error arriving from nowhere. flan_rt.c's bounds and arithmetic traps have
taken an emitter-threaded (loc, loclen) pair since they were written, and
[flan_dyn_cast_kind] is the fresh precedent on the dyn side; this is the same
pair, threaded through [arith], [want_nums] and [order] to the five
arithmetic and four ordering entry points. [eq] never traps and takes none.

The three trap printers take the pair and print nothing for a NULL loc, so
every other call site in the file — and test/dyn_ops.c, which calls the
runtime directly and has no source position — keeps its sentence byte for
byte. [trap_oom] is left alone: it is reached from [gc_alloc], which has no
site to be given and would have had to grow one on every allocation path in
the file for no reader's benefit.
2026-09-20 17:36:10 +07:00
5a62770e52 Classes and generic functions, milestone 2's last item
A defclass is a named dyn map with a shape tag, and a generic function
dispatches on it two ways: CLOS's, where the dispatch value is the class
of the first argument, and Clojure's, where a body computes it. They are
one mechanism and not two — a class dispatcher is (class-of arg0) as the
dispatch function, which is what lets a method written for the class
point and one written for the value :point be the same branch.

    (defclass point [x y])
    (point 3 4)                 ; the constructor, positional
    (class-of p)                ; :point, or nil for anything else
    (defgeneric area [self] dyn)
    (defmethod area point [p] (* (get p :x) (get p :y)))
    (defmulti describe [x] dyn (get x :kind))
    (defmethod describe :square [s] ...)
    (defmethod describe :else [s] ...)

A slot is a key in the instance's own map, so get, put and has-key? are
how one is read and written and no operation was added for any of it.
What the class adds is the tag, and the tag lives in the object's header
rather than in a reserved entry — the queue's note said a reserved key
and this departs from it, because a key would be counted by len, walked
by the renderer and compared by equality, so every instance would answer
a length one larger than its slot count and print a key nobody wrote. A
header field cannot be reached by get or put at all, so no user key can
collide with it. It costs nothing: the map arm of flan_obj's union grows
to the size the view arm already had, and sizeof(flan_obj) is unchanged.
It needs no tracing either — the tag is an interned keyword entry, which
is immortal and is not a collector object.

The tag shows up in exactly three places: class-of answers it, equality
compares it (two instances of one class compare by their slots; an
instance and a plain map with the same entries do not, which is
Clojure's answer for a record beside a map), and both renderers print it
— #point{ :x 1 :y 2}, Clojure's own spelling.

None of the four forms reaches the checker. lib/classes.ml turns the
whole declaration list into ordinary defns at the top of build_program,
the way Shim.expand already turns a declare-c into a declare plus a
defn: a class becomes its constructor, a generic becomes one function
whose body binds the dispatch value and compares it down a chain, and a
method becomes a branch of that chain. It is a pass and not a macro
because a macro sees one form and the generic's body is not decidable
until every method is in hand — a method may be written above its
generic, below it, or arrive at a reload an hour later.

That last case is why the method bodies are inlined rather than lifted.
A generic is exactly one top-level name, so adding a method to a running
program is the ordinary redefinition of one function, through the cell
every call site already goes through. session.ml names the generic
alongside the method's own declaration name for that reason. The cost,
recorded rather than hidden: a method is not separately callable and is
not a frame of its own.

A dispatch that finds no method signals NoMethod, a prelude struct
carrying the generic's name and the dispatch value that missed. A
condition and not a trap, because a miss is something a program can be
written to answer, and handler-case around the call is the shape. Its
value field is dyn, the first condition here with one; the per-type
descriptor an item-2 struct carries is what the collector reaches it by.
No restart is established at the miss, which is BoundsError's decision
taken for BoundsError's reason.

Both backends, identically: the two new runtime entry points are
declared in emit.ml and the x86 backend needs nothing, since a dyn call
is a dyn call there. Deferred and written down in FIX.org: inheritance,
multi-argument dispatch, :before/:after/:around, named-slot
construction, unknown-slot checking, and computed dispatch values.
2026-09-20 15:36:13 +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
10610fb76b The corpus program for a cast that opens a box 2026-09-20 13:54:07 +07:00
b6ea14f930 A numeric cast opens a dyn box itself 2026-09-20 13:52:27 +07:00
6461322aa9 The length write-back moves back inside the seqlock odd window
end_value did the release store and left the caller to narrow its length
afterwards, which is a length a reader is entitled to have missed. Split into
truncate_value and close_value so flan_dev_watch_end can store its 32-bit
length between them. Benign today -- only a full slot is truncated and a full
slot length is already WATCH_VAL -- and not a rule anyone would keep.

The two externs flan_dev.c borrows from flan_rt.c now get a value probe, once
per process on the first render, the way flan_vec_layout ties the three
statements of the vec header together: nothing else compares those prototypes
and the link matches names, not types.
2026-09-20 13:00:55 +07:00
9eed4cbd8c The dyn header claimed a guarantee the linker does not give
test/dyn_ops.c includes the header and calls most of what it declares, so a
renamed function fails to link. It does not catch a signature that drifts
while the name stays -- C links on names, not types -- and it does not reach
flan_dyn_map_get, flan_dyn_map_set, flan_dyn_map_contains, flan_dyn_is_nil or
flan_dyn_need_not_nil at all, none of which dyn_ops.c mentions. The comment
now says that, and says which hole is closable from here.
2026-09-20 13:00:55 +07:00
e441d62874 Eight emitters were four renderings, and the escape table was three copies
The dev runtime rendered a value twice over: [flan_dev_emit_{u64,i64,f64,str}]
into the result buffer an evaluation is read back from, and
[flan_dev_watch_emit_*] into the current watch slot, with the same four bodies
either side and the sink as the only difference. [flan_dev_result_end] and
[flan_dev_watch_end] were the same ellipsis-and-generation close, comment for
comment, over two buffers.

So the rendering takes the sink as a parameter and the eight entry points are
eight one-line calls into four statics. The exports stay eight: the compiler
emits four of them by name (Session.externs) and a program reaches the watch
four through declare-c. ABI does not collapse because the bodies did.

The escape table is now [flan_escape_char] in flan_rt.c, once: what one byte
reads as inside a quoted string, into a caller's four bytes. A table and not a
printer, because the framings are genuinely different — [flan_escape_bytes]
builds a capped slice to hand back and the dev pair streams into a buffer it
does not own the end of — and the framing is the part that is each caller's.
The dyn printer keeps its own copy, which is docs/SPIKE-DUPLICITY.md §9's one
defended repeat: it is inside the runtime that owns the storage it walks. Its
comment, and flan_rt.c's, no longer tell a reader to change the other two.

Same for the NaN rule, which was spelled four times: [flan_f64_format] is
flan_rt.c's [flan_f64_to_bytes] without the slice, and the REPL emitter and the
watch table call it rather than restating "%g, and nan unsigned".

Byte-identical, checked two ways. A session driven over the daemon's socket
before and after — every arm of the emit family, the escapes, and a string long
enough to reach the truncating close — diffs empty. And a harness linking both
trees' flan_rt.c + flan_dev.c compares 8173 renderings: every byte 0..255
through both string emitters and through flan_escape_bytes, every length across
both caps and the ellipsis either side of them, both NaN signs, both infinities,
i64 and u64 at their extremes. Identical.

Dead code, each verified by its own grep before removal. These are exported C
symbols, so a program could reach one through declare-c; the evidence is that
nothing in the tree does, including the docs that write the surface down.

  flan_dev_watch_u64 (flan_dev.c) — one occurrence repo-wide, its own
  definition. The i64/f64/str siblings are declare-c'd in
  test/programs/dev-watch.flan and written down in emacs/MANUAL.md; this one
  appears in neither, and in no other file.

  flan_break_resume (flan_rt.c) — the only non-prose reference was a stale
  extern in vendor/agent/flan_agent.c with no call under it. Both gone.
  [flan_name_id] stays: the bounds and arithmetic conditions still hash through
  it. [flan_restart_take]'s comment no longer points at a function that is not
  there.

  flan_dev_watch_enabled (flan_dev.c) — prototyped in flan_agent.c, never
  called. [watch_on] is still read directly by the three sites that gate on it.

  clang_stamp (lib/build.ml) — a [lazy] never forced; one occurrence.

  marshal (lib/expand.ml) — no reference anywhere. [write], which it wrapped,
  is called twice in [call], so the [let rec] group is demoted to keep it.

  is_bytes (lib/js.ml) — dead within js.ml. Nothing else in that file is
  touched: the JS backend is parked, not dead.

Kept on purpose: [Loc.forget_sources], documented in docs/BUILT.md as
deliberately retained, and [flan_dev_watch_num_f64], which emacs/MANUAL.md
declares as public surface.

Two comments in lib/dev.ml argued the orphan grace in terms of elisp symbols
from before the rename — [flan-dev--open], [flan-dev--connection],
[flan-dev-poll-interval], and a file called emacs/flan-dev.el. None of those
exist. Re-spelled as [flan--open], [flan--connection], [flan-poll-interval] and
emacs/flan.el, which is where they are; the reasoning is load-bearing and is
unchanged.

dune test: exit 0. 59 lines of code out, 24 lines net of the prose that says
why.
2026-09-20 11:57:35 +07:00
4f060d07db The park's root reset stops at the globals' watermark
# Conflicts:
#	test/test_dyn.ml
2026-09-20 11:24:46 +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