Two claims corrected against the thing they claimed about. A named object is linked whole -- symbol-driven selection is an archive rule, and dropping unreached code inside an included object needs -ffunction-sections and --gc-sections, which the link line does not pass. nm on any corpus program finds flan_dyn_add and flan_gc_collect in it. So the file said something the build does not do. What makes --no-gc possible is the other half of the same argument and was already written beside it: nothing refers to flan_dyn.c, so not compiling it is a change at three sites and nowhere else. And the boundary. "Typed Flan has no implicit widening" is true of a value and not of a literal: (g 1) against (defn g [x f64] ...) compiles, because the checker gives the literal the type the parameter asks for, while (defn h [y i64] f64 (g y)) is refused. A dyn value written as 1 has been through flan_dyn_from_i64 and cannot remember, so the same source read as dyn traps where read as typed it does not. Stated where the compiler lane will find it, with the two ways to close it, both of them the compiler's.
34 KiB
The dynamic-value runtime: NaN-boxed values over a mark-sweep heap
Milestone 1 of dynamic-by-default. Unannotated code computes with values that carry their type at run
time; fully annotated code compiles to exactly what it compiled to before; and a build that wants
neither a tag nor a collector can be given one that has neither. This document is the runtime half —
runtime/flan_dyn.h, runtime/flan_dyn.c, and the C tests in test/dyn_ops.c driven by
test/test_dyn.ml. The compiler half — deciding which locals are dyn, emitting the root pushes,
enforcing --no-gc — is a parallel lane and is built against the ABI in the header, which is fixed.
Everything here is implemented and green. dune test --force includes test_dyn, which is 29 processes
and 0.42 seconds; dune build @sanitize covers the same C under ASan and UBSan.
1. Value representation: NaN-boxing
flan_dyn is uint64_t, one machine word, so a dyn local is a register or a stack slot and never a
struct whose return convention two backends would have to agree about. That much was fixed by the ABI.
What was open was the tagging scheme, and the two candidates are the usual pair.
Low-bit tagging puts the tag in the bottom three bits of a word and requires every payload to be
either a pointer to something aligned or an integer with three bits to spare. Its cost lands on floats:
an f64 uses all 64 of its bits, so a low-bit scheme either boxes every float on the heap or steals the
mantissa's low bits and computes with a shortened double.
NaN-boxing does the opposite. A double is itself — the bit pattern is returned unchanged — and everything else hides inside the quiet-NaN space, which IEEE 754 leaves as 2^52 unused encodings.
This codebase decides it. f64 is a first-class type here, not a library afterthought: sand.flan runs
a physics loop, calc-me.flan is a float calculator, the prelude carries a hand-written format-f64
because %g was not enough, and flan_rt.c has three paragraphs on how to print a NaN. A representation
whose float path is the expensive one is the wrong representation for this language. Under NaN-boxing
flan_dyn_from_f64 is a memcpy of 8 bytes and flan_dyn_need_f64 is the same in reverse; neither
allocates, neither branches except on the NaN canonicalisation below.
The layout:
63 62..52 51 50..48 47..0
1 1...1 1 tag payload
^ sign ^ quiet
A word is boxed when (v & 0xFFF8000000000000) == 0xFFF8000000000000 — sign bit set, exponent all
ones, quiet bit set. Bits 50..48 are three tag bits; bits 47..0 are the payload, which is exactly the
width of an x86-64 user-space pointer (48-bit canonical form, top 16 bits zero for user addresses).
Anything else is a double, read straight back.
Four box tags are used and four are free:
| tag | meaning | payload |
|---|---|---|
| 0 | nil | 0 |
| 1 | bool | 0 or 1 |
| 2 | int | 48-bit two's complement, sign-extended on read |
| 3 | object | pointer to a flan_obj (text, vec, or a wide int) |
| 4–7 | unassigned | reserved for interop; see §7 |
The negative-NaN collision, and why canonicalising is free here
Every NaN-boxing scheme has to answer for the doubles that are already negative quiet NaNs: their bits
are indistinguishable from a box. flan_dyn_from_f64 answers it by mapping every NaN to the one
positive quiet NaN on the way in.
That is not a new rule invented for this file. flan_rt.c's flan_f64_to_bytes already renders every
NaN as nan with no sign, and carries the argument at length: IEEE 754 does not specify the sign of a
NaN any operation produces, LLVM's constant folder and divsd disagree about (/ 0.0 0.0), and the same
two-line program printed nan through one backend and -nan through the other. The runtime already
decided that a NaN's sign is not a fact about the arithmetic. This takes the same line one step further
and declines to store it. Nothing observable is lost: NaN is not equal to itself, so no comparison can
see which NaN it is, and the printer was already refusing to say. -0.0 is untouched and keeps its sign,
which is tested.
Integers: 48 bits inline, the rest on the heap
i64 is first class here and 48 bits is not 64. The two honest options were a 48-bit integer with a
64-bit name, or a box for the overflow. This takes the box.
An i64 in ±2^47 — that is ±140,737,488,355,327 — is inline: every array index, every byte count, every
timestamp in milliseconds until the year 6429, every value any program in the corpus computes. Outside
that range flan_dyn_from_i64 allocates a flan_obj of kind OBJ_INT holding the full 64 bits. Both
shapes report int from flan_dyn_tag, compare and print identically, and round-trip through
flan_dyn_need_i64; nothing above the ABI can tell them apart. The cost is one range test on the
constructor and one branch in dyn_int_value, and the benefit is that a dyn i64 is an i64.
u64 has no constructor in this ABI, deliberately. Typed Flan distinguishes u64 from i64 — that is
why flan_u64_to_bytes exists beside flan_i64_to_bytes — and a u64 above 2^63 has no dyn spelling in
milestone 1. It is an interop question (§7), not a representation one: the box already holds 64 bits and
a seventh tag would distinguish the signedness.
Why not a two-word value
It would have made all of the above go away: a tag word and a payload word, no NaN games, no integer
boxing. It was not available — typedef uint64_t flan_dyn is in the fixed ABI — and it would have been
the wrong call anyway, for the reason flan_rt.c's header gives about returning structs by value: the
emitted .ll would then have to agree with the platform's struct-return convention, which works on
x86-64 and silently does not on wasm32, and this project has two backends and a third target.
2. Heap objects
One header, three kinds, one singly-linked list of everything allocated.
typedef struct flan_obj {
struct flan_obj *next; /* every object ever allocated, newest first */
uint8_t kind; /* OBJ_TEXT | OBJ_VEC | OBJ_INT */
uint8_t mark;
int64_t len; /* bytes of a text, elements of a vec */
union {
int64_t i; /* OBJ_INT */
struct { flan_dyn *items; int64_t cap; } v; /* OBJ_VEC */
} u; /* OBJ_TEXT's bytes trail */
} flan_obj;
- Text is immutable and length-prefixed, with the bytes inline after the header — one allocation per
string, and a length that is a count and not a NUL scan.
flan_dyn_from_bytescopies, so a literal in.rodata, a frame slot, or a slice about to be dropped are all legitimate sources. Nothing in the ABI writes into a text;flan_dyn_set_aton one refuses by name rather than copying on write. - Vec is mutable, with its elements in a plain
mallocblock hanging off the header rather than in a GC object of their own. Growth is then areallocrather than a copy this file writes, and the elements are never reachable except through their vec, so giving them an identity would buy nothing and cost a header. Their bytes are counted in the live figure and freed when the vec is swept. - Int is the overflow case above.
mark is a byte in the header and not a bit in a side table. A side table is the right answer when the
sweep is the cost, and the sweep is never going to be the cost here — see §3.
There is no free list, no size class, and no interning. Two texts built from the same bytes are two
objects, which test_dyn asserts directly: equality is bytewise (§5) and a test that only compared
structurally would pass against an implementation that had quietly shared.
3. The collector
Mark-sweep, precise, non-moving, stop-the-world, in about 120 lines.
The author's constraint was explicit — "the answer to 'I need more performance' will never be a faster GC, it will be to type the whole program" — so there is no generation, no card table, no write barrier, no incremental phase and no reference counting. The design goal is that somebody can read the whole collector in one sitting and believe it.
Trigger
Collection happens inside gc_alloc and nowhere else. The policy is the plainest one that bounds the
heap:
if (gc_bytes + need > gc_next) collect();
...
gc_next = gc_bytes * 2; if (gc_next < gc_floor) gc_next = gc_floor;
Collect when this allocation would carry the heap past a limit; afterwards set the limit to twice what
survived, with a floor (1 MiB by default) so a program with a tiny live set does not collect every other
allocation. That gives amortised O(1) collection work per byte allocated, and a heap bounded at roughly
twice the live set plus the floor — which is the property test_dyn's million-allocation case asserts,
as a bound rather than as an exact figure, because pinning the high-water mark would make a tuning change
a test failure.
flan_gc_set_floor is an ABI extension and exists for the tests: a megabyte of floor would make the
million-allocation case a megabyte of arithmetic before it proved anything. It recomputes the trigger
from the new floor rather than only raising it to meet it — raising alone left a heap that had been given
a lower floor still running to the old one, which is a bug this document's first draft had.
A vec's element array growing is a realloc and not a collection point, deliberately: the value being
pushed may not be rooted yet. The bytes are charged to the heap immediately so the trigger sees them at
the next real allocation, but nothing is swept in the middle of a push.
Mark
An explicit worklist, not recursion. A vec of a vec of a vec is an ordinary dyn value and its depth is
the program's, not the runtime's; a recursive marker would put the heap's depth on the C stack and a long
enough chain would overflow it during a collection, which is the worst available moment. The mark stack
is grown on demand and kept between collections, so a steady program stops paying for it after the first.
test_dyn's nested mode is a chain 64 deep and would notice a marker that stopped being iterative.
Only a vec has anything to trace; a text and a boxed int are leaves and marking them is the whole visit.
Sweep
Walk the all-objects list, unlink and free anything unmarked, clear the mark on anything else, and subtract the freed bytes. A vec's element array is freed with its header.
What "precise" buys
The collector never guesses whether a word is a pointer, which matters more here than usual. NaN-boxing makes conservative scanning wrong in both directions: a live double is bit-identical to a boxed pointer often enough to retain garbage indefinitely, and a payload with the box stripped is not the pointer a scanner would recognise. Precision is cheaper than the arguments about it.
4. Roots
No conservative stack scanning. The compiler emits flan_dyn_root_push per dyn local on entry and one
flan_dyn_root_pop(n) for the lot on the way out — shadow-stack style, exactly as flan_dev.c's frame
chain works and for the same reasons its header gives: a pushed record needs no agreement with the
optimiser about what a frame looks like, is the same on wasm32 as on x86-64, and needs no .eh_frame
walk to borrow (this language lowers every non-local exit explicitly and has none).
The root stack holds addresses, not values, so a local that is reassigned needs no second push. It grows on demand, because a deep recursion over dyn locals is an ordinary program and a fixed table would be a limit nobody could predict. Dyn globals go through the same pair, pushed once at startup and never popped.
The one contract the compiler lane must honour: a slot must hold a valid flan_dyn before it is
pushed. The collector reads every registered address on every mark, and an uninitialised slot is a word
of stack garbage that will be decoded. Storing flan_dyn_nil() at the declaration is the whole of it.
flan_dyn_root_pop clamps at empty rather than refusing. A pop that outruns its pushes means the frame
machinery is already out of step, and the useful thing at that point is a heap that still collects.
flan_dyn_root_reset is an ABI extension with one caller: the merged dev build's main, which is
re-entered by longjmp and therefore pops no frame, so every root the finished run pushed still points
into stack the next run is about to write over. It is the exact counterpart of flan_dev_frames_reset
and flan_condition_stacks_reset, which exist for the same longjmp.
The temporaries ring, and the hazard it closes
"Collection triggers only inside flan_gc_alloc, so between pushes nothing moves" protects against
relocation. It does not protect against freeing, and mark-sweep frees whatever is unreachable — which
includes an object allocated a moment ago and not yet stored anywhere the collector looks. Concretely:
flan_dyn_push(v, flan_dyn_add(flan_dyn_from_bytes(p, n), other));
Two allocating calls in one expression. C leaves argument evaluation order unspecified, so this is not even a window a careful emitter could close by ordering its calls.
The answer is the smallest one that does not depend on another agent having read this document: every
object flan_dyn.c allocates is written into a fixed 64-slot ring, and the marker roots the whole ring
unconditionally. Any expression making at most 64 allocations before rooting its result is safe. The
cost is one store and a masked increment per allocation, plus up to 64 objects of float in the heap,
which the trigger absorbs because the trigger is a fraction of live bytes and not a count. There is no
ABI change and no contract for the compiler lane to get wrong.
The visible consequence, and the only one: after a full collection up to 64 unreachable objects are still
alive. test_dyn's unrooted mode asserts exactly that — "reclaimed all but the ring" — rather than
pretending the figure is zero.
An expression with 65 allocating calls and no intervening root would be a single Flan form with 65 constructors in it. If that ever exists, the compiler will have rooted its intermediates and the ring is belt on top of braces.
5. The operations, as implemented
Arithmetic — + - * / %
Two ints answer an int; a float anywhere answers a float; anything non-numeric traps.
The promotion is the one place dyn is more permissive than the typed language, and it is deliberate.
Typed Flan has no implicit widening anywhere — (print-i64 x) used to force an explicit (i64 x) at
every site, and the prelude's history is full of that. But (+ 1 2.5) has exactly one sensible answer,
and a dynamic language that refuses it is not dynamic in any useful sense. A program that wants the
refusal annotates, which is the whole bargain of dynamic-by-default.
Integer division and remainder by zero trap, matching typed Flan, which signals ArithError and dies
with "divide by zero" if nothing handles it. INT64_MIN / -1 gets its own sentence for the reason
flan_rt.c gives it one. The float cases are left to IEEE: 1.0/0.0 is inf and that is an answer.
Float % is computed by truncated division rather than by calling fmod, which would pull math.h into
this translation unit for one operator; the quotient is range-checked first, because casting a double
past 2^63 to an integer is undefined rather than merely wrong.
Ordering — < <= > >=
Numbers against numbers (promoting across the two tags), text against text bytewise, and nothing else.
Bytewise means memcmp with the shorter operand first on a tie — the order a sort of (Vec string)
wants, and the only one needing no locale and no collation table.
A number against a text traps. The temptation is to order by tag so that everything is comparable and sorting never fails; the reason not to is that the resulting order is an artefact of this file's tag numbering, and a program sorting a mixed vec would get a stable answer that means nothing.
NaN is unordered in all four directions, which is what IEEE says and what test_dyn asserts.
Equality — =
Structural, and the only operation here that never traps: two values of unrelated tags are unequal, which is an answer. Making it an error would mean a dyn program could not ask "is this the string I expected" without first checking that it is a string at all.
- Numbers compare by value across the two tags:
(= 1 1.0)is true. - Text is bytewise, not by identity.
flan_dyn_from_bytescopies and does not intern, so two texts built from the same bytes are two objects; the only defensible equality for an immutable byte string is its bytes. Embedded NULs are compared like any other byte — the length is a count, not astrlen. - A vec is equal element by element, with an identity shortcut first.
- NaN is not equal to itself.
Cycles: flan_dyn_set_at lets a vec contain itself, so the recursion is capped at 64. Past the cap two
vecs are equal only if they are the same vec, which terminates and answers correctly for the case that
actually arises — a cycle compared against itself. Two distinct cyclic vecs of the same shape answer
false, which is a wrong answer to a question nobody has asked yet; the honest fix is a visited set and it
can be added the day somebody needs it.
len, at, set-at, push
len is bytes of a text or elements of a vec. at is an element of a vec or a byte of a text as an int
— which is what (at s i) on a (Slice u8) does in the typed language; codepoints are utf8's job and
stay there. set-at and push are vec-only, and a set-at on a text says "a text is immutable — build
another one" rather than quietly copying.
An index that is not an int is a different sentence from a container that is not indexable, because
(at v "1") and (at 3 1) are different mistakes and one message naming both would name neither.
print
Structural, and per tag it renders what typed print renders. The reference forms were captured from a
running program, not read off lib/render.ml — that file is the REPL's inspector and not necessarily
println's expansion:
| tag | rendering | example |
|---|---|---|
| int | %lld |
42 |
| float | %g, NaN unsigned |
3.5, 1, nan |
| bool | the word | true |
| text | bare at the top level | hi |
| text | quoted and escaped inside a structure | "a b", "q\"\n" |
| vec | a slice's spelling | [ 1 2 3] |
| nil | — | nil |
The leading space before every element is what lib/render.ml's slice loop emits and what a Flan program
prints today; a tidier answer would diverge from an acceptance test. The escape table is the third copy
of the one in flan_rt.c's flan_escape_bytes and flan_dev.c's flan_dev_emit_str — those two are
already kept identical because the REPL parses the printed form back, and this one joins them. If that
table changes, change all three.
A typed Vec prints as <vec> and a dyn vec does not. That looks like a mismatch and is not. The
typed printer's refusal is about walking storage it does not own — render.ml says so, and directs you to
(print (as-slice v)), which borrows explicitly at the call site. A dyn vec's storage belongs to the
collector, the printer is inside the runtime that owns it, and there is nobody to ask permission from. So
a dyn vec prints the way a slice does, which is also the only rendering that carries any information.
print has a depth cap of 16. A typed value cannot contain itself, so the typed printer needs no
run-time cap; set-at makes a dyn vec that can, so this one has one, and past it prints ..., which is
the mark render.ml uses for the same idea.
6. The typed boundary
flan_dyn_need_i64, _need_f64, _need_bool are what an annotated parameter does with a dyn argument
and what a dyn expression does where the checker wants a machine value. There is no widening and no
coercion at the boundary: flan_dyn_need_f64 of an int traps.
That is the asymmetry worth defending, because the operators do promote (§5). The two are different
questions. An operator has no stated expectation to violate — (+ 1 2.5) was written by somebody who
wanted a number and there is one obvious number. An annotation is a stated expectation, written down by
a person, and quietly turning their i64 into an f64 would make the boundary the one place in the
language where a value changed type without anybody writing it. Typed Flan does not widen a value:
(defn g [x f64] f64 x)
(defn h [y i64] f64 (g y)) ; scratch.flan:2:24: expected f64, found i64
The one divergence the compiler lane has to know about
Typed Flan does not widen a value, but a literal adopts its expected type. (g 1) compiles and
prints 1, because the checker gives the literal 1 the f64 the parameter asks for; (vec-new i64)
followed by (push v 1) works the same way.
A dyn value has no such history. Once 1 has been through flan_dyn_from_i64 it is tagged int, and
nothing downstream can recover that it was written as a literal in a position that wanted a float. So:
(g 1) ; typed: compiles, prints 1
(g some-dyn) ; dyn, where some-dyn came from the literal 1: traps, "a float was wanted"
That is a real divergence between the same source read as typed and read as dyn, and it is stated here rather than discovered later. Two ways out, both the compiler's and neither this file's:
- Tag the literal at the constructor. Where the checker can see that an unannotated literal flows to
a float context, emit
flan_dyn_from_f64instead offlan_dyn_from_i64. This is the same inference the checker already performs for typed literals and it keeps the boundary sharp. - Emit a conversion at the call, where both sides are visible, rather than softening
flan_dyn_need_f64— which sees only a tag and could not tell a literal-derived int from a computed one.
Softening the boundary itself is the option not to take: it would accept (g (len xs)) as readily as
(g 1), and those are not the same mistake.
7. Interop, which is out of scope for M1 and not precluded
A typed (Vec i64) crossing into dyn code, without copying, is milestone 2. The layout leaves room for
it in three specific ways.
Four spare tags. Bits 50..48 give eight box tags and four are used. Tag 4 is the natural home for a
typed handle: payload = pointer to a typed object, with the element type recovered from a side table or
from a word in the object's own header. Tag 5 can carry u64 above 2^63, which is the other value typed
Flan holds and this ABI currently cannot name.
The object header is not the only thing the collector can trace. mark_push dispatches on kind,
and a fourth kind whose tracer is "call a function pointer in a descriptor" is a local change — a handle
to a typed (Vec Enemy) would need the collector to know which fields of Enemy are dyn, and a
per-type descriptor emitted by the compiler is how that arrives. Nothing in the current layout has to
move for that: the union grows an arm.
A typed vec is not a flan_obj and does not need to become one. The interop that matters is the
cheap direction — a typed vec is {ptr, len, cap} in memory the arena owns, and a dyn handle to it
should be a borrow, not a copy. That means the collector must be able to trace into memory it did not
allocate and must never free it. The kind byte is what distinguishes "I own these bytes" from "I am a
window onto someone else's", and the sweep already branches on it.
What is genuinely open, and is stated here so the M2 lane does not discover it late: a typed vec's
lifetime is its arena's, and the collector has no say in it. A dyn value outliving the arena its handle
points into is a dangling read that the current design cannot detect. The allocation registry in
flan_dev.c already answers exactly that question for Ptr — render.ml's pointer arm follows a live
one and mourns a dead one — so the shape of the answer exists. It is not built here.
8. Dropping the collector
--no-gc enforcement is the compiler lane's. This lane's contribution is that it is possible, which
is a property of the build and not a wish:
flan_dyn.cis its own translation unit and always has been. It is compiled fromRuntime_src.dyn_sourceinlib/build.ml(bothexecutableandmacro_module) and inlib/dev.ml, besideflan_rt.candflan_dev.cand never merged with them.- The dependency runs one way only.
flan_dyn.ccallsflan_write_stdoutandflan_trapinflan_rt.c. Nothing inflan_rt.corflan_dev.cnames a symbol inflan_dyn.c. One back-reference —flan_rt_initcallingflan_gc_init, say, which was the obvious thing to write — would make the collector unconditional and the refusal a lie, so the heap initialises itself lazily on first allocation instead. The linker does not drop it today, and this document will not claim it does.flan_dyn.ois a named object on the link line, not an archive member, andldincludes a named object in full — symbol-driven selection is a.arule. Dead-code elimination inside an included object needs-ffunction-sections -fdata-sections -Wl,--gc-sections, which the link line does not pass. Measured rather than assumed: build any corpus program andnmit, andflan_dyn_addandflan_gc_collectare both there as defined symbols.
What the one-way dependency buys is the better mechanism anyway. The object is selected at the file
level: --no-gc is a one-line change at each of the three sites that name Runtime_src.dyn_source — the
same per-target selection select_csrcs already performs for a package's C — and once it is not
compiled, nothing has to be dropped. That only works because nothing else in the runtime refers to it;
flan_dev.c is compiled into every build today precisely because it is referred to (a package's C
names it, and package sources are collected whatever main does), and its own comment says so at length.
flan_dyn.c has no such entanglement, and keeping it that way is the constraint this section exists to
record.
The residual-dynamism refusal itself — deciding that a program is not fully annotated and saying which form is not — is the compiler's, and is not attempted here.
9. Cost sketch, per operation
No microbenchmarks; these are counts of what the code does.
| operation | cost |
|---|---|
from_f64, need_f64 |
one 8-byte copy; one compare for the NaN canonicalisation |
from_i64 |
one range compare, then a mask and an or — or an allocation past ±2^47 |
need_i64 |
tag test, then a shift pair to sign-extend |
from_bool, nil, need_bool |
mask and or; tag test |
tag |
one mask-compare, one shift, and a load for the object case |
add/sub/mul |
two tag tests, the arithmetic, one from_i64 (which may allocate at the extremes) |
div/rem |
the above plus a zero test and an overflow test |
lt/le/gt/ge |
two tag tests and a compare; text is memcmp |
eq |
word compare first; then tags, then bytes or elements — O(size) at worst |
len |
tag test and a load |
at |
two tag tests, a bounds compare, a load |
set_at |
the same, plus a store; no write barrier, because there is no generation to have one for |
push |
tag test, a capacity test, amortised O(1); the doubling is a realloc |
print |
O(size), streamed, no allocation |
root_push / root_pop |
a store and an increment; a realloc when the stack doubles |
| allocation | a trigger compare, a malloc, a header init, a ring store |
| collection | O(live) to mark, O(all) to sweep; amortised O(1) per byte allocated |
The shape to take away: every operation is a handful of instructions plus whatever the arithmetic
costs, and the only unbounded ones are eq, print and collection itself. If that is not fast enough,
type the program.
10. ABI extensions
The header in runtime/flan_dyn.h is the agreed ABI with nothing removed and no signature changed.
Seven things are added; none is something the compiler lane must emit.
| addition | why |
|---|---|
flan_dyn_root_reset() |
the merged dev build's longjmp re-entry, exactly as flan_dev_frames_reset |
flan_dyn_tag() |
the break loop and the inspector; the first question anyone asks a stopped dyn value |
flan_dyn_tag_name() |
the same table the trap messages are written from, so a message and an inspector cannot disagree |
FLAN_DYN_TAG_* |
the numbers flan_dyn_tag answers with |
flan_gc_count() |
the tests: a live-bytes figure alone cannot tell a collecting heap from one with small objects |
flan_gc_set_floor() |
the tests: a 1 MiB floor makes the million-allocation case slow and uninformative |
flan_trap() in flan_rt.c |
a thin exported wrapper over the existing static rt_trap, so a dyn type error takes the same path as flan_bounds_fail and the other six rather than re-implementing the hook, the flush, the socket and _exit(134) |
One more thing the compiler lane should know about, which is a build fact rather than an ABI addition:
Build.compile_c now drops runtime/flan_dyn.h into the directory it compiles each translation unit
in, so a package's C — or an emitted shim — can #include "flan_dyn.h". flan_dyn.c itself does not
include it and declares its own prototypes, because the object cache is keyed on source text and a header
edit would serve an object compiled against the previous one. The two copies are kept honest
mechanically: test/dyn_ops.c includes the header and names every function in it, so a divergence is a
compile or link error in dune test.
11. Traps
A dyn type error is a trap and not an abort: it takes flan_trap, which calls flan_trap_hook if one is
installed and then rt_die. In a dev session the hook parks the program for inspection; in a standalone
build nothing installs it and the program dies where it stands. The sentence is the same either way,
which is the point of routing through the hook rather than calling abort in the runtime.
The sentence names the operation, both tags as words, and both values:
dyn +: int and text, and it takes two numbers — (+ 3 "hi")
dyn at: index 9 is out of bounds for text of length 2 — "hi"
dyn f64: int, and a float was wanted — (f64 1)
Tag names are always words — int, float, text, vec, nil, bool — and never numbers. They come
from one table that flan_dyn_tag_name also reads. Values are rendered into a bounded stack buffer with
a depth cap of 2, truncated rather than allocated: a trap is the one moment when allocating would be a
second thing to go wrong.
Four trap names, because they are four different mistakes and somebody standing in one wants to know which without reading the sentence twice:
| name | when |
|---|---|
DynType |
a tag that is not what the operation wanted |
DynRange |
an index outside a vec or a text |
DynArith |
division by zero, or the one quotient that overflows |
DynHeap |
an allocation the host refused |
The bounds case does not signal BoundsError the way typed indexing does, and that is a limitation
rather than a preference: flan_bounds_error takes a loc and a transfer channel, and a dyn operation
has neither in its hands. It is the same reason the six traps in flan_rt.c park rather than signal. If
the compiler lane threads a loc through — it knows one at every call site — the dyn bounds case can
join the condition system unchanged, and that is the obvious next increment.
12. What is tested
test/dyn_ops.c, driven by test/test_dyn.ml against test/programs/dyn-host.flan — a C main linked
against a Flan program with no main, the arrangement dev_limits.c and reload_host.c already use,
because these are C entry points with no Flan spelling. One binary, 29 runs, 0.42 s.
| mode | what it establishes |
|---|---|
ops |
every operation's happy path; every tag and its word; f64 round trips at 0, -0.0, 1e308 and NaN; i64 at both inline edges, both boxed edges, INT64_MIN and INT64_MAX; arithmetic crossing into the box; NaN unordered in all four directions; text identity vs equality; embedded NULs; the empty text; vec growth past its initial capacity; a vec that contains itself, compared and printed; every printed form as an exact string |
gc |
a million allocations against a hundred rooted values; heap high-water under 512 KiB where a non-collecting heap would hold ~40 MiB; the live set intact afterwards |
unrooted |
the positive control — an object nothing points at is reclaimed. Without it every other case would pass against a collector that never freed |
nested |
a chain of vecs 64 deep traced through one root, across real collections |
sharing |
one vec in three slots: identity (not just equality), a write through one path read through another, survival when the direct root goes, and a single sweep when the last reference does |
refuse:* |
24 refusals, one process each, asserted on the sentence as well as the exit status — a process that died some other way is not the guard firing, and the code cannot tell them apart |
dune build @sanitize runs the same C under ASan and UBSan. That sweep matters more here than anywhere
else in the corpus: every other program in it allocates and never frees, which is a policy ASan can only
agree with, and this one frees — a mark-sweep collector is precisely a machine for freeing something that
is still reachable. A use-after-free is what a wrong marker looks like from outside, and it is invisible
to the assertions above, because the freed bytes are usually still the bytes that were there. Leaks stay
off, as they are for the whole file: the temporaries ring holds 64 objects alive at exit on purpose, and
every one of them would be reported.